diff --git a/.claude/rules/push-pipeline.md b/.claude/rules/push-pipeline.md index 0a3d8e4..83484d0 100644 --- a/.claude/rules/push-pipeline.md +++ b/.claude/rules/push-pipeline.md @@ -38,8 +38,8 @@ The push pipeline lives in `packages/shared/src/push/`. It is invoked by `apps/w Push pipeline (`packages/shared/src/push/`): - `push.logic.ts` — `buildPushJobs()` (pure, paginated), `dispatchJob()` (circuit-breaker) -- `push.repository.ts` — `getAllCandidates`, `getVerifiedChannelsBulk`, `upsertHistoryBatch`, `stampLastPushDate`, `incrementChannelFailures`, `resetChannelFailuresForUsers`, `recordPushRun` -- `channels/` — `interface.ts`, `telegram.ts`, `line.ts`, `email.ts`, `email-template.tsx`, `registry.ts` (`createChannelRegistry` factory) +- `push.repository.ts` — `getAllCandidates`, `getVerifiedChannelsBulk`, `upsertHistoryBatch`, `stampLastPushDate`, `incrementChannelFailures`, `resetChannelFailures`, `recordPushRun` +- `channels/` — `email-template.tsx`, `registry.ts` (defines `NotificationChannel` as a function type and `createChannelRegistry` that builds closures over `sendTelegramMessage` / `sendLineMessage` / `sendEmailMessage`) Web cron endpoint (`apps/web/`): - `app/api/cron/push/route.ts` — POST handler; auth; overlap guard; invokes shared pipeline diff --git a/.claude/rules/shared-patterns.md b/.claude/rules/shared-patterns.md index 99137e8..88da9ad 100644 --- a/.claude/rules/shared-patterns.md +++ b/.claude/rules/shared-patterns.md @@ -7,8 +7,8 @@ paths: ## Structure -- `src/channels/` — `sendTelegramMessage`, `sendLineMessage`, `sendEmailMessage` return `SendResult` with `shouldRetry`. Push channel classes delegate here; admin `forceNotifyAll` calls directly. -- `src/push/` — Full push pipeline: `buildPushJobs()`, `dispatchJob()`, `recordPushRun()`, repository functions, channel classes (`TelegramChannel`, `LineChannel`, `EmailChannel`), `createChannelRegistry()` factory. Invoked from `apps/web/app/api/cron/push/route.ts` (the hourly cron target). +- `src/channels/` — `sendTelegramMessage`, `sendLineMessage`, `sendEmailMessage` return `SendResult` with `shouldRetry`. Shared by the push pipeline (via `createChannelRegistry`) and admin `forceNotifyAll`. +- `src/push/` — Full push pipeline: `buildPushJobs()`, `dispatchJob()`, `recordPushRun()`, repository functions. `createChannelRegistry()` returns a `Record` where each `NotificationChannel` is a function `(identifier, msg) => Promise` that wraps one of the raw channel senders. Invoked from `apps/web/app/api/cron/push/route.ts` (the hourly cron target). - `src/services/problem-selector.ts` — `selectProblemForUser()` single source of truth for cron push and admin force-notify. - `src/services/badge-checker.ts` — `evaluateBadgeCondition()` evaluates badge requirement JSONB against user context. - `src/utils/notification-formatters.ts` — `formatTelegramMessage`, `buildFlexBubble`, `formatEmailSubject`, `buildTelegramReplyMarkup`. diff --git a/apps/web/__tests__/api/cron/push/route.test.ts b/apps/web/__tests__/api/cron/push/route.test.ts index 47bcc3b..bf447ec 100644 --- a/apps/web/__tests__/api/cron/push/route.test.ts +++ b/apps/web/__tests__/api/cron/push/route.test.ts @@ -39,9 +39,10 @@ vi.mock('@caffecode/shared', async (importOriginal) => { ...actual, buildPushJobs: (...args: unknown[]) => mockBuildPushJobs(...args), recordPushRun: (...args: unknown[]) => mockRecordPushRun(...args), - TelegramChannel: vi.fn(), - LineChannel: vi.fn(), - EmailChannel: vi.fn(), + createChannelRegistry: vi.fn().mockReturnValue({ + telegram: vi.fn(), + line: vi.fn(), + }), } }) diff --git a/packages/shared/src/push/__tests__/email.test.ts b/packages/shared/src/push/__tests__/email.test.ts deleted file mode 100644 index 01f7732..0000000 --- a/packages/shared/src/push/__tests__/email.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' -import type { PushMessage } from '../../types/push.js' - -// Mock @react-email/render to avoid rendering React Email components in tests -vi.mock('@react-email/render', () => ({ - render: vi.fn().mockResolvedValue('mocked email'), -})) - -// Mock the shared sendEmailMessage to avoid real HTTP calls -const sendEmailMock = vi.fn() -vi.mock('../../channels/email.js', async () => { - const actual = await vi.importActual('../../channels/email.js') - return { - ...actual, - sendEmailMessage: (...args: unknown[]) => sendEmailMock(...args), - } -}) - -import { EmailChannel } from '../channels/email.js' - -const msg: PushMessage = { - title: 'Two Sum', - difficulty: 'Easy', - leetcodeId: 1, - explanation: 'Use a hash map to find complement.', - url: 'https://caffecode.net/problems/two-sum', - problemSlug: 'two-sum', - problemId: 1, -} - -describe('EmailChannel', () => { - beforeEach(() => { - sendEmailMock.mockReset() - sendEmailMock.mockResolvedValue({ success: true }) - }) - - it('can be instantiated with apiKey and from', () => { - const channel = new EmailChannel('fake-api-key', 'CaffeCode ') - expect(channel).toBeDefined() - }) - - it('send calls sendEmailMessage with correct arguments', async () => { - const channel = new EmailChannel('fake-api-key', 'CaffeCode ') - - const result = await channel.send('user@example.com', msg) - - expect(sendEmailMock).toHaveBeenCalledOnce() - - const [apiKey, from, to, passedMsg, opts] = sendEmailMock.mock.calls[0] - expect(apiKey).toBe('fake-api-key') - expect(from).toBe('CaffeCode ') - expect(to).toBe('user@example.com') - expect(passedMsg).toEqual(msg) - expect(opts).toHaveProperty('html') - expect(typeof opts.html).toBe('string') - expect(opts.html).toBe('mocked email') - - expect(result).toEqual({ success: true }) - }) - - it('send propagates failure result from sendEmailMessage', async () => { - sendEmailMock.mockResolvedValue({ success: false, error: '422 Unprocessable', shouldRetry: false }) - - const channel = new EmailChannel('fake-api-key', 'CaffeCode ') - const result = await channel.send('user@example.com', msg) - - expect(result.success).toBe(false) - if (!result.success) { - expect(result.shouldRetry).toBe(false) - } - }) -}) diff --git a/packages/shared/src/push/__tests__/line-channel.test.ts b/packages/shared/src/push/__tests__/line-channel.test.ts deleted file mode 100644 index 9297920..0000000 --- a/packages/shared/src/push/__tests__/line-channel.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -// packages/shared/src/push/__tests__/line-channel.test.ts -import { describe, it, expect, vi } from 'vitest' -import type { PushMessage, SendResult } from '../../types/push.js' - -// Mock the shared sendLineMessage -const mockSendLineMessage = vi.fn() -vi.mock('../../channels/line.js', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - sendLineMessage: (...args: unknown[]) => mockSendLineMessage(...args), - } -}) - -import { LineChannel } from '../channels/line.js' - -const sampleMsg: PushMessage = { - title: 'Two Sum', - difficulty: 'Easy', - leetcodeId: 1, - explanation: 'Use a hash map.', - url: 'https://caffecode.net/problems/two-sum', - problemSlug: 'two-sum', - problemId: 42, -} - -describe('LineChannel', () => { - it('delegates to shared sendLineMessage with correct args', async () => { - const expectedResult: SendResult = { success: true } - mockSendLineMessage.mockResolvedValue(expectedResult) - - const channel = new LineChannel('test-line-token') - const result = await channel.send('line-user-123', sampleMsg) - - expect(mockSendLineMessage).toHaveBeenCalledWith( - 'test-line-token', - 'line-user-123', - sampleMsg, - ) - expect(result).toEqual(expectedResult) - }) - - it('propagates failure SendResult from shared sender', async () => { - const failResult: SendResult = { - success: false, - shouldRetry: false, - error: 'HTTP 403: bot blocked', - } - mockSendLineMessage.mockResolvedValue(failResult) - - const channel = new LineChannel('test-line-token') - const result = await channel.send('line-user-456', sampleMsg) - - expect(result.success).toBe(false) - if (!result.success) { - expect(result.shouldRetry).toBe(false) - expect(result.error).toBe('HTTP 403: bot blocked') - } - }) -}) diff --git a/packages/shared/src/push/__tests__/line.test.ts b/packages/shared/src/push/__tests__/line.test.ts index f831988..ceb65e8 100644 --- a/packages/shared/src/push/__tests__/line.test.ts +++ b/packages/shared/src/push/__tests__/line.test.ts @@ -1,6 +1,5 @@ import { describe, it, expect } from 'vitest' import { buildFlexBubble } from '../../utils/notification-formatters.js' -import { LineChannel } from '../channels/line.js' import type { PushMessage } from '../../types/push.js' type FlexBubble = { diff --git a/packages/shared/src/push/__tests__/push.pipeline.paused.test.ts b/packages/shared/src/push/__tests__/push.pipeline.paused.test.ts index d930c6e..b80f272 100644 --- a/packages/shared/src/push/__tests__/push.pipeline.paused.test.ts +++ b/packages/shared/src/push/__tests__/push.pipeline.paused.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import type { SupabaseClient } from '@supabase/supabase-js' import type { LimitFunction } from 'p-limit' -import type { NotificationChannel } from '../channels/interface.js' +import type { NotificationChannel } from '../channels/registry.js' import type { SendResult } from '../../types/push.js' // Mock selectProblemForUser to return a problem for each candidate @@ -99,16 +99,14 @@ describe('buildPushJobs — channel pausing', () => { // First call: permanent failure. Second call: should be skipped (paused) let callCount = 0 - const channel: NotificationChannel = { - send: vi.fn().mockImplementation(async () => { - callCount++ - if (callCount === 1) { - return { success: false, shouldRetry: false, error: '403 Forbidden' } as SendResult - } - // This should not be reached if pausing works - return { success: true } as SendResult - }), - } + const channel: NotificationChannel = vi.fn().mockImplementation(async () => { + callCount++ + if (callCount === 1) { + return { success: false, shouldRetry: false, error: '403 Forbidden' } as SendResult + } + // This should not be reached if pausing works + return { success: true } as SendResult + }) // Both users share the same channel ID by making the mock return same channel_id const fromMock = vi.fn().mockImplementation((table: string) => { @@ -161,20 +159,18 @@ describe('buildPushJobs — channel pausing', () => { const users = [makeCandidate('user-1')] const db = makeSupabaseMock(users) - const channel: NotificationChannel = { - send: vi.fn().mockResolvedValue({ - success: false, - shouldRetry: true, - error: '500 Server Error', - } as SendResult), - } + const channel: NotificationChannel = vi.fn().mockResolvedValue({ + success: false, + shouldRetry: true, + error: '500 Server Error', + } as SendResult) const stats = await buildPushJobs(db, { telegram: channel }, noopLimit) // Job fails but channel should NOT be paused (shouldRetry=true) expect(stats.failed).toBe(1) - // channel.send should have been called (not skipped) - expect(channel.send).toHaveBeenCalled() + // channel should have been called (not skipped) + expect(channel).toHaveBeenCalled() }) it('pausing one channel does not affect other channels for the same user', async () => { @@ -214,19 +210,15 @@ describe('buildPushJobs — channel pausing', () => { const db = { from: fromMock, rpc: rpcMock } as unknown as SupabaseClient - const telegramChannel: NotificationChannel = { - send: vi.fn().mockResolvedValue({ - success: false, - shouldRetry: false, - error: '403 Forbidden', - } as SendResult), - } - const lineChannel: NotificationChannel = { - send: vi.fn().mockResolvedValue({ - success: true, - shouldRetry: false, - } as SendResult), - } + const telegramChannel: NotificationChannel = vi.fn().mockResolvedValue({ + success: false, + shouldRetry: false, + error: '403 Forbidden', + } as SendResult) + const lineChannel: NotificationChannel = vi.fn().mockResolvedValue({ + success: true, + shouldRetry: false, + } as SendResult) const stats = await buildPushJobs( db, @@ -235,7 +227,7 @@ describe('buildPushJobs — channel pausing', () => { ) // Telegram failed permanently, but LINE should still succeed - expect(lineChannel.send).toHaveBeenCalled() + expect(lineChannel).toHaveBeenCalled() expect(stats.succeeded).toBeGreaterThanOrEqual(1) }) }) diff --git a/packages/shared/src/push/__tests__/push.pipeline.test.ts b/packages/shared/src/push/__tests__/push.pipeline.test.ts index 52e6353..7b7f713 100644 --- a/packages/shared/src/push/__tests__/push.pipeline.test.ts +++ b/packages/shared/src/push/__tests__/push.pipeline.test.ts @@ -20,7 +20,7 @@ import { import { selectProblemForUser } from '../../services/problem-selector.js' import type { SupabaseClient } from '@supabase/supabase-js' import type { SelectedProblem } from '../../types/push.js' -import type { NotificationChannel } from '../channels/interface.js' +import type { NotificationChannel } from '../channels/registry.js' const mockGetAllCandidates = vi.mocked(getAllCandidates) const mockGetChannels = vi.mocked(getVerifiedChannelsBulk) @@ -72,9 +72,7 @@ function makeChannel(overrides: Partial = {}): VerifiedChannel // Channel registry stub: dispatches succeed without real HTTP calls function makeChannelRegistry(channelTypes = ['telegram', 'email', 'line']): Record { - const channel: NotificationChannel = { - send: vi.fn().mockResolvedValue({ success: true }), - } + const channel: NotificationChannel = vi.fn().mockResolvedValue({ success: true }) return Object.fromEntries(channelTypes.map(t => [t, channel])) } @@ -227,12 +225,10 @@ describe('buildPushJobs — pipeline orchestration', () => { ]) const registry: Record = { - telegram: { - send: vi.fn().mockImplementation(async (identifier: string) => { - if (identifier === 'tg-u1') return { success: true } - return { success: false, shouldRetry: false, error: 'permanent failure' } - }), - }, + telegram: vi.fn().mockImplementation(async (identifier: string) => { + if (identifier === 'tg-u1') return { success: true } + return { success: false, shouldRetry: false, error: 'permanent failure' } + }), } const stats = await buildPushJobs(db, registry, noopLimit) @@ -251,9 +247,7 @@ describe('buildPushJobs — pipeline orchestration', () => { mockGetChannels.mockResolvedValueOnce([makeChannel({ id: 'ch-1', user_id: 'u1' })]) const registry: Record = { - telegram: { - send: vi.fn().mockResolvedValue({ success: false, shouldRetry: false, error: 'blocked' }), - }, + telegram: vi.fn().mockResolvedValue({ success: false, shouldRetry: false, error: 'blocked' }), } const stats = await buildPushJobs(db, registry, noopLimit) diff --git a/packages/shared/src/push/__tests__/push.worker.test.ts b/packages/shared/src/push/__tests__/push.worker.test.ts index 6b02e3f..8e9eae7 100644 --- a/packages/shared/src/push/__tests__/push.worker.test.ts +++ b/packages/shared/src/push/__tests__/push.worker.test.ts @@ -4,7 +4,7 @@ import type { LimitFunction } from 'p-limit' import { buildPushJobs, dispatchJob, type PushJobData } from '../push.logic.js' import { recordPushRun } from '../push.repository.js' import type { SupabaseClient } from '@supabase/supabase-js' -import type { NotificationChannel } from '../channels/interface.js' +import type { NotificationChannel } from '../channels/registry.js' const noopLimit = vi.fn((fn: () => unknown) => fn()) as unknown as LimitFunction @@ -99,10 +99,9 @@ describe('dispatchJob', () => { it('increments failure counter via RPC on permanent send failure', async () => { const { mock, rpcMock } = makeSupabaseMock() - const channel: NotificationChannel = { - - send: vi.fn().mockResolvedValue({ success: false, shouldRetry: false, error: '403 Forbidden' }), - } + const channel: NotificationChannel = vi.fn().mockResolvedValue({ + success: false, shouldRetry: false, error: '403 Forbidden', + }) await dispatchJob(makeJob(), channel, mock) @@ -111,10 +110,7 @@ describe('dispatchJob', () => { it('does not reset failure counter per-channel on successful send (bulk reset handles this)', async () => { const { mock, fromMock, rpcMock } = makeSupabaseMock() - const channel: NotificationChannel = { - - send: vi.fn().mockResolvedValue({ success: true }), - } + const channel: NotificationChannel = vi.fn().mockResolvedValue({ success: true }) await dispatchJob(makeJob(), channel, mock) @@ -127,10 +123,9 @@ describe('dispatchJob', () => { const fromMock = vi.fn() const rpcMock = vi.fn() const mock = { from: fromMock, rpc: rpcMock } as unknown as SupabaseClient - const channel: NotificationChannel = { - - send: vi.fn().mockResolvedValue({ success: false, shouldRetry: true, error: '500 Server Error' }), - } + const channel: NotificationChannel = vi.fn().mockResolvedValue({ + success: false, shouldRetry: true, error: '500 Server Error', + }) await dispatchJob(makeJob(), channel, mock) @@ -142,9 +137,9 @@ describe('dispatchJob', () => { it('retryable failure does NOT call incrementChannelFailures', async () => { const { mock, rpcMock } = makeSupabaseMock() - const channel: NotificationChannel = { - send: vi.fn().mockResolvedValue({ success: false, shouldRetry: true, error: '429 Too Many Requests' }), - } + const channel: NotificationChannel = vi.fn().mockResolvedValue({ + success: false, shouldRetry: true, error: '429 Too Many Requests', + }) await dispatchJob(makeJob(), channel, mock) @@ -159,9 +154,7 @@ describe('dispatchJob', () => { const job = makeJob() // Simulate successful send — per-channel reset removed; bulk resetChannelFailures in buildPushJobs handles it - const channel: NotificationChannel = { - send: vi.fn().mockResolvedValue({ success: true }), - } + const channel: NotificationChannel = vi.fn().mockResolvedValue({ success: true }) await dispatchJob(job, channel, mock) @@ -171,9 +164,9 @@ describe('dispatchJob', () => { it('three sequential permanent failures each call incrementChannelFailures once', async () => { const { mock, rpcMock } = makeSupabaseMock() - const channel: NotificationChannel = { - send: vi.fn().mockResolvedValue({ success: false, shouldRetry: false, error: '403 Forbidden' }), - } + const channel: NotificationChannel = vi.fn().mockResolvedValue({ + success: false, shouldRetry: false, error: '403 Forbidden', + }) await dispatchJob(makeJob(), channel, mock) await dispatchJob(makeJob(), channel, mock) @@ -190,11 +183,9 @@ describe('dispatchJob', () => { it('retryable failure then permanent failure increments counter exactly once', async () => { const { mock, rpcMock } = makeSupabaseMock() - const channel: NotificationChannel = { - send: vi.fn() - .mockResolvedValueOnce({ success: false, shouldRetry: true, error: '503 Service Unavailable' }) - .mockResolvedValueOnce({ success: false, shouldRetry: false, error: '403 Forbidden' }), - } + const channel: NotificationChannel = vi.fn() + .mockResolvedValueOnce({ success: false, shouldRetry: true, error: '503 Service Unavailable' }) + .mockResolvedValueOnce({ success: false, shouldRetry: false, error: '403 Forbidden' }) await dispatchJob(makeJob(), channel, mock) // retryable — no increment await dispatchJob(makeJob(), channel, mock) // permanent — increment once diff --git a/packages/shared/src/push/__tests__/registry.test.ts b/packages/shared/src/push/__tests__/registry.test.ts index 1f31701..6734baf 100644 --- a/packages/shared/src/push/__tests__/registry.test.ts +++ b/packages/shared/src/push/__tests__/registry.test.ts @@ -1,17 +1,51 @@ -import { describe, it, expect } from 'vitest' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import type { PushMessage } from '../../types/push.js' + +vi.mock('@react-email/render', () => ({ + render: vi.fn().mockResolvedValue('mocked email'), +})) + +const sendTelegramMock = vi.fn().mockResolvedValue({ success: true }) +const sendLineMock = vi.fn().mockResolvedValue({ success: true }) +const sendEmailMock = vi.fn().mockResolvedValue({ success: true }) + +vi.mock('../../channels/telegram.js', () => ({ + sendTelegramMessage: (...args: unknown[]) => sendTelegramMock(...args), +})) +vi.mock('../../channels/line.js', () => ({ + sendLineMessage: (...args: unknown[]) => sendLineMock(...args), +})) +vi.mock('../../channels/email.js', () => ({ + sendEmailMessage: (...args: unknown[]) => sendEmailMock(...args), +})) + import { createChannelRegistry } from '../channels/registry.js' +const msg: PushMessage = { + title: 'Two Sum', + difficulty: 'Easy', + leetcodeId: 1, + explanation: 'Use a hash map.', + url: 'https://caffecode.net/problems/two-sum', + problemSlug: 'two-sum', + problemId: 42, +} + describe('createChannelRegistry', () => { + beforeEach(() => { + sendTelegramMock.mockClear() + sendLineMock.mockClear() + sendEmailMock.mockClear() + }) + it('includes telegram + line channels when tokens are provided', () => { const registry = createChannelRegistry({ telegramBotToken: 'test-telegram-token', lineChannelAccessToken: 'test-line-token', }) - expect(registry).toHaveProperty('telegram') - expect(registry).toHaveProperty('line') - expect(registry.telegram).toBeDefined() - expect(registry.line).toBeDefined() + expect(typeof registry.telegram).toBe('function') + expect(typeof registry.line).toBe('function') }) it('includes email channel when resendApiKey is provided', () => { @@ -22,8 +56,7 @@ describe('createChannelRegistry', () => { resendFromEmail: 'CaffeCode ', }) - expect(registry).toHaveProperty('email') - expect(registry.email).toBeDefined() + expect(typeof registry.email).toBe('function') }) it('excludes email channel when resendApiKey is absent', () => { @@ -32,6 +65,61 @@ describe('createChannelRegistry', () => { lineChannelAccessToken: 'test-line-token', }) - expect(registry).not.toHaveProperty('email') + expect(registry.email).toBeUndefined() + }) + + it('telegram channel delegates to sendTelegramMessage with token', async () => { + const registry = createChannelRegistry({ + telegramBotToken: 'tg-token', + lineChannelAccessToken: 'line-token', + }) + + await registry.telegram!('12345', msg) + expect(sendTelegramMock).toHaveBeenCalledWith('tg-token', '12345', msg) + }) + + it('line channel delegates to sendLineMessage with token', async () => { + const registry = createChannelRegistry({ + telegramBotToken: 'tg-token', + lineChannelAccessToken: 'line-token', + }) + + await registry.line!('line-user', msg) + expect(sendLineMock).toHaveBeenCalledWith('line-token', 'line-user', msg) + }) + + it('email channel renders template and delegates to sendEmailMessage', async () => { + const registry = createChannelRegistry({ + telegramBotToken: 'tg-token', + lineChannelAccessToken: 'line-token', + resendApiKey: 're_key', + resendFromEmail: 'CaffeCode ', + }) + + await registry.email!('user@example.com', msg) + expect(sendEmailMock).toHaveBeenCalledWith( + 're_key', + 'CaffeCode ', + 'user@example.com', + msg, + { html: 'mocked email' }, + ) + }) + + it('email channel uses default from address when resendFromEmail is omitted', async () => { + const registry = createChannelRegistry({ + telegramBotToken: 'tg-token', + lineChannelAccessToken: 'line-token', + resendApiKey: 're_key', + }) + + await registry.email!('user@example.com', msg) + expect(sendEmailMock).toHaveBeenCalledWith( + 're_key', + 'CaffeCode ', + 'user@example.com', + msg, + { html: 'mocked email' }, + ) }) }) diff --git a/packages/shared/src/push/__tests__/telegram.test.ts b/packages/shared/src/push/__tests__/telegram.test.ts deleted file mode 100644 index 5a633fa..0000000 --- a/packages/shared/src/push/__tests__/telegram.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { describe, it, expect, vi } from 'vitest' -import { TelegramChannel } from '../channels/telegram.js' -import type { PushMessage } from '../../types/push.js' - -const sendMock = vi.fn() -vi.mock('../../channels/telegram.js', async () => { - const actual = await vi.importActual('../../channels/telegram.js') - return { - ...actual, - sendTelegramMessage: (...args: unknown[]) => sendMock(...args), - } -}) - -const msg: PushMessage = { - title: 'Two Sum', - difficulty: 'Easy', - leetcodeId: 1, - explanation: '使用 **Hash Table** 可以在 O(n) 解決', - url: 'https://example.com/problems/two-sum', - problemSlug: 'two-sum', - problemId: 1, -} - -describe('TelegramChannel.send', () => { - const channel = new TelegramChannel('fake-token') - - it('delegates to sendTelegramMessage', async () => { - sendMock.mockResolvedValue({ success: true }) - const result = await channel.send('12345', msg) - expect(sendMock).toHaveBeenCalledWith('fake-token', '12345', msg) - expect(result.success).toBe(true) - }) -}) diff --git a/packages/shared/src/push/channels/email.ts b/packages/shared/src/push/channels/email.ts deleted file mode 100644 index afbab7b..0000000 --- a/packages/shared/src/push/channels/email.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { render } from '@react-email/render' -import { createElement } from 'react' -import { sendEmailMessage } from '../../channels/email.js' -import type { PushMessage, SendResult } from '../../types/push.js' -import type { NotificationChannel } from './interface.js' -import { DailyProblemEmail } from './email-template.js' - -export class EmailChannel implements NotificationChannel { - constructor( - private readonly apiKey: string, - private readonly from: string - ) {} - - async send(emailAddress: string, msg: PushMessage): Promise { - const html = await render( - createElement(DailyProblemEmail, { - title: msg.title, - difficulty: msg.difficulty, - leetcodeId: msg.leetcodeId, - problemUrl: msg.url, - }) - ) - - return sendEmailMessage(this.apiKey, this.from, emailAddress, msg, { html }) - } -} diff --git a/packages/shared/src/push/channels/index.ts b/packages/shared/src/push/channels/index.ts index 5727d17..bc8b7f7 100644 --- a/packages/shared/src/push/channels/index.ts +++ b/packages/shared/src/push/channels/index.ts @@ -1,7 +1,3 @@ -export type { NotificationChannel } from './interface.js' -export { TelegramChannel } from './telegram.js' -export { LineChannel } from './line.js' -export { EmailChannel } from './email.js' -export { DailyProblemEmail } from './email-template.js' +export type { NotificationChannel, ChannelRegistryConfig } from './registry.js' export { createChannelRegistry } from './registry.js' -export type { ChannelRegistryConfig } from './registry.js' +export { DailyProblemEmail } from './email-template.js' diff --git a/packages/shared/src/push/channels/interface.ts b/packages/shared/src/push/channels/interface.ts deleted file mode 100644 index 89735f9..0000000 --- a/packages/shared/src/push/channels/interface.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type { PushMessage, SendResult } from '../../types/push.js' - -export interface NotificationChannel { - /** Send message to a specific recipient */ - send(channelIdentifier: string, msg: PushMessage): Promise -} diff --git a/packages/shared/src/push/channels/line.ts b/packages/shared/src/push/channels/line.ts deleted file mode 100644 index 88e0922..0000000 --- a/packages/shared/src/push/channels/line.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { sendLineMessage } from '../../channels/line.js' -import type { PushMessage, SendResult } from '../../types/push.js' -import type { NotificationChannel } from './interface.js' - -export class LineChannel implements NotificationChannel { - constructor(private readonly channelAccessToken: string) {} - - async send(lineUserId: string, msg: PushMessage): Promise { - return sendLineMessage(this.channelAccessToken, lineUserId, msg) - } -} diff --git a/packages/shared/src/push/channels/registry.ts b/packages/shared/src/push/channels/registry.ts index b35443f..033b534 100644 --- a/packages/shared/src/push/channels/registry.ts +++ b/packages/shared/src/push/channels/registry.ts @@ -1,7 +1,16 @@ -import { TelegramChannel } from './telegram.js' -import { LineChannel } from './line.js' -import { EmailChannel } from './email.js' -import type { NotificationChannel } from './interface.js' +import { render } from '@react-email/render' +import { createElement } from 'react' +import { sendTelegramMessage } from '../../channels/telegram.js' +import { sendLineMessage } from '../../channels/line.js' +import { sendEmailMessage } from '../../channels/email.js' +import { DailyProblemEmail } from './email-template.js' +import type { PushMessage, SendResult } from '../../types/push.js' + +/** A notification channel is a function that delivers a push message to one recipient. */ +export type NotificationChannel = ( + identifier: string, + msg: PushMessage, +) => Promise export interface ChannelRegistryConfig { telegramBotToken: string @@ -10,12 +19,33 @@ export interface ChannelRegistryConfig { resendFromEmail?: string } -export function createChannelRegistry(config: ChannelRegistryConfig): Record { - return { - telegram: new TelegramChannel(config.telegramBotToken), - line: new LineChannel(config.lineChannelAccessToken), - ...(config.resendApiKey - ? { email: new EmailChannel(config.resendApiKey, config.resendFromEmail ?? 'CaffeCode ') } - : {}), +const DEFAULT_EMAIL_FROM = 'CaffeCode ' + +async function renderDailyProblemEmail(msg: PushMessage): Promise { + return render( + createElement(DailyProblemEmail, { + title: msg.title, + difficulty: msg.difficulty, + leetcodeId: msg.leetcodeId, + problemUrl: msg.url, + }), + ) +} + +export function createChannelRegistry( + config: ChannelRegistryConfig, +): Record { + const registry: Record = { + telegram: (id, msg) => sendTelegramMessage(config.telegramBotToken, id, msg), + line: (id, msg) => sendLineMessage(config.lineChannelAccessToken, id, msg), + } + if (config.resendApiKey) { + const apiKey = config.resendApiKey + const from = config.resendFromEmail ?? DEFAULT_EMAIL_FROM + registry.email = async (id, msg) => { + const html = await renderDailyProblemEmail(msg) + return sendEmailMessage(apiKey, from, id, msg, { html }) + } } + return registry } diff --git a/packages/shared/src/push/channels/telegram.ts b/packages/shared/src/push/channels/telegram.ts deleted file mode 100644 index c80aff1..0000000 --- a/packages/shared/src/push/channels/telegram.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { sendTelegramMessage } from '../../channels/telegram.js' -import type { PushMessage, SendResult } from '../../types/push.js' -import type { NotificationChannel } from './interface.js' - -export class TelegramChannel implements NotificationChannel { - constructor(private readonly botToken: string) {} - - async send(chatId: string, msg: PushMessage): Promise { - return sendTelegramMessage(this.botToken, chatId, msg) - } -} diff --git a/packages/shared/src/push/index.ts b/packages/shared/src/push/index.ts index e190f97..f9f3810 100644 --- a/packages/shared/src/push/index.ts +++ b/packages/shared/src/push/index.ts @@ -13,11 +13,5 @@ export { } from './push.repository.js' export type { PushCandidate, VerifiedChannel } from './push.repository.js' -export { - TelegramChannel, - LineChannel, - EmailChannel, - DailyProblemEmail, - createChannelRegistry, -} from './channels/index.js' +export { DailyProblemEmail, createChannelRegistry } from './channels/index.js' export type { NotificationChannel, ChannelRegistryConfig } from './channels/index.js' diff --git a/packages/shared/src/push/push.logic.ts b/packages/shared/src/push/push.logic.ts index 55624d5..c8373a7 100644 --- a/packages/shared/src/push/push.logic.ts +++ b/packages/shared/src/push/push.logic.ts @@ -6,7 +6,7 @@ import type { SupabaseClient } from '@supabase/supabase-js' import type { LimitFunction } from 'p-limit' import pLimit from 'p-limit' import { selectProblemForUser } from '../services/problem-selector.js' -import type { NotificationChannel } from './channels/interface.js' +import type { NotificationChannel } from './channels/registry.js' import type { ChannelType, Difficulty, PushMessage, SendResult } from '../types/push.js' import { getAllCandidates, @@ -289,7 +289,7 @@ export async function dispatchJob( problemId: job.problemId, } - const result = await channel.send(job.channelIdentifier, msg) + const result = await channel(job.channelIdentifier, msg) if (!result.success && !result.shouldRetry) { await incrementChannelFailures(db, job.channelId)