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
+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 };