- spec_path column added to tasks table - API POST/PATCH support spec_path field - Discord /tasks detail view shows '📄 Full Spec' link to Gitea when spec exists - Backfilled 7 existing tasks with spec directory paths Architecture: Database tracks state, Git stores documentation, they link to each other. Chronicler #78 | firefrost-services
370 lines
13 KiB
JavaScript
370 lines
13 KiB
JavaScript
/**
|
|
* /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' }
|
|
)
|
|
)
|
|
.addIntegerOption(option =>
|
|
option.setName('number')
|
|
.setDescription('View a specific task by number (e.g. 26)')
|
|
);
|
|
|
|
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 handleTaskDetail(interaction, taskNumber) {
|
|
try {
|
|
const result = await db.query(
|
|
'SELECT * FROM tasks WHERE task_number = $1',
|
|
[taskNumber]
|
|
);
|
|
|
|
if (result.rows.length === 0) {
|
|
return interaction.editReply(`❌ Task #${taskNumber} not found.`);
|
|
}
|
|
|
|
const t = result.rows[0];
|
|
const pri = PRIORITY_EMOJI[t.priority] || '⚪';
|
|
const sta = STATUS_EMOJI[t.status] || '⬡';
|
|
|
|
const embed = new EmbedBuilder()
|
|
.setTitle(`${sta} #${t.task_number} — ${t.title}`)
|
|
.setColor(
|
|
t.status === 'done' ? 0x22c55e :
|
|
t.status === 'blocked' ? 0xef4444 :
|
|
t.priority === 'critical' ? 0xef4444 :
|
|
t.priority === 'high' ? 0xff6b35 :
|
|
0x4ECDC4
|
|
)
|
|
.addFields(
|
|
{ name: 'Status', value: `${sta} ${t.status}`, inline: true },
|
|
{ name: 'Priority', value: `${pri} ${t.priority}`, inline: true },
|
|
{ name: 'Owner', value: t.owner || 'unassigned', inline: true }
|
|
)
|
|
.setTimestamp(new Date(t.updated_at));
|
|
|
|
if (t.description) {
|
|
embed.setDescription(t.description.length > 400 ? t.description.substring(0, 400) + '...' : t.description);
|
|
}
|
|
|
|
if (t.tags && t.tags.length > 0) {
|
|
embed.addFields({ name: 'Tags', value: t.tags.map(tag => `\`${tag}\``).join(' '), inline: false });
|
|
}
|
|
|
|
if (t.spec_path) {
|
|
const specUrl = `https://git.firefrostgaming.com/firefrost-gaming/firefrost-operations-manual/src/branch/master/${t.spec_path}`;
|
|
embed.addFields({ name: '📄 Full Spec', value: `[View in Gitea](${specUrl})`, inline: false });
|
|
}
|
|
|
|
if (t.completed_at) {
|
|
const completedDate = new Date(t.completed_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
|
embed.addFields({ name: 'Completed', value: `${completedDate}${t.completed_by ? ` by ${t.completed_by}` : ''}`, inline: false });
|
|
}
|
|
|
|
embed.setFooter({ text: `Created ${new Date(t.created_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })} · Updated ${new Date(t.updated_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}` });
|
|
|
|
// Action buttons for active tasks
|
|
const rows = [];
|
|
if (t.status !== 'done' && t.status !== 'obsolete') {
|
|
const buttons = [
|
|
new ButtonBuilder()
|
|
.setCustomId(`task_done_${t.id}`)
|
|
.setLabel('Mark Done')
|
|
.setStyle(ButtonStyle.Success),
|
|
new ButtonBuilder()
|
|
.setCustomId(`task_progress_${t.id}`)
|
|
.setLabel('In Progress')
|
|
.setStyle(ButtonStyle.Primary)
|
|
];
|
|
if (t.owner === 'unassigned') {
|
|
buttons.push(
|
|
new ButtonBuilder()
|
|
.setCustomId(`task_take_${t.id}`)
|
|
.setLabel('Take Task')
|
|
.setStyle(ButtonStyle.Secondary)
|
|
);
|
|
}
|
|
rows.push(new ActionRowBuilder().addComponents(buttons));
|
|
}
|
|
|
|
await interaction.editReply({ embeds: [embed], components: rows });
|
|
|
|
} catch (err) {
|
|
console.error('Task detail error:', err);
|
|
await interaction.editReply('❌ Error loading task details.');
|
|
}
|
|
}
|
|
|
|
async function handleTasksCommand(interaction) {
|
|
if (!isStaff(interaction.member)) {
|
|
return interaction.reply({
|
|
content: '❌ This command is restricted to Staff members.',
|
|
ephemeral: true
|
|
});
|
|
}
|
|
|
|
await interaction.deferReply();
|
|
|
|
// Check if viewing a specific task
|
|
const taskNumber = interaction.options.getInteger('number');
|
|
if (taskNumber) {
|
|
return handleTaskDetail(interaction, taskNumber);
|
|
}
|
|
|
|
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 });
|
|
}
|
|
}
|
|
|
|
if (customId.startsWith('task_progress_')) {
|
|
const taskId = customId.replace('task_progress_', '');
|
|
const owner = ownerFromDiscord(interaction.member);
|
|
|
|
try {
|
|
const result = await db.query(
|
|
`UPDATE tasks SET status = 'in_progress', owner = $1, 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}** marked in progress by ${owner}!`,
|
|
ephemeral: false
|
|
});
|
|
} else {
|
|
await interaction.reply({ content: '❌ Task not found.', ephemeral: true });
|
|
}
|
|
} catch (err) {
|
|
console.error('Task progress error:', err);
|
|
await interaction.reply({ content: '❌ Error updating task.', ephemeral: true });
|
|
}
|
|
}
|
|
}
|
|
|
|
module.exports = { tasksCommand, handleTasksCommand, handleTaskButton };
|