From 4cc7aaa969eb2e6dd181ab9c0efeda7c3f3e19a8 Mon Sep 17 00:00:00 2001 From: William Swanson Date: Tue, 2 Aug 2022 22:39:27 -0700 Subject: [PATCH 1/6] Clean up serverlet request types --- src/types/requestTypes.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/types/requestTypes.ts b/src/types/requestTypes.ts index f4acbcb..22d9c40 100644 --- a/src/types/requestTypes.ts +++ b/src/types/requestTypes.ts @@ -11,10 +11,9 @@ export interface Logger { } export interface LoggedRequest extends ExpressRequest { - // Logging stuff: - date: Date - ip: string - log: Logger + readonly date: Date + readonly ip: string + readonly log: Logger } export interface DbRequest extends LoggedRequest { @@ -25,6 +24,6 @@ export interface ApiRequest extends DbRequest { readonly apiKey: ApiKey // Taken from the Express request: - json: unknown - query: unknown + readonly json: unknown + readonly query: unknown } From caf06b9d8ff65da0bac356f7fd596cf63601dd10 Mon Sep 17 00:00:00 2001 From: William Swanson Date: Fri, 5 Aug 2022 14:33:03 -0700 Subject: [PATCH 2/6] Adjust the error response JSON format --- src/types/responseTypes.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/types/responseTypes.ts b/src/types/responseTypes.ts index 9ef40d2..aee207e 100644 --- a/src/types/responseTypes.ts +++ b/src/types/responseTypes.ts @@ -19,14 +19,14 @@ export function jsonResponse( * Construct an HttpResponse object with an error message. */ export function errorResponse( - message: string, + error: string, opts: { status?: number; headers?: HttpHeaders } = {} ): HttpResponse { const { status = 500, headers = {} } = opts return { status, headers: { 'content-type': 'application/json', ...headers }, - body: JSON.stringify({ message }) + body: JSON.stringify({ error }) } } From 0121844b66871dc105e8a1ba04670b927111318f Mon Sep 17 00:00:00 2001 From: William Swanson Date: Fri, 5 Aug 2022 14:43:41 -0700 Subject: [PATCH 3/6] Rename `withApiKey` to `withLegacyApiKey` The v2 API passes the key in the payload. --- .../{withApiKey.ts => withLegacyApiKey.ts} | 2 +- src/server/urls.ts | 18 +++++++++--------- 2 files changed, 10 insertions(+), 10 deletions(-) rename src/middleware/{withApiKey.ts => withLegacyApiKey.ts} (96%) diff --git a/src/middleware/withApiKey.ts b/src/middleware/withLegacyApiKey.ts similarity index 96% rename from src/middleware/withApiKey.ts rename to src/middleware/withLegacyApiKey.ts index be3d61b..9454d8e 100644 --- a/src/middleware/withApiKey.ts +++ b/src/middleware/withLegacyApiKey.ts @@ -8,7 +8,7 @@ import { errorResponse } from '../types/responseTypes' * Checks the API key passed in the request headers, * then passes the request along if the key is valid. */ -export const withApiKey = +export const withLegacyApiKey = (server: Serverlet): Serverlet => async request => { const { connection, headers, log } = request diff --git a/src/server/urls.ts b/src/server/urls.ts index 403f5b1..d88d1bb 100644 --- a/src/server/urls.ts +++ b/src/server/urls.ts @@ -1,6 +1,6 @@ import { pickMethod, pickPath, Serverlet } from 'serverlet' -import { withApiKey } from '../middleware/withApiKey' +import { withLegacyApiKey } from '../middleware/withLegacyApiKey' import { attachUserV1Route, enableCurrencyV1Route, @@ -23,29 +23,29 @@ const urls: { [path: string]: Serverlet } = { '/': healthCheckRoute, '/v1/device/?': pickMethod({ - POST: withApiKey(registerDeviceV1Route) + POST: withLegacyApiKey(registerDeviceV1Route) }), '/v1/notification/send/?': pickMethod({ - POST: withApiKey(sendNotificationV1Route) + POST: withLegacyApiKey(sendNotificationV1Route) }), // The GUI accesses `/v1//user?userId=...` with an extra `/`: '/v1/+user/?': pickMethod({ - GET: withApiKey(fetchStateV1Route) + GET: withLegacyApiKey(fetchStateV1Route) }), '/v1/user/device/attach/?': pickMethod({ - POST: withApiKey(attachUserV1Route) + POST: withLegacyApiKey(attachUserV1Route) }), '/v1/user/notifications/?': pickMethod({ - POST: withApiKey(registerCurrenciesV1Route) + POST: withLegacyApiKey(registerCurrenciesV1Route) }), '/v1/user/notifications/toggle/?': pickMethod({ - POST: withApiKey(toggleStateV1Route) + POST: withLegacyApiKey(toggleStateV1Route) }), '/v1/user/notifications/[0-9A-Za-z]+/?': pickMethod({ - GET: withApiKey(fetchCurrencyV1Route), - PUT: withApiKey(enableCurrencyV1Route) + GET: withLegacyApiKey(fetchCurrencyV1Route), + PUT: withLegacyApiKey(enableCurrencyV1Route) }) } export const allRoutes: Serverlet = pickPath(urls, missingRoute) From 94a54e68c1af042bb3a3f88fceda0bde343568a5 Mon Sep 17 00:00:00 2001 From: William Swanson Date: Thu, 28 Jul 2022 15:11:21 -0700 Subject: [PATCH 4/6] Implement v2 databases and HTTP endpoints --- package.json | 1 + src/db/couchDevices.ts | 173 +++++++++++++++++++ src/db/couchPushEvents.ts | 318 +++++++++++++++++++++++++++++++++++ src/db/couchSetup.ts | 6 +- src/middleware/withDevice.ts | 60 +++++++ src/routes/deviceRoutes.ts | 54 ++++++ src/routes/loginRoutes.ts | 49 ++++++ src/server/urls.ts | 15 ++ src/types/pushApiTypes.ts | 126 ++++++++++++++ src/types/pushCleaners.ts | 97 +++++++++++ src/types/pushTypes.ts | 114 +++++++++++++ src/types/requestTypes.ts | 9 + yarn.lock | 5 + 13 files changed, 1026 insertions(+), 1 deletion(-) create mode 100644 src/db/couchDevices.ts create mode 100644 src/db/couchPushEvents.ts create mode 100644 src/middleware/withDevice.ts create mode 100644 src/routes/deviceRoutes.ts create mode 100644 src/routes/loginRoutes.ts create mode 100644 src/types/pushApiTypes.ts create mode 100644 src/types/pushCleaners.ts diff --git a/package.json b/package.json index 876b566..c5cae2d 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "firebase-admin": "^8.12.1", "nano": "^9.0.5", "node-fetch": "^2.6.7", + "rfc4648": "^1.5.2", "serverlet": "^0.1.1" }, "devDependencies": { diff --git a/src/db/couchDevices.ts b/src/db/couchDevices.ts new file mode 100644 index 0000000..d78f9f7 --- /dev/null +++ b/src/db/couchDevices.ts @@ -0,0 +1,173 @@ +import { + asArray, + asDate, + asObject, + asOptional, + asString, + uncleaner +} from 'cleaners' +import { + asCouchDoc, + asMaybeConflictError, + asMaybeNotFoundError, + DatabaseSetup, + makeJsDesign +} from 'edge-server-tools' +import { ServerScope } from 'nano' +import { base64 } from 'rfc4648' + +import { asBase64 } from '../types/pushCleaners' +import { Device } from '../types/pushTypes' + +/** + * A device returned from the database. + * Mutate the `device` object, then call `save` to commit the changes. + */ +export interface DeviceRow { + device: Device + save: () => Promise +} + +/** + * An API key, as stored in Couch. + */ +export const asCouchDevice = asCouchDoc>( + asObject({ + created: asDate, + + // Status: + apiKey: asOptional(asString), + deviceToken: asOptional(asString), + loginIds: asArray(asBase64), + visited: asDate + }) +) +const wasCouchDevice = uncleaner(asCouchDevice) +type CouchDevice = ReturnType + +/** + * Looks up devices that contain a particular login. + */ +const loginIdDesign = makeJsDesign('loginId', ({ emit }) => ({ + map: function (doc) { + for (let i = 0; i < doc.loginIds.length; ++i) { + emit(doc.loginIds[i], null) + } + } +})) + +export const couchDevicesSetup: DatabaseSetup = { + name: 'push-devices', + documents: { + '_design/loginId': loginIdDesign + } +} + +/** + * Looks up a device by its id. + * If the device does not exist in the database, creates a fresh row. + */ +export async function getDeviceById( + connection: ServerScope, + deviceId: string, + date: Date +): Promise { + const db = connection.use(couchDevicesSetup.name) + const raw = await db.get(deviceId).catch(error => { + if (asMaybeNotFoundError(error) != null) return + throw error + }) + + if (raw == null) { + return makeDeviceRow(connection, { + created: date, + deviceId, + apiKey: undefined, + deviceToken: undefined, + loginIds: [], + visited: date + }) + } + const clean = asCouchDevice(raw) + return makeDeviceRow( + connection, + { ...clean.doc, deviceId: clean.id }, + clean.rev + ) +} + +/** + * Finds all the devices that have logged into this account. + */ +export async function getDevicesByLoginId( + connection: ServerScope, + loginId: Uint8Array +): Promise { + const db = connection.use(couchDevicesSetup.name) + const response = await db.view('loginId', 'loginId', { + include_docs: true, + key: base64.stringify(loginId) + }) + return response.rows.map(row => { + const clean = asCouchDevice(row.doc) + return makeDeviceRow( + connection, + { ...clean.doc, deviceId: clean.id }, + clean.rev + ) + }) +} + +function makeDeviceRow( + connection: ServerScope, + device: Device, + rev?: string +): DeviceRow { + const db = connection.db.use(couchDevicesSetup.name) + let base = { ...device } + + return { + device, + + async save(): Promise { + while (true) { + // Write to the database: + const doc: CouchDevice = { + doc: device, + id: device.deviceId, + rev + } + const response = await db.insert(wasCouchDevice(doc)).catch(error => { + if (asMaybeConflictError(error) == null) throw error + }) + + // If that worked, the merged document is now the latest: + if (response?.ok === true) { + base = { ...device } + rev = doc.rev + return + } + + // Something went wrong, so grab the latest remote document: + const raw = await db.get(device.deviceId) + const clean = asCouchDevice(raw) + rev = clean.rev + const remote = clean.doc + + // If we don't have local edits, take the remote field: + if (device.apiKey === base.apiKey) { + device.apiKey = remote.apiKey + } + if (device.deviceToken === base.deviceToken) { + device.deviceToken = remote.deviceToken + } + if (device.loginIds === base.loginIds) { + device.loginIds = remote.loginIds + } + if (remote.visited > device.visited) { + device.visited = remote.visited + } + } + } + } +} diff --git a/src/db/couchPushEvents.ts b/src/db/couchPushEvents.ts new file mode 100644 index 0000000..e6d0f90 --- /dev/null +++ b/src/db/couchPushEvents.ts @@ -0,0 +1,318 @@ +import { + asArray, + asBoolean, + asDate, + asEither, + asNull, + asObject, + asOptional, + asString, + uncleaner +} from 'cleaners' +import { + asCouchDoc, + asMaybeConflictError, + DatabaseSetup, + makeJsDesign, + viewToStream +} from 'edge-server-tools' +import { ServerScope } from 'nano' +import { base64 } from 'rfc4648' + +import { + asBase64, + asBroadcastTx, + asPushEventState, + asPushMessage, + asPushTrigger +} from '../types/pushCleaners' +import { NewPushEvent, PushEvent } from '../types/pushTypes' + +/** + * An event returned from the database. + * Mutate the `event` object, then call `save` to commit the changes. + */ +export interface PushEventRow { + event: PushEvent + save: () => Promise +} + +/** + * A push event, as stored in Couch. + */ +export const asCouchPushEvent = asCouchDoc( + asObject({ + created: asDate, + eventId: asString, // Not the document id! + deviceId: asOptional(asString), + loginId: asOptional(asBase64), + + // Event: + broadcastTxs: asOptional(asArray(asBroadcastTx)), + pushMessage: asOptional(asPushMessage), + recurring: asBoolean, + trigger: asPushTrigger, + + // Status: + broadcastTxErrors: asOptional(asArray(asEither(asString, asNull))), + pushMessageError: asOptional(asString), + state: asPushEventState, + triggered: asOptional(asDate) + }) +) +const wasCouchPushEvent = uncleaner(asCouchPushEvent) +type CouchPushEvent = ReturnType + +/** + * Looks up events attached to devices. + */ +const deviceIdDesign = makeJsDesign('deviceId', ({ emit }) => ({ + map: function (doc) { + if (doc.deviceId == null) return + if (doc.state === 'cancelled') return + if (doc.state === 'hidden') return + emit(doc.deviceId, null) + } +})) + +/** + * Looks up events attached to logins. + */ +const loginIdDesign = makeJsDesign('loginId', ({ emit }) => ({ + map: function (doc) { + if (doc.loginId == null) return + if (doc.state === 'cancelled') return + if (doc.state === 'hidden') return + emit(doc.loginId, null) + } +})) + +/** + * Looks up active address-balance events. + */ +const addressBalanceDesign = makeJsDesign('address-balance', ({ emit }) => ({ + map: function (doc) { + if (doc.trigger == null) return + if (doc.trigger.type !== 'address-balance') return + if (doc.state !== 'waiting') return + emit(doc._id, null) + } +})) + +/** + * Looks up active price-related events. + */ +const priceDesign = makeJsDesign('price', ({ emit }) => ({ + map: function (doc) { + if (doc.trigger == null) return + const type = doc.trigger.type + if (type !== 'price-change' && type !== 'price-level') return + if (doc.state !== 'waiting') return + emit(doc._id, null) + } +})) + +/** + * Looks up active price-related events. + */ +const txConfirmDesign = makeJsDesign('tx-confirm', ({ emit }) => ({ + map: function (doc) { + if (doc.trigger == null) return + if (doc.trigger.type !== 'tx-confirm') return + if (doc.state !== 'waiting') return + emit(doc._id, null) + } +})) + +export const couchEventsSetup: DatabaseSetup = { + name: 'push-events', + + documents: { + '_design/address-balance': addressBalanceDesign, + '_design/deviceId': deviceIdDesign, + '_design/loginId': loginIdDesign, + '_design/price': priceDesign, + '_design/tx-confirm': txConfirmDesign + } +} + +export async function addEvent( + connection: ServerScope, + event: PushEvent, + created: Date +): Promise { + const db = connection.use(couchEventsSetup.name) + try { + await db.insert( + wasCouchPushEvent({ + doc: event, + id: created.toISOString() + }) + ) + } catch (error) { + if (asMaybeConflictError(error) == null) throw error + await addEvent(connection, event, new Date(created.valueOf() + 1)) + } +} + +export async function adjustEvents( + connection: ServerScope, + opts: { + date: Date + deviceId?: string + loginId?: Uint8Array + createEvents?: NewPushEvent[] + removeEvents?: string[] + } +): Promise { + const { date, deviceId, loginId, createEvents = [], removeEvents = [] } = opts + + // Load existing events: + const eventRows = + deviceId != null + ? await getEventsByDeviceId(connection, deviceId) + : loginId != null + ? await getEventsByLoginId(connection, loginId) + : [] + + // Remove events from the array: + const removeSet = new Set(removeEvents) + for (const event of createEvents) removeSet.add(event.eventId) + const out: PushEvent[] = eventRows + .map(row => row.event) + .filter(event => !removeSet.has(event.eventId)) + + // Perform the deletion on the database: + for (const row of eventRows) { + if (!removeSet.has(row.event.eventId)) continue + if (row.event.state === 'waiting') row.event.state = 'cancelled' + else row.event.state = 'hidden' + await row.save() + } + + // Add new events: + for (const create of createEvents) { + const event: PushEvent = { + ...create, + created: date, + deviceId, + loginId, + state: 'waiting' + } + await addEvent(connection, event, date) + out.push(event) + } + + return out +} + +export async function getEventsByDeviceId( + connection: ServerScope, + deviceId: string +): Promise { + const db = connection.use(couchEventsSetup.name) + const response = await db.view('deviceId', 'deviceId', { + include_docs: true, + key: deviceId + }) + return response.rows.map(row => makePushEventRow(connection, row.doc)) +} + +export async function getEventsByLoginId( + connection: ServerScope, + loginId: Uint8Array +): Promise { + const db = connection.use(couchEventsSetup.name) + const response = await db.view('loginId', 'loginId', { + include_docs: true, + key: base64.stringify(loginId) + }) + return response.rows.map(row => makePushEventRow(connection, row.doc)) +} + +export async function* streamAddressBalanceEvents( + connection: ServerScope +): AsyncIterableIterator { + const db = connection.use(couchEventsSetup.name) + const stream = viewToStream(async params => { + return await db.view('address-balance', 'address-balance', params) + }) + for await (const raw of stream) { + yield makePushEventRow(connection, raw) + } +} + +export async function* streamPriceEvents( + connection: ServerScope +): AsyncIterableIterator { + const db = connection.use(couchEventsSetup.name) + const stream = viewToStream(async params => { + return await db.view('price', 'price', params) + }) + for await (const raw of stream) { + yield makePushEventRow(connection, raw) + } +} + +export async function* streamTxConfirmEvents( + connection: ServerScope +): AsyncIterableIterator { + const db = connection.use(couchEventsSetup.name) + const stream = viewToStream(async params => { + return await db.view('tx-confirm', 'tx-confirm', params) + }) + for await (const raw of stream) { + yield makePushEventRow(connection, raw) + } +} + +function makePushEventRow(connection: ServerScope, raw: unknown): PushEventRow { + const db = connection.db.use(couchEventsSetup.name) + const clean = asCouchPushEvent(raw) + let { id, rev } = clean + const event = clean.doc + let base = { ...event } + + return { + event, + + async save(): Promise { + while (true) { + // Write to the database: + const doc: CouchPushEvent = { doc: event, id, rev } + const response = await db + .insert(wasCouchPushEvent(doc)) + .catch(error => { + if (asMaybeConflictError(error) == null) throw error + }) + + // If that worked, the merged document is now the latest: + if (response?.ok === true) { + base = { ...event } + rev = doc.rev + return + } + + // Something went wrong, so grab the latest remote document: + const raw = await db.get(id) + const clean = asCouchPushEvent(raw) + rev = clean.rev + const remote = clean.doc + + // If we don't have local edits, take the remote field: + if (event.broadcastTxErrors === base.broadcastTxErrors) { + event.broadcastTxErrors = remote.broadcastTxErrors + } + if (event.pushMessageError === base.pushMessageError) { + event.pushMessageError = remote.pushMessageError + } + if (event.state === base.state) { + event.state = remote.state + } + if (event.triggered === base.triggered) { + event.triggered = remote.triggered + } + } + } + } +} diff --git a/src/db/couchSetup.ts b/src/db/couchSetup.ts index d158e23..452e31b 100644 --- a/src/db/couchSetup.ts +++ b/src/db/couchSetup.ts @@ -7,6 +7,8 @@ import { ServerScope } from 'nano' import { serverConfig } from '../serverConfig' import { couchApiKeysSetup } from './couchApiKeys' +import { couchDevicesSetup } from './couchDevices' +import { couchEventsSetup } from './couchPushEvents' import { settingsSetup, syncedReplicators } from './couchSettings' // --------------------------------------------------------------------------- @@ -43,8 +45,10 @@ export async function setupDatabases( await setupDatabase(connection, settingsSetup, options) await Promise.all([ setupDatabase(connection, couchApiKeysSetup, options), - setupDatabase(connection, thresholdsSetup, options), + setupDatabase(connection, couchDevicesSetup, options), + setupDatabase(connection, couchEventsSetup, options), setupDatabase(connection, devicesSetup, options), + setupDatabase(connection, thresholdsSetup, options), setupDatabase(connection, usersSetup, options) ]) } diff --git a/src/middleware/withDevice.ts b/src/middleware/withDevice.ts new file mode 100644 index 0000000..c0c7440 --- /dev/null +++ b/src/middleware/withDevice.ts @@ -0,0 +1,60 @@ +import { asMaybe } from 'cleaners' +import { Serverlet } from 'serverlet' + +import { getApiKeyByKey } from '../db/couchApiKeys' +import { getDeviceById } from '../db/couchDevices' +import { asPushRequestBody } from '../types/pushApiTypes' +import { DbRequest, DeviceRequest } from '../types/requestTypes' +import { errorResponse } from '../types/responseTypes' + +/** + * Parses the request payload and looks up the device. + * Legacy routes do not use this one. + */ +export const withDevice = + (server: Serverlet): Serverlet => + async request => { + const { connection, date, log, req } = request + + // Parse the common request body: + const body = asMaybe(asPushRequestBody)(req.body) + if (body == null) { + return errorResponse('Bad request body', { status: 400 }) + } + + // Look up the key in the database: + const apiKey = await log.debugTime( + 'getApiKeyByKey', + getApiKeyByKey(connection, body.apiKey) + ) + if (apiKey == null) { + return errorResponse('Incorrect API key', { status: 401 }) + } + + // Look up the device in the database, or get a dummy row: + const deviceRow = await log.debugTime( + 'getDeviceById', + getDeviceById(connection, body.deviceId, date) + ) + if (body.apiKey != null) { + deviceRow.device.apiKey = body.apiKey + } + if (body.deviceToken != null) { + deviceRow.device.deviceToken = body.deviceToken + } + deviceRow.device.visited = date + + // Pass that along: + const result = await server({ + ...request, + apiKey, + deviceRow, + loginId: body.loginId, + payload: body.data + }) + + // Flush any changes (such as the visited date): + await deviceRow.save() + + return result + } diff --git a/src/routes/deviceRoutes.ts b/src/routes/deviceRoutes.ts new file mode 100644 index 0000000..5dcacd8 --- /dev/null +++ b/src/routes/deviceRoutes.ts @@ -0,0 +1,54 @@ +import { asMaybe, uncleaner } from 'cleaners' + +import { adjustEvents, getEventsByDeviceId } from '../db/couchPushEvents' +import { withDevice } from '../middleware/withDevice' +import { asDevicePayload, asDeviceUpdatePayload } from '../types/pushApiTypes' +import { errorResponse, jsonResponse } from '../types/responseTypes' + +const wasDevicePayload = uncleaner(asDevicePayload) + +/** + * POST /v2/device + */ +export const deviceFetchRoute = withDevice(async request => { + const { + connection, + deviceRow: { device } + } = request + + const eventRows = await getEventsByDeviceId(connection, device.deviceId) + + return jsonResponse( + wasDevicePayload({ + loginIds: device.loginIds, + events: eventRows.map(row => row.event) + }) + ) +}) + +/** + * POST /v2/device/update + */ +export const deviceUpdateRoute = withDevice(async request => { + const { + connection, + date, + deviceRow: { device }, + payload + } = request + + const clean = asMaybe(asDeviceUpdatePayload)(payload) + if (clean == null) { + return errorResponse('Incorrect device update payload', { status: 400 }) + } + + device.loginIds = clean.loginIds + const events = await adjustEvents(connection, { + date, + deviceId: device.deviceId, + createEvents: clean.createEvents, + removeEvents: clean.removeEvents + }) + + return jsonResponse(wasDevicePayload({ loginIds: device.loginIds, events })) +}) diff --git a/src/routes/loginRoutes.ts b/src/routes/loginRoutes.ts new file mode 100644 index 0000000..8c1c069 --- /dev/null +++ b/src/routes/loginRoutes.ts @@ -0,0 +1,49 @@ +import { asMaybe, uncleaner } from 'cleaners' + +import { adjustEvents, getEventsByLoginId } from '../db/couchPushEvents' +import { withDevice } from '../middleware/withDevice' +import { asLoginPayload, asLoginUpdatePayload } from '../types/pushApiTypes' +import { errorResponse, jsonResponse } from '../types/responseTypes' + +const wasLoginPayload = uncleaner(asLoginPayload) + +/** + * POST /v2/login + */ +export const loginFetchRoute = withDevice(async request => { + const { connection, loginId } = request + + if (loginId == null) { + return errorResponse('No login provided', { status: 400 }) + } + + const eventRows = await getEventsByLoginId(connection, loginId) + return jsonResponse( + wasLoginPayload({ + events: eventRows.map(row => row.event) + }) + ) +}) + +/** + * POST /v2/login/update + */ +export const loginUpdateRoute = withDevice(async request => { + const { connection, date, payload, loginId } = request + + if (loginId == null) { + return errorResponse('No login provided', { status: 400 }) + } + const clean = asMaybe(asLoginUpdatePayload)(payload) + if (clean == null) { + return errorResponse('Incorrect login update payload', { status: 400 }) + } + + const events = await adjustEvents(connection, { + date, + loginId, + createEvents: clean.createEvents, + removeEvents: clean.removeEvents + }) + return jsonResponse(wasLoginPayload({ events })) +}) diff --git a/src/server/urls.ts b/src/server/urls.ts index d88d1bb..6900259 100644 --- a/src/server/urls.ts +++ b/src/server/urls.ts @@ -1,6 +1,7 @@ import { pickMethod, pickPath, Serverlet } from 'serverlet' import { withLegacyApiKey } from '../middleware/withLegacyApiKey' +import { deviceFetchRoute, deviceUpdateRoute } from '../routes/deviceRoutes' import { attachUserV1Route, enableCurrencyV1Route, @@ -10,6 +11,7 @@ import { registerDeviceV1Route, toggleStateV1Route } from '../routes/legacyRoutes' +import { loginFetchRoute, loginUpdateRoute } from '../routes/loginRoutes' import { sendNotificationV1Route } from '../routes/notificationRoute' import { DbRequest } from '../types/requestTypes' import { errorResponse, jsonResponse } from '../types/responseTypes' @@ -46,6 +48,19 @@ const urls: { [path: string]: Serverlet } = { '/v1/user/notifications/[0-9A-Za-z]+/?': pickMethod({ GET: withLegacyApiKey(fetchCurrencyV1Route), PUT: withLegacyApiKey(enableCurrencyV1Route) + }), + + '/v2/device/?': pickMethod({ + POST: deviceFetchRoute + }), + '/v2/device/update/?': pickMethod({ + POST: deviceUpdateRoute + }), + '/v2/login/?': pickMethod({ + POST: loginFetchRoute + }), + '/v2/login/update/?': pickMethod({ + POST: loginUpdateRoute }) } export const allRoutes: Serverlet = pickPath(urls, missingRoute) diff --git a/src/types/pushApiTypes.ts b/src/types/pushApiTypes.ts new file mode 100644 index 0000000..047557e --- /dev/null +++ b/src/types/pushApiTypes.ts @@ -0,0 +1,126 @@ +import { + asArray, + asBoolean, + asDate, + asEither, + asNull, + asObject, + asOptional, + asString, + asUnknown, + Cleaner +} from 'cleaners' + +import { + asBase64, + asBroadcastTx, + asNewPushEvent, + asPushEventState, + asPushMessage, + asPushTrigger +} from './pushCleaners' +import { NewPushEvent, PushEvent } from './pushTypes' + +// --------------------------------------------------------------------------- +// Request types +// --------------------------------------------------------------------------- + +export interface PushRequestBody { + // The request payload: + data?: unknown + + // Who is making the request: + apiKey: string + deviceId: string + deviceToken?: string + + // For logins: + loginId?: Uint8Array +} + +export interface DeviceUpdatePayload { + loginIds: Uint8Array[] + createEvents?: NewPushEvent[] + removeEvents?: string[] +} + +/** + * PUSH /v2/login/update payload. + */ +export interface LoginUpdatePayload { + createEvents?: NewPushEvent[] + removeEvents?: string[] +} + +// --------------------------------------------------------------------------- +// Request cleaners +// --------------------------------------------------------------------------- + +export const asPushRequestBody: Cleaner = asObject({ + // The request payload: + data: asUnknown, + + // Who is making the request: + apiKey: asString, + deviceId: asString, + deviceToken: asOptional(asString), + + // For logins: + loginId: asOptional(asBase64) +}) + +/** + * PUSH /v2/device/update payload. + */ +export const asDeviceUpdatePayload: Cleaner = asObject({ + loginIds: asArray(asBase64), + createEvents: asOptional(asArray(asNewPushEvent), []), + removeEvents: asOptional(asArray(asString), []) +}) + +/** + * PUSH /v2/login/update payload. + */ +export const asLoginUpdatePayload: Cleaner = asObject({ + createEvents: asOptional(asArray(asNewPushEvent), []), + removeEvents: asOptional(asArray(asString), []) +}) + +// --------------------------------------------------------------------------- +// Response types +// --------------------------------------------------------------------------- + +/** + * A push event returned from a query. + */ +export const asPushEventStatus: Cleaner< + Omit +> = asObject({ + eventId: asString, + + broadcastTxs: asOptional(asArray(asBroadcastTx)), + pushMessage: asOptional(asPushMessage), + recurring: asBoolean, + trigger: asPushTrigger, + + // Status: + broadcastTxErrors: asOptional(asArray(asEither(asString, asNull))), + pushMessageError: asOptional(asString), + state: asPushEventState, + triggered: asOptional(asDate) +}) + +/** + * POST /v2/device response payload. + */ +export const asDevicePayload = asObject({ + loginIds: asArray(asBase64), + events: asArray(asPushEventStatus) +}) + +/** + * POST /v2/login response payload. + */ +export const asLoginPayload = asObject({ + events: asArray(asPushEventStatus) +}) diff --git a/src/types/pushCleaners.ts b/src/types/pushCleaners.ts new file mode 100644 index 0000000..d98b0b0 --- /dev/null +++ b/src/types/pushCleaners.ts @@ -0,0 +1,97 @@ +import { + asArray, + asBoolean, + asCodec, + asEither, + asNumber, + asObject, + asOptional, + asString, + asValue, + Cleaner +} from 'cleaners' +import { base64 } from 'rfc4648' + +import { + AddressBalanceTrigger, + BroadcastTx, + NewPushEvent, + PriceChangeTrigger, + PriceLevelTrigger, + PushEventState, + PushMessage, + PushTrigger, + TxConfirmTrigger +} from './pushTypes' + +export const asBase64 = asCodec( + raw => base64.parse(asString(raw)), + clean => base64.stringify(clean) +) + +export const asAddressBalanceTrigger: Cleaner = asObject( + { + type: asValue('address-balance'), + pluginId: asString, + tokenId: asOptional(asString), + address: asString, + aboveAmount: asOptional(asString), // Satoshis or Wei or such + belowAmount: asOptional(asString) // Satoshis or Wei or such + } +) + +export const asPriceChangeTrigger: Cleaner = asObject({ + type: asValue('price-change'), + pluginId: asString, + tokenId: asOptional(asString), + dailyChange: asOptional(asNumber), // Percentage + hourlyChange: asOptional(asNumber) // Percentage +}) + +export const asPriceLevelTrigger: Cleaner = asObject({ + type: asValue('price-level'), + currencyPair: asString, // From our rates server + aboveRate: asOptional(asNumber), + belowRate: asOptional(asNumber) +}) + +export const asTxConfirmTrigger: Cleaner = asObject({ + type: asValue('tx-confirm'), + pluginId: asString, + confirmations: asNumber, + txid: asString +}) + +export const asPushTrigger: Cleaner = asEither( + asAddressBalanceTrigger, + asPriceChangeTrigger, + asPriceLevelTrigger, + asTxConfirmTrigger +) + +export const asBroadcastTx: Cleaner = asObject({ + pluginId: asString, + rawTx: asBase64 +}) + +export const asPushMessage: Cleaner = asObject({ + title: asOptional(asString), + body: asOptional(asString), + data: asObject(asString) +}) + +export const asPushEventState: Cleaner = asValue( + 'waiting', + 'cancelled', + 'triggered', + 'complete', + 'hidden' +) + +export const asNewPushEvent: Cleaner = asObject({ + eventId: asString, + broadcastTxs: asOptional(asArray(asBroadcastTx)), + pushMessage: asOptional(asPushMessage), + recurring: asBoolean, + trigger: asPushTrigger +}) diff --git a/src/types/pushTypes.ts b/src/types/pushTypes.ts index 979fcb8..204acd4 100644 --- a/src/types/pushTypes.ts +++ b/src/types/pushTypes.ts @@ -28,3 +28,117 @@ export interface ApiKey { admin: boolean adminsdk?: FirebaseAdminKey } + +/** + * An app installed on a single phone. + * + * This the in-memory format, independent of the database. + */ +export interface Device { + readonly created: Date + readonly deviceId: string + + // Settings: + apiKey: string | undefined // Which app to send to? + deviceToken: string | undefined + loginIds: Uint8Array[] + visited: Date +} + +// +// Events that devices or logins may subscribe to. +// + +export interface AddressBalanceTrigger { + readonly type: 'address-balance' + readonly pluginId: string + readonly tokenId?: string + readonly address: string + readonly aboveAmount?: string // Satoshis or Wei or such + readonly belowAmount?: string // Satoshis or Wei or such +} + +export interface PriceChangeTrigger { + readonly type: 'price-change' + readonly pluginId: string + readonly tokenId?: string + readonly dailyChange?: number // Percentage + readonly hourlyChange?: number // Percentage +} + +export interface PriceLevelTrigger { + readonly type: 'price-level' + readonly currencyPair: string // From our rates server + readonly aboveRate?: number + readonly belowRate?: number +} + +export interface TxConfirmTrigger { + readonly type: 'tx-confirm' + readonly pluginId: string + readonly confirmations: number + readonly txid: string +} + +export type PushTrigger = + | AddressBalanceTrigger + | PriceChangeTrigger + | PriceLevelTrigger + | TxConfirmTrigger + +/** + * Broadcasts a transaction to a blockchain. + */ +export interface BroadcastTx { + readonly pluginId: string + readonly rawTx: Uint8Array // asBase64 +} + +/** + * Sends a push notification. + */ +export interface PushMessage { + readonly title?: string + readonly body?: string + readonly data?: { [key: string]: string } // JSON to push to device +} + +export type PushEventState = + | 'waiting' // Waiting for the trigger + | 'cancelled' // Removed before the trigger happened + | 'triggered' // The trigger happened, but not the effects + | 'complete' // The trigger and effects are done + | 'hidden' // Removed after being triggered + +/** + * Combines a trigger with an action. + * This the in-memory format, independent of the database. + */ +export interface PushEvent { + readonly created: Date + readonly eventId: string // From the client, not globally unique + readonly deviceId?: string + readonly loginId?: Uint8Array + + readonly broadcastTxs?: BroadcastTx[] + readonly pushMessage?: PushMessage + readonly recurring: boolean // Go back to waiting once complete + readonly trigger: PushTrigger + + // Mutable state: + broadcastTxErrors?: Array // For ones that fail + pushMessageError?: string // If we couldn't send + state: PushEventState + triggered?: Date // When did we see the trigger? +} + +/** + * Template for creating new push events. + */ +export interface NewPushEvent { + readonly eventId: string + readonly broadcastTxs?: BroadcastTx[] + readonly pushMessage?: PushMessage + readonly recurring: boolean + readonly trigger: PushTrigger +} diff --git a/src/types/requestTypes.ts b/src/types/requestTypes.ts index 22d9c40..5ee8738 100644 --- a/src/types/requestTypes.ts +++ b/src/types/requestTypes.ts @@ -1,6 +1,7 @@ import { ServerScope } from 'nano' import { ExpressRequest } from 'serverlet/express' +import { DeviceRow } from '../db/couchDevices' import { ApiKey } from './pushTypes' export interface Logger { @@ -27,3 +28,11 @@ export interface ApiRequest extends DbRequest { readonly json: unknown readonly query: unknown } + +export interface DeviceRequest extends DbRequest { + readonly payload: unknown + + readonly apiKey: ApiKey + readonly deviceRow: DeviceRow + readonly loginId?: Uint8Array +} diff --git a/yarn.lock b/yarn.lock index f33be48..82a1f30 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3369,6 +3369,11 @@ reusify@^1.0.4: resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.0.4.tgz#90da382b1e126efc02146e90845a88db12925d76" integrity sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw== +rfc4648@^1.5.2: + version "1.5.2" + resolved "https://registry.yarnpkg.com/rfc4648/-/rfc4648-1.5.2.tgz#cf5dac417dd83e7f4debf52e3797a723c1373383" + integrity sha512-tLOizhR6YGovrEBLatX1sdcuhoSCXddw3mqNVAcKxGJ+J0hFeJ+SjeWCv5UPA/WU3YzWPPuCVYgXBKZUPGpKtg== + rfdc@^1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/rfdc/-/rfdc-1.3.0.tgz#d0b7c441ab2720d05dc4cf26e01c89631d9da08b" From 32b732ab26790a8cbec17aa318897bf1e28c610a Mon Sep 17 00:00:00 2001 From: William Swanson Date: Fri, 5 Aug 2022 14:05:04 -0700 Subject: [PATCH 5/6] Add a demo app --- README.md | 2 + docs/demo.ts | 112 +++++++++++++++++++++++++++++++++++++++++++++++++++ package.json | 1 + 3 files changed, 115 insertions(+) create mode 100644 docs/demo.ts diff --git a/README.md b/README.md index 0cb7b9d..9649323 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,8 @@ This server sends push notifications to Edge client apps. It contains an HTTP server that clients can use to register for notifications, and a background process that checks for price changes and actually sends the messages. +The docs folder has can find [an example of how to use the v2 API](./docs/demo.ts). + ## Setup This server requires a working copies of Node.js, Yarn, PM2, and CouchDB. We also recommend using Caddy to terminate SSL connections. diff --git a/docs/demo.ts b/docs/demo.ts new file mode 100644 index 0000000..730b72a --- /dev/null +++ b/docs/demo.ts @@ -0,0 +1,112 @@ +import { asJSON, asMaybe, asObject, asString, uncleaner } from 'cleaners' +import fetch from 'node-fetch' +import { base64 } from 'rfc4648' + +import { + asDeviceUpdatePayload, + asLoginUpdatePayload, + asPushRequestBody +} from '../src/types/pushApiTypes' + +// We are going to use uncleaners to type-check our payloads: +const wasPushRequestBody = uncleaner(asPushRequestBody) +const wasDeviceUpdatePayload = uncleaner(asDeviceUpdatePayload) +const wasLoginUpdatePayload = uncleaner(asLoginUpdatePayload) + +/** + * Failed requests usually return this as their body. + */ +const asErrorBody = asJSON( + asObject({ + error: asString + }) +) + +const apiKey = 'demo-api-key' +const deviceId = 'example-device' +const loginId = base64.parse('EE+tBb5wM63qwCDVidzwUQThH9ekCSfpUuTQYujSmY8=') + +/** + * All push server HTTP methods use "POST" with JSON. + */ +async function postJson(uri: string, body: unknown): Promise { + console.log(JSON.stringify(body, null, 1)) + const response = await fetch(uri, { + body: JSON.stringify(body), + headers: { + accept: 'application/json', + 'content-type': 'application/json' + }, + method: 'POST' + }) + if (!response.ok) { + const error = asMaybe(asErrorBody)(await response.text()) + let message = `POST ${uri} returned ${response.status}` + if (error != null) message += `: ${error.error}` + throw new Error(message) + } + return await response.json() +} + +async function main(): Promise { + // Create a device: + await postJson( + 'http://127.0.0.1:8001/v2/device/update/', + wasPushRequestBody({ + apiKey, + deviceId, + data: wasDeviceUpdatePayload({ loginIds: [loginId] }) + }) + ) + console.log(`Updated device "${deviceId}"`) + + // Grab the device status: + console.log( + await postJson( + 'http://127.0.0.1:8001/v2/device/', + wasPushRequestBody({ apiKey, deviceId }) + ) + ) + + // Subscribe the user to a price change: + await postJson( + 'http://127.0.0.1:8001/v2/login/update/', + wasPushRequestBody({ + apiKey, + deviceId, + loginId, + data: wasLoginUpdatePayload({ + createEvents: [ + { + eventId: 'demo-event', + pushMessage: { + title: 'Example title', + body: 'Example body', + data: { what: 'happened' } + }, + recurring: false, + trigger: { + type: 'price-level', + currencyPair: 'BTC-USD', + aboveRate: 50000 + } + } + ] + }) + }) + ) + console.log(`Updated login "${base64.stringify(loginId)}"`) + + // Grab the login status: + console.log( + await postJson( + 'http://127.0.0.1:8001/v2/login/', + wasPushRequestBody({ apiKey, deviceId, loginId }) + ) + ) +} + +main().catch(error => { + console.error(String(error)) + process.exitCode = 1 +}) diff --git a/package.json b/package.json index c5cae2d..d03bac9 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "scripts": { "build": "sucrase -q -t typescript,imports -d ./lib ./src", "clean": "rimraf lib", + "demo": "node -r sucrase/register docs/demo.ts", "fix": "yarn-deduplicate && eslint . --fix", "lint": "eslint .", "precommit": "lint-staged && npm-run-all types prepare", From 615ddddcae6e3e85fb79b8e8ea95e069b0a18235 Mon Sep 17 00:00:00 2001 From: William Swanson Date: Thu, 4 Aug 2022 10:50:14 -0700 Subject: [PATCH 6/6] Add a `triggerEvent` helper method --- src/price-script/checkPriceChanges.ts | 11 +++- src/price-script/index.ts | 17 +----- src/routes/notificationRoute.ts | 7 ++- src/util/pushSender.ts | 81 +++++++++++++++++++-------- src/util/triggerEvent.ts | 53 ++++++++++++++++++ 5 files changed, 125 insertions(+), 44 deletions(-) create mode 100644 src/util/triggerEvent.ts diff --git a/src/price-script/checkPriceChanges.ts b/src/price-script/checkPriceChanges.ts index 81e134f..ec957e9 100644 --- a/src/price-script/checkPriceChanges.ts +++ b/src/price-script/checkPriceChanges.ts @@ -1,11 +1,13 @@ import io from '@pm2/io' import { MetricType } from '@pm2/io/build/main/services/metrics' +import nano from 'nano' import { syncedSettings } from '../db/couchSettings' import { CurrencyThreshold } from '../models/CurrencyThreshold' import { Device } from '../models/Device' import { User } from '../models/User' -import { PushResult, PushSender } from '../util/pushSender' +import { serverConfig } from '../serverConfig' +import { makePushSender, PushResult } from '../util/pushSender' import { fetchThresholdPrice } from './fetchThresholdPrices' // Firebase Messaging API limits batch messages to 500 @@ -23,7 +25,10 @@ export interface NotificationPriceChange { priceChange: number } -export async function checkPriceChanges(sender: PushSender): Promise { +export async function checkPriceChanges(apiKey: string): Promise { + const { couchUri } = serverConfig + const sender = await makePushSender(nano(couchUri)) + // Sends a notification to devices about a price change async function sendNotification( thresholdPrice: NotificationPriceChange, @@ -40,7 +45,7 @@ export async function checkPriceChanges(sender: PushSender): Promise { const body = `${currencyCode} is ${direction} ${symbol}${priceChange}% to $${displayPrice} in the last ${time}.` const data = {} - return await sender.send(title, body, deviceTokens, data) + return await sender.send(apiKey, deviceTokens, { title, body, data }) } // Fetch list of threshold items and their prices diff --git a/src/price-script/index.ts b/src/price-script/index.ts index 1f3df7e..94269dd 100644 --- a/src/price-script/index.ts +++ b/src/price-script/index.ts @@ -2,11 +2,9 @@ import io from '@pm2/io' import { makePeriodicTask } from 'edge-server-tools' import nano from 'nano' -import { getApiKeyByKey } from '../db/couchApiKeys' import { syncedSettings } from '../db/couchSettings' import { setupDatabases } from '../db/couchSetup' import { serverConfig } from '../serverConfig' -import { makePushSender } from '../util/pushSender' import { checkPriceChanges } from './checkPriceChanges' const runCounter = io.counter({ @@ -25,24 +23,13 @@ async function main(): Promise { throw new Error('No partner apiKeys') } - // Read the API keys from settings: - const senders = await Promise.all( - syncedSettings.doc.apiKeys.map(async partner => { - const apiKey = await getApiKeyByKey(connection, partner.apiKey) - if (apiKey == null) { - throw new Error(`Cannot find API key ${partner.apiKey}`) - } - return await makePushSender(apiKey) - }) - ) - // Check the prices every few minutes: const task = makePeriodicTask( async () => { runCounter.inc() - for (const sender of senders) { - await checkPriceChanges(sender) + for (const apiKey of syncedSettings.doc.apiKeys) { + await checkPriceChanges(apiKey.apiKey) } }, 60 * 1000 * syncedSettings.doc.priceCheckInMinutes, diff --git a/src/routes/notificationRoute.ts b/src/routes/notificationRoute.ts index 599856d..a8d9c94 100644 --- a/src/routes/notificationRoute.ts +++ b/src/routes/notificationRoute.ts @@ -15,11 +15,11 @@ import { makePushSender } from '../util/pushSender' * Response body: unused */ export const sendNotificationV1Route: Serverlet = async request => { - const { apiKey, json, log } = request + const { apiKey, connection, json, log } = request const { title, body, data, userId } = asSendNotificationBody(json) if (!apiKey.admin) return errorResponse('Not an admin', { status: 401 }) - const sender = await makePushSender(apiKey) + const sender = makePushSender(connection) const user = await User.fetch(userId) if (user == null) { @@ -34,7 +34,8 @@ export const sendNotificationV1Route: Serverlet = async request => { } } - const response = await sender.send(title, body, tokens, data) + const message = { title, body, data } + const response = await sender.send(apiKey.apiKey, tokens, message) const { successCount, failureCount } = response log( `Sent notifications to user ${userId} devices: ${successCount} success - ${failureCount} failure` diff --git a/src/util/pushSender.ts b/src/util/pushSender.ts index bed810b..3c9d89f 100644 --- a/src/util/pushSender.ts +++ b/src/util/pushSender.ts @@ -1,7 +1,8 @@ import io from '@pm2/io' import admin from 'firebase-admin' +import { ServerScope } from 'nano' -import { ApiKey } from '../types/pushTypes' +import { getApiKeyByKey } from '../db/couchApiKeys' const successCounter = io.counter({ id: 'notifications:success:total', @@ -19,43 +20,77 @@ export interface PushResult { export interface PushSender { send: ( - title: string, - body: string, - tokens: string[], - data?: { [key: string]: string } + apiKey: string, + deviceTokens: string[], + message: { + title?: string + body?: string + data?: { [key: string]: string } + } ) => Promise } -export async function makePushSender(apiKey: ApiKey): Promise { - const name = `app:${apiKey.appId}` - let app: admin.app.App - try { - admin.app(name) - } catch (err) { - app = admin.initializeApp( - { - // TODO: We have never passed the correct data type here, - // so either update our database or write a translation layer: - credential: admin.credential.cert(apiKey.adminsdk as any) - }, - name +/** + * Creates a push notification sender object. + * This object uses a cache to map appId's to Firebase credentials, + * based on the Couch database. + */ +export function makePushSender(connection: ServerScope): PushSender { + // Map apiKey's to message senders, or `null` if missing: + const senders = new Map() + + async function getSender( + apiKey: string + ): Promise { + const cached = senders.get(apiKey) + // Null is a valid cache hit: + if (cached !== undefined) { + return cached + } + + // Look up the API key for this appId: + const apiKeyRow = await getApiKeyByKey(connection, apiKey) + if (apiKeyRow == null || apiKeyRow.adminsdk == null) { + senders.set(apiKey, null) + return null + } + + // TODO: We have never passed the correct data type here, + // so either update our database or write a translation layer: + const serviceAccount: any = apiKeyRow.adminsdk + + // Create a sender if we have an API key for them: + const app = admin.initializeApp( + { credential: admin.credential.cert(serviceAccount) }, + serviceAccount.projectId ?? serviceAccount.project_id ) + const sender = app.messaging() + senders.set(apiKey, sender) + return sender } return { - async send(title, body, tokens, data = {}) { - const response = await app - .messaging() + async send(apiKey, tokens, message) { + const { title = '', body = '', data = {} } = message + + const failure = { + successCount: 0, + failureCount: tokens.length + } + + const sender = await getSender(apiKey) + if (sender == null) return failure + + const response = await sender .sendMulticast({ data, notification: { title, body }, tokens }) - .catch(() => ({ successCount: 0, failureCount: tokens.length })) + .catch(() => failure) successCounter.inc(response.successCount) failureCounter.inc(response.failureCount) - return response } } diff --git a/src/util/triggerEvent.ts b/src/util/triggerEvent.ts new file mode 100644 index 0000000..e79d577 --- /dev/null +++ b/src/util/triggerEvent.ts @@ -0,0 +1,53 @@ +import { ServerScope } from 'nano' + +import { getDeviceById, getDevicesByLoginId } from '../db/couchDevices' +import { PushEventRow } from '../db/couchPushEvents' +import { PushSender } from './pushSender' + +/** + * Handles all the effects once a row has been triggered. + */ +export async function triggerEvent( + connection: ServerScope, + sender: PushSender, + eventRow: PushEventRow, + date: Date +): Promise { + const { event } = eventRow + const { broadcastTxs = [], pushMessage } = event + + if (pushMessage != null) { + const deviceRows = + event.deviceId != null + ? [await getDeviceById(connection, event.deviceId, date)] + : event.loginId != null + ? await getDevicesByLoginId(connection, event.loginId) + : [] + + // Sort the devices by app: + const apiKeys = new Map() + for (const row of deviceRows) { + const { apiKey, deviceToken } = row.device + if (apiKey == null || deviceToken == null) continue + const tokens = apiKeys.get(apiKey) ?? [] + tokens.push(deviceToken) + apiKeys.set(apiKey, tokens) + } + + for (const [apiKey, tokens] of apiKeys) { + await sender.send(apiKey, tokens, pushMessage) + } + + // TODO: Take note of any errors. + event.pushMessageError = undefined + } + + for (const tx of broadcastTxs) { + console.log(tx) // TODO + event.broadcastTxErrors = [] + } + + event.state = event.recurring ? 'waiting' : 'triggered' + event.triggered = date + await eventRow.save() +}