V2 lives entirely under v2/ and is built around three asks the team raised about V1: per-video assets sometimes drifted onto the wrong trend, hashtag scrapes returned junk that wasn't filterable per-client, and there was no multi-user model behind Microsoft SSO. Highlights: - Stable TikTok numeric-id key for every per-video asset; URL form drift is logged loudly to drift_log.jsonl and never silently nulls assets. Stage 5 manifest hard-gates Stage 6 if any selected video is missing any required asset; --drop-failing auto-backfills from the next-best recipe candidates. - Per-brief engagement floor (min_likes / min_plays / min_stl_pct), applied at Apify scrape time and re-validated locally; spend_log.json records raw_returned vs kept_after_floor per scrape. - Users + teams + memberships with owner/admin/editor/viewer roles; SSO upserts a user keyed on Azure oid, auto-creates a personal team, and a super-admin is bootstrapped via BOOTSTRAP_SUPER_ADMIN_EMAIL on first sign-in. Phase A integration test: 16/16 pass. - 10-stage TS pipeline (brief → seed → scrape1 → select → scrape2 → validate → analyse → insights → trends → qa → build) wired through one CLI; each stage idempotent + resumable from disk via .state sentinels. §4.5 rubrics shipped under prompts/ and loaded into Claude calls. - React 18 + Vite + TS + Tailwind operator SPA: brief intake form, team management, super-admin user list, help/FAQ ported from V1. - Separate Docker Compose project (name: social-reporting-v2, port 3457, Postgres 5437) with deploy/setup-v2.sh, deploy-v2.sh, rollback-to-v1.sh scripts that take over V1's /social-reports URL and let us roll back. Verification: 62 unit tests pass (auth/session, ids extractor with full URL fixture, engagement floor, recipes, manifest, linking-fix, MoM compare). Live smoke run on a Dove brief: 1400 raw → 253 kept (82% culled) → 21 fully-bundled videos → 25 editorial trends across 8 brief-driven categories, with drift=0. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
52 lines
1.7 KiB
TypeScript
52 lines
1.7 KiB
TypeScript
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
|
|
export const MAX_BODY_SIZE = 1024 * 1024; // 1 MB
|
|
|
|
export function sendJSON(res: ServerResponse, status: number, data: unknown): void {
|
|
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify(data));
|
|
}
|
|
|
|
export function sendText(res: ServerResponse, status: number, text: string, contentType = 'text/plain'): void {
|
|
res.writeHead(status, { 'Content-Type': contentType });
|
|
res.end(text);
|
|
}
|
|
|
|
export function parseBody(req: IncomingMessage): Promise<string> {
|
|
return new Promise((resolveP, reject) => {
|
|
const chunks: Buffer[] = [];
|
|
let size = 0;
|
|
req.on('data', (c: Buffer) => {
|
|
size += c.length;
|
|
if (size > MAX_BODY_SIZE) {
|
|
req.destroy();
|
|
reject(new Error('Request body too large'));
|
|
return;
|
|
}
|
|
chunks.push(c);
|
|
});
|
|
req.on('end', () => resolveP(Buffer.concat(chunks).toString()));
|
|
req.on('error', reject);
|
|
});
|
|
}
|
|
|
|
export async function parseJSONBody<T = unknown>(req: IncomingMessage): Promise<T> {
|
|
const body = await parseBody(req);
|
|
return JSON.parse(body) as T;
|
|
}
|
|
|
|
export function parseCookies(req: IncomingMessage): Record<string, string> {
|
|
const cookies: Record<string, string> = {};
|
|
const header = req.headers.cookie || '';
|
|
for (const pair of header.split(';')) {
|
|
const eq = pair.indexOf('=');
|
|
if (eq === -1) continue;
|
|
cookies[pair.slice(0, eq).trim()] = pair.slice(eq + 1).trim();
|
|
}
|
|
return cookies;
|
|
}
|
|
|
|
export function clientIp(req: IncomingMessage): string {
|
|
const xff = (req.headers['x-forwarded-for'] as string | undefined)?.split(',')[0]?.trim();
|
|
return xff || req.socket.remoteAddress || 'unknown';
|
|
}
|