(core) add per-user per-org preferences to database

Summary:
Adds preferences to orgs.  There are a few flavors:
 * `userOrgPrefs`: these are specific to a certain user and a certain org.
 * `orgPrefs`: these are specific to a certain org, and apply to all users.
 * `userPrefs`: these are specific to a certain user, and apply to all orgs.

The three flavors of prefs are reported by `GET` for an org, and can be modified by `PATCH` for an org.  The user needs to have UPDATE rights to change `orgPrefs`, but can change `userOrgPrefs` and `userPrefs` without that right since the settings only affect themselves.

Test Plan: added tests

Reviewers: dsagal

Reviewed By: dsagal

Differential Revision: https://phab.getgrist.com/D2572
This commit is contained in:
Paul Fitzpatrick 2020-08-04 11:52:56 -04:00
parent 30866c6c95
commit 6b24d496db
6 changed files with 209 additions and 12 deletions

9
app/common/Prefs.ts Normal file
View File

@ -0,0 +1,9 @@
// A collection of preferences related to a user or org (or combination).
export interface Prefs {
// TODO replace this with real preferences.
placeholder?: string;
}
export type UserPrefs = Prefs;
export type UserOrgPrefs = Prefs;
export type OrgPrefs = Prefs;

View File

@ -7,6 +7,7 @@ import {DocCreationInfo} from 'app/common/DocListAPI';
import {Features} from 'app/common/Features'; import {Features} from 'app/common/Features';
import {isClient} from 'app/common/gristUrls'; import {isClient} from 'app/common/gristUrls';
import {FullUser} from 'app/common/LoginSessionAPI'; import {FullUser} from 'app/common/LoginSessionAPI';
import {OrgPrefs, UserOrgPrefs, UserPrefs} from 'app/common/Prefs';
import * as roles from 'app/common/roles'; import * as roles from 'app/common/roles';
import {addCurrentOrgToPath} from 'app/common/urlUtils'; import {addCurrentOrgToPath} from 'app/common/urlUtils';
@ -31,8 +32,13 @@ export const commonPropertyKeys = ['createdAt', 'name', 'updatedAt'];
export interface OrganizationProperties extends CommonProperties { export interface OrganizationProperties extends CommonProperties {
domain: string|null; domain: string|null;
// Organization includes preferences relevant to interacting with its content.
userOrgPrefs?: UserOrgPrefs; // Preferences specific to user and org
orgPrefs?: OrgPrefs; // Preferences specific to org (but not a particular user)
userPrefs?: UserPrefs; // Preferences specific to user (but not a particular org)
} }
export const organizationPropertyKeys = [...commonPropertyKeys, 'domain']; export const organizationPropertyKeys = [...commonPropertyKeys, 'domain',
'orgPrefs', 'userOrgPrefs', 'userPrefs'];
// Basic information about an organization, excluding the user's access level // Basic information about an organization, excluding the user's access level
export interface OrganizationWithoutAccessInfo extends OrganizationProperties { export interface OrganizationWithoutAccessInfo extends OrganizationProperties {
@ -232,6 +238,7 @@ export interface UserAPI {
renameOrg(orgId: number|string, name: string): Promise<void>; renameOrg(orgId: number|string, name: string): Promise<void>;
renameWorkspace(workspaceId: number, name: string): Promise<void>; renameWorkspace(workspaceId: number, name: string): Promise<void>;
renameDoc(docId: string, name: string): Promise<void>; renameDoc(docId: string, name: string): Promise<void>;
updateOrg(orgId: number|string, props: Partial<OrganizationProperties>): Promise<void>;
updateDoc(docId: string, props: Partial<DocumentProperties>): Promise<void>; updateDoc(docId: string, props: Partial<DocumentProperties>): Promise<void>;
deleteOrg(orgId: number|string): Promise<void>; deleteOrg(orgId: number|string): Promise<void>;
deleteWorkspace(workspaceId: number): Promise<void>; // delete workspace permanently deleteWorkspace(workspaceId: number): Promise<void>; // delete workspace permanently
@ -384,6 +391,13 @@ export class UserAPIImpl extends BaseAPI implements UserAPI {
return this.updateDoc(docId, {name}); return this.updateDoc(docId, {name});
} }
public async updateOrg(orgId: number|string, props: Partial<OrganizationProperties>): Promise<void> {
await this.request(`${this._url}/api/orgs/${orgId}`, {
method: 'PATCH',
body: JSON.stringify(props)
});
}
public async updateDoc(docId: string, props: Partial<DocumentProperties>): Promise<void> { public async updateDoc(docId: string, props: Partial<DocumentProperties>): Promise<void> {
await this.request(`${this._url}/api/docs/${docId}`, { await this.request(`${this._url}/api/docs/${docId}`, {
method: 'PATCH', method: 'PATCH',

View File

@ -1,9 +1,11 @@
import {Column, Entity, JoinColumn, ManyToOne, OneToMany, OneToOne, import {Column, Entity, JoinColumn, ManyToOne, OneToMany, OneToOne,
PrimaryGeneratedColumn, RelationId} from "typeorm"; PrimaryGeneratedColumn, RelationId} from "typeorm";
import {Role} from "app/common/roles"; import {Role} from "app/common/roles";
import {OrganizationProperties, organizationPropertyKeys} from "app/common/UserAPI"; import {OrganizationProperties, organizationPropertyKeys} from "app/common/UserAPI";
import {AclRuleOrg} from "./AclRule"; import {AclRuleOrg} from "./AclRule";
import {BillingAccount} from "./BillingAccount"; import {BillingAccount} from "./BillingAccount";
import {Pref} from "./Pref";
import {Resource} from "./Resource"; import {Resource} from "./Resource";
import {User} from "./User"; import {User} from "./User";
import {Workspace} from "./Workspace"; import {Workspace} from "./Workspace";
@ -68,6 +70,13 @@ export class Organization extends Resource {
@Column({name: 'host', type: 'text', nullable: true}) @Column({name: 'host', type: 'text', nullable: true})
public host: string|null; public host: string|null;
// Any prefs relevant to the org and user. This relation is marked to not result
// in saves, since OneToMany saves in TypeORM are not reliable - see e.g. later
// parts of this issue:
// https://github.com/typeorm/typeorm/issues/3095
@OneToMany(type => Pref, pref => pref.org, {persistence: false})
public prefs?: Pref[];
public checkProperties(props: any): props is Partial<OrganizationProperties> { public checkProperties(props: any): props is Partial<OrganizationProperties> {
return super.checkProperties(props, organizationPropertyKeys); return super.checkProperties(props, organizationPropertyKeys);
} }

View File

@ -0,0 +1,31 @@
import {Prefs} from 'app/common/Prefs';
import {Organization} from 'app/gen-server/entity/Organization';
import {User} from 'app/gen-server/entity/User';
import {nativeValues} from 'app/gen-server/lib/values';
import {Column, Entity, JoinColumn, ManyToOne, PrimaryColumn} from 'typeorm';
@Entity({name: 'prefs'})
export class Pref {
// This table may refer to users and/or orgs.
// We pretend userId/orgId are the primary key since TypeORM insists on having
// one, but we haven't marked them as so in the DB since the SQL standard frowns
// on nullable primary keys (and Postgres doesn't support them). We could add
// another primary key, but we don't actually need one.
@PrimaryColumn({name: 'user_id'})
public userId: number|null;
@PrimaryColumn({name: 'org_id'})
public orgId: number|null;
@ManyToOne(type => User)
@JoinColumn({name: 'user_id'})
public user?: User;
@ManyToOne(type => Organization)
@JoinColumn({name: 'org_id'})
public org?: Organization;
// Finally, the actual preferences, in JSON.
@Column({type: nativeValues.jsonEntityType})
public prefs: Prefs;
}

View File

@ -18,6 +18,7 @@ import {Document} from "app/gen-server/entity/Document";
import {Group} from "app/gen-server/entity/Group"; import {Group} from "app/gen-server/entity/Group";
import {Login} from "app/gen-server/entity/Login"; import {Login} from "app/gen-server/entity/Login";
import {AccessOption, AccessOptionWithRole, Organization} from "app/gen-server/entity/Organization"; import {AccessOption, AccessOptionWithRole, Organization} from "app/gen-server/entity/Organization";
import {Pref} from "app/gen-server/entity/Pref";
import {getDefaultProductNames, Product, starterFeatures} from "app/gen-server/entity/Product"; import {getDefaultProductNames, Product, starterFeatures} from "app/gen-server/entity/Product";
import {User} from "app/gen-server/entity/User"; import {User} from "app/gen-server/entity/User";
import {Workspace} from "app/gen-server/entity/Workspace"; import {Workspace} from "app/gen-server/entity/Workspace";
@ -641,6 +642,18 @@ export class HomeDBManager extends EventEmitter {
} }
qb = this._withAccess(qb, effectiveUserId, 'orgs'); qb = this._withAccess(qb, effectiveUserId, 'orgs');
qb = qb.leftJoinAndSelect('orgs.owner', 'owner'); qb = qb.leftJoinAndSelect('orgs.owner', 'owner');
// Add preference information that will be relevant for presentation of the org.
// That includes preference information specific to the site and the user,
// or specific just to the site, or specific just to the user.
qb = qb.leftJoinAndMapMany('orgs.prefs', Pref, 'prefs',
'(prefs.org_id = orgs.id or prefs.org_id IS NULL) AND ' +
'(prefs.user_id = :userId or prefs.user_id IS NULL)',
{userId});
// Apply a particular order (user+org first if present, then org, then user).
// Slightly round-about syntax because Sqlite and Postgres disagree about NULL
// ordering (Sqlite does support NULL LAST syntax now, but not on our fork yet).
qb = qb.addOrderBy('coalesce(prefs.org_id, 0)', 'DESC');
qb = qb.addOrderBy('coalesce(prefs.user_id, 0)', 'DESC');
const result = await this._verifyAclPermissions(qb); const result = await this._verifyAclPermissions(qb);
if (result.status === 200) { if (result.status === 200) {
// Return the only org. // Return the only org.
@ -1164,19 +1177,45 @@ export class HomeDBManager extends EventEmitter {
return orgResult; return orgResult;
} }
// If setting anything more than prefs:
// Checks that the user has UPDATE permissions to the given org. If not, throws an // Checks that the user has UPDATE permissions to the given org. If not, throws an
// error. Otherwise updates the given org with the given name. Returns an empty // error. Otherwise updates the given org with the given name. Returns an empty
// query result with status 200 on success. // query result with status 200 on success.
// For setting userPrefs or userOrgPrefs:
// These are user-specific setting, so are allowed with VIEW access (that includes
// guests). Prefs are replaced in their entirety, not merged.
// For setting orgPrefs:
// These are not user-specific, so require UPDATE permissions.
public async updateOrg( public async updateOrg(
scope: Scope, scope: Scope,
orgKey: string|number, orgKey: string|number,
props: Partial<OrganizationProperties> props: Partial<OrganizationProperties>
): Promise<QueryResult<number>> { ): Promise<QueryResult<number>> {
// TODO: Unsetting a domain will likely have to be supported.
// Check the scope of the modifications.
let markPermissions: number = Permissions.VIEW;
let modifyOrg: boolean = false;
let modifyPrefs: boolean = false;
for (const key of Object.keys(props)) {
if (key === 'orgPrefs') {
// If setting orgPrefs, make sure we have UPDATE rights since this
// will affect other users.
markPermissions = Permissions.UPDATE;
modifyPrefs = true;
} else if (key === 'userPrefs' || key === 'userOrgPrefs') {
// These keys only affect the current user.
modifyPrefs = true;
} else {
markPermissions = Permissions.UPDATE;
modifyOrg = true;
}
}
// TODO: Unsetting a domain will likely have to be supported; also possibly prefs.
return await this._connection.transaction(async manager => { return await this._connection.transaction(async manager => {
const orgQuery = this.org(scope, orgKey, { const orgQuery = this.org(scope, orgKey, {
manager, manager,
markPermissions: Permissions.UPDATE markPermissions,
}); });
const queryResult = await verifyIsPermitted(orgQuery); const queryResult = await verifyIsPermitted(orgQuery);
if (queryResult.status !== 200) { if (queryResult.status !== 200) {
@ -1185,14 +1224,33 @@ export class HomeDBManager extends EventEmitter {
} }
// Update the fields and save. // Update the fields and save.
const org: Organization = queryResult.data; const org: Organization = queryResult.data;
org.checkProperties(props);
if (modifyOrg) {
if (props.domain) { if (props.domain) {
if (org.owner) { if (org.owner) {
throw new ApiError('Cannot set a domain for a personal organization', 400); throw new ApiError('Cannot set a domain for a personal organization', 400);
} }
} }
org.checkProperties(props);
org.updateFromProperties(props); org.updateFromProperties(props);
await manager.save(org); await manager.save(org);
}
if (modifyPrefs) {
for (const flavor of ['orgPrefs', 'userOrgPrefs', 'userPrefs'] as const) {
const prefs = props[flavor];
if (prefs === undefined) { continue; }
const orgId = ['orgPrefs', 'userOrgPrefs'].includes(flavor) ? org.id : null;
const userId = ['userOrgPrefs', 'userPrefs'].includes(flavor) ? scope.userId : null;
await manager.createQueryBuilder()
.insert()
// if pref flavor has been set before, update it
.onConflict('(COALESCE(org_id,0), COALESCE(user_id,0)) DO UPDATE SET prefs = :prefs')
// TypeORM muddles JSON handling a bit here
.setParameters({prefs: JSON.stringify(prefs)})
.into(Pref)
.values({orgId, userId, prefs})
.execute();
}
}
return {status: 200}; return {status: 200};
}); });
} }
@ -3135,6 +3193,20 @@ export class HomeDBManager extends EventEmitter {
value[key] = managers; value[key] = managers;
continue; continue;
} }
if (key === 'prefs' && Array.isArray(subValue)) {
delete value[key];
const prefs = this._normalizeQueryResults(subValue, childOptions);
for (const pref of prefs) {
if (pref.orgId && pref.userId) {
value['userOrgPrefs'] = pref.prefs;
} else if (pref.orgId) {
value['orgPrefs'] = pref.prefs;
} else if (pref.userId) {
value['userPrefs'] = pref.prefs;
}
}
continue;
}
if (key !== 'permissions') { if (key !== 'permissions') {
value[key] = this._normalizeQueryResults(subValue, childOptions); value[key] = this._normalizeQueryResults(subValue, childOptions);
continue; continue;

View File

@ -0,0 +1,62 @@
import {nativeValues} from 'app/gen-server/lib/values';
import {MigrationInterface, QueryRunner, Table} from 'typeorm';
export class Prefs1596456522124 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<any> {
await queryRunner.createTable(new Table({
name: 'prefs',
columns: [
{
name: 'org_id',
type: 'integer',
isNullable: true,
},
{
name: 'user_id',
type: 'integer',
isNullable: true,
},
{
name: 'prefs',
type: nativeValues.jsonType
}
],
foreignKeys: [
{
columnNames: ['org_id'],
referencedColumnNames: ['id'],
referencedTableName: 'orgs',
onDelete: 'CASCADE' // delete pref if linked to org that is deleted
},
{
columnNames: ['user_id'],
referencedColumnNames: ['id'],
referencedTableName: 'users',
onDelete: 'CASCADE' // delete pref if linked to user that is deleted
},
],
indices: [
{ columnNames: ['org_id', 'user_id'] },
{ columnNames: ['user_id'] },
],
checks: [
// Make sure pref refers to something, either a user or an org or both
{
columnNames: ['user_id', 'org_id'],
expression: 'COALESCE(user_id, org_id) IS NOT NULL'
},
]
}));
// Having trouble convincing TypeORM to create an index on expressions.
// Luckily, the SQL is identical for Sqlite and Postgres:
await queryRunner.manager.query(
'CREATE UNIQUE INDEX "prefs__user_id__org_id" ON "prefs" ' +
'(COALESCE(user_id,0), COALESCE(org_id,0))'
);
}
public async down(queryRunner: QueryRunner): Promise<any> {
await queryRunner.dropTable('prefs');
}
}