Purpose. The Marketing command center (
/portal/marketing) is the contractor's demand-generation and attribution hub. It ties the inbound side (who's on the website right now, tracked calls, visitor chat, channel attribution) to the outbound side (audiences, automations, email campaigns, nurture) and to the channels the org actually publishes on (their website, blog, social, ads, system emails, and an installable PWA). Much of the surface area it governs is prospect/public-facing β the tenant's live website, blog, chat widget and tracking beacon β while the command center itself is staff-only.
Status: π‘ Live core, several dormant/half-built channels. The command-center shell and its inbound loop are solidly live and used in production: call tracking, visitor tracking/presence (GoBuild Live), public chat with an AI concierge, attribution rollups, audiences, marketing workflows, and Resend-backed email/campaign sending all work end to end (app/routers/marketing_cc.py, live.py, chat_public.py, services/marketing_send.py). But the acquisition channels are uneven and this area holds the most half-built experiments in the product:
ads_build.py:4-6, templates/pmax_studio.html:265).social_publish.py:52-58, social_themes.py, social_ai.py:95-134)./portal/website is now a custom-pages + folder-sites + SEO manager (website.py:50).landing_portal.py:373-393, config.py:173-174).Audience. The command center and every /portal/marketing/*, /portal/website, /portal/blog, /portal/email* and /portal/campaigns route is internal staff (office + PM), gated by require_portal_role(UserRole.pm, UserRole.office) and the marketing entitlement (marketing.py:29-32, marketing_cc.py:43-46, entitlements.py:25). Several website/blog write actions are further owner-only (website.py:128,181,β¦). The public surfaces β the tenant's website/blog/folder-sites, the {{gb:...}} lead form, the chat widget, the tracking beacon, and the GoBuild-Live PWA endpoints β are prospect/client-facing and unauthenticated (org resolved from the request Host header).
The command center is a single full-screen Alpine shell, templates/marketing_command_center.html, served by marketing.dashboard at GET /portal/marketing (marketing.py:53-66); ?classic=1 falls back to the legacy tabbed dashboard (marketing/dashboard.html, seeded once by marketing_seed.ensure_examples). Panes fetch JSON on demand from the sibling router app/routers/marketing_cc.py (same /portal/marketing prefix).
Native panes in the rail (marketing_command_center.html:180-210): Dashboard (overview KPIs), Calls, Attribution, Tracking numbers, Audiences, Nurture, Automations, Campaigns, Ads. The rail then links out (β) to the standalone channel tools: Social (/portal/marketing/social), Website (/portal/website), Blog (/portal/blog), System emails (/portal/email). GoBuild Live and the PWA are not rail items β they are public-site features surfaced elsewhere (Live has its own /live staff PWA).
Theme is per-user (nav_prefs.cc_theme). The AI provider under the "β¨" actions is DeepSeek primary with a Google Gemini fallback for the shared chat() entry point (integrations/ai/deepseek.py:28-35); the Live concierge, social captions and ad creative call Gemini directly.
Reference. GET /portal/marketing/dashboard builds headline KPIs + a visitβleadβcallβestimatingβwon funnel + a daily visits/leads trend + channel mix over a 7/30/90/all window (marketing_cc.py:411-510). GET /portal/marketing/overview is a funnel-by-first-touch-channel rollup (marketing_cc.py:369-408). Calls is a recorded-call inbox with AI summary/sentiment/transcript and a pre-call context timeline (chat/SMS/email/page-views the caller did before dialing), from GET /portal/marketing/calls* (marketing_cc.py:77-213); recordings are proxied through Twilio Basic-auth (:126-141). Tracking numbers provisions/edits/releases per-channel Twilio numbers via call_tracking.provision_tracking_number β this spends money (marketing_cc.py:258-364, note :303).
How-to. Office lands on Dashboard; Calls is worked like an inbox (filter by outcome/channel, play recording, read context); Tracking numbers is buy-a-number β set forward-to β attach a channel.
Audience: staff. Status: π’ Live. Gotcha: the dashboard's spend/roas tiles are hardcoded None β they "light up when ad accounts have Basic access," which the ads integration never reaches (marketing_cc.py:462). Call tracking is dormant until Twilio is configured.
Reference.
Person spine, with a live count/sample preview and an AI "describe an audience" drafter (marketing_cc.py:515-600; standalone builder at marketing.py:96-266). seg_svc.FIELDS/OPS_BY_TYPE define the criteria vocabulary.workflows.run_due advances due enrollments each tick (services/workflows.py:236-308), called from scheduler.run_lead_followups (scheduler.py:312). Nodes: email/sms/delay/task/tag/field, plus branches. Triggers: manual / lead_created / status_change / segment (marketing_cc.py:713-814, marketing.py:271-465).marketing_send.send_marketing_email β the same tracked, suppression-checked, unsubscribe-stamped path the classic sender uses (marketing_cc.py:988-1024).marketing.py:439-446) or launch the campaign.Reference. app/routers/website.py (prefix /portal, owner-gated writes). Important: the visual section-based builder has been removed β the in-code comment is explicit: "now that the templated builder is gone, we create a minimal, unpublished substrate on demand" (website.py:50). /portal/website now renders website_dashboard.html, a custom-pages + folder-sites manager with an SEO Studio, not a section editor. The LandingPage row is reduced to a hosting substrate (slug / subdomain / custom_domain / shell); its JSON content engine in services/landing.py is retained but no longer driven by any builder UI.
How-to. Portal β Website β new page/folder β edit HTML/CSS in the in-hub code editor β publish; optionally file pages into a folder and publish that folder as a standalone branded site with its own subdomain/custom domain. Custom-domain attach is real (set β DNS/__up verify β Caddy on-demand TLS via /internal/tls-allow, website.py:480-522, landing_portal.py:237-255).
Audience: staff/owner (builder); public for the served sites. Status: π‘ β custom-page/folder hosting is live; the "builder" name is a stale label for a capability that no longer exists.
Dead/stale: _mirror_draft() is defined but never called (website.py:64-70); the module docstring still describes the removed "Wizard β editor (sections, gallery)" flow (website.py:1-6). LandingPage.content section-editing is orphaned.
Reference. File-backed, operator-authored HTML pages attached to an org's site β the escape hatch (the gobuild-page skill) when themed sections don't fit. Disk layout custom_pages/{org_id}/{slug}/index.html (+ optional style.css, .versions/); the matching CustomPage DB row carries the publish flag + nav/SEO metadata (services/custom_pages.py:1-19). The repo currently holds 65 org directories under custom_pages/. Trust: rendered with |safe, NOT nh3-sanitized β owner-only lane (custom_pages.py:17-19).
How-to. Served through the org's substrate LandingPage: _render_custom (landing_portal.py:335-414) wraps a fragment page in the themed site shell as a single custom section, or serves a full <!doctype> mock verbatim (shell skipped, so signed-off designs stay pixel-identical). {{gb:...}} shortcodes expand server-side into wired partials β lead_form, client_login, gallery, booking, cta, reviews, services, chat etc. (services/shortcodes.py:129-141). The {{gb:lead_form}} posts to /p/{slug}/contact, which creates a real hub Lead (source "Website"), stitches attribution, and fires the lead_created workflow (landing_portal.py:671-714).
Audience: owner (authoring); public (served). Status: π’ Live.
Dormant: the per-page assets/ dir is "reserved for later phases," not wired (custom_pages.py:11); _sections/ block scaffolding (read_section/scaffold_section, custom_pages.py:486-535) existed for the removed builder and appears to have no live caller now.
Reference. app/routers/blog_admin.py (prefix /portal/blog, pm/office + marketing). Blogs are anchored on folder-sites (CustomPageFolder), never the old site builder. Full CRUD: new/save/publish-toggle/feature/delete (blog_admin.py:158-335). AI drafting is a real LLM call β blog_ai.generate() calls DeepSeek in JSON mode, anonymizes referenced jobs (city+status only, never client name/address), and always produces a draft (blog_ai.py:39-168; never auto-publishes). A trend-aware idea planner (content_planner) and a scheduled auto-drafter (blog_ai.run_due, drafts only) also exist.
How-to. Portal β Blog β pick a folder-site β enable β Generate (AI draft) or New β edit β Publish. Served publicly on the folder-site host: index /p/{slug}/blog, post /p/{slug}/blog/{post_slug} (published-only unless ?preview), RSS /blog/feed.xml, with BlogPosting JSON-LD, requiring folder.blog_on (landing_portal.py:485-521, services/blog.py).
Audience: staff (authoring); public (served). Status: π’ Live.
Reference. app/routers/ads.py (owner-gated OAuth connect) + app/routers/ads_build.py (pm/office PMax draft studio). Surfaced in the CC via GET /portal/marketing/ad-accounts (marketing_cc.py:1029-1057). Google Ads is the only platform with real API code, and only the account-linking half: real token exchange / refresh / listAccessibleCustomers (integrations/ads/google_ads.py:58-110). Creative is real Gemini β text assets via gemini.chat and images via gemini-2.5-flash-image (services/ad_creative.py:67-105).
How-to. Owner connects a Google Ads account via OAuth (/portal/integrations/google-ads/connect, gated by oauth_ready + a Fernet ad_token_encryption_key, ads.py:33-94) β staff create a PMax draft β open the full-screen PMax Studio (ads_build.py:93-104) β generate an AI asset-group/images β pick a landing page β save.
Audience: staff/owner. Status: βͺ Draft + creative only β no publish.
Dormant/dead-ends (this is the most half-built area):
listAccessibleCustomers; a repo-wide search for mutate/campaignService finds nothing. The Launch button is hardcoded :disabled="true" (templates/pmax_studio.html:265-266); docstring confirms mutations are an unbuilt "Phase 4" (ads_build.py:4-6).config.py:286-292, ads.py:6).AdCampaign.external_id/external_status/last_error are dead placeholder fields (serialized but never written, ads_build.py:48-49); link_mode="managed" is never created; google_ads_enabled informs the UI but doesn't gate the connect flow (marketing_cc.py:1050).services/attribution.py) is the live half: it captures gclid/msclkid/utm on the public site and stitches sessions to leads to feed the funnel-by-channel analytics.Reference. app/routers/social.py (prefix /portal/marketing/social, pm/office), + a Meta OAuth router and a signature-gated public media router (social.py:590-714). Facebook + Instagram only (one Meta Graph client) β no LinkedIn/X/Google-Business anywhere (platform whitelist hard-limited, social.py:254). OAuth is genuine per-instance (CSRF state β long-lived Page token, encrypted at rest, social.py:615-687). Publishing is real code β social_publish.publish_due (scheduler-driven, scheduler.py:224) actually POSTs photos/containers to the Graph API (integrations/social/meta.py:97-115). AI captions/headlines are real Gemini (services/social_ai.py:107-157); frame compositing (Pillow) and Templated.io designer renders are real.
How-to. /portal/marketing/social β AI Studio compose (/generate renders Templated designs + Gemini caption) β save drafts β 7-day calendar β drag-schedule / autofill / optional approval β background loop publishes due posts to FB/IG.
Audience: staff. Status: βͺ Real but ships dormant β publish_post short-circuits to failed / "No connected account" unless meta_enabled (default False) and an account is connected (social_publish.py:52-58, config.py:340-343); AI Studio hidden until a Templated key is set. Compose/frame/schedule work with no Meta app.
Dead code: social_themes.py (a full Canva-style Pillow theme engine) has zero callers; social_ai.generate_post_image()/generate_background() (the Gemini-image path) are orphaned, superseded by Templated (social_ai.py:95-134). frame_preset_id is force-set to None on save despite a preset CRUD API existing (social.py:271). Ad scopes are intentionally deferred "until the ads manager is built" (meta.py:16-22).
Three distinct surfaces, all pm/office-gated, all sending through Resend (integrations/email/resend_client.py:63-69 β the only ESP; no SendGrid/SES/SMTP):
/portal/email (email_dashboard.py): the catalog of every transactional/automated email GoBuild sends on the org's behalf (~25 types across 4 categories, services/email_registry.py:31-274), each with an on/off toggle + a copy editor overridable per org (EmailSetting / EmailTemplate), plus a live send log (SentEmail)./portal/campaigns (email_studio.py:57-383, despite the filename): marketing blasts to segments/lists via marketing_send.send_marketing_email, with sent/opened/clicked rollups and AI drafting./portal/email-templates (email_studio.py:386-611): the branded, editable transactional template library (kickoff/proposal/invoice/appointment/review). Editors are raw-HTML textareas with merge-field chips + a live branded iframe preview (not WYSIWYG). AI generate/personalize/rewrite are real DeepSeek calls that degrade gracefully.The three overlap by slug: store:"template" registry entries resolve their copy from the same EmailTemplate catalog the Studio edits, so a Studio edit propagates into the transactional send (outbound_email.resolve_content β email_studio.render_for).
How-to. Studio β build/AI-personalize the branded template library; System Emails β toggle which automated emails fire + tweak copy; Campaigns β compose/AI-draft β pick a segment/list β send. Every surface previews through /portal/emails/preview and can "send a test to me."
Compliance/tracking (real): per-org creds are resolved via credentials.email_for (shared GoLive key dressed as the org, or the org's BYO Resend key) β no global fallback; send is refused without resolvable creds. Suppression (CAN-SPAM/CASL), signed one-click unsubscribe (/u/{token}), and open (pixel /t/o/β¦gif) + click (/t/c/β¦) tracking are all wired in marketing_send.py + tracking.py.
Audience: staff (office/PM). Status: π’ Live (config-gated on resend_api_key).
Dormant/dead-ends: inbound email is inert by default β resend_inbound_secret/marketing_reply_to default empty, so marketing replies bounce to updates@gobuild.ca and "stop-on-reply" won't fire until MX/inbound routing is configured (config.py:125-129, marketing_send.py:104-105). The critical registry flag is dead (read but never set True), so nothing prevents toggling off password_reset/team_invite (email_dashboard.py:76-77). Legacy DripSend is dual-read for back-compat but not written by current paths. Stale "Keystone" branding remains in several docstrings.
Reference. GET /portal/nurture (nurture.py:45-72) + the CC's Nurture pane (marketing_cc.py:605-683) manage two lead follow-up mechanisms: cadences (LeadCadence β templated task sequences that materialize into LeadTasks) and drip sequences (DripSequence β timed email sends with an opens/clicks engagement rollup). Create/toggle/archive both; defaults seeded on first load (followup_svc.ensure_defaults).
How-to. Build a cadence/drip here, then apply it on a lead from the Sales CC (/cadence, /drip).
Audience: staff. Status: π‘ β cadence tasks are live, but drip email sending is dormant: the scheduler still calls lead_followup.send_due_drips but it is annotated "(dormant)" in-code, superseded by the Workflows engine (scheduler.py:313). Drips can be authored and enrolled, but the modern automation path is Workflows/Automations, not DripSequence.
Reference. A staff-only installable PWA showing who is on the contractor's website right now β page, geo, dwell β with push alerts and owner-initiated chat backed by an AI concierge. Routers: live.py (/live, staff), track.py (/api/track, public beacon collector), chat_public.py (/api/chat/*, public widget), plus email-tracking tracking.py. Everything here is real and wired end to end β the Live agent found no stubs/TODOs across the 11 files.
app/static/site/track.js POSTs UTM/gclid/referrer/title to /api/track via sendBeacon, then a 15s heartbeat to /api/track/ping (track.js:29-70). It is actually embedded on themed sites (landing_public.html:161,166), blog/folder pages (blog.py:240-242) and imported mocks (landing_portal.py:398). The collector mints a first-party gb_vid cookie, records VisitorSession+Touchpoint, and fires GeoIP + alerts (track.py:43-169).GET /live/visitors selects VisitorSession rows with last_seen_at >= now-70s (LIVE_WINDOW_SEC), folds multiple sessions of one person via union-find, and keeps mid-chat visitors on the board (live.py:224-271)./api/chat/poll every 4s; /api/chat/send threads via live_chat.visitor_reply, storing messages as Communication rows on a Lead so they appear in the normal hub Messages inbox (chat_public.py:39-241, live_chat.py:47-90). Read receipts are real.google_search grounding β replies, proactive openers, and JSON-mode lead extraction, sandboxed to a static KB with no CRM data; posts as "GoBuild Assistant," greets once per visitor, won't talk over a human, and on a lead-ready fires the Book-a-demo workflow + emails sales (live_ai.py:100-411).PushSubscriptions β returning-lead / chat-message / lead-captured / new-visitor, with cooldowns and an in-app alert log (live_alerts.py:44-118)./api/track*, /api/chat/*, /live/manifest.webmanifest, /live/sw.js. Staff-gated (owner/pm/office): the /live dashboard and all its data endpoints (live.py:30,41-48).config.py), the concierge needs GEMINI_API_KEY, and marketing-site tracking needs gobuild_marketing_org_id/host config. GeoIP uses ipinfo.io (or keyless ip-api.com fallback) and can be disabled.Reference. Each client marketing site can be installed as a PWA with an auto-branded icon, resolved by Host header. Routes in app/routers/landing_portal.py: GET /manifest.webmanifest (:281-293), GET /sw.js (:296-301), GET /appicon-{size}.png (:304-330). The manifest is generated in Python from org.settings["pwa"] (name/theme/monogram, written at site import, admin_api.py:601-606); the icon is a Pillow-drawn square (brand color + centered monogram). Web push endpoints live in app/routers/push.py (/portal/push/vapid-public-key, /portal/push/subscribe) backed by a real pywebpush client (integrations/push/webpush_client.py).
How-to. With the PWA plugin on, an imported full-page mock site injects the manifest link + SW registration + a floating "π² Install app" button (captured from beforeinstallprompt, landing_portal.py:386-393). The install link is distributed via an admin QR/share card on the prospect page (showcase/templates/admin_prospect.html:92-99, QR from /admin/qr) and the on-site button.
Audience. Public prospect-site visitors (installability/Install button); web-push subscribe is gated to logged-in portal users only (push.py:38); push send reaches subscribed staff/clients β never anonymous marketing-site visitors.
Status: π‘ Real but narrowly scoped, and push is dormant.
Dormant/dead-ends:
<!doctype> branch, landing_portal.py:373-393); normal _render_core builder pages never reference the manifest, so /manifest.webmanifest etc. are unreachable for them even with pwa.on._SITE_SW caches just the home page and has no push/notificationclick handler (landing_portal.py:262-271), so web push cannot reach public client sites (the push-capable SWs are the separate staff/portal/live/field workers).is_configured() (config.py:173-174). There is no unsubscribe route despite the push.py docstring claiming one, and push.py serves no SW.admin_prospect.html:92-98).| Area | Flag | Evidence |
|---|---|---|
| Ads | βͺ No publish to any platform; Launch hard-disabled | pmax_studio.html:265, ads_build.py:4-6; no mutate code anywhere |
| Ads | βͺ Bing = config-only scaffold; AdCampaign.external_* dead fields |
config.py:286-292, ads.py:6, ads_build.py:48-49 |
| Ads | βͺ Dashboard spend/roas hardcoded None |
marketing_cc.py:462 |
| Social | βͺ FB/IG only; publishing dormant until Meta app + connect | social_publish.py:52-58, config.py:340-343 |
| Social | β Dead code: social_themes.py; Gemini image fns in social_ai.py |
no callers; social_ai.py:95-134 |
| Social | β οΈ frame_preset_id force-nulled despite preset CRUD |
social.py:271 |
| Website | π‘ Visual section builder removed; _mirror_draft dead |
website.py:50,64-70, docstring :1-6 |
| Website/SEO | β οΈ SEO Studio over-reports sitemap/robots/schema for folder-sites (substrate is unpublished) | seo_analysis.py:170-173 vs website.py:56-60, landing_portal.py:559-569 |
| Nurture | π‘ DripSequence email sending dormant (superseded by Workflows) |
scheduler.py:313 "(dormant)" |
| β οΈ Inbound/reply-capture inert by default; "stop-on-reply" won't fire | config.py:125-129, marketing_send.py:104-105 |
|
β οΈ critical flag dead β no email is protected from being toggled off |
email_dashboard.py:76-77 (never set) |
|
| PWA | π‘ Only imported full-page mocks are installable, not themed builder sites | landing_portal.py:373-393 |
| PWA | βͺ Web push dormant (no VAPID default); no unsubscribe route; marketing-site SW is offline-only | config.py:173-174, push.py:1-26, landing_portal.py:262-271 |
| All AI/integration paths | β οΈ Best-effort β tracking, alerts, sync, AI, e-sign swallow exceptions and never block the primary action, so failures are silent | e.g. track.py:118-119, outbound_email.py:141-150 |
Provider summary (what's wired to real services vs config-gated): Email β Resend (live, gated on key). SMS/voice/call-tracking β Twilio (gated). Ads β Google Ads OAuth+read only (no write); Bing absent. Social β Meta Graph (FB/IG) publish, dormant until configured. Chat/blog/ad/social AI β Gemini; segment/campaign/email AI β DeepSeek (Gemini fallback). Push β pywebpush/VAPID (dormant). Contacts/chat backbone β Chatwoot. Marketing-site demo form (marketing_forms.py) β email sales@gobuild.ca + Mautic (GoBuild's own funnel, not a tenant lead).