chore: Move to Typescript (#2783)
This PR moves the entire project to Typescript. Due to the ~1000 ignores this will lead to a messy codebase for a while, but the churn is worth it – all of those ignore comments are places that were never type-safe previously. closes #1282
This commit is contained in:
433
server/models/User.ts
Normal file
433
server/models/User.ts
Normal file
@@ -0,0 +1,433 @@
|
||||
import crypto from "crypto";
|
||||
import { addMinutes, subMinutes } from "date-fns";
|
||||
import JWT from "jsonwebtoken";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { languages } from "@shared/i18n";
|
||||
import Logger from "@server/logging/logger";
|
||||
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 ".";
|
||||
|
||||
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 initial = this.name ? this.name[0] : "?";
|
||||
const hash = crypto
|
||||
.createHash("md5")
|
||||
.update(this.email || "")
|
||||
.digest("hex");
|
||||
return `${DEFAULT_AVATAR_HOST}/avatar/${hash}/${initial}.png`;
|
||||
},
|
||||
|
||||
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", {
|
||||
include: [
|
||||
{
|
||||
model: models.UserAuthentication,
|
||||
as: "authentications",
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
// 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
|
||||
.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)
|
||||
);
|
||||
};
|
||||
|
||||
// @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);
|
||||
|
||||
// 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;
|
||||
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,
|
||||
},
|
||||
},
|
||||
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,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
throw ValidationError("At least one admin is required");
|
||||
}
|
||||
};
|
||||
|
||||
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