From 94a54e68c1af042bb3a3f88fceda0bde343568a5 Mon Sep 17 00:00:00 2001 From: William Swanson Date: Thu, 28 Jul 2022 15:11:21 -0700 Subject: [PATCH] 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"