Our current content review workflow uses Google Docs in a shared Drive folder. It has too much friction:
The review portal solves all of this by putting the review workflow inside our own infrastructure where all three participants have clean, purpose-built interfaces.
Kristi's experience should be: open a link on her phone, read content, tap to approve or leave a note. That's it. No folder management. No mode switching. No learning curve.
Three users interact with this system. Each has a different access pattern.
| User | Access Method | What They Do |
|---|---|---|
| Charlotte (COO agent) | REST API endpoints with admin JWT, or browser UI | Submit content for review, read Kristi's feedback, update content after changes |
| Kristi (Reviewer) | Magic link (unique token URL, no password) | View pending items, approve/flag content, leave inline comments |
| Jason (Founder) | Admin dashboard (existing admin auth) | See review status dashboard, override decisions if needed |
Kristi is not a Stylify user — she doesn't have an account and shouldn't need one. Instead:
reviewers table stores her email and a unique access token (UUID v4).stylify-ai.com/review?token=abc123...Kristi reviews on her phone. A magic link she bookmarks once is zero-friction. Adding a password adds a login screen, a "forgot password" flow, and a barrier every time she opens the link. For an internal reviewer with one user, a long-lived token URL is the right tradeoff.
Two new tables. Follows the existing pattern: BIGINT auto-incrementing IDs, timestamps, foreign keys to users table where applicable.
content_reviews — The Review Items-- Migration: 022_content_review_portal.sql
-- Run one statement at a time in Supabase SQL Editor
CREATE TABLE content_reviews (
id BIGSERIAL PRIMARY KEY,
title TEXT NOT NULL, -- e.g. "Email 1: Welcome + Playbook"
content_type TEXT NOT NULL, -- 'email' | 'landing_page' | 'scorecard' | 'lead_magnet' | 'instagram' | 'dm_template' | 'legal' | 'other'
content_body TEXT NOT NULL, -- The full content (plain text or markdown)
context_brief TEXT, -- "What is this? Why does it matter?" for the reviewer
status TEXT NOT NULL DEFAULT 'pending', -- 'pending' | 'approved' | 'needs_changes' | 'revised' | 'archived'
version INTEGER NOT NULL DEFAULT 1, -- Increments on each revision
parent_id BIGINT REFERENCES content_reviews(id), -- Links revised versions to original
submitted_by TEXT NOT NULL DEFAULT 'charlotte', -- Which agent submitted this
expert_score NUMERIC(3,1), -- Expert Panel average score (e.g. 7.8)
brand_rules TEXT, -- Relevant brand rules shown to reviewer
live_url TEXT, -- Link to see it live (if applicable)
reviewed_by TEXT, -- Reviewer name (filled on approval/rejection)
reviewed_at TIMESTAMPTZ, -- When the decision was made
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_content_reviews_status ON content_reviews(status);
CREATE INDEX idx_content_reviews_type ON content_reviews(content_type);
review_comments — Inline FeedbackCREATE TABLE review_comments (
id BIGSERIAL PRIMARY KEY,
review_id BIGINT NOT NULL REFERENCES content_reviews(id) ON DELETE CASCADE,
author TEXT NOT NULL, -- 'kristi' | 'jason' | 'charlotte'
comment TEXT NOT NULL, -- The feedback text
section_ref TEXT, -- Which section this comment is about (e.g. "Q3" or "CTA headline")
original_text TEXT, -- The specific text being commented on (for inline edits)
suggested_text TEXT, -- The replacement text (if suggesting a rewrite)
resolved BOOLEAN DEFAULT FALSE, -- Charlotte marks resolved after incorporating
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_review_comments_review ON review_comments(review_id);
reviewers — Reviewer Access TokensCREATE TABLE reviewers (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL, -- 'Kristi'
email TEXT UNIQUE NOT NULL, -- 'kristiwait@hotmail.com'
token TEXT UNIQUE NOT NULL, -- UUID v4, used in URL
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Seed Kristi's reviewer record
INSERT INTO reviewers (name, email, token)
VALUES ('Kristi', 'kristiwait@hotmail.com', gen_random_uuid()::text);
When approved content is edited, Charlotte creates a new content_reviews row with parent_id pointing to the original and version incremented. The original's status changes to 'revised'. Kristi sees the new version in her queue automatically. The parent_id chain gives us full version history.
New route file: backend/src/routes/reviewRoutes.js
These endpoints use the reviewer token from the URL query parameter. Middleware: authenticateReviewer — looks up token in reviewers table, attaches reviewer info to req.reviewer.
| Method | Endpoint | What It Does |
|---|---|---|
| GET | /api/reviews?token=xxx |
List all reviews. Default: pending first, then needs_changes, then approved. Returns: id, title, content_type, status, version, expert_score, created_at, comment_count. |
| GET | /api/reviews/:id?token=xxx |
Full review detail: all fields from content_reviews + all comments for this review. |
| POST | /api/reviews/:id/approve?token=xxx |
Sets status='approved', reviewed_by=reviewer name, reviewed_at=now(). Body: optional { note: "..." } |
| POST | /api/reviews/:id/request-changes?token=xxx |
Sets status='needs_changes', reviewed_by, reviewed_at. Body: required { note: "..." } |
| POST | /api/reviews/:id/comments?token=xxx |
Add a comment. Body: { comment, section_ref?, original_text?, suggested_text? } |
| Method | Endpoint | What It Does |
|---|---|---|
| POST | /api/admin/reviews |
Submit new content for review. Body: { title, content_type, content_body, context_brief, expert_score, brand_rules, live_url, submitted_by } |
| PUT | /api/admin/reviews/:id |
Update content (creates new version if status was 'approved'). Body: same as POST. |
| GET | /api/admin/reviews/dashboard |
Returns counts by status: { pending: N, approved: N, needs_changes: N, total: N } |
| POST | /api/admin/reviews/:id/comments/:commentId/resolve |
Mark a comment as resolved (after Charlotte incorporates the feedback). |
| POST | /api/admin/reviewers/rotate-token |
Regenerate a reviewer's token (security — invalidates old URL). Body: { email } |
Route: /review (public route, token-authenticated via query param)
Mobile-first. Kristi reviews on her phone. Every element must be thumb-friendly: large tap targets, no hover states required, clear visual hierarchy. The desktop version is just a wider version of the mobile layout — not a separate design.
Visual design notes:
Detail view features:
live_url is set — opens the actual page in a new tab so she can see it in context.section_ref is auto-filled based on the nearest section header.original_text. She can type a suggested replacement (suggested_text) or just a note. This replaces Google Docs' Suggesting mode.brand_rules is set, show a collapsible "Brand Rules" panel so Kristi can reference them while reviewing.Comment features:
original_text.suggested_text and knows exactly what to change.Route: /admin/reviews (uses existing admin auth — adminAuth middleware)
This is a simple dashboard panel. Could be a new tab on the existing Six Engine Command Center, or a standalone page. Minimal build — just shows the status at a glance.
Charlotte (via browser automation) can use both the admin UI and the API endpoints. For submitting content, the admin UI provides a form. For reading Kristi's feedback programmatically, the API endpoints return structured JSON — far more reliable than trying to parse Google Docs suggestions.
POST /api/admin/reviews with all fields. Cleanest — Charlotte can do this from a Task subagent./admin/reviews, fills the submission form, clicks Submit.GET /api/reviews/:id?token=admin (or API with admin JWT).suggested_text: Charlotte makes the exact change, then marks the comment resolved.PUT /api/admin/reviews/:id → creates v2 with parent_id linking to v1).parent_id = original's ID and version = 2.// New files
backend/src/migrations/022_content_review_portal.sql
backend/src/routes/reviewRoutes.js
backend/src/middleware/reviewerAuth.js
// Frontend (new components)
frontend/src/components/ReviewPortal.jsx // Kristi's view
frontend/src/components/ReviewPortal.css
frontend/src/components/AdminReviews.jsx // Jason's admin view (optional: add to CommandCenter)
// backend/src/middleware/reviewerAuth.js
const authenticateReviewer = async (req, res, next) => {
const token = req.query.token;
if (!token) return res.status(401).json({ error: 'Review token required' });
const reviewer = await supabase
.from('reviewers')
.select('*')
.eq('token', token)
.eq('is_active', true)
.single();
if (!reviewer.data) return res.status(401).json({ error: 'Invalid review token' });
req.reviewer = reviewer.data;
next();
};
The content_body is stored as plain text with simple section markers. On the frontend, render with:
SECTION N: or all-caps text → rendered as section headers.- → rendered as list items.Headline:, Button:, Subtext: etc. → rendered with bold labels.On mobile, text selection is native (long-press). After selection, show a floating "Comment" button near the selection. Use the Selection API:
const handleTextSelection = () => {
const selection = window.getSelection();
if (selection.toString().trim()) {
setSelectedText(selection.toString());
// Find nearest section header for section_ref
const range = selection.getRangeAt(0);
const section = range.startContainer.parentElement
.closest('[data-section]');
setSectionRef(section?.dataset.section || 'General');
setShowCommentBox(true);
}
};
This is an internal tool that doesn't affect the customer-facing app or Meta's review process. The migration adds new tables (doesn't modify existing ones). The new routes are isolated. The frontend component loads on a new route. This can be built, tested, and deployed during the production freeze without any risk to Meta approval.
Once the review portal is live:
Not for initial build. Documenting so we don't forget:
/admin/reviews, or a new tab on the existing Six Engine Command Center? (Recommend: standalone page for now — simpler build.)Assumption: Kristi's phone browser supports the Selection API for text highlighting. All modern mobile browsers (Safari iOS, Chrome Android) support this, but the floating "Comment" button behavior after selection may need testing on her specific device. Fallback: section-level comments (tap between sections) work on any device.
Uncertainty: Whether Charlotte should submit content via the API directly (requires making HTTP requests from Claude CoWork, which isn't currently straightforward) or via the browser UI (which we know works but is slower). The spec supports both paths — Stitch should build both the API and the form UI so we can use whichever works better in practice.