diff --git a/app/common/Prefs.ts b/app/common/Prefs.ts new file mode 100644 index 00000000..adc0c640 --- /dev/null +++ b/app/common/Prefs.ts @@ -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; diff --git a/app/common/UserAPI.ts b/app/common/UserAPI.ts index 8b4218ac..fd105c80 100644 --- a/app/common/UserAPI.ts +++ b/app/common/UserAPI.ts @@ -7,6 +7,7 @@ import {DocCreationInfo} from 'app/common/DocListAPI'; import {Features} from 'app/common/Features'; import {isClient} from 'app/common/gristUrls'; import {FullUser} from 'app/common/LoginSessionAPI'; +import {OrgPrefs, UserOrgPrefs, UserPrefs} from 'app/common/Prefs'; import * as roles from 'app/common/roles'; import {addCurrentOrgToPath} from 'app/common/urlUtils'; @@ -31,8 +32,13 @@ export const commonPropertyKeys = ['createdAt', 'name', 'updatedAt']; export interface OrganizationProperties extends CommonProperties { 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 export interface OrganizationWithoutAccessInfo extends OrganizationProperties { @@ -232,6 +238,7 @@ export interface UserAPI { renameOrg(orgId: number|string, name: string): Promise; renameWorkspace(workspaceId: number, name: string): Promise; renameDoc(docId: string, name: string): Promise; + updateOrg(orgId: number|string, props: Partial): Promise; updateDoc(docId: string, props: Partial): Promise; deleteOrg(orgId: number|string): Promise; deleteWorkspace(workspaceId: number): Promise; // delete workspace permanently @@ -384,6 +391,13 @@ export class UserAPIImpl extends BaseAPI implements UserAPI { return this.updateDoc(docId, {name}); } + public async updateOrg(orgId: number|string, props: Partial): Promise { + await this.request(`${this._url}/api/orgs/${orgId}`, { + method: 'PATCH', + body: JSON.stringify(props) + }); + } + public async updateDoc(docId: string, props: Partial): Promise { await this.request(`${this._url}/api/docs/${docId}`, { method: 'PATCH', diff --git a/app/gen-server/entity/Organization.ts b/app/gen-server/entity/Organization.ts index 6edbabfb..321055bd 100644 --- a/app/gen-server/entity/Organization.ts +++ b/app/gen-server/entity/Organization.ts @@ -1,9 +1,11 @@ import {Column, Entity, JoinColumn, ManyToOne, OneToMany, OneToOne, PrimaryGeneratedColumn, RelationId} from "typeorm"; + import {Role} from "app/common/roles"; import {OrganizationProperties, organizationPropertyKeys} from "app/common/UserAPI"; import {AclRuleOrg} from "./AclRule"; import {BillingAccount} from "./BillingAccount"; +import {Pref} from "./Pref"; import {Resource} from "./Resource"; import {User} from "./User"; import {Workspace} from "./Workspace"; @@ -68,6 +70,13 @@ export class Organization extends Resource { @Column({name: 'host', type: 'text', nullable: true}) 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 { return super.checkProperties(props, organizationPropertyKeys); } diff --git a/app/gen-server/entity/Pref.ts b/app/gen-server/entity/Pref.ts new file mode 100644 index 00000000..deca87aa --- /dev/null +++ b/app/gen-server/entity/Pref.ts @@ -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; +} diff --git a/app/gen-server/lib/HomeDBManager.ts b/app/gen-server/lib/HomeDBManager.ts index 50d78a40..7fa4e185 100644 --- a/app/gen-server/lib/HomeDBManager.ts +++ b/app/gen-server/lib/HomeDBManager.ts @@ -18,6 +18,7 @@ import {Document} from "app/gen-server/entity/Document"; import {Group} from "app/gen-server/entity/Group"; import {Login} from "app/gen-server/entity/Login"; 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 {User} from "app/gen-server/entity/User"; import {Workspace} from "app/gen-server/entity/Workspace"; @@ -641,6 +642,18 @@ export class HomeDBManager extends EventEmitter { } qb = this._withAccess(qb, effectiveUserId, 'orgs'); 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); if (result.status === 200) { // Return the only org. @@ -1164,19 +1177,45 @@ export class HomeDBManager extends EventEmitter { return orgResult; } - // 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 - // query result with status 200 on success. + // If setting anything more than prefs: + // 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 + // 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( scope: Scope, orgKey: string|number, props: Partial ): Promise> { - // 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 => { const orgQuery = this.org(scope, orgKey, { manager, - markPermissions: Permissions.UPDATE + markPermissions, }); const queryResult = await verifyIsPermitted(orgQuery); if (queryResult.status !== 200) { @@ -1185,14 +1224,33 @@ export class HomeDBManager extends EventEmitter { } // Update the fields and save. const org: Organization = queryResult.data; - if (props.domain) { - if (org.owner) { - throw new ApiError('Cannot set a domain for a personal organization', 400); + org.checkProperties(props); + if (modifyOrg) { + if (props.domain) { + if (org.owner) { + throw new ApiError('Cannot set a domain for a personal organization', 400); + } + } + org.updateFromProperties(props); + 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(); } } - org.checkProperties(props); - org.updateFromProperties(props); - await manager.save(org); return {status: 200}; }); } @@ -3135,6 +3193,20 @@ export class HomeDBManager extends EventEmitter { value[key] = managers; 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') { value[key] = this._normalizeQueryResults(subValue, childOptions); continue; diff --git a/app/gen-server/migration/1596456522124-Prefs.ts b/app/gen-server/migration/1596456522124-Prefs.ts new file mode 100644 index 00000000..52c8e41b --- /dev/null +++ b/app/gen-server/migration/1596456522124-Prefs.ts @@ -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 { + 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 { + await queryRunner.dropTable('prefs'); + } +}