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 a `pending`.
- **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, lleva `flagged: { reason }`, visible para todos, para que tu app la difumine o avise antes de enseñarla. Un moderador la limpia con `moderate(id, { action: 'approve' })`.
- **`hold`** — si la IA pone pegas, la entry espera en la cola (`pending`) con `moderation.by: 'prescreen'` y el motivo. Si no se pudo comprobar (sin clave, o la IA no respondió), también espera, con `moderation.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)
```js
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: '…' },
});
// 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)
```js
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' | '',
parent: null | '',
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:
```js
// 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`, en `list`, `get` y `publish`.
- `where` es `{ campo: valor }` o `{ campo: { eq, ne, gt, gte, lt, lte, in } }`, como en las tablas; `orderBy`, `'campo'` o `'-campo'`. Los de `index` van con índice.
- Errores: `unknown_field` (no está declarado) e `invalid_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`:
```js
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 filtro `lang: 'fr'` la encuentra si está en francés **o** traducida al francés.
- Con `locale`, `list` y `get` devuelven `title`/`summary` en 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 en `shownLang` en qué idioma está lo que ves y en `langs` todos los que tiene. Una traducción sin `summary` muestra el propio.
- `get` trae además `text` (el texto propio) e `i18n` entero, para editarlo. En `update`, `i18n` **sustituye** el mapa completo; `i18n: null` lo vacía.
- Hasta **16 traducciones**, con los mismos topes que el texto propio (`title` 120, `summary` 500). 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//entries?q=&category=&tags=a,b&lang=&contentType=&parent=null&sort=&cursor=&limit=&locale=
GET /api/community/public//entries/?locale= → { entry, payload }
GET /api/community/public//entries//reviews?cursor=&limit=
GET /api/community/public//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//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 con `update(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.
```js
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()` y `favorites()` traen en cada entry `me: { 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)`, donde `reviewerId` es el `author.id` de la reseña; también pueden los moderadores, y el dev desde su servidor (`POST /api/backend/apps//community/reply`) o el MCP. Una respuesta por reseña: la nueva sustituye a la anterior y `null` la retira. Sale bajo la reseña en `reviews()` como `reply` y, para quien reseñó, en su `relation()`. 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//community/uses` con `{ 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.reportThreshold` denunciantes distintos (por defecto 3; 0 = nunca) la entry se auto-suspende: su `status` pasa a **`pending`** (no a `suspended`), desaparece del listado y el autor recibe `community.on('updated')` con `action: 'suspended'`. Eso importa al trabajar la cola: en `pending` conviven las entries que esperan primera aprobación y las que han caido por denuncias — distínguelas por `moderation.by`, que vale `'reports'` en las automáticas. El status `suspended` es 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.
```js
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 con `downvotes_off`. Apagarlo después hace que los votos en contra que ya hay dejen de contar: `downvotes` sale 0, `score` es solo los votos a favor y `sort: 'votes'` ordena por ellos. No se borran: si lo vuelves a encender, vuelven a contar. Quien votó en contra sigue viendo su `me.vote: -1` y puede retirarlo con `vote(id, 0)`.
- `sort: 'votes'` ordena por `score`; a igual `score`, la que tiene más votos a favor.
- El autor no vota lo suyo (`own_entry`). Un voto que no es `1`, `-1`, `0` ni `null` da `invalid_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_query` por 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.
```js
// 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.
- `openLink` exige sesión (`login_required`). Un enlace desconocido, gastado o caducado responde siempre `link_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_links` de 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
```js
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 }
```
- `id` es 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`); un `id` desconocido → `link_not_found`.
### Tarjeta pública de una entry
Para que un usuario pegue una entry en Discord, WhatsApp o X:
```js
const url = neblla.community.cardUrl(entry.id); // https://play.neblla.com/c//
// … 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 `image` de la entry (sin `image`, 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=`; `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: true` la 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.
```js
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=&entry=` 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:
```js
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:
```javascript
// 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.