Swagger docs + Error tracking + Email + Production keystore

- Swagger UI at /docs (70+ routes documented)
- Error tracking: error_logs table, /errors/report, /errors/recent, /errors/stats
- Global error handler logs to DB
- Email: nodemailer, /email/send, /email/reminder (SMTP placeholder)
- Production keystore: RSA 2048, valid 2053, info.hasdo.taskteam

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-29 18:27:33 +00:00
parent 6089f4d310
commit 070a62bdf1
7 changed files with 154 additions and 0 deletions

10
api/package-lock.json generated
View File

@@ -21,6 +21,7 @@
"dotenv": "^17.3.1", "dotenv": "^17.3.1",
"fastify": "^5.8.4", "fastify": "^5.8.4",
"ioredis": "^5.10.1", "ioredis": "^5.10.1",
"nodemailer": "^8.0.4",
"passport": "^0.7.0", "passport": "^0.7.0",
"passport-facebook": "^3.0.0", "passport-facebook": "^3.0.0",
"passport-google-oauth20": "^2.0.0", "passport-google-oauth20": "^2.0.0",
@@ -1392,6 +1393,15 @@
"node-gyp-build-test": "build-test.js" "node-gyp-build-test": "build-test.js"
} }
}, },
"node_modules/nodemailer": {
"version": "8.0.4",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.4.tgz",
"integrity": "sha512-k+jf6N8PfQJ0Fe8ZhJlgqU5qJU44Lpvp2yvidH3vp1lPnVQMgi4yEEMPXg5eJS1gFIJTVq1NHBk7Ia9ARdSBdQ==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/nodemon": { "node_modules/nodemon": {
"version": "3.1.14", "version": "3.1.14",
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz", "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz",

View File

@@ -25,6 +25,7 @@
"dotenv": "^17.3.1", "dotenv": "^17.3.1",
"fastify": "^5.8.4", "fastify": "^5.8.4",
"ioredis": "^5.10.1", "ioredis": "^5.10.1",
"nodemailer": "^8.0.4",
"passport": "^0.7.0", "passport": "^0.7.0",
"passport-facebook": "^3.0.0", "passport-facebook": "^3.0.0",
"passport-google-oauth20": "^2.0.0", "passport-google-oauth20": "^2.0.0",

View File

@@ -6,6 +6,8 @@ const jwt = require("@fastify/jwt");
const rateLimit = require("@fastify/rate-limit"); const rateLimit = require("@fastify/rate-limit");
const { Pool } = require("pg"); const { Pool } = require("pg");
const Redis = require("ioredis"); const Redis = require("ioredis");
const swagger = require("@fastify/swagger");
const swaggerUi = require("@fastify/swagger-ui");
// Database pool // Database pool
const pool = new Pool({ const pool = new Pool({
@@ -58,6 +60,31 @@ const start = async () => {
redis: redis.status redis: redis.status
})); }));
// Swagger/OpenAPI documentation
await app.register(swagger, {
openapi: {
info: { title: "Task Team API", version: "1.0.0", description: "Personal life management system API" },
servers: [{ url: "https://api.hasdo.info" }],
tags: [
{ name: "Auth", description: "Authentication & OAuth" },
{ name: "Tasks", description: "Task management" },
{ name: "Groups", description: "Task groups" },
{ name: "Goals", description: "Goals & AI planner" },
{ name: "Projects", description: "Project management" },
{ name: "Collaboration", description: "Team collaboration" },
{ name: "Connectors", description: "External integrations" },
{ name: "Notifications", description: "Push notifications" },
{ name: "System", description: "Health & monitoring" }
]
}
});
await app.register(swaggerUi, {
routePrefix: "/docs",
uiConfig: { docExpansion: "list", deepLinking: true }
});
// Register routes // Register routes
await app.register(require("./routes/tasks"), { prefix: "/api/v1" }); await app.register(require("./routes/tasks"), { prefix: "/api/v1" });
await app.register(require("./routes/groups"), { prefix: "/api/v1" }); await app.register(require("./routes/groups"), { prefix: "/api/v1" });
@@ -74,6 +101,8 @@ const start = async () => {
await app.register(require("./routes/deploy"), { prefix: "/api/v1" }); await app.register(require("./routes/deploy"), { prefix: "/api/v1" });
await app.register(require("./routes/system"), { prefix: "/api/v1" }); await app.register(require("./routes/system"), { prefix: "/api/v1" });
await app.register(require("./routes/collaboration"), { prefix: "/api/v1" }); await app.register(require("./routes/collaboration"), { prefix: "/api/v1" });
await app.register(require("./routes/email"), { prefix: "/api/v1" });
await app.register(require("./routes/errors"), { prefix: "/api/v1" });
try { try {
await app.listen({ port: process.env.PORT || 3000, host: "0.0.0.0" }); await app.listen({ port: process.env.PORT || 3000, host: "0.0.0.0" });

55
api/src/routes/email.js Normal file
View File

@@ -0,0 +1,55 @@
// Task Team — Email Notifications — 2026-03-29
const nodemailer = require("nodemailer");
async function emailRoutes(app) {
const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST || "smtp.gmail.com",
port: parseInt(process.env.SMTP_PORT || "587"),
secure: false,
auth: {
user: process.env.SMTP_USER || "",
pass: process.env.SMTP_PASS || ""
}
});
// Send email
app.post("/email/send", async (req) => {
const { to, subject, text, html } = req.body;
if (!to || !subject) throw { statusCode: 400, message: "to and subject required" };
if (!process.env.SMTP_USER) {
return { status: "skipped", message: "SMTP not configured" };
}
const info = await transporter.sendMail({
from: process.env.SMTP_FROM || '"Task Team" <noreply@hasdo.info>',
to, subject, text, html
});
return { status: "sent", messageId: info.messageId };
});
// Send task reminder
app.post("/email/reminder", async (req) => {
const { user_id, task_id } = req.body;
const { rows: users } = await app.db.query("SELECT email, name FROM users WHERE id=$1", [user_id]);
const { rows: tasks } = await app.db.query("SELECT title, due_at FROM tasks WHERE id=$1", [task_id]);
if (!users.length || !tasks.length) throw { statusCode: 404, message: "User or task not found" };
if (!process.env.SMTP_USER) return { status: "skipped", message: "SMTP not configured" };
await transporter.sendMail({
from: '"Task Team" <noreply@hasdo.info>',
to: users[0].email,
subject: `Pripominka: ${tasks[0].title}`,
html: `<h2>Pripominka ukolu</h2><p><strong>${tasks[0].title}</strong></p><p>Termin: ${tasks[0].due_at || "bez terminu"}</p><p><a href="https://tasks.hasdo.info/tasks">Otevrit v Task Team</a></p>`
});
return { status: "sent" };
});
// Email config status
app.get("/email/status", async () => {
return { configured: !!process.env.SMTP_USER, host: process.env.SMTP_HOST || "not set" };
});
}
module.exports = emailRoutes;

55
api/src/routes/errors.js Normal file
View File

@@ -0,0 +1,55 @@
// Task Team — Error Tracking — 2026-03-29
async function errorRoutes(app) {
CREATE INDEX IF NOT EXISTS idx_errors_created ON error_logs(created_at DESC);
`);
// Log error from client
app.post("/errors/report", async (req) => {
const { message, stack, url, metadata } = req.body;
await app.db.query(
"INSERT INTO error_logs (message, stack, url, method, metadata) VALUES ($1,$2,$3,$4,$5)",
[message || "Unknown error", stack || "", url || "", req.method, JSON.stringify(metadata || {})]
);
return { status: "logged" };
});
// List recent errors (admin)
app.get("/errors/recent", async (req) => {
const { rows } = await app.db.query(
"SELECT * FROM error_logs ORDER BY created_at DESC LIMIT 50"
);
return { data: rows };
});
// Error stats
app.get("/errors/stats", async (req) => {
const { rows } = await app.db.query(`
SELECT
count(*) as total,
count(*) FILTER (WHERE created_at > NOW() - INTERVAL '1 hour') as last_hour,
count(*) FILTER (WHERE created_at > NOW() - INTERVAL '24 hours') as last_day
FROM error_logs
`);
return { data: rows[0] };
});
// Global error handler for the API itself
app.setErrorHandler(async (error, request, reply) => {
app.log.error(error);
try {
await app.db.query(
"INSERT INTO error_logs (level, message, stack, url, method) VALUES ($1,$2,$3,$4,$5)",
[error.statusCode >= 500 ? "error" : "warn", error.message, error.stack || "", request.url, request.method]
);
} catch (e) {
// Silent catch - error logging should not break the response
}
reply.status(error.statusCode || 500).send({
error: error.message || "Internal Server Error",
statusCode: error.statusCode || 500
});
});
}
module.exports = errorRoutes;

View File

@@ -0,0 +1,4 @@
storeFile=../../task-team-release.keystore
storePassword=TaskTeam2026Release!
keyAlias=task-team
keyPassword=TaskTeam2026Release!

Binary file not shown.