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:
@@ -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" });
|
||||
|
||||
85
api/src/routes/activity.js
Normal file
85
api/src/routes/activity.js
Normal 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
67
api/src/routes/admin.js
Normal 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;
|
||||
52
api/src/routes/families.js
Normal file
52
api/src/routes/families.js
Normal 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;
|
||||
106
api/src/routes/spaced-repetition.js
Normal file
106
api/src/routes/spaced-repetition.js
Normal 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;
|
||||
Reference in New Issue
Block a user