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:
Claude (Chronicler #78)
2026-04-11 13:56:45 +00:00
parent 075ab899c5
commit 48f74e8658
4 changed files with 384 additions and 1 deletions

View File

@@ -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', () => {

View 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 };

View File

@@ -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) {

View File

@@ -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;