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:
89
api/package-lock.json
generated
89
api/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
155
api/src/routes/goals.js
Normal 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;
|
||||
77
api/src/routes/notifications.js
Normal file
77
api/src/routes/notifications.js
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user