import bcrypt from "bcryptjs"; import { identity, isEmpty, pickBy } from "lodash"; import { DateTime, DurationObject } from "luxon"; import { Db, MongoError } from "mongodb"; import uuid from "uuid"; import { DeepPartial, Omit, Sub } from "coral-common/types"; import { dotize } from "coral-common/utils/dotize"; import { ConfirmEmailTokenExpired, DuplicateEmailError, DuplicateUserError, LocalProfileAlreadySetError, LocalProfileNotSetError, PasswordResetTokenExpired, TokenNotFoundError, UserAlreadyBannedError, UserAlreadyPremoderated, UserAlreadySuspendedError, UsernameAlreadySetError, UserNotFoundError, } from "coral-server/errors"; import logger from "coral-server/logger"; import { Connection, ConnectionInput, Query, resolveConnection, } from "coral-server/models/helpers"; import { TenantResource } from "coral-server/models/tenant"; import { DigestibleTemplate } from "coral-server/queue/tasks/mailer/templates"; import { users as collection } from "coral-server/services/mongodb/collections"; import { GQLBanStatus, GQLDIGEST_FREQUENCY, GQLPremodStatus, GQLSuspensionStatus, GQLTimeRange, GQLUSER_ROLE, GQLUsernameStatus, GQLUserNotificationSettings, } from "coral-server/graph/tenant/schema/__generated__/types"; import { CommentStatusCounts, createEmptyCommentStatusCounts, } from "../comment"; import { getLocalProfile, hasLocalProfile } from "./helpers"; export interface LocalProfile { type: "local"; id: string; password: string; /** * passwordID is used to help protect against double password change race * conditions. Because the password cannot be compared against directly, this * ID can be used as it is only changed when the password is changed. */ passwordID: string; /** * resetID is used during a password reset process to prevent replay attacks. * When a password reset email is sent, a resetID is associated with the * account and the token. When a given reset token is used, it is cleared from * the user, preventing the same reset URL from being used multiple times. */ resetID?: string; } export interface OIDCProfile { type: "oidc"; id: string; issuer: string; audience: string; } export interface SSOProfile { type: "sso"; id: string; lastIssuedAt: Date; } export interface FacebookProfile { type: "facebook"; id: string; } export interface GoogleProfile { type: "google"; id: string; } /** * Profile is all the different profiles that a given User may have associated * with their account. */ export type Profile = | LocalProfile | OIDCProfile | SSOProfile | FacebookProfile | GoogleProfile; export interface Token { readonly id: string; name: string; createdAt: Date; } /** * ModeratorNote ModeratorNote is a note left by a moderator on the subject of a user. */ export interface ModeratorNote { /** * id is the identifier of the Note. */ id: string; /** * body is the content of the Note */ body: string; /** * createdAt is the date in which the Note was created. */ createdAt: Date; /** * createdBy is the Moderator that authored the Note */ createdBy: string; } /** * SuspensionStatusHistory SuspensionStatusHistory is the list of all suspension * events against a specific User. */ export interface SuspensionStatusHistory { /** * id is a specific reference for a particular suspension status that will be * used internally to update suspension records. */ id: string; /** * from represents a range of time where a user suspension applies. */ from: GQLTimeRange; /** * createdBy is the ID for the User that suspended the User. If `null`, the * suspension was created by the system. */ createdBy?: string; /** * createdAt is the time that the given suspension time frame was created. */ createdAt: Date; /** * modifiedBy is the ID for the User that modified the suspension for this * User. If `null`, the suspension has not been edited, or has been edited by * the system. */ modifiedBy?: string; /** * modifiedAt is the time that the date that the given suspension time frame * was edited at. */ modifiedAt?: Date; /** * message is the email message content sent to the user. */ message: string; } /** * SuspensionStatus stores the user suspension status as well as the history of * changes. */ export interface SuspensionStatus { /** * history is the list of all suspension events against a specific User. */ history: SuspensionStatusHistory[]; } /** * BanStatusHistory is the list of all ban events against a specific User. */ export interface BanStatusHistory { /** * id is a specific reference for a particular banned status that will be * used internally to update banned records. */ id: string; /** * active, when true, indicates that the user is banned from this status. */ active: boolean; /** * createdBy is the ID for the User that banned the User. If `null`, the ban * was created by the system. */ createdBy?: string; /** * createdAt is the time that the given ban was added. */ createdAt: Date; message?: string; } /** * BanStatus contains information about a ban for a given User. */ export interface BanStatus { /** * active when true, indicates that the given user is banned. */ active: boolean; /** * history is the list of all ban events against a specific User. */ history: BanStatusHistory[]; } export interface UsernameHistory { /** * id is a specific reference for a particular username history that will be * used internally to update username records. */ id: string; /** * username is the username that was assigned */ username: string; /** * createdBy is the user that created this username */ createdBy: string; /** * createdAt is the time the username was created */ createdAt: Date; } export interface UsernameStatus { /** * history is the list of all usernames for this user */ history: UsernameHistory[]; } /** * PremodStatusHistory is the history of premod status changes * against a specific User. */ export interface PremodStatusHistory { /** * active when true, indicates that the given user is premodded. */ active: boolean; /** * createdBy is the ID for the User that premodded the User. If `null`, the * premod was created by the system. */ createdBy?: string; /** * createdAt is the time that the given premod status was set. */ createdAt: Date; } /** * PremodStatus is the status of whether a user is set to mandatory premod */ export interface PremodStatus { /** * active when true, indicates that the given user is set to mandatory premod. */ active: boolean; /** * history is a list of previous enable/disable of premod status */ history: PremodStatusHistory[]; } /** * UserStatus stores the user status information regarding moderation state. */ export interface UserStatus { /** * suspension stores the user suspension status as well as the history of * changes. */ suspension: SuspensionStatus; /** * ban stores the user ban status as well as the history of changes. */ ban: BanStatus; /** * username stores the history of username changes for this user. */ username: UsernameStatus; /** * premod stores whether a user is set to mandatory premod and history of * premod status. */ premod: PremodStatus; } /** * IgnoredUser is the entry describing a User being ignored. */ export interface IgnoredUser { /** * id is the ID of the User that was ignored. */ id: string; /** * createdAt is the date that the User was ignored on. */ createdAt: Date; } /** * Digest is the actual digest entry that is created every time a digest is * queued for aUser. */ export interface Digest { /** * template is a given digestable template that was generated during the * notification processing phase. This contains the context typically provided * to the individual notification emails that are sent. */ template: DigestibleTemplate; /** * createdAt is the date that the digest entry was created at. */ createdAt: Date; } export interface UserCommentCounts { status: CommentStatusCounts; } /** * User is someone that leaves Comments, and logs in. */ export interface User extends TenantResource { /** * id is the identifier of the User. */ readonly id: string; /** * username is the name of the User visible to other Users. */ username?: string; /** * avatar is the url to the avatar for a specific User. */ avatar?: string; /** * email is the current email address for the User. */ email?: string; /** * * badges are user display badges */ badges?: string[]; /** * emailVerificationID is used to store state regarding the verification state * of an email address to prevent replay attacks. */ emailVerificationID?: string; /** * emailVerified when true indicates that the given email address has been verified. */ emailVerified?: boolean; /** * profiles is the array of profiles assigned to the user. When a user deletes * their account, this is unset. */ profiles?: Profile[]; /** * tokens lists the access tokens associated with the account. */ tokens: Token[]; /** * role is the current role of the User. */ role: GQLUSER_ROLE; /** * notifications stores the notification settings for the given User. */ notifications: GQLUserNotificationSettings; /** * digests stores all the notification digests on the User that are scheduled * to be sent out based on the User's notification preferences. */ digests: Digest[]; /** * hasDigests is true when there is digests to send. */ hasDigests?: boolean; /** * status stores the user status information regarding moderation state. */ status: UserStatus; /** * ignoredUsers stores the users that have been ignored by this User. */ ignoredUsers: IgnoredUser[]; /** * moderatorNotes are notes left by moderators about the User. */ moderatorNotes: ModeratorNote[]; /** * lastDownloadedAt is the last time the user requested to download their * user data. */ lastDownloadedAt?: Date; /** * createdAt is the time that the User was created at. */ createdAt: Date; /** * scheduledDeletionDate is the time that a user is scheduled to be deleted. * If this is null, the user has not requested for their account to be deleted. */ scheduledDeletionDate?: Date; /** * deletedAt is the time that this user was deleted from our system. */ deletedAt?: Date; /** * commentCounts are the tallies of all comment statuses for this * user. */ commentCounts: UserCommentCounts; } function hashPassword(password: string): Promise { return bcrypt.hash(password, 10); } export interface FindOrCreateUserInput { id?: string; username?: string; avatar?: string; email?: string; badges?: string[]; emailVerified?: boolean; role: GQLUSER_ROLE; profile: Profile; } /** * findOrCreateUserInput converts the FindOrCreateUserInput input into aUse. * * @param tenantID ID of the Tenant to create the user for * @param input the input for creating a User * @param now the current date */ async function findOrCreateUserInput( tenantID: string, { id = uuid.v4(), profile, ...input }: FindOrCreateUserInput, now: Date ): Promise> { // default are the properties set by the application when a new user is // created. const defaults: Sub = { tenantID, tokens: [], ignoredUsers: [], status: { username: { history: [], }, suspension: { history: [] }, ban: { active: false, history: [] }, premod: { active: false, history: [] }, }, notifications: { onReply: false, onFeatured: false, onModeration: false, onStaffReplies: false, digestFrequency: GQLDIGEST_FREQUENCY.NONE, }, moderatorNotes: [], digests: [], createdAt: now, commentCounts: { status: createEmptyCommentStatusCounts(), }, }; if (input.username) { // Add the username history to the user. defaults.status.username.history.push({ id: uuid.v4(), username: input.username, createdBy: id, createdAt: now, }); } // Store the user's profiles in a new array. const profiles: Profile[] = []; // Mutate the profiles to ensure we mask handle any secrets. switch (profile.type) { case "local": { // Hash the user's password with bcrypt. const password = await hashPassword(profile.password); profiles.push({ ...profile, password }); break; } default: // Push the profile onto the User. profiles.push(profile); break; } // Merge the defaults and the input together. return { ...defaults, ...input, profiles, id, }; } export async function findOrCreateUser( mongo: Db, tenantID: string, input: FindOrCreateUserInput, now: Date ) { const user = await findOrCreateUserInput(tenantID, input, now); try { await collection(mongo).findOneAndUpdate( { tenantID, profiles: { $elemMatch: { id: input.profile.id, type: input.profile.type, }, }, }, { $setOnInsert: user }, { // False to return the updated document instead of the original // document. returnOriginal: false, upsert: true, } ); } catch (err) { // Evaluate the error, if it is in regards to violating the unique index, // then return a duplicate User error. if (err instanceof MongoError && err.code === 11000) { // Check if duplicate index was about the email. if (err.errmsg && err.errmsg.includes("tenantID_1_email_1")) { throw new DuplicateEmailError(input.email!); } throw new DuplicateUserError(); } throw err; } return user; } export type CreateUserInput = FindOrCreateUserInput; export async function createUser( mongo: Db, tenantID: string, input: CreateUserInput, now: Date ) { const user = await findOrCreateUserInput(tenantID, input, now); try { // Insert it into the database. This may throw an error. await collection(mongo).insertOne(user); } catch (err) { // Evaluate the error, if it is in regards to violating the unique index, // then return a duplicate User error. if (err instanceof MongoError && err.code === 11000) { // Check if duplicate index was about the email. if (err.errmsg && err.errmsg.includes("tenantID_1_email_1")) { throw new DuplicateEmailError(input.email!); } throw new DuplicateUserError(); } throw err; } return user; } export async function retrieveUser(mongo: Db, tenantID: string, id: string) { return collection(mongo).findOne({ tenantID, id }); } export async function retrieveManyUsers( mongo: Db, tenantID: string, ids: string[] ) { const cursor = collection(mongo).find({ tenantID, id: { $in: ids, }, }); const users = await cursor.toArray(); return ids.map(id => users.find(user => user.id === id) || null); } export async function retrieveUserWithProfile( mongo: Db, tenantID: string, profile: Partial> ) { return collection(mongo).findOne({ tenantID, profiles: { $elemMatch: profile, }, }); } export async function retrieveUserWithEmail( mongo: Db, tenantID: string, email: string ) { return collection(mongo).findOne({ tenantID, $or: [ { profiles: { $elemMatch: { id: email, type: "local" }, }, }, { email }, ], }); } /** * updateUserRole updates a given User's role. * * @param mongo mongodb database to interact with * @param tenantID Tenant ID where the User resides * @param id ID of the User that we are updating * @param role new role to set to the User */ export async function updateUserRole( mongo: Db, tenantID: string, id: string, role: GQLUSER_ROLE ) { const result = await collection(mongo).findOneAndUpdate( { id, tenantID }, { $set: { role } }, { // False to return the updated document instead of the original // document. returnOriginal: false, } ); if (!result.value) { throw new UserNotFoundError(id); } return result.value; } export async function verifyUserPassword( user: Pick, password: string, withEmail?: string ) { const profile = getLocalProfile(user, withEmail); if (!profile) { throw new LocalProfileNotSetError(); } return bcrypt.compare(password, profile.password); } export async function updateUserPassword( mongo: Db, tenantID: string, id: string, password: string, passwordID: string ) { // Hash the password. const hashedPassword = await hashPassword(password); // Update the user with the new password. const result = await collection(mongo).findOneAndUpdate( { tenantID, id, // This ensures that the document we're updating already has a local // profile associated with them. profiles: { $elemMatch: { type: "local", passwordID, }, }, }, { $set: { // Update the passwordID with a new one. "profiles.$[profiles].passwordID": uuid(), "profiles.$[profiles].password": hashedPassword, }, }, { arrayFilters: [{ "profiles.type": "local" }], // False to return the updated document instead of the original // document. returnOriginal: false, } ); if (!result.value) { const user = await retrieveUser(mongo, tenantID, id); if (!user) { throw new UserNotFoundError(id); } const profile = getLocalProfile(user); if (!profile) { throw new LocalProfileNotSetError(); } if (profile.passwordID !== passwordID) { throw new Error("passwordID mismatch"); } throw new Error("an unexpected error occurred"); } return result.value || null; } export async function scheduleDeletionDate( mongo: Db, tenantID: string, userID: string, deletionDate: Date ) { const result = await collection(mongo).findOneAndUpdate( { id: userID, tenantID, }, { $set: { scheduledDeletionDate: deletionDate, }, }, { returnOriginal: false, } ); if (!result.value) { throw new Error("Unable to update user deletion date."); } return result.value || null; } export async function clearDeletionDate( mongo: Db, tenantID: string, userID: string ) { const result = await collection(mongo).findOneAndUpdate( { id: userID, tenantID, }, { $unset: { scheduledDeletionDate: "", }, }, { // We want to return edited user so that // we send back the cleared scheduledDeletionDate // to the client returnOriginal: false, } ); if (!result.value) { throw new Error("Unable to update user deletion date."); } return result.value || null; } export interface UpdateUserInput { email?: string; username?: string; badges?: string[]; role?: GQLUSER_ROLE; } export async function updateUserFromSSO( mongo: Db, tenantID: string, id: string, update: UpdateUserInput, lastIssuedAt: Date ) { // Update the user with the new properties. const result = await collection(mongo).findOneAndUpdate( { tenantID, id, // This ensures that the document we're updating already has a sso // profile associated with them. "profiles.type": "sso", }, { $set: { "profiles.$[profiles].lastIssuedAt": lastIssuedAt, ...update, }, }, { arrayFilters: [{ "profiles.type": "sso" }], // False to return the updated document instead of the original // document. returnOriginal: false, } ); if (!result.value) { const user = await retrieveUserWithProfile(mongo, tenantID, { type: "sso", id, }); if (!user) { throw new UserNotFoundError(id); } throw new Error("an unexpected error occurred"); } return result.value || null; } /** * setUserUsername will set the username of the User if the username hasn't * already been used before. * * @param mongo the database handle * @param tenantID the ID to the Tenant * @param id the ID of the User where we are setting the username on * @param username the username that we want to set */ export async function setUserUsername( mongo: Db, tenantID: string, id: string, username: string ) { // TODO: (wyattjoh) investigate adding the username previously used to an array. // The username wasn't found, so add it to the user. const result = await collection(mongo).findOneAndUpdate( { tenantID, id, username: null, }, { $set: { username, }, }, { // False to return the updated document instead of the original // document. returnOriginal: false, } ); if (!result.value) { // Try to get the current user to discover what happened. const user = await retrieveUser(mongo, tenantID, id); if (!user) { throw new UserNotFoundError(id); } if (user.username) { throw new UsernameAlreadySetError(); } throw new Error("an unexpected error occurred"); } return result.value; } /** * updateUsername will set the username of the User. * * @param mongo the database handle * @param tenantID the ID to the Tenant * @param id the ID of the User where we are setting the username on * @param username the username that we want to set * @param createdBy the user making the change */ export async function updateUserUsername( mongo: Db, tenantID: string, id: string, username: string, createdBy: string, now = new Date() ) { const usernameHistory: UsernameHistory = { id: uuid(), username, createdBy, createdAt: now, }; const result = await collection(mongo).findOneAndUpdate( { id, tenantID }, { $set: { username, }, $push: { "status.username.history": usernameHistory, }, }, { // False to return the updated document instead of the original // document. returnOriginal: false, } ); if (!result.value) { const user = await retrieveUser(mongo, tenantID, id); if (!user) { throw new UserNotFoundError(id); } throw new Error("an unexpected error occurred"); } return result.value; } /** * setUserEmail will set the email address of the User if they don't already * have one associated with them, and it hasn't been used before. * * @param mongo the database handle * @param tenantID the ID to the Tenant * @param id the ID of the User where we are setting the email address on * @param emailAddress the email address we want to set */ export async function setUserEmail( mongo: Db, tenantID: string, id: string, emailAddress: string ) { // Lowercase the email address. const email = emailAddress.toLowerCase(); // Search to see if this email has been used before. let user = await collection(mongo).findOne({ tenantID, email, }); if (user) { throw new DuplicateEmailError(email); } // The email wasn't found, so try to update the User. const result = await collection(mongo).findOneAndUpdate( { tenantID, id, email: null, }, { $set: { email, }, }, { // False to return the updated document instead of the original // document. returnOriginal: false, } ); if (!result.value) { // Try to get the current user to discover what happened. user = await retrieveUser(mongo, tenantID, id); if (!user) { throw new UserNotFoundError(id); } if (user.email) { throw new UsernameAlreadySetError(); } throw new Error("an unexpected error occurred"); } return result.value; } /** * updateUserEmail will update a given User's email address to the one provided. * * @param mongo the database that we are interacting with * @param tenantID the Tenant ID of the Tenant where the User exists * @param id the User ID that we are updating * @param emailAddress email address that we are setting on the User * @param emailVerified whether email is verified */ export async function updateUserEmail( mongo: Db, tenantID: string, id: string, emailAddress: string, emailVerified = false ) { // Lowercase the email address. const email = emailAddress.toLowerCase(); try { // The email wasn't found, so try to update the User. const result = await collection(mongo).findOneAndUpdate( { tenantID, id, }, { $set: { email, emailVerified, "profiles.$[profiles].id": email, }, }, { arrayFilters: [{ "profiles.type": "local" }], returnOriginal: false, } ); if (!result.value) { // Try to get the current user to discover what happened. const user = await retrieveUser(mongo, tenantID, id); if (!user) { throw new UserNotFoundError(id); } throw new Error("an unexpected error occurred"); } return result.value; } catch (err) { if (err instanceof MongoError && err.code === 11000) { throw new DuplicateEmailError(email); } throw err; } } /** * updateUserAvatar will update the avatar associated with a User. If the avatar * is not provided, it will be unset. * * @param mongo the database that we are interacting with * @param tenantID the Tenant ID of the Tenant where the User exists * @param id the User ID that we are updating * @param avatar URL that the avatar exists at */ export async function updateUserAvatar( mongo: Db, tenantID: string, id: string, avatar?: string ) { // The email wasn't found, so try to update the User. const result = await collection(mongo).findOneAndUpdate( { tenantID, id, }, { // This will ensure that if the avatar isn't provided, it will unset the // avatar on the User. [avatar ? "$set" : "$unset"]: { avatar: avatar ? avatar : 1, }, }, { // False to return the updated document instead of the original // document. returnOriginal: false, } ); if (!result.value) { // Try to get the current user to discover what happened. const user = await retrieveUser(mongo, tenantID, id); if (!user) { throw new UserNotFoundError(id); } throw new Error("an unexpected error occurred"); } return result.value; } /** * setUserLocalProfile will set the local profile for a User if they don't * already have one associated with them and the profile doesn't exist on any * other User already. * * @param mongo the database handle * @param tenantID the ID to the Tenant * @param id the ID of the User where we are setting the local profile on * @param emailAddress the email address we want to set * @param password the password we want to set */ export async function setUserLocalProfile( mongo: Db, tenantID: string, id: string, emailAddress: string, password: string ) { // Lowercase the email address. const email = emailAddress.toLowerCase(); // Try to see if this local profile already exists on a User. let user = await retrieveUserWithProfile(mongo, tenantID, { type: "local", id: email, }); if (user) { throw new DuplicateEmailError(email); } // Hash the password. const hashedPassword = await hashPassword(password); // Create the profile that we'll use. const profile: LocalProfile = { type: "local", id: email, password: hashedPassword, passwordID: uuid(), }; // The profile wasn't found, so add it to the User. const result = await collection(mongo).findOneAndUpdate( { tenantID, id, // This ensures that the document we're updating does not contain a local // profile. "profiles.type": { $ne: "local" }, }, { $push: { profiles: profile, }, }, { // False to return the updated document instead of the original // document. returnOriginal: false, } ); if (!result.value) { // Try to get the current user to discover what happened. user = await retrieveUser(mongo, tenantID, id); if (!user) { throw new UserNotFoundError(id); } // Check to see if the user has any local profile (not just a local profile // with a specific email). if (hasLocalProfile(user)) { throw new LocalProfileAlreadySetError(); } throw new Error("an unexpected error occurred"); } return result.value; } export async function createUserToken( mongo: Db, tenantID: string, userID: string, name: string, now = new Date() ) { // Create the Token that we'll be adding to the User. const token: Readonly = { id: uuid.v4(), name, createdAt: now, }; const result = await collection(mongo).findOneAndUpdate( { id: userID, tenantID, }, { $push: { tokens: token }, }, { // False to return the updated document instead of the original // document. returnOriginal: false, } ); if (!result.value) { throw new UserNotFoundError(userID); } return { user: result.value, token, }; } export async function deactivateUserToken( mongo: Db, tenantID: string, userID: string, id: string ) { // Try to remove the Token from the User. const result = await collection(mongo).findOneAndUpdate( { id: userID, tenantID, "tokens.id": id, }, { $pull: { tokens: { id } }, }, { // True to return the original document instead of the updated // document. returnOriginal: true, } ); if (!result.value) { const user = await retrieveUser(mongo, tenantID, userID); if (!user) { throw new UserNotFoundError(id); } // Check to see if the User had that Token in the first place. if (!user.tokens.find(t => t.id === id)) { throw new TokenNotFoundError(); } throw new Error("an unexpected error occurred"); } // We have to typecast here because we know at this point that the record does // contain the Token. const token: Token = result.value.tokens.find(t => t.id === id) as Token; // Mutate the user in order to remove the Token from the list of Token's. const updatedUser: Readonly = { ...result.value, tokens: result.value.tokens.filter(t => t.id !== id), }; return { user: updatedUser, token, }; } export type UserConnectionInput = ConnectionInput; export async function retrieveUserConnection( mongo: Db, tenantID: string, input: UserConnectionInput ): Promise>>> { // Create the query. const query = new Query(collection(mongo)).where({ tenantID }); // If a filter is being applied, filter it as well. if (input.filter) { query.where(input.filter); } return retrieveConnection(input, query); } async function retrieveConnection( input: UserConnectionInput, query: Query ): Promise>>> { // Apply the pagination arguments to the query. query.orderBy({ createdAt: -1 }); if (input.after) { query.where({ createdAt: { $lt: input.after as Date } }); } // Return a connection. return resolveConnection(query, input, user => user.createdAt); } /** * premodUser will set a user to mandatory premod. * * @param mongo the mongo database handle * @param tenantID the Tenant's ID where the User exists * @param id the ID of the user being banned * @param createdBy the ID of the user premodding * @param now the current date */ export async function premodUser( mongo: Db, tenantID: string, id: string, createdBy: string, now = new Date() ) { // Create the new ban. const premodStatusHistory: PremodStatusHistory = { active: true, createdBy, createdAt: now, }; // Try to update the user if the user isn't already banned. const result = await collection(mongo).findOneAndUpdate( { id, tenantID, "status.premod.active": { $ne: true, }, }, { $set: { "status.premod.active": true, }, $push: { "status.premod.history": premodStatusHistory, }, }, { // False to return the updated document instead of the original // document. returnOriginal: false, } ); if (!result.value) { // Get the user so we can figure out why the ban operation failed. const user = await retrieveUser(mongo, tenantID, id); if (!user) { throw new UserNotFoundError(id); } // Check to see if the user is already banned. const premod = consolidateUserPremodStatus(user.status.premod); if (premod.active) { throw new UserAlreadyPremoderated(); } throw new Error("an unexpected error occurred"); } return result.value; } /** * removeUserPremod will lift a user premod requirement * * @param mongo the mongo database handle * @param tenantID the Tenant's ID where the User exists * @param id the ID of the user having their ban lifted * @param createdBy the ID of the user lifting the premod * @param now the current date */ export async function removeUserPremod( mongo: Db, tenantID: string, id: string, createdBy: string, now = new Date() ) { // Create the new ban. const premod: PremodStatusHistory = { active: false, createdBy, createdAt: now, }; // Try to update the user if the user isn't already banned. const result = await collection(mongo).findOneAndUpdate( { id, tenantID, "status.premod.active": true, }, { $set: { "status.premod.active": false, }, $push: { "status.premod.history": premod, }, }, { // False to return the updated document instead of the original // document. returnOriginal: false, } ); if (!result.value) { // Get the user so we can figure out why the ban operation failed. const user = await retrieveUser(mongo, tenantID, id); if (!user) { throw new UserNotFoundError(id); } // The user wasn't banned already, so nothing needs to be done! return user; } return result.value; } /** * banUser will ban a specific user from interacting with the site. * * @param mongo the mongo database handle * @param tenantID the Tenant's ID where the User exists * @param id the ID of the user being banned * @param createdBy the ID of the user banning the above mentioned user * @param message message to banned user * @param now the current date */ export async function banUser( mongo: Db, tenantID: string, id: string, createdBy: string, message?: string, now = new Date() ) { // Create the new ban. const banHistory: BanStatusHistory = { id: uuid(), active: true, createdBy, createdAt: now, message, }; // Try to update the user if the user isn't already banned. const result = await collection(mongo).findOneAndUpdate( { id, tenantID, "status.ban.active": { $ne: true, }, }, { $set: { "status.ban.active": true, }, $push: { "status.ban.history": banHistory, }, }, { // False to return the updated document instead of the original // document. returnOriginal: false, } ); if (!result.value) { // Get the user so we can figure out why the ban operation failed. const user = await retrieveUser(mongo, tenantID, id); if (!user) { throw new UserNotFoundError(id); } // Check to see if the user is already banned. const ban = consolidateUserBanStatus(user.status.ban); if (ban.active) { throw new UserAlreadyBannedError(); } throw new Error("an unexpected error occurred"); } return result.value; } /** * removeUserBan will lift a user ban from a User allowing them to interact with * the site again. * * @param mongo the mongo database handle * @param tenantID the Tenant's ID where the User exists * @param id the ID of the user having their ban lifted * @param createdBy the ID of the user lifting the ban * @param now the current date */ export async function removeUserBan( mongo: Db, tenantID: string, id: string, createdBy: string, now = new Date() ) { // Create the new ban. const ban: BanStatusHistory = { id: uuid(), active: false, createdBy, createdAt: now, }; // Try to update the user if the user isn't already banned. const result = await collection(mongo).findOneAndUpdate( { id, tenantID, $or: [ { "status.ban.active": { $ne: false, }, }, { "status.ban.history": { $size: 0, }, }, ], }, { $set: { "status.ban.active": false, }, $push: { "status.ban.history": ban, }, }, { // False to return the updated document instead of the original // document. returnOriginal: false, } ); if (!result.value) { // Get the user so we can figure out why the ban operation failed. const user = await retrieveUser(mongo, tenantID, id); if (!user) { throw new UserNotFoundError(id); } // The user wasn't banned already, so nothing needs to be done! return user; } return result.value; } /** * suspendUser will suspend a user for a specific time range from interacting * with the site. * * @param mongo the mongo database handle * @param tenantID the Tenant's ID where the User exists * @param id the ID of the user being suspended * @param createdBy the ID of the user banning the above mentioned user * @param finish the date the suspension ends * @param message the message sent to suspended user in email * @param now the current date */ export async function suspendUser( mongo: Db, tenantID: string, id: string, createdBy: string, finish: Date, message: string, now = new Date() ) { // Create the new suspension. const suspension: SuspensionStatusHistory = { id: uuid(), from: { start: now, finish, }, createdBy, createdAt: now, message, }; // Try to update the user if the user isn't already suspended. const result = await collection(mongo).findOneAndUpdate( { id, tenantID, "status.suspension.history": { $not: { $elemMatch: { "from.start": { $lte: now, }, "from.finish": { $gt: now, }, }, }, }, }, { $push: { "status.suspension.history": suspension, }, }, { // False to return the updated document instead of the original // document. returnOriginal: false, } ); if (!result.value) { // Get the user so we can figure out why the suspend operation failed. const user = await retrieveUser(mongo, tenantID, id); if (!user) { throw new UserNotFoundError(id); } // Check to see if the user is already suspended. const suspended = consolidateUserSuspensionStatus( user.status.suspension, now ); if (suspended.active && suspended.until) { throw new UserAlreadySuspendedError(suspended.until); } throw new Error("an unexpected error occurred"); } return result.value; } /** * removeUserSuspensions will lift any active suspensions. * * @param mongo the mongo database handle * @param tenantID the Tenant's ID where the User exists * @param id the ID of the User having their suspension lifted * @param modifiedBy the ID of the User lifting the suspension * @param now the current date */ export async function removeActiveUserSuspensions( mongo: Db, tenantID: string, id: string, modifiedBy: string, now = new Date() ) { // Prepare the update payload. const update: DeepPartial = { from: { finish: now, }, modifiedAt: now, modifiedBy, }; // Try to update the user suspension times. const result = await collection(mongo).findOneAndUpdate( { tenantID, id }, { $set: dotize({ "status.suspension.history.$[active]": update, }), }, { arrayFilters: [ // Change the finish date on all suspension records that indicate their // active time within our current range. { "active.from.start": { $lte: now }, "active.from.finish": { $gt: now }, }, ], // False to return the updated document instead of the original // document. returnOriginal: false, } ); if (!result.value) { // Get the user so we can figure out why the suspend operation failed. const user = await retrieveUser(mongo, tenantID, id); if (!user) { throw new UserNotFoundError(id); } // The user wasn't already suspended, so nothing needs to be done! return user; } logger.debug({ result }, "finished update operation"); return result.value; } export type ConsolidatedBanStatus = Omit & Pick; export type ConsolidatedUsernameStatus = Omit & Pick; export type ConsolidatedPremodStatus = Omit & Pick; export function consolidateUsernameStatus( username: User["status"]["username"] ) { return username; } export function consolidateUserBanStatus(ban: User["status"]["ban"]) { return ban; } export function consolidateUserPremodStatus(premod: User["status"]["premod"]) { return premod; } export type ConsolidatedSuspensionStatus = Omit< GQLSuspensionStatus, "history" > & Pick; export function consolidateUserSuspensionStatus( suspension: User["status"]["suspension"], now = new Date() ): ConsolidatedSuspensionStatus { return suspension.history.reduce( (status: ConsolidatedSuspensionStatus, history) => { // Check to see if we're currently suspended. if (history.from.start <= now && history.from.finish > now) { status.active = true; // Ensure that we have the furthest suspension finish time. if (!status.until || status.until < history.from.finish) { status.until = history.from.finish; } } return status; }, { active: false, history: suspension.history, } ); } export interface ConsolidatedUserStatus { suspension: ConsolidatedSuspensionStatus; ban: ConsolidatedBanStatus; premod: ConsolidatedPremodStatus; } export function consolidateUserStatus( status: User["status"], now = new Date() ): ConsolidatedUserStatus { // Return the status. return { suspension: consolidateUserSuspensionStatus(status.suspension, now), ban: consolidateUserBanStatus(status.ban), premod: consolidateUserPremodStatus(status.premod), }; } /** * createOrRetrieveUserPasswordResetID will create/retrieve a password reset ID * on the User. * * @param mongo MongoDB instance to interact with * @param tenantID Tenant ID that the User exists on * @param id ID of the User that we are creating a reset ID with */ export async function createOrRetrieveUserPasswordResetID( mongo: Db, tenantID: string, id: string ): Promise { // Create the ID. const resetID = uuid.v4(); // Associate the resetID with the user. const result = await collection(mongo).findOneAndUpdate( { tenantID, id, // This ensures that the document we're updating already has a local // profile associated with them and also doesn't have a resetID. profiles: { $elemMatch: { type: "local", resetID: null, }, }, }, { $set: { "profiles.$[profiles].resetID": resetID, }, }, { arrayFilters: [{ "profiles.type": "local" }], // False to return the updated document instead of the original // document. returnOriginal: false, } ); if (!result.value) { const user = await retrieveUser(mongo, tenantID, id); if (!user) { throw new UserNotFoundError(id); } const localProfile = getLocalProfile(user); if (!localProfile) { throw new LocalProfileNotSetError(); } if (localProfile.resetID) { return localProfile.resetID; } throw new Error("an unexpected error occurred"); } return resetID; } export async function createOrRetrieveUserEmailVerificationID( mongo: Db, tenantID: string, id: string ): Promise { // Create the ID. const emailVerificationID = uuid.v4(); // Associate the resetID with the user. const result = await collection(mongo).findOneAndUpdate( { tenantID, id, // This ensures that we don't set a emailVerificationID when there is one // already. emailVerificationID: null, $or: [{ emailVerified: false }, { emailVerified: null }], }, { $set: { emailVerificationID, }, }, { // False to return the updated document instead of the original // document. returnOriginal: false, } ); if (!result.value) { const user = await retrieveUser(mongo, tenantID, id); if (!user) { throw new UserNotFoundError(id); } if (user.emailVerified) { throw new Error("email address has already been verified"); } if (user.emailVerificationID) { return user.emailVerificationID; } throw new Error("an unexpected error occurred"); } return emailVerificationID; } export async function resetUserPassword( mongo: Db, tenantID: string, id: string, password: string, passwordID: string, resetID: string ) { // Hash the password. const hashedPassword = await hashPassword(password); // Update the user with the new password. const result = await collection(mongo).findOneAndUpdate( { tenantID, id, // This ensures that the document we're updating already has a local // profile associated with them and also matches the resetID specified. profiles: { $elemMatch: { type: "local", passwordID, resetID, }, }, }, { $set: { // Update the passwordID with a new one. "profiles.$[profiles].passwordID": uuid(), "profiles.$[profiles].password": hashedPassword, }, $unset: { "profiles.$[profiles].resetID": "", }, }, { arrayFilters: [{ "profiles.type": "local", "profiles.resetID": resetID }], // False to return the updated document instead of the original // document. returnOriginal: false, } ); if (!result.value) { const user = await retrieveUser(mongo, tenantID, id); if (!user) { throw new UserNotFoundError(id); } const profile = getLocalProfile(user); if (!profile) { throw new LocalProfileNotSetError(); } if (profile.resetID !== resetID) { throw new PasswordResetTokenExpired("reset id mismatch"); } if (profile.passwordID !== passwordID) { throw new PasswordResetTokenExpired("password id mismatch"); } throw new Error("an unexpected error occurred"); } return result.value || null; } export async function confirmUserEmail( mongo: Db, tenantID: string, id: string, email: string, emailVerificationID: string ) { // Update the user with a confirmed email address. const result = await collection(mongo).findOneAndUpdate( { tenantID, id, email, emailVerificationID, }, { $set: { emailVerified: true, }, $unset: { emailVerificationID: "", }, }, { // False to return the updated document instead of the original // document. returnOriginal: false, } ); if (!result.value) { const user = await retrieveUser(mongo, tenantID, id); if (!user) { throw new UserNotFoundError(id); } if (user.email !== email) { throw new ConfirmEmailTokenExpired("email mismatch"); } if (user.emailVerificationID !== emailVerificationID) { throw new ConfirmEmailTokenExpired("email verification id mismatch"); } throw new Error("an unexpected error occurred"); } return result.value || null; } export async function ignoreUser( mongo: Db, tenantID: string, id: string, ignoreUserID: string, now = new Date() ) { const ignoredUser: IgnoredUser = { id: ignoreUserID, createdAt: now, }; const result = await collection(mongo).findOneAndUpdate( { id, tenantID, ignoredUsers: { $not: { $eq: { id: ignoreUserID, }, }, }, }, { $push: { ignoredUsers: ignoredUser }, }, { // False to return the updated document instead of the original // document. returnOriginal: false, } ); if (!result.value) { // Get the user so we can figure out why the ignore operation failed. const user = await retrieveUser(mongo, tenantID, id); if (!user) { throw new UserNotFoundError(id); } // TODO: extract function if ( user.ignoredUsers && user.ignoredUsers.some(u => u.id === ignoreUserID) ) { // TODO: improve error throw new Error("user already ignored"); } throw new Error("an unexpected error occurred"); } return result.value; } export async function removeUserIgnore( mongo: Db, tenantID: string, id: string, ignoreUserID: string ) { const result = await collection(mongo).findOneAndUpdate( { id, tenantID, "ignoredUsers.id": ignoreUserID, }, { $pull: { ignoredUsers: { id: ignoreUserID } }, }, { // False to return the updated document instead of the original // document. returnOriginal: false, } ); if (!result.value) { // Get the user so we can figure out why the ignore operation failed. const user = await retrieveUser(mongo, tenantID, id); if (!user) { throw new UserNotFoundError(id); } // TODO: extract function if ( user.ignoredUsers && user.ignoredUsers.every(u => u.id !== ignoreUserID) ) { // TODO: improve error throw new Error("user already not ignored"); } throw new Error("an unexpected error occurred"); } return result.value; } export async function setUserLastDownloadedAt( mongo: Db, tenantID: string, id: string, now: Date ) { const result = await collection(mongo).findOneAndUpdate( { id, tenantID, }, { $set: { lastDownloadedAt: now }, }, { // False to return the updated document instead of the original // document. returnOriginal: false, } ); if (!result.value) { // Get the user so we can figure out why the ignore operation failed. const user = await retrieveUser(mongo, tenantID, id); if (!user) { throw new UserNotFoundError(id); } throw new Error("an unexpected error occurred"); } return result.value; } export type NotificationSettingsInput = Partial; export async function updateUserNotificationSettings( mongo: Db, tenantID: string, id: string, settings: NotificationSettingsInput ) { const result = await collection(mongo).findOneAndUpdate( { id, tenantID, }, { $set: dotize({ notifications: settings, }), }, { // False to return the updated document instead of the original // document. returnOriginal: false, } ); if (!result.value) { // Get the user so we can figure out why the update operation failed. const user = await retrieveUser(mongo, tenantID, id); if (!user) { throw new UserNotFoundError(id); } throw new Error("an unexpected error occurred"); } return result.value; } /** * insertUserNotificationDigests will push the notification contexts onto the * User so that notifications can now be queued. * * @param mongo the database to put the notification digests into * @param tenantID the ID of the Tenant that this User exists on * @param id the ID of the User to insert the digests onto * @param templates the templates that represent the digests to be inserted * @param now the current time */ export async function insertUserNotificationDigests( mongo: Db, tenantID: string, id: string, templates: DigestibleTemplate[], now: Date ) { // Form the templates into digests to be sent. const digests: Digest[] = templates.map(template => ({ template, createdAt: now, })); const result = await collection(mongo).findOneAndUpdate( { id, tenantID, }, { $push: { digests: { $each: digests }, }, $set: { hasDigests: true, }, }, { // False to return the updated document instead of the original // document. returnOriginal: false, } ); if (!result.value) { // Get the user so we can figure out why the update operation failed. const user = await retrieveUser(mongo, tenantID, id); if (!user) { throw new UserNotFoundError(id); } throw new Error("an unexpected error occurred"); } return result.value; } /** * pullUserNotificationDigests will pull notification digests for a given User * so it can be added to the mailer queue. * * @param mongo the database to pull digests from * @param tenantID the tenant ID to pull digests for * @param frequency the frequency that we're scanning for to limit the digest * operation */ export async function pullUserNotificationDigests( mongo: Db, tenantID: string, frequency: GQLDIGEST_FREQUENCY ) { const result = await collection(mongo).findOneAndUpdate( { tenantID, "notifications.digestFrequency": frequency, hasDigests: true, }, { $set: { digests: [], hasDigests: false } }, { // True to return the original document instead of the updated document. returnOriginal: true, } ); return result.value || null; } /** * retrieveUserScheduledForDeletion will return a user that is scheduled for * deletion as well as create a reschedule date in the future. * * @param mongo the database to pull scheduled users to delete from * @param tenantID the tenant ID to pull users that have been scheduled for * deletion on * @param rescheduledDuration duration in which to reschedule * @param now the current time */ export async function retrieveUserScheduledForDeletion( mongo: Db, tenantID: string, rescheduledDuration: DurationObject, now: Date ) { const rescheduledDeletionDate = DateTime.fromJSDate(now) .plus(rescheduledDuration) .toJSDate(); const result = await collection(mongo).findOneAndUpdate( { tenantID, scheduledDeletionDate: { $lte: now }, }, { $set: { scheduledDeletionDate: rescheduledDeletionDate, }, }, { // We want to get back the user with // modified scheduledDeletionDate returnOriginal: false, } ); return result.value || null; } /** * createModeratorNote will add a note to a users account * * @param mongo the database to put the notification digests into * @param tenantID the ID of the Tenant that this User exists on * @param id the ID of the User who is the subject of the note * @param createdBy the ID of Moderator that is creating the note * @param note the contents of the note * @param now the current time that the note was created */ export async function createModeratorNote( mongo: Db, tenantID: string, id: string, createdBy: string, note: string, now = new Date() ) { const moderatorNote: ModeratorNote = { id: uuid(), createdAt: now, body: note, createdBy, }; const result = await collection(mongo).findOneAndUpdate( { id, tenantID }, { $push: { moderatorNotes: moderatorNote, }, }, { // False to return the updated document instead of the original // document. returnOriginal: false, } ); if (!result.value) { throw new UserNotFoundError(id); } return result.value; } /** * deleteModeratorNote will remove a note from a user profile * * @param mongo the database to put the notification digests into * @param tenantID the ID of the Tenant that this User exists on * @param userID the ID of the user * @param id the ID of the note to delete * @param createdBy the ID of the note author */ export async function deleteModeratorNote( mongo: Db, tenantID: string, userID: string, id: string, createdBy: string ) { const result = await collection(mongo).findOneAndUpdate( { id: userID, tenantID, }, { $pull: { moderatorNotes: { id, createdBy }, }, }, { returnOriginal: false, } ); if (!result.value) { throw new UserNotFoundError(id); } return result.value; } export async function updateUserCommentCounts( mongo: Db, tenantID: string, id: string, commentCounts: DeepPartial ) { // Update all the specific comment moderation queue counts. const update: DeepPartial = { commentCounts }; const $inc = pickBy(dotize(update), identity); if (isEmpty($inc)) { // Nothing needs to be incremented, just return the User. return retrieveUser(mongo, tenantID, id); } logger.trace({ update: { $inc } }, "incrementing user comment counts"); const result = await collection(mongo).findOneAndUpdate( { id, tenantID }, { $inc }, { // False to return the updated document instead of the original // document. returnOriginal: false, } ); return result.value || null; }