diff --git a/api/package-lock.json b/api/package-lock.json
index be3d4e4..6aaa05d 100644
--- a/api/package-lock.json
+++ b/api/package-lock.json
@@ -13,6 +13,7 @@
"@fastify/cors": "^11.2.0",
"@fastify/helmet": "^13.0.2",
"@fastify/jwt": "^10.0.0",
+ "@fastify/rate-limit": "^10.3.0",
"@fastify/swagger": "^9.7.0",
"@fastify/swagger-ui": "^5.2.5",
"bcrypt": "^6.0.0",
@@ -247,6 +248,27 @@
"ipaddr.js": "^2.1.0"
}
},
+ "node_modules/@fastify/rate-limit": {
+ "version": "10.3.0",
+ "resolved": "https://registry.npmjs.org/@fastify/rate-limit/-/rate-limit-10.3.0.tgz",
+ "integrity": "sha512-eIGkG9XKQs0nyynatApA3EVrojHOuq4l6fhB4eeCk4PIOeadvOJz9/4w3vGI44Go17uaXOWEcPkaD8kuKm7g6Q==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@lukeed/ms": "^2.0.2",
+ "fastify-plugin": "^5.0.0",
+ "toad-cache": "^3.7.0"
+ }
+ },
"node_modules/@fastify/send": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/@fastify/send/-/send-4.1.0.tgz",
diff --git a/api/package.json b/api/package.json
index 9d5234b..af56901 100644
--- a/api/package.json
+++ b/api/package.json
@@ -17,6 +17,7 @@
"@fastify/cors": "^11.2.0",
"@fastify/helmet": "^13.0.2",
"@fastify/jwt": "^10.0.0",
+ "@fastify/rate-limit": "^10.3.0",
"@fastify/swagger": "^9.7.0",
"@fastify/swagger-ui": "^5.2.5",
"bcrypt": "^6.0.0",
diff --git a/api/src/index.js b/api/src/index.js
index 5309afa..9d08a11 100644
--- a/api/src/index.js
+++ b/api/src/index.js
@@ -3,6 +3,7 @@ require("dotenv").config();
const Fastify = require("fastify");
const cors = require("@fastify/cors");
const jwt = require("@fastify/jwt");
+const rateLimit = require("@fastify/rate-limit");
const { Pool } = require("pg");
const Redis = require("ioredis");
@@ -25,6 +26,12 @@ redis.on("error", (err) => {
// Plugins
app.register(cors, { origin: true });
+app.register(rateLimit, {
+ max: 100,
+ timeWindow: "1 minute",
+ keyGenerator: (req) => req.ip,
+ errorResponseBuilder: () => ({ error: "Too many requests", statusCode: 429 })
+});
app.register(jwt, { secret: process.env.JWT_SECRET || "taskteam-jwt-secret-2026" });
// Decorate with db and redis
@@ -50,6 +57,7 @@ app.register(require("./routes/connectors/pohoda"), { prefix: "/api/v1" });
app.register(require("./routes/chat"), { prefix: "/api/v1" });
app.register(require("./routes/notifications"), { prefix: "/api/v1" });
app.register(require("./routes/goals"), { prefix: "/api/v1" });
+app.register(require("./routes/system"), { prefix: "/api/v1" });
// Graceful shutdown
const shutdown = async (signal) => {
diff --git a/api/src/routes/deploy.js b/api/src/routes/deploy.js
new file mode 100644
index 0000000..41af8d9
--- /dev/null
+++ b/api/src/routes/deploy.js
@@ -0,0 +1,58 @@
+// Task Team — Deploy webhook — 2026-03-29
+const { execSync } = require("child_process");
+
+const DEPLOY_SECRET = process.env.DEPLOY_SECRET || "taskteam-deploy-2026";
+const EXEC_OPTS = { encoding: "utf8", env: { ...process.env, PATH: "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" } };
+
+async function deployRoutes(app) {
+ app.post("/deploy/webhook", async (req, reply) => {
+ // Verify secret
+ const secret = req.headers["x-gitea-secret"] || (req.body && req.body.secret);
+ if (secret !== DEPLOY_SECRET) {
+ return reply.code(401).send({ error: "invalid secret" });
+ }
+
+ const ref = (req.body && req.body.ref) || "";
+ if (!ref.includes("master") && !ref.includes("main")) {
+ return { status: "skipped", reason: "not master branch" };
+ }
+
+ app.log.info("Deploy triggered by Gitea webhook");
+
+ try {
+ // Pull latest code
+ const pullResult = execSync("cd /opt/task-team && git pull origin master 2>&1", { ...EXEC_OPTS, timeout: 30000 });
+
+ // Install deps if package.json changed
+ execSync("cd /opt/task-team/api && npm install --production 2>&1", { ...EXEC_OPTS, timeout: 60000 });
+
+ // Reload API (zero-downtime)
+ execSync("pm2 reload taskteam-api 2>&1", { ...EXEC_OPTS, timeout: 15000 });
+
+ // Build frontend
+ execSync("cd /opt/task-team/apps/tasks && NEXT_PUBLIC_API_URL=http://localhost:3000 npm run build 2>&1", { ...EXEC_OPTS, timeout: 120000 });
+
+ // Reload web
+ execSync("pm2 reload taskteam-web 2>&1", { ...EXEC_OPTS, timeout: 15000 });
+
+ return { status: "deployed", pull: pullResult.trim().split("\n").slice(-3).join("\n") };
+ } catch (e) {
+ app.log.error(e, "Deploy failed");
+ return reply.code(500).send({ status: "failed", error: e.message });
+ }
+ });
+
+ // Manual deploy status
+ app.get("/deploy/status", async () => {
+ try {
+ const commit = execSync("cd /opt/task-team && git log --oneline -1", { ...EXEC_OPTS }).trim();
+ const pm2 = execSync("pm2 jlist 2>/dev/null", { ...EXEC_OPTS });
+ const procs = JSON.parse(pm2).map(p => ({ name: p.name, status: p.pm2_env.status, uptime: p.pm2_env.pm_uptime }));
+ return { status: "ok", commit, processes: procs };
+ } catch (e) {
+ return { status: "error", message: e.message };
+ }
+ });
+}
+
+module.exports = deployRoutes;
diff --git a/api/src/routes/system.js b/api/src/routes/system.js
new file mode 100644
index 0000000..bc52854
--- /dev/null
+++ b/api/src/routes/system.js
@@ -0,0 +1,77 @@
+// System health & monitoring routes — 2026-03-29
+module.exports = async function systemRoutes(app, opts) {
+ const os = require('os');
+
+ // GET /api/v1/system/health — comprehensive system status
+ app.get('/system/health', async (request, reply) => {
+ // System info
+ const system = {
+ hostname: os.hostname(),
+ uptime: Math.floor(os.uptime()),
+ loadavg: os.loadavg(),
+ cpus: os.cpus().length,
+ memory: {
+ total: Math.round(os.totalmem() / 1024 / 1024),
+ free: Math.round(os.freemem() / 1024 / 1024),
+ used_pct: Math.round((1 - os.freemem() / os.totalmem()) * 100)
+ }
+ };
+
+ // DB check
+ let db_status = { status: 'error' };
+ try {
+ const { rows } = await app.db.query('SELECT NOW() as time, pg_database_size(current_database()) as size');
+ db_status = {
+ status: 'ok',
+ time: rows[0].time,
+ size_mb: Math.round(rows[0].size / 1024 / 1024)
+ };
+ } catch (e) {
+ db_status = { status: 'error', message: e.message };
+ }
+
+ // Redis check
+ let redis_status = { status: 'error' };
+ try {
+ const pong = await app.redis.ping();
+ const info = await app.redis.info('memory');
+ const usedMem = info.match(/used_memory_human:(.+)/)?.[1]?.trim();
+ redis_status = {
+ status: pong === 'PONG' ? 'ok' : 'error',
+ memory: usedMem
+ };
+ } catch (e) {
+ redis_status = { status: 'error', message: e.message };
+ }
+
+ // Task stats
+ let task_stats = {};
+ try {
+ const { rows } = await app.db.query(`
+ SELECT status, count(*)::int as count FROM tasks GROUP BY status
+ UNION ALL SELECT 'total', count(*)::int FROM tasks
+ UNION ALL SELECT 'users', count(*)::int FROM users
+ UNION ALL SELECT 'goals', count(*)::int FROM goals
+ `);
+ task_stats = Object.fromEntries(rows.map(r => [r.status, r.count]));
+ } catch (e) {
+ task_stats = { error: e.message };
+ }
+
+ // Determine overall status
+ const overall = (db_status.status === 'ok' && redis_status.status === 'ok') ? 'ok' : 'degraded';
+
+ return {
+ status: overall,
+ timestamp: new Date().toISOString(),
+ system,
+ database: db_status,
+ redis: redis_status,
+ tasks: task_stats,
+ version: require('../../package.json').version || '1.0.0'
+ };
+ });
+
+ // GET /api/v1/system/ping — lightweight liveness check
+ app.get('/system/ping', async () => ({ pong: true, ts: Date.now() }));
+};
diff --git a/api/src/routes/tasks.js b/api/src/routes/tasks.js
index 2955e70..e79c747 100644
--- a/api/src/routes/tasks.js
+++ b/api/src/routes/tasks.js
@@ -1,7 +1,55 @@
-// Task Team — Tasks CRUD with Redis Caching — 2026-03-29
+// Task Team — Tasks CRUD with Redis Caching + Input Validation — 2026-03-29
const CACHE_TTL = 30; // seconds
const CACHE_PREFIX = "taskteam:tasks:";
+// Validation constants
+const VALID_STATUSES = ["pending", "in_progress", "done", "completed", "cancelled"];
+const VALID_PRIORITIES = ["urgent", "high", "medium", "low"];
+const MAX_TITLE_LENGTH = 500;
+const MAX_DESCRIPTION_LENGTH = 5000;
+
+// Input validation helper
+function validateTaskInput(body, isUpdate = false) {
+ const errors = [];
+
+ if (!isUpdate) {
+ // title is required for create
+ if (!body.title || typeof body.title !== "string" || body.title.trim().length === 0) {
+ errors.push("title is required and must be a non-empty string");
+ }
+ }
+
+ if (body.title !== undefined) {
+ if (typeof body.title !== "string") {
+ errors.push("title must be a string");
+ } else if (body.title.length > MAX_TITLE_LENGTH) {
+ errors.push("title must not exceed " + MAX_TITLE_LENGTH + " characters");
+ }
+ }
+
+ if (body.description !== undefined && body.description !== null) {
+ if (typeof body.description !== "string") {
+ errors.push("description must be a string");
+ } else if (body.description.length > MAX_DESCRIPTION_LENGTH) {
+ errors.push("description must not exceed " + MAX_DESCRIPTION_LENGTH + " characters");
+ }
+ }
+
+ if (body.status !== undefined && body.status !== null) {
+ if (!VALID_STATUSES.includes(body.status)) {
+ errors.push("status must be one of: " + VALID_STATUSES.join(", "));
+ }
+ }
+
+ if (body.priority !== undefined && body.priority !== null) {
+ if (!VALID_PRIORITIES.includes(body.priority)) {
+ errors.push("priority must be one of: " + VALID_PRIORITIES.join(", "));
+ }
+ }
+
+ return errors;
+}
+
async function taskRoutes(app) {
// Helper: build cache key from query params
@@ -91,20 +139,30 @@ async function taskRoutes(app) {
return result;
});
- // Create task (invalidates cache)
- app.post("/tasks", async (req) => {
+ // Create task (with validation, invalidates cache)
+ app.post("/tasks", async (req, reply) => {
+ const errors = validateTaskInput(req.body, false);
+ if (errors.length > 0) {
+ return reply.status(400).send({ error: "Validation failed", details: errors, statusCode: 400 });
+ }
+
const { title, description, status, group_id, priority, scheduled_at, due_at, assigned_to } = req.body;
const { rows } = await app.db.query(
`INSERT INTO tasks (title, description, status, group_id, priority, scheduled_at, due_at, assigned_to)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *`,
- [title, description || "", status || "pending", group_id, priority || "medium", scheduled_at, due_at, assigned_to || []]
+ [title.trim(), description || "", status || "pending", group_id, priority || "medium", scheduled_at, due_at, assigned_to || []]
);
await invalidateTaskCaches();
return { data: rows[0] };
});
- // Update task (invalidates cache)
- app.put("/tasks/:id", async (req) => {
+ // Update task (with validation, invalidates cache)
+ app.put("/tasks/:id", async (req, reply) => {
+ const errors = validateTaskInput(req.body, true);
+ if (errors.length > 0) {
+ return reply.status(400).send({ error: "Validation failed", details: errors, statusCode: 400 });
+ }
+
const fields = req.body;
const sets = [];
const params = [];
@@ -112,7 +170,7 @@ async function taskRoutes(app) {
for (const [key, value] of Object.entries(fields)) {
if (["title","description","status","group_id","priority","scheduled_at","due_at","assigned_to","completed_at"].includes(key)) {
sets.push(`${key} = $${i}`);
- params.push(value);
+ params.push(key === "title" && typeof value === "string" ? value.trim() : value);
i++;
}
}
diff --git a/apps/tasks/app/calendar/page.tsx b/apps/tasks/app/calendar/page.tsx
index b369fe2..c65a538 100644
--- a/apps/tasks/app/calendar/page.tsx
+++ b/apps/tasks/app/calendar/page.tsx
@@ -4,6 +4,7 @@ import FullCalendar from '@fullcalendar/react';
import dayGridPlugin from '@fullcalendar/daygrid';
import timeGridPlugin from '@fullcalendar/timegrid';
import interactionPlugin from '@fullcalendar/interaction';
+import { useTranslation } from '@/lib/i18n';
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
@@ -17,8 +18,16 @@ interface Task {
group_color: string;
}
+const LOCALE_MAP: Record Zeptejte se na cokoliv ohledně vašich úkolů {t("chat.subtitle")} Začněte konverzaci {t("chat.startConversation")}
- Napište zprávu a AI asistent vám pomůže s úkoly
+ {t("chat.helpText")}
Kalendar
+ {t('calendar.title')}
AI Asistent
- {t("chat.title")}
+