Skip to content

Commit

Permalink
out with the Actees, in with explicit site/project preferences
Browse files Browse the repository at this point in the history
  • Loading branch information
brontolosone committed Sep 13, 2024
1 parent bd62997 commit 817eaca
Show file tree
Hide file tree
Showing 5 changed files with 153 additions and 55 deletions.
27 changes: 19 additions & 8 deletions lib/model/migrations/20240910-01-add-user_preferences.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,28 @@
// except according to the terms contained in the LICENSE file.

const up = (db) => db.raw(`
CREATE TABLE "user_preferences" (
"userId" integer NOT NULL REFERENCES users ("actorId"),
"acteeId" varchar(36) NOT NULL REFERENCES actees ("id"),
"propertyName" text NOT NULL CHECK (length("propertyName") > 0),
"propertyValue" jsonb NOT NULL,
CONSTRAINT "primary key" PRIMARY KEY ("userId", "acteeId", "propertyName")
CREATE TABLE user_site_preferences (
"userId" integer NOT NULL REFERENCES users ("actorId"),
"propertyName" text NOT NULL CHECK (length("propertyName") > 0),
"propertyValue" jsonb NOT NULL,
CONSTRAINT "user_site_preferences_primary_key" PRIMARY KEY ("userId", "propertyName")
);
CREATE INDEX ON "user_preferences" ("userId"); -- Primary key index is used for PUTing/DELETE-ing individual rows, but this index is used when aggregating all of a user's preferences.
CREATE TABLE user_project_preferences (
"userId" integer NOT NULL REFERENCES users ("actorId"),
"projectId" integer NOT NULL REFERENCES projects ("id"),
"propertyName" text NOT NULL CHECK (length("propertyName") > 0),
"propertyValue" jsonb NOT NULL,
CONSTRAINT "user_project_preferences_primary_key" PRIMARY KEY ("userId", "projectId", "propertyName")
);
-- Primary key indices are used for PUTing/DELETE-ing individual rows — but the below indices are
-- used when aggregating all of a user's preferences.
CREATE INDEX ON "user_site_preferences" ("userId");
CREATE INDEX ON "user_project_preferences" ("userId");
`);

const down = (db) => db.schema.dropTable('user_preferences');
const down = (db) => Promise.all([db.schema.dropTable('user_site_preferences'), db.schema.dropTable('user_project_preferences')]);

module.exports = { up, down };

130 changes: 94 additions & 36 deletions lib/model/query/user-preferences.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,51 +9,109 @@

const { sql } = require('slonik');

const remove = (userId, acteeId, propertyName) => ({ maybeOne }) =>
maybeOne(sql`
DELETE FROM
"user_preferences"
WHERE
("userId", "acteeId", "propertyName")
=
(${userId}, ${acteeId}, ${propertyName})
RETURNING
1 AS "delcnt"

const getForUser = (userId) => ({ one }) =>
one(sql`
SELECT
(
SELECT
jsonb_build_object(
'projects',
coalesce(
jsonb_object_agg(
projprops."projectId",
projprops.props
),
jsonb_build_object()
)
)
FROM
(
SELECT
"projectId",
jsonb_object_agg("propertyName", "propertyValue") AS props
FROM
user_project_preferences
WHERE
"userId" = ${userId}
GROUP BY
"projectId"
) AS projprops
)
||
(
SELECT
jsonb_build_object(
'site',
coalesce(
jsonb_object_agg(
user_site_preferences."propertyName",
user_site_preferences."propertyValue"
),
jsonb_build_object()
)
)
FROM
user_site_preferences
WHERE
"userId" = ${userId}
)
AS preferences
`);


const put = (userId, acteeId, propertyName, propertyValue) => ({ one }) =>
one(sql`
INSERT INTO "user_preferences"
("userId", "acteeId", "propertyName", "propertyValue")
VALUES
(${userId}, ${acteeId}, ${propertyName}, ${propertyValue})
ON CONFLICT ON CONSTRAINT "primary key"
const _writeProperty = (tablename, subject, userId, propertyName, propertyValue) => ({ one }) => {
const targetColumns = ['userId', 'propertyName', 'propertyValue']
.concat((subject === null) ? [] : ['projectId'])
.map(el => sql.identifier([el]));

const values = [userId, propertyName, sql.json(propertyValue)]
.concat((subject === null) ? [] : [subject]);

return one(sql`
INSERT INTO ${sql.identifier([tablename])}
(${sql.join(targetColumns, `, `)})
VALUES
(${sql.join(values, `, `)})
ON CONFLICT ON CONSTRAINT ${sql.identifier([`${tablename}_primary_key`])}
DO UPDATE
SET "propertyValue" = ${propertyValue}
SET "propertyValue" = ${sql.json(propertyValue)}
RETURNING
1 AS "modified_count"
`);
};


const getForUser = (userId) => ({ maybeOne }) =>
maybeOne(sql`
WITH "props" AS (
SELECT
"acteeId",
jsonb_object_agg("propertyName", "propertyValue") AS "acteeprops"
FROM
"user_preferences"
WHERE
"userId" = ${userId}
GROUP BY
"acteeId"
)
SELECT
coalesce(jsonb_object_agg("acteeId", "acteeprops"), jsonb_build_object()) AS "preferences"
FROM
"props"
const _removeProperty = (tablename, subject, userId, propertyName) => ({ maybeOne }) => {
const targetColumns = ['userId', 'propertyName']
.concat((subject === null) ? [] : ['projectId'])
.map(el => sql.identifier([el]));

const values = [userId, propertyName]
.concat((subject === null) ? [] : [subject]);

return maybeOne(sql`
DELETE FROM ${sql.identifier([tablename])}
WHERE
(${sql.join(targetColumns, `, `)})
=
(${sql.join(values, `, `)})
RETURNING
1 AS "delcnt"
`);
};


const writeSiteProperty = (userId, propertyName, propertyValue) => ({ one }) =>
_writeProperty('user_site_preferences', null, userId, propertyName, propertyValue)({ one });

const removeSiteProperty = (userId, propertyName) => ({ maybeOne }) =>
_removeProperty('user_site_preferences', null, userId, propertyName)({ maybeOne });

const writeProjectProperty = (userId, projectId, propertyName, propertyValue) => ({ one }) =>
_writeProperty('user_project_preferences', projectId, userId, propertyName, propertyValue)({ one });

const removeProjectProperty = (userId, projectId, propertyName) => ({ maybeOne }) =>
_removeProperty('user_project_preferences', projectId, userId, propertyName)({ maybeOne });

module.exports = { remove, put, getForUser };
module.exports = { removeSiteProperty, writeSiteProperty, writeProjectProperty, removeProjectProperty, getForUser };
6 changes: 5 additions & 1 deletion lib/model/query/users.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
const { sql } = require('slonik');
const { map } = require('ramda');
const { Actor, User } = require('../frames');
const { getForUser } = require('./user-preferences');
const { hashPassword } = require('../../util/crypto');
const { unjoiner, page, equals, QueryOptions } = require('../../util/db');
const { reject } = require('../../util/promise');
Expand Down Expand Up @@ -86,10 +87,13 @@ const emailEverExisted = (email) => ({ maybeOne }) =>
maybeOne(sql`select true from users where email=${email} limit 1`)
.then((user) => user.isDefined());

const getPreferences = (userId) => ({ one }) => getForUser(userId)({ one });

module.exports = {
create, update,
updatePassword, invalidatePassword, provisionPasswordResetToken,
getAll, getByEmail, getByActorId,
emailEverExisted
emailEverExisted,
getPreferences,
};

42 changes: 33 additions & 9 deletions lib/resources/user-preferences.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,24 +16,48 @@ module.exports = (service, endpoint) => {
//////////////////////////////////////////////////////////////////////////////
// User preferences (UI settings)

service.delete('/user-preferences/:acteeId/:propertyName', endpoint.simple(({ UserPreferences }, { auth, params }) => {
//////////////////////////////////////////////////////////////////////////////
// Endpoint to get all of a user's preferences.
// For completeness and ease of debugging; as these preferences are normally
// pulled in by the frontend through the extended version of /users/current.
service.get('/user-preferences', endpoint.simple(({ UserPreferences }, { auth }) => {
if (auth.actor.value === undefined) return Problem.user.insufficientRights();
return UserPreferences.remove(auth.actor.value.id, params.acteeId, params.propertyName)
.then(getOrNotFound)
.then(always({ status: 204 }));
return UserPreferences.getForUser(auth.actor.value.id)
.then(res => ({ headers: { 'Content-Type': 'application/json; charset=utf-8' }, body: res.preferences }));
}));

service.put('/user-preferences/:acteeId/:propertyName', endpoint.simple(({ UserPreferences }, { body, auth, params }) => {
//////////////
// Per-project
service.put('/user-preferences/project/:projectId/:propertyName', endpoint.simple(({ UserPreferences }, { body, auth, params }) => {
// Expects a body of {"propertyValue": X}, where X will go into the propertyValue column.
if (body.propertyValue === undefined) return Problem.user.propertyNotFound({ property: 'propertyValue' });
if (auth.actor.value === undefined) return Problem.user.insufficientRights();
return UserPreferences.put(auth.actor.value.id, params.acteeId, params.propertyName, body.propertyValue)
return UserPreferences.writeProjectProperty(auth.actor.value.id, params.projectId, params.propertyName, body.propertyValue)
.then(always({ status: 201 }));
}));

service.get('/user-preferences', endpoint.simple(({ UserPreferences }, { auth }) => {
service.delete('/user-preferences/project/:projectId/:propertyName', endpoint.simple(({ UserPreferences }, { auth, params }) => {
if (auth.actor.value === undefined) return Problem.user.insufficientRights();
return UserPreferences.getForUser(auth.actor.value.id)
.then(res => ({ headers: { 'Content-Type': 'application/json; charset=utf-8' }, body: res.value.preferences }));
return UserPreferences.removeProjectProperty(auth.actor.value.id, params.projectId, params.propertyName)
.then(getOrNotFound)
.then(always({ status: 204 }));
}));

///////////
// Sitewide
service.delete('/user-preferences/site/:propertyName', endpoint.simple(({ UserPreferences }, { auth, params }) => {
if (auth.actor.value === undefined) return Problem.user.insufficientRights();
return UserPreferences.removeSiteProperty(auth.actor.value.id, params.propertyName)
.then(getOrNotFound)
.then(always({ status: 204 }));
}));

service.put('/user-preferences/site/:propertyName', endpoint.simple(({ UserPreferences }, { body, auth, params }) => {
// Expects a body of {"propertyValue": X}, where X will go into the propertyValue column.
if (body.propertyValue === undefined) return Problem.user.propertyNotFound({ property: 'propertyValue' });
if (auth.actor.value === undefined) return Problem.user.insufficientRights();
return UserPreferences.writeSiteProperty(auth.actor.value.id, params.propertyName, body.propertyValue)
.then(always({ status: 201 }));
}));

};
3 changes: 2 additions & 1 deletion lib/resources/users.js
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,8 @@ module.exports = (service, endpoint) => {
auth.actor.map((actor) =>
((queryOptions.extended === true)
? Promise.all([ Users.getByActorId(actor.id).then(getOrNotFound), Auth.verbsOn(actor.id, '*') ])
.then(([ user, verbs ]) => Object.assign({ verbs }, user.forApi()))
.then(([ user, verbs ]) => Users.getPreferences(user.actorId)
.then((preferences) => Object.assign(Object.assign({ verbs }, user.forApi()), preferences)))
: Users.getByActorId(actor.id).then(getOrNotFound)))
.orElse(Problem.user.notFound())));

Expand Down

0 comments on commit 817eaca

Please sign in to comment.