Skip to content

Commit

Permalink
feat(usage): Add a FeatureUsageStore and move Identity to the DB
Browse files Browse the repository at this point in the history
Summary:
This is a WIP

Depends on D3799 on billing.nylas.com

This adds a `FeatureUsageStore` which determines whether a feature can be
used or not. It also allows us to record "using" a feature.

Feature Usage is ultimately backed by the Nylas Identity and cached
locally in the Identity object. Since feature usage is attached to the
Nylas Identity, we move the whole Identity object (except for the ID) to
the database.

This includes a migration (with tests!) to move the Nylas Identity from
the config into the Database. We still, however, need the Nylas ID to stay
in the config so it can be synchronously accessed by the /browser process
on bootup when determining what windows to show. It's also convenient to
know what the Nylas ID is by looking at the config. There's logic (with
tests!) to make sure these stay in sync. If you delete the Nylas ID from
the config, it'll be the same as logging you out.

The schema for the feature usage can be found in more detail on D3799. By
the time it reaches Nylas Mail, the Nylas ID object has a `feature_usage`
attribute that has each feature (keyed by the feature name) and
information about the plans attached to it. The schema Nylas Mail sees
looks like:

```
"feature_usage": {
   "snooze": {
     quota: 10,
     peroid: 'monthly',
     used_in_period: 8,
     feature_limit_name: 'Snooze Group A',
   },
}
```

See D3799 for more info about how these are generated.

One final change that's in here is how Stores are loaded. Most of our
core stores are loaded at require time, but now things like the
IdentityStore need to do asynchronous things on activation. In reality
most of our stores do this and it's a miracle it hasn't caused more
problems! Now when stores activate we optionally look for an `activate`
method and `await` for it. This was necessary so downstream classes (like
the Onboarding Store), see a fully initialized IdentityStore by the time
it's time to use them

Test Plan: New tests!

Reviewers: khamidou, juan, halla

Reviewed By: juan

Differential Revision: https://phab.nylas.com/D3808
  • Loading branch information
emorikawa committed Feb 3, 2017
1 parent 5a38305 commit 3111c16
Show file tree
Hide file tree
Showing 16 changed files with 499 additions and 104 deletions.
4 changes: 2 additions & 2 deletions internal_packages/onboarding/lib/onboarding-store.es6
Original file line number Diff line number Diff line change
Expand Up @@ -137,10 +137,10 @@ class OnboardingStore extends NylasStore {
this.trigger();
}

_onAuthenticationJSONReceived = (json) => {
_onAuthenticationJSONReceived = async (json) => {
const isFirstAccount = AccountStore.accounts().length === 0;

Actions.setNylasIdentity(json);
await IdentityStore.saveIdentity(json);

setTimeout(() => {
if (isFirstAccount) {
Expand Down
2 changes: 0 additions & 2 deletions spec/auto-update-manager-spec.coffee
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,6 @@ describe "AutoUpdateManager", ->
get: (key) =>
if key is 'nylas.accounts'
return @accounts
if key is 'nylas.identity.id'
return @nylasIdentityId
if key is 'env'
return 'production'
onDidChange: (key, callback) =>
Expand Down
82 changes: 82 additions & 0 deletions spec/stores/feature-usage-store-spec.es6
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import {TaskQueueStatusStore} from 'nylas-exports'
import FeatureUsageStore from '../../src/flux/stores/feature-usage-store'
import Task from '../../src/flux/tasks/task'
import SendFeatureUsageEventTask from '../../src/flux/tasks/send-feature-usage-event-task'
import IdentityStore from '../../src/flux/stores/identity-store'

describe("FeatureUsageStore", function featureUsageStoreSpec() {
beforeEach(() => {
this.oldIdent = IdentityStore._identity;
IdentityStore._identity = {id: 'foo'}
IdentityStore._identity.feature_usage = {
"is-usable": {
quota: 10,
peroid: 'monthly',
used_in_period: 8,
feature_limit_name: 'Usable Group A',
},
"not-usable": {
quota: 10,
peroid: 'monthly',
used_in_period: 10,
feature_limit_name: 'Unusable Group A',
},
}
});

afterEach(() => {
IdentityStore._identity = this.oldIdent
});

describe("isUsable", () => {
it("returns true if a feature hasn't met it's quota", () => {
expect(FeatureUsageStore.isUsable("is-usable")).toBe(true)
});

it("returns false if a feature is at its quota", () => {
expect(FeatureUsageStore.isUsable("not-usable")).toBe(false)
});

it("warns if asking for an unsupported feature", () => {
spyOn(NylasEnv, "reportError")
expect(FeatureUsageStore.isUsable("unsupported")).toBe(false)
expect(NylasEnv.reportError).toHaveBeenCalled()
});
});

describe("useFeature", () => {
beforeEach(() => {
spyOn(SendFeatureUsageEventTask.prototype, "performRemote").andReturn(Promise.resolve(Task.Status.Success));
spyOn(IdentityStore, "saveIdentity").andCallFake((ident) => {
IdentityStore._identity = ident
})
spyOn(TaskQueueStatusStore, "waitForPerformLocal").andReturn(Promise.resolve())
});

it("returns the num remaining if successful", async () => {
let numLeft = await FeatureUsageStore.useFeature('is-usable');
expect(numLeft).toBe(1)
numLeft = await FeatureUsageStore.useFeature('is-usable');
expect(numLeft).toBe(0)
});

it("throws if it was over quota", async () => {
try {
await FeatureUsageStore.useFeature("not-usable");
throw new Error("This should throw")
} catch (err) {
expect(err.message).toMatch(/not usable/)
}
});

it("throws if using an unsupported feature", async () => {
spyOn(NylasEnv, "reportError")
try {
await FeatureUsageStore.useFeature("unsupported");
throw new Error("This should throw")
} catch (err) {
expect(err.message).toMatch(/supported/)
}
});
});
});
75 changes: 75 additions & 0 deletions spec/stores/identity-store-spec.es6
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import {ipcRenderer} from 'electron';
import {KeyManager, DatabaseTransaction, SendFeatureUsageEventTask} from 'nylas-exports'
import IdentityStore from '../../src/flux/stores/identity-store'

const TEST_NYLAS_ID = "icihsnqh4pwujyqihlrj70vh"
const TEST_TOKEN = "test-token"

describe("IdentityStore", function identityStoreSpec() {
beforeEach(() => {
this.identityJSON = {
valid_until: 1500093224,
firstname: "Nylas 050",
lastname: "Test",
free_until: 1500006814,
email: "nylas050test@evanmorikawa.com",
id: TEST_NYLAS_ID,
seen_welcome_page: true,
}
});

it("logs out of nylas identity properly", async () => {
IdentityStore._identity = this.identityJSON;
spyOn(NylasEnv.config, 'unset')
spyOn(KeyManager, "deletePassword")
spyOn(ipcRenderer, "send")
spyOn(DatabaseTransaction.prototype, "persistJSONBlob").andReturn(Promise.resolve())

const promise = IdentityStore._onLogoutNylasIdentity()
IdentityStore._onIdentityChanged(null)
return promise.then(() => {
expect(KeyManager.deletePassword).toHaveBeenCalled()
expect(ipcRenderer.send).toHaveBeenCalled()
expect(ipcRenderer.send.calls[0].args[1]).toBe("application:relaunch-to-initial-windows")
expect(DatabaseTransaction.prototype.persistJSONBlob).toHaveBeenCalled()
const ident = DatabaseTransaction.prototype.persistJSONBlob.calls[0].args[1]
expect(ident).toBe(null)
})
});

it("can log a feature usage event", () => {
spyOn(IdentityStore, "nylasIDRequest");
spyOn(IdentityStore, "saveIdentity");
IdentityStore._identity = this.identityJSON
IdentityStore._identity.token = TEST_TOKEN;
IdentityStore._onEnvChanged()
const t = new SendFeatureUsageEventTask("snooze");
t.performRemote()
const opts = IdentityStore.nylasIDRequest.calls[0].args[0]
expect(opts).toEqual({
method: "POST",
url: "https://billing.nylas.com/n1/user/feature_usage_event",
body: {
feature_name: 'snooze',
},
})
});

describe("returning the identity object", () => {
it("returns the identity as null if it looks blank", () => {
IdentityStore._identity = null;
expect(IdentityStore.identity()).toBe(null);
IdentityStore._identity = {};
expect(IdentityStore.identity()).toBe(null);
IdentityStore._identity = {token: 'bad'};
expect(IdentityStore.identity()).toBe(null);
});

it("returns a proper clone of the identity", () => {
IdentityStore._identity = {id: 'bar', deep: {obj: 'baz'}};
const ident = IdentityStore.identity();
IdentityStore._identity.deep.obj = 'changed';
expect(ident.deep.obj).toBe('baz');
});
});
});
2 changes: 1 addition & 1 deletion src/K2
Submodule K2 updated from d85ca0 to ae7815
26 changes: 22 additions & 4 deletions src/browser/application.es6
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import {EventEmitter} from 'events';

import WindowManager from './window-manager';
import FileListCache from './file-list-cache';
import DatabaseReader from './database-reader';
import ConfigMigrator from './config-migrator';
import ApplicationMenu from './application-menu';
import AutoUpdateManager from './auto-update-manager';
import SystemTrayManager from './system-tray-manager';
Expand All @@ -24,7 +26,7 @@ let clipboard = null;
// The application's singleton class.
//
export default class Application extends EventEmitter {
start(options) {
async start(options) {
const {resourcePath, configDirPath, version, devMode, specMode, safeMode} = options;

// Normalize to make sure drive letter case is consistent on Windows
Expand All @@ -38,12 +40,18 @@ export default class Application extends EventEmitter {
this.fileListCache = new FileListCache();
this.nylasProtocolHandler = new NylasProtocolHandler(this.resourcePath, this.safeMode);

this.databaseReader = new DatabaseReader({configDirPath, specMode});
await this.databaseReader.open();

const Config = require('../config');
const config = new Config();
this.config = config;
this.configPersistenceManager = new ConfigPersistenceManager({configDirPath, resourcePath});
config.load();

this.configMigrator = new ConfigMigrator(this.config, this.databaseReader);
this.configMigrator.migrate()

this.packageMigrationManager = new PackageMigrationManager({config, configDirPath, version})
this.packageMigrationManager.migrate()

Expand All @@ -52,7 +60,7 @@ export default class Application extends EventEmitter {
initializeInBackground = false;
}

this.autoUpdateManager = new AutoUpdateManager(version, config, specMode);
this.autoUpdateManager = new AutoUpdateManager(version, config, specMode, this.databaseReader);
this.applicationMenu = new ApplicationMenu(version);
this.windowManager = new WindowManager({
resourcePath: this.resourcePath,
Expand Down Expand Up @@ -123,7 +131,6 @@ export default class Application extends EventEmitter {
}
}


// On Windows, removing a file can fail if a process still has it open. When
// we close windows and log out, we need to wait for these processes to completely
// exit and then delete the file. It's hard to tell when this happens, so we just
Expand Down Expand Up @@ -160,7 +167,7 @@ export default class Application extends EventEmitter {
openWindowsForTokenState() {
const accounts = this.config.get('nylas.accounts');
const hasAccount = accounts && accounts.length > 0;
const hasN1ID = this.config.get('nylas.identity.id');
const hasN1ID = this._getNylasId();

if (hasAccount && hasN1ID) {
this.windowManager.ensureWindow(WindowManager.MAIN_WINDOW);
Expand All @@ -173,7 +180,14 @@ export default class Application extends EventEmitter {
}
}

_getNylasId() {
const identity = this.databaseReader.getJSONBlob("NylasID") || {}
return identity.id
}

_relaunchToInitialWindows = ({resetConfig, resetDatabase} = {}) => {
// This will re-fetch the NylasID to update the feed url
this.autoUpdateManager.updateFeedURL()
this.setDatabasePhase('close');
this.windowManager.destroyAllWindows();

Expand Down Expand Up @@ -270,6 +284,10 @@ export default class Application extends EventEmitter {

this.on('application:relaunch-to-initial-windows', this._relaunchToInitialWindows);

this.on('application:onIdentityChanged', () => {
this.autoUpdateManager.updateFeedURL()
});

this.on('application:quit', () => {
app.quit()
});
Expand Down
15 changes: 6 additions & 9 deletions src/browser/auto-update-manager.es6
Original file line number Diff line number Diff line change
Expand Up @@ -18,31 +18,28 @@ const preferredChannel = 'nylas-mail'

export default class AutoUpdateManager extends EventEmitter {

constructor(version, config, specMode) {
constructor(version, config, specMode, databaseReader) {
super();

this.state = IdleState;
this.version = version;
this.config = config;
this.databaseReader = databaseReader
this.specMode = specMode;
this.preferredChannel = preferredChannel;

this._updateFeedURL();
this.updateFeedURL();

this.config.onDidChange(
'nylas.identity.id',
this._updateFeedURL
);
this.config.onDidChange(
'nylas.accounts',
this._updateFeedURL
this.updateFeedURL
);

setTimeout(() => this.setupAutoUpdater(), 0);
}

parameters = () => {
let updaterId = this.config.get("nylas.identity.id");
let updaterId = (this.databaseReader.getJSONBlob("NylasID") || {}).id
if (!updaterId) {
updaterId = "anonymous";
}
Expand All @@ -66,7 +63,7 @@ export default class AutoUpdateManager extends EventEmitter {
};
}

_updateFeedURL = () => {
updateFeedURL = () => {
const params = this.parameters();

let host = `edgehill.nylas.com`;
Expand Down
25 changes: 25 additions & 0 deletions src/browser/config-migrator.es6
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
export default class ConfigMigrator {
constructor(config, database) {
this.config = config;
this.database = database;
}

migrate() {
/**
* In version before 1.0.21 we stored the Nylas ID Identity in the Config.
* After 1.0.21 we moved it into the JSONBlob Database Store.
*/
const oldIdentity = this.config.get("nylas.identity") || {};
if (!oldIdentity.id) return;
const key = "NylasID"
const q = `REPLACE INTO JSONBlob (id, data, client_id) VALUES (?,?,?)`;
const jsonBlobData = {
id: key,
clientId: key,
serverId: key,
json: oldIdentity,
}
this.database.database.prepare(q).run([key, JSON.stringify(jsonBlobData), key])
this.config.set("nylas.identity", null)
}
}
22 changes: 22 additions & 0 deletions src/browser/database-reader.es6
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import {setupDatabase, databasePath} from '../database-helpers'

export default class DatabaseReader {
constructor({configDirPath, specMode}) {
this.databasePath = databasePath(configDirPath, specMode)
}

async open() {
this.database = await setupDatabase(this.databasePath)
}

getJSONBlob(key) {
const q = `SELECT * FROM JSONBlob WHERE id = '${key}'`;
try {
const row = this.database.prepare(q).get();
if (!row || !row.data) return null
return (JSON.parse(row.data) || {}).json
} catch (err) {
return null
}
}
}
1 change: 0 additions & 1 deletion src/flux/actions.es6
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,6 @@ class Actions {
/*
Public: Manage the Nylas identity
*/
static setNylasIdentity = ActionScopeWindow;
static logoutNylasIdentity = ActionScopeWindow;

/*
Expand Down

0 comments on commit 3111c16

Please sign in to comment.