Phase 3-4: Goals AI planner, Team tasks, Push notifications, i18n (CZ/HE/RU/UA)

- Goals CRUD API + AI study plan generator + progress reports
- Goals frontend page with progress bars
- Team tasks: assign, transfer, collaborate endpoints
- Push notifications: web-push, VAPID, subscribe/send
- i18n: 4 languages (cs, he, ru, ua) translation files
- notifications.js + goals.js routes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude CLI Agent
2026-03-29 13:12:19 +00:00
parent eac9e72404
commit fea4d38ce8
19 changed files with 1176 additions and 112 deletions

89
api/package-lock.json generated
View File

@@ -21,7 +21,8 @@
"ioredis": "^5.10.1",
"pg": "^8.20.0",
"redis": "^5.11.0",
"uuid": "^13.0.0"
"uuid": "^13.0.0",
"web-push": "^3.6.7"
},
"devDependencies": {
"nodemon": "^3.1.14"
@@ -435,6 +436,15 @@
"integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==",
"license": "MIT"
},
"node_modules/agent-base": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
"license": "MIT",
"engines": {
"node": ">= 14"
}
},
"node_modules/ajv": {
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
@@ -590,6 +600,12 @@
"node": ">=8"
}
},
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
"node_modules/chokidar": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
@@ -978,6 +994,15 @@
"node": ">=18.0.0"
}
},
"node_modules/http_ece": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz",
"integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==",
"license": "MIT",
"engines": {
"node": ">=16"
}
},
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
@@ -998,6 +1023,19 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/https-proxy-agent": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.2",
"debug": "4"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/ignore-by-default": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz",
@@ -1145,6 +1183,27 @@
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"license": "MIT"
},
"node_modules/jwa": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
"license": "MIT",
"dependencies": {
"buffer-equal-constant-time": "^1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"node_modules/jws": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
"license": "MIT",
"dependencies": {
"jwa": "^2.0.1",
"safe-buffer": "^5.0.1"
}
},
"node_modules/light-my-request": {
"version": "6.6.0",
"resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz",
@@ -1236,6 +1295,15 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/minipass": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
@@ -1905,6 +1973,25 @@
"uuid": "dist-node/bin/uuid"
}
},
"node_modules/web-push": {
"version": "3.6.7",
"resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz",
"integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==",
"license": "MPL-2.0",
"dependencies": {
"asn1.js": "^5.3.0",
"http_ece": "1.2.0",
"https-proxy-agent": "^7.0.0",
"jws": "^4.0.0",
"minimist": "^1.2.5"
},
"bin": {
"web-push": "src/cli.js"
},
"engines": {
"node": ">= 16"
}
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",

View File

@@ -25,7 +25,8 @@
"ioredis": "^5.10.1",
"pg": "^8.20.0",
"redis": "^5.11.0",
"uuid": "^13.0.0"
"uuid": "^13.0.0",
"web-push": "^3.6.7"
},
"devDependencies": {
"nodemon": "^3.1.14"

View File

@@ -48,6 +48,8 @@ app.register(require("./routes/connectors/odoo"), { prefix: "/api/v1" });
app.register(require("./routes/connectors/moodle"), { prefix: "/api/v1" });
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" });
// Graceful shutdown
const shutdown = async (signal) => {

155
api/src/routes/goals.js Normal file
View File

@@ -0,0 +1,155 @@
// Task Team — Goals API + AI Planner — 2026-03-29
const Anthropic = require("@anthropic-ai/sdk");
async function goalRoutes(app) {
const anthropic = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
// List goals
app.get("/goals", async (req) => {
const { rows } = await app.db.query(
"SELECT g.*, tg.name as group_name, tg.color as group_color, tg.icon as group_icon FROM goals g LEFT JOIN task_groups tg ON g.group_id = tg.id ORDER BY g.target_date ASC NULLS LAST"
);
return { data: rows };
});
// Get goal with related tasks
app.get("/goals/:id", async (req) => {
const { rows } = await app.db.query("SELECT * FROM goals WHERE id = $1", [req.params.id]);
if (!rows.length) throw { statusCode: 404, message: "Goal not found" };
const { rows: tasks } = await app.db.query(
"SELECT * FROM tasks WHERE external_id LIKE $1 ORDER BY scheduled_at ASC",
["goal:" + req.params.id + "%"]
);
return { data: { ...rows[0], tasks } };
});
// Create goal
app.post("/goals", async (req) => {
const { title, target_date, group_id, user_id, plan } = req.body;
const { rows } = await app.db.query(
"INSERT INTO goals (title, target_date, group_id, user_id, plan) VALUES ($1, $2, $3, $4, $5) RETURNING *",
[title, target_date || null, group_id || null, user_id || null, JSON.stringify(plan || {})]
);
return { data: rows[0] };
});
// Update goal progress
app.put("/goals/:id", async (req) => {
const { title, target_date, progress_pct, plan } = req.body;
const { rows } = await app.db.query(
"UPDATE goals SET title=COALESCE($1,title), target_date=COALESCE($2,target_date), progress_pct=COALESCE($3,progress_pct), plan=COALESCE($4,plan), updated_at=NOW() WHERE id=$5 RETURNING *",
[title, target_date, progress_pct, plan ? JSON.stringify(plan) : null, req.params.id]
);
if (!rows.length) throw { statusCode: 404, message: "Goal not found" };
return { data: rows[0] };
});
// Delete goal
app.delete("/goals/:id", async (req) => {
const { rowCount } = await app.db.query("DELETE FROM goals WHERE id = $1", [req.params.id]);
if (!rowCount) throw { statusCode: 404, message: "Goal not found" };
return { status: "deleted" };
});
// AI: Generate study plan for a goal
app.post("/goals/:id/plan", async (req) => {
const { rows } = await app.db.query("SELECT * FROM goals WHERE id = $1", [req.params.id]);
if (!rows.length) throw { statusCode: 404, message: "Goal not found" };
const goal = rows[0];
const { rows: groups } = await app.db.query(
"SELECT name, time_zones FROM task_groups WHERE id = $1", [goal.group_id]
);
const groupInfo = groups[0] || {};
const response = await anthropic.messages.create({
model: "claude-sonnet-4-20250514",
max_tokens: 2048,
system: "Jsi planovaci AI asistent. Generujes detailni studijni/pracovni plany v JSON formatu. Odpovidej v cestine.",
messages: [{
role: "user",
content: "Vytvor tydenni plan pro tento cil:\nCil: " + goal.title +
"\nTermin: " + (goal.target_date || "bez terminu") +
"\nAktualni progres: " + goal.progress_pct + "%" +
"\nSkupina: " + (groupInfo.name || "obecna") +
"\nCasove zony skupiny: " + JSON.stringify(groupInfo.time_zones || []) +
"\n\nVrat JSON s polem \"weeks\" kde kazdy tyden ma \"week_number\", \"focus\", \"tasks\" (pole s title, description, duration_hours, day_of_week).\nZahrn spaced repetition pro opakovani."
}]
});
let plan;
try {
const text = response.content[0].text;
const jsonMatch = text.match(/\{[\s\S]*\}/);
plan = jsonMatch ? JSON.parse(jsonMatch[0]) : { raw: text };
} catch (e) {
plan = { raw: response.content[0].text };
}
// Save plan
await app.db.query(
"UPDATE goals SET plan = $1, updated_at = NOW() WHERE id = $2",
[JSON.stringify(plan), goal.id]
);
// Create tasks from plan
let tasksCreated = 0;
if (plan.weeks) {
for (const week of plan.weeks) {
for (const task of (week.tasks || [])) {
await app.db.query(
"INSERT INTO tasks (title, description, group_id, external_id, external_source, status, priority) VALUES ($1, $2, $3, $4, $5, $6, $7)",
[
task.title,
task.description || "",
goal.group_id,
"goal:" + goal.id + ":w" + week.week_number,
"goal",
"pending",
"medium"
]
);
tasksCreated++;
}
}
}
return { data: { plan, tasks_created: tasksCreated } };
});
// AI: Progress report for a goal
app.get("/goals/:id/report", async (req) => {
const { rows } = await app.db.query("SELECT * FROM goals WHERE id = $1", [req.params.id]);
if (!rows.length) throw { statusCode: 404, message: "Goal not found" };
const { rows: tasks } = await app.db.query(
"SELECT title, status, completed_at FROM tasks WHERE external_id LIKE $1",
["goal:" + req.params.id + "%"]
);
const done = tasks.filter(t => t.status === "completed" || t.status === "done").length;
const total = tasks.length;
const response = await anthropic.messages.create({
model: "claude-sonnet-4-20250514",
max_tokens: 1024,
system: "Jsi AI coach. Davas motivacni zpetnou vazbu k plneni cilu. Odpovidej v cestine, strucne.",
messages: [{
role: "user",
content: "Cil: " + rows[0].title +
"\nSplneno: " + done + "/" + total + " ukolu (" + (total ? Math.round(done / total * 100) : 0) + "%)" +
"\nProgres: " + rows[0].progress_pct + "%" +
"\nDej zpetnou vazbu a doporuceni."
}]
});
return {
data: {
report: response.content[0].text,
stats: { done, total, pct: total ? Math.round(done / total * 100) : 0 }
}
};
});
}
module.exports = goalRoutes;

View File

@@ -0,0 +1,77 @@
// Task Team — Push Notifications — 2026-03-29
const webpush = require("web-push");
async function notificationRoutes(app) {
// Generate VAPID keys if not exist
const vapidKeys = {
publicKey: process.env.VAPID_PUBLIC_KEY || "",
privateKey: process.env.VAPID_PRIVATE_KEY || ""
};
if (!vapidKeys.publicKey) {
const keys = webpush.generateVAPIDKeys();
vapidKeys.publicKey = keys.publicKey;
vapidKeys.privateKey = keys.privateKey;
console.log("VAPID keys generated. Add to env:", JSON.stringify(keys));
}
webpush.setVapidDetails("mailto:admin@hasdo.info", vapidKeys.publicKey, vapidKeys.privateKey);
// Create subscriptions table if not exists
await app.db.query(`
CREATE TABLE IF NOT EXISTS push_subscriptions (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
subscription JSONB NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
)
`);
// Get VAPID public key
app.get("/notifications/vapid-key", async () => {
return { data: { publicKey: vapidKeys.publicKey } };
});
// Subscribe
app.post("/notifications/subscribe", async (req) => {
const { subscription, user_id } = req.body;
await app.db.query(
"INSERT INTO push_subscriptions (user_id, subscription) VALUES ($1, $2)",
[user_id, JSON.stringify(subscription)]
);
return { status: "subscribed" };
});
// Send notification (internal use)
app.post("/notifications/send", async (req) => {
const { user_id, title, body, url } = req.body;
const { rows } = await app.db.query(
"SELECT subscription FROM push_subscriptions WHERE user_id = $1", [user_id]
);
let sent = 0;
for (const row of rows) {
try {
await webpush.sendNotification(
JSON.parse(row.subscription),
JSON.stringify({ title, body, url: url || "/tasks" })
);
sent++;
} catch (e) {
if (e.statusCode === 410) {
await app.db.query("DELETE FROM push_subscriptions WHERE subscription = $1", [row.subscription]);
}
}
}
return { status: "sent", count: sent };
});
// Unsubscribe
app.delete("/notifications/unsubscribe", async (req) => {
const { user_id } = req.body;
await app.db.query("DELETE FROM push_subscriptions WHERE user_id = $1", [user_id]);
return { status: "unsubscribed" };
});
}
module.exports = notificationRoutes;

View File

@@ -153,6 +153,62 @@ async function taskRoutes(app) {
);
return { data: rows[0] };
});
// === Team Collaboration Endpoints ===
// Assign task to user
app.post("/tasks/:id/assign", async (req) => {
const { user_id } = req.body;
if (!user_id) throw { statusCode: 400, message: "user_id is required" };
const { rows } = await app.db.query(
"UPDATE tasks SET assigned_to = array_append(COALESCE(assigned_to, ARRAY[]::uuid[]), $1::uuid), updated_at = NOW() WHERE id = $2 RETURNING *",
[user_id, req.params.id]
);
if (!rows.length) throw { statusCode: 404, message: "Task not found" };
await invalidateTaskCaches();
return { data: rows[0] };
});
// Transfer task (creates pending transfer)
app.post("/tasks/:id/transfer", async (req) => {
const { to_user_id, message } = req.body;
if (!to_user_id) throw { statusCode: 400, message: "to_user_id is required" };
await app.db.query(
"INSERT INTO task_comments (task_id, content, is_ai) VALUES ($1, $2, true)",
[req.params.id, JSON.stringify({ type: "transfer_request", to: to_user_id, message: message || "", status: "pending" })]
);
return { status: "transfer_requested" };
});
// Accept/reject transfer
app.post("/tasks/:id/transfer/respond", async (req) => {
const { accept, user_id } = req.body;
if (accept) {
if (!user_id) throw { statusCode: 400, message: "user_id is required" };
const { rows } = await app.db.query(
"UPDATE tasks SET user_id = $1, updated_at = NOW() WHERE id = $2 RETURNING *",
[user_id, req.params.id]
);
if (!rows.length) throw { statusCode: 404, message: "Task not found" };
await invalidateTaskCaches();
return { data: rows[0], status: "transferred" };
}
return { status: "rejected" };
});
// Collaborate on task
app.post("/tasks/:id/collaborate", async (req) => {
const { user_id } = req.body;
if (!user_id) throw { statusCode: 400, message: "user_id is required" };
const { rows } = await app.db.query(
"UPDATE tasks SET assigned_to = array_append(COALESCE(assigned_to, ARRAY[]::uuid[]), $1::uuid), updated_at = NOW() WHERE id = $2 RETURNING *",
[user_id, req.params.id]
);
if (!rows.length) throw { statusCode: 404, message: "Task not found" };
await invalidateTaskCaches();
return { data: rows[0] };
});
}
module.exports = taskRoutes;