diff --git a/src/db/couchDevices.ts b/src/db/couchDevices.ts new file mode 100644 index 0000000..353f96a --- /dev/null +++ b/src/db/couchDevices.ts @@ -0,0 +1,102 @@ +import { asNumber, asObject, asOptional, asString, uncleaner } from 'cleaners' +import { + asCouchDoc, + asMaybeConflictError, + asMaybeNotFoundError, + DatabaseSetup +} from 'edge-server-tools' +import { ServerScope } from 'nano' + +import { DeviceRow } from '../types/dbTypes' +import { Device } from '../types/pushTypes' + +export const asCouchDevice = asCouchDoc>( + asObject({ + appId: asString, + tokenId: asOptional(asString), + deviceDescription: asString, + osType: asString, + edgeVersion: asString, + edgeBuildNumber: asNumber + }) +) +const wasCouchDevice = uncleaner(asCouchDevice) +type CouchDevice = ReturnType +export const devicesSetup: DatabaseSetup = { name: 'db_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 + }) + if (raw == null) return null + const deviceDoc = asCouchDevice(raw) + return unpackDevice(deviceDoc) +} + +export const saveDeviceToDB = async ( + connection: ServerScope, + device: Device +): Promise => { + 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, + raw: unknown +): DeviceRow => { + const db = connection.db.use(devicesSetup.name) + let base = asCouchDevice(raw) + const device: Device = { ...base.doc, deviceId: base.id } + return { + device, + async save() { + 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) + } + } + } +} + +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..bfb5455 100644 --- a/src/db/couchSetup.ts +++ b/src/db/couchSetup.ts @@ -7,23 +7,17 @@ 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' } - -const usersSetup: DatabaseSetup = { - name: 'db_user_settings' - // documents: { - // '_design/filter': makeJsDesign('by-currency', ?), - // '_design/map': makeJsDesign('currency-codes', ?) - // } -} +export const thresholdsSetup: DatabaseSetup = { name: 'db_currency_thresholds' } // --------------------------------------------------------------------------- // Setup routine diff --git a/src/db/couchUsers.ts b/src/db/couchUsers.ts new file mode 100644 index 0000000..ff74804 --- /dev/null +++ b/src/db/couchUsers.ts @@ -0,0 +1,202 @@ +import { + asBoolean, + asMap, + asObject, + asOptional, + Cleaner, + uncleaner +} from 'cleaners' +import { + asCouchDoc, + asMaybeConflictError, + asMaybeNotFoundError, + DatabaseSetup, + makeJsDesign +} from 'edge-server-tools' +import { ServerScope } from 'nano' + +import { UserRow } from '../types/dbTypes' +import { + Device, + User, + UserCurrencyCodes, + UserCurrencyHours, + UserDevices, + UserNotifications +} from '../types/pushTypes' + +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 + }) +) + +const wasCouchUser = uncleaner(asCouchUser) +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 + + // 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) + } + } + } + } + } + })), + '_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) + } + } + }, + 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 => { + let updated = false + for (const device of devices) { + if (user.devices[device.deviceId] == null) { + user.devices[device.deviceId] = true + updated = true + } + } + if (updated) { + 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, 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() { + 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) + } + } + } +} + +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +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) + 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 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 + }) + if (raw == null) return null + 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..f78b994 --- /dev/null +++ b/src/db/utils/couchOps.ts @@ -0,0 +1,22 @@ +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/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 }) } 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 81e134f..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, @@ -64,7 +69,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 ) @@ -77,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 @@ -125,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, diff --git a/src/routes/legacyRoutes.ts b/src/routes/legacyRoutes.ts index ad36a99..3e4d8fb 100644 --- a/src/routes/legacyRoutes.ts +++ b/src/routes/legacyRoutes.ts @@ -6,12 +6,16 @@ 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' +import { errorResponse, 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,27 @@ 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) + 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] + } + + 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 +142,8 @@ 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) + if (user == null) return errorResponse(`User ${userId} not found`) const currencySettings = user.notifications.currencyCodes[currencyCode] ?? { '1': false, '24': false @@ -140,14 +169,15 @@ 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) + if (user == null) return errorResponse(`User ${userId} not found`) 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 +199,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) 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 +} 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 +}