Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion docs/tutorials/05-notifications.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
97 changes: 97 additions & 0 deletions packages/cli/src/commands/notify.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
68 changes: 68 additions & 0 deletions packages/cli/src/commands/notify.ts
Original file line number Diff line number Diff line change
@@ -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;
};
21 changes: 21 additions & 0 deletions packages/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 <text>', 'The title.')
.requiredOption('--body <text>', 'The text under the title.')
.option('--path <path>', 'The page it opens, such as /schedule, or a full https:// link.')
.option('--icon <url>', '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();
Loading