feat: panel de creatividades (pipeline editorial) en /dashboard/panel-cliente
Nueva tabla creative_pipeline (separada de creatives, que es solo para anuncios aprobados Meta/Google): Propuesta -> En diseño -> Revisión interna -> Aprobado/Rechazado -> Publicado, con RLS por tenant. - sql/creative_pipeline.sql: esquema + políticas RLS - /api/creative-pipeline: CRUD con transiciones restringidas (el cliente solo puede aprobar/rechazar una pieza en revisión interna; el resto de movimientos y la creación son solo agencia) - /api/creative-pipeline/upload + /media: portada y slides de carrusel sobre el bucket privado tenant-assets ya existente - CreativePipelineBoard: tablero agrupado por mes, con subida de imagen inline y acciones según rol - Alta del módulo "Panel de Creatividades" en tenant-modules y sidebar Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WqGTGPznxTp8mwMg6Yv4Ew
This commit is contained in:
67
src/app/api/creative-pipeline/media/route.ts
Normal file
67
src/app/api/creative-pipeline/media/route.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import { serviceHeaders, supabaseUrl } from '@/lib/agency';
|
||||
import { resolveActiveTenant } from '@/lib/tenant-context';
|
||||
import { ASSETS_BUCKET, serviceSupabase } from '@/lib/assets';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/** GET ?path=<storage_path> — redirect to a short-lived signed URL. */
|
||||
export async function GET(req: NextRequest) {
|
||||
const path = req.nextUrl.searchParams.get('path')?.trim() || '';
|
||||
if (!path) {
|
||||
return NextResponse.json({ error: 'path requerido' }, { status: 400 });
|
||||
}
|
||||
|
||||
const supabase = await createClient();
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const ctx = await resolveActiveTenant(user);
|
||||
if (!ctx.tenantId) {
|
||||
return NextResponse.json({ error: 'Sin tenant' }, { status: 400 });
|
||||
}
|
||||
|
||||
// El path siempre empieza por "{tenant_id}/pipeline/..." — evita servir
|
||||
// ficheros de otro tenant aunque conozcan la ruta.
|
||||
if (!path.startsWith(`${ctx.tenantId}/pipeline/`)) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
// Doble check contra la fila (evita servir paths "adivinados").
|
||||
const headers = serviceHeaders();
|
||||
if (headers) {
|
||||
const r = await fetch(
|
||||
`${supabaseUrl()}/rest/v1/creative_pipeline?tenant_id=eq.${encodeURIComponent(
|
||||
ctx.tenantId
|
||||
)}&or=(cover_path.eq.${encodeURIComponent(path)},slide_paths.cs.${encodeURIComponent(
|
||||
JSON.stringify([path])
|
||||
)})&select=id&limit=1`,
|
||||
{ headers, cache: 'no-store' }
|
||||
);
|
||||
if (r.ok) {
|
||||
const rows = await r.json();
|
||||
if (!rows?.length) {
|
||||
return NextResponse.json({ error: 'No encontrado' }, { status: 404 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const svc = serviceSupabase();
|
||||
if (!svc) {
|
||||
return NextResponse.json({ error: 'Service role required' }, { status: 503 });
|
||||
}
|
||||
|
||||
const signed = await svc.storage.from(ASSETS_BUCKET).createSignedUrl(path, 300);
|
||||
if (signed.error || !signed.data?.signedUrl) {
|
||||
return NextResponse.json(
|
||||
{ error: signed.error?.message || 'No signed URL' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
return NextResponse.redirect(signed.data.signedUrl);
|
||||
}
|
||||
300
src/app/api/creative-pipeline/route.ts
Normal file
300
src/app/api/creative-pipeline/route.ts
Normal file
@@ -0,0 +1,300 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import {
|
||||
isAgencyRole,
|
||||
serviceHeaders,
|
||||
supabaseUrl,
|
||||
userRole,
|
||||
} from '@/lib/agency';
|
||||
import { resolveActiveTenant } from '@/lib/tenant-context';
|
||||
|
||||
const SELECT =
|
||||
'id,external_ref,title,format,month,status,cover_path,cover_mime,slide_paths,caption,notes,approved_at,created_at,updated_at';
|
||||
|
||||
const FORMATS = new Set(['post', 'story', 'reel', 'carrusel']);
|
||||
|
||||
const ALL_STATUS = [
|
||||
'propuesta',
|
||||
'en_diseno',
|
||||
'revision_interna',
|
||||
'aprobado',
|
||||
'rechazado',
|
||||
'publicado',
|
||||
] as const;
|
||||
type Status = (typeof ALL_STATUS)[number];
|
||||
const STATUS_SET = new Set<string>(ALL_STATUS);
|
||||
|
||||
// El cliente solo puede mover una pieza que esté en revisión interna,
|
||||
// y únicamente hacia aprobar o rechazar. El resto de transiciones (incluida
|
||||
// "publicado") son cosa del equipo de la agencia.
|
||||
const CLIENT_ALLOWED_TRANSITIONS: Record<string, Status[]> = {
|
||||
revision_interna: ['aprobado', 'rechazado'],
|
||||
};
|
||||
|
||||
function str(v: unknown, max = 2000): string | null {
|
||||
if (typeof v !== 'string') return null;
|
||||
const t = v.trim();
|
||||
if (!t) return null;
|
||||
return t.slice(0, max);
|
||||
}
|
||||
|
||||
function slidePaths(v: unknown): string[] {
|
||||
if (!Array.isArray(v)) return [];
|
||||
return v.filter((x): x is string => typeof x === 'string').slice(0, 10);
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const supabase = await createClient();
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const ctx = await resolveActiveTenant(user);
|
||||
const month = req.nextUrl.searchParams.get('month')?.trim() || null;
|
||||
const isAgency = isAgencyRole(userRole(user));
|
||||
|
||||
if (ctx.portfolioWide) {
|
||||
return NextResponse.json({ items: [], isAgency, warning: 'Selecciona un cliente en Contexto' });
|
||||
}
|
||||
if (!ctx.tenantId) {
|
||||
return NextResponse.json({ items: [], isAgency });
|
||||
}
|
||||
|
||||
let q = `${supabaseUrl()}/rest/v1/creative_pipeline?tenant_id=eq.${encodeURIComponent(
|
||||
ctx.tenantId
|
||||
)}&select=${SELECT}&order=month.asc,created_at.asc`;
|
||||
if (month) q += `&month=eq.${encodeURIComponent(month)}`;
|
||||
|
||||
if (ctx.viaView) {
|
||||
const headers = serviceHeaders();
|
||||
if (!headers) {
|
||||
return NextResponse.json(
|
||||
{ items: [], isAgency, warning: 'Service role required for superadmin view' },
|
||||
{ status: 200 }
|
||||
);
|
||||
}
|
||||
const r = await fetch(q, { headers, cache: 'no-store' });
|
||||
if (!r.ok) {
|
||||
return NextResponse.json({ items: [], isAgency, warning: await r.text() });
|
||||
}
|
||||
return NextResponse.json({ items: await r.json(), isAgency });
|
||||
}
|
||||
|
||||
let query = supabase
|
||||
.from('creative_pipeline')
|
||||
.select(SELECT)
|
||||
.order('month', { ascending: true })
|
||||
.order('created_at', { ascending: true });
|
||||
if (month) query = query.eq('month', month);
|
||||
|
||||
const { data, error } = await query;
|
||||
if (error) {
|
||||
return NextResponse.json({ items: [], isAgency, warning: error.message });
|
||||
}
|
||||
return NextResponse.json({ items: data ?? [], isAgency });
|
||||
}
|
||||
|
||||
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 agency = isAgencyRole(userRole(user));
|
||||
if (!agency) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Solo el equipo de la agencia puede crear piezas' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const ctx = await resolveActiveTenant(user);
|
||||
if (!ctx.tenantId || ctx.portfolioWide) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Selecciona un cliente en Contexto' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const body = (await req.json().catch(() => ({}))) as Record<string, unknown>;
|
||||
const title = str(body.title, 300);
|
||||
if (!title) {
|
||||
return NextResponse.json({ error: 'title requerido' }, { status: 400 });
|
||||
}
|
||||
const format = str(body.format, 20);
|
||||
|
||||
const row = {
|
||||
tenant_id: ctx.tenantId,
|
||||
title,
|
||||
external_ref: str(body.external_ref, 80),
|
||||
format: format && FORMATS.has(format) ? format : 'post',
|
||||
month: str(body.month, 7),
|
||||
caption: str(body.caption, 4000),
|
||||
notes: str(body.notes, 2000),
|
||||
status: 'propuesta',
|
||||
created_by: user.id,
|
||||
};
|
||||
|
||||
const headers = serviceHeaders();
|
||||
if (!headers) {
|
||||
return NextResponse.json({ error: 'Service role required' }, { status: 503 });
|
||||
}
|
||||
const r = await fetch(`${supabaseUrl()}/rest/v1/creative_pipeline`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...headers,
|
||||
'Content-Type': 'application/json',
|
||||
Prefer: 'return=representation',
|
||||
},
|
||||
body: JSON.stringify(row),
|
||||
});
|
||||
if (!r.ok) {
|
||||
return NextResponse.json({ error: await r.text() }, { status: 500 });
|
||||
}
|
||||
const data = await r.json();
|
||||
return NextResponse.json({ item: Array.isArray(data) ? data[0] : data }, { status: 201 });
|
||||
}
|
||||
|
||||
export async function PATCH(req: NextRequest) {
|
||||
const supabase = await createClient();
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const ctx = await resolveActiveTenant(user);
|
||||
if (!ctx.tenantId) {
|
||||
return NextResponse.json({ error: 'Sin tenant' }, { status: 400 });
|
||||
}
|
||||
|
||||
const body = (await req.json().catch(() => ({}))) as Record<string, unknown>;
|
||||
const id = str(body.id, 80);
|
||||
if (!id) {
|
||||
return NextResponse.json({ error: 'id requerido' }, { status: 400 });
|
||||
}
|
||||
|
||||
const agency = isAgencyRole(userRole(user));
|
||||
const headers = serviceHeaders();
|
||||
|
||||
// Resolve current row first — status transitions are checked against it.
|
||||
const curRes = await fetch(
|
||||
`${supabaseUrl()}/rest/v1/creative_pipeline?id=eq.${encodeURIComponent(
|
||||
id
|
||||
)}&tenant_id=eq.${encodeURIComponent(ctx.tenantId)}&select=status`,
|
||||
{ headers: headers || undefined, cache: 'no-store' }
|
||||
);
|
||||
const curRows = curRes.ok ? await curRes.json() : [];
|
||||
const current: string | undefined = curRows?.[0]?.status;
|
||||
if (!current) {
|
||||
return NextResponse.json({ error: 'Pieza no encontrada' }, { status: 404 });
|
||||
}
|
||||
|
||||
const clean: Record<string, unknown> = {
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
if (typeof body.status === 'string' && STATUS_SET.has(body.status)) {
|
||||
const next = body.status as Status;
|
||||
if (agency) {
|
||||
clean.status = next;
|
||||
} else {
|
||||
const allowed = CLIENT_ALLOWED_TRANSITIONS[current] || [];
|
||||
if (!allowed.includes(next)) {
|
||||
return NextResponse.json(
|
||||
{ error: `Como cliente solo puedes aprobar o rechazar una pieza en revisión interna` },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
clean.status = next;
|
||||
}
|
||||
if (next === 'aprobado') {
|
||||
clean.approved_by = user.id;
|
||||
clean.approved_at = new Date().toISOString();
|
||||
}
|
||||
}
|
||||
|
||||
// Resto de campos: solo agencia los edita.
|
||||
if (agency) {
|
||||
const title = str(body.title, 300);
|
||||
if (title) clean.title = title;
|
||||
const month = str(body.month, 7);
|
||||
if (month) clean.month = month;
|
||||
const format = str(body.format, 20);
|
||||
if (format && FORMATS.has(format)) clean.format = format;
|
||||
if ('caption' in body) clean.caption = str(body.caption, 4000);
|
||||
if ('notes' in body) clean.notes = str(body.notes, 2000);
|
||||
if ('cover_path' in body) clean.cover_path = str(body.cover_path, 500);
|
||||
if ('cover_mime' in body) clean.cover_mime = str(body.cover_mime, 100);
|
||||
if ('slide_paths' in body) clean.slide_paths = slidePaths(body.slide_paths);
|
||||
}
|
||||
|
||||
if (!headers) {
|
||||
return NextResponse.json({ error: 'Service role required' }, { status: 503 });
|
||||
}
|
||||
const r = await fetch(
|
||||
`${supabaseUrl()}/rest/v1/creative_pipeline?id=eq.${encodeURIComponent(
|
||||
id
|
||||
)}&tenant_id=eq.${encodeURIComponent(ctx.tenantId)}`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
...headers,
|
||||
'Content-Type': 'application/json',
|
||||
Prefer: 'return=representation',
|
||||
},
|
||||
body: JSON.stringify(clean),
|
||||
}
|
||||
);
|
||||
if (!r.ok) {
|
||||
return NextResponse.json({ error: await r.text() }, { status: 500 });
|
||||
}
|
||||
const data = await r.json();
|
||||
return NextResponse.json({ item: Array.isArray(data) ? data[0] : data });
|
||||
}
|
||||
|
||||
export async function DELETE(req: NextRequest) {
|
||||
const supabase = await createClient();
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
const agency = isAgencyRole(userRole(user));
|
||||
if (!agency) {
|
||||
return NextResponse.json({ error: 'Solo la agencia puede borrar' }, { status: 403 });
|
||||
}
|
||||
|
||||
const ctx = await resolveActiveTenant(user);
|
||||
if (!ctx.tenantId) {
|
||||
return NextResponse.json({ error: 'Sin tenant' }, { status: 400 });
|
||||
}
|
||||
|
||||
const id = req.nextUrl.searchParams.get('id')?.trim() || '';
|
||||
if (!id) {
|
||||
return NextResponse.json({ error: 'id requerido' }, { status: 400 });
|
||||
}
|
||||
|
||||
const headers = serviceHeaders();
|
||||
if (!headers) {
|
||||
return NextResponse.json({ error: 'Service role required' }, { status: 503 });
|
||||
}
|
||||
const r = await fetch(
|
||||
`${supabaseUrl()}/rest/v1/creative_pipeline?id=eq.${encodeURIComponent(
|
||||
id
|
||||
)}&tenant_id=eq.${encodeURIComponent(ctx.tenantId)}`,
|
||||
{ method: 'DELETE', headers }
|
||||
);
|
||||
if (!r.ok) {
|
||||
return NextResponse.json({ error: await r.text() }, { status: 500 });
|
||||
}
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
141
src/app/api/creative-pipeline/upload/route.ts
Normal file
141
src/app/api/creative-pipeline/upload/route.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import { isAgencyRole, serviceHeaders, supabaseUrl, userRole } from '@/lib/agency';
|
||||
import { resolveActiveTenant } from '@/lib/tenant-context';
|
||||
import {
|
||||
ASSETS_BUCKET,
|
||||
MAX_ASSET_BYTES,
|
||||
ensureAssetsBucket,
|
||||
sanitizeFileName,
|
||||
serviceSupabase,
|
||||
} from '@/lib/assets';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
const ALLOWED_MIME = new Set(['image/jpeg', 'image/png', 'image/webp']);
|
||||
|
||||
/**
|
||||
* POST multipart: sube portada o slide de una pieza del pipeline.
|
||||
* fields: file, itemId (requerido), slot ('cover' | 'slide'), index? (para slide, 0-based)
|
||||
*/
|
||||
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 });
|
||||
}
|
||||
if (!isAgencyRole(userRole(user))) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Solo el equipo de la agencia puede subir imágenes' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const ctx = await resolveActiveTenant(user);
|
||||
if (!ctx.tenantId || ctx.portfolioWide) {
|
||||
return NextResponse.json({ error: 'Selecciona un cliente en Contexto' }, { status: 400 });
|
||||
}
|
||||
|
||||
const form = await req.formData();
|
||||
const file = form.get('file');
|
||||
const itemId = String(form.get('itemId') || '').trim();
|
||||
const slot = String(form.get('slot') || 'cover').trim();
|
||||
const index = Math.max(0, parseInt(String(form.get('index') || '0'), 10) || 0);
|
||||
|
||||
if (!(file instanceof File)) {
|
||||
return NextResponse.json({ error: 'file requerido' }, { status: 400 });
|
||||
}
|
||||
if (!itemId) {
|
||||
return NextResponse.json({ error: 'itemId requerido' }, { status: 400 });
|
||||
}
|
||||
if (file.size <= 0 || file.size > MAX_ASSET_BYTES) {
|
||||
return NextResponse.json(
|
||||
{ error: `Archivo vacío o > ${MAX_ASSET_BYTES / (1024 * 1024)} MB` },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
const mime = file.type || 'application/octet-stream';
|
||||
if (!ALLOWED_MIME.has(mime)) {
|
||||
return NextResponse.json({ error: 'Solo jpg/png/webp' }, { status: 400 });
|
||||
}
|
||||
|
||||
const bucketOk = await ensureAssetsBucket();
|
||||
if (!bucketOk.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: bucketOk.error || 'No se pudo asegurar el bucket' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
const svc = serviceSupabase();
|
||||
if (!svc) {
|
||||
return NextResponse.json({ error: 'Service role required' }, { status: 503 });
|
||||
}
|
||||
|
||||
const headers = serviceHeaders();
|
||||
if (!headers) {
|
||||
return NextResponse.json({ error: 'Service role required' }, { status: 503 });
|
||||
}
|
||||
|
||||
// Verifica que la pieza existe y pertenece al tenant activo.
|
||||
const curRes = await fetch(
|
||||
`${supabaseUrl()}/rest/v1/creative_pipeline?id=eq.${encodeURIComponent(
|
||||
itemId
|
||||
)}&tenant_id=eq.${encodeURIComponent(ctx.tenantId)}&select=id,slide_paths`,
|
||||
{ headers, cache: 'no-store' }
|
||||
);
|
||||
const curRows = curRes.ok ? await curRes.json() : [];
|
||||
if (!curRows?.[0]) {
|
||||
return NextResponse.json({ error: 'Pieza no encontrada' }, { status: 404 });
|
||||
}
|
||||
|
||||
const ext = (sanitizeFileName(file.name || 'img.jpg').split('.').pop() || 'jpg').toLowerCase();
|
||||
const fileName = slot === 'cover' ? `cover.${ext}` : `slide-${index + 1}.${ext}`;
|
||||
const storagePath = `${ctx.tenantId}/pipeline/${itemId}/${fileName}`;
|
||||
const buf = Buffer.from(await file.arrayBuffer());
|
||||
|
||||
const up = await svc.storage.from(ASSETS_BUCKET).upload(storagePath, buf, {
|
||||
contentType: mime,
|
||||
upsert: true,
|
||||
});
|
||||
if (up.error) {
|
||||
return NextResponse.json({ error: up.error.message }, { status: 500 });
|
||||
}
|
||||
|
||||
const patch: Record<string, unknown> = { updated_at: new Date().toISOString() };
|
||||
if (slot === 'cover') {
|
||||
patch.cover_path = storagePath;
|
||||
patch.cover_mime = mime;
|
||||
} else {
|
||||
const slides: string[] = Array.isArray(curRows[0].slide_paths)
|
||||
? [...curRows[0].slide_paths]
|
||||
: [];
|
||||
slides[index] = storagePath;
|
||||
patch.slide_paths = slides;
|
||||
}
|
||||
|
||||
const patchRes = await fetch(
|
||||
`${supabaseUrl()}/rest/v1/creative_pipeline?id=eq.${encodeURIComponent(itemId)}`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
...headers,
|
||||
'Content-Type': 'application/json',
|
||||
Prefer: 'return=representation',
|
||||
},
|
||||
body: JSON.stringify(patch),
|
||||
}
|
||||
);
|
||||
if (!patchRes.ok) {
|
||||
await svc.storage.from(ASSETS_BUCKET).remove([storagePath]);
|
||||
return NextResponse.json({ error: await patchRes.text() }, { status: 500 });
|
||||
}
|
||||
const data = await patchRes.json();
|
||||
return NextResponse.json({
|
||||
item: Array.isArray(data) ? data[0] : data,
|
||||
storage_path: storagePath,
|
||||
});
|
||||
}
|
||||
11
src/app/dashboard/panel-cliente/page.tsx
Normal file
11
src/app/dashboard/panel-cliente/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Panel de Creatividades — pipeline editorial interno (Propuesta → Diseño →
|
||||
* Revisión interna → Aprobación), visible también para el cliente.
|
||||
*/
|
||||
'use client';
|
||||
|
||||
import CreativePipelineBoard from '@/components/CreativePipelineBoard';
|
||||
|
||||
export default function PanelClientePage() {
|
||||
return <CreativePipelineBoard />;
|
||||
}
|
||||
Reference in New Issue
Block a user