From f9c4ec631c05833ccf03f9b7303f15114679415d Mon Sep 17 00:00:00 2001 From: Admin Date: Mon, 30 Mar 2026 01:13:07 +0000 Subject: [PATCH] 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) --- api/src/features/gamification.js | 73 +++++++++++++++++++++++++++ api/src/features/media-input.js | 79 +++++++++++++++++++++++++++++ api/src/features/registry.js | 24 +++++++++ api/src/features/templates.js | 87 ++++++++++++++++++++++++++++++++ api/src/index.js | 5 +- 5 files changed, 266 insertions(+), 2 deletions(-) create mode 100644 api/src/features/gamification.js create mode 100644 api/src/features/media-input.js create mode 100644 api/src/features/registry.js create mode 100644 api/src/features/templates.js diff --git a/api/src/features/gamification.js b/api/src/features/gamification.js new file mode 100644 index 0000000..f3c61db --- /dev/null +++ b/api/src/features/gamification.js @@ -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; diff --git a/api/src/features/media-input.js b/api/src/features/media-input.js new file mode 100644 index 0000000..1ca74c6 --- /dev/null +++ b/api/src/features/media-input.js @@ -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; diff --git a/api/src/features/registry.js b/api/src/features/registry.js new file mode 100644 index 0000000..2a23471 --- /dev/null +++ b/api/src/features/registry.js @@ -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 }; diff --git a/api/src/features/templates.js b/api/src/features/templates.js new file mode 100644 index 0000000..cf76288 --- /dev/null +++ b/api/src/features/templates.js @@ -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; diff --git a/api/src/index.js b/api/src/index.js index aae5dae..4b284c3 100644 --- a/api/src/index.js +++ b/api/src/index.js @@ -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 + ")");