Muro
Resumen
Sección titulada «Resumen»El Muro es el feed social interno de la organización. La app móvil actúa principalmente como lectora: consume el feed paginado, muestra cada publicación según su tipo, y permite un conjunto acotado de interacciones.
Acciones cubiertas por este módulo en mobile:
- Leer el feed paginado (publicaciones, encuestas, celebraciones y comunicados).
- Comentar publicaciones y comunicados.
- Reaccionar con un emoji a publicaciones, comunicados y comentarios.
- Votar en encuestas y ver resultados en vivo.
- Confirmar o cancelar la asistencia a un evento.
- Listar los grupos del usuario (para filtrar el feed por grupo).
Fuera de alcance en mobile (no documentado aquí): crear/editar/eliminar publicaciones, encuestas o celebraciones, y administración de grupos (crear grupo, agregar/quitar miembros o administradores). Esas operaciones existen en el backend para el portal web, pero el scope funcional de mobile es solo lectura + las interacciones listadas arriba.
sequenceDiagram
participant App as App móvil
participant API as th-core-api
Note over App,API: Carga inicial del feed
App->>API: GET /v2/muro/me/groups
API-->>App: grupos del usuario (para armar el filtro)
App->>API: GET /v2/muro/publications (page, limit, reactions=true, withMyGroups=true)
API-->>App: feed paginado, cada item con su campo "type"
App->>API: GET /v2/muro/celebrations/today/:celebrationFor
API-->>App: celebración de hoy para ese usuario, o null
Note over App: Renderiza cada item del feed según "type" (POST / POLL / CELEBRATION / COMMUNICATION)
rect rgba(90, 130, 255, 0.13)
Note over App,API: Comentar
App->>API: POST /v2/muro/publications/:id/comments
API-->>App: comentario creado
App->>API: GET /v2/muro/publications/:id/comments (page, limit, reactions=true)
API-->>App: comentarios paginados, en árbol por parentId
end
rect rgba(60, 200, 120, 0.13)
Note over App,API: Reaccionar (toggle)
App->>API: POST /v2/muro/reactions/:id
API-->>App: reacción creada, actualizada o eliminada
end
rect rgba(255, 170, 60, 0.13)
Note over App,API: Votar encuesta
App->>API: POST /v2/muro/polls/:id/vote
API-->>App: encuesta con myVote y resultados
loop cada 15s mientras la encuesta esté abierta
App->>API: GET /v2/muro/polls/:id
API-->>App: resultados actualizados
end
end
rect rgba(255, 90, 90, 0.13)
Note over App,API: Confirmar / cancelar asistencia a evento
App->>API: PATCH /v2/me/events/:id/confirm
API-->>App: status CONFIRMED, confirmed=true
App->>API: PATCH /v2/me/events/:id/cancel
API-->>App: status DECLINED, confirmed=false
end
Endpoints
Sección titulada «Endpoints»GET /v2/muro/publications
Sección titulada «GET /v2/muro/publications»Lista paginada de publicaciones publicadas (status = published), ordenadas por publishedAt DESC. Es el endpoint principal del feed.
Query params (además de page/limit/sortBy/filter.* estándar — ver Convenciones):
| Param | Tipo | Obligatorio | Descripción |
|---|---|---|---|
reactions |
boolean | No (default false) |
Si es true, cada item incluye el resumen de reacciones y la reacción del usuario actual. |
withMyGroups |
boolean | No (default false) |
Si es true, incluye también publicaciones de los grupos a los que pertenece el usuario (además de las que no tienen grupo). Si es false, solo se listan publicaciones sin grupo. |
filter.type |
string | No | Filtra por tipo de publicación: POST, POLL, CELEBRATION o COMMUNICATION. |
filter.groupId |
string (UUID) | No | Filtra por un grupo específico. Si se envía, ignora withMyGroups. |
Response — Paginated<MuroPublicationResponseDto>. El campo content cambia de forma según type (ver Lógica de cliente):
{ "data": [ { "id": "b1a2c3d4-0001-4a11-9c22-111111111111", "ownerType": "USER", "ownerId": "jperez", "type": "POST", "visibility": "company", "pinned": 0, "commentsCount": 3, "impressionsCount": 0, "status": "published", "createdAt": "2026-07-14T13:00:00.000Z", "updatedAt": "2026-07-14T13:00:00.000Z", "publishedAt": "2026-07-14T13:00:00.000Z", "groupId": null, "groupName": null, "owner": { "username": "jperez", "avatar": "https://cdn.twiins.com/avatars/jperez.jpg", "name": "Juan", "lastName": "Pérez", "position": { "name": "Analista de RRHH" } }, "content": { "id": "3f1e2d3c-post-0001", "title": null, "content": "¡Arrancamos el sprint con todo! 🚀", "attachments": [] }, "reactions": { "targetId": "b1a2c3d4-0001-4a11-9c22-111111111111", "targetType": "MURO_PUBLICATION", "reactions": [{ "emoji": "👍", "emojiCode": "1f44d", "count": 4 }], "totalReactions": 4, "userReaction": null } }, { "id": "b1a2c3d4-0002-4a11-9c22-222222222222", "ownerType": "USER", "ownerId": "mgomez", "type": "POLL", "visibility": "company", "pinned": 0, "commentsCount": 0, "impressionsCount": 0, "status": "published", "createdAt": "2026-07-15T09:00:00.000Z", "updatedAt": "2026-07-15T09:00:00.000Z", "publishedAt": "2026-07-15T09:00:00.000Z", "groupId": null, "groupName": null, "owner": { "username": "mgomez", "avatar": "https://cdn.twiins.com/avatars/mgomez.jpg", "name": "María", "lastName": "Gómez", "position": { "name": "People Partner" } }, "content": { "id": "poll-0001", "question": "¿Qué día prefieren para el team building?", "selectionType": "SINGLE", "durationAmount": 3, "durationUnit": "DAYS", "closesAt": "2026-07-18T09:00:00.000Z", "closed": false, "totalVoters": 12, "options": [ { "id": "opt-1", "text": "Viernes", "position": 0, "votesCount": 0, "percentage": null }, { "id": "opt-2", "text": "Sábado", "position": 1, "votesCount": 0, "percentage": null } ], "myVote": [], "canSeeResults": false, "voters": [] }, "reactions": { "targetId": "b1a2c3d4-0002-4a11-9c22-222222222222", "targetType": "MURO_PUBLICATION", "reactions": [], "totalReactions": 0, "userReaction": null } } ], "meta": { "itemsPerPage": 10, "totalItems": 34, "currentPage": 1, "totalPages": 4, "sortBy": [["publishedAt", "DESC"]] }, "links": { "current": "...?page=1&limit=10" }}Errores: solo los genéricos de Convenciones (401/403).
GET /v2/muro/publications/{id}
Sección titulada «GET /v2/muro/publications/{id}»Obtiene una publicación puntual por id (cualquier type).
| Query param | Tipo | Descripción |
|---|---|---|
reactions |
boolean | Igual que en el listado. |
Response: un único MuroPublicationResponseDto, misma forma que un item del listado.
GET /v2/muro/celebrations/today/{celebrationFor}
Sección titulada «GET /v2/muro/celebrations/today/{celebrationFor}»Devuelve la celebración (cumpleaños, aniversario o promoción) ya publicada hoy para el usuario indicado en celebrationFor (su username).
| Param | Tipo | Descripción |
|---|---|---|
celebrationFor (path) |
string | Username del usuario celebrado. |
reactions (query) |
boolean | Igual que en el listado. |
Response: un MuroPublicationResponseDto con type: "CELEBRATION", o null si todavía no se publicó ninguna celebración para ese usuario hoy.
{ "id": "b1a2c3d4-0003-4a11-9c22-333333333333", "ownerType": "BUSINESS", "type": "CELEBRATION", "content": { "id": "cel-0001", "celebrationFor": "jperez", "celebrationType": "BIRTHDAY", "message": "¡Feliz cumpleaños, Juan! 🎉", "attachments": [], "celebratedUser": { "username": "jperez", "avatar": "https://cdn.twiins.com/avatars/jperez.jpg", "name": "Juan", "lastName": "Pérez", "details": { "birthDate": "1994-07-16" } } }}Errores: solo los genéricos (401/403).
GET /v2/muro/me/groups
Sección titulada «GET /v2/muro/me/groups»Lista los grupos a los que pertenece el usuario autenticado, con su rol.
Response:
[ { "id": "grp-0001", "name": "Equipo Producto", "description": "Novedades del equipo de Producto", "image": null, "type": "PRIVATE", "membersCount": 14, "createdAt": "2025-11-02T10:00:00.000Z", "owner": { "username": "mgomez", "avatar": "...", "name": "María", "lastName": "Gómez" }, "isAdmin": false, "role": "MEMBER" }]| Campo | Enum | Valores |
|---|---|---|
type |
MuroGroupTypeEnum |
PUBLIC, PRIVATE |
role / isAdmin |
MuroGroupMemberRoleEnum |
OWNER, ADMIN, MEMBER — isAdmin es true para OWNER/ADMIN |
Usa el id de cada grupo como valor de filter.groupId al pedir el feed filtrado por grupo.
Comentarios
Sección titulada «Comentarios»GET /v2/muro/publications/{id}/comments
Sección titulada «GET /v2/muro/publications/{id}/comments»Lista paginada de comentarios de una publicación (aplica a cualquier type, incluyendo COMMUNICATION: el feed reutiliza este mismo endpoint con el id de la publicación, no el id interno del comunicado).
| Query param | Tipo | Obligatorio | Descripción |
|---|---|---|---|
page |
number | Sí (el backend usa 1 si no llega) |
Página. |
limit |
number | Sí (el backend usa 10 si no llega) |
Ítems por página. |
reactions |
boolean | No | Incluye reacciones de cada comentario (y de sus respuestas). |
Response: { data: MuroComment[], meta } — sin el wrapper links del paginador estándar. Los comentarios respuesta vienen anidados en comments.
{ "data": [ { "id": 501, "publicationId": "b1a2c3d4-0001-4a11-9c22-111111111111", "userId": "mgomez", "parentId": null, "content": "¡Vamos con todo!", "createdAt": "2026-07-14T13:05:00.000Z", "updatedAt": "2026-07-14T13:05:00.000Z", "user": { "username": "mgomez", "avatar": "...", "name": "María", "lastName": "Gómez" }, "comments": [ { "id": 502, "parentId": 501, "userId": "jperez", "content": "Gracias María!", "createdAt": "2026-07-14T13:10:00.000Z", "user": { "username": "jperez", "avatar": "...", "name": "Juan", "lastName": "Pérez" }, "comments": [] } ] } ], "meta": { "totalItems": 3, "itemsPerPage": 10, "totalPages": 1, "currentPage": 1 }}POST /v2/muro/publications/{id}/comments
Sección titulada «POST /v2/muro/publications/{id}/comments»Crea un comentario (o una respuesta, si se envía parentId) en una publicación.
Body — CreateCommentDto:
| Campo | Tipo | Obligatorio | Descripción |
|---|---|---|---|
content |
string | Sí | Máximo 500 caracteres. |
parentId |
number | No | Id del comentario padre, para responder un hilo. |
Response: el MuroComment creado (mismo shape que un item de la lista, sin comments anidados).
Errores: 400 si content supera 500 caracteres o está vacío.
Reacciones
Sección titulada «Reacciones»POST /v2/muro/reactions/{id}
Sección titulada «POST /v2/muro/reactions/{id}»Crea, actualiza o elimina (toggle) la reacción del usuario a una publicación o a un comentario del muro. No existe un endpoint separado para “quitar” reacción: enviar el mismo emoji que ya tenías la elimina.
Body — CreateMuroReactionDto:
| Campo | Tipo | Obligatorio | Descripción |
|---|---|---|---|
emoji |
string | Sí | Exactamente un carácter emoji Unicode válido (el backend no restringe a una lista fija; el cliente de referencia ofrece 6: 👍 🎉 🫶 ❤️ 💡 🤔). |
targetType |
enum | Sí | MURO_PUBLICATION (reaccionar a la publicación, id = id de la publicación) o MURO_COMMENT (reaccionar a un comentario, id = id numérico del comentario). |
Response — ReactionResponseDto:
{ "targetId": "b1a2c3d4-0001-4a11-9c22-111111111111", "targetType": "MURO_PUBLICATION", "emoji": "🎉", "emojiCode": "1f389", "userId": "jperez", "createdAt": "2026-07-16T15:00:00.000Z"}Errores: 400 si emoji no es exactamente un carácter emoji válido, o si targetType no es uno de los dos valores permitidos.
Encuestas
Sección titulada «Encuestas»GET /v2/muro/polls/{id}
Sección titulada «GET /v2/muro/polls/{id}»Obtiene una encuesta puntual (envuelta en su publicación), mapeada según el usuario autenticado. Se usa para refrescar resultados en vivo mientras la encuesta sigue abierta.
Response: MuroPublicationResponseDto con type: "POLL" — mismo content que en el feed (ver tabla de campos en Lógica de cliente).
Errores: 400 "Poll not found" si el id no corresponde a una encuesta existente.
POST /v2/muro/polls/{id}/vote
Sección titulada «POST /v2/muro/polls/{id}/vote»Registra (o reemplaza) el voto del usuario autenticado en una encuesta abierta.
Body — VotePollDto:
| Campo | Tipo | Obligatorio | Descripción |
|---|---|---|---|
optionIds |
string[] (UUID) | Sí | Mínimo 1 elemento. Para encuestas SINGLE, debe traer exactamente una opción. |
Response: igual que GET /v2/muro/polls/{id} — la publicación completa con content.myVote, content.options[].votesCount/percentage y content.canSeeResults ya actualizados para el usuario que acaba de votar.
Errores:
| Código | message |
Causa |
|---|---|---|
400 |
Poll not found |
El id no corresponde a una encuesta. |
400 |
Poll is closed |
closesAt ya pasó. |
400 |
Single selection polls require exactly one option |
Encuesta SINGLE con optionIds.length !== 1. |
400 |
One or more options do not belong to the poll |
Algún optionId no pertenece a esta encuesta. |
Comunicados
Sección titulada «Comunicados»GET /v2/me/communications
Sección titulada «GET /v2/me/communications»Lista paginada de comunicados institucionales dirigidos al usuario. Es un listado independiente del feed de /v2/muro/publications (no lo reemplaza): sirve para una vista dedicada de “Comunicados” si la app la necesita. Los comunicados con publishOnWall = true también aparecen mezclados en el feed principal como type: "COMMUNICATION".
| Query param | Tipo | Descripción |
|---|---|---|
reactions |
boolean | Incluye resumen de reacciones. |
page, limit, sortBy, filter.* |
— | Paginación estándar. |
Response — Paginated<CommunicationResponseDto>:
{ "data": [ { "id": "comm-0001", "subject": "Nueva política de home office", "summary": "A partir de agosto, el esquema híbrido pasa a 3 días presenciales.", "text": "<p>Detalle completo de la política...</p>", "coverImageUrl": "https://cdn.twiins.com/comm/cover-0001.jpg", "status": "PUBLISHED", "publishedAt": "2026-07-10T08:00:00.000Z", "expiredAt": null, "publicationId": "b1a2c3d4-0004-4a11-9c22-444444444444", "commentsCount": 2, "attachments": [], "user": { "username": "rrhh_admin", "name": "Recursos", "lastName": "Humanos" }, "channels": ["WALL"] } ], "meta": { "itemsPerPage": 10, "totalItems": 5, "currentPage": 1, "totalPages": 1 }}GET /v2/muro/communications/{id}/comments
Sección titulada «GET /v2/muro/communications/{id}/comments»Lista paginada de comentarios de un comunicado, usando su id propio (content.id / el id que devuelve GET /v2/me/communications). Mismos query params que GET /v2/muro/publications/{id}/comments (page, limit requeridos por Swagger pero con default 1/10; reactions opcional).
Response: mismo shape que el listado de comentarios de publicaciones.
POST /v2/muro/communications/{id}/comments
Sección titulada «POST /v2/muro/communications/{id}/comments»Crea un comentario en un comunicado, por su id propio.
Body: mismo CreateCommentDto que en publicaciones (content, parentId opcional).
Response: el MuroComment creado.
POST /v2/muro/communications/reactions/{id}
Sección titulada «POST /v2/muro/communications/reactions/{id}»Crea, actualiza o elimina (toggle) la reacción a un comunicado o a un comentario de comunicado, por id propio.
Body — CreateCommunicationReactionDto:
| Campo | Tipo | Obligatorio | Descripción |
|---|---|---|---|
emoji |
string | Sí | Igual regla que en reacciones del muro. |
targetType |
enum | Sí | Valores en el wire: "COMMUNICATION" (reaccionar al comunicado) o "COMM_COMMENT" (reaccionar a un comentario del comunicado). |
Response: ReactionResponseDto, igual forma que la reacción de publicaciones.
Eventos
Sección titulada «Eventos»GET /v2/me/events
Sección titulada «GET /v2/me/events»Lista paginada de eventos próximos visibles para el usuario (eventos de su empresa, según su información organizacional).
| Query param | Tipo | Obligatorio | Descripción |
|---|---|---|---|
from |
string (ISO date) | No | Inicio del rango. Excluyente con range. |
to |
string (ISO date) | No | Fin del rango. Excluyente con range. |
range |
enum | No | day, week o month — preset de rango calculado en el servidor. Excluyente con from/to. |
attendeeStatus |
enum | No | Filtra por el estado de asistencia del usuario actual: CONFIRMED, INVITED, DECLINED. |
page, limit, sortBy |
— | No | Paginación estándar. |
Response — Paginated<EventListItemResponseDto>:
{ "data": [ { "id": "evt-0001", "title": "Team building Q3", "description": "Salida de integración del equipo", "date": "2026-07-25T15:00:00.000Z", "endDate": "2026-07-25T19:00:00.000Z", "location": "Parque La Carolina", "attendees": 18, "canEdit": false, "canDelete": false, "confirmed": false, "coverImage": "https://cdn.twiins.com/events/evt-0001-cover.jpg", "eventType": "PRESENCIAL", "capacity": 30, "eventLink": null, "status": "ACTIVE" } ], "meta": { "itemsPerPage": 10, "totalItems": 4, "currentPage": 1, "totalPages": 1 }}GET /v2/me/events/{id}
Sección titulada «GET /v2/me/events/{id}»Detalle completo de un evento, incluyendo adjuntos.
Response — EventDetailResponseDto (extiende el item de lista con):
| Campo | Tipo | Descripción |
|---|---|---|
images |
string[] | URLs firmadas de los adjuntos tipo imagen. |
links |
string[] | URLs externas adjuntas. |
files |
{ name, url }[] |
Documentos adjuntos, con URL firmada de descarga. |
Errores: 404 "Event not found" si el evento no existe o no es visible para el usuario (empresa distinta).
PATCH /v2/me/events/{id}/confirm
Sección titulada «PATCH /v2/me/events/{id}/confirm»Confirma la asistencia del usuario autenticado. Es idempotente: confirmar dos veces no falla.
Response — AttendanceResult:
{ "status": "CONFIRMED", "attendees": 19, "confirmed": true }Errores:
| Código | message / code |
Causa |
|---|---|---|
404 |
Event not found |
El evento no existe. |
404 |
You are not invited to this event |
El usuario no está en la lista de invitados del evento (no hay fila de asistente para él). |
409 |
code: "EVENT_FULL" |
El evento tiene maxCapacity y ya se alcanzó (confirmedCount >= maxCapacity). |
PATCH /v2/me/events/{id}/cancel
Sección titulada «PATCH /v2/me/events/{id}/cancel»Cancela (declina) la asistencia del usuario autenticado. También idempotente.
Response — AttendanceResult:
{ "status": "DECLINED", "attendees": 18, "confirmed": false }Errores: mismos 404 que en confirm (evento inexistente / usuario no invitado). No aplica el 409 de capacidad.
Lógica de cliente
Sección titulada «Lógica de cliente»Discriminador de tipo de publicación
Sección titulada «Discriminador de tipo de publicación»Cada item del feed (GET /v2/muro/publications) trae un campo type (MuroPublicationTypeEnum) que determina cómo renderizarlo y de qué forma es content:
type |
Qué es | Forma de content |
|---|---|---|
POST |
Publicación de texto/imagen de un usuario o de la empresa (ownerType: "BUSINESS") |
{ id, title, content, attachments[] } |
POLL |
Encuesta | Ver tabla de encuestas más abajo |
CELEBRATION |
Cumpleaños / aniversario / ascenso | { id, celebrationFor, celebrationType, message, attachments[], celebratedUser } |
COMMUNICATION |
Comunicado institucional publicado en el muro | El objeto Communication completo (id propio distinto al id de la publicación — ver aviso arriba) |
Reglas adicionales de presentación:
ownerType: "BUSINESS"→ mostrar como publicación institucional (avatar/nombre = logo y nombre del tenant, no de un usuario).groupNamepresente (nonull) → la publicación pertenece a un grupo; mostrar el nombre del grupo como badge.pinned === 1→ destacar visualmente (el comunicado “fijado”).- El botón de editar/eliminar solo aplica a
POSTy solo dentro de los 30 minutos posteriores apublishedAt(el backend rechazaPATCH/DELETEde publicaciones fuera de esa ventana) — irrelevante para mobile ya que esas acciones no están en scope, pero explica por qué unPOST“viejo” nunca debería mostrar acciones de edición si en algún momento se agregan.
Encuestas (content cuando type: "POLL")
Sección titulada «Encuestas (content cuando type: "POLL")»| Campo | Tipo | Descripción |
|---|---|---|
selectionType |
SINGLE | MULTIPLE |
Determina la UX de voto (ver abajo). |
closesAt |
string (ISO) | Fecha/hora de cierre. |
closed |
boolean | true si now >= closesAt. |
myVote |
string[] | Ids de las opciones que votó el usuario actual. Vacío si no votó. |
canSeeResults |
boolean | true si el usuario ya votó o la encuesta está cerrada. |
options[].votesCount / percentage |
number | null | Solo tienen valor real cuando canSeeResults es true; si no, vienen en 0/null para no filtrar resultados a quien no votó. |
voters |
array | Detalle de quién votó qué opción — solo se llena si canSeeResults && myVote.length > 0. |
totalVoters |
number | Cantidad de usuarios únicos que votaron (visible siempre, incluso sin canSeeResults). |
Reglas de UI:
- Si
!canSeeResults && !closed→ mostrar los botones de votación (PollOptionsVote), no los resultados.selectionType: "SINGLE"→ tocar una opción vota inmediatamente (POST /polls/{id}/votecon un solooptionId).selectionType: "MULTIPLE"→ tocar opciones las selecciona/deselecciona localmente (multi-toggle); un botón “Votar” separado confirma y envía todos losoptionIdsseleccionados. No permitir confirmar con 0 opciones seleccionadas.
- Si
canSeeResults || closed→ mostrar resultados (barra de progreso por opción conpercentage) en vez de los botones de voto. - Mientras la encuesta esté abierta y visible en pantalla, refrescar con
GET /v2/muro/polls/{id}cada ~15 segundos (polling) para reflejar nuevos votos de otros usuarios; detener el polling cuandocontent.closed === true. - Un usuario puede cambiar su voto mientras la encuesta esté abierta: volver a llamar
POST /votecon otras opciones reemplaza el voto anterior (no lo suma).
Celebraciones
Sección titulada «Celebraciones»celebratedUser.details trae birthDate/entryDate según celebrationType (BIRTHDAY, ANNIVERSARY, PROMOTION — MuroCelebrationTypeEnum); úsalos solo para mostrar contexto (por ejemplo la fecha), el message ya viene resuelto por el backend listo para mostrar.
Eventos: estados de asistencia
Sección titulada «Eventos: estados de asistencia»El backend maneja tres estados por asistente (AttendeeStatus: INVITED, CONFIRMED, DECLINED), pero los DTOs de lectura solo exponen un booleano confirmed, no el estado completo. La UI de referencia usa esta regla binaria:
confirmed |
Botón a mostrar | Acción |
|---|---|---|
false |
“Confirmar asistencia” | PATCH /v2/me/events/{id}/confirm |
true |
“Cancelar asistencia” | PATCH /v2/me/events/{id}/cancel |
Otras reglas:
canEdit/canDeletecontrolan acciones fuera de scope de mobile (edición/borrado de evento) — ignóralos si no se implementan esas acciones.- Si
PATCH .../confirmresponde409concode: "EVENT_FULL", deshabilita el botón de confirmar y muestra el mensaje del evento lleno; no reintentar automáticamente. - Si
PATCH .../confirmo.../cancelresponde404 "You are not invited to this event", el usuario no es un invitado válido de ese evento — oculta las acciones de asistencia (no debería llegar a este estado si el evento salió deGET /v2/me/events, que ya filtra por visibilidad, pero puede pasar con deep links viejos).
Reacciones: comportamiento de toggle
Sección titulada «Reacciones: comportamiento de toggle»POST /v2/muro/reactions/{id} (o su equivalente de comunicados) es la misma llamada para reaccionar por primera vez, cambiar de emoji o quitar la reacción:
- Sin reacción previa del usuario +
emojinuevo → se crea la reacción. - Reacción previa con otro emoji +
emojinuevo → se actualiza (reemplaza) la reacción. - Reacción previa con el mismo
emojique ya tenías → se elimina (toggle “apagar”).
Para saber qué mostrar antes de la primera interacción, usa reactions.userReaction del item (pedido con ?reactions=true): si no es null, resalta ese emoji como “tu reacción” en la UI.
Comentarios en árbol
Sección titulada «Comentarios en árbol»GET .../comments devuelve los comentarios raíz con sus respuestas anidadas en comments. Reglas de UI:
- Solo se permite un nivel de respuesta en la UI de referencia (una respuesta a una respuesta se sigue guardando con
parentIdapuntando al comentario raíz, no se anida visualmente más de un nivel). - El contador
publication.commentsCountes la fuente de verdad para el badge “N comentarios”; no lo calcules sumando el array de comentarios cargado (puede estar paginado/incompleto).