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:
73
api/src/features/gamification.js
Normal file
73
api/src/features/gamification.js
Normal 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;
|
||||||
79
api/src/features/media-input.js
Normal file
79
api/src/features/media-input.js
Normal 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;
|
||||||
24
api/src/features/registry.js
Normal file
24
api/src/features/registry.js
Normal 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 };
|
||||||
87
api/src/features/templates.js
Normal file
87
api/src/features/templates.js
Normal 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;
|
||||||
@@ -70,8 +70,6 @@ const start = async () => {
|
|||||||
pid: process.pid,
|
pid: process.pid,
|
||||||
redis: redis.status
|
redis: redis.status
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
||||||
// Swagger/OpenAPI documentation
|
// Swagger/OpenAPI documentation
|
||||||
await app.register(swagger, {
|
await app.register(swagger, {
|
||||||
openapi: {
|
openapi: {
|
||||||
@@ -132,6 +130,9 @@ const start = async () => {
|
|||||||
await app.register(require("./routes/activity"), { prefix: "/api/v1" });
|
await app.register(require("./routes/activity"), { prefix: "/api/v1" });
|
||||||
await app.register(require("./routes/families"), { prefix: "/api/v1" });
|
await app.register(require("./routes/families"), { prefix: "/api/v1" });
|
||||||
|
|
||||||
|
// Register features (modular)
|
||||||
|
const { registerFeatures } = require("./features/registry");
|
||||||
|
await registerFeatures(app);
|
||||||
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" });
|
||||||
console.log("Task Team API listening on port " + (process.env.PORT || 3000) + " (pid: " + process.pid + ")");
|
console.log("Task Team API listening on port " + (process.env.PORT || 3000) + " (pid: " + process.pid + ")");
|
||||||
|
|||||||
Reference in New Issue
Block a user