Sternboard: free retro board, no signup (initial)
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:
@@ -0,0 +1,3 @@
|
||||
DATABASE_URL=postgres://retro:retro@localhost:5433/retro
|
||||
PORT=3001
|
||||
WEB_ORIGIN=http://localhost:5173
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { Config } from "drizzle-kit";
|
||||
|
||||
export default {
|
||||
schema: "./src/db/schema.ts",
|
||||
out: "./drizzle",
|
||||
dialect: "postgresql",
|
||||
dbCredentials: {
|
||||
url: process.env.DATABASE_URL ?? "postgres://retro:retro@localhost:5433/retro",
|
||||
},
|
||||
} satisfies Config;
|
||||
Generated
+4426
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "sternboard-server",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"serve": "tsx src/index.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"db:push": "drizzle-kit push",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^10.0.1",
|
||||
"@fastify/static": "^8.3.0",
|
||||
"drizzle-orm": "^0.36.4",
|
||||
"fastify": "^5.1.0",
|
||||
"nanoid": "^5.0.9",
|
||||
"postgres": "^3.4.5",
|
||||
"socket.io": "^4.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"drizzle-kit": "^0.28.1",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.2",
|
||||
"vitest": "^2.1.8"
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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(),
|
||||
});
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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}`);
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { startTestServer, createBoard, join, type TestServer } from "./helpers.js";
|
||||
import { runCleanup } from "../src/app.js";
|
||||
import { db, schema } from "../src/db/index.js";
|
||||
|
||||
let server: TestServer;
|
||||
beforeAll(async () => {
|
||||
server = await startTestServer();
|
||||
});
|
||||
afterAll(async () => {
|
||||
await server.close();
|
||||
});
|
||||
|
||||
describe("Autodestrucción", () => {
|
||||
it("borra boards caducados y avisa a la room", async () => {
|
||||
const board = await createBoard(server.url, { ttlHours: 1 });
|
||||
// forzamos expires_at al pasado
|
||||
await db
|
||||
.update(schema.boards)
|
||||
.set({ expiresAt: new Date(Date.now() - 1000) })
|
||||
.where(eq(schema.boards.id, board.boardId));
|
||||
|
||||
const alice = await join(server.url, board.boardId, "alice", "Alice");
|
||||
const destroyed = new Promise<void>((resolve) =>
|
||||
alice.socket.once("board:destroyed", () => resolve()),
|
||||
);
|
||||
|
||||
const deleted = await runCleanup(server.io);
|
||||
expect(deleted).toContain(board.boardId);
|
||||
await destroyed; // la room recibió el aviso
|
||||
|
||||
// ya no existe en DB
|
||||
const rows = await db.select().from(schema.boards).where(eq(schema.boards.id, board.boardId));
|
||||
expect(rows).toHaveLength(0);
|
||||
|
||||
alice.socket.close();
|
||||
});
|
||||
|
||||
it("no borra boards sin caducidad ni los aún vigentes", async () => {
|
||||
const persistent = await createBoard(server.url, { ttlHours: null });
|
||||
const future = await createBoard(server.url, { ttlHours: 24 });
|
||||
|
||||
const deleted = await runCleanup(server.io);
|
||||
expect(deleted).not.toContain(persistent.boardId);
|
||||
expect(deleted).not.toContain(future.boardId);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||
import { startTestServer, createBoard, join, type TestServer } from "./helpers.js";
|
||||
|
||||
let server: TestServer;
|
||||
beforeAll(async () => {
|
||||
server = await startTestServer();
|
||||
});
|
||||
afterAll(async () => {
|
||||
await server.close();
|
||||
});
|
||||
|
||||
describe("Columnas y color", () => {
|
||||
it("recolorear cambia el color de TODAS mis notas", async () => {
|
||||
const board = await createBoard(server.url);
|
||||
const alice = await join(server.url, board.boardId, "alice", "Alice");
|
||||
const colId = alice.last!.columns[0].id;
|
||||
alice.emit("note:add", { columnId: colId, text: "a", color: "#ffe08a" });
|
||||
alice.emit("note:add", { columnId: colId, text: "b", color: "#ffe08a" });
|
||||
await alice.waitFor((s) => s.notes.length === 2);
|
||||
|
||||
alice.emit("note:recolorMine", { color: "#b9ddf5" });
|
||||
const recolored = await alice.waitFor((s) => s.notes.every((n) => n.color === "#b9ddf5"));
|
||||
expect(recolored.notes.map((n) => n.color)).toEqual(["#b9ddf5", "#b9ddf5"]);
|
||||
|
||||
alice.close();
|
||||
});
|
||||
|
||||
it("añadir, renombrar, reordenar y eliminar columnas (moderador)", async () => {
|
||||
const board = await createBoard(server.url); // mad-sad-glad => 3 columnas
|
||||
const alice = await join(server.url, board.boardId, "alice", "Alice");
|
||||
const tok = board.moderatorToken;
|
||||
|
||||
// añadir
|
||||
alice.emit("column:add", { title: "Extra", moderatorToken: tok });
|
||||
let st = await alice.waitFor((s) => s.columns.length === 4);
|
||||
const extra = st.columns.find((c) => c.title === "Extra")!;
|
||||
expect(extra).toBeTruthy();
|
||||
|
||||
// renombrar
|
||||
alice.emit("column:rename", { columnId: extra.id, title: "Renombrada", moderatorToken: tok });
|
||||
st = await alice.waitFor((s) => s.columns.some((c) => c.id === extra.id && c.title === "Renombrada"));
|
||||
expect(st.columns.find((c) => c.id === extra.id)!.title).toBe("Renombrada");
|
||||
|
||||
// reordenar: poner la última primera
|
||||
const ids = st.columns.map((c) => c.id);
|
||||
const reordered = [ids[ids.length - 1], ...ids.slice(0, -1)];
|
||||
alice.emit("column:reorder", { orderedIds: reordered, moderatorToken: tok });
|
||||
st = await alice.waitFor((s) => s.columns[0].id === reordered[0]);
|
||||
expect(st.columns.map((c) => c.id)).toEqual(reordered);
|
||||
|
||||
// eliminar
|
||||
alice.emit("column:delete", { columnId: extra.id, moderatorToken: tok });
|
||||
st = await alice.waitFor((s) => s.columns.length === 3);
|
||||
expect(st.columns.some((c) => c.id === extra.id)).toBe(false);
|
||||
|
||||
alice.close();
|
||||
});
|
||||
|
||||
it("la fase se comparte (board:phase)", async () => {
|
||||
const board = await createBoard(server.url);
|
||||
const alice = await join(server.url, board.boardId, "alice", "Alice");
|
||||
const bob = await join(server.url, board.boardId, "bob", "Bob");
|
||||
expect(alice.last!.phase).toBe("writing");
|
||||
|
||||
alice.emit("board:phase", { phase: "voting", moderatorToken: board.moderatorToken });
|
||||
const bobState = await bob.waitFor((s) => s.phase === "voting");
|
||||
expect(bobState.phase).toBe("voting");
|
||||
|
||||
alice.close();
|
||||
bob.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import postgres from "postgres";
|
||||
|
||||
// DDL self-contained: crea la base de datos de test desde cero (idempotente).
|
||||
const DDL = `
|
||||
DROP TABLE IF EXISTS action_items, votes, notes, groups, columns, boards CASCADE;
|
||||
|
||||
CREATE TABLE boards (
|
||||
id text PRIMARY KEY,
|
||||
title text NOT NULL,
|
||||
phase text NOT NULL DEFAULT 'writing',
|
||||
revealed boolean NOT NULL DEFAULT false,
|
||||
votes_revealed boolean NOT NULL DEFAULT false,
|
||||
votes_per_user integer NOT NULL DEFAULT 3,
|
||||
allow_multi_vote boolean NOT NULL DEFAULT true,
|
||||
moderation_mode text NOT NULL DEFAULT 'everyone',
|
||||
show_card_creators boolean NOT NULL DEFAULT true,
|
||||
locked boolean NOT NULL DEFAULT false,
|
||||
timer_ends_at timestamptz,
|
||||
timer_running boolean NOT NULL DEFAULT false,
|
||||
moderator_token text NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
expires_at timestamptz
|
||||
);
|
||||
|
||||
CREATE TABLE columns (
|
||||
id text PRIMARY KEY,
|
||||
board_id text NOT NULL REFERENCES boards(id) ON DELETE CASCADE,
|
||||
title text NOT NULL,
|
||||
position integer NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE groups (
|
||||
id text PRIMARY KEY,
|
||||
board_id text NOT NULL REFERENCES boards(id) ON DELETE CASCADE,
|
||||
column_id text NOT NULL REFERENCES columns(id) ON DELETE CASCADE,
|
||||
title text NOT NULL DEFAULT '',
|
||||
position double precision NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE notes (
|
||||
id text PRIMARY KEY,
|
||||
board_id text NOT NULL REFERENCES boards(id) ON DELETE CASCADE,
|
||||
column_id text NOT NULL REFERENCES columns(id) ON DELETE CASCADE,
|
||||
group_id text REFERENCES groups(id) ON DELETE SET NULL,
|
||||
author_id text NOT NULL,
|
||||
author_name text NOT NULL,
|
||||
text text NOT NULL,
|
||||
color text,
|
||||
revealed boolean NOT NULL DEFAULT false,
|
||||
position double precision NOT NULL DEFAULT 0,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE votes (
|
||||
board_id text NOT NULL REFERENCES boards(id) ON DELETE CASCADE,
|
||||
target_type text NOT NULL,
|
||||
target_id text NOT NULL,
|
||||
participant_id text NOT NULL,
|
||||
count integer NOT NULL DEFAULT 1
|
||||
);
|
||||
CREATE UNIQUE INDEX votes_target_participant_uniq ON votes (target_id, participant_id);
|
||||
|
||||
CREATE TABLE action_items (
|
||||
id text PRIMARY KEY,
|
||||
board_id text NOT NULL REFERENCES boards(id) ON DELETE CASCADE,
|
||||
text text NOT NULL,
|
||||
owner text,
|
||||
done boolean NOT NULL DEFAULT false,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
`;
|
||||
|
||||
export async function setup() {
|
||||
// globalSetup no recibe `test.env`; replicamos el default de vitest.config.ts.
|
||||
const url =
|
||||
process.env.TEST_DATABASE_URL ??
|
||||
process.env.DATABASE_URL ??
|
||||
"postgres://retro:retro@localhost:5433/retro_test";
|
||||
// Asegura que exista la base de datos de test (crea retro_test si falta).
|
||||
const dbName = new URL(url).pathname.slice(1);
|
||||
const adminUrl = url.replace(`/${dbName}`, "/postgres");
|
||||
const admin = postgres(adminUrl, { max: 1 });
|
||||
try {
|
||||
const exists = await admin`SELECT 1 FROM pg_database WHERE datname = ${dbName}`;
|
||||
if (exists.length === 0) await admin.unsafe(`CREATE DATABASE ${dbName}`);
|
||||
} finally {
|
||||
await admin.end();
|
||||
}
|
||||
|
||||
const sql = postgres(url, { max: 1 });
|
||||
try {
|
||||
await sql.unsafe(DDL);
|
||||
} finally {
|
||||
await sql.end();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||
import { startTestServer, createBoard, join, type TestServer } from "./helpers.js";
|
||||
|
||||
let server: TestServer;
|
||||
beforeAll(async () => {
|
||||
server = await startTestServer();
|
||||
});
|
||||
afterAll(async () => {
|
||||
await server.close();
|
||||
});
|
||||
|
||||
async function twoNotes(boardId: string, url: string) {
|
||||
const alice = await join(url, boardId, "alice", "Alice");
|
||||
const colId = alice.last!.columns[0].id;
|
||||
alice.emit("note:add", { columnId: colId, text: "A" });
|
||||
alice.emit("note:add", { columnId: colId, text: "B" });
|
||||
const st = await alice.waitFor((s) => s.notes.length === 2);
|
||||
const [a, b] = st.notes;
|
||||
return { alice, colId, a, b };
|
||||
}
|
||||
|
||||
describe("Grupos", () => {
|
||||
it("agrupar dos notas crea un grupo votable", async () => {
|
||||
const board = await createBoard(server.url);
|
||||
const { alice, a, b } = await twoNotes(board.boardId, server.url);
|
||||
|
||||
alice.emit("group:create", { noteId: b.id, targetNoteId: a.id });
|
||||
const grouped = await alice.waitFor((s) => s.groups.length === 1);
|
||||
const groupId = grouped.groups[0].id;
|
||||
expect(grouped.notes.every((n) => n.groupId === groupId)).toBe(true);
|
||||
|
||||
// votar el grupo como unidad
|
||||
alice.emit("vote:toggle", { targetType: "group", targetId: groupId });
|
||||
const voted = await alice.waitFor((s) => s.votes.some((v) => v.targetId === groupId));
|
||||
expect(voted.votes.filter((v) => v.targetId === groupId)).toHaveLength(1);
|
||||
|
||||
alice.close();
|
||||
});
|
||||
|
||||
it("al agrupar se reinician los votos sueltos de las notas", async () => {
|
||||
const board = await createBoard(server.url);
|
||||
const { alice, a, b } = await twoNotes(board.boardId, server.url);
|
||||
|
||||
alice.emit("vote:toggle", { targetType: "note", targetId: a.id });
|
||||
await alice.waitFor((s) => s.votes.some((v) => v.targetId === a.id));
|
||||
|
||||
alice.emit("group:create", { noteId: b.id, targetNoteId: a.id });
|
||||
const after = await alice.waitFor((s) => s.groups.length === 1);
|
||||
expect(after.votes.filter((v) => v.targetId === a.id)).toHaveLength(0);
|
||||
|
||||
alice.close();
|
||||
});
|
||||
|
||||
it("sacar la última nota de un grupo lo elimina", async () => {
|
||||
const board = await createBoard(server.url);
|
||||
const { alice, colId, a, b } = await twoNotes(board.boardId, server.url);
|
||||
|
||||
alice.emit("group:create", { noteId: b.id, targetNoteId: a.id });
|
||||
const grouped = await alice.waitFor((s) => s.groups.length === 1);
|
||||
expect(grouped.groups).toHaveLength(1);
|
||||
|
||||
// saca una nota: el grupo sigue (1 miembro)
|
||||
alice.emit("note:move", { noteId: a.id, columnId: colId, groupId: null, position: 5 });
|
||||
await alice.waitFor((s) => s.notes.filter((n) => n.groupId).length === 1);
|
||||
|
||||
// saca la última: el grupo se borra
|
||||
alice.emit("note:move", { noteId: b.id, columnId: colId, groupId: null, position: 6 });
|
||||
const empty = await alice.waitFor((s) => s.groups.length === 0);
|
||||
expect(empty.groups).toHaveLength(0);
|
||||
|
||||
alice.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import { buildServer, type BuiltServer } from "../src/app.js";
|
||||
import { io as ioc, type Socket } from "socket.io-client";
|
||||
import type { BoardState } from "../src/room.js";
|
||||
|
||||
export type TestServer = BuiltServer & { port: number; url: string };
|
||||
|
||||
export async function startTestServer(): Promise<TestServer> {
|
||||
const server = buildServer({ cleanup: false });
|
||||
const port = await server.listen(0); // puerto efímero
|
||||
return Object.assign(server, { port, url: `http://localhost:${port}` });
|
||||
}
|
||||
|
||||
export async function createBoard(
|
||||
url: string,
|
||||
body: Record<string, unknown> = {},
|
||||
): Promise<{ boardId: string; moderatorToken: string; expiresAt: string | null }> {
|
||||
const res = await fetch(`${url}/api/boards`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ templateId: "mad-sad-glad", votesPerUser: 3, ...body }),
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// Cliente de test: mantiene el último estado recibido para evitar carreras
|
||||
// (el estado buscado puede haber llegado antes de adjuntar el listener).
|
||||
export class Client {
|
||||
last: BoardState | null = null;
|
||||
constructor(public socket: Socket) {
|
||||
socket.on("board:state", (s: BoardState) => (this.last = s));
|
||||
}
|
||||
|
||||
emit(event: string, payload: Record<string, unknown>) {
|
||||
this.socket.emit(event, payload);
|
||||
}
|
||||
|
||||
// Resuelve cuando el último estado (presente o futuro) cumple el predicado.
|
||||
waitFor(predicate: (s: BoardState) => boolean, timeoutMs = 10000): Promise<BoardState> {
|
||||
if (this.last && predicate(this.last)) return Promise.resolve(this.last);
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
this.socket.off("board:state", handler);
|
||||
reject(new Error("waitFor timeout"));
|
||||
}, timeoutMs);
|
||||
const handler = (s: BoardState) => {
|
||||
if (predicate(s)) {
|
||||
clearTimeout(timer);
|
||||
this.socket.off("board:state", handler);
|
||||
resolve(s);
|
||||
}
|
||||
};
|
||||
this.socket.on("board:state", handler);
|
||||
});
|
||||
}
|
||||
|
||||
close() {
|
||||
this.socket.close();
|
||||
}
|
||||
}
|
||||
|
||||
// Conecta, hace join y devuelve un Client ya con su primer estado.
|
||||
export function join(
|
||||
url: string,
|
||||
boardId: string,
|
||||
participantId: string,
|
||||
name: string,
|
||||
): Promise<Client> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const socket = ioc(url, { transports: ["websocket"], forceNew: true });
|
||||
const client = new Client(socket);
|
||||
const timer = setTimeout(() => reject(new Error("join timeout")), 10000);
|
||||
socket.on("connect", () => socket.emit("board:join", { boardId, participantId, name }));
|
||||
socket.once("error", (e) => {
|
||||
clearTimeout(timer);
|
||||
reject(new Error(`join error: ${JSON.stringify(e)}`));
|
||||
});
|
||||
const ready = (s: BoardState) => {
|
||||
clearTimeout(timer);
|
||||
socket.off("board:state", ready);
|
||||
client.last = s;
|
||||
resolve(client);
|
||||
};
|
||||
socket.on("board:state", ready);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||
import { startTestServer, type TestServer } from "./helpers.js";
|
||||
|
||||
let server: TestServer;
|
||||
beforeAll(async () => {
|
||||
server = await startTestServer();
|
||||
});
|
||||
afterAll(async () => {
|
||||
await server.close();
|
||||
});
|
||||
|
||||
const snapshot = {
|
||||
version: 1,
|
||||
board: { title: "Importado", votesPerUser: 4 },
|
||||
columns: [
|
||||
{ id: "c1", title: "Columna A", position: 0 },
|
||||
{ id: "c2", title: "Columna B", position: 1 },
|
||||
],
|
||||
groups: [],
|
||||
notes: [
|
||||
{
|
||||
id: "n1",
|
||||
columnId: "c1",
|
||||
groupId: null,
|
||||
authorId: "x",
|
||||
authorName: "X",
|
||||
text: "nota importada",
|
||||
revealed: true,
|
||||
position: 1,
|
||||
},
|
||||
],
|
||||
votes: [{ targetType: "note", targetId: "n1", participantId: "x" }],
|
||||
actionItems: [{ text: "tarea importada", owner: "X" }],
|
||||
};
|
||||
|
||||
async function importAndExport(mode: "full" | "structure") {
|
||||
const res = await fetch(`${server.url}/api/boards/import`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ snapshot, mode }),
|
||||
});
|
||||
const { boardId } = await res.json();
|
||||
const md = await (await fetch(`${server.url}/api/boards/${boardId}/export`)).text();
|
||||
return { boardId, md };
|
||||
}
|
||||
|
||||
describe("Import", () => {
|
||||
it("modo 'full' importa columnas, notas y votos", async () => {
|
||||
const { md } = await importAndExport("full");
|
||||
expect(md).toContain("# Importado");
|
||||
expect(md).toContain("## Columna A");
|
||||
expect(md).toContain("nota importada — _X_ (1 👍)");
|
||||
expect(md).toContain("tarea importada");
|
||||
});
|
||||
|
||||
it("modo 'structure' importa solo columnas y config, sin notas", async () => {
|
||||
const { md } = await importAndExport("structure");
|
||||
expect(md).toContain("## Columna A");
|
||||
expect(md).toContain("## Columna B");
|
||||
expect(md).not.toContain("nota importada");
|
||||
expect(md).not.toContain("tarea importada");
|
||||
});
|
||||
|
||||
it("rechaza un snapshot sin columnas", async () => {
|
||||
const res = await fetch(`${server.url}/api/boards/import`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ snapshot: { version: 1, columns: [] }, mode: "full" }),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Export con opciones", () => {
|
||||
async function importFull(): Promise<string> {
|
||||
const res = await fetch(`${server.url}/api/boards/import`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ snapshot, mode: "full" }),
|
||||
});
|
||||
return (await res.json()).boardId;
|
||||
}
|
||||
const md = (id: string, qs: string) =>
|
||||
fetch(`${server.url}/api/boards/${id}/export${qs}`).then((r) => r.text());
|
||||
|
||||
it("autores y votos se pueden desactivar por query", async () => {
|
||||
const id = await importFull();
|
||||
expect(await md(id, "")).toContain("nota importada — _X_ (1 👍)"); // defaults
|
||||
const noAuthors = await md(id, "?authors=0");
|
||||
expect(noAuthors).toContain("nota importada (1 👍)");
|
||||
expect(noAuthors).not.toContain("_X_");
|
||||
const noVotes = await md(id, "?votes=0");
|
||||
expect(noVotes).not.toContain("👍");
|
||||
});
|
||||
|
||||
it("la fecha de creación se añade al título con created=1", async () => {
|
||||
const id = await importFull();
|
||||
const withDate = await md(id, "?created=1");
|
||||
expect(withDate).toMatch(/^# \d+\/\d+\/\d+ - Importado/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||
import { startTestServer, createBoard, join, type TestServer } from "./helpers.js";
|
||||
|
||||
let server: TestServer;
|
||||
beforeAll(async () => {
|
||||
server = await startTestServer();
|
||||
});
|
||||
afterAll(async () => {
|
||||
await server.close();
|
||||
});
|
||||
|
||||
const myCount = (state: { votes: { targetId: string; participantId: string; count: number }[] }, targetId: string) =>
|
||||
state.votes
|
||||
.filter((v) => v.targetId === targetId && v.participantId === "alice")
|
||||
.reduce((s, v) => s + v.count, 0);
|
||||
|
||||
describe("Multivoto", () => {
|
||||
it("apila votos en el mismo ítem hasta el cupo y deja quitar", async () => {
|
||||
const board = await createBoard(server.url, { votesPerUser: 3 }); // allowMultiVote por defecto: true
|
||||
const alice = await join(server.url, board.boardId, "alice", "Alice");
|
||||
const colId = alice.last!.columns[0].id;
|
||||
alice.emit("note:add", { columnId: colId, text: "favorita" });
|
||||
const noteId = (await alice.waitFor((s) => s.notes.length === 1)).notes[0].id;
|
||||
|
||||
alice.emit("vote:add", { targetType: "note", targetId: noteId });
|
||||
alice.emit("vote:add", { targetType: "note", targetId: noteId });
|
||||
alice.emit("vote:add", { targetType: "note", targetId: noteId });
|
||||
const stacked = await alice.waitFor((s) => myCount(s, noteId) === 3);
|
||||
expect(myCount(stacked, noteId)).toBe(3);
|
||||
|
||||
// 4º voto: cupo agotado, no sube
|
||||
alice.emit("vote:add", { targetType: "note", targetId: noteId });
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
expect(myCount(alice.last!, noteId)).toBe(3);
|
||||
|
||||
// quitar uno
|
||||
alice.emit("vote:remove", { targetId: noteId });
|
||||
const afterRemove = await alice.waitFor((s) => myCount(s, noteId) === 2);
|
||||
expect(myCount(afterRemove, noteId)).toBe(2);
|
||||
|
||||
alice.close();
|
||||
});
|
||||
|
||||
it("con allowMultiVote=false no se puede apilar en el mismo ítem", async () => {
|
||||
const board = await createBoard(server.url, { votesPerUser: 5 });
|
||||
const alice = await join(server.url, board.boardId, "alice", "Alice");
|
||||
alice.emit("board:update", { allowMultiVote: false, moderatorToken: board.moderatorToken });
|
||||
await alice.waitFor((s) => s.allowMultiVote === false);
|
||||
|
||||
const colId = alice.last!.columns[0].id;
|
||||
alice.emit("note:add", { columnId: colId, text: "uno" });
|
||||
const noteId = (await alice.waitFor((s) => s.notes.length === 1)).notes[0].id;
|
||||
|
||||
alice.emit("vote:add", { targetType: "note", targetId: noteId });
|
||||
alice.emit("vote:add", { targetType: "note", targetId: noteId });
|
||||
const after = await alice.waitFor((s) => myCount(s, noteId) >= 1);
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
expect(myCount(after, noteId)).toBe(1);
|
||||
|
||||
alice.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||
import { startTestServer, createBoard, join, type TestServer } from "./helpers.js";
|
||||
|
||||
let server: TestServer;
|
||||
beforeAll(async () => {
|
||||
server = await startTestServer();
|
||||
});
|
||||
afterAll(async () => {
|
||||
await server.close();
|
||||
});
|
||||
|
||||
describe("Moderación y opciones", () => {
|
||||
it("por defecto cualquiera modera; al reclamar solo el reclamante", async () => {
|
||||
const board = await createBoard(server.url);
|
||||
const alice = await join(server.url, board.boardId, "alice", "Alice");
|
||||
const bob = await join(server.url, board.boardId, "bob", "Bob");
|
||||
|
||||
// modo abierto por defecto
|
||||
expect(alice.last!.moderationMode).toBe("everyone");
|
||||
|
||||
// Alice reclama (sin token, permitido en modo abierto) y recibe un token nuevo
|
||||
const claimed = new Promise<string>((resolve) =>
|
||||
alice.socket.once("board:claimed", (p: { moderatorToken: string }) => resolve(p.moderatorToken)),
|
||||
);
|
||||
alice.emit("board:claim", {});
|
||||
const token = await claimed;
|
||||
await alice.waitFor((s) => s.moderationMode === "single");
|
||||
|
||||
// Bob (sin token) ya no puede revelar; Alice con su token sí
|
||||
bob.emit("board:reveal", { revealed: true });
|
||||
await new Promise((r) => setTimeout(r, 150));
|
||||
expect(bob.last!.revealed).toBe(false);
|
||||
|
||||
alice.emit("board:reveal", { revealed: true, moderatorToken: token });
|
||||
const revealed = await alice.waitFor((s) => s.revealed === true);
|
||||
expect(revealed.revealed).toBe(true);
|
||||
|
||||
alice.close();
|
||||
bob.close();
|
||||
});
|
||||
|
||||
it("bloquear deja el board en solo lectura (no se añaden notas)", async () => {
|
||||
const board = await createBoard(server.url);
|
||||
const alice = await join(server.url, board.boardId, "alice", "Alice");
|
||||
const colId = alice.last!.columns[0].id;
|
||||
|
||||
alice.emit("board:update", { locked: true }); // modo abierto: cualquiera modera
|
||||
await alice.waitFor((s) => s.locked === true);
|
||||
|
||||
alice.emit("note:add", { columnId: colId, text: "no debería entrar" });
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
expect(alice.last!.notes).toHaveLength(0);
|
||||
|
||||
// desbloquear y ahora sí
|
||||
alice.emit("board:update", { locked: false });
|
||||
await alice.waitFor((s) => s.locked === false);
|
||||
alice.emit("note:add", { columnId: colId, text: "ahora sí" });
|
||||
const after = await alice.waitFor((s) => s.notes.length === 1);
|
||||
expect(after.notes[0].text).toBe("ahora sí");
|
||||
|
||||
alice.close();
|
||||
});
|
||||
|
||||
it("votes:clear borra todos los votos", async () => {
|
||||
const board = await createBoard(server.url);
|
||||
const alice = await join(server.url, board.boardId, "alice", "Alice");
|
||||
const colId = alice.last!.columns[0].id;
|
||||
alice.emit("note:add", { columnId: colId, text: "x" });
|
||||
const noteId = (await alice.waitFor((s) => s.notes.length === 1)).notes[0].id;
|
||||
alice.emit("vote:add", { targetType: "note", targetId: noteId });
|
||||
await alice.waitFor((s) => s.votes.length === 1);
|
||||
|
||||
alice.emit("votes:clear", {});
|
||||
const cleared = await alice.waitFor((s) => s.votes.length === 0);
|
||||
expect(cleared.votes).toHaveLength(0);
|
||||
|
||||
alice.close();
|
||||
});
|
||||
|
||||
it("participant:rename actualiza la autoría de mis notas", async () => {
|
||||
const board = await createBoard(server.url);
|
||||
const alice = await join(server.url, board.boardId, "alice", "Alice");
|
||||
const colId = alice.last!.columns[0].id;
|
||||
alice.emit("note:add", { columnId: colId, text: "mía" });
|
||||
await alice.waitFor((s) => s.notes.length === 1 && s.notes[0].authorName === "Alice");
|
||||
|
||||
alice.emit("participant:rename", { name: "Alicia" });
|
||||
const renamed = await alice.waitFor((s) => s.notes[0]?.authorName === "Alicia");
|
||||
expect(renamed.notes[0].authorName).toBe("Alicia");
|
||||
expect(renamed.participants.find((p) => p.id === "alice")?.name).toBe("Alicia");
|
||||
|
||||
alice.close();
|
||||
});
|
||||
|
||||
it("la presencia lista a los participantes conectados", async () => {
|
||||
const board = await createBoard(server.url);
|
||||
const alice = await join(server.url, board.boardId, "alice", "Alice");
|
||||
const bob = await join(server.url, board.boardId, "bob", "Bob");
|
||||
const st = await alice.waitFor((s) => s.participants.length === 2);
|
||||
expect(st.participants.map((p) => p.name).sort()).toEqual(["Alice", "Bob"]);
|
||||
alice.close();
|
||||
bob.close();
|
||||
});
|
||||
|
||||
it("action item: marcar hecho", async () => {
|
||||
const board = await createBoard(server.url);
|
||||
const alice = await join(server.url, board.boardId, "alice", "Alice");
|
||||
alice.emit("actionitem:add", { text: "documentar" });
|
||||
const withItem = await alice.waitFor((s) => s.actionItems.length === 1);
|
||||
const id = withItem.actionItems[0].id;
|
||||
expect(withItem.actionItems[0].done).toBe(false);
|
||||
|
||||
alice.emit("actionitem:toggle", { id, done: true });
|
||||
const done = await alice.waitFor((s) => s.actionItems[0]?.done === true);
|
||||
expect(done.actionItems[0].done).toBe(true);
|
||||
|
||||
alice.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||
import { startTestServer, createBoard, join, type TestServer } from "./helpers.js";
|
||||
|
||||
let server: TestServer;
|
||||
beforeAll(async () => {
|
||||
server = await startTestServer();
|
||||
});
|
||||
afterAll(async () => {
|
||||
await server.close();
|
||||
});
|
||||
|
||||
describe("Notas privadas y publicar", () => {
|
||||
it("note:publishMine publica todas mis notas de la columna", async () => {
|
||||
const board = await createBoard(server.url);
|
||||
const alice = await join(server.url, board.boardId, "alice", "Alice");
|
||||
const bob = await join(server.url, board.boardId, "bob", "Bob");
|
||||
const colId = alice.last!.columns[0].id;
|
||||
|
||||
alice.emit("note:add", { columnId: colId, text: "uno" });
|
||||
alice.emit("note:add", { columnId: colId, text: "dos" });
|
||||
await alice.waitFor((s) => s.notes.length === 2);
|
||||
|
||||
// Bob recibe las notas pero sin texto (privadas)
|
||||
const bobBefore = await bob.waitFor((s) => s.notes.length === 2);
|
||||
expect(bobBefore.notes.every((n) => n.text === "")).toBe(true);
|
||||
|
||||
alice.emit("note:publishMine", { columnId: colId });
|
||||
|
||||
// guard de longitud: every() es vacuamente true sobre [] -> exigimos las 2 notas
|
||||
const bobAfter = await bob.waitFor((s) => s.notes.length === 2 && s.notes.every((n) => n.text !== ""));
|
||||
expect(bobAfter.notes.map((n) => n.text).sort()).toEqual(["dos", "uno"]);
|
||||
|
||||
alice.close();
|
||||
bob.close();
|
||||
});
|
||||
|
||||
it("agrupar publica las notas implicadas", async () => {
|
||||
const board = await createBoard(server.url);
|
||||
const alice = await join(server.url, board.boardId, "alice", "Alice");
|
||||
const bob = await join(server.url, board.boardId, "bob", "Bob");
|
||||
const colId = alice.last!.columns[0].id;
|
||||
|
||||
alice.emit("note:add", { columnId: colId, text: "A" });
|
||||
alice.emit("note:add", { columnId: colId, text: "B" });
|
||||
const st = await alice.waitFor((s) => s.notes.length === 2);
|
||||
const [a, b] = st.notes;
|
||||
|
||||
alice.emit("group:create", { noteId: b.id, targetNoteId: a.id });
|
||||
|
||||
// Bob ve ambas notas con texto (agrupar es público)
|
||||
const bobAfter = await bob.waitFor(
|
||||
(s) => s.groups.length === 1 && s.notes.length === 2 && s.notes.every((n) => n.text !== ""),
|
||||
);
|
||||
expect(bobAfter.notes.map((n) => n.text).sort()).toEqual(["A", "B"]);
|
||||
|
||||
alice.close();
|
||||
bob.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||
import { startTestServer, createBoard, type TestServer } from "./helpers.js";
|
||||
|
||||
let server: TestServer;
|
||||
beforeAll(async () => {
|
||||
server = await startTestServer();
|
||||
});
|
||||
afterAll(async () => {
|
||||
await server.close();
|
||||
});
|
||||
|
||||
describe("REST", () => {
|
||||
it("lista plantillas predefinidas", async () => {
|
||||
const res = await fetch(`${server.url}/api/templates`);
|
||||
const templates = await res.json();
|
||||
expect(Array.isArray(templates)).toBe(true);
|
||||
expect(templates.find((t: any) => t.id === "mad-sad-glad")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("crea un board desde plantilla y devuelve token de moderador", async () => {
|
||||
const board = await createBoard(server.url, { title: "Sprint 1" });
|
||||
expect(board.boardId).toHaveLength(12);
|
||||
expect(board.moderatorToken.length).toBeGreaterThan(10);
|
||||
|
||||
const meta = await (await fetch(`${server.url}/api/boards/${board.boardId}`)).json();
|
||||
expect(meta.title).toBe("Sprint 1");
|
||||
});
|
||||
|
||||
it("crea un board con columnas libres", async () => {
|
||||
const board = await createBoard(server.url, {
|
||||
templateId: undefined,
|
||||
columns: ["Bien", "Mal", "Acciones"],
|
||||
});
|
||||
const md = await (await fetch(`${server.url}/api/boards/${board.boardId}/export`)).text();
|
||||
expect(md).toContain("## Bien");
|
||||
expect(md).toContain("## Mal");
|
||||
});
|
||||
|
||||
it("fija expiresAt cuando se pasa ttlHours", async () => {
|
||||
const board = await createBoard(server.url, { ttlHours: 2 });
|
||||
expect(board.expiresAt).toBeTruthy();
|
||||
expect(new Date(board.expiresAt!).getTime()).toBeGreaterThan(Date.now());
|
||||
});
|
||||
|
||||
it("devuelve 404 para board inexistente", async () => {
|
||||
const res = await fetch(`${server.url}/api/boards/noexiste123`);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("rechaza plantilla inexistente con 400", async () => {
|
||||
const res = await fetch(`${server.url}/api/boards`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ templateId: "nope" }),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||
import { startTestServer, createBoard, join, type TestServer } from "./helpers.js";
|
||||
|
||||
let server: TestServer;
|
||||
beforeAll(async () => {
|
||||
server = await startTestServer();
|
||||
});
|
||||
afterAll(async () => {
|
||||
await server.close();
|
||||
});
|
||||
|
||||
describe("Revelado granular", () => {
|
||||
it("el autor revela solo su propia nota", async () => {
|
||||
const board = await createBoard(server.url);
|
||||
const alice = await join(server.url, board.boardId, "alice", "Alice");
|
||||
const bob = await join(server.url, board.boardId, "bob", "Bob");
|
||||
const colId = alice.last!.columns[0].id;
|
||||
|
||||
alice.emit("note:add", { columnId: colId, text: "mía" });
|
||||
let bobState = await bob.waitFor((s) => s.notes.length === 1);
|
||||
expect(bobState.notes[0].text).toBe(""); // oculta
|
||||
|
||||
const noteId = (await alice.waitFor((s) => s.notes.length === 1)).notes[0].id;
|
||||
alice.emit("note:reveal", { noteId, revealed: true });
|
||||
|
||||
bobState = await bob.waitFor((s) => s.notes[0]?.text === "mía");
|
||||
expect(bobState.notes[0].text).toBe("mía");
|
||||
|
||||
alice.close();
|
||||
bob.close();
|
||||
});
|
||||
|
||||
it("los totales de votos se ocultan hasta que el moderador los revela", async () => {
|
||||
const board = await createBoard(server.url);
|
||||
const alice = await join(server.url, board.boardId, "alice", "Alice");
|
||||
const bob = await join(server.url, board.boardId, "bob", "Bob");
|
||||
const colId = alice.last!.columns[0].id;
|
||||
|
||||
alice.emit("note:add", { columnId: colId, text: "vota esto" });
|
||||
const noteId = (await alice.waitFor((s) => s.notes.length === 1)).notes[0].id;
|
||||
await bob.waitFor((s) => s.notes.length === 1);
|
||||
|
||||
// Bob vota; Alice NO debe ver el voto de Bob (totales ocultos)
|
||||
bob.emit("vote:toggle", { targetType: "note", targetId: noteId });
|
||||
await bob.waitFor((s) => s.votes.some((v) => v.targetId === noteId)); // Bob ve el suyo
|
||||
|
||||
const aliceView = alice.last!;
|
||||
expect(aliceView.votes.filter((v) => v.targetId === noteId)).toHaveLength(0);
|
||||
|
||||
// El moderador revela los votos => Alice ve el de Bob
|
||||
alice.emit("board:revealVotes", { revealed: true, moderatorToken: board.moderatorToken });
|
||||
const revealed = await alice.waitFor((s) => s.votesRevealed && s.votes.length === 1);
|
||||
expect(revealed.votes[0].participantId).toBe("bob");
|
||||
|
||||
alice.close();
|
||||
bob.close();
|
||||
});
|
||||
|
||||
it("cambiar los ajustes del board requiere token de moderador", async () => {
|
||||
const board = await createBoard(server.url, { votesPerUser: 3 });
|
||||
const alice = await join(server.url, board.boardId, "alice", "Alice");
|
||||
|
||||
// sin token: ignorado
|
||||
alice.emit("board:update", { title: "Hackeado", votesPerUser: 99 });
|
||||
// con token: aplica
|
||||
alice.emit("board:update", { title: "Renombrado", votesPerUser: 7, moderatorToken: board.moderatorToken });
|
||||
|
||||
const updated = await alice.waitFor((s) => s.title === "Renombrado");
|
||||
expect(updated.votesPerUser).toBe(7);
|
||||
|
||||
alice.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||
import { startTestServer, createBoard, join, type TestServer } from "./helpers.js";
|
||||
|
||||
let server: TestServer;
|
||||
beforeAll(async () => {
|
||||
server = await startTestServer();
|
||||
});
|
||||
afterAll(async () => {
|
||||
await server.close();
|
||||
});
|
||||
|
||||
describe("Socket.IO — flujo de retro", () => {
|
||||
it("oculta las notas de otros hasta revelar", async () => {
|
||||
const board = await createBoard(server.url);
|
||||
const alice = await join(server.url, board.boardId, "alice", "Alice");
|
||||
const bob = await join(server.url, board.boardId, "bob", "Bob");
|
||||
const colId = alice.last!.columns[0].id;
|
||||
|
||||
alice.emit("note:add", { columnId: colId, text: "secreto de Alice" });
|
||||
|
||||
const bobState = await bob.waitFor((s) => s.notes.length === 1);
|
||||
expect(bobState.notes[0].text).toBe(""); // oculta para Bob
|
||||
expect(bobState.notes[0].authorName).toBe("Alice");
|
||||
|
||||
const aliceState = await alice.waitFor((s) => s.notes.length === 1);
|
||||
expect(aliceState.notes[0].text).toBe("secreto de Alice"); // visible para la autora
|
||||
|
||||
alice.close();
|
||||
bob.close();
|
||||
});
|
||||
|
||||
it("revela las notas a todos cuando el moderador lo pide", async () => {
|
||||
const board = await createBoard(server.url);
|
||||
const alice = await join(server.url, board.boardId, "alice", "Alice");
|
||||
const bob = await join(server.url, board.boardId, "bob", "Bob");
|
||||
const colId = alice.last!.columns[0].id;
|
||||
|
||||
alice.emit("note:add", { columnId: colId, text: "visible tras revelar" });
|
||||
await bob.waitFor((s) => s.notes.length === 1);
|
||||
|
||||
// Bob (sin token) no puede revelar; Alice (moderadora) sí
|
||||
bob.emit("board:reveal", { revealed: true });
|
||||
alice.emit("board:reveal", { revealed: true, moderatorToken: board.moderatorToken });
|
||||
|
||||
const revealed = await bob.waitFor((s) => s.revealed === true);
|
||||
expect(revealed.notes[0].text).toBe("visible tras revelar");
|
||||
|
||||
alice.close();
|
||||
bob.close();
|
||||
});
|
||||
|
||||
it("respeta el límite de votos por persona", async () => {
|
||||
const board = await createBoard(server.url, { votesPerUser: 2 });
|
||||
const alice = await join(server.url, board.boardId, "alice", "Alice");
|
||||
const colId = alice.last!.columns[0].id;
|
||||
|
||||
for (const txt of ["a", "b", "c"]) alice.emit("note:add", { columnId: colId, text: txt });
|
||||
const withNotes = await alice.waitFor((s) => s.notes.length === 3);
|
||||
const ids = withNotes.notes.map((n) => n.id);
|
||||
const mine = (s: { votes: { participantId: string }[] }) =>
|
||||
s.votes.filter((v) => v.participantId === "alice").length;
|
||||
|
||||
// vota las dos primeras (confirmando cada una)
|
||||
alice.emit("vote:toggle", { noteId: ids[0] });
|
||||
await alice.waitFor((s) => mine(s) === 1);
|
||||
alice.emit("vote:toggle", { noteId: ids[1] });
|
||||
await alice.waitFor((s) => mine(s) === 2);
|
||||
|
||||
// el tercer voto se rechaza (cupo = 2)
|
||||
alice.emit("vote:toggle", { noteId: ids[2] });
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
expect(mine(alice.last!)).toBe(2);
|
||||
|
||||
// quitar un voto libera cupo
|
||||
alice.emit("vote:toggle", { noteId: ids[0] });
|
||||
const afterUnvote = await alice.waitFor((s) => mine(s) === 1);
|
||||
expect(afterUnvote.votes).toHaveLength(1);
|
||||
|
||||
alice.close();
|
||||
});
|
||||
|
||||
it("comparte el timer (endsAt) con todos los participantes", async () => {
|
||||
const board = await createBoard(server.url);
|
||||
const alice = await join(server.url, board.boardId, "alice", "Alice");
|
||||
const bob = await join(server.url, board.boardId, "bob", "Bob");
|
||||
|
||||
alice.emit("timer:start", { seconds: 300, moderatorToken: board.moderatorToken });
|
||||
const bobState = await bob.waitFor((s) => s.timerRunning);
|
||||
expect(bobState.timerEndsAt).toBeTruthy();
|
||||
expect(new Date(bobState.timerEndsAt!).getTime()).toBeGreaterThan(Date.now());
|
||||
|
||||
alice.close();
|
||||
bob.close();
|
||||
});
|
||||
|
||||
it("crea action items visibles para todos", async () => {
|
||||
const board = await createBoard(server.url);
|
||||
const alice = await join(server.url, board.boardId, "alice", "Alice");
|
||||
const bob = await join(server.url, board.boardId, "bob", "Bob");
|
||||
|
||||
alice.emit("actionitem:add", { text: "documentar el deploy" });
|
||||
const bobState = await bob.waitFor((s) => s.actionItems.length === 1);
|
||||
expect(bobState.actionItems[0].text).toBe("documentar el deploy");
|
||||
|
||||
alice.close();
|
||||
bob.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
import { beforeEach } from "vitest";
|
||||
import postgres from "postgres";
|
||||
|
||||
// Limpia las tablas antes de cada test para aislarlos.
|
||||
const sql = postgres(process.env.DATABASE_URL!, { max: 1 });
|
||||
|
||||
beforeEach(async () => {
|
||||
await sql.unsafe("TRUNCATE action_items, votes, notes, groups, columns, boards CASCADE");
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
const TEST_DB =
|
||||
process.env.TEST_DATABASE_URL ?? "postgres://retro:retro@localhost:5433/retro_test";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globalSetup: "./test/global-setup.ts",
|
||||
setupFiles: "./test/truncate.ts",
|
||||
env: { DATABASE_URL: TEST_DB },
|
||||
fileParallelism: false, // comparten la misma DB; evitamos carreras
|
||||
hookTimeout: 30_000,
|
||||
testTimeout: 20_000,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user