diff --git a/docs/tutorials/05-notifications.md b/docs/tutorials/05-notifications.md index fe79dbf3ab..4b0cbb9279 100644 --- a/docs/tutorials/05-notifications.md +++ b/docs/tutorials/05-notifications.md @@ -22,7 +22,13 @@ To get a notification an attendee has to: General notifications are sent to everyone (authenticated and anonymous) who has enabled "General notifications". These are sent manually by conference organizers. -To send a "General notification": +To send a "General notification" from the command line: + + FIRESTORE_TARGET=production ./hb notify --title "Doors open" --body "Registration is in the main hall." --path /schedule + +`--path` is the page the notification opens, or a full `https://` link. `--icon` replaces the icon from `config/notifications`. It asks before sending from production, and `--yes` sends without asking. Without `FIRESTORE_TARGET=production`, it adds the notification to the emulator, where `npm start` runs the function. It needs the `notifications` and `functions` features, since the `sendGeneralNotification` function sends it. + +To send one from the Firebase console instead: 1. Got to the [project's Firestore page](https://console.firebase.google.com/u/0/project/_/firestore/data/) 1. "Start collection" with the ID `notifications` diff --git a/packages/cli/src/commands/notify.test.ts b/packages/cli/src/commands/notify.test.ts new file mode 100644 index 0000000000..4091bbdc35 --- /dev/null +++ b/packages/cli/src/commands/notify.test.ts @@ -0,0 +1,97 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { confirm } from '../lib/prompt.js'; +import { siteFeatures } from '../utils/site-features.js'; +import { runNotify } from './notify.js'; + +const { create, doc, collection } = vi.hoisted(() => { + const create = vi.fn(); + const doc = vi.fn(() => ({ create })); + const collection = vi.fn(() => ({ doc })); + return { create, doc, collection }; +}); + +vi.mock('../lib/firestore.js', () => ({ firestore: { collection } })); +vi.mock('../lib/prompt.js', () => ({ confirm: vi.fn() })); +vi.mock('../utils/site-features.js', () => ({ siteFeatures: vi.fn() })); +vi.mock('../utils/firebase-project.js', () => ({ resolveFirebaseProjectId: () => 'fest-2027' })); + +const output = () => vi.mocked(console.log).mock.calls.map(([line]) => String(line)); + +describe('runNotify', () => { + const isTTY = process.stdin.isTTY; + + beforeEach(() => { + vi.spyOn(console, 'log').mockImplementation(() => undefined); + vi.mocked(siteFeatures).mockReturnValue({}); + vi.setSystemTime(new Date('2027-10-15T09:00:00Z')); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.clearAllMocks(); + vi.useRealTimers(); + delete process.env['FIRESTORE_TARGET']; + process.stdin.isTTY = isTTY; + }); + + it('adds the notification to the emulator, which the function sends', async () => { + expect(await runNotify({ title: ' Doors open ', body: 'Welcome!', path: '/schedule' })).toBe( + true, + ); + + expect(collection).toHaveBeenCalledWith('notifications'); + expect(doc).toHaveBeenCalledWith(String(Date.parse('2027-10-15T09:00:00Z'))); + expect(create).toHaveBeenCalledWith({ + title: 'Doors open', + body: 'Welcome!', + path: '/schedule', + }); + expect(output().at(-1)).toContain('to the emulator'); + expect(confirm).not.toHaveBeenCalled(); + }); + + it('refuses an empty title or body, and a link that could run code', async () => { + expect(await runNotify({ title: ' ', body: 'Welcome!' })).toBe(false); + expect( + await runNotify({ title: 'Doors open', body: 'Welcome!', path: 'javascript:alert(1)' }), + ).toBe(false); + + expect(create).not.toHaveBeenCalled(); + expect(output()).toContain('✘ The title is empty.'); + expect(output().some((line) => line.includes('path'))).toBe(true); + }); + + it('refuses when notifications or functions are off, since nothing would send it', async () => { + vi.mocked(siteFeatures).mockReturnValue({ functions: false }); + + expect(await runNotify({ title: 'Doors open', body: 'Welcome!' })).toBe(false); + expect(create).not.toHaveBeenCalled(); + expect(output()).toEqual(['✘ Nothing would send it: features.functions off.']); + }); + + it('asks before sending from production, and sends nothing when told no', async () => { + process.env['FIRESTORE_TARGET'] = 'production'; + process.stdin.isTTY = true; + vi.mocked(confirm).mockResolvedValueOnce(false).mockResolvedValueOnce(true); + + expect(await runNotify({ title: 'Doors open', body: 'Welcome!' })).toBe(false); + expect(create).not.toHaveBeenCalled(); + + expect(await runNotify({ title: 'Doors open', body: 'Welcome!' })).toBe(true); + expect(confirm).toHaveBeenCalledWith( + 'Send "Doors open" to every subscribed device of fest-2027?', + ); + expect(output().at(-1)).toContain('to fest-2027'); + }); + + it('needs --yes for production without a terminal to ask in', async () => { + process.env['FIRESTORE_TARGET'] = 'production'; + process.stdin.isTTY = false; + + expect(await runNotify({ title: 'Doors open', body: 'Welcome!' })).toBe(false); + expect(create).not.toHaveBeenCalled(); + + expect(await runNotify({ title: 'Doors open', body: 'Welcome!', yes: true })).toBe(true); + expect(confirm).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/src/commands/notify.ts b/packages/cli/src/commands/notify.ts new file mode 100644 index 0000000000..b94c5007aa --- /dev/null +++ b/packages/cli/src/commands/notify.ts @@ -0,0 +1,68 @@ +import { documentMessages } from '../lib/content.js'; +import { confirm } from '../lib/prompt.js'; +import { resolveFirebaseProjectId } from '../utils/firebase-project.js'; +import { findRepoRoot } from '../utils/node-version.js'; +import { siteFeatures } from '../utils/site-features.js'; + +export interface NotifyOptions { + title: string; + body: string; + /** The page or link the notification opens. */ + path?: string; + icon?: string; + /** Send to production without asking. */ + yes?: boolean; +} + +/** + * Sends a general notification to every device that turned general notifications on, by adding + * it to `notifications`. The `sendGeneralNotification` function sends it from there. Writes to the + * emulator unless FIRESTORE_TARGET=production. Returns whether it was added. + */ +export const runNotify = async ({ title, body, path, icon, yes = false }: NotifyOptions) => { + const repoRoot = findRepoRoot(process.cwd()) ?? process.cwd(); + const features = siteFeatures(repoRoot); + const off = (['notifications', 'functions'] as const).filter((name) => features[name] === false); + if (off.length) { + console.log(`✘ Nothing would send it: features.${off.join(' and features.')} off.`); + return false; + } + + const notification = { + title: title.trim(), + body: body.trim(), + ...(path ? { path } : {}), + ...(icon ? { icon } : {}), + }; + const id = String(Date.now()); + const problems = [ + ...(notification.title ? [] : ['The title is empty.']), + ...(notification.body ? [] : ['The body is empty.']), + ...documentMessages(`notifications/${id}`, notification), + ]; + if (problems.length) { + for (const problem of problems) console.log(`✘ ${problem}`); + return false; + } + + const projectId = + process.env['FIRESTORE_TARGET'] === 'production' + ? resolveFirebaseProjectId(repoRoot) + : undefined; + if (projectId && !yes) { + const question = `Send "${notification.title}" to every subscribed device of ${projectId}?`; + if (!process.stdin.isTTY) { + console.log(`✘ ${question} Run again with --yes to send it without asking.`); + return false; + } + if (!(await confirm(question))) return false; + } + + const { firestore } = await import('../lib/firestore.js'); + await firestore.collection('notifications').doc(id).create(notification); + console.log( + `✔ Added notifications/${id} to ${projectId ?? 'the emulator'}. ` + + 'The sendGeneralNotification function sends it to every subscribed device.', + ); + return true; +}; diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 8bc32362b0..b1a09798af 100755 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -10,6 +10,7 @@ import { runFirestoreCsv } from './commands/firestore-csv.js'; import { runFirestoreExport } from './commands/firestore-export.js'; import { runFirestoreInit } from './commands/firestore-init/index.js'; import { type InitOptions, runInit } from './commands/init/index.js'; +import { type NotifyOptions, runNotify } from './commands/notify.js'; import { runSetup } from './commands/setup.js'; import { runSetupGitHub } from './commands/setup-github.js'; import { type UpgradeOptions, runUpgrade } from './commands/upgrade.js'; @@ -214,4 +215,24 @@ program } }); +program + .command('notify') + .description( + 'Send a push notification to every device that turned on general notifications ' + + '(targets the local emulator unless FIRESTORE_TARGET=production is set).', + ) + .requiredOption('--title ', 'The title.') + .requiredOption('--body ', 'The text under the title.') + .option('--path ', 'The page it opens, such as /schedule, or a full https:// link.') + .option('--icon ', 'An icon instead of the one in config/notifications.') + .option('-y, --yes', 'Send it from production without asking.') + .action(async (options: NotifyOptions) => { + try { + process.exitCode = (await runNotify(options)) ? 0 : 1; + } catch (error) { + console.log(error); + process.exitCode = 1; + } + }); + program.parse();