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>
78 lines
2.8 KiB
TypeScript
78 lines
2.8 KiB
TypeScript
// V2 SSO persistence: every Azure sign-in upserts a user row and ensures
|
|
// the user has at least one team (personal team auto-created on first sign-in).
|
|
import { upsertSsoUser, setSuperAdmin, type UserRow } from '../db/users.js';
|
|
import { createTeam, getTeamBySlug } from '../db/teams.js';
|
|
import { addMembership, userHasAnyMembership } from '../db/memberships.js';
|
|
import { envStr } from '../lib/env.js';
|
|
|
|
function emailToSlug(email: string): string {
|
|
const local = email.split('@')[0] ?? 'user';
|
|
const base = local.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
|
|
return base || 'user';
|
|
}
|
|
|
|
async function uniquePersonalSlug(email: string): Promise<string> {
|
|
const base = emailToSlug(email);
|
|
let candidate = `${base}-personal`;
|
|
let i = 1;
|
|
while (await getTeamBySlug(candidate)) {
|
|
i += 1;
|
|
candidate = `${base}-personal-${i}`;
|
|
}
|
|
return candidate;
|
|
}
|
|
|
|
export interface SsoLandingResult {
|
|
user: UserRow;
|
|
active_team_id: string;
|
|
bootstrapped_super_admin: boolean;
|
|
}
|
|
|
|
/**
|
|
* Single entry point for SSO sign-in persistence.
|
|
* 1. Upsert user keyed on Azure oid.
|
|
* 2. Promote to super-admin if email matches BOOTSTRAP_SUPER_ADMIN_EMAIL.
|
|
* 3. If user has no team memberships, create a personal team and add them as owner.
|
|
* 4. Pick an active team for the session.
|
|
*/
|
|
export async function landSsoUser(input: {
|
|
oid: string;
|
|
email: string;
|
|
display_name: string;
|
|
}): Promise<SsoLandingResult> {
|
|
let user = await upsertSsoUser(input);
|
|
|
|
// Bootstrap super-admin from env on first sign-in (sticky once true).
|
|
let bootstrapped = false;
|
|
const bootstrapEmail = envStr('BOOTSTRAP_SUPER_ADMIN_EMAIL').toLowerCase();
|
|
if (bootstrapEmail && !user.is_super_admin && user.email.toLowerCase() === bootstrapEmail) {
|
|
user = await setSuperAdmin(user.id, true);
|
|
bootstrapped = true;
|
|
console.log(`[auth] Bootstrapped super-admin: ${user.email}`);
|
|
}
|
|
|
|
// Ensure at least one team membership.
|
|
const hasTeam = await userHasAnyMembership(user.id);
|
|
let activeTeamId: string;
|
|
if (!hasTeam) {
|
|
const slug = await uniquePersonalSlug(user.email);
|
|
const personalTeam = await createTeam({
|
|
slug,
|
|
name: `${user.display_name || user.email}'s workspace`,
|
|
isPersonal: true,
|
|
});
|
|
await addMembership({ teamId: personalTeam.id, userId: user.id, role: 'owner', addedBy: null });
|
|
activeTeamId = personalTeam.id;
|
|
} else {
|
|
// Pick the user's first team as the default active team.
|
|
const { sql } = await import('../db/client.js');
|
|
const [row] = await sql<{ team_id: string }[]>`
|
|
SELECT team_id FROM team_memberships WHERE user_id = ${user.id}
|
|
ORDER BY added_at ASC LIMIT 1
|
|
`;
|
|
if (!row) throw new Error('landSsoUser: user has membership but no row returned');
|
|
activeTeamId = row.team_id;
|
|
}
|
|
|
|
return { user, active_team_id: activeTeamId, bootstrapped_super_admin: bootstrapped };
|
|
}
|