From 161f42f83c5b05cd4cff6ffef39d4012d2140c1e Mon Sep 17 00:00:00 2001 From: juancarmore Date: Tue, 24 Feb 2026 19:49:52 +0100 Subject: [PATCH] backend: enhance document update and replacement methods in BaseRepository with partial updates and safety checks --- .../src/repositories/base.repository.ts | 148 ++++++++++++++++-- 1 file changed, 138 insertions(+), 10 deletions(-) diff --git a/meet-ce/backend/src/repositories/base.repository.ts b/meet-ce/backend/src/repositories/base.repository.ts index 98b71678..41f8cdcc 100644 --- a/meet-ce/backend/src/repositories/base.repository.ts +++ b/meet-ce/backend/src/repositories/base.repository.ts @@ -148,20 +148,33 @@ export abstract class BaseRepository, updateData: UpdateQuery): Promise { + protected async updatePartialOne( + filter: FilterQuery, + update: UpdateQuery | Partial + ): Promise { try { + const isUpdateQuery = Object.keys(update).some((key) => key.startsWith('$')); + const safeUpdate = isUpdateQuery + ? (update as UpdateQuery) + : this.buildUpdateQuery(update as Partial); + + if (!safeUpdate.$set && !safeUpdate.$unset) { + throw new Error('Partial update requires at least one field to set or unset'); + } + const document = (await this.model - .findOneAndUpdate(filter, updateData, { - new: true, - runValidators: true, - lean: true + .findOneAndUpdate(filter, safeUpdate, { + new: true, // Return the updated document + runValidators: true, // Ensure update data is validated against schema + lean: true, // Return plain JavaScript object instead of Mongoose document + upsert: false // Do not create a new document if none matches the filter }) .exec()) as (Require_id & { __v: number }) | null; @@ -178,6 +191,61 @@ export abstract class BaseRepository, replacement: TDomain): Promise { + try { + const existingDocument = (await this.model.findOne(filter).lean().exec()) as + | (Require_id & { __v: number }) + | null; + + if (!existingDocument) { + this.logger.error('No document found to replace with filter:', filter); + throw new Error('Document not found for replacement'); + } + + // Build replacement document by merging existing document's fields that are not in the replacement object + const documentOnlyFields = Object.fromEntries( + Object.entries(existingDocument).filter( + ([key]) => !Object.prototype.hasOwnProperty.call(replacement, key) + ) + ); + const replacementDocument = { + ...documentOnlyFields, + ...replacement + } as TDocument; + + const document = (await this.model + .findOneAndReplace(filter, replacementDocument, { + new: true, + runValidators: true, + lean: true, + upsert: false + }) + .exec()) as (Require_id & { __v: number }) | null; + + if (!document) { + this.logger.error('Document disappeared during replacement with filter:', filter); + throw new Error('Document not found during replacement'); + } + + this.logger.debug(`Document with ID '${document._id}' replaced`); + return this.toDomain(document); + } catch (error) { + this.logger.error('Error replacing document:', error); + throw error; + } + } + /** * Deletes a document by a custom filter. * @@ -213,10 +281,11 @@ export abstract class BaseRepository): UpdateQuery { + const $set: Record = {}; + const $unset: Record = {}; + + const buildUpdateQueryDeep = (input: Record, prefix = ''): void => { + for (const key in input) { + const value = input[key]; + const path = prefix ? `${prefix}.${key}` : key; + + if (value === undefined) { + // Mark field for unsetting if value is undefined + $unset[path] = ''; + } else if (this.isPlainObject(value)) { + // Recursively build update query for nested objects + buildUpdateQueryDeep(value, path); + } else { + // Set field value for $set operator + $set[path] = value; + } + } + }; + + buildUpdateQueryDeep(partial as Record); + + const updateQuery: UpdateQuery = {}; + + if (Object.keys($set).length > 0) { + updateQuery.$set = $set; + } + + if (Object.keys($unset).length > 0) { + updateQuery.$unset = $unset; + } + + return updateQuery; + } + + /** + * Checks whether a value is a plain object. + */ + private isPlainObject(value: unknown): value is Record { + if (value === null || typeof value !== 'object') { + return false; + } + + if (Array.isArray(value)) { + return false; + } + + return Object.getPrototypeOf(value) === Object.prototype; + } + /** * Encodes a cursor for pagination. * Creates a base64-encoded token containing the last document's sort field value and _id.