@@ -1,6 +1,25 @@
|
||||
import crypto from "crypto";
|
||||
import { addMinutes, subMinutes } from "date-fns";
|
||||
import JWT from "jsonwebtoken";
|
||||
import { Transaction, QueryTypes, FindOptions, Op } from "sequelize";
|
||||
import {
|
||||
Table,
|
||||
Column,
|
||||
IsIP,
|
||||
IsEmail,
|
||||
HasOne,
|
||||
Default,
|
||||
IsIn,
|
||||
BeforeDestroy,
|
||||
BeforeSave,
|
||||
BeforeCreate,
|
||||
AfterCreate,
|
||||
BelongsTo,
|
||||
ForeignKey,
|
||||
DataType,
|
||||
HasMany,
|
||||
Scopes,
|
||||
} from "sequelize-typescript";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { languages } from "@shared/i18n";
|
||||
import Logger from "@server/logging/logger";
|
||||
@@ -8,427 +27,441 @@ import { DEFAULT_AVATAR_HOST } from "@server/utils/avatars";
|
||||
import { palette } from "@server/utils/color";
|
||||
import { publicS3Endpoint, uploadToS3FromUrl } from "@server/utils/s3";
|
||||
import { ValidationError } from "../errors";
|
||||
import { DataTypes, sequelize, encryptedFields, Op } from "../sequelize";
|
||||
import {
|
||||
UserAuthentication,
|
||||
Star,
|
||||
Collection,
|
||||
NotificationSetting,
|
||||
ApiKey,
|
||||
} from ".";
|
||||
import ApiKey from "./ApiKey";
|
||||
import Collection from "./Collection";
|
||||
import NotificationSetting from "./NotificationSetting";
|
||||
import Star from "./Star";
|
||||
import Team from "./Team";
|
||||
import UserAuthentication from "./UserAuthentication";
|
||||
import ParanoidModel from "./base/ParanoidModel";
|
||||
import Encrypted, {
|
||||
setEncryptedColumn,
|
||||
getEncryptedColumn,
|
||||
} from "./decorators/Encrypted";
|
||||
import Fix from "./decorators/Fix";
|
||||
|
||||
const User = sequelize.define(
|
||||
"user",
|
||||
{
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true,
|
||||
},
|
||||
email: {
|
||||
type: DataTypes.STRING,
|
||||
},
|
||||
username: {
|
||||
type: DataTypes.STRING,
|
||||
},
|
||||
name: DataTypes.STRING,
|
||||
avatarUrl: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true,
|
||||
},
|
||||
isAdmin: DataTypes.BOOLEAN,
|
||||
isViewer: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
defaultValue: false,
|
||||
allowNull: false,
|
||||
},
|
||||
service: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true,
|
||||
},
|
||||
serviceId: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true,
|
||||
unique: true,
|
||||
},
|
||||
jwtSecret: encryptedFields().vault("jwtSecret"),
|
||||
lastActiveAt: DataTypes.DATE,
|
||||
lastActiveIp: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true,
|
||||
},
|
||||
lastSignedInAt: DataTypes.DATE,
|
||||
lastSignedInIp: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true,
|
||||
},
|
||||
lastSigninEmailSentAt: DataTypes.DATE,
|
||||
suspendedAt: DataTypes.DATE,
|
||||
suspendedById: DataTypes.UUID,
|
||||
language: {
|
||||
type: DataTypes.STRING,
|
||||
defaultValue: process.env.DEFAULT_LANGUAGE,
|
||||
validate: {
|
||||
isIn: [languages],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
paranoid: true,
|
||||
getterMethods: {
|
||||
isSuspended() {
|
||||
return !!this.suspendedAt;
|
||||
},
|
||||
|
||||
isInvited() {
|
||||
return !this.lastActiveAt;
|
||||
},
|
||||
|
||||
avatarUrl() {
|
||||
const original = this.getDataValue("avatarUrl");
|
||||
|
||||
if (original) {
|
||||
return original;
|
||||
}
|
||||
|
||||
const color = this.color.replace(/^#/, "");
|
||||
const initial = this.name ? this.name[0] : "?";
|
||||
const hash = crypto
|
||||
.createHash("md5")
|
||||
.update(this.email || "")
|
||||
.digest("hex");
|
||||
return `${DEFAULT_AVATAR_HOST}/avatar/${hash}/${initial}.png?c=${color}`;
|
||||
},
|
||||
|
||||
color() {
|
||||
const idAsHex = crypto.createHash("md5").update(this.id).digest("hex");
|
||||
const idAsNumber = parseInt(idAsHex, 16);
|
||||
return palette[idAsNumber % palette.length];
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
// Class methods
|
||||
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'models' implicitly has an 'any' type.
|
||||
User.associate = (models) => {
|
||||
User.hasMany(models.ApiKey, {
|
||||
as: "apiKeys",
|
||||
onDelete: "cascade",
|
||||
});
|
||||
User.hasMany(models.NotificationSetting, {
|
||||
as: "notificationSettings",
|
||||
onDelete: "cascade",
|
||||
});
|
||||
User.hasMany(models.Document, {
|
||||
as: "documents",
|
||||
});
|
||||
User.hasMany(models.View, {
|
||||
as: "views",
|
||||
});
|
||||
User.hasMany(models.UserAuthentication, {
|
||||
as: "authentications",
|
||||
});
|
||||
User.belongsTo(models.Team);
|
||||
User.addScope("withAuthentications", {
|
||||
@Scopes(() => ({
|
||||
withAuthentications: {
|
||||
include: [
|
||||
{
|
||||
model: models.UserAuthentication,
|
||||
model: UserAuthentication,
|
||||
as: "authentications",
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
},
|
||||
}))
|
||||
@Table({ tableName: "users", modelName: "user" })
|
||||
@Fix
|
||||
class User extends ParanoidModel {
|
||||
@IsEmail
|
||||
@Column
|
||||
email: string | null;
|
||||
|
||||
// Instance methods
|
||||
User.prototype.collectionIds = async function (options = {}) {
|
||||
const collectionStubs = await Collection.scope({
|
||||
method: ["withMembership", this.id],
|
||||
}).findAll({
|
||||
attributes: ["id", "permission"],
|
||||
where: {
|
||||
teamId: this.teamId,
|
||||
},
|
||||
paranoid: true,
|
||||
...options,
|
||||
});
|
||||
return (
|
||||
collectionStubs
|
||||
@Column
|
||||
username: string | null;
|
||||
|
||||
@Column
|
||||
name: string;
|
||||
|
||||
@Default(false)
|
||||
@Column
|
||||
isAdmin: boolean;
|
||||
|
||||
@Default(false)
|
||||
@Column
|
||||
isViewer: boolean;
|
||||
|
||||
@Column(DataType.BLOB)
|
||||
@Encrypted
|
||||
get jwtSecret() {
|
||||
return getEncryptedColumn(this, "jwtSecret");
|
||||
}
|
||||
|
||||
set jwtSecret(value: string) {
|
||||
setEncryptedColumn(this, "jwtSecret", value);
|
||||
}
|
||||
|
||||
@Column
|
||||
lastActiveAt: Date | null;
|
||||
|
||||
@IsIP
|
||||
@Column
|
||||
lastActiveIp: string | null;
|
||||
|
||||
@Column
|
||||
lastSignedInAt: Date | null;
|
||||
|
||||
@IsIP
|
||||
@Column
|
||||
lastSignedInIp: string | null;
|
||||
|
||||
@Column
|
||||
lastSigninEmailSentAt: Date | null;
|
||||
|
||||
@Column
|
||||
suspendedAt: Date | null;
|
||||
|
||||
@Default(process.env.DEFAULT_LANGUAGE)
|
||||
@IsIn([languages])
|
||||
@Column
|
||||
language: string;
|
||||
|
||||
@Column(DataType.STRING)
|
||||
get avatarUrl() {
|
||||
const original = this.getDataValue("avatarUrl");
|
||||
|
||||
if (original) {
|
||||
return original;
|
||||
}
|
||||
|
||||
const color = this.color.replace(/^#/, "");
|
||||
const initial = this.name ? this.name[0] : "?";
|
||||
const hash = crypto
|
||||
.createHash("md5")
|
||||
.update(this.email || "")
|
||||
.digest("hex");
|
||||
return `${DEFAULT_AVATAR_HOST}/avatar/${hash}/${initial}.png?c=${color}`;
|
||||
}
|
||||
|
||||
set avatarUrl(value: string | null) {
|
||||
this.setDataValue("avatarUrl", value);
|
||||
}
|
||||
|
||||
// associations
|
||||
|
||||
@HasOne(() => User, "suspendedById")
|
||||
suspendedBy: User;
|
||||
|
||||
@ForeignKey(() => User)
|
||||
@Column(DataType.UUID)
|
||||
suspendedById: string;
|
||||
|
||||
@BelongsTo(() => Team)
|
||||
team: Team;
|
||||
|
||||
@ForeignKey(() => Team)
|
||||
@Column(DataType.UUID)
|
||||
teamId: string;
|
||||
|
||||
@HasMany(() => UserAuthentication)
|
||||
authentications: UserAuthentication[];
|
||||
|
||||
// getters
|
||||
|
||||
get isSuspended(): boolean {
|
||||
return !!this.suspendedAt;
|
||||
}
|
||||
|
||||
get isInvited() {
|
||||
return !this.lastActiveAt;
|
||||
}
|
||||
|
||||
get color() {
|
||||
const idAsHex = crypto.createHash("md5").update(this.id).digest("hex");
|
||||
const idAsNumber = parseInt(idAsHex, 16);
|
||||
return palette[idAsNumber % palette.length];
|
||||
}
|
||||
|
||||
// instance methods
|
||||
|
||||
collectionIds = async (options = {}) => {
|
||||
const collectionStubs = await Collection.scope({
|
||||
method: ["withMembership", this.id],
|
||||
}).findAll({
|
||||
attributes: ["id", "permission"],
|
||||
where: {
|
||||
teamId: this.teamId,
|
||||
},
|
||||
paranoid: true,
|
||||
...options,
|
||||
});
|
||||
|
||||
return collectionStubs
|
||||
.filter(
|
||||
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'c' implicitly has an 'any' type.
|
||||
(c) =>
|
||||
c.permission === "read" ||
|
||||
c.permission === "read_write" ||
|
||||
c.memberships.length > 0 ||
|
||||
c.collectionGroupMemberships.length > 0
|
||||
)
|
||||
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'c' implicitly has an 'any' type.
|
||||
.map((c) => c.id)
|
||||
);
|
||||
};
|
||||
.map((c) => c.id);
|
||||
};
|
||||
|
||||
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'ip' implicitly has an 'any' type.
|
||||
User.prototype.updateActiveAt = function (ip, force = false) {
|
||||
const fiveMinutesAgo = subMinutes(new Date(), 5);
|
||||
updateActiveAt = (ip: string, force = false) => {
|
||||
const fiveMinutesAgo = subMinutes(new Date(), 5);
|
||||
|
||||
// ensure this is updated only every few minutes otherwise
|
||||
// we'll be constantly writing to the DB as API requests happen
|
||||
if (this.lastActiveAt < fiveMinutesAgo || force) {
|
||||
this.lastActiveAt = new Date();
|
||||
this.lastActiveIp = ip;
|
||||
// ensure this is updated only every few minutes otherwise
|
||||
// we'll be constantly writing to the DB as API requests happen
|
||||
if (!this.lastActiveAt || this.lastActiveAt < fiveMinutesAgo || force) {
|
||||
this.lastActiveAt = new Date();
|
||||
this.lastActiveIp = ip;
|
||||
|
||||
return this.save({
|
||||
hooks: false,
|
||||
});
|
||||
}
|
||||
|
||||
return this;
|
||||
};
|
||||
|
||||
updateSignedIn = (ip: string) => {
|
||||
this.lastSignedInAt = new Date();
|
||||
this.lastSignedInIp = ip;
|
||||
return this.save({
|
||||
hooks: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'ip' implicitly has an 'any' type.
|
||||
User.prototype.updateSignedIn = function (ip) {
|
||||
this.lastSignedInAt = new Date();
|
||||
this.lastSignedInIp = ip;
|
||||
return this.save({
|
||||
hooks: false,
|
||||
});
|
||||
};
|
||||
|
||||
// Returns a session token that is used to make API requests and is stored
|
||||
// in the client browser cookies to remain logged in.
|
||||
User.prototype.getJwtToken = function (expiresAt?: Date) {
|
||||
return JWT.sign(
|
||||
{
|
||||
id: this.id,
|
||||
expiresAt: expiresAt ? expiresAt.toISOString() : undefined,
|
||||
type: "session",
|
||||
},
|
||||
this.jwtSecret
|
||||
);
|
||||
};
|
||||
|
||||
// Returns a temporary token that is only used for transferring a session
|
||||
// between subdomains or domains. It has a short expiry and can only be used once
|
||||
User.prototype.getTransferToken = function () {
|
||||
return JWT.sign(
|
||||
{
|
||||
id: this.id,
|
||||
createdAt: new Date().toISOString(),
|
||||
expiresAt: addMinutes(new Date(), 1).toISOString(),
|
||||
type: "transfer",
|
||||
},
|
||||
this.jwtSecret
|
||||
);
|
||||
};
|
||||
|
||||
// Returns a temporary token that is only used for logging in from an email
|
||||
// It can only be used to sign in once and has a medium length expiry
|
||||
User.prototype.getEmailSigninToken = function () {
|
||||
return JWT.sign(
|
||||
{
|
||||
id: this.id,
|
||||
createdAt: new Date().toISOString(),
|
||||
type: "email-signin",
|
||||
},
|
||||
this.jwtSecret
|
||||
);
|
||||
};
|
||||
|
||||
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'model' implicitly has an 'any' type.
|
||||
const uploadAvatar = async (model) => {
|
||||
const endpoint = publicS3Endpoint();
|
||||
const { avatarUrl } = model;
|
||||
|
||||
if (
|
||||
avatarUrl &&
|
||||
!avatarUrl.startsWith("/api") &&
|
||||
!avatarUrl.startsWith(endpoint) &&
|
||||
!avatarUrl.startsWith(DEFAULT_AVATAR_HOST)
|
||||
) {
|
||||
try {
|
||||
const newUrl = await uploadToS3FromUrl(
|
||||
avatarUrl,
|
||||
`avatars/${model.id}/${uuidv4()}`,
|
||||
"public-read"
|
||||
);
|
||||
if (newUrl) model.avatarUrl = newUrl;
|
||||
} catch (err) {
|
||||
Logger.error("Couldn't upload user avatar image to S3", err, {
|
||||
url: avatarUrl,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'model' implicitly has an 'any' type.
|
||||
const setRandomJwtSecret = (model) => {
|
||||
model.jwtSecret = crypto.randomBytes(64).toString("hex");
|
||||
};
|
||||
|
||||
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'model' implicitly has an 'any' type.
|
||||
const removeIdentifyingInfo = async (model, options) => {
|
||||
await NotificationSetting.destroy({
|
||||
where: {
|
||||
userId: model.id,
|
||||
},
|
||||
transaction: options.transaction,
|
||||
});
|
||||
await ApiKey.destroy({
|
||||
where: {
|
||||
userId: model.id,
|
||||
},
|
||||
transaction: options.transaction,
|
||||
});
|
||||
await Star.destroy({
|
||||
where: {
|
||||
userId: model.id,
|
||||
},
|
||||
transaction: options.transaction,
|
||||
});
|
||||
await UserAuthentication.destroy({
|
||||
where: {
|
||||
userId: model.id,
|
||||
},
|
||||
transaction: options.transaction,
|
||||
});
|
||||
model.email = null;
|
||||
model.name = "Unknown";
|
||||
model.avatarUrl = "";
|
||||
model.serviceId = null;
|
||||
model.username = null;
|
||||
model.lastActiveIp = null;
|
||||
model.lastSignedInIp = null;
|
||||
// this shouldn't be needed once this issue is resolved:
|
||||
// https://github.com/sequelize/sequelize/issues/9318
|
||||
await model.save({
|
||||
hooks: false,
|
||||
transaction: options.transaction,
|
||||
});
|
||||
};
|
||||
|
||||
User.beforeDestroy(removeIdentifyingInfo);
|
||||
User.beforeSave(uploadAvatar);
|
||||
User.beforeCreate(setRandomJwtSecret);
|
||||
// By default when a user signs up we subscribe them to email notifications
|
||||
// when documents they created are edited by other team members and onboarding
|
||||
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'user' implicitly has an 'any' type.
|
||||
User.afterCreate(async (user, options) => {
|
||||
await Promise.all([
|
||||
NotificationSetting.findOrCreate({
|
||||
where: {
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
event: "documents.update",
|
||||
},
|
||||
transaction: options.transaction,
|
||||
}),
|
||||
NotificationSetting.findOrCreate({
|
||||
where: {
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
event: "emails.onboarding",
|
||||
},
|
||||
transaction: options.transaction,
|
||||
}),
|
||||
NotificationSetting.findOrCreate({
|
||||
where: {
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
event: "emails.features",
|
||||
},
|
||||
transaction: options.transaction,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
User.getCounts = async function (teamId: string) {
|
||||
const countSql = `
|
||||
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",
|
||||
COUNT(CASE WHEN "lastActiveAt" IS NULL THEN 1 END) as "invitedCount",
|
||||
COUNT(CASE WHEN "suspendedAt" IS NULL AND "lastActiveAt" IS NOT NULL THEN 1 END) as "activeCount",
|
||||
COUNT(*) as count
|
||||
FROM users
|
||||
WHERE "deletedAt" IS NULL
|
||||
AND "teamId" = :teamId
|
||||
`;
|
||||
const results = await sequelize.query(countSql, {
|
||||
type: sequelize.QueryTypes.SELECT,
|
||||
replacements: {
|
||||
teamId,
|
||||
},
|
||||
});
|
||||
const counts = results[0];
|
||||
return {
|
||||
active: parseInt(counts.activeCount),
|
||||
admins: parseInt(counts.adminCount),
|
||||
viewers: parseInt(counts.viewerCount),
|
||||
all: parseInt(counts.count),
|
||||
invited: parseInt(counts.invitedCount),
|
||||
suspended: parseInt(counts.suspendedCount),
|
||||
};
|
||||
};
|
||||
|
||||
User.findAllInBatches = async (
|
||||
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'query' implicitly has an 'any' type.
|
||||
query,
|
||||
// @ts-expect-error ts-migrate(2749) FIXME: 'User' refers to a value, but is being used as a t... Remove this comment to see the full error message
|
||||
callback: (users: Array<User>, query: Record<string, any>) => Promise<void>
|
||||
) => {
|
||||
if (!query.offset) query.offset = 0;
|
||||
if (!query.limit) query.limit = 10;
|
||||
let results;
|
||||
|
||||
do {
|
||||
results = await User.findAll(query);
|
||||
await callback(results, query);
|
||||
query.offset += query.limit;
|
||||
} while (results.length >= query.limit);
|
||||
};
|
||||
|
||||
User.prototype.demote = async function (
|
||||
teamId: string,
|
||||
to: "member" | "viewer"
|
||||
) {
|
||||
const res = await User.findAndCountAll({
|
||||
where: {
|
||||
teamId,
|
||||
isAdmin: true,
|
||||
id: {
|
||||
[Op.ne]: this.id,
|
||||
// Returns a session token that is used to make API requests and is stored
|
||||
// in the client browser cookies to remain logged in.
|
||||
getJwtToken = (expiresAt?: Date) => {
|
||||
return JWT.sign(
|
||||
{
|
||||
id: this.id,
|
||||
expiresAt: expiresAt ? expiresAt.toISOString() : undefined,
|
||||
type: "session",
|
||||
},
|
||||
},
|
||||
limit: 1,
|
||||
});
|
||||
this.jwtSecret
|
||||
);
|
||||
};
|
||||
|
||||
if (res.count >= 1) {
|
||||
if (to === "member") {
|
||||
return this.update({
|
||||
isAdmin: false,
|
||||
isViewer: false,
|
||||
});
|
||||
} else if (to === "viewer") {
|
||||
return this.update({
|
||||
isAdmin: false,
|
||||
isViewer: true,
|
||||
});
|
||||
// Returns a temporary token that is only used for transferring a session
|
||||
// between subdomains or domains. It has a short expiry and can only be used once
|
||||
getTransferToken = () => {
|
||||
return JWT.sign(
|
||||
{
|
||||
id: this.id,
|
||||
createdAt: new Date().toISOString(),
|
||||
expiresAt: addMinutes(new Date(), 1).toISOString(),
|
||||
type: "transfer",
|
||||
},
|
||||
this.jwtSecret
|
||||
);
|
||||
};
|
||||
|
||||
// Returns a temporary token that is only used for logging in from an email
|
||||
// It can only be used to sign in once and has a medium length expiry
|
||||
getEmailSigninToken = () => {
|
||||
return JWT.sign(
|
||||
{
|
||||
id: this.id,
|
||||
createdAt: new Date().toISOString(),
|
||||
type: "email-signin",
|
||||
},
|
||||
this.jwtSecret
|
||||
);
|
||||
};
|
||||
|
||||
demote = async (teamId: string, to: "member" | "viewer") => {
|
||||
const res = await (this.constructor as typeof User).findAndCountAll({
|
||||
where: {
|
||||
teamId,
|
||||
isAdmin: true,
|
||||
id: {
|
||||
[Op.ne]: this.id,
|
||||
},
|
||||
},
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
if (res.count >= 1) {
|
||||
if (to === "member") {
|
||||
return this.update({
|
||||
isAdmin: false,
|
||||
isViewer: false,
|
||||
});
|
||||
} else if (to === "viewer") {
|
||||
return this.update({
|
||||
isAdmin: false,
|
||||
isViewer: true,
|
||||
});
|
||||
}
|
||||
|
||||
return undefined;
|
||||
} else {
|
||||
throw ValidationError("At least one admin is required");
|
||||
}
|
||||
} else {
|
||||
throw ValidationError("At least one admin is required");
|
||||
};
|
||||
|
||||
promote = () => {
|
||||
return this.update({
|
||||
isAdmin: true,
|
||||
isViewer: false,
|
||||
});
|
||||
};
|
||||
|
||||
activate = () => {
|
||||
return this.update({
|
||||
suspendedById: null,
|
||||
suspendedAt: null,
|
||||
});
|
||||
};
|
||||
|
||||
// hooks
|
||||
|
||||
@BeforeDestroy
|
||||
static removeIdentifyingInfo = async (
|
||||
model: User,
|
||||
options: { transaction: Transaction }
|
||||
) => {
|
||||
await NotificationSetting.destroy({
|
||||
where: {
|
||||
userId: model.id,
|
||||
},
|
||||
transaction: options.transaction,
|
||||
});
|
||||
await ApiKey.destroy({
|
||||
where: {
|
||||
userId: model.id,
|
||||
},
|
||||
transaction: options.transaction,
|
||||
});
|
||||
await Star.destroy({
|
||||
where: {
|
||||
userId: model.id,
|
||||
},
|
||||
transaction: options.transaction,
|
||||
});
|
||||
await UserAuthentication.destroy({
|
||||
where: {
|
||||
userId: model.id,
|
||||
},
|
||||
transaction: options.transaction,
|
||||
});
|
||||
model.email = null;
|
||||
model.name = "Unknown";
|
||||
model.avatarUrl = null;
|
||||
model.username = null;
|
||||
model.lastActiveIp = null;
|
||||
model.lastSignedInIp = null;
|
||||
|
||||
// this shouldn't be needed once this issue is resolved:
|
||||
// https://github.com/sequelize/sequelize/issues/9318
|
||||
await model.save({
|
||||
hooks: false,
|
||||
transaction: options.transaction,
|
||||
});
|
||||
};
|
||||
|
||||
@BeforeSave
|
||||
static uploadAvatar = async (model: User) => {
|
||||
const endpoint = publicS3Endpoint();
|
||||
const { avatarUrl } = model;
|
||||
|
||||
if (
|
||||
avatarUrl &&
|
||||
!avatarUrl.startsWith("/api") &&
|
||||
!avatarUrl.startsWith(endpoint) &&
|
||||
!avatarUrl.startsWith(DEFAULT_AVATAR_HOST)
|
||||
) {
|
||||
try {
|
||||
const newUrl = await uploadToS3FromUrl(
|
||||
avatarUrl,
|
||||
`avatars/${model.id}/${uuidv4()}`,
|
||||
"public-read"
|
||||
);
|
||||
if (newUrl) model.avatarUrl = newUrl;
|
||||
} catch (err) {
|
||||
Logger.error("Couldn't upload user avatar image to S3", err, {
|
||||
url: avatarUrl,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@BeforeCreate
|
||||
static setRandomJwtSecret = (model: User) => {
|
||||
model.jwtSecret = crypto.randomBytes(64).toString("hex");
|
||||
};
|
||||
|
||||
// By default when a user signs up we subscribe them to email notifications
|
||||
// when documents they created are edited by other team members and onboarding
|
||||
@AfterCreate
|
||||
static subscribeToNotifications = async (
|
||||
model: User,
|
||||
options: { transaction: Transaction }
|
||||
) => {
|
||||
await Promise.all([
|
||||
NotificationSetting.findOrCreate({
|
||||
where: {
|
||||
userId: model.id,
|
||||
teamId: model.teamId,
|
||||
event: "documents.update",
|
||||
},
|
||||
transaction: options.transaction,
|
||||
}),
|
||||
NotificationSetting.findOrCreate({
|
||||
where: {
|
||||
userId: model.id,
|
||||
teamId: model.teamId,
|
||||
event: "emails.onboarding",
|
||||
},
|
||||
transaction: options.transaction,
|
||||
}),
|
||||
NotificationSetting.findOrCreate({
|
||||
where: {
|
||||
userId: model.id,
|
||||
teamId: model.teamId,
|
||||
event: "emails.features",
|
||||
},
|
||||
transaction: options.transaction,
|
||||
}),
|
||||
]);
|
||||
};
|
||||
|
||||
static getCounts = async function (teamId: string) {
|
||||
const countSql = `
|
||||
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",
|
||||
COUNT(CASE WHEN "lastActiveAt" IS NULL THEN 1 END) as "invitedCount",
|
||||
COUNT(CASE WHEN "suspendedAt" IS NULL AND "lastActiveAt" IS NOT NULL THEN 1 END) as "activeCount",
|
||||
COUNT(*) as count
|
||||
FROM users
|
||||
WHERE "deletedAt" IS NULL
|
||||
AND "teamId" = :teamId
|
||||
`;
|
||||
const [results] = await this.sequelize.query(countSql, {
|
||||
type: QueryTypes.SELECT,
|
||||
replacements: {
|
||||
teamId,
|
||||
},
|
||||
});
|
||||
|
||||
const counts: {
|
||||
activeCount: string;
|
||||
adminCount: string;
|
||||
invitedCount: string;
|
||||
suspendedCount: string;
|
||||
viewerCount: string;
|
||||
count: string;
|
||||
} = results as any;
|
||||
|
||||
return {
|
||||
active: parseInt(counts.activeCount),
|
||||
admins: parseInt(counts.adminCount),
|
||||
viewers: parseInt(counts.viewerCount),
|
||||
all: parseInt(counts.count),
|
||||
invited: parseInt(counts.invitedCount),
|
||||
suspended: parseInt(counts.suspendedCount),
|
||||
};
|
||||
};
|
||||
|
||||
static async findAllInBatches(
|
||||
query: FindOptions<User>,
|
||||
callback: (users: Array<User>, query: FindOptions<User>) => Promise<void>
|
||||
) {
|
||||
if (!query.offset) query.offset = 0;
|
||||
if (!query.limit) query.limit = 10;
|
||||
let results;
|
||||
|
||||
do {
|
||||
results = await this.findAll(query);
|
||||
await callback(results, query);
|
||||
query.offset += query.limit;
|
||||
} while (results.length >= query.limit);
|
||||
}
|
||||
};
|
||||
|
||||
User.prototype.promote = async function () {
|
||||
return this.update({
|
||||
isAdmin: true,
|
||||
isViewer: false,
|
||||
});
|
||||
};
|
||||
|
||||
User.prototype.activate = async function () {
|
||||
return this.update({
|
||||
suspendedById: null,
|
||||
suspendedAt: null,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export default User;
|
||||
|
||||
Reference in New Issue
Block a user