diff --git a/services/arbiter-3.0/src/discord/events.js b/services/arbiter-3.0/src/discord/events.js index 169b15e..ab0dd1f 100644 --- a/services/arbiter-3.0/src/discord/events.js +++ b/services/arbiter-3.0/src/discord/events.js @@ -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', () => { diff --git a/services/arbiter-3.0/src/discord/tasks.js b/services/arbiter-3.0/src/discord/tasks.js new file mode 100644 index 0000000..b45ae0a --- /dev/null +++ b/services/arbiter-3.0/src/discord/tasks.js @@ -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 }; diff --git a/services/arbiter-3.0/src/index.js b/services/arbiter-3.0/src/index.js index 1d56546..644f27c 100644 --- a/services/arbiter-3.0/src/index.js +++ b/services/arbiter-3.0/src/index.js @@ -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) { diff --git a/services/arbiter-3.0/src/routes/api.js b/services/arbiter-3.0/src/routes/api.js index da20b66..e397e17 100644 --- a/services/arbiter-3.0/src/routes/api.js +++ b/services/arbiter-3.0/src/routes/api.js @@ -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;