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:
@@ -42,3 +42,5 @@ credentials.json
|
|||||||
# Project-specific
|
# Project-specific
|
||||||
*.db-journal
|
*.db-journal
|
||||||
*.db-wal
|
*.db-wal
|
||||||
|
# Demo credentials (never commit)
|
||||||
|
demo-creds*.txt
|
||||||
|
|||||||
+537
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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
@@ -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> ·
|
||||||
|
<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 { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 || '') + ' · 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>
|
||||||
Reference in New Issue
Block a user