mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
281b778b2d
* fix: Suspended users should not be included in cached member count for groups * fix: Defer CounterCache hook registration until model is initialized The previous test-only no-op hid a timing bug where setImmediate could fire before the Sequelize instance had registered the related model, causing "Model not initialized" failures. Poll until the model is ready, and unref the pending immediate so it does not keep the event loop alive in environments where the database is never initialized. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * perf: Reduce overhead of group member count invalidation Select only the groupId column with raw queries and de-duplicate before issuing Redis deletes, avoiding loading full GroupUser rows into memory when a user belongs to many groups. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * chore: unref Redis healthcheck interval Don't keep the Node event loop alive solely for the periodic ping; the event loop should drain on its own when the application is shutting down or a Jest worker is finishing. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * refactor: Centralize counter cache key in RedisPrefixHelper Avoid duplicating the "count:<Model>:<relation>:<id>" string between the CounterCache decorator and the User suspension hook by routing both through a single getCounterCacheKey helper. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix: Walk to parent transaction when scheduling cache invalidation Nested savepoints commit independently of their outer transaction, so afterCommit callbacks attached to the inner transaction may run after the outer rolls back, or never run at all. Match the pattern used in Collection, Event, and base/Model and walk to the parent transaction so the cache invalidation fires after the real outer commit. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
977 lines
24 KiB
TypeScript
977 lines
24 KiB
TypeScript
import crypto from "node:crypto";
|
|
import { addHours, addMinutes, subMinutes } from "date-fns";
|
|
import JWT from "jsonwebtoken";
|
|
import type { Context } from "koa";
|
|
import type {
|
|
Transaction,
|
|
SaveOptions,
|
|
FindOptions,
|
|
InferAttributes,
|
|
InferCreationAttributes,
|
|
} from "sequelize";
|
|
import { QueryTypes, Op } from "sequelize";
|
|
import { type InstanceUpdateOptions } from "sequelize";
|
|
import {
|
|
Table,
|
|
Column,
|
|
IsIP,
|
|
IsEmail,
|
|
Default,
|
|
IsIn,
|
|
BeforeDestroy,
|
|
BeforeCreate,
|
|
BelongsTo,
|
|
ForeignKey,
|
|
DataType,
|
|
HasMany,
|
|
Scopes,
|
|
IsDate,
|
|
AllowNull,
|
|
AfterUpdate,
|
|
BeforeUpdate,
|
|
} from "sequelize-typescript";
|
|
import { UserPreferenceDefaults } from "@shared/constants";
|
|
import { languages } from "@shared/i18n";
|
|
import type {
|
|
NotificationSettings,
|
|
UserPreference,
|
|
UserPreferences,
|
|
NotificationEventType,
|
|
} from "@shared/types";
|
|
import {
|
|
CollectionPermission,
|
|
NotificationEventDefaults,
|
|
UserRole,
|
|
DocumentPermission,
|
|
} from "@shared/types";
|
|
import { UserRoleHelper } from "@shared/utils/UserRoleHelper";
|
|
import { stringToColor } from "@shared/utils/color";
|
|
import type { locales } from "@shared/utils/date";
|
|
import { UserValidation } from "@shared/validations";
|
|
import env from "@server/env";
|
|
import DeleteAttachmentTask from "@server/queues/tasks/DeleteAttachmentTask";
|
|
import type { APIContext } from "@server/types";
|
|
import { VerificationCode } from "@server/utils/VerificationCode";
|
|
import parseAttachmentIds from "@server/utils/parseAttachmentIds";
|
|
import { CacheHelper } from "@server/utils/CacheHelper";
|
|
import { RedisPrefixHelper } from "@server/utils/RedisPrefixHelper";
|
|
import { ValidationError } from "../errors";
|
|
import Attachment from "./Attachment";
|
|
import AuthenticationProvider from "./AuthenticationProvider";
|
|
import Collection from "./Collection";
|
|
import Group from "./Group";
|
|
import GroupUser from "./GroupUser";
|
|
import Team from "./Team";
|
|
import UserAuthentication from "./UserAuthentication";
|
|
import UserMembership from "./UserMembership";
|
|
import UserPasskey from "./UserPasskey";
|
|
import ParanoidModel from "./base/ParanoidModel";
|
|
import Encrypted from "./decorators/Encrypted";
|
|
import Fix from "./decorators/Fix";
|
|
import IsUrlOrRelativePath from "./validators/IsUrlOrRelativePath";
|
|
import Length from "./validators/Length";
|
|
import NotContainsUrl from "./validators/NotContainsUrl";
|
|
import { SkipChangeset } from "./decorators/Changeset";
|
|
|
|
/**
|
|
* Flags that are available for setting on the user.
|
|
*/
|
|
export enum UserFlag {
|
|
InviteSent = "inviteSent",
|
|
InviteReminderSent = "inviteReminderSent",
|
|
Desktop = "desktop",
|
|
DesktopWeb = "desktopWeb",
|
|
MobileWeb = "mobileWeb",
|
|
AvatarUpdated = "avatarUpdated",
|
|
}
|
|
|
|
@Scopes(() => ({
|
|
withAuthentications: {
|
|
include: [
|
|
{
|
|
separate: true,
|
|
model: UserAuthentication,
|
|
as: "authentications",
|
|
include: [
|
|
{
|
|
model: AuthenticationProvider,
|
|
as: "authenticationProvider",
|
|
where: {
|
|
enabled: true,
|
|
},
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
withTeam: {
|
|
include: [
|
|
{
|
|
model: Team,
|
|
as: "team",
|
|
required: true,
|
|
},
|
|
],
|
|
},
|
|
withInvitedBy: {
|
|
include: [
|
|
{
|
|
model: User,
|
|
as: "invitedBy",
|
|
required: true,
|
|
},
|
|
],
|
|
},
|
|
invited: {
|
|
where: {
|
|
lastActiveAt: {
|
|
[Op.is]: null,
|
|
},
|
|
},
|
|
},
|
|
}))
|
|
@Table({ tableName: "users", modelName: "user" })
|
|
@Fix
|
|
class User extends ParanoidModel<
|
|
InferAttributes<User>,
|
|
Partial<InferCreationAttributes<User>>
|
|
> {
|
|
@IsEmail
|
|
@Length({
|
|
min: 1,
|
|
max: UserValidation.maxEmailLength,
|
|
msg: `User email must be between 1 and ${UserValidation.maxEmailLength} characters`,
|
|
})
|
|
@Column
|
|
email: string | null;
|
|
|
|
@NotContainsUrl
|
|
@Length({
|
|
min: 1,
|
|
max: UserValidation.maxNameLength,
|
|
msg: `User name must be between 1 and ${UserValidation.maxNameLength} characters`,
|
|
})
|
|
@Column
|
|
name: string;
|
|
|
|
@Default(UserRole.Member)
|
|
@Column(DataType.ENUM(...Object.values(UserRole)))
|
|
role: UserRole;
|
|
|
|
@Column(DataType.BLOB)
|
|
@Encrypted
|
|
jwtSecret: string;
|
|
|
|
@IsDate
|
|
@Column
|
|
@SkipChangeset
|
|
lastActiveAt: Date | null;
|
|
|
|
@IsIP
|
|
@Column
|
|
@SkipChangeset
|
|
lastActiveIp: string | null;
|
|
|
|
@IsDate
|
|
@Column
|
|
@SkipChangeset
|
|
lastSignedInAt: Date | null;
|
|
|
|
@IsIP
|
|
@Column
|
|
@SkipChangeset
|
|
lastSignedInIp: string | null;
|
|
|
|
@IsDate
|
|
@Column
|
|
@SkipChangeset
|
|
lastSigninEmailSentAt: Date | null;
|
|
|
|
@IsDate
|
|
@Column
|
|
suspendedAt: Date | null;
|
|
|
|
@Column(DataType.JSONB)
|
|
flags: { [key in UserFlag]?: number } | null;
|
|
|
|
@AllowNull
|
|
@Column(DataType.JSONB)
|
|
preferences: UserPreferences | null;
|
|
|
|
@Column(DataType.JSONB)
|
|
notificationSettings: NotificationSettings;
|
|
|
|
@Default(env.DEFAULT_LANGUAGE)
|
|
@IsIn([languages])
|
|
@Column(DataType.STRING)
|
|
language: keyof typeof locales | null;
|
|
|
|
@AllowNull
|
|
@Column(DataType.STRING)
|
|
timezone: string | null;
|
|
|
|
@AllowNull
|
|
@IsUrlOrRelativePath
|
|
@Length({ max: 4096, msg: "avatarUrl must be less than 4096 characters" })
|
|
@Column(DataType.STRING)
|
|
get avatarUrl() {
|
|
const original = this.getDataValue("avatarUrl");
|
|
|
|
if (original && !original.startsWith("https://tiley.herokuapp.com")) {
|
|
return original;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
set avatarUrl(value: string | null) {
|
|
this.setDataValue("avatarUrl", value);
|
|
}
|
|
|
|
// associations
|
|
@BelongsTo(() => User, "suspendedById")
|
|
suspendedBy: User | null;
|
|
|
|
@ForeignKey(() => User)
|
|
@Column(DataType.UUID)
|
|
suspendedById: string | null;
|
|
|
|
@BelongsTo(() => User, "invitedById")
|
|
invitedBy: User | null;
|
|
|
|
@ForeignKey(() => User)
|
|
@Column(DataType.UUID)
|
|
invitedById: string | null;
|
|
|
|
@BelongsTo(() => Team)
|
|
team: Team;
|
|
|
|
@ForeignKey(() => Team)
|
|
@Column(DataType.UUID)
|
|
teamId: string;
|
|
|
|
@HasMany(() => UserAuthentication)
|
|
authentications: UserAuthentication[];
|
|
|
|
@HasMany(() => UserPasskey)
|
|
passkeys: UserPasskey[];
|
|
|
|
// getters
|
|
|
|
get isSuspended(): boolean {
|
|
return !!this.suspendedAt || !!this.team?.isSuspended;
|
|
}
|
|
|
|
/**
|
|
* Whether the user has been invited but not yet signed in.
|
|
*/
|
|
get isInvited() {
|
|
return !this.lastActiveAt;
|
|
}
|
|
|
|
/**
|
|
* Whether the user is an admin.
|
|
*/
|
|
get isAdmin() {
|
|
return this.role === UserRole.Admin;
|
|
}
|
|
|
|
/**
|
|
* Whether the user is a member (editor).
|
|
*/
|
|
get isMember() {
|
|
return this.role === UserRole.Member;
|
|
}
|
|
|
|
/**
|
|
* Whether the user is a viewer.
|
|
*/
|
|
get isViewer() {
|
|
return this.role === UserRole.Viewer;
|
|
}
|
|
|
|
/**
|
|
* Whether the user is a guest.
|
|
*/
|
|
get isGuest() {
|
|
return this.role === UserRole.Guest;
|
|
}
|
|
|
|
get color() {
|
|
return stringToColor(this.id);
|
|
}
|
|
|
|
get defaultCollectionPermission(): CollectionPermission {
|
|
return this.isViewer
|
|
? CollectionPermission.Read
|
|
: CollectionPermission.ReadWrite;
|
|
}
|
|
|
|
get defaultDocumentPermission(): DocumentPermission {
|
|
return this.isViewer
|
|
? DocumentPermission.Read
|
|
: DocumentPermission.ReadWrite;
|
|
}
|
|
|
|
/**
|
|
* Returns a code that can be used to delete this user account. The code will
|
|
* be rotated when the user signs out.
|
|
*
|
|
* @returns The deletion code.
|
|
*/
|
|
get deleteConfirmationCode() {
|
|
return crypto
|
|
.createHash("md5")
|
|
.update(this.jwtSecret)
|
|
.digest("hex")
|
|
.replace(/[l1IoO0]/gi, "")
|
|
.slice(0, 8)
|
|
.toUpperCase();
|
|
}
|
|
|
|
// instance methods
|
|
|
|
/**
|
|
* Sets a preference for the users notification settings.
|
|
*
|
|
* @param type The type of notification event
|
|
* @param value Set the preference to true/false
|
|
*/
|
|
public setNotificationEventType = (
|
|
type: NotificationEventType,
|
|
value = true
|
|
) => {
|
|
this.notificationSettings = {
|
|
...this.notificationSettings,
|
|
[type]: value,
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Returns the current preference for the given notification event type taking
|
|
* into account the default system value.
|
|
*
|
|
* @param type The type of notification event
|
|
* @returns The current preference
|
|
*/
|
|
public subscribedToEventType = (type: NotificationEventType) =>
|
|
this.notificationSettings[type] ?? NotificationEventDefaults[type] ?? false;
|
|
|
|
/**
|
|
* User flags are for storing information on a user record that is not visible
|
|
* to the user itself.
|
|
*
|
|
* @param flag The flag to set
|
|
* @param value Set the flag to true/false
|
|
* @returns The current user flags
|
|
*/
|
|
public setFlag = (flag: UserFlag, value = true) => {
|
|
if (!this.flags) {
|
|
this.flags = {};
|
|
}
|
|
const binary = value ? 1 : 0;
|
|
if (this.flags[flag] !== binary) {
|
|
this.flags = {
|
|
...this.flags,
|
|
[flag]: binary,
|
|
};
|
|
}
|
|
|
|
return this.flags;
|
|
};
|
|
|
|
/**
|
|
* Returns the content of the given user flag.
|
|
*
|
|
* @param flag The flag to retrieve
|
|
* @returns The flag value
|
|
*/
|
|
public getFlag = (flag: UserFlag) => this.flags?.[flag] ?? 0;
|
|
|
|
/**
|
|
* User flags are for storing information on a user record that is not visible
|
|
* to the user itself.
|
|
*
|
|
* @param flag The flag to set
|
|
* @param value The amount to increment by, defaults to 1
|
|
* @returns The current user flags
|
|
*/
|
|
public incrementFlag = (flag: UserFlag, value = 1) => {
|
|
if (!this.flags) {
|
|
this.flags = {};
|
|
}
|
|
this.flags = {
|
|
...this.flags,
|
|
[flag]: (this.flags[flag] ?? 0) + value,
|
|
};
|
|
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 = <K extends UserPreference>(
|
|
preference: K,
|
|
value: NonNullable<UserPreferences[K]>
|
|
) => {
|
|
if (!this.preferences) {
|
|
this.preferences = {};
|
|
}
|
|
this.preferences = {
|
|
...this.preferences,
|
|
[preference]: value,
|
|
};
|
|
return this.preferences;
|
|
};
|
|
|
|
/**
|
|
* Returns the value of the givem preference
|
|
*
|
|
* @param preference The user preference to retrieve
|
|
* @returns The preference value if set, else the default value.
|
|
*/
|
|
public getPreference = <K extends UserPreference>(
|
|
preference: K
|
|
): NonNullable<UserPreferences[K]> =>
|
|
(this.preferences?.[preference] ??
|
|
UserPreferenceDefaults[preference] ??
|
|
false) as NonNullable<UserPreferences[K]>;
|
|
|
|
/**
|
|
* Returns the user's active groups.
|
|
*
|
|
* @param options Additional options to pass to the find
|
|
* @returns An array of groups
|
|
*/
|
|
public groups = (options: FindOptions<Group> = {}) =>
|
|
Group.scope({
|
|
method: ["withMembership", this.id],
|
|
}).findAll({
|
|
where: {
|
|
teamId: this.teamId,
|
|
},
|
|
...options,
|
|
});
|
|
|
|
/**
|
|
* Returns the user's active group ids.
|
|
*
|
|
* @param options Additional options to pass to the find
|
|
* @returns An array of group ids
|
|
*/
|
|
public groupIds = async (options: FindOptions<Group> = {}) =>
|
|
(await this.groups(options)).map((g) => g.id);
|
|
|
|
/**
|
|
* Returns the user's active collection ids. This includes collections the user
|
|
* has access to through group memberships.
|
|
*
|
|
* @param options Additional options to pass to the find
|
|
* @returns An array of collection ids
|
|
*/
|
|
public collectionIds = async (options: FindOptions<Collection> = {}) => {
|
|
const hasOptions =
|
|
options.transaction || options.paranoid === false || options.lock;
|
|
|
|
const fetchCollectionIds = async () => {
|
|
const collectionStubs = await Collection.findAll({
|
|
attributes: ["id"],
|
|
where: {
|
|
teamId: this.teamId,
|
|
[Op.or]: [
|
|
...(this.isGuest
|
|
? []
|
|
: [
|
|
{
|
|
permission: {
|
|
[Op.in]: Object.values(CollectionPermission),
|
|
},
|
|
},
|
|
]),
|
|
{
|
|
"$memberships.id$": { [Op.ne]: null },
|
|
},
|
|
{
|
|
"$groupMemberships.id$": { [Op.ne]: null },
|
|
},
|
|
],
|
|
},
|
|
include: [
|
|
{
|
|
association: "memberships",
|
|
attributes: [],
|
|
required: false,
|
|
where: {
|
|
userId: this.id,
|
|
},
|
|
},
|
|
{
|
|
association: "groupMemberships",
|
|
attributes: [],
|
|
required: false,
|
|
include: [
|
|
{
|
|
association: "group",
|
|
attributes: [],
|
|
required: true,
|
|
include: [
|
|
{
|
|
association: "groupUsers",
|
|
attributes: [],
|
|
required: true,
|
|
where: {
|
|
userId: this.id,
|
|
},
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
paranoid: true,
|
|
...options,
|
|
});
|
|
|
|
return Array.from(new Set(collectionStubs.map((c) => c.id)));
|
|
};
|
|
|
|
if (hasOptions) {
|
|
return fetchCollectionIds();
|
|
}
|
|
|
|
return (
|
|
(await CacheHelper.getDataOrSet<string[]>(
|
|
RedisPrefixHelper.getUserCollectionIdsKey(this.id),
|
|
fetchCollectionIds,
|
|
10
|
|
)) ?? []
|
|
);
|
|
};
|
|
|
|
updateActiveAt = async (ctx: Context, force = false) => {
|
|
const { ip } = ctx.request;
|
|
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 || this.lastActiveAt < fiveMinutesAgo || force) {
|
|
this.lastActiveAt = new Date();
|
|
this.lastActiveIp = ip;
|
|
}
|
|
|
|
// Track the clients each user is using
|
|
if (ctx.userAgent?.source.includes("Outline/")) {
|
|
this.setFlag(UserFlag.Desktop);
|
|
} else if (ctx.userAgent?.isDesktop) {
|
|
this.setFlag(UserFlag.DesktopWeb);
|
|
} else if (ctx.userAgent?.isMobile) {
|
|
this.setFlag(UserFlag.MobileWeb);
|
|
}
|
|
|
|
// Save only writes to the database if there are changes
|
|
return this.save({
|
|
hooks: false,
|
|
});
|
|
};
|
|
|
|
updateSignedIn = (ctx: Context | APIContext) => {
|
|
const now = new Date();
|
|
this.lastActiveAt = now;
|
|
this.lastActiveIp = ctx.request.ip;
|
|
this.lastSignedInAt = now;
|
|
this.lastSignedInIp = ctx.request.ip;
|
|
return this.save({ hooks: false, transaction: ctx.state.transaction });
|
|
};
|
|
|
|
/**
|
|
* Rotate's the users JWT secret. This has the effect of invalidating ALL
|
|
* previously issued tokens.
|
|
*
|
|
* @param options Save options
|
|
* @returns Promise that resolves when database persisted
|
|
*/
|
|
rotateJwtSecret = (options: SaveOptions) => {
|
|
User.setRandomJwtSecret(this);
|
|
return this.save(options);
|
|
};
|
|
|
|
/**
|
|
* Returns a session token that is used to make API requests and is stored
|
|
* in the client browser cookies to remain logged in.
|
|
*
|
|
* @param expiresAt The time the token will expire at
|
|
* @param service The authentication service used to generate the token, if applicable
|
|
* @returns The session token
|
|
*/
|
|
getJwtToken = (expiresAt?: Date, service?: string) =>
|
|
JWT.sign(
|
|
{
|
|
id: this.id,
|
|
expiresAt: expiresAt ? expiresAt.toISOString() : undefined,
|
|
type: "session",
|
|
service,
|
|
},
|
|
this.jwtSecret
|
|
);
|
|
|
|
/**
|
|
* Returns a session token that is used to make collaboration requests and is
|
|
* stored in the client memory.
|
|
*
|
|
* @returns The session token
|
|
*/
|
|
getCollaborationToken = () =>
|
|
JWT.sign(
|
|
{
|
|
id: this.id,
|
|
expiresAt: addHours(new Date(), 24).toISOString(),
|
|
type: "collaboration",
|
|
},
|
|
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.
|
|
*
|
|
* @param The authentication service used to generate the token, if applicable
|
|
* @returns The transfer token
|
|
*/
|
|
getTransferToken = (service?: string) =>
|
|
JWT.sign(
|
|
{
|
|
id: this.id,
|
|
createdAt: new Date().toISOString(),
|
|
expiresAt: addMinutes(new Date(), 1).toISOString(),
|
|
type: "transfer",
|
|
service,
|
|
},
|
|
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
|
|
*
|
|
* @param ctx The request context, used to get the IP address of the request
|
|
* @returns The email signin token
|
|
*/
|
|
getEmailSigninToken = (ctx: Context) =>
|
|
JWT.sign(
|
|
{
|
|
id: this.id,
|
|
ip: ctx.request.ip,
|
|
createdAt: new Date().toISOString(),
|
|
type: "email-signin",
|
|
},
|
|
this.jwtSecret
|
|
);
|
|
|
|
/**
|
|
* Generate a 6-digit verification code for email authentication
|
|
* and store it in Redis with a 10-minute TTL.
|
|
*
|
|
* @returns The 6-digit verification code
|
|
*/
|
|
getEmailVerificationCode = async (): Promise<string> => {
|
|
if (!this.email) {
|
|
throw ValidationError("Email is required");
|
|
}
|
|
|
|
const code = VerificationCode.generate();
|
|
await VerificationCode.store(this.teamId, this.email, code);
|
|
return code;
|
|
};
|
|
|
|
/**
|
|
* Returns a temporary token that can be used to update the users
|
|
* email address.
|
|
*
|
|
* @param email The new email address
|
|
* @returns The token
|
|
*/
|
|
getEmailUpdateToken = (email: string) =>
|
|
JWT.sign(
|
|
{
|
|
id: this.id,
|
|
createdAt: new Date().toISOString(),
|
|
email,
|
|
type: "email-update",
|
|
},
|
|
this.jwtSecret
|
|
);
|
|
|
|
/**
|
|
* Returns a list of teams that have a user matching this user's email.
|
|
*
|
|
* @returns A promise resolving to a list of teams
|
|
*/
|
|
availableTeams = async () =>
|
|
Team.findAll({
|
|
include: [
|
|
{
|
|
model: this.constructor as typeof User,
|
|
required: true,
|
|
where: { email: this.email },
|
|
},
|
|
],
|
|
order: [["createdAt", "ASC"]],
|
|
});
|
|
|
|
// hooks
|
|
|
|
@BeforeDestroy
|
|
static async checkLastUser(
|
|
model: User,
|
|
{ transaction }: { transaction: Transaction }
|
|
) {
|
|
const usersCount = await this.count({
|
|
where: {
|
|
teamId: model.teamId,
|
|
},
|
|
transaction,
|
|
});
|
|
|
|
if (usersCount === 1) {
|
|
throw ValidationError(
|
|
"Cannot delete last user on the team, delete the workspace instead."
|
|
);
|
|
}
|
|
}
|
|
|
|
@BeforeDestroy
|
|
static async checkLastAdmin(
|
|
model: User,
|
|
{ transaction }: { transaction: Transaction }
|
|
) {
|
|
if (model.role !== UserRole.Admin) {
|
|
return;
|
|
}
|
|
|
|
const otherAdminsCount = await this.count({
|
|
where: {
|
|
teamId: model.teamId,
|
|
role: UserRole.Admin,
|
|
id: {
|
|
[Op.ne]: model.id,
|
|
},
|
|
},
|
|
transaction,
|
|
});
|
|
|
|
if (otherAdminsCount === 0) {
|
|
throw ValidationError(
|
|
"Cannot delete account as only admin. Please make another user admin and try again."
|
|
);
|
|
}
|
|
}
|
|
|
|
@BeforeDestroy
|
|
static removeIdentifyingInfo = async (
|
|
model: User,
|
|
options: { transaction: Transaction }
|
|
) => {
|
|
model.email = null;
|
|
model.name = "Unknown";
|
|
model.avatarUrl = 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,
|
|
});
|
|
};
|
|
|
|
@BeforeCreate
|
|
static setRandomJwtSecret = (model: User) => {
|
|
model.jwtSecret = crypto.randomBytes(64).toString("hex");
|
|
};
|
|
|
|
@BeforeUpdate
|
|
static async checkRoleChange(
|
|
model: User,
|
|
options: InstanceUpdateOptions<InferAttributes<User>>
|
|
) {
|
|
const previousRole = model.previous("role");
|
|
|
|
if (
|
|
model.changed("role") &&
|
|
previousRole === UserRole.Admin &&
|
|
UserRoleHelper.isRoleLower(model.role, UserRole.Admin)
|
|
) {
|
|
const { count } = await this.findAndCountAll({
|
|
where: {
|
|
teamId: model.teamId,
|
|
role: UserRole.Admin,
|
|
id: {
|
|
[Op.ne]: model.id,
|
|
},
|
|
},
|
|
limit: 1,
|
|
transaction: options.transaction,
|
|
});
|
|
if (count === 0) {
|
|
throw ValidationError("At least one admin is required");
|
|
}
|
|
}
|
|
}
|
|
|
|
@AfterUpdate
|
|
static async updateMembershipPermissions(
|
|
model: User,
|
|
options: InstanceUpdateOptions<InferAttributes<User>>
|
|
) {
|
|
const previousRole = model.previous("role");
|
|
|
|
if (
|
|
previousRole &&
|
|
model.changed("role") &&
|
|
UserRoleHelper.isRoleLower(model.role, UserRole.Member) &&
|
|
!UserRoleHelper.isRoleLower(previousRole, UserRole.Viewer)
|
|
) {
|
|
await UserMembership.update(
|
|
{
|
|
permission: CollectionPermission.Read,
|
|
},
|
|
{
|
|
transaction: options.transaction,
|
|
where: {
|
|
userId: model.id,
|
|
},
|
|
}
|
|
);
|
|
}
|
|
}
|
|
|
|
// When a user's suspension state changes, invalidate the cached member count
|
|
// for every group they belong to so the count reflects only active members.
|
|
@AfterUpdate
|
|
static async invalidateGroupMemberCount(
|
|
model: User,
|
|
options: InstanceUpdateOptions<InferAttributes<User>>
|
|
) {
|
|
if (!model.changed("suspendedAt")) {
|
|
return;
|
|
}
|
|
|
|
const groupUsers = await GroupUser.findAll({
|
|
attributes: ["groupId"],
|
|
where: { userId: model.id },
|
|
transaction: options.transaction,
|
|
raw: true,
|
|
});
|
|
|
|
const groupIds = [
|
|
...new Set(groupUsers.map((groupUser) => groupUser.groupId)),
|
|
];
|
|
|
|
if (!groupIds.length) {
|
|
return;
|
|
}
|
|
|
|
const invalidate = async () => {
|
|
await Promise.all(
|
|
groupIds.map((groupId) =>
|
|
CacheHelper.removeData(
|
|
RedisPrefixHelper.getCounterCacheKey("Group", "members", groupId)
|
|
)
|
|
)
|
|
);
|
|
};
|
|
|
|
if (options.transaction) {
|
|
const transaction = options.transaction.parent || options.transaction;
|
|
transaction.afterCommit(invalidate);
|
|
} else {
|
|
await invalidate();
|
|
}
|
|
}
|
|
|
|
@AfterUpdate
|
|
static deletePreviousAvatar = async (model: User) => {
|
|
const previousAvatarUrl = model.previous("avatarUrl");
|
|
if (previousAvatarUrl && previousAvatarUrl !== model.avatarUrl) {
|
|
const attachmentIds = parseAttachmentIds(previousAvatarUrl, true);
|
|
if (!attachmentIds.length) {
|
|
return;
|
|
}
|
|
|
|
const attachment = await Attachment.findOne({
|
|
where: {
|
|
id: attachmentIds[0],
|
|
teamId: model.teamId,
|
|
userId: model.id,
|
|
},
|
|
});
|
|
|
|
if (attachment) {
|
|
await new DeleteAttachmentTask().schedule({
|
|
attachmentId: attachment.id,
|
|
teamId: model.teamId,
|
|
});
|
|
}
|
|
}
|
|
};
|
|
|
|
static findByEmail = async function (ctx: APIContext, email: string) {
|
|
return this.findOne({
|
|
where: {
|
|
teamId: ctx.state.auth.user.teamId,
|
|
email: email.trim().toLowerCase(),
|
|
},
|
|
...ctx.context,
|
|
});
|
|
};
|
|
|
|
static getCounts = async function (teamId: string) {
|
|
const countSql = `
|
|
SELECT
|
|
COUNT(CASE WHEN "suspendedAt" IS NOT NULL THEN 1 END) as "suspendedCount",
|
|
COUNT(CASE WHEN "role" = :roleAdmin THEN 1 END) as "adminCount",
|
|
COUNT(CASE WHEN "role" = :roleViewer 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,
|
|
roleAdmin: UserRole.Admin,
|
|
roleViewer: UserRole.Viewer,
|
|
},
|
|
});
|
|
|
|
const counts: {
|
|
activeCount: string;
|
|
adminCount: string;
|
|
invitedCount: string;
|
|
suspendedCount: string;
|
|
viewerCount: string;
|
|
count: string;
|
|
} = results;
|
|
|
|
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),
|
|
};
|
|
};
|
|
}
|
|
|
|
export default User;
|