From c8803cee1dd15a725d07f0ce4d54e5aad9f62d25 Mon Sep 17 00:00:00 2001 From: Xipu Li Date: Fri, 22 Jul 2022 17:34:14 -0700 Subject: [PATCH 1/8] Add types definition --- src/types/pushTypes.ts | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/src/types/pushTypes.ts b/src/types/pushTypes.ts index 979fcb8..c611700 100644 --- a/src/types/pushTypes.ts +++ b/src/types/pushTypes.ts @@ -28,3 +28,37 @@ export interface ApiKey { admin: boolean adminsdk?: FirebaseAdminKey } + +export interface UserDevices { + [deviceId: string]: boolean +} + +export interface UserNotifications { + enabled?: boolean + currencyCodes: UserCurrencyCodes +} + +export interface UserCurrencyCodes { + [currencyCode: string]: UserCurrencyHours +} + +export interface UserCurrencyHours { + '1': boolean + '24': boolean +} + +export interface User { + userId: string + devices: UserDevices + notifications: UserNotifications +} + +export interface Device { + deviceId: string + appId: string + tokenId?: string + deviceDescription: string + osType: string + edgeVersion: string + edgeBuildNumber: number +} From 3cd88a91e1ee67dc868cb86c9b496a1fd499ceda Mon Sep 17 00:00:00 2001 From: Xipu Li Date: Fri, 22 Jul 2022 17:35:21 -0700 Subject: [PATCH 2/8] Create couch cleanup files for Device and User models --- src/db/couchDevices.ts | 69 +++++++++++++ src/db/couchSetup.ts | 16 ++- src/db/couchUsers.ts | 140 ++++++++++++++++++++++++++ src/db/utils/couchOps.ts | 23 +++++ src/price-script/checkPriceChanges.ts | 3 +- 5 files changed, 241 insertions(+), 10 deletions(-) create mode 100644 src/db/couchDevices.ts create mode 100644 src/db/couchUsers.ts create mode 100644 src/db/utils/couchOps.ts diff --git a/src/db/couchDevices.ts b/src/db/couchDevices.ts new file mode 100644 index 0000000..76a33eb --- /dev/null +++ b/src/db/couchDevices.ts @@ -0,0 +1,69 @@ +import { asNumber, asObject, asOptional, asString } from 'cleaners' +import { + asCouchDoc, + asMaybeNotFoundError, + DatabaseSetup +} from 'edge-server-tools' +import { ServerScope } from 'nano' + +import { Device, User } from '../types/pushTypes' +import { saveToDB } from './utils/couchOps' + +export const asCouchDevice = asCouchDoc>( + asObject({ + appId: asString, + tokenId: asOptional(asString), + deviceDescription: asString, + osType: asString, + edgeVersion: asString, + edgeBuildNumber: asNumber + }) +) + +type CouchDevice = ReturnType +export const devicesSetup: DatabaseSetup = { name: 'db_devices' } + +export const fetchDevicesByUser = async ( + connection: ServerScope, + user: User +): Promise => { + const devices = [] + for (const deviceId in user.devices) { + const device = await fetchDevice(connection, deviceId) + devices.push(device) + } + return devices +} + +export const fetchDevice = async ( + connection: ServerScope, + deviceId: string +): Promise => { + const db = connection.db.use(devicesSetup.name) + const raw = await db.get(deviceId).catch(error => { + if (asMaybeNotFoundError(error) != null) return + throw error + }) + const deviceDoc = asCouchDevice(raw) + return unpackDevice(deviceDoc) +} + +export const saveDeviceToDB = async ( + connection: ServerScope, + device: Device +): Promise => { + const db = connection.db.use(devicesSetup.name) + await saveToDB(db, packDevice(device)) +} + +export const unpackDevice = (doc: CouchDevice): Device => { + return { ...doc.doc, deviceId: doc.id } +} + +export const packDevice = (device: Device): CouchDevice => { + const { deviceId, ...doc } = device + return { + id: deviceId, + doc: doc + } +} diff --git a/src/db/couchSetup.ts b/src/db/couchSetup.ts index d158e23..2b64ba1 100644 --- a/src/db/couchSetup.ts +++ b/src/db/couchSetup.ts @@ -6,23 +6,21 @@ import { import { ServerScope } from 'nano' import { serverConfig } from '../serverConfig' -import { couchApiKeysSetup } from './couchApiKeys' +import { devicesSetup } from './couchDevices' import { settingsSetup, syncedReplicators } from './couchSettings' +import { usersSetup } from './couchUsers' // --------------------------------------------------------------------------- // Databases // --------------------------------------------------------------------------- -const thresholdsSetup: DatabaseSetup = { name: 'db_currency_thresholds' } +export const apiKeysSetup: DatabaseSetup = { name: 'db_api_keys' } -const devicesSetup: DatabaseSetup = { name: 'db_devices' } +export const thresholdsSetup: DatabaseSetup = { name: 'db_currency_thresholds' } -const usersSetup: DatabaseSetup = { - name: 'db_user_settings' - // documents: { - // '_design/filter': makeJsDesign('by-currency', ?), - // '_design/map': makeJsDesign('currency-codes', ?) - // } +export const defaultsSetup: DatabaseSetup = { + name: 'defaults' + // syncedDocuments: ['thresholds'] } // --------------------------------------------------------------------------- diff --git a/src/db/couchUsers.ts b/src/db/couchUsers.ts new file mode 100644 index 0000000..ca9b035 --- /dev/null +++ b/src/db/couchUsers.ts @@ -0,0 +1,140 @@ +import { asBoolean, asMap, asObject, asOptional, Cleaner } from 'cleaners' +import { + asCouchDoc, + asMaybeNotFoundError, + DatabaseSetup, + makeJsDesign +} from 'edge-server-tools' +import { ServerScope } from 'nano' + +import { + Device, + User, + UserCurrencyCodes, + UserCurrencyHours, + UserDevices, + UserNotifications +} from '../types/pushTypes' +import { saveToDB } from './utils/couchOps' + +export const asUserDevices: Cleaner = asObject(asBoolean) +export type IDevicesByCurrencyHoursViewResponse = ReturnType< + typeof asUserDevices +> +export const asUserCurrencyHours: Cleaner = asObject({ + '1': asBoolean, + '24': asBoolean +}) +export const asUserCurrencyCodes: Cleaner = + asMap(asUserCurrencyHours) + +export const asUserNotifications: Cleaner = asObject({ + enabled: asOptional(asBoolean), + currencyCodes: asUserCurrencyCodes +}) + +export const asCouchUser = asCouchDoc>( + asObject({ + devices: asUserDevices, + notifications: asUserNotifications + }) +) + +type CouchUser = ReturnType + +export const usersSetup: DatabaseSetup = { + name: 'db_user_settings', + documents: { + '_design/filter': makeJsDesign('by-currency', ({ emit }) => ({ + map: function (doc) { + const notifs = doc.notifications + if (notifs?.enabled && notifs.currencyCodes) { + const codes = notifs.currencyCodes + for (const currencyCode in codes) { + for (const hours in codes[currencyCode]) { + const enabled = codes[currencyCode][hours] + if (enabled) { + emit([currencyCode, hours], doc.devices) + } + } + } + } + } + })), + '_design/map': makeJsDesign('currency-codes', ({ emit }) => ({ + map: function (doc) { + if (doc.notifications?.currencyCodes) { + for (const code in doc.notifications.currencyCodes) { + emit(null, code) + } + } + }, + reduce: function (keys, values, rereduce) { + return Array.from(new Set(values)) + } + })) + } +} + +// ------------------------------------------------------------------------------ +// Functions associated with User +// ------------------------------------------------------------------------------ + +export const cleanUpMissingDevices = async ( + connection: ServerScope, + user: User, + devices: Device[] +): Promise => { + const db = connection.db.use(usersSetup.name) + let updated = false + for (const device of devices) { + if (user.devices[device.deviceId] == null) { + user.devices[device.deviceId] = true + updated = true + } + } + if (updated) await saveToDB(db, packUser(user)) +} + +export const devicesByCurrencyHours = async ( + connection: ServerScope, + hours: string, + currencyCode: string +) => { + return await connection.db + .use(usersSetup.name) + .view('filter', 'by-currency', { + key: [currencyCode, hours] + }) +} + +export const saveUserToDB = async ( + connection: ServerScope, + user: User +): Promise => { + const db = connection.db.use(usersSetup.name) + await saveToDB(db, packUser(user)) +} + +export const fetchUser = async ( + connection: ServerScope, + userId: string +): Promise => { + const db = connection.db.use(usersSetup.name) + const raw = await db.get(userId).catch(error => { + if (asMaybeNotFoundError(error) != null) return + throw error + }) + const userDoc = asCouchUser(raw) + return unpackUser(userDoc) +} + +export const unpackUser = (doc: CouchUser): User => { + return { ...doc.doc, userId: doc.id } +} +export const packUser = (user: User): CouchUser => { + return { + doc: { devices: user.devices, notifications: user.notifications }, + id: user.userId + } +} diff --git a/src/db/utils/couchOps.ts b/src/db/utils/couchOps.ts new file mode 100644 index 0000000..74c2b26 --- /dev/null +++ b/src/db/utils/couchOps.ts @@ -0,0 +1,23 @@ +import { CouchDoc } from 'edge-server-tools' +import { DocumentScope } from 'nano' + +export const saveToDB = async ( + db: DocumentScope, + doc: CouchDoc +): Promise => { + try { + await db.insert({ + ...doc.doc, + _id: doc.id, + _rev: doc.rev ?? undefined + }) + } catch (err: any) { + switch (err.statusCode) { + case 404: + throw new Error('Database does not exist') + + default: + throw err + } + } +} diff --git a/src/price-script/checkPriceChanges.ts b/src/price-script/checkPriceChanges.ts index 81e134f..d04b4da 100644 --- a/src/price-script/checkPriceChanges.ts +++ b/src/price-script/checkPriceChanges.ts @@ -64,7 +64,8 @@ export async function checkPriceChanges(sender: PushSender): Promise { ) if (priceData == null) continue - const { rows: usersDevices } = await User.devicesByCurrencyHours( + const { rows: usersDevices } = await devicesByCurrencyHours( + connection, currencyCode, hours ) From 0e43c932cc3b84cc9b37b0d5f49e85d6cab5142b Mon Sep 17 00:00:00 2001 From: Xipu Li Date: Thu, 28 Jul 2022 17:32:03 -0700 Subject: [PATCH 3/8] Replace references to models by cleanups --- src/db/couchSetup.ts | 1 + src/models/Device.ts | 29 ------ src/models/User.ts | 124 -------------------------- src/price-script/checkPriceChanges.ts | 17 ++-- src/price-script/index.ts | 2 +- 5 files changed, 14 insertions(+), 159 deletions(-) delete mode 100644 src/models/Device.ts delete mode 100644 src/models/User.ts diff --git a/src/db/couchSetup.ts b/src/db/couchSetup.ts index 2b64ba1..fdd8c5e 100644 --- a/src/db/couchSetup.ts +++ b/src/db/couchSetup.ts @@ -6,6 +6,7 @@ import { import { ServerScope } from 'nano' import { serverConfig } from '../serverConfig' +import { couchApiKeysSetup } from './couchApiKeys' import { devicesSetup } from './couchDevices' import { settingsSetup, syncedReplicators } from './couchSettings' import { usersSetup } from './couchUsers' diff --git a/src/models/Device.ts b/src/models/Device.ts deleted file mode 100644 index eb9a331..0000000 --- a/src/models/Device.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { asNumber, asObject, asOptional, asString } from 'cleaners' -import Nano from 'nano' - -import { serverConfig } from '../serverConfig' -import { Base } from './base' - -const nanoDb = Nano(serverConfig.couchUri) -const dbDevices = nanoDb.db.use>('db_devices') - -const asDevice = asObject({ - appId: asString, - tokenId: asOptional(asString), - deviceDescription: asString, - osType: asString, - edgeVersion: asString, - edgeBuildNumber: asNumber -}) - -export class Device extends Base implements ReturnType { - public static table = dbDevices - public static asType = asDevice - - public appId!: string - public tokenId!: string | undefined - public deviceDescription!: string - public osType!: string - public edgeVersion!: string - public edgeBuildNumber!: number -} diff --git a/src/models/User.ts b/src/models/User.ts deleted file mode 100644 index af9c726..0000000 --- a/src/models/User.ts +++ /dev/null @@ -1,124 +0,0 @@ -/* eslint-disable @typescript-eslint/explicit-function-return-type */ -/* eslint-disable @typescript-eslint/no-dynamic-delete */ -/* eslint-disable @typescript-eslint/strict-boolean-expressions */ - -import { asBoolean, asMap, asObject, asOptional } from 'cleaners' -import Nano from 'nano' - -import { serverConfig } from '../serverConfig' -import { Base } from './base' -import { Device } from './Device' - -const nanoDb = Nano(serverConfig.couchUri) -const dbUserSettings = - nanoDb.db.use>('db_user_settings') - -const asUserDevices = asMap(asBoolean) -const asUserCurrencyHours = asObject({ - '1': asBoolean, - '24': asBoolean -}) -const asUserCurrencyCodes = asMap(asUserCurrencyHours) -const asUserNotifications = asObject({ - enabled: asOptional(asBoolean), - currencyCodes: asUserCurrencyCodes -}) -const asUser = asObject({ - devices: asUserDevices, - notifications: asUserNotifications -}) - -export interface INotificationsEnabledViewResponse { - devices: ReturnType - currencyCodes: ReturnType -} - -export type IDevicesByCurrencyHoursViewResponse = ReturnType< - typeof asUserDevices -> - -export class User extends Base implements ReturnType { - public static table = dbUserSettings - public static asType = asUser - - public devices: ReturnType - public notifications: ReturnType - - // @ts-expect-error - constructor(...args) { - super(...args) - - // @ts-expect-error - if (!this.devices) this.devices = {} - // @ts-expect-error - if (!this.notifications) { - this.notifications = { - enabled: true, - currencyCodes: {} - } - } - } - - // Fetch data for users that have notifications enabled using CouchDB Design Document View - // https://notif1.edge.app:6984/_utils/#/database/db_user_settings/_design/filter/_view/by-currency - public static async devicesByCurrencyHours( - currencyCode: string, - hours: string - ) { - return await User.table.view( - 'filter', - 'by-currency', - { key: [currencyCode, hours] } - ) - } - - public async attachDevice(deviceId: string) { - const device = await Device.fetch(deviceId) - if (!device) - throw new Error('Device must be registered before attaching to user.') - - this.devices[deviceId] = true - - await this.save() - } - - public async fetchDevices(): Promise { - const devices: Device[] = [] - - let updated = false - for (const deviceId in this.devices) { - const device = await Device.fetch(deviceId) - if (device) { - devices.push(device) - continue - } - - delete this.devices[deviceId] - updated = true - } - - if (updated) await this.save() - - return devices - } - - public async registerNotifications(currencyCodes: string[]) { - const currencyCodesToUnregister = Object.keys( - this.notifications.currencyCodes - ).filter(code => !currencyCodes.includes(code)) - for (const code of currencyCodesToUnregister) { - delete this.notifications.currencyCodes[code] - } - - for (const code of currencyCodes) { - if (code in this.notifications.currencyCodes) continue - - this.notifications.currencyCodes[code] = { - '1': true, - '24': true - } - } - - await this.save() - } -} diff --git a/src/price-script/checkPriceChanges.ts b/src/price-script/checkPriceChanges.ts index d04b4da..8a01a12 100644 --- a/src/price-script/checkPriceChanges.ts +++ b/src/price-script/checkPriceChanges.ts @@ -1,10 +1,12 @@ import io from '@pm2/io' import { MetricType } from '@pm2/io/build/main/services/metrics' +import { ServerScope } from 'nano' +import { devicesSetup } from '../db/couchDevices' import { syncedSettings } from '../db/couchSettings' +import { devicesByCurrencyHours } from '../db/couchUsers' import { CurrencyThreshold } from '../models/CurrencyThreshold' -import { Device } from '../models/Device' -import { User } from '../models/User' +import { Device } from '../types/pushTypes' import { PushResult, PushSender } from '../util/pushSender' import { fetchThresholdPrice } from './fetchThresholdPrices' @@ -23,7 +25,10 @@ export interface NotificationPriceChange { priceChange: number } -export async function checkPriceChanges(sender: PushSender): Promise { +export async function checkPriceChanges( + connection: ServerScope, + sender: PushSender +): Promise { // Sends a notification to devices about a price change async function sendNotification( thresholdPrice: NotificationPriceChange, @@ -78,7 +83,7 @@ export async function checkPriceChanges(sender: PushSender): Promise { deviceIds.push(deviceId) } } - const tokenGenerator = deviceTokenGenerator(deviceIds) + const tokenGenerator = deviceTokenGenerator(connection, deviceIds) let done = false let successCount = 0 let failureCount = 0 @@ -126,14 +131,16 @@ export async function checkPriceChanges(sender: PushSender): Promise { } async function* deviceTokenGenerator( + connection: ServerScope, deviceIds: string[] ): AsyncGenerator { const tokenSet: Set = new Set() let tokens: string[] = [] let bookmark: string | undefined let done = false + const dbDevices = connection.use(devicesSetup.name) while (!done) { - const response = await Device.table.find({ + const response = await dbDevices.find({ bookmark, selector: { _id: { diff --git a/src/price-script/index.ts b/src/price-script/index.ts index 1f3df7e..cc73a71 100644 --- a/src/price-script/index.ts +++ b/src/price-script/index.ts @@ -42,7 +42,7 @@ async function main(): Promise { runCounter.inc() for (const sender of senders) { - await checkPriceChanges(sender) + await checkPriceChanges(connection, sender) } }, 60 * 1000 * syncedSettings.doc.priceCheckInMinutes, From 6b4be04bcf8c292099d6578ceb9616aa6d4404fe Mon Sep 17 00:00:00 2001 From: Xipu Li Date: Fri, 29 Jul 2022 12:28:08 -0700 Subject: [PATCH 4/8] fixup! Replace references to models by cleanups --- src/db/couchSetup.ts | 5 --- src/routes/legacyRoutes.ts | 66 ++++++++++++++++++++++++--------- src/routes/notificationRoute.ts | 35 +++++++++++++++-- 3 files changed, 80 insertions(+), 26 deletions(-) diff --git a/src/db/couchSetup.ts b/src/db/couchSetup.ts index fdd8c5e..bfb5455 100644 --- a/src/db/couchSetup.ts +++ b/src/db/couchSetup.ts @@ -19,11 +19,6 @@ export const apiKeysSetup: DatabaseSetup = { name: 'db_api_keys' } export const thresholdsSetup: DatabaseSetup = { name: 'db_currency_thresholds' } -export const defaultsSetup: DatabaseSetup = { - name: 'defaults' - // syncedDocuments: ['thresholds'] -} - // --------------------------------------------------------------------------- // Setup routine // --------------------------------------------------------------------------- diff --git a/src/routes/legacyRoutes.ts b/src/routes/legacyRoutes.ts index ad36a99..b8305e0 100644 --- a/src/routes/legacyRoutes.ts +++ b/src/routes/legacyRoutes.ts @@ -6,13 +6,17 @@ import { asString, asValue } from 'cleaners' +import nano from 'nano' import { Serverlet } from 'serverlet' -import { Device } from '../models/Device' -import { User } from '../models/User' +import { fetchDevice, saveDeviceToDB } from '../db/couchDevices' +import { fetchUser, saveUserToDB } from '../db/couchUsers' +import { serverConfig } from '../serverConfig' import { ApiRequest } from '../types/requestTypes' import { jsonResponse } from '../types/responseTypes' +const connection = nano(serverConfig.couchUri) + /** * The GUI names this `registerDevice`, and calls it at boot. * @@ -25,13 +29,13 @@ export const registerDeviceV1Route: Serverlet = async request => { const { deviceId } = asRegisterDeviceQuery(query) const clean = asRegisterDeviceRequest(json) - let device = await Device.fetch(deviceId) + let device = await fetchDevice(connection, deviceId) if (device != null) { - await device.save(clean as any) + await saveDeviceToDB(connection, device) log('Device updated.') } else { - device = new Device(clean as any, deviceId) - await device.save() + device = { ...clean, deviceId } + await saveDeviceToDB(connection, device) log(`Device registered.`) } @@ -49,7 +53,7 @@ export const registerDeviceV1Route: Serverlet = async request => { export const fetchStateV1Route: Serverlet = async request => { const { log, query } = request const { userId } = asUserIdQuery(query) - const result = await User.fetch(userId) + const result = fetchUser(connection, userId) log(`Got user settings for ${userId}`) @@ -67,10 +71,15 @@ export const attachUserV1Route: Serverlet = async request => { const { log, query } = request const { deviceId, userId } = asAttachUserQuery(query) - let user = await User.fetch(userId) - if (user == null) user = new User(null, userId) + let user = await fetchUser(connection, userId) + if (user == null) + user = { + userId, + devices: { deviceId: true }, + notifications: { currencyCodes: {} } + } - await user.attachDevice(deviceId) + await saveUserToDB(connection, user) log(`Successfully attached device "${deviceId}" to user "${userId}"`) @@ -92,8 +101,24 @@ export const registerCurrenciesV1Route: Serverlet< const { userId } = asUserIdQuery(query) const { currencyCodes } = asRegisterCurrenciesBody(json) - const user = await User.fetch(userId) - await user.registerNotifications(currencyCodes) + const user = await fetchUser(connection, userId) + const currencyCodesToUnregister = Object.keys( + user.notifications.currencyCodes + ).filter(code => !currencyCodes.includes(code)) + for (const code of currencyCodesToUnregister) { + delete user.notifications.currencyCodes[code] + } + + for (const code of currencyCodes) { + if (code in user.notifications.currencyCodes) continue + + user.notifications.currencyCodes[code] = { + '1': true, + '24': true + } + } + + await saveUserToDB(connection, user) log(`Registered notifications for user ${userId}: ${String(currencyCodes)}`) @@ -114,7 +139,7 @@ export const fetchCurrencyV1Route: Serverlet = async request => { const match = path.match(/notifications\/([0-9A-Za-z]+)\/?$/) const currencyCode = match != null ? match[1] : '' - const user = await User.fetch(userId) + const user = await fetchUser(connection, userId) const currencySettings = user.notifications.currencyCodes[currencyCode] ?? { '1': false, '24': false @@ -140,14 +165,14 @@ export const enableCurrencyV1Route: Serverlet = async request => { const match = path.match(/notifications\/([0-9A-Za-z]+)\/?$/) const currencyCode = match != null ? match[1] : '' - const user = await User.fetch(userId) + const user = await fetchUser(connection, userId) const currencySettings = user.notifications.currencyCodes[currencyCode] ?? { '1': false, '24': false } user.notifications.currencyCodes[currencyCode] = currencySettings currencySettings[hours] = enabled - await user.save() + await saveUserToDB(connection, user) log(`Updated notification settings for user ${userId} for ${currencyCode}`) @@ -169,10 +194,15 @@ export const toggleStateV1Route: Serverlet = async request => { const { enabled } = asToggleStateBody(json) log(`enabled: ${String(enabled)}`) - let user = await User.fetch(userId) - if (user == null) user = new User(null, userId) + let user = await fetchUser(connection, userId) + if (user == null) + user = { + userId, + devices: {}, + notifications: { currencyCodes: {} } + } user.notifications.enabled = enabled - await user.save() + await saveUserToDB(connection, user) log(`User notifications toggled to: ${String(enabled)}`) diff --git a/src/routes/notificationRoute.ts b/src/routes/notificationRoute.ts index 599856d..9aff14b 100644 --- a/src/routes/notificationRoute.ts +++ b/src/routes/notificationRoute.ts @@ -1,11 +1,18 @@ import { asObject, asOptional, asString } from 'cleaners' +import { asMaybeNotFoundError } from 'edge-server-tools' +import nano from 'nano' import { Serverlet } from 'serverlet' -import { User } from '../models/User' +import { asCouchDevice, devicesSetup, unpackDevice } from '../db/couchDevices' +import { fetchUser, saveUserToDB } from '../db/couchUsers' +import { serverConfig } from '../serverConfig' +import { Device } from '../types/pushTypes' import { ApiRequest } from '../types/requestTypes' import { errorResponse, jsonResponse } from '../types/responseTypes' import { makePushSender } from '../util/pushSender' +const connection = nano(serverConfig.couchUri) + /** * The login server names this `sendNotification`, * and calls it when there is a new device login. @@ -21,13 +28,35 @@ export const sendNotificationV1Route: Serverlet = async request => { if (!apiKey.admin) return errorResponse('Not an admin', { status: 401 }) const sender = await makePushSender(apiKey) - const user = await User.fetch(userId) + const user = await fetchUser(connection, userId) if (user == null) { return errorResponse('User does not exist.', { status: 404 }) } const tokens: string[] = [] - const devices = await user.fetchDevices() + const devices: Device[] = [] + + let updated = false + for (const deviceId in user.devices) { + const raw = await connection.db + .use(devicesSetup.name) + .get(deviceId) + .catch(error => { + if (asMaybeNotFoundError(error) != null) return + throw error + }) + const deviceDoc = asCouchDevice(raw) + const device = unpackDevice(deviceDoc) + if (device != null) { + devices.push(device) + continue + } + + delete user.devices[deviceId] + updated = true + } + + if (updated) await saveUserToDB(connection, user) for (const device of devices) { if (device.tokenId != null) { tokens.push(device.tokenId) From 225bb8ecb2beae20e569535832c014c3e6c76ea1 Mon Sep 17 00:00:00 2001 From: Xipu Li Date: Mon, 1 Aug 2022 15:56:29 -0700 Subject: [PATCH 5/8] Add error handling for empty api keys --- src/middleware/withApiKey.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/middleware/withApiKey.ts b/src/middleware/withApiKey.ts index be3d61b..0d97c19 100644 --- a/src/middleware/withApiKey.ts +++ b/src/middleware/withApiKey.ts @@ -15,7 +15,7 @@ export const withApiKey = // Parse the key out of the headers: const header = headers['x-api-key'] - if (header == null) { + if (header == null || header === '') { return errorResponse('Missing API key', { status: 401 }) } From f412c7915b81b07a370558194472b66446847300 Mon Sep 17 00:00:00 2001 From: Xipu Li Date: Mon, 1 Aug 2022 16:49:49 -0700 Subject: [PATCH 6/8] Allow returning null for User and Device --- src/db/couchDevices.ts | 17 +++-------------- src/db/couchUsers.ts | 8 +++++++- src/routes/legacyRoutes.ts | 7 ++++++- 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/src/db/couchDevices.ts b/src/db/couchDevices.ts index 76a33eb..3745d05 100644 --- a/src/db/couchDevices.ts +++ b/src/db/couchDevices.ts @@ -6,7 +6,7 @@ import { } from 'edge-server-tools' import { ServerScope } from 'nano' -import { Device, User } from '../types/pushTypes' +import { Device } from '../types/pushTypes' import { saveToDB } from './utils/couchOps' export const asCouchDevice = asCouchDoc>( @@ -23,27 +23,16 @@ export const asCouchDevice = asCouchDoc>( type CouchDevice = ReturnType export const devicesSetup: DatabaseSetup = { name: 'db_devices' } -export const fetchDevicesByUser = async ( - connection: ServerScope, - user: User -): Promise => { - const devices = [] - for (const deviceId in user.devices) { - const device = await fetchDevice(connection, deviceId) - devices.push(device) - } - return devices -} - export const fetchDevice = async ( connection: ServerScope, deviceId: string -): Promise => { +): Promise => { const db = connection.db.use(devicesSetup.name) const raw = await db.get(deviceId).catch(error => { if (asMaybeNotFoundError(error) != null) return throw error }) + if (raw == null) return null const deviceDoc = asCouchDevice(raw) return unpackDevice(deviceDoc) } diff --git a/src/db/couchUsers.ts b/src/db/couchUsers.ts index ca9b035..5b9e552 100644 --- a/src/db/couchUsers.ts +++ b/src/db/couchUsers.ts @@ -48,11 +48,14 @@ export const usersSetup: DatabaseSetup = { '_design/filter': makeJsDesign('by-currency', ({ emit }) => ({ map: function (doc) { const notifs = doc.notifications + + // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions if (notifs?.enabled && notifs.currencyCodes) { const codes = notifs.currencyCodes for (const currencyCode in codes) { for (const hours in codes[currencyCode]) { const enabled = codes[currencyCode][hours] + // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions if (enabled) { emit([currencyCode, hours], doc.devices) } @@ -63,6 +66,7 @@ export const usersSetup: DatabaseSetup = { })), '_design/map': makeJsDesign('currency-codes', ({ emit }) => ({ map: function (doc) { + // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions if (doc.notifications?.currencyCodes) { for (const code in doc.notifications.currencyCodes) { emit(null, code) @@ -96,6 +100,7 @@ export const cleanUpMissingDevices = async ( if (updated) await saveToDB(db, packUser(user)) } +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type export const devicesByCurrencyHours = async ( connection: ServerScope, hours: string, @@ -119,12 +124,13 @@ export const saveUserToDB = async ( export const fetchUser = async ( connection: ServerScope, userId: string -): Promise => { +): Promise => { const db = connection.db.use(usersSetup.name) const raw = await db.get(userId).catch(error => { if (asMaybeNotFoundError(error) != null) return throw error }) + if (raw == null) return null const userDoc = asCouchUser(raw) return unpackUser(userDoc) } diff --git a/src/routes/legacyRoutes.ts b/src/routes/legacyRoutes.ts index b8305e0..3e4d8fb 100644 --- a/src/routes/legacyRoutes.ts +++ b/src/routes/legacyRoutes.ts @@ -13,7 +13,7 @@ import { fetchDevice, saveDeviceToDB } from '../db/couchDevices' import { fetchUser, saveUserToDB } from '../db/couchUsers' import { serverConfig } from '../serverConfig' import { ApiRequest } from '../types/requestTypes' -import { jsonResponse } from '../types/responseTypes' +import { errorResponse, jsonResponse } from '../types/responseTypes' const connection = nano(serverConfig.couchUri) @@ -102,10 +102,13 @@ export const registerCurrenciesV1Route: Serverlet< const { currencyCodes } = asRegisterCurrenciesBody(json) const user = await fetchUser(connection, userId) + if (user == null) return errorResponse(`User ${userId} not found`) + const currencyCodesToUnregister = Object.keys( user.notifications.currencyCodes ).filter(code => !currencyCodes.includes(code)) for (const code of currencyCodesToUnregister) { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete delete user.notifications.currencyCodes[code] } @@ -140,6 +143,7 @@ export const fetchCurrencyV1Route: Serverlet = async request => { const currencyCode = match != null ? match[1] : '' const user = await fetchUser(connection, userId) + if (user == null) return errorResponse(`User ${userId} not found`) const currencySettings = user.notifications.currencyCodes[currencyCode] ?? { '1': false, '24': false @@ -166,6 +170,7 @@ export const enableCurrencyV1Route: Serverlet = async request => { const currencyCode = match != null ? match[1] : '' const user = await fetchUser(connection, userId) + if (user == null) return errorResponse(`User ${userId} not found`) const currencySettings = user.notifications.currencyCodes[currencyCode] ?? { '1': false, '24': false From d35aea6be0c9a9944a5b536afeb7b56e6d104553 Mon Sep 17 00:00:00 2001 From: Xipu Li Date: Mon, 1 Aug 2022 17:08:16 -0700 Subject: [PATCH 7/8] Refactor save to db methods --- src/db/couchDevices.ts | 26 +++++++++++++++++++++----- src/db/couchUsers.ts | 38 ++++++++++++++++++++++++++++++++------ src/db/utils/couchOps.ts | 1 - src/types/dbTypes.ts | 11 +++++++++++ 4 files changed, 64 insertions(+), 12 deletions(-) create mode 100644 src/types/dbTypes.ts diff --git a/src/db/couchDevices.ts b/src/db/couchDevices.ts index 3745d05..a740f86 100644 --- a/src/db/couchDevices.ts +++ b/src/db/couchDevices.ts @@ -1,4 +1,4 @@ -import { asNumber, asObject, asOptional, asString } from 'cleaners' +import { asNumber, asObject, asOptional, asString, uncleaner } from 'cleaners' import { asCouchDoc, asMaybeNotFoundError, @@ -6,8 +6,8 @@ import { } from 'edge-server-tools' import { ServerScope } from 'nano' +import { DeviceRow } from '../types/dbTypes' import { Device } from '../types/pushTypes' -import { saveToDB } from './utils/couchOps' export const asCouchDevice = asCouchDoc>( asObject({ @@ -19,7 +19,7 @@ export const asCouchDevice = asCouchDoc>( edgeBuildNumber: asNumber }) ) - +const wasCouchDevice = uncleaner(asCouchDevice) type CouchDevice = ReturnType export const devicesSetup: DatabaseSetup = { name: 'db_devices' } @@ -41,8 +41,24 @@ export const saveDeviceToDB = async ( connection: ServerScope, device: Device ): Promise => { - const db = connection.db.use(devicesSetup.name) - await saveToDB(db, packDevice(device)) + const { save } = makeDeviceRow(connection, packDevice(device)) + await save() +} + +export const makeDeviceRow = ( + connection: ServerScope, + doc: CouchDevice +): DeviceRow => { + const device = unpackDevice(doc) + return { + device, + async save() { + doc.doc = packDevice(device).doc + const db = connection.db.use(devicesSetup.name) + const result = await db.insert(wasCouchDevice(doc)) + doc.rev = result?.rev + } + } } export const unpackDevice = (doc: CouchDevice): Device => { diff --git a/src/db/couchUsers.ts b/src/db/couchUsers.ts index 5b9e552..388086b 100644 --- a/src/db/couchUsers.ts +++ b/src/db/couchUsers.ts @@ -1,4 +1,11 @@ -import { asBoolean, asMap, asObject, asOptional, Cleaner } from 'cleaners' +import { + asBoolean, + asMap, + asObject, + asOptional, + Cleaner, + uncleaner +} from 'cleaners' import { asCouchDoc, asMaybeNotFoundError, @@ -7,6 +14,7 @@ import { } from 'edge-server-tools' import { ServerScope } from 'nano' +import { UserRow } from '../types/dbTypes' import { Device, User, @@ -15,7 +23,6 @@ import { UserDevices, UserNotifications } from '../types/pushTypes' -import { saveToDB } from './utils/couchOps' export const asUserDevices: Cleaner = asObject(asBoolean) export type IDevicesByCurrencyHoursViewResponse = ReturnType< @@ -40,6 +47,7 @@ export const asCouchUser = asCouchDoc>( }) ) +const wasCouchUser = uncleaner(asCouchUser) type CouchUser = ReturnType export const usersSetup: DatabaseSetup = { @@ -89,7 +97,6 @@ export const cleanUpMissingDevices = async ( user: User, devices: Device[] ): Promise => { - const db = connection.db.use(usersSetup.name) let updated = false for (const device of devices) { if (user.devices[device.deviceId] == null) { @@ -97,7 +104,26 @@ export const cleanUpMissingDevices = async ( updated = true } } - if (updated) await saveToDB(db, packUser(user)) + if (updated) { + const { save } = makeUserRow(connection, packUser(user)) + await save() + } +} + +export const makeUserRow = ( + connection: ServerScope, + doc: CouchUser +): UserRow => { + const user = unpackUser(doc) + return { + user, + async save() { + doc.doc = packUser(user).doc + const db = connection.db.use(usersSetup.name) + const result = await db.insert(wasCouchUser(doc)) + doc.rev = result?.rev + } + } } // eslint-disable-next-line @typescript-eslint/explicit-function-return-type @@ -117,8 +143,8 @@ export const saveUserToDB = async ( connection: ServerScope, user: User ): Promise => { - const db = connection.db.use(usersSetup.name) - await saveToDB(db, packUser(user)) + const { save } = makeUserRow(connection, packUser(user)) + await save() } export const fetchUser = async ( diff --git a/src/db/utils/couchOps.ts b/src/db/utils/couchOps.ts index 74c2b26..f78b994 100644 --- a/src/db/utils/couchOps.ts +++ b/src/db/utils/couchOps.ts @@ -15,7 +15,6 @@ export const saveToDB = async ( switch (err.statusCode) { case 404: throw new Error('Database does not exist') - default: throw err } diff --git a/src/types/dbTypes.ts b/src/types/dbTypes.ts new file mode 100644 index 0000000..3cb3cc5 --- /dev/null +++ b/src/types/dbTypes.ts @@ -0,0 +1,11 @@ +import { Device, User } from './pushTypes' + +export interface DeviceRow { + device: Device + save: () => Promise +} + +export interface UserRow { + user: User + save: () => Promise +} From cf96b6d7d6065947ebee31d0117645506b8244c6 Mon Sep 17 00:00:00 2001 From: Xipu Li Date: Mon, 1 Aug 2022 17:18:51 -0700 Subject: [PATCH 8/8] fixup! Refactor save to db methods --- src/db/couchDevices.ts | 42 ++++++++++++++++++++++++++++------ src/db/couchUsers.ts | 52 +++++++++++++++++++++++++++++++++--------- 2 files changed, 76 insertions(+), 18 deletions(-) diff --git a/src/db/couchDevices.ts b/src/db/couchDevices.ts index a740f86..353f96a 100644 --- a/src/db/couchDevices.ts +++ b/src/db/couchDevices.ts @@ -1,6 +1,7 @@ import { asNumber, asObject, asOptional, asString, uncleaner } from 'cleaners' import { asCouchDoc, + asMaybeConflictError, asMaybeNotFoundError, DatabaseSetup } from 'edge-server-tools' @@ -41,22 +42,49 @@ export const saveDeviceToDB = async ( connection: ServerScope, device: Device ): Promise => { - const { save } = makeDeviceRow(connection, packDevice(device)) + const db = connection.db.use(devicesSetup.name) + + const raw = await db.get(device.deviceId).catch(error => { + if (asMaybeNotFoundError(error) != null) return + throw error + }) + if (raw == null) return + const { save } = makeDeviceRow(connection, raw) await save() } export const makeDeviceRow = ( connection: ServerScope, - doc: CouchDevice + raw: unknown ): DeviceRow => { - const device = unpackDevice(doc) + const db = connection.db.use(devicesSetup.name) + let base = asCouchDevice(raw) + const device: Device = { ...base.doc, deviceId: base.id } return { device, async save() { - doc.doc = packDevice(device).doc - const db = connection.db.use(devicesSetup.name) - const result = await db.insert(wasCouchDevice(doc)) - doc.rev = result?.rev + let remote = base + while (true) { + // Write to the database: + const doc: CouchDevice = { + doc: { ...device }, + id: remote.id, + rev: remote.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 = doc + return + } + + // Something went wrong, so grab the latest remote document: + const raw = await db.get(device.deviceId) + remote = asCouchDevice(raw) + } } } } diff --git a/src/db/couchUsers.ts b/src/db/couchUsers.ts index 388086b..ff74804 100644 --- a/src/db/couchUsers.ts +++ b/src/db/couchUsers.ts @@ -8,6 +8,7 @@ import { } from 'cleaners' import { asCouchDoc, + asMaybeConflictError, asMaybeNotFoundError, DatabaseSetup, makeJsDesign @@ -105,23 +106,46 @@ export const cleanUpMissingDevices = async ( } } if (updated) { - const { save } = makeUserRow(connection, packUser(user)) + const db = connection.db.use(usersSetup.name) + const raw = await db.get(user.userId).catch(error => { + if (asMaybeNotFoundError(error) != null) return + throw error + }) + if (raw == null) return + const { save } = makeUserRow(connection, raw) await save() } } -export const makeUserRow = ( - connection: ServerScope, - doc: CouchUser -): UserRow => { - const user = unpackUser(doc) +export const makeUserRow = (connection: ServerScope, raw: unknown): UserRow => { + const db = connection.db.use(usersSetup.name) + let base = asCouchUser(raw) + const user: User = { ...base.doc, userId: base.id } return { user, async save() { - doc.doc = packUser(user).doc - const db = connection.db.use(usersSetup.name) - const result = await db.insert(wasCouchUser(doc)) - doc.rev = result?.rev + let remote = base + while (true) { + // Write to the database: + const doc: CouchUser = { + doc: { ...user }, + id: remote.id, + rev: remote.rev + } + const response = await db.insert(wasCouchUser(doc)).catch(error => { + if (asMaybeConflictError(error) == null) throw error + }) + + // If that worked, the merged document is now the latest: + if (response?.ok === true) { + base = doc + return + } + + // Something went wrong, so grab the latest remote document: + const raw = await db.get(user.userId) + remote = asCouchUser(raw) + } } } } @@ -143,7 +167,13 @@ export const saveUserToDB = async ( connection: ServerScope, user: User ): Promise => { - const { save } = makeUserRow(connection, packUser(user)) + const db = connection.db.use(usersSetup.name) + const raw = await db.get(user.userId).catch(error => { + if (asMaybeNotFoundError(error) != null) return + throw error + }) + if (raw == null) return + const { save } = makeUserRow(connection, raw) await save() }