Capítulo comunidad de la guía Neblla — contenido creado y mantenido por los usuarios (entries), búsqueda y moderación. BETA. El contrato base está en core.txt. # Comunidad (BETA) ## Qué es Un almacén 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: si quieres una imagen, la codificas en base64 dentro del payload y pagas su tamaño. 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, dato privado) — fase siguiente; todavía no está en el SDK. Requiere la función `community` activada en Ajustes → Funciones (depende de `login`). Está **apagada por defecto**: activarla implica moderar. ## Modos de moderación (Ajustes → Community) - **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`), tope de payload por entry (por defecto 64 KB, máximo 1 MB), vocabulario de categorías y de `contentType` (vacío = libre), tope de entries por usuario, texto de reglas. 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 retira: borrado real 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_storage_exceeded` (`err.current`, `err.cap`), `not_owner`, `entry_not_found`, `parent_not_found`. Evento `'communityUpdated'` `{ 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', // 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 }` (identidad pública cross-app, nunca el email) y `mine: true` si es tuya. Desde los ficheros de `server/` (`neblla` global) solo existen `community.list`, `community.facets` y `community.get`: el server peer es el navegador de otro jugador 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. 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' }); // relation = { favorite, rating, review, reported } · null borra una ranura: { rating: null } // stats = { favorites, ratingCount, ratingSum, ratingAvg, reviews, reports, children } 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, mine } 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 }` si hay sesión: pintas "mis estrellas" sin una llamada por fila. `sort: 'popular'` ordena por favoritas y `'top'` por valoración media. - 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 **suspende**: vuelve a `pending`, desaparece del listado y el autor recibe `communityUpdated` con `action: 'suspended'`. Aprobarla o restaurarla desestima las denuncias; retirarla las da por buenas. - Un usuario silenciado (`community_muted`) no puede relacionarse con entries, pero sí denunciar. ## Compartir en privado por enlace (CLIENT, requiere login) Una entry puede ser **privada**: no aparece en `list`, `facets` ni búsquedas; solo la ven su autor y quien abra un enlace suyo. 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, 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; // tú decides el formato del enlace // 2. Quien lo recibe abre la app con ese enlace, entra con Neblla y: const { entry, payload } = await neblla.community.openLink(token); // gasta un uso ``` - 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. ### 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`. - 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) ```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 // reason ∈ copyright | inappropriate | hate | harassment | spam | illegal | privacy | other ``` `reject` y `remove` exigen `reason`; el autor recibe `communityUpdated` 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 jugando, no publica ni edita), *banned*, *terminated*. ## Cuota y almacenamiento Publicar y editar cuestan cuota por bytes (`community_publish`); leer un payload cuesta sus bytes (`community_read`); listar, facetas, `mine` y la cola tienen coste fijo (`community_query`). El almacenamiento de la comunidad tiene su propio tope por app (20 MB free, 200 MB Pro), separado del de datos de usuario: al superarlo, `publish`/`update` rechazan con `community_storage_exceeded`. Todo el contenido de la comunidad es tuyo y vive en el almacén 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), `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. Al borrar la cuenta de un usuario, sus entries se borran con ella. ## Lo que aún no está Notificación formal desde fuera de la app (titulares de derechos sin cuenta), aviso al denunciante al cerrarse el caso, peso por fiabilidad del denunciante, dato libre del dev en la relación, URL pública por entry, campos indexados definidos por el dev, filtro previo con IA. Ver el capítulo XXI del backbone.