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:
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