feat: operator docs, Gitea push guide, and creatives API scaffold

Unblock ops with marketing@ provisioning steps and start multi-tenant creatives against Supabase RLS.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
4Ventures
2026-09-21 12:12:57 +02:00
parent 2df3fe6983
commit 6e3d561f4b
5 changed files with 203 additions and 2 deletions

View File

@@ -21,8 +21,8 @@ Health: `GET /api/health`
- [docs/IDENTITY.md](./docs/IDENTITY.md)
- [SECURITY.md](./SECURITY.md)
- [docs/CONVERGENCE.md](./docs/CONVERGENCE.md)
- [docs/RUNBOOK.md](./docs/RUNBOOK.md)
- [sql/rls_checklist.sql](./sql/rls_checklist.sql)
- [docs/OPERATOR.md](./docs/OPERATOR.md) — alta `marketing@4ventures.es`
- [docs/PUSH.md](./docs/PUSH.md) — push a Gitea con token
## Rutas

55
docs/OPERATOR.md Normal file
View File

@@ -0,0 +1,55 @@
# Operador marketing@4ventures.es
La contraseña del portal legado **no** vale en Supabase Auth (`invalid_credentials`).
## Alta / reset en Studio
1. Abre https://supabase.4ventures.es (Studio) con cuenta admin de infra.
2. **Authentication → Users → Add user**
- Email: `marketing@4ventures.es`
- Password: genera una fuerte (guárdala en el gestor de la agencia)
- Auto Confirm: sí
3. En el usuario → **User Metadata** / **App Metadata** (JSON), por ejemplo:
```json
{
"role": "admin",
"company": "4Ventures",
"slug": "4ventures-hub"
}
```
App metadata (preferible para rol):
```json
{
"role": "admin",
"tenant_id": "<uuid-del-tenant-hub-si-existe>"
}
```
4. Si usáis `provision_client`, también podéis:
```sql
select public.provision_client(
'4Ventures',
'4ventures-hub',
'marketing@4ventures.es',
'CONTRASEÑA_FUERTE_AQUI',
'admin'
);
```
5. Login en https://app.4ventures.es/login con ese email/password.
## Rotación de API keys (obligatorio)
Settings → API → Regenerate `anon` y `service_role`.
Actualizar en Dokploy env del contenedor `app` (y n8n si las usa).
Nunca volver a pegar keys en PDFs ni `.env.example`.
## Verificación
- Rol `admin` → ve `/dashboard/agencia`
- Rol `client` → redirigido fuera de agencia
- Chat/simulate solo con sesión

32
docs/PUSH.md Normal file
View File

@@ -0,0 +1,32 @@
# Push a Gitea (bloqueado sin token)
HTTPS a `git.4ventures.es` exige autenticación. SSH :22 hace timeout (solo HTTPS público).
## Crear token (1 vez)
1. Entra en https://git.4ventures.es (usuario con acceso a `Marketing/4ventures-client-portal`).
2. Settings → Applications → Generate New Token
- Nombre: `cursor-push`
- Scopes: `write:repository`
3. Copia el token (solo se muestra una vez).
## Push de la rama de seguridad
```bash
cd /Users/4ventures/4ventures-client-portal
git checkout harden/auth-tenant-security
# Opción A — URL con usuario (el SO pedirá contraseña = el token)
git push -u origin harden/auth-tenant-security
# Opción B — token en URL (no dejarlo en historial de shell si puedes evitarlo)
# git push -u https://USER:TOKEN@git.4ventures.es/Marketing/4ventures-client-portal.git HEAD
```
Usuario Gitea típico: el de Marcos / marketing (el que pueda escribir en Marketing).
## Después del push
1. Dokploy → servicio `app` → Redeploy / pull `harden/auth-tenant-security` o merge a `main`.
2. `curl -sf https://app.4ventures.es/api/health`
3. Login con operador aprovisionado (ver `docs/OPERATOR.md`).

48
sql/creatives.sql Normal file
View File

@@ -0,0 +1,48 @@
-- Creatives multi-tenant (Supabase)
-- Run in SQL editor after reviewing names against existing schema.
create table if not exists public.creatives (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null references public.tenants(id) on delete cascade,
title text not null,
status text not null default 'draft'
check (status in ('draft', 'in_review', 'approved', 'rejected')),
mime_type text,
storage_path text,
bytes int,
sha256 text,
created_by uuid references auth.users(id),
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create index if not exists creatives_tenant_idx on public.creatives (tenant_id, updated_at desc);
alter table public.creatives enable row level security;
-- Adjust JWT claim path to match your Auth Hooks
create policy creatives_tenant_select on public.creatives
for select using (
tenant_id::text = coalesce(
auth.jwt() -> 'app_metadata' ->> 'tenant_id',
auth.jwt() -> 'user_metadata' ->> 'tenant_id'
)
);
create policy creatives_tenant_insert on public.creatives
for insert with check (
tenant_id::text = coalesce(
auth.jwt() -> 'app_metadata' ->> 'tenant_id',
auth.jwt() -> 'user_metadata' ->> 'tenant_id'
)
);
create policy creatives_tenant_update on public.creatives
for update using (
tenant_id::text = coalesce(
auth.jwt() -> 'app_metadata' ->> 'tenant_id',
auth.jwt() -> 'user_metadata' ->> 'tenant_id'
)
);
-- Storage bucket suggestion: creatives (private), path = {tenant_id}/{id}

View File

@@ -0,0 +1,66 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
export async function GET() {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { data, error } = await supabase
.from('creatives')
.select('id,title,status,mime_type,bytes,created_at,updated_at')
.order('updated_at', { ascending: false });
if (error) {
// Table may not exist yet — soft message for pre-migration
return NextResponse.json(
{ creatives: [], warning: error.message },
{ status: 200 }
);
}
return NextResponse.json({ creatives: data ?? [] });
}
export async function POST(req: NextRequest) {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const tenantId =
user.app_metadata?.tenant_id || user.user_metadata?.tenant_id || null;
if (!tenantId) {
return NextResponse.json({ error: 'Usuario sin tenant_id' }, { status: 403 });
}
const body = await req.json().catch(() => ({}));
const title = typeof body.title === 'string' ? body.title.trim() : '';
if (!title) {
return NextResponse.json({ error: 'title requerido' }, { status: 400 });
}
const { data, error } = await supabase
.from('creatives')
.insert({
tenant_id: tenantId,
title,
status: 'draft',
created_by: user.id,
})
.select('id,title,status,created_at')
.single();
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
return NextResponse.json({ creative: data }, { status: 201 });
}