Content OS

Client Portal — App Flows

The approval gate in the middle of the admin pipeline. One screen, three tabs, and a deliberately small surface.

Code: client-app/ · Port: 3001 · Access: one client login per portal Back to apps index · Admin console


What it is

Four page files, five API routes — and every one has real logic. It is small on purpose, not unfinished. Its own README states the intent: no configs, creators, hooks, audience, drafts, or admin vocabulary.

The whole product is one screen: /dashboard, with three tabs and a date picker. / is a redirect to it.


Sign in

/login
      |
rate limit: 10 attempts per minute per IP     <- fail-open
      |
compare against CLIENT_EMAIL / CLIENT_PASSWORD    <- constant-time, env-provided
      |
upsert the customers row                      <- first login bootstraps it
      |
HS256 cookie: content_engine_client_token     <- 15-day expiry
      |
/dashboard

There is no user table and no self-registration. One env-provided credential serves the whole portal. The cookie name is deliberately different from the admin app's so the two can share a parent domain without clobbering each other. JWT_SECRET must match the admin app.

Three gates, in order

GateChecksWhere
MiddlewareCookie presence only — not the signatureclient-app/middleware.ts
requireSession()Real HS256 verify, algorithm pinned, exp mandatoryNode handlers
Tenant gateA content_tenants row exists for this customer/dashboard

Signature checking is kept out of middleware on purpose — the note in the code says edge/runtime discrepancies were redirect-looping valid sessions.

A valid login with no tenant assigned cannot reach the app: it bounces to /login?error=no_tenants"No active workspace found for this account." That is the real first-run state, and an admin clears it by assigning a tenant.


The core loop: review today's stories

/dashboard?date=YYYY-MM-DD
      |
content_news_items for that day, scored and sorted
      |
split into three queues by source group:  Blogs | News | Competitors
      |
ONE card at a time — not a list
      |
   [Y] Write a script          [N] Skip
      |                            |
reviewStatus: approved         reviewStatus: disapproved
      |                            + delete matching content_script_queue rows
upsert content_script_queue
      |
upsert content_generation_jobs  { status: queued, origin: "client" }
      |
      |  the admin worker picks it up — the portal never calls the admin API
      |
poll every 5s, up to 5 minutes
      |
"Writing the script..."  ->  "Script ready"

Keyboard-driven (Y / N), optimistic with rollback. A skip is reversible from a "Show what you already decided" section.

Failure is handled honestly: if the approval saved but the enqueue failed, the card says "Approved, but the script couldn't be started" and offers Retry.


Reviewing the script

Three duration cuts (30s / 60s / 120s, default 60s) and four tabs — Hook, Script, Caption, Keywords.

Redaction happens at the data layer, not the UI. toClientVersions() keeps only hook, script body, caption and CTA. On-screen text, b-roll, editing notes, framework and scores never reach the browser — the comment in the code is explicit that this is done at the data boundary so a future component change cannot leak them back in.

Actions: Approve (content_scripts.status = approved) or Send back (back to draft). Plus copy-to-clipboard per section and a WhatsApp share whose payload is trimmed to the four client-facing blocks.


Calendar

Month grid with dots on days that have posts, and a day panel showing status, time, caption, notes, keywords, platform, pillar, owner and channel badges.

Read-only. Nothing schedules or reschedules from here.


What a client can see, versus do

Can seeCan do
Today's blogs, news and competitor storiesApprove or skip a story
Article summaries and source linksUn-skip something they skipped
Generated scripts — 3 cuts, 4 sectionsTrigger generation (implicitly, by approving)
The posting calendar and day plansRetry a failed generation
Brand profile — company, categories, competitor count, audienceApprove a script, or send it back

Absent entirely: commenting, uploads, downloads, editing script text, scheduling, inviting users, notifications, and any settings screen.


How it connects to the admin app

Same MongoDB, same collections, direct access — no API hop. The portal never calls the admin app over HTTP; the two meet in the database.

Admin producesCollectionClient screen
Ingested, scored articlescontent_news_itemsDiscovery tab
Generated scriptscontent_scriptsScripts tab
Posting schedulecontent_calendar_entriesCalendar tab
Brand profilecontent_tenants and friendsCompany popover

And what the client hands back: reviewStatus on news items, rows in content_script_queue, jobs tagged origin: "client", and status on scripts.

Tenant scoping is consistent. tenantId is never taken from the request — it is resolved server-side from the session every time, along the chain JWT.sub → customers._id → content_tenants.customerId.


Where it stops

  • A rejection carries no reason. Skip and send-back are both silent — there is no comment or feedback field anywhere, so the admin side learns that something was rejected but never why. The single biggest gap.
  • One tenant only. getTenantIdForCustomer() takes the most recently updated tenant and there is no workspace switcher, so a customer with two tenants silently only ever sees one.
  • No blog review. The "Blogs" queue reviews blog-sourced news items, not the long-form drafts the admin app writes. There is no content_blog_* access at all.
  • Session expiry is untidy on two routes. The PATCH handlers do not wrap requireSession(), so an expired-but-present cookie returns a 500 rather than a clean 401.
  • No password reset, MFA, or revocation — consistent with the admin app.

The whole thing in nine steps

  1. Open the portal, land on /login.
  2. Sign in with the credentials the agency gave you.
  3. Discovery tab, today's date. Pick a pile — Blogs, News or Competitors.
  4. One story at a time: read it, then Write a script (Y) or Skip (N).
  5. Approving starts generation in the background — up to about five minutes.
  6. Switch to Scripts. Pick a duration, read Hook / Script / Caption / Keywords.
  7. Copy a section, or share the lot to WhatsApp.
  8. Approve the script, or send it back.
  9. Calendar shows when approved work is scheduled. Change the day with the date arrows; sign out from the menu.
Source: roadmap/apps/client-app.md