feat: ChatOps Task Management System (Gemini-architected)
Database: - tasks table in PostgreSQL (id, task_number, title, status, priority, owner, tags) - 45 tasks migrated from BACKLOG.md + tasks-index files - Indexes on status, priority, owner, task_number API (for Chroniclers/Catalysts): - GET /api/internal/tasks — list with filters (status, priority, owner) - GET /api/internal/tasks/summary — stats by status and priority - POST /api/internal/tasks — create new task (auto-numbers) - PATCH /api/internal/tasks/:id — update status/priority/owner Discord (for Meg/Holly/Michael): - /tasks command with filter options (open, in_progress, mine, high, active, done, all) - Mark Done buttons — one tap to complete a task - Take Task buttons — claim unassigned tasks - Color-coded priority and status emoji - Staff-only access Architecture: PostgreSQL → Arbiter API → Discord buttons (ChatOps) Gemini consultation: gemini-task-management-redesign-2026-04-11.md Chronicler #78 | firefrost-services
This commit is contained in:
@@ -1,10 +1,19 @@
|
||||
const { handleLinkCommand } = require('./commands');
|
||||
const { handleCreateServerCommand } = require('./createserver');
|
||||
const { handleDelServerCommand } = require('./delserver');
|
||||
const { handleTasksCommand, handleTaskButton } = require('./tasks');
|
||||
const discordRoleSync = require('../services/discordRoleSync');
|
||||
|
||||
function registerEvents(client) {
|
||||
client.on('interactionCreate', async interaction => {
|
||||
// Button interactions
|
||||
if (interaction.isButton()) {
|
||||
if (interaction.customId.startsWith('task_')) {
|
||||
await handleTaskButton(interaction);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!interaction.isChatInputCommand()) return;
|
||||
if (interaction.commandName === 'link') {
|
||||
await handleLinkCommand(interaction);
|
||||
@@ -15,6 +24,9 @@ function registerEvents(client) {
|
||||
if (interaction.commandName === 'delserver') {
|
||||
await handleDelServerCommand(interaction);
|
||||
}
|
||||
if (interaction.commandName === 'tasks') {
|
||||
await handleTasksCommand(interaction);
|
||||
}
|
||||
});
|
||||
|
||||
client.on('ready', () => {
|
||||
|
||||
250
services/arbiter-3.0/src/discord/tasks.js
Normal file
250
services/arbiter-3.0/src/discord/tasks.js
Normal file
@@ -0,0 +1,250 @@
|
||||
/**
|
||||
* /tasks Command
|
||||
* View and manage Firefrost tasks via Discord buttons.
|
||||
*
|
||||
* Usage:
|
||||
* /tasks — Show open tasks
|
||||
* /tasks status:done — Show completed tasks
|
||||
* /tasks mine — Show your tasks
|
||||
*
|
||||
* Buttons: Mark Done, Take Task, Details
|
||||
*
|
||||
* Created: April 11, 2026
|
||||
* Chronicler: #78
|
||||
*/
|
||||
|
||||
const { SlashCommandBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder } = require('discord.js');
|
||||
const db = require('../database');
|
||||
|
||||
const STAFF_ROLES = ['Staff', '🛡️ Moderator', '👑 The Wizard', '💎 The Emissary', '✨ The Catalyst'];
|
||||
|
||||
const PRIORITY_EMOJI = {
|
||||
critical: '🔴',
|
||||
high: '🟠',
|
||||
medium: '🟡',
|
||||
low: '🔵',
|
||||
wish: '🟣'
|
||||
};
|
||||
|
||||
const STATUS_EMOJI = {
|
||||
open: '⬡',
|
||||
in_progress: '🔄',
|
||||
blocked: '⛔',
|
||||
done: '✅',
|
||||
obsolete: '⚫'
|
||||
};
|
||||
|
||||
const tasksCommand = new SlashCommandBuilder()
|
||||
.setName('tasks')
|
||||
.setDescription('View and manage Firefrost tasks')
|
||||
.addStringOption(option =>
|
||||
option.setName('filter')
|
||||
.setDescription('Filter tasks')
|
||||
.addChoices(
|
||||
{ name: 'Open (default)', value: 'open' },
|
||||
{ name: 'In Progress', value: 'in_progress' },
|
||||
{ name: 'My Tasks', value: 'mine' },
|
||||
{ name: 'High Priority', value: 'high' },
|
||||
{ name: 'All Active', value: 'active' },
|
||||
{ name: 'Done', value: 'done' },
|
||||
{ name: 'Everything', value: 'all' }
|
||||
)
|
||||
);
|
||||
|
||||
function isStaff(member) {
|
||||
return member.roles.cache.some(role => STAFF_ROLES.includes(role.name));
|
||||
}
|
||||
|
||||
function ownerFromDiscord(user) {
|
||||
// Map Discord users to task owners
|
||||
// This could be database-driven later
|
||||
const displayName = user.displayName || user.username;
|
||||
if (displayName.includes('Frosty') || displayName.includes('Wizard')) return 'Michael';
|
||||
if (displayName.includes('Ginger') || displayName.includes('Emissary')) return 'Meg';
|
||||
if (displayName.includes('unicorn') || displayName.includes('Catalyst')) return 'Holly';
|
||||
return displayName;
|
||||
}
|
||||
|
||||
async function buildTaskEmbed(tasks, filterLabel) {
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle(`📋 Firefrost Tasks — ${filterLabel}`)
|
||||
.setColor(0x4ECDC4)
|
||||
.setFooter({ text: `${tasks.length} task(s) · Use buttons to update` })
|
||||
.setTimestamp();
|
||||
|
||||
if (tasks.length === 0) {
|
||||
embed.setDescription('No tasks found for this filter.');
|
||||
return { embed, rows: [] };
|
||||
}
|
||||
|
||||
// Build description with task list
|
||||
const lines = tasks.slice(0, 15).map(t => {
|
||||
const pri = PRIORITY_EMOJI[t.priority] || '⚪';
|
||||
const sta = STATUS_EMOJI[t.status] || '⬡';
|
||||
const owner = t.owner !== 'unassigned' ? ` · ${t.owner}` : '';
|
||||
return `${sta} ${pri} **#${t.task_number}** ${t.title}${owner}`;
|
||||
});
|
||||
|
||||
if (tasks.length > 15) {
|
||||
lines.push(`\n*...and ${tasks.length - 15} more*`);
|
||||
}
|
||||
|
||||
embed.setDescription(lines.join('\n'));
|
||||
|
||||
// Build button rows (max 5 buttons per row, max 5 rows)
|
||||
const rows = [];
|
||||
const actionableTasks = tasks.filter(t => t.status !== 'done' && t.status !== 'obsolete').slice(0, 10);
|
||||
|
||||
if (actionableTasks.length > 0) {
|
||||
// "Mark Done" buttons - first 5
|
||||
const doneButtons = actionableTasks.slice(0, 5).map(t =>
|
||||
new ButtonBuilder()
|
||||
.setCustomId(`task_done_${t.id}`)
|
||||
.setLabel(`✓ #${t.task_number}`)
|
||||
.setStyle(ButtonStyle.Success)
|
||||
);
|
||||
if (doneButtons.length > 0) {
|
||||
rows.push(new ActionRowBuilder().addComponents(doneButtons));
|
||||
}
|
||||
|
||||
// "Take" buttons for unassigned - next row
|
||||
const unassigned = actionableTasks.filter(t => t.owner === 'unassigned').slice(0, 5);
|
||||
if (unassigned.length > 0) {
|
||||
const takeButtons = unassigned.map(t =>
|
||||
new ButtonBuilder()
|
||||
.setCustomId(`task_take_${t.id}`)
|
||||
.setLabel(`📌 Take #${t.task_number}`)
|
||||
.setStyle(ButtonStyle.Primary)
|
||||
);
|
||||
rows.push(new ActionRowBuilder().addComponents(takeButtons));
|
||||
}
|
||||
}
|
||||
|
||||
return { embed, rows };
|
||||
}
|
||||
|
||||
async function handleTasksCommand(interaction) {
|
||||
if (!isStaff(interaction.member)) {
|
||||
return interaction.reply({
|
||||
content: '❌ This command is restricted to Staff members.',
|
||||
ephemeral: true
|
||||
});
|
||||
}
|
||||
|
||||
await interaction.deferReply();
|
||||
|
||||
const filter = interaction.options.getString('filter') || 'open';
|
||||
|
||||
try {
|
||||
let query, params = [], filterLabel;
|
||||
|
||||
switch (filter) {
|
||||
case 'open':
|
||||
query = `SELECT * FROM tasks WHERE status = 'open' ORDER BY
|
||||
CASE priority WHEN 'critical' THEN 1 WHEN 'high' THEN 2 WHEN 'medium' THEN 3 WHEN 'low' THEN 4 WHEN 'wish' THEN 5 END, task_number`;
|
||||
filterLabel = 'Open';
|
||||
break;
|
||||
case 'in_progress':
|
||||
query = `SELECT * FROM tasks WHERE status = 'in_progress' ORDER BY task_number`;
|
||||
filterLabel = 'In Progress';
|
||||
break;
|
||||
case 'mine':
|
||||
const owner = ownerFromDiscord(interaction.member);
|
||||
query = `SELECT * FROM tasks WHERE owner = $1 AND status NOT IN ('done', 'obsolete') ORDER BY
|
||||
CASE priority WHEN 'critical' THEN 1 WHEN 'high' THEN 2 WHEN 'medium' THEN 3 WHEN 'low' THEN 4 WHEN 'wish' THEN 5 END`;
|
||||
params = [owner];
|
||||
filterLabel = `${owner}'s Tasks`;
|
||||
break;
|
||||
case 'high':
|
||||
query = `SELECT * FROM tasks WHERE priority IN ('critical', 'high') AND status NOT IN ('done', 'obsolete') ORDER BY
|
||||
CASE priority WHEN 'critical' THEN 1 WHEN 'high' THEN 2 END, task_number`;
|
||||
filterLabel = 'High Priority';
|
||||
break;
|
||||
case 'active':
|
||||
query = `SELECT * FROM tasks WHERE status NOT IN ('done', 'obsolete') ORDER BY
|
||||
CASE priority WHEN 'critical' THEN 1 WHEN 'high' THEN 2 WHEN 'medium' THEN 3 WHEN 'low' THEN 4 WHEN 'wish' THEN 5 END, task_number`;
|
||||
filterLabel = 'All Active';
|
||||
break;
|
||||
case 'done':
|
||||
query = `SELECT * FROM tasks WHERE status = 'done' ORDER BY completed_at DESC LIMIT 20`;
|
||||
filterLabel = 'Recently Completed';
|
||||
break;
|
||||
case 'all':
|
||||
query = `SELECT * FROM tasks ORDER BY
|
||||
CASE status WHEN 'in_progress' THEN 1 WHEN 'open' THEN 2 WHEN 'blocked' THEN 3 WHEN 'done' THEN 4 WHEN 'obsolete' THEN 5 END,
|
||||
CASE priority WHEN 'critical' THEN 1 WHEN 'high' THEN 2 WHEN 'medium' THEN 3 WHEN 'low' THEN 4 WHEN 'wish' THEN 5 END`;
|
||||
filterLabel = 'Everything';
|
||||
break;
|
||||
}
|
||||
|
||||
const result = await db.query(query, params);
|
||||
const { embed, rows } = await buildTaskEmbed(result.rows, filterLabel);
|
||||
|
||||
await interaction.editReply({
|
||||
embeds: [embed],
|
||||
components: rows
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
console.error('/tasks error:', err);
|
||||
await interaction.editReply('❌ Error loading tasks.');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTaskButton(interaction) {
|
||||
const customId = interaction.customId;
|
||||
|
||||
if (customId.startsWith('task_done_')) {
|
||||
const taskId = customId.replace('task_done_', '');
|
||||
const completedBy = ownerFromDiscord(interaction.member);
|
||||
|
||||
try {
|
||||
const result = await db.query(
|
||||
`UPDATE tasks SET status = 'done', completed_at = NOW(), completed_by = $1, updated_at = NOW()
|
||||
WHERE id = $2 RETURNING task_number, title`,
|
||||
[completedBy, taskId]
|
||||
);
|
||||
|
||||
if (result.rows.length > 0) {
|
||||
const t = result.rows[0];
|
||||
await interaction.reply({
|
||||
content: `✅ **#${t.task_number} — ${t.title}** marked done by ${completedBy}!`,
|
||||
ephemeral: false
|
||||
});
|
||||
} else {
|
||||
await interaction.reply({ content: '❌ Task not found.', ephemeral: true });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Task done error:', err);
|
||||
await interaction.reply({ content: '❌ Error updating task.', ephemeral: true });
|
||||
}
|
||||
}
|
||||
|
||||
if (customId.startsWith('task_take_')) {
|
||||
const taskId = customId.replace('task_take_', '');
|
||||
const owner = ownerFromDiscord(interaction.member);
|
||||
|
||||
try {
|
||||
const result = await db.query(
|
||||
`UPDATE tasks SET owner = $1, status = 'in_progress', updated_at = NOW()
|
||||
WHERE id = $2 RETURNING task_number, title`,
|
||||
[owner, taskId]
|
||||
);
|
||||
|
||||
if (result.rows.length > 0) {
|
||||
const t = result.rows[0];
|
||||
await interaction.reply({
|
||||
content: `📌 **#${t.task_number} — ${t.title}** claimed by ${owner}!`,
|
||||
ephemeral: false
|
||||
});
|
||||
} else {
|
||||
await interaction.reply({ content: '❌ Task not found.', ephemeral: true });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Task take error:', err);
|
||||
await interaction.reply({ content: '❌ Error updating task.', ephemeral: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { tasksCommand, handleTasksCommand, handleTaskButton };
|
||||
@@ -19,6 +19,7 @@ const { registerEvents } = require('./discord/events');
|
||||
const { linkCommand } = require('./discord/commands');
|
||||
const { createServerCommand } = require('./discord/createserver');
|
||||
const { delServerCommand } = require('./discord/delserver');
|
||||
const { tasksCommand } = require('./discord/tasks');
|
||||
const { initCron } = require('./sync/cron');
|
||||
const discordRoleSync = require('./services/discordRoleSync');
|
||||
|
||||
@@ -132,7 +133,7 @@ const rest = new REST({ version: '10' }).setToken(process.env.DISCORD_BOT_TOKEN)
|
||||
console.log('Refreshing application (/) commands.');
|
||||
await rest.put(
|
||||
Routes.applicationGuildCommands(process.env.DISCORD_CLIENT_ID, process.env.GUILD_ID),
|
||||
{ body: [linkCommand.toJSON(), createServerCommand.toJSON(), delServerCommand.toJSON()] },
|
||||
{ body: [linkCommand.toJSON(), createServerCommand.toJSON(), delServerCommand.toJSON(), tasksCommand.toJSON()] },
|
||||
);
|
||||
console.log('✅ Successfully reloaded application (/) commands.');
|
||||
} catch (error) {
|
||||
|
||||
@@ -395,4 +395,124 @@ router.post('/mcp/log', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// TASK MANAGEMENT API
|
||||
// =============================================================================
|
||||
|
||||
// GET /api/internal/tasks — List tasks with optional filters
|
||||
router.get('/tasks', async (req, res) => {
|
||||
try {
|
||||
const { status, priority, owner } = req.query;
|
||||
let where = 'WHERE 1=1';
|
||||
const params = [];
|
||||
let p = 0;
|
||||
|
||||
if (status) {
|
||||
p++; where += ` AND status = $${p}`; params.push(status);
|
||||
} else {
|
||||
where += ` AND status NOT IN ('done', 'obsolete')`;
|
||||
}
|
||||
if (priority) { p++; where += ` AND priority = $${p}`; params.push(priority); }
|
||||
if (owner) { p++; where += ` AND owner = $${p}`; params.push(owner); }
|
||||
|
||||
const result = await db.query(
|
||||
`SELECT * FROM tasks ${where} ORDER BY
|
||||
CASE priority
|
||||
WHEN 'critical' THEN 1 WHEN 'high' THEN 2
|
||||
WHEN 'medium' THEN 3 WHEN 'low' THEN 4 WHEN 'wish' THEN 5
|
||||
END, task_number`,
|
||||
params
|
||||
);
|
||||
res.json({ tasks: result.rows, count: result.rows.length });
|
||||
} catch (err) {
|
||||
console.error('❌ [Tasks] List error:', err);
|
||||
res.status(500).json({ error: 'Failed to fetch tasks' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/internal/tasks/summary — Quick stats
|
||||
router.get('/tasks/summary', async (req, res) => {
|
||||
try {
|
||||
const result = await db.query(`
|
||||
SELECT status, COUNT(*) as count FROM tasks GROUP BY status ORDER BY status
|
||||
`);
|
||||
const byPriority = await db.query(`
|
||||
SELECT priority, COUNT(*) as count FROM tasks
|
||||
WHERE status NOT IN ('done', 'obsolete')
|
||||
GROUP BY priority ORDER BY
|
||||
CASE priority
|
||||
WHEN 'critical' THEN 1 WHEN 'high' THEN 2
|
||||
WHEN 'medium' THEN 3 WHEN 'low' THEN 4 WHEN 'wish' THEN 5
|
||||
END
|
||||
`);
|
||||
res.json({ byStatus: result.rows, byPriority: byPriority.rows });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Failed to fetch summary' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/internal/tasks — Create a new task
|
||||
router.post('/tasks', async (req, res) => {
|
||||
try {
|
||||
const { title, description, priority, owner, tags } = req.body;
|
||||
if (!title) return res.status(400).json({ error: 'Title required' });
|
||||
|
||||
// Auto-assign next task number
|
||||
const maxResult = await db.query('SELECT COALESCE(MAX(task_number), 0) + 1 as next FROM tasks');
|
||||
const taskNumber = maxResult.rows[0].next;
|
||||
|
||||
const result = await db.query(
|
||||
`INSERT INTO tasks (task_number, title, description, priority, owner, tags)
|
||||
VALUES ($1, $2, $3, $4, $5, $6) RETURNING *`,
|
||||
[taskNumber, title, description || null, priority || 'medium', owner || 'unassigned', tags || null]
|
||||
);
|
||||
console.log(`📋 [Tasks] Created #${taskNumber}: ${title}`);
|
||||
res.json({ success: true, task: result.rows[0] });
|
||||
} catch (err) {
|
||||
console.error('❌ [Tasks] Create error:', err);
|
||||
res.status(500).json({ error: 'Failed to create task' });
|
||||
}
|
||||
});
|
||||
|
||||
// PATCH /api/internal/tasks/:id — Update a task
|
||||
router.patch('/tasks/:id', async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { status, priority, owner, title, description, completed_by } = req.body;
|
||||
|
||||
const updates = [];
|
||||
const params = [];
|
||||
let p = 0;
|
||||
|
||||
if (status) { p++; updates.push(`status = $${p}`); params.push(status); }
|
||||
if (priority) { p++; updates.push(`priority = $${p}`); params.push(priority); }
|
||||
if (owner) { p++; updates.push(`owner = $${p}`); params.push(owner); }
|
||||
if (title) { p++; updates.push(`title = $${p}`); params.push(title); }
|
||||
if (description !== undefined) { p++; updates.push(`description = $${p}`); params.push(description); }
|
||||
|
||||
// Auto-set completed_at when marking done
|
||||
if (status === 'done') {
|
||||
updates.push(`completed_at = NOW()`);
|
||||
if (completed_by) { p++; updates.push(`completed_by = $${p}`); params.push(completed_by); }
|
||||
}
|
||||
|
||||
updates.push(`updated_at = NOW()`);
|
||||
|
||||
if (updates.length <= 1) return res.status(400).json({ error: 'Nothing to update' });
|
||||
|
||||
p++; params.push(id);
|
||||
const result = await db.query(
|
||||
`UPDATE tasks SET ${updates.join(', ')} WHERE id = $${p} RETURNING *`,
|
||||
params
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) return res.status(404).json({ error: 'Task not found' });
|
||||
console.log(`📋 [Tasks] Updated #${result.rows[0].task_number}: ${updates.join(', ')}`);
|
||||
res.json({ success: true, task: result.rows[0] });
|
||||
} catch (err) {
|
||||
console.error('❌ [Tasks] Update error:', err);
|
||||
res.status(500).json({ error: 'Failed to update task' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
Reference in New Issue
Block a user