refactor: migrate from MongoDB to Prisma for data management, removing mongoose models and updating services to use Prisma client

This commit is contained in:
Julien Froidefond
2025-10-16 22:22:20 +02:00
parent 677e2ae884
commit 3cd58f63e6
21 changed files with 636 additions and 576 deletions

View File

@@ -1,35 +0,0 @@
import mongoose from "mongoose";
const configSchema = new mongoose.Schema(
{
userId: {
type: String,
required: true,
unique: true,
},
url: {
type: String,
required: true,
},
username: {
type: String,
required: true,
},
authHeader: {
type: String,
required: true,
},
},
{
timestamps: true,
}
);
// Middleware pour mettre à jour le champ updatedAt avant la sauvegarde
configSchema.pre("save", function (next) {
this.updatedAt = new Date();
next();
});
export const KomgaConfig =
mongoose.models.KomgaConfig || mongoose.model("KomgaConfig", configSchema);

View File

@@ -1,23 +0,0 @@
import mongoose from "mongoose";
const favoriteSchema = new mongoose.Schema(
{
userId: {
type: String,
required: true,
index: true,
},
seriesId: {
type: String,
required: true,
},
},
{
timestamps: true,
}
);
// Index composé pour s'assurer qu'un utilisateur ne peut pas avoir deux fois le même favori
favoriteSchema.index({ userId: 1, seriesId: 1 }, { unique: true });
export const FavoriteModel = mongoose.models.Favorite || mongoose.model("Favorite", favoriteSchema);

View File

@@ -1,94 +0,0 @@
import mongoose from "mongoose";
const preferencesSchema = new mongoose.Schema(
{
userId: {
type: String,
required: true,
unique: true,
},
showThumbnails: {
type: Boolean,
default: true,
},
cacheMode: {
type: String,
enum: ["memory", "file"],
default: "memory",
},
showOnlyUnread: {
type: Boolean,
default: false,
required: false,
},
debug: {
type: Boolean,
default: false,
required: false,
},
displayMode: {
compact: {
type: Boolean,
default: false,
},
itemsPerPage: {
type: Number,
default: 20,
enum: [20, 50, 100],
},
},
},
{
timestamps: true,
strict: true,
toObject: {
transform: function (doc, ret) {
// Force la conversion en booléen
ret.showOnlyUnread = Boolean(ret.showOnlyUnread);
ret.debug = Boolean(ret.debug);
ret.displayMode = ret.displayMode || { compact: false, itemsPerPage: 20 };
ret.displayMode.compact = Boolean(ret.displayMode.compact);
return ret;
},
},
}
);
// Middleware pour s'assurer que les booléens sont toujours des booléens
preferencesSchema.pre("save", function (next) {
if (this.showOnlyUnread === undefined) {
this.showOnlyUnread = false;
}
if (this.debug === undefined) {
this.debug = false;
}
if (!this.displayMode) {
this.displayMode = { compact: false, itemsPerPage: 20 };
}
this.showOnlyUnread = Boolean(this.showOnlyUnread);
this.debug = Boolean(this.debug);
this.displayMode.compact = Boolean(this.displayMode.compact);
next();
});
preferencesSchema.pre("findOneAndUpdate", function (next) {
const update = this.getUpdate() as mongoose.UpdateQuery<any>;
if (update && "$set" in update && update.$set && typeof update.$set === "object") {
if ("showOnlyUnread" in update.$set) {
update.$set.showOnlyUnread = Boolean(update.$set.showOnlyUnread);
}
if ("debug" in update.$set) {
update.$set.debug = Boolean(update.$set.debug);
}
if ("displayMode" in update.$set) {
update.$set.displayMode = {
compact: Boolean(update.$set.displayMode?.compact),
itemsPerPage: update.$set.displayMode?.itemsPerPage || 20,
};
}
}
next();
});
export const PreferencesModel =
mongoose.models.Preferences || mongoose.model("Preferences", preferencesSchema);

View File

@@ -1,46 +0,0 @@
import mongoose from "mongoose";
const ttlConfigSchema = new mongoose.Schema(
{
userId: {
type: String,
required: true,
unique: true,
},
defaultTTL: {
type: Number,
default: 5,
},
homeTTL: {
type: Number,
default: 5,
},
librariesTTL: {
type: Number,
default: 1440,
},
seriesTTL: {
type: Number,
default: 5,
},
booksTTL: {
type: Number,
default: 5,
},
imagesTTL: {
type: Number,
default: 1440,
},
},
{
timestamps: true,
}
);
// Middleware pour mettre à jour le champ updatedAt avant la sauvegarde
ttlConfigSchema.pre("save", function (next) {
this.updatedAt = new Date();
next();
});
export const TTLConfig = mongoose.models.TTLConfig || mongoose.model("TTLConfig", ttlConfigSchema);

View File

@@ -1,36 +0,0 @@
import mongoose from "mongoose";
const userSchema = new mongoose.Schema(
{
email: {
type: String,
required: true,
unique: true,
lowercase: true,
trim: true,
},
password: {
type: String,
required: true,
},
roles: {
type: [String],
default: ["ROLE_USER"],
},
authenticated: {
type: Boolean,
default: true,
},
},
{
timestamps: true,
}
);
// Middleware pour mettre à jour le champ updatedAt avant la sauvegarde
userSchema.pre("save", function (next) {
this.updatedAt = new Date();
next();
});
export const UserModel = mongoose.models.User || mongoose.model("User", userSchema);

View File

@@ -1,52 +0,0 @@
import mongoose from "mongoose";
import { ERROR_CODES } from "../constants/errorCodes";
import { AppError } from "../utils/errors";
const MONGODB_URI = process.env.MONGODB_URI;
if (!MONGODB_URI) {
throw new AppError(ERROR_CODES.MONGODB.MISSING_URI);
}
interface MongooseCache {
conn: typeof mongoose | null;
promise: Promise<typeof mongoose> | null;
}
declare global {
var mongoose: MongooseCache | undefined;
}
let cached: MongooseCache = global.mongoose || { conn: null, promise: null };
if (!global.mongoose) {
global.mongoose = { conn: null, promise: null };
}
async function connectDB(): Promise<typeof mongoose> {
if (cached.conn) {
return cached.conn;
}
if (!cached.promise) {
const opts = {
bufferCommands: false,
};
cached.promise = mongoose.connect(MONGODB_URI!, opts).then((mongoose) => {
return mongoose;
});
}
try {
cached.conn = await cached.promise;
} catch (e) {
console.error("Error connecting to MongoDB:", e);
cached.promise = null;
throw new AppError(ERROR_CODES.MONGODB.CONNECTION_FAILED, {}, e);
}
return cached.conn;
}
export default connectDB;

14
src/lib/prisma.ts Normal file
View File

@@ -0,0 +1,14 @@
import { PrismaClient } from "@prisma/client";
declare global {
var prisma: PrismaClient | undefined;
}
const prisma = global.prisma || new PrismaClient();
if (process.env.NODE_ENV !== "production") {
global.prisma = prisma;
}
export default prisma;

View File

@@ -1,5 +1,4 @@
import connectDB from "@/lib/mongodb";
import { UserModel } from "@/lib/models/user.model";
import prisma from "@/lib/prisma";
import bcrypt from "bcryptjs";
import { ERROR_CODES } from "../../constants/errorCodes";
import { AppError } from "../../utils/errors";
@@ -15,15 +14,16 @@ export class AuthServerService {
private static readonly SALT_ROUNDS = 10;
static async registerUser(email: string, password: string): Promise<UserData> {
await connectDB();
//check if password is strong
if (!AuthServerService.isPasswordStrong(password)) {
throw new AppError(ERROR_CODES.AUTH.PASSWORD_NOT_STRONG);
}
// Check if user already exists
const existingUser = await UserModel.findOne({ email: email.toLowerCase() });
const existingUser = await prisma.user.findUnique({
where: { email: email.toLowerCase() },
});
if (existingUser) {
throw new AppError(ERROR_CODES.AUTH.EMAIL_EXISTS);
}
@@ -32,15 +32,17 @@ export class AuthServerService {
const hashedPassword = await bcrypt.hash(password, this.SALT_ROUNDS);
// Create new user
const user = await UserModel.create({
email: email.toLowerCase(),
password: hashedPassword,
roles: ["ROLE_USER"],
authenticated: true,
const user = await prisma.user.create({
data: {
email: email.toLowerCase(),
password: hashedPassword,
roles: ["ROLE_USER"],
authenticated: true,
},
});
const userData: UserData = {
id: user._id.toString(),
id: user.id,
email: user.email,
roles: user.roles,
authenticated: true,
@@ -67,9 +69,9 @@ export class AuthServerService {
}
static async loginUser(email: string, password: string): Promise<UserData> {
await connectDB();
const user = await UserModel.findOne({ email: email.toLowerCase() });
const user = await prisma.user.findUnique({
where: { email: email.toLowerCase() },
});
if (!user) {
throw new AppError(ERROR_CODES.AUTH.INVALID_CREDENTIALS);
@@ -82,7 +84,7 @@ export class AuthServerService {
}
const userData: UserData = {
id: user._id.toString(),
id: user.id,
email: user.email,
roles: user.roles,
authenticated: true,

View File

@@ -1,6 +1,4 @@
import connectDB from "@/lib/mongodb";
import { KomgaConfig as KomgaConfigModel } from "@/lib/models/config.model";
import { TTLConfig as TTLConfigModel } from "@/lib/models/ttl-config.model";
import prisma from "@/lib/prisma";
import { DebugService } from "./debug.service";
import { getCurrentUser } from "../auth-utils";
import { ERROR_CODES } from "../../constants/errorCodes";
@@ -19,28 +17,27 @@ export class ConfigDBService {
static async saveConfig(data: KomgaConfigData): Promise<KomgaConfig> {
try {
const user: User | null = await this.getCurrentUser();
await connectDB();
const authHeader: string = Buffer.from(`${data.username}:${data.password}`).toString(
"base64"
);
const config: KomgaConfig | null = await KomgaConfigModel.findOneAndUpdate(
{ userId: user.id },
{
const config = await prisma.komgaConfig.upsert({
where: { userId: user.id },
update: {
url: data.url,
username: data.username,
authHeader,
},
create: {
userId: user.id,
url: data.url,
username: data.username,
// password: data.password,
authHeader,
},
{ upsert: true, new: true }
);
if (!config) {
throw new AppError(ERROR_CODES.CONFIG.SAVE_ERROR);
}
});
return config;
return config as KomgaConfig;
} catch (error) {
if (error instanceof AppError) {
throw error;
@@ -52,11 +49,12 @@ export class ConfigDBService {
static async getConfig(): Promise<KomgaConfig | null> {
try {
const user: User | null = await this.getCurrentUser();
await connectDB();
return DebugService.measureMongoOperation("getConfig", async () => {
const config: KomgaConfig | null = await KomgaConfigModel.findOne({ userId: user.id });
return config;
const config = await prisma.komgaConfig.findUnique({
where: { userId: user.id },
});
return config as KomgaConfig | null;
});
} catch (error) {
if (error instanceof AppError) {
@@ -69,11 +67,12 @@ export class ConfigDBService {
static async getTTLConfig(): Promise<TTLConfig | null> {
try {
const user: User | null = await this.getCurrentUser();
await connectDB();
return DebugService.measureMongoOperation("getTTLConfig", async () => {
const config: TTLConfig | null = await TTLConfigModel.findOne({ userId: user.id });
return config;
const config = await prisma.tTLConfig.findUnique({
where: { userId: user.id },
});
return config as TTLConfig | null;
});
} catch (error) {
if (error instanceof AppError) {
@@ -86,23 +85,30 @@ export class ConfigDBService {
static async saveTTLConfig(data: TTLConfigData): Promise<TTLConfig> {
try {
const user: User | null = await this.getCurrentUser();
await connectDB();
return DebugService.measureMongoOperation("saveTTLConfig", async () => {
const config: TTLConfig | null = await TTLConfigModel.findOneAndUpdate(
{ userId: user.id },
{
userId: user.id,
...data,
const config = await prisma.tTLConfig.upsert({
where: { userId: user.id },
update: {
defaultTTL: data.defaultTTL,
homeTTL: data.homeTTL,
librariesTTL: data.librariesTTL,
seriesTTL: data.seriesTTL,
booksTTL: data.booksTTL,
imagesTTL: data.imagesTTL,
},
{ upsert: true, new: true }
);
create: {
userId: user.id,
defaultTTL: data.defaultTTL,
homeTTL: data.homeTTL,
librariesTTL: data.librariesTTL,
seriesTTL: data.seriesTTL,
booksTTL: data.booksTTL,
imagesTTL: data.imagesTTL,
},
});
if (!config) {
throw new AppError(ERROR_CODES.CONFIG.TTL_SAVE_ERROR);
}
return config;
return config as TTLConfig;
});
} catch (error) {
if (error instanceof AppError) {

View File

@@ -1,5 +1,4 @@
import connectDB from "@/lib/mongodb";
import { FavoriteModel } from "@/lib/models/favorite.model";
import prisma from "@/lib/prisma";
import { DebugService } from "./debug.service";
import { getCurrentUser } from "../auth-utils";
import { ERROR_CODES } from "../../constants/errorCodes";
@@ -30,12 +29,13 @@ export class FavoriteService {
static async isFavorite(seriesId: string): Promise<boolean> {
try {
const user = await this.getCurrentUser();
await connectDB();
return DebugService.measureMongoOperation("isFavorite", async () => {
const favorite = await FavoriteModel.findOne({
userId: user.id,
seriesId: seriesId,
const favorite = await prisma.favorite.findFirst({
where: {
userId: user.id,
seriesId: seriesId,
},
});
return !!favorite;
});
@@ -51,14 +51,21 @@ export class FavoriteService {
static async addToFavorites(seriesId: string): Promise<void> {
try {
const user = await this.getCurrentUser();
await connectDB();
await DebugService.measureMongoOperation("addToFavorites", async () => {
await FavoriteModel.findOneAndUpdate(
{ userId: user.id, seriesId },
{ userId: user.id, seriesId },
{ upsert: true }
);
await prisma.favorite.upsert({
where: {
userId_seriesId: {
userId: user.id,
seriesId,
},
},
update: {},
create: {
userId: user.id,
seriesId,
},
});
});
this.dispatchFavoritesChanged();
@@ -73,12 +80,13 @@ export class FavoriteService {
static async removeFromFavorites(seriesId: string): Promise<void> {
try {
const user = await this.getCurrentUser();
await connectDB();
await DebugService.measureMongoOperation("removeFromFavorites", async () => {
await FavoriteModel.findOneAndDelete({
userId: user.id,
seriesId,
await prisma.favorite.deleteMany({
where: {
userId: user.id,
seriesId,
},
});
});
@@ -93,35 +101,48 @@ export class FavoriteService {
*/
static async getAllFavoriteIds(): Promise<string[]> {
const user = await this.getCurrentUser();
await connectDB();
return DebugService.measureMongoOperation("getAllFavoriteIds", async () => {
const favorites = await FavoriteModel.find({ userId: user.id });
const favorites = await prisma.favorite.findMany({
where: { userId: user.id },
select: { seriesId: true },
});
return favorites.map((favorite) => favorite.seriesId);
});
}
static async addFavorite(seriesId: string) {
const user = await this.getCurrentUser();
await connectDB();
return DebugService.measureMongoOperation("addFavorite", async () => {
const favorite = await FavoriteModel.findOneAndUpdate(
{ userId: user.id, seriesId },
{ userId: user.id, seriesId },
{ upsert: true, new: true }
);
const favorite = await prisma.favorite.upsert({
where: {
userId_seriesId: {
userId: user.id,
seriesId,
},
},
update: {},
create: {
userId: user.id,
seriesId,
},
});
return favorite;
});
}
static async removeFavorite(seriesId: string): Promise<boolean> {
const user = await this.getCurrentUser();
await connectDB();
return DebugService.measureMongoOperation("removeFavorite", async () => {
const result = await FavoriteModel.deleteOne({ userId: user.id, seriesId });
return result.deletedCount > 0;
const result = await prisma.favorite.deleteMany({
where: {
userId: user.id,
seriesId,
},
});
return result.count > 0;
});
}
}

View File

@@ -1,11 +1,10 @@
import { PreferencesModel } from "@/lib/models/preferences.model";
import prisma from "@/lib/prisma";
import { getCurrentUser } from "../auth-utils";
import { ERROR_CODES } from "../../constants/errorCodes";
import { AppError } from "../../utils/errors";
import type { UserPreferences } from "@/types/preferences";
import { defaultPreferences } from "@/types/preferences";
import type { User } from "@/types/komga";
import connectDB from "@/lib/mongodb";
export class PreferencesService {
static async getCurrentUser(): Promise<User> {
@@ -19,15 +18,20 @@ export class PreferencesService {
static async getPreferences(): Promise<UserPreferences> {
try {
const user = await this.getCurrentUser();
await connectDB();
const preferences = await PreferencesModel.findOne({ userId: user.id });
const preferences = await prisma.preferences.findUnique({
where: { userId: user.id },
});
if (!preferences) {
return defaultPreferences;
return { ...defaultPreferences };
}
return {
...defaultPreferences,
...preferences.toObject(),
_id: undefined, //plain object KO on server components hydration
showThumbnails: preferences.showThumbnails,
cacheMode: preferences.cacheMode as "memory" | "file",
showOnlyUnread: preferences.showOnlyUnread,
debug: preferences.debug,
displayMode: preferences.displayMode as UserPreferences["displayMode"],
};
} catch (error) {
if (error instanceof AppError) {
@@ -40,18 +44,34 @@ export class PreferencesService {
static async updatePreferences(preferences: Partial<UserPreferences>): Promise<UserPreferences> {
try {
const user = await this.getCurrentUser();
await connectDB();
const updatedPreferences = await PreferencesModel.findOneAndUpdate(
{ userId: user.id },
{ $set: preferences },
{ new: true, upsert: true }
);
const updateData: Record<string, any> = {};
if (preferences.showThumbnails !== undefined) updateData.showThumbnails = preferences.showThumbnails;
if (preferences.cacheMode !== undefined) updateData.cacheMode = preferences.cacheMode;
if (preferences.showOnlyUnread !== undefined) updateData.showOnlyUnread = preferences.showOnlyUnread;
if (preferences.debug !== undefined) updateData.debug = preferences.debug;
if (preferences.displayMode !== undefined) updateData.displayMode = preferences.displayMode;
const result = {
...defaultPreferences,
...updatedPreferences.toObject(),
const updatedPreferences = await prisma.preferences.upsert({
where: { userId: user.id },
update: updateData,
create: {
userId: user.id,
showThumbnails: preferences.showThumbnails ?? defaultPreferences.showThumbnails,
cacheMode: preferences.cacheMode ?? defaultPreferences.cacheMode,
showOnlyUnread: preferences.showOnlyUnread ?? defaultPreferences.showOnlyUnread,
debug: preferences.debug ?? defaultPreferences.debug,
displayMode: preferences.displayMode ?? defaultPreferences.displayMode,
},
});
return {
showThumbnails: updatedPreferences.showThumbnails,
cacheMode: updatedPreferences.cacheMode as "memory" | "file",
showOnlyUnread: updatedPreferences.showOnlyUnread,
debug: updatedPreferences.debug,
displayMode: updatedPreferences.displayMode as UserPreferences["displayMode"],
};
return result;
} catch (error) {
if (error instanceof AppError) {
throw error;

View File

@@ -18,4 +18,4 @@ export const defaultPreferences: UserPreferences = {
compact: false,
itemsPerPage: 20,
},
} as const;
};