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)

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)


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 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)


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',   // 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.


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)

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.


// 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

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 }

Moderar desde la app (CLIENT, solo moderadores de la app)


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.