Cuaderno — Multiusuario y Sharing Colaborativo
Introducida en el commit ab1c02f (2026-05-20), esta feature convierte el Cuaderno de un módulo mono-usuario a un módulo colaborativo con control de acceso por nota, detección de edición concurrente y adjuntos de imagen.
Modelo de acceso (sharing)
Cada notebook_note tiene el campo shared_with TEXT NOT NULL DEFAULT '' con tres semánticas:
Valor de shared_with | Significado |
|---|---|
'' (cadena vacía) | Privada — solo el owner_id puede leer y editar |
'ALL' | Todo el equipo — cualquier miembro del staff con sesión activa |
'a@x.com,b@y.com' | CSV de emails — solo los destinatarios explícitos |
Seguridad de la búsqueda LIKE: el filtro
shared_with LIKE '%email%'es seguro con emails@esfericlabs.comporque no hay colisiones de substrings en el staff actual. Si se incorporara staff externo, revisar.
Reglas de autorización
- Lectura: owner OR
shared_with = 'ALL'ORshared_with LIKE '%email%'. - Edición (PUT): owner OR cualquier collaborador listado. Los collaboradores no pueden cambiar el
shared_with— campo restringido al owner. - Borrado (DELETE): exclusivo del owner. Los collaboradores solo leen y editan.
- Upload de imágenes: owner OR collaborador.
Cambios de esquema (Migración 0032)
Archivo: migrations/0032_notebook_sharing.sql
ALTER TABLE notebook_notes ADD COLUMN shared_with TEXT NOT NULL DEFAULT '';
ALTER TABLE notebook_notes ADD COLUMN version INTEGER NOT NULL DEFAULT 1;
CREATE INDEX IF NOT EXISTS idx_notebook_shared_with ON notebook_notes(shared_with);
El campo version se incrementa con version = version + 1 en cada PUT, lo que permite al frontend detectar saves concurrentes mediante polling ligero.
Endpoints afectados / nuevos
GET /api/notebook (modificado)
- Antes: devolvía solo notas propias (
WHERE owner_id = ?). - Ahora: devolvía propias + compartidas (
WHERE owner_id = ?1 OR shared_with = 'ALL' OR shared_with LIKE ?2). - Nuevos campos en la respuesta:
shared_with,version.
GET /api/notebook/[id] (refactorizado)
Movido de functions/api/notebook/[id].ts a functions/api/notebook/[id]/index.ts.
loadOwned → loadAccessible: usa hasAccess() en lugar de filtrar solo por owner_id.
PUT /api/notebook/[id] (modificado)
- Acepta nuevo campo
shared_with(solo ejecutado sime === existing.owner_id). - Incrementa
versionen cada save. - El log de actividad ahora también registra cambios de
shared_with.
GET /api/notebook/[id]/version (nuevo)
Polling endpoint ligero. Devuelve solo { version, updated_at }. Usado por el editor cada 30 s en notas compartidas para mostrar el banner de edición concurrente.
POST /api/notebook/[id]/images (nuevo)
Upload multipart a R2. Restricciones: 5 MB máx, tipos permitidos image/png | jpeg | webp | gif. Guarda fila en notebook_attachments y devuelve { id, url, filename, content_type }.
GET /api/notebook-images/[id] (nuevo)
Stream del objeto R2. Verifica acceso a la nota padre antes de servir. Headers: Cache-Control: private, max-age=3600.
Cambios en frontend
Toolbar — 7 nuevas funciones
| Botón | Acción |
|---|---|
| ↶ Undo | undo de @codemirror/commands |
| ↷ Redo | redo de @codemirror/commands |
| Aa | Toggle spellcheck/autocorrect/autocapitalize vía Compartment (persistido en localStorage) |
| ⚠ | Inserta !!texto!! → renderizado como <mark class="urgent"> (bg rojo translúcido + border izq rojo) |
| 🖼 | File input → POST /api/notebook/[id]/images → inserta  |
| 😊 | Abre EmojiPicker custom (~100 emojis, 6 categorías, búsqueda, sin deps externas) |
| Compartir | ShareDropdown (Privada / Todo el equipo / multi-select). Solo visible al owner. |
Banner de edición concurrente
En notas compartidas el editor hace polling cada 30 s a /api/notebook/[id]/version. Si version remota > version local, muestra banner amarillo:
“X editó esta nota hace Nm. Refrescar / Ignorar”
NoteRow en sidebar
- Badge ”👤 Nombre” con border izquierdo cyan si la nota es ajena (shared recibida).
- Badge ”🔗” si es propia y está compartida.
- Acciones duplicar/borrar solo visibles para el owner.
NoteEditor meta header
Muestra "Compartida por X" (notas ajenas) o el label "Compartida · Edu, Dani" (propias compartidas).
Dependencias técnicas
- R2 bucket:
TASK_ATTACHMENTS(shared con task-attachments, prefijonotebook/). - D1: tabla
notebook_attachments(ver [[entity—cuaderno—endpoint—notebook-attachments]]). resolveActorEmail: todos los handlers migran derequest.headers.get('cf-access-authenticated-user-email')aresolveActorEmail(request)de_lib/staff.@codemirror/commands: ya era dependencia del editor; se exponenundo/redo.
Limitaciones conocidas
- El filtro
LIKE '%email%'no escala a equipos grandes o con emails con substrings comunes. Solución futura: tabla pivotnotebook_shares. - No hay resolución de conflictos merge: el último en guardar gana. El banner es solo aviso.
EmojiPickerbusca solo por nombre de categoría, no por nombre de emoji.
Véase también
- [[entity—cuaderno—endpoint—notebook-attachments]]
- [[entity—cuaderno—endpoint—notebook-version]]