update user refactor

This commit is contained in:
Alex Holliday
2026-01-16 20:29:54 +00:00
parent 88bb8ef2d3
commit cb5b8e0673
16 changed files with 293 additions and 80 deletions
+13 -4
View File
@@ -37,9 +37,6 @@ import crypto from "crypto";
import { games, GameDig } from "gamedig";
import jmespath from "jmespath";
import { fileURLToPath } from "url";
import { ObjectId } from "mongodb";
// DB Modules
import { NormalizeData, NormalizeDataUptimeDetails } from "../utils/dataUtils.js";
import { GenerateAvatarImage } from "../utils/imageProcessing.js";
@@ -48,7 +45,7 @@ import { ParseBoolean } from "../utils/utils.js";
// Models
import Monitor from "../db/models/Monitor.js";
import User from "../db/models/User.js";
import InviteToken from "../db/models/InviteToken.js";
import InviteToken from "../db/models/Invite.js";
import StatusPage from "../db/models/StatusPage.js";
import Team from "../db/models/Team.js";
import MaintenanceWindow from "../db/models/MaintenanceWindow.js";
@@ -74,10 +71,14 @@ import {
MongoChecksRepository,
MongoMonitorStatsRepository,
MongoStatusPagesRepository,
MongoUsersRepository,
MongoInvitesRepository,
IMonitorsRepository,
IChecksRepository,
IMonitorStatsRepository,
IStatusPagesRepository,
IUsersRepository,
IInvitesRepository,
} from "@/repositories/index.js";
export type InitializedSerivces = {
@@ -107,6 +108,8 @@ export type InitializedSerivces = {
checksRepository: IChecksRepository;
monitorStatsRepository: IMonitorStatsRepository;
statusPagesRepository: IStatusPagesRepository;
usersRepository: IUsersRepository;
invitesRepository: IInvitesRepository;
};
export const initializeServices = async ({
@@ -158,6 +161,8 @@ export const initializeServices = async ({
const checksRepository = new MongoChecksRepository(logger);
const monitorStatsRepository = new MongoMonitorStatsRepository();
const statusPagesRepository = new MongoStatusPagesRepository();
const usersRepository = new MongoUsersRepository();
const invitesRepository = new MongoInvitesRepository();
const networkService = new NetworkService({
axios,
@@ -240,6 +245,8 @@ export const initializeServices = async ({
errorService,
jobQueue: superSimpleQueue,
monitorsRepository,
usersRepository,
invitesRepository,
});
const diagnosticService = new DiagnosticService();
@@ -298,6 +305,8 @@ export const initializeServices = async ({
checksRepository,
monitorStatsRepository,
statusPagesRepository,
usersRepository,
invitesRepository,
};
Object.values(services).forEach((service) => {
+1
View File
@@ -38,6 +38,7 @@ class AuthController {
newUser.email = newUser.email.toLowerCase();
}
await registrationBodyValidation.validateAsync(newUser);
const { user, token } = await this.userService.registerUser(newUser, newUserToken, req.file);
res.status(200).json({
success: true,
+30
View File
@@ -0,0 +1,30 @@
import { Schema, model, type Types } from "mongoose";
import type { Invite } from "@/types/invite.js";
type InviteDocumentBase = Omit<Invite, "id" | "teamId" | "createdAt" | "updatedAt" | "expiry"> & {
teamId: Types.ObjectId;
expiry: Date;
};
interface InviteDocument extends InviteDocumentBase {
_id: Types.ObjectId;
createdAt: Date;
updatedAt: Date;
}
const InviteSchema = new Schema<InviteDocument>(
{
email: { type: String, required: true, unique: true },
teamId: { type: Schema.Types.ObjectId, ref: "Team", immutable: true, required: true },
role: { type: [String], required: true, default: ["user"] },
token: { type: String, required: true },
expiry: { type: Date, default: Date.now, expires: 3600 },
},
{ timestamps: true }
);
const InviteModel = model<InviteDocument>("Invite", InviteSchema);
export type { InviteDocument };
export { InviteModel };
export default InviteModel;
-35
View File
@@ -1,35 +0,0 @@
import mongoose from "mongoose";
const InviteTokenSchema = mongoose.Schema(
{
email: {
type: String,
required: true,
unique: true,
},
teamId: {
type: mongoose.Schema.Types.ObjectId,
ref: "Team",
immutable: true,
required: true,
},
role: {
type: Array,
required: true,
default: ["user"],
},
token: {
type: String,
required: true,
},
expiry: {
type: Date,
default: Date.now,
expires: 3600,
},
},
{
timestamps: true,
}
);
export default mongoose.model("InviteToken", InviteTokenSchema);
+3
View File
@@ -12,3 +12,6 @@ export { default as StatusPageModel } from "@/db/models/StatusPage.js";
export * from "@/db/models/User.js";
export { default as UserModel } from "@/db/models/User.js";
export * from "@/db/models/Invite.js";
export { default as InviteModel } from "@/db/models/Invite.js";
+5 -2
View File
@@ -10,5 +10,8 @@ export { default as MongoMonitorStatsRepository } from "@/repositories/monitor-s
export * from "@/repositories/status-pages/IStatusPagesRepository.js";
export { default as MongoStatusPagesRepository } from "@/repositories/status-pages/MongoStatusPagesRepository.js";
export * from "@/repositories/users/IUserRepository.js";
export { default as MongoUserRepository } from "@/repositories/users/MongoUserRepository.js";
export * from "@/repositories/users/IUsersRepository.js";
export { default as MongoUsersRepository } from "@/repositories/users/MongoUsersRepository.js";
export * from "@/repositories/invites/IInvitesRepository.js";
export { default as MongoInvitesRepository } from "@/repositories/invites/MongoInviteRepository.js";
@@ -0,0 +1,11 @@
import type { Invite } from "@/types/index.js";
export interface IInvitesRepository {
// create
// fetch
findByTokenAndDelete(token: string): Promise<Invite>;
// update
// delete
// other
}
@@ -0,0 +1,45 @@
import { IInvitesRepository } from "@/repositories/index.js";
import type { Invite } from "@/types/index.js";
import { type InviteDocument, InviteModel } from "@/db/models/index.js";
import { AppError } from "@/utils/AppError.js";
import mongoose from "mongoose";
class MongoInvitesRepository implements IInvitesRepository {
private toStringId = (value?: mongoose.Types.ObjectId | string | null): string => {
if (!value) {
return "";
}
return value instanceof mongoose.Types.ObjectId ? value.toString() : String(value);
};
private toDateString = (value?: Date | string | null): string => {
if (!value) {
return new Date(0).toISOString();
}
return value instanceof Date ? value.toISOString() : new Date(value).toISOString();
};
protected toEntity = (doc: InviteDocument): Invite => {
return {
id: this.toStringId(doc._id),
email: doc.email,
teamId: this.toStringId(doc.teamId),
role: doc.role ?? [],
token: doc.token,
expiry: this.toDateString(doc.expiry),
createdAt: this.toDateString(doc.createdAt),
updatedAt: this.toDateString(doc.updatedAt),
};
};
findByTokenAndDelete = async (token: string) => {
const invite = await InviteModel.findOneAndDelete({
token,
});
if (invite === null) {
throw new AppError({ message: "Invite not found", status: 404 });
}
return this.toEntity(invite);
};
}
export default MongoInvitesRepository;
@@ -1,9 +0,0 @@
import type { User } from "@/types/index.js";
export interface IUsersRepository {
// create
// fetch
// update
// delete
// other
}
@@ -0,0 +1,12 @@
import type { User } from "@/types/index.js";
export interface IUsersRepository {
// create
create(user: Partial<User>, imageFile?: Express.Multer.File | null): Promise<User>;
// fetch
findByEmail(email: string): Promise<User>;
// update
updateById(id: string, patch: Partial<User>, file?: Express.Multer.File | null): Promise<User>;
// delete
// other
findSuperAdmin(): Promise<boolean>;
}
@@ -1,4 +0,0 @@
import { IUsersRepository } from "@/repositories/index.js";
class MongoUserRepository implements IUsersRepository {}
export default MongoUserRepository;
@@ -0,0 +1,125 @@
import mongoose from "mongoose";
import { IUsersRepository } from "@/repositories/index.js";
import { UserModel, type UserDocument } from "@/db/models/index.js";
import type { User, UserProfileImage } from "@/types/index.js";
import { GenerateAvatarImage } from "@/utils/imageProcessing.js";
import { ParseBoolean } from "@/utils/utils.js";
import { AppError } from "@/utils/AppError.js";
const SERVICE_NAME = "MongoUsersRepository";
class MongoUsersRepository implements IUsersRepository {
static SERVICE_NAME = SERVICE_NAME;
private toStringId = (value?: mongoose.Types.ObjectId | string | null): string => {
if (!value) {
return "";
}
return value instanceof mongoose.Types.ObjectId ? value.toString() : String(value);
};
private toDateString = (value?: Date | string | null): string => {
if (!value) {
return new Date(0).toISOString();
}
return value instanceof Date ? value.toISOString() : new Date(value).toISOString();
};
private mapProfileImage = (image?: (UserProfileImage & { data?: Buffer }) | null) => {
if (!image) {
return undefined;
}
return {
data: image.data,
contentType: image.contentType,
};
};
protected toEntity = (doc: UserDocument): User => {
return {
id: this.toStringId(doc._id),
firstName: doc.firstName,
lastName: doc.lastName,
email: doc.email,
password: doc.password,
avatarImage: doc.avatarImage ?? undefined,
profileImage: this.mapProfileImage(doc.profileImage),
isActive: doc.isActive ?? false,
isVerified: doc.isVerified ?? false,
role: doc.role ?? [],
teamId: this.toStringId(doc.teamId),
checkTTL: doc.checkTTL ?? undefined,
createdAt: this.toDateString(doc.createdAt),
updatedAt: this.toDateString(doc.updatedAt),
};
};
create = async (user: Partial<User>, imageFile: Express.Multer.File | null) => {
if (imageFile) {
// 1. Save the full size image
user.profileImage = {
data: imageFile.buffer,
contentType: imageFile.mimetype,
};
// 2. Get the avatar sized image
const avatar = await GenerateAvatarImage(imageFile);
user.avatarImage = avatar;
}
const newUser = new UserModel(user);
await newUser.save();
const sanitizedUser = await UserModel.findOne({ _id: newUser._id }).select("-password").select("-profileImage");
if (!sanitizedUser) {
throw new AppError({ message: "Failed to create user", service: SERVICE_NAME, status: 500 });
}
return this.toEntity(sanitizedUser);
};
findByEmail = async (email: string) => {
const user = await UserModel.findOne({ email: email }).select("-profileImage");
if (!user) {
throw new AppError({ message: "User not found", service: SERVICE_NAME, status: 404 });
}
return this.toEntity(user);
};
updateById = async (id: string, patch: Partial<User & { deleteProfileImage?: boolean }>, file?: Express.Multer.File | null): Promise<User> => {
const candidateUser = { ...patch };
if (ParseBoolean(candidateUser.deleteProfileImage) === true) {
candidateUser.profileImage = undefined;
candidateUser.avatarImage = undefined;
} else if (file) {
// 1. Save the full size image
candidateUser.profileImage = {
data: file.buffer,
contentType: file.mimetype,
};
// 2. Get the avatar sized image
const avatar = await GenerateAvatarImage(file);
candidateUser.avatarImage = avatar;
}
const updatedUser = await UserModel.findOneAndUpdate(
{ _id: id },
candidateUser,
{ new: true } // Returns updated user instead of pre-update user
)
.select("-password")
.select("-profileImage");
if (!updatedUser) {
throw new AppError({ message: "User not found", service: SERVICE_NAME, status: 404 });
}
return this.toEntity(updatedUser);
};
findSuperAdmin = async () => {
const superAdmin = await UserModel.findOne({ role: "superadmin" });
if (superAdmin !== null) {
return true;
}
return false;
};
}
export default MongoUsersRepository;
+35 -21
View File
@@ -1,5 +1,7 @@
import { IMonitorsRepository } from "@/repositories/index.js";
import { IInvitesRepository, IMonitorsRepository, IUsersRepository } from "@/repositories/index.js";
import Team from "@/db/models/Team.js";
import type { User } from "@/types/index.js";
import bcrypt from "bcryptjs";
const SERVICE_NAME = "userService";
@@ -16,6 +18,8 @@ class UserService {
private jobQueue: any;
private crypto: any;
private monitorsRepository: IMonitorsRepository;
private usersRepository: IUsersRepository;
private invitesRepository: IInvitesRepository;
constructor({
crypto,
@@ -28,6 +32,8 @@ class UserService {
errorService,
jobQueue,
monitorsRepository,
usersRepository,
invitesRepository,
}: {
crypto: any;
db: any;
@@ -39,6 +45,8 @@ class UserService {
errorService: any;
jobQueue: any;
monitorsRepository: IMonitorsRepository;
usersRepository: IUsersRepository;
invitesRepository: IInvitesRepository;
}) {
this.db = db;
this.emailService = emailService;
@@ -50,6 +58,8 @@ class UserService {
this.jobQueue = jobQueue;
this.crypto = crypto;
this.monitorsRepository = monitorsRepository;
this.usersRepository = usersRepository;
this.invitesRepository = invitesRepository;
}
get serviceName() {
@@ -66,33 +76,37 @@ class UserService {
registerUser = async (user: Partial<User>, inviteToken: string, file: any) => {
// Create a new user
// If superAdmin exists, a token should be attached to all further register requests
const superAdminExists = await this.db.userModule.checkSuperadmin();
const superAdminExists = await this.usersRepository.findSuperAdmin();
if (superAdminExists) {
const invitedUser = await this.db.inviteModule.getInviteTokenAndDelete(inviteToken);
user.role = invitedUser.role;
user.teamId = invitedUser.teamId;
const invite = await this.invitesRepository.findByTokenAndDelete(inviteToken);
user.role = invite.role;
user.teamId = invite.teamId;
} else {
// This is the first account, create JWT secret to use if one is not supplied by env
const jwtSecret = this.crypto.randomBytes(64).toString("hex");
await this.db.settingsModule.updateAppSettings({ jwtSecret });
// Create a new team
const team = new Team({
email: user.email,
});
user.teamId = team._id;
}
const newUser = await this.db.userModule.insertUser({ ...user }, file);
const newUser = await this.usersRepository.create({ ...user }, file);
this.logger.debug({
message: "New user created",
service: SERVICE_NAME,
method: "registerUser",
details: newUser._id,
details: newUser.id,
});
const userForToken = { ...newUser._doc };
delete userForToken.profileImage;
delete userForToken.avatarImage;
delete newUser.profileImage;
delete newUser.avatarImage;
const appSettings = await this.settingsService.getSettings();
const token = this.issueToken(userForToken, appSettings);
const token = this.issueToken(newUser, appSettings);
try {
const html = await this.emailService.buildEmail("welcomeEmailTemplate", {
@@ -120,17 +134,18 @@ class UserService {
loginUser = async (email: string, password: string) => {
// Check if user exists
const user = await this.db.userModule.getUserByEmail(email);
const user = await this.usersRepository.findByEmail(email);
// Compare password
const match = await user.comparePassword(password);
const match = await bcrypt.compare(password, user.password);
if (match !== true) {
throw this.errorService.createAuthenticationError(this.stringService.authIncorrectPassword);
}
// Remove password from user object. Should this be abstracted to DB layer?
const userWithoutPassword = { ...user._doc };
delete userWithoutPassword.password;
delete userWithoutPassword.avatarImage;
const userWithoutPassword = { ...user };
userWithoutPassword.password = "";
userWithoutPassword.avatarImage = "";
// Happy path, return token
const appSettings = await this.settingsService.getSettings();
@@ -140,16 +155,16 @@ class UserService {
return { user: userWithoutPassword, token };
};
editUser = async (updates: any, file: any, currentUser: any) => {
editUser = async (updates: Partial<User & { newPassword?: string }>, file: any, currentUser: any) => {
// Change Password check
if (updates?.password && updates?.newPassword) {
// Get user's email
// Add user email to body for DB operation
updates.email = currentUser.email;
// Get user
const user = await this.db.userModule.getUserByEmail(currentUser.email);
const user = await this.usersRepository.findByEmail(currentUser.email);
// Compare passwords
const match = await user.comparePassword(updates?.password);
const match = await bcrypt.compare(updates?.password, user.password);
// If not a match, throw a 403
// 403 instead of 401 to avoid triggering axios interceptor
if (!match) {
@@ -159,8 +174,7 @@ class UserService {
updates.password = updates.newPassword;
}
const updatedUser = await this.db.userModule.updateUser({ userId: currentUser?._id, user: updates, file: file });
return updatedUser;
return await this.usersRepository.updateById(currentUser.id, updates, file);
};
checkSuperadminExists = async () => {
+1
View File
@@ -4,3 +4,4 @@ export * from "@/types/monitorStats.js";
export * from "@/types/statusPage.js";
export * from "@/types/network.js";
export * from "@/types/user.js";
export * from "@/types/invite.js";
+12
View File
@@ -0,0 +1,12 @@
import { UserRole } from "./user.js";
export interface Invite {
id: string;
email: string;
teamId: string;
role: UserRole[];
token: string;
expiry: string;
createdAt: string;
updatedAt: string;
}
-5
View File
@@ -1,8 +1,3 @@
/**
* Converts a request body parameter to a boolean.
* @param {string | boolean} value
* @returns {boolean}
*/
const ParseBoolean = (value) => {
if (value === true || value === "true") {
return true;