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
| Gate | Checks | Where |
|---|---|---|
| Middleware | Cookie presence only — not the signature | client-app/middleware.ts |
requireSession() | Real HS256 verify, algorithm pinned, exp mandatory | Node handlers |
| Tenant gate | A 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 see | Can do |
|---|---|
| Today's blogs, news and competitor stories | Approve or skip a story |
| Article summaries and source links | Un-skip something they skipped |
| Generated scripts — 3 cuts, 4 sections | Trigger generation (implicitly, by approving) |
| The posting calendar and day plans | Retry a failed generation |
| Brand profile — company, categories, competitor count, audience | Approve 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 produces | Collection | Client screen |
|---|---|---|
| Ingested, scored articles | content_news_items | Discovery tab |
| Generated scripts | content_scripts | Scripts tab |
| Posting schedule | content_calendar_entries | Calendar tab |
| Brand profile | content_tenants and friends | Company 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
- Open the portal, land on
/login. - Sign in with the credentials the agency gave you.
- Discovery tab, today's date. Pick a pile — Blogs, News or Competitors.
- One story at a time: read it, then Write a script (
Y) or Skip (N). - Approving starts generation in the background — up to about five minutes.
- Switch to Scripts. Pick a duration, read Hook / Script / Caption / Keywords.
- Copy a section, or share the lot to WhatsApp.
- Approve the script, or send it back.
- Calendar shows when approved work is scheduled. Change the day with the date arrows; sign out from the menu.