Sternboard: free retro board, no signup (initial)
CI/CD Pipeline / test (push) Successful in 26s
CI/CD Pipeline / build-and-push (push) Successful in 44s

Servidor Fastify + Socket.IO + Drizzle/Postgres; front Vite+React (EN/ES, tema claro/oscuro).
Tableros por columnas con plantillas, notas privadas/públicas, agrupación, votos
configurables (multivoto), timer compartido con alarma, fases, action items,
export Markdown/JSON e import, autodestrucción, gestión de columnas.
Imagen única (front servido por el servidor) y CI que la construye y publica.
This commit is contained in:
Millaguie
2026-06-16 10:27:33 +02:00
commit f11ea58c2c
64 changed files with 12359 additions and 0 deletions
+278
View File
@@ -0,0 +1,278 @@
import Fastify, { type FastifyInstance } from "fastify";
import cors from "@fastify/cors";
import fastifyStatic from "@fastify/static";
import { Server } from "socket.io";
import { lt, isNotNull, and } from "drizzle-orm";
import { nanoid } from "nanoid";
import { db, schema } from "./db/index.js";
import { TEMPLATES, getTemplate } from "./templates.js";
import { registerSockets } from "./socket.js";
import { loadBoardState } from "./room.js";
import { boardToMarkdown, DEFAULT_MD_OPTIONS } from "./markdown.js";
export type BuiltServer = {
app: FastifyInstance;
io: Server;
listen: (port: number) => Promise<number>;
close: () => Promise<void>;
};
export function buildServer(opts: { webOrigin?: string; cleanup?: boolean } = {}): BuiltServer {
const webOrigin = opts.webOrigin ?? process.env.WEB_ORIGIN ?? "http://localhost:5173";
const app = Fastify({ logger: false });
let cleanupTimer: ReturnType<typeof setInterval> | null = null;
app.register(cors, { origin: webOrigin });
app.get("/api/templates", async () => TEMPLATES);
app.post("/api/boards", async (req, reply) => {
const body = req.body as {
title?: string;
templateId?: string;
columns?: string[];
votesPerUser?: number;
ttlHours?: number | null;
};
const title = (body.title ?? "").trim() || "Retro";
const votesPerUser = clampInt(body.votesPerUser ?? 3, 0, 50);
let columnTitles: string[] = [];
if (body.templateId) {
const tpl = getTemplate(body.templateId);
if (!tpl) return reply.code(400).send({ error: "template_not_found" });
columnTitles = tpl.columns;
} else if (body.columns && body.columns.length > 0) {
columnTitles = body.columns.map((c) => c.trim()).filter(Boolean);
}
if (columnTitles.length === 0) columnTitles = ["Columna 1", "Columna 2", "Columna 3"];
const id = nanoid(12);
const moderatorToken = nanoid(24);
const expiresAt =
body.ttlHours && body.ttlHours > 0 ? new Date(Date.now() + body.ttlHours * 3600 * 1000) : null;
await db.transaction(async (tx) => {
await tx.insert(schema.boards).values({ id, title, votesPerUser, moderatorToken, expiresAt });
await tx
.insert(schema.columns)
.values(columnTitles.map((t, i) => ({ id: nanoid(), boardId: id, title: t, position: i })));
});
return reply.send({ boardId: id, moderatorToken, expiresAt: expiresAt?.toISOString() ?? null });
});
app.get("/api/boards/:id/export", async (req, reply) => {
const { id } = req.params as { id: string };
const state = await loadBoardState(id);
if (!state) return reply.code(404).send({ error: "board_not_found" });
const q = req.query as Record<string, string | undefined>;
// un parámetro presente y != '0'/'false' cuenta como activado; si no se pasa ninguno, defaults
const on = (v: string | undefined, dflt: boolean) => (v === undefined ? dflt : v !== "0" && v !== "false");
const md = boardToMarkdown(state, {
authors: on(q.authors, DEFAULT_MD_OPTIONS.authors),
votes: on(q.votes, DEFAULT_MD_OPTIONS.votes),
expandGroups: on(q.groups, DEFAULT_MD_OPTIONS.expandGroups),
creationDate: on(q.created, DEFAULT_MD_OPTIONS.creationDate),
exportDate: on(q.exported, DEFAULT_MD_OPTIONS.exportDate),
});
// ?download=1 fuerza descarga; si no, texto inline (para la previsualización)
if (q.download && q.download !== "0") {
reply.header("Content-Disposition", `attachment; filename="${slugify(state.title)}.md"`);
}
reply.header("Content-Type", "text/markdown; charset=utf-8").send(md);
});
app.get("/api/boards/:id", async (req, reply) => {
const { id } = req.params as { id: string };
const state = await loadBoardState(id);
if (!state) return reply.code(404).send({ error: "board_not_found" });
return reply.send({ id: state.id, title: state.title, expiresAt: state.expiresAt });
});
// Export completo del board a JSON (config + columnas + grupos + notas + votos + acciones).
app.get("/api/boards/:id/export.json", async (req, reply) => {
const { id } = req.params as { id: string };
const state = await loadBoardState(id);
if (!state) return reply.code(404).send({ error: "board_not_found" });
const snapshot = {
version: 1 as const,
board: { title: state.title, votesPerUser: state.votesPerUser, allowMultiVote: state.allowMultiVote },
columns: state.columns,
groups: state.groups,
notes: state.notes,
votes: state.votes,
actionItems: state.actionItems.map((a) => ({ text: a.text, owner: a.owner })),
};
reply
.header("Content-Type", "application/json; charset=utf-8")
.header("Content-Disposition", `attachment; filename="${slugify(state.title)}.retro.json"`)
.send(JSON.stringify(snapshot, null, 2));
});
// Import: crea un board NUEVO desde un snapshot. mode 'full' (todo) o 'structure' (solo columnas+config).
app.post("/api/boards/import", async (req, reply) => {
const body = req.body as {
snapshot?: ImportSnapshot;
mode?: "full" | "structure";
title?: string;
ttlHours?: number | null;
};
const snap = body.snapshot;
const mode = body.mode === "structure" ? "structure" : "full";
if (!snap || !Array.isArray(snap.columns) || snap.columns.length === 0) {
return reply.code(400).send({ error: "invalid_snapshot" });
}
const result = await importBoard(snap, mode, { title: body.title, ttlHours: body.ttlHours });
return reply.send(result);
});
// En producción servimos el front estático (build de Vite) desde el mismo servidor,
// con fallback SPA: cualquier ruta que no sea /api ni /socket.io devuelve index.html.
if (process.env.WEB_DIST) {
app.register(fastifyStatic, { root: process.env.WEB_DIST, prefix: "/" });
app.setNotFoundHandler((req, reply) => {
if (req.url.startsWith("/api") || req.url.startsWith("/socket.io")) {
return reply.code(404).send({ error: "not_found" });
}
return reply.sendFile("index.html");
});
}
const io = new Server(app.server, { cors: { origin: webOrigin } });
registerSockets(io);
if (opts.cleanup) {
cleanupTimer = setInterval(() => runCleanup(io).catch(() => {}), 60_000);
}
return {
app,
io,
listen: async (port: number) => {
await app.listen({ port, host: "0.0.0.0" });
const addr = app.server.address();
return typeof addr === "object" && addr ? addr.port : port;
},
close: async () => {
if (cleanupTimer) clearInterval(cleanupTimer);
io.close();
await app.close();
},
};
}
// Borra boards caducados y avisa a sus rooms. Exportado para poder testearlo.
export async function runCleanup(io: Server): Promise<string[]> {
const expired = await db
.delete(schema.boards)
.where(and(isNotNull(schema.boards.expiresAt), lt(schema.boards.expiresAt, new Date())))
.returning({ id: schema.boards.id });
for (const b of expired) io.to(b.id).emit("board:destroyed");
return expired.map((b) => b.id);
}
function clampInt(n: number, min: number, max: number) {
return Math.max(min, Math.min(max, Math.round(n)));
}
function slugify(s: string) {
return s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "retro";
}
export type ImportSnapshot = {
version: number;
board: { title: string; votesPerUser: number; allowMultiVote?: boolean };
columns: { id: string; title: string; position: number }[];
groups: { id: string; columnId: string; title: string; position: number }[];
notes: {
id: string;
columnId: string;
groupId: string | null;
authorId: string;
authorName: string;
text: string;
color?: string | null;
revealed: boolean;
position: number;
}[];
votes: { targetType: string; targetId: string; participantId: string; count?: number }[];
actionItems: { text: string; owner: string | null }[];
};
// Crea un board nuevo desde un snapshot, remapeando todos los ids.
// 'structure' importa solo columnas + config; 'full' importa todo el contenido.
export async function importBoard(
snap: ImportSnapshot,
mode: "full" | "structure",
opts: { title?: string; ttlHours?: number | null },
): Promise<{ boardId: string; moderatorToken: string; expiresAt: string | null }> {
const id = nanoid(12);
const moderatorToken = nanoid(24);
const title = (opts.title ?? snap.board?.title ?? "Retro").trim() || "Retro";
const votesPerUser = clampInt(snap.board?.votesPerUser ?? 3, 0, 50);
const allowMultiVote = snap.board?.allowMultiVote ?? true;
const expiresAt =
opts.ttlHours && opts.ttlHours > 0 ? new Date(Date.now() + opts.ttlHours * 3600 * 1000) : null;
await db.transaction(async (tx) => {
await tx.insert(schema.boards).values({ id, title, votesPerUser, allowMultiVote, moderatorToken, expiresAt });
const colMap = new Map<string, string>();
for (const c of snap.columns) {
const newId = nanoid();
colMap.set(c.id, newId);
await tx.insert(schema.columns).values({ id: newId, boardId: id, title: c.title, position: c.position });
}
if (mode === "structure") return;
const groupMap = new Map<string, string>();
for (const g of snap.groups ?? []) {
const colId = colMap.get(g.columnId);
if (!colId) continue;
const newId = nanoid();
groupMap.set(g.id, newId);
await tx
.insert(schema.groups)
.values({ id: newId, boardId: id, columnId: colId, title: g.title, position: g.position });
}
const targetMap = new Map<string, string>(); // viejo id (nota/grupo) -> nuevo id, para los votos
for (const [oldG, newG] of groupMap) targetMap.set(oldG, newG);
for (const n of snap.notes ?? []) {
const colId = colMap.get(n.columnId);
if (!colId) continue;
const newId = nanoid();
targetMap.set(n.id, newId);
await tx.insert(schema.notes).values({
id: newId,
boardId: id,
columnId: colId,
groupId: n.groupId ? groupMap.get(n.groupId) ?? null : null,
authorId: n.authorId,
authorName: n.authorName,
text: n.text,
color: n.color ?? null,
revealed: n.revealed,
position: n.position,
});
}
for (const v of snap.votes ?? []) {
const targetId = targetMap.get(v.targetId);
if (!targetId) continue;
await tx.insert(schema.votes).values({
boardId: id,
targetType: v.targetType,
targetId,
participantId: v.participantId,
count: v.count ?? 1,
});
}
for (const a of snap.actionItems ?? []) {
await tx.insert(schema.actionItems).values({ id: nanoid(), boardId: id, text: a.text, owner: a.owner });
}
});
return { boardId: id, moderatorToken, expiresAt: expiresAt?.toISOString() ?? null };
}
+12
View File
@@ -0,0 +1,12 @@
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import * as schema from "./schema.js";
const url = process.env.DATABASE_URL;
if (!url) throw new Error("DATABASE_URL no definido");
// max alto: las transacciones de voto usan FOR UPDATE y, bajo ráfagas concurrentes,
// no deben quedarse sin conexión (evita timeouts esporádicos).
const client = postgres(url, { max: 20 });
export const db = drizzle(client, { schema });
export { schema };
+95
View File
@@ -0,0 +1,95 @@
import {
pgTable,
text,
integer,
boolean,
timestamp,
doublePrecision,
uniqueIndex,
} from "drizzle-orm/pg-core";
export const boards = pgTable("boards", {
id: text("id").primaryKey(), // slug no adivinable
title: text("title").notNull(),
phase: text("phase").notNull().default("writing"), // writing | revealed | voting | done
revealed: boolean("revealed").notNull().default(false), // moderador revela TODAS las notas
votesRevealed: boolean("votes_revealed").notNull().default(false), // moderador revela los totales
votesPerUser: integer("votes_per_user").notNull().default(3),
allowMultiVote: boolean("allow_multi_vote").notNull().default(true), // varios votos al mismo ítem
moderationMode: text("moderation_mode").notNull().default("everyone"), // 'everyone' | 'single'
showCardCreators: boolean("show_card_creators").notNull().default(true),
locked: boolean("locked").notNull().default(false), // solo lectura
timerEndsAt: timestamp("timer_ends_at", { withTimezone: true }),
timerRunning: boolean("timer_running").notNull().default(false),
moderatorToken: text("moderator_token").notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
expiresAt: timestamp("expires_at", { withTimezone: true }), // autodestrucción; null = sin caducidad
});
export const columns = pgTable("columns", {
id: text("id").primaryKey(),
boardId: text("board_id")
.notNull()
.references(() => boards.id, { onDelete: "cascade" }),
title: text("title").notNull(),
position: integer("position").notNull(),
});
// Un grupo (cluster) de notas dentro de una columna. Es la unidad votable.
export const groups = pgTable("groups", {
id: text("id").primaryKey(),
boardId: text("board_id")
.notNull()
.references(() => boards.id, { onDelete: "cascade" }),
columnId: text("column_id")
.notNull()
.references(() => columns.id, { onDelete: "cascade" }),
title: text("title").notNull().default(""),
position: doublePrecision("position").notNull().default(0), // fractional indexing
});
export const notes = pgTable("notes", {
id: text("id").primaryKey(),
boardId: text("board_id")
.notNull()
.references(() => boards.id, { onDelete: "cascade" }),
columnId: text("column_id")
.notNull()
.references(() => columns.id, { onDelete: "cascade" }),
groupId: text("group_id").references(() => groups.id, { onDelete: "set null" }), // null = suelta
authorId: text("author_id").notNull(),
authorName: text("author_name").notNull(),
text: text("text").notNull(),
color: text("color"), // color del post-it (hex); null = color por defecto
revealed: boolean("revealed").notNull().default(false), // el autor revela su propia nota
position: doublePrecision("position").notNull().default(0),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
});
// Voto sobre un votable genérico: una nota suelta o un grupo.
export const votes = pgTable(
"votes",
{
boardId: text("board_id")
.notNull()
.references(() => boards.id, { onDelete: "cascade" }),
targetType: text("target_type").notNull(), // 'note' | 'group'
targetId: text("target_id").notNull(),
participantId: text("participant_id").notNull(),
count: integer("count").notNull().default(1), // nº de votos de este participante a este ítem
},
(t) => ({
uniq: uniqueIndex("votes_target_participant_uniq").on(t.targetId, t.participantId),
}),
);
export const actionItems = pgTable("action_items", {
id: text("id").primaryKey(),
boardId: text("board_id")
.notNull()
.references(() => boards.id, { onDelete: "cascade" }),
text: text("text").notNull(),
owner: text("owner"),
done: boolean("done").notNull().default(false),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
});
+10
View File
@@ -0,0 +1,10 @@
// Carga .env (cargador nativo de Node) salvo que las vars ya vengan del entorno
// — así Playwright/CI pueden inyectar otra DATABASE_URL sin que el fichero la pise.
// Importar este módulo el PRIMERO de todos por su efecto secundario.
if (!process.env.DATABASE_URL) {
try {
process.loadEnvFile();
} catch {
// sin .env: se usarán las variables del entorno
}
}
+7
View File
@@ -0,0 +1,7 @@
import "./env.js"; // primero: carga .env antes de tocar la DB
import { buildServer } from "./app.js";
const PORT = Number(process.env.PORT ?? 3001);
const server = buildServer({ cleanup: true });
await server.listen(PORT);
console.log(`Sternboard server escuchando en :${PORT}`);
+85
View File
@@ -0,0 +1,85 @@
import type { BoardState } from "./room.js";
export type MarkdownOptions = {
authors: boolean; // mostrar nombres de autor
votes: boolean; // mostrar conteo de votos
expandGroups: boolean; // listar las notas dentro de cada grupo
creationDate: boolean; // añadir la fecha de creación al título
exportDate: boolean; // añadir línea con la fecha de exportación
};
export const DEFAULT_MD_OPTIONS: MarkdownOptions = {
authors: true,
votes: true,
expandGroups: true,
creationDate: false,
exportDate: false,
};
function fmtDate(d: Date): string {
return `${d.getMonth() + 1}/${d.getDate()}/${d.getFullYear()}`;
}
// Export del estado del board a Markdown, con opciones (grupos, autores, votos, fechas).
export function boardToMarkdown(
state: BoardState,
opts: MarkdownOptions = DEFAULT_MD_OPTIONS,
now: Date = new Date(),
): string {
const lines: string[] = [];
const created = opts.creationDate ? `${fmtDate(new Date(state.createdAt))} - ` : "";
lines.push(`# ${created}${state.title}`);
if (opts.exportDate) lines.push(`_Exported: ${fmtDate(now)}_`);
lines.push("");
const votesOf = (targetId: string) =>
state.votes.filter((v) => v.targetId === targetId).reduce((sum, v) => sum + v.count, 0);
const votePart = (n: number) => (opts.votes && n > 0 ? ` (${n} 👍)` : "");
const authorPart = (name: string) => (opts.authors ? ` — _${name}_` : "");
for (const col of state.columns) {
lines.push(`## ${col.title}`);
const groups = state.groups.filter((g) => g.columnId === col.id);
const standalone = state.notes.filter((n) => n.columnId === col.id && !n.groupId);
type Item = { votes: number; render: () => string[] };
const items: Item[] = [];
for (const g of groups) {
const members = state.notes.filter((n) => n.groupId === g.id);
const v = votesOf(g.id);
items.push({
votes: v,
render: () => {
const out = [`- **${g.title?.trim() || "Grupo"}**${votePart(v)}`];
if (opts.expandGroups) for (const m of members) out.push(` - ${m.text}${authorPart(m.authorName)}`);
return out;
},
});
}
for (const n of standalone) {
const v = votesOf(n.id);
items.push({ votes: v, render: () => [`- ${n.text}${authorPart(n.authorName)}${votePart(v)}`] });
}
if (items.length === 0) {
lines.push("_(sin notas)_");
} else {
items.sort((a, b) => b.votes - a.votes);
for (const it of items) lines.push(...it.render());
}
lines.push("");
}
if (state.actionItems.length > 0) {
lines.push("## 📋 Action items");
for (const a of state.actionItems) {
const owner = a.owner ? ` — **${a.owner}**` : "";
lines.push(`- [${a.done ? "x" : " "}] ${a.text}${owner}`);
}
lines.push("");
}
return lines.join("\n");
}
+102
View File
@@ -0,0 +1,102 @@
import { eq } from "drizzle-orm";
import { db, schema } from "./db/index.js";
export type BoardState = {
id: string;
title: string;
phase: string;
revealed: boolean; // todas las notas reveladas (moderador)
votesRevealed: boolean; // totales de votos visibles (moderador)
votesPerUser: number;
allowMultiVote: boolean;
moderationMode: string; // 'everyone' | 'single'
showCardCreators: boolean;
locked: boolean;
createdAt: string;
timerEndsAt: string | null;
timerRunning: boolean;
expiresAt: string | null;
youAreModerator?: boolean; // se rellena por-viewer al difundir
participants?: { id: string; name: string }[]; // presencia, se rellena al difundir
columns: { id: string; title: string; position: number }[];
groups: { id: string; columnId: string; title: string; position: number }[];
notes: {
id: string;
columnId: string;
groupId: string | null;
authorId: string;
authorName: string;
text: string;
color: string | null;
revealed: boolean;
position: number;
}[];
votes: { targetType: string; targetId: string; participantId: string; count: number }[];
actionItems: { id: string; text: string; owner: string | null; done: boolean }[];
};
export async function loadBoardState(boardId: string): Promise<BoardState | null> {
const [board] = await db.select().from(schema.boards).where(eq(schema.boards.id, boardId));
if (!board) return null;
const [cols, grps, ns, vs, ais] = await Promise.all([
db.select().from(schema.columns).where(eq(schema.columns.boardId, boardId)),
db.select().from(schema.groups).where(eq(schema.groups.boardId, boardId)),
db.select().from(schema.notes).where(eq(schema.notes.boardId, boardId)),
db.select().from(schema.votes).where(eq(schema.votes.boardId, boardId)),
db.select().from(schema.actionItems).where(eq(schema.actionItems.boardId, boardId)),
]);
return {
id: board.id,
title: board.title,
phase: board.phase,
revealed: board.revealed,
votesRevealed: board.votesRevealed,
votesPerUser: board.votesPerUser,
allowMultiVote: board.allowMultiVote,
moderationMode: board.moderationMode,
showCardCreators: board.showCardCreators,
locked: board.locked,
createdAt: board.createdAt.toISOString(),
timerEndsAt: board.timerEndsAt ? board.timerEndsAt.toISOString() : null,
timerRunning: board.timerRunning,
expiresAt: board.expiresAt ? board.expiresAt.toISOString() : null,
columns: cols
.map((c) => ({ id: c.id, title: c.title, position: c.position }))
.sort((a, b) => a.position - b.position),
groups: grps.map((g) => ({ id: g.id, columnId: g.columnId, title: g.title, position: g.position })),
notes: ns.map((n) => ({
id: n.id,
columnId: n.columnId,
groupId: n.groupId,
authorId: n.authorId,
authorName: n.authorName,
text: n.text,
color: n.color,
revealed: n.revealed,
position: n.position,
})),
votes: vs.map((v) => ({
targetType: v.targetType,
targetId: v.targetId,
participantId: v.participantId,
count: v.count,
})),
actionItems: ais.map((a) => ({ id: a.id, text: a.text, owner: a.owner, done: a.done })),
};
}
// Vista que recibe un participante concreto:
// - texto de notas ajenas oculto salvo que el board o la nota estén revelados.
// - votos: si los totales no están revelados, solo ve los suyos.
export function viewFor(state: BoardState, participantId: string): BoardState {
const notes = state.notes.map((n) => {
const visible = state.revealed || n.revealed || !!n.groupId || n.authorId === participantId;
return visible ? n : { ...n, text: "" };
});
const votes = state.votesRevealed
? state.votes
: state.votes.filter((v) => v.participantId === participantId);
return { ...state, notes, votes };
}
+591
View File
@@ -0,0 +1,591 @@
import type { Server } from "socket.io";
import { and, eq, sql } from "drizzle-orm";
import { nanoid } from "nanoid";
import { db, schema } from "./db/index.js";
import { loadBoardState, viewFor } from "./room.js";
type SessionData = { boardId: string; participantId: string; name: string; moderatorToken?: string };
// La sesión se guarda por socket.id para poder leerla también al difundir
// (fetchSockets devuelve sockets remotos sin acceso a closures).
const sessions = new Map<string, SessionData>();
export function registerSockets(io: Server) {
io.on("connection", (socket) => {
socket.on("disconnect", () => sessions.delete(socket.id));
socket.on(
"board:join",
async (p: { boardId: string; participantId: string; name: string; moderatorToken?: string }) => {
const state = await loadBoardState(p.boardId);
if (!state) return socket.emit("error", { message: "board_not_found" });
if (!p.name?.trim()) return socket.emit("error", { message: "name_required" });
sessions.set(socket.id, {
boardId: p.boardId,
participantId: p.participantId,
name: p.name.trim(),
moderatorToken: p.moderatorToken,
});
socket.join(p.boardId);
await broadcast(io, p.boardId);
},
);
const guard = (): SessionData | null => {
const s = sessions.get(socket.id);
if (!s) {
socket.emit("error", { message: "not_joined" });
return null;
}
return s;
};
// moderador si el board es abierto ('everyone') o el token coincide ('single')
const requireMod = async (boardId: string, token?: string) => {
const [board] = await db.select().from(schema.boards).where(eq(schema.boards.id, boardId));
if (!board) return false;
return board.moderationMode === "everyone" || board.moderatorToken === token;
};
// ¿el board está bloqueado (solo lectura)? bloquea las mutaciones de contenido.
const isLocked = async (boardId: string) => {
const [board] = await db
.select({ locked: schema.boards.locked })
.from(schema.boards)
.where(eq(schema.boards.id, boardId));
return !!board?.locked;
};
// guard combinado: sesión válida y board no bloqueado
const writable = async (): Promise<SessionData | null> => {
const s = guard();
if (!s) return null;
if (await isLocked(s.boardId)) return null;
return s;
};
// ---------- notas ----------
socket.on("note:add", async (p: { columnId: string; text: string; color?: string }) => {
const s = await writable();
if (!s) return;
await db.insert(schema.notes).values({
id: nanoid(),
boardId: s.boardId,
columnId: p.columnId,
authorId: s.participantId,
authorName: s.name,
text: p.text ?? "",
color: typeof p.color === "string" ? p.color.slice(0, 16) : null,
position: Date.now(), // ordenación por creación
});
await broadcast(io, s.boardId);
});
socket.on("note:update", async (p: { noteId: string; text: string }) => {
const s = await writable();
if (!s) return;
await db
.update(schema.notes)
.set({ text: p.text })
.where(and(eq(schema.notes.id, p.noteId), eq(schema.notes.authorId, s.participantId)));
await broadcast(io, s.boardId);
});
socket.on("note:delete", async (p: { noteId: string }) => {
const s = await writable();
if (!s) return;
const [note] = await db.select().from(schema.notes).where(eq(schema.notes.id, p.noteId));
if (!note || note.authorId !== s.participantId) return;
await db.delete(schema.votes).where(eq(schema.votes.targetId, p.noteId));
await db.delete(schema.notes).where(eq(schema.notes.id, p.noteId));
if (note.groupId) await deleteGroupIfEmpty(note.groupId);
await broadcast(io, s.boardId);
});
// el autor publica/despublica su propia nota
socket.on("note:reveal", async (p: { noteId: string; revealed: boolean }) => {
const s = await writable();
if (!s) return;
await db
.update(schema.notes)
.set({ revealed: p.revealed })
.where(and(eq(schema.notes.id, p.noteId), eq(schema.notes.authorId, s.participantId)));
await broadcast(io, s.boardId);
});
// cambiar mi color: recolorea TODAS mis notas (y vale para las futuras en el cliente)
socket.on("note:recolorMine", async (p: { color: string }) => {
const s = await writable();
if (!s) return;
const color = typeof p.color === "string" ? p.color.slice(0, 16) : null;
if (!color) return;
await db
.update(schema.notes)
.set({ color })
.where(and(eq(schema.notes.boardId, s.boardId), eq(schema.notes.authorId, s.participantId)));
await broadcast(io, s.boardId);
});
// "Publish all": publica todas mis notas (opcionalmente de una columna)
socket.on("note:publishMine", async (p: { columnId?: string }) => {
const s = await writable();
if (!s) return;
const filters = [eq(schema.notes.boardId, s.boardId), eq(schema.notes.authorId, s.participantId)];
if (p.columnId) filters.push(eq(schema.notes.columnId, p.columnId));
await db.update(schema.notes).set({ revealed: true }).where(and(...filters));
await broadcast(io, s.boardId);
});
// colocación unificada: mover de columna, reordenar y entrar/salir de grupo
socket.on(
"note:move",
async (p: { noteId: string; columnId: string; groupId?: string | null; position: number }) => {
const s = await writable();
if (!s) return;
const [note] = await db.select().from(schema.notes).where(eq(schema.notes.id, p.noteId));
if (!note) return;
const newGroup = p.groupId ?? null;
await db
.update(schema.notes)
// entrar en un grupo implica publicar la nota (agrupar es acción pública)
.set({ columnId: p.columnId, groupId: newGroup, position: p.position, ...(newGroup ? { revealed: true } : {}) })
.where(eq(schema.notes.id, p.noteId));
if (newGroup !== note.groupId) {
// cambia su estado de agrupación => sus votos sueltos dejan de aplicar
await db.delete(schema.votes).where(eq(schema.votes.targetId, p.noteId));
if (note.groupId) await deleteGroupIfEmpty(note.groupId);
}
await broadcast(io, s.boardId);
},
);
// ---------- grupos ----------
// soltar una nota sobre otra: crea grupo (o se une al de la nota destino)
socket.on("group:create", async (p: { noteId: string; targetNoteId: string }) => {
const s = await writable();
if (!s) return;
if (p.noteId === p.targetNoteId) return;
const [target] = await db.select().from(schema.notes).where(eq(schema.notes.id, p.targetNoteId));
const [dragged] = await db.select().from(schema.notes).where(eq(schema.notes.id, p.noteId));
if (!target || !dragged) return;
let groupId = target.groupId;
if (!groupId) {
groupId = nanoid();
await db.insert(schema.groups).values({
id: groupId,
boardId: s.boardId,
columnId: target.columnId,
title: target.text.slice(0, 60), // por defecto, el texto de la primera nota (la ancla)
position: target.position,
});
await db
.update(schema.notes)
.set({ groupId, position: 0, revealed: true })
.where(eq(schema.notes.id, target.id));
}
const oldGroup = dragged.groupId;
await db
.update(schema.notes)
.set({ groupId, columnId: target.columnId, position: Date.now(), revealed: true })
.where(eq(schema.notes.id, dragged.id));
// votar el grupo es la unidad => reiniciamos votos sueltos de las notas implicadas
await db.delete(schema.votes).where(eq(schema.votes.targetId, target.id));
await db.delete(schema.votes).where(eq(schema.votes.targetId, dragged.id));
if (oldGroup && oldGroup !== groupId) await deleteGroupIfEmpty(oldGroup);
await broadcast(io, s.boardId);
});
socket.on("group:rename", async (p: { groupId: string; title: string }) => {
const s = await writable();
if (!s) return;
await db.update(schema.groups).set({ title: p.title }).where(eq(schema.groups.id, p.groupId));
await broadcast(io, s.boardId);
});
socket.on("group:move", async (p: { groupId: string; columnId: string; position: number }) => {
const s = await writable();
if (!s) return;
await db
.update(schema.groups)
.set({ columnId: p.columnId, position: p.position })
.where(eq(schema.groups.id, p.groupId));
// las notas del grupo siguen al grupo de columna
await db
.update(schema.notes)
.set({ columnId: p.columnId })
.where(eq(schema.notes.groupId, p.groupId));
await broadcast(io, s.boardId);
});
socket.on("group:delete", async (p: { groupId: string }) => {
const s = await writable();
if (!s) return;
await db.update(schema.notes).set({ groupId: null }).where(eq(schema.notes.groupId, p.groupId));
await db.delete(schema.votes).where(eq(schema.votes.targetId, p.groupId));
await db.delete(schema.groups).where(eq(schema.groups.id, p.groupId));
await broadcast(io, s.boardId);
});
// ---------- votos ----------
// presupuesto usado por el participante = suma de contadores
const usedVotes = (tx: Pick<typeof db, "select">, boardId: string, participantId: string) =>
tx
.select({ used: sql<number>`coalesce(sum(${schema.votes.count}), 0)::int` })
.from(schema.votes)
.where(and(eq(schema.votes.boardId, boardId), eq(schema.votes.participantId, participantId)))
.then((r) => r[0]?.used ?? 0);
// añade un voto (apila si allowMultiVote); respeta el cupo total por persona
socket.on("vote:add", async (p: { targetType?: string; targetId?: string; noteId?: string }) => {
const s = await writable();
if (!s) return;
const targetType = p.targetType ?? "note";
const targetId = p.targetId ?? p.noteId;
if (!targetId) return;
await db.transaction(async (tx) => {
const [board] = await tx
.select()
.from(schema.boards)
.where(eq(schema.boards.id, s.boardId))
.for("update");
if (!board) return;
if ((await usedVotes(tx, s.boardId, s.participantId)) >= board.votesPerUser) return; // cupo agotado
const [row] = await tx
.select()
.from(schema.votes)
.where(and(eq(schema.votes.targetId, targetId), eq(schema.votes.participantId, s.participantId)));
if (row) {
if (!board.allowMultiVote) return; // ya votado y no se permite apilar
await tx
.update(schema.votes)
.set({ count: row.count + 1 })
.where(and(eq(schema.votes.targetId, targetId), eq(schema.votes.participantId, s.participantId)));
} else {
await tx.insert(schema.votes).values({
boardId: s.boardId,
targetType,
targetId,
participantId: s.participantId,
count: 1,
});
}
});
await broadcast(io, s.boardId);
});
// quita un voto (decrementa; borra la fila al llegar a 0)
socket.on("vote:remove", async (p: { targetId?: string; noteId?: string }) => {
const s = await writable();
if (!s) return;
const targetId = p.targetId ?? p.noteId;
if (!targetId) return;
const [row] = await db
.select()
.from(schema.votes)
.where(and(eq(schema.votes.targetId, targetId), eq(schema.votes.participantId, s.participantId)));
if (!row) return;
if (row.count > 1) {
await db
.update(schema.votes)
.set({ count: row.count - 1 })
.where(and(eq(schema.votes.targetId, targetId), eq(schema.votes.participantId, s.participantId)));
} else {
await db
.delete(schema.votes)
.where(and(eq(schema.votes.targetId, targetId), eq(schema.votes.participantId, s.participantId)));
}
await broadcast(io, s.boardId);
});
// legacy: alterna por completo el voto de un ítem (todo/nada)
socket.on("vote:toggle", async (p: { targetType?: string; targetId?: string; noteId?: string }) => {
const s = await writable();
if (!s) return;
const targetType = p.targetType ?? "note";
const targetId = p.targetId ?? p.noteId;
if (!targetId) return;
await db.transaction(async (tx) => {
const [board] = await tx
.select()
.from(schema.boards)
.where(eq(schema.boards.id, s.boardId))
.for("update");
if (!board) return;
const [row] = await tx
.select()
.from(schema.votes)
.where(and(eq(schema.votes.targetId, targetId), eq(schema.votes.participantId, s.participantId)));
if (row) {
await tx
.delete(schema.votes)
.where(and(eq(schema.votes.targetId, targetId), eq(schema.votes.participantId, s.participantId)));
return;
}
if ((await usedVotes(tx, s.boardId, s.participantId)) >= board.votesPerUser) return;
await tx
.insert(schema.votes)
.values({ boardId: s.boardId, targetType, targetId, participantId: s.participantId, count: 1 });
});
await broadcast(io, s.boardId);
});
// ---------- ajustes del board (moderador) ----------
socket.on(
"board:update",
async (p: {
title?: string;
votesPerUser?: number;
allowMultiVote?: boolean;
moderationMode?: string;
showCardCreators?: boolean;
locked?: boolean;
moderatorToken?: string;
}) => {
const s = guard();
if (!s) return;
if (!(await requireMod(s.boardId, p.moderatorToken))) return socket.emit("error", { message: "forbidden" });
const set: Record<string, unknown> = {};
if (typeof p.title === "string" && p.title.trim()) set.title = p.title.trim();
if (typeof p.votesPerUser === "number") set.votesPerUser = Math.max(0, Math.min(50, Math.round(p.votesPerUser)));
if (typeof p.allowMultiVote === "boolean") set.allowMultiVote = p.allowMultiVote;
if (p.moderationMode === "everyone" || p.moderationMode === "single") set.moderationMode = p.moderationMode;
if (typeof p.showCardCreators === "boolean") set.showCardCreators = p.showCardCreators;
if (typeof p.locked === "boolean") set.locked = p.locked;
if (Object.keys(set).length) await db.update(schema.boards).set(set).where(eq(schema.boards.id, s.boardId));
await broadcast(io, s.boardId);
},
);
// reclamar la retro: pasa a moderación 'single' con un token nuevo solo para ti
socket.on("board:claim", async (p: { moderatorToken?: string }) => {
const s = guard();
if (!s) return;
if (!(await requireMod(s.boardId, p.moderatorToken))) return socket.emit("error", { message: "forbidden" });
const token = nanoid(24);
await db
.update(schema.boards)
.set({ moderationMode: "single", moderatorToken: token })
.where(eq(schema.boards.id, s.boardId));
const sess = sessions.get(socket.id);
if (sess) sess.moderatorToken = token;
socket.emit("board:claimed", { moderatorToken: token });
await broadcast(io, s.boardId);
});
// quitar todos los votos del board (moderador)
socket.on("votes:clear", async (p: { moderatorToken?: string }) => {
const s = guard();
if (!s) return;
if (!(await requireMod(s.boardId, p.moderatorToken))) return socket.emit("error", { message: "forbidden" });
await db.delete(schema.votes).where(eq(schema.votes.boardId, s.boardId));
await broadcast(io, s.boardId);
});
// borrar la retro (moderador)
socket.on("board:remove", async (p: { moderatorToken?: string }) => {
const s = guard();
if (!s) return;
if (!(await requireMod(s.boardId, p.moderatorToken))) return socket.emit("error", { message: "forbidden" });
await db.delete(schema.boards).where(eq(schema.boards.id, s.boardId));
io.to(s.boardId).emit("board:destroyed");
});
// ---------- columnas (moderador) ----------
socket.on("column:add", async (p: { title?: string; moderatorToken?: string }) => {
const s = guard();
if (!s) return;
if (!(await requireMod(s.boardId, p.moderatorToken))) return socket.emit("error", { message: "forbidden" });
const [{ max }] = await db
.select({ max: sql<number>`coalesce(max(${schema.columns.position}), -1)::int` })
.from(schema.columns)
.where(eq(schema.columns.boardId, s.boardId));
await db.insert(schema.columns).values({
id: nanoid(),
boardId: s.boardId,
title: (p.title ?? "").trim() || "Nueva columna",
position: max + 1,
});
await broadcast(io, s.boardId);
});
socket.on("column:rename", async (p: { columnId: string; title: string; moderatorToken?: string }) => {
const s = guard();
if (!s) return;
if (!(await requireMod(s.boardId, p.moderatorToken))) return socket.emit("error", { message: "forbidden" });
const title = (p.title ?? "").trim();
if (!title) return;
await db
.update(schema.columns)
.set({ title })
.where(and(eq(schema.columns.id, p.columnId), eq(schema.columns.boardId, s.boardId)));
await broadcast(io, s.boardId);
});
socket.on("column:delete", async (p: { columnId: string; moderatorToken?: string }) => {
const s = guard();
if (!s) return;
if (!(await requireMod(s.boardId, p.moderatorToken))) return socket.emit("error", { message: "forbidden" });
// no dejar el board sin columnas
const cols = await db.select({ id: schema.columns.id }).from(schema.columns).where(eq(schema.columns.boardId, s.boardId));
if (cols.length <= 1) return;
await db
.delete(schema.columns)
.where(and(eq(schema.columns.id, p.columnId), eq(schema.columns.boardId, s.boardId)));
await broadcast(io, s.boardId);
});
// reordenar: el cliente envía el orden deseado de ids
socket.on("column:reorder", async (p: { orderedIds: string[]; moderatorToken?: string }) => {
const s = guard();
if (!s) return;
if (!(await requireMod(s.boardId, p.moderatorToken))) return socket.emit("error", { message: "forbidden" });
if (!Array.isArray(p.orderedIds)) return;
await db.transaction(async (tx) => {
for (let i = 0; i < p.orderedIds.length; i++) {
await tx
.update(schema.columns)
.set({ position: i })
.where(and(eq(schema.columns.id, p.orderedIds[i]), eq(schema.columns.boardId, s.boardId)));
}
});
await broadcast(io, s.boardId);
});
// cambiar mi propio nombre (actualiza la autoría de mis notas)
socket.on("participant:rename", async (p: { name: string }) => {
const s = guard();
if (!s) return;
const name = (p.name ?? "").trim();
if (!name) return;
s.name = name;
await db
.update(schema.notes)
.set({ authorName: name })
.where(and(eq(schema.notes.boardId, s.boardId), eq(schema.notes.authorId, s.participantId)));
await broadcast(io, s.boardId);
});
socket.on("board:reveal", async (p: { revealed: boolean; moderatorToken?: string }) => {
const s = guard();
if (!s) return;
if (!(await requireMod(s.boardId, p.moderatorToken))) return socket.emit("error", { message: "forbidden" });
await db.update(schema.boards).set({ revealed: p.revealed }).where(eq(schema.boards.id, s.boardId));
await broadcast(io, s.boardId);
});
socket.on("board:revealVotes", async (p: { revealed: boolean; moderatorToken?: string }) => {
const s = guard();
if (!s) return;
if (!(await requireMod(s.boardId, p.moderatorToken))) return socket.emit("error", { message: "forbidden" });
await db.update(schema.boards).set({ votesRevealed: p.revealed }).where(eq(schema.boards.id, s.boardId));
await broadcast(io, s.boardId);
});
// fase de la retro (Lluvia/Votación/Discusión), compartida (moderador)
socket.on("board:phase", async (p: { phase: string; moderatorToken?: string }) => {
const s = guard();
if (!s) return;
if (!(await requireMod(s.boardId, p.moderatorToken))) return socket.emit("error", { message: "forbidden" });
if (!["writing", "voting", "done", "revealed"].includes(p.phase)) return;
await db.update(schema.boards).set({ phase: p.phase }).where(eq(schema.boards.id, s.boardId));
await broadcast(io, s.boardId);
});
socket.on("timer:start", async (p: { seconds: number; moderatorToken?: string }) => {
const s = guard();
if (!s) return;
if (!(await requireMod(s.boardId, p.moderatorToken))) return socket.emit("error", { message: "forbidden" });
const endsAt = new Date(Date.now() + Math.max(1, p.seconds) * 1000);
await db
.update(schema.boards)
.set({ timerEndsAt: endsAt, timerRunning: true })
.where(eq(schema.boards.id, s.boardId));
await broadcast(io, s.boardId);
});
socket.on("timer:stop", async (p: { moderatorToken?: string }) => {
const s = guard();
if (!s) return;
if (!(await requireMod(s.boardId, p.moderatorToken))) return socket.emit("error", { message: "forbidden" });
await db.update(schema.boards).set({ timerRunning: false }).where(eq(schema.boards.id, s.boardId));
await broadcast(io, s.boardId);
});
// ---------- action items ----------
socket.on("actionitem:add", async (p: { text: string }) => {
const s = guard();
if (!s) return;
await db.insert(schema.actionItems).values({ id: nanoid(), boardId: s.boardId, text: p.text, owner: null });
await broadcast(io, s.boardId);
});
socket.on("actionitem:update", async (p: { id: string; text?: string; owner?: string | null }) => {
const s = guard();
if (!s) return;
const set: Record<string, unknown> = {};
if (p.text !== undefined) set.text = p.text;
if (p.owner !== undefined) set.owner = p.owner;
await db.update(schema.actionItems).set(set).where(eq(schema.actionItems.id, p.id));
await broadcast(io, s.boardId);
});
socket.on("actionitem:delete", async (p: { id: string }) => {
const s = guard();
if (!s) return;
await db.delete(schema.actionItems).where(eq(schema.actionItems.id, p.id));
await broadcast(io, s.boardId);
});
socket.on("actionitem:toggle", async (p: { id: string; done: boolean }) => {
const s = guard();
if (!s) return;
await db.update(schema.actionItems).set({ done: p.done }).where(eq(schema.actionItems.id, p.id));
await broadcast(io, s.boardId);
});
});
}
// Borra un grupo si se ha quedado sin notas (y sus votos).
async function deleteGroupIfEmpty(groupId: string) {
const [{ count }] = await db
.select({ count: sql<number>`count(*)::int` })
.from(schema.notes)
.where(eq(schema.notes.groupId, groupId));
if (count === 0) {
await db.delete(schema.votes).where(eq(schema.votes.targetId, groupId));
await db.delete(schema.groups).where(eq(schema.groups.id, groupId));
}
}
// Difunde a cada socket de la room su propia vista: oculta notas/votos si procede,
// e inyecta la presencia y si ese viewer es moderador.
async function broadcast(io: Server, boardId: string) {
const state = await loadBoardState(boardId);
if (!state) return;
// token del moderador (secreto, no va en el estado público): solo para comparar
const [board] = await db
.select({ token: schema.boards.moderatorToken })
.from(schema.boards)
.where(eq(schema.boards.id, boardId));
const modToken = board?.token;
const socketsInRoom = await io.in(boardId).fetchSockets();
// presencia: participantes conectados, deduplicados por participantId
const byId = new Map<string, string>();
for (const s of socketsInRoom) {
const sess = sessions.get(s.id);
if (sess) byId.set(sess.participantId, sess.name);
}
const participants = [...byId].map(([id, name]) => ({ id, name }));
for (const s of socketsInRoom) {
const sess = sessions.get(s.id);
const pid = sess?.participantId;
const view = pid ? viewFor(state, pid) : state;
const youAreModerator =
state.moderationMode === "everyone" || (!!sess?.moderatorToken && sess.moderatorToken === modToken);
s.emit("board:state", { ...view, participants, youAreModerator });
}
}
+48
View File
@@ -0,0 +1,48 @@
// Plantillas predefinidas: cada una es una lista ordenada de columnas.
export type Template = {
id: string;
name: string;
columns: string[];
};
export const TEMPLATES: Template[] = [
{
id: "mad-sad-glad",
name: "Mad / Sad / Glad",
columns: ["😠 Mad", "😢 Sad", "😄 Glad"],
},
{
id: "start-stop-continue",
name: "Start / Stop / Continue",
columns: ["🟢 Start", "🔴 Stop", "🔵 Continue"],
},
{
id: "4ls",
name: "4 Ls",
columns: ["👍 Liked", "📚 Learned", "🕳️ Lacked", "🎯 Longed for"],
},
{
id: "went-well",
name: "Funcionó / No funcionó / Acciones",
columns: ["✅ Funcionó", "⚠️ No funcionó", "💡 Acciones"],
},
{
id: "sailboat",
name: "Velero (Sailboat)",
columns: ["⛵ Viento", "⚓ Ancla", "🪨 Rocas", "🏝️ Meta"],
},
{
id: "daki",
name: "DAKI",
columns: ["🗑️ Drop", " Add", "📌 Keep", "📈 Improve"],
},
{
id: "blank",
name: "En blanco (columnas libres)",
columns: ["Columna 1", "Columna 2", "Columna 3"],
},
];
export function getTemplate(id: string): Template | undefined {
return TEMPLATES.find((t) => t.id === id);
}