diff --git a/TODO.md b/TODO.md index c0983a6..f85e78b 100644 --- a/TODO.md +++ b/TODO.md @@ -2,18 +2,14 @@ ## High Impact, Moderate Effort – Do Next -- [ ] Agent personas for chat — per-project `system_prompt` in settings + per-section `agent_prompt` override. Ship 5-6 presets (PRD Architect, Technical Reviewer, UX/Design, QA Strategist, Executive Summary). Users pick preset or write custom. Passed as system message to the chat provider. Section-level prompt overrides project-level when chatting in that section context. -- [ ] Section templates — offer project templates beyond blank/SnapHabit: "SaaS MVP", "API Design", "Mobile App", "Infrastructure Migration". Each is a seed SQL or importable markdown showcasing different dependency patterns. -- [ ] add notes (accordion) to section. Here, I would like the option to add a note to the entire section. -- [ ] We need to update the readme relative to all our recent changes, create a preview as we did through the playwrite, and make it as commercially product-successful as possible to attract potential users' attention. +- [ ] Agent personas for chat — per-project `system_prompt` in settings + per-section `agent_prompt` override. Ship 5-6 presets (PRD Architect, Technical Reviewer, UX/Design, QA Strategist, Executive Summary). Users pick preset or write custom. Passed as system message to the chat provider. Section-level prompt overrides project-level when chatting in that section context. *(Base system prompt exists in `_chat_system_prompt`, needs UI + per-project/section override)* ## Medium Impact, Lower Effort – Quick Wins - [ ] CONTRIBUTING.md — how to run tests locally, code style expectations, PR process - [ ] GitHub repo metadata — description, topics/tags (`mcp`, `claude`, `prd`, `product-requirements`, `ai-tools`, `developer-tools`, `mcp-server`), website URL - [ ] `prd_diff_sections` tool — unified diff between two revisions of a section, avoids loading both and diffing manually -- [ ] ui playwrite tests -- [ ] add playwrite preview autoupdate (ci for pr + agents.md for agents instaction which sections observe) +- [ ] UI Playwright tests ## Medium Impact, Higher Effort – Plan For These @@ -34,10 +30,9 @@ - [ ] Add `prd_merge_sections` tool (combine two sections into one) - [ ] Add `prd_reorder_sections` tool (bulk sort_order update) - [ ] Export as PDF via headless browser -- [ ] UI: keyboard navigation (j/k to move through sections, Enter to select) - [ ] MCP auth for remote Claude clients (SSH tunnel or authenticated ingress) -- [ ] Redis jti uniqueness for WS tokens (SET NX EX — currently TODO in code) - [ ] move to tasks github projects +- [ ] add Playwright preview autoupdate (CI for PR + agents.md for agent instructions on which sections to observe) ## Done @@ -88,3 +83,8 @@ - [x] Org-level encrypted API key — Fernet/AES-256 - [x] Wider chat section — 40% viewport width, min 480px, max 700px - [x] Honest token savings math — section_access_log with session-based dedup, coverage fractions (full/summary/snippet), 30-min session windowing +- [x] Section templates — SaaS MVP, Mobile App project templates with seeded sections and dependencies +- [x] Notes accordion — per-section collapsible notes with inline editing +- [x] README + Playwright demo preview — `scripts/record_demo.py` rewritten, demo.gif regenerated +- [x] Redis jti uniqueness for WS tokens — SET NX EX replay protection in websocket handler +- [x] Security hardening — member endpoint auth, ws-token auth from session, schema-qualified `to_regclass`, default secret warnings diff --git a/api/app.py b/api/app.py index 40ddb16..c8f5f3c 100644 --- a/api/app.py +++ b/api/app.py @@ -2463,7 +2463,7 @@ async def add_project_member(slug: str, request: Request): VALUES ($1, $2, $3) ON CONFLICT (project_id, user_id) DO UPDATE SET role = EXCLUDED.role RETURNING id, user_id, role, created_at - """, proj["id"], _uuid.UUID(user_id), role) + """, proj["id"], user_id, role) return row_dict(row) except Exception: return JSONResponse({"error": "internal error"}, 500) @@ -2484,7 +2484,7 @@ async def remove_project_member(slug: str, user_id: str, request: Request): return JSONResponse({"error": f"project '{slug}' not found"}, 404) result = await pool.execute( "DELETE FROM project_members WHERE project_id = $1 AND user_id = $2", - proj["id"], _uuid.UUID(user_id), + proj["id"], user_id, ) removed = result.split()[-1] != "0" return {"removed": removed} diff --git a/db/07_multi_user.sql b/db/07_multi_user.sql index ff3ac96..d8a98e9 100644 --- a/db/07_multi_user.sql +++ b/db/07_multi_user.sql @@ -16,7 +16,7 @@ DO $$ BEGIN SELECT 1 FROM information_schema.columns WHERE table_name = 'projects' AND column_name = 'created_by' ) THEN - ALTER TABLE projects ADD COLUMN created_by UUID; + ALTER TABLE projects ADD COLUMN created_by TEXT; END IF; END $$; @@ -25,7 +25,7 @@ DO $$ BEGIN SELECT 1 FROM information_schema.columns WHERE table_name = 'sections' AND column_name = 'updated_by' ) THEN - ALTER TABLE sections ADD COLUMN updated_by UUID; + ALTER TABLE sections ADD COLUMN updated_by TEXT; END IF; END $$; @@ -34,7 +34,7 @@ DO $$ BEGIN SELECT 1 FROM information_schema.columns WHERE table_name = 'section_revisions' AND column_name = 'created_by' ) THEN - ALTER TABLE section_revisions ADD COLUMN created_by UUID; + ALTER TABLE section_revisions ADD COLUMN created_by TEXT; END IF; END $$; @@ -43,7 +43,7 @@ DO $$ BEGIN SELECT 1 FROM information_schema.columns WHERE table_name = 'section_comments' AND column_name = 'created_by' ) THEN - ALTER TABLE section_comments ADD COLUMN created_by UUID; + ALTER TABLE section_comments ADD COLUMN created_by TEXT; END IF; END $$; @@ -52,7 +52,7 @@ DO $$ BEGIN SELECT 1 FROM information_schema.columns WHERE table_name = 'chat_messages' AND column_name = 'created_by' ) THEN - ALTER TABLE chat_messages ADD COLUMN created_by UUID; + ALTER TABLE chat_messages ADD COLUMN created_by TEXT; END IF; END $$; @@ -61,7 +61,7 @@ DO $$ BEGIN SELECT 1 FROM information_schema.columns WHERE table_name = 'mcp_activity' AND column_name = 'user_id' ) THEN - ALTER TABLE mcp_activity ADD COLUMN user_id UUID; + ALTER TABLE mcp_activity ADD COLUMN user_id TEXT; END IF; END $$; @@ -69,7 +69,7 @@ END $$; CREATE TABLE IF NOT EXISTS project_members ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE, - user_id UUID NOT NULL, + user_id TEXT NOT NULL, role TEXT NOT NULL CHECK (role IN ('owner', 'admin', 'editor', 'commenter', 'viewer')), created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), diff --git a/demo.gif b/demo.gif index 7e14704..0ac63e2 100644 Binary files a/demo.gif and b/demo.gif differ diff --git a/scripts/record_demo.py b/scripts/record_demo.py index 9555f24..de7db57 100644 --- a/scripts/record_demo.py +++ b/scripts/record_demo.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Record a ~45s demo video of PRDforge Web UI. +"""Record a ~30s demo video of PRDforge Web UI. Prerequisites: docker compose up -d @@ -9,14 +9,11 @@ Usage: python scripts/record_demo.py - # With custom credentials - DEMO_EMAIL=admin@example.com DEMO_PASSWORD=secret python scripts/record_demo.py - Output: demo.webm in the project root. Convert to GIF (requires ffmpeg): - ffmpeg -i demo.webm -vf \ + ffmpeg -y -i demo.webm -vf \ "fps=12,scale=960:-1:flags=lanczos,split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse" \ demo.gif """ @@ -40,62 +37,95 @@ WIDTH, HEIGHT = 1440, 900 -def bootstrap_user(page): - """Ensure demo user exists via /api/auth/setup (idempotent).""" - result = page.evaluate(f""" - fetch('/api/auth/setup', {{ - method: 'POST', - headers: {{'Content-Type': 'application/json'}}, - body: JSON.stringify({{ - name: '{DEMO_NAME}', - email: '{DEMO_EMAIL}', - password: '{DEMO_PASSWORD}' - }}) - }}).then(r => ({{ status: r.status, ok: r.ok }})) +def api(page, method, path, body=None): + """Browser-context fetch helper (inherits session cookie).""" + js_body = f", body: '{body}'" if body else "" + return page.evaluate(f""" + fetch('{path}', {{ + method: '{method}', + headers: {{'Content-Type': 'application/json'}} + {js_body} + }}).then(r => r.json().then(d => ({{status: r.status, ...d}})).catch(() => ({{status: r.status}}))) """) - status = result.get("status", 0) + + +def bootstrap_user(page): + r = api(page, "POST", "/api/auth/setup", + f'{{"name":"{DEMO_NAME}","email":"{DEMO_EMAIL}","password":"{DEMO_PASSWORD}"}}') + status = r.get("status", 0) if status not in (200, 409): print(f"ERROR: /api/auth/setup returned {status}. Stack may not be running.") sys.exit(1) - print(f"Bootstrap: status={status} ({'created' if status == 200 else 'already exists'})") + print(f"Bootstrap: {'created' if status == 200 else 'exists'}") def sign_in(page): - """Sign in via the UI form.""" page.goto(f"{BASE_URL}/signin") - page.fill("#email", DEMO_EMAIL) + page.wait_for_timeout(400) + page.fill("#email", DEMO_EMAIL, timeout=3000) page.fill("#password", DEMO_PASSWORD) + page.wait_for_timeout(200) page.click('button[type="submit"]') - - # Wait for redirect to /projects page.wait_for_url("**/projects", timeout=10_000) - print("Signed in successfully") + print("Signed in") def ensure_demo_project(page): - """Create demo project with saas-mvp template (ignore if slug exists).""" - result = page.evaluate(f""" - fetch('/api/projects', {{ - method: 'POST', - headers: {{'Content-Type': 'application/json'}}, - body: JSON.stringify({{ - name: 'PRDforge Demo', - slug: '{DEMO_PROJECT_SLUG}', - description: 'Demo SaaS MVP project', - template_id: 'saas-mvp' - }}) - }}).then(r => ({{ status: r.status, ok: r.ok }})) - """) - status = result.get("status", 0) - if status in (200, 201, 409): - print(f"Demo project: status={status}") - else: - print(f"WARNING: create project returned {status}") + r = api(page, "POST", "/api/projects", + f'{{"name":"PRDforge Demo","slug":"{DEMO_PROJECT_SLUG}",' + '"description":"SaaS MVP requirements — AI-assisted editing","template_id":"saas-mvp"}') + print(f"Project: status={r.get('status')}") + + +def seed_comments(page): + comments = [ + ("overview", "Describe the product vision", + "product vision and the problem", + "We should tighten the vision statement — needs a one-liner elevator pitch."), + ("tech-stack", "Describe the high-level system", + "high-level system architecture", + "Should we commit to a monorepo or separate frontend/backend repos from day one?"), + ("api-design", "POST /api/auth/login", + "### Authentication", + "Rate limiting on login? 5 attempts per minute per IP seems reasonable."), + ] + ids = [] + for slug, anchor, prefix, body in comments: + r = api(page, "POST", + f"/api/projects/{DEMO_PROJECT_SLUG}/sections/{slug}/comments", + f'{{"anchor_text":"{anchor}","anchor_prefix":"{prefix}","body":"{body}"}}') + cid = r.get("id") + if cid: + ids.append((slug, cid)) + print(f"Seeded {len(ids)} comments") + return ids + + +def cleanup_comments(page, comment_ids): + for slug, cid in comment_ids: + api(page, "DELETE", + f"/api/projects/{DEMO_PROJECT_SLUG}/sections/{slug}/comments/{cid}") + + +def click_sidebar(page, text): + btn = page.locator("nav button").filter(has_text=text).first + if btn.is_visible(timeout=2000): + btn.click() + return True + return False + + +def click_tab(page, value): + tab = page.locator(f'[value="{value}"]').first + if tab.is_visible(timeout=2000): + tab.click() + return True + return False def main(): video_dir = tempfile.mkdtemp(prefix="prdforge-demo-") - demo_comment_id = None + comment_ids = [] with sync_playwright() as p: browser = p.chromium.launch() @@ -106,131 +136,88 @@ def main(): ) page = context.new_page() - # ── Setup ────────────────────────────────────────────────── + # ── Pre-recording setup ────────────────────────────────────── page.goto(BASE_URL) - page.wait_for_timeout(1000) + page.wait_for_timeout(500) bootstrap_user(page) - # ── Scene 1: Sign in (0-4s) ─────────────────────────────── + # ── Sign in fast ───────────────────────────────────────────── sign_in(page) - page.wait_for_timeout(1500) + page.wait_for_timeout(300) + # Create project after sign-in (needs auth cookie) ensure_demo_project(page) - page.wait_for_timeout(500) - # ── Scene 2: Projects list → click demo project (4-7s) ─── - page.reload() - page.wait_for_timeout(1500) + # ── Projects list (brief) ──────────────────────────────────── + page.goto(f"{BASE_URL}/projects") + page.wait_for_timeout(1200) - # Click the demo project card + # Enter demo project card = page.locator("text=PRDforge Demo").first - if card.is_visible(): + if card.is_visible(timeout=3000): card.click() else: page.goto(f"{BASE_URL}/projects/{DEMO_PROJECT_SLUG}") - page.wait_for_timeout(2000) - - # ── Scene 3: Browse sections (7-16s) ────────────────────── - sidebar_buttons = page.locator("nav button") - - # Click through sidebar sections - for section_text in ["Tech Stack", "Data Model", "API Design"]: - btn = sidebar_buttons.filter(has_text=section_text).first - if btn.is_visible(): - btn.click() - page.wait_for_timeout(2000) - - # ── Scene 4: Select text → comment form (16-22s) ────────── - # Create a comment via API for demo purposes - demo_comment_id = page.evaluate(f""" - fetch('/api/projects/{DEMO_PROJECT_SLUG}/sections/api-design/comments', {{ - method: 'POST', - headers: {{'Content-Type': 'application/json'}}, - body: JSON.stringify({{ - anchor_text: 'POST /api/auth/login', - anchor_prefix: '### `', - anchor_suffix: '`', - body: 'Should we add rate limiting to the login endpoint?' - }}) - }}).then(r => r.json()).then(d => d.id || null).catch(() => null) - """) - page.wait_for_timeout(500) + page.wait_for_timeout(1500) - # Reload section to show comment - btn = sidebar_buttons.filter(has_text="API Design").first - if btn.is_visible(): - btn.click() - page.wait_for_timeout(2500) + # Seed comments + comment_ids = seed_comments(page) - # ── Scene 5: Dependencies tab (22-28s) ──────────────────── - deps_tab = page.locator('[value="deps"]').first - if deps_tab.is_visible(): - deps_tab.click() - page.wait_for_timeout(4000) - - # ── Scene 6: Stats tab (28-33s) ─────────────────────────── - stats_tab = page.locator('[value="stats"]').first - if stats_tab.is_visible(): - stats_tab.click() - page.wait_for_timeout(3000) - - # Back to sections - sections_tab = page.locator('[value="sections"]').first - if sections_tab.is_visible(): - sections_tab.click() - page.wait_for_timeout(1000) - - # ── Scene 7: Chat panel (33-39s) ────────────────────────── - chat_tab = page.locator('[value="chat"]').first - if chat_tab.is_visible(): - chat_tab.click() - page.wait_for_timeout(1500) - - # Type a message (don't send — just show the composer) - chat_input = page.locator('textarea[placeholder]').first - if chat_input.is_visible(): - chat_input.fill("What sections need the most attention?") - page.wait_for_timeout(2000) - chat_input.fill("") - - # Back to sections - if sections_tab.is_visible(): - sections_tab.click() - page.wait_for_timeout(1000) - - # ── Scene 8: Theme toggle (39-43s) ──────────────────────── + # ── Switch to light mode immediately ───────────────────────── theme_btn = page.locator('button[aria-label="Toggle theme"]').first - if theme_btn.is_visible(): - theme_btn.click() - page.wait_for_timeout(2000) + if theme_btn.is_visible(timeout=1500): theme_btn.click() - page.wait_for_timeout(1500) + page.wait_for_timeout(800) + + # ── Browse a couple of sections ────────────────────────────── + # Product Overview is auto-selected, pause briefly + page.wait_for_timeout(1200) + + click_sidebar(page, "Tech Stack") + page.wait_for_timeout(1500) + + click_sidebar(page, "Data Model") + page.wait_for_timeout(1500) + + # ── Comments tab ───────────────────────────────────────────── + click_tab(page, "comments") + page.wait_for_timeout(2500) + + # ── Dependencies tab ───────────────────────────────────────── + click_tab(page, "dependencies") + page.wait_for_timeout(3000) + + # ── Changelog tab ──────────────────────────────────────────── + click_tab(page, "changelog") + page.wait_for_timeout(2500) + + # ── Stats tab ──────────────────────────────────────────────── + click_tab(page, "stats") + page.wait_for_timeout(2500) + + # ── Settings page ──────────────────────────────────────────── + settings_btn = page.locator("text=Settings").last + if settings_btn.is_visible(timeout=1500): + settings_btn.click() + page.wait_for_timeout(2500) - # ── Scene 9: Final hold (43-46s) ────────────────────────── - page.wait_for_timeout(2000) + # ── Final hold ─────────────────────────────────────────────── + page.wait_for_timeout(800) - # ── Cleanup: delete demo comment ─────────────────────────── - if demo_comment_id: - page.evaluate(f""" - fetch('/api/projects/{DEMO_PROJECT_SLUG}/sections/api-design/comments/{demo_comment_id}', {{ - method: 'DELETE' - }}) - """) + # ── Cleanup ────────────────────────────────────────────────── + cleanup_comments(page, comment_ids) - # Finalize video video_path = page.video.path() context.close() browser.close() - # Copy video to project root shutil.copy2(video_path, OUTPUT) shutil.rmtree(video_dir, ignore_errors=True) print(f"\nDemo video saved to: {OUTPUT}") - print() - print("Convert to GIF with:") print( - ' ffmpeg -i demo.webm -vf "fps=12,scale=960:-1:flags=lanczos,' + '\nConvert to GIF:\n' + ' ffmpeg -y -i demo.webm -vf "fps=12,scale=960:-1:flags=lanczos,' 'split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse" demo.gif' )