mirror of
https://github.com/bluewave-labs/Checkmate.git
synced 2026-05-23 02:29:30 -05:00
update user refactor
This commit is contained in:
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Executable
+30
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user