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:
@@ -21,8 +21,8 @@ Health: `GET /api/health`
|
|||||||
- [docs/IDENTITY.md](./docs/IDENTITY.md)
|
- [docs/IDENTITY.md](./docs/IDENTITY.md)
|
||||||
- [SECURITY.md](./SECURITY.md)
|
- [SECURITY.md](./SECURITY.md)
|
||||||
- [docs/CONVERGENCE.md](./docs/CONVERGENCE.md)
|
- [docs/CONVERGENCE.md](./docs/CONVERGENCE.md)
|
||||||
- [docs/RUNBOOK.md](./docs/RUNBOOK.md)
|
- [docs/OPERATOR.md](./docs/OPERATOR.md) — alta `marketing@4ventures.es`
|
||||||
- [sql/rls_checklist.sql](./sql/rls_checklist.sql)
|
- [docs/PUSH.md](./docs/PUSH.md) — push a Gitea con token
|
||||||
|
|
||||||
## Rutas
|
## Rutas
|
||||||
|
|
||||||
|
|||||||
55
docs/OPERATOR.md
Normal file
55
docs/OPERATOR.md
Normal 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
32
docs/PUSH.md
Normal 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
48
sql/creatives.sql
Normal 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}
|
||||||
66
src/app/api/creatives/route.ts
Normal file
66
src/app/api/creatives/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user