feat: add media-input, gamification, and templates features

- media-input: universal media upload (text/audio/photo/video) with base64 encoding, file storage, and transcription stub
- gamification: points, streaks, levels, badges, leaderboard with auto-leveling
- templates: predefined task sets with 3 default templates (Weekly Sprint, Study Plan, Moving Checklist)
- All features registered via modular registry.js for easy enable/disable

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-30 01:13:07 +00:00
parent 0c3fc44440
commit f9c4ec631c
5 changed files with 266 additions and 2 deletions

View File

@@ -0,0 +1,73 @@
// Task Team — Gamification — points, streaks, badges
async function gamificationFeature(app) {
await app.db.query(`
CREATE TABLE IF NOT EXISTS user_stats (
user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
points INTEGER DEFAULT 0,
level INTEGER DEFAULT 1,
streak_days INTEGER DEFAULT 0,
longest_streak INTEGER DEFAULT 0,
tasks_completed INTEGER DEFAULT 0,
last_active DATE DEFAULT CURRENT_DATE,
badges JSONB DEFAULT '[]'
);
`).catch(() => {});
// Get user stats
app.get("/gamification/stats/:userId", async (req) => {
let { rows } = await app.db.query("SELECT * FROM user_stats WHERE user_id=$1", [req.params.userId]);
if (!rows.length) {
await app.db.query("INSERT INTO user_stats (user_id) VALUES ($1) ON CONFLICT DO NOTHING", [req.params.userId]);
rows = (await app.db.query("SELECT * FROM user_stats WHERE user_id=$1", [req.params.userId])).rows;
}
const stats = rows[0];
stats.next_level_points = stats.level * 100;
stats.progress_pct = Math.min(100, Math.round((stats.points % (stats.level * 100)) / (stats.level * 100) * 100));
return { data: stats };
});
// Award points (called internally when task completed)
app.post("/gamification/award", async (req) => {
const { user_id, points, reason } = req.body;
const { rows } = await app.db.query(`
INSERT INTO user_stats (user_id, points, tasks_completed, last_active)
VALUES ($1, $2, 1, CURRENT_DATE)
ON CONFLICT (user_id) DO UPDATE SET
points = user_stats.points + $2,
tasks_completed = user_stats.tasks_completed + 1,
streak_days = CASE
WHEN user_stats.last_active = CURRENT_DATE - 1 THEN user_stats.streak_days + 1
WHEN user_stats.last_active = CURRENT_DATE THEN user_stats.streak_days
ELSE 1
END,
longest_streak = GREATEST(user_stats.longest_streak,
CASE WHEN user_stats.last_active = CURRENT_DATE - 1 THEN user_stats.streak_days + 1 ELSE 1 END),
level = 1 + (user_stats.points + $2) / 100,
last_active = CURRENT_DATE
RETURNING *
`, [user_id, points || 10]);
return { data: rows[0] };
});
// Leaderboard
app.get("/gamification/leaderboard", async (req) => {
const { rows } = await app.db.query(
"SELECT us.*, u.name, u.avatar_url FROM user_stats us JOIN users u ON us.user_id=u.id ORDER BY us.points DESC LIMIT 20"
);
return { data: rows };
});
// Badges
app.get("/gamification/badges", async () => {
return { data: [
{ id: "first_task", name: "First Task", icon: "target", description: "Completed first task" },
{ id: "streak_7", name: "7 Day Streak", icon: "fire", description: "7 consecutive days active" },
{ id: "streak_30", name: "30 Day Streak", icon: "diamond", description: "30 consecutive days active" },
{ id: "collaborator", name: "Team Player", icon: "handshake", description: "Collaborated on 5+ tasks" },
{ id: "goal_master", name: "Goal Master", icon: "trophy", description: "Completed 3 goals" },
{ id: "speed_demon", name: "Speed Demon", icon: "lightning", description: "Completed 5 tasks in one day" },
{ id: "polyglot", name: "Polyglot", icon: "globe", description: "Used 3+ languages" }
]};
});
}
module.exports = gamificationFeature;

View File

@@ -0,0 +1,79 @@
// Task Team — Media Input — text, audio transcription, photo, video
const { writeFileSync, mkdirSync, existsSync } = require("fs");
const path = require("path");
const crypto = require("crypto");
const UPLOAD_DIR = "/opt/task-team/uploads";
async function mediaInputFeature(app) {
if (!existsSync(UPLOAD_DIR)) mkdirSync(UPLOAD_DIR, { recursive: true });
// Create media table
await app.db.query(`
CREATE TABLE IF NOT EXISTS media_attachments (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
task_id UUID REFERENCES tasks(id) ON DELETE CASCADE,
user_id UUID,
type VARCHAR(20) NOT NULL,
filename VARCHAR(500),
path TEXT,
size_bytes INTEGER DEFAULT 0,
mime_type VARCHAR(100),
transcription TEXT,
metadata JSONB DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT NOW()
)
`).catch(() => {});
// Upload media (base64 encoded in JSON body for simplicity)
app.post("/media/upload", async (req) => {
const { task_id, user_id, type, data, filename, mime_type } = req.body;
// type: "text", "audio", "photo", "video"
let filePath = null;
let size = 0;
let transcription = null;
if (type === "text") {
transcription = data; // plain text, no file
} else if (data) {
// Save base64 file
const buffer = Buffer.from(data, "base64");
const ext = mime_type?.split("/")[1] || type;
const fname = crypto.randomBytes(8).toString("hex") + "." + ext;
const dir = path.join(UPLOAD_DIR, type);
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
filePath = path.join(dir, fname);
writeFileSync(filePath, buffer);
size = buffer.length;
// Audio transcription via AI
if (type === "audio" && process.env.ANTHROPIC_API_KEY) {
transcription = "[Audio transcription pending — needs Whisper API]";
}
}
const { rows } = await app.db.query(
`INSERT INTO media_attachments (task_id, user_id, type, filename, path, size_bytes, mime_type, transcription)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING *`,
[task_id, user_id, type, filename || "", filePath || "", size, mime_type || "", transcription || ""]
);
return { data: rows[0] };
});
// Get media for task
app.get("/media/task/:taskId", async (req) => {
const { rows } = await app.db.query(
"SELECT id, task_id, type, filename, size_bytes, mime_type, transcription, created_at FROM media_attachments WHERE task_id=$1 ORDER BY created_at DESC",
[req.params.taskId]
);
return { data: rows };
});
// Delete media
app.delete("/media/:id", async (req) => {
await app.db.query("DELETE FROM media_attachments WHERE id=$1", [req.params.id]);
return { status: "deleted" };
});
}
module.exports = mediaInputFeature;

View File

@@ -0,0 +1,24 @@
// Task Team — Feature Registry
// Each feature is a separate file. Add/remove here to enable/disable.
const features = [
{ name: "media-input", path: "./media-input", prefix: "/api/v1" },
{ name: "gamification", path: "./gamification", prefix: "/api/v1" },
{ name: "templates", path: "./templates", prefix: "/api/v1" },
{ name: "time-tracking", path: "./time-tracking", prefix: "/api/v1" },
{ name: "kanban", path: "./kanban", prefix: "/api/v1" },
{ name: "ai-briefing", path: "./ai-briefing", prefix: "/api/v1" },
{ name: "webhooks-outgoing", path: "./webhooks-outgoing", prefix: "/api/v1" },
];
async function registerFeatures(app) {
for (const f of features) {
try {
await app.register(require(f.path), { prefix: f.prefix });
app.log.info(`Feature loaded: ${f.name}`);
} catch (e) {
app.log.warn(`Feature ${f.name} failed to load: ${e.message}`);
}
}
}
module.exports = { registerFeatures };

View File

@@ -0,0 +1,87 @@
// Task Team — Task Templates — predefined task sets
async function templatesFeature(app) {
await app.db.query(`
CREATE TABLE IF NOT EXISTS task_templates (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(255) NOT NULL,
description TEXT DEFAULT '',
category VARCHAR(50),
tasks JSONB NOT NULL DEFAULT '[]',
created_by UUID,
is_public BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT NOW()
)
`).catch(() => {});
// List templates
app.get("/templates", async (req) => {
const { rows } = await app.db.query("SELECT * FROM task_templates WHERE is_public=true ORDER BY name");
return { data: rows };
});
// Create template
app.post("/templates", async (req) => {
const { name, description, category, tasks, created_by } = req.body;
const { rows } = await app.db.query(
"INSERT INTO task_templates (name, description, category, tasks, created_by) VALUES ($1,$2,$3,$4,$5) RETURNING *",
[name, description || "", category || "general", JSON.stringify(tasks || []), created_by]
);
return { data: rows[0] };
});
// Apply template (creates tasks from template)
app.post("/templates/:id/apply", async (req) => {
const { user_id, group_id } = req.body;
const { rows: templates } = await app.db.query("SELECT * FROM task_templates WHERE id=$1", [req.params.id]);
if (!templates.length) throw { statusCode: 404, message: "Template not found" };
const taskList = templates[0].tasks;
let created = 0;
for (const t of taskList) {
await app.db.query(
"INSERT INTO tasks (title, description, user_id, group_id, priority, status) VALUES ($1,$2,$3,$4,$5,$6)",
[t.title, t.description || "", user_id, group_id, t.priority || "medium", "pending"]
);
created++;
}
return { status: "ok", created, template: templates[0].name };
});
// Delete template
app.delete("/templates/:id", async (req) => {
await app.db.query("DELETE FROM task_templates WHERE id=$1", [req.params.id]);
return { status: "deleted" };
});
// Seed default templates
const { rows: existing } = await app.db.query("SELECT count(*) as c FROM task_templates");
if (parseInt(existing[0].c) === 0) {
const defaults = [
{ name: "Weekly Sprint", category: "work", tasks: [
{ title: "Sprint planning", priority: "high" },
{ title: "Daily standup notes", priority: "medium" },
{ title: "Code review", priority: "high" },
{ title: "Sprint retrospective", priority: "medium" }
]},
{ name: "Study Plan", category: "study", tasks: [
{ title: "Read chapter", priority: "medium" },
{ title: "Take notes", priority: "medium" },
{ title: "Practice exercises", priority: "high" },
{ title: "Review flashcards", priority: "low" },
{ title: "Weekly quiz", priority: "high" }
]},
{ name: "Moving Checklist", category: "personal", tasks: [
{ title: "Pack kitchen", priority: "high" },
{ title: "Notify utilities", priority: "high" },
{ title: "Change address", priority: "medium" },
{ title: "Clean old place", priority: "medium" },
{ title: "Unpack essentials", priority: "high" }
]}
];
for (const t of defaults) {
await app.db.query("INSERT INTO task_templates (name, category, tasks) VALUES ($1,$2,$3)",
[t.name, t.category, JSON.stringify(t.tasks)]);
}
}
}
module.exports = templatesFeature;

View File

@@ -70,8 +70,6 @@ const start = async () => {
pid: process.pid,
redis: redis.status
}));
// Swagger/OpenAPI documentation
await app.register(swagger, {
openapi: {
@@ -132,6 +130,9 @@ const start = async () => {
await app.register(require("./routes/activity"), { prefix: "/api/v1" });
await app.register(require("./routes/families"), { prefix: "/api/v1" });
// Register features (modular)
const { registerFeatures } = require("./features/registry");
await registerFeatures(app);
try {
await app.listen({ port: process.env.PORT || 3000, host: "0.0.0.0" });
console.log("Task Team API listening on port " + (process.env.PORT || 3000) + " (pid: " + process.pid + ")");