Skip to content

Commit b73aeb5

Browse files
authored
feat: standalone help center with MCP tools and UI polish (#128)
* docs: add help center standalone subdomain design spec * docs: add help center standalone implementation plan * feat(db): add schema changes for standalone help center Add parentId/icon to kb_categories, position/description to kb_articles, change embedding dimension to 768, add helpCenterConfig to settings, and create kb_domain_verifications table for custom domain tracking. * feat(settings): add HelpCenterConfig types, service, and schemas Add settings infrastructure for the standalone help center feature: - HelpCenterConfig and HelpCenterSeoConfig interfaces with defaults - getHelpCenterConfig/updateHelpCenterConfig service functions - helpCenterConfig in TenantSettings (parsed from DB text column) - Zod schemas for config and SEO updates - Server functions (admin-only GET/POST endpoints) - Updated category schemas (parentId, icon) and article schemas (position, description) - Barrel export updated with new types and defaults * feat(help-center): update category/article CRUD for parentId, icon, position, description Add parentId and icon support to createCategory/updateCategory. Add position and description support to createArticle/updateArticle. Add listPublicArticlesForCategory service function and server function. Update HelpCenterCategory and HelpCenterArticle types to include the new fields. All new fields are passed through in serialized responses. * feat(routing): add hostname detection and routing for standalone help center Add isHelpCenterHost pure helper with 16 unit tests covering custom domain, convention subdomain (help.{slug}.{baseDomain}), port stripping, and edge cases. Wire detection into bootstrap, root route context, and portal guard. Support HELP_CENTER_DEV env var and ?mode=help-center query param overrides for development. * feat(help-center): add layout route and landing page Add the help center pathless layout route (_helpcenter.tsx) and landing page with hero search and category grid. The layout guards on helpCenterHost, feature flag, and config.enabled. It injects theme CSS, branding, i18n, and renders HelpCenterHeader with category tabs. Components: - help-center-header: logo, org name, category tabs, compact search - help-center-search: hero search with 300ms debounced API calls, dropdown results; compact button placeholder for inner pages - help-center-category-grid: responsive grid of category cards - help-center-utils: pure functions for category filtering, active state detection, content truncation (with tests) * feat(help-center): add category page with sidebar, breadcrumbs, and subcategory support - Category layout route loads category by slug, articles, and subcategory articles - Sidebar component shows category articles with active state and indented subcategories - Breadcrumbs component renders Help Center > Category > Article trail - Category index page displays article cards with descriptions and arrow icons - Pure utility functions (getSubcategories, buildCategoryBreadcrumbs) with full test coverage * feat(help-center): add article detail page with TOC, prev/next nav, and feedback TDD'd extractHeadings (TipTap JSON -> TOC) and computePrevNext pure functions. Article detail route loads via getPublicArticleBySlugFn, renders rich text content, breadcrumbs, sticky table of contents with scroll spy, previous/next article navigation, and thumbs up/down feedback with optimistic updates. Full SEO head tags with OG and canonical URL. * feat(settings): restructure admin settings with Portal and Help Center sections Move Widget settings from Feedback to a new Portal section, add Portal General page with feature toggles, and create Help Center General/SEO settings pages gated by the helpCenter feature flag. Old /admin/settings/widget route redirects to /admin/settings/portal-widget. * feat(help-center): add Gemini embedding service for KB articles Generates embeddings via Gemini text-embedding-004 (768 dims) through the OpenAI-compatible API. Triggers fire-and-forget on article create/update. * feat(help-center): add hybrid search combining keyword + semantic matching Adds a hybrid search service that combines tsvector keyword search with pgvector semantic similarity (0.4/0.6 weighting). Falls back to keyword-only when embeddings are unavailable. Updates the widget kb-search endpoint and adds a public searchPublicArticlesFn server function. * fix(help-center): include category id in kb-search response for backward compat * feat(help-center): add custom domain verification service and server functions CNAME-based domain verification so customers can serve their help center on a custom domain. Includes DNS lookup, pending/verified/failed status tracking, and server functions for adding, checking, and removing domains. * feat(help-center): add SEO structured data and sitemap Add JSON-LD structured data (Article, BreadcrumbList, CollectionPage) to help center article and category pages, a sitemap.xml route for search engine crawling, and noindex meta for authenticated help centers. * refactor(portal): remove old help center routes, update widget links to subdomain Delete portal help center routes (/help, /help/$category, /help/$category/$article) and components now replaced by standalone help center on its own subdomain. Add getHelpCenterBaseUrl helper to build the correct help center URL (custom domain or convention subdomain), and update the widget help detail "view on portal" link to navigate to the standalone help center instead of the old portal route. * fix(help-center): resolve type errors in embedding service and article route - Cast articleId to HelpCenterArticleId in embedding service eq() call - Rename shadowed loaderData variable to parentLoaderData in article head() * fix(help-center): rename _helpcenter to hc for TanStack Router compatibility TanStack Router's file-based routing doesn't support two pathless layouts (_portal and _helpcenter) both resolving to /. Renamed to /hc path prefix. On production subdomains, a reverse proxy maps help.acme.com/* -> /hc/*. The HELP_CENTER_DEV=true env or helpCenterHost detection in _portal.tsx redirects to /hc when on the help center subdomain. * fix(help-center): update all route references and links for /hc prefix After renaming _helpcenter to hc, all internal references needed updating: - getRouteApi() calls: /_helpcenter -> /hc - Article/category links: add /hc prefix - Breadcrumb URLs: add /hc prefix - Header logo and tab links: point to /hc - Tests updated to match new paths * docs: add help center category management design spec * docs: add help center category management implementation plan * feat(help-center): add CategoryNav sidebar with drag-to-reorder * feat(help-center): add CategoryFormDialog with emoji picker * feat(help-center): integrate CategoryNav sidebar and inline status filter * feat(help-center): add inline category creation from article editor * feat(help-center): add inline category creation to create article dialog * fix: update tests for domain verification change, fix unused vars lint * test: add category schema tests for emoji icons, position reorder, form payloads * fix: track help redirect routes, use branded HelpCenterCategoryId type * fix: parallelize drag-reorder mutations, fix article serialization consistency * docs: add MCP help center tools design spec Covers new scopes (read:help-center, write:help-center), extending search and get_details tools, 4 new write tools, 1 new resource, and feature flag gating. * docs: add MCP help center tools implementation plan 10 tasks covering scopes, feature flag, search/get_details extensions, 4 new write tools, resource, and doc header update. * feat(mcp): add read:help-center and write:help-center scopes * feat(mcp): register help center scopes in OAuth provider and consent screen * feat(mcp): extend search tool with articles entity * feat(mcp): add help center tools (get_details, create/update/delete article, manage category) - Expand help-center.service imports (getArticleById, getCategoryById, createArticle, updateArticle, publishArticle, unpublishArticle, deleteArticle, createCategory, updateCategory, deleteCategory) - Add HelpCenterArticleId and HelpCenterCategoryId to @quackback/ids type imports - Extend get_details with helpcenter_article and helpcenter_category prefix routing (per-branch scope checking) - Add create_article tool (draft creation, write:help-center scope) - Add update_article tool (patch fields + publish/unpublish via publishedAt, write:help-center scope) - Add delete_article tool (soft-delete, write:help-center scope) - Add manage_category tool (create/update/delete, write:help-center scope) - Add getArticleDetails and getCategoryDetails helper functions * feat(mcp): add help-center/categories resource, refactor scopeGated * docs(mcp): update tools.ts header to list all 27 tools * fix(mcp): clarify article status values in search, fix publishedAt description Add article status values (draft/published/all) to the search tool's status field description. Clarify that update_article publishedAt publishes immediately rather than using the provided timestamp. * feat: refresh OAuth consent screen UX Group scopes by feature area (Feedback, Changelog, Help Center) with monochrome Read/Write pills. Remove Card wrapper to match login page layout. Larger buttons, quieter footer, error state for failed consent. * fix: align help center category nav with shared filter section styling Use text-[10px] header size, remove divider, wrap items in mt-2 container to match the changelog/feedback sidebar patterns. * fix: align all admin left sidebar headers to consistent styling - Help center: use text-[10px] header, matching FilterSection pattern - Roadmap: shrink + button from h-7 to h-5, reduce header bottom gap - Analytics: add "Sections" header label using shared header pattern - All sidebars now use: text-[10px] font-semibold uppercase tracking-wider, py-1 header row, mt-2 items wrapper, h-5 w-5 action buttons * fix: align users sidebar header to shared admin sidebar pattern Use text-[10px] uppercase header, h-5 w-5 + button, remove divider, wrap items in mt-2 container to match all other admin left sidebars. * fix: replace raw select with shadcn Select for category dropdown Use Radix-based Select component with proper popover, icons, and styling instead of a native <select> element. * fix: prefill category select when categories are loaded Only set Select value when categoryId matches a loaded category, preventing the placeholder from showing when the value exists but items haven't rendered yet. * fix: show category value even before categories list loads Pass categoryId directly to Select value instead of gating on categories being loaded. Radix handles unmatched values gracefully and renders correctly once items load. * fix: enforce authenticated-only access on help center routes Check session in beforeLoad when access is set to 'authenticated'. Redirects unauthenticated users to login. Previously the setting only affected the robots meta tag but didn't gate actual access. * refactor: simplify MCP tools and consent screen after review - Extract articleResult(), categoryResult(), truncateExcerpt(), requireHelpCenterWrite() helpers to eliminate copy-paste - Use publish/unpublish return value in update_article instead of re-fetching - Pre-derive isSafeUrl booleans in consent legal links - Drop redundant optional chaining in hc.tsx after null guard * feat: rename API routes from /api/v1/kb to /api/v1/help-center Rename to match user-facing product name. Fix serialization bug in listPublicArticlesForCategoryFn that called serializeArticle on partial results missing createdAt/updatedAt fields. * feat: redesign widget help tab with category grid and fix settings - Move Help tab toggle from Content section to Tabs section alongside Feedback and Changelog in widget settings - Remove redundant Content settings card (image uploads handled by Permissions) - Replace search-only help tab with mini help center: search bar + 2-column category card grid, with drill-down into category article lists - Add widget-help-category component for browsing articles within a category - Fix widget loader to gate help tab on feature flag AND help center enabled AND widget config (was only checking feature flag) - Update settings preview to reflect help tab with category grid * fix: resolve PR #128 review comments and simplify help center settings - Fix search result links missing /hc prefix (P1 codex finding) - Fix CNAME target mismatch: UI now shows help-proxy.quackback.app matching the verification service - Fix categories.test.ts type errors by casting mock auth contexts - Fix $articleSlug.tsx route ID comparison type error - Remove help-center-seo settings page (SEO features enabled by default) - Simplify domain settings: remove redundant subdomain display, inline CNAME instructions, cleaner custom domain UX * fix: remove domain verification status from help center settings No proxy infrastructure exists yet, so pending/verified badges and CNAME instructions are premature. Keep custom domain as a simple input. * fix: cast test mocks in articles.test.ts and remove claude workflow - Fix articles.test.ts type errors (same as categories.test.ts fix) - Remove .github/workflows/claude.yml * fix: update MCP handler test counts for help center tools and resources
1 parent a035099 commit b73aeb5

115 files changed

Lines changed: 22510 additions & 1823 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/claude.yml

Lines changed: 0 additions & 49 deletions
This file was deleted.

apps/web/src/components/admin/analytics/analytics-page.tsx

Lines changed: 25 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -48,24 +48,31 @@ export function AnalyticsPage() {
4848
{/* Left sidebar */}
4949
<aside className="hidden lg:flex w-64 xl:w-72 shrink-0 flex-col border-r border-border/50 bg-card/30 overflow-hidden">
5050
<ScrollArea className="h-full">
51-
<div className="p-5 space-y-6">
52-
<div className="space-y-1">
53-
{navItems.map(({ key, label, icon: Icon }) => (
54-
<button
55-
key={key}
56-
type="button"
57-
onClick={() => setSection(key)}
58-
className={cn(
59-
'flex w-full items-center gap-2 px-2.5 py-1.5 rounded-md text-xs font-medium transition-colors',
60-
section === key
61-
? 'bg-muted text-foreground'
62-
: 'text-muted-foreground hover:text-foreground hover:bg-muted/50'
63-
)}
64-
>
65-
<Icon className={cn('h-3.5 w-3.5 shrink-0', section === key && 'text-primary')} />
66-
{label}
67-
</button>
68-
))}
51+
<div className="p-5 space-y-0">
52+
<div className="pb-4">
53+
<span className="inline-block py-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
54+
Sections
55+
</span>
56+
<div className="mt-2 space-y-1">
57+
{navItems.map(({ key, label, icon: Icon }) => (
58+
<button
59+
key={key}
60+
type="button"
61+
onClick={() => setSection(key)}
62+
className={cn(
63+
'flex w-full items-center gap-2 px-2.5 py-1.5 rounded-md text-xs font-medium transition-colors',
64+
section === key
65+
? 'bg-muted text-foreground'
66+
: 'text-muted-foreground hover:text-foreground hover:bg-muted/50'
67+
)}
68+
>
69+
<Icon
70+
className={cn('h-3.5 w-3.5 shrink-0', section === key && 'text-primary')}
71+
/>
72+
{label}
73+
</button>
74+
))}
75+
</div>
6976
</div>
7077
</div>
7178
</ScrollArea>
Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
import { useState, useEffect } from 'react'
2+
import {
3+
Dialog,
4+
DialogContent,
5+
DialogDescription,
6+
DialogFooter,
7+
DialogHeader,
8+
DialogTitle,
9+
} from '@/components/ui/dialog'
10+
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
11+
import { Button } from '@/components/ui/button'
12+
import { Input } from '@/components/ui/input'
13+
import { Label } from '@/components/ui/label'
14+
import { Switch } from '@/components/ui/switch'
15+
import { useCreateCategory, useUpdateCategory } from '@/lib/client/mutations/help-center'
16+
import type { HelpCenterCategoryId } from '@quackback/ids'
17+
18+
const CATEGORY_EMOJIS = [
19+
'📁',
20+
'📂',
21+
'📚',
22+
'📖',
23+
'📝',
24+
'📋',
25+
'📌',
26+
'📎',
27+
'💡',
28+
'⚡',
29+
'🔧',
30+
'🛠️',
31+
'⚙️',
32+
'🔑',
33+
'🔒',
34+
'🔓',
35+
'🚀',
36+
'🎯',
37+
'✅',
38+
'❓',
39+
'💬',
40+
'📣',
41+
'📢',
42+
'🔔',
43+
'💰',
44+
'💳',
45+
'🏷️',
46+
'📊',
47+
'📈',
48+
'🗂️',
49+
'🗃️',
50+
'📦',
51+
'🌐',
52+
'🔗',
53+
'🖥️',
54+
'📱',
55+
'🎨',
56+
'🧩',
57+
'🔍',
58+
'📡',
59+
'👤',
60+
'👥',
61+
'🏢',
62+
'🎓',
63+
'📅',
64+
'⏰',
65+
'🛡️',
66+
'🧪',
67+
] as const
68+
69+
const DEFAULT_EMOJI = '📁'
70+
71+
interface CategoryFormDialogProps {
72+
open: boolean
73+
onOpenChange: (open: boolean) => void
74+
initialValues?: {
75+
id: HelpCenterCategoryId
76+
name: string
77+
description: string | null
78+
icon: string | null
79+
isPublic: boolean
80+
}
81+
onCreated?: (categoryId: string) => void
82+
}
83+
84+
export function CategoryFormDialog({
85+
open,
86+
onOpenChange,
87+
initialValues,
88+
onCreated,
89+
}: CategoryFormDialogProps) {
90+
const isEdit = !!initialValues
91+
const createCategory = useCreateCategory()
92+
const updateCategory = useUpdateCategory()
93+
94+
const [icon, setIcon] = useState(DEFAULT_EMOJI)
95+
const [name, setName] = useState('')
96+
const [description, setDescription] = useState('')
97+
const [isPublic, setIsPublic] = useState(true)
98+
const [emojiOpen, setEmojiOpen] = useState(false)
99+
100+
useEffect(() => {
101+
if (open) {
102+
setIcon(initialValues?.icon || DEFAULT_EMOJI)
103+
setName(initialValues?.name || '')
104+
setDescription(initialValues?.description || '')
105+
setIsPublic(initialValues?.isPublic ?? true)
106+
}
107+
}, [open, initialValues])
108+
109+
const isPending = createCategory.isPending || updateCategory.isPending
110+
111+
const handleSubmit = async (e: React.FormEvent) => {
112+
e.preventDefault()
113+
const trimmedName = name.trim()
114+
const trimmedDesc = description.trim()
115+
if (!trimmedName) return
116+
117+
if (isEdit) {
118+
await updateCategory.mutateAsync({
119+
id: initialValues.id,
120+
name: trimmedName,
121+
description: trimmedDesc || null,
122+
icon,
123+
isPublic,
124+
})
125+
} else {
126+
const result = await createCategory.mutateAsync({
127+
name: trimmedName,
128+
description: trimmedDesc || undefined,
129+
icon,
130+
isPublic,
131+
})
132+
onCreated?.(result.id)
133+
}
134+
onOpenChange(false)
135+
}
136+
137+
return (
138+
<Dialog open={open} onOpenChange={onOpenChange}>
139+
<DialogContent className="sm:max-w-md">
140+
<DialogHeader>
141+
<DialogTitle>{isEdit ? 'Edit category' : 'New category'}</DialogTitle>
142+
<DialogDescription>
143+
{isEdit ? 'Update category details.' : 'Create a new help center category.'}
144+
</DialogDescription>
145+
</DialogHeader>
146+
147+
<form onSubmit={handleSubmit} className="space-y-4">
148+
<div className="space-y-2">
149+
<Label htmlFor="category-name">Name</Label>
150+
<div className="flex items-center gap-2">
151+
<Popover open={emojiOpen} onOpenChange={setEmojiOpen}>
152+
<PopoverTrigger asChild>
153+
<button
154+
type="button"
155+
className="h-9 w-9 rounded-md border border-border/50 flex items-center justify-center text-lg hover:bg-muted transition-colors shrink-0"
156+
>
157+
{icon}
158+
</button>
159+
</PopoverTrigger>
160+
<PopoverContent className="w-auto p-2" align="start">
161+
<div className="grid grid-cols-8 gap-1">
162+
{CATEGORY_EMOJIS.map((emoji) => (
163+
<button
164+
key={emoji}
165+
type="button"
166+
className="h-8 w-8 rounded-md hover:bg-muted flex items-center justify-center text-lg transition-colors"
167+
onClick={() => {
168+
setIcon(emoji)
169+
setEmojiOpen(false)
170+
}}
171+
>
172+
{emoji}
173+
</button>
174+
))}
175+
</div>
176+
</PopoverContent>
177+
</Popover>
178+
<Input
179+
id="category-name"
180+
value={name}
181+
onChange={(e) => setName(e.target.value)}
182+
placeholder="e.g., Getting Started"
183+
required
184+
className="flex-1"
185+
/>
186+
</div>
187+
</div>
188+
189+
<div className="space-y-2">
190+
<Label htmlFor="category-description">Description</Label>
191+
<Input
192+
id="category-description"
193+
value={description}
194+
onChange={(e) => setDescription(e.target.value)}
195+
placeholder="Optional short description"
196+
/>
197+
</div>
198+
199+
<div className="flex items-center justify-between">
200+
<div>
201+
<Label>Public</Label>
202+
<p className="text-xs text-muted-foreground">Visible on your public help center</p>
203+
</div>
204+
<Switch checked={isPublic} onCheckedChange={setIsPublic} />
205+
</div>
206+
207+
<DialogFooter>
208+
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
209+
Cancel
210+
</Button>
211+
<Button type="submit" disabled={isPending || !name.trim()}>
212+
{isPending ? (isEdit ? 'Saving...' : 'Creating...') : isEdit ? 'Save' : 'Create'}
213+
</Button>
214+
</DialogFooter>
215+
</form>
216+
</DialogContent>
217+
</Dialog>
218+
)
219+
}

apps/web/src/components/admin/help-center/help-center-article-modal.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
usePublishArticle,
1717
useUnpublishArticle,
1818
} from '@/lib/client/mutations/help-center'
19+
import { getInitialContentJson } from '@/components/admin/feedback/detail/post-utils'
1920
import { helpCenterQueries } from '@/lib/client/queries/help-center'
2021
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui/sheet'
2122
import { Button } from '@/components/ui/button'
@@ -66,7 +67,7 @@ function ArticleModalContent({ articleId, onClose }: ArticleModalContentProps) {
6667
if (article && !hasInitialized) {
6768
form.setValue('title', article.title)
6869
form.setValue('content', article.content)
69-
setContentJson(article.contentJson as JSONContent | null)
70+
setContentJson(getInitialContentJson(article))
7071
setCategoryId(article.categoryId)
7172
setIsPublished(!!article.publishedAt)
7273
setHasInitialized(true)

0 commit comments

Comments
 (0)