Initial Planlink implementation

This commit is contained in:
2026-03-05 14:15:58 -05:00
commit ad6c63b699
39 changed files with 7510 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
node_modules
.env
.env.*
dist
coverage
.DS_Store
+117
View File
@@ -0,0 +1,117 @@
# Planlink - Bitacora y planers
## Objetivo
Construir desde cero una app tipo Planka (kanban colaborativo) usando React + Node + Tailwind + MariaDB, sin TypeScript y con arquitectura modular. Este archivo se usara para registrar cada paso validado y los planers que la app va a manejar.
## Referencia funcional (Planka)
Planka ofrece tableros kanban colaborativos con proyectos, boards, listas y cards, drag and drop, actualizacion en tiempo real, markdown en descripciones, notificaciones y soporte multilenguaje. Esta lista es la referencia base para el alcance inicial. Ver repositorio oficial para mas detalles.
## Stack confirmado
- Frontend: React (sin TS) + Tailwind
- Backend: Node.js (sin TS)
- DB: MariaDB
- Realtime: WebSocket (Socket.io) desde el inicio
## Autenticacion (MVP)
- Email/password basico para acceder a la app
- Acceso al asistente IA solo despues de login
- Google OAuth (opcional, pendiente de confirmacion)
## Arquitectura propuesta (modular)
- client/: app React
- server/: API Node
- db/: migraciones y seeders
- docs/: notas tecnicas (opcional)
## Modulos iniciales
1) Auth y usuarios (email/password, roles basicos)
2) Proyectos (un proyecto es la unidad principal, sin workspaces)
3) Invitaciones a proyecto (owner invita usuarios)
4) Boards
5) Listas
6) Cards (tareas)
7) Etiquetas
8) Comentarios
9) Adjuntos
10) Actividad / historial
11) Realtime (eventos por board)
12) Asistente de planers (tipo Codex, para generar planes desde la app)
- Modo A: plantillas predefinidas (sin IA)
- Modo B: IA via OpenAI API (server-side, sin login de usuario)
- UI web integrada en la app (chat + planes guardados)
## Modelo de datos (borrador)
- users
- projects
- project_members
- project_invites
- boards
- lists
- cards
- card_members
- labels
- card_labels
- comments
- attachments
- activities
- sessions (o tokens)
- planner_requests
- planner_outputs
## API (borrador)
- POST /auth/register, /auth/login, /auth/logout
- GET /auth/google, /auth/google/callback
- GET /me
- CRUD /projects, /boards, /lists, /cards
- POST /projects/:id/invites
- POST /projects/:id/members
- POST /cards/:id/members
- POST /cards/:id/labels
- POST /cards/:id/comments
- POST /cards/:id/attachments
- GET /boards/:id/activity
- POST /planner/requests
- GET /planner/requests/:id
- POST /planner/requests/:id/run (ejecuta plantilla o IA)
## Realtime (borrador)
- Namespace por board
- Eventos: list:create, list:update, list:delete, card:create, card:update, card:move, comment:create, label:update
## Pasos validados (bitacora)
- 2026-03-05: Se confirma stack y referencia funcional tipo Planka. Se crea esta bitacora.
- 2026-03-05: Alcance MVP decidido: auth email/password, proyectos sin workspaces, invitaciones por owner, roles owner/admin/member, realtime con Socket.io, asistente de planers.
- 2026-03-05: Asistente de planers tendra dos modos: plantillas sin IA y generacion con OpenAI API (server-side).
- 2026-03-05: Se agrega Google OAuth como opcion de login (flujo web).
- 2026-03-05: Se crea BD planlinked y usuario planlinked con permisos completos. Se aplica esquema v1 en MariaDB.
- 2026-03-05: Se implementa backend base con auth email/password, JWT cookie, y CRUD inicial para proyectos/boards/listas/cards + planner (plantilla).
- 2026-03-05: Frontend base con login/registro, listado y creacion de proyectos, y modo claro/oscuro.
- 2026-03-05: Se configura .env de server/client con datos MariaDB planlinked.
- 2026-03-05: UI conecta proyectos -> boards -> listas -> cards con endpoints reales.
- 2026-03-05: Asistente de planers con plantillas y streaming IA via OpenAI (backend + UI).
- 2026-03-05: Se agrega switch AI_ENABLED en .env y prompt maestro en docs/PLANNER_PROMPT.md.
- 2026-03-05: Se agrega flujo para pegar un plan Markdown y guardarlo en Planlink.
- 2026-03-05: Se agrega modal para generar prompt guiado y area para pegar el resultado.
- 2026-03-05: Se agrega historial y edicion de planers guardados (UI + endpoints).
- 2026-03-05: Se agrega drag & drop basico de cards entre listas con persistencia de posicion.
- 2026-03-05: Se agrega reordenamiento de cards dentro de la misma lista y entre listas (persistencia de posiciones).
- 2026-03-05: Se agrega drag & drop de listas y modal de card (titulo, descripcion, fecha, comentarios).
- 2026-03-05: Se agrega soporte basico de etiquetas y miembros en cards (UI + endpoints).
- 2026-03-05: Se agrega miembros de proyecto e invitaciones basicas (UI + endpoints).
- 2026-03-05: Se agrega soporte de adjuntos (UI + endpoints).
- 2026-03-05: Se agrega realtime basico (Socket.io) para listas y cards en UI.
- 2026-03-05: Se agrega actividad de board y notificaciones basicas (toast).
- 2026-03-05: Se agrega gestion de invitaciones (listar, revocar) en UI.
- 2026-03-05: Se agrega aceptacion de invitaciones por token en UI.
- 2026-03-05: Se agrega busqueda simple de cards por titulo en UI.
- 2026-03-05: Se agregan filtros de cards por etiqueta y miembro (con mapeo por card).
- 2026-03-05: Se endurecen permisos backend por rol (boards/listas/labels solo admin/owner).
- 2026-03-05: Se crea plan de pruebas en docs/TEST_PLAN.md.
- 2026-03-05: Se agregan restricciones basicas por rol en UI (member limita acciones).
## Siguiente paso propuesto
- Crear estructura base del repo (client/server/db) y configuracion inicial.
- Ajustar modelo de datos segun MVP (sin workspaces).
- Esqueleto backend + frontend con modo claro/oscuro.
## Planers (a construir y mantener)
- (pendiente) planers de proyectos y tableros
+18
View File
@@ -0,0 +1,18 @@
<!doctype html>
<html lang="es" class="h-full">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Planlink</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
</head>
<body class="h-full bg-slate-50 text-slate-900 dark:bg-slate-950 dark:text-slate-100">
<div id="root" class="h-full"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
+2614
View File
File diff suppressed because it is too large Load Diff
+23
View File
@@ -0,0 +1,23 @@
{
"name": "planlink-client",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"socket.io-client": "^4.7.5"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.41",
"tailwindcss": "^3.4.10",
"vite": "^5.4.1"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
};
+1569
View File
File diff suppressed because it is too large Load Diff
+238
View File
@@ -0,0 +1,238 @@
const API_URL = import.meta.env.VITE_API_URL || "http://localhost:4000/api";
async function request(path, options = {}) {
const res = await fetch(`${API_URL}${path}`, {
credentials: "include",
headers: {
"Content-Type": "application/json",
...(options.headers || {})
},
...options
});
const data = await res.json();
if (!res.ok) {
const message = data?.message || "request failed";
throw new Error(message);
}
return data;
}
export function login(payload) {
return request("/auth/login", {
method: "POST",
body: JSON.stringify(payload)
});
}
export function register(payload) {
return request("/auth/register", {
method: "POST",
body: JSON.stringify(payload)
});
}
export function logout() {
return request("/auth/logout", { method: "POST" });
}
export function me() {
return request("/me");
}
export function listProjects() {
return request("/projects");
}
export function createProject(payload) {
return request("/projects", {
method: "POST",
body: JSON.stringify(payload)
});
}
export function listProjectMembers(projectId) {
return request(`/projects/${projectId}/members`);
}
export function inviteProjectMember(projectId, payload) {
return request(`/projects/${projectId}/invites`, {
method: "POST",
body: JSON.stringify(payload)
});
}
export function listProjectInvites(projectId) {
return request(`/projects/${projectId}/invites`);
}
export function acceptProjectInvite(projectId, token) {
return request(`/projects/${projectId}/invites/accept`, {
method: "POST",
body: JSON.stringify({ token })
});
}
export function revokeProjectInvite(projectId, inviteId) {
return request(`/projects/${projectId}/invites/revoke`, {
method: "POST",
body: JSON.stringify({ inviteId })
});
}
export function listBoards(projectId) {
return request(`/boards?projectId=${projectId}`);
}
export function createBoard(payload) {
return request("/boards", {
method: "POST",
body: JSON.stringify(payload)
});
}
export function listBoardActivity(boardId) {
return request(`/boards/${boardId}/activity`);
}
export function listLists(boardId) {
return request(`/lists?boardId=${boardId}`);
}
export function createList(payload) {
return request("/lists", {
method: "POST",
body: JSON.stringify(payload)
});
}
export function updateList(id, payload) {
return request(`/lists/${id}`, {
method: "PATCH",
body: JSON.stringify(payload)
});
}
export function listCards(listId) {
return request(`/cards?listId=${listId}`);
}
export function createCard(payload) {
return request("/cards", {
method: "POST",
body: JSON.stringify(payload)
});
}
export function updateCard(id, payload) {
return request(`/cards/${id}`, {
method: "PATCH",
body: JSON.stringify(payload)
});
}
export function listComments(cardId) {
return request(`/cards/${cardId}/comments`);
}
export function addComment(cardId, body) {
return request(`/cards/${cardId}/comments`, {
method: "POST",
body: JSON.stringify({ body })
});
}
export function listAttachments(cardId) {
return request(`/cards/${cardId}/attachments`);
}
export function addAttachment(cardId, payload) {
return request(`/cards/${cardId}/attachments`, {
method: "POST",
body: JSON.stringify(payload)
});
}
export function listCardMembers(cardId) {
return request(`/cards/${cardId}/members`);
}
export function addCardMember(cardId, userId) {
return request(`/cards/${cardId}/members`, {
method: "POST",
body: JSON.stringify({ userId })
});
}
export function listCardLabels(cardId) {
return request(`/cards/${cardId}/labels`);
}
export function addCardLabel(cardId, labelId) {
return request(`/cards/${cardId}/labels`, {
method: "POST",
body: JSON.stringify({ labelId })
});
}
export function listLabels(projectId) {
return request(`/labels?projectId=${projectId}`);
}
export function createLabel(payload) {
return request("/labels", {
method: "POST",
body: JSON.stringify(payload)
});
}
export function createPlannerRequest(payload) {
return request("/planner/requests", {
method: "POST",
body: JSON.stringify(payload)
});
}
export function runPlannerRequest(id) {
return request(`/planner/requests/${id}/run`, { method: "POST" });
}
export function streamPlannerRequest(id, onDelta, onDone, onError) {
const url = `${API_URL}/planner/requests/${id}/stream`;
const source = new EventSource(url, { withCredentials: true });
source.addEventListener("delta", (event) => {
const data = JSON.parse(event.data);
onDelta?.(data.delta || "");
});
source.addEventListener("done", () => {
onDone?.();
source.close();
});
source.addEventListener("error", () => {
onError?.();
source.close();
});
return source;
}
export function saveManualPlanner(payload) {
return request("/planner/manual", {
method: "POST",
body: JSON.stringify(payload)
});
}
export function listPlanners() {
return request("/planner");
}
export function updatePlanner(id, outputText) {
return request(`/planner/requests/${id}`, {
method: "PATCH",
body: JSON.stringify({ outputText })
});
}
+10
View File
@@ -0,0 +1,10 @@
import React from "react";
import { createRoot } from "react-dom/client";
import App from "./App.jsx";
import "./styles.css";
createRoot(document.getElementById("root")).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
+12
View File
@@ -0,0 +1,12 @@
import { io } from "socket.io-client";
const SOCKET_URL = import.meta.env.VITE_SOCKET_URL || "http://localhost:4000";
let socket;
export function getSocket() {
if (!socket) {
socket = io(SOCKET_URL, { withCredentials: true });
}
return socket;
}
+7
View File
@@ -0,0 +1,7 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
color-scheme: light dark;
}
+12
View File
@@ -0,0 +1,12 @@
export default {
darkMode: "class",
content: ["./index.html", "./src/**/*.{js,jsx}"],
theme: {
extend: {
fontFamily: {
display: ["Space Grotesk", "ui-sans-serif", "system-ui"]
}
}
},
plugins: []
};
+9
View File
@@ -0,0 +1,9 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
port: 5173
}
});
+186
View File
@@ -0,0 +1,186 @@
-- Planlink schema v1
CREATE TABLE IF NOT EXISTS users (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
name VARCHAR(255) NOT NULL,
avatar_url VARCHAR(512),
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS projects (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
description TEXT,
owner_id BIGINT UNSIGNED NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_projects_owner FOREIGN KEY (owner_id) REFERENCES users(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS project_members (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
project_id BIGINT UNSIGNED NOT NULL,
user_id BIGINT UNSIGNED NOT NULL,
role ENUM('owner','admin','member') NOT NULL DEFAULT 'member',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uq_project_members (project_id, user_id),
CONSTRAINT fk_project_members_project FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE,
CONSTRAINT fk_project_members_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS project_invites (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
project_id BIGINT UNSIGNED NOT NULL,
email VARCHAR(255) NOT NULL,
role ENUM('admin','member') NOT NULL DEFAULT 'member',
token VARCHAR(128) NOT NULL UNIQUE,
status ENUM('pending','accepted','revoked') NOT NULL DEFAULT 'pending',
invited_by BIGINT UNSIGNED NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
expires_at DATETIME,
CONSTRAINT fk_project_invites_project FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE,
CONSTRAINT fk_project_invites_inviter FOREIGN KEY (invited_by) REFERENCES users(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS boards (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
project_id BIGINT UNSIGNED NOT NULL,
name VARCHAR(255) NOT NULL,
description TEXT,
position INT NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_boards_project FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS lists (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
board_id BIGINT UNSIGNED NOT NULL,
name VARCHAR(255) NOT NULL,
position INT NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_lists_board FOREIGN KEY (board_id) REFERENCES boards(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS cards (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
list_id BIGINT UNSIGNED NOT NULL,
title VARCHAR(255) NOT NULL,
description TEXT,
position INT NOT NULL DEFAULT 0,
due_at DATETIME,
created_by BIGINT UNSIGNED NOT NULL,
archived_at DATETIME,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_cards_list FOREIGN KEY (list_id) REFERENCES lists(id) ON DELETE CASCADE,
CONSTRAINT fk_cards_creator FOREIGN KEY (created_by) REFERENCES users(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS card_members (
card_id BIGINT UNSIGNED NOT NULL,
user_id BIGINT UNSIGNED NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (card_id, user_id),
CONSTRAINT fk_card_members_card FOREIGN KEY (card_id) REFERENCES cards(id) ON DELETE CASCADE,
CONSTRAINT fk_card_members_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS labels (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
project_id BIGINT UNSIGNED NOT NULL,
name VARCHAR(255) NOT NULL,
color VARCHAR(32) NOT NULL DEFAULT 'slate',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_labels_project FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS card_labels (
card_id BIGINT UNSIGNED NOT NULL,
label_id BIGINT UNSIGNED NOT NULL,
PRIMARY KEY (card_id, label_id),
CONSTRAINT fk_card_labels_card FOREIGN KEY (card_id) REFERENCES cards(id) ON DELETE CASCADE,
CONSTRAINT fk_card_labels_label FOREIGN KEY (label_id) REFERENCES labels(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS comments (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
card_id BIGINT UNSIGNED NOT NULL,
user_id BIGINT UNSIGNED NOT NULL,
body TEXT NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_comments_card FOREIGN KEY (card_id) REFERENCES cards(id) ON DELETE CASCADE,
CONSTRAINT fk_comments_user FOREIGN KEY (user_id) REFERENCES users(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS attachments (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
card_id BIGINT UNSIGNED NOT NULL,
user_id BIGINT UNSIGNED NOT NULL,
file_name VARCHAR(255) NOT NULL,
file_url VARCHAR(1024) NOT NULL,
file_size BIGINT UNSIGNED,
mime_type VARCHAR(128),
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_attachments_card FOREIGN KEY (card_id) REFERENCES cards(id) ON DELETE CASCADE,
CONSTRAINT fk_attachments_user FOREIGN KEY (user_id) REFERENCES users(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS activities (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
project_id BIGINT UNSIGNED NOT NULL,
board_id BIGINT UNSIGNED,
card_id BIGINT UNSIGNED,
actor_id BIGINT UNSIGNED NOT NULL,
type VARCHAR(64) NOT NULL,
payload JSON,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_activities_project FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE,
CONSTRAINT fk_activities_board FOREIGN KEY (board_id) REFERENCES boards(id) ON DELETE SET NULL,
CONSTRAINT fk_activities_card FOREIGN KEY (card_id) REFERENCES cards(id) ON DELETE SET NULL,
CONSTRAINT fk_activities_actor FOREIGN KEY (actor_id) REFERENCES users(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS sessions (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT UNSIGNED NOT NULL,
refresh_token VARCHAR(255) NOT NULL,
expires_at DATETIME NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
revoked_at DATETIME,
CONSTRAINT fk_sessions_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS planner_requests (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
project_id BIGINT UNSIGNED,
user_id BIGINT UNSIGNED NOT NULL,
input_text TEXT NOT NULL,
mode ENUM('template','ai') NOT NULL,
status ENUM('queued','running','done','failed') NOT NULL DEFAULT 'queued',
template_key VARCHAR(128),
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_planner_requests_project FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE SET NULL,
CONSTRAINT fk_planner_requests_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS planner_outputs (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
request_id BIGINT UNSIGNED NOT NULL,
output_text LONGTEXT,
output_json JSON,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_planner_outputs_request FOREIGN KEY (request_id) REFERENCES planner_requests(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE INDEX idx_projects_owner ON projects(owner_id);
CREATE INDEX idx_boards_project ON boards(project_id);
CREATE INDEX idx_lists_board ON lists(board_id);
CREATE INDEX idx_cards_list ON cards(list_id);
CREATE INDEX idx_comments_card ON comments(card_id);
CREATE INDEX idx_activities_project ON activities(project_id);
+10
View File
@@ -0,0 +1,10 @@
# Gitea - Planlinked
## Remote
- URL: https://gitea.linkedcorp.net/mgonzalez/planlinked.git
## Token
- Token: 233726190bf6d6407d26e247189d1896a82f9ae2
## Nota
Este token tiene acceso al repo. Si se comparte, se recomienda rotarlo.
+51
View File
@@ -0,0 +1,51 @@
# Prompt para generar planers (Codex / ChatGPT)
## Como usarlo
Copia este prompt en Codex/ChatGPT para que genere un plan listo para pegar en **Pegar plan generado** dentro de Planlink.
---
## Prompt maestro
Eres un asistente para Planlink. Quiero que generes un **planer** en formato Markdown listo para pegar en mi app.
**Reglas**:
- Devuelve solo Markdown, sin explicaciones adicionales.
- Usa este formato exacto (no agregues secciones adicionales):
# Titulo del plan
## Objetivo
- ...
## Alcance
- ...
## Lista de tareas (Kanban)
### Pendiente
- ...
### En progreso
- ...
### Hecho
- ...
## Entregables
- ...
## Riesgos
- ...
## Notas
- ...
**Contexto del plan**:
[Describe aqui lo que necesitas: proyecto, objetivos, fechas, recursos, restricciones]
---
## Ejemplo de uso (rellena tu contexto)
Contexto del plan:
"Quiero lanzar una nueva funcionalidad de invitaciones en Planlink en 3 semanas con un equipo de 2 personas. Necesito tareas, riesgos y entregables claros."
+64
View File
@@ -0,0 +1,64 @@
# Planlink - Plan de Pruebas
## Setup
- Levantar server y client con IP `192.168.222.72`.
- Verificar `.env` en `server/` y `client/`.
- Confirmar conexion a MariaDB (planlinked).
## Auth
- Registro nuevo usuario -> login correcto.
- Login con credenciales invalidas -> error.
- Logout -> `GET /me` debe fallar.
## Proyectos
- Crear proyecto -> rol `owner`.
- Listar proyectos -> aparecen los propios.
- Invitar usuario (admin/owner) -> token visible en UI.
- Revocar invitacion -> status `revoked`.
- Aceptar invitacion con token -> usuario agregado.
## Roles
- Usuario `member` no puede crear boards/listas/labels.
- Usuario `admin/owner` si puede.
## Boards
- Crear board -> aparece en lista.
- Cambiar de proyecto -> boards cambian correctamente.
## Listas
- Crear lista -> aparece en board.
- Drag & drop de listas -> orden persistente.
## Cards
- Crear card en lista.
- Drag & drop entre listas -> posicion actualizada.
- Reordenar dentro de lista -> persistencia.
## Card modal
- Editar titulo, descripcion, fecha.
- Agregar comentarios.
- Agregar adjuntos (URL).
## Etiquetas
- Crear etiqueta de proyecto.
- Asignar etiqueta a card.
- Filtrar cards por etiqueta.
## Miembros
- Mostrar miembros del proyecto.
- Asignar miembro a card.
- Filtrar cards por miembro.
## Realtime
- Dos sesiones abiertas (2 navegadores).
- Crear lista/card en una -> aparece en otra.
- Actualizar card -> reflejado en otra.
## Planers
- Generar prompt guiado -> copiar -> pegar plan.
- Guardar plan en BD -> aparece en historial.
- Editar plan -> se actualiza.
## Actividad
- Ver actividad por board.
- Acciones de listas/cards generan eventos.
+1420
View File
File diff suppressed because it is too large Load Diff
+23
View File
@@ -0,0 +1,23 @@
{
"name": "planlink-server",
"version": "0.1.0",
"private": true,
"type": "commonjs",
"scripts": {
"dev": "node src/index.js",
"start": "node src/index.js"
},
"dependencies": {
"bcryptjs": "^2.4.3",
"cookie-parser": "^1.4.6",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.19.2",
"jsonwebtoken": "^9.0.2",
"morgan": "^1.10.0",
"mysql2": "^3.11.0",
"openai": "^4.70.0",
"socket.io": "^4.7.5",
"uuid": "^10.0.0"
}
}
+23
View File
@@ -0,0 +1,23 @@
const express = require("express");
const cors = require("cors");
const morgan = require("morgan");
const cookieParser = require("cookie-parser");
const routes = require("./routes");
const app = express();
app.use(cors({
origin: process.env.CLIENT_ORIGIN || "http://localhost:5173",
credentials: true
}));
app.use(express.json({ limit: "2mb" }));
app.use(cookieParser());
app.use(morgan("dev"));
app.get("/health", (req, res) => {
res.json({ ok: true, service: "planlink" });
});
app.use("/api", routes);
module.exports = app;
+12
View File
@@ -0,0 +1,12 @@
const dotenv = require("dotenv");
dotenv.config();
const required = ["JWT_SECRET"]; // Extend when wiring auth
for (const key of required) {
if (!process.env[key]) {
// Keep a warning for now; enforce later when auth is wired
console.warn(`[env] Missing ${key}`);
}
}
+22
View File
@@ -0,0 +1,22 @@
const mysql = require("mysql2/promise");
let pool;
function getPool() {
if (!pool) {
pool = mysql.createPool({
host: process.env.DB_HOST,
port: Number(process.env.DB_PORT || 3306),
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
waitForConnections: true,
connectionLimit: 10,
queueLimit: 0
});
}
return pool;
}
module.exports = { getPool };
+14
View File
@@ -0,0 +1,14 @@
const { getPool } = require("./pool");
async function query(sql, params = []) {
const pool = getPool();
const [rows] = await pool.execute(sql, params);
return rows;
}
async function getOne(sql, params = []) {
const rows = await query(sql, params);
return rows[0] || null;
}
module.exports = { query, getOne };
+13
View File
@@ -0,0 +1,13 @@
require("./config/env");
const http = require("http");
const app = require("./app");
const { initSocket } = require("./realtime/socket");
const port = Number(process.env.PORT || 4000);
const httpServer = http.createServer(app);
initSocket(httpServer);
httpServer.listen(port, () => {
console.log(`planlink server listening on :${port}`);
});
+16
View File
@@ -0,0 +1,16 @@
const jwt = require("jsonwebtoken");
function requireAuth(req, res, next) {
const token = req.cookies?.planlink_token;
if (!token) return res.status(401).json({ ok: false, message: "unauthorized" });
try {
const payload = jwt.verify(token, process.env.JWT_SECRET);
req.user = payload;
return next();
} catch (err) {
return res.status(401).json({ ok: false, message: "invalid token" });
}
}
module.exports = { requireAuth };
+21
View File
@@ -0,0 +1,21 @@
const { query } = require("../db/queries");
let ioRef;
function setIO(io) {
ioRef = io;
}
function emitBoard(boardId, event, payload) {
if (!ioRef || !boardId) return;
ioRef.to(`board:${boardId}`).emit(event, payload);
}
async function logActivity({ projectId, boardId, cardId, actorId, type, payload }) {
await query(
"INSERT INTO activities (project_id, board_id, card_id, actor_id, type, payload) VALUES (?, ?, ?, ?, ?, ?)",
[projectId, boardId || null, cardId || null, actorId, type, payload ? JSON.stringify(payload) : null]
);
}
module.exports = { setIO, emitBoard, logActivity };
+27
View File
@@ -0,0 +1,27 @@
const { Server } = require("socket.io");
const { setIO } = require("./events");
function initSocket(httpServer) {
const io = new Server(httpServer, {
cors: {
origin: process.env.CLIENT_ORIGIN || "http://localhost:5173",
credentials: true
}
});
setIO(io);
io.on("connection", (socket) => {
socket.on("board:join", (boardId) => {
if (boardId) socket.join(`board:${boardId}`);
});
socket.on("board:leave", (boardId) => {
if (boardId) socket.leave(`board:${boardId}`);
});
});
return io;
}
module.exports = { initSocket };
+82
View File
@@ -0,0 +1,82 @@
const express = require("express");
const { getOne, query } = require("../db/queries");
const { hashPassword, verifyPassword } = require("../utils/password");
const { signUser } = require("../utils/jwt");
const { requireAuth } = require("../middleware/auth");
const router = express.Router();
const COOKIE_OPTS = {
httpOnly: true,
sameSite: "lax",
secure: false
};
router.post("/register", async (req, res) => {
const { email, password, name } = req.body || {};
if (!email || !password || !name) {
return res.status(400).json({ ok: false, message: "missing fields" });
}
const existing = await getOne("SELECT id FROM users WHERE email = ?", [email]);
if (existing) {
return res.status(409).json({ ok: false, message: "email exists" });
}
const passwordHash = await hashPassword(password);
const result = await query(
"INSERT INTO users (email, password_hash, name) VALUES (?, ?, ?)",
[email, passwordHash, name]
);
const user = { id: result.insertId, email, name };
const token = signUser(user);
res.cookie("planlink_token", token, COOKIE_OPTS);
return res.status(201).json({ ok: true, user });
});
router.post("/login", async (req, res) => {
const { email, password } = req.body || {};
if (!email || !password) {
return res.status(400).json({ ok: false, message: "missing fields" });
}
const user = await getOne(
"SELECT id, email, name, password_hash FROM users WHERE email = ?",
[email]
);
if (!user) {
return res.status(401).json({ ok: false, message: "invalid credentials" });
}
const valid = await verifyPassword(password, user.password_hash);
if (!valid) {
return res.status(401).json({ ok: false, message: "invalid credentials" });
}
const token = signUser(user);
res.cookie("planlink_token", token, COOKIE_OPTS);
return res.json({ ok: true, user: { id: user.id, email: user.email, name: user.name } });
});
router.post("/logout", (req, res) => {
res.clearCookie("planlink_token", COOKIE_OPTS);
res.json({ ok: true });
});
router.get("/me", requireAuth, async (req, res) => {
const user = await getOne("SELECT id, email, name, avatar_url FROM users WHERE id = ?", [req.user.id]);
if (!user) return res.status(404).json({ ok: false, message: "not found" });
res.json({ ok: true, user });
});
// Google OAuth placeholders
router.get("/google", (req, res) => {
res.status(501).json({ ok: false, message: "google oauth not implemented" });
});
router.get("/google/callback", (req, res) => {
res.status(501).json({ ok: false, message: "google oauth callback not implemented" });
});
module.exports = router;
+80
View File
@@ -0,0 +1,80 @@
const express = require("express");
const { query, getOne } = require("../db/queries");
const { requireAuth } = require("../middleware/auth");
const router = express.Router();
async function canAccessProject(userId, projectId) {
const row = await getOne(
"SELECT 1 FROM project_members WHERE project_id = ? AND user_id = ?",
[projectId, userId]
);
return Boolean(row);
}
async function getProjectRole(userId, projectId) {
const row = await getOne(
"SELECT role FROM project_members WHERE project_id = ? AND user_id = ?",
[projectId, userId]
);
return row?.role || null;
}
router.get("/", requireAuth, async (req, res) => {
const projectId = req.query.projectId ? Number(req.query.projectId) : null;
let items;
if (projectId) {
const ok = await canAccessProject(req.user.id, projectId);
if (!ok) return res.status(403).json({ ok: false, message: "forbidden" });
items = await query("SELECT * FROM boards WHERE project_id = ? ORDER BY position ASC", [projectId]);
} else {
items = await query(
`SELECT b.*\n FROM boards b\n JOIN project_members pm ON pm.project_id = b.project_id\n WHERE pm.user_id = ?\n ORDER BY b.created_at DESC`,
[req.user.id]
);
}
res.json({ ok: true, items });
});
router.post("/", requireAuth, async (req, res) => {
const { projectId, name, description } = req.body || {};
if (!projectId || !name) return res.status(400).json({ ok: false, message: "missing fields" });
const ok = await canAccessProject(req.user.id, projectId);
if (!ok) return res.status(403).json({ ok: false, message: "forbidden" });
const role = await getProjectRole(req.user.id, projectId);
if (role === "member") {
return res.status(403).json({ ok: false, message: "insufficient role" });
}
const result = await query(
"INSERT INTO boards (project_id, name, description, position) VALUES (?, ?, ?, ?)",
[projectId, name, description || null, 0]
);
const board = await getOne("SELECT * FROM boards WHERE id = ?", [result.insertId]);
res.status(201).json({ ok: true, item: board });
});
router.get("/:id/activity", requireAuth, async (req, res) => {
const boardId = Number(req.params.id);
const board = await getOne("SELECT project_id FROM boards WHERE id = ?", [boardId]);
if (!board) return res.status(404).json({ ok: false, message: "not found" });
const ok = await canAccessProject(req.user.id, board.project_id);
if (!ok) return res.status(403).json({ ok: false, message: "forbidden" });
const items = await query(
`SELECT a.id, a.type, a.payload, a.created_at, u.name as actor_name
FROM activities a
JOIN users u ON u.id = a.actor_id
WHERE a.board_id = ?
ORDER BY a.created_at DESC
LIMIT 200`,
[boardId]
);
res.json({ ok: true, items });
});
module.exports = router;
+227
View File
@@ -0,0 +1,227 @@
const express = require("express");
const { query, getOne } = require("../db/queries");
const { requireAuth } = require("../middleware/auth");
const { emitBoard, logActivity } = require("../realtime/events");
const router = express.Router();
async function canAccessCard(userId, cardId) {
const row = await getOne(
`SELECT pm.user_id\n FROM cards c\n JOIN lists l ON l.id = c.list_id\n JOIN boards b ON b.id = l.board_id\n JOIN project_members pm ON pm.project_id = b.project_id\n WHERE c.id = ? AND pm.user_id = ?`,
[cardId, userId]
);
return Boolean(row);
}
async function canAccessList(userId, listId) {
const row = await getOne(
`SELECT pm.user_id\n FROM lists l\n JOIN boards b ON b.id = l.board_id\n JOIN project_members pm ON pm.project_id = b.project_id\n WHERE l.id = ? AND pm.user_id = ?`,
[listId, userId]
);
return Boolean(row);
}
router.get("/", requireAuth, async (req, res) => {
const listId = req.query.listId ? Number(req.query.listId) : null;
if (!listId) return res.status(400).json({ ok: false, message: "listId required" });
const ok = await canAccessList(req.user.id, listId);
if (!ok) return res.status(403).json({ ok: false, message: "forbidden" });
const items = await query(
"SELECT * FROM cards WHERE list_id = ? AND archived_at IS NULL ORDER BY position ASC",
[listId]
);
res.json({ ok: true, items });
});
router.post("/", requireAuth, async (req, res) => {
const { listId, title, description, position, dueAt } = req.body || {};
if (!listId || !title) return res.status(400).json({ ok: false, message: "missing fields" });
const ok = await canAccessList(req.user.id, listId);
if (!ok) return res.status(403).json({ ok: false, message: "forbidden" });
const result = await query(
"INSERT INTO cards (list_id, title, description, position, due_at, created_by) VALUES (?, ?, ?, ?, ?, ?)",
[listId, title, description || null, Number(position || 0), dueAt || null, req.user.id]
);
const card = await getOne("SELECT * FROM cards WHERE id = ?", [result.insertId]);
const board = await getOne(
"SELECT b.id, b.project_id FROM lists l JOIN boards b ON b.id = l.board_id WHERE l.id = ?",
[listId]
);
if (board) {
emitBoard(board.id, "card:create", { card });
await logActivity({
projectId: board.project_id,
boardId: board.id,
cardId: card.id,
actorId: req.user.id,
type: "card.create",
payload: { cardId: card.id }
});
}
res.status(201).json({ ok: true, item: card });
});
router.patch("/:id", requireAuth, async (req, res) => {
const cardId = Number(req.params.id);
const { title, description, position, dueAt, listId } = req.body || {};
const ok = await canAccessCard(req.user.id, cardId);
if (!ok) return res.status(403).json({ ok: false, message: "forbidden" });
if (listId) {
const canMove = await canAccessList(req.user.id, listId);
if (!canMove) return res.status(403).json({ ok: false, message: "forbidden" });
}
await query(
`UPDATE cards\n SET title = COALESCE(?, title),\n description = COALESCE(?, description),\n position = COALESCE(?, position),\n due_at = COALESCE(?, due_at),\n list_id = COALESCE(?, list_id)\n WHERE id = ?`,
[title || null, description || null, position ?? null, dueAt || null, listId || null, cardId]
);
const updated = await getOne("SELECT * FROM cards WHERE id = ?", [cardId]);
const board = await getOne(
"SELECT b.id, b.project_id FROM cards c JOIN lists l ON l.id = c.list_id JOIN boards b ON b.id = l.board_id WHERE c.id = ?",
[cardId]
);
if (board) {
emitBoard(board.id, "card:update", { card: updated });
await logActivity({
projectId: board.project_id,
boardId: board.id,
cardId: cardId,
actorId: req.user.id,
type: "card.update",
payload: { cardId }
});
}
res.json({ ok: true, item: updated });
});
router.post("/:id/members", requireAuth, async (req, res) => {
const cardId = Number(req.params.id);
const { userId } = req.body || {};
if (!userId) return res.status(400).json({ ok: false, message: "userId required" });
const ok = await canAccessCard(req.user.id, cardId);
if (!ok) return res.status(403).json({ ok: false, message: "forbidden" });
await query(
"INSERT IGNORE INTO card_members (card_id, user_id) VALUES (?, ?)",
[cardId, userId]
);
res.status(201).json({ ok: true });
});
router.get("/:id/members", requireAuth, async (req, res) => {
const cardId = Number(req.params.id);
const ok = await canAccessCard(req.user.id, cardId);
if (!ok) return res.status(403).json({ ok: false, message: "forbidden" });
const items = await query(
`SELECT u.id, u.name, u.email
FROM card_members cm
JOIN users u ON u.id = cm.user_id
WHERE cm.card_id = ?`,
[cardId]
);
res.json({ ok: true, items });
});
router.post("/:id/labels", requireAuth, async (req, res) => {
const cardId = Number(req.params.id);
const { labelId } = req.body || {};
if (!labelId) return res.status(400).json({ ok: false, message: "labelId required" });
const ok = await canAccessCard(req.user.id, cardId);
if (!ok) return res.status(403).json({ ok: false, message: "forbidden" });
await query(
"INSERT IGNORE INTO card_labels (card_id, label_id) VALUES (?, ?)",
[cardId, labelId]
);
res.status(201).json({ ok: true });
});
router.get("/:id/labels", requireAuth, async (req, res) => {
const cardId = Number(req.params.id);
const ok = await canAccessCard(req.user.id, cardId);
if (!ok) return res.status(403).json({ ok: false, message: "forbidden" });
const items = await query(
`SELECT l.id, l.name, l.color
FROM card_labels cl
JOIN labels l ON l.id = cl.label_id
WHERE cl.card_id = ?`,
[cardId]
);
res.json({ ok: true, items });
});
router.post("/:id/comments", requireAuth, async (req, res) => {
const cardId = Number(req.params.id);
const { body } = req.body || {};
if (!body) return res.status(400).json({ ok: false, message: "body required" });
const ok = await canAccessCard(req.user.id, cardId);
if (!ok) return res.status(403).json({ ok: false, message: "forbidden" });
const result = await query(
"INSERT INTO comments (card_id, user_id, body) VALUES (?, ?, ?)",
[cardId, req.user.id, body]
);
res.status(201).json({ ok: true, commentId: result.insertId });
});
router.get("/:id/comments", requireAuth, async (req, res) => {
const cardId = Number(req.params.id);
const ok = await canAccessCard(req.user.id, cardId);
if (!ok) return res.status(403).json({ ok: false, message: "forbidden" });
const items = await query(
`SELECT c.id, c.body, c.created_at, u.id as user_id, u.name as user_name
FROM comments c
JOIN users u ON u.id = c.user_id
WHERE c.card_id = ?
ORDER BY c.created_at ASC`,
[cardId]
);
res.json({ ok: true, items });
});
router.post("/:id/attachments", requireAuth, async (req, res) => {
const cardId = Number(req.params.id);
const { fileName, fileUrl, fileSize, mimeType } = req.body || {};
if (!fileName || !fileUrl) {
return res.status(400).json({ ok: false, message: "fileName and fileUrl required" });
}
const ok = await canAccessCard(req.user.id, cardId);
if (!ok) return res.status(403).json({ ok: false, message: "forbidden" });
const result = await query(
"INSERT INTO attachments (card_id, user_id, file_name, file_url, file_size, mime_type) VALUES (?, ?, ?, ?, ?, ?)",
[cardId, req.user.id, fileName, fileUrl, fileSize || null, mimeType || null]
);
res.status(201).json({ ok: true, attachmentId: result.insertId });
});
router.get("/:id/attachments", requireAuth, async (req, res) => {
const cardId = Number(req.params.id);
const ok = await canAccessCard(req.user.id, cardId);
if (!ok) return res.status(403).json({ ok: false, message: "forbidden" });
const items = await query(
`SELECT id, file_name, file_url, file_size, mime_type, created_at
FROM attachments
WHERE card_id = ?
ORDER BY created_at DESC`,
[cardId]
);
res.json({ ok: true, items });
});
module.exports = router;
+31
View File
@@ -0,0 +1,31 @@
const express = require("express");
const auth = require("./auth");
const { requireAuth } = require("../middleware/auth");
const { getOne } = require("../db/queries");
const projects = require("./projects");
const boards = require("./boards");
const lists = require("./lists");
const cards = require("./cards");
const planner = require("./planner");
const labels = require("./labels");
const router = express.Router();
router.use("/auth", auth);
router.use("/projects", projects);
router.use("/boards", boards);
router.use("/lists", lists);
router.use("/cards", cards);
router.use("/planner", planner);
router.use("/labels", labels);
router.get("/me", requireAuth, async (req, res) => {
const user = await getOne(
"SELECT id, email, name, avatar_url FROM users WHERE id = ?",
[req.user.id]
);
if (!user) return res.status(404).json({ ok: false, message: "not found" });
res.json({ ok: true, user });
});
module.exports = router;
+53
View File
@@ -0,0 +1,53 @@
const express = require("express");
const { query, getOne } = require("../db/queries");
const { requireAuth } = require("../middleware/auth");
const router = express.Router();
async function canAccessProject(userId, projectId) {
const row = await getOne(
"SELECT 1 FROM project_members WHERE project_id = ? AND user_id = ?",
[projectId, userId]
);
return Boolean(row);
}
async function getProjectRole(userId, projectId) {
const row = await getOne(
"SELECT role FROM project_members WHERE project_id = ? AND user_id = ?",
[projectId, userId]
);
return row?.role || null;
}
router.get("/", requireAuth, async (req, res) => {
const projectId = req.query.projectId ? Number(req.query.projectId) : null;
if (!projectId) return res.status(400).json({ ok: false, message: "projectId required" });
const ok = await canAccessProject(req.user.id, projectId);
if (!ok) return res.status(403).json({ ok: false, message: "forbidden" });
const role = await getProjectRole(req.user.id, projectId);
if (role === "member") {
return res.status(403).json({ ok: false, message: "insufficient role" });
}
const items = await query("SELECT * FROM labels WHERE project_id = ?", [projectId]);
res.json({ ok: true, items });
});
router.post("/", requireAuth, async (req, res) => {
const { projectId, name, color } = req.body || {};
if (!projectId || !name) return res.status(400).json({ ok: false, message: "missing fields" });
const ok = await canAccessProject(req.user.id, projectId);
if (!ok) return res.status(403).json({ ok: false, message: "forbidden" });
const result = await query(
"INSERT INTO labels (project_id, name, color) VALUES (?, ?, ?)",
[projectId, name, color || "slate"]
);
const label = await getOne("SELECT * FROM labels WHERE id = ?", [result.insertId]);
res.status(201).json({ ok: true, item: label });
});
module.exports = router;
+108
View File
@@ -0,0 +1,108 @@
const express = require("express");
const { query, getOne } = require("../db/queries");
const { emitBoard, logActivity } = require("../realtime/events");
const { requireAuth } = require("../middleware/auth");
const router = express.Router();
async function canAccessBoard(userId, boardId) {
const row = await getOne(
`SELECT pm.user_id\n FROM boards b\n JOIN project_members pm ON pm.project_id = b.project_id\n WHERE b.id = ? AND pm.user_id = ?`,
[boardId, userId]
);
return Boolean(row);
}
async function getRoleByBoard(userId, boardId) {
const row = await getOne(
`SELECT pm.role\n FROM boards b\n JOIN project_members pm ON pm.project_id = b.project_id\n WHERE b.id = ? AND pm.user_id = ?`,
[boardId, userId]
);
return row?.role || null;
}
router.get("/", requireAuth, async (req, res) => {
const boardId = req.query.boardId ? Number(req.query.boardId) : null;
if (!boardId) return res.status(400).json({ ok: false, message: "boardId required" });
const ok = await canAccessBoard(req.user.id, boardId);
if (!ok) return res.status(403).json({ ok: false, message: "forbidden" });
const role = await getRoleByBoard(req.user.id, boardId);
if (role === "member") {
return res.status(403).json({ ok: false, message: "insufficient role" });
}
const items = await query("SELECT * FROM lists WHERE board_id = ? ORDER BY position ASC", [boardId]);
res.json({ ok: true, items });
});
router.post("/", requireAuth, async (req, res) => {
const { boardId, name, position } = req.body || {};
if (!boardId || !name) return res.status(400).json({ ok: false, message: "missing fields" });
const ok = await canAccessBoard(req.user.id, boardId);
if (!ok) return res.status(403).json({ ok: false, message: "forbidden" });
const result = await query(
"INSERT INTO lists (board_id, name, position) VALUES (?, ?, ?)",
[boardId, name, Number(position || 0)]
);
const list = await getOne("SELECT * FROM lists WHERE id = ?", [result.insertId]);
const board = await getOne("SELECT project_id FROM boards WHERE id = ?", [boardId]);
emitBoard(boardId, "list:create", { list });
if (board) {
await logActivity({
projectId: board.project_id,
boardId,
actorId: req.user.id,
type: "list.create",
payload: { listId: list.id }
});
}
res.status(201).json({ ok: true, item: list });
});
router.patch("/:id", requireAuth, async (req, res) => {
const listId = Number(req.params.id);
const { name, position } = req.body || {};
const list = await getOne(
`SELECT l.id, l.board_id\n FROM lists l\n WHERE l.id = ?`,
[listId]
);
if (!list) return res.status(404).json({ ok: false, message: "not found" });
const ok = await canAccessBoard(req.user.id, list.board_id);
if (!ok) return res.status(403).json({ ok: false, message: "forbidden" });
const role = await getRoleByBoard(req.user.id, list.board_id);
if (role === "member") {
return res.status(403).json({ ok: false, message: "insufficient role" });
}
await query(
"UPDATE lists SET name = COALESCE(?, name), position = COALESCE(?, position) WHERE id = ?",
[name || null, position ?? null, listId]
);
const updated = await getOne("SELECT * FROM lists WHERE id = ?", [listId]);
emitBoard(list.board_id, "list:update", { list: updated });
res.json({ ok: true, item: updated });
});
router.delete("/:id", requireAuth, async (req, res) => {
const listId = Number(req.params.id);
const list = await getOne("SELECT id, board_id FROM lists WHERE id = ?", [listId]);
if (!list) return res.status(404).json({ ok: false, message: "not found" });
const ok = await canAccessBoard(req.user.id, list.board_id);
if (!ok) return res.status(403).json({ ok: false, message: "forbidden" });
const role = await getRoleByBoard(req.user.id, list.board_id);
if (role === "member") {
return res.status(403).json({ ok: false, message: "insufficient role" });
}
await query("DELETE FROM lists WHERE id = ?", [listId]);
emitBoard(list.board_id, "list:delete", { listId });
res.json({ ok: true });
});
module.exports = router;
+190
View File
@@ -0,0 +1,190 @@
const express = require("express");
const { query, getOne } = require("../db/queries");
const { requireAuth } = require("../middleware/auth");
const { renderTemplate } = require("../services/plannerTemplates");
const { getOpenAI } = require("../services/openai");
const router = express.Router();
router.post("/requests", requireAuth, async (req, res) => {
const { projectId, inputText, mode, templateKey } = req.body || {};
if (!inputText || !mode) {
return res.status(400).json({ ok: false, message: "inputText and mode required" });
}
const result = await query(
"INSERT INTO planner_requests (project_id, user_id, input_text, mode, template_key) VALUES (?, ?, ?, ?, ?)",
[projectId || null, req.user.id, inputText, mode, templateKey || null]
);
res.status(201).json({ ok: true, requestId: result.insertId, status: "queued" });
});
// Guardar un planner manual pegado desde Codex/ChatGPT
router.post("/manual", requireAuth, async (req, res) => {
const { projectId, outputText, title } = req.body || {};
if (!outputText) {
return res.status(400).json({ ok: false, message: "outputText required" });
}
const result = await query(
"INSERT INTO planner_requests (project_id, user_id, input_text, mode, status) VALUES (?, ?, ?, 'template', 'done')",
[projectId || null, req.user.id, title || "Manual planner"]
);
await query(
"INSERT INTO planner_outputs (request_id, output_text) VALUES (?, ?)",
[result.insertId, outputText]
);
res.status(201).json({ ok: true, requestId: result.insertId });
});
router.get("/requests/:id", requireAuth, async (req, res) => {
const requestId = Number(req.params.id);
const reqRow = await getOne(
"SELECT * FROM planner_requests WHERE id = ? AND user_id = ?",
[requestId, req.user.id]
);
if (!reqRow) return res.status(404).json({ ok: false, message: "not found" });
const output = await getOne(
"SELECT * FROM planner_outputs WHERE request_id = ?",
[requestId]
);
res.json({ ok: true, request: reqRow, output });
});
router.get("/", requireAuth, async (req, res) => {
const items = await query(
`SELECT pr.id, pr.project_id, pr.input_text, pr.mode, pr.status, pr.created_at,
po.output_text
FROM planner_requests pr
LEFT JOIN planner_outputs po ON po.request_id = pr.id
WHERE pr.user_id = ?
ORDER BY pr.created_at DESC
LIMIT 200`,
[req.user.id]
);
res.json({ ok: true, items });
});
router.patch("/requests/:id", requireAuth, async (req, res) => {
const requestId = Number(req.params.id);
const { outputText } = req.body || {};
if (!outputText) return res.status(400).json({ ok: false, message: "outputText required" });
const reqRow = await getOne(
"SELECT id FROM planner_requests WHERE id = ? AND user_id = ?",
[requestId, req.user.id]
);
if (!reqRow) return res.status(404).json({ ok: false, message: "not found" });
const existing = await getOne(
"SELECT id FROM planner_outputs WHERE request_id = ?",
[requestId]
);
if (existing) {
await query("UPDATE planner_outputs SET output_text = ? WHERE request_id = ?", [
outputText,
requestId
]);
} else {
await query("INSERT INTO planner_outputs (request_id, output_text) VALUES (?, ?)", [
requestId,
outputText
]);
}
res.json({ ok: true });
});
router.post("/requests/:id/run", requireAuth, async (req, res) => {
const requestId = Number(req.params.id);
const reqRow = await getOne(
"SELECT * FROM planner_requests WHERE id = ? AND user_id = ?",
[requestId, req.user.id]
);
if (!reqRow) return res.status(404).json({ ok: false, message: "not found" });
if (reqRow.mode === "template") {
await query("UPDATE planner_requests SET status = 'running' WHERE id = ?", [requestId]);
const outputText = renderTemplate(reqRow.template_key || "default", reqRow.input_text);
await query(
"INSERT INTO planner_outputs (request_id, output_text) VALUES (?, ?)",
[requestId, outputText]
);
await query("UPDATE planner_requests SET status = 'done' WHERE id = ?", [requestId]);
return res.json({ ok: true, id: requestId, status: "done", outputText });
}
// IA: ejecutar via streaming en endpoint dedicado
await query("UPDATE planner_requests SET status = 'queued' WHERE id = ?", [requestId]);
return res.json({ ok: true, id: requestId, status: "queued", message: "use /stream" });
});
router.get("/requests/:id/stream", requireAuth, async (req, res) => {
const requestId = Number(req.params.id);
const reqRow = await getOne(
"SELECT * FROM planner_requests WHERE id = ? AND user_id = ?",
[requestId, req.user.id]
);
if (!reqRow) return res.status(404).json({ ok: false, message: "not found" });
if (reqRow.mode !== "ai") {
return res.status(400).json({ ok: false, message: "mode must be ai" });
}
if (process.env.AI_ENABLED !== "true") {
return res.status(403).json({ ok: false, message: "AI disabled" });
}
if (!process.env.OPENAI_API_KEY) {
return res.status(400).json({ ok: false, message: "OPENAI_API_KEY missing" });
}
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache, no-transform");
res.setHeader("Connection", "keep-alive");
res.flushHeaders();
await query("UPDATE planner_requests SET status = 'running' WHERE id = ?", [requestId]);
let fullText = "";
try {
const client = getOpenAI();
const stream = await client.responses.create({
model: process.env.OPENAI_MODEL || "gpt-4o-mini",
input: [
{
role: "system",
content:
"Eres un asistente que genera planers para proyectos estilo kanban. Devuelve un plan claro en Markdown con secciones: Objetivo, Alcance, Lista de tareas, Entregables."
},
{ role: "user", content: reqRow.input_text }
],
stream: true
});
for await (const event of stream) {
if (event.type === "response.output_text.delta") {
fullText += event.delta;
res.write(`event: delta\\n`);
res.write(`data: ${JSON.stringify({ delta: event.delta })}\\n\\n`);
}
}
await query(
"INSERT INTO planner_outputs (request_id, output_text) VALUES (?, ?)",
[requestId, fullText]
);
await query("UPDATE planner_requests SET status = 'done' WHERE id = ?", [requestId]);
res.write(`event: done\\n`);
res.write(`data: ${JSON.stringify({ ok: true })}\\n\\n`);
res.end();
} catch (err) {
await query("UPDATE planner_requests SET status = 'failed' WHERE id = ?", [requestId]);
res.write(`event: error\\n`);
res.write(`data: ${JSON.stringify({ message: err.message })}\\n\\n`);
res.end();
}
});
module.exports = router;
+145
View File
@@ -0,0 +1,145 @@
const express = require("express");
const { v4: uuidv4 } = require("uuid");
const { query, getOne } = require("../db/queries");
const { requireAuth } = require("../middleware/auth");
const router = express.Router();
async function getProjectRole(userId, projectId) {
const row = await getOne(
"SELECT role FROM project_members WHERE project_id = ? AND user_id = ?",
[projectId, userId]
);
return row?.role || null;
}
router.get("/", requireAuth, async (req, res) => {
const items = await query(
`SELECT p.*,\n pm.role\n FROM projects p\n JOIN project_members pm ON pm.project_id = p.id\n WHERE pm.user_id = ?\n ORDER BY p.created_at DESC`,
[req.user.id]
);
res.json({ ok: true, items });
});
router.post("/", requireAuth, async (req, res) => {
const { name, description } = req.body || {};
if (!name) return res.status(400).json({ ok: false, message: "name required" });
const result = await query(
"INSERT INTO projects (name, description, owner_id) VALUES (?, ?, ?)",
[name, description || null, req.user.id]
);
await query(
"INSERT INTO project_members (project_id, user_id, role) VALUES (?, ?, 'owner')",
[result.insertId, req.user.id]
);
const project = await getOne("SELECT * FROM projects WHERE id = ?", [result.insertId]);
res.status(201).json({ ok: true, item: project });
});
router.post("/:id/invites", requireAuth, async (req, res) => {
const projectId = Number(req.params.id);
const { email, role } = req.body || {};
if (!email) return res.status(400).json({ ok: false, message: "email required" });
const currentRole = await getProjectRole(req.user.id, projectId);
if (!currentRole || (currentRole !== "owner" && currentRole !== "admin")) {
return res.status(403).json({ ok: false, message: "forbidden" });
}
const token = uuidv4().replace(/-/g, "");
const result = await query(
"INSERT INTO project_invites (project_id, email, role, token, invited_by) VALUES (?, ?, ?, ?, ?)",
[projectId, email, role === "admin" ? "admin" : "member", token, req.user.id]
);
res.status(201).json({ ok: true, inviteId: result.insertId, token, email, role: role === "admin" ? "admin" : "member" });
});
router.get("/:id/invites", requireAuth, async (req, res) => {
const projectId = Number(req.params.id);
const role = await getProjectRole(req.user.id, projectId);
if (!role || (role !== "owner" && role !== "admin")) {
return res.status(403).json({ ok: false, message: "forbidden" });
}
const items = await query(
"SELECT id, email, role, status, created_at FROM project_invites WHERE project_id = ? ORDER BY created_at DESC",
[projectId]
);
res.json({ ok: true, items });
});
router.post("/:id/members", requireAuth, async (req, res) => {
const projectId = Number(req.params.id);
const { userId, role } = req.body || {};
if (!userId) return res.status(400).json({ ok: false, message: "userId required" });
const currentRole = await getProjectRole(req.user.id, projectId);
if (!currentRole || currentRole !== "owner") {
return res.status(403).json({ ok: false, message: "forbidden" });
}
await query(
"INSERT INTO project_members (project_id, user_id, role) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE role = VALUES(role)",
[projectId, userId, role === "admin" ? "admin" : "member"]
);
res.status(201).json({ ok: true });
});
router.get("/:id/members", requireAuth, async (req, res) => {
const projectId = Number(req.params.id);
const role = await getProjectRole(req.user.id, projectId);
if (!role) return res.status(403).json({ ok: false, message: "forbidden" });
const items = await query(
`SELECT u.id, u.name, u.email, pm.role
FROM project_members pm
JOIN users u ON u.id = pm.user_id
WHERE pm.project_id = ?
ORDER BY u.name ASC`,
[projectId]
);
res.json({ ok: true, items });
});
router.post("/:id/invites/accept", requireAuth, async (req, res) => {
const projectId = Number(req.params.id);
const { token } = req.body || {};
if (!token) return res.status(400).json({ ok: false, message: "token required" });
const invite = await getOne(
"SELECT * FROM project_invites WHERE project_id = ? AND token = ? AND status = 'pending'",
[projectId, token]
);
if (!invite) return res.status(404).json({ ok: false, message: "invite not found" });
await query(
"INSERT INTO project_members (project_id, user_id, role) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE role = VALUES(role)",
[projectId, req.user.id, invite.role]
);
await query("UPDATE project_invites SET status = 'accepted' WHERE id = ?", [invite.id]);
res.json({ ok: true });
});
router.post("/:id/invites/revoke", requireAuth, async (req, res) => {
const projectId = Number(req.params.id);
const { inviteId } = req.body || {};
if (!inviteId) return res.status(400).json({ ok: false, message: "inviteId required" });
const role = await getProjectRole(req.user.id, projectId);
if (!role || (role !== "owner" && role !== "admin")) {
return res.status(403).json({ ok: false, message: "forbidden" });
}
await query(
"UPDATE project_invites SET status = 'revoked' WHERE id = ? AND project_id = ?",
[inviteId, projectId]
);
res.json({ ok: true });
});
module.exports = router;
+12
View File
@@ -0,0 +1,12 @@
const OpenAI = require("openai");
let client;
function getOpenAI() {
if (!client) {
client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
}
return client;
}
module.exports = { getOpenAI };
+15
View File
@@ -0,0 +1,15 @@
const templates = {
default: ({ input }) => {
return `# Plan\n\n## Objetivo\n${input}\n\n## Pasos\n1. Definir alcance\n2. Crear tareas por lista\n3. Asignar responsables\n4. Estimar tiempos\n5. Revisar y ajustar\n`;
},
lanzamiento: ({ input }) => {
return `# Plan de lanzamiento\n\n## Objetivo\n${input}\n\n## Checklist\n- Definir fecha\n- Crear tablero\n- Asignar tareas\n- QA\n- Publicacion\n`;
}
};
function renderTemplate(key, input) {
const tpl = templates[key] || templates.default;
return tpl({ input });
}
module.exports = { renderTemplate };
+11
View File
@@ -0,0 +1,11 @@
const jwt = require("jsonwebtoken");
function signUser(user) {
return jwt.sign(
{ id: user.id, email: user.email, name: user.name },
process.env.JWT_SECRET,
{ expiresIn: "7d" }
);
}
module.exports = { signUser };
+13
View File
@@ -0,0 +1,13 @@
const bcrypt = require("bcryptjs");
const SALT_ROUNDS = 10;
async function hashPassword(password) {
return bcrypt.hash(password, SALT_ROUNDS);
}
async function verifyPassword(password, hash) {
return bcrypt.compare(password, hash);
}
module.exports = { hashPassword, verifyPassword };