Capítulo comunidad de la guía Neblla — contenido creado y mantenido por los usuarios (entries), búsqueda y moderación. El contrato base está en core.txt; el criterio de cuándo usar Neblla, en empezar.txt.
Comunidad
Qué es
Un espacio de contenido creado y mantenido por los usuarios de tu app, mediado por Neblla: partituras, niveles, problemas de ajedrez, recetas, listings… Lo que se publica se llama entry. Tú no montas base de datos, ni endpoints, ni panel de moderación: el SDK publica, lista, busca y modera; Neblla almacena, indexa y hace cumplir la autoría.
Una entry tiene tres capas:
1. Payload — JSON libre que solo tu app entiende. Neblla no lo indexa ni lo interpreta. Lleva un contentType (etiqueta libre, p. ej. "musicxml" o "puzzle/v1") para que sepas cómo leerlo. Neblla no aloja assets dentro de una entry: si quieres una imagen ahí, la codificas en base64 en el payload y pagas su tamaño. (Para lo que suben los usuarios —fotos, PDF, audio— conecta el almacenamiento de ficheros que prefieras, como Cloudinary, S3 o R2: el navegador sube el fichero directo allí y la entry guarda solo su URL: con Cloudinary, con un preset de subida; con R2 o S3, con una URL firmada que te da tu Server o una función pequeña tuya, porque se firma con tu clave. Crece sin límite y no ocupa tu almacenamiento ni tu cuota. Los ficheros de la propia app (300 por cuenta gratis, compartidos por sus apps; 600 por proyecto Pro) son para tu código y tus assets.) Y cuándo eso está bien tiene una regla, porque en pagos.txt te decimos lo contrario y no es una contradicción: un payload de entry se lee de uno en uno —alguien abre *esa* entry— así que pagas la imagen cuando alguien de verdad la mira. Un catálogo de tienda, en cambio, se pinta entero en cada visita, así que ahí el base64 te cobra todas las imágenes a todo el que entra y la URL externa es la respuesta. Regla corta: base64 para lo que se abre de uno en uno, URL para lo que se lista. 2. Campos indexados (fijos, de Neblla) — title, summary, category, tags, lang, contentType, parentId, status, owner, stats, fechas. Por ellos se lista, busca y ordena. 3. Relación usuario↔entry (favorito, valoración, reseña, denuncia, voto, dato privado) — ya está — ver "Favoritas, valoraciones, reseñas y denuncias" y "Votos" más abajo.
Requiere el módulo community encendido en Config → Módulos (o neblla_update_app con features: { community: true }). Está apagado por defecto: encenderlo implica moderar.
Modos de moderación (panel: Personas › Comunidad)
- open — la entry se publica al instante.
- review — nace
pending; tú (backoffice) o un moderador de la app la aprueban o rechazan. Si el autor edita una entry publicada, vuelve apending. - closed — solo publican los moderadores (tienda curada).
Otros ajustes: quién publica (users | moderators), límite antiabuso de payload por entry (64 KB por defecto; lo ajustas o lo quitas), vocabulario de categorías y de contentType (vacío = libre), tope de entries por usuario, texto de reglas.
Las reglas las acepta cada usuario. Si escribes reglas (community.rules), la primera vez que un usuario publica (o edita una entry listada), Neblla le enseña tus reglas en un pop-up pequeño suyo, traducidas a su idioma, y la entry sale cuando las acepta; tú no programas nada. Se guarda la versión que aceptó: si cambias el texto, en su siguiente entry se le vuelven a pedir. Sin reglas no sale nada, las entries privadas no las piden y tú (MCP, tu servidor) tampoco.
Filtro previo con IA (opcional)
community.prescreen = { mode: 'off' | 'flag' | 'hold', images: false }. Cuando un usuario publica o edita, la IA integrada lee el título, el resumen y el texto del payload (y, con images: true, hasta 3 imágenes en base64 del payload) contra tus reglas. Usa tu clave de Claude, la misma de neblla.ai.prompt(): cuesta tus tokens y por eso viene apagado.
flag— la entry se publica igual; si la IA pone pegas, llevaflagged: { reason }, visible para todos, para que tu app la difumine o avise antes de enseñarla. Un moderador la limpia conmoderate(id, { action: 'approve' }).hold— si la IA pone pegas, la entry espera en la cola (pending) conmoderation.by: 'prescreen'y el motivo. Si no se pudo comprobar (sin clave, o la IA no respondió), también espera, conmoderation.unavailable: true: una entry sin revisar no pasa sola.- Una edición se vuelve a revisar entera; si arregla una entry retenida, sale publicada (salvo en modo
review). Los moderadores y tú no pasáis por el filtro, ni las entries privadas.
Estados de una entry: published · pending · rejected · removed. Solo las published aparecen en list(); el autor ve todas las suyas en mine() con el motivo de moderación si lo hay.
Publicar y gestionar lo mío (CLIENT, requiere login)
const entry = await neblla.community.publish({
title: 'Where Is My Mind',
summary: 'Arreglo fácil para piano',
category: 'pop',
tags: ['fácil', 'pixies'],
lang: 'es',
contentType: 'musicxml',
payload: { xml: '<score-partwise>…</score-partwise>' },
});
// entry.status === 'published' (modo open) o 'pending' (modo review)
await neblla.community.update(entry.id, { summary: 'Arreglo fácil, dos manos' });
await neblla.community.unpublish(entry.id); // el autor la retira: se borra con sus
// reseñas, favoritos y entries hijas. Para corregir una entry, usa update(). En modo
// `review`, una edición vuelve a `pending` hasta que un moderador la aprueba.
const { entries, next } = await neblla.community.mine(); // todas mis entries, todos los estados
Errores (la promesa rechaza con err.code): login_required, feature_disabled, title_required, contentType_required, payload_required, payload_too_large (err.bytes, err.max), category_not_allowed / content_type_not_allowed (err.allowed), too_many_tags, entry_limit_reached, moderators_only, community_muted, community_suspended (Neblla puso la comunidad en pausa), community_storage_exceeded (err.current, err.cap), not_owner, entry_not_found, parent_not_found, rules_declined (cerró el pop-up de las reglas sin aceptarlas), popup_blocked (el navegador bloqueó ese pop-up; err.url lo abre desde un botón).
Evento neblla.community.on('reportClosed') { entryId, outcome } (CLIENT): se cerró el caso de una entry que denuncié. outcome es upheld (se retiró) o dismissed (sigue publicada). No trae el mensaje del moderador: ese es para el autor.
Evento neblla.community.on('updated') { entry, action } (CLIENT): moderación tocó una entry mía. action ∈ published | pending | rejected | removed | restored | modified | suspended. entry.moderation trae { reason, message } escrito por el moderador.
Hilos, colecciones y versiones
parentId cuelga una entry de otra. Con un solo mecanismo salen los comentarios (una entry hija con payload de texto), las colecciones y las versiones o remezclas con procedencia. list({ parent: id }) devuelve las hijas; list({ parent: null }) solo raíces. La madre suma stats.children. El parentId se fija al publicar y no cambia.
Listar, buscar y leer (BOTH — también para invitados)
const { entries, next } = await neblla.community.list({
q: 'pixies', // texto libre sobre title/summary/tags
category: 'pop',
tags: ['fácil'], // todas las etiquetas dadas
lang: 'es',
contentType: 'musicxml',
owner: 'me' | '<owner.id>',
parent: null | '<entryId>',
sort: 'recent' | 'oldest' | 'title' | 'popular' | 'top' | 'used' | 'votes', // con q, por relevancia si no indicas sort
cursor: next, // paginación opaca; limit ≤ 50 (default 20)
limit: 20,
});
const facets = await neblla.community.facets(['category', 'tags']); // { category: [{ value, count }], tags: [...] }
const { entry, payload } = await neblla.community.get(entryId); // el payload solo aquí
list() devuelve solo la capa indexada más stats; el payload se pide entry a entry con get(). Cada entry incluye owner: { id, name, slug } (nunca el email) y mine: true si es tuya. Los tres campos no son de la misma naturaleza y conviene saberlo: id es el userId por app —el mismo de neblla_list_users, así que puedes ir de un usuario a todo lo que publicó sin cruzar nombres— mientras que name y slug son la identidad pública de la persona y son las mismas en cualquier otra app Neblla. Si te importa la privacidad de tus usuarios, ese es el campo que viaja entre apps, no el id (ver identidad.txt).
Campos propios
Además de category y tags, cada app declara sus campos, con tipo, y filtra por rangos:
// una vez: neblla_update_app({ community: { fields: { difficulty: 'integer', bpm: 'number', genre: 'string' }, index: ['difficulty'] } })
await neblla.community.publish({ title, contentType, payload, fields: { difficulty: 3, bpm: 92, genre: 'jazz' } });
await neblla.community.update(id, { fields: { difficulty: 4 } });
const { entries } = await neblla.community.list({ where: { difficulty: { gte: 3, lt: 5 } }, orderBy: '-difficulty' });
- Los campos vuelven en
entry.fields, enlist,getypublish. wherees{ campo: valor }o{ campo: { eq, ne, gt, gte, lt, lte, in } }, como en las tablas;orderBy,'campo'o'-campo'. Los deindexvan con índice.- Errores:
unknown_field(no está declarado) einvalid_field_value(no es de su tipo). - Con BYODB, en tu base de datos.
Una entry en varios idiomas
title, summary y lang son el texto propio de la entry. Para que se lea en otros idiomas, publícala (o actualízala) con i18n:
await neblla.community.publish({
title: 'Ajedrez', summary: 'Juega contra tus amigos', lang: 'es',
i18n: { en: { title: 'Chess', summary: 'Play against your friends' }, fr: { title: 'Échecs' } },
contentType: 'app', payload: { … },
});
const { entries } = await neblla.community.list({ q: 'echecs', locale: 'fr' });
const { entry } = await neblla.community.get(id, { locale: 'pt-BR' });
- La búsqueda (
q) encuentra la entry por las palabras de cualquiera de sus idiomas, y el filtrolang: 'fr'la encuentra si está en francés o traducida al francés. - Con
locale,listygetdevuelventitle/summaryen ese idioma si la entry lo tiene (primero el exacto,pt-br; luego el base,pt), y si no, el texto propio. Cada entry dice enshownLangen qué idioma está lo que ves y enlangstodos los que tiene. Una traducción sinsummarymuestra el propio. gettrae ademástext(el texto propio) ei18nentero, para editarlo. Enupdate,i18nsustituye el mapa completo;i18n: nulllo vacía.- Hasta 16 traducciones, con los mismos topes que el texto propio (
title120,summary500). Errores:invalid_i18n,invalid_i18n_lang(código no válido:fr,pt-br,zh-hant),too_many_translations(err.max).
Imagen de la tarjeta
image es una URL https:// (hasta 512 caracteres) que viaja con la entry en list, para pintar tarjetas sin pedir el payload de cada una. Opcional; invalid_image si no es una URL https. Neblla no la descarga ni la aloja: apunta a donde ya esté la imagen.
Lectura pública por HTTP
Lo publicado se puede leer sin arrancar el SDK, para la primera pintura de una página, un render en servidor o un buscador. Sin login y con CORS abierto:
GET /api/community/public/<appId>/entries?q=&category=&tags=a,b&lang=&contentType=&parent=null&sort=&cursor=&limit=&locale=
GET /api/community/public/<appId>/entries/<entryId>?locale= → { entry, payload }
GET /api/community/public/<appId>/entries/<entryId>/reviews?cursor=&limit=
GET /api/community/public/<appId>/facets?fields=category,tags
Mismas respuestas que list, get, reviews y facets para un invitado: solo entries publicadas y ninguna relación de nadie (no hay lector). Cuesta la misma cuota que la lectura por el SDK, pero las respuestas salen unos segundos de una caché: una página muy visitada que pide la misma lista paga una lectura por intervalo, no una por visitante. Errores: 404 feature_disabled (la app no tiene comunidad), 404 entry_not_found, 403 community_suspended, 429 sin crédito. Para escribir, el SDK o tu servidor (/api/backend/apps/<appId>/community/{publish,update,delete}, ver mcp.txt).
Desde los ficheros de server/ (neblla global) solo existen community.list, community.facets y community.get: Fog es el navegador de otro usuario y nunca publica ni modera en nombre de nadie.
Favoritas, valoraciones, reseñas y denuncias (CLIENT, requiere login)
Cada usuario tiene una relación con cada entry publicada.
visibility tiene tres valores:
'public'(por defecto): la ve cualquiera, también los invitados y la lectura pública por HTTP. Sin cifrado.'protected': la ve un grupo. Los dueños de la entry son sus administradores, los editores que nombren pueden leerla y escribirla, y los moderadores de la app (el dev lo es siempre) pueden leerla, por ejemplo para aprobarla antes de hacerla pública. Va cifrada con una clave del grupo y no aparece en listados ni búsquedas de quien no esté en él:publish({ …, visibility: 'protected', group: { editors: [userIdA, userIdB] } }), y el grupo se cambia conupdate(id, { group }).'private': solo su autor. Su contenido (el payload) va cifrado de extremo a extremo: se cifra y se descifra en el navegador, y ni Neblla puede leerlo. El título, el resumen y las etiquetas van en claro, para que el autor pueda listar las suyas: no pongas ahí nada privado. Para abrirla en otro dispositivo tuyo o dársela a otra persona, se usa un enlace con clave (ver "Compartir en privado por enlace").
Cambiar de visibilidad conserva el grupo y sus roles, así que una entry puede pasar de protegida a pública y volver sin reconfigurar a nadie. Si moderación la retira, una entry bloqueada la siguen viendo sus dueños y los moderadores, y una baneada solo el dev y los moderadores; si era privada, tampoco ellos pueden leer su contenido, y solo su dueño puede quitarle el cifrado personal y pasarla a protegida.
Ojo a los nombres de campo, cambian entre escribir y leer. El texto se escribe con relate(id, { review: 'Preciosa' }), pero reviews() devuelve cada una como { author, rating, text, at, mine } — el texto llega en text, no en review. Pintar r.review te da una lista vacía y ningún error.
Topes de los campos indexados (pasarse rechaza la publicación): title 120 caracteres, summary 500, category 40, 20 tags máximo de 32 caracteres cada uno (too_many_tags lleva max), lang 8. maxEntriesPerUser vale 0 por defecto, que significa sin límite (hasta 10.000); reportThreshold vale 3 por defecto (hasta 1000) y se configura en el community de la app. Escribirla es un merge parcial: tocar la favorita no borra la valoración.
await neblla.community.relate(entryId, { favorite: true });
const { relation, stats } = await neblla.community.relate(entryId, { rating: 4, review: 'Muy bonita', locale: 'es' });
// locale (opcional): el idioma en que está escrita la reseña; reviews() lo devuelve en `lang`
// relation = { favorite, rating, review, reported, vote } · null borra una ranura: { rating: null }
// stats = { favorites, ratingCount, ratingSum, ratingAvg, reviews, reports, children, uses, upvotes, downvotes, score }
const mia = await neblla.community.relation(entryId); // mi relación con esa entry
const { entries, next } = await neblla.community.favorites(); // mis favoritas publicadas
const { reviews, next: n2 } = await neblla.community.reviews(entryId); // BOTH: reseñas públicas, recientes primero
// reviews[i] = { author: { id, name, slug }, rating, text, at, lang?, mine, reply } reply = { text, at } | null
await neblla.community.reply(entryId, reviews[0].author.id, '¡Gracias! Más niveles pronto'); // el autor de la entry responde (null la retira)
await neblla.community.report(entryId, { reason: 'copyright', message: 'Es un arreglo con derechos.' });
// → { ok, suspended } reason ∈ copyright | inappropriate | hate | harassment | spam | illegal | privacy | other ('other' exige message)
list(),get(),mine()yfavorites()traen en cada entryme: { favorite, rating, review, reported, vote }si hay login: pintas "mis estrellas" sin una llamada por fila.sort: 'popular'ordena por favoritas,'top'por valoración media,'used'por usos y'votes'por votos.- Responder reseñas. El autor de la entry responde cada reseña con
reply(entryId, reviewerId, texto), dondereviewerIdes elauthor.idde la reseña; también pueden los moderadores, y el dev desde su servidor (POST /api/backend/apps/<appId>/community/reply) o el MCP. Una respuesta por reseña: la nueva sustituye a la anterior ynullla retira. Sale bajo la reseña enreviews()comoreplyy, para quien reseñó, en surelation(). Si retira su reseña, la respuesta se va con ella. Errores:not_author,review_not_found,reply_too_long(1000 caracteres). - Usos. Cada entry lleva
stats.uses, que solo suma su dueño desde su servidor:POST /api/backend/apps/<appId>/community/usescon{ entryId, n }(por defecto 1).sort: 'used'lo lee: «más jugadas», «más descargadas», «más abiertas». - El autor no puede valorar, reseñar ni denunciar su propia entry (
own_entry); sí marcarla como favorita. - Una denuncia por usuario y entry (
already_reported), máximo 20 al día por cuenta (report_limit_reached). El autor nunca sabe quién denunció; el dev lo ve como id opaco. - Al llegar a
community.reportThresholddenunciantes distintos (por defecto 3; 0 = nunca) la entry se auto-suspende: sustatuspasa apending(no asuspended), desaparece del listado y el autor recibecommunity.on('updated')conaction: 'suspended'. Eso importa al trabajar la cola: enpendingconviven las entries que esperan primera aprobación y las que han caido por denuncias — distínguelas pormoderation.by, que vale'reports'en las automáticas. El statussuspendedes el que pone un moderador a mano mientras hay un caso abierto. Aprobarla o restaurarla desestima las denuncias; retirarla las da por buenas. No todas las denuncias pesan igual para ese umbral: quien acumula denuncias desestimadas cuenta menos — la mitad si tiene más desestimadas que confirmadas, y nada si tiene 3 o más desestimadas y ninguna confirmada (su denuncia queda igualmente registrada para el moderador). Nadie cuenta más de uno. - Un usuario silenciado (
community_muted) no puede relacionarse con entries, pero sí denunciar.
Votos
Cada usuario con login da un voto por entry: a favor y, si tu app lo enciende, también en contra. Sirve para todo lo que se elige entre muchos: en una app de vecinos, cada propuesta («poner bancos en la plaza») tiene sus votos y salen primero las más votadas; igual las ideas de un foro, las recetas o las sugerencias de un curso.
await neblla.community.vote(entryId); // a favor (lo mismo que vote(entryId, 1))
await neblla.community.vote(entryId, -1); // en contra: solo con community.downvotes: true
const { vote, stats } = await neblla.community.vote(entryId, null); // quitarlo (0 también)
// stats = { …, upvotes, downvotes, score } score = a favor − en contra
const { entries } = await neblla.community.list({ sort: 'votes' }); // las más votadas primero
// entries[i].stats.score, y entries[i].me.vote = 1 | -1 | null con login
- Votar otra vez cambia el voto, no suma otro. También vale
relate(entryId, { vote }). - El voto en contra está apagado por defecto: actívalo con
neblla_update_app({ community: { downvotes: true } })o en el panel (Personas › Comunidad, «Votos en contra»). Sin él,vote(id, -1)rechaza condownvotes_off. Apagarlo después hace que los votos en contra que ya hay dejen de contar:downvotessale 0,scorees solo los votos a favor ysort: 'votes'ordena por ellos. No se borran: si lo vuelves a encender, vuelven a contar. Quien votó en contra sigue viendo sume.vote: -1y puede retirarlo convote(id, 0). sort: 'votes'ordena porscore; a igualscore, la que tiene más votos a favor.- El autor no vota lo suyo (
own_entry). Un voto que no es1,-1,0ninulldainvalid_vote. - Nadie ve quién votó: ni el autor, ni los demás, ni el dev. Solo los recuentos y, cada uno, su propio voto en
me.vote. - Los votos de un usuario baneado no cuentan. Al banearlo (*banned* o *terminated*) sus votos salen de los recuentos, y vuelven si levantas el baneo. Uno silenciado (
community_muted) no puede votar, pero lo que ya votó sigue contando. Si lo borras de tu app, sus votos se van con él. - Cuesta lo mismo que una favorita:
community_querypor voto.
Compartir en privado por enlace (CLIENT, requiere login)
Una entry privada no aparece en list, facets ni búsquedas, y va cifrada de extremo a extremo. Un enlace con clave la abre: el enlace lleva la clave de descifrado detrás de #, que el navegador nunca manda al servidor, así que quien tiene el enlace la lee y Neblla sigue sin poder. Sirve para mandar algo a una persona concreta (por WhatsApp, email…) sin publicarlo.
// 1. El autor crea la entry privada (no pasa por la cola de revisión) y un enlace
const e = await neblla.community.publish({ title: 'Para Gonzalo', contentType: 'partitura', payload, visibility: 'private' });
const { token, key, expiresAt } = await neblla.community.shareLink(e.id, {
uses: 1, // aperturas permitidas (1 = una sola descarga; máx. 100)
expiresInDays: 10, // caducidad (máx. 30)
deleteEntry: true, // al gastarse el último uso (o caducar) se borra también la entry
});
const url = location.origin + location.pathname + '?envio=' + token + '#k=' + key; // la clave, detrás de '#'
// 2. Quien lo recibe abre la app con ese enlace, entra con Neblla y:
const { entry, payload } = await neblla.community.openLink(token, key); // gasta un uso; descifra en su navegador
- El token son 256 bits aleatorios (base64url, 43 caracteres); Neblla guarda solo su SHA-256, así que tu base de datos nunca contiene un enlace utilizable.
openLinkexige sesión (login_required). Un enlace desconocido, gastado o caducado responde siemprelink_not_found, sin pistas.- Solo el autor puede crear enlaces de su entry (
not_owner). Funciona igual con entries públicas (deleteEntry: false). - Los enlaces viven en la tabla
community_linksde tu base de datos (BYO-DB), como el resto de la comunidad. La clave no se guarda en ninguna parte: si se pierde el enlace, se crea otro. - Tus otros dispositivos. El mismo mecanismo abre tus entries privadas en otro navegador tuyo:
const { url } = await neblla.community.deviceLink()da un enlace (o un QR) con tu clave personal; ábrelo en el otro dispositivo, con tu misma cuenta, y desde entonces ese navegador lee todas tus entries privadas.
Ver y revocar mis enlaces
const mis = await neblla.community.links(); // o links({ entryId }) para una entry
// → [{ id, entryId, usesLeft, expiresAt, createdAt, deleteEntry }], los más nuevos primero
await neblla.community.revokeLink(mis[0].id); // → { revoked: true }
ides el hash del token: sirve para revocar, pero no abre nada. El token en sí no se puede recuperar.- Solo el autor ve y revoca sus enlaces (
not_owner); uniddesconocido →link_not_found.
Tarjeta pública de una entry
Para que un usuario pegue una entry en Discord, WhatsApp o X:
const url = neblla.community.cardUrl(entry.id); // https://play.neblla.com/c/<appId>/<entryId>
// … y al cargar tu app:
const id = neblla.community.linkedEntry(); // el id si se llegó por una tarjeta, si no null
if (id) { const { entry, payload } = await neblla.community.get(id); /* ábrela */ }
- La vista previa del chat sale de las etiquetas Open Graph: título, resumen e
imagede la entry (sinimage, la portada de la app) y el nombre de la app. No lleva el payload ni lo lee. - Quien hace clic llega a tu app con
?entry=<id>;linkedEntry()lo lee (y descarta lo que no tenga forma de id). - Solo tienen tarjeta las entries publicadas. Una privada, pendiente, retirada o inexistente da el mismo 404: el enlace no revela que exista. Para compartir una privada,
shareLink. - Revocar el último enlace de una entry creada con
deleteEntry: truela borra también, igual que al gastarse. - Los enlaces caducados o gastados se limpian solos (con su entry desechable) cada vez que la app crea o lista enlaces.
Moderar desde la app (CLIENT, solo moderadores de la app)
Quién modera. Los moderadores de la app son los miembros de su grupo reservado moderators (identidad.txt, «Grupos y permisos»), por su id de usuario en la app: los nombras y los quitas desde el panel (Personas › Moderación), con neblla_set_moderator o, con el módulo de grupos, desde la API de administración y tu servidor (group: 'moderators'). El grupo vive en los datos de la app (con BYODB, en tu base), así que sigue a los datos como los usuarios: pre con los datos de pro modera con los de pro; pre con sus propios datos tiene su grupo, vacío al empezar: añádete con tu usuario de pre (neblla_set_moderator con env: 'pre', o el back office en pre) o añade a quien quieras, y moderas allí sin tocar pro.
const { entries } = await neblla.community.queue({ status: 'pending' });
await neblla.community.moderate(entryId, { action: 'approve' });
await neblla.community.moderate(entryId, { action: 'reject', reason: 'copyright', message: 'Partitura con copyright vigente.' });
// action ∈ approve | reject | remove | restore | suspend
// reason ∈ copyright | inappropriate | hate | harassment | spam | illegal | privacy | other
reject y remove exigen reason; el autor recibe community.on('updated') con el motivo y el mensaje. remove conserva la entry y su payload (para apelar por la bandeja dev↔usuario); restore la devuelve a published. El backoffice del dev portal tiene la misma cola con contadores por estado, y el detalle de cada entry muestra sus denuncias agregadas por motivo.
Sobre el autor, desde la ficha del usuario en el backoffice: silenciar en la comunidad (community_muted: sigue usando la app, no publica ni edita), *banned*, *terminated*.
La cola viene por gravedad. Las entries pending y suspended llegan primero las más serias y, dentro de cada nivel, la más antigua primero; cada una trae priority: notice (aviso legal formal) > serious (ilegal, copyright, privacidad, odio o acoso) > reports (suspendida por denuncias) > prescreen (retenida o marcada por el filtro previo) > review (espera aprobación). queue({ status: 'pending', sort: 'oldest' }) da el orden de llegada sin más.
Reincidencia. community.recidivism = { removals: 3, days: 30 } (valores por defecto; removals: 0 lo apaga): cuando a un mismo autor le retiran (remove) removals entries en days días, queda silenciado en la comunidad solo (community_muted, con communityMutedBy: 'recidivism'); moderate devuelve entonces authorAction: 'muted'. Si estando silenciado llega al doble, su ficha del backoffice y neblla_list_users muestran banSuggested: true (authorAction: 'ban_suggested'). Neblla nunca banea ni cierra una cuenta por su cuenta: esa decisión es tuya, y en cuanto cambias su estado la sugerencia desaparece.
Notificaciones formales desde fuera de la app
Cualquiera —sin cuenta y sin ser usuario de la app— puede presentar una notificación formal sobre una entry publicada: un titular de derechos o quien detecte contenido ilegal. El formulario está en /notice?app=<appId>&entry=<entryId> y pide lo que exige un procedimiento de retirada: identidad y contacto del reclamante, la obra afectada, una declaración de buena fe y una firma.
Presentarla suspende la entry al momento (status: 'suspended', moderation.by: 'notice', con moderation.caseId) y abre un caso. Te llega un email avisando de que hay un caso esperando; la reclamación, con los datos del reclamante, está en el detalle de la entry en el panel (Personas › Comunidad, filtro Suspendidas), donde la das por buena o la desestimas, y por MCP como neblla_community_cases. Al autor se le avisa por community.on('updated') y puede contra-notificar desde tu app:
await neblla.community.counterNotice(entryId, 'La compuse yo en 2023; aquí está el proyecto original.');
// → { cases: [{ caseId, status: 'countered' }] } solo el autor; errores: case_not_found, not_the_author, missing_field
El reclamante recibe la respuesta por email. Una contra-notificación no restaura la entry por sí sola, y cerrar un caso tampoco la vuelve a publicar: restaurar es un acto aparte y deliberado, para que ni una respuesta ni un clic sin querer devuelvan algo.
Para poner un enlace de "denunciar un problema legal" en tu propia interfaz, neblla.community.noticeUrl(entryId) da la dirección del formulario para esa entry; la tarjeta pública de la entry (cardUrl) también enlaza a él.
Como las notificaciones formales van dirigidas a Neblla como plataforma, Neblla también puede actuar por su cuenta: suspender o restaurar una entry (queda moderation.by: 'platform' y encabeza la cola) o poner en pausa la comunidad entera de una app. Durante la pausa los usuarios no pueden publicar, leer ni relacionarse con entries (community_suspended) y la tarjeta pública no se ve; tú recibes un email con el motivo, el backoffice lo muestra (y neblla_get_community_config trae platformHold), y sigues pudiendo revisar y moderar. Tu app sigue funcionando.
Cuota y almacenamiento
Publicar y editar cuestan cuota por bytes (community_publish); leer un payload cuesta sus bytes (community_read); listar, facetas, mine, la cola y cada favorita, valoración o voto tienen coste fijo (community_query). La comunidad comparte la base de datos de la app con los datos de usuario (50 MB por cuenta en gratis, compartidos por sus apps; 100 MB por proyecto Pro): al llenarse, publish/update rechazan con community_storage_exceeded.
Todo el contenido de la comunidad es tuyo y vive en la base de datos de tu app: con BYO-DB, en tu base de datos (Turso, Postgres, MySQL o Mongo); sin él, en el Turso por defecto de Neblla. Tablas: community_entries (metadatos indexados, estado, moderación y contadores), community_entry_tags, community_relations (favoritas, valoraciones, reseñas, denuncias, votos), community_links (enlaces de compartir, solo el hash) y community_payloads (el payload); en Mongo, las colecciones neblla_community_*. Neblla solo guarda en su base la configuración de la app, el contador de almacenamiento y la identidad de los usuarios. Las entries de un usuario se borran cuando se borra al usuario de tu app (lo pide él, o lo haces tú desde el backoffice o con el MCP). Si borra su cuenta de Neblla, no: su fila en tu app y lo que publicó se quedan, sin cuenta Neblla detrás.
Un ranking con comunidad
Una tabla de puntuaciones se monta con entries de comunidad y un campo propio para ordenar:
// una vez: neblla_update_app({ community: { fields: { puntos: 'integer' }, index: ['puntos'] } })
// al acabar una partida, en client.js
await neblla.community.publish({
title: nombre, category: 'score', contentType: 'score', lang: 'es',
fields: { puntos }, payload: { at: Date.now(), segundos }
});
// la tabla, legible también por invitados
const { entries } = await neblla.community.list({
contentType: 'score', orderBy: '-puntos', parent: null, limit: 20
});
Así la puntuación la publica el propio jugador y nadie la comprueba: vale para un ranking entre amigos sin nada en juego. Para uno que no se pueda falsear, neblla.scores (ver identidad.txt): la partida empieza en Neblla y lo imposible no entra.
Usa mode: 'open' en la configuración de comunidad, para que cada puntuación se publique al momento en vez de pasar por la cola de revisión. Y si el ranking tiene que vivir con el resto de tus datos, neblla_byodb pone los datos de comunidad en tu propia base de datos, donde puedes consultarlo también con tu propio SQL.
Un espacio compartido más grande que una sala
Un documento que edita un equipo —un tablero, un documento colaborativo, el inventario de una partida larga, una wiki interna— vive aquí y no en room.data, que es para el estado de la sala. Una entry admite 64 KB de payload por defecto, un límite antiabuso que cambias a lo que necesites con neblla_update_app({ community: { maxPayloadBytes: 262144 } }), o quitas con maxPayloadBytes: 0. La base de datos (50 MB por cuenta gratis, compartidos por sus apps; 100 MB por proyecto Pro, o la tuya con BYODB) da para unas ochocientas entries de 64 KB en gratis, y por defecto no hay tope de entries por usuario (maxEntriesPerUser: 0).
Requisitos: el módulo community (features: { community: true }) y login. Los invitados leen; escriben los usuarios con login.
Solo para el equipo. Publícalas visibility: 'protected' con el equipo como grupo, y lo interno del equipo no lo ve nadie más de la app.
Una entry por miembro. Escribir en una entry es cosa de su dueño, de los editores de su grupo (si es protected) o de un moderador de la app. Aun así, para el estado de un equipo, dale a cada miembro su propia entry con una category común, y el estado del equipo es la unión de todas, que cualquiera lee con community.list({ category }). Así no hay conflictos de escritura por construcción, nadie necesita poderes de moderación, y quién cambió qué sale gratis porque cada trozo lleva su owner.
La sala avisa, comunidad guarda. Para que los demás vean un cambio al momento, quien escribe hace su community.update(...) y manda un message.send a la sala con el id de la entry; los demás releen solo esa entry con community.get(id). Por la sala solo viajan punteros de decenas de bytes, así que el tamaño del documento no depende del de la sala.
Lo que cuesta. Publicar o actualizar cuesta los bytes del payload; leer una entry, la petición como un mensaje (64 + sus bytes) y la entry como respuesta (64 + sus bytes); un list o un facets, 50 unidades fijas más la lista; y el aviso por la sala, su tamaño. Parte el estado del equipo en entries por miembro o por sección y relee solo la que cambió. BBDD propia con neblla_byodb mueve la base de datos a tu proveedor; el tráfico de comunidad se cuenta igual.