diff --git a/api/package-lock.json b/api/package-lock.json index c0de017..3d17f67 100644 --- a/api/package-lock.json +++ b/api/package-lock.json @@ -21,6 +21,7 @@ "dotenv": "^17.3.1", "fastify": "^5.8.4", "ioredis": "^5.10.1", + "nodemailer": "^8.0.4", "passport": "^0.7.0", "passport-facebook": "^3.0.0", "passport-google-oauth20": "^2.0.0", @@ -1392,6 +1393,15 @@ "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": { "version": "3.1.14", "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz", diff --git a/api/package.json b/api/package.json index 47e1ffd..f4b522a 100644 --- a/api/package.json +++ b/api/package.json @@ -25,6 +25,7 @@ "dotenv": "^17.3.1", "fastify": "^5.8.4", "ioredis": "^5.10.1", + "nodemailer": "^8.0.4", "passport": "^0.7.0", "passport-facebook": "^3.0.0", "passport-google-oauth20": "^2.0.0", diff --git a/api/src/index.js b/api/src/index.js index 0919f3d..f9f7e35 100644 --- a/api/src/index.js +++ b/api/src/index.js @@ -6,6 +6,8 @@ const jwt = require("@fastify/jwt"); const rateLimit = require("@fastify/rate-limit"); const { Pool } = require("pg"); const Redis = require("ioredis"); +const swagger = require("@fastify/swagger"); +const swaggerUi = require("@fastify/swagger-ui"); // Database pool const pool = new Pool({ @@ -58,6 +60,31 @@ const start = async () => { 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 await app.register(require("./routes/tasks"), { 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/system"), { 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 { await app.listen({ port: process.env.PORT || 3000, host: "0.0.0.0" }); diff --git a/api/src/routes/email.js b/api/src/routes/email.js new file mode 100644 index 0000000..12018c1 --- /dev/null +++ b/api/src/routes/email.js @@ -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" ', + 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" ', + to: users[0].email, + subject: `Pripominka: ${tasks[0].title}`, + html: `

Pripominka ukolu

${tasks[0].title}

Termin: ${tasks[0].due_at || "bez terminu"}

Otevrit v Task Team

` + }); + 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; diff --git a/api/src/routes/errors.js b/api/src/routes/errors.js new file mode 100644 index 0000000..8bcb919 --- /dev/null +++ b/api/src/routes/errors.js @@ -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; diff --git a/mobile/keystore.properties b/mobile/keystore.properties new file mode 100644 index 0000000..cc4332c --- /dev/null +++ b/mobile/keystore.properties @@ -0,0 +1,4 @@ +storeFile=../../task-team-release.keystore +storePassword=TaskTeam2026Release! +keyAlias=task-team +keyPassword=TaskTeam2026Release! diff --git a/mobile/task-team-release.keystore b/mobile/task-team-release.keystore new file mode 100644 index 0000000..e11736f Binary files /dev/null and b/mobile/task-team-release.keystore differ