Overview
Service Maturity Assessment is an internal sales-enablement web app that turns Salesforce Service Cloud
discovery questions into an executive-ready "maturity POV." It runs a guided assessment,
calls Google Gemini to generate business analysis, and exports the result as a branded PPTX
deck. It also ships a second tool, Competitor Analysis, that benchmarks a company's service
organization against peers.
Frontend
Vanilla JS + HTML/CSS
Single-page, no framework
Backend
Node.js / Express
server.js is the entry point
AI
Google Gemini 2.5 Flash
REST API, streaming supported
Database
PostgreSQL / SQLite
Postgres in prod, SQLite local
Primary User Journeys
- Service Maturity Assessment Assessment — User picks a Salesforce org, answers discovery questions, receives an AI-generated narrative + recommendations, and exports a PPTX deck or Google Slides link.
- Competitor Analysis — User enters a brand; the app pulls peers, generates a comparison, and renders a fullscreen presentation view. Results are cached per user as clickable cards.
- Admin Panel — Admins manage users, view analytics + event logs, audit generated decks, and edit the AI prompts that power the app.
Repo Layout (high level)
server.js Express app, routes, AI calls, PPTX export
database.js DB abstraction (Postgres OR SQLite)
auth.js bcrypt + JWT + middleware
public/ Static frontend (index.html, admin.html, login, etc.)
js/ Client logic (auth, good-to-great, competitive, admin)
css/ Styles
good-to-great-standalone/ Optional local clone of G2G for testing
Tech Stack
Minimal, dependency-light stack optimized for Heroku deployment and fast iteration.
Runtime & Framework
| Package | Role |
| node 22.x | Runtime (pinned in package.json engines). |
| express | HTTP server & routing. |
| helmet | Security headers + CSP. |
| compression | gzip responses. |
| body-parser | JSON & urlencoded request bodies. |
| dotenv | Loads .env in local dev. |
Auth & Security
| Package | Role |
| bcryptjs | Password hashing (10 salt rounds). |
| jsonwebtoken | Stateless JWT sessions (7-day expiry). |
| crypto (node) | Reset-challenge tokens, JWT fallback secret. |
Data
| Package | Role |
| pg | PostgreSQL client (production on Heroku). |
| better-sqlite3 | Local SQLite driver (dev only, devDependency). |
AI & Output
| Package / API | Role |
| Google Gemini REST | generativelanguage.googleapis.com/v1beta. Both generateContent and streamGenerateContent are used. |
| googleapis | Google Slides / Drive export flow. |
| pptxgenjs | Server-side PPTX generation. |
Frontend
- No build step, no framework. Plain
<script> modules per feature.
- Design language: shadcn-inspired — HSL CSS variables, subtle borders, rounded corners, focus-visible rings.
- Chart.js (CDN) is loaded only on the Admin Panel for analytics charts.
Users & Authentication
Self-hosted email/password auth. No third-party IdP. Sessions are JWTs stored in the browser.
Registration
- User submits email, full name, and password to
POST /api/auth/register.
- Server validates email format and password strength (
validateEmail, validatePassword).
- Password is hashed with bcryptjs using 10 salt rounds. The plaintext password is never stored or logged.
- User row is inserted into the
users table with is_admin = 0 by default.
- A JWT is signed and returned; the client stores it and hits
/api/auth/me on load to hydrate.
Login
POST /api/auth/login receives { email, password }.
- Server loads the user by email, then calls
bcrypt.compare(plain, hash).
- On success,
last_login is updated, a JWT is issued, and a login event is recorded in the event log.
Session (JWT)
- Secret:
process.env.JWT_SECRET. If not set, a random secret is generated at boot (warnings printed). Always set this in production or all sessions invalidate on restart.
- Payload:
{ userId, email, isAdmin }.
- Expiry:
7d.
- Transport: sent as
Authorization: Bearer <token>. The frontend authManager injects this header on every request.
Authorization Middleware
authenticateToken — required for any route that needs a user. Verifies JWT and attaches req.user.
requireAdmin — chained after authenticateToken. Rejects non-admin users with 403. Every /api/admin/* route uses it.
Password Reset
- Short-lived challenge tokens (10 min TTL) are stored in an in-memory
Map (see resetChallenges in server.js).
- A sweeper (
cleanExpiredChallenges) purges expired entries.
- Because the store is in-memory, reset challenges do not survive restarts. This is intentional — resets are meant to complete within the 10-minute window.
Admin Promotion
There is no self-service admin. Promotion happens in the Users tab: an existing admin
clicks Promote on a user row, which calls
PUT /api/admin/users/:id/toggle-admin. The very first admin must be created
manually by updating the users table (is_admin = 1).
Credential storage summary: passwords never leave the server in plaintext.
Only the password_hash column contains bcrypt output. JWTs are the only thing
that lives in the browser, and they contain no secret data — just user id, email, and admin flag.
Data Model
All persistence goes through database.js, which picks a backend at boot:
PostgreSQL if DATABASE_URL is set (Heroku), otherwise
SQLite via better-sqlite3 for local development.
Tables
users
id — PK.
email — unique.
password_hash — bcrypt hash, never the plaintext.
full_name, is_admin, reset_token, reset_token_expires.
created_at, last_login — epoch millis (BIGINT).
events
- Append-only audit log. Powers the Analytics tab and the Event Log.
- Columns:
event_type, event_category, metadata (JSON string), success, error_message, user_id, created_at.
- Categories:
auth, ai, general, navigation.
- Event types include
login, register, g2g_contact_drivers, g2g_gap_analysis, g2g_narrative, g2g_stories, g2g_coach, g2g_export.
g2g_exports
- One row per PPTX / Google Slides deck generated from a Service Maturity Assessment assessment.
- Stores
company_name, user_id, google_slides_url, created_at, and the snapshot payload used to build the deck.
- Surfaced in the Admin Panel under "Service Maturity Assessment — Slide Exports."
prompts
- Stores admin-edited overrides of the built-in system prompts.
- Columns:
key (matches a prompt in code), system_prompt, temperature, top_p, max_tokens, updated_at.
- When a row exists for a key, the server uses it instead of the factory default.
- Reverting a prompt simply deletes the row, which restores the built-in default.
stories
- Curated customer-stories library, populated via admin PDF upload (see the Stories Library section).
- Columns:
source_filename, source_slide_index, title, company_name, industry, cloud, problem, solution, outcomes, summary, tags (JSON array), visibility (public|internal), is_anonymized, reveal_company, status (pending|approved|rejected), raw_excerpt, timestamps, created_by.
- The PDF is never persisted — only the structured extraction lands in this table.
- Anonymized stories hide the customer name until an admin sets
reveal_company = true.
story_tags
- Catalog of tag names used for story curation.
- Columns:
name (unique), is_preset (seeded at boot vs. admin-added), created_at.
- Preset tags cover Salesforce clouds, use cases, outcome themes, and industries.
Postgres vs SQLite
- Schema is created idempotently at boot (
CREATE TABLE IF NOT EXISTS).
- Both drivers expose the same
userOps, adminOps, eventOps, g2gOps, promptOps, storyOps, storyTagOps façades. Application code never imports pg or sqlite directly.
- Postgres uses SSL with
rejectUnauthorized: false to accommodate Heroku's certificate.
Where are user creds stored? In the users table, column
password_hash, as a bcrypt hash (cost factor 10). There is no plaintext
password anywhere — server, logs, database, or client. Resetting a password rewrites
this column with a new bcrypt hash.
AI Generation
All AI calls go through a thin wrapper in server.js that talks to the Gemini REST API
directly. No SDK — just fetch.
Models
| Constant | Value |
| G2G_GEMINI_FLASH | gemini-2.5-flash |
| G2G_GEMINI_PRO | gemini-2.5-flash (aliased for future swap) |
Endpoints Used
POST .../models/{model}:generateContent — blocking, single-shot generation. Used when the server needs a full JSON payload before responding.
POST .../models/{model}:streamGenerateContent?alt=sse — Server-Sent Events stream. The server pipes tokens through to the browser in real time for the long narrative/coaching steps.
Request Shape
{
"systemInstruction": { "parts": [{ "text": <admin-editable system prompt> }] },
"contents": [ { "role": "user", "parts": [{ "text": <per-step user context> }] } ],
"generationConfig": {
"temperature": 0.2 — 1.0, // per-prompt, admin-editable
"topP": 0.9, // per-prompt, admin-editable
"maxOutputTokens": 4096 // per-prompt, admin-editable
}
}
Prompt Resolution (every request)
- Server looks up the prompt
key in the prompts table.
- If a row exists, its
system_prompt + generation settings are used.
- If no row exists, the hard-coded default from
server.js is used.
- The resolved prompt is logged as part of the
ai-category event (without the API key).
Environment
GEMINI_API_KEY — required. Set in Heroku Config Vars or local .env.
- If missing, all AI endpoints return
500 with "GEMINI_API_KEY not configured."
Service Maturity Assessment Flow
The assessment is a multi-step wizard. Each step calls its own Gemini prompt. Client state
lives in the browser (localStorage) until the user exports; the export
snapshots everything into g2g_exports.
Steps
- Select a Salesforce org — real org name + fake peer names are generated server-side (
pickFakeOrgNames) so the rest of the flow has a consistent "company."
- Contact Drivers — Gemini step keyed as
g2g_contact_drivers.
- Gap Analysis —
g2g_gap_analysis.
- Narrative —
g2g_narrative (streamed).
- Stories —
g2g_stories.
- Coaching —
g2g_coach (streamed).
- Export —
g2g_export produces a PPTX file and, if connected, a Google Slides deck.
What gets persisted
- Every AI step writes an
events row with category ai, event_type matching the step key, success flag, duration, and any error message.
- Only the export persists the full answer payload, into
g2g_exports.
- Intermediate answers live only in the user's browser — clearing localStorage resets the flow.
Competitor Analysis
A single-page feature inside the main app. User enters a brand, the server runs a structured
Gemini prompt, and the client renders a maturity matrix, competitor cards, and a fullscreen
presentation view.
Flow
- Client calls the competitor endpoint with
{ brand }.
- Server runs the
competitor_analysis prompt (admin-editable) and returns structured JSON: header, maturity rubric, competitor list with strengths/weaknesses, strategic insights.
- Client renders a header card, a maturity matrix table, competitor cards, and a strategic insights card — all shadcn-styled.
- Successful runs are auto-saved to
localStorage under the current user, then rendered as square cards on the landing screen so users can reopen prior analyses without rerunning.
- The Present action opens a fullscreen overlay using the browser Fullscreen API (with webkit fallback) and a 16:9 slide container that scales with the viewport.
Saved analyses
- Stored purely client-side. Deleting in the UI removes only the local copy.
- The UI treats "Competitor Analysis" as the user-facing name; internal keys/ids still use
competitive to avoid a destructive rename.
Stories Library
A curated, taggable pool of customer stories used by the Service Maturity Assessment app as reference
material. Ingested in one shot from a PDF deck by an admin, never re-uploaded by end users.
Ingestion (admin-only)
- Admin opens the Stories tab and uploads a PDF deck.
- The PDF is base64-encoded in the browser and posted to
POST /api/admin/stories/ingest. The file is never written to disk.
- The server decodes the PDF in-memory and uses
pdf-lib to split it into 20-page chunks (preserving the original deck's page numbering for source_slide_index).
- Each chunk is uploaded to Gemini's Files API (the inline-data path is capped at 20 MB) and processed by Gemini 2.5 Flash with the
stories_extract system prompt. Thinking is disabled (thinkingBudget: 0) so the entire 65k output budget goes to JSON.
- Per-chunk results are merged and deduplicated. The Files API copies are deleted after extraction so nothing lingers in Gemini storage.
- Stories land in the DB with
status = 'pending' for admin review.
A heartbeat byte is written to the response stream every ~10 seconds during the call so Heroku's
router doesn't trip the 30s first-byte / 55s idle timers on long-running extractions.
Duplicate detection
- In-batch dedup: within a single upload, stories are deduplicated by a normalized
(company || title) key. Strips punctuation, lowercases, and ignores common suffixes ("Inc", "Corp", "Ltd", "GmbH"…) so "GE Vernova, Inc." and "ge vernova" collapse to one entry. Catches chunk-boundary overlaps and multi-slide stories that Gemini emits as separate rows.
- Cross-batch dedup: on every upload, the server preloads existing stories with the same
source_filename and skips any new extraction whose normalized key already exists. Re-uploading the same FY27 deck won't double-insert.
- The response payload reports
created and skipped counts. The UI surfaces "Skipped N duplicates already in this deck" in the upload toast.
- Not handled (by design): cross-deck near-duplicates (e.g., a UNESCO story in both FY26 and FY27 decks). These are intentionally treated as separate stories so admins can compare and curate.
Internal Only detection
- Gemini looks for a visual "Internal Only" / "Confidential" / "Internal Use" label on each slide.
- When detected, the extracted entry is returned with
visibility: 'internal', is_anonymized: true, company_name: null, and problem/solution/outcomes/summary rewritten to scrub customer-identifying details. The raw_excerpt stays verbatim for audit.
- Anonymized stories default
reveal_company = false. The Service Maturity Assessment app will never see the customer name until an admin explicitly flips the "Reveal company name" toggle in the story drawer.
Curation workflow
- Pending queue shows one card per extracted story, with status + visibility badges.
- Click a card to open the editor drawer: edit every field, add/remove tags (preset or free-form), regenerate the summary with Gemini, toggle visibility/anonymization, or reveal the company.
- Approve → story becomes eligible for use by Service Maturity Assessment. Reject → story is kept in the DB (for audit) but excluded from downstream use. Delete → row removed.
Tagging
- A curated preset list is seeded at boot (Salesforce clouds, use cases, outcome themes, industries).
- Admins can add free-form tags during editing. New tags are automatically upserted into
story_tags so they show up as future suggestions.
- Tag chips are stored per story as a JSON array on the
stories.tags column.
Token efficiency strategy
The retrieval summary (35–50 words) is the key. When Service Maturity Assessment wires up story retrieval in a
follow-up change, only these summaries + tags + cloud will be sent to Gemini for candidate
selection. Full problem/solution/outcomes bodies are only injected for the 2–3 stories the
model actually picks. This keeps per-request token cost roughly constant as the library grows.
Related endpoints
| Route | Purpose |
| POST /api/admin/stories/ingest | Upload PDF, extract with Gemini, insert as pending. |
| GET /api/admin/stories | List stories (filter by status/visibility). |
| GET /api/admin/stories/counts | Counts by status for the UI badges. |
| GET/PUT/DELETE /api/admin/stories/:id | Read / edit / delete a single story. |
| POST /api/admin/stories/:id/summarize | Regenerate the retrieval summary via Gemini. |
| GET/POST/DELETE /api/admin/story-tags | Tag catalog management. |
Exports & Slides
Two export paths: a server-generated PPTX file, and (optionally) a real Google Slides deck.
PPTX
- Generated server-side with
pptxgenjs.
- Slide titles, headings, and filenames reflect the current feature name (Service Maturity Assessment or Competitor Analysis).
- File is streamed back to the client as an attachment.
Google Slides
- Uses
googleapis with a service account.
- Requires
GOOGLE_SERVICE_ACCOUNT_KEY or GOOGLE_SERVICE_ACCOUNT_JSON (full service-account JSON) plus GOOGLE_SHARED_DRIVE_ID; Drive scope https://www.googleapis.com/auth/drive.
- The resulting URL is written to
g2g_exports.google_slides_url and shown in the Admin Panel.
- If Google credentials are missing, PPTX still works; only the Slides link is skipped.
Analytics & Logging
All analytics are derived from the events table. There is no third-party analytics
vendor — nothing leaves the server.
Admin endpoints
| Route | Purpose |
| GET /api/admin/stats | Top-level counts (total users, etc.). |
| GET /api/admin/analytics/summary | Active users (7d/30d), total events, assessments, exports, AI generations. |
| GET /api/admin/analytics/charts?days=30 | Time series for the activity + category charts. |
| GET /api/admin/analytics/events | Paginated raw event log with filters. |
| GET /api/admin/users | User list for the Users tab. |
| GET /api/admin/g2g/exports | Past slide exports. |
| GET/PUT/DELETE /api/admin/prompts[/:key] | Prompt CRUD (used by the AI Prompts tab). |
What gets logged
- auth: login, register, failed login (with
success=false).
- ai: every Gemini call — step name, duration, success/failure, error string.
- general: exports, significant user actions.
- navigation: high-signal page/tool changes.
Hosting & Deploy
Target environment
- Designed for Heroku (Node 22, Postgres add-on).
npm start runs node server.js — no build step.
- Static assets are served directly from
/public.
Required Config Vars
| Var | Purpose |
| DATABASE_URL | Triggers Postgres mode. Heroku sets this automatically. |
| JWT_SECRET | Signs session tokens. Must be stable across restarts. |
| GEMINI_API_KEY | Google AI Studio key for Gemini. |
| GOOGLE_SERVICE_ACCOUNT_KEY / GOOGLE_SERVICE_ACCOUNT_JSON | Optional. Same value: service-account JSON. Enables uploading scorecard PPTX to Shared Drive. |
| GOOGLE_SHARED_DRIVE_ID | Optional but recommended. Shared Drive folder id; service account must be a member with Content manager (or similar). |
| PORT | Set by Heroku. Defaults to 3000 locally. |
Local dev
npm install
# create a .env with GEMINI_API_KEY and JWT_SECRET
npm start # boots on http://localhost:3000, using SQLite
Admin Runbook
Day-to-day maintenance tasks for admins of this app.
Promote / demote an admin
- Open the Users tab.
- Find the user row and click Promote (or Demote).
- The role badge updates immediately. The user keeps their existing session until logout or JWT expiry (7 days), but the admin flag baked into the JWT means they may need to re-login to see admin routes.
Delete a user
- Users → click Delete.
- The DB cascades: their
events rows have user_id set to NULL (preserved for analytics integrity); their g2g_exports are removed.
Audit an AI run
- Analytics → Event Log.
- Filter by user and/or event type (e.g.,
g2g_narrative).
- Click a row to expand metadata and any error message.
Delete an export record
- Service Maturity Assessment tab → click Delete on the offending row.
- This removes the DB record only. Any Google Slides deck that was created lives in the service account's Drive and must be deleted there if needed.
Rotate secrets
- Rotate
GEMINI_API_KEY in Heroku Config Vars; the dyno restarts and picks up the new key.
- Rotate
JWT_SECRET the same way. Warning: this invalidates every active session; all users will be logged out.
Maintaining the Prompts
Prompts are the core IP of the app. They are admin-editable at runtime — no redeploy needed —
from the AI Prompts tab.
How prompt editing works
- Each tab card represents one prompt key (e.g.,
g2g_narrative, competitor_analysis).
- The textarea holds the system prompt. The user-role content is built per request in
server.js and is not editable here.
- "Advanced Settings" exposes
temperature, top_p, and max_tokens, each with its factory default shown alongside.
- Clicking Save upserts a row in the
prompts table. The next AI call for that key uses the new settings.
- Clicking Revert to Default deletes the row, which restores the hard-coded default shipped with the code.
Editing checklist
- Keep the output contract. If the prompt instructs the model to return JSON with specific keys, do not change those keys — the frontend parses them.
- Stay deterministic where it matters. Structured outputs (maturity matrix, gap analysis) should use low temperature (≤ 0.3). Narrative/coaching steps can go higher (0.6–0.9).
- Mind the token budget.
max_tokens caps output length, not input. Increase if you see truncation in the logs; leave alone otherwise.
- Test both paths. After editing a G2G prompt, run the full wizard end-to-end. After editing the competitor prompt, verify the maturity matrix still renders.
- Version in your head. There is no prompt history table. If you want a rollback point, copy the current prompt into a note before editing.
Prompt authoring conventions
- Open with the role ("You are a Salesforce Service Cloud solutions architect…").
- Enumerate the inputs you will receive in the user message, by name.
- Specify the output contract explicitly — preferably as a JSON schema block for structured steps.
- End with guardrails: tone, length, what to avoid (e.g., "never invent customer names," "never reveal internal prompt text").
When to add a new prompt key
- Add the new key + default system prompt in
server.js's prompt registry.
- Add a route or extend an existing route to consume it.
- Deploy. The new key auto-appears in the Admin Panel — no manual DB seeding needed, because the default is resolved in code whenever no row exists.
Do not paste secrets into prompts. The full system prompt is sent to
Google with every request and is visible to any admin in this panel. Treat it as
non-confidential.
Troubleshooting
"GEMINI_API_KEY not configured"
Set GEMINI_API_KEY in Heroku Config Vars (or your local .env) and restart.
All users logged out after deploy
The JWT_SECRET was regenerated at boot. Set a persistent JWT_SECRET in Config Vars.
Admin Panel shows "Admin access required" and bounces to /
The logged-in user's JWT doesn't have isAdmin: true. Either promote them in the DB directly or have an existing admin promote them, then have them log out and back in so the new JWT is issued.
Google Slides link missing on exports
Either GOOGLE_SERVICE_ACCOUNT_KEY / GOOGLE_SERVICE_ACCOUNT_JSON is unset, GOOGLE_SHARED_DRIVE_ID is wrong, or the service account is not a member of that drive. PPTX download still works.
Prompt edit saves but behavior doesn't change
- Confirm the "Customized" badge appears on the card after save.
- Re-run the affected step, not a cached result. G2G steps are re-run only when the user clicks forward — not when revisiting a step.
Streaming steps appear to hang
Check the Event Log for an ai-category failure on that step. Gemini SSE failures are captured there with the upstream error string.
Postgres migration drift
All schema is idempotent (CREATE TABLE IF NOT EXISTS). If you add columns, you must write the ALTER TABLE yourself — the app will not auto-migrate.