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 = { + cs: 'cs', + he: 'he', + ru: 'ru', + ua: 'uk', +}; + export default function CalendarPage() { const [tasks, setTasks] = useState([]); + const { t, locale } = useTranslation(); useEffect(() => { fetch(`${API_URL}/api/v1/tasks?limit=100`) @@ -42,7 +51,7 @@ export default function CalendarPage() { return (
-

Kalendar

+

{t('calendar.title')}

([]); const [input, setInput] = useState(""); @@ -20,6 +22,8 @@ export default function ChatPage() { const messagesEndRef = useRef(null); const inputRef = useRef(null); + const timeLocale = locale === "ua" ? "uk-UA" : locale === "cs" ? "cs-CZ" : locale === "he" ? "he-IL" : "ru-RU"; + useEffect(() => { if (!token) { router.replace("/login"); @@ -63,7 +67,7 @@ export default function ChatPage() { const assistantMsg: ChatMessage = { id: (Date.now() + 1).toString(), role: "assistant", - content: data.reply || data.message || "Omlouvám se, nemohl jsem zpracovat vaši zprávu.", + content: data.reply || data.message || t("chat.processError"), timestamp: new Date(), }; setMessages((prev) => [...prev, assistantMsg]); @@ -71,7 +75,7 @@ export default function ChatPage() { const errorMsg: ChatMessage = { id: (Date.now() + 1).toString(), role: "assistant", - content: "Chat asistent je momentálně nedostupný. Zkuste to prosím později.", + content: t("chat.unavailable"), timestamp: new Date(), }; setMessages((prev) => [...prev, errorMsg]); @@ -100,8 +104,8 @@ export default function ChatPage() {
-

AI Asistent

-

Zeptejte se na cokoliv ohledně vašich úkolů

+

{t("chat.title")}

+

{t("chat.subtitle")}

@@ -114,9 +118,9 @@ export default function ChatPage() { -

Začněte konverzaci

+

{t("chat.startConversation")}

- Napište zprávu a AI asistent vám pomůže s úkoly + {t("chat.helpText")}

)} @@ -139,7 +143,7 @@ export default function ChatPage() { msg.role === "user" ? "text-blue-200" : "text-muted" }`} > - {msg.timestamp.toLocaleTimeString("cs-CZ", { hour: "2-digit", minute: "2-digit" })} + {msg.timestamp.toLocaleTimeString(timeLocale, { hour: "2-digit", minute: "2-digit" })}

@@ -168,7 +172,7 @@ export default function ChatPage() { value={input} onChange={(e) => setInput(e.target.value)} onKeyDown={handleKeyDown} - placeholder="Napište zprávu..." + placeholder={t("chat.placeholder")} rows={1} className="flex-1 px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-2xl bg-white dark:bg-gray-800 focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none resize-none text-sm" style={{ maxHeight: "120px" }} @@ -177,7 +181,7 @@ export default function ChatPage() { onClick={handleSend} disabled={loading || !input.trim()} className="p-3 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white rounded-full transition-colors flex-shrink-0" - aria-label="Odeslat" + aria-label={t("chat.send")} > diff --git a/apps/tasks/app/goals/page.tsx b/apps/tasks/app/goals/page.tsx index aa6ab60..01cdad9 100644 --- a/apps/tasks/app/goals/page.tsx +++ b/apps/tasks/app/goals/page.tsx @@ -3,6 +3,7 @@ import { useEffect, useState, useCallback } from "react"; import { useRouter } from "next/navigation"; import { useAuth } from "@/lib/auth"; +import { useTranslation } from "@/lib/i18n"; import { getGoals, getGoal, @@ -20,6 +21,7 @@ import { export default function GoalsPage() { const { token } = useAuth(); + const { t, locale } = useTranslation(); const router = useRouter(); const [goals, setGoals] = useState([]); const [groups, setGroups] = useState([]); @@ -36,6 +38,8 @@ export default function GoalsPage() { const [formDate, setFormDate] = useState(""); const [formGroup, setFormGroup] = useState(""); + const dateLocale = locale === "ua" ? "uk-UA" : locale === "cs" ? "cs-CZ" : locale === "he" ? "he-IL" : "ru-RU"; + const loadData = useCallback(async () => { if (!token) return; setLoading(true); @@ -47,12 +51,12 @@ export default function GoalsPage() { setGoals(goalsRes.data || []); setGroups(groupsRes.data || []); } catch (err) { - console.error("Chyba pri nacitani:", err); - setError("Nepodarilo se nacist data"); + console.error("Load error:", err); + setError(t("common.error")); } finally { setLoading(false); } - }, [token]); + }, [token, t]); useEffect(() => { if (!token) { @@ -77,8 +81,8 @@ export default function GoalsPage() { setShowForm(false); loadData(); } catch (err) { - console.error("Chyba pri vytvareni:", err); - setError("Nepodarilo se vytvorit cil"); + console.error("Create error:", err); + setError(t("common.error")); } } @@ -90,7 +94,7 @@ export default function GoalsPage() { setPlanResult(null); setReport(null); } catch (err) { - console.error("Chyba pri nacitani cile:", err); + console.error("Load goal error:", err); } } @@ -101,13 +105,12 @@ export default function GoalsPage() { try { const res = await generateGoalPlan(token, goalId); setPlanResult(res.data); - // Reload goal to get updated plan const updated = await getGoal(token, goalId); setSelectedGoal(updated.data); loadData(); } catch (err) { - console.error("Chyba pri generovani planu:", err); - setError("Nepodarilo se vygenerovat plan. Zkuste to znovu."); + console.error("Plan error:", err); + setError(t("common.error")); } finally { setAiLoading(null); } @@ -121,8 +124,8 @@ export default function GoalsPage() { const res = await getGoalReport(token, goalId); setReport(res.data); } catch (err) { - console.error("Chyba pri ziskavani reportu:", err); - setError("Nepodarilo se ziskat report. Zkuste to znovu."); + console.error("Report error:", err); + setError(t("common.error")); } finally { setAiLoading(null); } @@ -137,25 +140,25 @@ export default function GoalsPage() { setSelectedGoal({ ...selectedGoal, progress_pct: pct }); } } catch (err) { - console.error("Chyba pri aktualizaci:", err); + console.error("Update error:", err); } } async function handleDelete(goalId: string) { if (!token) return; - if (!confirm("Opravdu chcete smazat tento cil?")) return; + if (!confirm(t("tasks.confirmDelete"))) return; try { await deleteGoal(token, goalId); setSelectedGoal(null); loadData(); } catch (err) { - console.error("Chyba pri mazani:", err); + console.error("Delete error:", err); } } function formatDate(d: string | null) { - if (!d) return "Bez terminu"; - return new Date(d).toLocaleDateString("cs-CZ", { day: "numeric", month: "short", year: "numeric" }); + if (!d) return t("tasks.noDue"); + return new Date(d).toLocaleDateString(dateLocale, { day: "numeric", month: "short", year: "numeric" }); } function progressColor(pct: number) { @@ -171,12 +174,12 @@ export default function GoalsPage() {
{/* Header */}
-

Cile

+

{t("goals.title")}

@@ -184,7 +187,7 @@ export default function GoalsPage() { {error && (
{error} - +
)} @@ -192,19 +195,18 @@ export default function GoalsPage() { {showForm && (
- + setFormTitle(e.target.value)} - placeholder="Napr. Naucit se TypeScript" className="w-full px-3 py-2 border rounded-lg dark:bg-gray-800 dark:border-gray-700 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-blue-500 min-h-[44px]" required />
- +
- +
- Generuji plan... + {t("common.loading")} ) : ( <> - Generovat plan (AI) + {t("goals.plan")} )} @@ -353,14 +354,14 @@ export default function GoalsPage() { {aiLoading === "report" ? ( <>
- Generuji report... + {t("common.loading")} ) : ( <> - Report (AI) + {t("goals.report")} )} @@ -370,22 +371,22 @@ export default function GoalsPage() { {planResult && (

- Vygenerovany plan ({planResult.tasks_created} ukolu vytvoreno) + {t("goals.plan")} ({planResult.tasks_created})

{planResult.plan.weeks ? (
{(planResult.plan.weeks as Array<{ week_number: number; focus: string; tasks: Array<{ title: string; duration_hours?: number; day_of_week?: string }> }>).map((week, i) => (

- Tyden {week.week_number}: {week.focus} + #{week.week_number}: {week.focus}

    - {(week.tasks || []).map((t, j) => ( + {(week.tasks || []).map((wt, j) => (
  • - {t.title} - {t.duration_hours && ({t.duration_hours}h)} - {t.day_of_week && [{t.day_of_week}]} + {wt.title} + {wt.duration_hours && ({wt.duration_hours}h)} + {wt.day_of_week && [{wt.day_of_week}]}
  • ))}
@@ -404,9 +405,9 @@ export default function GoalsPage() { {report && (
-

AI Report

+

{t("goals.report")}

- {report.stats.done}/{report.stats.total} splneno ({report.stats.pct}%) + {report.stats.done}/{report.stats.total} ({report.stats.pct}%)

{report.report}

@@ -416,19 +417,19 @@ export default function GoalsPage() { {/* Existing plan */} {selectedGoal.plan && Object.keys(selectedGoal.plan).length > 0 && !planResult && (
-

Ulozeny plan

+

{t("goals.plan")}

{(selectedGoal.plan as Record).weeks ? (
{((selectedGoal.plan as Record).weeks as Array<{ week_number: number; focus: string; tasks: Array<{ title: string; duration_hours?: number; day_of_week?: string }> }>).map((week, i) => (

- Tyden {week.week_number}: {week.focus} + #{week.week_number}: {week.focus}

    - {(week.tasks || []).map((t, j) => ( + {(week.tasks || []).map((wt, j) => (
  • - {t.title} + {wt.title}
  • ))}
@@ -447,7 +448,7 @@ export default function GoalsPage() { {selectedGoal.tasks && selectedGoal.tasks.length > 0 && (

- Souvisejici ukoly ({selectedGoal.tasks.length}) + {t("nav.tasks")} ({selectedGoal.tasks.length})

{(selectedGoal.tasks as Array<{ id: string; title: string; status: string }>).map((task) => ( @@ -461,7 +462,7 @@ export default function GoalsPage() { "bg-gray-400" }`} /> {task.title} - {task.status} + {t(`tasks.status.${task.status}`)}
))}
@@ -473,7 +474,7 @@ export default function GoalsPage() { onClick={() => handleDelete(selectedGoal.id)} className="w-full py-2 text-red-500 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg text-sm font-medium transition-colors min-h-[44px]" > - Smazat cil + {t("tasks.delete")}
)} diff --git a/apps/tasks/app/tasks/[id]/page.tsx b/apps/tasks/app/tasks/[id]/page.tsx index 2aa0a3d..57bb988 100644 --- a/apps/tasks/app/tasks/[id]/page.tsx +++ b/apps/tasks/app/tasks/[id]/page.tsx @@ -3,6 +3,7 @@ import { useEffect, useState, useCallback } from "react"; import { useRouter, useParams } from "next/navigation"; import { useAuth } from "@/lib/auth"; +import { useTranslation } from "@/lib/i18n"; import { getTask, getGroups, @@ -18,25 +19,9 @@ function isDone(status: string): boolean { return status === "done" || status === "completed"; } -function formatDate(dateStr: string | null | undefined): string { - if (!dateStr) return "Bez termínu"; - try { - const d = new Date(dateStr); - if (isNaN(d.getTime())) return "Bez termínu"; - return d.toLocaleDateString("cs-CZ", { - day: "numeric", - month: "numeric", - year: "numeric", - hour: "2-digit", - minute: "2-digit", - }); - } catch { - return "Bez termínu"; - } -} - export default function TaskDetailPage() { const { token } = useAuth(); + const { t, locale } = useTranslation(); const router = useRouter(); const params = useParams(); const id = params.id as string; @@ -47,6 +32,25 @@ export default function TaskDetailPage() { const [deleting, setDeleting] = useState(false); const [error, setError] = useState(""); + const dateLocale = locale === "ua" ? "uk-UA" : locale === "cs" ? "cs-CZ" : locale === "he" ? "he-IL" : "ru-RU"; + + function formatDate(dateStr: string | null | undefined): string { + if (!dateStr) return t("tasks.noDue"); + try { + const d = new Date(dateStr); + if (isNaN(d.getTime())) return t("tasks.noDue"); + return d.toLocaleDateString(dateLocale, { + day: "numeric", + month: "numeric", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + }); + } catch { + return t("tasks.noDue"); + } + } + const loadTask = useCallback(async () => { if (!token || !id) return; setLoading(true); @@ -59,12 +63,12 @@ export default function TaskDetailPage() { setGroups(groupsData.data || []); } catch (err) { setError( - err instanceof Error ? err.message : "Chyba při načítání úkolu" + err instanceof Error ? err.message : t("tasks.loadError") ); } finally { setLoading(false); } - }, [token, id]); + }, [token, id, t]); useEffect(() => { if (!token) { @@ -83,14 +87,14 @@ export default function TaskDetailPage() { async function handleDelete() { if (!token || !id) return; - if (!confirm("Opravdu smazat tento úkol?")) return; + if (!confirm(t("tasks.confirmDelete"))) return; setDeleting(true); try { await deleteTask(token, id); router.push("/tasks"); } catch (err) { setError( - err instanceof Error ? err.message : "Chyba při mazání" + err instanceof Error ? err.message : t("common.error") ); setDeleting(false); } @@ -103,7 +107,7 @@ export default function TaskDetailPage() { loadTask(); } catch (err) { setError( - err instanceof Error ? err.message : "Chyba při změně stavu" + err instanceof Error ? err.message : t("common.error") ); } } @@ -121,12 +125,12 @@ export default function TaskDetailPage() { if (error || !task) { return (
-

{error || "Úkol nenalezen"}

+

{error || t("tasks.notFound")}

); @@ -135,23 +139,23 @@ export default function TaskDetailPage() { if (editing) { return (
-

Upravit úkol

+

{t("tasks.editTask")}

setEditing(false)} - submitLabel="Uložit změny" + submitLabel={t("tasks.saveChanges")} />
); } - const PRIORITY_LABELS: Record = { - low: { label: "Nízká", dot: "🟢" }, - medium: { label: "Střední", dot: "🟡" }, - high: { label: "Vysoká", dot: "🟠" }, - urgent: { label: "Urgentní", dot: "🔴" }, + const PRIORITY_LABELS: Record = { + low: { dot: "\ud83d\udfe2" }, + medium: { dot: "\ud83d\udfe1" }, + high: { dot: "\ud83d\udfe0" }, + urgent: { dot: "\ud83d\udd34" }, }; const pri = PRIORITY_LABELS[task.priority] || PRIORITY_LABELS.medium; @@ -177,7 +181,7 @@ export default function TaskDetailPage() { d="M15 19l-7-7 7-7" /> - Zpět + {t("common.back")} {/* Task detail card */} @@ -206,14 +210,14 @@ export default function TaskDetailPage() {
- Priorita: + {t("tasks.form.priority")}: - {pri.dot} {pri.label} + {pri.dot} {t(`tasks.priority.${task.priority}`)}
{task.group_name && (
- Skupina: + {t("tasks.form.group")}: )}
- Termín: + {t("tasks.form.dueDate")}: {formatDate(task.due_at)}
- Vytvořeno: + {t("tasks.created")}: {formatDate(task.created_at)}
{task.completed_at && (
- Dokončeno: + {t("tasks.completed")}: {formatDate(task.completed_at)} @@ -257,7 +261,7 @@ export default function TaskDetailPage() { onClick={() => handleQuickStatus("done")} className="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg text-sm font-medium transition-colors" > - Označit jako hotové + {t("tasks.markDone")} )} {task.status === "pending" && ( @@ -265,7 +269,7 @@ export default function TaskDetailPage() { onClick={() => handleQuickStatus("in_progress")} className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium transition-colors" > - Zahájit + {t("tasks.start")} )} {taskDone && ( @@ -273,7 +277,7 @@ export default function TaskDetailPage() { onClick={() => handleQuickStatus("pending")} className="px-4 py-2 bg-yellow-600 hover:bg-yellow-700 text-white rounded-lg text-sm font-medium transition-colors" > - Znovu otevřít + {t("tasks.reopen")} )}
@@ -284,14 +288,14 @@ export default function TaskDetailPage() { onClick={() => setEditing(true)} className="flex-1 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors font-medium" > - Upravit + {t("tasks.edit")}
diff --git a/apps/tasks/components/GroupSelector.tsx b/apps/tasks/components/GroupSelector.tsx index a121914..a62805c 100644 --- a/apps/tasks/components/GroupSelector.tsx +++ b/apps/tasks/components/GroupSelector.tsx @@ -2,6 +2,7 @@ import { useRef, useEffect } from "react"; import { Group } from "@/lib/api"; +import { useTranslation } from "@/lib/i18n"; interface GroupSelectorProps { groups: Group[]; @@ -16,6 +17,7 @@ export default function GroupSelector({ }: GroupSelectorProps) { const scrollRef = useRef(null); const activeRef = useRef(null); + const { t } = useTranslation(); // Scroll active button into view useEffect(() => { @@ -46,7 +48,7 @@ export default function GroupSelector({ : "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700 min-h-[44px]" }`} > - Vse + {t("tasks.all")} {groups.map((g) => (
)} @@ -155,7 +159,7 @@ export default function TaskCard({ task, onComplete }: TaskCardProps) { - {new Date(task.due_at).toLocaleDateString("cs-CZ")} + {new Date(task.due_at).toLocaleDateString(dateLocale)} )}
@@ -165,7 +169,7 @@ export default function TaskCard({ task, onComplete }: TaskCardProps) {
diff --git a/backup.sh b/backup.sh new file mode 100755 index 0000000..4037166 --- /dev/null +++ b/backup.sh @@ -0,0 +1,22 @@ +#!/bin/bash +BACKUP_DIR="/opt/task-team/backups" +DATE=$(date +%Y%m%d_%H%M) +PGDUMP="/usr/lib/postgresql/18/bin/pg_dump" +mkdir -p $BACKUP_DIR + +# Dump all databases +PGPASSWORD="TaskTeam2026!" $PGDUMP -h 10.10.10.10 -U taskteam -d taskteam -F c -f "$BACKUP_DIR/taskteam_$DATE.dump" + +if [ $? -eq 0 ]; then + # Compress + gzip -f "$BACKUP_DIR/taskteam_$DATE.dump" 2>/dev/null + echo "$(date '+%Y-%m-%d %H:%M:%S') OK: taskteam_$DATE.dump.gz" +else + echo "$(date '+%Y-%m-%d %H:%M:%S') FAIL: pg_dump exited with error" +fi + +# Keep last 7 daily backups +find $BACKUP_DIR -name "*.dump.gz" -mtime +7 -delete +find $BACKUP_DIR -name "*.dump" -mtime +7 -delete + +ls -lh $BACKUP_DIR/ | tail -5 diff --git a/backups/taskteam_20260329_1317.dump.gz b/backups/taskteam_20260329_1317.dump.gz new file mode 100644 index 0000000..41ab959 Binary files /dev/null and b/backups/taskteam_20260329_1317.dump.gz differ diff --git a/ecosystem.config.js b/ecosystem.config.js index 960ca4c..9a05ba2 100644 --- a/ecosystem.config.js +++ b/ecosystem.config.js @@ -14,7 +14,8 @@ module.exports = { JWT_SECRET: "taskteam-jwt-secret-2026-secure-key", ANTHROPIC_API_KEY: "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", NOTION_API_KEY: "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", - NOTION_TASKS_DB: "659a5381-564a-453a-9e2b-1345c457cca9" + NOTION_TASKS_DB: "659a5381-564a-453a-9e2b-1345c457cca9", + DEPLOY_SECRET: "taskteam-deploy-2026", }, max_memory_restart: "500M", watch: false,