diff --git a/server/migrations/20220907132304-user-preferences.js b/server/migrations/20220907132304-user-preferences.js new file mode 100644 index 000000000..5fad095fe --- /dev/null +++ b/server/migrations/20220907132304-user-preferences.js @@ -0,0 +1,14 @@ +'use strict'; + +module.exports = { + async up (queryInterface, Sequelize) { + return queryInterface.addColumn("users", "preferences", { + type: Sequelize.JSONB, + allowNull: true, + }); + }, + + async down (queryInterface, Sequelize) { + return queryInterface.removeColumn("users", "preferences"); + } +}; diff --git a/server/migrations/20220907140227-team-preferences.js b/server/migrations/20220907140227-team-preferences.js new file mode 100644 index 000000000..9cd8f25fd --- /dev/null +++ b/server/migrations/20220907140227-team-preferences.js @@ -0,0 +1,14 @@ +"use strict"; + +module.exports = { + async up(queryInterface, Sequelize) { + return queryInterface.addColumn("teams", "preferences", { + type: Sequelize.JSONB, + allowNull: true, + }); + }, + + async down(queryInterface, Sequelize) { + return queryInterface.removeColumn("teams", "preferences"); + }, +}; diff --git a/server/models/Team.ts b/server/models/Team.ts index 2af250246..53a7dbf5a 100644 --- a/server/models/Team.ts +++ b/server/models/Team.ts @@ -36,6 +36,8 @@ import NotContainsUrl from "./validators/NotContainsUrl"; const readFile = util.promisify(fs.readFile); +export type TeamPreferences = Record; + @Scopes(() => ({ withDomains: { include: [{ model: TeamDomain }], @@ -124,6 +126,10 @@ class Team extends ParanoidModel { @Column defaultUserRole: string; + @AllowNull + @Column(DataType.JSONB) + preferences: TeamPreferences | null; + // getters /** diff --git a/server/models/User.ts b/server/models/User.ts index ffa69dbfc..42568300b 100644 --- a/server/models/User.ts +++ b/server/models/User.ts @@ -54,6 +54,12 @@ export enum UserRole { Viewer = "viewer", } +export enum UserPreference { + RememberLastPath = "rememberLastPath", +} + +export type UserPreferences = { [key in UserPreference]?: boolean }; + @Scopes(() => ({ withAuthentications: { include: [ @@ -152,6 +158,10 @@ class User extends ParanoidModel { @Column(DataType.JSONB) flags: { [key in UserFlag]?: number } | null; + @AllowNull + @Column(DataType.JSONB) + preferences: UserPreferences | null; + @Default(env.DEFAULT_LANGUAGE) @IsIn([languages]) @Column @@ -290,6 +300,35 @@ class User extends ParanoidModel { return this.flags; }; + /** + * Preferences set by the user that decide application behavior and ui. + * + * @param preference The user preference to set + * @param value Sets the preference value + * @returns The current user preferences + */ + public setPreference = (preference: UserPreference, value: boolean) => { + if (!this.preferences) { + this.preferences = {}; + } + this.preferences[preference] = value; + this.changed("preferences", true); + + return this.preferences; + }; + + /** + * Returns the passed preference value + * + * @param preference The user preference to retrieve + * @returns The preference value if set, else undefined + */ + public getPreference = (preference: UserPreference) => { + return !!this.preferences && this.preferences[preference] + ? this.preferences[preference] + : undefined; + }; + collectionIds = async (options = {}) => { const collectionStubs = await Collection.scope({ method: ["withMembership", this.id], @@ -575,7 +614,7 @@ class User extends ParanoidModel { static getCounts = async function (teamId: string) { const countSql = ` - SELECT + SELECT COUNT(CASE WHEN "suspendedAt" IS NOT NULL THEN 1 END) as "suspendedCount", COUNT(CASE WHEN "isAdmin" = true THEN 1 END) as "adminCount", COUNT(CASE WHEN "isViewer" = true THEN 1 END) as "viewerCount", diff --git a/server/presenters/team.ts b/server/presenters/team.ts index d48daa2f9..74748dd47 100644 --- a/server/presenters/team.ts +++ b/server/presenters/team.ts @@ -17,5 +17,6 @@ export default function present(team: Team) { defaultUserRole: team.defaultUserRole, inviteRequired: team.inviteRequired, allowedDomains: team.allowedDomains?.map((d) => d.name), + preferences: team.preferences, }; } diff --git a/server/presenters/user.ts b/server/presenters/user.ts index fdf8dbae3..03b280f62 100644 --- a/server/presenters/user.ts +++ b/server/presenters/user.ts @@ -1,5 +1,6 @@ import env from "@server/env"; import { User } from "@server/models"; +import { UserPreferences } from "@server/models/User"; type Options = { includeDetails?: boolean; @@ -18,6 +19,7 @@ type UserPresentation = { isViewer: boolean; email?: string | null; language?: string; + preferences?: UserPreferences | null; }; export default (user: User, options: Options = {}): UserPresentation => { @@ -37,6 +39,7 @@ export default (user: User, options: Options = {}): UserPresentation => { if (options.includeDetails) { userData.email = user.email; userData.language = user.language || env.DEFAULT_LANGUAGE; + userData.preferences = user.preferences; } return userData; diff --git a/server/routes/api/__snapshots__/users.test.ts.snap b/server/routes/api/__snapshots__/users.test.ts.snap index a02b0473e..9b8e275d9 100644 --- a/server/routes/api/__snapshots__/users.test.ts.snap +++ b/server/routes/api/__snapshots__/users.test.ts.snap @@ -14,6 +14,7 @@ Object { "language": "en_US", "lastActiveAt": null, "name": "User 1", + "preferences": null, "updatedAt": "2018-01-02T00:00:00.000Z", }, "ok": true, @@ -69,6 +70,7 @@ Object { "language": "en_US", "lastActiveAt": null, "name": "User 1", + "preferences": null, "updatedAt": "2018-01-02T00:00:00.000Z", }, "ok": true, @@ -106,6 +108,7 @@ Object { "language": "en_US", "lastActiveAt": null, "name": "User 1", + "preferences": null, "updatedAt": "2018-01-02T00:00:00.000Z", }, "ok": true, @@ -143,6 +146,7 @@ Object { "language": "en_US", "lastActiveAt": null, "name": "User 1", + "preferences": null, "updatedAt": "2018-01-02T00:00:00.000Z", }, "ok": true, @@ -198,6 +202,7 @@ Object { "language": "en_US", "lastActiveAt": null, "name": "User 1", + "preferences": null, "updatedAt": "2018-01-02T00:00:00.000Z", }, "ok": true, @@ -262,6 +267,7 @@ Object { "language": "en_US", "lastActiveAt": null, "name": "User 1", + "preferences": null, "updatedAt": "2018-01-02T00:00:00.000Z", }, "ok": true, diff --git a/server/routes/api/users.test.ts b/server/routes/api/users.test.ts index 1ce4d9c2c..b5d87c64f 100644 --- a/server/routes/api/users.test.ts +++ b/server/routes/api/users.test.ts @@ -393,6 +393,46 @@ describe("#users.update", () => { expect(body.data.name).toEqual("New name"); }); + it("should fail upon sending invalid user preference", async () => { + const { user } = await seed(); + const res = await server.post("/api/users.update", { + body: { + token: user.getJwtToken(), + name: "New name", + preferences: { invalidPreference: "invalidValue" }, + }, + }); + expect(res.status).toEqual(400); + }); + + it("should fail upon sending invalid user preference value", async () => { + const { user } = await seed(); + const res = await server.post("/api/users.update", { + body: { + token: user.getJwtToken(), + name: "New name", + preferences: { rememberLastPath: "invalidValue" }, + }, + }); + expect(res.status).toEqual(400); + }); + + it("should update rememberLastPath user preference", async () => { + const { user } = await seed(); + const res = await server.post("/api/users.update", { + body: { + token: user.getJwtToken(), + name: "New name", + preferences: { + rememberLastPath: true, + }, + }, + }); + const body = await res.json(); + expect(res.status).toEqual(200); + expect(body.data.preferences.rememberLastPath).toBe(true); + }); + it("should require authentication", async () => { const res = await server.post("/api/users.update"); const body = await res.json(); diff --git a/server/routes/api/users.ts b/server/routes/api/users.ts index 4e6b934f0..5cba9404e 100644 --- a/server/routes/api/users.ts +++ b/server/routes/api/users.ts @@ -17,7 +17,7 @@ import logger from "@server/logging/Logger"; import auth from "@server/middlewares/authentication"; import { rateLimiter } from "@server/middlewares/rateLimiter"; import { Event, User, Team } from "@server/models"; -import { UserFlag, UserRole } from "@server/models/User"; +import { UserFlag, UserRole, UserPreference } from "@server/models/User"; import { can, authorize } from "@server/policies"; import { presentUser, presentPolicies } from "@server/presenters"; import { @@ -26,6 +26,8 @@ import { assertPresent, assertArray, assertUuid, + assertKeysIn, + assertBoolean, } from "@server/validation"; import pagination from "./middlewares/pagination"; @@ -174,7 +176,7 @@ router.post("users.info", auth(), async (ctx) => { router.post("users.update", auth(), async (ctx) => { const { user } = ctx.state; - const { name, avatarUrl, language } = ctx.body; + const { name, avatarUrl, language, preferences } = ctx.body; if (name) { user.name = name; } @@ -184,6 +186,16 @@ router.post("users.update", auth(), async (ctx) => { if (language) { user.language = language; } + if (preferences) { + assertKeysIn(preferences, UserPreference); + if (preferences.rememberLastPath) { + assertBoolean(preferences.rememberLastPath); + user.setPreference( + UserPreference.RememberLastPath, + preferences.rememberLastPath + ); + } + } await user.save(); await Event.create({ name: "users.update", diff --git a/server/validation.ts b/server/validation.ts index 57f4f2358..70acd1b21 100644 --- a/server/validation.ts +++ b/server/validation.ts @@ -33,6 +33,21 @@ export const assertIn = ( } }; +/** + * Asserts that an object contains no other keys than specified + * by a type + * + * @param obj The object to check for assertion + * @param type The type to check against + * @throws {ValidationError} + */ +export function assertKeysIn( + obj: Record, + type: { [key: string]: number | string } +) { + Object.keys(obj).forEach((key) => assertIn(key, Object.values(type))); +} + export const assertSort = ( value: string, model: any, @@ -78,6 +93,24 @@ export function assertUrl( } } +/** + * Asserts that the passed value is a valid boolean + * + * @param value The value to check for assertion + * @param [message] The error message to show + * @throws {ValidationError} + */ +export function assertBoolean( + value: IncomingValue, + message?: string +): asserts value { + if (typeof value !== "boolean") { + throw ValidationError( + message ?? `${String(value)} is a ${typeof value}, not a boolean!` + ); + } +} + export function assertUuid( value: IncomingValue, message?: string