diff --git a/__test__/support/environment/TestEnvironment.ts b/__test__/support/environment/TestEnvironment.ts index 561406f53..6fa854df9 100644 --- a/__test__/support/environment/TestEnvironment.ts +++ b/__test__/support/environment/TestEnvironment.ts @@ -4,12 +4,12 @@ import type { ServerAppConfig, } from 'src/shared/config/types'; import type { RecursivePartial } from 'src/shared/context/types'; +import { clearAll } from 'src/shared/database/client'; import MainHelper from 'src/shared/helpers/MainHelper'; import { DUMMY_ONESIGNAL_ID, DUMMY_PUSH_TOKEN } from '../../constants'; import { generateNewSubscription } from '../helpers/core'; import { initOSGlobals, - resetDatabase, stubDomEnvironment, stubNotification, } from './TestEnvironmentHelpers'; @@ -32,8 +32,7 @@ export interface TestEnvironmentConfig { export class TestEnvironment { static async initialize(config: TestEnvironmentConfig = {}) { // reset db & localStorage - resetDatabase(); - + await clearAll(); const oneSignal = await initOSGlobals(config); if (config.useMockIdentityModel) { diff --git a/__test__/support/environment/TestEnvironmentHelpers.ts b/__test__/support/environment/TestEnvironmentHelpers.ts index efc60efa5..9bc3d2818 100644 --- a/__test__/support/environment/TestEnvironmentHelpers.ts +++ b/__test__/support/environment/TestEnvironmentHelpers.ts @@ -2,6 +2,7 @@ import { type DOMWindow, JSDOM, ResourceLoader } from 'jsdom'; import CoreModule from 'src/core/CoreModule'; import { SubscriptionModel } from 'src/core/models/SubscriptionModel'; import { ModelChangeTags } from 'src/core/types/models'; +import { setPushId, setPushToken } from 'src/shared/database/subscription'; import { NotificationType, SubscriptionType, @@ -14,7 +15,6 @@ import UserNamespace from '../../../src/onesignal/UserNamespace'; import Context from '../../../src/page/models/Context'; import { getSlidedownElement } from '../../../src/page/slidedown/SlidedownElement'; import Emitter from '../../../src/shared/libraries/Emitter'; -import Database from '../../../src/shared/services/Database'; import { CUSTOM_LINK_CSS_CLASSES } from '../../../src/shared/slidedown/constants'; import { DEFAULT_USER_AGENT, @@ -23,18 +23,11 @@ import { DUMMY_SUBSCRIPTION_ID_3, } from '../../constants'; import MockNotification from '../mocks/MockNotification'; -import Random from '../utils/Random'; import TestContext from './TestContext'; import { type TestEnvironmentConfig } from './TestEnvironment'; declare const global: any; -export function resetDatabase() { - // Erase and reset IndexedDb database name to something random - Database.resetInstance(); - Database.databaseInstanceName = Random.getRandomString(10); -} - export async function initOSGlobals(config: TestEnvironmentConfig = {}) { global.OneSignal = OneSignal; global.OneSignal.EVENTS = ONESIGNAL_EVENTS; @@ -153,8 +146,8 @@ export const setupSubModelStore = async ({ token, onesignalId, }); - await Database.setPushId(pushModel.id); - await Database.setPushToken(pushModel.token); + await setPushId(pushModel.id); + await setPushToken(pushModel.token); OneSignal.coreDirector.subscriptionModelStore.replaceAll( [pushModel], ModelChangeTags.NO_PROPOGATE, diff --git a/__test__/unit/models/deliveryPlatformKind.test.ts b/__test__/unit/models/deliveryPlatformKind.test.ts index 8a37d4fad..773fd703f 100644 --- a/__test__/unit/models/deliveryPlatformKind.test.ts +++ b/__test__/unit/models/deliveryPlatformKind.test.ts @@ -5,9 +5,5 @@ describe('DeliveryPlatformKind', () => { expect(DeliveryPlatformKind.ChromeLike).toBe(5); expect(DeliveryPlatformKind.SafariLegacy).toBe(7); expect(DeliveryPlatformKind.Firefox).toBe(8); - expect(DeliveryPlatformKind.Email).toBe(11); - expect(DeliveryPlatformKind.Edge).toBe(12); - expect(DeliveryPlatformKind.SMS).toBe(14); - expect(DeliveryPlatformKind.SafariVapid).toBe(17); }); }); diff --git a/__test__/unit/notifications/permission.test.ts b/__test__/unit/notifications/permission.test.ts index 12f0759d4..02163a90d 100644 --- a/__test__/unit/notifications/permission.test.ts +++ b/__test__/unit/notifications/permission.test.ts @@ -19,7 +19,7 @@ function expectPermissionChangeEvent( describe('Notifications namespace permission properties', () => { beforeEach(async () => { - TestEnvironment.initialize(); + await TestEnvironment.initialize(); }); afterEach(() => { diff --git a/__test__/unit/pushSubscription/nativePermissionChange.test.ts b/__test__/unit/pushSubscription/nativePermissionChange.test.ts index da8186f2a..882b1c096 100644 --- a/__test__/unit/pushSubscription/nativePermissionChange.test.ts +++ b/__test__/unit/pushSubscription/nativePermissionChange.test.ts @@ -8,11 +8,12 @@ import { import { TestEnvironment } from '__test__/support/environment/TestEnvironment'; import { createPushSub } from '__test__/support/environment/TestEnvironmentHelpers'; import { MockServiceWorker } from '__test__/support/mocks/MockServiceWorker'; +import { db, getOptionsValue } from 'src/shared/database/client'; +import { setAppState as setDBAppState } from 'src/shared/database/config'; import * as PermissionUtils from 'src/shared/helpers/permissions'; import Emitter from 'src/shared/libraries/Emitter'; import { checkAndTriggerSubscriptionChanged } from 'src/shared/listeners'; import { AppState } from 'src/shared/models/AppState'; -import Database from 'src/shared/services/Database'; import MainHelper from '../../../src/shared/helpers/MainHelper'; import { NotificationPermission } from '../../../src/shared/models/NotificationPermission'; @@ -31,12 +32,12 @@ describe('Notification Types are set correctly on subscription change', () => { }); afterEach(async () => { - await Database.remove('subscriptions'); - await Database.remove('Options'); + await db.clear('subscriptions'); + await db.clear('Options'); }); const setDbPermission = async (permission: NotificationPermission) => { - await Database.put('Options', { + await db.put('Options', { key: 'notificationPermission', value: permission, }); @@ -77,8 +78,7 @@ describe('Notification Types are set correctly on subscription change', () => { await MainHelper.checkAndTriggerNotificationPermissionChanged(); // should update the db - const dbPermission = await Database.get( - 'Options', + const dbPermission = await getOptionsValue( 'notificationPermission', ); expect(dbPermission).toBe(NotificationPermission.Granted); @@ -89,11 +89,8 @@ describe('Notification Types are set correctly on subscription change', () => { describe('checkAndTriggerSubscriptionChanged', async () => { const setAppState = async (appState: Partial) => { - const currentAppState = await Database.get( - 'Options', - 'appState', - ); - await Database.setAppState({ + const currentAppState = (await getOptionsValue('appState'))!; + await setDBAppState({ ...currentAppState, ...appState, }); diff --git a/package-lock.json b/package-lock.json index 3b3747dd3..aff95a311 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "1.2.0", "license": "SEE LICENSE IN LICENSE", "dependencies": { + "idb": "^8.0.3", "jsonp": "github:OneSignal/jsonp#onesignal", "uuid": "^11.1.0" }, @@ -3432,6 +3433,12 @@ "node": ">=0.10.0" } }, + "node_modules/idb": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/idb/-/idb-8.0.3.tgz", + "integrity": "sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg==", + "license": "ISC" + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", diff --git a/package.json b/package.json index 7c4db52b8..05ba380ef 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,7 @@ "description": "Web push notifications from OneSignal.", "type": "module", "dependencies": { + "idb": "^8.0.3", "jsonp": "github:OneSignal/jsonp#onesignal", "uuid": "^11.1.0" }, @@ -75,17 +76,17 @@ "size-limit": [ { "path": "./build/releases/OneSignalSDK.page.js", - "limit": "630 B", + "limit": "490 B", "gzip": true }, { "path": "./build/releases/OneSignalSDK.page.es6.js", - "limit": "58.6 kB", + "limit": "55.08 kB", "gzip": true }, { "path": "./build/releases/OneSignalSDK.sw.js", - "limit": "22.4 kB", + "limit": "15.42 kB", "gzip": true }, { diff --git a/src/core/CoreModuleDirector.ts b/src/core/CoreModuleDirector.ts index bd16f2839..bcb8ab714 100644 --- a/src/core/CoreModuleDirector.ts +++ b/src/core/CoreModuleDirector.ts @@ -1,15 +1,15 @@ import FuturePushSubscriptionRecord from 'src/page/userModel/FuturePushSubscriptionRecord'; +import { getPushToken, setPushId } from 'src/shared/database/subscription'; import { IDManager } from 'src/shared/managers/IDManager'; import { SubscriptionChannel, SubscriptionType, } from 'src/shared/subscriptions/constants'; import type { SubscriptionChannelValue } from 'src/shared/subscriptions/types'; +import { logMethodCall } from 'src/shared/utils/utils'; import SubscriptionHelper from '../../src/shared/helpers/SubscriptionHelper'; import MainHelper from '../shared/helpers/MainHelper'; import { RawPushSubscription } from '../shared/models/RawPushSubscription'; -import Database from '../shared/services/Database'; -import { logMethodCall } from '../shared/utils/utils'; import CoreModule from './CoreModule'; import { IdentityModel } from './models/IdentityModel'; import { PropertiesModel } from './models/PropertiesModel'; @@ -61,7 +61,7 @@ export class CoreModuleDirector { new FuturePushSubscriptionRecord(rawPushSubscription).serialize(), ); model.id = IDManager.createLocalId(); - Database.setPushId(model.id); + setPushId(model.id); // we enqueue a login operation w/ a create subscription operation the first time we generate/save a push subscription model this.core.subscriptionModelStore.add(model, ModelChangeTags.HYDRATE); @@ -136,7 +136,7 @@ export class CoreModuleDirector { logMethodCall( 'CoreModuleDirector.getPushSubscriptionModelByLastKnownToken', ); - const lastKnownPushToken = await Database.getPushToken(); + const lastKnownPushToken = await getPushToken(); if (lastKnownPushToken) { return this.getSubscriptionOfTypeWithToken( SubscriptionChannel.Push, diff --git a/src/core/executors/LoginUserOperationExecutor.test.ts b/src/core/executors/LoginUserOperationExecutor.test.ts index d80cd6528..e62d39231 100644 --- a/src/core/executors/LoginUserOperationExecutor.test.ts +++ b/src/core/executors/LoginUserOperationExecutor.test.ts @@ -18,7 +18,8 @@ import { setCreateUserError, setCreateUserResponse, } from '__test__/support/helpers/requests'; -import Database from 'src/shared/services/Database'; +import { clearAll } from 'src/shared/database/client'; +import { getPushId, setPushId } from 'src/shared/database/subscription'; import { NotificationType, SubscriptionType, @@ -55,7 +56,7 @@ describe('LoginUserOperationExecutor', () => { }); beforeEach(async () => { - await Database.clear(); + await clearAll(); identityModelStore = new IdentityModelStore(); propertiesModelStore = new PropertiesModelStore(); subscriptionModelStore = new SubscriptionModelStore(); @@ -165,7 +166,7 @@ describe('LoginUserOperationExecutor', () => { DUMMY_ONESIGNAL_ID, ); propertiesModelStore.model.setProperty('onesignalId', DUMMY_ONESIGNAL_ID); - await Database.setPushId(DUMMY_SUBSCRIPTION_ID); + await setPushId(DUMMY_SUBSCRIPTION_ID); const subscriptionModel = new SubscriptionModel(); subscriptionModel.setProperty('id', DUMMY_SUBSCRIPTION_ID); @@ -191,7 +192,7 @@ describe('LoginUserOperationExecutor', () => { expect(propertiesModelStore.model.getProperty('onesignalId')).toEqual( DUMMY_ONESIGNAL_ID_2, ); - expect(await Database.getPushId()).toEqual(DUMMY_SUBSCRIPTION_ID_2); + expect(await getPushId()).toEqual(DUMMY_SUBSCRIPTION_ID_2); expect(subscriptionModel.getProperty('id')).toEqual( DUMMY_SUBSCRIPTION_ID_2, ); diff --git a/src/core/executors/LoginUserOperationExecutor.ts b/src/core/executors/LoginUserOperationExecutor.ts index 55292a62f..a44a48882 100644 --- a/src/core/executors/LoginUserOperationExecutor.ts +++ b/src/core/executors/LoginUserOperationExecutor.ts @@ -3,6 +3,12 @@ import { ExecutionResult, type IOperationExecutor, } from 'src/core/types/operation'; +import { getConsentGiven } from 'src/shared/database/config'; +import { + getPushId, + setPushId, + setPushToken, +} from 'src/shared/database/subscription'; import { getTimeZoneId } from 'src/shared/helpers/general'; import { getConsentRequired } from 'src/shared/helpers/localStorage'; import { @@ -11,7 +17,6 @@ import { } from 'src/shared/helpers/NetworkUtils'; import Log from 'src/shared/libraries/Log'; import { checkAndTriggerUserChanged } from 'src/shared/listeners'; -import Database from 'src/shared/services/Database'; import { IdentityConstants, OPERATION_NAME } from '../constants'; import { type IPropertiesModelKeys } from '../models/PropertiesModel'; import { type IdentityModelStore } from '../modelStores/IdentityModelStore'; @@ -80,7 +85,7 @@ export class LoginUserOperationExecutor implements IOperationExecutor { operations: Operation[], ): Promise { const consentRequired = getConsentRequired(); - const consentGiven = await Database.getConsentGiven(); + const consentGiven = await getConsentGiven(); if (consentRequired && !consentGiven) { throw new Error('Consent required but not given'); @@ -231,10 +236,10 @@ export class LoginUserOperationExecutor implements IOperationExecutor { if (!backendSub || !('id' in backendSub)) continue; idTranslations[localId] = backendSub.id; - const pushSubscriptionId = await Database.getPushId(); + const pushSubscriptionId = await getPushId(); if (pushSubscriptionId === localId) { - await Database.setPushId(backendSub.id); - await Database.setPushToken(backendSub.token); + await setPushId(backendSub.id); + await setPushToken(backendSub.token); } const model = diff --git a/src/core/executors/RefreshUserOperationExecutor.test.ts b/src/core/executors/RefreshUserOperationExecutor.test.ts index 622b26ba5..a82dfe749 100644 --- a/src/core/executors/RefreshUserOperationExecutor.test.ts +++ b/src/core/executors/RefreshUserOperationExecutor.test.ts @@ -12,7 +12,8 @@ import { setGetUserError, setGetUserResponse, } from '__test__/support/helpers/requests'; -import Database from 'src/shared/services/Database'; +import { clearAll } from 'src/shared/database/client'; +import { setPushId } from 'src/shared/database/subscription'; import { NotificationType, SubscriptionType, @@ -40,7 +41,7 @@ vi.mock('src/shared/libraries/Log'); describe('RefreshUserOperationExecutor', () => { beforeEach(async () => { - await Database.clear(); // in case subscription model (from previous tests) are loaded from db + await clearAll(); // in case subscription model (from previous tests) are loaded from db identityModelStore = new IdentityModelStore(); propertiesModelStore = new PropertiesModelStore(); subscriptionModelStore = new SubscriptionModelStore(); @@ -181,7 +182,7 @@ describe('RefreshUserOperationExecutor', () => { pushSubModel.notification_types = NotificationType.Subscribed; subscriptionModelStore.add(pushSubModel); - await Database.setPushId(DUMMY_SUBSCRIPTION_ID_2); + await setPushId(DUMMY_SUBSCRIPTION_ID_2); const executor = getExecutor(); const refreshOp = new RefreshUserOperation(APP_ID, DUMMY_ONESIGNAL_ID); diff --git a/src/core/executors/RefreshUserOperationExecutor.ts b/src/core/executors/RefreshUserOperationExecutor.ts index fb2963d14..6011e2446 100644 --- a/src/core/executors/RefreshUserOperationExecutor.ts +++ b/src/core/executors/RefreshUserOperationExecutor.ts @@ -1,10 +1,10 @@ +import { getPushId } from 'src/shared/database/subscription'; import { getResponseStatusType, ResponseStatusType, } from 'src/shared/helpers/NetworkUtils'; import SubscriptionHelper from 'src/shared/helpers/SubscriptionHelper'; import Log from 'src/shared/libraries/Log'; -import Database from 'src/shared/services/Database'; import { NotificationType } from 'src/shared/subscriptions/constants'; import { IdentityConstants, OPERATION_NAME } from '../constants'; import { IdentityModel } from '../models/IdentityModel'; @@ -120,7 +120,7 @@ export class RefreshUserOperationExecutor implements IOperationExecutor { } } - const pushSubscriptionId = await Database.getPushId(); + const pushSubscriptionId = await getPushId(); if (pushSubscriptionId) { const cachedPushModel = diff --git a/src/core/executors/SubscriptionOperationExecutor.test.ts b/src/core/executors/SubscriptionOperationExecutor.test.ts index 3948e4f14..d73c69299 100644 --- a/src/core/executors/SubscriptionOperationExecutor.test.ts +++ b/src/core/executors/SubscriptionOperationExecutor.test.ts @@ -7,7 +7,8 @@ import { createPushSub } from '__test__/support/environment/TestEnvironmentHelpe import { SomeOperation } from '__test__/support/helpers/executors'; import { server } from '__test__/support/mocks/server'; import { http, HttpResponse } from 'msw'; -import Database from 'src/shared/services/Database'; +import { db } from 'src/shared/database/client'; +import { getPushId, setPushId } from 'src/shared/database/subscription'; import { NotificationType, SubscriptionType, @@ -65,7 +66,7 @@ describe('SubscriptionOperationExecutor', () => { }); afterEach(async () => { - await Database.remove('subscriptions'); + await db.clear('subscriptions'); }); const getExecutor = () => { @@ -136,7 +137,7 @@ describe('SubscriptionOperationExecutor', () => { subscriptionModelStore.add(model); setCreateSubscriptionResponse(BACKEND_SUBSCRIPTION_ID); - await Database.setPushId(DUMMY_SUBSCRIPTION_ID); + await setPushId(DUMMY_SUBSCRIPTION_ID); const executor = getExecutor(); const createOp = new CreateSubscriptionOperation({ @@ -158,7 +159,7 @@ describe('SubscriptionOperationExecutor', () => { }); // Verify models were updated - await expect(Database.getPushId()).resolves.toBe(BACKEND_SUBSCRIPTION_ID); + await expect(getPushId()).resolves.toBe(BACKEND_SUBSCRIPTION_ID); const subscriptionModel = subscriptionModelStore.getBySubscriptionId( BACKEND_SUBSCRIPTION_ID, ); @@ -279,7 +280,7 @@ describe('SubscriptionOperationExecutor', () => { // Missing error with rebuild ops subscriptionsModelStore.add(pushSubscription); - await Database.setPushId(DUMMY_SUBSCRIPTION_ID_3); + await setPushId(DUMMY_SUBSCRIPTION_ID_3); const res6 = await executor.execute([createOp]); expect(res6).toMatchObject({ diff --git a/src/core/executors/SubscriptionOperationExecutor.ts b/src/core/executors/SubscriptionOperationExecutor.ts index e0afb4403..3601d5f91 100644 --- a/src/core/executors/SubscriptionOperationExecutor.ts +++ b/src/core/executors/SubscriptionOperationExecutor.ts @@ -3,12 +3,16 @@ import { type IOperationExecutor, } from 'src/core/types/operation'; import type { IRebuildUserService } from 'src/core/types/user'; +import { + getPushId, + setPushId, + setPushToken, +} from 'src/shared/database/subscription'; import { getResponseStatusType, ResponseStatusType, } from 'src/shared/helpers/NetworkUtils'; import Log from 'src/shared/libraries/Log'; -import Database from 'src/shared/services/Database'; import { IdentityConstants, OPERATION_NAME } from '../constants'; import { type SubscriptionModelStore } from '../modelStores/SubscriptionModelStore'; import { type NewRecordsState } from '../operationRepo/NewRecordsState'; @@ -137,10 +141,10 @@ export class SubscriptionOperationExecutor implements IOperationExecutor { ); } - const pushSubscriptionId = await Database.getPushId(); + const pushSubscriptionId = await getPushId(); if (pushSubscriptionId === createOperation.subscriptionId) { - await Database.setPushId(backendSubscriptionId); - await Database.setPushToken(subscription?.token); + await setPushId(backendSubscriptionId); + await setPushToken(subscription?.token); } return new ExecutionResponse( diff --git a/src/core/modelRepo/ModelStore.ts b/src/core/modelRepo/ModelStore.ts index 09df703c1..d310bc8fe 100644 --- a/src/core/modelRepo/ModelStore.ts +++ b/src/core/modelRepo/ModelStore.ts @@ -7,10 +7,10 @@ import { type IModelStoreChangeHandler, ModelChangeTags, type ModelChangeTagValue, - type ModelNameType, } from 'src/core/types/models'; +import { db } from 'src/shared/database/client'; +import type { IndexedDBSchema, ModelNameType } from 'src/shared/database/types'; import { EventProducer } from 'src/shared/helpers/EventProducer'; -import Database from 'src/shared/services/Database'; import type { IModelChangedHandler, Model, @@ -119,7 +119,7 @@ export abstract class ModelStore< this.changeSubscription.fire((handler) => handler.onModelRemoved(item, tag), ); - Database.remove(this.modelName, item.modelId); + db.delete(this.modelName, item.modelId); } this.models = []; @@ -146,7 +146,7 @@ export abstract class ModelStore< // no longer listen for changes to this model model.unsubscribe(this); - await Database.remove(this.modelName, model.modelId); + await db.delete(this.modelName, model.modelId); this.persist(); this.changeSubscription.fire((handler) => @@ -161,7 +161,7 @@ export abstract class ModelStore< protected async load(): Promise { if (!this.modelName) return; - const jsonArray: DBModel[] = await Database.getAll(this.modelName); + const jsonArray = (await db.getAll(this.modelName)) as unknown as DBModel[]; const shouldRePersist = this.models.length > 0; @@ -191,11 +191,11 @@ export abstract class ModelStore< if (!this.modelName || !this.hasLoadedFromCache) return; for (const model of this.models) { - await Database.put(this.modelName, { + await db.put(this.modelName, { modelId: model.modelId, modelName: this.modelName, // TODO: ModelName is a legacy property, could be removed sometime after web refactor launch ...model.toJSON(), - }); + } as IndexedDBSchema[typeof this.modelName]['value']); } } diff --git a/src/core/modelRepo/OperationModelStore.ts b/src/core/modelRepo/OperationModelStore.ts index 9050dc99b..7820201ed 100644 --- a/src/core/modelRepo/OperationModelStore.ts +++ b/src/core/modelRepo/OperationModelStore.ts @@ -1,3 +1,4 @@ +import type { IDBStoreName } from 'src/shared/database/types'; import Log from 'src/shared/libraries/Log'; import { OPERATION_NAME } from '../constants'; import { CreateSubscriptionOperation } from '../operations/CreateSubscriptionOperation'; @@ -11,14 +12,13 @@ import { SetPropertyOperation } from '../operations/SetPropertyOperation'; import { TrackCustomEventOperation } from '../operations/TrackCustomEventOperation'; import { TransferSubscriptionOperation } from '../operations/TransferSubscriptionOperation'; import { UpdateSubscriptionOperation } from '../operations/UpdateSubscriptionOperation'; -import { ModelName } from '../types/models'; import { ModelStore } from './ModelStore'; // Implements logic similar to Android SDK's OperationModelStore // Reference: https://github.com/OneSignal/OneSignal-Android-SDK/blob/5.1.31/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/operations/impl/OperationModelStore.kt export class OperationModelStore extends ModelStore { constructor() { - super(ModelName.Operations); + super('operations' satisfies IDBStoreName); } async loadOperations(): Promise { diff --git a/src/core/modelRepo/RebuildUserService.ts b/src/core/modelRepo/RebuildUserService.ts index 171dc2dfc..1ebc64473 100644 --- a/src/core/modelRepo/RebuildUserService.ts +++ b/src/core/modelRepo/RebuildUserService.ts @@ -1,4 +1,4 @@ -import Database from 'src/shared/services/Database'; +import { getPushId } from 'src/shared/database/subscription'; import { IdentityModel } from '../models/IdentityModel'; import { PropertiesModel } from '../models/PropertiesModel'; import { SubscriptionModel } from '../models/SubscriptionModel'; @@ -54,7 +54,7 @@ export class RebuildUserService implements IRebuildUserService { new LoginUserOperation(appId, onesignalId, identityModel.externalId), ); - const pushSubscriptionId = await Database.getPushId(); + const pushSubscriptionId = await getPushId(); const pushSubscription = subscriptionModels.find( (s) => s.id === pushSubscriptionId, ); diff --git a/src/core/modelStores/IdentityModelStore.ts b/src/core/modelStores/IdentityModelStore.ts index 476610056..7a3fe579b 100644 --- a/src/core/modelStores/IdentityModelStore.ts +++ b/src/core/modelStores/IdentityModelStore.ts @@ -1,5 +1,5 @@ +import type { IDBStoreName } from 'src/shared/database/types'; import { IdentityModel } from '../models/IdentityModel'; -import { ModelName } from '../types/models'; import { SimpleModelStore } from './SimpleModelStore'; import { SingletonModelStore } from './SingletonModelStore'; @@ -7,6 +7,11 @@ import { SingletonModelStore } from './SingletonModelStore'; // Reference: https://github.com/OneSignal/OneSignal-Android-SDK/blob/5.1.31/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/identity/IdentityModelStore.kt export class IdentityModelStore extends SingletonModelStore { constructor() { - super(new SimpleModelStore(() => new IdentityModel(), ModelName.Identity)); + super( + new SimpleModelStore( + () => new IdentityModel(), + 'identity' satisfies IDBStoreName, + ), + ); } } diff --git a/src/core/modelStores/PropertiesModelStore.ts b/src/core/modelStores/PropertiesModelStore.ts index f3d04a4b5..9fbca20cf 100644 --- a/src/core/modelStores/PropertiesModelStore.ts +++ b/src/core/modelStores/PropertiesModelStore.ts @@ -1,14 +1,17 @@ import { SimpleModelStore } from 'src/core/modelStores/SimpleModelStore'; import { SingletonModelStore } from 'src/core/modelStores/SingletonModelStore'; +import type { IDBStoreName } from 'src/shared/database/types'; import { PropertiesModel } from '../models/PropertiesModel'; -import { ModelName } from '../types/models'; // Implements logic similar to Android's SDK's PropertiesModelStore // Reference: https://github.com/OneSignal/OneSignal-Android-SDK/blob/5.1.31/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/properties/PropertiesModelStore.kt export class PropertiesModelStore extends SingletonModelStore { constructor() { super( - new SimpleModelStore(() => new PropertiesModel(), ModelName.Properties), + new SimpleModelStore( + () => new PropertiesModel(), + 'properties' satisfies IDBStoreName, + ), ); } } diff --git a/src/core/modelStores/SimpleModelStore.ts b/src/core/modelStores/SimpleModelStore.ts index 7ec113bc9..c11dc6696 100644 --- a/src/core/modelStores/SimpleModelStore.ts +++ b/src/core/modelStores/SimpleModelStore.ts @@ -1,6 +1,7 @@ import { ModelStore } from 'src/core/modelRepo/ModelStore'; import { Model } from 'src/core/models/Model'; -import type { DatabaseModel, ModelNameType } from 'src/core/types/models'; +import type { DatabaseModel } from 'src/core/types/models'; +import type { ModelNameType } from 'src/shared/database/types'; // Implements logic similar to Android SDK's SimpleModelStore // Reference: https://github.com/OneSignal/OneSignal-Android-SDK/blob/5.1.31/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/modeling/SimpleModelStore.kt diff --git a/src/core/modelStores/SubscriptionModelStore.ts b/src/core/modelStores/SubscriptionModelStore.ts index cba519716..24d1d750c 100644 --- a/src/core/modelStores/SubscriptionModelStore.ts +++ b/src/core/modelStores/SubscriptionModelStore.ts @@ -2,8 +2,8 @@ import { SimpleModelStore } from 'src/core/modelStores/SimpleModelStore'; import { ModelChangeTags, type ModelChangeTagValue, - ModelName, } from 'src/core/types/models'; +import type { IDBStoreName } from 'src/shared/database/types'; import SubscriptionHelper from 'src/shared/helpers/SubscriptionHelper'; import { SubscriptionModel } from '../models/SubscriptionModel'; @@ -11,7 +11,10 @@ import { SubscriptionModel } from '../models/SubscriptionModel'; // Reference: https://github.com/OneSignal/OneSignal-Android-SDK/blob/5.1.31/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/subscriptions/SubscriptionModelStore.kt export class SubscriptionModelStore extends SimpleModelStore { constructor() { - super(() => new SubscriptionModel(), ModelName.Subscriptions); + super( + () => new SubscriptionModel(), + 'subscriptions' satisfies IDBStoreName, + ); } getBySubscriptionId(subscriptionId: string): SubscriptionModel | undefined { diff --git a/src/core/operationRepo/OperationRepo.test.ts b/src/core/operationRepo/OperationRepo.test.ts index b10cfb8fc..08d35178b 100644 --- a/src/core/operationRepo/OperationRepo.test.ts +++ b/src/core/operationRepo/OperationRepo.test.ts @@ -4,8 +4,9 @@ import { DUMMY_SUBSCRIPTION_ID, } from '__test__/constants'; import { fakeWaitForOperations } from '__test__/support/helpers/executors'; +import { db } from 'src/shared/database/client'; +import type { IndexedDBSchema } from 'src/shared/database/types'; import Log from 'src/shared/libraries/Log'; -import Database, { type OperationItem } from 'src/shared/services/Database'; import { SubscriptionType } from 'src/shared/subscriptions/constants'; import { describe, expect, type Mock, vi } from 'vitest'; import { OperationModelStore } from '../modelRepo/OperationModelStore'; @@ -16,7 +17,6 @@ import { Operation as OperationBase, } from '../operations/Operation'; import { SetAliasOperation } from '../operations/SetAliasOperation'; -import { ModelName } from '../types/models'; import { ExecutionResult, type IOperationExecutor } from '../types/operation'; import { OP_REPO_EXECUTION_INTERVAL, @@ -48,7 +48,7 @@ describe('OperationRepo', () => { ]; beforeEach(async () => { - await Database.remove(ModelName.Operations); + await db.clear('operations'); mockOperationModelStore = new OperationModelStore(); opRepo = new OperationRepo( @@ -63,9 +63,7 @@ describe('OperationRepo', () => { // for tests that call start on the op repo if (!opRepo.isPaused) { await vi.waitUntil(async () => { - const dbOps = await Database.getAll( - ModelName.Operations, - ); + const dbOps = await db.getAll('operations'); return dbOps.length === 0; }); } @@ -129,9 +127,9 @@ describe('OperationRepo', () => { expect(mockOperationModelStore.list()).toEqual([op1, op2]); // persist happens in the background, so we need to wait for it to complete - let ops: OperationItem[] = []; + let ops: IndexedDBSchema['operations']['value'][] = []; await vi.waitUntil(async () => { - ops = await Database.getAll(ModelName.Operations); + ops = await db.getAll('operations'); return ops.length === 2; }); @@ -142,12 +140,12 @@ describe('OperationRepo', () => { { ...op2.toJSON(), modelId: op2.modelId, - modelName: ModelName.Operations, + modelName: 'operations', }, { ...op1.toJSON(), modelId: op1.modelId, - modelName: ModelName.Operations, + modelName: 'operations', }, ]); }); @@ -155,30 +153,30 @@ describe('OperationRepo', () => { test('operations can be loaded from IndexedDb on start', async () => { const op = new SetAliasOperation(); const op2 = new CreateSubscriptionOperation(); - await Database.put(ModelName.Operations, { + await db.put('operations', { ...op.toJSON(), modelId: '1', - modelName: ModelName.Operations, + modelName: 'operations', }); - await Database.put(ModelName.Operations, { + await db.put('operations', { ...op2.toJSON(), modelId: '2', - modelName: ModelName.Operations, + modelName: 'operations', }); await opRepo.loadSavedOperations(); - const list = await Database.getAll(ModelName.Operations); + const list = await db.getAll('operations'); expect(list).toEqual([ { ...op.toJSON(), modelId: '1', - modelName: ModelName.Operations, + modelName: 'operations', }, { ...op2.toJSON(), modelId: '2', - modelName: ModelName.Operations, + modelName: 'operations', }, ]); }); diff --git a/src/core/operationRepo/OperationRepo.ts b/src/core/operationRepo/OperationRepo.ts index 389262b4b..8a54ce181 100644 --- a/src/core/operationRepo/OperationRepo.ts +++ b/src/core/operationRepo/OperationRepo.ts @@ -4,12 +4,11 @@ import { type IOperationRepo, type IStartableService, } from 'src/core/types/operation'; +import { db } from 'src/shared/database/client'; import { delay } from 'src/shared/helpers/general'; import Log from 'src/shared/libraries/Log'; -import Database from 'src/shared/services/Database'; import { type OperationModelStore } from '../modelRepo/OperationModelStore'; import { GroupComparisonType, type Operation } from '../operations/Operation'; -import { ModelName } from '../types/models'; import { OP_REPO_DEFAULT_FAIL_RETRY_BACKOFF, OP_REPO_EXECUTION_INTERVAL, @@ -18,7 +17,7 @@ import { import { type NewRecordsState } from './NewRecordsState'; const removeOpFromDB = (op: Operation) => { - Database.remove(ModelName.Operations, op.modelId); + db.delete('operations', op.modelId); }; // Implements logic similar to Android SDK's OperationRepo & OperationQueueItem diff --git a/src/core/types/models.ts b/src/core/types/models.ts index ae16f4839..e306020d3 100644 --- a/src/core/types/models.ts +++ b/src/core/types/models.ts @@ -1,13 +1,5 @@ import type { Model, ModelChangedArgs } from 'src/core/models/Model'; -export const ModelName = { - Operations: 'operations', - Identity: 'identity', - Properties: 'properties', - Subscriptions: 'subscriptions', -} as const; -export type ModelNameType = (typeof ModelName)[keyof typeof ModelName]; - export const ModelChangeTags = { /** * A change was performed through normal means. diff --git a/src/entries/pageSdkInit2.test.ts b/src/entries/pageSdkInit2.test.ts index 87eee2ad1..53fbe1a47 100644 --- a/src/entries/pageSdkInit2.test.ts +++ b/src/entries/pageSdkInit2.test.ts @@ -17,10 +17,10 @@ import { } from '__test__/support/helpers/requests'; import { server } from '__test__/support/mocks/server'; import { SubscriptionModel } from 'src/core/models/SubscriptionModel'; -import { ModelName } from 'src/core/types/models'; +import { db } from 'src/shared/database/client'; +import type { SubscriptionSchema } from 'src/shared/database/types'; import Log from 'src/shared/libraries/Log'; import { IDManager } from 'src/shared/managers/IDManager'; -import Database, { type SubscriptionItem } from 'src/shared/services/Database'; describe('pageSdkInit 2', () => { beforeEach(async () => { @@ -94,11 +94,9 @@ describe('pageSdkInit 2', () => { }); // wait user subscriptions to be refresh/replaced - let subscriptions: SubscriptionItem[] = []; + let subscriptions: SubscriptionSchema[] = []; await vi.waitUntil(async () => { - subscriptions = await Database.getAll( - ModelName.Subscriptions, - ); + subscriptions = await db.getAll('subscriptions'); return subscriptions.length === 2; }); subscriptions.sort((a, b) => a.type.localeCompare(b.type)); diff --git a/src/onesignal/NotificationsNamespace.test.ts b/src/onesignal/NotificationsNamespace.test.ts index 33c2bbb47..9e6e154f6 100644 --- a/src/onesignal/NotificationsNamespace.test.ts +++ b/src/onesignal/NotificationsNamespace.test.ts @@ -1,9 +1,9 @@ import { TestEnvironment } from '__test__/support/environment/TestEnvironment'; +import { getAppState } from 'src/shared/database/config'; import { EmptyArgumentError, WrongTypeArgumentError, } from 'src/shared/errors/common'; -import Database from 'src/shared/services/Database'; import NotificationsNamespace from './NotificationsNamespace'; describe('NotificationsNamespace', () => { @@ -15,7 +15,7 @@ describe('NotificationsNamespace', () => { const notifications = new NotificationsNamespace(); await notifications.setDefaultUrl('https://test.com'); - const appState = await Database.getAppState(); + const appState = await getAppState(); expect(appState.defaultNotificationUrl).toBe('https://test.com'); await expect(notifications.setDefaultUrl(undefined)).rejects.toThrow( @@ -32,7 +32,7 @@ describe('NotificationsNamespace', () => { const notifications = new NotificationsNamespace(); await notifications.setDefaultTitle('My notification title'); - const appState = await Database.getAppState(); + const appState = await getAppState(); expect(appState.defaultNotificationTitle).toBe('My notification title'); // @ts-expect-error - testing throwing invalid type diff --git a/src/onesignal/NotificationsNamespace.ts b/src/onesignal/NotificationsNamespace.ts index a03e067f2..fa58de873 100644 --- a/src/onesignal/NotificationsNamespace.ts +++ b/src/onesignal/NotificationsNamespace.ts @@ -1,17 +1,16 @@ +import { getAppState, setAppState } from 'src/shared/database/config'; import { EmptyArgumentError, MalformedArgumentError, WrongTypeArgumentError, } from 'src/shared/errors/common'; import { isValidUrl } from 'src/shared/helpers/validators'; -import { fireStoredNotificationClicks } from 'src/shared/listeners'; import type { NotificationEventName, NotificationEventTypeMap, } from 'src/shared/notifications/types'; import { EventListenerBase } from '../page/userModel/EventListenerBase'; import { NotificationPermission } from '../shared/models/NotificationPermission'; -import Database from '../shared/services/Database'; import { awaitOneSignalInitAndSupported, logMethodCall, @@ -64,9 +63,9 @@ export default class NotificationsNamespace extends EventListenerBase { throw MalformedArgumentError('url'); await awaitOneSignalInitAndSupported(); logMethodCall('setDefaultNotificationUrl', url); - const appState = await Database.getAppState(); + const appState = await getAppState(); appState.defaultNotificationUrl = url; - await Database.setAppState(appState); + await setAppState(appState); } /** @@ -87,9 +86,9 @@ export default class NotificationsNamespace extends EventListenerBase { } await awaitOneSignalInitAndSupported(); - const appState = await Database.getAppState(); + const appState = await getAppState(); appState.defaultNotificationTitle = title; - await Database.setAppState(appState); + await setAppState(appState); } /** @@ -125,10 +124,6 @@ export default class NotificationsNamespace extends EventListenerBase { listener: (obj: NotificationEventTypeMap[K]) => void, ): void { OneSignal.emitter.on(event, listener); - - if (event === 'click') { - fireStoredNotificationClicks(); - } } removeEventListener( diff --git a/src/onesignal/OneSignal.test.ts b/src/onesignal/OneSignal.test.ts index 86f18d298..09ea78bf0 100644 --- a/src/onesignal/OneSignal.test.ts +++ b/src/onesignal/OneSignal.test.ts @@ -41,34 +41,35 @@ import { PropertiesModel } from 'src/core/models/PropertiesModel'; import { OperationQueueItem } from 'src/core/operationRepo/OperationRepo'; import { type ICreateUserSubscription } from 'src/core/types/api'; import { ModelChangeTags } from 'src/core/types/models'; +import { db } from 'src/shared/database/client'; +import type { + IndexedDBSchema, + SubscriptionSchema, +} from 'src/shared/database/types'; import { setConsentRequired } from 'src/shared/helpers/localStorage'; import Log from 'src/shared/libraries/Log'; import { IDManager } from 'src/shared/managers/IDManager'; -import Database, { - type IdentityItem, - type PropertiesItem, - type SubscriptionItem, -} from 'src/shared/services/Database'; const errorSpy = vi.spyOn(Log, 'error').mockImplementation(() => ''); const debugSpy = vi.spyOn(Log, 'debug'); +type IdentityItem = IndexedDBSchema['identity']['value']; + const getIdentityItem = async ( condition: (identity: IdentityItem) => boolean = () => true, ) => { let identity: IdentityItem | undefined; await vi.waitUntil(async () => { - identity = (await Database.get('identity'))?.[0]; + identity = (await db.getAll('identity'))?.[0]; return identity && condition(identity); }); return identity; }; -const getPropertiesItem = async () => - (await Database.get('properties'))[0]; +const getPropertiesItem = async () => (await db.getAll('properties'))?.[0]; const setupIdentity = async () => { - await Database.put('identity', { + await db.put('identity', { modelId: '123', modelName: 'identity', onesignal_id: DUMMY_ONESIGNAL_ID, @@ -105,8 +106,7 @@ describe('OneSignal', () => { afterEach(async () => { window.OneSignal.coreDirector.operationRepo.queue = []; - await Database.remove('operations'); - await waitForOperations(); + await db.clear('operations'); window.OneSignal.coreDirector.subscriptionModelStore.replaceAll( [], ModelChangeTags.HYDRATE, @@ -197,7 +197,7 @@ describe('OneSignal', () => { const email = 'test@test.com'; const getEmailSubscriptionDbItems = async () => - (await Database.get('subscriptions')).filter( + (await db.getAll<'subscriptions'>('subscriptions')).filter( (s) => s.type === 'Email', ); @@ -277,7 +277,7 @@ describe('OneSignal', () => { let dbSubscriptions = await getEmailSubscriptionDbItems(); expect(dbSubscriptions).toHaveLength(1); - await waitForOperations(6); + await vi.waitUntil(() => createSubscriptionFn.mock.calls.length === 1); window.OneSignal.User.removeEmail(email); await vi.waitUntil(() => deleteSubscriptionFn.mock.calls.length === 1); @@ -289,10 +289,10 @@ describe('OneSignal', () => { describe('sms', () => { const sms = '+1234567890'; const getSmsSubscriptionDbItems = async (length: number) => { - let subscriptions: SubscriptionItem[] = []; + let subscriptions: SubscriptionSchema[] = []; await vi.waitUntil(async () => { subscriptions = ( - await Database.get('subscriptions') + await db.getAll<'subscriptions'>('subscriptions') ).filter((s) => s.type === 'SMS'); return subscriptions.length === length; }); @@ -571,7 +571,7 @@ describe('OneSignal', () => { const sms = '+1234567890'; beforeEach(async () => { - await Database.remove('subscriptions', DUMMY_SUBSCRIPTION_ID); + await db.delete('subscriptions', DUMMY_SUBSCRIPTION_ID); setCreateSubscriptionResponse({ response: { @@ -648,9 +648,9 @@ describe('OneSignal', () => { }, }); - let dbSubscriptions: SubscriptionItem[] = []; + let dbSubscriptions: SubscriptionSchema[] = []; await vi.waitUntil(async () => { - dbSubscriptions = await Database.get('subscriptions'); + dbSubscriptions = await db.getAll<'subscriptions'>('subscriptions'); return dbSubscriptions.length === 3; }); @@ -679,7 +679,7 @@ describe('OneSignal', () => { }, }); - await Database.remove('subscriptions'); + await db.clear('subscriptions'); const identityModel = OneSignal.coreDirector.getIdentityModel(); identityModel.setProperty( @@ -787,9 +787,11 @@ describe('OneSignal', () => { await waitForOperations(5); - const dbSubscriptions = (await Database.get( - 'subscriptions', - )) as any[]; + let dbSubscriptions: SubscriptionSchema[] = []; + await vi.waitUntil(async () => { + dbSubscriptions = await db.getAll<'subscriptions'>('subscriptions'); + return dbSubscriptions.length === 3; + }); expect(dbSubscriptions).toHaveLength(3); @@ -817,10 +819,11 @@ describe('OneSignal', () => { ModelChangeTags.NO_PROPOGATE, ); - const dbSubscriptions = (await Database.get( - 'subscriptions', - )) as any[]; - expect(dbSubscriptions).toHaveLength(1); + let dbSubscriptions: SubscriptionSchema[] = []; + await vi.waitUntil(async () => { + dbSubscriptions = await db.getAll<'subscriptions'>('subscriptions'); + return dbSubscriptions.length === 1; + }); await window.OneSignal.login(externalId); @@ -933,8 +936,7 @@ describe('OneSignal', () => { onesignalId: DUMMY_ONESIGNAL_ID, }); - const subscriptions = - await Database.get('subscriptions'); + const subscriptions = await db.getAll('subscriptions'); expect(subscriptions).toEqual([ { device_model: '', diff --git a/src/onesignal/OneSignal.ts b/src/onesignal/OneSignal.ts index c6fb116f4..7746db969 100644 --- a/src/onesignal/OneSignal.ts +++ b/src/onesignal/OneSignal.ts @@ -1,6 +1,9 @@ import type Bell from 'src/page/bell/Bell'; import { getAppConfig } from 'src/shared/config/app'; import type { AppConfig, AppUserConfig } from 'src/shared/config/types'; +import { db } from 'src/shared/database/client'; +import { getConsentGiven } from 'src/shared/database/config'; +import { getSubscription } from 'src/shared/database/subscription'; import { windowEnvString } from 'src/shared/environment/detect'; import { EmptyArgumentError, @@ -26,6 +29,7 @@ import { import { Browser } from 'src/shared/useragent/constants'; import { getBrowserName, getBrowserVersion } from 'src/shared/useragent/detect'; import { VERSION } from 'src/shared/utils/EnvVariables'; +import { logMethodCall } from 'src/shared/utils/utils'; import CoreModule from '../core/CoreModule'; import { CoreModuleDirector } from '../core/CoreModuleDirector'; import LoginManager from '../page/managers/LoginManager'; @@ -36,9 +40,7 @@ import { ProcessOneSignalPushCalls } from '../page/utils/ProcessOneSignalPushCal import MainHelper from '../shared/helpers/MainHelper'; import Emitter from '../shared/libraries/Emitter'; import Log from '../shared/libraries/Log'; -import Database from '../shared/services/Database'; import OneSignalEvent from '../shared/services/OneSignalEvent'; -import { logMethodCall } from '../shared/utils/utils'; import DebugNamespace from './DebugNamesapce'; import NotificationsNamespace from './NotificationsNamespace'; import { ONESIGNAL_EVENTS } from './OneSignalEvents'; @@ -53,7 +55,7 @@ export default class OneSignal { const core = new CoreModule(); await core.init(); OneSignal.coreDirector = new CoreModuleDirector(core); - const subscription = await Database.getSubscription(); + const subscription = await getSubscription(); const permission = await OneSignal.context.permissionManager.getPermissionStatus(); OneSignal.User = new UserNamespace(true, subscription, permission); @@ -149,7 +151,7 @@ export default class OneSignal { await OneSignal._initializeCoreModuleAndOSNamespaces(); if (getConsentRequired()) { - const providedConsent = await Database.getConsentGiven(); + const providedConsent = await getConsentGiven(); if (!providedConsent) { OneSignal.pendingInit = true; return; @@ -228,7 +230,8 @@ export default class OneSignal { throw WrongTypeArgumentError('consent'); } - await Database.setConsentGiven(consent); + await db.put('Options', { key: 'userConsent', value: consent }); + if (consent && OneSignal.pendingInit) await OneSignal._delayedInit(); } @@ -267,7 +270,7 @@ export default class OneSignal { static initialized = false; static _didLoadITILibrary = false; static notifyButton: Bell | null = null; - static database = Database; + static database = db; static event = OneSignalEvent; private static pendingInit = true; diff --git a/src/onesignal/PushSubscriptionNamespace.ts b/src/onesignal/PushSubscriptionNamespace.ts index 040fcf353..c1513b80a 100644 --- a/src/onesignal/PushSubscriptionNamespace.ts +++ b/src/onesignal/PushSubscriptionNamespace.ts @@ -1,3 +1,8 @@ +import { getDBAppConfig } from 'src/shared/database/config'; +import { + getSubscription, + setSubscription, +} from 'src/shared/database/subscription'; import { AppIDMissingError, MalformedArgumentError, @@ -11,7 +16,6 @@ import type { SubscriptionChangeEvent } from '../page/models/SubscriptionChangeE import { EventListenerBase } from '../page/userModel/EventListenerBase'; import Log from '../shared/libraries/Log'; import { Subscription } from '../shared/models/Subscription'; -import Database from '../shared/services/Database'; import { awaitOneSignalInitAndSupported, logMethodCall, @@ -122,8 +126,8 @@ export default class PushSubscriptionNamespace extends EventListenerBase { private async _enable(enabled: boolean): Promise { await awaitOneSignalInitAndSupported(); - const appConfig = await Database.getAppConfig(); - const subscriptionFromDb = await Database.getSubscription(); + const appConfig = await getDBAppConfig(); + const subscriptionFromDb = await getSubscription(); if (!appConfig.appId) { throw AppIDMissingError; @@ -133,7 +137,7 @@ export default class PushSubscriptionNamespace extends EventListenerBase { } subscriptionFromDb.optedOut = !enabled; - await Database.setSubscription(subscriptionFromDb); + await setSubscription(subscriptionFromDb); onInternalSubscriptionSet(subscriptionFromDb.optedOut).catch((e) => { Log.error(e); }); diff --git a/src/onesignal/User.ts b/src/onesignal/User.ts index b43e32b0a..4925a9ce3 100644 --- a/src/onesignal/User.ts +++ b/src/onesignal/User.ts @@ -13,7 +13,7 @@ import { SubscriptionType, } from 'src/shared/subscriptions/constants'; import type { SubscriptionTypeValue } from 'src/shared/subscriptions/types'; -import { logMethodCall } from '../shared/utils/utils'; +import { logMethodCall } from 'src/shared/utils/utils'; export default class User { static singletonInstance?: User; diff --git a/src/page/managers/LoginManager.ts b/src/page/managers/LoginManager.ts index 5f53d844f..a12d18b3b 100644 --- a/src/page/managers/LoginManager.ts +++ b/src/page/managers/LoginManager.ts @@ -1,11 +1,11 @@ import { LoginUserOperation } from 'src/core/operations/LoginUserOperation'; import { TransferSubscriptionOperation } from 'src/core/operations/TransferSubscriptionOperation'; import { ModelChangeTags } from 'src/core/types/models'; +import { db } from 'src/shared/database/client'; import MainHelper from 'src/shared/helpers/MainHelper'; import OneSignal from '../../onesignal/OneSignal'; import UserDirector from '../../onesignal/UserDirector'; import Log from '../../shared/libraries/Log'; -import Database from '../../shared/services/Database'; export default class LoginManager { // Other internal classes should await on this if they access users @@ -20,7 +20,7 @@ export default class LoginManager { token?: string, ): Promise { if (token) { - Database.setJWTToken(token); + db.put('Ids', { id: token, type: 'jwtToken' }); } let identityModel = OneSignal.coreDirector.getIdentityModel(); diff --git a/src/shared/config/constants.ts b/src/shared/config/constants.ts index e4b912a80..dfe6c54bb 100644 --- a/src/shared/config/constants.ts +++ b/src/shared/config/constants.ts @@ -11,13 +11,5 @@ export const NotificationClickActionBehavior = { export const ConfigIntegrationKind = { TypicalSite: 'typical', WordPress: 'wordpress', - Shopify: 'shopify', - Blogger: 'blogger', - Magento: 'magento', - Drupal: 'drupal', - SquareSpace: 'squarespace', - Joomla: 'joomla', - Weebly: 'weebly', - Wix: 'wix', Custom: 'custom', } as const; diff --git a/src/shared/database/client.test.ts b/src/shared/database/client.test.ts new file mode 100644 index 000000000..4658abe61 --- /dev/null +++ b/src/shared/database/client.test.ts @@ -0,0 +1,322 @@ +import { + APP_ID, + DUMMY_EXTERNAL_ID, + DUMMY_ONESIGNAL_ID, +} from '__test__/constants'; +import { deleteDB, type IDBPDatabase } from 'idb'; +import { SubscriptionType } from '../subscriptions/constants'; +import { closeDb, getDb } from './client'; +import { DATABASE_NAME } from './constants'; +import type { IndexedDBSchema } from './types'; + +vi.useRealTimers; + +beforeEach(async () => { + await closeDb(); + await deleteDB(DATABASE_NAME); +}); + +describe('general', () => { + const values: IndexedDBSchema['Options']['value'][] = [ + { key: 'consentGiven', value: true }, + { key: 'defaultIcon', value: 'icon' }, + { key: 'defaultTitle', value: 'title' }, + ]; + + test('should set _isNewVisitor to true if OneSignal is defined', async () => { + (global as any).OneSignal = { + _isNewVisitor: false, + }; + const OneSignal = (global as any).OneSignal; + await getDb(); + expect(OneSignal._isNewVisitor).toBe(true); + }); + + test('can get 1 or all values', async () => { + const db = await getDb(); + for (const value of values) { + await db.put('Options', value); + } + + const retrievedValue = await db.get('Options', 'consentGiven'); + expect(retrievedValue).toEqual({ + key: 'consentGiven', + value: true, + }); + + const retrievedValues = await db.getAll('Options'); + expect(retrievedValues).toEqual(values); + }); + + test('can set/update a value', async () => { + const db = await getDb(); + await db.put('Options', { key: 'consentGiven', value: 'optionsValue' }); + const retrievedValue = await db.get('Options', 'consentGiven'); + expect(retrievedValue).toEqual({ + key: 'consentGiven', + value: 'optionsValue', + }); + + // can update value + await db.put('Options', { key: 'consentGiven', value: 'optionsValue2' }); + const retrievedValue2 = await db.get('Options', 'consentGiven'); + expect(retrievedValue2).toEqual({ + key: 'consentGiven', + value: 'optionsValue2', + }); + + await expect( + // @ts-expect-error - for testing invalid value + db.put('Options', ''), + ).rejects.toThrow( + 'Data provided to an operation does not meet requirements.', + ); + }); + + test('can remove a value', async () => { + const db = await getDb(); + for (const value of values) { + await db.put('Options', value); + } + + // can remove a single value + await db.delete('Options', 'consentGiven'); + const retrievedValue = await db.get('Options', 'consentGiven'); + expect(retrievedValue).toBeUndefined(); + + // can remove remaining values + await db.clear('Options'); + const retrievedValues = await db.getAll('Options'); + expect(retrievedValues).toEqual([]); + + // resolves undefined if key does not exist + await expect( + // @ts-expect-error - using invalid key for testing + db.delete('Options', 'non-existent-key'), + ).resolves.toBeUndefined(); + }); +}); + +describe('migrations', () => { + describe('v5', () => { + test('can to write to new v5 tables', async () => { + const db = await getDb(); + const result = await db.put('Outcomes.NotificationClicked', { + appId: APP_ID, + notificationId: '1', + timestamp: 1, + }); + expect(result).toEqual('1'); + + const result2 = await db.put('Outcomes.NotificationReceived', { + appId: APP_ID, + notificationId: '1', + timestamp: 1, + }); + expect(result2).toEqual('1'); + }); + + // Tests NotificationClicked records migrate over from a v15 SDK version + test('migrates notificationId type records into Outcomes.NotificationClicked', async () => { + const db = await getDb(4); + + await db.put('NotificationClicked', { notificationId: '1' }); + await db.put('NotificationClicked', { notificationId: '2' }); + await closeDb(); + + const db2 = await getDb(5); + const result = await db2.getAll('Outcomes.NotificationClicked'); + expect(result).toEqual([ + { appId: undefined, notificationId: '1', timestamp: undefined }, + { appId: undefined, notificationId: '2', timestamp: undefined }, + ]); + + // old table should be removed + expect(db2.objectStoreNames).not.toContain('NotificationClicked'); + }); + + // Tests NotificationReceived records migrate over from a v15 SDK version + test('migrates notificationId type records into Outcomes.NotificationReceived', async () => { + const db = await getDb(4); + await db.put('NotificationReceived', { + appId: APP_ID, + notificationId: '1', + timestamp: 1, + }); + await db.put('NotificationReceived', { + appId: APP_ID, + notificationId: '2', + timestamp: 1, + }); + await closeDb(); + + const db2 = await getDb(5); + const result = await db2.getAll('Outcomes.NotificationReceived'); + expect(result).toEqual([ + { appId: APP_ID, notificationId: '1', timestamp: 1 }, + { appId: APP_ID, notificationId: '2', timestamp: 1 }, + ]); + + // old table should be removed + expect(db2.objectStoreNames).not.toContain('NotificationReceived'); + }); + + // Tests records coming from a broken SDK (160000.beta4 to 160000) and upgrading to fixed v5 db + test('migrates notification.id type records into Outcomes.NotificationClicked', async () => { + // 1. Put the db's schema into the broken v4 state that SDK v16000000 had + const openDbRequest = indexedDB.open(DATABASE_NAME, 4); + const dbOpenPromise = new Promise((resolve) => { + openDbRequest.onsuccess = resolve; + }); + const dbUpgradePromise = new Promise((resolve) => { + openDbRequest.onupgradeneeded = () => { + const db = openDbRequest.result; + db.createObjectStore('NotificationClicked', { + keyPath: 'notification.id', + }); + db.createObjectStore('NotificationReceived', { + keyPath: 'notificationId', + }); + resolve(); + }; + }); + await Promise.all([dbOpenPromise, dbUpgradePromise]); + + // 2. Put a record into the DB with the old schema + openDbRequest.result + .transaction(['NotificationClicked'], 'readwrite') + .objectStore('NotificationClicked') + .put({ notification: { id: '1' } }); + openDbRequest.result.close(); + + // 3. Open the DB with the OneSignal IndexedDb class + const db2 = await getDb(5); + const result = await db2.getAll('Outcomes.NotificationClicked'); + // 4. Expect the that data is brought over to the new table. + expect(result).toEqual([ + { appId: undefined, notificationId: '1', timestamp: undefined }, + ]); + }); + }); + + describe('v6', () => { + const populateLegacySubscriptions = async ( + db: IDBPDatabase, + ) => { + await db.put('emailSubscriptions', { + modelId: '1', + modelName: 'emailSubscriptions', + onesignalId: DUMMY_ONESIGNAL_ID, + type: SubscriptionType.Email, + token: 'email-token', + }); + await db.put('pushSubscriptions', { + modelId: '2', + modelName: 'pushSubscriptions', + onesignalId: DUMMY_ONESIGNAL_ID, + type: SubscriptionType.ChromePush, + token: 'push-token', + }); + await db.put('smsSubscriptions', { + modelId: '3', + modelName: 'smsSubscriptions', + onesignalId: DUMMY_ONESIGNAL_ID, + type: SubscriptionType.SMS, + token: 'sms-token', + }); + }; + + const migratedSubscriptions = { + email: { + modelId: '1', + modelName: 'subscriptions', + externalId: undefined, + onesignalId: DUMMY_ONESIGNAL_ID, + type: SubscriptionType.Email, + token: 'email-token', + }, + push: { + modelId: '2', + modelName: 'subscriptions', + externalId: undefined, + onesignalId: DUMMY_ONESIGNAL_ID, + type: SubscriptionType.ChromePush, + token: 'push-token', + }, + sms: { + modelId: '3', + modelName: 'subscriptions', + externalId: undefined, + onesignalId: DUMMY_ONESIGNAL_ID, + type: SubscriptionType.SMS, + token: 'sms-token', + }, + }; + + test('can write to new subscriptions table', async () => { + const db = await getDb(); + const result = await db.put('subscriptions', { + modelId: '1', + modelName: 'subscriptions', + onesignalId: '1', + type: 'email', + token: 'token', + }); + expect(result).toEqual('1'); + }); + + test('migrates v5 email, push, sms subscriptions records to v6 subscriptions record', async () => { + const db = await getDb(5); + await populateLegacySubscriptions(db); + await closeDb(); + + const db2 = await getDb(6); + const result = await db2.getAll('subscriptions'); + expect(result).toEqual([ + migratedSubscriptions.email, + migratedSubscriptions.push, + migratedSubscriptions.sms, + ]); + + // old tables should be removed + const oldTableNames = [ + 'emailSubscriptions', + 'pushSubscriptions', + 'smsSubscriptions', + ]; + for (const tableName of oldTableNames) { + expect(db2.objectStoreNames).not.toContain(tableName); + } + }); + + test('migrates v5 email, push, sms subscriptions records of logged in user to v6 subscriptions record with external id', async () => { + const db = await getDb(5); + await populateLegacySubscriptions(db); + // user is logged in + await db.put('identity', { + modelId: '4', + modelName: 'identity', + onesignalId: DUMMY_ONESIGNAL_ID, + externalId: DUMMY_EXTERNAL_ID, + }); + await closeDb(); + + const db2 = await getDb(6); + const result = await db2.getAll('subscriptions'); + expect(result).toEqual([ + { + ...migratedSubscriptions.email, + externalId: DUMMY_EXTERNAL_ID, + }, + { + ...migratedSubscriptions.push, + externalId: DUMMY_EXTERNAL_ID, + }, + { + ...migratedSubscriptions.sms, + externalId: DUMMY_EXTERNAL_ID, + }, + ]); + }); + }); +}); diff --git a/src/shared/database/client.ts b/src/shared/database/client.ts new file mode 100644 index 000000000..6dc2e5e1c --- /dev/null +++ b/src/shared/database/client.ts @@ -0,0 +1,156 @@ +import { openDB } from 'idb'; +import Log from '../libraries/Log'; +import { ONESIGNAL_SESSION_KEY } from '../session/constants'; +import { IS_SERVICE_WORKER } from '../utils/EnvVariables'; +import { DATABASE_NAME, VERSION } from './constants'; +import type { IDBStoreName, IdKey, IndexedDBSchema, OptionKey } from './types'; +import { + migrateModelNameSubscriptionsTableForV6, + migrateOutcomesNotificationClickedTableForV5, + migrateOutcomesNotificationReceivedTableForV5, +} from './upgrade'; + +let dbInstance: Awaited>> | null = + null; + +export const getDb = async (version = VERSION) => { + if (dbInstance) return dbInstance; + dbInstance = await openDB(DATABASE_NAME, version, { + upgrade(_db, oldVersion, newVersion, transaction) { + const newDbVersion = newVersion || version; + if (newDbVersion >= 1 && oldVersion < 1) { + _db.createObjectStore('Ids', { keyPath: 'type' }); + _db.createObjectStore('NotificationOpened', { keyPath: 'url' }); + _db.createObjectStore('Options', { keyPath: 'key' }); + } + + if (newDbVersion >= 2 && oldVersion < 2) { + _db.createObjectStore('Sessions', { keyPath: 'sessionKey' }); + _db.createObjectStore('NotificationReceived', { + keyPath: 'notificationId', + }); + // NOTE: 160000.beta4 to 160000 releases modified this line below as + // "{ keyPath: "notification.id" }". This resulted in DB v4 either + // having "notificationId" or "notification.id" depending if the visitor + // was new while this version was live. + // DB v5 was created to trigger a migration to fix this bug. + _db.createObjectStore('NotificationClicked', { + keyPath: 'notificationId', + }); + } + + if (newDbVersion >= 3 && oldVersion < 3) { + _db.createObjectStore('SentUniqueOutcome', { keyPath: 'outcomeName' }); + } + + if (newDbVersion >= 4 && oldVersion < 4) { + _db.createObjectStore('identity', { keyPath: 'modelId' }); + _db.createObjectStore('properties', { keyPath: 'modelId' }); + _db.createObjectStore('pushSubscriptions', { + keyPath: 'modelId', + }); + _db.createObjectStore('smsSubscriptions', { + keyPath: 'modelId', + }); + _db.createObjectStore('emailSubscriptions', { + keyPath: 'modelId', + }); + } + + if (newDbVersion >= 5 && oldVersion < 5) { + migrateOutcomesNotificationClickedTableForV5(_db, transaction); + migrateOutcomesNotificationReceivedTableForV5(_db, transaction); + } + + if (newDbVersion >= 6 && oldVersion < 6) { + migrateModelNameSubscriptionsTableForV6(_db, transaction); + } + + if (newDbVersion >= 7 && oldVersion < 7) { + _db.createObjectStore('operations', { keyPath: 'modelId' }); + } + + // TODO: next version delete NotificationOpened table + + if (!IS_SERVICE_WORKER && typeof OneSignal !== 'undefined') { + OneSignal._isNewVisitor = true; + } + }, + blocked() { + Log.debug('IndexedDB: Blocked event'); + }, + }); + return dbInstance; +}; + +// Export db object with the same API as before +export const db = { + async get( + storeName: K, + key: IndexedDBSchema[K]['key'], + ): Promise { + const _db = await getDb(); + return _db.get(storeName, key); + }, + async getAll( + storeName: K, + ): Promise { + const _db = await getDb(); + return _db.getAll(storeName); + }, + async put( + storeName: K, + value: IndexedDBSchema[K]['value'], + ) { + const _db = await getDb(); + return _db.put(storeName, value); + }, + async delete( + storeName: K, + key: IndexedDBSchema[K]['key'], + ) { + const _db = await getDb(); + return _db.delete(storeName, key); + }, + async clear(storeName: K) { + const _db = await getDb(); + return _db.clear(storeName); + }, + get objectStoreNames() { + return dbInstance?.objectStoreNames || []; + }, +}; + +export const getOptionsValue = async ( + key: OptionKey, +): Promise => { + const result = await db.get('Options', key); + if (result && 'value' in result) return result.value as T; + return null; +}; + +export const getIdsValue = async (key: IdKey): Promise => { + const result = await db.get('Ids', key); + if (result && 'id' in result) return result.id as T; + return null; +}; + +export const getCurrentSession = async () => { + return (await db.get('Sessions', ONESIGNAL_SESSION_KEY)) ?? null; +}; + +export const cleanupCurrentSession = async () => { + await db.delete('Sessions', ONESIGNAL_SESSION_KEY); +}; + +export const clearAll = async () => { + const objectStoreNames = db.objectStoreNames; + for (const storeName of objectStoreNames) { + await db.clear(storeName); + } +}; + +export const closeDb = async () => { + await dbInstance?.close(); + dbInstance = null; +}; diff --git a/src/shared/database/config.ts b/src/shared/database/config.ts new file mode 100644 index 000000000..27f8a11aa --- /dev/null +++ b/src/shared/database/config.ts @@ -0,0 +1,69 @@ +import { AppState } from '../models/AppState'; +import { db, getIdsValue, getOptionsValue } from './client'; + +export const getDBAppConfig = async () => { + const config: any = {}; + const appIdStr = await getIdsValue('appId'); + config.appId = appIdStr; + config.vapidPublicKey = await getOptionsValue('vapidPublicKey'); + return config; +}; + +export const getAppState = async (): Promise => { + const state = new AppState(); + state.defaultNotificationUrl = await getOptionsValue('defaultUrl'); + state.defaultNotificationTitle = + await getOptionsValue('defaultTitle'); + state.lastKnownPushEnabled = await getOptionsValue('isPushEnabled'); + + // lastKnown are used to track changes to the user's subscription + // state. Displayed in the `current` & `previous` fields of the `subscriptionChange` event. + state.lastKnownPushId = await getOptionsValue('lastPushId'); + state.lastKnownPushToken = await getOptionsValue('lastPushToken'); + state.lastKnownOptedIn = await getOptionsValue('lastOptedIn'); + return state; +}; + +export const setAppState = async (appState: AppState) => { + if (appState.defaultNotificationUrl) + await db.put('Options', { + key: 'defaultUrl', + value: appState.defaultNotificationUrl, + }); + if ( + appState.defaultNotificationTitle || + appState.defaultNotificationTitle === '' + ) + await db.put('Options', { + key: 'defaultTitle', + value: appState.defaultNotificationTitle, + }); + + if (appState.lastKnownPushEnabled != null) + await db.put('Options', { + key: 'isPushEnabled', + value: appState.lastKnownPushEnabled, + }); + + if (appState.lastKnownPushId != null) + await db.put('Options', { + key: 'lastPushId', + value: appState.lastKnownPushId, + }); + + if (appState.lastKnownPushToken != null) + await db.put('Options', { + key: 'lastPushToken', + value: appState.lastKnownPushToken, + }); + + if (appState.lastKnownOptedIn != null) + await db.put('Options', { + key: 'lastOptedIn', + value: appState.lastKnownOptedIn, + }); +}; + +export const getConsentGiven = async () => { + return await getOptionsValue('consentGiven'); +}; diff --git a/src/shared/database/constants.ts b/src/shared/database/constants.ts new file mode 100644 index 000000000..6ce87f94f --- /dev/null +++ b/src/shared/database/constants.ts @@ -0,0 +1,3 @@ +export const DATABASE_NAME = 'ONE_SIGNAL_SDK_DB'; + +export const VERSION = 7; diff --git a/src/shared/database/notifications.ts b/src/shared/database/notifications.ts new file mode 100644 index 000000000..3be6b4545 --- /dev/null +++ b/src/shared/database/notifications.ts @@ -0,0 +1,53 @@ +import { + notificationClickedForOutcomesFromDatabase, + notificationClickedForOutcomesToDatabase, + notificationReceivedForOutcomesFromDatabase, + notificationReceivedForOutcomesToDatabase, +} from '../helpers/serializer'; +import type { + OutcomesNotificationClicked, + OutcomesNotificationReceived, +} from '../models/OutcomesNotificationEvents'; +import type { + IOSNotification, + NotificationClickEventInternal, +} from '../notifications/types'; +import { db } from './client'; + +export const putNotificationClickedForOutcomes = async ( + appId: string, + event: NotificationClickEventInternal, +) => { + await db.put( + 'Outcomes.NotificationClicked', + notificationClickedForOutcomesToDatabase(appId, event), + ); +}; + +export const putNotificationReceivedForOutcomes = async ( + appId: string, + notification: IOSNotification, +) => { + await db.put( + 'Outcomes.NotificationReceived', + notificationReceivedForOutcomesToDatabase(appId, notification, Date.now()), + ); +}; + +export const getAllNotificationClickedForOutcomes = async (): Promise< + OutcomesNotificationClicked[] +> => { + const notifications = await db.getAll('Outcomes.NotificationClicked'); + return notifications.map((notification) => + notificationClickedForOutcomesFromDatabase(notification), + ); +}; + +export const getAllNotificationReceivedForOutcomes = async (): Promise< + OutcomesNotificationReceived[] +> => { + const notifications = await db.getAll('Outcomes.NotificationReceived'); + return notifications.map((notification) => + notificationReceivedForOutcomesFromDatabase(notification), + ); +}; diff --git a/src/shared/database/subscription.ts b/src/shared/database/subscription.ts new file mode 100644 index 000000000..744a44d71 --- /dev/null +++ b/src/shared/database/subscription.ts @@ -0,0 +1,86 @@ +import { Subscription } from '../models/Subscription'; +import { db, getOptionsValue } from './client'; + +export const getPushId = async () => { + return await getOptionsValue('lastPushId'); +}; +export const setPushId = async (pushId: string | undefined) => { + await db.put('Options', { key: 'lastPushId', value: pushId }); +}; + +export const getPushToken = async () => { + return await getOptionsValue('lastPushToken'); +}; +export const setPushToken = async (pushToken: string | undefined) => { + await db.put('Options', { key: 'lastPushToken', value: pushToken }); +}; + +export const getSubscription = async () => { + const subscription = new Subscription(); + subscription.deviceId = (await db.get('Ids', 'userId'))?.id; + subscription.subscriptionToken = (await db.get('Ids', 'registrationId'))?.id; + + // The preferred database key to store our subscription + const dbOptedOut = await getOptionsValue('optedOut'); + // For backwards compatibility, we need to read from this if the above is not found + const dbNotOptedOut = await getOptionsValue('subscription'); + const createdAt = await getOptionsValue('subscriptionCreatedAt'); + const expirationTime = await getOptionsValue( + 'subscriptionExpirationTime', + ); + + if (dbOptedOut != null) { + subscription.optedOut = dbOptedOut; + } else { + if (dbNotOptedOut == null) { + subscription.optedOut = false; + } else { + subscription.optedOut = !dbNotOptedOut; + } + } + subscription.createdAt = createdAt ?? null; + subscription.expirationTime = expirationTime ?? null; + + return subscription; +}; + +export const setSubscription = async (subscription: Subscription) => { + if (subscription.deviceId) { + await db.put('Ids', { + type: 'userId', + id: subscription.deviceId, + }); + } + + if (typeof subscription.subscriptionToken !== 'undefined') { + // Allow null subscriptions to be set + await db.put('Ids', { + type: 'registrationId', + id: subscription.subscriptionToken, + }); + } + + if (subscription.optedOut != null) { + // Checks if null or undefined, allows false + await db.put('Options', { + key: 'optedOut', + value: subscription.optedOut, + }); + } + + if (subscription.createdAt != null) { + await db.put('Options', { + key: 'subscriptionCreatedAt', + value: subscription.createdAt, + }); + } + + if (subscription.expirationTime != null) { + await db.put('Options', { + key: 'subscriptionExpirationTime', + value: subscription.expirationTime, + }); + } else { + await db.delete('Options', 'subscriptionExpirationTime'); + } +}; diff --git a/src/shared/database/types.ts b/src/shared/database/types.ts new file mode 100644 index 000000000..5ebb4ec2d --- /dev/null +++ b/src/shared/database/types.ts @@ -0,0 +1,191 @@ +import type { DBSchema, StoreNames } from 'idb'; +import type { + NotificationClickedForOutcomesSchema, + NotificationClickForOpenHandlingSchema, + NotificationReceivedForOutcomesSchema, +} from '../helpers/serializer'; +import type { AppState } from '../models/AppState'; +import type { SentUniqueOutcome } from '../models/Outcomes'; +import type { Session } from '../session/types'; +import { ModelName } from './constants'; + +export type ModelNameType = (typeof ModelName)[keyof typeof ModelName]; + +export type IdKey = 'appId' | 'registrationId' | 'userId' | 'jwtToken'; + +export type OptionKey = + | 'appState' + | 'consentGiven' + | 'defaultIcon' + | 'defaultTitle' + | 'defaultUrl' + | 'isPushEnabled' + | 'lastOptedIn' + | 'lastPushId' + | 'lastPushToken' + | 'nonPushPromptsDismissCount' + | 'notificationClickHandlerAction' + | 'notificationClickHandlerMatch' + | 'notificationPermission' + | 'optedOut' + | 'pageTitle' + | 'persistNotification' + | 'previousExternalId' + | 'previousOneSignalId' + | 'promptDismissCount' + | 'subscription' + | 'subscriptionCreatedAt' + | 'subscriptionExpirationTime' + | 'userConsent' + | 'vapidPublicKey' + | 'webhooks.cors' + | 'webhooks.notification.clicked' + | 'webhooks.notification.dismissed' + | 'webhooks.notification.willDisplay'; + +export interface SubscriptionSchema { + modelId: string; + modelName: 'subscriptions'; + onesignalId?: string; + externalId?: string; + id?: string; + type: string; + token: string; + notification_types?: string; + enabled?: boolean; + web_auth?: boolean; + web_p256?: boolean; + device_model?: string; + device_os?: string; + sdk?: string; +} + +export interface IndexedDBSchema extends DBSchema { + /** + * @deprecated - should be migrated in openDB() + */ + pushSubscriptions: { + key: string; + value: Omit & { + modelName: 'pushSubscriptions'; + }; + }; + + /** + * @deprecated - should be migrated in openDB() + */ + smsSubscriptions: { + key: string; + value: Omit & { + modelName: 'smsSubscriptions'; + }; + }; + + /** + * @deprecated - should be migrated in openDB() + */ + emailSubscriptions: { + key: string; + value: Omit & { + modelName: 'emailSubscriptions'; + }; + }; + + /** + * @deprecated - should be migrated in openDB() + */ + NotificationReceived: { + key: string; + value: NotificationReceivedForOutcomesSchema; + }; + /** + * @deprecated - should be migrated in openDB() + */ + NotificationClicked: { + key: string; + value: { notificationId: string; [key: string]: any }; + }; + + 'Outcomes.NotificationClicked': { + key: string; + value: NotificationClickedForOutcomesSchema; + }; + + 'Outcomes.NotificationReceived': { + key: string; + value: NotificationReceivedForOutcomesSchema; + }; + + Ids: { + key: IdKey; + value: { type: IdKey; id: string | null }; + }; + + NotificationOpened: { + key: string; + value: NotificationClickForOpenHandlingSchema; + }; + + Options: { + key: OptionKey; + value: { + key: OptionKey; + value: boolean | number | string | AppState | undefined | null; + }; + }; + + Sessions: { + key: string; + value: Session; + }; + + SentUniqueOutcome: { + key: string; + value: SentUniqueOutcome; + }; + + identity: { + key: string; + value: { + modelId: string; + modelName: 'identity'; + onesignal_id?: string; + onesignalId?: string; + external_id?: string; + externalId?: string; + }; + }; + + properties: { + key: string; + value: { + modelId: string; + modelName: 'properties'; + country: string; + first_active: number; + ip: string; + language: string; + last_active: number; + onesignalId: string; + tags: Record; + timezone_id: string; + }; + }; + + subscriptions: { + key: string; + value: SubscriptionSchema; + }; + + operations: { + key: string; + value: { + modelId: string; + modelName: 'operations'; + name: string; + [key: string]: unknown; + }; + }; +} + +export type IDBStoreName = StoreNames; diff --git a/src/shared/database/upgrade.ts b/src/shared/database/upgrade.ts new file mode 100644 index 000000000..a756d592f --- /dev/null +++ b/src/shared/database/upgrade.ts @@ -0,0 +1,91 @@ +import type { IDBPDatabase, IDBPTransaction } from 'idb'; +import type { IndexedDBSchema } from './types'; + +type Transaction = IDBPTransaction; + +// Table rename "NotificationClicked" -> "Outcomes.NotificationClicked" +// and migrate existing records. +// Motivation: This is done to correct the keyPath, you can't change it +// so a new table must be created. +// Background: Table was created with wrong keyPath of "notification.id" +// for new visitors for versions 160000.beta4 to 160000.beta8. Writes were +// attempted as "notificationId" in released 160000 however they may +// have failed if the visitor was new when those releases were in the wild. +// However those new on 160000.beta4 to 160000.beta8 will have records +// saved as "notification.id" that will be converted here. +export async function migrateOutcomesNotificationClickedTableForV5( + db: IDBPDatabase, + transaction: Transaction, +) { + const oldTableName = 'NotificationClicked'; + const newTableName = 'Outcomes.NotificationClicked'; + + db.createObjectStore(newTableName, { keyPath: 'notificationId' }); + let cursor = await transaction.objectStore(oldTableName).openCursor(); + + while (cursor) { + const oldValue = cursor.value; + + await transaction.objectStore(newTableName).put({ + // notification.id was possible from 160000.beta4 to 160000.beta8 + notificationId: oldValue.notificationId || oldValue.notification.id, + appId: oldValue.appId, + timestamp: oldValue.timestamp, + }); + + cursor = await cursor.continue(); + } + db.deleteObjectStore(oldTableName); +} + +// Table rename "NotificationReceived" -> "Outcomes.NotificationReceived" +// and migrate existing records. +// Motivation: Consistency of using pre-fix "Outcomes." like we have for +// the "Outcomes.NotificationClicked" table. +export async function migrateOutcomesNotificationReceivedTableForV5( + db: IDBPDatabase, + transaction: Transaction, +) { + const oldTableName = 'NotificationReceived'; + const newTableName = 'Outcomes.NotificationReceived'; + db.createObjectStore(newTableName, { keyPath: 'notificationId' }); + + let cursor = await transaction.objectStore(oldTableName).openCursor(); + while (cursor) { + await transaction.objectStore(newTableName).put(cursor.value); + cursor = await cursor.continue(); + } + db.deleteObjectStore(oldTableName); +} + +export async function migrateModelNameSubscriptionsTableForV6( + db: IDBPDatabase, + transaction: Transaction, +) { + const newTableName = 'subscriptions'; + db.createObjectStore(newTableName, { keyPath: 'modelId' }); + + let currentExternalId: string | undefined; + const identityData = await transaction.objectStore('identity').getAll(); + + if (identityData.length > 0) { + currentExternalId = identityData[0].externalId; + } + + for (const legacyModelName of [ + 'emailSubscriptions', + 'pushSubscriptions', + 'smsSubscriptions', + ] as const) { + let cursor = await transaction.objectStore(legacyModelName).openCursor(); + while (cursor) { + await transaction.objectStore(newTableName).put({ + ...cursor.value, + modelName: 'subscriptions', + externalId: currentExternalId, + }); + cursor = await cursor.continue(); + } + db.deleteObjectStore(legacyModelName); + } +} diff --git a/src/shared/helpers/DismissHelper.ts b/src/shared/helpers/DismissHelper.ts index a42b3b2b0..154c32a8e 100644 --- a/src/shared/helpers/DismissHelper.ts +++ b/src/shared/helpers/DismissHelper.ts @@ -1,3 +1,4 @@ +import { db, getOptionsValue } from 'src/shared/database/client'; import { DismissCountKey, DismissPrompt, @@ -7,7 +8,6 @@ import { import TimedLocalStorage from '../../page/modules/TimedLocalStorage'; import { windowEnvString } from '../environment/detect'; import Log from '../libraries/Log'; -import Database from '../services/Database'; const DISMISS_TYPE_COUNT_MAP = { [DismissPrompt.Push]: DismissCountKey.PromptDismissCount, @@ -27,7 +27,7 @@ export class DismissHelper { const countKey = DISMISS_TYPE_COUNT_MAP[type]; const timeKey = DISMISS_TYPE_TIME_MAP[type]; - let dismissCount = await Database.get('Options', countKey); + let dismissCount = await getOptionsValue(countKey); if (!dismissCount) { dismissCount = 0; } @@ -43,7 +43,7 @@ export class DismissHelper { `(${windowEnvString} environment) OneSignal: User dismissed the ${type} ` + `notification prompt; reprompt after ${dismissDays} days.`, ); - await Database.put('Options', { key: countKey, value: dismissCount }); + await db.put('Options', { key: countKey, value: dismissCount }); const dismissMinutes = dismissDays * 24 * 60; return TimedLocalStorage.setItem(timeKey, 'dismissed', dismissMinutes); diff --git a/src/shared/helpers/MainHelper.ts b/src/shared/helpers/MainHelper.ts index e7359d553..acfa7719a 100755 --- a/src/shared/helpers/MainHelper.ts +++ b/src/shared/helpers/MainHelper.ts @@ -1,12 +1,14 @@ -import type { NotificationIcons } from 'src/shared/notifications/types'; +import { db, getOptionsValue } from '../database/client'; +import { getDBAppConfig } from '../database/config'; +import { getSubscription } from '../database/subscription'; import { getOneSignalApiUrl, useSafariLegacyPush } from '../environment/detect'; import { AppIDMissingError, MalformedArgumentError } from '../errors/common'; import Log from '../libraries/Log'; +import type { NotificationIcons } from '../notifications/types'; import type { AppUserConfigPromptOptions, SlidedownOptions, } from '../prompts/types'; -import Database from '../services/Database'; import { getPlatformNotificationIcon, logMethodCall } from '../utils/utils'; import { getValueOrDefault } from './general'; import { triggerNotificationPermissionChanged } from './permissions'; @@ -31,7 +33,7 @@ export default class MainHelper { buttons, ); - const appConfig = await Database.getAppConfig(); + const appConfig = await getDBAppConfig(); if (!appConfig.appId) throw AppIDMissingError; if (!OneSignal.Notifications.permission) @@ -89,16 +91,16 @@ export default class MainHelper { } static async checkAndTriggerNotificationPermissionChanged() { - const previousPermission = await Database.get( - 'Options', + const previousPermission = await getOptionsValue( 'notificationPermission', ); + const currentPermission = await OneSignal.context.permissionManager.getPermissionStatus(); if (previousPermission !== currentPermission) { await triggerNotificationPermissionChanged(); - await Database.put('Options', { + await db.put('Options', { key: 'notificationPermission', value: currentPermission, }); @@ -215,7 +217,7 @@ export default class MainHelper { } static async getDeviceId(): Promise { - const subscription = await OneSignal.database.getSubscription(); + const subscription = await getSubscription(); return subscription.deviceId || undefined; } diff --git a/src/shared/helpers/OutcomesHelper.ts b/src/shared/helpers/OutcomesHelper.ts index a801252af..8aa6786aa 100644 --- a/src/shared/helpers/OutcomesHelper.ts +++ b/src/shared/helpers/OutcomesHelper.ts @@ -1,4 +1,9 @@ import { sortArrayOfObjects } from '../context/helpers'; +import { db, getCurrentSession } from '../database/client'; +import { + getAllNotificationClickedForOutcomes, + getAllNotificationReceivedForOutcomes, +} from '../database/notifications'; import Log from '../libraries/Log'; import type { OutcomeProps } from '../models/OutcomeProps'; import { @@ -8,9 +13,6 @@ import { } from '../models/Outcomes'; import type { OutcomesNotificationReceived } from '../models/OutcomesNotificationEvents'; import type { OutcomesConfig } from '../outcomes/types'; -import Database, { - TABLE_OUTCOMES_NOTIFICATION_RECEIVED, -} from '../services/Database'; import { awaitOneSignalInitAndSupported, logMethodCall } from '../utils/utils'; const SEND_OUTCOME = 'sendOutcome'; @@ -90,8 +92,7 @@ export default class OutcomesHelper { * @returns Promise */ async getAttributedNotifsByUniqueOutcomeName(): Promise { - const sentOutcomes = - await Database.getAll('SentUniqueOutcome'); + const sentOutcomes = await db.getAll('SentUniqueOutcome'); return sentOutcomes .filter((o) => o.outcomeName === this.outcomeName) .reduce((acc: string[], curr: SentUniqueOutcome) => { @@ -124,11 +125,8 @@ export default class OutcomesHelper { async saveSentUniqueOutcome(newNotificationIds: string[]): Promise { const outcomeName = this.outcomeName; - const existingSentOutcome = await Database.get( - 'SentUniqueOutcome', - outcomeName, - ); - const currentSession = await Database.getCurrentSession(); + const existingSentOutcome = await db.get('SentUniqueOutcome', outcomeName); + const currentSession = await getCurrentSession(); const existingNotificationIds = !!existingSentOutcome ? existingSentOutcome.notificationIds @@ -136,7 +134,7 @@ export default class OutcomesHelper { const notificationIds = [...existingNotificationIds, ...newNotificationIds]; const timestamp = currentSession ? currentSession.startTimestamp : null; - await Database.put('SentUniqueOutcome', { + await db.put('SentUniqueOutcome', { outcomeName, notificationIds, sentDuringSession: timestamp, @@ -144,16 +142,13 @@ export default class OutcomesHelper { } async wasSentDuringSession() { - const sentOutcome = await Database.get( - 'SentUniqueOutcome', - this.outcomeName, - ); + const sentOutcome = await db.get('SentUniqueOutcome', this.outcomeName); if (!sentOutcome) { return false; } - const session = await Database.getCurrentSession(); + const session = await getCurrentSession(); const sessionExistsAndWasPreviouslySent = session && sentOutcome.sentDuringSession === session.startTimestamp; @@ -244,8 +239,7 @@ export async function getConfigAttribution( /* direct notifications */ if (config.direct && config.direct.enabled) { - const clickedNotifications = - await Database.getAllNotificationClickedForOutcomes(); + const clickedNotifications = await getAllNotificationClickedForOutcomes(); if (clickedNotifications.length > 0) { return { type: OutcomeAttributionType.Direct, @@ -261,7 +255,7 @@ export async function getConfigAttribution( const maxTimestamp = beginningOfTimeframe.getTime(); const allReceivedNotification = - await Database.getAllNotificationReceivedForOutcomes(); + await getAllNotificationReceivedForOutcomes(); Log.debug( `\tFound total of ${allReceivedNotification.length} received notifications`, ); @@ -295,7 +289,7 @@ export async function getConfigAttribution( ) .map((notif) => notif.notificationId); notificationIdsToDelete.forEach((id) => - Database.remove(TABLE_OUTCOMES_NOTIFICATION_RECEIVED, id), + db.delete('Outcomes.NotificationReceived', id), ); Log.debug( `\t${notificationIdsToDelete.length} received notifications will be deleted.`, diff --git a/src/shared/helpers/init.test.ts b/src/shared/helpers/init.test.ts index dc7c19e2a..c4698caaa 100644 --- a/src/shared/helpers/init.test.ts +++ b/src/shared/helpers/init.test.ts @@ -3,7 +3,7 @@ import { TestEnvironment } from '__test__/support/environment/TestEnvironment'; import Context from 'src/page/models/Context'; import { type AppConfig } from 'src/shared/config/types'; import type { Mock } from 'vitest'; -import Database from '../services/Database'; +import { db } from '../database/client'; import * as InitHelper from './init'; let isSubscriptionExpiringSpy: Mock; @@ -87,21 +87,19 @@ test('correct degree of persistNotification setting should be stored', async () // If not set, default to true delete config.userConfig.persistNotification; await InitHelper.saveInitOptions(); - let persistNotification = await Database.get( - 'Options', - 'persistNotification', - ); + let persistNotification = (await db.get('Options', 'persistNotification')) + ?.value; expect(persistNotification).toBe(true); // If set to false, ensure value is false config.userConfig.persistNotification = false; await InitHelper.saveInitOptions(); - persistNotification = await Database.get('Options', 'persistNotification'); + persistNotification = (await db.get('Options', 'persistNotification'))?.value; expect(persistNotification).toBe(false); // If set to true, ensure value is true config.userConfig.persistNotification = true; await InitHelper.saveInitOptions(); - persistNotification = await Database.get('Options', 'persistNotification'); + persistNotification = (await db.get('Options', 'persistNotification'))?.value; expect(persistNotification).toBe(true); }); diff --git a/src/shared/helpers/init.ts b/src/shared/helpers/init.ts index 677d1554c..cd8aafc15 100755 --- a/src/shared/helpers/init.ts +++ b/src/shared/helpers/init.ts @@ -1,11 +1,13 @@ import Bell from '../../page/bell/Bell'; import type { AppConfig } from '../config/types'; import type { ContextInterface } from '../context/types'; +import { db } from '../database/client'; +import { getSubscription, setSubscription } from '../database/subscription'; +import type { OptionKey } from '../database/types'; import Log from '../libraries/Log'; import { CustomLinkManager } from '../managers/CustomLinkManager'; import { NotificationPermission } from '../models/NotificationPermission'; import { SubscriptionStrategyKind } from '../models/SubscriptionStrategyKind'; -import Database from '../services/Database'; import LimitStore from '../services/LimitStore'; import OneSignalEvent from '../services/OneSignalEvent'; import { IS_SERVICE_WORKER } from '../utils/EnvVariables'; @@ -73,16 +75,16 @@ async function sessionInit(): Promise { const isOptedOut = (await OneSignal.context.subscriptionManager.isOptedOut()) ?? false; // saves isOptedOut to localStorage. used for require user interaction functionality - const subscription = await Database.getSubscription(); + const subscription = await getSubscription(); subscription.optedOut = isOptedOut; - await Database.setSubscription(subscription); + await setSubscription(subscription); await handleAutoResubscribe(isOptedOut); const isSubscribed = await OneSignal.context.subscriptionManager.isPushNotificationsEnabled(); // saves isSubscribed to IndexedDb. used for require user interaction functionality - await Database.setIsPushEnabled(!!isSubscribed); + await db.put('Options', { key: 'isPushEnabled', value: !!isSubscribed }); if (OneSignal.config?.userConfig.promptOptions?.autoPrompt && !isOptedOut) { OneSignal.context.promptsManager.spawnAutoPrompts(); @@ -135,11 +137,11 @@ async function storeInitialValues() { await OneSignal.context.permissionManager.getPermissionStatus(); const isOptedOut = await OneSignal.context.subscriptionManager.isOptedOut(); LimitStore.put('subscription.optedOut', isOptedOut); - await Database.put('Options', { + await db.put('Options', { key: 'isPushEnabled', value: isPushEnabled, }); - await Database.put('Options', { + await db.put('Options', { key: 'notificationPermission', value: notificationPermission, }); @@ -287,7 +289,7 @@ export async function saveInitOptions() { const persistNotification = OneSignal.config?.userConfig.persistNotification; opPromises.push( - Database.put('Options', { + db.put('Options', { key: 'persistNotification', value: persistNotification != null ? persistNotification : true, }), @@ -304,37 +306,36 @@ export async function saveInitOptions() { webhookOptions[event as keyof typeof webhookOptions] ) { opPromises.push( - Database.put('Options', { - key: `webhooks.${event}`, + db.put('Options', { + key: `webhooks.${event}` as OptionKey, value: webhookOptions[event as keyof typeof webhookOptions], }), ); } else { opPromises.push( - Database.put('Options', { key: `webhooks.${event}`, value: false }), + db.put('Options', { + key: `webhooks.${event}` as OptionKey, + value: false, + }), ); } }); if (webhookOptions && webhookOptions.cors) { - opPromises.push( - Database.put('Options', { key: `webhooks.cors`, value: true }), - ); + opPromises.push(db.put('Options', { key: `webhooks.cors`, value: true })); } else { - opPromises.push( - Database.put('Options', { key: `webhooks.cors`, value: false }), - ); + opPromises.push(db.put('Options', { key: `webhooks.cors`, value: false })); } if (OneSignal.config?.userConfig.notificationClickHandlerMatch) { opPromises.push( - Database.put('Options', { + db.put('Options', { key: 'notificationClickHandlerMatch', value: OneSignal.config.userConfig.notificationClickHandlerMatch, }), ); } else { opPromises.push( - Database.put('Options', { + db.put('Options', { key: 'notificationClickHandlerMatch', value: 'exact', }), @@ -343,14 +344,14 @@ export async function saveInitOptions() { if (OneSignal.config?.userConfig.notificationClickHandlerAction) { opPromises.push( - Database.put('Options', { + db.put('Options', { key: 'notificationClickHandlerAction', value: OneSignal.config.userConfig.notificationClickHandlerAction, }), ); } else { opPromises.push( - Database.put('Options', { + db.put('Options', { key: 'notificationClickHandlerAction', value: 'navigate', }), @@ -362,10 +363,10 @@ export async function saveInitOptions() { export async function initSaveState(overridingPageTitle?: string) { const appId = MainHelper.getAppId(); const config: AppConfig = OneSignal.config!; - await Database.put('Ids', { type: 'appId', id: appId }); + await db.put('Ids', { type: 'appId', id: appId }); const pageTitle: string = overridingPageTitle || config.siteName || document.title || 'Notification'; - await Database.put('Options', { key: 'pageTitle', value: pageTitle }); + await db.put('Options', { key: 'pageTitle', value: pageTitle }); Log.info(`OneSignal: Set pageTitle to be '${pageTitle}'.`); } diff --git a/src/shared/helpers/permissions.ts b/src/shared/helpers/permissions.ts index 03de520fb..8e20165d3 100644 --- a/src/shared/helpers/permissions.ts +++ b/src/shared/helpers/permissions.ts @@ -1,4 +1,4 @@ -import Database from '../services/Database'; +import { db, getOptionsValue } from '../database/client'; import OneSignalEvent from '../services/OneSignalEvent'; // This flag prevents firing the NOTIFICATION_PERMISSION_CHANGED_AS_STRING event twice @@ -24,8 +24,7 @@ export const triggerNotificationPermissionChanged = async (force = false) => { const privateTriggerNotificationPermissionChanged = async (force: boolean) => { const newPermission: NotificationPermission = await OneSignal.context.permissionManager.getPermissionStatus(); - const previousPermission: NotificationPermission = await Database.get( - 'Options', + const previousPermission = await getOptionsValue( 'notificationPermission', ); @@ -34,7 +33,7 @@ const privateTriggerNotificationPermissionChanged = async (force: boolean) => { return; } - await Database.put('Options', { + await db.put('Options', { key: 'notificationPermission', value: newPermission, }); @@ -47,7 +46,7 @@ const privateTriggerNotificationPermissionChanged = async (force: boolean) => { }; const triggerBooleanPermissionChangeEvent = ( - previousPermission: NotificationPermission, + previousPermission: NotificationPermission | null, newPermission: NotificationPermission, force: boolean, ): void => { diff --git a/src/shared/helpers/serializer.ts b/src/shared/helpers/serializer.ts index 6f0d028de..a931feb85 100644 --- a/src/shared/helpers/serializer.ts +++ b/src/shared/helpers/serializer.ts @@ -81,44 +81,6 @@ function toDatabaseButtons( ); } -export function notificationClickFromDatabase( - record: NotificationClickForOpenHandlingSchema, -): NotificationClickEventInternal { - return { - result: { - actionId: record.action, - url: record.url, - }, - notification: { - notificationId: record.id, - title: record.heading, - body: record.content, - additionalData: record.data, - launchURL: record.url, - confirmDelivery: record.rr, - icon: record.icon, - image: record.image, - topic: record.tag, - badgeIcon: record.badge, - actionButtons: toOSNotificationButtons(record.buttons), - }, - timestamp: record.timestamp, - }; -} - -function toOSNotificationButtons( - buttons?: NotificationButtonsClickForOpenHandlingSchema[], -): IOSNotificationActionButton[] | undefined { - return buttons?.map( - (button): IOSNotificationActionButton => ({ - actionId: button.action, - text: button.title, - icon: button.icon, - launchURL: button.url, - }), - ); -} - export interface NotificationClickedForOutcomesSchema { readonly appId: string; readonly notificationId: string; // indexDb's keyPath diff --git a/src/shared/helpers/service-worker.ts b/src/shared/helpers/service-worker.ts index b326fb6e5..8c14cf025 100755 --- a/src/shared/helpers/service-worker.ts +++ b/src/shared/helpers/service-worker.ts @@ -4,11 +4,16 @@ import { } from '../../sw/helpers/CancelableTimeout'; import OneSignalApiSW from '../api/OneSignalApiSW'; import { encodeHashAsUriComponent } from '../context/helpers'; +import { + cleanupCurrentSession, + db, + getCurrentSession, +} from '../database/client'; +import { getAllNotificationClickedForOutcomes } from '../database/notifications'; import Log from '../libraries/Log'; import type { OutcomesNotificationClicked } from '../models/OutcomesNotificationEvents'; import Path from '../models/Path'; import type { OutcomesConfig } from '../outcomes/types'; -import Database from '../services/Database'; import { SessionOrigin, SessionStatus } from '../session/constants'; import { initializeNewSession } from '../session/helpers'; import type { Session, SessionOriginValue } from '../session/types'; @@ -49,19 +54,19 @@ export async function upsertSession( sessionOrigin: SessionOriginValue, outcomesConfig: OutcomesConfig, ): Promise { - const existingSession = await Database.getCurrentSession(); + const existingSession = await getCurrentSession(); if (!existingSession) { const session: Session = initializeNewSession({ appId }); // if there is a record about a clicked notification in our database, attribute session to it. const clickedNotification: OutcomesNotificationClicked | null = - await Database.getLastNotificationClickedForOutcomes(appId); + await getLastNotificationClickedForOutcomes(appId); if (clickedNotification) { session.notificationId = clickedNotification.notificationId; } - await Database.upsertSession(session); + await db.put('Sessions', session); await sendOnSessionCallIfNotPlayerCreate( appId, onesignalId, @@ -94,7 +99,7 @@ export async function upsertSession( existingSession.status = SessionStatus.Active; existingSession.lastActivatedTimestamp = currentTimestamp; existingSession.lastDeactivatedTimestamp = null; - await Database.upsertSession(existingSession); + await db.put('Sessions', existingSession); return; } @@ -110,7 +115,7 @@ export async function upsertSession( outcomesConfig, ); const session: Session = initializeNewSession({ appId }); - await Database.upsertSession(session); + await db.put('Sessions', session); await sendOnSessionCallIfNotPlayerCreate( appId, onesignalId, @@ -128,7 +133,7 @@ export async function deactivateSession( sendOnFocusEnabled: boolean, outcomesConfig: OutcomesConfig, ): Promise { - const existingSession = await Database.getCurrentSession(); + const existingSession = await getCurrentSession(); if (!existingSession) { Log.debug('No active session found. Cannot deactivate.'); @@ -181,7 +186,7 @@ export async function deactivateSession( thresholdInSeconds, ); - await Database.upsertSession(existingSession); + await db.put('Sessions', existingSession); return cancelableFinalize; } @@ -201,8 +206,8 @@ async function sendOnSessionCallIfNotPlayerCreate( return; } - Database.upsertSession(session); - Database.resetSentUniqueOutcomes(); + db.put('Sessions', session); + resetSentUniqueOutcomes(); // USER MODEL TO DO: handle potential 404 - user does not exist await OneSignalApiSW.updateUserSession(appId, onesignalId, subscriptionId); @@ -237,8 +242,8 @@ async function finalizeSession( } await Promise.all([ - Database.cleanupCurrentSession(), - Database.removeAllNotificationClickedForOutcomes(), + cleanupCurrentSession(), + db.clear('Outcomes.NotificationClicked'), ]); Log.debug( 'Finalize session finished', @@ -246,6 +251,29 @@ async function finalizeSession( ); } +const resetSentUniqueOutcomes = async (): Promise => { + const outcomes = await db.getAll('SentUniqueOutcome'); + const promises = outcomes.map((o) => { + o.sentDuringSession = null; + return db.put('SentUniqueOutcome', o); + }); + await Promise.all(promises); +}; + +const getLastNotificationClickedForOutcomes = async ( + appId: string, +): Promise => { + let allClickedNotifications: OutcomesNotificationClicked[] = []; + try { + allClickedNotifications = await getAllNotificationClickedForOutcomes(); + } catch (e) { + Log.error('Database.getLastNotificationClickedForOutcomes', e); + } + const predicate = (notification: OutcomesNotificationClicked) => + notification.appId === appId; + return allClickedNotifications.find(predicate) || null; +}; + function timeInSecondsBetweenTimestamps( timestamp1: number, timestamp2: number, diff --git a/src/shared/libraries/workerMessenger/constants.ts b/src/shared/libraries/workerMessenger/constants.ts index 743b1129a..68253388d 100644 --- a/src/shared/libraries/workerMessenger/constants.ts +++ b/src/shared/libraries/workerMessenger/constants.ts @@ -5,7 +5,6 @@ export const WorkerMessengerCommand = { NotificationWillDisplay: 'notification.willDisplay', NotificationClicked: 'notification.clicked', NotificationDismissed: 'notification.dismissed', - RedirectPage: 'command.redirect', SessionUpsert: 'os.session.upsert', SessionDeactivate: 'os.session.deactivate', AreYouVisible: 'os.page_focused_request', diff --git a/src/shared/listeners.test.ts b/src/shared/listeners.test.ts index ee693236c..b35eb03d1 100644 --- a/src/shared/listeners.test.ts +++ b/src/shared/listeners.test.ts @@ -1,19 +1,19 @@ import { TestEnvironment } from '__test__/support/environment/TestEnvironment'; -import * as eventListeners from 'src/shared/listeners'; +import OneSignal from 'src/onesignal/OneSignal'; +import type { Mock } from 'vitest'; -describe('Notification Listeners', () => { - beforeEach(async () => { - await TestEnvironment.initialize(); - }); +let emitterSpy: Mock; - afterEach(() => { - vi.resetAllMocks(); - }); +beforeEach(async () => { + await TestEnvironment.initialize(); + emitterSpy = vi.spyOn(OneSignal.emitter, 'on'); +}); + +afterEach(() => { + vi.resetAllMocks(); +}); - test('Adding click listener fires internal EventHelper', async () => { - const stub = vi.spyOn(eventListeners, 'fireStoredNotificationClicks'); - // @ts-expect-error - listener doesnt matter - OneSignal.Notifications.addEventListener('click', null); - expect(stub).toHaveBeenCalledTimes(1); - }); +test('Adding click listener fires internal EventHelper', async () => { + OneSignal.Notifications.addEventListener('click', () => {}); + expect(emitterSpy).toHaveBeenCalledTimes(1); }); diff --git a/src/shared/listeners.ts b/src/shared/listeners.ts index 5f0976390..5450e12ac 100644 --- a/src/shared/listeners.ts +++ b/src/shared/listeners.ts @@ -1,19 +1,21 @@ import UserNamespace from 'src/onesignal/UserNamespace'; import type { SubscriptionChangeEvent } from 'src/page/models/SubscriptionChangeEvent'; import type { UserChangeEvent } from 'src/page/models/UserChangeEvent'; +import { db, getOptionsValue } from './database/client'; +import { getAppState, setAppState } from './database/config'; import { decodeHtmlEntities } from './helpers/dom'; import MainHelper from './helpers/MainHelper'; import Log from './libraries/Log'; import { CustomLinkManager } from './managers/CustomLinkManager'; +import { UserState } from './models/UserState'; import type { NotificationClickEvent, NotificationClickEventInternal, } from './notifications/types'; import { isCategorySlidedownConfigured } from './prompts/helpers'; -import Database from './services/Database'; import LimitStore from './services/LimitStore'; import OneSignalEvent from './services/OneSignalEvent'; -import { awaitOneSignalInitAndSupported, logMethodCall } from './utils/utils'; +import { logMethodCall } from './utils/utils'; export async function checkAndTriggerSubscriptionChanged() { logMethodCall('checkAndTriggerSubscriptionChanged'); @@ -23,9 +25,9 @@ export async function checkAndTriggerSubscriptionChanged() { await OneSignal.context.subscriptionManager.isPushNotificationsEnabled(); // isOptedIn = native permission granted && is not opted out const isOptedIn: boolean = - await OneSignal.context.subscriptionManager.isOptedIn(); + await OneSignal.context.subscriptionManager.isOptedIn!(); - const appState = await Database.getAppState(); + const appState = await getAppState(); const { lastKnownPushEnabled, lastKnownPushId, @@ -49,13 +51,13 @@ export async function checkAndTriggerSubscriptionChanged() { } // update notification_types via core module - await context.subscriptionManager.updateNotificationTypes(); + await context.subscriptionManager.updateNotificationTypes!(); appState.lastKnownPushEnabled = isPushEnabled; appState.lastKnownPushToken = currentPushToken; appState.lastKnownPushId = pushSubscriptionId; appState.lastKnownOptedIn = isOptedIn; - await Database.setAppState(appState); + await setAppState(appState); const change: SubscriptionChangeEvent = { previous: { @@ -91,10 +93,35 @@ export function triggerNotificationClick( ); } +const getUserState = async (): Promise => { + const userState = new UserState(); + userState.previousOneSignalId = ''; + userState.previousExternalId = ''; + // previous are used to track changes to the user's state. + // Displayed in the `current` & `previous` fields of the `userChange` event. + userState.previousOneSignalId = await getOptionsValue( + 'previousOneSignalId', + ); + userState.previousExternalId = + await getOptionsValue('previousExternalId'); + return userState; +}; + +const setUserState = async (userState: UserState) => { + await db.put('Options', { + key: 'previousOneSignalId', + value: userState.previousOneSignalId, + }); + await db.put('Options', { + key: 'previousExternalId', + value: userState.previousExternalId, + }); +}; + export async function checkAndTriggerUserChanged() { logMethodCall('checkAndTriggerUserChanged'); - const userState = await Database.getUserState(); + const userState = await getUserState(); const { previousOneSignalId, previousExternalId } = userState; const identityModel = await OneSignal.coreDirector.getIdentityModel(); @@ -110,7 +137,7 @@ export async function checkAndTriggerUserChanged() { userState.previousOneSignalId = currentOneSignalId; userState.previousExternalId = currentExternalId; - await Database.setUserState(userState); + await setUserState(userState); const change: UserChangeEvent = { current: { @@ -130,86 +157,6 @@ function triggerUserChanged(change: UserChangeEvent) { ); } -/** - * When notifications are clicked, because the site isn't open, the notification is stored in the database. The next - * time the page opens, the event is triggered if its less than 5 minutes (usually page opens instantly from click). - */ -export async function fireStoredNotificationClicks() { - await awaitOneSignalInitAndSupported(); - const url = - OneSignal.config?.pageUrl || - OneSignal.config?.userConfig.pageUrl || - document.URL; - - async function fireEventWithNotification( - selectedEvent: NotificationClickEventInternal, - ) { - // Remove the notification from the recently clicked list - // Once this page processes this retroactively provided clicked event, nothing should get the same event - const appState = await Database.getAppState(); - // @ts-expect-error - TODO: address this is a workaround to fix the type error - appState.pendingNotificationClickEvents![selectedEvent.result.url!] = null; - await Database.setAppState(appState); - - const timestamp = selectedEvent.timestamp; - if (timestamp) { - const minutesSinceNotificationClicked = - (Date.now() - timestamp) / 1000 / 60; - if (minutesSinceNotificationClicked > 5) return; - } - - triggerNotificationClick(selectedEvent); - } - - const appState = await Database.getAppState(); - - /* Is the flag notificationClickHandlerMatch: origin enabled? - - If so, this means we should provide a retroactive notification.clicked event as long as there exists any recently clicked - notification that matches this site's origin. - - Otherwise, the default behavior is to only provide a retroactive notification.clicked event if this page's URL exactly - matches the notification's URL. - */ - const notificationClickHandlerMatch = await Database.get( - 'Options', - 'notificationClickHandlerMatch', - ); - if (notificationClickHandlerMatch === 'origin') { - for (const clickedNotificationUrl of Object.keys( - appState.pendingNotificationClickEvents!, - )) { - // Using notificationClickHandlerMatch: 'origin', as long as the notification's URL's origin matches our current tab's origin, - // fire the clicked event - if (new URL(clickedNotificationUrl).origin === location.origin) { - const clickedNotification = - appState.pendingNotificationClickEvents![clickedNotificationUrl]; - await fireEventWithNotification(clickedNotification); - } - } - } else { - /* - If a user is on https://site.com, document.URL and location.href both report the page's URL as https://site.com/. - This causes checking for notifications for the current URL to fail, since there is a notification for https://site.com, - but there is no notification for https://site.com/. - - As a workaround, if there are no notifications for https://site.com/, we'll do a check for https://site.com. - */ - let pageClickedNotifications = - appState.pendingNotificationClickEvents?.[url]; - if (pageClickedNotifications) { - await fireEventWithNotification(pageClickedNotifications); - } else if (!pageClickedNotifications && url.endsWith('/')) { - const urlWithoutTrailingSlash = url.substring(0, url.length - 1); - pageClickedNotifications = - appState.pendingNotificationClickEvents?.[urlWithoutTrailingSlash]; - if (pageClickedNotifications) { - await fireEventWithNotification(pageClickedNotifications); - } - } - } -} - async function onSubscriptionChanged_evaluateNotifyButtonDisplayPredicate() { if (!OneSignal.config?.userConfig.notifyButton) return; diff --git a/src/shared/managers/ServiceWorkerManager.ts b/src/shared/managers/ServiceWorkerManager.ts index 202dd5167..f939b377a 100644 --- a/src/shared/managers/ServiceWorkerManager.ts +++ b/src/shared/managers/ServiceWorkerManager.ts @@ -23,7 +23,6 @@ import type { NotificationForegroundWillDisplayEvent, NotificationForegroundWillDisplayEventSerializable, } from '../notifications/types'; -import Database from '../services/Database'; import OneSignalEvent from '../services/OneSignalEvent'; import type { PageVisibilityRequest, @@ -309,13 +308,6 @@ export class ServiceWorkerManager { Log.debug( 'notification.clicked event received, but no event listeners; storing event in IndexedDb for later retrieval.', ); - /* For empty notifications without a URL, use the current document's URL */ - let url = event.result.url; - if (!url) { - // Least likely to modify, since modifying this property changes the page's URL - url = location.href; - } - await Database.putNotificationClickedEventPendingUrlOpening(event); } else { await triggerNotificationClick(event); } diff --git a/src/shared/managers/SubscriptionManager.test.ts b/src/shared/managers/SubscriptionManager.test.ts index eeef632a9..be310c0f3 100644 --- a/src/shared/managers/SubscriptionManager.test.ts +++ b/src/shared/managers/SubscriptionManager.test.ts @@ -9,6 +9,7 @@ import { getSubscriptionFn, MockServiceWorker, } from '__test__/support/mocks/MockServiceWorker'; +import { setPushToken } from '../database/subscription'; import { RawPushSubscription } from '../models/RawPushSubscription'; import { IDManager } from './IDManager'; import { updatePushSubscriptionModelWithRawSubscription } from './subscription/page'; @@ -38,9 +39,7 @@ describe('SubscriptionManager', () => { expect(subModels.length).toBe(0); // mimicing the event helper checkAndTriggerSubscriptionChanged - await OneSignal.database.setPushToken( - rawSubscription.w3cEndpoint?.toString(), - ); + await setPushToken(rawSubscription.w3cEndpoint?.toString()); await updatePushSubscriptionModelWithRawSubscription(rawSubscription); @@ -142,9 +141,7 @@ describe('SubscriptionManager', () => { setCreateUserResponse(); const rawSubscription = getRawSubscription(); - await OneSignal.database.setPushToken( - rawSubscription.w3cEndpoint?.toString(), - ); + await setPushToken(rawSubscription.w3cEndpoint?.toString()); const pushModel = await setupSubModelStore({ id: '123', diff --git a/src/shared/managers/subscription/base.ts b/src/shared/managers/subscription/base.ts index 943dd301f..148cd7477 100644 --- a/src/shared/managers/subscription/base.ts +++ b/src/shared/managers/subscription/base.ts @@ -1,3 +1,7 @@ +import { + getSubscription, + setSubscription, +} from 'src/shared/database/subscription'; import type { NotificationTypeValue } from 'src/shared/subscriptions/types'; import type { ContextInterface, ContextSWInterface } from '../../context/types'; import { useSafariLegacyPush } from '../../environment/detect'; @@ -8,7 +12,6 @@ import { SubscriptionStrategyKind, type SubscriptionStrategyKindValue, } from '../../models/SubscriptionStrategyKind'; -import Database from '../../services/Database'; import OneSignalEvent from '../../services/OneSignalEvent'; import { SessionOrigin } from '../../session/constants'; import { Browser } from '../../useragent/constants'; @@ -83,7 +86,7 @@ export class SubscriptionManagerBase< this.context.sessionManager.upsertSession(SessionOrigin.UserCreate); } - const subscription = await Database.getSubscription(); + const subscription = await getSubscription(); // User Model: TO DO: Remove this once we have a better way to determine if the user is subscribed subscription.deviceId = DEFAULT_DEVICE_ID; subscription.optedOut = false; @@ -98,7 +101,7 @@ export class SubscriptionManagerBase< } else { subscription.subscriptionToken = null; } - await Database.setSubscription(subscription); + await setSubscription(subscription); if (!IS_SERVICE_WORKER) { OneSignalEvent.trigger(OneSignal.EVENTS.REGISTERED); @@ -111,7 +114,7 @@ export class SubscriptionManagerBase< } public async isAlreadyRegisteredWithOneSignal(): Promise { - const { deviceId } = await Database.getSubscription(); + const { deviceId } = await getSubscription(); return !!deviceId; } @@ -235,12 +238,12 @@ export class SubscriptionManagerBase< updateCreatedAt: boolean, expirationTime: number | null, ): Promise { - const bundle = await Database.getSubscription(); + const bundle = await getSubscription(); if (updateCreatedAt) { bundle.createdAt = new Date().getTime(); } bundle.expirationTime = expirationTime; - await Database.setSubscription(bundle); + await setSubscription(bundle); } // Subscribes the ServiceWorker for a pushToken. diff --git a/src/shared/managers/subscription/page.ts b/src/shared/managers/subscription/page.ts index 99172063f..51e373f2b 100644 --- a/src/shared/managers/subscription/page.ts +++ b/src/shared/managers/subscription/page.ts @@ -3,6 +3,7 @@ import UserDirector from 'src/onesignal/UserDirector'; import LoginManager from 'src/page/managers/LoginManager'; import FuturePushSubscriptionRecord from 'src/page/userModel/FuturePushSubscriptionRecord'; import type { ContextInterface } from 'src/shared/context/types'; +import { getSubscription } from 'src/shared/database/subscription'; import { getOneSignalApiUrl, useSafariLegacyPush, @@ -27,7 +28,6 @@ import { UnsubscriptionStrategy, type UnsubscriptionStrategyValue, } from 'src/shared/models/UnsubscriptionStrategy'; -import Database from 'src/shared/services/Database'; import OneSignalEvent from 'src/shared/services/OneSignalEvent'; import { NotificationType } from 'src/shared/subscriptions/constants'; import type { NotificationTypeValue } from 'src/shared/subscriptions/types'; @@ -105,7 +105,7 @@ export class SubscriptionManagerPage extends SubscriptionManagerBase { - const { optedOut } = await Database.getSubscription(); + const { optedOut } = await getSubscription(); if (optedOut) { return NotificationType.UserOptedOut; } @@ -141,7 +141,7 @@ export class SubscriptionManagerPage extends SubscriptionManagerBase void, ): Promise { logMethodCall('isOptedOut', callback); - const { optedOut } = await Database.getSubscription(); + const { optedOut } = await getSubscription(); executeCallback(callback, optedOut); return optedOut; } @@ -165,7 +165,7 @@ export class SubscriptionManagerPage extends SubscriptionManagerBase { - const { optedOut, subscriptionToken } = await Database.getSubscription(); + const { optedOut, subscriptionToken } = await getSubscription(); const pushSubscriptionModel = await OneSignal.coreDirector.getPushSubscriptionModel(); @@ -498,7 +498,7 @@ export class SubscriptionManagerPage extends SubscriptionManagerBase ...) -export interface PendingNotificationClickEvents { - [key: string]: NotificationClickEventInternal; // key = result.url -} - class AppState { - defaultNotificationUrl: string | undefined; - defaultNotificationTitle: string | undefined; + defaultNotificationUrl: string | null | undefined; + defaultNotificationTitle: string | null | undefined; /** * Whether the user is currently completely subscribed, including not opted out. Database cached version of * isPushNotificationsEnabled(). */ - lastKnownPushEnabled: boolean | undefined; + lastKnownPushEnabled: boolean | null | undefined; - lastKnownPushToken: string | undefined; + lastKnownPushToken: string | null | undefined; - lastKnownPushId: string | undefined; + lastKnownPushId: string | null | undefined; // default true - lastKnownOptedIn = true; - - pendingNotificationClickEvents: PendingNotificationClickEvents | undefined; + lastKnownOptedIn: boolean | null = true; } export { AppState }; diff --git a/src/shared/models/DeliveryPlatformKind.ts b/src/shared/models/DeliveryPlatformKind.ts index 9b6172d63..1021d5d35 100644 --- a/src/shared/models/DeliveryPlatformKind.ts +++ b/src/shared/models/DeliveryPlatformKind.ts @@ -3,8 +3,6 @@ export const DeliveryPlatformKind = { SafariLegacy: 7, Firefox: 8, Email: 11, - Edge: 12, - SMS: 14, SafariVapid: 17, } as const; diff --git a/src/shared/models/Subscription.ts b/src/shared/models/Subscription.ts index ec5c60d61..f919626f7 100755 --- a/src/shared/models/Subscription.ts +++ b/src/shared/models/Subscription.ts @@ -17,7 +17,7 @@ export class Subscription implements Serializable { * A UTC timestamp of when this subscription was created. This value is not modified when a * subscription is merely refreshed, only when a subscription is created anew. */ - createdAt: number | undefined; + createdAt: number | null | undefined; /** * This property is stored on the native PushSubscription object. */ diff --git a/src/shared/models/UserState.ts b/src/shared/models/UserState.ts index 2291fbe48..fc419be30 100644 --- a/src/shared/models/UserState.ts +++ b/src/shared/models/UserState.ts @@ -1,6 +1,6 @@ class UserState { - previousOneSignalId: string | undefined; - previousExternalId: string | undefined; + previousOneSignalId: string | null | undefined; + previousExternalId: string | null | undefined; } export { UserState }; diff --git a/src/shared/services/Database.ts b/src/shared/services/Database.ts deleted file mode 100644 index c454bd025..000000000 --- a/src/shared/services/Database.ts +++ /dev/null @@ -1,683 +0,0 @@ -import Emitter from '../libraries/Emitter'; -import IndexedDb from './IndexedDb'; - -import type { - ICreateUserSubscription, - IUserProperties, -} from 'src/core/types/api'; -import type { ModelNameType } from 'src/core/types/models'; -import type { AppConfig } from '../config/types'; -import { - type NotificationClickForOpenHandlingSchema, - type NotificationReceivedForOutcomesSchema, - notificationClickFromDatabase, - notificationClickToDatabase, - notificationClickedForOutcomesFromDatabase, - notificationClickedForOutcomesToDatabase, - notificationReceivedForOutcomesFromDatabase, - notificationReceivedForOutcomesToDatabase, -} from '../helpers/serializer'; -import Log from '../libraries/Log'; -import { - AppState, - type PendingNotificationClickEvents, -} from '../models/AppState'; -import type { SentUniqueOutcome } from '../models/Outcomes'; -import type { - OutcomesNotificationClicked, - OutcomesNotificationReceived, -} from '../models/OutcomesNotificationEvents'; -import { Subscription } from '../models/Subscription'; -import { UserState } from '../models/UserState'; -import type { - IOSNotification, - NotificationClickEventInternal, -} from '../notifications/types'; -import { ONESIGNAL_SESSION_KEY } from '../session/constants'; -import type { Session } from '../session/types'; - -const DatabaseEventName = { - SET: 0, -} as const; - -interface DatabaseResult { - id: any; - value: any; - data: any; - timestamp: any; -} - -/** - * "NotificationOpened" = Pending Notification Click events that haven't fired yet - */ - -export const INDEXED_DB_NAME = 'ONE_SIGNAL_SDK_DB'; -export const TABLE_OUTCOMES_NOTIFICATION_CLICKED = - 'Outcomes.NotificationClicked'; -export const TABLE_OUTCOMES_NOTIFICATION_RECEIVED = - 'Outcomes.NotificationReceived'; -export const TABLE_NOTIFICATION_OPENED = 'NotificationOpened'; -export const TABLE_SESSIONS = 'Sessions'; - -export type OneSignalDbTable = - | 'Options' - | 'Ids' - | typeof TABLE_SESSIONS - | typeof TABLE_NOTIFICATION_OPENED - | typeof TABLE_OUTCOMES_NOTIFICATION_RECEIVED - | typeof TABLE_OUTCOMES_NOTIFICATION_CLICKED - | 'SentUniqueOutcome' - | ModelNameType; - -export interface ModelItem { - modelId: string; - modelName: ModelNameType; -} - -export interface SubscriptionItem extends ModelItem, ICreateUserSubscription { - id: string; - onesignalId: string; -} -export interface IdentityItem extends ModelItem { - onesignal_id: string; - externalId: string; -} -export interface PropertiesItem extends ModelItem, IUserProperties { - onesignalId: string; -} -export interface OperationItem extends ModelItem { - appId: string; - onesignalId: string; - name: string; - [key: string]: unknown; -} - -export default class Database { - public emitter: Emitter; - private database: IndexedDb; - private databaseName: string; - - /* Temp Database Proxy */ - public static databaseInstanceName: string; - private static databaseInstance: Database | null; - /* End Temp Database Proxy */ - - public static EVENTS = DatabaseEventName; - - constructor(databaseName: string) { - this.databaseName = databaseName; - this.emitter = new Emitter(); - this.database = new IndexedDb(this.databaseName); - } - - public static resetInstance(): void { - Database.databaseInstance = null; - } - - public static get singletonInstance(): Database { - if (!Database.databaseInstanceName) { - Database.databaseInstanceName = INDEXED_DB_NAME; - } - if (!Database.databaseInstance) { - Database.databaseInstance = new Database(Database.databaseInstanceName); - } - - return Database.databaseInstance; - } - - static applyDbResultFilter( - table: OneSignalDbTable, - key?: string, - result?: DatabaseResult, - ) { - switch (table) { - case 'Options': - if (result && key) return result.value; - else if (result && !key) return result; - else return null; - case 'Ids': - if (result && key) return result.id; - else if (result && !key) return result; - else return null; - default: - if (result) return result; - else return null; - } - } - - /** - * Asynchronously retrieves the value of the key at the table (if key is specified), or the entire table - * (if key is not specified). - * @param table The table to retrieve the value from. - * @param key The key in the table to retrieve the value of. Leave blank to get the entire table. - * @returns {Promise} Returns a promise that fulfills when the value(s) are available. - */ - async get(table: OneSignalDbTable, key?: string): Promise { - const result = await this.database.get(table, key); - const cleanResult = Database.applyDbResultFilter(table, key, result); - return cleanResult; - } - - public async getAll(table: OneSignalDbTable): Promise { - const result = await this.database.getAll(table); - return result; - } - - /** - * Asynchronously puts the specified value in the specified table. - * @param table - * @param keypath - */ - async put(table: OneSignalDbTable, keypath: any): Promise { - await new Promise((resolve) => { - this.database.put(table, keypath).then(() => resolve()); - }); - this.emitter.emit(Database.EVENTS.SET, keypath); - } - - /** - * Asynchronously removes the specified key from the table, or if the key is not specified, removes all - * keys in the table. - * @returns {Promise} Returns a promise containing a key that is fulfilled when deletion is completed. - */ - remove(table: OneSignalDbTable, keypath?: string) { - return this.database.remove(table, keypath); - } - - async getAppConfig(): Promise { - const config: any = {}; - const appIdStr: string = await this.get('Ids', 'appId'); - config.appId = appIdStr; - config.vapidPublicKey = await this.get('Options', 'vapidPublicKey'); - return config; - } - - async setAppConfig(appConfig: AppConfig): Promise { - if (appConfig.appId) - await this.put('Ids', { type: 'appId', id: appConfig.appId }); - if (appConfig.vapidPublicKey) - await this.put('Options', { - key: 'vapidPublicKey', - value: appConfig.vapidPublicKey, - }); - } - - async getAppState(): Promise { - const state = new AppState(); - state.defaultNotificationUrl = await this.get( - 'Options', - 'defaultUrl', - ); - state.defaultNotificationTitle = await this.get( - 'Options', - 'defaultTitle', - ); - state.lastKnownPushEnabled = await this.get( - 'Options', - 'isPushEnabled', - ); - state.pendingNotificationClickEvents = - await this.getAllPendingNotificationClickEvents(); - // lastKnown are used to track changes to the user's subscription - // state. Displayed in the `current` & `previous` fields of the `subscriptionChange` event. - state.lastKnownPushId = await this.get('Options', 'lastPushId'); - state.lastKnownPushToken = await this.get( - 'Options', - 'lastPushToken', - ); - state.lastKnownOptedIn = await this.get('Options', 'lastOptedIn'); - return state; - } - - async setIsPushEnabled(enabled: boolean): Promise { - await this.put('Options', { key: 'isPushEnabled', value: enabled }); - } - - async setAppState(appState: AppState) { - if (appState.defaultNotificationUrl) - await this.put('Options', { - key: 'defaultUrl', - value: appState.defaultNotificationUrl, - }); - if ( - appState.defaultNotificationTitle || - appState.defaultNotificationTitle === '' - ) - await this.put('Options', { - key: 'defaultTitle', - value: appState.defaultNotificationTitle, - }); - if (appState.lastKnownPushEnabled != null) - await this.setIsPushEnabled(appState.lastKnownPushEnabled); - if (appState.lastKnownPushId != null) - await this.put('Options', { - key: 'lastPushId', - value: appState.lastKnownPushId, - }); - if (appState.lastKnownPushToken != null) - await this.put('Options', { - key: 'lastPushToken', - value: appState.lastKnownPushToken, - }); - if (appState.lastKnownOptedIn != null) - await this.put('Options', { - key: 'lastOptedIn', - value: appState.lastKnownOptedIn, - }); - if (appState.pendingNotificationClickEvents) { - const clickedNotificationUrls = Object.keys( - appState.pendingNotificationClickEvents, - ); - for (const url of clickedNotificationUrls) { - const notificationDetails = - appState.pendingNotificationClickEvents[url]; - if (notificationDetails) { - await this.put(TABLE_NOTIFICATION_OPENED, { - url: url, - data: (notificationDetails as any).data, - timestamp: (notificationDetails as any).timestamp, - }); - } else if (notificationDetails === null) { - // If we get an object like: - // { "http://site.com/page": null} - // It means we need to remove that entry - await this.remove(TABLE_NOTIFICATION_OPENED, url); - } - } - } - } - - async getUserState(): Promise { - const userState = new UserState(); - userState.previousOneSignalId = ''; - userState.previousExternalId = ''; - // previous are used to track changes to the user's state. - // Displayed in the `current` & `previous` fields of the `userChange` event. - userState.previousOneSignalId = await this.get( - 'Options', - 'previousOneSignalId', - ); - userState.previousExternalId = await this.get( - 'Options', - 'previousExternalId', - ); - return userState; - } - - async setUserState(userState: UserState) { - await this.put('Options', { - key: 'previousOneSignalId', - value: userState.previousOneSignalId, - }); - await this.put('Options', { - key: 'previousExternalId', - value: userState.previousExternalId, - }); - } - - async getSubscription(): Promise { - const subscription = new Subscription(); - subscription.deviceId = await this.get('Ids', 'userId'); - subscription.subscriptionToken = await this.get( - 'Ids', - 'registrationId', - ); - - // The preferred database key to store our subscription - const dbOptedOut = await this.get('Options', 'optedOut'); - // For backwards compatibility, we need to read from this if the above is not found - const dbNotOptedOut = await this.get('Options', 'subscription'); - const createdAt = await this.get( - 'Options', - 'subscriptionCreatedAt', - ); - const expirationTime = await this.get( - 'Options', - 'subscriptionExpirationTime', - ); - - if (dbOptedOut != null) { - subscription.optedOut = dbOptedOut; - } else { - if (dbNotOptedOut == null) { - subscription.optedOut = false; - } else { - subscription.optedOut = !dbNotOptedOut; - } - } - subscription.createdAt = createdAt; - subscription.expirationTime = expirationTime; - - return subscription; - } - - async setDeviceId(deviceId: string | null): Promise { - await this.put('Ids', { type: 'userId', id: deviceId }); - } - - async setSubscription(subscription: Subscription) { - if (subscription.deviceId) { - await this.setDeviceId(subscription.deviceId); - } - if (typeof subscription.subscriptionToken !== 'undefined') { - // Allow null subscriptions to be set - await this.put('Ids', { - type: 'registrationId', - id: subscription.subscriptionToken, - }); - } - if (subscription.optedOut != null) { - // Checks if null or undefined, allows false - await this.put('Options', { - key: 'optedOut', - value: subscription.optedOut, - }); - } - if (subscription.createdAt != null) { - await this.put('Options', { - key: 'subscriptionCreatedAt', - value: subscription.createdAt, - }); - } - if (subscription.expirationTime != null) { - await this.put('Options', { - key: 'subscriptionExpirationTime', - value: subscription.expirationTime, - }); - } else { - await this.remove('Options', 'subscriptionExpirationTime'); - } - } - - async setJWTToken(token: string): Promise { - await this.put('Ids', { type: 'jwtToken', id: token }); - } - - async getJWTToken(): Promise { - return await this.get('Ids', 'jwtToken'); - } - - async setProvideUserConsent(consent: boolean): Promise { - await this.put('Options', { key: 'userConsent', value: consent }); - } - - async getConsentGiven(): Promise { - return await this.get('Options', 'userConsent'); - } - - private async getSession(sessionKey: string): Promise { - return await this.get(TABLE_SESSIONS, sessionKey); - } - - private async setSession(session: Session): Promise { - await this.put(TABLE_SESSIONS, session); - } - - private async removeSession(sessionKey: string): Promise { - await this.remove(TABLE_SESSIONS, sessionKey); - } - - async getLastNotificationClickedForOutcomes( - appId: string, - ): Promise { - let allClickedNotifications: OutcomesNotificationClicked[] = []; - try { - allClickedNotifications = - await this.getAllNotificationClickedForOutcomes(); - } catch (e) { - Log.error('Database.getLastNotificationClickedForOutcomes', e); - } - const predicate = (notification: OutcomesNotificationClicked) => - notification.appId === appId; - return allClickedNotifications.find(predicate) || null; - } - - async getAllNotificationClickedForOutcomes(): Promise< - OutcomesNotificationClicked[] - > { - const notifications = - await this.getAll( - TABLE_OUTCOMES_NOTIFICATION_CLICKED, - ); - return notifications.map((notification) => - notificationClickedForOutcomesFromDatabase(notification), - ); - } - - async putNotificationClickedForOutcomes( - appId: string, - event: NotificationClickEventInternal, - ): Promise { - await this.put( - TABLE_OUTCOMES_NOTIFICATION_CLICKED, - notificationClickedForOutcomesToDatabase(appId, event), - ); - } - - async putNotificationClickedEventPendingUrlOpening( - event: NotificationClickEventInternal, - ): Promise { - await this.put( - TABLE_NOTIFICATION_OPENED, - notificationClickToDatabase(event), - ); - } - - private async getAllPendingNotificationClickEvents(): Promise { - const clickedNotifications: PendingNotificationClickEvents = {}; - const eventsFromDb = - await this.getAll( - TABLE_NOTIFICATION_OPENED, - ); - for (const eventFromDb of eventsFromDb) { - const event = notificationClickFromDatabase(eventFromDb); - const url = event.result.url; - if (!url) { - continue; - } - clickedNotifications[url] = event; - } - return clickedNotifications; - } - - async removeAllNotificationClickedForOutcomes(): Promise { - await this.remove(TABLE_OUTCOMES_NOTIFICATION_CLICKED); - } - - async getAllNotificationReceivedForOutcomes(): Promise< - OutcomesNotificationReceived[] - > { - const notifications = - await this.getAll( - TABLE_OUTCOMES_NOTIFICATION_RECEIVED, - ); - return notifications.map((notification) => - notificationReceivedForOutcomesFromDatabase(notification), - ); - } - - async putNotificationReceivedForOutcomes( - appId: string, - notification: IOSNotification, - ): Promise { - await this.put( - TABLE_OUTCOMES_NOTIFICATION_RECEIVED, - notificationReceivedForOutcomesToDatabase( - appId, - notification, - new Date().getTime(), - ), - ); - } - - async resetSentUniqueOutcomes(): Promise { - const outcomes = await this.getAll('SentUniqueOutcome'); - const promises = outcomes.map((o) => { - o.sentDuringSession = null; - return Database.put('SentUniqueOutcome', o); - }); - await Promise.all(promises); - } - - static async clear() { - const objectStoreNames = - await Database.singletonInstance.database.objectStoreNames(); - for (const objectStoreName of objectStoreNames) { - await Database.singletonInstance.database.remove(objectStoreName); - } - } - - static async getPushId(): Promise { - return this.get('Options', 'lastPushId'); - } - static async setPushId(pushId: string | undefined): Promise { - await this.put('Options', { key: 'lastPushId', value: pushId }); - } - static async getPushToken(): Promise { - return this.get('Options', 'lastPushToken'); - } - static async setPushToken(pushToken: string | undefined): Promise { - await this.put('Options', { key: 'lastPushToken', value: pushToken }); - } - - static async setIsPushEnabled(enabled: boolean): Promise { - return Database.singletonInstance.setIsPushEnabled(enabled); - } - - public static async getCurrentSession(): Promise { - return await Database.singletonInstance.getSession(ONESIGNAL_SESSION_KEY); - } - - public static async upsertSession(session: Session): Promise { - await Database.singletonInstance.setSession(session); - } - - public static async cleanupCurrentSession(): Promise { - await Database.singletonInstance.removeSession(ONESIGNAL_SESSION_KEY); - } - - static async setSubscription(subscription: Subscription) { - return await Database.singletonInstance.setSubscription(subscription); - } - - static async getSubscription(): Promise { - return await Database.singletonInstance.getSubscription(); - } - - static async setJWTToken(token: string) { - return await Database.singletonInstance.setJWTToken(token); - } - - static async getJWTToken(): Promise { - return await Database.singletonInstance.getJWTToken(); - } - - static async setConsentGiven(consent: boolean): Promise { - return await Database.singletonInstance.setProvideUserConsent(consent); - } - - static async getConsentGiven(): Promise { - return await Database.singletonInstance.getConsentGiven(); - } - - static async setAppState(appState: AppState) { - return await Database.singletonInstance.setAppState(appState); - } - - static async getAppState(): Promise { - return await Database.singletonInstance.getAppState(); - } - - static async setUserState(userState: UserState) { - return await Database.singletonInstance.setUserState(userState); - } - - static async getUserState(): Promise { - return await Database.singletonInstance.getUserState(); - } - - static async setAppConfig(appConfig: AppConfig) { - return await Database.singletonInstance.setAppConfig(appConfig); - } - - static async getAppConfig(): Promise { - return await Database.singletonInstance.getAppConfig(); - } - - static async getLastNotificationClickedForOutcomes( - appId: string, - ): Promise { - return await Database.singletonInstance.getLastNotificationClickedForOutcomes( - appId, - ); - } - - static async removeAllNotificationClickedForOutcomes(): Promise { - return await Database.singletonInstance.removeAllNotificationClickedForOutcomes(); - } - - static async getAllNotificationReceivedForOutcomes(): Promise< - OutcomesNotificationReceived[] - > { - return await Database.singletonInstance.getAllNotificationReceivedForOutcomes(); - } - - static async putNotificationReceivedForOutcomes( - appId: string, - notification: IOSNotification, - ): Promise { - return await Database.singletonInstance.putNotificationReceivedForOutcomes( - appId, - notification, - ); - } - - static async getAllNotificationClickedForOutcomes(): Promise< - OutcomesNotificationClicked[] - > { - return await Database.singletonInstance.getAllNotificationClickedForOutcomes(); - } - - static async putNotificationClickedForOutcomes( - appId: string, - event: NotificationClickEventInternal, - ): Promise { - return await Database.singletonInstance.putNotificationClickedForOutcomes( - appId, - event, - ); - } - - static async putNotificationClickedEventPendingUrlOpening( - event: NotificationClickEventInternal, - ): Promise { - return await Database.singletonInstance.putNotificationClickedEventPendingUrlOpening( - event, - ); - } - - static async resetSentUniqueOutcomes(): Promise { - return await Database.singletonInstance.resetSentUniqueOutcomes(); - } - - static async setDeviceId(deviceId: string | null): Promise { - await Database.singletonInstance.setDeviceId(deviceId); - } - - static async remove(table: OneSignalDbTable, keypath?: string) { - return await Database.singletonInstance.remove(table, keypath); - } - - static async put(table: OneSignalDbTable, keypath: any) { - return await Database.singletonInstance.put(table, keypath); - } - - static async get(table: OneSignalDbTable, key?: string): Promise { - return await Database.singletonInstance.get(table, key); - } - - static async getAll(table: OneSignalDbTable): Promise> { - return await Database.singletonInstance.getAll(table); - } - // END: Static mappings to instance methods -} diff --git a/src/shared/services/IndexedDb.ts b/src/shared/services/IndexedDb.ts deleted file mode 100644 index feac6f51f..000000000 --- a/src/shared/services/IndexedDb.ts +++ /dev/null @@ -1,397 +0,0 @@ -import { ModelName } from 'src/core/types/models'; -import { containsMatch } from '../context/helpers'; -import Emitter from '../libraries/Emitter'; -import Log from '../libraries/Log'; - -const DATABASE_VERSION = 7; - -export const LegacyModelName = { - PushSubscriptions: 'pushSubscriptions', - EmailSubscriptions: 'emailSubscriptions', - SmsSubscriptions: 'smsSubscriptions', -} as const; - -export default class IndexedDb { - public emitter: Emitter; - private database: IDBDatabase | undefined; - private openLock: Promise | undefined; - private readonly databaseName: string; - private readonly dbVersion: number; - - constructor(databaseName: string, dbVersion = DATABASE_VERSION) { - this.emitter = new Emitter(); - this.databaseName = databaseName; - this.dbVersion = dbVersion; - } - - private open(databaseName: string): Promise { - return new Promise((resolve) => { - let request: IDBOpenDBRequest | undefined = undefined; - try { - // Open algorithm: https://www.w3.org/TR/IndexedDB/#h-opening - request = indexedDB.open(databaseName, this.dbVersion); - } catch (e) { - // Errors should be thrown on the request.onerror event, but just in case Firefox throws additional errors - // for profile schema too high - } - if (!request) { - return null; - } - request.onerror = this.onDatabaseOpenError.bind(this); - request.onblocked = this.onDatabaseOpenBlocked.bind(this); - request.onupgradeneeded = this.onDatabaseUpgradeNeeded.bind(this); - request.onsuccess = () => { - this.database = request.result; - this.database.onerror = this.onDatabaseError; - this.database.onversionchange = this.onDatabaseVersionChange; - resolve(this.database); - }; - }); - } - - public async close(): Promise { - // TODO:CLEANUP: Seems we have always had two DB connections open - // one could be delete to clean this up. - const dbLock = await this.ensureDatabaseOpen(); - dbLock.close(); - this.database?.close(); - } - - private async ensureDatabaseOpen(): Promise { - if (!this.openLock) { - this.openLock = this.open(this.databaseName); - } - return await this.openLock; - } - - private onDatabaseOpenError(event: any) { - // Prevent the error from bubbling: https://bugzilla.mozilla.org/show_bug.cgi?id=1331103#c3 - /** - * To prevent error reporting tools like Sentry.io from picking up errors that - * the site owner can't do anything about and use up their quota, hide database open - * errors. - */ - event.preventDefault(); - const error = event.target.error; - if ( - containsMatch( - error.message, - 'The operation failed for reasons unrelated to the database itself and not covered by any other error code', - ) || - containsMatch( - error.message, - 'A mutation operation was attempted on a database that did not allow mutations', - ) - ) { - Log.warn( - "OneSignal: IndexedDb web storage is not available on this origin since this profile's IndexedDb schema has been upgraded in a newer version of Firefox. See: https://bugzilla.mozilla.org/show_bug.cgi?id=1236557#c6", - ); - } else { - Log.warn('OneSignal: Fatal error opening IndexedDb database:', error); - } - } - - public objectStoreNames(): string[] { - return Array.from(this.database?.objectStoreNames || []); - } - - /** - * Error events bubble. Error events are targeted at the request that generated the error, then the event bubbles to - * the transaction, and then finally to the database object. If you want to avoid adding error handlers to every - * request, you can instead add a single error handler on the database object. - */ - private onDatabaseError(event: any) { - Log.debug('IndexedDb: Generic database error', event.target.errorCode); - } - - /** - * Occurs when the upgradeneeded should be triggered because of a version change but the database is still in use - * (that is, not closed) somewhere, even after the versionchange event was sent. - */ - private onDatabaseOpenBlocked(): void { - Log.debug('IndexedDb: Blocked event'); - } - - /** - * Occurs when a database structure change (IDBOpenDBRequest.onupgradeneeded event or IDBFactory.deleteDatabase) was - * requested elsewhere (most probably in another window/tab on the same computer). - * - * versionchange Algorithm: https://www.w3.org/TR/IndexedDB/#h-versionchange-transaction-steps - * - * Ref: https://developer.mozilla.org/en-US/docs/Web/API/IDBDatabase/onversionchange - */ - private onDatabaseVersionChange(): void { - Log.debug('IndexedDb: versionchange event'); - } - - /** - * Occurs when a new version of the database needs to be created, or has not been created before, or a new version - * of the database was requested to be opened when calling window.indexedDB.open. - * - * Ref: https://developer.mozilla.org/en-US/docs/Web/API/IDBOpenDBRequest/onupgradeneeded - */ - private onDatabaseUpgradeNeeded(event: IDBVersionChangeEvent): void { - Log.debug( - 'IndexedDb: Database is being rebuilt or upgraded (upgradeneeded event).', - ); - const target = event.target as IDBOpenDBRequest; - const transaction = target.transaction; - if (!transaction) { - throw Error("Can't migrate DB without a transaction"); - } - const db = target.result; - const newDbVersion = event.newVersion || Number.MAX_SAFE_INTEGER; - if (newDbVersion >= 1 && event.oldVersion < 1) { - db.createObjectStore('Ids', { keyPath: 'type' }); - db.createObjectStore('NotificationOpened', { keyPath: 'url' }); - db.createObjectStore('Options', { keyPath: 'key' }); - } - if (newDbVersion >= 2 && event.oldVersion < 2) { - db.createObjectStore('Sessions', { keyPath: 'sessionKey' }); - db.createObjectStore('NotificationReceived', { - keyPath: 'notificationId', - }); - // NOTE: 160000.beta4 to 160000 releases modified this line below as - // "{ keyPath: "notification.id" }". This resulted in DB v4 either - // having "notificationId" or "notification.id" depending if the visitor - // was new while this version was live. - // DB v5 was created to trigger a migration to fix this bug. - db.createObjectStore('NotificationClicked', { - keyPath: 'notificationId', - }); - } - if (newDbVersion >= 3 && event.oldVersion < 3) { - db.createObjectStore('SentUniqueOutcome', { keyPath: 'outcomeName' }); - } - if (newDbVersion >= 4 && event.oldVersion < 4) { - db.createObjectStore(ModelName.Identity, { keyPath: 'modelId' }); - db.createObjectStore(ModelName.Properties, { keyPath: 'modelId' }); - db.createObjectStore(LegacyModelName.PushSubscriptions, { - keyPath: 'modelId', - }); - db.createObjectStore(LegacyModelName.SmsSubscriptions, { - keyPath: 'modelId', - }); - db.createObjectStore(LegacyModelName.EmailSubscriptions, { - keyPath: 'modelId', - }); - } - if (newDbVersion >= 5 && event.oldVersion < 5) { - this.migrateOutcomesNotificationClickedTableForV5(db, transaction); - this.migrateOutcomesNotificationReceivedTableForV5(db, transaction); - } - if (newDbVersion >= 6 && event.oldVersion < 6) { - this.migrateModelNameSubscriptionsTableForV6(db, transaction); - } - if (newDbVersion >= 7 && event.oldVersion < 7) { - db.createObjectStore(ModelName.Operations, { keyPath: 'modelId' }); - } - // Wrap in conditional for tests - if (typeof OneSignal !== 'undefined') { - OneSignal._isNewVisitor = true; - } - } - - // Table rename "NotificationClicked" -> "Outcomes.NotificationClicked" - // and migrate existing records. - // Motivation: This is done to correct the keyPath, you can't change it - // so a new table must be created. - // Background: Table was created with wrong keyPath of "notification.id" - // for new visitors for versions 160000.beta4 to 160000.beta8. Writes were - // attempted as "notificationId" in released 160000 however they may - // have failed if the visitor was new when those releases were in the wild. - // However those new on 160000.beta4 to 160000.beta8 will have records - // saved as "notification.id" that will be converted here. - private migrateOutcomesNotificationClickedTableForV5( - db: IDBDatabase, - transaction: IDBTransaction, - ) { - const newTableName = 'Outcomes.NotificationClicked'; - db.createObjectStore(newTableName, { keyPath: 'notificationId' }); - - const oldTableName = 'NotificationClicked'; - const cursor = transaction.objectStore(oldTableName).openCursor(); - cursor.onsuccess = () => { - if (!cursor.result) { - // Delete old table once we have gone through all records - db.deleteObjectStore(oldTableName); - return; - } - const oldValue = cursor.result.value; - transaction.objectStore(newTableName).put({ - // notification.id was possible from 160000.beta4 to 160000.beta8 - notificationId: oldValue.notificationId || oldValue.notification.id, - appId: oldValue.appId, - timestamp: oldValue.timestamp, - }); - cursor.result.continue(); - }; - cursor.onerror = () => { - // If there is an error getting old records nothing we can do but - // move on. Old table will stay around so an attempt could be made - // later. - console.error( - 'Could not migrate NotificationClicked records', - cursor.error, - ); - }; - } - - // Table rename "NotificationReceived" -> "Outcomes.NotificationReceived" - // and migrate existing records. - // Motivation: Consistency of using pre-fix "Outcomes." like we have for - // the "Outcomes.NotificationClicked" table. - private migrateOutcomesNotificationReceivedTableForV5( - db: IDBDatabase, - transaction: IDBTransaction, - ) { - const newTableName = 'Outcomes.NotificationReceived'; - db.createObjectStore(newTableName, { keyPath: 'notificationId' }); - - const oldTableName = 'NotificationReceived'; - const cursor = transaction.objectStore(oldTableName).openCursor(); - cursor.onsuccess = () => { - if (!cursor.result) { - // Delete old table once we have gone through all records - db.deleteObjectStore(oldTableName); - return; - } - transaction.objectStore(newTableName).put(cursor.result.value); - cursor.result.continue(); - }; - cursor.onerror = () => { - // If there is an error getting old records nothing we can do but - // move on. Old table will stay around so an attempt could be made - // later. - console.error( - 'Could not migrate NotificationReceived records', - cursor.error, - ); - }; - } - - private migrateModelNameSubscriptionsTableForV6( - db: IDBDatabase, - transaction: IDBTransaction, - ) { - const newTableName = ModelName.Subscriptions; - db.createObjectStore(newTableName, { keyPath: 'modelId' }); - - let currentExternalId: string; - const identityCursor = transaction - .objectStore(ModelName.Identity) - .openCursor(); - identityCursor.onsuccess = () => { - if (identityCursor.result) { - currentExternalId = identityCursor.result.value.externalId; - } - }; - identityCursor.onerror = () => { - console.error( - 'Could not find ' + ModelName.Identity + ' records', - identityCursor.error, - ); - }; - - Object.values(LegacyModelName).forEach((oldTableName) => { - const legacyCursor = transaction.objectStore(oldTableName).openCursor(); - legacyCursor.onsuccess = () => { - if (!legacyCursor.result) { - // Delete old table once we have gone through all records - db.deleteObjectStore(oldTableName); - return; - } - const oldValue = legacyCursor.result.value; - - transaction.objectStore(newTableName).put({ - ...oldValue, - modelName: ModelName.Subscriptions, - externalId: currentExternalId, - }); - legacyCursor.result.continue(); - }; - legacyCursor.onerror = () => { - // If there is an error getting old records nothing we can do but - // move on. Old table will stay around so an attempt could be made - // later. - console.error( - 'Could not migrate ' + oldTableName + ' records', - legacyCursor.error, - ); - }; - }); - } - - private async dbOperation( - table: string, - method: 'get' | 'getAll' | 'put' | 'delete' | 'clear', - keyOrValue?: IDBValidKey, - ): Promise { - const database = await this.ensureDatabaseOpen(); - - return await new Promise((resolve, reject) => { - try { - const store = database - .transaction( - table, - method === 'get' || method === 'getAll' ? 'readonly' : 'readwrite', - ) - .objectStore(table); - - const request: IDBRequest = - method === 'getAll' || method === 'clear' - ? store[method]() - : store[method](keyOrValue as IDBValidKey); - - request.onsuccess = () => { - resolve(request.result); - }; - request.onerror = (e) => { - Log.error( - 'Database ' + method.toUpperCase() + ' Transaction Error:', - e, - ); - reject(e); - }; - } catch (e) { - Log.error('Database ' + method.toUpperCase() + ' Error:', e); - reject(e); - } - }); - } - - /** - * Asynchronously retrieves the value of the key at the table (if key is specified), or the entire table - * (if key is not specified). - * @param table The table to retrieve the value from. - * @param key The key in the table to retrieve the value of. Leave blank to get the entire table. - * @returns {Promise} Returns a promise that fulfills when the value(s) are available. - */ - public async get(table: string, key?: string): Promise { - return key - ? this.dbOperation(table, 'get', key) - : this.dbOperation(table, 'getAll'); - } - - public async getAll(table: string): Promise { - return this.dbOperation(table, 'getAll'); - } - - /** - * Asynchronously puts the specified value in the specified table. - */ - public async put(table: string, value: any) { - return this.dbOperation(table, 'put', value); - } - - /** - * Asynchronously removes the specified key from the table, or if the key is not specified, removes - * all keys in the table. - * @returns {Promise} Returns a promise containing a key that is fulfilled when deletion is completed. - */ - public async remove(table: string, key?: string) { - return key - ? this.dbOperation(table, 'delete', key) - : this.dbOperation(table, 'clear'); - } -} diff --git a/src/shared/services/indexedDb.test.ts b/src/shared/services/indexedDb.test.ts deleted file mode 100644 index ee7b93065..000000000 --- a/src/shared/services/indexedDb.test.ts +++ /dev/null @@ -1,293 +0,0 @@ -import { DUMMY_EXTERNAL_ID, DUMMY_ONESIGNAL_ID } from '__test__/constants'; -import Random from '__test__/support/utils/Random'; -import { ModelName } from 'src/core/types/models'; -import Log from 'src/shared/libraries/Log'; -import { SubscriptionType } from 'src/shared/subscriptions/constants'; -import IndexedDb, { LegacyModelName } from './IndexedDb'; - -function newOSIndexedDb( - dbName = Random.getRandomString(10), - dbVersion = Number.MAX_SAFE_INTEGER, -): IndexedDb { - return new IndexedDb(dbName, dbVersion); -} - -const LogErrorSpy = vi.spyOn(Log, 'error').mockImplementation(() => ''); - -describe('IndexedDB Service', () => { - beforeEach(() => { - LogErrorSpy.mockClear(); - }); - - describe('general', () => { - const values = [ - { key: 'optionsKey', value: 'optionsValue' }, - { key: 'optionsKey2', value: 'optionsValue2' }, - { key: 'optionsKey3', value: 'optionsValue3' }, - ]; - - test('can get 1 or all values', async () => { - const db = newOSIndexedDb(); - for (const value of values) { - await db.put('Options', value); - } - - const retrievedValue = await db.get('Options', 'optionsKey'); - expect(retrievedValue).toEqual({ - key: 'optionsKey', - value: 'optionsValue', - }); - - const retrievedValues = await db.get('Options'); - expect(retrievedValues).toEqual(values); - }); - - test('can set/update a value', async () => { - const db = newOSIndexedDb(); - await db.put('Options', { key: 'optionsKey', value: 'optionsValue' }); - const retrievedValue = await db.get('Options', 'optionsKey'); - expect(retrievedValue).toEqual({ - key: 'optionsKey', - value: 'optionsValue', - }); - - // can update value - await db.put('Options', { key: 'optionsKey', value: 'optionsValue2' }); - const retrievedValue2 = await db.get('Options', 'optionsKey'); - expect(retrievedValue2).toEqual({ - key: 'optionsKey', - value: 'optionsValue2', - }); - - await expect(db.put('Options', '')).rejects.toThrow(); - expect(LogErrorSpy.mock.calls[0][0]).toBe('Database PUT Error:'); - }); - - test('can remove a value', async () => { - const db = newOSIndexedDb(); - - for (const value of values) { - await db.put('Options', value); - } - - // can remove a single value - await db.remove('Options', 'optionsKey'); - const retrievedValue = await db.get('Options', 'optionsKey'); - expect(retrievedValue).toBeUndefined(); - - // can remove remaining values - await db.remove('Options'); - const retrievedValues = await db.getAll('Options'); - expect(retrievedValues).toEqual([]); - - // can handle errors - await expect(db.remove('')).rejects.toThrow(); - expect(LogErrorSpy.mock.calls[0][0]).toBe('Database CLEAR Error:'); - }); - }); - - describe('migrations', () => { - describe('v5', () => { - test('can to write to new v5 tables', async () => { - const db = newOSIndexedDb('testDbv5', 5); - const result = await db.put('Outcomes.NotificationClicked', { - notificationId: '1', - }); - expect(result).toEqual('1'); - - const result2 = await db.put('Outcomes.NotificationReceived', { - notificationId: '1', - }); - expect(result2).toEqual('1'); - }); - - // Tests NotificationClicked records migrate over from a v15 SDK version - test('migrates notificationId type records into Outcomes.NotificationClicked', async () => { - const dbName = 'testDbV4upgradeToV5' + Random.getRandomString(10); - const db = newOSIndexedDb(dbName, 4); - await db.put('NotificationClicked', { notificationId: '1' }); - await db.put('NotificationClicked', { notificationId: '2' }); - db.close(); - - const db2 = newOSIndexedDb(dbName, 5); - const result = await db2.getAll('Outcomes.NotificationClicked'); - expect(result).toEqual([ - { appId: undefined, notificationId: '1', timestamp: undefined }, - { appId: undefined, notificationId: '2', timestamp: undefined }, - ]); - - // old table should be removed - expect(db2.objectStoreNames()).not.toContain('NotificationClicked'); - }); - - // Tests NotificationReceived records migrate over from a v15 SDK version - test('migrates notificationId type records into Outcomes.NotificationReceived', async () => { - const dbName = 'testDbV4upgradeToV5' + Random.getRandomString(10); - const db = newOSIndexedDb(dbName, 4); - await db.put('NotificationReceived', { notificationId: '1' }); - await db.put('NotificationReceived', { notificationId: '2' }); - db.close(); - - const db2 = newOSIndexedDb(dbName, 5); - const result = await db2.getAll('Outcomes.NotificationReceived'); - expect(result).toEqual([ - { appId: undefined, notificationId: '1', timestamp: undefined }, - { appId: undefined, notificationId: '2', timestamp: undefined }, - ]); - - // old table should be removed - expect(db2.objectStoreNames()).not.toContain('NotificationReceived'); - }); - - // Tests records coming from a broken SDK (160000.beta4 to 160000) and upgrading to fixed v5 db - test('migrates notification.id type records into Outcomes.NotificationClicked', async () => { - const dbName = 'testDbV4upgradeToV5' + Random.getRandomString(10); - - // 1. Put the db's schema into the broken v4 state that SDK v16000000 had - const openDbRequest = indexedDB.open(dbName, 4); - const dbOpenPromise = new Promise((resolve) => { - openDbRequest.onsuccess = resolve; - }); - const dbUpgradePromise = new Promise((resolve) => { - openDbRequest.onupgradeneeded = () => { - const db = openDbRequest.result; - db.createObjectStore('NotificationClicked', { - keyPath: 'notification.id', - }); - db.createObjectStore('NotificationReceived', { - keyPath: 'notificationId', - }); - resolve(); - }; - }); - await Promise.all([dbOpenPromise, dbUpgradePromise]); - - // 2. Put a record into the DB with the old schema - openDbRequest.result - .transaction(['NotificationClicked'], 'readwrite') - .objectStore('NotificationClicked') - .put({ notification: { id: '1' } }); - openDbRequest.result.close(); - - // 3. Open the DB with the OneSignal IndexedDb class - const db2 = newOSIndexedDb(dbName, 5); - const result = await db2.getAll('Outcomes.NotificationClicked'); - // 4. Expect the that data is brought over to the new table. - expect(result).toEqual([ - { appId: undefined, notificationId: '1', timestamp: undefined }, - ]); - }); - }); - - describe('v6', () => { - const populateLegacySubscriptions = async (db: IndexedDb) => { - await db.put(LegacyModelName.EmailSubscriptions, { - modelId: '1', - modelName: LegacyModelName.EmailSubscriptions, - onesignalId: DUMMY_ONESIGNAL_ID, - type: SubscriptionType.Email, - }); - await db.put(LegacyModelName.PushSubscriptions, { - modelId: '2', - modelName: LegacyModelName.PushSubscriptions, - onesignalId: DUMMY_ONESIGNAL_ID, - type: SubscriptionType.ChromePush, - }); - await db.put(LegacyModelName.SmsSubscriptions, { - modelId: '3', - modelName: LegacyModelName.SmsSubscriptions, - onesignalId: DUMMY_ONESIGNAL_ID, - type: SubscriptionType.SMS, - }); - }; - - const migratedSubscriptions = { - email: { - modelId: '1', - modelName: ModelName.Subscriptions, - externalId: undefined, - onesignalId: DUMMY_ONESIGNAL_ID, - type: SubscriptionType.Email, - }, - push: { - modelId: '2', - modelName: ModelName.Subscriptions, - externalId: undefined, - onesignalId: DUMMY_ONESIGNAL_ID, - type: SubscriptionType.ChromePush, - }, - sms: { - modelId: '3', - modelName: ModelName.Subscriptions, - externalId: undefined, - onesignalId: DUMMY_ONESIGNAL_ID, - type: SubscriptionType.SMS, - }, - }; - - test('can write to new subscriptions table', async () => { - const db = newOSIndexedDb('testDbv6', 6); - const result = await db.put(ModelName.Subscriptions, { - modelId: '1', - }); - expect(result).toEqual('1'); - }); - - test('migrates v5 email, push, sms subscriptions records to v6 subscriptions record', async () => { - const dbName = 'testDbV5upgradeToV6' + Random.getRandomString(10); - const db = newOSIndexedDb(dbName, 5); - await populateLegacySubscriptions(db); - db.close(); - - const db2 = newOSIndexedDb(dbName, 6); - const result = await db2.getAll(ModelName.Subscriptions); - expect(result).toEqual([ - migratedSubscriptions.email, - migratedSubscriptions.push, - migratedSubscriptions.sms, - ]); - - // old tables should be removed - const oldTableNames = [ - LegacyModelName.EmailSubscriptions, - LegacyModelName.PushSubscriptions, - LegacyModelName.SmsSubscriptions, - ]; - for (const tableName of oldTableNames) { - expect(db2.objectStoreNames()).not.toContain(tableName); - } - }); - - test('migrates v5 email, push, sms subscriptions records of logged in user to v6 subscriptions record with external id', async () => { - const dbName = 'testDbV5upgradeToV6' + Random.getRandomString(10); - const db = newOSIndexedDb(dbName, 5); - await populateLegacySubscriptions(db); - // user is logged in - await db.put(ModelName.Identity, { - modelId: '4', - modelName: ModelName.Identity, - onesignalId: DUMMY_ONESIGNAL_ID, - externalId: DUMMY_EXTERNAL_ID, - }); - db.close(); - - const db2 = newOSIndexedDb(dbName, 6); - const result = await db2.getAll(ModelName.Subscriptions); - expect(result).toEqual([ - { - ...migratedSubscriptions.email, - externalId: DUMMY_EXTERNAL_ID, - }, - { - ...migratedSubscriptions.push, - externalId: DUMMY_EXTERNAL_ID, - }, - { - ...migratedSubscriptions.sms, - externalId: DUMMY_EXTERNAL_ID, - }, - ]); - }); - }); - }); -}); diff --git a/src/shared/session/constants.ts b/src/shared/session/constants.ts index 77d2e24e7..f50294bc8 100644 --- a/src/shared/session/constants.ts +++ b/src/shared/session/constants.ts @@ -11,7 +11,6 @@ export const SessionOrigin = { VisibilityVisible: 3, VisibilityHidden: 4, BeforeUnload: 5, - PageRefresh: 6, Focus: 7, Blur: 8, } as const; diff --git a/src/shared/slidedown/constants.ts b/src/shared/slidedown/constants.ts index cd7742484..3a7acd491 100644 --- a/src/shared/slidedown/constants.ts +++ b/src/shared/slidedown/constants.ts @@ -111,7 +111,6 @@ export const CHANNEL_CAPTURE_CONTAINER_CSS_CLASSES = { }; export const CHANNEL_CAPTURE_CONTAINER_CSS_IDS = { - channelCaptureContainer: 'channel-capture-container', // currently unused smsInputWithValidationElement: 'sms-input-with-validation-element', emailInputWithValidationElement: 'email-input-with-validation-element', onesignalSmsInput: 'iti-onesignal-sms-input', diff --git a/src/sw/serviceWorker/ServiceWorker.test.ts b/src/sw/serviceWorker/ServiceWorker.test.ts index f69943ef6..fbf3477fa 100644 --- a/src/sw/serviceWorker/ServiceWorker.test.ts +++ b/src/sw/serviceWorker/ServiceWorker.test.ts @@ -12,6 +12,12 @@ import { http, HttpResponse } from 'msw'; import OneSignalApiBase from 'src/shared/api/OneSignalApiBase'; import { ConfigIntegrationKind } from 'src/shared/config/constants'; import type { AppConfig } from 'src/shared/config/types'; +import { clearAll, db, getCurrentSession } from 'src/shared/database/client'; +import { + getAllNotificationClickedForOutcomes, + putNotificationClickedForOutcomes, +} from 'src/shared/database/notifications'; +import { getSubscription } from 'src/shared/database/subscription'; import Log from 'src/shared/libraries/Log'; import { WorkerMessengerCommand } from 'src/shared/libraries/workerMessenger/constants'; import { DEFAULT_DEVICE_ID } from 'src/shared/managers/subscription/constants'; @@ -19,12 +25,6 @@ import { SubscriptionManagerSW } from 'src/shared/managers/subscription/sw'; import { DeliveryPlatformKind } from 'src/shared/models/DeliveryPlatformKind'; import { RawPushSubscription } from 'src/shared/models/RawPushSubscription'; import { SubscriptionStrategyKind } from 'src/shared/models/SubscriptionStrategyKind'; -import Database, { - TABLE_NOTIFICATION_OPENED, - TABLE_OUTCOMES_NOTIFICATION_CLICKED, - TABLE_OUTCOMES_NOTIFICATION_RECEIVED, - TABLE_SESSIONS, -} from 'src/shared/services/Database'; import { ONESIGNAL_SESSION_KEY, SessionOrigin, @@ -92,8 +92,8 @@ describe('ServiceWorker', () => { beforeEach(async () => { isServiceWorker = false; - await Database.cleanupCurrentSession(); - await Database.put('Ids', { + await clearAll(); + await db.put('Ids', { type: 'appId', id: appId, }); @@ -162,8 +162,8 @@ describe('ServiceWorker', () => { const notificationId = payload.custom.i; // db should mark the notification as received - const notifcationReceived = await Database.getAll( - TABLE_OUTCOMES_NOTIFICATION_RECEIVED, + const notifcationReceived = await db.getAll( + 'Outcomes.NotificationReceived', ); expect(notifcationReceived).toEqual( expect.arrayContaining([ @@ -258,8 +258,8 @@ describe('ServiceWorker', () => { expect(notificationClose).toHaveBeenCalled(); // should save clicked info to db - const notificationClicked = await Database.getAll( - TABLE_OUTCOMES_NOTIFICATION_CLICKED, + const notificationClicked = await db.getAll( + 'Outcomes.NotificationClicked', ); expect(notificationClicked).toEqual( expect.arrayContaining([ @@ -300,20 +300,6 @@ describe('ServiceWorker', () => { pushSubscriptionId, ); - // should update DB and call open url - const pendingUrlOpening = await Database.getAll( - TABLE_NOTIFICATION_OPENED, - ); - expect(pendingUrlOpening).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - id: notificationId, - timestamp: expect.any(Number), - url: launchURL, - }), - ]), - ); - // should open url expect(self.clients.openWindow).toHaveBeenCalledWith(launchURL); }); @@ -344,11 +330,11 @@ describe('ServiceWorker', () => { http.post(`**/players`, () => HttpResponse.json({ id: null })), ); - await Database.put('Ids', { + await db.put('Ids', { type: 'userId', id: null, }); - await Database.put('Ids', { + await db.put('Ids', { type: 'registrationId', id: '456', }); @@ -359,7 +345,7 @@ describe('ServiceWorker', () => { await dispatchEvent(event); // should remove previous ids - const ids = await Database.getAll('Ids'); + const ids = await db.getAll('Ids'); expect(ids).toEqual([ { type: 'appId', @@ -388,7 +374,7 @@ describe('ServiceWorker', () => { ); // the device id will be reset regardless of the old subscription state - const subscription = await Database.getSubscription(); + const subscription = await getSubscription(); expect(subscription.deviceId).toBe(DEFAULT_DEVICE_ID); }); @@ -463,9 +449,7 @@ describe('ServiceWorker', () => { timestamp: Date.now(), }; - beforeEach(async () => { - await Database.cleanupCurrentSession(); - }); + beforeEach(async () => {}); describe('session upsert event', () => { test('with safari client', async () => { @@ -473,7 +457,7 @@ describe('ServiceWorker', () => { // @ts-expect-error - custom property, not part of the spec self.cancel = cancel; - await Database.put(TABLE_SESSIONS, session); + await db.put('Sessions', session); const event = new ExtendableMessageEvent('message', { command: WorkerMessengerCommand.SessionUpsert, @@ -492,14 +476,14 @@ describe('ServiceWorker', () => { }); // should de-active session since can't determine focused window for Safari - const updatedSession = (await Database.getCurrentSession())!; + const updatedSession = (await getCurrentSession())!; expect(updatedSession.status).toBe(SessionStatus.Inactive); expect(updatedSession.lastDeactivatedTimestamp).not.toBeNull(); expect(updatedSession.accumulatedDuration).not.toBe(0); }); test('with non-safari client', async () => { - await Database.putNotificationClickedForOutcomes(appId, clickOutcome); + await putNotificationClickedForOutcomes(appId, clickOutcome); const event = new ExtendableMessageEvent('message', { command: WorkerMessengerCommand.SessionUpsert, @@ -511,7 +495,7 @@ describe('ServiceWorker', () => { await dispatchEvent(event); // should create a new session - const updatedSession = (await Database.getCurrentSession())!; + const updatedSession = (await getCurrentSession())!; expect(updatedSession).toEqual( expect.objectContaining({ accumulatedDuration: 0, @@ -554,11 +538,11 @@ describe('ServiceWorker', () => { ); matchAllFn.mockResolvedValueOnce([unfocusedClient]); - await Database.put(TABLE_SESSIONS, { + await db.put('Sessions', { ...session, status: SessionStatus.Inactive, }); - await Database.putNotificationClickedForOutcomes(appId, clickOutcome); + await putNotificationClickedForOutcomes(appId, clickOutcome); const event = new ExtendableMessageEvent('message', { command: WorkerMessengerCommand.SessionDeactivate, @@ -574,9 +558,9 @@ describe('ServiceWorker', () => { // should finalize session then clean up sessions await vi.advanceTimersByTimeAsync(15000); - const currentSession = await Database.getCurrentSession(); + const currentSession = await getCurrentSession(); const notificationClicked = - await Database.getAllNotificationClickedForOutcomes(); + await getAllNotificationClickedForOutcomes(); expect(currentSession).toBeNull(); expect(notificationClicked).toEqual([]); diff --git a/src/sw/serviceWorker/ServiceWorker.ts b/src/sw/serviceWorker/ServiceWorker.ts index 1fbe1150f..926a8e203 100755 --- a/src/sw/serviceWorker/ServiceWorker.ts +++ b/src/sw/serviceWorker/ServiceWorker.ts @@ -3,6 +3,20 @@ import OneSignalApiSW from 'src/shared/api/OneSignalApiSW'; import { getServerAppConfig } from 'src/shared/config/app'; import type { AppConfig } from 'src/shared/config/types'; import { containsMatch } from 'src/shared/context/helpers'; +import { + db, + getCurrentSession, + getOptionsValue, +} from 'src/shared/database/client'; +import { getAppState, getDBAppConfig } from 'src/shared/database/config'; +import { + putNotificationClickedForOutcomes, + putNotificationReceivedForOutcomes, +} from 'src/shared/database/notifications'; +import { + getSubscription, + setSubscription, +} from 'src/shared/database/subscription'; import { getDeviceType } from 'src/shared/environment/detect'; import { delay } from 'src/shared/helpers/general'; import { @@ -23,12 +37,11 @@ import type { NotificationClickEventInternal, NotificationForegroundWillDisplayEventSerializable, } from 'src/shared/notifications/types'; -import Database from 'src/shared/services/Database'; import { SessionStatus } from 'src/shared/session/constants'; -import type { - PageVisibilityRequest, - PageVisibilityResponse, - UpsertOrDeactivateSessionPayload, +import { + type PageVisibilityRequest, + type PageVisibilityResponse, + type UpsertOrDeactivateSessionPayload, } from 'src/shared/session/types'; import { NotificationType } from 'src/shared/subscriptions/constants'; import type { NotificationTypeValue } from 'src/shared/subscriptions/types'; @@ -164,7 +177,7 @@ export class OneSignalServiceWorker { return appId; } } - const { appId } = await Database.getAppConfig(); + const { appId } = await getDBAppConfig(); return appId; } @@ -275,10 +288,7 @@ export class OneSignalServiceWorker { const notification = toOSNotification(rawNotification); notificationReceivedPromises.push( - Database.putNotificationReceivedForOutcomes( - appId, - notification, - ), + putNotificationReceivedForOutcomes(appId, notification), ); // TODO: decide what to do with all the notif received promises // Probably should have it's own error handling but not blocking the rest of the execution? @@ -591,14 +601,14 @@ export class OneSignalServiceWorker { ); // Use the default title if one isn't provided - const defaultTitle: string = await OneSignalServiceWorker._getTitle(); + const defaultTitle = await OneSignalServiceWorker._getTitle(); // Use the default icon if one isn't provided - const defaultIcon: string = await Database.get('Options', 'defaultIcon'); + const defaultIcon = await getOptionsValue('defaultIcon'); // Get option of whether we should leave notification displaying indefinitely - const persistNotification = await Database.get( - 'Options', + const persistNotification = await getOptionsValue( 'persistNotification', ); + // Get app ID for tag value const appId = await OneSignalServiceWorker.getAppId(); @@ -751,7 +761,7 @@ export class OneSignalServiceWorker { } const { defaultNotificationUrl: dbDefaultNotificationUrl } = - await Database.getAppState(); + await getAppState(); if (dbDefaultNotificationUrl) { return dbDefaultNotificationUrl; } @@ -777,14 +787,12 @@ export class OneSignalServiceWorker { let notificationClickHandlerMatch = 'exact'; let notificationClickHandlerAction = 'navigate'; - const matchPreference = await Database.get( - 'Options', + const matchPreference = await getOptionsValue( 'notificationClickHandlerMatch', ); if (matchPreference) notificationClickHandlerMatch = matchPreference; - const actionPreference = await Database.get( - 'Options', + const actionPreference = await getOptionsValue( 'notificationClickHandlerAction', ); if (actionPreference) notificationClickHandlerAction = actionPreference; @@ -810,7 +818,7 @@ export class OneSignalServiceWorker { Log.info('NotificationClicked', notificationClickEvent); const saveNotificationClickedPromise = (async (notificationClickEvent) => { try { - const existingSession = await Database.getCurrentSession(); + const existingSession = await getCurrentSession(); if ( existingSession && existingSession.status === SessionStatus.Active @@ -818,17 +826,14 @@ export class OneSignalServiceWorker { return; } - await Database.putNotificationClickedForOutcomes( - appId, - notificationClickEvent, - ); + await putNotificationClickedForOutcomes(appId, notificationClickEvent); // upgrade existing session to be directly attributed to the notif // if it results in re-focusing the site if (existingSession) { existingSession.notificationId = notificationClickEvent.notification.notificationId; - await Database.upsertSession(existingSession); + await db.put('Sessions', existingSession); } } catch (e) { Log.error('Failed to save clicked notification.', e); @@ -910,9 +915,6 @@ export class OneSignalServiceWorker { try { if (notificationOpensLink) { Log.debug(`Redirecting HTTPS site to (${launchUrl}).`); - await Database.putNotificationClickedEventPendingUrlOpening( - notificationClickEvent, - ); await client.navigate(launchUrl); } else { Log.debug('Not navigating because link is special.'); @@ -922,9 +924,6 @@ export class OneSignalServiceWorker { } } else { // If client.navigate() isn't available, we have no other option but to open a new tab to the URL. - await Database.putNotificationClickedEventPendingUrlOpening( - notificationClickEvent, - ); await OneSignalServiceWorker.openUrl(launchUrl); } } @@ -934,9 +933,6 @@ export class OneSignalServiceWorker { } if (notificationOpensLink && !doNotOpenLink) { - await Database.putNotificationClickedEventPendingUrlOpening( - notificationClickEvent, - ); await OneSignalServiceWorker.openUrl(launchUrl); } if (saveNotificationClickedPromise) { @@ -1038,9 +1034,8 @@ export class OneSignalServiceWorker { // Get our current device ID let deviceIdExists: boolean; { - let deviceId: string | null | undefined = ( - await Database.getSubscription() - ).deviceId; + let deviceId: string | null | undefined = (await getSubscription()) + .deviceId; deviceIdExists = !!deviceId; if (!deviceIdExists && event.oldSubscription) { @@ -1052,9 +1047,9 @@ export class OneSignalServiceWorker { ); // Store the device ID, so it can be looked up when subscribing - const subscription = await Database.getSubscription(); + const subscription = await getSubscription(); subscription.deviceId = deviceId; - await Database.setSubscription(subscription); + await setSubscription(subscription); } deviceIdExists = !!deviceId; } @@ -1081,8 +1076,8 @@ export class OneSignalServiceWorker { const hasNewSubscription = !!rawPushSubscription; if (!deviceIdExists && !hasNewSubscription) { - await Database.remove('Ids', 'userId'); - await Database.remove('Ids', 'registrationId'); + await db.delete('Ids', 'userId'); + await db.delete('Ids', 'registrationId'); } else { /* Determine subscription state we should set new record to. @@ -1116,8 +1111,8 @@ export class OneSignalServiceWorker { static _getTitle(): Promise { return new Promise((resolve) => { Promise.all([ - Database.get('Options', 'defaultTitle'), - Database.get('Options', 'pageTitle'), + getOptionsValue('defaultTitle'), + getOptionsValue('pageTitle'), ]).then(([defaultTitle, pageTitle]) => { if (defaultTitle !== null) { resolve(defaultTitle); diff --git a/src/sw/serviceWorker/helpers.ts b/src/sw/serviceWorker/helpers.ts index c6c8b3ef6..01e6286f8 100644 --- a/src/sw/serviceWorker/helpers.ts +++ b/src/sw/serviceWorker/helpers.ts @@ -1,6 +1,4 @@ -import type { SubscriptionModel } from 'src/core/models/SubscriptionModel'; -import { ModelName } from 'src/core/types/models'; -import Database from 'src/shared/services/Database'; +import { db } from 'src/shared/database/client'; /** * WARNING: This is a temp workaround for the ServiceWorker context only! @@ -10,9 +8,7 @@ import Database from 'src/shared/services/Database'; export async function getPushSubscriptionIdByToken( token: string, ): Promise { - const pushSubscriptions = await Database.getAll( - ModelName.Subscriptions, - ); + const pushSubscriptions = await db.getAll('subscriptions'); for (const pushSubscription of pushSubscriptions) { if (pushSubscription['token'] === token) { return pushSubscription['id'] as string; diff --git a/src/sw/webhooks/OSWebhookSender.ts b/src/sw/webhooks/OSWebhookSender.ts index 50c29fe55..dec2fc15f 100644 --- a/src/sw/webhooks/OSWebhookSender.ts +++ b/src/sw/webhooks/OSWebhookSender.ts @@ -1,19 +1,16 @@ +import { getOptionsValue } from 'src/shared/database/client'; +import type { OptionKey } from 'src/shared/database/types'; import Log from 'src/shared/libraries/Log'; -import Database from '../../shared/services/Database'; import type { IOSWebhookEventPayload } from '../serviceWorker/types'; export class OSWebhookSender { async send(payload: IOSWebhookEventPayload): Promise { - const webhookTargetUrl = await Database.get( - 'Options', - `webhooks.${payload.event}`, + const webhookTargetUrl = await getOptionsValue( + `webhooks.${payload.event}` as OptionKey, ); if (!webhookTargetUrl) return; - const isServerCorsEnabled = await Database.get( - 'Options', - 'webhooks.cors', - ); + const isServerCorsEnabled = await getOptionsValue('webhooks.cors'); const fetchOptions: RequestInit = { method: 'post',