diff --git a/backend/controllers/data.controller.js b/backend/controllers/data.controller.js index c3a77046..84e81ca3 100644 --- a/backend/controllers/data.controller.js +++ b/backend/controllers/data.controller.js @@ -1,180 +1,250 @@ const { sanitize } = require("../utils/input.validation"); -const mongoose = require('mongoose'); +const mongoose = require("mongoose"); const Project = require("../models/Project"); const { getConnection } = require("../utils/connection.manager"); const { getCompiledModel } = require("../utils/injectModel"); const QueryEngine = require("../utils/queryEngine"); const { validateData, validateUpdateData } = require("../utils/validateData"); +function handleDuplicateKeyError(err, res) { + if (err && err.code === 11000) { + const field = Object.keys(err.keyPattern || {})[0] || "field"; + const value = err.keyValue?.[field]; + + return res.status(409).json({ + success: false, + error: `Value '${value}' already exists for field '${field}'`, + code: "DUPLICATE_VALUE", + }); + } + + return null; +} + // Validate MongoDB ObjectId const isValidId = (id) => mongoose.Types.ObjectId.isValid(id); // INSERT DATA module.exports.insertData = async (req, res) => { - try { - console.time("insert data") - const { collectionName } = req.params; - const project = req.project; - - const collectionConfig = project.collections.find(c => c.name === collectionName); - if (!collectionConfig) return res.status(404).json({ error: "Collection not found" }); - - const schemaRules = collectionConfig.model; - const incomingData = req.body; - - // Recursive validation for all field types - const { error, cleanData } = validateData(incomingData, schemaRules); - if (error) return res.status(400).json({ error }); - - const safeData = sanitize(cleanData); - - let docSize = 0; - if (!project.resources.db.isExternal) { - docSize = Buffer.byteLength(JSON.stringify(safeData)); - if ((project.databaseUsed || 0) + docSize > project.databaseLimit) { - return res.status(403).json({ error: "Database limit exceeded." }); - } - } - - const connection = await getConnection(project._id); - const Model = getCompiledModel(connection, collectionConfig, project._id, project.resources.db.isExternal); - - const result = await Model.create(safeData); - - if (!project.resources.db.isExternal) { - await Project.updateOne( - { _id: project._id }, - { $inc: { databaseUsed: docSize } } - ); - } - - console.timeEnd("insert data") - res.status(201).json(result); - } catch (err) { - console.error(err); - res.status(500).json({ error: err.message }); + try { + console.time("insert data"); + const { collectionName } = req.params; + const project = req.project; + + const collectionConfig = project.collections.find( + (c) => c.name === collectionName, + ); + if (!collectionConfig) + return res.status(404).json({ error: "Collection not found" }); + + const schemaRules = collectionConfig.model; + const incomingData = req.body; + + // Recursive validation for all field types + const { error, cleanData } = validateData(incomingData, schemaRules); + if (error) return res.status(400).json({ error }); + + const safeData = sanitize(cleanData); + + let docSize = 0; + if (!project.resources.db.isExternal) { + docSize = Buffer.byteLength(JSON.stringify(safeData)); + if ((project.databaseUsed || 0) + docSize > project.databaseLimit) { + return res.status(403).json({ error: "Database limit exceeded." }); + } + } + + const connection = await getConnection(project._id); + const Model = getCompiledModel( + connection, + collectionConfig, + project._id, + project.resources.db.isExternal, + ); + + const result = await Model.create(safeData); + + if (!project.resources.db.isExternal) { + await Project.updateOne( + { _id: project._id }, + { $inc: { databaseUsed: docSize } }, + ); } + + console.timeEnd("insert data"); + res.status(201).json(result); + } catch (err) { + const duplicateResponse = handleDuplicateKeyError(err, res); + if (duplicateResponse) return; + + console.error(err); + res.status(500).json({ error: err.message }); + } }; // GET ALL DATA module.exports.getAllData = async (req, res) => { - try { - console.time("getall") - const { collectionName } = req.params; - const project = req.project; - - const collectionConfig = project.collections.find(c => c.name === collectionName); - if (!collectionConfig) return res.status(404).json({ error: "Collection not found" }); - - const connection = await getConnection(project._id); - const Model = getCompiledModel(connection, collectionConfig, project._id, project.resources.db.isExternal); - - const features = new QueryEngine(Model.find(), req.query) - .filter() - .sort() - .paginate(); - - const data = await features.query.lean(); - console.timeEnd("getall") - res.json(data); - } catch (err) { - console.error(err); - res.status(500).json({ error: err.message }); - } + try { + console.time("getall"); + const { collectionName } = req.params; + const project = req.project; + + const collectionConfig = project.collections.find( + (c) => c.name === collectionName, + ); + if (!collectionConfig) + return res.status(404).json({ error: "Collection not found" }); + + const connection = await getConnection(project._id); + const Model = getCompiledModel( + connection, + collectionConfig, + project._id, + project.resources.db.isExternal, + ); + + const features = new QueryEngine(Model.find(), req.query) + .filter() + .sort() + .paginate(); + + const data = await features.query.lean(); + console.timeEnd("getall"); + res.json(data); + } catch (err) { + console.error(err); + res.status(500).json({ error: err.message }); + } }; // GET SINGLE DOC module.exports.getSingleDoc = async (req, res) => { - try { - const { collectionName, id } = req.params; - const project = req.project; - - // ensure valid mongose objct id - if (!isValidId(id)) return res.status(400).json({ error: "Invalid ID format." }); - - const collectionConfig = project.collections.find(c => c.name === collectionName); - if (!collectionConfig) return res.status(404).json({ error: "Collection not found" }); - - const connection = await getConnection(project._id); - const Model = getCompiledModel(connection, collectionConfig, project._id, project.resources.db.isExternal); - - const doc = await Model.findById(id).lean(); - if (!doc) return res.status(404).json({ error: "Document not found." }); - - res.json(doc); - } catch (err) { - console.error(err); - res.status(500).json({ error: err.message }); - } + try { + const { collectionName, id } = req.params; + const project = req.project; + + // ensure valid mongose objct id + if (!isValidId(id)) + return res.status(400).json({ error: "Invalid ID format." }); + + const collectionConfig = project.collections.find( + (c) => c.name === collectionName, + ); + if (!collectionConfig) + return res.status(404).json({ error: "Collection not found" }); + + const connection = await getConnection(project._id); + const Model = getCompiledModel( + connection, + collectionConfig, + project._id, + project.resources.db.isExternal, + ); + + const doc = await Model.findById(id).lean(); + if (!doc) return res.status(404).json({ error: "Document not found." }); + + res.json(doc); + } catch (err) { + console.error(err); + res.status(500).json({ error: err.message }); + } }; // UPDATE DATA module.exports.updateSingleData = async (req, res) => { - try { - const { collectionName, id } = req.params; - const project = req.project; - const incomingData = req.body; - - if (!isValidId(id)) return res.status(400).json({ error: "Invalid ID format." }); - - const collectionConfig = project.collections.find(c => c.name === collectionName); - if (!collectionConfig) return res.status(404).json({ error: "Collection not found" }); - - const connection = await getConnection(project._id); - const Model = getCompiledModel(connection, collectionConfig, project._id, project.resources.db.isExternal); - - // Recursive validation for all field types - const schemaRules = collectionConfig.model; - const { error: validationError, updateData } = validateUpdateData(incomingData, schemaRules); - if (validationError) return res.status(400).json({ error: validationError }); - - const sanitizedData = sanitize(updateData); - - const result = await Model.findByIdAndUpdate(id, { $set: sanitizedData }, { new: true }).lean(); - if (!result) return res.status(404).json({ error: "Document not found." }); - - res.json({ message: "Updated", data: result }); - } catch (err) { - console.error(err); - res.status(500).json({ error: err.message }); - } + try { + const { collectionName, id } = req.params; + const project = req.project; + const incomingData = req.body; + + if (!isValidId(id)) + return res.status(400).json({ error: "Invalid ID format." }); + + const collectionConfig = project.collections.find( + (c) => c.name === collectionName, + ); + if (!collectionConfig) + return res.status(404).json({ error: "Collection not found" }); + + const connection = await getConnection(project._id); + const Model = getCompiledModel( + connection, + collectionConfig, + project._id, + project.resources.db.isExternal, + ); + + // Recursive validation for all field types + const schemaRules = collectionConfig.model; + const { error: validationError, updateData } = validateUpdateData( + incomingData, + schemaRules, + ); + if (validationError) + return res.status(400).json({ error: validationError }); + + const sanitizedData = sanitize(updateData); + + const result = await Model.findByIdAndUpdate( + id, + { $set: sanitizedData }, + { new: true }, + ).lean(); + if (!result) return res.status(404).json({ error: "Document not found." }); + + res.json({ message: "Updated", data: result }); + } catch (err) { + const duplicateResponse = handleDuplicateKeyError(err, res); + if (duplicateResponse) return; + + console.error(err); + res.status(500).json({ error: err.message }); + } }; // DELETE DATA module.exports.deleteSingleDoc = async (req, res) => { - try { - const { collectionName, id } = req.params; - const project = req.project; - - if (!isValidId(id)) return res.status(400).json({ error: "Invalid ID format." }); - - const collectionConfig = project.collections.find(c => c.name === collectionName); - if (!collectionConfig) return res.status(404).json({ error: "Collection not found" }); - - const connection = await getConnection(project._id); - const Model = getCompiledModel(connection, collectionConfig, project._id, project.resources.db.isExternal); - - const docToDelete = await Model.findById(id); - if (!docToDelete) return res.status(404).json({ error: "Document not found." }); - - let docSize = 0; - if (!project.resources.db.isExternal) { - docSize = Buffer.byteLength(JSON.stringify(docToDelete)); - } - - await Model.deleteOne({ _id: id }); + try { + const { collectionName, id } = req.params; + const project = req.project; + + if (!isValidId(id)) + return res.status(400).json({ error: "Invalid ID format." }); + + const collectionConfig = project.collections.find( + (c) => c.name === collectionName, + ); + if (!collectionConfig) + return res.status(404).json({ error: "Collection not found" }); + + const connection = await getConnection(project._id); + const Model = getCompiledModel( + connection, + collectionConfig, + project._id, + project.resources.db.isExternal, + ); + + const docToDelete = await Model.findById(id); + if (!docToDelete) + return res.status(404).json({ error: "Document not found." }); + + let docSize = 0; + if (!project.resources.db.isExternal) { + docSize = Buffer.byteLength(JSON.stringify(docToDelete)); + } - if (!project.resources.db.isExternal) { - let databaseUsed = Math.max(0, (project.databaseUsed || 0) - docSize); - await Project.updateOne( - { _id: project._id }, - { $set: { databaseUsed } } - ); - } + await Model.deleteOne({ _id: id }); - res.json({ message: "Document deleted", id }); - } catch (err) { - console.error(err); - res.status(500).json({ error: err.message }); + if (!project.resources.db.isExternal) { + let databaseUsed = Math.max(0, (project.databaseUsed || 0) - docSize); + await Project.updateOne({ _id: project._id }, { $set: { databaseUsed } }); } -}; \ No newline at end of file + + res.json({ message: "Document deleted", id }); + } catch (err) { + console.error(err); + res.status(500).json({ error: err.message }); + } +}; diff --git a/backend/controllers/schema.controller.js b/backend/controllers/schema.controller.js index 82c2e53a..4c18e5e5 100644 --- a/backend/controllers/schema.controller.js +++ b/backend/controllers/schema.controller.js @@ -1,90 +1,146 @@ -const { createSchemaApiKeySchema } = require('../utils/input.validation'); -const Project = require('../models/Project'); -const { v4: uuidv4 } = require('uuid'); -const { deleteProjectById, setProjectById, deleteProjectByApiKeyCache } = require('../services/redisCaching'); -const { z } = require('zod'); +const { createSchemaApiKeySchema } = require("../utils/input.validation"); +const Project = require("../models/Project"); +const { v4: uuidv4 } = require("uuid"); +const { + deleteProjectById, + setProjectById, + deleteProjectByApiKeyCache, +} = require("../services/redisCaching"); +const { z } = require("zod"); +const { getConnection } = require("../utils/connection.manager"); +const { + getCompiledModel, + clearCompiledModel, +} = require("../utils/injectModel"); +const { createUniqueIndexes } = require("../utils/indexManager"); module.exports.checkSchema = async (req, res) => { - try { - const { collectionName } = req.params; - const project = req.project; - - if (!project) return res.status(401).json({ error: "Project missing from request." }); + try { + const { collectionName } = req.params; + const project = req.project; - const collectionConfig = project.collections.find(c => c.name === collectionName); + if (!project) + return res.status(401).json({ error: "Project missing from request." }); - if (!collectionConfig) { - return res.status(404).json({ error: "Schema/Collection not found" }); - } + const collectionConfig = project.collections.find( + (c) => c.name === collectionName, + ); - res.status(200).json({ message: "Schema exists", collection: collectionConfig }); - } catch (err) { - console.error(err); - res.status(500).json({ error: err.message }); + if (!collectionConfig) { + return res.status(404).json({ error: "Schema/Collection not found" }); } + + res + .status(200) + .json({ message: "Schema exists", collection: collectionConfig }); + } catch (err) { + console.error(err); + res.status(500).json({ error: err.message }); + } }; module.exports.createSchema = async (req, res) => { - try { - const { name, fields } = createSchemaApiKeySchema.parse(req.body); - let project = req.project; - - const projectId = project._id; - const fullProject = await Project.findById(projectId); - - if (!fullProject) return res.status(404).json({ error: 'Project not found' }); - - const exists = fullProject.collections.find(c => c.name === name); - if (exists) return res.status(400).json({ error: 'Collection/Schema already exists' }); - - if (!fullProject.jwtSecret) fullProject.jwtSecret = uuidv4(); - - // Recursive field transformer (API uses 'name', internal uses 'key') - function transformField(f) { - const mappedType = f.type.charAt(0).toUpperCase() + f.type.slice(1).toLowerCase(); - const mapped = { - key: f.name, - type: mappedType, - required: f.required === true, - }; - if (f.ref) mapped.ref = f.ref; - if (f.items) { - mapped.items = { - type: f.items.type.charAt(0).toUpperCase() + f.items.type.slice(1).toLowerCase(), - }; - if (f.items.fields) { - mapped.items.fields = f.items.fields.map(sf => transformField(sf)); - } - } - if (f.fields) { - mapped.fields = f.fields.map(sf => transformField(sf)); - } - return mapped; + try { + const { name, fields } = createSchemaApiKeySchema.parse(req.body); + const project = req.project; + + const projectId = project._id; + const fullProject = await Project.findById(projectId); + + if (!fullProject) + return res.status(404).json({ error: "Project not found" }); + + const exists = fullProject.collections.find((c) => c.name === name); + if (exists) + return res + .status(400) + .json({ error: "Collection/Schema already exists" }); + + if (!fullProject.jwtSecret) fullProject.jwtSecret = uuidv4(); + + // Recursive field transformer (API uses 'name', internal uses 'key') + function transformField(f) { + const mappedType = + f.type.charAt(0).toUpperCase() + f.type.slice(1).toLowerCase(); + const mapped = { + key: f.name, + type: mappedType, + required: f.required === true, + unique: f.unique === true, + }; + if (f.ref) mapped.ref = f.ref; + if (f.items) { + mapped.items = { + type: + f.items.type.charAt(0).toUpperCase() + + f.items.type.slice(1).toLowerCase(), + }; + if (f.items.fields) { + mapped.items.fields = f.items.fields.map((sf) => transformField(sf)); } + } + if (f.fields) { + mapped.fields = f.fields.map((sf) => transformField(sf)); + } + return mapped; + } - const transformedFields = (fields || []).map(f => transformField(f)); - - fullProject.collections.push({ name: name, model: transformedFields }); - await fullProject.save(); + const transformedFields = (fields || []).map((f) => transformField(f)); - // Clear redis cache - await deleteProjectById(projectId.toString()); - await setProjectById(projectId.toString(), fullProject); - await deleteProjectByApiKeyCache(fullProject.publishableKey); - await deleteProjectByApiKeyCache(fullProject.secretKey); - if (req.hashedApiKey) { - await deleteProjectByApiKeyCache(req.hashedApiKey); - } + fullProject.collections.push({ name: name, model: transformedFields }); + await fullProject.save(); - const projectObj = fullProject.toObject(); - delete projectObj.publishableKey; - delete projectObj.secretKey; - delete projectObj.jwtSecret; + try { + const collectionConfig = fullProject.collections.find( + (c) => c.name === name, + ); + + const connection = await getConnection(fullProject._id); + const Model = getCompiledModel( + connection, + collectionConfig, + fullProject._id, + fullProject.resources.db.isExternal, + ); + + await createUniqueIndexes(Model, collectionConfig.model); + } catch (error) { + const compiledCollectionName = fullProject.resources.db.isExternal + ? name + : `${fullProject._id}_${name}`; + + const connection = await getConnection(fullProject._id); + clearCompiledModel(connection, compiledCollectionName); + + fullProject.collections = fullProject.collections.filter( + (c) => c.name !== name, + ); + await fullProject.save(); + + return res.status(400).json({ error: error.message }); + } - res.status(201).json({ message: "Schema created successfully", project: projectObj }); - } catch (err) { - if (err instanceof z.ZodError) return res.status(400).json({ error: err.errors }); - console.error(err); - res.status(500).json({ error: err.message }); + // Clear redis cache + await deleteProjectById(projectId.toString()); + await setProjectById(projectId.toString(), fullProject); + await deleteProjectByApiKeyCache(fullProject.publishableKey); + await deleteProjectByApiKeyCache(fullProject.secretKey); + if (req.hashedApiKey) { + await deleteProjectByApiKeyCache(req.hashedApiKey); } + + const projectObj = fullProject.toObject(); + delete projectObj.publishableKey; + delete projectObj.secretKey; + delete projectObj.jwtSecret; + + res + .status(201) + .json({ message: "Schema created successfully", project: projectObj }); + } catch (err) { + if (err instanceof z.ZodError) + return res.status(400).json({ error: err.errors }); + console.error(err); + res.status(500).json({ error: err.message }); + } }; diff --git a/backend/controllers/storage.controller.js b/backend/controllers/storage.controller.js index b1fd9861..152263c2 100644 --- a/backend/controllers/storage.controller.js +++ b/backend/controllers/storage.controller.js @@ -6,202 +6,196 @@ const { isProjectStorageExternal } = require("../utils/project.helpers"); const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB const getBucket = (project) => - isProjectStorageExternal(project) ? "files" : "dev-files"; + isProjectStorageExternal(project) ? "files" : "dev-files"; /** * Upload File */ module.exports.uploadFile = async (req, res) => { - try { - const file = req.file; - if (!file) { - return res.status(400).json({ error: "No file uploaded." }); - } - - if (file.size > MAX_FILE_SIZE) { - return res.status(413).json({ error: "File size exceeds limit." }); - } - - const project = req.project; - const external = isProjectStorageExternal(project); - const bucket = getBucket(project); - - // ATOMIC QUOTA RESERVATION - if (!external) { - const result = await Project.updateOne( - { - _id: project._id, - $expr: { $lte: [{ $add: ["$storageUsed", file.size] }, "$storageLimit"] } - }, - { $inc: { storageUsed: file.size } } - ); - - if (result.matchedCount === 0) { - return res.status(403).json({ error: "Internal storage limit exceeded." }); - } - } - - const supabase = await getStorage(project); - - const safeName = file.originalname.replace(/\s+/g, "_"); - const filePath = `${project._id}/${randomUUID()}_${safeName}`; - - const { error: uploadError } = await supabase.storage - .from(bucket) - .upload(filePath, file.buffer, { - contentType: file.mimetype, - upsert: false - }); - - if (uploadError) { - // ROLLBACK QUOTA - if (!external) { - await Project.updateOne( - { _id: project._id }, - { $inc: { storageUsed: -file.size } } - ); - } - throw uploadError; - } - - const { data: publicUrlData } = supabase.storage - .from(bucket) - .getPublicUrl(filePath); - - return res.status(201).json({ - message: "File uploaded successfully", - url: publicUrlData.publicUrl, - path: filePath, - provider: external ? "external" : "internal" - }); - } catch (err) { - return res.status(500).json({ - error: "File upload failed", - details: - process.env.NODE_ENV === "development" - ? err.message - : undefined - }); + try { + const file = req.file; + if (!file) { + return res.status(400).json({ error: "No file uploaded." }); + } + + if (file.size > MAX_FILE_SIZE) { + return res.status(413).json({ error: "File size exceeds limit." }); + } + + const project = req.project; + const external = isProjectStorageExternal(project); + const bucket = getBucket(project); + + // ATOMIC QUOTA RESERVATION + if (!external) { + const result = await Project.updateOne( + { + _id: project._id, + $expr: { + $lte: [{ $add: ["$storageUsed", file.size] }, "$storageLimit"], + }, + }, + { $inc: { storageUsed: file.size } }, + ); + + if (result.matchedCount === 0) { + return res + .status(403) + .json({ error: "Internal storage limit exceeded." }); + } + } + + const supabase = await getStorage(project); + + const safeName = file.originalname.replace(/\s+/g, "_"); + const filePath = `${project._id}/${randomUUID()}_${safeName}`; + + const { error: uploadError } = await supabase.storage + .from(bucket) + .upload(filePath, file.buffer, { + contentType: file.mimetype, + upsert: false, + }); + + if (uploadError) { + // ROLLBACK QUOTA + if (!external) { + await Project.updateOne( + { _id: project._id }, + { $inc: { storageUsed: -file.size } }, + ); + } + throw uploadError; } + + const { data: publicUrlData } = supabase.storage + .from(bucket) + .getPublicUrl(filePath); + + return res.status(201).json({ + message: "File uploaded successfully", + url: publicUrlData.publicUrl, + path: filePath, + provider: external ? "external" : "internal", + }); + } catch (err) { + return res.status(500).json({ + error: "File upload failed", + details: process.env.NODE_ENV === "development" ? err.message : undefined, + }); + } }; /** * Delete File */ module.exports.deleteFile = async (req, res) => { - try { - const { path } = req.body; - if (!path) { - return res.status(400).json({ error: "File path is required." }); - } - - const project = req.project; - const external = isProjectStorageExternal(project); - const bucket = getBucket(project); - - if (!path.startsWith(`${project._id}/`)) { - return res.status(403).json({ error: "Access denied." }); - } - - const supabase = await getStorage(project); - - // Fetch metadata before delete (for internal storage accounting) - let fileSize = 0; - if (!external) { - const { data, error } = await supabase.storage - .from(bucket) - .list(path.split("/")[0], { - search: path.split("/").slice(1).join("/") - }); - - if (error) throw error; - if (data?.length) { - fileSize = data[0].metadata?.size || 0; - } - } - - const { error: deleteError } = await supabase.storage - .from(bucket) - .remove([path]); - - if (deleteError) throw deleteError; - - if (!external && fileSize > 0) { - await Project.updateOne( - { _id: project._id }, - { $inc: { storageUsed: -fileSize } } - ); - } - - return res.json({ message: "File deleted successfully" }); - } catch (err) { - return res.status(500).json({ - error: "File deletion failed", - details: - process.env.NODE_ENV === "development" - ? err.message - : undefined + try { + const { path } = req.body; + if (!path) { + return res.status(400).json({ error: "File path is required." }); + } + + const project = req.project; + const external = isProjectStorageExternal(project); + const bucket = getBucket(project); + + if (!path.startsWith(`${project._id}/`)) { + return res.status(403).json({ error: "Access denied." }); + } + + const supabase = await getStorage(project); + + // Fetch metadata before delete (for internal storage accounting) + let fileSize = 0; + if (!external) { + const { data, error } = await supabase.storage + .from(bucket) + .list(path.split("/")[0], { + search: path.split("/").slice(1).join("/"), }); + + if (error) throw error; + if (data?.length) { + fileSize = data[0].metadata?.size || 0; + } } -}; -module.exports.deleteAllFiles = async (req, res) => { - try { - const project = req.project; // assuming middleware attaches project - if (!project) { - return res.status(404).json({ error: "Project not found" }); - } + const { error: deleteError } = await supabase.storage + .from(bucket) + .remove([path]); + + if (deleteError) throw deleteError; - const supabase = await getStorage(project); - const bucket = getBucket(project); + if (!external && fileSize > 0) { + await Project.updateOne( + { _id: project._id }, + { $inc: { storageUsed: -fileSize } }, + ); + } + + return res.json({ message: "File deleted successfully" }); + } catch (err) { + return res.status(500).json({ + error: "File deletion failed", + details: process.env.NODE_ENV === "development" ? err.message : undefined, + }); + } +}; - let hasMore = true; - let deletedCount = 0; +module.exports.deleteAllFiles = async (req, res) => { + try { + const project = req.project; // assuming middleware attaches project + if (!project) { + return res.status(404).json({ error: "Project not found" }); + } - while (hasMore) { - const { data: files, error } = await supabase.storage - .from(bucket) - .list(project._id.toString(), { limit: 100 }); + const supabase = await getStorage(project); + const bucket = getBucket(project); - if (error) throw error; + let hasMore = true; + let deletedCount = 0; - if (!files || files.length === 0) { - hasMore = false; - break; - } + while (hasMore) { + const { data: files, error } = await supabase.storage + .from(bucket) + .list(project._id.toString(), { limit: 100 }); - const paths = files.map(f => `${project._id}/${f.name}`); + if (error) throw error; - const { error: removeError } = await supabase.storage - .from(bucket) - .remove(paths); + if (!files || files.length === 0) { + hasMore = false; + break; + } - if (removeError) throw removeError; + const paths = files.map((f) => `${project._id}/${f.name}`); - deletedCount += files.length; - } + const { error: removeError } = await supabase.storage + .from(bucket) + .remove(paths); - // Reset usage only for internal storage - if (!isProjectStorageExternal(project)) { - await Project.updateOne( - { _id: project._id }, - { $set: { storageUsed: 0 } } - ); - } + if (removeError) throw removeError; - res.json({ - success: true, - deleted: deletedCount, - provider: isProjectStorageExternal(project) ? "external" : "internal" - }); + deletedCount += files.length; + } - } catch (err) { - res.status(500).json({ - error: "Failed to delete files", - details: - process.env.NODE_ENV === "development" - ? err.message - : undefined - }); + // Reset usage only for internal storage + if (!isProjectStorageExternal(project)) { + await Project.updateOne( + { _id: project._id }, + { $set: { storageUsed: 0 } }, + ); } -}; \ No newline at end of file + + res.json({ + success: true, + deleted: deletedCount, + provider: isProjectStorageExternal(project) ? "external" : "internal", + }); + } catch (err) { + res.status(500).json({ + error: "Failed to delete files", + details: process.env.NODE_ENV === "development" ? err.message : undefined, + }); + } +}; diff --git a/backend/utils/indexManager.js b/backend/utils/indexManager.js new file mode 100644 index 00000000..4c8dc6e5 --- /dev/null +++ b/backend/utils/indexManager.js @@ -0,0 +1,48 @@ +async function findDuplicates(Model, fieldName) { + return Model.aggregate([ + { + $match: { + [fieldName]: { $exists: true, $ne: null }, + }, + }, + { + $group: { + _id: `$${fieldName}`, + count: { $sum: 1 }, + }, + }, + { + $match: { count: { $gt: 1 } }, + }, + ]); +} + +async function createUniqueIndexes(Model, fields = []) { + const supportedTypes = new Set(["String", "Number", "Boolean", "Date"]); + + for (const field of fields) { + if (!field.unique) continue; + if (!supportedTypes.has(field.type)) continue; + + // Check for duplicate values before creating the index + const duplicates = await findDuplicates(Model, field.key); + + if (duplicates.length > 0) { + throw new Error( + `Cannot add unique constraint: ${duplicates.length} duplicate values found for field '${field.key}'`, + ); + } + + // Create MongoDB unique index + await Model.collection.createIndex( + { [field.key]: 1 }, + { + unique: true, + sparse: !field.required, + name: `${field.key}_1`, + }, + ); + } +} + +module.exports = { createUniqueIndexes }; diff --git a/backend/utils/input.validation.js b/backend/utils/input.validation.js index 38e4627f..882cda9b 100644 --- a/backend/utils/input.validation.js +++ b/backend/utils/input.validation.js @@ -1,171 +1,295 @@ const z = require("zod"); module.exports.loginSchema = z.object({ - email: z.string() - .min(1, { message: "Email is required." }) - .email({ message: "Invalid email format." }) - .max(100, { message: "Email is too long." }), - password: z.string() - .min(6, { message: "Password must be at least 6 characters" }) - .max(100, { message: "Password is too long." }) + email: z + .string() + .min(1, { message: "Email is required." }) + .email({ message: "Invalid email format." }) + .max(100, { message: "Email is too long." }), + password: z + .string() + .min(6, { message: "Password must be at least 6 characters" }) + .max(100, { message: "Password is too long." }), }); module.exports.signupSchema = z.object({ - username: z.string() - .min(3, { message: "Username must be at least 3 characters." }) - .max(50, { message: "Username must be between 3 and 50 characters." }), - - email: z.string() - .min(1, { message: "Email is required." }) - .email({ message: "Invalid email format." }) - .max(100, { message: "Email is too long." }), - - password: z.string() - .min(6, { message: "Password must be at least 6 characters." }) - .max(100, { message: "Password is too long." }) + username: z + .string() + .min(3, { message: "Username must be at least 3 characters." }) + .max(50, { message: "Username must be between 3 and 50 characters." }), + + email: z + .string() + .min(1, { message: "Email is required." }) + .email({ message: "Invalid email format." }) + .max(100, { message: "Email is too long." }), + + password: z + .string() + .min(6, { message: "Password must be at least 6 characters." }) + .max(100, { message: "Password is too long." }), }); module.exports.changePasswordSchema = z.object({ - currentPassword: z.string().min(1, "Current password is required"), - newPassword: z.string().min(6, "New password must be at least 6 characters") + currentPassword: z.string().min(1, "Current password is required"), + newPassword: z.string().min(6, "New password must be at least 6 characters"), }); module.exports.deleteAccountSchema = z.object({ - password: z.string().min(1, "Password is required") + password: z.string().min(1, "Password is required"), }); module.exports.onlyEmailSchema = z.object({ - email: z.string().email("Invalid email format") + email: z.string().email("Invalid email format"), }); module.exports.verifyOtpSchema = z.object({ - email: z.string().email("Invalid email format"), - otp: z.string().length(6, "OTP must be 6 digits") + email: z.string().email("Invalid email format"), + otp: z.string().length(6, "OTP must be 6 digits"), }); module.exports.resetPasswordSchema = z.object({ - email: z.string().email("Invalid email format"), - otp: z.string().length(6, "OTP must be 6 digits"), - newPassword: z.string().min(6, "Password must be at least 6 characters").max(100, "Password is too long.") + email: z.string().email("Invalid email format"), + otp: z.string().length(6, "OTP must be 6 digits"), + newPassword: z + .string() + .min(6, "Password must be at least 6 characters") + .max(100, "Password is too long."), }); - module.exports.createProjectSchema = z.object({ - name: z.string().min(1, "Project name is required"), - description: z.string().optional() + name: z.string().min(1, "Project name is required"), + description: z.string().optional(), }); // FUNCTION - BUILD FIELD SCHEMA ZOD const MAX_FIELD_DEPTH = 3; const buildFieldSchemaZod = (depth = 1) => { - const base = z.object({ - key: z.string().min(1, "Field name is required"), - type: z.enum(['String', 'Number', 'Boolean', 'Date', 'Object', 'Array', 'Ref']), - required: z.boolean().optional(), - ref: z.string().optional(), - items: z.object({ - type: z.enum(['String', 'Number', 'Boolean', 'Date', 'Object', 'Ref']), - fields: z.lazy(() => depth < MAX_FIELD_DEPTH ? z.array(buildFieldSchemaZod(depth + 1)).optional() : z.undefined().optional()), - }).optional(), - fields: z.lazy(() => depth < MAX_FIELD_DEPTH ? z.array(buildFieldSchemaZod(depth + 1)).optional() : z.undefined().optional()), - }).refine(data => { - if (data.type === 'Object' && (!data.fields || data.fields.length === 0)) return false; - if (data.type === 'Array' && !data.items) return false; - if (data.type === 'Ref' && !data.ref) return false; - if (depth >= MAX_FIELD_DEPTH && (data.type === 'Object' || (data.type === 'Array' && data.items?.type === 'Object'))) return false; + const base = z + .object({ + key: z.string().min(1, "Field name is required"), + type: z.enum([ + "String", + "Number", + "Boolean", + "Date", + "Object", + "Array", + "Ref", + ]), + required: z.boolean().optional(), + unique: z.boolean().optional(), + ref: z.string().optional(), + items: z + .object({ + type: z.enum([ + "String", + "Number", + "Boolean", + "Date", + "Object", + "Ref", + ]), + fields: z.lazy(() => + depth < MAX_FIELD_DEPTH + ? z.array(buildFieldSchemaZod(depth + 1)).optional() + : z.undefined().optional(), + ), + }) + .optional(), + fields: z.lazy(() => + depth < MAX_FIELD_DEPTH + ? z.array(buildFieldSchemaZod(depth + 1)).optional() + : z.undefined().optional(), + ), + }) + .refine( + (data) => { + if ( + data.type === "Object" && + (!data.fields || data.fields.length === 0) + ) + return false; + if (data.type === "Array" && !data.items) return false; + if (data.type === "Ref" && !data.ref) return false; + if ( + depth >= MAX_FIELD_DEPTH && + (data.type === "Object" || + (data.type === "Array" && data.items?.type === "Object")) + ) + return false; return true; - }, { message: "Invalid field configuration for the given type, or nesting depth exceeded (max 3 levels)." }); + }, + { + message: + "Invalid field configuration for the given type, or nesting depth exceeded (max 3 levels).", + }, + ); - return base; + return base; }; const fieldSchemaZod = buildFieldSchemaZod(1); // SCHEMA - CREATE COLLECTION (DASHBOARD) module.exports.createCollectionSchema = z.object({ - projectId: z.string().min(1, "Project ID is required"), - collectionName: z.string().min(1, "Collection Name is required"), - schema: z.array(fieldSchemaZod).optional() + projectId: z.string().min(1, "Project ID is required"), + collectionName: z.string().min(1, "Collection Name is required"), + schema: z.array(fieldSchemaZod).optional(), }); // SCHEMA - CREATE COLLECTION (API) const buildApiFieldSchemaZod = (depth = 1) => { - const base = z.object({ - name: z.string().min(1, "Field name is required"), - type: z.enum([ - 'string', 'number', 'boolean', 'date', 'object', 'array', 'ref', - 'String', 'Number', 'Boolean', 'Date', 'Object', 'Array', 'Ref' - ]), - required: z.boolean().optional(), - ref: z.string().optional(), - items: z.object({ - type: z.enum([ - 'string', 'number', 'boolean', 'date', 'object', 'ref', - 'String', 'Number', 'Boolean', 'Date', 'Object', 'Ref' - ]), - fields: z.lazy(() => depth < MAX_FIELD_DEPTH ? z.array(buildApiFieldSchemaZod(depth + 1)).optional() : z.undefined().optional()), - }).optional(), - fields: z.lazy(() => depth < MAX_FIELD_DEPTH ? z.array(buildApiFieldSchemaZod(depth + 1)).optional() : z.undefined().optional()), - }).refine(data => { - const normalType = data.type.charAt(0).toUpperCase() + data.type.slice(1).toLowerCase(); - if (normalType === 'Object' && (!data.fields || data.fields.length === 0)) return false; - if (normalType === 'Array' && !data.items) return false; - if (normalType === 'Ref' && !data.ref) return false; - if (depth >= MAX_FIELD_DEPTH && (normalType === 'Object' || (normalType === 'Array' && data.items?.type?.charAt(0).toUpperCase() + data.items?.type?.slice(1).toLowerCase() === 'Object'))) return false; + const base = z + .object({ + name: z.string().min(1, "Field name is required"), + type: z.enum([ + "string", + "number", + "boolean", + "date", + "object", + "array", + "ref", + "String", + "Number", + "Boolean", + "Date", + "Object", + "Array", + "Ref", + ]), + required: z.boolean().optional(), + unique: z.boolean().optional(), + ref: z.string().optional(), + items: z + .object({ + type: z.enum([ + "string", + "number", + "boolean", + "date", + "object", + "ref", + "String", + "Number", + "Boolean", + "Date", + "Object", + "Ref", + ]), + fields: z.lazy(() => + depth < MAX_FIELD_DEPTH + ? z.array(buildApiFieldSchemaZod(depth + 1)).optional() + : z.undefined().optional(), + ), + }) + .optional(), + fields: z.lazy(() => + depth < MAX_FIELD_DEPTH + ? z.array(buildApiFieldSchemaZod(depth + 1)).optional() + : z.undefined().optional(), + ), + }) + .refine( + (data) => { + const normalType = + data.type.charAt(0).toUpperCase() + data.type.slice(1).toLowerCase(); + if ( + normalType === "Object" && + (!data.fields || data.fields.length === 0) + ) + return false; + if (normalType === "Array" && !data.items) return false; + if (normalType === "Ref" && !data.ref) return false; + if ( + depth >= MAX_FIELD_DEPTH && + (normalType === "Object" || + (normalType === "Array" && + data.items?.type?.charAt(0).toUpperCase() + + data.items?.type?.slice(1).toLowerCase() === + "Object")) + ) + return false; return true; - }, { message: "Invalid field configuration for the given type, or nesting depth exceeded (max 3 levels)." }); + }, + { + message: + "Invalid field configuration for the given type, or nesting depth exceeded (max 3 levels).", + }, + ); - return base; + return base; }; module.exports.createSchemaApiKeySchema = z.object({ - name: z.string().min(1, "Collection Name is required"), - fields: z.array(buildApiFieldSchemaZod(1)).optional() + name: z.string().min(1, "Collection Name is required"), + fields: z.array(buildApiFieldSchemaZod(1)).optional(), }); module.exports.sanitize = (obj) => { - const clean = {}; - for (const key in obj) { - if (!key.startsWith('$')) { - clean[key] = obj[key]; - } + const clean = {}; + for (const key in obj) { + if (!key.startsWith("$")) { + clean[key] = obj[key]; } - return clean; + } + return clean; }; -const emptyToUndefined = z.preprocess((val) => (val === "" || val === null ? undefined : val), z.string().optional()); +const emptyToUndefined = z.preprocess( + (val) => (val === "" || val === null ? undefined : val), + z.string().optional(), +); -module.exports.updateExternalConfigSchema = z.object({ - dbUri: z.preprocess((val) => (val === "" || val === null ? undefined : val), - z.string().optional().refine(val => !val || val.startsWith('mongodb'), { - message: "Invalid Database URI format." - }) +module.exports.updateExternalConfigSchema = z + .object({ + dbUri: z.preprocess( + (val) => (val === "" || val === null ? undefined : val), + z + .string() + .optional() + .refine((val) => !val || val.startsWith("mongodb"), { + message: "Invalid Database URI format.", + }), ), - storageUrl: z.preprocess((val) => (val === "" || val === null ? undefined : val), - z.string().url("Invalid Storage URL format").optional() + storageUrl: z.preprocess( + (val) => (val === "" || val === null ? undefined : val), + z.string().url("Invalid Storage URL format").optional(), ), storageKey: emptyToUndefined, - storageProvider: z.enum(['supabase', 'aws', 'cloudinary']).optional() -}).refine(data => { - if (data.storageUrl && !data.storageKey) return false; - if (data.storageKey && !data.storageUrl) return false; - return !!(data.dbUri || (data.storageUrl && data.storageKey)); -}, { - message: "Provide either a DB URI or a complete Storage config (URL + Key)." -}); + storageProvider: z.enum(["supabase", "aws", "cloudinary"]).optional(), + }) + .refine( + (data) => { + if (data.storageUrl && !data.storageKey) return false; + if (data.storageKey && !data.storageUrl) return false; + return !!(data.dbUri || (data.storageUrl && data.storageKey)); + }, + { + message: + "Provide either a DB URI or a complete Storage config (URL + Key).", + }, + ); -module.exports.userSignupSchema = z.object({ - username: z.string() - .min(3, { message: "Username must be at least 3 characters." }) - .max(50, { message: "Username must be between 3 and 50 characters." }).optional(), +module.exports.userSignupSchema = z + .object({ + username: z + .string() + .min(3, { message: "Username must be at least 3 characters." }) + .max(50, { message: "Username must be between 3 and 50 characters." }) + .optional(), - email: z.string() - .min(1, { message: "Email is required." }) - .email({ message: "Invalid email format." }) - .max(100, { message: "Email is too long." }), + email: z + .string() + .min(1, { message: "Email is required." }) + .email({ message: "Invalid email format." }) + .max(100, { message: "Email is too long." }), - password: z.string() - .min(6, { message: "Password must be at least 6 characters." }) - .max(100, { message: "Password is too long." }) -}).passthrough(); \ No newline at end of file + password: z + .string() + .min(6, { message: "Password must be at least 6 characters." }) + .max(100, { message: "Password is too long." }), + }) + .passthrough(); diff --git a/package-lock.json b/package-lock.json index 3a974f85..35510ce7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,7 +1,7 @@ { "name": "urBackend", - "lockfileVersion": 3, "version": "0.1.0", + "lockfileVersion": 3, "requires": true, "packages": {} }