Single source of truth for the structure of topiqu-blog.
- Framework: Nuxt 4 (Vue 3.5, Composition API), TypeScript strict.
- Styling: UnoCSS (
uno.config.ts—presetWind3+presetTypography), SCSS partials.presetTypographyis what makes theprose*utilities real. The article body (pages/clanky/[slug].vue) has always been marked up withprose prose-gray prose-h2:… prose-blockquote:… dark:prose-invert, but onlypresetWind3was 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.cssExtendadds 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-bordersor--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'sdefaultPipelineIncludecovers.vue/.tsxbut not plain.ts, and@unocss/nuxtonly ever widensexclude. Moving the body classes intoshared/utils/articleProse.tstherefore 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, noproserule is emitted at all (.proseappearing inside JS selector strings inArticle/TOC.vuedoes not count as a token).uno.config.tsnow setscontent.pipeline.includeto the default regex plusshared/**/*.ts;tests/unit/articleProse.test.tsasserts both that the path matches a scanned pattern and that thethead th/tbody tdborder rules actually generate. - Tables leave
proserather than fight it (shared/utils/articleProse.ts→ARTICLE_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 thecssExtendcell 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-proseis.Article/Parsed.vuewraps every top-level<table>inARTICLE_TABLE_CLASS; published tables use a distinct header, row separators, zebra striping, hover feedback and a horizontally scrollable framed surface.EDITOR_TABLE_CLASSre-uses the same cells onTiptap/Editor.vue'sEditorContentand adds the chrome only TipTap emits (.tableWrapper,.selectedCell,.column-resize-handle); the editing surface had no table styling at all. The TipTapTable/TableRow/TableHeader/TableCellextensions were already registered — what was missing was the toolbar (insert, plus row/column/header commands shown only inside a table) andcolwidthwas already whitelisted insanitize.ts. Tests assert every token in both class lists actually emits CSS: an arbitrary variant UnoCSS cannot parse fails silently. presetTypographyis the article's only vertical rhythm, becausebase.scssopens with* { margin: 0 }. While the prose CSS was dead, the editor's blank<p></p>was the sole spacing — hence the ZWSPp:empty::beforethat 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, soARTICLE_PROSE_CLASShides it ([&_p:empty]:hidden) and tightens the preset's essay-widthhrmargin.:emptycatches only one of the three spellings.articleSchema.content'sdescribe()asked the model for a<br>at the end of every paragraph — correct while the body was rawv-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 (dropBlankLinesinfinalizeArticle, 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 areprose-img:my-0, notmy-6—finalizeArticleemits<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 imgpadding was a third layer). Keepspace-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.
useArticleScrollContextmeasures 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→ generatedschema.prisma, ~50 models). See §6. - Editor: Tiptap 3 (custom extensions in
extensions/:Poll,slashCommand,indent). - AI: Vercel
aiSDK 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 intests/unit/aiModels.test.ts), resolved to provider clients byserver/utils/ai/models.ts(aiModel/aiImageModel). Never hardcode a model string at a call site — that includes theArticleTranslation.modelaudit column, which readsaiModelId('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-previewis a preview ID — re-check it before it moves to GA or is retired. - Manual generation streams:
server/utils/ai/article.tssplits intoresearchTopic(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/generateemits NDJSON progress, finalized content, then an authoritativebillingevent 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.tsholds six broad formats, several dramaturgical variants per format, and each format's eligible optional modules (answer, takeaways, FAQ, poll, table, body images, YouTube).topicSchemaselectsformat + variant + up to three modules; the writer receives the variant's progression, whileapplyFormatis 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 realisedformat / structureVariant / modulessignatures and stores only the nullableArticle.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.tsaccepts 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+AIfeatures (PREMIUM; configurable CUSTOM). The nightlysearch-console-syncstores finaliseddate/page/queryperformance, thensearch-console-autopilotatomically 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 togenerate-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.
articleImagemoved off Imagen 4 Fast because Google shuts the Imagen family down on 2026-08-17;tests/unit/aiModels.test.tsnow fails on anyimagen-*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, somodels.tsis unchanged, but Gemini image models ignoren/maxImagesPerCall(dropped fromai/image.ts) and return PNG rather than WebP, whichimageExtension(output.image.mediaType)already handles. - Cloud / Infra: AWS S3, Rekognition, SES; Stripe (
@unlok-co/nuxt-stripe); Vercel deploy (vercel.json) — migrating to Dokploy/VPS, seeMIGRATION.md; container image atDockerfile(multi-stage bun,node-serverpreset viaNITRO_PRESET); PWA (@vite-pwa/nuxt). No browser runtime: OG images render via@takumi-rs/*(Rust/WASM) and both PDF routes viapdfkit— there is no Playwright/Chromium/jsPDF in the stack, andplaywright-corewas removed as a dead dependency. Don't reintroduce a headless browser without revisitingMIGRATION.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 injectsUPSTASH_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:
mjmltemplates inemails/, sent via SES (server/utils/sendEmail.ts). - Observability:
@sentry/nuxt(error tracking + session replay), ingested by Better Stack.
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)
pages/— 15 route files; Czech-language URLs (autor,autorizace,clanky,stitky,uzivatel,master,drafts). Admin section underadmin/. Thestitky/[slug]andautor/[name]listings share one presentational componentArticle/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 thev-if-collapsedNuxtImgthat made the page look broken), the name as theh1, the bio — andstitky/[slug]renders#tag(the#isaria-hidden, so the accessible name stays the bare tag). The removed copy is replaced by information: both endpoints return a policy-scopedtotal(db.article.countunder enhanced Prisma, so drafts stay invisible to non-owners) rendered via the pluralizedarticles.articlesCount.totalis deliberately unfiltered bysearch— it describes the author/tag, not the current query, so it does not flicker while typing.- While there:
api/tags/slug/[slug].tsselected each article's author withuser: { 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.ArticleCardDataonly ever readsuser.username, so it is now an explicitselect: { username: true }, matchingby-author. Rule of thumb this encodes:omitis a deny-list and leaks by default — public projections must beselect.
- While there:
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 whilefeatPendingor when there is something to show (previouslyArticle/SkeletonCard.vuefell into its loaded branch witharticle === undefinedand emitted a ghost card linking toclanky-slugwithslug: 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).latestArticleis one sorted computed feeding both the hero link's slug and title, replacing two computeds that each re-sorted the whole list and carried unreachablecommon.noItemsfallbacks (the template only reads them behind a non-empty guard).- The default layout publishes the tenant theme as
--client-accent; homepage editorial accents andArticle/SkeletonCard.vuetag/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 toArticle/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 viaimmediate: !isBlankSite. Deliberately keyed off the server count rather than the resolved feed: both endpoints read through the same enhanced-Prisma policy scope, sototalArticles === 0means "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.vueis the shell (centred full-height section, gradient/dot backdrop, logo or name monogram) and picks one of two bodies byisOwner.Visitor.vue: site name ash1, description, a "coming soon" pill (articles.empty.badge) andarticles.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 isnoindexuntil the first article, primary/secondary CTAs toadmin-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.isOwnerisrole === '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(neverdone— it is the reason the page exists),branding(logoUrlanddescription→settings?tab=branding),voice(focusandaudience→settings?tab=content,lockedoffAI_CAPABLE_PLANSso BASIC sees a lock instead of a link into a tab that is not rendered for it) anddomain(domainVerified→/admin, whereAdmin/DomainVerificationBanner.vuelives). 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 onlyarticles.noResults.message. This also fixes the single-article case:filteredArticlesexcludes 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].tsreturns thetotalArticlescount it already computed, and the page emitsrobots: '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 blockinguseFetchrather thanuseLazyFetch: robots meta must be correct in the SSR HTML, and a lazy fetch would rendernoindexfor 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 drivesisBlankSite, sonoindexand the dedicated empty page always agree.
- The default layout publishes the tenant theme as
settings/index.vue(adminmiddleware) — full client settings, migrated from the formerClient/Preferences.vuemodal into a dedicated page. Sections are tabs driven by?tab=(branding/content/integrations/ai/billing, gated by plan +tokenLimit+billingPlan). Owns the client fetch + sharedform(seeded by the pureapp/utils/buildClientSettingsForm.ts), one stickyPATCH /api/clients/:idsave, and anonBeforeRouteLeaveunsaved-changes guard whose dirty check is afast-deep-equalofformvs apristinesnapshot (replaces the old hand-maintained field-by-field boolean). Consumescommon.preferences.*(incl. newpreferences.tabs.*). Opened fromSidebar.vuecog →router.push('/settings').
- Both listing headers are identity headers, not sentences. They previously led with
components/— grouped by domain:Admin/,App/,Article/(incl.Article/Empty/— the zero-article homepage, seepages/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 editorTiptapEditor.vue. The client settings sections live inForm/Client/(Branding/Content/LinkedIn/AI/Billing) +Settings/Nav.vue(tab rail), composed bypages/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 —useModalResponsepowersModal/Mini.vue's imperativeask()returningPromise<'ok'|'no'>— anduseTime, see Time stack below).- Time/date stack — single source of truth in
shared/utils/time.ts(TIME_PRESETS:date,datetime,short,shortDatetime,timeasIntl.DateTimeFormatOptions, plus therelativepreset).<AppTime :datetime preset>(App/Time.vue) wraps the built-in<NuxtTime>— SSR-safe, live-ticking relative, locale auto-injected fromuseI18n— for templates;useTime().formatTime(date, preset, localeOverride?)is the JS-context counterpart (table cell renderers,$tinterpolation) using the same presets viaIntl. Prefer these overdate-fnsformat(). The smart hybrid relative/absolute timestampformatDate(shared/utils/index.ts, custom thresholds +articles.dateFormats.*keys) is a deliberately separate concern and still usesdate-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.lastLoginis written only by the successful server authorize path, andAuth/Form.vuemust reject a returnedsignIn().errorbefore 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) isadmin && user.clientSiteId === article.clientSiteId— the exact@@allow('all', …)onArticle, 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].vuehadrole === 'admin' && session.user.id === data.user.idinline and hid the edit/status/comments controls the server would have accepted;pages/index.vue'sisOwnernever had that clause. - Data-fetching split (HARD RULE) — two layers, chosen by whether the response is SSR/SEO-critical:
useFetch/useAsyncDatafor 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 driverobotsmeta — do not migrate them.- A
useFetchpayload you write back into needsdeep: true. Nuxt 4 defaultsdeeptofalse, sodatais ashallowRef.pages/clanky/[slug].vuewriteslikes/shared/followerCountback into the article after each action; atriggerRefre-renders the page butArticle/ActionsBar.vuegets the same object identity, sohasPropsChangedbails and the counts only moved on a manual refresh. AtriggerRefbeside auseFetchpayload 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 throughgetEnhancedPrisma), 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.tsand are the invalidation contract; the prefix hierarchy (['articles'] ⊃ ['articles','list'] / ['articles','detail',id] ⊃ …tags / …available-tags, plus['clients']and['stats']) is pinned bytests/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 viaonServerPrefetchand bare$fetchdoes not forward the session cookie (401). Mutations are client-only and use plain$fetch. - Global defaults live in
colada.options.tsat the repo root, not innuxt.config.ts— the module declaresconfigKey: 'colada'but reads a root-level file, and silently falls back toexport default {}if it is missing.staleTimeis 300 s (library default is 5 s, which would refetch on practically every remount and defeat the point of the cache);gcTime30 min. The module also auto-importsuseQuery/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
dataundefined,rowsempty, and the table showed "no articles" for what was actually a broken request. Each migrated component derivesloadFailedfromerrorand an absence of data (so a refetch failure overplaceholderDatakeeps the stale rows visible instead of blanking them), and offersrefetch(). - Route-type blowups: typing a query as
requestFetch('/api/…')can triggerTS2321 Excessive stack depth— Nitro's route-matching types leak into Colada's generic inference. Fix by passing an explicit generic sourced fromInternalApi['/api/route']['default' | 'get'](nitropack/types), which keeps the real response type without re-resolving the route. For mutations, anasync … => { await $fetch(…) }body (concretePromise<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 dirtiesarticlesandstats; editing dirties onlyarticles; a tag edit dirties just that article's detail). Call it directly after the mutation — do not reintroduce an event bus. mittis fully removed (useArticleEvent/useClientEventdeleted, dependency dropped). It was only ever a cache-invalidation mechanism — every handler was a barerefresh()— 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) andTags/Manager.vue(embedded inArticle/Modal.vueand the editor) both hit/api/articles/:id/tagsand are both keyedqueryKeys.articles.tags(id). They previously held two independent caches, so adding a tag in one left the other stale — andArticle/Modal.vue'sinvalidateArticleDetail()only reached half its own subtree.Tags/Manager.vuekeeps its localtagBufferstaging 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 byenabled: () => !!props.article?.id. - Creating a tag dirties two roots — the article's detail and
['tags'], the tenant tag catalogue behind/api/tagsshared byTags/Manager.vueandTags/Create.vue.Article/Tag.vue's create mutation invalidates both. - Migrated:
Article/Table.vueandClient/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 byenabled: () => !isBasicPlan). Mutation sites (Article/Modal.vue,Client/Create.vue,pages/admin/editor/[id].vue) invalidate rather than emit. Still onuseFetch: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+refetchIntervalcandidate).
- Connection state is a state, not a toast —
Network/Indicator.vue(mounted once inapp.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 iscomposables/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 globalHeader.vueis a fixedh-18overlay: 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 attop-24, and the editor header attop-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 inlayouts/default.vue: the root wrapper isoverflow-x-clip, notoverflow-hidden.hiddenmakes that box a scroll container, andposition: stickyresolves 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 andFooter.vue— all four had been written as sticky and none of them stuck.clipstill contains horizontal bleed, which is the reason the class is there next tomax-w-screen, without establishing a scrollport (andoverflow-ystaysvisiblebesideclip, unlike besidehidden). Don't swap it back.assets/styles/— global SCSS (entrybase.scss, loaded vianuxt.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.scssand 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 !importantexists because HeadlessUI'sDialogroot is a full-screen wrapper whose visible surface is theDialogPanelinside it. Written as a bare[role='dialog'], that!importantalso 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 (DialogPanelgetsheadlessui-dialog-panel-*and no role, so the companion panel rules still match).tests/unit/globalStyles.test.tsfails if the scope is ever widened again.
- The transparent-dialog rule is HeadlessUI-specific, not a role selector.
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 standaloneupload.ts.tasks/— Nitro scheduled tasks. All wrapped viadefineMonitoredTask(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 manualrunTask()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 — resolvesgetServerSession, enforces optionalrole/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), plusai/andlinkedin/subdirs.linkedin/publisher.tspublishes drafts to LinkedIn behind an atomic claim (executePublishflipsDraftStatus→PUBLISHINGvia a guardedupdateMany, so concurrent cron runs / manual triggers can never double-post;PublishedPost.draftIdunique is the DB backstop).publishApprovedDraftis the cron/manual entry for human-approved drafts;publishDecisionAndExecuteis 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 bypublisher.tsand thelinkedin-synccron (which caches the token per company for the run).linkedin/oauthState.ts(signOAuthState/verifyOAuthState) HMAC-signs the OAuthstatewithAUTH_SECRET; theconnect→callbackflow is session-guarded (admin/superadmin only), derivesclientSiteIdfrom the session (never the query), binds the signed state to an httpOnly CSRF cookie, and re-checksclientSiteIdownership on callback before writing tokens via enhanced Prisma — closing the previous unauthenticated-IDOR hole.
components/Dev/Console.vue— a draggable, dev-only floating panel (Teleport to body,useDraggable+useLocalStoragefor persisted position/collapse). Rendered inapp.vuevia 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 onlocalhost; impersonate seed users (reader/admin/super) via the realsignIn('credentials')flow with seed creds; show git branch/short-hash/dirty flag and the resolved tenant + plan. Toggle/hide viaCtrl+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 fromserver/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 tolanding.topiqu.comwas removed; the app no longer routes anyone to a landing subdomain. app.topiqu.com+*.topiqu.comcatch-all → this app project.- Wildcard TLS for
*.topiqu.comis managed by Dokploy itself (its Traefik certificate handling), not by a Cloudflare Origin CA cert or a hand-editedtraefik.ymlresolver. 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.
- Apex
- Auth / OAuth: OAuth completes on
app.topiqu.com(Google/GitHub authorized redirect URIs includehttps://app.topiqu.com/api/auth/callback/*; the apextopiqu.comURI also stays registered).authjsruns withtrustHost(no pinnedAUTH_ORIGIN) so theredirect_uriis derived from the request host.Auth/Form.vue#handleSocialAuth: onapp.topiqu.comit callssignIn(provider)directly; on any other host (tenant subdomain) it hops toapp.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.comsubdomain (theredirectcallback whitelists*.topiqu.com). - App surfaces (
app.vue):app.topiqu.comis 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-appLanding/tree was removed as a duplicate of it. - Dev resolver note:
server/api/clients/slug/[slug].tsmatches{ OR: [domain, name] }outside production solocalhost(seededClientSite.domain = localhost) resolves to a tenant; production still matchesdomainonly. It returnspublicClientSiteSelectonly — see Who may read whichClientSitefield — souseClientSite()is typedPublicClientSite, and anything privileged has to come fromuseClientSiteStatus(). - Admin surfaces are bound to their own tenant's host (
middleware/admin.ts, coveringadmin,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 readauth.user.plan, which the JWT callback re-reads from the DB on every request.
- Notifications are persisted in the DB (source of truth) and delivered to the client by polling, not push. The client (
Notification/Bar.vue) pollsGET /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.vuerenders 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.vueuses 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
articleEditorSnapshotvalues 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+ theuseRealtimecomposable), which is unviable on serverless/Vercel: held connections incur per-request/wall-clock billing and isolated function memory means apublishin 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 inserver/utils/notificationsPoll.tsand 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).
- 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 sumstotalWordsoveraiInvolvement: 'FULL'only (the editor demotes toASSISTon the first human edit) and prices it with the tenant's current rate.Article.savedAmount/savedTimeMinutesare 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 getslinkedinModeon every save, because the settings form defaults it — so that branch runs for tenants who never connected LinkedIn. It must not create aLinkedinCompany: only the OAuth callback has a reallinkedinOrgId, 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.
humanHourlyRateheld a CZK figure while the UI formatted it as the tenant'scurrency(default USD), inflating the number ~23×. Migration20260808120000_human_rate_usdrenames it tohumanHourlyRateUsd; display multiplies byuseCurrencyRatelikeBilling.vue/AI.vue. The default (35) is stated twice —client.zmodelandDEFAULT_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.vueis 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 withhover:scale, which made nothing look important and implied every card was clickable.Chartsis 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 tomin-width: autoand 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 amin-w-0parent with ashrink-0metric beside it. Modal/index.vuehad no dark tokens at all — a white panel in dark mode, with the one card that did carrydark:variants floating in it. The panel and its title gradients now have them.h-11/12is untouched and still forces every modal to ~92% viewport height regardless of content.Charts.vuetakes semantics, not a Chart.js config —kind(trendbar↔line /breakdownbar↔pie),labels,values; it owns colours and scales. It used to branch ontitle === 'Rozložení sdílení podle platformy', so every chart picked the wrong type in English. Colour is keyed to the entity, not its rank — henceStats/Dialog.vuealways 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
ArticleReactiongates a like:ArticleSharecarriesuserId/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. ArticleViewis the view-event log (migration20260819120000_article_view_events);Article.viewsstays the running counter, so sorting and totals keep their fast path. One row per visitor per article per UTC day, andsessionIdis 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.tsinserts withON CONFLICT DO NOTHINGand 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, hencetrackingSince(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.viewedOnoverVIEW_TREND_DAYS(30). It replaced a 7-day series bucketed byCOALESCE(publishedAt, createdAt), which counted views accumulated by articles published that day — not views that happened that day. - Views were forgeable.
view.post.tshad no session, no dedup, no rate limit and nostatusfilter: a loop ofcurlmoved the number, and an admin previewing a draft inflated it. The identity is now server-issued (theanon_sessioncookie, never read from the body) and drafts are rejected. Totals andtopArticlefilterstatus: '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. topTagsranks 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 andORDER BY SUM(views). Note the metric double-counts by design: an article with three tags gives its views to all three.
Every provider connect (LinkedIn, Search Console) starts on the tenant's own host — middleware/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 withsessionCookieDomainfromsessionGuard.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/settingsis a 404 on the wrong site. The locale rides inside the signed state (whitelisted tocs/enon the way out) becausei18n_langis host-only; the tenant host comes fromClientSite.domain.stripe/*.tsstill builds unprefixed/settings?tab=billingreturn 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/connect.get.ts403sappType=pagesand 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.tsfails if an organization scope reappears.- Everything downstream of the guard is intact:
LinkedinCompany.typestill carries'pages' | 'personal',token.tsstill readsLINKEDIN_CLIENT_ID_COMPANY, and the callback keeps itsgetPagesUrnbranch — unreachable, sinceappTyperides in an HMAC-signed state onlyconnectcan mint. Re-enabling is reverting the guard plus the Connect Page button inForm/Client/LinkedIn.vue; do not delete thepagesplumbing to "clean up". clients/[id]PATCH no longer prefers apagesrow when applyinglinkedinMode— it takes the tenant's singleLinkedinCompanyregardless oftype, so a legacy pages row stays editable.
zod/— split intocommon/,enums/,input/,models/,objects/with a barrelindex.ts. Schemas reused by both client forms and server validation.types/— hand-written cross-cut TS types.article.ts → ArticleCardDatais the row shape consumed byArticle/Collection.vue(shared by thestitkyandautorlisting pages).utils/— pure helpers shared by app + server.savings.tsis the single "what would a human have charged for these words" formula — see Blog statistics in §4.
app/utils/also holdsqueryKeys.ts(Pinia Colada key factory, see §3 Data-fetching split) alongsidebuildClientSettingsForm.ts.
z-layers.ts— single source of truth for stacking order (Z_LAYERS):header100 →overlay1000 (modals/slide-overs/sidebar/fixed chrome) →devtools5000 →popover9000 (dropdowns/selects/pickers) →top9500 (global loading bar). Fed intouno.config.tstheme.zIndexasz-<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 freshz-[…].
docs/external-api.mdis 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 anyserver/api/external/*response shape has to land in both.
- Every external route authenticates through
server/utils/externalApi.ts → requireExternalClientusing the tenant'sx-api-key; soft-deleted sites are refused and authenticated responses areprivate, no-store. An admin generates or rotates the single key throughPOST /api/clients/[id]/api-key; rotation immediately invalidates the previous key. The key is stored onClientSiteand is visible to the owning tenant in Settings → Integrations. GET /api/external/articlesremains the backwards-compatible collection endpoint. It returns source-language, publishedArticlerows only, newest first. Its original fields and nestedtags[].tagshape remain intact; additive fields now exposeupdatedAt,publishedAt, reading time/word count, sources, cover credit, series and metadata for published translations. Pagination ispage+limit(defaults 1/10, maximum 100), and response metadata includes the primary language and applied tag filters. Optional comma-separatedtagfiltering is trimmed/deduplicated and retains AND semantics: an article must carry every requested tag slug.GET /api/external/articles/:idreturns one published tenant article with the complete safe external projection. Its response uses flattags, identifies the sourcelanguage, and exposes published translation summaries asavailableTranslations; a foreign, draft, archived or missing id is the same 404.GET /api/external/tagsis discovery for filtering: only tags actually attached to at least one published article of the authenticated tenant, sorted by name, witharticleCount. This deliberately avoids leaking global/other-tenant tags and omits empty tags.GET /api/external/sitereturns 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.
- The installable
Topiqu Syncplugin consumes the existing authenticated external API and stores source-language articles as native WordPresspostrows. 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:
safehashes the last imported title/slug/excerpt/body and preserves a locally edited post,overwritemakes Topiqu authoritative, andnew_onlyimports once._topiqu_updated_atavoids 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.zipand is generated fromwordpress/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 aTBDstatus 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]andfeatured/[slug]may carrylikedByUseronly because their 10-min shared cache is reached without a session (tests/server/articles/feedPresentation.test.tspins that condition).- One anonymous identity:
server/utils/anonSession.ts. Writes issue theanon_sessioncookie, GETs only read it — a minting GET hands one to every crawler. Reactions must never take it from the body asf4ece21did with a FingerprintJSvisitorId: the caller picks that value, so it farms freely and never matches what reads resolve. Abuse budget isconsumeRateLimitonipKey(event)inreaction.post.ts, create path only. Client/PreferencesGuide.vuerenders the live prompt block fromserver/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, pasti18nCompleteness;tests/unit/preferencesGuide.test.tscovers them.- Unsaved changes are communicated by
app/components/UnsavedBar.vue— one body-teleported floating bar shared by/settingsand/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.
- Authored in ZenStack, split by domain: a thin root
prisma/schema.zmodel(generator/datasource/plugins +imports) pulls inprisma/models/*.zmodel(base,article,poll,client,user,comment,notification,linkedin,log). Enums colocate with their domain; cross-domain enums + abstractBase/Ownablelive inbase.zmodel. Generatesschema.prisma. Imports must be at the top of each file; cross-file relations need explicitimports (ZenStack resolves symbols only over a file's transitive imports). - Migrations in
prisma/migrations/. bun buildpipeline:zenstack generate→prisma migrate deploy→nuxt build.
ArticleViewis the only event table with a non-nullable identity column (sessionId). That is deliberate:ArticleShare,ArticleReactionandArticleFeedbackall put nullableuserId/sessionIdin their@@unique, and Postgres treats NULLs as distinct, so those indexes never fire and the handlers carry the dedup alone. CopyArticleView's shape, not theirs.- Fully normalized:
Poll(question, order) →PollOption(label, order) →PollResult(vote). All cascade-delete fromArticle.PollResultreferences bothPolland the chosenPollOptionby real FK (+articleIdkept denormalized so engagement stats useArticle._count.pollResults). Two@@uniqueconstraints —(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 intoArticle.contentas<div data-type="poll" data-poll-id data-question data-options>, wheredata-optionsis a JSON array of{ id, label }. - On article create/edit,
server/utils/articlePolls.ts → syncArticlePollsreconciles the embedded blocks with thePoll/PollOptionrows and stamps each block with server-assigned ids (poll id on the block, option ids insidedata-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 inshared/utils/polls.ts(normalizePollOptions). syncArticlePollsis mandatory on every write path that persistsArticle.content— it is the only code that createsPoll/PollOptionrows 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.finalizeArticleemits only a cosmeticdata-idand label-only options, so thegenerate-articlecron (which writes AI output straight to the row) must run the same sync +sanitizeHtmlasarticles/index.post.tsand[id]/index.patch.ts; it previously did neither, shipping polls whose every click was a silent no-op (Poll.vuebails before$fetchon a missing option id, and the GET counts endpoint happily returns an empty tally for an unknownpollId, so the widget looked live).scripts/backfill-polls.tsrepairs rows written before the fix (dry-run by default,APPLY=1to write). Covered bytests/server/articles/articlePolls.test.ts.- The editor rewrites
data-optionson every keystroke (Tiptap/Editor.vue → validateContent, fed byonChange), so that pass must round-trip the option ids and be idempotent. It went throughshared/utils/polls.ts → pollOptionsAttr(tests/unit/polls.test.ts) instead of an inlineString(x)that assumed the legacystring[]shape — that wrote[object Object]over every label and dropped the ids, whichPoll.vuethen 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 viagroupBy) +vote.post.ts(cast; relies on the unique constraint → P2002 → 409). - Render:
Article/Parsed.vue(client-side parse) →Article/Poll.vue(votes byoptionId); homepage "latest poll" viaextractPollDatainby-clientsite/[slug].ts. Both requiredata-poll-idand do not fall back todata-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_normalizedmigration wipes any pre-existingPollResultrows (old text-based votes couldn't be remapped to option ids without parsing HTML) — a one-time, intentional reset.
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(+STALEon 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.vue→common.preferences.translation.*). Gated on the activeAIClientFeature + planPRO/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 intests/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 → generateTranslationusesaiModel('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 onBATCH_SIZEinserver/tasks/translate-pending.ts). Poll/embed/img blocks are masked out with cheerio before the model sees them (maskContentBlocks) —data-poll-id/optionId/imagesrcnever travel through the LLM as free text; poll question+labels, imagealt/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 onrebuildContent(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 viaconsumeClientTokens('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) cronserver/tasks/translate-pending.ts(every 5 min) drainsPENDING/STALEwith a per-row atomic claim (guardedupdateMany→TRANSLATING) so concurrent runs never double-translate — AUTO →PUBLISHED, HYBRID →READY(awaiting review), out-of-budget rows release back toPENDING. Enqueue/STALE is wired into all four publish paths viaserver/utils/ai/translationQueue.ts → syncArticleTranslationQueue(articles/index.post,[id]/index.patch,publish-check,generate-article). STALE keys off an explicit content-change signal, notArticle.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 theArticleTranslationslug, which does not exist onArticleat all.[id]/index.get.tshandled that;[id]/relatednever did, and itsfindUniqueOrThrowturned it into an unhandled 500 on every/enarticle 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.relatedalso localizes its cards vialocalizeArticlesand is scoped toclientSiteId—Tag.clientSiteIdis nullable, so a global tag matched articles across tenants. - Review UI — HYBRID's missing half.
READYmeans "awaiting review", but until now nothing inapp/could review it: no page readArticleTranslation, and no client calledPOST /api/articles/[id]/translate. HYBRID therefore burned tokens writing rows no one could publish, and MANUAL was unreachable. The reviewer isArticle/Translations.vue, mounted inadmin/editor/[id].vuefor 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.vuesurfaces the queue on/admin(top 5READYrows, 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
clientSiteIdexplicitly on top of the ZenStack policy.ArticleTranslation's read rule isstatus == '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
sourcetoHUMAN— the audit column must stop claiming a machine wrote text a person rewrote.PUBLISHEDis refused unless title+content exist, so a body-less queue row (PENDING/TRANSLATING/FAILED) can't be published into a blank localized page with ahreflangpointing 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].vuenow carriesArticle/Editor/LanguageTabs.vue(a segmented control in the sticky header, stacked below it undersm): 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.tsowns that state:activeLang === ''means the source, so the page branches on one flag instead of tracking two ideas of "current language". It isreactive()-wrapped at the call site so the template readstr.isSourcerather thantr.isSource.value. The page routes the shared textareas throughtitleModel/excerptModel/bodyModelcomputeds, souseTextareaAutosizestays bound to one thing while the underlying target changes.hasChangesfolds intr.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 thetranslationStatusDot/translationStatusBadgeclass maps.resolveActiveLanguagealso fixes a latent bug in the old inlinewatchEffect— it picked the firstREADYrow 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 neitherby-clientsite/[slug].tsnorfeatured/[slug].tstouchedArticleTranslationat all, so an/envisitor got Czech titles linking to Czech slugs and a published translation was reachable only viahreflangor Google — tokens were being spent on pages nobody could click to. Both endpoints now takelocaleand overlay each card with itsPUBLISHEDtranslation.overlayTranslationonly 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), andlocalizeArticlesfiltersstatus: '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 localizedNuxtLinks 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 ashreflang, so a language is never offered that would only fall back. - Admin
Article/Table.vue— alanguagescolumn, not extra rows: the bloat guard from §6b holds.articles/search.tsgainedinclude: { 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,ArticleStatusCellis the single status presentation and emits its update directly toActionsBar— 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 anmdi:open-in-newlink to the live page of whichever language is on screen. It is passed asuseArticleTranslations(id, initialLang)rather than assigned toactiveLangafterwards, and the reconcilingwatchEffectis gated onstatus === 'success'. Both matter: an emptytargetLanguagesmeans either "this site has no targets" or "the request has not resolved", and reconciling against the second silently reset?lang=ento the source tab before the payload arrived — the deep link looked like it did nothing.
- Article detail — gated on
- SEO framing that drove the above: translated pages are not duplicate content — that is what
hreflangis 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.
- Endpoints:
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 @ 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_FIELDS → publicClientSiteSelect (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 byuseClientSite()on every page → lands in the SSR payload) andclients/[id]/by-userid.get.ts(public, powers the author card) select through the whitelist. Both previously returned the whole row from rawprisma, i.e. every tenant'sapiKeyand Stripe ids were readable bycurl-ing any blog.@omitwould not have helped: these go through rawprisma, andClientSiteis@@allow('read', true)anyway, so the select is the control.clients/index.get.tsisrole: 'superadmin'viarequireDb. It was session-optional withgetEnhancedPrisma(user), and because the model's read policy istruethat 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, keepsapiKey/Stripe ids for the owner) now 403s unlessid === user.clientSiteIdor the caller is superadmin. The old check was['superadmin','admin'].includes(role)with an attacker-controlledid— any tenant admin could read any other tenant's full row.clients/status.get.tsis where owner-only numbers live now (plan,tokenLimit/tokenRemaining/totalUsage,createdAt,firstPaidAt,focus,audience, plus a derivedhasActiveSubscriptionboolean — 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;adminmin-role, and it returnsnullfor a superadmin with no own site. Exposed asuseClientSiteStatus()next touseClientSite()and consumed bypages/admin/index.vue(trial modal),Client/Version.vue(quota bar) andArticle/Empty/Owner.vue(focus/audiencefor the launch checklist) — all of which used to read those fields off the public payload.useClientSiteStatus()fetches throughuseRequestFetch()and returns theuseAsyncDatahandle, notdata.value. Both halves were bugs: bare$fetchduring SSR sends no session cookie, so the endpoint 401'd, a.catch(() => null)swallowed it, and thenullwas 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). Returningdata.valuethen froze the snapshot at setup time, so nothing re-rendered after a top-up. The sameuseRequestFetch()rule as the Colada queries above — it applies to any authenticateduseAsyncDatahandler. 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.
checkout.post.tscreates every tenant onTRIAL_PLAN(=PREMIUM) withfirstPaidAt: null, so the trial exercises the whole product including the crons. The plan column therefore cannot tell you whether anyone paid —firstPaidAtdoes, and every predicate inshared/utils/trial.ts(isInTrial,trialExpired,needsTrialDowngrade, pure +tests/unit/trial.test.ts) reads it. Before this the trial was a client-sideplan === 'BASIC' && age < 14dcheck inpages/admin/index.vue, which meantAI_CAPABLE_PLANSlocked the launch checklist'svoicestep for exactly the people being courted.stripeWebhook.ts → marksFirstPayment: atrialingcheckout movesplanbut must not stampfirstPaidAt, or the trial ends the day it starts. Bothcheckout.session.completedand thesubscription.updatedtrial-end path callsyncPlanFeatureson whatever plan they resolve.trial-expirycron (0 5 * * *) only touches card-less trials.expiredTrialWhererequiresstripeSubscriptionId: nullbecause Stripe owns a card-backed trial's lifecycle — promoting on conversion, revoking throughrevokesPlanon a failed first invoice — and a cron downgrade would race that webhook mid-conversion.tests/server/tasks/trialExpiry.test.tspins the SQL filter against the predicate; drift either strands tenants on the trial plan forever or wastes the query.syncPlanFeaturesat signup is deliberately non-fatal (try/catch,TRIAL_FEATURE_PROVISIONING_FAILED). It throws on an unseededFeaturecatalog — 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 fromemptySite.ts):generate/index.post.tsreads it off the row it already fetches,generate/enhance.post.tsviarequireAiPlan. 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 leavestokenRemainingalone (a trial tenant may have bought a token pack). - Known wart:
end-trial.post.tswritesfirstPaidAtas 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.
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:
ClientFeaturerows → what the settings panel rendered (activeFeatures), written only byfeatures.patch.ts; nothing created them on a plan grant, so a paying tenant had none.ClientSite.enableAi/enableCron/enableSentiment→ what the translation endpoint and cron actually checked, written only at onboarding and never by the toggle.- Bare plan checks →
sentiment-analysis.ts; andgenerate-article.tschecked nothing at all beyondgenerationFrequency+ 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 andbillingLockedUntilcarries real billing weight).syncPlanFeatures(tx, siteId, plan)applies it and is called on every plan transition: allstripe/webhook.tspaths (promote, portal plan change, revocation → BASIC), the superadmin plan edit inclients/[id]/index.patch.ts, and the dev switch_dev/plan.patch.ts— each inside the same transaction as theplanwrite.syncPlanFeaturesis "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) andrecalcFeatureBilling. 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 relationwherefor cron batch queries) orhasActiveFeature(db, siteId, code). Both take a structural DB type rather thanPrisma.TransactionClient, matchingtranslationQueue.ts/translationSlug.ts— the concrete Prisma type does not accept the enhanced client. The read path takes the narrowFeatureReadDb(clientFeature.countonly); only the write path needs the widerFeatureSyncDb(addsfeature+clientSite). - Backfilled/plan-granted rows get
billingLockedUntil = now()(no lock):billableMonthlyTotalbills 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 — seebillableFeatureWherebelow. autoReleasecannot outliveARTICLE_CRONS(syncAutoRelease). The switch is hidden without scheduled generation (Form/Client/AI.vue) and costs a danger confirm to turn on, so leaving ittruethrough 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-pendingalready refused to drain the queue without it, so a revoked tenant still ontranslationMode: AUTOkept piling up invisiblePENDINGrows on every publish — which a later re-subscribe would flush as one burst of months-old translations.featuresis optional in the structuralQueueDb(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
Featurecatalog is load-bearing and was seeded by hand. With gating derived from it, an empty catalog would silently switch every feature off for everyone, sosyncPlanFeaturesthrows on a missing code instead of skipping it, andprisma/seed.tsupserts the three rows (update: {}— it never overwrites real prices). - Consequence to know:
sentiment-analysis.tsused to run forplan IN (PRO, PREMIUM), butgetAllowedFeaturesonly grantsSENTIMENTon 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.vuequoted a per-feature monthly price on every plan even thoughbillableMonthlyTotalreturns 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 intoForm/Client/FeatureToggle.vue(accent classes passed as whole static strings so UnoCSS still sees them).
- 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 bothclients/[id]/index.get.ts(allowedFeatures) andclients/[id]/features.patch.tsso 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,monthlyPaymentstays 0 (the plan itself is the Stripe charge). CUSTOM → à-la-carte:monthlyPayment = Σ billed featurepriceMonthly``(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: trueORbillingLockedUntil > 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 withbillingLockedUntilalready 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 requiresgetAllowedFeatures[code](else 403) and its AI prerequisite active (else 400); disabling AI cascades —getDependents('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.priceMonthlyholds USD; the whole display layer is USD-base:POST /api/currency→server/utils/currency.ts → usdCrossRate(CNB daily rates, cross-converted via CZK,amount-normalized, unit-tested),useCurrencyRatereturns USD→target (1for USD), andBilling.vue/AI.vuemultiply USD amounts by the rate (was CZK-base divide). CZK is now just another display currency.
- Client: all handlers (
subscribe,checkout,portal,webhook,onboarding/checkout) share one lazy singleton —server/utils/stripe.ts → useStripe()— which readsSTRIPE_SKonce and pinsapiVersion: '2025-08-27.basil'(SDK 18.5.0) so a Stripe-side default bump can't shift behavior. Lazy (not eager likeprisma) soonboarding/checkoutkeeps 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_PREMIUMmust 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(modesubscription). Bodyplan+ optionalinterval(month/year); whenintervalis absent it falls back to the site'sbillingPlan === 'ANNUAL' ? 'year' : 'month'.intervalis stamped into checkout + subscriptionmetadata. - Token top-ups:
POST /api/stripe/checkout(modepayment, ad-hocprice_data). Token packs are a server-side catalog (shared/utils/tokenPacks.ts,getTokenPack) — the client sends only apackid; price + token amount are resolved server-side (closes price-tampering).tokenLimitis the site's total allocated token capacity andtokenRemainingis its spendable part; the invariant is0 ≤ tokenRemaining ≤ tokenLimit. A top-up increments both values by the purchased amount, whiletotalUsagerecords actual consumption exclusively throughconsumeClientTokens. 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. Migration20260831113000_token_capacity_invariantrepairs historical data and installs checks; follow-up20260831123000_token_capacity_rolling_deployadds 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 thesettingsbilling tab. Requires the Customer Portal to be enabled/configured in the Stripe Dashboard (test + live). - Auth:
checkout,subscribe, andportalall require a session and deriveclientSiteIdfrom it (superadmin may override via body) — never trustclientSiteIdfrom the body (was an IDOR). - Webhook (
POST /api/stripe/webhook) handlescheckout.session.completedfor both modes,customer.subscription.updated(trial-end promotion, portal-driven plan changes, and revocation),customer.subscription.deleted(→ downgrade to BASIC), andinvoice.payment_succeeded(→ bumplastPaidAt/lastInvoicedAt). Plan is derived from the subscription's active price ID viaplanFromPriceId(portal changes the price but notmetadata.plan), falling back to metadata.planFromPriceIdrecognizes 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 inserver/utils/stripeWebhook.ts(unit-tested);extractSubscriptionIdreadsinvoice.parent.subscription_details.subscription(Stripe API2025-03-31.basilremoved top-levelinvoice.subscription). - Losing a plan is not only
subscription.deleted.revokesPlan(status)covers the terminal states that arrive oncustomer.subscription.updatedand never produce adeletedevent: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_dueis 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).clearSubscriptiononly on a terminal deletion — anunpaidsubscription still exists in Stripe and revives on payment (updated → activere-promotes it), so its id is kept.stripeCustomerIdsurvives either way, or the tenant loses portal access to their own invoice history. - Billing UI: the
settingsbilling 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, whenstripeCustomerId), 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.vueon the admin dashboard, the only one that checks out inline (POST /api/stripe/subscribe). It reads the samegetUpgradeTarget(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. Itsv-ifused to be a page-levelplan === 'BASIC', which hid the PREMIUM pitch entirely while standing in for that guard by accident. Dismissals are aupsell-dismissedlocalStorage 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,isAdminonly). Its panel renders the quota headline + progress bar, the pack list, recent client logs and connection chips; the whole quota block is gated onhasTokenPlan(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 intests/unit/tokenPackPresentation.test.ts) — a pure mapper overTOKEN_PACK_LIST. Prices/token amounts are never re-declared client-side: money is formatted byformatTokenPackPricefrom the shared catalog, and onlyPACK_PRESENTATION(icon / i18n key /featured) is app-layer. A pack with no presentation entry degrades to its catalogname+ 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=billingrather 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/resetis 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 givenappearance-none border-0 bg-transparent p-0 text-inherit.base.scssthen addsbutton:hover(light) andbutton+button:hover(dark) backgrounds at specificity(0,1,1)/(0,1,2), so a plainbg-transparent(0,1,0)loses in dark mode — cancelling them needs thedark:-prefixed duplicates. Any new unstyled button in this codebase faces the same two layers. - Pack rows are plain
<button>s, notButton/index.vue— that component forcesh-12,justify-center, its own border/shadow, ahover:scale-105, and avariantClassbackground appended after any custombg-*, so custom gradients/variants fight it non-deterministically in UnoCSS. Badges are laid out in flow (neverabsolutewith negative offsets inside anoverflow-hiddenparent, which previously clipped the "best value" ribbon and overlapped the price).
- Pack view models come from
- Onboarding auto-login.
onboarding/checkout.post.tsmints a single-useonboardingLoginToken(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 Stripesuccess_urlpaths).pages/autorizace/index.vueredeems it on mount viasignIn('credentials', { loginToken }); the Credentials provider'sauthorizebranches toauthorizeWithOnboardingToken(server/api/auth/[...].ts) which validates token+expiry, atomically single-uses it (updateManyguarded on the token →count===0blocks replay/races), then reuses the normal JWT/session pipeline. On expiry/failure the page toastscommon.auth.onboardingLoginExpiredand 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.
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 inapp/composables/useGam.ts → initialize— so GAM revenue reports break down per site.useAdChanceis now only an SSR-safeuseStatemirror of that targeting (the former client-sideMath.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 intests/unit/adRevenue.test.ts):splitAdRevenue/buildEarningRowapply the plan ratio to GAM gross; the invariantclientCents + platformCents == grossCentsholds (incl. negative clawbacks).CLIENT_SHAREis the single source of the ratio. - Ledger:
AdEarning(prisma/models/ads.zmodel, migration20260725120000_ad_earnings) — one row per(clientSiteId, periodStart, periodEnd)holding gross/client/platform cents, ashareRatiosnapshot, andAdPayoutStatus. Read = superadmin or admin of own site; create/update/delete = superadmin/system only (clients cannot fabricate earnings).shareRatiois snapshotted per row so a later plan change never retroactively rewrites past earnings. - Ingest:
server/tasks/gam-sync.ts(cron0 4 * * *) pulls the previous UTC day per-client and idempotently upserts the ledger (re-runs correct the numbers). No-ops whenGAM_NETWORK_CODE/GAM_SERVICE_ACCOUNT_KEYare unset; fails loudly on half-configuration so revenue is never silently dropped. - GAM Reporting client (
server/utils/ads/, no new dependency):gamAuth.tsmints an RS256 service-account JWT withnode:cryptoand exchanges it for anadmanageraccess token (cached until ~2 min before expiry);gamReport.tsdrives the Ad Manager REST v1 report lifecycle — create report →:run→ poll the operation → paginate:fetchRows. Attribution comes from theclient_idkey-value dimension named byGAM_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 singlemicrosToCentsrounding, so split rows can't drift a cent. Pure helpers (toGamDate,gamDateRange— turns our exclusiveperiodEndinto GAM's inclusiveendDate—buildReportDefinition,readValue,parseReportRows) are unit-tested intests/unit/gamReport.test.ts; the network path is untested-by-design (no live credentials). - Read API:
GET /api/clients/earnings(requireDb,minRole: 'admin') — optionalfrom/to(default last 12 months, hard cap 24) andclientSiteId. Tenant isolation is ZenStack policy, not thewhereclause: an admin passing a foreignclientSiteIdsimply gets nothing back. Returnssummaries(one per currency —summarizeEarningsnever sums across currencies), each with totals,byMonth, andclientCentsByPayoutStatus, plus abySitebreakdown and ascopeflag (platformfor an unfiltered superadmin). - Consent and advertising:
ConsentManager.vueis 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-onlytopiqu_consentcookie 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 pureconsentLauncherFor: admin and superadmin get a dedicated cookie action directly in the collapsedClientVersionbar; readers and anonymous visitors getConsentSettingsButtonin the same bottom-right area after making a choice; non-public reader surfaces get neither. Both launchers expose the sharedbottom-action-barmarker, 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/sidebarand configure its GAM privacy message for its domains.ArticleTOC.vueowns the desktop right rail and exposes itssidebarslot 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 withcookie_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>:asideimpliescomplementary, which does not allowdialog, 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/payoutRefare the anchors already in place; admin earnings UI (API exists, no page yet). - Token top-ups need no Stripe catalog objects —
stripe/checkoutbuilds ad-hocprice_datafromshared/utils/tokenPacks.ts(10k/$2.99, 25k/$4.99, 50k/$9.99) at checkout, so packs are ready without pre-created Prices.
- ESLint 9 (
@nuxt/eslint+eslint-plugin-perfectionist), Prettier 3. - Testing: Vitest 4 with
@nuxt/test-utils, jsdom, V8 coverage. Config invitest.config.ts(jsdom env). Tests live intests/**, co-located*.test.tsis also picked up. First suite:tests/server/stripe/webhook.test.ts. - Typecheck via
vue-tsc(bun run typecheck); kept out ofbuildfor fast deploys, run separately in CI. - Package manager: bun (
bun.lock), pinned to one version inpackage.json,.prototools, Docker and CI.tests/unit/releaseToolchain.test.tsprevents 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.
- 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:scheduledTasksfire once per replica. Now live rather than hypothetical: all eight crons run natively, so scalingwebpast 1 replica in Dokploy — a one-click action — would duplicate article generation and double-countgam-syncrevenue. Settle this before anyone scales: keepwebat 1, move the tasks into a single-replica worker, or dropnitro.scheduledTasksfor Dokploy Schedule Jobs hitting aCRON_SECRET-guarded endpoint.- No long-running worker yet.
server/worker.tsand the queue abstraction never landed, so the queue epic intodois unblocked but unstarted. Open question inside it: whether the queue belongs in Redis Streams at all, givenArticleJob/PdfJobare Prisma models and PostgresSELECT … FOR UPDATE SKIP LOCKEDwould 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 theArticleTranslationsidecar (see §6 → Article Translations).pages/clanky/[slug].vueresolves content locale-scoped (primary language → sourceArticle; other locale → itsPUBLISHEDtranslation, falling back to the source as a legacy i18n alias when none exists). Once ≥1 translation is published, the page emits realhreflang+x-defaultand self-canonicalises per locale; with no translation it still collapses the duplicate/cs↔/enalias 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 app —
useCanonicalOrigin()(app/composables/, wrapping the purebuildCanonicalOrigin/toAbsoluteUrlinshared/utils/seo.ts, tested bytests/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 hardcodedi18n.baseUrl/site.urlwould stamp the wrong host on every client site. It forceshttps:outside dev (a proxy that dropsx-forwarded-protowould otherwise emithttp:canonicals) and strips a leadingwww..pages/clanky/[slug],stitky/[slug]andautor/[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 onwww. - The i18n-generated
hreflanglinks are absolutised inapp.vue.useLocaleHead()prefixes itslinkentries withi18n.baseUrl, which is unset (and cannot be set statically — see above), so it emitted relativehrefs oni18n-alt-*/i18n-xd; search engines ignore relative hreflang, which is what a Bing audit ofpixbo.topiqu.comflagged.app.vuenow mapsi18nHead.linkthroughtoAbsoluteUrl(href, canonicalOrigin)before spreading it intouseHead, so every generated alternate is fully qualified per tenant. Already-absolute hrefs pass through untouched, so this stays correct ifbaseUrlis ever configured.
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.vueused to split the stored HTML inonMountedbehind animport.meta.clientguard, so the served markup contained an empty container — and GPTBot, ClaudeBot, PerplexityBot and CCBot do not run JavaScript. The split now happens inarticles/[id]/index.get.tsviaserver/utils/articleBlocks.ts(cheerio) and ships asblockson the payload. Shaping rules live once inshared/utils/articleBlocks.ts; the editor preview adaptsDOMParseronto the same builder (app/utils/articleBlocks.ts). Adjacent HTML nodes merge into one run — a wrapper per node breaks the adjacent-sibling rulesprosedepends on. Stored body images remain raw HTML, butArticle/Parsed.vuenow injects Nuxt Image/IPX WebP candidates throughshared/utils/articleImages.ts. Every image retains its direct upload indata-original-src; a failed proxy response removessrcsetand retries that upload, so responsive delivery cannot make the article unreadable. - Heading anchors come from
headingSlug(NFKD, soPřehled trhu→prehled-trhu, notp-ehled-trhu).stampHeadingIdswrites them on save (POST/PATCH); the block builder stamps any body that predates that, and never overwrites an existing id.Article/TOC.vueonly generates ids for the editor preview. - Structured data is one linked graph, not three hand-built
ld+jsonblobs.app.vuedefines 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 detachesWebPage.isPartOf;useArticleSeoadds Article/BlogPosting, the author Person ({authorUrl}#author, the same@idautor/[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.sourcesbecomescitation,publishedAtbecomesdatePublished(createdAtis when the draft was opened), andtotalWords/readingTimebecomewordCount/timeRequired. A second bare#organizationnode next to#identityis the module's own logo node, not a duplicate. - Sitemap is entirely
server/api/__sitemap__/urls.get.ts;excludeAppSourcesandautoI18n: falseturn off the module's own discovery, which only ever finds the app shell. It emits alternates only forPUBLISHEDtranslations, and skips the homepage for a tenant with no articles and tag/author pages on BASIC — in both cases the page itself emitsnoindex, 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.tsrestates thei18n.pagessegments because Nitro has nolocalePath.tests/unit/routes.test.tsasserts them againstnuxt.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.tspushes the tenant's name/description/locale into the site-config stack aboveSiteConfigPriority.runtime.nuxt-seo-utilsbuildstitleTemplateandogSiteNamefromsite.name, so without it every tenant's pages were titled… | Topiqu AI Blogand 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.
useLocaleHeadderives alternates from the route alone, so it advertised a translation that may not exist;app.vuefilters itsalternatelinks out on the article route.x-defaultisalternates[0], which the API builds as the source language. - Extraction fields.
Article.answer/keyTakeaways/faq(mirrored onArticleTranslation) 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 sameArticleSummary/ArticleFaqcomponents as the public article, because they live outside the TipTap HTML body.readFaq(shared/utils/articleFaq.ts) narrows the Json column;FAQPageis emitted only when it is non-empty. The mirror is only as good as the write —generateTranslationtranslates and bills all three, but both write sites build their owndataobject, so a dropped field costs the tokens and then renders the source language under a translated body (tests/server/ai/translationRow.test.tspins both).[id]/index.get.tsfalls 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, andCRAWL_LIMITraises 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 bothnuxt.config.tsand the runtime. Three fetching groups (ANSWER_ENGINE_BOTSretrieval/user-triggered,TRAINING_BOTScorpus,SEARCH_BOTSclassic) plusAI_GROUNDING_TOKENS, which are not crawlers —Google-ExtendedandApplebot-Extendedhave no user agent at all and exist only as robots.txt control tokens for Gemini and Apple Intelligence, so they are absent fromdetectCrawler. Matching is longest-token-first orClaude-SearchBotresolves asClaudeBot. server/plugins/crawlerLog.tsships 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.txtand the.mdvariants, 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, oneh1, and the schema graph complete with no dangling@id. That last check is what caughtWebPage.isPartOfpointing at a WebSite node that no longer existed.
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.
- No vendor lock-in. Better Stack's error tracking speaks the Sentry SDK protocol, so we instrument with the official
@sentry/nuxtSDK 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.
sentry.client.config.ts(project root) — SDK init fromruntimeConfig.public.sentry. Empty DSN disables the SDK (local / CI).sentry.server.config.ts— same init, but readsprocess.envand must never calluseRuntimeConfig(). 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 isbun --bun server/index.mjs, sosentry.autoInjectServerSentry: 'top-level-import'imports it from the server entry instead. Without either,Sentry.initnever 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 withoutSENTRY_AUTH_TOKEN);sourcemap.client: 'hidden'.server/plugins/errorLogging.ts— Nitroerrorhook, 5xx only, shipping to Better Stack Logs viautils/logger.ts. Sentry covers error tracking; this is the second trail, alongside thecron:andaudit: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.tssees what Nitro turns into a 5xx. Atry/catchthat 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-argumentreportErrorauto-import. It matters most where a failure is designed to be non-fatal: best-effort image generation, stock-provider fallbacks, andarticles/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.example—NUXT_PUBLIC_SENTRY_DSN,SENTRY_URL/ORG/PROJECT/AUTH_TOKEN.- Replay masks all text + media (
maskAllText,blockAllMedia) for GDPR. - CSP: works as-is —
nuxt-securityconnect-srcallowshttps:, replay worker uses existingblob:inscript-src.
app/app.config.tsis the central Nuxt UI visual configuration;app/assets/styles/main.csscontains global semantic tokens, locally bundled variable fonts, reading typography, and reduced-motion behavior.app/components/AppMedia.vueis the shared aspect-ratio-safe media primitive. Stable local and allowlisted CDN URLs render throughNuxtImgas responsive WebP; untrusted or signed URLs stay direct.app/composables/useImageRetry.tsretries transient sources and falls back to an original upload when supplied. New AI article images pass throughserver/utils/images/optimize.tsbefore 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.vueuses 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 sameuseAsyncDataentry, and both go through onefetchClientSiteso they cannot drift.Header.vueneeds 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/settingscallsrefreshClientSite(); itsgetCachedDatareturnsundefinedforcause === '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:UFileUploadfor the empty state (it owns the dashed dropzone, drag-and-drop and the dragging state),USliderfor zoom,UAlertfor 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.useAvatarCropperexposesacceptFileso a file can arrive from a drop, not only from its own file dialog. Transparency behind a logo or favicon is drawn by.transparency-gridinmain.cssfrom--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.vuewas written against a<Modal>andForm/Client/IntegrationsCatalog.vueagainst 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 (UModalwith#body,AppFormField), andnuxtUiContract.test.tsresolves every PascalCase template tag againstapp/components/, Nuxt UI and an explicit external allowlist. - A rejected upload always names its reason.
File/Uploader.vuetoasts acommon.upload.*message with the measured value against the limit instead of a generic failure, andserver/api/upload.tsreturns the same keys translated, so a client-side refusal and a server-side one read alike. The favicon limits are one source of truth inshared/utils/favicon.ts. A file the browser cannot decode resolves the probe tonull— the earlierImage.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 everyArticleCardrenders 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.vuekeeps 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 fromclients/[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.vuehero,Article/Empty/Visitor.vue, bothForm/Client/Branding.vuepreviews) render it below the name attext-base text-highlighted, above the lightertext-lg text-muteddescription. In the hero it was a flex sibling of the logo, wheremin-width: autostopped it shrinking and one long word escaped the column; it is now a block child of the column withmax-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 inog:titleonly —<title>stays the bare name because it is per-page and templated. tests/unit/i18nCompleteness.test.tskeeps Czech and English keys synchronized and rejects unresolved static translation calls.playwright.config.tsandtests/e2e/provide deterministic responsive visual and accessibility checks. They require a separateTEST_DATABASE_URLand refuse to reuseDATABASE_URL. Therelease-smokeproject 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.jsto a non-cached, non-app-shell navigation configuration.