Add demo cases (HarvestLink v1/v2), subscription technical contract, portal + ops-portal HTML

- demo-case-harvestlink.md + v2: fictional staged proposals for product-marketing video (no PII)
- CONTRACT.md: subscription model / contact form / receipt fixes technical contract (read-only analysis 2026-08-19)
- portal/index.html + ops-portal/: customer portal + ops portal single-file builds
- .gitignore: demo-creds*.txt excluded
This commit is contained in:
root
2026-08-26 02:26:50 -04:00
parent 3c07727f5c
commit 6ac8a185ef
7 changed files with 2725 additions and 1 deletions
+2
View File
@@ -42,3 +42,5 @@ credentials.json
# Project-specific
*.db-journal
*.db-wal
# Demo credentials (never commit)
demo-creds*.txt
+537
View File
@@ -0,0 +1,537 @@
# VerdictTank — Subscription Model, Contact Form, Receipt Fixes: Technical Contract
Status: DRAFT for review. Read-only analysis of the live codebase on Core (152.53.192.33) as of
2026-08-19. No code was modified, no service restarted, no schema migration applied.
Surfaces inspected:
- Backend API: `/opt/verdicttank/api.py` (989 lines, FastAPI, systemd `verdicttank-api.service`, port 8201 behind Caddy)
- Database: `/opt/verdicttank/users.db` (SQLite)
- Ops admin portal: `/var/www/verdicttank-ops/index.html` (1060 lines, static SPA, served at ops.verdicttank.com, calls `/api/verdicttank/admin/*`)
- Marketing site: `/var/www/verdicttank/index.html` (1602 lines, served at verdicttank.com)
- Client portal source: `/root/projects/verdicttank-mockups/portal-index.html` (1068 lines) — source of the LIVE file on app3 at `/home/myverdicttank/htdocs/my.verdicttank.com/index.html`, served at my.verdicttank.com
Canonical tier quotas used throughout this contract (authoritative, not invented):
| Tier | Included / term | Term | Overage price/each |
|---|---|---|---|
| Free | 1 | lifetime | none (hard stop) |
| One-Shot | 1 | one-time | none (hard stop) |
| Pro | 8 | monthly | $18 |
| Enterprise | 50 | monthly | $16 |
| White-Label | 100 | monthly | $28 |
---
## 1. CURRENT API INVENTORY
All routes are under `PREFIX = "/api/verdicttank"` unless noted. FastAPI app `verdicttank-api`,
`version="0.2.0"`, CORS allows `https://verdicttank.com`, `https://proposals.iamgmb.com`,
`https://core.itpropartner.com`, `https://my.verdicttank.com`; methods GET/POST/OPTIONS only.
### Public / health
- `GET /api/verdicttank/health``{status:"ok", service:"verdicttank-api", version:"0.2.0"}`
### Customer auth (embedded JWT, no social, no Stack Auth)
- `POST /api/verdicttank/auth/register` — body `{email, password, name?}``{token, user:{id,email,name}}`. 400 invalid email, 400 password<8 chars, 409 duplicate email.
- `POST /api/verdicttank/auth/login` — body `{email, password}``{token, user:{id,email,name}}`. 401 bad creds, 429 after 5 failures/15min (in-memory, resets on restart).
- `GET /api/verdicttank/auth/me` — Bearer → `{user:{id,email,name,created_at}}`. 401 if none.
- `POST /api/verdicttank/auth/forgot-password` — body `{email}``{message}` (always 200, no account enumeration). Sends reset email via `smtplib` if account exists.
- `POST /api/verdicttank/auth/reset-password` — body `{token, new_password}``{message}`. 400 invalid/expired token, 400 password<8.
- `POST /api/verdicttank/auth/change-password` — Bearer, body `{current_password, new_password}``{message}`. 401 not authed, 400 wrong current password / short new password.
### Submission / results
- `POST /api/verdicttank/submit` — multipart form: `name`(str,""), `email`(str,""), `proposal_name`(required), `proposal_text`(str,""), `proposal_file`(UploadFile, optional), `tier`(str,"free"), optional Bearer. Invalid tier silently coerces to `"free"`**no rejection, no quota check today**. 422 if name/email missing or neither text nor file. Response (`SubmissionResponse`): `{review_id, status:"queued", message, submitted_at}`.
- `GET /api/verdicttank/status/{review_id}``{review_id, status, verdict, proposal_strength, investor_readiness, composite, progress, report_url, submitted_at, completed_at}`. 404 unknown id.
- `GET /api/verdicttank/results/{review_id}` → full structured result: `review_id, status, proposal_name, name, tier, tier_label, submitted_at, completed_at, verdict, verdict_rationale, executive_summary, proposal_strength, investor_readiness, composite, divergence, dimensions[], fatal_flaws[], action_plan[], kill_criteria[], consensus[], report_url, error`.
- `GET /api/verdicttank/reviews` — Bearer → `{reviews:[{review_id, proposal_name, tier, tier_label, status, verdict, proposal_strength, investor_readiness, composite, submitted_at, completed_at}]}` (only rows where `user_id` matches). 401 if no Bearer.
- `GET /api/verdicttank/report/{review_id}` → PDF file or 404 until `status=="complete"`.
- `GET /api/receipt/{review_id}` (note: **no `/verdicttank` prefix**, pre-existing quirk) → receipt PDF, no auth required, 404 if not generated.
### Staff/admin (separate auth track, see section 3)
- `POST /api/verdicttank/admin/login` — body `{email, password}``{token, user:{email,name}}`. Delegates to auth2/Hexclave password sign-in, then a staff-team or owner-email check; 401 bad creds, 403 not staff.
- `GET /api/verdicttank/admin/submissions?status=&q=&limit=&offset=` — Bearer staff → `{submissions:[...], total}` (all users, filterable, `limit` clamped 1-500).
- `GET /api/verdicttank/admin/submissions/{review_id}` — Bearer staff → full detail incl. `user_id`, `panel_audit`, `receipt_url`.
- `GET /api/verdicttank/admin/stats` — Bearer staff → `{total, completed, failed, queued, processing, by_status:{}, by_tier:{}}`.
### Misc
- `POST /api/verdicttank/assist` — body `{message, section_id, section_title, current_content, full_draft}``{reply}`. Calls internal LLM (`admin-ai.itpropartner.com`, DeepSeek Flash) for proposal-builder brainstorming. No auth. Unrelated to subscription work but shares the router.
**No Stripe or any payment-provider code exists anywhere in api.py.** `tier` on `/submit` is a
free-text form field with zero enforcement — a client can submit `enterprise` with no account and
no charge. There is no per-user submission counter, no quota table, no renewal/expiration logic.
---
## 2. CURRENT DATA MODEL
`/opt/verdicttank/users.db`, SQLite, confirmed via `sqlite3 ... ".schema"`:
```sql
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
name TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL
);
CREATE TABLE sqlite_sequence(name, seq); -- auto-managed by SQLite for AUTOINCREMENT
```
That is the **entire** schema. No `tier`, `plan_status`, `term`, `quota`, `renewal`, `expiration`,
`cancelled_at`, `stripe_customer_id`, or any subscription-related column exists on `users`, and
there is no second table for subscriptions, invites, quota grants, or notifications.
Submissions themselves are **not** in SQLite — each submission is a flat JSON file at
`/opt/verdicttank/data/{review_id}.json` (fields: review_id, name, email, user_id, proposal_name,
proposal_text, proposal_file, tier, status, submitted_at, completed_at, verdict, report_path,
receipt_path, plus fields the worker fills in later: proposal_strength, investor_readiness,
composite, divergence, dimensions, fatal_flaws, action_plan, kill_criteria, consensus,
panel_audit, executive_summary, verdict_rationale, error, progress). There is no SQL table
indexing submissions by user_id/tier/date — the API scans `DATA_DIR.glob("*.json")` for
`/reviews`, `/admin/submissions`, and `/admin/stats`. Any subscription/quota logic that needs to
count "submissions this term" must either scan this JSON directory (works today, gets slower as
volume grows) or maintain a running counter in SQLite (recommended — see Section 6).
---
## 3. AUTH MODEL (as actually implemented — confirmed, not assumed)
There is **no Stack Auth / Hexclave auth2 for customers**. Two entirely separate, non-interoperable
auth tracks exist in the same file:
### 3a. Customer auth — self-contained embedded JWT
- Identity source: local SQLite `users` table only.
- Password hashing: stdlib `hashlib.pbkdf2_hmac("sha256", ..., 200_000 iterations)`, stored as
`pbkdf2_sha256$<iters>$<salt_hex>$<hash_hex>`. bcrypt is NOT installed/used.
- Token: PyJWT (`import jwt as pyjwt`), algorithm HS256, 30-day TTL (`TOKEN_TTL_SECONDS = 60*60*24*30`).
Payload: `{sub: str(user_id), email, exp}`.
- Transport: `Authorization: Bearer <token>` header only. No cookies, no session store.
- Secret: `_load_jwt_secret()` reads env `VERDICTTANK_JWT_SECRET` first; else reads/creates
`/opt/verdicttank/.jwt_secret` (0600, `secrets.token_urlsafe(48)`). **Confirmed on disk**:
`.jwt_secret` EXISTS (64 bytes, 0600, root-owned, last modified 2026-08-18 21:38), alongside
`.admin_jwt_secret` (64 bytes, 2026-08-18 23:15). `/etc/verdicttank.env` does NOT define
`VERDICTTANK_JWT_SECRET`, so the on-disk file is the live secret source and persists across
restarts. No secret-persistence concern.
- Password reset: separate short-lived JWT (`purpose:"password_reset"`, 1h TTL), same secret,
emailed via direct `smtplib` to `mail.itpropartner.com:2525` (creds in `/etc/verdicttank.env`).
### 3b. Staff/admin auth — auth2 (Hexclave) delegated, JWT re-mint
- `POST admin/login` receives `{email,password}`, calls auth2 password sign-in
(`https://auth2-api.itpropartner.com/api/v1/auth/password/sign-in`) server-side using
`HEXCLAVE_PUBLISHABLE_KEY`/`HEXCLAVE_PROJECT_ID` from env, then `GET users/me`.
- Staff gate: hardcoded `OWNER_EMAILS = {g@germainebrown.com, info@itpropartner.com,
shonuff@germainebrown.com}` bypasses the team check; otherwise queries auth2 team
`verdicttank-it-staff` via `GET /api/latest/teams?user_id=me&query=verdicttank-it-staff`.
- On success, api.py mints ITS OWN JWT (not an auth2 token) with `{sub: email, role:"staff", name,
iat, exp}`, HS256, 12h TTL, signed with a **separate** secret: env `VERDICTTANK_ADMIN_JWT_SECRET`
else `/opt/verdicttank/.admin_jwt_secret` (0600). **Confirmed on disk**: this file exists,
64 bytes, root-owned, `-rw-------`, last modified 2026-08-18.
- `require_staff()` dependency decodes with the admin secret and requires `role=="staff"`. Because
the two JWT secrets are different, a customer token can never be replayed against an admin route.
- No customer identity is used anywhere in the admin path — admin/staff users are NOT rows in
`users.db`. This means admin actions in a1/a2 below (invite, cancel) must look up the *target
customer* by email in `users.db`, not by staff identity.
### Implication for the subscription build
Any new admin endpoint (invite, cancel-subscription, quota grant) is protected by `require_staff`
exactly like the existing four admin endpoints — no new auth mechanism is needed. Any new
client-facing endpoint (subscription-state, contact, quota display) is protected by the existing
`current_user(authorization)` helper exactly like `/reviews`. No Stack Auth exists for customers
and none should be introduced — do not add Hexclave dependencies to the customer path.
---
## 4. PROPOSED SCHEMA MIGRATION
Two changes: (1) extend `users` with subscription/quota columns, (2) add three new tables for
invites, extra-submission grants, and ops notifications. All statements are additive
(`ALTER TABLE ... ADD COLUMN` / `CREATE TABLE IF NOT EXISTS`) so they are safe to run against the
live `users.db` without data loss; SQLite ALTER TABLE ADD COLUMN never rewrites existing rows and
existing users get the column default.
```sql
-- 4.1: extend users with tier/subscription state
ALTER TABLE users ADD COLUMN tier TEXT NOT NULL DEFAULT 'free';
-- one of: free | oneshot | pro | enterprise | whitelabel
ALTER TABLE users ADD COLUMN subscription_status TEXT NOT NULL DEFAULT 'active';
-- one of: active | pending_cancel | expired | demo
ALTER TABLE users ADD COLUMN term_start TEXT;
-- ISO8601 UTC; start of current billing/quota term. NULL for free/oneshot (no recurring term).
ALTER TABLE users ADD COLUMN term_end TEXT;
-- ISO8601 UTC; renewal date for monthly tiers (pro/enterprise/whitelabel).
-- For demo accounts, this doubles as the auto-expiration timestamp (see a1).
ALTER TABLE users ADD COLUMN cancel_at_term_end INTEGER NOT NULL DEFAULT 0;
-- 0/1 boolean. Set to 1 by a2 (cancel). Subscription stays 'active' with full quota until
-- term_end, then a scheduled job (see 6.4) flips tier to 'free' and status to 'expired'.
ALTER TABLE users ADD COLUMN submissions_used_this_term INTEGER NOT NULL DEFAULT 0;
-- Running counter of submissions consumed against the included quota in the CURRENT term.
-- Reset to 0 whenever term_start advances (monthly rollover) — see 6.3.
ALTER TABLE users ADD COLUMN extra_submissions_granted INTEGER NOT NULL DEFAULT 0;
-- Cumulative count of manually-admin-granted overage submissions available beyond the
-- included quota for the CURRENT term. Since there is no payment provider, this is set only
-- by an admin action (b3 "paid additional submissions" are recorded manually).
ALTER TABLE users ADD COLUMN extra_submissions_used INTEGER NOT NULL DEFAULT 0;
-- Of extra_submissions_granted, how many have been consumed this term.
ALTER TABLE users ADD COLUMN is_demo INTEGER NOT NULL DEFAULT 0;
-- 1 if this account was created via the ops "create demo account" flow (a1).
ALTER TABLE users ADD COLUMN demo_expires_at TEXT;
-- ISO8601 UTC; set only for demo accounts. Same auto-expire mechanism as term_end but kept
-- as a distinct column so a real subscriber's term_end is never confused with a demo cutoff.
-- 4.2: invite-to-register tokens (a1)
CREATE TABLE IF NOT EXISTS invites (
id INTEGER PRIMARY KEY AUTOINCREMENT,
token TEXT UNIQUE NOT NULL,
email TEXT NOT NULL,
tier TEXT NOT NULL DEFAULT 'free',
is_demo INTEGER NOT NULL DEFAULT 0,
expires_in_days INTEGER NOT NULL DEFAULT 14, -- the X in "X-day expiration"
created_by TEXT NOT NULL, -- staff email from admin JWT
created_at TEXT NOT NULL,
redeemed_at TEXT, -- NULL until the invite is used
redeemed_user_id INTEGER,
invite_expires_at TEXT NOT NULL -- the invite LINK's own expiry (separate from
-- the resulting demo account's expiry)
);
-- 4.3: extra-submission grant ledger (b3 — audit trail for manually-added overage)
CREATE TABLE IF NOT EXISTS submission_grants (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id),
quantity INTEGER NOT NULL, -- positive = granted, negative = consumed (optional; or
-- keep consumption as a simple counter on users and use
-- this table purely as a grant-only audit log)
reason TEXT, -- free text, e.g. "manual overage payment received 8/19"
granted_by TEXT NOT NULL, -- staff email
granted_at TEXT NOT NULL
);
-- 4.4: ops notifications (b1 contact form)
CREATE TABLE IF NOT EXISTS ops_notifications (
id INTEGER PRIMARY KEY AUTOINCREMENT,
type TEXT NOT NULL DEFAULT 'contact', -- extensible: contact | cancellation | demo_expiring
payload TEXT NOT NULL, -- JSON blob: {name, email, subject, message, user_id}
created_at TEXT NOT NULL,
read_at TEXT -- NULL until a staff member marks it read in ops portal
);
```
Notes:
- `submissions_used_this_term` + `extra_submissions_used` together define "remaining" (Section 6).
- Free and One-Shot are lifetime/one-time, so `term_start`/`term_end` stay NULL for them; quota
enforcement instead checks `submissions_used_this_term >= 1` for the life of the account
(Free) or the single submission ever made (One-Shot) — see 6.1/6.2.
- No `stripe_customer_id` or any payment column is added, matching the "no Stripe exists"
constraint. `submission_grants` is the entire "billing" record for overage — a manual ledger.
---
## 5. PROPOSED NEW ENDPOINTS
All new endpoints live under the existing `PREFIX = "/api/verdicttank"` router in api.py. Admin
endpoints reuse `require_staff()`; client endpoints reuse `current_user()`.
### a1. Ops — invite-to-register + create demo account
**`POST /api/verdicttank/admin/invite`** (staff Bearer)
```json
// Request
{
"email": "prospect@example.com",
"tier": "pro",
"is_demo": true,
"expires_in_days": 14
}
// Response 200
{
"invite_token": "3f9c1a...e2",
"invite_url": "https://my.verdicttank.com/?invite=3f9c1a...e2",
"email": "prospect@example.com",
"tier": "pro",
"is_demo": true,
"expires_in_days": 14,
"invite_expires_at": "2026-09-02T00:00:00+00:00"
}
```
Behavior: inserts a row into `invites` (`invite_expires_at = now + expires_in_days` for the LINK
itself, default 7 days for the link unless staff overrides — the "X-day expiration" in the task
refers to the resulting **demo account's** lifetime, not necessarily the invite link's; if the
same X should govern both, set link expiry = `expires_in_days` too). Does NOT create the user yet.
409 if an unredeemed invite already exists for that email+tier combo (optional; else always allow
multiple). Sends an email to the invitee with `invite_url` (reuses the existing `send_email()` /
SMTP helper).
**`GET /api/verdicttank/invite/{token}`** (public, no auth)
```json
// Response 200
{"email": "prospect@example.com", "tier": "pro", "is_demo": true, "valid": true}
// Response 410 (expired or already redeemed)
{"detail": "This invite link is no longer valid."}
```
Used by the client portal to pre-fill/lock the registration form when `?invite=` is present.
**`POST /api/verdicttank/auth/register`** (existing endpoint, extended)
```json
// Request adds optional field
{"email": "...", "password": "...", "name": "...", "invite_token": "3f9c1a...e2"}
```
If `invite_token` is present and valid: on success, set the new user's `tier`, `is_demo=1`,
`demo_expires_at = now + expires_in_days`, `term_start/term_end` per tier rules, mark the invite
row `redeemed_at`/`redeemed_user_id`. If invalid/expired, 410 (do not silently fall back to free —
surface the error so the client can prompt for a fresh invite).
**Auto-expiration mechanism**: no code path runs on a timer inside api.py today (no scheduler).
Two implementation options, pick one explicitly before building:
1. **Lazy expiry** (recommended, zero new infra): every place that reads `tier`/quota
(`current_user`, `/submit`, `/subscription-state`) checks `is_demo=1 AND demo_expires_at < now`
and, if true, downgrades the row to `tier='free', subscription_status='expired', is_demo=0` in
that same request before proceeding. Self-healing, no new process, but a demo that no one
touches after expiry stays "expired-but-not-yet-flagged" in the DB until next access — cosmetic
only, does not affect enforcement since /submit checks it too.
2. **Cron sweep**: a new script (`/opt/verdicttank/expire_demos.py`) run via systemd timer/cron
that scans `WHERE is_demo=1 AND demo_expires_at < now` and downgrades in bulk. More visible in
`admin/stats`/ops list at all times, but is new infrastructure this contract does not currently
assume exists — would need its own service unit, outside api.py's process model.
### a2. Ops — cancel subscription, effective at end of term
**`POST /api/verdicttank/admin/cancel-subscription`** (staff Bearer)
```json
// Request
{"user_id": 42}
// Response 200
{
"user_id": 42,
"tier": "pro",
"subscription_status": "pending_cancel",
"cancel_at_term_end": true,
"term_end": "2026-09-19T00:00:00+00:00",
"message": "Subscription will remain active with full quota through 2026-09-19, then move to Free."
}
```
Behavior: sets `cancel_at_term_end=1`. Does **not** change `tier`, `subscription_status`
(stays `active` until term boundary, per the "not immediate lock" constraint — this endpoint sets
`subscription_status='pending_cancel'` purely as a display flag, tier/quota enforcement is
unaffected until term_end passes), does not touch `submissions_used_this_term`. The customer keeps
full remaining quota for the rest of the paid term. At `term_end`, the same lazy-expiry or
cron-sweep mechanism from a1 flips `tier='free'`, `subscription_status='expired'`,
`cancel_at_term_end=0`.
**`POST /api/verdicttank/admin/reactivate-subscription`** (staff Bearer, companion undo)
```json
// Request
{"user_id": 42}
// Response 200
{"user_id": 42, "subscription_status": "active", "cancel_at_term_end": false}
```
Lets staff undo a pending cancellation before term_end (e.g. customer called back). Not explicitly
requested but a near-zero-cost companion to a2 that avoids a support dead-end; flag as optional.
### b1. Client — Contact VerdictTank form
**`POST /api/verdicttank/contact`** (public; Bearer optional to attach `user_id`/pre-fill)
```json
// Request
{
"name": "Jane Prospect",
"email": "jane@example.com",
"subject": "Question about Enterprise seats",
"message": "..."
}
// Response 200
{"message": "Thanks — we've received your message and will reply within one business day."}
// Response 422 (missing/invalid fields)
{"detail": "A valid email address is required."}
```
Behavior: (1) sends an email via the existing `send_email()`/`smtplib` helper — sender
`noreply@verdicttank.com`, recipient `support@verdicttank.com`, subject prefixed
`[VerdictTank Contact] {subject}`, body includes name/email/message/user_id-if-known; (2) inserts
a row into `ops_notifications` (`type='contact'`, `payload` = the request JSON + resolved
`user_id`) so it also surfaces in the ops portal without depending on email deliverability. Note:
current SMTP config (`VERDICTTANK_SMTP_FROM`) defaults to `noreply@itpropartner.com`, not
`noreply@verdicttank.com` — confirm the `verdicttank.com` domain has SPF/DKIM/relay authorization
for that From address before wiring, or the send will fail/land in spam (see Constraints note in
Section 6).
**`GET /api/verdicttank/admin/notifications`** (staff Bearer, ops portal display for b1)
```json
// Response 200
{"notifications": [{"id": 1, "type": "contact", "payload": {...}, "created_at": "...", "read_at": null}], "total": 1}
```
**`POST /api/verdicttank/admin/notifications/{id}/read`** (staff Bearer)
```json
// Response 200
{"id": 1, "read_at": "2026-08-19T14:02:00+00:00"}
```
### b2 / b3. Client — subscription/quota state (remaining submissions, renewal date, paid extras)
**`GET /api/verdicttank/subscription`** (Bearer required)
```json
// Response 200 — Pro example
{
"tier": "pro",
"tier_label": "Pro Review",
"subscription_status": "active",
"cancel_at_term_end": false,
"term_start": "2026-08-01T00:00:00+00:00",
"term_end": "2026-09-01T00:00:00+00:00",
"included_per_term": 8,
"submissions_used_this_term": 5,
"included_remaining": 3,
"extra_submissions_granted": 2,
"extra_submissions_used": 1,
"extra_remaining": 1,
"total_remaining": 4,
"overage_price_each": 18,
"is_demo": false,
"demo_expires_at": null
}
// Response 200 — Free example
{
"tier": "free",
"tier_label": "Free Assessment",
"subscription_status": "active",
"cancel_at_term_end": false,
"term_start": null,
"term_end": null,
"included_per_term": 1,
"submissions_used_this_term": 1,
"included_remaining": 0,
"extra_submissions_granted": 0,
"extra_submissions_used": 0,
"extra_remaining": 0,
"total_remaining": 0,
"overage_price_each": null,
"is_demo": false,
"demo_expires_at": null
}
```
This single endpoint backs both b2 (remaining + renewal date = `term_end`) and b3 (paid additional
submissions = `extra_submissions_granted`/`extra_submissions_used`/`extra_remaining`). The client
portal's dashboard view calls this alongside the existing `/reviews` call and renders a quota card.
### Quota-blocking on submit (implicit, not a new endpoint — extends existing `/submit`)
`POST /api/verdicttank/submit` gains a pre-check: if the caller is authenticated and
`total_remaining <= 0`, return `403` with:
```json
{"detail": "You've used all included and extra submissions for this term. Contact us to add more."}
```
Anonymous (no-Bearer) submissions are unaffected by quota (today's behavior — tier is
self-reported and unenforced for guests; enforcing quota requires an account, so this contract
does not change the current guest-checkout path, only the authenticated path).
---
## 6. QUOTA ENFORCEMENT RULES
### 6.1 Included quota by tier (authoritative table, restated)
| Tier | Included/term | Term length | Overage/each |
|---|---|---|---|
| free | 1 | lifetime (no reset, ever) | not allowed (hard stop) |
| oneshot | 1 | one-time (no reset, ever) | not allowed (hard stop) |
| pro | 8 | 1 month | $18 |
| enterprise | 50 | 1 month | $16 |
| whitelabel | 100 | 1 month | $28 |
### 6.2 Remaining-submissions formula
```
included_remaining = max(0, included_per_term(tier) - submissions_used_this_term)
extra_remaining = max(0, extra_submissions_granted - extra_submissions_used)
total_remaining = included_remaining + extra_remaining
```
For `free`/`oneshot`, `included_per_term` is a fixed lifetime cap of 1 and `term_end` is always
NULL — there is no rollover, so `included_remaining` only ever goes from 1 to 0 and never resets.
### 6.3 Consumption order and counter updates (on a successful `/submit` by an authenticated user)
1. Compute `total_remaining` as above. If `<= 0`, reject with 403 (see Section 5).
2. If `included_remaining > 0`: increment `submissions_used_this_term` by 1 (consume included
quota first).
3. Else (included exhausted, extra available): increment `extra_submissions_used` by 1 (consume
paid/manual extras second).
4. Anonymous/guest submissions (no Bearer) are NOT counted against any user's quota — they have no
`user_id` to attribute to, matching current behavior where `tier` is self-reported and
unenforced for guests.
### 6.4 Term rollover (monthly tiers: pro/enterprise/whitelabel)
On any authenticated request that reads or mutates quota (submit, `/subscription`), first check:
`if now >= term_end: ` then roll the term forward:
```
new_term_start = term_end
new_term_end = term_end + 1 month
submissions_used_this_term = 0
extra_submissions_granted = 0 -- extras do NOT carry over; each term's manual grants are scoped
extra_submissions_used = 0 -- to that term, consistent with "no billing exists to track a running balance"
```
Then, if `cancel_at_term_end == 1` at the moment of rollover: instead of rolling forward, downgrade
— `tier='free'`, `subscription_status='expired'`, `cancel_at_term_end=0`, `term_start=NULL`,
`term_end=NULL`. This is the actual mechanical trigger for a2's "effective at end of term": the
cancellation flag is inert until the term boundary is crossed, at which point the same rollover
check that would normally renew the term instead terminates it. This makes rollover/expiry a
**pure function of "did a request happen after term_end", not a background job** — matching the
lazy-expiry approach recommended in Section 5/a1 and requiring no new systemd timer.
### 6.5 Demo accounts (a1)
Independent of tier quota — a demo account still has a real `tier` (e.g. "pro") and consumes quota
normally per 6.2/6.3, but additionally has `demo_expires_at`. On any request, if
`is_demo==1 AND now >= demo_expires_at`: downgrade identically to the cancellation end-of-term path
(`tier='free'`, `subscription_status='expired'`, `is_demo=0`, `demo_expires_at=NULL`) — a demo's
expiration is immediate/hard (not "end of term" like a real cancellation), since a demo was never a
paid commitment.
### 6.6 Marketing site consistency (c1)
The `#pricing` section text must not contradict the enforced numbers above — in particular, the
current live copy calls Pro "Unlimited submissions and revisions for one active proposal at a
time," which directly conflicts with the 8/month quota. Section c1's job is to replace that claim
with the actual per-tier count (Free: 1 review; One-Shot: 1 review; Pro: 8/month; Enterprise:
50/month; White-Label: 100/month) so the enforced backend and the marketing promise match.
---
## 7. KNOWN RECEIPT DEFECTS (confirmed exact lines, `/opt/verdicttank/api.py`, `generate_receipt()`)
1. **Line 892** — `<div class="value">{submission["submitted_at"][:19].replace("T", " ")}</div>` —
prints raw UTC with no timezone label or conversion (e.g. "2026-08-19 14:02:00" with no "UTC"
suffix), so an Eastern-time submitter sees what looks like their own local time but is 4-5 hours
off. Fix: append explicit `" UTC"` suffix (safest, no timezone-conversion risk) or convert to a
configured business timezone with a labeled abbreviation.
2. **Lines 897-905** — the "What Happens Next" card hardcodes the full panel description
("Multi-Seat Panel — Independent reasoning seats from separate providers review your proposal
in a single pass, scoring ten dimensions") on every receipt regardless of tier. Per
`references/subscription-and-receipt.md` and `api-and-auth.md`, Free tier actually runs a
reduced 4-seat panel (research + primary + legal + financial), not the 9-seat/10-dimension
panel. Fix: branch this block on `submission["tier"] == "free"` and use tier-accurate seat/
dimension counts in both branches.
3. **Line 891** — `<div class="value">{label} — {price}</div>` — em dash between tier label and
price. **Line 908** — `VerdictTank — a product of <strong>IT Pro Partner</strong>` — em dash in
footer. Both violate the house style rule (no em/en dashes on VerdictTank surfaces). Fix:
replace `` with a plain separator (e.g. `,` or `|`) in both lines; grep the whole
`generate_receipt()` function body for any other ``/`` before considering the fix complete
(a byte-level codepoint scan is safer than eyeballing, per the platform skill's pitfall notes).
No other defects in `generate_receipt()` were found on this pass; the QUEUED badge and 4-step
copy layout are otherwise accurate to the current submit-flow behavior.
---
## Open items requiring a decision before implementation (not assumptions made in this contract)
- Whether the invite LINK's own expiry should equal the demo account's expiry X, or be a shorter
fixed window (e.g. 7 days to redeem) independent of the demo lifetime once redeemed (Section 5/a1).
- Whether `noreply@verdicttank.com` has outbound send authorization (SPF/DKIM/relay credentials)
distinct from the currently configured `noreply@itpropartner.com` — b1 assumes it does per the
task's stated sender, but the live `VERDICTTANK_SMTP_FROM` env default is the itpropartner.com
address; this needs its own SMTP credential/domain verification, not a code change.
- Whether extra-submission grants (b3) should be a simple counter (as modeled here) or a full
ledger table (`submission_grants`, also proposed in Section 4) for audit purposes — the schema
proposes both so either can be adopted without a second migration.
+69
View File
@@ -0,0 +1,69 @@
# VerdictTank Demo Case: HarvestLink (Revised)
Revised after panel feedback. Same fictional company; changes are real responses to the
first review's fatal flaws: corrected market sizing, added pilot unit economics, a worked
pricing waterfall, a compliance plan, and a reframed Sacramento-only ask.
---
## HarvestLink: Direct Farm-to-Restaurant Sourcing
### The Problem
Independent restaurants lose roughly 30% of their produce spend to spoilage and distributor
markup. Small and mid-size farms have no efficient channel to reach restaurants directly, so
they sell through aggregators that take the margin.
### The Solution
HarvestLink is a marketplace and logistics platform connecting local farms directly to
independent restaurants. Restaurants order through a mobile app; farms fulfill through a shared
cold-chain delivery network. Built-in demand forecasting tells farms what to plant and
restaurants what to order, cutting waste on both sides.
### Market (corrected)
US independent restaurants spend an estimated $28 billion a year on fresh produce. Our three
launch metros (Sacramento, Austin, Portland) hold roughly 7.3 million people, about 2.2% of the
US population, which implies a served market near $620 million, not the $1.9 billion we
previously stated. Bottom-up check: about 4,800 independent restaurants across the three metros
at an average $130,000 a year in produce spend lands on the same ~$620 million figure.
### Business Model (with pricing waterfall)
8% take rate on marketplace transactions plus a $99/month restaurant subscription after a
60-day free trial. Farms sell at a 12-18% premium over their farm-gate wholesale price, while
restaurants still pay less than distributor pricing.
Worked waterfall on a $3,000/month produce basket:
- Distributor today: $2,308 wholesale x 30% markup = $3,000.
- HarvestLink: $2,308 wholesale x 12% farm premium = $2,585, plus 8% take = $2,792.
- Restaurant pays $2,792, saving $208/month before the $99 subscription; net ~$109/month after.
### Traction and Unit Economics (Sacramento pilot, actual)
12 farms and 9 restaurants live. $18,400 GMV in three months, 71% repeat order rate. Two
restaurants churned over delivery windows.
- Average order value: $240.
- Order frequency: 2.8 orders per restaurant per month.
- GMV per restaurant: $681/month.
- Take revenue: 8% x $18,400 = $1,472 for the quarter; subscription is $0 during trials.
- Delivery: shared cold-chain routes at $28/stop today. Take revenue alone ($19/stop) does not
cover delivery; the $99 subscription is what makes a route contribution-positive. Route
break-even is roughly 45-60 restaurants per route at current AOV and frequency. We are at 9,
so the plan is Sacramento-only density before any expansion.
### Compliance Plan
PACA license, FSMA/cold-chain HACCP food-safety procedures, cargo and liability insurance,
written supplier and restaurant terms, and a recall procedure before scaling beyond the pilot.
Multistate compliance is deferred: we are Sacramento-only for this round, so California-only
compliance applies.
### Team and Hiring
Founder with seven years in restaurant operations and supply chain at a regional chain, plus one
contract engineer. This round funds a full-time logistics/operations lead and a sales hire;
both are prerequisites before any second-city launch.
### The Ask
$250,000 to run a 12-month Sacramento-only proof: grow 9 to 60 restaurants, hire the
logistics and sales leads, lock in a cold-chain partner, and reach route-level contribution
breakeven. Austin and Portland are deferred until Sacramento breakeven is proven.
### Use of Funds
45% logistics and delivery network, 30% sales and onboarding, 15% engineering, 10% compliance
and operations.
+42
View File
@@ -0,0 +1,42 @@
# VerdictTank Demo Case: HarvestLink
Staged proposal for the VerdictTank product-marketing video. Fictional company, no real
people, no PII. Intentionally a "typical first-draft" with realistic texture and gaps so the
panel has real material to score.
---
## HarvestLink: Direct Farm-to-Restaurant Sourcing
### The Problem
Independent restaurants lose roughly 30% of their produce spend to spoilage and distributor
markup. Small and mid-size farms have no efficient channel to reach restaurants directly, so
they sell through aggregators that take the margin.
### The Solution
HarvestLink is a marketplace and logistics platform connecting local farms directly to
independent restaurants. Restaurants order through a mobile app; farms fulfill through a shared
cold-chain delivery network. Built-in demand forecasting tells farms what to plant and
restaurants what to order, cutting waste on both sides.
### Market
US independent restaurants spend an estimated $28 billion a year on fresh produce. Our three
launch cities (Sacramento, Austin, Portland) represent a $1.9 billion served market.
### Business Model
8% take rate on marketplace transactions plus a $99/month restaurant subscription after a
60-day free trial. Farms sell at a 12-18% premium over wholesale by cutting out the distributor.
### Traction
12 pilot farms and 9 restaurants live in Sacramento. $18,400 GMV in three months, 71% repeat
order rate. Two restaurants churned over delivery windows.
### Team
Founder with seven years in restaurant operations and supply chain at a regional chain. One
contract engineer. No full-time hires yet.
### The Ask
$500,000 seed to build the logistics layer, hire a head of sales, and launch Austin and Portland.
### Use of Funds
40% engineering, 30% sales and onboarding, 20% cold-chain logistics partners, 10% operations.
+125
View File
@@ -0,0 +1,125 @@
# VerdictTank Staff Dashboard — Build Contract (2026-08-18)
Pinned contract for the `ops.verdicttank.com` staff dashboard. Three teams build
against this. Do NOT invent fields; every field below exists in the live data or is
explicitly new here.
## Goal
Staff (IT Pro Partner internal) can sign in and view ALL VerdictTank submissions and
their full results. Customers stay on `my.verdicttank.com` (product JWT). Staff use
auth2 (Stack Auth / Hexclave). Owner-email fallback guarantees the two owners can
always sign in.
## Topology (unchanged)
- API: Core, `/opt/verdicttank/api.py`, `127.0.0.1:8201`, systemd `verdicttank-api.service`.
- Submissions: one JSON file per review in `/opt/verdicttank/data/*.json`.
- auth2: app3, `auth2-api.itpropartner.com` (API) / `auth2.itpropartner.com` (dashboard).
- Python: `/root/docker/super-search/venv/bin/python3`. PyJWT imported as `import jwt as pyjwt`.
## auth2 (Stack Auth) constants
- Sign-in: `POST https://auth2-api.itpropartner.com/api/v1/auth/password/sign-in`
body `{"email": ..., "password": ...}`.
Headers: `x-hexclave-publishable-client-key: zZhcrUZXs8EqihvXzl7vqvasrRlj7kWn6+eENpX+6Eo=`,
`x-hexclave-access-type: client`, `x-hexclave-project-id: internal`, `Content-Type: application/json`.
Success returns `{access_token, refresh_token, user_id}`. `access_token` is OPAQUE (not a JWT).
- Current user: `GET https://auth2-api.itpropartner.com/api/latest/users/me`
headers `x-hexclave-access-token: <access_token>`, `x-hexclave-access-type: client`,
`x-hexclave-project-id: internal`. Returns `{id, display_name, primary_email}`.
- Team gate: `GET https://auth2-api.itpropartner.com/api/latest/teams?user_id=me&query=verdicttank-it-staff`
(same access-token headers). Membership enforced server-side: `items.length > 0` == member.
- Publishable key above is the `internal` project's key (client-visible, not a secret).
- Owner emails (hardcoded fallback, always admitted): `g@germainebrown.com`, `info@itpropartner.com`, `shonuff@germainebrown.com`.
All three exist as auth2 ProjectUsers (usedForAuth=TRUE): g@ = seed admin, info@ = "IT Pro Partner Info", shonuff@ = "Sho'Nuff".
## New admin endpoints (all prefixed `/api/verdicttank/admin/`)
### POST /api/verdicttank/admin/login
Body `{email, password}`.
1. Call auth2 sign-in (above). 200 -> parse `access_token`. Non-200 -> 401 `{"detail":"Invalid credentials"}`.
2. `users/me` -> `display_name`, `primary_email`.
3. Staff check: `primary_email` (or `email`) in OWNER_EMAILS, OR team gate returns `items.length > 0`.
4. If staff: issue admin JWT (below), return `{token, user:{email, name}}`.
5. If not staff: 403 `{"detail":"Not authorized"}`.
### GET /api/verdicttank/admin/submissions (admin JWT)
Query: `status` (optional), `q` (optional: case-insensitive match on name/email/proposal_name),
`limit` (default 100, max 500), `offset` (default 0).
Returns `{submissions:[...], total:N}`.
Each item: `review_id, proposal_name, name, email, tier, tier_label, status, verdict,
proposal_strength, investor_readiness, composite, submitted_at, completed_at`.
Use the same pattern as the existing `/reviews` handler: `for path in sorted(DATA_DIR.glob("*.json"), reverse=True)`.
### GET /api/verdicttank/admin/submissions/{review_id} (admin JWT)
Full detail — every field in `/results/{id}` PLUS the staff-only fields:
`review_id, status, proposal_name, name, email, user_id, tier, tier_label, submitted_at,
completed_at, verdict, verdict_rationale, executive_summary, proposal_strength,
investor_readiness, composite, divergence, dimensions, fatal_flaws, action_plan,
kill_criteria, consensus, panel_audit, report_url, receipt_url, error`.
`report_url` = `/api/verdicttank/report/{id}` (only when status == complete, else null).
`receipt_url` = `/api/receipt/{id}` (only when receipt_path exists).
### GET /api/verdicttank/admin/stats (admin JWT)
Returns `{total, completed, failed, queued, processing, by_tier:{tier:count,...}, by_status:{...}}`.
Compute from all `*.json` in DATA_DIR.
## Admin JWT (separate from customer JWT)
- Secret: `VERDICTTANK_ADMIN_JWT_SECRET` env, else persist `secrets.token_urlsafe(48)` to
`/opt/verdicttank/.admin_jwt_secret` (mode 0600). DO NOT reuse the customer `.jwt_secret`.
- Algo HS256, TTL 12 hours.
- Claims: `{sub: email, role: "staff", name: name, exp, iat}`.
- Dependency `require_staff(authorization: str = Header(None))`: decode with the admin
secret, require `role == "staff"`, else 401. Reuse the existing `import jwt as pyjwt`.
## Submission JSON fields (already in data files)
`review_id, status(queued|processing|complete|failed), proposal_name, name, email, tier,
submitted_at, completed_at, verdict, verdict_rationale, executive_summary,
proposal_strength, investor_readiness, composite, divergence, dimensions(list of
{key,label,score}), fatal_flaws, action_plan, kill_criteria, consensus,
panel_audit(list), report_path, receipt_path, progress, error, user_id(optional)`.
Existing helpers in api.py: `load_submission(review_id)`, `save_submission(review_id, sub)`,
`DATA_DIR`, `TIERS`, `tier_label(tier)`, `current_user(authorization)`.
## Staff frontend (ops.verdicttank.com) — single-file SPA
Served from Core Caddy (see infra). The SPA calls the API SAME-ORIGIN via the
`/api/*` reverse proxy, so base = `""` (relative), e.g. `fetch('/api/verdicttank/admin/login', ...)`.
No CORS, no cross-origin auth2 calls from the browser — all auth2 interaction is
server-side in the API.
- Login screen: email + password -> `POST /api/verdicttank/admin/login` -> store
`{token, user}` in localStorage. Send `Authorization: Bearer <token>` on all admin calls.
- 401/403 -> show access-denied / re-show login.
- Dashboard: stats bar from `/api/verdicttank/admin/stats`; submissions table from
`/api/verdicttank/admin/submissions`; search box (`q`), status filter (`status`), pagination.
- Row click -> `/api/verdicttank/admin/submissions/{id}` -> detail view: two 0-100 scores,
10 dimensions (0-10 bars), verdict + rationale, executive summary, fatal flaws, action plan,
kill criteria, consensus, panel audit (collapsible), links to report_url and receipt_url.
- Download links: prepend `window.location.origin` to `report_url` / `receipt_url`.
Brand rules (non-negotiable): crimson `#dc2626`, Inter font (Google Fonts ok), dark/light
toggle, NO vendor/model names anywhere, NO em/en dashes (use hyphens), no fabricated
numbers (render real values only), footer "VerdictTank, a product of IT Pro Partner".
Single-file HTML/CSS/JS, vanilla, no frameworks, no CDNs beyond Google Fonts.
## Infra (Core + DNS + auth2)
1. DNS: `ops.verdicttank.com` A record -> `152.53.192.33`.
2. Caddy (Core `/etc/caddy/Caddyfile`) — add block:
`ops.verdicttank.com { root * /var/www/verdicttank-ops; file_server; handle /api/* { reverse_proxy 127.0.0.1:8201 } header Cache-Control "no-cache" }`
and in the existing `verdicttank.com` block add `handle /api/receipt/* { reverse_proxy 127.0.0.1:8201 }`
(fixes the receipt 404 for the customer portal).
3. auth2 team `verdicttank-it-staff` + add `g@germainebrown.com` as member (non-blocking:
owner fallback already admits the two owners).
4. Write `HEXCLAVE_PUBLISHABLE_KEY=zZhcrUZXs8EqihvXzl7vqvasrRlj7kWn6+eENpX+6Eo=` and
`HEXCLAVE_PROJECT_ID=internal` to `/etc/verdicttank.env` (API reads via os.getenv).
## Deploy order (conductor enforces)
Backend module -> `py_compile` -> `systemctl restart verdicttank-api` -> Caddy reload ->
DNS propagate -> frontend deployed to `/var/www/verdicttank-ops/index.html` -> e2e test.
File diff suppressed because it is too large Load Diff
+889
View File
@@ -0,0 +1,889 @@
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>VerdictTank | Client Portal</title>
<style>
:root {
--bg: #ffffff;
--bg2: #fafafa;
--text: #171717;
--text2: #525252;
--text3: #737373;
--accent: #dc2626;
--accent-hover: #b91c1c;
--accent-light: #fef2f2;
--border: #e5e5e5;
--green: #16a34a;
--lime: #65a30d;
--amber: #ca8a04;
--orange: #ea580c;
--red: #b91c1c;
--card: #ffffff;
--shadow: 0 1px 2px rgba(0,0,0,0.04), 0 8px 24px rgba(0,0,0,0.06);
}
[data-theme="dark"] {
--bg: #0a0a0a;
--bg2: #141414;
--text: #ededed;
--text2: #a1a1a1;
--text3: #8a8a8a;
--accent: #ef4444;
--accent-hover: #dc2626;
--accent-light: #2a1212;
--border: #262626;
--card: #141414;
--shadow: 0 1px 2px rgba(0,0,0,0.5), 0 8px 24px rgba(0,0,0,0.4);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html { scroll-behavior: smooth; }
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg);
color: var(--text);
font-size: 0.95rem;
line-height: 1.55;
min-height: 100vh;
display: flex;
flex-direction: column;
transition: background 0.2s, color 0.2s;
}
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
.container { width: 100%; max-width: 1040px; margin: 0 auto; padding: 0 1.25rem; }
/* Header */
.site-header {
border-bottom: 1px solid var(--border);
position: sticky; top: 0; z-index: 20;
background: var(--bg);
}
.header-inner {
display: flex; align-items: center; justify-content: space-between;
height: 60px;
}
.brand { display: flex; align-items: center; gap: 0.6rem; font-weight: 800; font-size: 1.1rem; letter-spacing: -0.02em; color: var(--text); }
.brand .mark {
width: 26px; height: 26px; border-radius: 7px; background: var(--accent);
display: inline-flex; align-items: center; justify-content: center;
color: #fff; font-size: 0.8rem; font-weight: 800;
}
.header-actions { display: flex; align-items: center; gap: 0.75rem; }
.user-chip { font-size: 0.85rem; color: var(--text2); }
/* Buttons */
.btn {
display: inline-flex; align-items: center; justify-content: center; gap: 0.4rem;
padding: 0.55rem 1.1rem; border-radius: 8px; border: 1px solid transparent;
font-weight: 600; font-size: 0.9rem; cursor: pointer; font-family: inherit;
transition: background 0.15s, border-color 0.15s, opacity 0.15s;
text-decoration: none; line-height: 1.2;
}
.btn:hover { text-decoration: none; }
.btn-primary { background: var(--accent); color: #fff; }
.btn-primary:hover { background: var(--accent-hover); }
.btn-primary:disabled { opacity: 0.55; cursor: not-allowed; }
.btn-secondary { background: transparent; color: var(--text); border-color: var(--border); }
.btn-secondary:hover { border-color: var(--text3); }
.btn-ghost { background: transparent; color: var(--text2); border: none; padding: 0.4rem 0.6rem; }
.btn-ghost:hover { color: var(--text); background: var(--accent-light); }
.btn-sm { padding: 0.35rem 0.8rem; font-size: 0.82rem; }
.icon-btn {
background: transparent; border: 1px solid var(--border); border-radius: 8px;
width: 34px; height: 34px; cursor: pointer; color: var(--text2); font-size: 0.95rem;
display: inline-flex; align-items: center; justify-content: center; font-family: inherit;
}
.icon-btn:hover { color: var(--text); border-color: var(--text3); }
/* Cards & panels */
.card {
background: var(--card); border: 1px solid var(--border); border-radius: 12px;
padding: 1.5rem; box-shadow: var(--shadow);
}
.panel-title { font-size: 1.15rem; font-weight: 700; margin-bottom: 0.25rem; }
.panel-sub { color: var(--text3); font-size: 0.85rem; margin-bottom: 1.25rem; }
/* Forms */
.field { margin-bottom: 1.1rem; }
.field label { display: block; font-size: 0.85rem; font-weight: 600; margin-bottom: 0.35rem; color: var(--text2); }
.field .hint { font-size: 0.78rem; color: var(--text3); margin-top: 0.3rem; }
input[type="text"], input[type="email"], input[type="password"], textarea, select {
width: 100%; padding: 0.6rem 0.75rem; border-radius: 8px;
border: 1px solid var(--border); background: var(--bg); color: var(--text);
font-family: inherit; font-size: 0.9rem;
}
input:focus, textarea:focus, select:focus { outline: 2px solid var(--accent); outline-offset: 1px; border-color: var(--accent); }
textarea { resize: vertical; min-height: 130px; }
.form-error {
background: var(--accent-light); border: 1px solid var(--accent);
color: var(--accent); border-radius: 8px; padding: 0.7rem 0.9rem;
font-size: 0.85rem; margin-bottom: 1rem; display: none;
}
.form-error.show { display: block; }
/* Auth */
.auth-wrap { max-width: 420px; margin: 3rem auto; }
.auth-tabs { display: flex; border-bottom: 1px solid var(--border); margin-bottom: 1.5rem; }
.auth-tab {
flex: 1; text-align: center; padding: 0.7rem; cursor: pointer; font-weight: 600;
color: var(--text3); border-bottom: 2px solid transparent; background: transparent;
border-top: none; border-left: none; border-right: none; font-family: inherit; font-size: 0.9rem;
}
.auth-tab.active { color: var(--accent); border-bottom-color: var(--accent); }
/* Dashboard */
.dash-head { display: flex; align-items: center; justify-content: space-between; margin: 2rem 0 1.5rem; flex-wrap: wrap; gap: 1rem; }
.dash-head h1 { font-size: 1.6rem; font-weight: 800; letter-spacing: -0.02em; }
.dash-head .sub { color: var(--text3); font-size: 0.9rem; margin-top: 0.2rem; }
table.reviews { width: 100%; border-collapse: collapse; font-size: 0.88rem; }
table.reviews th {
text-align: left; font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.04em;
color: var(--text3); padding: 0.6rem 0.75rem; border-bottom: 1px solid var(--border);
}
table.reviews td { padding: 0.85rem 0.75rem; border-bottom: 1px solid var(--border); vertical-align: middle; }
table.reviews tr:last-child td { border-bottom: none; }
table.reviews tbody tr:hover { background: var(--bg2); }
.prop-name { font-weight: 600; color: var(--text); }
.empty-state { text-align: center; padding: 3rem 1rem; color: var(--text3); }
.empty-state h3 { color: var(--text); font-size: 1.1rem; margin-bottom: 0.4rem; }
/* Badges */
.badge {
display: inline-flex; align-items: center; padding: 0.2rem 0.6rem; border-radius: 999px;
font-size: 0.72rem; font-weight: 700; letter-spacing: 0.02em;
}
.badge-queued { background: var(--accent-light); color: var(--accent); }
.badge-processing { background: #fef3c7; color: #b45309; }
.badge-complete { background: #f0fdf4; color: #15803d; }
.badge-failed { background: var(--accent-light); color: var(--red); }
[data-theme="dark"] .badge-processing { background: #2a1e08; color: #f59e0b; }
[data-theme="dark"] .badge-complete { background: #0a2a16; color: #4ade80; }
/* Score mini */
.score-mini { font-weight: 700; font-variant-numeric: tabular-nums; }
/* Tier selector */
.tier-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 0.6rem; }
.tier-opt {
border: 1px solid var(--border); border-radius: 10px; padding: 0.85rem 0.9rem; cursor: pointer;
transition: border-color 0.15s, background 0.15s; background: var(--bg);
}
.tier-opt:hover { border-color: var(--text3); }
.tier-opt.selected { border-color: var(--accent); background: var(--accent-light); }
.tier-opt input { position: absolute; opacity: 0; pointer-events: none; }
.tier-opt .tier-name { font-weight: 700; font-size: 0.88rem; }
.tier-opt .tier-price { color: var(--text3); font-size: 0.8rem; margin-top: 0.15rem; }
/* Results */
.score-hero-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin: 1.25rem 0; }
.score-card { text-align: center; padding: 1.5rem 1rem; border-radius: 12px; border: 1px solid var(--border); background: var(--bg2); }
.score-card .num { font-size: 3rem; font-weight: 800; line-height: 1; font-variant-numeric: tabular-nums; letter-spacing: -0.02em; }
.score-card .num span { font-size: 1rem; color: var(--text3); font-weight: 600; }
.score-card .lbl { font-weight: 700; margin-top: 0.4rem; font-size: 0.95rem; }
.score-card .blurb { color: var(--text3); font-size: 0.8rem; margin-top: 0.25rem; }
.verdict-banner {
display: flex; align-items: center; justify-content: space-between; gap: 1rem;
padding: 1rem 1.25rem; border-radius: 10px; margin-bottom: 1.5rem;
border: 1px solid var(--border); background: var(--bg2); flex-wrap: wrap;
}
.verdict-flag { display: inline-block; padding: 0.3rem 0.9rem; border-radius: 6px; color: #fff; font-weight: 800; font-size: 0.85rem; letter-spacing: 0.03em; }
.verdict-composite { font-weight: 800; font-size: 1.3rem; font-variant-numeric: tabular-nums; }
.section { margin: 2rem 0; }
.section h2 { font-size: 1.05rem; font-weight: 700; margin-bottom: 0.75rem; padding-bottom: 0.5rem; border-bottom: 1px solid var(--border); }
.prose { color: var(--text2); }
.prose p { margin-bottom: 0.75rem; }
/* Dimension table */
table.dims { width: 100%; border-collapse: collapse; font-size: 0.88rem; }
table.dims th { text-align: left; font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.04em; color: var(--text3); padding: 0.5rem 0.6rem; border-bottom: 1px solid var(--border); }
table.dims td { padding: 0.6rem; border-bottom: 1px solid var(--border); vertical-align: middle; }
table.dims tr:last-child td { border-bottom: none; }
table.dims .dim-score { font-weight: 700; font-variant-numeric: tabular-nums; width: 70px; text-align: right; }
.bar-track { background: var(--border); border-radius: 4px; height: 8px; width: 100%; }
.bar-fill { height: 8px; border-radius: 4px; }
/* Flaws & actions */
.flaw { border-left: 3px solid var(--border); padding: 0.6rem 0.9rem; margin: 0.8rem 0; }
.flaw .sev { display: inline-block; padding: 1px 8px; border-radius: 4px; font-size: 0.68rem; font-weight: 800; text-transform: uppercase; color: #fff; margin-right: 0.5rem; }
.sev-fatal { background: var(--red); } .sev-major { background: var(--orange); }
.sev-moderate { background: var(--amber); } .sev-minor { background: var(--text3); }
.flaw .issue { font-weight: 600; }
.flaw .why { color: var(--text3); font-size: 0.84rem; margin-top: 0.25rem; }
.flaw .fix { color: var(--green); font-size: 0.84rem; margin-top: 0.25rem; }
.action { padding: 0.6rem 0.9rem; margin: 0.5rem 0; border: 1px solid var(--border); border-radius: 8px; }
.action .n { display: inline-block; background: var(--accent); color: #fff; width: 22px; height: 22px; line-height: 22px; text-align: center; border-radius: 6px; font-size: 0.78rem; font-weight: 800; margin-right: 0.5rem; }
.action .rationale { color: var(--text3); font-size: 0.84rem; margin-top: 0.25rem; }
ul.bullets { padding-left: 1.2rem; }
ul.bullets li { margin: 0.4rem 0; color: var(--text2); }
.callout { border: 1px solid var(--amber); background: #fefce8; border-radius: 10px; padding: 0.9rem 1.1rem; }
[data-theme="dark"] .callout { background: #1f1a05; }
/* Progress / status */
.status-line { display: flex; align-items: center; gap: 0.7rem; padding: 1rem 1.25rem; border: 1px solid var(--border); border-radius: 10px; margin-bottom: 1.25rem; }
.spinner { width: 18px; height: 18px; border: 2px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin 0.8s linear infinite; flex-shrink: 0; }
@keyframes spin { to { transform: rotate(360deg); } }
/* Download actions */
.dl-actions { display: flex; gap: 0.75rem; flex-wrap: wrap; margin-top: 0.5rem; }
/* Footer */
footer { margin-top: 3rem; border-top: 1px solid var(--border); padding: 1.5rem 0 2.5rem; }
footer p { color: var(--text3); font-size: 0.85rem; }
footer strong { color: var(--text2); }
footer a { color: var(--accent); }
/* Utility */
.hidden { display: none !important; }
.muted { color: var(--text3); }
.mt-1 { margin-top: 0.5rem; } .mt-2 { margin-top: 1rem; } .mt-3 { margin-top: 1.5rem; }
.flex-between { display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; flex-wrap: wrap; }
main { flex: 1; }
@media (max-width: 640px) {
.score-hero-grid { grid-template-columns: 1fr; }
table.reviews th:nth-child(3), table.reviews td:nth-child(3),
table.reviews th:nth-child(5), table.reviews td:nth-child(5) { display: none; }
}
</style>
</head>
<body>
<header class="site-header">
<div class="container header-inner">
<a class="brand" href="#dashboard"><span class="mark">V</span> VerdictTank</a>
<div class="header-actions">
<span class="user-chip" id="userChip"></span>
<button class="btn btn-ghost btn-sm hidden" id="btnLogout">Sign out</button>
<button class="icon-btn" id="btnTheme" aria-label="Toggle theme" title="Toggle light / dark"></button>
</div>
</div>
</header>
<main>
<div class="container">
<!-- AUTH VIEW -->
<section id="view-auth" class="hidden">
<div class="auth-wrap">
<div class="auth-tabs">
<button class="auth-tab active" id="tabLogin" data-mode="login">Sign In</button>
<button class="auth-tab" id="tabRegister" data-mode="register">Create Account</button>
</div>
<div class="card">
<div class="form-error" id="authError"></div>
<form id="loginForm">
<div class="field">
<label for="loginEmail">Email</label>
<input type="email" id="loginEmail" autocomplete="email" required>
</div>
<div class="field">
<label for="loginPassword">Password</label>
<input type="password" id="loginPassword" autocomplete="current-password" required>
</div>
<button type="submit" class="btn btn-primary" style="width:100%" id="btnLogin">Sign In</button>
</form>
<form id="registerForm" class="hidden">
<div class="field">
<label for="regName">Full name</label>
<input type="text" id="regName" autocomplete="name" required>
</div>
<div class="field">
<label for="regEmail">Email</label>
<input type="email" id="regEmail" autocomplete="email" required>
</div>
<div class="field">
<label for="regPassword">Password</label>
<input type="password" id="regPassword" autocomplete="new-password" minlength="8" required>
<div class="hint">At least 8 characters.</div>
</div>
<button type="submit" class="btn btn-primary" style="width:100%" id="btnRegister">Create Account</button>
</form>
</div>
</div>
</section>
<!-- DASHBOARD VIEW -->
<section id="view-dashboard" class="hidden">
<div class="dash-head">
<div>
<h1>Your reviews</h1>
<div class="sub" id="dashSub">Review history for your account.</div>
</div>
<button class="btn btn-primary" id="btnNewReview">+ Start a review</button>
</div>
<div class="card" style="padding:0.5rem 1rem 1rem">
<div id="reviewsBody"></div>
</div>
</section>
<!-- SUBMIT VIEW -->
<section id="view-submit" class="hidden">
<div class="dash-head">
<div>
<h1>Submit a proposal</h1>
<div class="sub">Your document is reviewed by an independent panel of nine review providers.</div>
</div>
<button class="btn btn-secondary" id="btnSubmitBack">Back to reviews</button>
</div>
<div class="card">
<div class="form-error" id="submitError"></div>
<form id="submitForm">
<div class="field">
<label for="proposalName">Proposal name</label>
<input type="text" id="proposalName" required placeholder="e.g. Atlas fleet telematics platform">
</div>
<div class="field">
<label for="submitName">Your name</label>
<input type="text" id="submitName" required>
</div>
<div class="field">
<label for="submitEmail">Email</label>
<input type="email" id="submitEmail" required>
</div>
<div class="field">
<label>Review tier</label>
<div class="tier-grid" id="tierGrid"></div>
</div>
<div class="field">
<label for="proposalText">Proposal description</label>
<textarea id="proposalText" placeholder="Describe your proposal: the problem, market, model, and why it will work. The more detail you provide, the sharper the review."></textarea>
</div>
<div class="field">
<label for="proposalFile">Attach a document (optional)</label>
<input type="file" id="proposalFile" accept=".pdf,.docx,.txt,.md,.doc">
<div class="hint">PDF, DOCX, TXT, or Markdown.</div>
</div>
<div class="field">
<p class="muted" style="font-size:0.82rem">Your documents are processed by nine independent review providers. Eight of the nine do not train on your content. See the <a href="https://proposals.itpropartner.com/verdicttank/data-handling.html" rel="noopener">data-handling disclosure</a> for the one exception and our retention policy.</p>
</div>
<button type="submit" class="btn btn-primary" id="btnSubmit">Submit for review</button>
</form>
</div>
</section>
<!-- RESULTS VIEW -->
<section id="view-results" class="hidden">
<div class="dash-head">
<div>
<h1 id="resultsTitle">Review</h1>
<div class="sub" id="resultsMeta"></div>
</div>
<button class="btn btn-secondary" id="btnResultsBack">Back to reviews</button>
</div>
<div id="resultsBody"></div>
</section>
</div>
</main>
<footer>
<div class="container">
<p><strong>VerdictTank</strong>, a product of IT Pro Partner</p>
<p style="margin-top:0.5rem;">
<a href="https://verdicttank.com">VerdictTank</a> &middot;
<a href="https://itpropartner.com">IT Pro Partner</a>
</p>
</div>
</footer>
<script>
(function () {
'use strict';
var API_BASE = 'https://verdicttank.com/api/verdicttank';
var TIERS = [
{ key: 'free', label: 'Free Assessment', price: '$0' },
{ key: 'oneshot', label: 'One Shot Review', price: '$29' },
{ key: 'pro', label: 'Pro Review', price: '$119/mo' },
{ key: 'enterprise', label: 'Enterprise Review', price: '$699/mo' },
{ key: 'whitelabel', label: 'White Label Review', price: '$3,000/mo' }
];
var GROUPS = {
proposal_strength: {
label: 'Proposal Strength',
blurb: 'How well the document makes its case.',
keys: ['problem_clarity', 'market_reality', 'differentiation', 'business_model', 'evidence_quality']
},
investor_readiness: {
label: 'Investor Readiness',
blurb: 'How well the business survives scrutiny.',
keys: ['financial_credibility', 'execution_feasibility', 'team_capability', 'legal_risk', 'defensibility']
}
};
var VERDICT_BANDS = [
[80, 'STRONG', '#15803d'],
[65, 'PROMISING', '#65a30d'],
[50, 'CONDITIONAL', '#ca8a04'],
[35, 'WEAK', '#ea580c'],
[0, 'NOT VIABLE', '#b91c1c']
];
var state = {
token: localStorage.getItem('vt_token') || null,
user: safeJson(localStorage.getItem('vt_user'))
};
var pollTimer = null;
// ---------- helpers ----------
function safeJson(s) {
try { return s ? JSON.parse(s) : null; } catch (e) { return null; }
}
function esc(s) {
if (s === null || s === undefined) return '';
return String(s).replace(/[&<>"']/g, function (c) {
return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];
});
}
function $id(id) { return document.getElementById(id); }
function api(path, opts) {
opts = opts || {};
opts.headers = opts.headers || {};
if (state.token) opts.headers['Authorization'] = 'Bearer ' + state.token;
var isForm = !!(opts.body && opts.body instanceof FormData);
if (opts.body && !isForm) {
opts.headers['Content-Type'] = 'application/json';
opts.body = JSON.stringify(opts.body);
}
return fetch(API_BASE + path, opts).then(function (r) {
var ct = r.headers.get('content-type') || '';
var p = ct.indexOf('application/json') >= 0
? r.json()
: r.text().then(function (t) { return { _text: t }; });
return p.then(function (data) {
if (!r.ok) {
var msg = (data && data.detail) ? data.detail : ('Request failed (' + r.status + ')');
throw new Error(msg);
}
return data;
});
});
}
function bandFor(score) {
for (var i = 0; i < VERDICT_BANDS.length; i++) {
if (score >= VERDICT_BANDS[i][0]) return VERDICT_BANDS[i];
}
return VERDICT_BANDS[VERDICT_BANDS.length - 1];
}
function dimColor(v) {
if (v >= 8) return '#15803d';
if (v >= 6.5) return '#65a30d';
if (v >= 5) return '#ca8a04';
if (v >= 3.5) return '#ea580c';
return '#b91c1c';
}
// ---------- theme ----------
function initTheme() {
var saved = localStorage.getItem('theme');
if (saved === 'dark' || saved === 'light') {
document.documentElement.dataset.theme = saved;
} else if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
document.documentElement.dataset.theme = 'dark';
}
}
function toggleTheme() {
var next = document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark';
document.documentElement.dataset.theme = next;
localStorage.setItem('theme', next);
}
// ---------- view routing ----------
var VIEWS = ['auth', 'dashboard', 'submit', 'results'];
function showView(name) {
VIEWS.forEach(function (v) {
$id('view-' + v).classList.toggle('hidden', v !== name);
});
window.scrollTo(0, 0);
}
function renderHeader() {
var chip = $id('userChip');
var logout = $id('btnLogout');
if (state.user) {
chip.textContent = state.user.email || '';
logout.classList.remove('hidden');
} else {
chip.textContent = '';
logout.classList.add('hidden');
}
}
// ---------- auth ----------
function showAuthError(msg) { setError($id('authError'), msg); }
function setError(el, msg) {
if (!msg) { el.classList.remove('show'); el.textContent = ''; return; }
el.textContent = msg;
el.classList.add('show');
}
function onAuthed(data) {
state.token = data.token;
state.user = data.user;
localStorage.setItem('vt_token', data.token);
localStorage.setItem('vt_user', JSON.stringify(data.user));
renderHeader();
loadDashboard();
}
function doLogin(email, password) {
showAuthError(null);
api('/auth/login', { method: 'POST', body: { email: email, password: password } })
.then(onAuthed)
.catch(function (e) { showAuthError(e.message); });
}
function doRegister(name, email, password) {
showAuthError(null);
api('/auth/register', { method: 'POST', body: { email: email, password: password, name: name } })
.then(onAuthed)
.catch(function (e) { showAuthError(e.message); });
}
// ---------- dashboard ----------
function loadDashboard() {
showView('dashboard');
$id('dashSub').textContent = state.user ? ('Signed in as ' + state.user.email) : 'Review history for your account.';
var body = $id('reviewsBody');
body.innerHTML = '<p class="muted" style="padding:1rem">Loading reviews...</p>';
api('/reviews').then(function (data) {
var reviews = (data && data.reviews) || [];
if (!reviews.length) {
body.innerHTML = '<div class="empty-state"><h3>No reviews yet</h3><p>Submit your first proposal to get an independent panel review.</p><button class="btn btn-primary mt-2" id="btnEmptyNew">+ Start a review</button></div>';
var b = $id('btnEmptyNew'); if (b) b.addEventListener('click', renderSubmit);
return;
}
body.innerHTML = '<table class="reviews"><thead><tr><th>Proposal</th><th>Status</th><th>Score</th><th>Submitted</th><th></th></tr></thead><tbody>' +
reviews.map(function (r) {
var statusBadge = badgeFor(r.status);
var scoreCell = '';
if (r.status === 'complete' && typeof r.composite === 'number') {
var band = bandFor(r.composite);
scoreCell = '<span class="score-mini" style="color:' + band[2] + '">' + r.composite + '</span> / 100';
} else {
scoreCell = '<span class="muted">-</span>';
}
var when = formatDate(r.submitted_at);
return '<tr>' +
'<td><span class="prop-name">' + esc(r.proposal_name || 'Untitled') + '</span><br><span class="muted" style="font-size:0.78rem">' + esc(r.tier_label || '') + '</span></td>' +
'<td>' + statusBadge + '</td>' +
'<td>' + scoreCell + '</td>' +
'<td class="muted">' + esc(when) + '</td>' +
'<td style="text-align:right"><button class="btn btn-secondary btn-sm" data-view="' + esc(r.review_id) + '">View</button></td>' +
'</tr>';
}).join('') + '</tbody></table>';
body.querySelectorAll('[data-view]').forEach(function (b) {
b.addEventListener('click', function () { enterResults(b.getAttribute('data-view')); });
});
}).catch(function (e) {
body.innerHTML = '<div class="empty-state"><h3>Could not load reviews</h3><p>' + esc(e.message) + '</p></div>';
});
}
function badgeFor(status) {
var map = {
queued: 'badge-queued',
processing: 'badge-processing',
complete: 'badge-complete',
failed: 'badge-failed'
};
var cls = map[status] || 'badge-queued';
var label = (status || 'queued').charAt(0).toUpperCase() + (status || 'queued').slice(1);
return '<span class="badge ' + cls + '">' + label + '</span>';
}
function formatDate(iso) {
if (!iso) return '';
var d = new Date(iso);
if (isNaN(d.getTime())) return iso;
return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
}
// ---------- submit ----------
function renderSubmit() {
if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
showView('submit');
setError($id('submitError'), null);
$id('proposalName').value = '';
$id('proposalText').value = '';
$id('proposalFile').value = '';
if (state.user) {
$id('submitName').value = state.user.name || '';
$id('submitEmail').value = state.user.email || '';
}
var grid = $id('tierGrid');
grid.innerHTML = TIERS.map(function (t) {
return '<label class="tier-opt' + (t.key === 'free' ? ' selected' : '') + '">' +
'<input type="radio" name="tier" value="' + t.key + '"' + (t.key === 'free' ? ' checked' : '') + '>' +
'<div class="tier-name">' + esc(t.label) + '</div>' +
'<div class="tier-price">' + esc(t.price) + '</div>' +
'</label>';
}).join('');
grid.querySelectorAll('.tier-opt').forEach(function (opt) {
opt.addEventListener('click', function () {
grid.querySelectorAll('.tier-opt').forEach(function (o) { o.classList.remove('selected'); });
opt.classList.add('selected');
opt.querySelector('input').checked = true;
});
});
}
function doSubmit(form) {
setError($id('submitError'), null);
var name = $id('submitName').value.trim();
var email = $id('submitEmail').value.trim();
var proposalName = $id('proposalName').value.trim();
var proposalText = $id('proposalText').value.trim();
var fileInput = $id('proposalFile');
var tier = (form.querySelector('input[name="tier"]:checked') || {}).value || 'free';
if (!proposalName) { setError($id('submitError'), 'Proposal name is required.'); return; }
if (!name) { setError($id('submitError'), 'Your name is required.'); return; }
if (!email) { setError($id('submitError'), 'A valid email address is required.'); return; }
if (!proposalText && !(fileInput.files && fileInput.files.length)) {
setError($id('submitError'), 'Provide a proposal description or attach a document.'); return;
}
var fd = new FormData();
fd.append('name', name);
fd.append('email', email);
fd.append('proposal_name', proposalName);
fd.append('proposal_text', proposalText);
fd.append('tier', tier);
if (fileInput.files && fileInput.files.length) {
fd.append('proposal_file', fileInput.files[0]);
}
var btn = $id('btnSubmit');
btn.disabled = true;
btn.textContent = 'Submitting...';
api('/submit', { method: 'POST', body: fd })
.then(function (res) {
enterResults(res.review_id);
})
.catch(function (e) {
setError($id('submitError'), e.message);
btn.disabled = false;
btn.textContent = 'Submit for review';
});
}
// ---------- results ----------
function enterResults(reviewId) {
if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
showView('results');
$id('resultsTitle').textContent = 'Review ' + reviewId;
$id('resultsMeta').textContent = '';
$id('resultsBody').innerHTML = '<div class="status-line"><div class="spinner"></div><span>Loading review status...</span></div>';
pollReview(reviewId);
}
function pollReview(reviewId) {
function tick() {
api('/status/' + reviewId).then(function (s) {
if (s.status === 'complete') {
if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
loadResults(reviewId);
} else if (s.status === 'failed') {
if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
renderFailed(reviewId);
} else {
renderProgress(s);
}
}).catch(function (e) {
$id('resultsBody').innerHTML = '<div class="status-line"><div class="spinner"></div><span>Could not reach the review service. Retrying...</span></div>';
});
}
tick();
if (pollTimer) { clearInterval(pollTimer); }
pollTimer = setInterval(tick, 8000);
}
function renderProgress(s) {
var progress = s.progress || 'Waiting in queue...';
$id('resultsBody').innerHTML =
'<div class="status-line"><div class="spinner"></div><span>' + esc(progress) + '</span></div>' +
'<p class="muted">The panel is reviewing your proposal. This page updates automatically; you can leave and come back from your dashboard.</p>';
}
function renderFailed(reviewId) {
$id('resultsBody').innerHTML =
'<div class="status-line"><span class="badge badge-failed">Failed</span><span>The review could not be completed.</span></div>' +
'<button class="btn btn-secondary" onclick="window.location.reload()">Retry</button>';
}
function loadResults(reviewId) {
api('/results/' + reviewId).then(function (r) {
renderResults(r);
}).catch(function (e) {
$id('resultsBody').innerHTML = '<div class="status-line"><span>Could not load results: ' + esc(e.message) + '</span></div>';
});
}
function renderResults(r) {
$id('resultsTitle').textContent = r.proposal_name || ('Review ' + r.review_id);
$id('resultsMeta').textContent = (r.tier_label || '') + ' &middot; submitted ' + formatDate(r.submitted_at);
var composite = (typeof r.composite === 'number') ? r.composite : 0;
var band = bandFor(composite);
var verdictHtml = '<div class="verdict-banner">' +
'<div><span class="verdict-flag" style="background:' + band[2] + '">' + esc(r.verdict || band[1]) + '</span>' +
' <span class="muted" style="margin-left:0.4rem;font-size:0.85rem">Composite score</span></div>' +
'<div class="verdict-composite" style="color:' + band[2] + '">' + composite + ' <span class="muted" style="font-size:0.85rem;font-weight:600">/ 100</span></div>' +
'</div>';
var scoreCards = '<div class="score-hero-grid">' +
scoreCard(r.proposal_strength, GROUPS.proposal_strength.label, GROUPS.proposal_strength.blurb) +
scoreCard(r.investor_readiness, GROUPS.investor_readiness.label, GROUPS.investor_readiness.blurb) +
'</div>';
var dimsByKey = {};
(r.dimensions || []).forEach(function (d) { dimsByKey[d.key] = d; });
var dimSections = Object.keys(GROUPS).map(function (g) {
var group = GROUPS[g];
var rows = group.keys.map(function (k) {
var d = dimsByKey[k];
var label = (d && d.label) || k;
var v = (d && typeof d.score === 'number') ? d.score : 0;
var c = dimColor(v);
return '<tr><td>' + esc(label) + '</td>' +
'<td class="dim-score" style="color:' + c + '">' + v + ' / 10</td>' +
'<td style="width:40%"><div class="bar-track"><div class="bar-fill" style="width:' + Math.max(2, v * 10) + '%;background:' + c + '"></div></div></td></tr>';
}).join('');
return '<div class="section"><h2>' + esc(group.label) + '</h2>' +
'<table class="dims"><tbody>' + rows + '</tbody></table></div>';
}).join('');
var summaryHtml = '';
if (r.executive_summary) {
summaryHtml = '<div class="section"><h2>Executive Summary</h2><div class="prose"><p>' + esc(r.executive_summary) + '</p></div></div>';
}
if (r.verdict_rationale) {
summaryHtml += '<div class="section"><h2>Verdict Rationale</h2><div class="prose"><p>' + esc(r.verdict_rationale) + '</p></div></div>';
}
var flawsHtml = '';
var flaws = r.fatal_flaws || [];
if (flaws.length) {
flawsHtml = '<div class="section"><h2>Key Flaws</h2>' + flaws.map(function (f) {
var sev = String(f.severity || 'major').toLowerCase();
sev = ['fatal', 'major', 'moderate', 'minor'].indexOf(sev) >= 0 ? sev : 'major';
var why = f.why ? '<div class="why">' + esc(f.why) + '</div>' : '';
var fix = f.fix ? '<div class="fix">Corrective action: ' + esc(f.fix) + '</div>' : '';
return '<div class="flaw"><span class="sev sev-' + sev + '">' + sev + '</span>' +
'<span class="issue">' + esc(f.issue || '') + '</span>' + why + fix + '</div>';
}).join('') + '</div>';
}
var actions = r.action_plan || [];
var actionsHtml = '';
if (actions.length) {
actionsHtml = '<div class="section"><h2>Priority Action Plan</h2>' + actions.map(function (a, i) {
var rationale = a.rationale ? '<div class="rationale">' + esc(a.rationale) + '</div>' : '';
return '<div class="action"><span class="n">' + (i + 1) + '</span><strong>' + esc(a.action || '') + '</strong>' + rationale + '</div>';
}).join('') + '</div>';
}
var consensus = r.consensus || [];
var consensusHtml = '';
if (consensus.length) {
consensusHtml = '<div class="section"><h2>Panel Consensus</h2><ul class="bullets">' +
consensus.map(function (c) { return '<li>' + esc(c) + '</li>'; }).join('') + '</ul></div>';
}
var kills = r.kill_criteria || [];
var killsHtml = '';
if (kills.length) {
killsHtml = '<div class="section"><h2>Kill Criteria</h2><div class="callout">' +
'<p style="font-size:0.88rem;color:var(--text2)">If any of the following becomes true, the panel advises abandoning this proposal in its current form rather than continuing to invest in it.</p>' +
'<ul class="bullets" style="margin-top:0.5rem">' +
kills.map(function (k) { return '<li>' + esc(k) + '</li>'; }).join('') + '</ul></div>';
}
var reportUrl = 'https://verdicttank.com/api/verdicttank/report/' + encodeURIComponent(r.review_id);
var receiptUrl = 'https://verdicttank.com/api/receipt/' + encodeURIComponent(r.review_id);
var dlHtml = '<div class="section"><h2>Documents</h2><div class="dl-actions">' +
'<a class="btn btn-primary btn-sm" href="' + reportUrl + '" target="_blank" rel="noopener">Download report (PDF)</a>' +
'<a class="btn btn-secondary btn-sm" href="' + receiptUrl + '" target="_blank" rel="noopener">Download receipt (PDF)</a>' +
'</div></div>';
$id('resultsBody').innerHTML =
verdictHtml + scoreCards + summaryHtml + dimSections + flawsHtml + actionsHtml + consensusHtml + killsHtml + dlHtml;
}
function scoreCard(score, label, blurb) {
var s = (typeof score === 'number') ? score : 0;
var c = bandFor(s)[2];
return '<div class="score-card">' +
'<div class="num" style="color:' + c + '">' + s + '<span> / 100</span></div>' +
'<div class="lbl">' + esc(label) + '</div>' +
'<div class="blurb">' + esc(blurb) + '</div>' +
'</div>';
}
// ---------- wiring ----------
function init() {
initTheme();
renderHeader();
$id('btnTheme').addEventListener('click', toggleTheme);
// auth tabs
var tabs = { login: $id('tabLogin'), register: $id('tabRegister') };
Object.keys(tabs).forEach(function (mode) {
tabs[mode].addEventListener('click', function () {
Object.keys(tabs).forEach(function (m) { tabs[m].classList.toggle('active', m === mode); });
$id('loginForm').classList.toggle('hidden', mode !== 'login');
$id('registerForm').classList.toggle('hidden', mode !== 'register');
setError($id('authError'), null);
});
});
$id('loginForm').addEventListener('submit', function (e) {
e.preventDefault();
doLogin($id('loginEmail').value.trim(), $id('loginPassword').value);
});
$id('registerForm').addEventListener('submit', function (e) {
e.preventDefault();
doRegister($id('regName').value.trim(), $id('regEmail').value.trim(), $id('regPassword').value);
});
$id('btnLogout').addEventListener('click', function () {
state.token = null;
state.user = null;
localStorage.removeItem('vt_token');
localStorage.removeItem('vt_user');
if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
renderHeader();
showView('auth');
});
$id('btnNewReview').addEventListener('click', renderSubmit);
$id('btnSubmitBack').addEventListener('click', loadDashboard);
$id('btnResultsBack').addEventListener('click', loadDashboard);
$id('submitForm').addEventListener('submit', function (e) {
e.preventDefault();
doSubmit(e.target);
});
// initial route
if (state.token && state.user) {
loadDashboard();
} else {
showView('auth');
}
}
document.addEventListener('DOMContentLoaded', init);
})();
</script>
</body>
</html>