From 42881b1f5a05b793c748ea108b1d11fe3f7802ab Mon Sep 17 00:00:00 2001 From: Admin Date: Mon, 30 Mar 2026 00:54:38 +0000 Subject: [PATCH] Add activity monitor, family workspace, per-user rate limiting - Activity monitor API: phone usage tracking with report/summary/daily/ai-analysis endpoints - Family workspace: shared task groups with member management - Per-user API rate limiting: JWT-based key generator with IP fallback - Also includes previously uncommitted spaced-repetition and admin routes Co-Authored-By: Claude Opus 4.6 (1M context) --- api/src/index.js | 16 ++++- api/src/routes/activity.js | 85 ++++++++++++++++++++++ api/src/routes/admin.js | 67 ++++++++++++++++++ api/src/routes/families.js | 52 ++++++++++++++ api/src/routes/spaced-repetition.js | 106 ++++++++++++++++++++++++++++ 5 files changed, 325 insertions(+), 1 deletion(-) create mode 100644 api/src/routes/activity.js create mode 100644 api/src/routes/admin.js create mode 100644 api/src/routes/families.js create mode 100644 api/src/routes/spaced-repetition.js diff --git a/api/src/index.js b/api/src/index.js index 6f435d2..1eb9264 100644 --- a/api/src/index.js +++ b/api/src/index.js @@ -48,7 +48,17 @@ const start = async () => { await app.register(rateLimit, { max: 100, timeWindow: "1 minute", - keyGenerator: (req) => req.ip, + keyGenerator: (req) => { + // Use user ID if authenticated, otherwise IP + try { + const token = req.headers.authorization?.split(" ")[1]; + if (token) { + const decoded = app.jwt.decode(token); + return `user:${decoded.id}`; + } + } catch {} + return `ip:${req.ip}`; + }, errorResponseBuilder: () => ({ error: "Too many requests", statusCode: 429 }) }); await app.register(jwt, { secret: process.env.JWT_SECRET || "taskteam-jwt-secret-2026" }); @@ -115,6 +125,10 @@ const start = async () => { await app.register(require("./routes/email"), { prefix: "/api/v1" }); await app.register(require("./routes/errors"), { prefix: "/api/v1" }); await app.register(require("./routes/invitations"), { prefix: "/api/v1" }); + await app.register(require("./routes/spaced-repetition"), { prefix: "/api/v1" }); + await app.register(require("./routes/admin"), { prefix: "/api/v1" }); + await app.register(require("./routes/activity"), { prefix: "/api/v1" }); + await app.register(require("./routes/families"), { prefix: "/api/v1" }); try { await app.listen({ port: process.env.PORT || 3000, host: "0.0.0.0" }); diff --git a/api/src/routes/activity.js b/api/src/routes/activity.js new file mode 100644 index 0000000..8d02105 --- /dev/null +++ b/api/src/routes/activity.js @@ -0,0 +1,85 @@ +// Task Team — Activity Monitor — 2026-03-30 +async function activityRoutes(app) { + await app.db.query(` + CREATE TABLE IF NOT EXISTS activity_logs ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID REFERENCES users(id) ON DELETE CASCADE, + app_name VARCHAR(255) NOT NULL, + package_name VARCHAR(255), + duration_seconds INTEGER DEFAULT 0, + category VARCHAR(50), + recorded_at TIMESTAMPTZ DEFAULT NOW(), + device VARCHAR(100) + ); + CREATE INDEX IF NOT EXISTS idx_activity_user ON activity_logs(user_id, recorded_at DESC); + `).catch(() => {}); + + // Report activity from phone + app.post("/activity/report", async (req) => { + const { user_id, activities } = req.body; + // activities: [{app_name, package_name, duration_seconds, category}] + let inserted = 0; + for (const a of (activities || [])) { + await app.db.query( + "INSERT INTO activity_logs (user_id, app_name, package_name, duration_seconds, category, device) VALUES ($1,$2,$3,$4,$5,$6)", + [user_id, a.app_name, a.package_name || "", a.duration_seconds || 0, a.category || "other", a.device || "android"] + ); + inserted++; + } + return { status: "ok", inserted }; + }); + + // Get activity summary + app.get("/activity/summary", async (req) => { + const { user_id, days } = req.query; + const d = parseInt(days) || 7; + const { rows } = await app.db.query(` + SELECT category, sum(duration_seconds) as total_seconds, count(*) as sessions + FROM activity_logs + WHERE user_id = $1 AND recorded_at > NOW() - make_interval(days => $2) + GROUP BY category ORDER BY total_seconds DESC + `, [user_id, d]); + return { data: rows }; + }); + + // Get daily breakdown + app.get("/activity/daily", async (req) => { + const { user_id } = req.query; + const { rows } = await app.db.query(` + SELECT date_trunc('day', recorded_at)::date as day, + sum(duration_seconds) as total_seconds, + count(DISTINCT app_name) as apps_used + FROM activity_logs WHERE user_id = $1 AND recorded_at > NOW() - INTERVAL '7 days' + GROUP BY 1 ORDER BY 1 + `, [user_id]); + return { data: rows }; + }); + + // AI analysis of activity vs planned tasks + app.get("/activity/ai-analysis", async (req) => { + const { user_id } = req.query; + const { rows: activities } = await app.db.query( + `SELECT app_name, category, sum(duration_seconds) as total FROM activity_logs + WHERE user_id=$1 AND recorded_at > NOW() - INTERVAL '7 days' GROUP BY 1,2 ORDER BY total DESC LIMIT 10`, + [user_id] + ); + const { rows: tasks } = await app.db.query( + "SELECT title, status, group_id FROM tasks WHERE user_id=$1 ORDER BY created_at DESC LIMIT 10", + [user_id] + ); + + if (!activities.length) return { data: { message: "No activity data yet" } }; + + const Anthropic = require("@anthropic-ai/sdk"); + const anthropic = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY }); + const response = await anthropic.messages.create({ + model: "claude-sonnet-4-20250514", + max_tokens: 512, + system: "Analyzuj pouzivani telefonu vs planovane ukoly. Odpovez cesky, strucne.", + messages: [{ role: "user", content: `Aktivity: ${JSON.stringify(activities)}\nUkoly: ${JSON.stringify(tasks)}\nAnalyzuj: jsou aktivity v souladu s ukoly?` }] + }); + return { data: { analysis: response.content[0].text, activities, tasks } }; + }); +} + +module.exports = activityRoutes; diff --git a/api/src/routes/admin.js b/api/src/routes/admin.js new file mode 100644 index 0000000..e5eabbe --- /dev/null +++ b/api/src/routes/admin.js @@ -0,0 +1,67 @@ +// Task Team — Admin Dashboard — 2026-03-30 +async function adminRoutes(app) { + // User management + app.get("/admin/users", async (req) => { + const { rows } = await app.db.query( + `SELECT u.id, u.email, u.name, u.phone, u.language, u.auth_provider, u.created_at, + (SELECT count(*) FROM tasks WHERE user_id=u.id) as task_count, + (SELECT count(*) FROM goals WHERE user_id=u.id) as goal_count + FROM users u ORDER BY u.created_at DESC` + ); + return { data: rows }; + }); + + app.delete("/admin/users/:id", async (req) => { + await app.db.query("DELETE FROM users WHERE id = $1", [req.params.id]); + return { status: "deleted" }; + }); + + // System analytics + app.get("/admin/analytics", async (req) => { + const { rows: overview } = await app.db.query(` + SELECT + (SELECT count(*) FROM users) as total_users, + (SELECT count(*) FROM users WHERE created_at > NOW() - INTERVAL '7 days') as new_users_7d, + (SELECT count(*) FROM tasks) as total_tasks, + (SELECT count(*) FROM tasks WHERE status='completed' OR status='done') as completed_tasks, + (SELECT count(*) FROM tasks WHERE created_at > NOW() - INTERVAL '24 hours') as tasks_today, + (SELECT count(*) FROM goals) as total_goals, + (SELECT count(*) FROM invitations WHERE status='accepted') as accepted_invites, + (SELECT count(*) FROM task_comments WHERE is_ai=true) as ai_messages, + (SELECT count(*) FROM error_logs WHERE created_at > NOW() - INTERVAL '24 hours') as errors_24h, + (SELECT count(*) FROM projects) as total_projects + `); + + // Daily activity (last 7 days) + const { rows: daily } = await app.db.query(` + SELECT date_trunc('day', created_at)::date as day, count(*) as tasks_created + FROM tasks WHERE created_at > NOW() - INTERVAL '7 days' + GROUP BY 1 ORDER BY 1 + `); + + return { data: { overview: overview[0], daily } }; + }); + + // Recent activity feed + app.get("/admin/activity", async (req) => { + const { rows } = await app.db.query(` + (SELECT 'task_created' as type, title as detail, created_at FROM tasks ORDER BY created_at DESC LIMIT 5) + UNION ALL + (SELECT 'user_registered', email, created_at FROM users ORDER BY created_at DESC LIMIT 5) + UNION ALL + (SELECT 'goal_created', title, created_at FROM goals ORDER BY created_at DESC LIMIT 5) + UNION ALL + (SELECT 'invite_sent', invitee_email, created_at FROM invitations ORDER BY created_at DESC LIMIT 5) + ORDER BY created_at DESC LIMIT 20 + `); + return { data: rows }; + }); + + // Error log management + app.delete("/admin/errors/clear", async (req) => { + const { rowCount } = await app.db.query("DELETE FROM error_logs WHERE created_at < NOW() - INTERVAL '7 days'"); + return { status: "cleared", deleted: rowCount }; + }); +} + +module.exports = adminRoutes; diff --git a/api/src/routes/families.js b/api/src/routes/families.js new file mode 100644 index 0000000..649f7c7 --- /dev/null +++ b/api/src/routes/families.js @@ -0,0 +1,52 @@ +// Task Team — Family Workspace — 2026-03-30 +async function familyRoutes(app) { + await app.db.query(` + CREATE TABLE IF NOT EXISTS families ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + name VARCHAR(255) NOT NULL, + owner_id UUID REFERENCES users(id), + members UUID[] DEFAULT '{}', + settings JSONB DEFAULT '{}', + created_at TIMESTAMPTZ DEFAULT NOW() + ) + `).catch(() => {}); + + app.get("/families", async (req) => { + const { user_id } = req.query; + const { rows } = await app.db.query( + "SELECT * FROM families WHERE owner_id=$1 OR $1=ANY(members)", [user_id]); + return { data: rows }; + }); + + app.post("/families", async (req) => { + const { name, owner_id } = req.body; + const { rows } = await app.db.query( + "INSERT INTO families (name, owner_id, members) VALUES ($1,$2,ARRAY[$2]::uuid[]) RETURNING *", + [name, owner_id]); + return { data: rows[0] }; + }); + + app.post("/families/:id/members", async (req) => { + const { user_id } = req.body; + const { rows } = await app.db.query( + "UPDATE families SET members=array_append(members,$1::uuid) WHERE id=$2 RETURNING *", + [user_id, req.params.id]); + return { data: rows[0] }; + }); + + app.get("/families/:id/tasks", async (req) => { + const { rows: fam } = await app.db.query("SELECT members FROM families WHERE id=$1", [req.params.id]); + if (!fam.length) throw { statusCode: 404, message: "Family not found" }; + const { rows } = await app.db.query( + "SELECT t.*, u.name as creator_name FROM tasks t LEFT JOIN users u ON t.user_id=u.id WHERE t.user_id=ANY($1) ORDER BY t.created_at DESC", + [fam[0].members]); + return { data: rows }; + }); + + app.delete("/families/:id", async (req) => { + await app.db.query("DELETE FROM families WHERE id=$1", [req.params.id]); + return { status: "deleted" }; + }); +} + +module.exports = familyRoutes; diff --git a/api/src/routes/spaced-repetition.js b/api/src/routes/spaced-repetition.js new file mode 100644 index 0000000..0679729 --- /dev/null +++ b/api/src/routes/spaced-repetition.js @@ -0,0 +1,106 @@ +// Task Team — Spaced Repetition Engine — 2026-03-30 +// SM-2 algorithm for study goals + +function calculateNextReview(quality, repetitions, easeFactor, interval) { + // quality: 0-5 (0=complete fail, 5=perfect) + if (quality < 3) { + return { repetitions: 0, interval: 1, easeFactor }; + } + let newInterval; + if (repetitions === 0) newInterval = 1; + else if (repetitions === 1) newInterval = 6; + else newInterval = Math.round(interval * easeFactor); + + const newEase = Math.max(1.3, easeFactor + (0.1 - (5 - quality) * (0.08 + (5 - quality) * 0.02))); + return { repetitions: repetitions + 1, interval: newInterval, easeFactor: newEase }; +} + +async function spacedRepRoutes(app) { + // Create review_items table + await app.db.query(` + CREATE TABLE IF NOT EXISTS review_items ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID REFERENCES users(id) ON DELETE CASCADE, + goal_id UUID REFERENCES goals(id) ON DELETE CASCADE, + title VARCHAR(500) NOT NULL, + content TEXT DEFAULT '', + repetitions INTEGER DEFAULT 0, + ease_factor NUMERIC(4,2) DEFAULT 2.5, + interval_days INTEGER DEFAULT 1, + next_review TIMESTAMPTZ DEFAULT NOW(), + last_reviewed TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT NOW() + ) + `).catch(() => {}); + + // Get items due for review + app.get("/review/due", async (req) => { + const { user_id, goal_id, limit } = req.query; + let query = "SELECT * FROM review_items WHERE next_review <= NOW()"; + const params = []; + if (user_id) { params.push(user_id); query += ` AND user_id = $${params.length}`; } + if (goal_id) { params.push(goal_id); query += ` AND goal_id = $${params.length}`; } + query += " ORDER BY next_review ASC"; + if (limit) { params.push(limit); query += ` LIMIT $${params.length}`; } + const { rows } = await app.db.query(query, params); + return { data: rows, count: rows.length }; + }); + + // Create review item + app.post("/review/items", async (req) => { + const { user_id, goal_id, title, content } = req.body; + const { rows } = await app.db.query( + "INSERT INTO review_items (user_id, goal_id, title, content) VALUES ($1,$2,$3,$4) RETURNING *", + [user_id, goal_id, title, content || ""] + ); + return { data: rows[0] }; + }); + + // Submit review (rate quality 0-5) + app.post("/review/items/:id/review", async (req) => { + const { quality } = req.body; // 0-5 + if (quality < 0 || quality > 5) throw { statusCode: 400, message: "Quality must be 0-5" }; + + const { rows } = await app.db.query("SELECT * FROM review_items WHERE id = $1", [req.params.id]); + if (!rows.length) throw { statusCode: 404, message: "Item not found" }; + const item = rows[0]; + + const result = calculateNextReview(quality, item.repetitions, parseFloat(item.ease_factor), item.interval_days); + const nextReview = new Date(Date.now() + result.interval * 86400000); + + const { rows: updated } = await app.db.query( + `UPDATE review_items SET repetitions=$1, ease_factor=$2, interval_days=$3, + next_review=$4, last_reviewed=NOW() WHERE id=$5 RETURNING *`, + [result.repetitions, result.easeFactor, result.interval, nextReview, req.params.id] + ); + return { data: updated[0], next_review_in_days: result.interval }; + }); + + // Get single review item + app.get("/review/items/:id", async (req) => { + const { rows } = await app.db.query("SELECT * FROM review_items WHERE id = $1", [req.params.id]); + if (!rows.length) throw { statusCode: 404, message: "Item not found" }; + return { data: rows[0] }; + }); + + // Delete review item + app.delete("/review/items/:id", async (req) => { + const { rowCount } = await app.db.query("DELETE FROM review_items WHERE id = $1", [req.params.id]); + if (!rowCount) throw { statusCode: 404, message: "Item not found" }; + return { status: "deleted" }; + }); + + // Stats + app.get("/review/stats", async (req) => { + const { user_id } = req.query; + const { rows } = await app.db.query(` + SELECT count(*) as total, + count(*) FILTER (WHERE next_review <= NOW()) as due, + count(*) FILTER (WHERE last_reviewed > NOW() - INTERVAL '24 hours') as reviewed_today, + avg(ease_factor)::numeric(4,2) as avg_ease + FROM review_items WHERE user_id = $1`, [user_id || "00000000-0000-0000-0000-000000000000"]); + return { data: rows[0] }; + }); +} + +module.exports = spacedRepRoutes;