Skip to content
Open
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
4 changes: 2 additions & 2 deletions .claude/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
"runtimeExecutable": "bash",
"runtimeArgs": [
"-lc",
"cd \"$(git rev-parse --show-toplevel)\" && npm --prefix functions run build && npx -y node@22 node_modules/.bin/firebase emulators:start --project demo-open-feedback --only firestore,auth,functions,storage --import=./cypress/fixtures/firebase-emulators-data"
"cd \"$(git rev-parse --show-toplevel)\" && npm --prefix functions run build && npx -y node@24 node_modules/.bin/firebase emulators:start --project demo-open-feedback --only firestore,auth,functions,storage --import=./cypress/fixtures/firebase-emulators-data"
],
"port": 4100
},
Expand All @@ -15,7 +15,7 @@
"runtimeExecutable": "bash",
"runtimeArgs": [
"-lc",
"cd \"$(git rev-parse --show-toplevel)\" && VITE_APPID=1:000000000000:web:0000000000000000 VITE_API_KEY=demo-api-key VITE_AUTH_DOMAIN=demo-open-feedback.firebaseapp.com VITE_PROJECT_ID=demo-open-feedback VITE_STORAGE_BUCKET=demo-open-feedback.appspot.com VITE_EMULATORS=true npx -y node@22 node_modules/.bin/vite --port \"${PORT:-3100}\" --strictPort"
"cd \"$(git rev-parse --show-toplevel)\" && VITE_APPID=1:000000000000:web:0000000000000000 VITE_API_KEY=demo-api-key VITE_AUTH_DOMAIN=demo-open-feedback.firebaseapp.com VITE_PROJECT_ID=demo-open-feedback VITE_STORAGE_BUCKET=demo-open-feedback.appspot.com VITE_EMULATORS=true npx -y node@24 node_modules/.bin/vite --port \"${PORT:-3100}\" --strictPort"
],
"port": 3100,
"autoPort": true
Expand Down
2 changes: 1 addition & 1 deletion .nvmrc
Original file line number Diff line number Diff line change
@@ -1 +1 @@
22
24
4 changes: 3 additions & 1 deletion docs/app/1-install.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,9 @@ You have to create a Mailgun account if you want to send those emails, if not th

Mailgun API is used to send email through Cloud Functions. Configuration uses
environment variables (the [Firebase params/env approach](https://firebase.google.com/docs/functions/config-env);
the legacy `functions.config()` / Runtime Config is deprecated). Add the
the legacy `functions.config()` / Runtime Config is deprecated and is not read:
`firebase.json` sets `disallowLegacyRuntimeConfig`, so values set with
`firebase functions:config:set` are ignored). Add the
variables to a dotenv file under `functions/`:

- The application URL: `APP_URL` (ex: `http://localhost:3000`)
Expand Down
3 changes: 2 additions & 1 deletion firebase.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@
"npm --prefix \"$RESOURCE_DIR\" run lint",
"npm --prefix \"$RESOURCE_DIR\" run build"
],
"source": "functions"
"source": "functions",
"disallowLegacyRuntimeConfig": true
},
"storage": {
"rules": "config/storage.rules"
Expand Down
2,506 changes: 1,174 additions & 1,332 deletions functions/package-lock.json

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions functions/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
"test": "vitest"
},
"engines": {
"node": "22"
"node": "24"
},
"main": "lib/index.js",
"dependencies": {
Expand All @@ -29,9 +29,9 @@
"fastify-plugin": "^5.1.0",
"firebase": "^8.8.1",
"firebase-admin": "^13.10.0",
"firebase-functions": "^6.6.0",
"firebase-functions": "^7.4.0",
"firebase-functions-test": "^3.5.0",
"firebase-tools": "14.27.0",
"firebase-tools": "15.31.0",
"lodash": "^4.18.1",
"ts-custom-error": "^3.3.1"
},
Expand Down
61 changes: 61 additions & 0 deletions functions/src/callable/alert.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import firebaseFunctionsTest from 'firebase-functions-test'
import { alert } from './alert'

const test = firebaseFunctionsTest()
const alertWrapped = test.wrap(alert)

// Handlers may throw synchronously; normalise to a promise.
const callAlert = async (data: unknown) => alertWrapped({ data } as any)

describe('alert', () => {
const fetchMock = vi.fn()

beforeEach(() => {
vi.stubEnv('OPSGENIE_KEY', 'test-key')
vi.stubEnv('OPSGENIE_API', 'https://opsgenie.test')
fetchMock.mockResolvedValue(new Response('ok', { status: 202 }))
vi.stubGlobal('fetch', fetchMock)
})

afterEach(() => {
vi.unstubAllEnvs()
vi.unstubAllGlobals()
fetchMock.mockReset()
})

it('rejects empty data with failed-precondition', async () => {
await expect(callAlert({})).rejects.toMatchObject({
code: 'failed-precondition',
message: 'Input parameters are empty',
})
expect(fetchMock).not.toHaveBeenCalled()
})

it('rejects with failed-precondition when OpsGenie is not configured', async () => {
vi.stubEnv('OPSGENIE_KEY', '')

await expect(callAlert({ message: 'boom' })).rejects.toMatchObject({
code: 'failed-precondition',
message: 'Missing credentials for opsgenie',
})
expect(fetchMock).not.toHaveBeenCalled()
})

it('posts the request data to OpsGenie', async () => {
const data = { message: 'boom', priority: 'P2' }

await expect(callAlert(data)).resolves.toBe('ok')

expect(fetchMock).toHaveBeenCalledWith(
'https://opsgenie.test/v2/alerts',
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({
Authorization: 'GenieKey test-key',
}),
body: JSON.stringify(data),
})
)
})
})
9 changes: 5 additions & 4 deletions functions/src/callable/alert.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,20 @@
import * as functions from 'firebase-functions'
import { onCall, HttpsError } from 'firebase-functions/v2/https'
import { isEmpty } from 'lodash'
import { getOpsGenieEnv } from '../helpers/env'

export const alert = functions.https.onCall((data) => {
export const alert = onCall((request) => {
const data = request.data
const opsGenieEnv = getOpsGenieEnv()

if (isEmpty(data)) {
throw new functions.https.HttpsError(
throw new HttpsError(
'failed-precondition',
'Input parameters are empty'
)
}

if (opsGenieEnv === null) {
throw new functions.https.HttpsError(
throw new HttpsError(
'failed-precondition',
'Missing credentials for opsgenie'
)
Expand Down
2 changes: 1 addition & 1 deletion functions/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ export {
aggregateVotesUpdate,
} from './triggers/aggregateVotes'
export { userInviteCreated } from './triggers/invites/userInvite'
export { userCreate } from './triggers/userCreate'
export { userCreated } from './triggers/userCreated'

export { alert } from './callable/alert'
export { deleteProject } from './callable/deleteProject'
Expand Down
32 changes: 0 additions & 32 deletions functions/src/triggers/userCreate.spec.ts

This file was deleted.

17 changes: 0 additions & 17 deletions functions/src/triggers/userCreate.ts

This file was deleted.

41 changes: 41 additions & 0 deletions functions/src/triggers/userCreated.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { describe, it, expect } from 'vitest'
import firebaseFunctionsTest from 'firebase-functions-test'
import { userCreated } from './userCreated'
import { getFirestoreMocksAndInit } from '../testUtils/firestoreStub'

const test = firebaseFunctionsTest()

describe('userCreated', () => {
it('when a new anonymous user is created, do nothing', async () => {
const { get, where } = getFirestoreMocksAndInit()
get.mockImplementation(() => Promise.resolve([]))

const userCreatedWrapped = test.wrap(userCreated)

await expect(
userCreatedWrapped({
data: { uid: '123', email: undefined, providerData: [] },
} as any)
).resolves.toEqual('new anonymous user')

expect(where, 'firestore request not made').toHaveBeenCalledTimes(0)
})

it('when a user signs up with an email, look up their pending invites', async () => {
const { get, where } = getFirestoreMocksAndInit()
get.mockImplementation(() => Promise.resolve([]))

const userCreatedWrapped = test.wrap(userCreated)

await userCreatedWrapped({
data: { uid: '123', email: 'jane@example.com', providerData: [] },
} as any)

expect(where).toHaveBeenCalledWith(
'destinationUserInfo',
'==',
'jane@example.com'
)
expect(where).toHaveBeenCalledWith('status', '==', 'emailSent')
})
})
14 changes: 14 additions & 0 deletions functions/src/triggers/userCreated.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { onUserCreated } from 'firebase-functions/v2/identity'
import { isEmpty } from 'lodash'
import { checkPendingInviteAndProcessThem } from './invites/userInvite'

// Named userCreated (not userCreate): the Firebase CLI cannot upgrade a Gen1
// function to Gen2 in place, so the Gen1 `userCreate` is deleted on deploy.
export const userCreated = onUserCreated(async (event) => {
const user = event.data
if (isEmpty(user) || (isEmpty(user.email) && isEmpty(user.phoneNumber))) {
return 'new anonymous user'
}

return checkPendingInviteAndProcessThem(user)
})
Loading