(core) Move Notifier to /ext

Summary:
This makes it possible to configure a SendGrid-based Notifier
instance via a JSON configuration file.

Test Plan: Tested manually.

Reviewers: alexmojaki

Reviewed By: alexmojaki

Subscribers: paulfitz

Differential Revision: https://phab.getgrist.com/D3432
This commit is contained in:
George Gevoian 2022-05-17 15:25:36 -07:00
parent 365f3c7ae2
commit 2fd8a34ff8
10 changed files with 43 additions and 27 deletions

View File

@ -35,7 +35,7 @@ import {
UserAttributeRule UserAttributeRule
} from 'app/common/GranularAccessClause'; } from 'app/common/GranularAccessClause';
import {isHiddenCol} from 'app/common/gristTypes'; import {isHiddenCol} from 'app/common/gristTypes';
import {isObject} from 'app/common/gutil'; import {isNonNullish} from 'app/common/gutil';
import {SchemaTypes} from 'app/common/schema'; import {SchemaTypes} from 'app/common/schema';
import {MetaRowRecord} from 'app/common/TableData'; import {MetaRowRecord} from 'app/common/TableData';
import { import {
@ -1331,7 +1331,7 @@ function syncRecords(tableData: TableData, newRecords: RowRecord[],
const newRec = newRecordMap.get(uniqueId(r)); const newRec = newRecordMap.get(uniqueId(r));
const updated = newRec && {...r, ...newRec, id: r.id}; const updated = newRec && {...r, ...newRec, id: r.id};
return updated && !isEqual(updated, r) ? [r, updated] : null; return updated && !isEqual(updated, r) ? [r, updated] : null;
}).filter(isObject); }).filter(isNonNullish);
console.log("syncRecords: removing [%s], adding [%s], updating [%s]", console.log("syncRecords: removing [%s], adding [%s], updating [%s]",
removedRecords.map(uniqueId).join(", "), removedRecords.map(uniqueId).join(", "),

View File

@ -899,9 +899,9 @@ export function isAffirmative(parameter: any): boolean {
* Returns whether a value is neither null nor undefined, with a type guard for the return type. * Returns whether a value is neither null nor undefined, with a type guard for the return type.
* *
* This is particularly useful for filtering, e.g. if `array` includes values of type * This is particularly useful for filtering, e.g. if `array` includes values of type
* T|null|undefined, then TypeScript can tell that `array.filter(isObject)` has the type T[]. * T|null|undefined, then TypeScript can tell that `array.filter(isNonNullish)` has the type T[].
*/ */
export function isObject<T>(value: T | null | undefined): value is T { export function isNonNullish<T>(value: T | null | undefined): value is T {
return value !== null && value !== undefined; return value !== null && value !== undefined;
} }

View File

@ -1,7 +1,7 @@
import escapeRegExp = require('lodash/escapeRegExp'); import escapeRegExp = require('lodash/escapeRegExp');
import last = require('lodash/last'); import last = require('lodash/last');
import memoize = require('lodash/memoize'); import memoize = require('lodash/memoize');
import {getDistinctValues, isObject} from 'app/common/gutil'; import {getDistinctValues, isNonNullish} from 'app/common/gutil';
// Simply importing 'moment-guess' inconsistently imports bundle.js or bundle.esm.js depending on environment // Simply importing 'moment-guess' inconsistently imports bundle.js or bundle.esm.js depending on environment
import * as guessFormat from '@gristlabs/moment-guess/dist/bundle.js'; import * as guessFormat from '@gristlabs/moment-guess/dist/bundle.js';
import * as moment from 'moment-timezone'; import * as moment from 'moment-timezone';
@ -346,7 +346,7 @@ export function guessDateFormat(values: Array<string | null>, timezone: string =
* May return null if there are no matching formats or choosing one is too expensive. * May return null if there are no matching formats or choosing one is too expensive.
*/ */
export function guessDateFormats(values: Array<string | null>, timezone: string = 'UTC'): string[] | null { export function guessDateFormats(values: Array<string | null>, timezone: string = 'UTC'): string[] | null {
const dateStrings: string[] = values.filter(isObject); const dateStrings: string[] = values.filter(isNonNullish);
const sample = getDistinctValues(dateStrings, 100); const sample = getDistinctValues(dateStrings, 100);
const formats: Record<string, number> = {}; const formats: Record<string, number> = {};
for (const dateString of sample) { for (const dateString of sample) {

View File

@ -6,7 +6,6 @@ import {encodeUrl, getSlugIfNeeded, GristLoadConfig, IGristUrlState, isOrgInPath
import {getOrgUrlInfo} from 'app/common/gristUrls'; import {getOrgUrlInfo} from 'app/common/gristUrls';
import {UserProfile} from 'app/common/LoginSessionAPI'; import {UserProfile} from 'app/common/LoginSessionAPI';
import {tbind} from 'app/common/tbind'; import {tbind} from 'app/common/tbind';
import {UserConfig} from 'app/common/UserConfig';
import * as version from 'app/common/version'; import * as version from 'app/common/version';
import {ApiServer} from 'app/gen-server/ApiServer'; import {ApiServer} from 'app/gen-server/ApiServer';
import {Document} from "app/gen-server/entity/Document"; import {Document} from "app/gen-server/entity/Document";
@ -103,7 +102,7 @@ export class FlexServer implements GristServer {
public housekeeper: Housekeeper; public housekeeper: Housekeeper;
public server: http.Server; public server: http.Server;
public httpsServer?: https.Server; public httpsServer?: https.Server;
public settings: any; public settings?: Readonly<Record<string, unknown>>;
public worker: DocWorkerInfo; public worker: DocWorkerInfo;
public electronServerMethods: ElectronServerMethods; public electronServerMethods: ElectronServerMethods;
public readonly docsRoot: string; public readonly docsRoot: string;
@ -802,12 +801,17 @@ export class FlexServer implements GristServer {
}); });
} }
// Load user config file from standard location. Alternatively, a config object /**
// can be supplied, in which case no file is needed. The notion of a user config * Load user config file from standard location (if present).
// file doesn't mean much in hosted grist, so it is convenient to be able to skip it. *
public async loadConfig(settings?: UserConfig) { * Note that the user config file doesn't do anything today, but may be useful in
* the future for configuring things that don't fit well into environment variables.
*
* TODO: Revisit this, and update `GristServer.settings` type to match the expected shape
* of config.json. (ts-interface-checker could be useful here for runtime validation.)
*/
public async loadConfig() {
if (this._check('config')) { return; } if (this._check('config')) { return; }
if (!settings) {
const settingsPath = path.join(this.instanceRoot, 'config.json'); const settingsPath = path.join(this.instanceRoot, 'config.json');
if (await fse.pathExists(settingsPath)) { if (await fse.pathExists(settingsPath)) {
log.info(`Loading config from ${settingsPath}`); log.info(`Loading config from ${settingsPath}`);
@ -816,9 +820,6 @@ export class FlexServer implements GristServer {
log.info(`Loading empty config because ${settingsPath} missing`); log.info(`Loading empty config because ${settingsPath} missing`);
this.settings = {}; this.settings = {};
} }
} else {
this.settings = settings;
}
// TODO: We could include a third mock provider of login/logout URLs for better tests. Or we // TODO: We could include a third mock provider of login/logout URLs for better tests. Or we
// could create a mock SAML identity provider for testing this using the SAML flow. // could create a mock SAML identity provider for testing this using the SAML flow.

View File

@ -16,7 +16,7 @@ import { ErrorWithCode } from 'app/common/ErrorWithCode';
import { AclMatchInput, InfoEditor, InfoView } from 'app/common/GranularAccessClause'; import { AclMatchInput, InfoEditor, InfoView } from 'app/common/GranularAccessClause';
import { UserInfo } from 'app/common/GranularAccessClause'; import { UserInfo } from 'app/common/GranularAccessClause';
import { isCensored } from 'app/common/gristTypes'; import { isCensored } from 'app/common/gristTypes';
import { getSetMapValue, isObject, pruneArray } from 'app/common/gutil'; import { getSetMapValue, isNonNullish, pruneArray } from 'app/common/gutil';
import { canEdit, canView, isValidRole, Role } from 'app/common/roles'; import { canEdit, canView, isValidRole, Role } from 'app/common/roles';
import { FullUser, UserAccessData } from 'app/common/UserAPI'; import { FullUser, UserAccessData } from 'app/common/UserAPI';
import { HomeDBManager } from 'app/gen-server/lib/HomeDBManager'; import { HomeDBManager } from 'app/gen-server/lib/HomeDBManager';
@ -1039,7 +1039,7 @@ export class GranularAccess implements GranularAccessForBundle {
this._makeAdditions(rowsAfter, forceAdds), this._makeAdditions(rowsAfter, forceAdds),
this._removeRows(action, removals), this._removeRows(action, removals),
this._makeRemovals(rowsAfter, forceRemoves), this._makeRemovals(rowsAfter, forceRemoves),
].filter(isObject); ].filter(isNonNullish);
// Check whether there are column rules for this table, and if so whether they are row // Check whether there are column rules for this table, and if so whether they are row
// dependent. If so, we may need to update visibility of cells not mentioned in the // dependent. If so, we may need to update visibility of cells not mentioned in the

View File

@ -22,6 +22,7 @@ import * as express from 'express';
*/ */
export interface GristServer { export interface GristServer {
readonly create: ICreate; readonly create: ICreate;
settings?: Readonly<Record<string, unknown>>;
getHost(): string; getHost(): string;
getHomeUrl(req: express.Request, relPath?: string): string; getHomeUrl(req: express.Request, relPath?: string): string;
getHomeUrlByDocId(docId: string, relPath?: string): Promise<string>; getHomeUrlByDocId(docId: string, relPath?: string): Promise<string>;

View File

@ -46,10 +46,15 @@ export interface ICreateStorageOptions {
create(purpose: 'doc'|'meta', extraPrefix: string): ExternalStorage|undefined; create(purpose: 'doc'|'meta', extraPrefix: string): ExternalStorage|undefined;
} }
export interface ICreateNotifierOptions {
create(dbManager: HomeDBManager, gristConfig: GristServer): INotifier|undefined;
}
export function makeSimpleCreator(opts: { export function makeSimpleCreator(opts: {
sessionSecret?: string, sessionSecret?: string,
storage?: ICreateStorageOptions[], storage?: ICreateStorageOptions[],
activationMiddleware?: (db: HomeDBManager, app: express.Express) => Promise<void>, activationMiddleware?: (db: HomeDBManager, app: express.Express) => Promise<void>,
notifier?: ICreateNotifierOptions,
}): ICreate { }): ICreate {
return { return {
Billing(db) { Billing(db) {
@ -63,8 +68,9 @@ export function makeSimpleCreator(opts: {
} }
}; };
}, },
Notifier() { Notifier(dbManager, gristConfig) {
return { const {notifier} = opts;
return notifier?.create(dbManager, gristConfig) ?? {
get testPending() { return false; }, get testPending() { return false; },
deleteUser() { throw new Error('deleteUser unavailable'); }, deleteUser() { throw new Error('deleteUser unavailable'); },
}; };

View File

@ -40,3 +40,10 @@ export function makeForkIds(options: { userId: number|null, isAnonymous: boolean
export function getAssignmentId(docWorkerMap: IDocWorkerMap, docId: string): string { export function getAssignmentId(docWorkerMap: IDocWorkerMap, docId: string): string {
return docId; return docId;
} }
// Get the externalId to use for an AppSumo user. AppSumo identifies users by
// an activation email, so we just use that (with an appsumo/ prefix it allow
// for other families of id in the future).
export function getExternalIdForAppSumoUser(email: string) {
return `appsumo/${email}`;
}

View File

@ -154,6 +154,7 @@ export async function createDocManager(
export function createDummyGristServer(): GristServer { export function createDummyGristServer(): GristServer {
return { return {
create, create,
settings: {},
getHost() { return 'localhost:4242'; }, getHost() { return 'localhost:4242'; },
getHomeUrl() { return 'http://localhost:4242'; }, getHomeUrl() { return 'http://localhost:4242'; },
getHomeUrlByDocId() { return Promise.resolve('http://localhost:4242'); }, getHomeUrlByDocId() { return Promise.resolve('http://localhost:4242'); },

View File

@ -22,7 +22,7 @@ async function activateServer(home: FlexServer, docManager: DocManager) {
home.addDocWorkerMap(); home.addDocWorkerMap();
home.addAccessMiddleware(); home.addAccessMiddleware();
dbManager = home.getHomeDBManager(); dbManager = home.getHomeDBManager();
await home.loadConfig({}); await home.loadConfig();
home.addSessions(); home.addSessions();
home.addHealthCheck(); home.addHealthCheck();
docManager.testSetHomeDbManager(dbManager); docManager.testSetHomeDbManager(dbManager);