diff --git a/apps/dashboard-api/src/controllers/project.controller.js b/apps/dashboard-api/src/controllers/project.controller.js index c76af58fe..cdc86fcf0 100644 --- a/apps/dashboard-api/src/controllers/project.controller.js +++ b/apps/dashboard-api/src/controllers/project.controller.js @@ -1,1161 +1,1255 @@ -const mongoose = require("mongoose") -const {Project} = require("@urbackend/common") -const {Developer} = require("@urbackend/common") -const {Log} = require("@urbackend/common") +const mongoose = require("mongoose"); +const { Project } = require("@urbackend/common"); +const { Developer } = require("@urbackend/common"); +const { Log } = require("@urbackend/common"); const { getStorage } = require("@urbackend/common"); const { randomUUID } = require("crypto"); -const { createProjectSchema, createCollectionSchema, updateExternalConfigSchema } = require('@urbackend/common'); -const { generateApiKey, hashApiKey } = require('@urbackend/common'); -const { z } = require('zod'); -const { encrypt } = require('@urbackend/common'); -const { URL } = require('url'); +const { + createProjectSchema, + createCollectionSchema, + updateExternalConfigSchema, +} = require("@urbackend/common"); +const { generateApiKey, hashApiKey } = require("@urbackend/common"); +const { z } = require("zod"); +const { encrypt } = require("@urbackend/common"); +const { URL } = require("url"); const { getConnection } = require("@urbackend/common"); -const { getCompiledModel } = require("@urbackend/common") -const {QueryEngine} = require("@urbackend/common"); +const { getCompiledModel } = require("@urbackend/common"); +const { QueryEngine } = require("@urbackend/common"); const { storageRegistry } = require("@urbackend/common"); -const { deleteProjectByApiKeyCache, setProjectById, getProjectById, deleteProjectById } = require("@urbackend/common"); -const { isProjectStorageExternal, isProjectDbExternal, getBucket } = require("@urbackend/common"); -const { v4: uuidv4 } = require('uuid'); +const { + deleteProjectByApiKeyCache, + setProjectById, + getProjectById, + deleteProjectById, +} = require("@urbackend/common"); +const { isProjectStorageExternal, getBucket } = require("@urbackend/common"); const { getPublicIp } = require("@urbackend/common"); +const { clearCompiledModel } = require("@urbackend/common"); +const { createUniqueIndexes } = require("@urbackend/common"); -const normalizeFieldKey = (key) => String(key || '').replace(/\uFEFF/g, '').trim(); -const normalizeFieldType = (type) => String(type || '').trim().toLowerCase(); -const isRequiredField = (required) => ( - required === true || - required === 1 || - String(required).trim().toLowerCase() === 'true' || - String(required).trim() === '1' -); - -const toPlainObject = (value) => { - if (!value || typeof value !== 'object') return value; - if (typeof value.toObject === 'function') { - return value.toObject({ depopulate: true }); - } - if (value._doc && typeof value._doc === 'object') { - return { ...value._doc }; - } - return value; -}; +const validateUsersSchema = (schema) => { + if (!Array.isArray(schema)) return false; -const sanitizeSchemaFields = (schema = []) => { - if (!Array.isArray(schema)) return []; - return schema - .map((rawField) => { - const field = toPlainObject(rawField); - if (!field || typeof field !== 'object') return null; - const normalizedKey = normalizeFieldKey(field.key); - if (!normalizedKey) return null; - - const next = { ...field, key: normalizedKey }; - if (Array.isArray(field.fields)) { - next.fields = sanitizeSchemaFields(field.fields); - } - if (field.items && typeof field.items === 'object') { - next.items = { ...field.items }; - if (Array.isArray(field.items.fields)) { - next.items.fields = sanitizeSchemaFields(field.items.fields); - } - } - return next; - }) - .filter(Boolean); -}; + const sanitizedSchema = sanitizeSchemaFields(schema); -const validateUsersSchema = (schema) => { - if (!Array.isArray(schema)) return false; - const sanitizedSchema = sanitizeSchemaFields(schema); - const hasEmail = sanitizedSchema.find((f) => - normalizeFieldKey(f.key).toLowerCase() === 'email' && - normalizeFieldType(f.type) === 'string' && - isRequiredField(f.required) - ); - const hasPassword = sanitizedSchema.find((f) => - normalizeFieldKey(f.key).toLowerCase() === 'password' && - normalizeFieldType(f.type) === 'string' && - isRequiredField(f.required) - ); - return !!(hasEmail && hasPassword); -}; + const hasEmail = sanitizedSchema.find( + (f) => + normalizeFieldKey(f.key).toLowerCase() === "email" && + normalizeFieldType(f.type) === "string" && + isRequiredField(f.required), + ); -const getDefaultRlsForCollection = (collectionName, schema = []) => { - const normalizedName = String(collectionName || '').toLowerCase(); - const keys = sanitizeSchemaFields(schema).map(f => f.key); - - let ownerField = 'userId'; - if (normalizedName === 'users') { - ownerField = '_id'; - } else if (keys.includes('userId')) { - ownerField = 'userId'; - } else if (keys.includes('ownerId')) { - ownerField = 'ownerId'; - } + const hasPassword = sanitizedSchema.find( + (f) => + normalizeFieldKey(f.key).toLowerCase() === "password" && + normalizeFieldType(f.type) === "string" && + isRequiredField(f.required), + ); - return { - enabled: false, - mode: 'owner-write-only', - ownerField, - requireAuthForWrite: true - }; + return !!(hasEmail && hasPassword); }; +const normalizeFieldKey = (key) => + String(key || "") + .replace(/\uFEFF/g, "") + .trim(); +const normalizeFieldType = (type) => + String(type || "") + .trim() + .toLowerCase(); +const isRequiredField = (required) => + required === true || + required === 1 || + String(required).trim().toLowerCase() === "true" || + String(required).trim() === "1"; +const toPlainObject = (value) => { + if (!value || typeof value !== "object") return value; + if (typeof value.toObject === "function") { + return value.toObject({ depopulate: true }); + } + if (value._doc && typeof value._doc === "object") { + return { ...value._doc }; + } + return value; +}; +const sanitizeSchemaFields = (schema = []) => { + if (!Array.isArray(schema)) return []; + return schema + .map((rawField) => { + const field = toPlainObject(rawField); + if (!field || typeof field !== "object") return null; -module.exports.createProject = async (req, res) => { - try { - // POST FOR - PROJECT CREATION - const { name, description } = createProjectSchema.parse(req.body); - - // --- PROJECT LIMIT CHECK --- - const ADMIN_EMAIL = process.env.ADMIN_EMAIL; - - // GET MAX PROJECTS - const dev = await Developer.findById(req.user._id); - const MAX_PROJECTS = dev?.maxProjects || 3; - - const isUserAdmin = dev.email === ADMIN_EMAIL; - const projectCount = await Project.countDocuments({ owner: req.user._id }); - - if (!isUserAdmin && projectCount >= MAX_PROJECTS) { - return res.status(403).json({ - error: `Project limit reached. Your current plan allows up to ${MAX_PROJECTS} projects.`, - limit: MAX_PROJECTS, - current: projectCount - }); - } - // --------------------------- + const normalizedKey = normalizeFieldKey(field.key); + if (!normalizedKey) return null; - const rawPublishableKey = generateApiKey('pk_live_'); - const hashedPublishableKey = hashApiKey(rawPublishableKey); + const next = { ...field, key: normalizedKey }; - const rawSecretKey = generateApiKey('sk_live_'); - const hashedSecretKey = hashApiKey(rawSecretKey); + if (Array.isArray(field.fields)) { + next.fields = sanitizeSchemaFields(field.fields); + } - const rawJwtSecret = generateApiKey('jwt_'); + if (field.items && typeof field.items === "object") { + next.items = { ...field.items }; + if (Array.isArray(field.items.fields)) { + next.items.fields = sanitizeSchemaFields(field.items.fields); + } + } - const newProject = new Project({ - name, - description, - owner: req.user._id, - publishableKey: hashedPublishableKey, - secretKey: hashedSecretKey, - jwtSecret: rawJwtSecret - }); - await newProject.save(); + return next; + }) + .filter(Boolean); +}; - const projectObj = newProject.toObject(); - projectObj.publishableKey = rawPublishableKey; - projectObj.secretKey = rawSecretKey; - delete projectObj.jwtSecret; +const getDefaultRlsForCollection = (collectionName, schema = []) => { + const normalizedName = String(collectionName || "").toLowerCase(); + const keys = sanitizeSchemaFields(schema).map((f) => f.key); + + let ownerField = "userId"; + if (normalizedName === "users") { + ownerField = "_id"; + } else if (keys.includes("userId")) { + ownerField = "userId"; + } else if (keys.includes("ownerId")) { + ownerField = "ownerId"; + } + + return { + enabled: false, + mode: "owner-write-only", + ownerField, + requireAuthForWrite: true, + }; +}; - res.status(201).json(projectObj); - } catch (err) { - if (err instanceof z.ZodError) return res.status(400).json({ error: err.errors }); - res.status(500).json({ error: err.message }); +module.exports.createProject = async (req, res) => { + try { + // POST FOR - PROJECT CREATION + const { name, description } = createProjectSchema.parse(req.body); + + // --- PROJECT LIMIT CHECK --- + const ADMIN_EMAIL = process.env.ADMIN_EMAIL; + + // GET MAX PROJECTS + const dev = await Developer.findById(req.user._id); + const MAX_PROJECTS = dev?.maxProjects || 3; + + const isUserAdmin = dev.email === ADMIN_EMAIL; + const projectCount = await Project.countDocuments({ owner: req.user._id }); + + if (!isUserAdmin && projectCount >= MAX_PROJECTS) { + return res.status(403).json({ + error: `Project limit reached. Your current plan allows up to ${MAX_PROJECTS} projects.`, + limit: MAX_PROJECTS, + current: projectCount, + }); + } + // --------------------------- + + const rawPublishableKey = generateApiKey("pk_live_"); + const hashedPublishableKey = hashApiKey(rawPublishableKey); + + const rawSecretKey = generateApiKey("sk_live_"); + const hashedSecretKey = hashApiKey(rawSecretKey); + + const rawJwtSecret = generateApiKey("jwt_"); + + const newProject = new Project({ + name, + description, + owner: req.user._id, + publishableKey: hashedPublishableKey, + secretKey: hashedSecretKey, + jwtSecret: rawJwtSecret, + }); + await newProject.save(); + + const projectObj = newProject.toObject(); + projectObj.publishableKey = rawPublishableKey; + projectObj.secretKey = rawSecretKey; + delete projectObj.jwtSecret; + + res.status(201).json(projectObj); + } catch (err) { + if (err instanceof z.ZodError) { + return res.status(400).json({ error: err.issues }); } -} + res.status(500).json({ error: err.message }); + } +}; module.exports.getAllProject = async (req, res) => { - try { - const projects = await Project.find({ owner: req.user._id }) - .select('name description') - .lean(); + try { + const projects = await Project.find({ owner: req.user._id }) + .select("name description") + .lean(); + + res.status(200).json(projects); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}; - res.status(200).json(projects); - } catch (err) { - res.status(500).json({ error: err.message }); +module.exports.getSingleProject = async (req, res) => { + try { + let projectObj = await getProjectById(req.params.projectId); + + if (!projectObj) { + const project = await Project.findOne({ + _id: req.params.projectId, + owner: req.user._id, + }).select("-publishableKey -secretKey -jwtSecret"); + if (!project) + return res.status(404).json({ error: "Project not found." }); + projectObj = project.toObject(); + await setProjectById(req.params.projectId, projectObj); } -} - -module.exports.getSingleProject = async (req, res) => { - try { - let projectObj = await getProjectById(req.params.projectId); - - if (!projectObj) { - const project = await Project.findOne({ _id: req.params.projectId, owner: req.user._id }).select('-publishableKey -secretKey -jwtSecret'); - if (!project) return res.status(404).json({ error: "Project not found." }); - projectObj = project.toObject(); - await setProjectById(req.params.projectId, projectObj); - } + // Ownership Check (Even for Cache) + if (projectObj.owner.toString() !== req.user._id.toString()) { + return res.status(403).json({ error: "Access denied." }); + } - // Ownership Check (Even for Cache) - if (projectObj.owner.toString() !== req.user._id.toString()) { - return res.status(403).json({ error: "Access denied." }); + // Just to be safe, we remove sensitive fields again even if from cache + delete projectObj.publishableKey; + delete projectObj.secretKey; + delete projectObj.jwtSecret; + + // SANITIZE COLLECTION MODELS: Remove "password" from "users" collection schema + if (projectObj.collections && Array.isArray(projectObj.collections)) { + projectObj.collections = projectObj.collections.map((col) => { + if (col.name === "users" && col.model) { + return { + ...col, + model: col.model.filter((m) => m.key !== "password"), + rls: col.rls || getDefaultRlsForCollection(col.name, col.model), + }; } - // Just to be safe, we remove sensitive fields again even if from cache - delete projectObj.publishableKey; - delete projectObj.secretKey; - delete projectObj.jwtSecret; - - // SANITIZE COLLECTION MODELS: Remove "password" from "users" collection schema - if (projectObj.collections && Array.isArray(projectObj.collections)) { - projectObj.collections = projectObj.collections.map(col => { - if (col.name === 'users' && col.model) { - return { - ...col, - model: col.model.filter(m => m.key !== 'password'), - rls: col.rls || { - enabled: false, - mode: 'owner-write-only', - ownerField: '_id', - requireAuthForWrite: true - } - }; - } - return { - ...col, - rls: col.rls || getDefaultRlsForCollection(col.name, col.model) - }; - }); - } - - res.json(projectObj); - } catch (err) { - res.status(500).json({ error: err.message }); + return { + ...col, + rls: col.rls || getDefaultRlsForCollection(col.name, col.model), + }; + }); } -} + + res.json(projectObj); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}; module.exports.regenerateApiKey = async (req, res) => { - try { - const { keyType } = req.body; // 'publishable' or 'secret' - - if (keyType !== 'publishable' && keyType !== 'secret') { - return res.status(400).json({ error: "Invalid keyType. Must be 'publishable' or 'secret'." }); - } + try { + const { keyType } = req.body; // 'publishable' or 'secret' - const prefix = keyType === 'publishable' ? 'pk_live_' : 'sk_live_'; - const newApiKey = generateApiKey(prefix); - const hashed = hashApiKey(newApiKey); + if (keyType !== "publishable" && keyType !== "secret") { + return res + .status(400) + .json({ error: "Invalid keyType. Must be 'publishable' or 'secret'." }); + } - const oldApiProj = await Project.findOne({ _id: req.params.projectId, owner: req.user._id }) - .select('publishableKey secretKey'); - if (!oldApiProj) return res.status(404).json({ error: "Project not found." }); - - // CLEAR CACHE - await deleteProjectByApiKeyCache(oldApiProj.publishableKey); - await deleteProjectByApiKeyCache(oldApiProj.secretKey); + const prefix = keyType === "publishable" ? "pk_live_" : "sk_live_"; + const newApiKey = generateApiKey(prefix); + const hashed = hashApiKey(newApiKey); + + const oldApiProj = await Project.findOne({ + _id: req.params.projectId, + owner: req.user._id, + }).select("publishableKey secretKey"); + if (!oldApiProj) + return res.status(404).json({ error: "Project not found." }); + + // CLEAR CACHE + await deleteProjectByApiKeyCache(oldApiProj.publishableKey); + await deleteProjectByApiKeyCache(oldApiProj.secretKey); + + const updateField = + keyType === "publishable" + ? { publishableKey: hashed } + : { secretKey: hashed }; + + const project = await Project.findOneAndUpdate( + { _id: req.params.projectId, owner: req.user._id }, + { $set: updateField }, + { new: true }, + ); + if (!project) return res.status(404).json({ error: "Project not found." }); + + const projectObj = project.toObject(); + delete projectObj.publishableKey; + delete projectObj.secretKey; + delete projectObj.jwtSecret; + res.json({ apiKey: newApiKey, keyType, project: projectObj }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}; - const updateField = keyType === 'publishable' ? { publishableKey: hashed } : { secretKey: hashed }; +const isNamespaceNotFoundError = (err) => { + return err && (err.code === 26 || /ns not found/i.test(err.message)); +}; - const project = await Project.findOneAndUpdate( - { _id: req.params.projectId, owner: req.user._id }, - { $set: updateField }, - { new: true } - ); - if (!project) return res.status(404).json({ error: "Project not found." }); - - const projectObj = project.toObject(); - delete projectObj.publishableKey; - delete projectObj.secretKey; - delete projectObj.jwtSecret; - res.json({ apiKey: newApiKey, keyType, project: projectObj }); - } catch (err) { - res.status(500).json({ error: err.message }); +const dropCollectionIfExists = async (connection, collectionName) => { + try { + await connection.db.dropCollection(collectionName); + } catch (err) { + if (!isNamespaceNotFoundError(err)) { + throw err; } + } }; - - // VALIDATE URI const isSafeUri = (uri) => { - try { - const parsed = new URL(uri); - const host = parsed.hostname.toLowerCase(); - const badHosts = ['localhost', '127.0.0.1', '0.0.0.0', '::1']; - return !badHosts.includes(host); - } catch (e) { return false; } + try { + const parsed = new URL(uri); + const host = parsed.hostname.toLowerCase(); + const badHosts = ["localhost", "127.0.0.1", "0.0.0.0", "::1"]; + return !badHosts.includes(host); + } catch (e) { + return false; + } }; - - module.exports.updateExternalConfig = async (req, res) => { - try { - const { projectId } = req.params; - - // POST FOR - EXTERNAL CONFIG - const validatedData = updateExternalConfigSchema.parse(req.body); - const { - dbUri, - storageUrl, storageKey, storageProvider, - s3AccessKeyId, s3SecretAccessKey, s3Region, s3Endpoint, s3Bucket, publicUrlHost - } = validatedData; - - const updateData = {}; - - // DB CONFIG - if (dbUri) { - if (!isSafeUri(dbUri)) return res.status(400).json({ error: "DB URI is pointing to a restricted host (localhost/internal)." }); - - updateData['resources.db.config'] = encrypt(JSON.stringify({ dbUri })); - updateData['resources.db.isExternal'] = true; - - // --- VERIFY CONNECTION --- - console.log("Verifying connection to:", projectId); - try { - const tempConn = mongoose.createConnection(dbUri, { serverSelectionTimeoutMS: 5000 }); - await tempConn.asPromise(); - await tempConn.close(); - } catch (connErr) { - console.error("Verification Connection Failed:", connErr.message); - let errorMsg = "Could not connect to the provided MongoDB URI."; - - if (connErr.message.includes("Server selection timed out") || connErr.message.includes("Could not connect")) { - const serverIp = await getPublicIp(); - errorMsg = `Access Denied: Please whitelist Server IP [${serverIp}] in MongoDB Atlas.`; - } else { - errorMsg += " " + connErr.message; - } - - return res.status(400).json({ error: errorMsg }); - } - // ------------------------- - } + try { + const { projectId } = req.params; - // STORAGE CONFIG - if (storageProvider) { - let storageConfig = {}; - - if (storageProvider === 'supabase') { - if (storageUrl && storageKey) { - storageConfig = { storageProvider, storageUrl, storageKey }; - } - } else if (storageProvider === 's3' || storageProvider === 'cloudflare_r2') { - if (s3AccessKeyId && s3SecretAccessKey && s3Bucket) { - storageConfig = { - storageProvider, - accessKeyId: s3AccessKeyId, - secretAccessKey: s3SecretAccessKey, - bucket: s3Bucket, - publicUrlHost - }; - if (storageProvider === 's3') { - storageConfig.region = s3Region; - } else if (storageProvider === 'cloudflare_r2') { - storageConfig.endpoint = s3Endpoint; - storageConfig.region = 'auto'; - } - } - } - - if (Object.keys(storageConfig).length > 0) { - updateData['resources.storage.config'] = encrypt(JSON.stringify(storageConfig)); - updateData['resources.storage.isExternal'] = true; - } else { - return res.status(400).json({ - error: `Incomplete configuration for storage provider: ${storageProvider}. Please ensure all required keys for ${storageProvider === 'supabase' ? 'Supabase' : 'S3/R2'} are provided.` - }); - } - } else if (storageUrl && storageKey) { - // CONFIG - FALLBACK FOR LEGACY CLIENTS - const storageConfig = { - storageProvider: 'supabase', - storageUrl, - storageKey - }; - updateData['resources.storage.config'] = encrypt(JSON.stringify(storageConfig)); - updateData['resources.storage.isExternal'] = true; - } + // POST FOR - EXTERNAL CONFIG + const validatedData = updateExternalConfigSchema.parse(req.body); + const { dbUri, storageUrl, storageKey, storageProvider } = validatedData; - const project = await Project.findOneAndUpdate( - { _id: projectId, owner: req.user._id }, - { $set: updateData }, - { new: true } - ); + const updateData = {}; - if (!project) return res.status(404).json({ error: "Project not found or access denied." }); + // DB CONFIG + if (dbUri) { + if (!isSafeUri(dbUri)) + return res.status(400).json({ + error: + "DB URI is pointing to a restricted host (localhost/internal).", + }); - // CACHE - INVALIDATE STORAGE CLIENT - storageRegistry.delete(projectId.toString()); + updateData["resources.db.config"] = encrypt(JSON.stringify({ dbUri })); + updateData["resources.db.isExternal"] = true; - res.status(200).json({ message: "External configuration updated successfully." }); - } catch (err) { - if (err.name === 'ZodError') { - return res.status(400).json({ - error: err.errors?.[0]?.message || err.issues?.[0]?.message || "Validation failed" - }); + // --- VERIFY CONNECTION --- + console.log("Verifying connection to:", projectId); + try { + const tempConn = mongoose.createConnection(dbUri, { + serverSelectionTimeoutMS: 5000, + }); + await tempConn.asPromise(); + await tempConn.close(); + } catch (connErr) { + console.error("Verification Connection Failed:", connErr.message); + let errorMsg = "Could not connect to the provided MongoDB URI."; + + if ( + connErr.message.includes("Server selection timed out") || + connErr.message.includes("Could not connect") + ) { + const serverIp = await getPublicIp(); + errorMsg = `Access Denied: Please whitelist Server IP [${serverIp}] in MongoDB Atlas.`; + } else { + errorMsg += " " + connErr.message; } - console.error("External Config Error:", err); - res.status(500).json({ error: err.message }); + return res.status(400).json({ error: errorMsg }); + } + // ------------------------- } -} - -module.exports.deleteExternalDbConfig = async (req, res) => { - try { - const parsedBody = z.object({ - projectId: z.string(), - }).parse(req.body); - const { projectId } = parsedBody; - const project = await Project.findOne({ _id: { $eq: projectId }, owner: req.user._id }); - if (!project) return res.status(404).json({ error: "Project not found or access denied." }); - - project.resources.db.isExternal = false; - project.resources.db.config = null; - await project.save(); - - res.status(200).json({ message: "External configuration deleted successfully." }); - } catch (err) { - res.status(500).json({ error: err.message }); + // STORAGE CONFIG + if (storageUrl && storageKey) { + const storageConfig = { + storageUrl, + storageKey, + storageProvider: storageProvider || "supabase", + }; + updateData["resources.storage.config"] = encrypt( + JSON.stringify(storageConfig), + ); + updateData["resources.storage.isExternal"] = true; } -} - -module.exports.deleteExternalStorageConfig = async (req, res) => { - try { - const parsedBody = z.object({ - projectId: z.string(), - }).parse(req.body); - const { projectId } = parsedBody; - - const project = await Project.findOne({ _id: { $eq: projectId }, owner: req.user._id }); - if (!project) return res.status(404).json({ error: "Project not found or access denied." }); - - project.resources.storage.isExternal = false; - project.resources.storage.config = null; - // CACHE - INVALIDATE - storageRegistry.delete(projectId.toString()); - await deleteProjectById(projectId); - await setProjectById(projectId, project); - await project.save(); + const project = await Project.findOneAndUpdate( + { _id: projectId, owner: req.user._id }, + { $set: updateData }, + { new: true }, + ); - res.status(200).json({ message: "External configuration deleted successfully." }); - } catch (err) { - res.status(500).json({ error: err.message }); + if (!project) + return res + .status(404) + .json({ error: "Project not found or access denied." }); + + res + .status(200) + .json({ message: "External configuration updated successfully." }); + } catch (err) { + if (err instanceof z.ZodError) { + return res.status(400).json({ error: err.issues }); } -} + console.error("External Config Error:", err); + res.status(500).json({ error: err.message }); + } +}; + +module.exports.deleteExternalDbConfig = async (req, res) => { + try { + const parsedBody = z + .object({ + projectId: z.string(), + }) + .parse(req.body); + const { projectId } = parsedBody; + + const project = await Project.findOne({ + _id: { $eq: projectId }, + owner: req.user._id, + }); + if (!project) + return res + .status(404) + .json({ error: "Project not found or access denied." }); + + project.resources.db.isExternal = false; + project.resources.db.config = null; + await project.save(); + + res + .status(200) + .json({ message: "External configuration deleted successfully." }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}; +module.exports.deleteExternalStorageConfig = async (req, res) => { + try { + const parsedBody = z + .object({ + projectId: z.string(), + }) + .parse(req.body); + const { projectId } = parsedBody; + + const project = await Project.findOne({ + _id: { $eq: projectId }, + owner: req.user._id, + }); + if (!project) + return res + .status(404) + .json({ error: "Project not found or access denied." }); + + project.resources.storage.isExternal = false; + project.resources.storage.config = null; + + await project.save(); + await deleteProjectById(projectId); + await setProjectById(projectId, project.toObject()); + + res + .status(200) + .json({ message: "External configuration deleted successfully." }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}; // POST REQ FOR CREATE COLLECTION module.exports.createCollection = async (req, res) => { - try { - const { projectId, collectionName, schema } = createCollectionSchema.parse(req.body); - const sanitizedSchema = sanitizeSchemaFields(schema); + let project; + let connection; + let compiledCollectionName; + let collectionWasPersisted = false; + let collectionNameForRollback; + let collectionExistedBefore = false; + + try { + const { projectId, collectionName, schema } = createCollectionSchema.parse( + req.body, + ); - const project = await Project.findOne({ _id: projectId, owner: req.user._id }); - if (!project) return res.status(404).json({ error: 'Project not found' }); + collectionNameForRollback = collectionName; - const exists = project.collections.find(c => c.name === collectionName); - if (exists) return res.status(400).json({ error: 'Collection already exists' }); + project = await Project.findOne({ + _id: projectId, + owner: req.user._id, + }); + if (!project) return res.status(404).json({ error: "Project not found" }); - if (!project.jwtSecret) project.jwtSecret = uuidv4(); + const exists = project.collections.find((c) => c.name === collectionName); + if (exists) + return res.status(400).json({ error: "Collection already exists" }); - if (collectionName === 'users') { - if (!validateUsersSchema(sanitizedSchema)) { - return res.status(422).json({ error: "The 'users' collection must have required 'email' and 'password' string fields." }); - } - } + if (!project.jwtSecret) { + project.jwtSecret = generateApiKey("jwt_"); + } - project.collections.push({ - name: collectionName, - model: sanitizedSchema, - rls: getDefaultRlsForCollection(collectionName, sanitizedSchema) + if (collectionName === "users") { + if (!validateUsersSchema(schema)) { + return res.status(422).json({ + error: + "The 'users' collection must have required 'email' and 'password' string fields.", }); - await project.save(); - - await deleteProjectById(projectId); - await setProjectById(projectId, project); - await deleteProjectByApiKeyCache(project.publishableKey); - await deleteProjectByApiKeyCache(project.secretKey); - const projectObj = project.toObject(); - delete projectObj.publishableKey; - delete projectObj.secretKey; - delete projectObj.jwtSecret; - - res.status(201).json(projectObj); - } catch (err) { - if (err instanceof z.ZodError) return res.status(400).json({ error: err.errors }); - res.status(500).json({ error: err.message }); + } } -} -module.exports.deleteCollection = async (req, res) => { - try { - const { projectId, collectionName } = req.params; + compiledCollectionName = project.resources.db.isExternal + ? collectionName + : `${project._id}_${collectionName}`; - const project = await Project.findOne({ _id: projectId, owner: req.user._id }); - if (!project) return res.status(404).json({ error: "Project not found or access denied." }); + const newCollectionConfig = { + name: collectionName, + model: schema, + rls: getDefaultRlsForCollection(collectionName, schema), + }; - const collectionIndex = project.collections.findIndex(c => c.name === collectionName); - if (collectionIndex === -1) { - return res.status(404).json({ error: "Collection not found." }); - } + project.collections.push(newCollectionConfig); + await project.save(); + collectionWasPersisted = true; - const isExternal = project.resources?.db?.isExternal; + connection = await getConnection(projectId); - const connection = await getConnection(projectId); + collectionExistedBefore = await connection.db + .listCollections({ name: compiledCollectionName }, { nameOnly: true }) + .hasNext(); - const finalCollectionName = isExternal ? collectionName : `${project._id}_${collectionName}`; + const Model = getCompiledModel( + connection, + newCollectionConfig, + projectId, + project.resources.db.isExternal, + ); - try { - await connection.db.dropCollection(finalCollectionName); - } catch (e) { - console.warn("Failed to drop collection (might not exist):", finalCollectionName, e.message); - } + await createUniqueIndexes(Model, newCollectionConfig.model); - project.collections.splice(collectionIndex, 1); - await project.save(); + await deleteProjectById(projectId); + await setProjectById(projectId, project.toObject()); + await deleteProjectByApiKeyCache(project.publishableKey); + await deleteProjectByApiKeyCache(project.secretKey); - await deleteProjectById(projectId); - await setProjectById(projectId, project); + const projectObj = project.toObject(); + delete projectObj.publishableKey; + delete projectObj.secretKey; + delete projectObj.jwtSecret; - res.json({ message: `Collection '${collectionName}' deleted successfully.` }); - } catch (err) { - console.error("Delete Collection Error:", err); - res.status(500).json({ error: err.message }); - } -} - -module.exports.getData = async (req, res) => { + return res.status(201).json(projectObj); + } catch (err) { try { - const { projectId, collectionName } = req.params; - const project = await Project.findOne({ _id: projectId, owner: req.user._id }); - if (!project) return res.status(404).json({ error: "Project not found." }); - - const collectionConfig = project.collections.find(c => c.name === collectionName); - if (!collectionConfig) { - return res.status(404).json({ - error: "Collection not found", - collection: collectionName - }); - } - - const connection = await getConnection(projectId); - const model = getCompiledModel(connection, collectionConfig, projectId, project.resources.db.isExternal); + if (project && collectionWasPersisted) { + project.collections = project.collections.filter( + (c) => c.name !== collectionNameForRollback, + ); + await project.save(); + } - // const collectionsList = await mongoose.connection.db.listCollections({ name: finalCollectionName }).toArray(); + if (connection && compiledCollectionName) { + clearCompiledModel(connection, compiledCollectionName); - const query = model.find(); - if (collectionName === 'users') { - query.select('-password'); + if (!collectionExistedBefore) { + await dropCollectionIfExists(connection, compiledCollectionName); } - - const features = new QueryEngine(query, req.query) - .filter() - .sort() - .paginate(); - - const data = await features.query.lean(); - - res.json(data); - } catch (err) { - res.status(500).json({ error: err.message }); + } + } catch (rollbackErr) { + console.error("Create collection rollback failed:", rollbackErr); } -} -module.exports.insertData = async (req, res) => { - try { - console.time("insert data") - const { projectId, collectionName } = req.params; - const project = await Project.findOne({ _id: projectId, owner: req.user._id }); - if (!project) return res.status(404).json({ error: "Project not found." }); + if (err instanceof z.ZodError) { + return res.status(400).json({ error: err.issues }); + } - if (collectionName === 'users') { - return res.status(400).json({ error: "Direct inserts into 'users' collection are not allowed. Please use the Auth signup or admin endpoints." }); - } + return res.status(400).json({ error: err.message }); + } +}; - const finalCollectionName = `${project._id}_${collectionName}`; - const incomingData = req.body; +module.exports.deleteCollection = async (req, res) => { + try { + const { projectId, collectionName } = req.params; + + const project = await Project.findOne({ + _id: projectId, + owner: req.user._id, + }); + if (!project) { + return res + .status(404) + .json({ error: "Project not found or access denied." }); + } - const collectionConfig = project.collections.find(c => c.name === collectionName); - if (!collectionConfig) { - return res.status(404).json({ error: "Collection configuration not found." }); - } + const collectionIndex = project.collections.findIndex( + (c) => c.name === collectionName, + ); + if (collectionIndex === -1) { + return res.status(404).json({ error: "Collection not found." }); + } - let docSize = 0; - if (!project.resources.db.isExternal) { - docSize = Buffer.byteLength(JSON.stringify(incomingData)); + const isExternal = project.resources?.db?.isExternal; + const connection = await getConnection(projectId); - const limit = project.databaseLimit || 20 * 1024 * 1024; + const finalCollectionName = isExternal + ? collectionName + : `${project._id}_${collectionName}`; - if ((project.databaseUsed || 0) + docSize > limit) { - return res.status(403).json({ error: "Database limit exceeded. Delete some data." }); - } - } + await dropCollectionIfExists(connection, finalCollectionName); + clearCompiledModel(connection, finalCollectionName); - const connection = await getConnection(projectId); - const model = getCompiledModel(connection, collectionConfig, projectId, project.resources.db.isExternal); + project.collections.splice(collectionIndex, 1); + await project.save(); - const result = await model.create(incomingData); + await deleteProjectById(projectId); + await setProjectById(projectId, project.toObject()); + await deleteProjectByApiKeyCache(project.publishableKey); + await deleteProjectByApiKeyCache(project.secretKey); - if (!project.resources.db.isExternal) { - project.databaseUsed = (project.databaseUsed || 0) + docSize; - } - await project.save(); + return res.json({ + message: `Collection '${collectionName}' deleted successfully.`, + }); + } catch (err) { + console.error("Delete Collection Error:", err); + return res.status(500).json({ error: err.message }); + } +}; - res.json(result); - } catch (err) { - res.status(500).json({ error: err.message }); +module.exports.insertData = async (req, res) => { + try { + console.time("insert data"); + const { projectId, collectionName } = req.params; + const project = await Project.findOne({ + _id: projectId, + owner: req.user._id, + }); + if (!project) return res.status(404).json({ error: "Project not found." }); + + if (collectionName === "users") { + return res.status(400).json({ + error: + "Direct inserts into 'users' collection are not allowed. Please use the Auth signup or admin endpoints.", + }); } -} -module.exports.deleteRow = async (req, res) => { - try { - const { projectId, collectionName, id } = req.params; + const incomingData = req.body; - const project = await Project.findOne({ _id: projectId, owner: req.user._id }); - if (!project) return res.status(404).json({ error: "Project not found." }); - - const collectionConfig = project.collections.find(c => c.name === collectionName); - if (!collectionConfig) { - return res.status(404).json({ error: "Collection not found." }); - } - - const connection = await getConnection(projectId); - const Model = getCompiledModel(connection, collectionConfig, projectId, project.resources.db.isExternal); - - const docToDelete = await Model.findById(id); - if (!docToDelete) { - return res.status(404).json({ error: "Document not found." }); - } + const collectionConfig = project.collections.find( + (c) => c.name === collectionName, + ); + if (!collectionConfig) { + return res + .status(404) + .json({ error: "Collection configuration not found." }); + } - const docSize = Buffer.byteLength(JSON.stringify(docToDelete)); + let docSize = 0; + if (!project.resources.db.isExternal) { + docSize = Buffer.byteLength(JSON.stringify(incomingData)); + const limit = project.databaseLimit || 20 * 1024 * 1024; - await Model.deleteOne({ _id: id }); + if ((project.databaseUsed || 0) + docSize > limit) { + return res + .status(403) + .json({ error: "Database limit exceeded. Delete some data." }); + } + } - if (!project.resources.db.isExternal) { - project.databaseUsed = Math.max(0, (project.databaseUsed || 0) - docSize); - await project.save(); - } + const connection = await getConnection(projectId); + const model = getCompiledModel( + connection, + collectionConfig, + projectId, + project.resources.db.isExternal, + ); - res.json({ success: true, message: "Document deleted successfully" }); + const result = await model.create(incomingData); - } catch (err) { - console.error("Delete Error:", err); - res.status(500).json({ error: err.message }); + if (!project.resources.db.isExternal) { + project.databaseUsed = (project.databaseUsed || 0) + docSize; + } + await project.save(); + + res.json(result); + } catch (err) { + if (err && err.code === 11000) { + return res.status(409).json({ + error: "Duplicate value violates unique constraint.", + details: err.message, + }); } + + res.status(500).json({ error: err.message }); + } }; -module.exports.editRow = async (req, res) => { - try { - const { projectId, collectionName, id } = req.params; +module.exports.deleteRow = async (req, res) => { + try { + const { projectId, collectionName, id } = req.params; - const project = await Project.findOne({ _id: projectId, owner: req.user._id }); - if (!project) return res.status(404).json({ error: "Project not found." }); + const project = await Project.findOne({ + _id: projectId, + owner: req.user._id, + }); + if (!project) return res.status(404).json({ error: "Project not found." }); - const collectionConfig = project.collections.find(c => c.name === collectionName); - if (!collectionConfig) { - return res.status(404).json({ error: "Collection not found." }); - } + const collectionConfig = project.collections.find( + (c) => c.name === collectionName, + ); + if (!collectionConfig) { + return res.status(404).json({ error: "Collection not found." }); + } - const connection = await getConnection(projectId); - const Model = getCompiledModel(connection, collectionConfig, projectId, project.resources.db.isExternal); + const connection = await getConnection(projectId); + const Model = getCompiledModel( + connection, + collectionConfig, + projectId, + project.resources.db.isExternal, + ); - if (collectionName === 'users') { - delete req.body.password; - // Also ensure it's not and nested or sneaky - Object.keys(req.body).forEach(key => { - if (key.toLowerCase().includes('password')) delete req.body[key]; - }); - } + const docToDelete = await Model.findById(id); + if (!docToDelete) { + return res.status(404).json({ error: "Document not found." }); + } - const docToEdit = await Model.findById(id); - if (!docToEdit) { - return res.status(404).json({ error: "Document not found." }); - } + const docSize = Buffer.byteLength(JSON.stringify(docToDelete)); - const oldSize = Buffer.byteLength(JSON.stringify(docToEdit.toObject())); + await Model.deleteOne({ _id: id }); - docToEdit.set(req.body); + if (!project.resources.db.isExternal) { + project.databaseUsed = Math.max(0, (project.databaseUsed || 0) - docSize); + await project.save(); + } - const newSize = Buffer.byteLength(JSON.stringify(docToEdit.toObject())); - const sizeDiff = newSize - oldSize; + res.json({ success: true, message: "Document deleted successfully" }); + } catch (err) { + console.error("Delete Error:", err); + res.status(500).json({ error: err.message }); + } +}; - if (!project.resources.db.isExternal) { - const limit = project.databaseLimit || 500 * 1024 * 1024; - const currentUsed = project.databaseUsed || 0; +module.exports.editRow = async (req, res) => { + try { + const { projectId, collectionName, id } = req.params; - if (currentUsed + sizeDiff > limit) { - return res.status(403).json({ error: "Database limit exceeded." }); - } + const project = await Project.findOne({ + _id: projectId, + owner: req.user._id, + }); + if (!project) return res.status(404).json({ error: "Project not found." }); - project.databaseUsed = Math.max(0, currentUsed + sizeDiff); - await project.save(); - } + const collectionConfig = project.collections.find( + (c) => c.name === collectionName, + ); + if (!collectionConfig) { + return res.status(404).json({ error: "Collection not found." }); + } - const updatedDoc = await docToEdit.save(); - const responseData = updatedDoc.toObject(); - if (collectionName === 'users') { - delete responseData.password; - } + const connection = await getConnection(projectId); + const Model = getCompiledModel( + connection, + collectionConfig, + projectId, + project.resources.db.isExternal, + ); - res.json({ success: true, message: "Document edited successfully", data: responseData }); + if (collectionName === "users") { + delete req.body.password; + // Also ensure it's not and nested or sneaky + Object.keys(req.body).forEach((key) => { + if (key.toLowerCase().includes("password")) delete req.body[key]; + }); + } - } catch (err) { - console.error("Edit Error:", err); - res.status(500).json({ error: err.message }); + const docToEdit = await Model.findById(id); + if (!docToEdit) { + return res.status(404).json({ error: "Document not found." }); } -}; -module.exports.listFiles = async (req, res) => { - try { - const { projectId } = req.params; + const oldSize = Buffer.byteLength(JSON.stringify(docToEdit.toObject())); - const project = await Project.findOne({ _id: projectId, owner: req.user._id }) - .select("+resources.storage.config.encrypted +resources.storage.config.iv +resources.storage.config.tag resources.storage.isExternal storageUsed storageLimit"); - if (!project) return res.status(404).json({ error: "Project not found" }); + docToEdit.set(req.body); - const supabase = await getStorage(project); - const bucket = getBucket(project); + const newSize = Buffer.byteLength(JSON.stringify(docToEdit.toObject())); + const sizeDiff = newSize - oldSize; - const { data, error } = await supabase.storage - .from(bucket) - .list(`${projectId}`, { - limit: 100, - sortBy: { column: "created_at", order: "desc" } - }); + if (!project.resources.db.isExternal) { + const limit = project.databaseLimit || 500 * 1024 * 1024; + const currentUsed = project.databaseUsed || 0; - if (error) throw error; + if (currentUsed + sizeDiff > limit) { + return res.status(403).json({ error: "Database limit exceeded." }); + } + } - const files = data.map(file => { - const { data: url } = supabase.storage - .from(bucket) - .getPublicUrl(`${projectId}/${file.name}`); + const updatedDoc = await docToEdit.save(); - return { - ...file, - path: `${projectId}/${file.name}`, - publicUrl: url.publicUrl - }; - }); + if (!project.resources.db.isExternal) { + const currentUsed = project.databaseUsed || 0; + project.databaseUsed = Math.max(0, currentUsed + sizeDiff); + await project.save(); + } + + const responseData = updatedDoc.toObject(); + if (collectionName === "users") { + delete responseData.password; + } - res.json(files); - } catch (err) { - console.error(err); - res.status(500).json({ error: "Something Went Wrong", try: "Try checking docs or contact support - urbackend@bitbros.in" }); + res.json({ + success: true, + message: "Document edited successfully", + data: responseData, + }); + } catch (err) { + console.error("Edit Error:", err); + + if (err && err.code === 11000) { + return res.status(409).json({ + error: "Duplicate value violates unique constraint.", + details: err.message, + }); } + + res.status(500).json({ error: err.message }); + } }; +module.exports.listFiles = async (req, res) => { + try { + const { projectId } = req.params; + + const project = await Project.findOne({ + _id: projectId, + owner: req.user._id, + }).select( + "+resources.storage.config.encrypted +resources.storage.config.iv +resources.storage.config.tag resources.storage.isExternal storageUsed storageLimit", + ); + if (!project) return res.status(404).json({ error: "Project not found" }); + + const supabase = await getStorage(project); + const bucket = getBucket(project); + + const { data, error } = await supabase.storage + .from(bucket) + .list(`${projectId}`, { + limit: 100, + sortBy: { column: "created_at", order: "desc" }, + }); + + if (error) throw error; + + const files = data.map((file) => { + const { data: url } = supabase.storage + .from(bucket) + .getPublicUrl(`${projectId}/${file.name}`); + + return { + ...file, + path: `${projectId}/${file.name}`, + publicUrl: url.publicUrl, + }; + }); + + res.json(files); + } catch (err) { + console.error(err); + res.status(500).json({ + error: "Something Went Wrong", + try: "Try checking docs or contact support - urbackend@bitbros.in", + }); + } +}; module.exports.uploadFile = async (req, res) => { - try { - const { projectId } = req.params; - const file = req.file; - - if (!file) return res.status(400).json({ error: "No file uploaded" }); + try { + const { projectId } = req.params; + const file = req.file; - const project = await Project.findOne({ _id: projectId, owner: req.user._id }) - .select("+resources.storage.config.encrypted +resources.storage.config.iv +resources.storage.config.tag resources.storage.isExternal storageUsed storageLimit"); - if (!project) return res.status(404).json({ error: "Project not found" }); + if (!file) return res.status(400).json({ error: "No file uploaded" }); - const external = isProjectStorageExternal(project); + const project = await Project.findOne({ + _id: projectId, + owner: req.user._id, + }).select( + "+resources.storage.config.encrypted +resources.storage.config.iv +resources.storage.config.tag resources.storage.isExternal storageUsed storageLimit", + ); + if (!project) return res.status(404).json({ error: "Project not found" }); - if (!external) { - if (project.storageUsed + file.size > project.storageLimit) { - return res.status(403).json({ error: "Storage limit exceeded" }); - } - } + const external = isProjectStorageExternal(project); - const supabase = await getStorage(project); - const bucket = getBucket(project); + if (!external) { + if (project.storageUsed + file.size > project.storageLimit) { + return res.status(403).json({ error: "Storage limit exceeded" }); + } + } - const safeName = file.originalname.replace(/\s+/g, "_"); - const path = `${projectId}/${randomUUID()}_${safeName}`; + const supabase = await getStorage(project); + const bucket = getBucket(project); - const { error } = await supabase.storage - .from(bucket) - .upload(path, file.buffer, { - contentType: file.mimetype, - upsert: false - }); + const safeName = file.originalname.replace(/\s+/g, "_"); + const path = `${projectId}/${randomUUID()}_${safeName}`; - if (error) throw error; + const { error } = await supabase.storage + .from(bucket) + .upload(path, file.buffer, { + contentType: file.mimetype, + upsert: false, + }); - if (!external) { - project.storageUsed += file.size; - await project.save(); - } + if (error) throw error; - res.json({ success: true, path }); - } catch (err) { - res.status(500).json({ error: err }); + if (!external) { + project.storageUsed += file.size; + await project.save(); } -}; + res.json({ success: true, path }); + } catch (err) { + res.status(500).json({ error: err }); + } +}; module.exports.deleteFile = async (req, res) => { - try { - const { projectId } = req.params; - const { path } = req.body; - - if (!path) return res.status(400).json({ error: "Path required" }); + try { + const { projectId } = req.params; + const { path } = req.body; - const project = await Project.findOne({ _id: projectId, owner: req.user._id }) - .select("+resources.storage.config.encrypted +resources.storage.config.iv +resources.storage.config.tag resources.storage.isExternal storageUsed storageLimit"); - if (!project) return res.status(404).json({ error: "Project not found" }); + if (!path) return res.status(400).json({ error: "Path required" }); - if (!path.startsWith(`${projectId}/`)) { - return res.status(403).json({ error: "Access denied" }); - } + const project = await Project.findOne({ + _id: projectId, + owner: req.user._id, + }).select( + "+resources.storage.config.encrypted +resources.storage.config.iv +resources.storage.config.tag resources.storage.isExternal storageUsed storageLimit", + ); + if (!project) return res.status(404).json({ error: "Project not found" }); - const supabase = await getStorage(project); - const bucket = getBucket(project); - const external = isProjectStorageExternal(project); + if (!path.startsWith(`${projectId}/`)) { + return res.status(403).json({ error: "Access denied" }); + } - let fileSize = 0; + const supabase = await getStorage(project); + const bucket = getBucket(project); + const external = isProjectStorageExternal(project); - if (!external) { - const { data } = await supabase.storage - .from(bucket) - .list(projectId, { - search: path.split("/").pop() - }); + let fileSize = 0; - if (data?.length) { - fileSize = data[0]?.metadata?.size || 0; - } - } + if (!external) { + const { data } = await supabase.storage.from(bucket).list(projectId, { + search: path.split("/").pop(), + }); - const { error } = await supabase.storage.from(bucket).remove([path]); - if (error) throw error; + if (data?.length) { + fileSize = data[0]?.metadata?.size || 0; + } + } - if (!external && fileSize > 0) { - project.storageUsed = Math.max(0, project.storageUsed - fileSize); - await project.save(); - } + const { error } = await supabase.storage.from(bucket).remove([path]); + if (error) throw error; - res.json({ success: true }); - } catch (err) { - res.status(500).json({ error: err.message }); + if (!external && fileSize > 0) { + project.storageUsed = Math.max(0, project.storageUsed - fileSize); + await project.save(); } -}; + res.json({ success: true }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}; module.exports.deleteAllFiles = async (req, res) => { - try { - const { projectId } = req.params; - - const project = await Project.findOne({ _id: projectId, owner: req.user._id }) - .select("+resources.storage.config.encrypted +resources.storage.config.iv +resources.storage.config.tag resources.storage.isExternal storageUsed storageLimit"); - if (!project) return res.status(404).json({ error: "Project not found" }); - - const supabase = await getStorage(project); - const bucket = getBucket(project); + try { + const { projectId } = req.params; + + const project = await Project.findOne({ + _id: projectId, + owner: req.user._id, + }).select( + "+resources.storage.config.encrypted +resources.storage.config.iv +resources.storage.config.tag resources.storage.isExternal storageUsed storageLimit", + ); + if (!project) return res.status(404).json({ error: "Project not found" }); - let hasMore = true; - let deleted = 0; + const supabase = await getStorage(project); + const bucket = getBucket(project); - while (hasMore) { - const { data, error } = await supabase.storage - .from(bucket) - .list(projectId, { limit: 100 }); + let hasMore = true; + let deleted = 0; - if (error) throw error; + while (hasMore) { + const { data, error } = await supabase.storage + .from(bucket) + .list(projectId, { limit: 100 }); - if (data.length === 0) { - hasMore = false; - } else { - const paths = data.map(f => `${projectId}/${f.name}`); - await supabase.storage.from(bucket).remove(paths); - deleted += data.length; - } - } + if (error) throw error; - if (!isProjectStorageExternal(project)) { - project.storageUsed = 0; - await project.save(); - } + if (data.length === 0) { + hasMore = false; + } else { + const paths = data.map((f) => `${projectId}/${f.name}`); + await supabase.storage.from(bucket).remove(paths); + deleted += data.length; + } + } - res.json({ success: true, deleted }); - } catch (err) { - res.status(500).json({ error: err.message }); + if (!isProjectStorageExternal(project)) { + project.storageUsed = 0; + await project.save(); } -}; + res.json({ success: true, deleted }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}; module.exports.updateProject = async (req, res) => { - try { - const { name } = req.body; - const project = await Project.findOneAndUpdate( - { _id: req.params.projectId, owner: req.user._id }, - { $set: { name } }, - { new: true } - ); - if (!project) return res.status(404).json({ error: "Project not found." }); - - await deleteProjectById(project._id.toString()); - await deleteProjectByApiKeyCache(project.publishableKey); - await deleteProjectByApiKeyCache(project.secretKey); - - res.json(project); - } catch (err) { - res.status(500).json({ error: err.message }); - } -} + try { + const { name } = req.body; + const project = await Project.findOneAndUpdate( + { _id: req.params.projectId, owner: req.user._id }, + { $set: { name } }, + { new: true }, + ); + if (!project) return res.status(404).json({ error: "Project not found." }); -module.exports.updateAllowedDomains = async (req, res) => { - try { - const { domains } = req.body; - if (!Array.isArray(domains) || !domains.every(d => typeof d === 'string')) { - return res.status(400).json({ error: "domains must be an array of strings." }); - } + await deleteProjectById(project._id.toString()); + await deleteProjectByApiKeyCache(project.publishableKey); + await deleteProjectByApiKeyCache(project.secretKey); - const cleanedDomains = domains - .map(d => d.trim()) - .filter(d => d.length > 0); + res.json(project); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}; - const project = await Project.findOneAndUpdate( - { _id: req.params.projectId, owner: req.user._id }, - { $set: { allowedDomains: cleanedDomains } }, - { new: true } - ); +module.exports.updateAllowedDomains = async (req, res) => { + try { + const { domains } = req.body; + if ( + !Array.isArray(domains) || + !domains.every((d) => typeof d === "string") + ) { + return res + .status(400) + .json({ error: "domains must be an array of strings." }); + } - if (!project) return res.status(404).json({ error: "Project not found or access denied." }); - await deleteProjectById(project._id.toString()); - await setProjectById(project._id.toString(), project); - await deleteProjectByApiKeyCache(project.publishableKey); - await deleteProjectByApiKeyCache(project.secretKey); + const cleanedDomains = domains + .map((d) => d.trim()) + .filter((d) => d.length > 0); - res.json({ message: "Allowed domains updated", allowedDomains: project.allowedDomains }); - } catch (err) { - res.status(500).json({ error: err.message }); - } -} + const project = await Project.findOneAndUpdate( + { _id: req.params.projectId, owner: req.user._id }, + { $set: { allowedDomains: cleanedDomains } }, + { new: true }, + ); + + if (!project) + return res + .status(404) + .json({ error: "Project not found or access denied." }); + await deleteProjectById(project._id.toString()); + await setProjectById(project._id.toString(), project.toObject()); + await deleteProjectByApiKeyCache(project.publishableKey); + await deleteProjectByApiKeyCache(project.secretKey); + + res.json({ + message: "Allowed domains updated", + allowedDomains: project.allowedDomains, + }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}; module.exports.deleteProject = async (req, res) => { - try { - const projectId = req.params.projectId; - - const project = await Project.findOne({ - _id: projectId, - owner: req.user._id - }).select( - "+resources.storage.config.encrypted " + - "+resources.storage.config.iv " + - "+resources.storage.config.tag" - ); + try { + const projectId = req.params.projectId; + + const project = await Project.findOne({ + _id: projectId, + owner: req.user._id, + }).select( + "+resources.storage.config.encrypted " + + "+resources.storage.config.iv " + + "+resources.storage.config.tag", + ); - if (!project) { - return res.status(404).json({ error: "Project not found or access denied." }); - } + if (!project) { + return res + .status(404) + .json({ error: "Project not found or access denied." }); + } - // DROP COLLECTIONS: Only for internal databases - if (!project.resources.db.isExternal) { - for (const col of project.collections) { - const collectionName = `${project._id}_${col.name}`; - try { - await mongoose.connection.db.dropCollection(collectionName); - } catch (e) { } - } - - try { - await mongoose.connection.db.dropCollection(`${project._id}_users`); - } catch (e) { } - } + // DROP COLLECTIONS: Only for internal databases + if (!project.resources.db.isExternal) { + for (const col of project.collections) { + const collectionName = `${project._id}_${col.name}`; + try { + await mongoose.connection.db.dropCollection(collectionName); + } catch (e) {} + } - // DELETE: Only for internal Infraa - if (!isProjectStorageExternal(project)) { - const supabase = await getStorage(project); - const bucket = getBucket(project); + try { + await mongoose.connection.db.dropCollection(`${project._id}_users`); + } catch (e) {} + } - let hasMoreFiles = true; + // DELETE: Only for internal Infraa + if (!isProjectStorageExternal(project)) { + const supabase = await getStorage(project); + const bucket = getBucket(project); - while (hasMoreFiles) { - const { data: files, error } = await supabase.storage - .from(bucket) - .list(projectId, { limit: 100 }); + let hasMoreFiles = true; - if (error) throw error; + while (hasMoreFiles) { + const { data: files, error } = await supabase.storage + .from(bucket) + .list(projectId, { limit: 100 }); - if (files && files.length > 0) { - const paths = files.map(f => `${projectId}/${f.name}`); - await supabase.storage.from(bucket).remove(paths); - } else { - hasMoreFiles = false; - } - } + if (error) throw error; + + if (files && files.length > 0) { + const paths = files.map((f) => `${projectId}/${f.name}`); + await supabase.storage.from(bucket).remove(paths); + } else { + hasMoreFiles = false; } + } + } - await Project.deleteOne({ _id: projectId }); - storageRegistry.delete(projectId.toString()); + await Project.deleteOne({ _id: projectId }); + storageRegistry.delete(projectId.toString()); - res.json({ message: "Project and all associated resources deleted successfully" }); - } catch (err) { - console.error(err); - res.status(500).json({ error: err.message }); - } + res.json({ + message: "Project and all associated resources deleted successfully", + }); + } catch (err) { + console.error(err); + res.status(500).json({ error: err.message }); + } }; - module.exports.analytics = async (req, res) => { - try { - const { projectId } = req.params; - const project = await Project.findOne({ _id: projectId, owner: req.user._id }); - if (!project) return res.status(404).json({ error: "Project not found or access denied." }); - const totalRequests = await Log.countDocuments({ projectId }); - const logs = await Log.find({ projectId }).sort({ timestamp: -1 }).limit(50); - - const sevenDaysAgo = new Date(); - sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7); - - const chartData = await Log.aggregate([ - { $match: { projectId: new mongoose.Types.ObjectId(projectId), timestamp: { $gte: sevenDaysAgo } } }, - { - $group: { - _id: { $dateToString: { format: "%Y-%m-%d", date: "$timestamp" } }, - count: { $sum: 1 } - } - }, - { $sort: { _id: 1 } } - ]); - - res.json({ - storage: { used: project.storageUsed, limit: project.storageLimit }, - database: { used: project.databaseUsed, limit: project.databaseLimit }, - totalRequests, - logs, - chartData - }); - } catch (err) { - res.status(500).json({ error: err.message }); - } -} + try { + const { projectId } = req.params; + const project = await Project.findOne({ + _id: projectId, + owner: req.user._id, + }); + if (!project) + return res + .status(404) + .json({ error: "Project not found or access denied." }); + const totalRequests = await Log.countDocuments({ projectId }); + const logs = await Log.find({ projectId }) + .sort({ timestamp: -1 }) + .limit(50); + + const sevenDaysAgo = new Date(); + sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7); + + const chartData = await Log.aggregate([ + { + $match: { + projectId: new mongoose.Types.ObjectId(projectId), + timestamp: { $gte: sevenDaysAgo }, + }, + }, + { + $group: { + _id: { $dateToString: { format: "%Y-%m-%d", date: "$timestamp" } }, + count: { $sum: 1 }, + }, + }, + { $sort: { _id: 1 } }, + ]); + + res.json({ + storage: { used: project.storageUsed, limit: project.storageLimit }, + database: { used: project.databaseUsed, limit: project.databaseLimit }, + totalRequests, + logs, + chartData, + }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}; // FUNCTION - TOGGLE AUTH module.exports.toggleAuth = async (req, res) => { - try { - const { projectId } = req.params; - const { enable } = req.body; // true or false - - // Ensure user owns project - const project = await Project.findOne({ _id: projectId, owner: req.user._id }); - if (!project) return res.status(404).json({ error: "Project not found" }); - // console.log(project) - - if (enable) { - const usersCol = project.collections.find(c => c.name === 'users'); - if (!usersCol) { - return res.status(422).json({ - error: "Users Collection Missing", - message: "The 'users' collection must be created and configured with required 'email' and 'password' fields before enabling Authentication." - }); - } - - if (!validateUsersSchema(usersCol.model)) { - return res.status(422).json({ - error: "Invalid Users Schema", - message: "The 'users' collection must have required 'email' and 'password' string fields. Please fix the schema before enabling Auth." - }); - } - } - - project.isAuthEnabled = !!enable; - await project.save(); - - await deleteProjectById(projectId); - await deleteProjectByApiKeyCache(project.publishableKey); - await deleteProjectByApiKeyCache(project.secretKey); - - const projectObj = project.toObject(); - delete projectObj.publishableKey; - delete projectObj.secretKey; - delete projectObj.jwtSecret; - - if (projectObj.collections && Array.isArray(projectObj.collections)) { - projectObj.collections = projectObj.collections.map(col => { - if (col.name === 'users' && col.model) { - return { - ...col, - model: col.model.filter(m => m.key !== 'password'), - rls: col.rls || { - enabled: false, - mode: 'owner-write-only', - ownerField: '_id', - requireAuthForWrite: true - } - }; - } - return { - ...col, - rls: col.rls || getDefaultRlsForCollection(col.name, col.model) - }; - }); - } + try { + const { projectId } = req.params; + const { enable } = req.body; // true or false + + // Ensure user owns project + const project = await Project.findOne({ + _id: projectId, + owner: req.user._id, + }); + if (!project) return res.status(404).json({ error: "Project not found" }); + + if (enable) { + const usersCol = project.collections.find((c) => c.name === "users"); + if (!usersCol) { + return res.status(422).json({ + error: "Users Collection Missing", + message: + "The 'users' collection must be created and configured with required 'email' and 'password' fields before enabling Authentication.", + }); + } - res.json({ - message: `Authentication ${project.isAuthEnabled ? 'enabled' : 'disabled'} successfully`, - isAuthEnabled: project.isAuthEnabled, - project: projectObj + if (!validateUsersSchema(usersCol.model)) { + return res.status(422).json({ + error: "Invalid Users Schema", + message: + "The 'users' collection must have required 'email' and 'password' string fields. Please fix the schema before enabling Auth.", }); - } catch (err) { - res.status(500).json({ error: err.message }); + } } -} -// PATCH REQ - UPDATE COLLECTION RLS (V1) -module.exports.updateCollectionRls = async (req, res) => { - try { - const { projectId, collectionName } = req.params; - const { enabled, mode, ownerField, requireAuthForWrite } = req.body || {}; - - const project = await Project.findOne({ _id: projectId, owner: req.user._id }); - if (!project) return res.status(404).json({ error: "Project not found" }); - - const collection = project.collections.find(c => c.name === collectionName); - if (!collection) return res.status(404).json({ error: "Collection not found" }); - - const validMode = mode || collection?.rls?.mode || 'owner-write-only'; - if (validMode !== 'owner-write-only') { - return res.status(400).json({ error: "Unsupported RLS mode. Only 'owner-write-only' is allowed in V1." }); - } - - const modelKeys = (collection.model || []) - .map(f => String(f?.key || '').trim()) - .filter(Boolean); - const modelKeySet = new Set(modelKeys); - const modelKeyLowerMap = new Map(modelKeys.map(k => [k.toLowerCase(), k])); - - const requestedOwnerRaw = String(ownerField ?? collection?.rls?.ownerField ?? 'userId').trim(); - const requestedOwnerLower = requestedOwnerRaw.toLowerCase(); - const canonicalOwnerField = modelKeySet.has(requestedOwnerRaw) - ? requestedOwnerRaw - : modelKeyLowerMap.get(requestedOwnerLower); - const nextOwnerField = requestedOwnerRaw === '_id' ? '_id' : (canonicalOwnerField || requestedOwnerRaw); - - if (nextOwnerField !== '_id' && !modelKeySet.has(nextOwnerField)) { - return res.status(400).json({ - error: "Invalid owner field", - message: `ownerField '${nextOwnerField}' not found in collection schema` - }); - } - - // Restrict use of '_id' as ownerField to the 'users' collection only. - if (nextOwnerField === '_id' && collection.name !== 'users') { - return res.status(400).json({ - error: "Invalid owner field", - message: "ownerField '_id' is only allowed for the 'users' collection" - }); + project.isAuthEnabled = !!enable; + await project.save(); + + await deleteProjectById(projectId); + await deleteProjectByApiKeyCache(project.publishableKey); + await deleteProjectByApiKeyCache(project.secretKey); + + const projectObj = project.toObject(); + delete projectObj.publishableKey; + delete projectObj.secretKey; + delete projectObj.jwtSecret; + + if (projectObj.collections && Array.isArray(projectObj.collections)) { + projectObj.collections = projectObj.collections.map((col) => { + if (col.name === "users" && col.model) { + return { + ...col, + model: col.model.filter((m) => m.key !== "password"), + rls: col.rls || getDefaultRlsForCollection(col.name, col.model), + }; } - collection.rls = { - enabled: typeof enabled === 'boolean' ? enabled : !!collection?.rls?.enabled, - mode: validMode, - ownerField: nextOwnerField, - requireAuthForWrite: typeof requireAuthForWrite === 'boolean' - ? requireAuthForWrite - : (collection?.rls?.requireAuthForWrite ?? true) + return { + ...col, + rls: col.rls || getDefaultRlsForCollection(col.name, col.model), }; - - await project.save(); - - await deleteProjectById(projectId); - await deleteProjectByApiKeyCache(project.publishableKey); - await deleteProjectByApiKeyCache(project.secretKey); - - res.json({ - message: "Collection RLS updated", - collection: { - name: collection.name, - rls: collection.rls - } - }); - } catch (err) { - res.status(500).json({ error: err.message }); + }); } -} + + res.json({ + message: `Authentication ${project.isAuthEnabled ? "enabled" : "disabled"} successfully`, + isAuthEnabled: project.isAuthEnabled, + project: projectObj, + }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}; diff --git a/apps/public-api/src/controllers/data.controller.js b/apps/public-api/src/controllers/data.controller.js index c509338cf..f144f730e 100644 --- a/apps/public-api/src/controllers/data.controller.js +++ b/apps/public-api/src/controllers/data.controller.js @@ -1,180 +1,250 @@ const { sanitize } = require("@urbackend/common"); -const mongoose = require('mongoose'); +const mongoose = require("mongoose"); const { Project } = require("@urbackend/common"); const { getConnection } = require("@urbackend/common"); const { getCompiledModel } = require("@urbackend/common"); -const {QueryEngine} = require("@urbackend/common"); +const { QueryEngine } = require("@urbackend/common"); const { validateData, validateUpdateData } = require("@urbackend/common"); // Validate MongoDB ObjectId const isValidId = (id) => mongoose.Types.ObjectId.isValid(id); +const isDuplicateKeyError = (err) => { + return err && err.code === 11000; +}; + // 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) { + console.error(err); + + if (isDuplicateKeyError(err)) { + return res.status(409).json({ + error: "Duplicate value violates unique constraint.", + details: err.message, + }); + } + + 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, runValidators: true }, + ).lean(); + + if (!result) return res.status(404).json({ error: "Document not found." }); + + res.json({ message: "Updated", data: result }); + } catch (err) { + console.error(err); + + if (isDuplicateKeyError(err)) { + return res.status(409).json({ + error: "Duplicate value violates unique constraint.", + details: err.message, + }); } + + 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/apps/public-api/src/controllers/schema.controller.js b/apps/public-api/src/controllers/schema.controller.js index 708fa5a19..a8f7c0473 100644 --- a/apps/public-api/src/controllers/schema.controller.js +++ b/apps/public-api/src/controllers/schema.controller.js @@ -1,94 +1,212 @@ -const { - Project, - createSchemaApiKeySchema, - deleteProjectById, - setProjectById, - deleteProjectByApiKeyCache -} = require('@urbackend/common'); -const { v4: uuidv4 } = require('uuid'); -const { z } = require('zod'); +const { + Project, + createSchemaApiKeySchema, + deleteProjectById, + setProjectById, + deleteProjectByApiKeyCache, + getConnection, + getCompiledModel, + clearCompiledModel, + createUniqueIndexes, + generateApiKey, +} = require("@urbackend/common"); +const { z } = require("zod"); + +const isNamespaceNotFoundError = (err) => { + return err && (err.code === 26 || /ns not found/i.test(err.message)); +}; -module.exports.checkSchema = async (req, res) => { - try { - const { collectionName } = req.params; - const project = req.project; +const dropCollectionIfExists = async (connection, collectionName) => { + try { + await connection.db.dropCollection(collectionName); + } catch (err) { + if (!isNamespaceNotFoundError(err)) { + throw err; + } + } +}; - if (!project) return res.status(401).json({ error: "Project missing from request." }); +module.exports.checkSchema = async (req, res) => { + 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; + let fullProject; + let connection; + let compiledCollectionName; + let collectionWasPersisted = false; + let collectionNameForRollback; + let collectionExistedBefore = false; + + try { + const { name, fields } = createSchemaApiKeySchema.parse(req.body); + collectionNameForRollback = name; + const project = req.project; + if (!project) { + return res.status(401).json({ error: "Project missing from request." }); + } + + const projectId = project._id; + 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 = generateApiKey("jwt_"); + } + + const UNIQUE_SUPPORTED_TYPES = new Set([ + "String", + "Number", + "Boolean", + "Date", + ]); + // Recursive field transformer (API uses 'name', internal uses 'key') + function transformField(f, depth = 0) { + const mappedType = + f.type.charAt(0).toUpperCase() + f.type.slice(1).toLowerCase(); + + const wantsUnique = f.unique === true; + const isTopLevel = depth === 0; + const isSupportedUniqueType = UNIQUE_SUPPORTED_TYPES.has(mappedType); + + if (wantsUnique && (!isTopLevel || !isSupportedUniqueType)) { + throw new Error( + `Field '${f.name}' can only use unique=true on top-level String, Number, Boolean, or Date fields.`, + ); + } + + const mapped = { + key: f.name, + type: mappedType, + required: f.required === true, + unique: wantsUnique, + }; + + 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, depth + 1), + ); } + } - const transformedFields = (fields || []).map(f => transformField(f)); + if (f.fields) { + mapped.fields = f.fields.map((sf) => transformField(sf, depth + 1)); + } - fullProject.collections.push({ name: name, model: transformedFields }); + return mapped; + } + + const transformedFields = (fields || []).map((f) => transformField(f)); + + compiledCollectionName = fullProject.resources.db.isExternal + ? name + : `${fullProject._id}_${name}`; + + const newCollectionConfig = { + name, + model: transformedFields, + }; + + fullProject.collections.push(newCollectionConfig); + await fullProject.save(); + collectionWasPersisted = true; + + connection = await getConnection(fullProject._id); + + collectionExistedBefore = await connection.db + .listCollections({ name: compiledCollectionName }, { nameOnly: true }) + .hasNext(); + + const Model = getCompiledModel( + connection, + newCollectionConfig, + fullProject._id, + fullProject.resources.db.isExternal, + ); + + await createUniqueIndexes(Model, newCollectionConfig.model); + + // Clear redis cache + await deleteProjectById(projectId.toString()); + await setProjectById(projectId.toString(), fullProject.toObject()); + 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; + + return res + .status(201) + .json({ message: "Schema created successfully", project: projectObj }); + } catch (err) { + try { + if (fullProject && collectionWasPersisted) { + fullProject.collections = fullProject.collections.filter( + (c) => c.name !== collectionNameForRollback, + ); await fullProject.save(); + } - // 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); - } + if (connection && compiledCollectionName) { + clearCompiledModel(connection, compiledCollectionName); - const projectObj = fullProject.toObject(); - delete projectObj.publishableKey; - delete projectObj.secretKey; - delete projectObj.jwtSecret; + if (!collectionExistedBefore) { + await dropCollectionIfExists(connection, compiledCollectionName); + } + } + } catch (rollbackErr) { + console.error("Create schema rollback failed:", rollbackErr); + } - 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 }); + if (err instanceof z.ZodError) { + return res.status(400).json({ error: err.issues }); } + + console.error(err); + return res.status(400).json({ error: err.message }); + } }; diff --git a/packages/common/src/index.js b/packages/common/src/index.js index d2a55d3bb..49fd53d59 100644 --- a/packages/common/src/index.js +++ b/packages/common/src/index.js @@ -1,27 +1,27 @@ // Config -const { connectDB } = require('./config/db'); -const redis = require('./config/redis'); +const { connectDB } = require("./config/db"); +const redis = require("./config/redis"); // redis cache const { - setProjectByApiKeyCache, - getProjectByApiKeyCache, - deleteProjectByApiKeyCache, - setProjectById, - getProjectById, - deleteProjectById - } = require('./redis/redisCaching'); + setProjectByApiKeyCache, + getProjectByApiKeyCache, + deleteProjectByApiKeyCache, + setProjectById, + getProjectById, + deleteProjectById, +} = require("./redis/redisCaching"); // Models -const Developer = require('./models/Developer'); -const Project = require('./models/Project'); -const Release = require('./models/Release'); -const Log = require('./models/Log'); -const Otp = require('./models/otp'); +const Developer = require("./models/Developer"); +const Project = require("./models/Project"); +const Release = require("./models/Release"); +const Log = require("./models/Log"); +const Otp = require("./models/otp"); // Queues -const { authEmailQueue } = require('./queues/authEmailQueue'); -const { emailQueue } = require('./queues/emailQueue'); +const { authEmailQueue } = require("./queues/authEmailQueue"); +const { emailQueue } = require("./queues/emailQueue"); // Middleware const checkAuthEnabled = require('./middleware/checkAuthEnabled') @@ -30,38 +30,47 @@ const loadProjectForAdmin = require('./middleware/loadProjectForAdmin') const standardizeApiResponse = require('./middleware/standardizeApiResponse') // Utils -const { sendOtp, sendReleaseEmail, sendAuthOtpEmail } = require('./utils/emailService'); -const { - loginSchema, - signupSchema, - changePasswordSchema, - deleteAccountSchema, - onlyEmailSchema, - verifyOtpSchema, - resetPasswordSchema, - createProjectSchema, - createCollectionSchema, - createSchemaApiKeySchema, - sanitize, - userSignupSchema, - updateExternalConfigSchema -} = require('./utils/input.validation'); -const {garbageCollect, storageGarbageCollect} = require('./utils/GC'); -const { generateApiKey, hashApiKey } = require('./utils/api') -const { getConnection } = require('./utils/connection.manager') -const { encrypt, decrypt } = require('./utils/encryption'); -const { getCompiledModel, clearCompiledModel } = require('./utils/injectModel'); -const { getPublicIp } = require('./utils/network'); -const { isProjectStorageExternal, - isProjectDbExternal, - getBucket - } = require('./utils/project.helpers'); -const QueryEngine = require('./utils/queryEngine'); -const { registry, storageRegistry } = require('./utils/registry'); -const { getStorage } = require('./utils/storage.manager'); -const validateEnv = require('./utils/validateEnv'); -const {validateData, validateUpdateData} = require('./utils/validateData') -const sessionManager = require('./utils/session.manager'); +const { + sendOtp, + sendReleaseEmail, + sendAuthOtpEmail, +} = require("./utils/emailService"); +const { + loginSchema, + signupSchema, + changePasswordSchema, + deleteAccountSchema, + onlyEmailSchema, + verifyOtpSchema, + resetPasswordSchema, + createProjectSchema, + createCollectionSchema, + createSchemaApiKeySchema, + sanitize, + userSignupSchema, + updateExternalConfigSchema, +} = require("./utils/input.validation"); +const { garbageCollect, storageGarbageCollect } = require("./utils/GC"); +const { generateApiKey, hashApiKey } = require("./utils/api"); +const { getConnection } = require("./utils/connection.manager"); +const { encrypt, decrypt } = require("./utils/encryption"); +const { + getCompiledModel, + clearCompiledModel, + createUniqueIndexes, +} = require("./utils/injectModel"); +const { getPublicIp } = require("./utils/network"); +const { + isProjectStorageExternal, + isProjectDbExternal, + getBucket, +} = require("./utils/project.helpers"); +const QueryEngine = require("./utils/queryEngine"); +const { registry, storageRegistry } = require("./utils/registry"); +const { getStorage } = require("./utils/storage.manager"); +const validateEnv = require("./utils/validateEnv"); +const { validateData, validateUpdateData } = require("./utils/validateData"); +const sessionManager = require("./utils/session.manager"); module.exports = { connectDB, @@ -97,6 +106,7 @@ module.exports = { decrypt, getCompiledModel, clearCompiledModel, + createUniqueIndexes, getPublicIp, isProjectStorageExternal, isProjectDbExternal, @@ -119,5 +129,5 @@ module.exports = { validateData, validateUpdateData, userSignupSchema, - ...sessionManager -}; \ No newline at end of file + ...sessionManager, +}; diff --git a/packages/common/src/models/Project.js b/packages/common/src/models/Project.js index 58ea46605..133122715 100644 --- a/packages/common/src/models/Project.js +++ b/packages/common/src/models/Project.js @@ -1,94 +1,101 @@ -const mongoose = require('mongoose'); +const mongoose = require("mongoose"); // Encryption schema ko reuse karne ke liye alag se define kiya -const resourceConfigSchema = new mongoose.Schema({ +const resourceConfigSchema = new mongoose.Schema( + { encrypted: { type: String, select: false }, iv: { type: String, select: false }, - tag: { type: String, select: false } -}, { _id: false }); + tag: { type: String, select: false }, + }, + { _id: false }, +); const fieldSchema = new mongoose.Schema({ - key: { type: String, required: true }, - type: { - type: String, - required: true, - enum: ['String', 'Number', 'Boolean', 'Date', 'Object', 'Array', 'Ref'] - }, - required: { type: Boolean, default: false }, - // For type: 'Ref' — target collection name within the same project - ref: { type: String }, - // For type: 'Array' — describes each array item { type, fields? } - items: { type: mongoose.Schema.Types.Mixed }, + key: { type: String, required: true }, + type: { + type: String, + required: true, + enum: ["String", "Number", "Boolean", "Date", "Object", "Array", "Ref"], + }, + required: { type: Boolean, default: false }, + unique: { type: Boolean, default: false }, + // For type: 'Ref' — target collection name within the same project + ref: { type: String }, + // For type: 'Array' — describes each array item { type, fields? } + items: { type: mongoose.Schema.Types.Mixed }, }); + // For type: 'Object' — recursive sub-fields fieldSchema.add({ fields: [fieldSchema] }); const collectionSchema = new mongoose.Schema({ - name: { type: String, required: true }, - model: [fieldSchema], - rls: { - enabled: { type: Boolean, default: false }, - mode: { - type: String, - enum: ['owner-write-only'], - default: 'owner-write-only' - }, - ownerField: { type: String, default: 'userId' }, - requireAuthForWrite: { type: Boolean, default: true } - } + name: { type: String, required: true }, + model: [fieldSchema], + rls: { + enabled: { type: Boolean, default: false }, + mode: { + type: String, + enum: ["owner-write-only"], + default: "owner-write-only", + }, + ownerField: { type: String, default: "userId" }, + requireAuthForWrite: { type: Boolean, default: true }, + }, }); -const projectSchema = new mongoose.Schema({ +const projectSchema = new mongoose.Schema( + { name: { type: String, required: true }, description: String, owner: { - type: mongoose.Schema.Types.ObjectId, - ref: 'Developer' + type: mongoose.Schema.Types.ObjectId, + ref: "Developer", }, publishableKey: { - type: String, - required: true, - unique: true + type: String, + required: true, + unique: true, }, secretKey: { - type: String, - required: true, - unique: true + type: String, + required: true, + unique: true, }, jwtSecret: { - type: String, - required: true + type: String, + required: true, }, isAuthEnabled: { type: Boolean, default: false }, collections: [collectionSchema], allowedDomains: { - type: [String], - default: ['*'] + type: [String], + default: ["*"], }, // STORAGE LIMITS (Files) storageUsed: { type: Number, default: 0 }, - storageLimit: { type: Number, default: 20 * 1024 * 1024 }, // 20MB default + storageLimit: { type: Number, default: 20 * 1024 * 1024 }, // DATABASE LIMITS (JSON Docs) databaseUsed: { type: Number, default: 0 }, - databaseLimit: { type: Number, default: 20 * 1024 * 1024 }, // 20MB default + databaseLimit: { type: Number, default: 20 * 1024 * 1024 }, // Granular Resources Structure resources: { - db: { - isExternal: { type: Boolean, default: false }, - config: { type: resourceConfigSchema, default: null } - }, - storage: { - isExternal: { type: Boolean, default: false }, - config: { type: resourceConfigSchema, default: null } - } - } -}, { timestamps: true }); + db: { + isExternal: { type: Boolean, default: false }, + config: { type: resourceConfigSchema, default: null }, + }, + storage: { + isExternal: { type: Boolean, default: false }, + config: { type: resourceConfigSchema, default: null }, + }, + }, + }, + { timestamps: true }, +); projectSchema.index({ owner: 1 }); - -module.exports = mongoose.model('Project', projectSchema); +module.exports = mongoose.model("Project", projectSchema); diff --git a/packages/common/src/utils/injectModel.js b/packages/common/src/utils/injectModel.js index a2e936837..a7613d7af 100644 --- a/packages/common/src/utils/injectModel.js +++ b/packages/common/src/utils/injectModel.js @@ -1,126 +1,218 @@ const modelRegistry = new WeakMap(); const mongoose = require("mongoose"); +const { UNIQUE_SUPPORTED_TYPES_SET } = require("./schema.constants"); const typeMapping = { - String: String, - Number: Number, - Boolean: Boolean, - Date: Date + String: String, + Number: Number, + Boolean: Boolean, + Date: Date, }; -const normalizeKey = (key) => String(key || '').replace(/\uFEFF/g, '').trim(); - // Recursive field definition builder function buildFieldDef(field) { - // Object type — nested sub-schema - if (field.type === 'Object' && field.fields && field.fields.length > 0) { - const subSchema = {}; - field.fields.forEach(f => { - subSchema[f.key] = buildFieldDef(f); - }); - return { type: subSchema, required: !!field.required }; + // Object type — nested sub-schema + if (field.type === "Object" && field.fields && field.fields.length > 0) { + const subSchema = {}; + field.fields.forEach((f) => { + const normalizedKey = normalizeKey(f.key); + if (!normalizedKey) return; + + subSchema[normalizedKey] = buildFieldDef(f); + }); + return { type: subSchema, required: !!field.required }; + } + + // Array type + if (field.type === "Array") { + if (!field.items) { + return { + type: [mongoose.Schema.Types.Mixed], + required: !!field.required, + }; } + // Array of Objects + if ( + field.items.type === "Object" && + field.items.fields && + field.items.fields.length > 0 + ) { + const subSchema = {}; + field.items.fields.forEach((f) => { + const normalizedKey = normalizeKey(f.key); + if (!normalizedKey) return; - // Array type - if (field.type === 'Array') { - if (!field.items) { - return { type: [mongoose.Schema.Types.Mixed], required: !!field.required }; - } - // Array of Objects - if (field.items.type === 'Object' && field.items.fields && field.items.fields.length > 0) { - const subSchema = {}; - field.items.fields.forEach(f => { - subSchema[f.key] = buildFieldDef(f); - }); - return { type: [subSchema], required: !!field.required }; - } - // Array of Ref - if (field.items.type === 'Ref') { - return { - type: [{ type: mongoose.Schema.Types.ObjectId }], - required: !!field.required - }; - } - // Array of primitives - const itemType = typeMapping[field.items.type] || mongoose.Schema.Types.Mixed; - return { type: [itemType], required: !!field.required }; + subSchema[normalizedKey] = buildFieldDef(f); + }); + return { type: [subSchema], required: !!field.required }; } - - // Ref type — stores ObjectId - if (field.type === 'Ref') { - return { - type: mongoose.Schema.Types.ObjectId, - required: !!field.required - }; + // Array of Ref + if (field.items.type === "Ref") { + return { + type: [{ type: mongoose.Schema.Types.ObjectId }], + required: !!field.required, + }; } - - // Primitive types + // Array of primitives + const itemType = + typeMapping[field.items.type] || mongoose.Schema.Types.Mixed; + return { type: [itemType], required: !!field.required }; + } + + // Ref type — stores ObjectId + if (field.type === "Ref") { return { - type: typeMapping[field.type], - required: !!field.required + type: mongoose.Schema.Types.ObjectId, + required: !!field.required, }; -} + } -function buildMongooseSchema(fieldsArray) { - const schemaDef = {}; - fieldsArray.forEach(field => { - const normalizedKey = normalizeKey(field.key); - if (!normalizedKey) return; - schemaDef[normalizedKey] = buildFieldDef(field); - }); - return new mongoose.Schema(schemaDef, { timestamps: true, strict: false }); + // Primitive types + return { + type: typeMapping[field.type], + required: !!field.required, + }; +} +function normalizeKey(key) { + return String(key || "") + .replace(/\uFEFF/g, "") + .trim(); } +function buildMongooseSchema(fieldsArray = []) { + const schemaDef = {}; -function getCompiledModel(connection, collectionData, projectId, isExternal) { - - let collectionName = ""; + fieldsArray.forEach((field) => { + const normalizedKey = normalizeKey(field.key); + if (!normalizedKey) return; - if (!isExternal) { - collectionName = `${projectId}_${collectionData.name}` - } else { - collectionName = collectionData.name; + schemaDef[normalizedKey] = buildFieldDef(field); + }); - } + return new mongoose.Schema(schemaDef, { + timestamps: true, + strict: false, + }); +} +function getCompiledModel(connection, collectionData, projectId, isExternal) { + let collectionName = ""; + if (!isExternal) { + collectionName = `${projectId}_${collectionData.name}`; + } else { + collectionName = collectionData.name; + } - // Get per-connection cache - if (!modelRegistry.has(connection)) { - modelRegistry.set(connection, new Map()); - } + // Get per-connection cache + if (!modelRegistry.has(connection)) { + modelRegistry.set(connection, new Map()); + } - const connectionModels = modelRegistry.get(connection); + const connectionModels = modelRegistry.get(connection); - // If already compiled for THIS connection - if (connectionModels.has(collectionName)) { - return connectionModels.get(collectionName); - } + // If already compiled for THIS connection + if (connectionModels.has(collectionName)) { + return connectionModels.get(collectionName); + } - // If model already exists on connection (edge case) - if (connection.models[collectionName]) { - const existingModel = connection.models[collectionName]; - connectionModels.set(collectionName, existingModel); - return existingModel; - } + // If model already exists on connection (edge case) + if (connection.models[collectionName]) { + const existingModel = connection.models[collectionName]; + connectionModels.set(collectionName, existingModel); + return existingModel; + } - // Build schema + compile - const schema = buildMongooseSchema(collectionData.model); - const model = connection.model(collectionName, schema); + // Build schema + compile + const schema = buildMongooseSchema(collectionData.model); + const model = connection.model(collectionName, schema); - // Cache it - connectionModels.set(collectionName, model); + // Cache it + connectionModels.set(collectionName, model); - return model; + return model; } // Clear cached model (needed when schema changes) function clearCompiledModel(connection, collectionName) { - if (modelRegistry.has(connection)) { - modelRegistry.get(connection).delete(collectionName); + if (modelRegistry.has(connection)) { + modelRegistry.get(connection).delete(collectionName); + } + if (connection.models[collectionName]) { + delete connection.models[collectionName]; + } +} + +function getUniqueFieldFilter(fieldKey, isRequired) { + if (isRequired) { + return { [fieldKey]: { $exists: true } }; + } + + return { [fieldKey]: { $exists: true, $ne: null } }; +} + +async function findDuplicates(Model, fieldKey, isRequired) { + return Model.aggregate([ + { + $match: getUniqueFieldFilter(fieldKey, isRequired), + }, + { + $group: { + _id: `$${fieldKey}`, + count: { $sum: 1 }, + }, + }, + { + $match: { + count: { $gt: 1 }, + }, + }, + ]); +} + +async function createUniqueIndexes(Model, fields = []) { + for (const field of fields) { + if (!field.unique) continue; + if (!UNIQUE_SUPPORTED_TYPES_SET.has(field.type)) continue; + + const normalizedKey = normalizeKey(field.key); + if (!normalizedKey) continue; + + const duplicates = await findDuplicates( + Model, + normalizedKey, + !!field.required, + ); + + if (duplicates.length > 0) { + const examples = duplicates + .slice(0, 3) + .map((d) => JSON.stringify(d._id)) + .join(", "); + + throw new Error( + `Cannot create unique index on '${normalizedKey}'. ${duplicates.length} duplicate values exist.${examples ? ` Examples: ${examples}` : ""}`, + ); } - if (connection.models[collectionName]) { - delete connection.models[collectionName]; + + const indexName = `unique_${normalizedKey}_1`; + + const indexOptions = { + unique: true, + name: indexName, + }; + + if (!field.required) { + indexOptions.partialFilterExpression = { + [normalizedKey]: { $exists: true, $ne: null }, + }; } + + await Model.collection.createIndex({ [normalizedKey]: 1 }, indexOptions); + } } -module.exports = { getCompiledModel, clearCompiledModel }; +module.exports = { + getCompiledModel, + clearCompiledModel, + createUniqueIndexes, +}; diff --git a/packages/common/src/utils/input.validation.js b/packages/common/src/utils/input.validation.js index fc5aead24..8d97e3638 100644 --- a/packages/common/src/utils/input.validation.js +++ b/packages/common/src/utils/input.validation.js @@ -1,190 +1,357 @@ const z = require("zod"); +const { + MAX_FIELD_DEPTH, + UNIQUE_SUPPORTED_TYPES, +} = require("./schema.constants"); 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") + .regex(/^(?!\$)(?!.*\.)\S+$/, { + message: + "Field name must not start with '$', contain '.', or include whitespace", + }), + 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; + if (data.unique === true) { + if (depth > 1) return false; + if (!UNIQUE_SUPPORTED_TYPES.includes(data.type)) return false; + } return true; - }, { message: "Invalid field configuration for the given type, or nesting depth exceeded (max 3 levels)." }); + }, + { + message: + "Invalid field configuration, nesting depth exceeded (max 3 levels), or unique is only supported for top-level primitive fields.", + }, + ); - 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") + .regex(/^(?!\$)(?!.*\.)\S+$/, { + message: + "Field name must not start with '$', contain '.', or include whitespace", + }), + 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; + if (data.unique === true) { + if (depth > 1) return false; + if (!UNIQUE_SUPPORTED_TYPES.includes(normalType)) return false; + } + return true; - }, { message: "Invalid field configuration for the given type, or nesting depth exceeded (max 3 levels)." }); + }, + { + message: + "Invalid field configuration, nesting depth exceeded (max 3 levels), or unique is only supported for top-level primitive fields.", + }, + ); - 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', 's3', 'cloudflare_r2']).optional(), - + storageProvider: z.enum(["supabase", "s3", "cloudflare_r2"]).optional(), + // SCHEMA - AWS S3 / CLOUDFLARE R2 FIELDS s3AccessKeyId: emptyToUndefined, s3SecretAccessKey: emptyToUndefined, s3Region: emptyToUndefined, - s3Endpoint: z.preprocess((val) => (val === "" || val === null ? undefined : val), - z.string().url("Invalid Endpoint URL format").optional() + s3Endpoint: z.preprocess( + (val) => (val === "" || val === null ? undefined : val), + z.string().url("Invalid Endpoint URL format").optional(), ), s3Bucket: emptyToUndefined, - publicUrlHost: emptyToUndefined -}).refine(data => { - if (data.storageProvider === 'supabase') { + publicUrlHost: emptyToUndefined, + }) + .refine( + (data) => { + if (data.storageProvider === "supabase") { if (!data.storageUrl || !data.storageKey) return false; - } - if (data.storageProvider === 's3') { - if (!data.s3AccessKeyId || !data.s3SecretAccessKey || !data.s3Region || !data.s3Bucket) return false; - } - if (data.storageProvider === 'cloudflare_r2') { - if (!data.s3AccessKeyId || !data.s3SecretAccessKey || !data.s3Endpoint || !data.s3Bucket || !data.publicUrlHost) return false; - } - - // VALIDATION - REQUIRE DB URI OR STORAGE CONFIG - return !!(data.dbUri || data.storageProvider || (data.storageUrl && data.storageKey)); -}, { - message: "Provide either a DB URI or a complete Storage config for the selected provider." -}); + } + + if (data.storageProvider === "s3") { + if ( + !data.s3AccessKeyId || + !data.s3SecretAccessKey || + !data.s3Region || + !data.s3Bucket + ) { + return false; + } + } + + if (data.storageProvider === "cloudflare_r2") { + if ( + !data.s3AccessKeyId || + !data.s3SecretAccessKey || + !data.s3Endpoint || + !data.s3Bucket || + !data.publicUrlHost + ) { + return false; + } + } + + // VALIDATION - REQUIRE DB URI OR STORAGE CONFIG + return !!( + data.dbUri || + data.storageProvider || + (data.storageUrl && data.storageKey) + ); + }, + { + message: + "Provide either a DB URI or a complete Storage config for the selected provider.", + }, + ); 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." }), - - 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 + 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." }), + + password: z + .string() + .min(6, { message: "Password must be at least 6 characters." }) + .max(100, { message: "Password is too long." }), +}); diff --git a/packages/common/src/utils/schema.constants.js b/packages/common/src/utils/schema.constants.js new file mode 100644 index 000000000..a29f43a37 --- /dev/null +++ b/packages/common/src/utils/schema.constants.js @@ -0,0 +1,9 @@ +const MAX_FIELD_DEPTH = 3; +const UNIQUE_SUPPORTED_TYPES = ["String", "Number", "Boolean", "Date"]; +const UNIQUE_SUPPORTED_TYPES_SET = new Set(UNIQUE_SUPPORTED_TYPES); + +module.exports = { + MAX_FIELD_DEPTH, + UNIQUE_SUPPORTED_TYPES, + UNIQUE_SUPPORTED_TYPES_SET, +};