Ctrl K
ComunidadGuías…

server/models/User.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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 { LockHelper } from "@server/storage/LockHelper";
import { afterCommit } from "@server/storage/transaction";
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 { normalizeIp } from "@server/utils/ip";
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 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",
  MCP = "mcp",
  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" })
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(DataType.STRING)
  email: string | null;

  @NotContainsUrl
  @Length({
    min: 1,
    max: UserValidation.maxNameLength,
    msg: `User name must be between 1 and ${UserValidation.maxNameLength} characters`,
  })
  @Column(DataType.STRING)
  name: string;

  @Default(UserRole.Member)
  @Column(DataType.ENUM(...Object.values(UserRole)))
  role: UserRole;

  @Column(DataType.BLOB)
  @Encrypted
  jwtSecret: string;

  @IsDate
  @Column(DataType.DATE)
  @SkipChangeset
  lastActiveAt: Date | null;

  @IsIP
  @SkipChangeset
  @Column(DataType.STRING)
  get lastActiveIp(): string | null {
    return this.getDataValue("lastActiveIp");
  }

  set lastActiveIp(value: string | null) {
    this.setDataValue("lastActiveIp", normalizeIp(value));
  }

  @IsDate
  @Column(DataType.DATE)
  @SkipChangeset
  lastSignedInAt: Date | null;

  @IsIP
  @SkipChangeset
  @Column(DataType.STRING)
  get lastSignedInIp(): string | null {
    return this.getDataValue("lastSignedInIp");
  }

  set lastSignedInIp(value: string | null) {
    this.setDataValue("lastSignedInIp", normalizeIp(value));
  }

  @IsDate
  @Column(DataType.DATE)
  @SkipChangeset
  lastSigninEmailSentAt: Date | null;

  @IsDate
  @Column(DataType.DATE)
  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 given 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, set `skipCache` to bypass the cached response
   * @returns An array of collection ids
   */
  public collectionIds = async (
    options: FindOptions<Collection> & { skipCache?: boolean } = {}
  ) => {
    const { skipCache, ...findOptions } = options;
    const hasOptions =
      findOptions.transaction ||
      findOptions.paranoid === false ||
      findOptions.lock;

    const fetchCollectionIds = async () => {
      const collectionStubs = await Collection.findAll({
        attributes: ["id"],
        where: {
          teamId: this.teamId,
          // Only the owner can access a personal collection.
          ownerId: {
            [Op.or]: [{ [Op.is]: null }, { [Op.eq]: this.id }],
          },
          [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,
        ...findOptions,
      });

      return Array.from(new Set(collectionStubs.map((c) => c.id)));
    };

    if (hasOptions || skipCache) {
      return fetchCollectionIds();
    }

    return (
      (await CacheHelper.getDataOrSet<string[]>(
        RedisPrefixHelper.getUserCollectionIdsKey(this.id),
        fetchCollectionIds,
        10
      )) ?? []
    );
  };

  /**
   * Updates the lastActiveAt timestamp, at most once every five minutes.
   *
   * @param ctx the request context, if any, used to record the IP and client.
   * @param force whether to update even if the last update was recent.
   * @returns a promise that resolves with the updated user.
   */
  updateActiveAt = async (ctx?: Context, 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 || this.lastActiveAt < fiveMinutesAgo || force) {
      this.lastActiveAt = new Date();
      if (ctx) {
        this.lastActiveIp = ctx.request.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
   */
  getSessionToken = (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, from the IP address that requested
   * it, and expires after 10 minutes.
   *
   * @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 }
  ) {
    // Serialize concurrent deletion within the team, otherwise every request
    // can read the same count and pass the check.
    await LockHelper.acquire(
      model.sequelize,
      `users:${model.teamId}`,
      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;
    }

    // Shares the lock name with the check above, so both counts are taken
    // against a settled team.
    await LockHelper.acquire(
      model.sequelize,
      `users:${model.teamId}`,
      transaction
    );

    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)
    ) {
      // Shares the lock name with the deletion checks, so a demotion and a
      // deletion cannot each count the other account as the remaining admin.
      await LockHelper.acquire(
        model.sequelize,
        `users:${model.teamId}`,
        options.transaction
      );

      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 role changes their set of accessible collections may also
  // change, so invalidate the cached collection ids.
  @AfterUpdate
  static async invalidateCollectionIdsAfterRoleChange(
    model: User,
    options: InstanceUpdateOptions<InferAttributes<User>>
  ) {
    if (!model.changed("role")) {
      return;
    }

    await afterCommit(options.transaction, () =>
      CacheHelper.removeData(
        RedisPrefixHelper.getUserCollectionIdsKey(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;
    }

    await afterCommit(options.transaction, () =>
      Promise.all(
        groupIds.map((groupId) =>
          CacheHelper.removeData(
            RedisPrefixHelper.getCounterCacheKey("Group", "members", groupId)
          )
        )
      )
    );
  }

  @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 (
    this: typeof User,
    ctx: APIContext,
    email: string
  ) {
    return this.findOne({
      where: {
        teamId: ctx.state.auth.user.teamId,
        email: email.trim().toLowerCase(),
      },
      ...ctx.context,
    });
  };

  static getCounts = async function (this: typeof User, 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 [counts] = await this.sequelize!.query<{
      activeCount: string;
      adminCount: string;
      invitedCount: string;
      suspendedCount: string;
      viewerCount: string;
      count: string;
    }>(countSql, {
      type: QueryTypes.SELECT,
      replacements: {
        teamId,
        roleAdmin: UserRole.Admin,
        roleViewer: UserRole.Viewer,
      },
    });

    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;