diff --git a/bot/chorus_bot/cogs/thread_ingest.py b/bot/chorus_bot/cogs/thread_ingest.py index ad298ef..cb0a5aa 100644 --- a/bot/chorus_bot/cogs/thread_ingest.py +++ b/bot/chorus_bot/cogs/thread_ingest.py @@ -16,7 +16,11 @@ class ThreadIngestCog(commands.Cog): if thread.parent_id != self.config.inbox_channel_id: return - parent_message = await thread.parent.fetch_message(thread.id) + try: + parent_message = await thread.parent.fetch_message(thread.id) + except discord.NotFound: + # Standalone thread not started from a message -- nothing to ingest. + return if not parent_message.embeds: return diff --git a/bot/chorus_bot/main.py b/bot/chorus_bot/main.py index 34d0a2f..c64733e 100644 --- a/bot/chorus_bot/main.py +++ b/bot/chorus_bot/main.py @@ -9,25 +9,39 @@ from chorus_bot.cogs.thread_ingest import setup as setup_thread_ingest from chorus_bot.cogs.subscribe import setup as setup_subscribe -def build_bot() -> commands.Bot: +class ChorusBot(commands.Bot): + """Bot subclass that registers cogs + syncs the command tree exactly once, + in setup_hook (called before the gateway connects), instead of on_ready + (which fires on every RESUME/reconnect and would re-register cogs, + raising or duplicating listeners/loops each time).""" + + def __init__(self, config, backend_client: BackendClient, **kwargs): + super().__init__(**kwargs) + self.config = config + self.backend_client = backend_client + + async def setup_hook(self): + await setup_manual_share(self, self.config) + await setup_email_ingest(self, self.config) + await setup_thread_ingest(self, self.config, self.backend_client) + await setup_subscribe(self, self.config) + await self.tree.sync(guild=discord.Object(id=self.config.guild_id)) + + +def build_bot(config, backend_client: BackendClient) -> ChorusBot: intents = discord.Intents.default() intents.message_content = True intents.members = True - return commands.Bot(command_prefix="!", intents=intents) + return ChorusBot(config, backend_client, command_prefix="!", intents=intents) async def main(): config = load_config() - bot = build_bot() backend_client = BackendClient(config.backend_base_url) + bot = build_bot(config, backend_client) @bot.event async def on_ready(): - await setup_manual_share(bot, config) - await setup_email_ingest(bot, config) - await setup_thread_ingest(bot, config, backend_client) - await setup_subscribe(bot, config) - await bot.tree.sync(guild=discord.Object(id=config.guild_id)) print(f"Chorus bot ready as {bot.user}") await bot.start(config.discord_token) diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 9e0a4cf..60a4d35 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -6,13 +6,25 @@ import { fetchItems, fetchItem } from './api.js' const items = ref([]) const selectedItem = ref(null) +const includeDone = ref(false) +const errorMessage = ref('') async function loadItems() { - items.value = await fetchItems() + try { + errorMessage.value = '' + items.value = await fetchItems({ includeDone: includeDone.value }) + } catch (err) { + errorMessage.value = "Couldn't load items right now - try refreshing" + } } async function openItem(id) { - selectedItem.value = await fetchItem(id) + try { + errorMessage.value = '' + selectedItem.value = await fetchItem(id) + } catch (err) { + errorMessage.value = "Couldn't load that item right now - try refreshing" + } } function closeModal() { @@ -30,7 +42,36 @@ onMounted(loadItems) + + diff --git a/frontend/src/components/TriageList.vue b/frontend/src/components/TriageList.vue index 6343064..0f90d24 100644 --- a/frontend/src/components/TriageList.vue +++ b/frontend/src/components/TriageList.vue @@ -14,6 +14,7 @@ const emit = defineEmits(['select-item']) Date Type Stage + Follow-up date @@ -24,10 +25,11 @@ const emit = defineEmits(['select-item']) @click="emit('select-item', item.id)" > - {{ item.sender_id || '—' }} + {{ item.sender_id || 'not set' }} {{ new Date(item.captured_at).toLocaleDateString() }} {{ item.type || 'unset' }} {{ item.stage }} + {{ item.follow_up_date || '-' }} diff --git a/frontend/tests/ItemModal.spec.js b/frontend/tests/ItemModal.spec.js new file mode 100644 index 0000000..855f68b --- /dev/null +++ b/frontend/tests/ItemModal.spec.js @@ -0,0 +1,56 @@ +import { describe, it, expect, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import ItemModal from '../src/components/ItemModal.vue' + +vi.mock('../src/api.js', () => ({ + updateItem: vi.fn(async (id, fields) => ({ id, ...fields })), +})) + +function makeItem(overrides = {}) { + return { + id: 1, + modality: 'bh_email', + sender_id: 'jane@example.com', + raw_content: 'I have some books to donate.', + type: null, + stage: 'new', + notes: '', + follow_up_date: '', + ...overrides, + } +} + +describe('ItemModal', () => { + it('offers the donation stages when item.type is donation', () => { + const wrapper = mount(ItemModal, { props: { item: makeItem({ type: 'donation', stage: 'new' }) } }) + const options = wrapper.findAll('select')[1].findAll('option').map((o) => o.element.value) + expect(options).toEqual([ + 'new', 'replied', 'pickup_scheduled', 'picked_up', 'logged_in_bookmark', + ]) + }) + + it('offers only new/done stages when item.type is other', () => { + const wrapper = mount(ItemModal, { props: { item: makeItem({ type: 'other', stage: 'new' }) } }) + const options = wrapper.findAll('select')[1].findAll('option').map((o) => o.element.value) + expect(options).toEqual(['new', 'done']) + }) + + it('offers only the new stage when item.type is unset/null', () => { + const wrapper = mount(ItemModal, { props: { item: makeItem({ type: null, stage: 'new' }) } }) + const options = wrapper.findAll('select')[1].findAll('option').map((o) => o.element.value) + expect(options).toEqual(['new']) + }) + + it('resets stage to new when type changes away from donation while stage is pickup_scheduled', async () => { + const wrapper = mount(ItemModal, { + props: { item: makeItem({ type: 'donation', stage: 'pickup_scheduled' }) }, + }) + const stageSelect = wrapper.findAll('select')[1] + expect(stageSelect.element.value).toBe('pickup_scheduled') + + const typeSelect = wrapper.findAll('select')[0] + await typeSelect.setValue('other') + + expect(wrapper.findAll('select')[1].element.value).toBe('new') + }) +})