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) <noreply@anthropic.com>
This commit is contained in:
2026-03-30 00:54:38 +00:00
parent 524025bfe9
commit 42881b1f5a
5 changed files with 325 additions and 1 deletions

View File

@@ -48,7 +48,17 @@ const start = async () => {
await app.register(rateLimit, { await app.register(rateLimit, {
max: 100, max: 100,
timeWindow: "1 minute", 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 }) errorResponseBuilder: () => ({ error: "Too many requests", statusCode: 429 })
}); });
await app.register(jwt, { secret: process.env.JWT_SECRET || "taskteam-jwt-secret-2026" }); 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/email"), { prefix: "/api/v1" });
await app.register(require("./routes/errors"), { 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/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 { try {
await app.listen({ port: process.env.PORT || 3000, host: "0.0.0.0" }); await app.listen({ port: process.env.PORT || 3000, host: "0.0.0.0" });

View File

@@ -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;

67
api/src/routes/admin.js Normal file
View File

@@ -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;

View File

@@ -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;

View File

@@ -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;