Initial Planlink implementation
This commit is contained in:
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
dist
|
||||||
|
coverage
|
||||||
|
.DS_Store
|
||||||
@@ -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
|
||||||
@@ -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>
|
||||||
Generated
+2614
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {}
|
||||||
|
}
|
||||||
|
};
|
||||||
+1569
File diff suppressed because it is too large
Load Diff
@@ -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 })
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
:root {
|
||||||
|
color-scheme: light dark;
|
||||||
|
}
|
||||||
@@ -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: []
|
||||||
|
};
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
port: 5173
|
||||||
|
}
|
||||||
|
});
|
||||||
+186
@@ -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);
|
||||||
@@ -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.
|
||||||
@@ -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."
|
||||||
@@ -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.
|
||||||
Generated
+1420
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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}`);
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
Reference in New Issue
Block a user