diff --git a/bot/chorus_bot/cogs/manual_share.py b/bot/chorus_bot/cogs/manual_share.py index f5e9c90..d047e19 100644 --- a/bot/chorus_bot/cogs/manual_share.py +++ b/bot/chorus_bot/cogs/manual_share.py @@ -22,7 +22,13 @@ class ManualShareCog(commands.Cog): if modality is None: return + attachment_urls = [attachment.url for attachment in message.attachments] + raw_content = message.content + if attachment_urls: + attachments_block = "Attachments:\n" + "\n".join(attachment_urls) + raw_content = f"{raw_content}\n\n{attachments_block}" if raw_content else attachments_block + display_content = message.content or "(attachment only - see original message)" snippet = display_content if len(display_content) <= 120 else display_content[:117] + "..." @@ -33,6 +39,7 @@ class ManualShareCog(commands.Cog): raw_content=raw_content, captured_at=message.created_at, direction=direction_for_modality(modality), + image_url=attachment_urls[0] if attachment_urls else None, ) inbox = self.bot.get_channel(self.config.inbox_channel_id) diff --git a/bot/chorus_bot/cogs/thread_ingest.py b/bot/chorus_bot/cogs/thread_ingest.py index 6278618..f540e66 100644 --- a/bot/chorus_bot/cogs/thread_ingest.py +++ b/bot/chorus_bot/cogs/thread_ingest.py @@ -25,7 +25,7 @@ class ThreadIngestCog(commands.Cog): return parsed = parse_ping_embed(parent_message.embeds[0]) - await self.backend_client.create_item( + item = await self.backend_client.create_item( modality=parsed["modality"], raw_content=parsed["raw_content"], captured_at=parsed["captured_at"], @@ -33,8 +33,9 @@ class ThreadIngestCog(commands.Cog): sender_id=parsed["sender_id"], direction=parsed["direction"], ) + item_url = f"{self.config.web_base_url}/?item={item['id']}" await thread.send( - "Logged in Chorus - open the app to fill in details and track this one." + f"Logged in Chorus - {item_url} to fill in details and track this one." ) diff --git a/bot/chorus_bot/config.py b/bot/chorus_bot/config.py index cfaaf76..3ad796b 100644 --- a/bot/chorus_bot/config.py +++ b/bot/chorus_bot/config.py @@ -9,6 +9,7 @@ class Config: inbox_channel_id: int triage_role_id: int backend_base_url: str + web_base_url: str google_voice_imap_host: str google_voice_imap_user: str google_voice_imap_password: str @@ -33,6 +34,7 @@ def load_config() -> Config: inbox_channel_id=int(env("CHORUS_INBOX_CHANNEL_ID")), triage_role_id=int(env("CHORUS_TRIAGE_ROLE_ID")), backend_base_url=env("CHORUS_BACKEND_BASE_URL"), + web_base_url=os.environ.get("CHORUS_WEB_BASE_URL", "https://chorus.circuitforge.tech"), google_voice_imap_host=env("CHORUS_GVOICE_IMAP_HOST"), google_voice_imap_user=env("CHORUS_GVOICE_IMAP_USER"), google_voice_imap_password=env("CHORUS_GVOICE_IMAP_PASSWORD"), diff --git a/bot/chorus_bot/ping.py b/bot/chorus_bot/ping.py index 8a1dd4f..a938af7 100644 --- a/bot/chorus_bot/ping.py +++ b/bot/chorus_bot/ping.py @@ -36,7 +36,7 @@ def _safe_field_value(value: str) -> str: def build_ping_embed( *, modality: str, sender_id: str | None, snippet: str, raw_content: str, - captured_at: datetime, direction: str = "inbound", + captured_at: datetime, direction: str = "inbound", image_url: str | None = None, ) -> discord.Embed: label = MODALITY_LABELS.get(modality, modality) embed = discord.Embed( @@ -52,6 +52,11 @@ def build_ping_embed( embed.add_field( name="raw_content", value=_safe_field_value(raw_content), inline=False, ) + # Discord CDN attachment URLs carry a signed expiry (ex/is/hm query params), + # typically valid ~24h. Fine for same-day triage; a stale link just stops + # rendering the preview without breaking anything else in the embed. + if image_url: + embed.set_image(url=image_url) return embed diff --git a/bot/tests/test_manual_share.py b/bot/tests/test_manual_share.py index 3ca7747..da1a435 100644 --- a/bot/tests/test_manual_share.py +++ b/bot/tests/test_manual_share.py @@ -10,12 +10,20 @@ class _FakeConfig: triage_role_id = 222 -def _make_message(*, channel_name, content="hello", author_bot=False, channel_id=999): +def _make_attachment(url): + attachment = MagicMock(spec=discord.Attachment) + attachment.url = url + return attachment + + +def _make_message(*, channel_name, content="hello", author_bot=False, channel_id=999, + attachments=None): message = MagicMock(spec=discord.Message) message.author.bot = author_bot message.channel.id = channel_id message.channel.name = channel_name message.content = content + message.attachments = attachments or [] message.created_at = __import__("datetime").datetime(2026, 7, 13, 7, 0, 0) return message @@ -49,3 +57,42 @@ async def test_existing_modality_channel_still_forwards_with_inbound_direction() sent_embed = inbox.send.call_args.kwargs["embed"] parsed = parse_ping_embed(sent_embed) assert parsed["direction"] == "inbound" + + +@pytest.mark.asyncio +async def test_message_with_attachment_includes_image_and_url_in_raw_content(): + bot = MagicMock() + inbox = AsyncMock() + bot.get_channel.return_value = inbox + cog = ManualShareCog(bot, _FakeConfig()) + + attachment = _make_attachment("https://cdn.discordapp.com/attachments/1/2/screenshot.png") + message = _make_message( + channel_name="leads-found", content="Found on Nextdoor", attachments=[attachment], + ) + await cog.on_message(message) + + sent_embed = inbox.send.call_args.kwargs["embed"] + assert sent_embed.image.url == "https://cdn.discordapp.com/attachments/1/2/screenshot.png" + + parsed = parse_ping_embed(sent_embed) + assert "https://cdn.discordapp.com/attachments/1/2/screenshot.png" in parsed["raw_content"] + assert "Found on Nextdoor" in parsed["raw_content"] + + +@pytest.mark.asyncio +async def test_attachment_only_message_still_includes_image(): + bot = MagicMock() + inbox = AsyncMock() + bot.get_channel.return_value = inbox + cog = ManualShareCog(bot, _FakeConfig()) + + attachment = _make_attachment("https://cdn.discordapp.com/attachments/1/2/screenshot.png") + message = _make_message(channel_name="leads-found", content="", attachments=[attachment]) + await cog.on_message(message) + + sent_embed = inbox.send.call_args.kwargs["embed"] + assert sent_embed.image.url == "https://cdn.discordapp.com/attachments/1/2/screenshot.png" + + parsed = parse_ping_embed(sent_embed) + assert "https://cdn.discordapp.com/attachments/1/2/screenshot.png" in parsed["raw_content"] diff --git a/bot/tests/test_ping.py b/bot/tests/test_ping.py index 20c0289..ca34072 100644 --- a/bot/tests/test_ping.py +++ b/bot/tests/test_ping.py @@ -124,3 +124,21 @@ def test_parse_ping_embed_defaults_direction_when_field_missing(): parsed = ping.parse_ping_embed(embed) assert parsed["direction"] == "inbound" + + +def test_build_ping_embed_sets_image_when_url_given(): + embed = ping.build_ping_embed( + modality="lead_found", sender_id=None, snippet="found post", + raw_content="Found on Nextdoor", + captured_at=datetime(2026, 7, 13, 9, 0, 0, tzinfo=timezone.utc), + image_url="https://cdn.discordapp.com/attachments/1/2/screenshot.png", + ) + assert embed.image.url == "https://cdn.discordapp.com/attachments/1/2/screenshot.png" + + +def test_build_ping_embed_no_image_by_default(): + embed = ping.build_ping_embed( + modality="bh_email", sender_id="jane@example.com", snippet="hi", + raw_content="hi", captured_at=datetime(2026, 7, 13, 9, 0, 0, tzinfo=timezone.utc), + ) + assert embed.image.url is None diff --git a/bot/tests/test_thread_ingest.py b/bot/tests/test_thread_ingest.py index 05b439d..950b032 100644 --- a/bot/tests/test_thread_ingest.py +++ b/bot/tests/test_thread_ingest.py @@ -7,12 +7,26 @@ from chorus_bot.ping import build_ping_embed class _FakeConfig: inbox_channel_id = 111 + web_base_url = "https://chorus.circuitforge.tech" + + +def _make_thread(embed): + parent_message = MagicMock() + parent_message.embeds = [embed] + + thread = MagicMock(spec=discord.Thread) + thread.id = 555 + thread.parent_id = 111 + thread.parent.fetch_message = AsyncMock(return_value=parent_message) + thread.send = AsyncMock() + return thread @pytest.mark.asyncio async def test_thread_create_passes_direction_to_backend_client(): bot = MagicMock() backend_client = AsyncMock() + backend_client.create_item.return_value = {"id": 42} cog = ThreadIngestCog(bot, _FakeConfig(), backend_client) embed = build_ping_embed( @@ -22,16 +36,32 @@ async def test_thread_create_passes_direction_to_backend_client(): tzinfo=__import__("datetime").timezone.utc), direction="outbound", ) - parent_message = MagicMock() - parent_message.embeds = [embed] - - thread = MagicMock(spec=discord.Thread) - thread.id = 555 - thread.parent_id = 111 - thread.parent.fetch_message = AsyncMock(return_value=parent_message) - thread.send = AsyncMock() + thread = _make_thread(embed) await cog.on_thread_create(thread) backend_client.create_item.assert_awaited_once() assert backend_client.create_item.call_args.kwargs["direction"] == "outbound" + + +@pytest.mark.asyncio +async def test_thread_create_reply_includes_item_link(): + bot = MagicMock() + backend_client = AsyncMock() + backend_client.create_item.return_value = {"id": 42} + cog = ThreadIngestCog(bot, _FakeConfig(), backend_client) + + embed = build_ping_embed( + modality="lead_found", sender_id=None, snippet="found post", + raw_content="Someone posting about book donation", + captured_at=__import__("datetime").datetime(2026, 7, 13, 7, 0, 0, + tzinfo=__import__("datetime").timezone.utc), + direction="outbound", + ) + thread = _make_thread(embed) + + await cog.on_thread_create(thread) + + thread.send.assert_awaited_once() + reply_text = thread.send.call_args.args[0] + assert "https://chorus.circuitforge.tech/?item=42" in reply_text diff --git a/docker-compose.yml b/docker-compose.yml index 774c4e7..ada1539 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,10 +7,15 @@ services: - chorus_data:/data expose: - "8000" + networks: + - default + - caddy-internal bot: build: ./bot env_file: .env + environment: + PYTHONUNBUFFERED: "1" depends_on: - backend volumes: @@ -20,7 +25,16 @@ services: build: ./frontend expose: - "80" + networks: + - default + - caddy-internal volumes: chorus_data: chorus_bot_state: + +networks: + default: + caddy-internal: + name: caddy-proxy_caddy-internal + external: true diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 60a4d35..c080780 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,6 +1,7 @@ diff --git a/frontend/src/stages.js b/frontend/src/stages.js new file mode 100644 index 0000000..6017832 --- /dev/null +++ b/frontend/src/stages.js @@ -0,0 +1,19 @@ +export const DONATION_STAGES = ['new', 'replied', 'pickup_scheduled', 'picked_up', 'logged_in_bookmark'] +export const OTHER_STAGES = ['new', 'done'] +export const OUTBOUND_STAGES = [ + 'new', 'reviewed', 'outreach_drafted', 'outreach_sent', + 'replied', 'pickup_scheduled', 'picked_up', 'logged_in_bookmark', +] +export const UNSORTED_STAGES = ['new'] + +export const STAGE_LABELS = { + new: 'New', + replied: 'Replied', + pickup_scheduled: 'Pickup Scheduled', + picked_up: 'Picked Up', + logged_in_bookmark: 'Logged in Bookmark', + done: 'Done', + reviewed: 'Reviewed', + outreach_drafted: 'Outreach Drafted', + outreach_sent: 'Outreach Sent', +} diff --git a/frontend/src/theme.css b/frontend/src/theme.css index 553e88f..1f0fa1f 100644 --- a/frontend/src/theme.css +++ b/frontend/src/theme.css @@ -3,16 +3,16 @@ --color-surface: #f4f4f6; --color-text: #1a1a1e; --color-text-muted: #5b5b63; - --color-border: #d8d8dd; + --color-border: #8c8c91; --color-accent: #4a6fa5; --modality-bh_email: #4a6fa5; --modality-personal_email: #6f9c8f; - --modality-bh_text: #a5764a; + --modality-bh_text: #8c643f; --modality-personal_text: #a58a4a; --modality-voice: #8a6fa5; - --modality-fb_messenger: #4a6fa5; - --modality-nd_messenger: #6f8fa5; + --modality-fb_messenger: #3b5998; + --modality-nd_messenger: #597186; --spacing-sm: 0.5rem; --spacing-md: 1rem; @@ -26,7 +26,7 @@ --color-surface: #202024; --color-text: #e8e8ea; --color-text-muted: #a0a0a8; - --color-border: #33333a; + --color-border: #64646e; } } diff --git a/frontend/tests/App.spec.js b/frontend/tests/App.spec.js new file mode 100644 index 0000000..9d0a6c5 --- /dev/null +++ b/frontend/tests/App.spec.js @@ -0,0 +1,54 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { mount, flushPromises } from '@vue/test-utils' +import App from '../src/App.vue' + +vi.mock('../src/api.js', () => ({ + fetchItems: vi.fn(async () => []), + fetchItem: vi.fn(async (id) => ({ id })), +})) + +beforeEach(() => { + localStorage.clear() +}) + +describe('App view mode', () => { + it('defaults to list view when nothing is stored', async () => { + const wrapper = mount(App) + await flushPromises() + const listButton = wrapper.findAll('.view-toggle button').find((b) => b.text() === 'List') + expect(listButton.classes()).toContain('active') + }) + + it('honors a stored board view mode on mount', async () => { + localStorage.setItem('chorus_view_mode', 'board') + const wrapper = mount(App) + await flushPromises() + const boardButton = wrapper.findAll('.view-toggle button').find((b) => b.text() === 'Board') + expect(boardButton.classes()).toContain('active') + }) + + it('defaults to list view for an invalid stored value', async () => { + localStorage.setItem('chorus_view_mode', 'garbage') + const wrapper = mount(App) + await flushPromises() + const listButton = wrapper.findAll('.view-toggle button').find((b) => b.text() === 'List') + expect(listButton.classes()).toContain('active') + }) + + it('clicking Board writes the choice to localStorage', async () => { + const wrapper = mount(App) + await flushPromises() + const boardButton = wrapper.findAll('.view-toggle button').find((b) => b.text() === 'Board') + await boardButton.trigger('click') + expect(localStorage.getItem('chorus_view_mode')).toBe('board') + }) + + it('sets aria-pressed correctly on the view toggle buttons', async () => { + const wrapper = mount(App) + await flushPromises() + const listButton = wrapper.findAll('.view-toggle button').find((b) => b.text() === 'List') + const boardButton = wrapper.findAll('.view-toggle button').find((b) => b.text() === 'Board') + expect(listButton.attributes('aria-pressed')).toBe('true') + expect(boardButton.attributes('aria-pressed')).toBe('false') + }) +}) diff --git a/frontend/tests/KanbanBoard.spec.js b/frontend/tests/KanbanBoard.spec.js new file mode 100644 index 0000000..cb6bba5 --- /dev/null +++ b/frontend/tests/KanbanBoard.spec.js @@ -0,0 +1,107 @@ +import { describe, it, expect } from 'vitest' +import { mount } from '@vue/test-utils' +import KanbanBoard from '../src/components/KanbanBoard.vue' + +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', + direction: 'inbound', + follow_up_date: '', + ...overrides, + } +} + +describe('KanbanBoard', () => { + it('buckets items into the correct board and shows counts on tabs', () => { + const items = [ + makeItem({ id: 1, type: 'donation', direction: 'inbound', stage: 'new' }), + makeItem({ id: 2, type: 'other', direction: 'inbound', stage: 'new' }), + makeItem({ id: 3, type: null, direction: 'outbound', stage: 'new' }), + makeItem({ id: 4, type: null, direction: 'inbound', stage: 'new' }), + ] + const wrapper = mount(KanbanBoard, { props: { items } }) + const tabs = wrapper.findAll('[role="tab"]').map((t) => t.text()) + expect(tabs).toEqual([ + 'Donations (1)', 'Other (1)', 'Outbound Leads (1)', 'Unsorted (1)', + ]) + }) + + it('defaults to the Donations board and its first stage', () => { + const wrapper = mount(KanbanBoard, { props: { items: [] } }) + const activeTab = wrapper.find('[role="tab"][aria-selected="true"]') + expect(activeTab.text()).toContain('Donations') + }) + + it('shows humanized stage labels, not raw stage strings, on column headers', () => { + const items = [makeItem({ id: 1, type: 'donation', stage: 'pickup_scheduled' })] + const wrapper = mount(KanbanBoard, { props: { items } }) + expect(wrapper.text()).toContain('Pickup Scheduled') + expect(wrapper.text()).not.toContain('pickup_scheduled') + }) + + it('switching board tabs resets the active stage to that board\'s first stage', async () => { + const wrapper = mount(KanbanBoard, { props: { items: [] } }) + const outboundTab = wrapper.findAll('[role="tab"]').find((t) => t.text().includes('Outbound Leads')) + await outboundTab.trigger('click') + const breadcrumb = wrapper.find('.mobile-breadcrumb') + expect(breadcrumb.text()).toContain('New') + expect(breadcrumb.text()).toContain('Outbound Leads') + }) + + it('clicking a stage-strip button switches the active stage', async () => { + const items = [makeItem({ id: 1, type: 'donation', stage: 'replied' })] + const wrapper = mount(KanbanBoard, { props: { items } }) + const repliedButton = wrapper.findAll('.stage-strip-button').find((b) => b.text().includes('Replied')) + await repliedButton.trigger('click') + expect(wrapper.find('.mobile-breadcrumb').text()).toContain('Replied') + }) + + it('clicking a card emits select-item with the item id', async () => { + const items = [makeItem({ id: 42, type: 'donation', stage: 'new' })] + const wrapper = mount(KanbanBoard, { props: { items } }) + await wrapper.find('.card').trigger('click') + expect(wrapper.emitted('select-item')).toEqual([[42]]) + }) + + it('renders an empty-state message for a column with no items', () => { + const wrapper = mount(KanbanBoard, { props: { items: [] } }) + expect(wrapper.text()).toContain('Nothing here') + }) + + it('applies the is-active-mobile class only to the currently active stage column', () => { + const items = [ + makeItem({ id: 1, type: 'donation', stage: 'new' }), + makeItem({ id: 2, type: 'donation', stage: 'replied' }), + ] + const wrapper = mount(KanbanBoard, { props: { items } }) + const columns = wrapper.findAll('.column') + const activeColumns = columns.filter((c) => c.classes().includes('is-active-mobile')) + expect(activeColumns).toHaveLength(1) + expect(activeColumns[0].text()).toContain('New') + }) + + it('ArrowRight on a board tab moves selection and focus to the next tab', async () => { + const wrapper = mount(KanbanBoard, { props: { items: [] }, attachTo: document.body }) + const tabs = wrapper.findAll('[role="tab"]') + tabs[0].element.focus() + await tabs[0].trigger('keydown', { key: 'ArrowRight' }) + const updatedTabs = wrapper.findAll('[role="tab"]') + expect(updatedTabs[1].attributes('aria-selected')).toBe('true') + expect(document.activeElement).toBe(updatedTabs[1].element) + wrapper.unmount() + }) + + it('ArrowLeft wraps from the first tab to the last tab', async () => { + const wrapper = mount(KanbanBoard, { props: { items: [] }, attachTo: document.body }) + const tabs = wrapper.findAll('[role="tab"]') + await tabs[0].trigger('keydown', { key: 'ArrowLeft' }) + const updatedTabs = wrapper.findAll('[role="tab"]') + expect(updatedTabs[3].attributes('aria-selected')).toBe('true') + wrapper.unmount() + }) +}) diff --git a/frontend/tests/stages.spec.js b/frontend/tests/stages.spec.js new file mode 100644 index 0000000..cd14d91 --- /dev/null +++ b/frontend/tests/stages.spec.js @@ -0,0 +1,28 @@ +import { describe, it, expect } from 'vitest' +import { + DONATION_STAGES, OTHER_STAGES, OUTBOUND_STAGES, UNSORTED_STAGES, STAGE_LABELS, +} from '../src/stages.js' + +describe('stages.js', () => { + it('exports the expected stage lists', () => { + expect(DONATION_STAGES).toEqual([ + 'new', 'replied', 'pickup_scheduled', 'picked_up', 'logged_in_bookmark', + ]) + expect(OTHER_STAGES).toEqual(['new', 'done']) + expect(OUTBOUND_STAGES).toEqual([ + 'new', 'reviewed', 'outreach_drafted', 'outreach_sent', + 'replied', 'pickup_scheduled', 'picked_up', 'logged_in_bookmark', + ]) + expect(UNSORTED_STAGES).toEqual(['new']) + }) + + it('has a human-readable label for every stage across all lists', () => { + const allStages = new Set([ + ...DONATION_STAGES, ...OTHER_STAGES, ...OUTBOUND_STAGES, ...UNSORTED_STAGES, + ]) + for (const stage of allStages) { + expect(STAGE_LABELS[stage]).toBeTruthy() + expect(STAGE_LABELS[stage]).not.toBe(stage) + } + }) +}) diff --git a/frontend/tests/theme.spec.js b/frontend/tests/theme.spec.js new file mode 100644 index 0000000..0964c10 --- /dev/null +++ b/frontend/tests/theme.spec.js @@ -0,0 +1,33 @@ +import { describe, it, expect } from 'vitest' +import { readFileSync } from 'fs' +import { fileURLToPath } from 'url' +import { dirname, join } from 'path' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const themeCss = readFileSync(join(__dirname, '../src/theme.css'), 'utf-8') + +describe('theme.css contrast fixes', () => { + it('strengthens --color-border to a WCAG-compliant value in light mode', () => { + expect(themeCss).toContain('--color-border: #8c8c91;') + }) + + it('strengthens --color-border to a WCAG-compliant value in dark mode', () => { + expect(themeCss).toContain('--color-border: #64646e;') + }) + + it('darkens --modality-bh_text for 4.5:1 contrast with white text', () => { + expect(themeCss).toContain('--modality-bh_text: #8c643f;') + }) + + it('darkens --modality-nd_messenger for 4.5:1 contrast with white text', () => { + expect(themeCss).toContain('--modality-nd_messenger: #597186;') + }) + + it('differentiates --modality-fb_messenger from --modality-bh_email', () => { + const bhEmailMatch = themeCss.match(/--modality-bh_email: (#[0-9a-f]{6});/) + const fbMessengerMatch = themeCss.match(/--modality-fb_messenger: (#[0-9a-f]{6});/) + expect(bhEmailMatch).not.toBeNull() + expect(fbMessengerMatch).not.toBeNull() + expect(bhEmailMatch[1]).not.toBe(fbMessengerMatch[1]) + }) +})