Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
b2e5ee7
feat: setup consumer server
renganathc May 18, 2026
b77cdd9
feat: configure Express server with CORS, CSRF, rate limiting, and gr…
renganathc May 18, 2026
dc574fc
refractor: reuse existing auth middleware
renganathc May 18, 2026
337809f
feat: implement handler in project controller
renganathc May 18, 2026
513a445
feat: create project route and add db export endpoint
renganathc May 18, 2026
fff9e5d
feat: add project route to express app
renganathc May 18, 2026
1e0c4f0
feat: add BullMQ queue for db export jobs
renganathc May 18, 2026
18d56d9
refactor: move db export job creation to dashboard API
renganathc May 19, 2026
8720a97
refactor: move export endpoint to projects router and remove dbExport…
renganathc May 19, 2026
8365bc2
feat: implement project ownership verifcation and queuing logic in ex…
renganathc May 19, 2026
f239af0
feat: implement plan based rate limiting
renganathc May 19, 2026
7bbd7e7
feat: cache project lookup in Redis before MongoDB fallback
renganathc May 19, 2026
e6bbf40
feat: implement the DB export worker logic
renganathc May 20, 2026
a11ebe3
feat: add export email job handling to email worker
renganathc May 20, 2026
9af3d7a
feat: implement DB export worker completion and failure handling and …
renganathc May 20, 2026
5fcc31e
feat: add sendExportReadyEmail function to emailService.js
renganathc May 20, 2026
8d27faf
fix: modify emailQueue.js to use sendExportReadyEmail fn
renganathc May 20, 2026
e02ac5f
fix (package.json): modify dev script to also run consumer server
renganathc May 20, 2026
349d3c8
refactor: add dedicated consumer entrypoint and graceful shutdown
renganathc May 21, 2026
6308ad3
chore: add Dockerfile
renganathc May 21, 2026
a9fc502
feat(storage): Implement function to return unified S3Client for all …
renganathc May 24, 2026
bc58f42
fix (storage): Remove bucket from getS3Storage return body, rename am…
renganathc May 24, 2026
8652211
feat: update export worker to use getS3CompatibleStorage function, st…
renganathc May 26, 2026
6c1d483
fix: CodeQL format string warning in export logger
renganathc May 27, 2026
762dd1d
refactor(export-worker): route exports through legacy getStorage abst…
renganathc May 28, 2026
5503a95
fix (db-export): modify db export controller and endpoint to accept a…
renganathc May 28, 2026
87dc102
fix (db-export): modify export worker to ahndle individual collection…
renganathc May 28, 2026
30e0182
fix (email-queue): Implement error handling for 'send-export-email'
renganathc May 28, 2026
91c0983
fix: update res structure in export controller and handle unknown job…
renganathc May 28, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions apps/consumer/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
FROM node:22-alpine

WORKDIR /app

# Copy root package files
COPY package.json package-lock.json ./

# Copy workspace package.json files
COPY packages/common/package.json ./packages/common/
COPY apps/consumer/package.json ./apps/consumer/

RUN npm ci

# Copy actual source code
COPY packages/common ./packages/common
COPY apps/consumer ./apps/consumer

WORKDIR /app/apps/consumer

CMD ["npm", "run", "start"]
Comment thread
renganathc marked this conversation as resolved.
15 changes: 15 additions & 0 deletions apps/consumer/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"name": "consumer",
"version": "1.0.0",
"description": "",
"main": "src/app.js",
"scripts": {
"dev": "node src/index.js",
"start": "node src/index.js",
"test": "jest --testPathPatterns=src/"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "commonjs"
}
Comment on lines +1 to +15
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | πŸ”΄ Critical | ⚑ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Show consumer manifest
echo "---- apps/consumer/package.json ----"
cat -n apps/consumer/package.json

# Identify import/require usage in consumer src
echo "---- imports in apps/consumer/src ----"
rg -n --no-heading "(require\\(|from\\s+['\"]|import\\s+['\"])\\s*('dotenv'|\"dotenv\"|dotenv)|\\bbullmq\\b|`@urbackend/common`" apps/consumer/src || true

# Show consumer src key files referenced
echo "---- list apps/consumer/src ----"
find apps/consumer/src -maxdepth 2 -type f -print

# Inspect monorepo root package.json for workspace/hoisting rules
echo "---- root package.json ----"
ls -1
if [ -f package.json ]; then cat -n package.json; fi

# If a package manager config exists (pnpm/yarn/npm), show workspace relevant sections
for f in pnpm-workspace.yaml yarn.lock package-lock.json pnpm-lock.yaml .npmrc .yarnrc.yml; do
  if [ -e "$f" ]; then
    echo "---- $f ----"
    head -n 80 "$f" || true
  fi
done

Repository: geturbackend/urBackend

Length of output: 4313


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Show consumer manifest
echo "---- apps/consumer/package.json ----"
cat -n apps/consumer/package.json

# Identify import/require usage in consumer src
echo "---- imports in apps/consumer/src ----"
rg -n --no-heading "(require\\(|from\\s+['\"]|import\\s+['\"])\\s*('dotenv'|\"dotenv\"|dotenv)|\\bbullmq\\b|`@urbackend/common`" apps/consumer/src || true

# Show consumer src key files referenced
echo "---- list apps/consumer/src ----"
find apps/consumer/src -maxdepth 2 -type f -print

# Inspect monorepo root package.json for workspace/hoisting rules
echo "---- root package.json ----"
ls -1
if [ -f package.json ]; then cat -n package.json; fi

# If a package manager config exists (pnpm/yarn/npm), show workspace relevant sections
for f in pnpm-workspace.yaml yarn.lock package-lock.json pnpm-lock.yaml .npmrc .yarnrc.yml; do
  if [ -e "$f" ]; then
    echo "---- $f ----"
    head -n 80 "$f" || true
  fi
done

Repository: geturbackend/urBackend

Length of output: 4313


Declare consumer runtime dependencies in apps/consumer/package.json.

apps/consumer imports dotenv, @urbackend/common, and bullmq, but its package.json declares none (and package-lock.json shows no dependencies recorded for apps/consumer). This can break module resolution when installing/running the consumer workspace in isolation. Match the versions/protocol used by the other workspaces in this monorepo (e.g., dotenv ^17.2.3, bullmq ^5.70.1, @urbackend/common *).

πŸ€– Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/consumer/package.json` around lines 1 - 15, Add a "dependencies" section
to apps/consumer/package.json declaring the runtime packages the consumer
imports: add "dotenv": "^17.2.3", "bullmq": "^5.70.1", and "`@urbackend/common`":
"*" (to match the monorepo protocol/versions used elsewhere), then run the
workspace install to update the lockfile; update any existing script or import
usage if package names differ. Ensure the "dependencies" key is present
alongside "scripts" and uses the exact versions listed so module resolution
works when the consumer workspace is installed/run in isolation.

35 changes: 35 additions & 0 deletions apps/consumer/src/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
const dotenv = require('dotenv');
dotenv.config({ path: require('path').join(__dirname, '../../../.env') });

const { validateEnv } = require('@urbackend/common');

if (process.env.NODE_ENV !== 'test') {
validateEnv();
}

const { initExportWorker } = require('./workers/export.worker');

const { connectDB } = require('@urbackend/common');

(async () => {
try {
await connectDB();

const worker = initExportWorker();

console.log('[CONSUMER] Export worker started and listening for jobs...');

const shutdown = async () => {
console.log('Shutting down worker...');
await worker.close();
process.exit(0);
};

process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);

} catch (err) {
console.error('Failed to start worker:', err);
process.exit(1);
}
})();
161 changes: 161 additions & 0 deletions apps/consumer/src/workers/export.worker.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
const { Worker } = require('bullmq');
const { PassThrough } = require('stream');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { GetObjectCommand } = require('@aws-sdk/client-s3');
const { getSignedUrl } = require('@aws-sdk/s3-request-presigner');

const {
redis,
exportQueue,
emailQueue,
Project,
getConnection,
getCompiledModel,
getS3CompatibleStorage,
getStorage,
decrypt,
getBucket
} = require('@urbackend/common');

const initExportWorker = () => {
const worker = new Worker(exportQueue.name, async (job) => {
const { projectId, collectionName, userId, email } = job.data;
console.log(`[ExportWorker] Starting export for collection ${collectionName} in project ${projectId} requested by ${email}`);

const project = await Project.findById(projectId);
if (!project) throw new Error('Project not found');

const col = project.collections.find(c => c.name === collectionName);
if (!col) throw new Error(`Collection ${collectionName} not found`);

const connection = await getConnection(projectId);
const bucket = getBucket(project);
const storagePath = `${projectId}/exports/${collectionName}_export_${Date.now()}.json`;

let provider = 'supabase';
if (project.resources?.storage?.isExternal) {
try {
const decrypted = decrypt(project.resources.storage.config);
const config = JSON.parse(decrypted);
provider = config.storageProvider || 'supabase';
} catch (err) {
console.error("[ExportWorker] Error decrypting storage config:", err);
}
}

const client = await getStorage(project);

console.log(`[ExportWorker] Preparing upload to storage (Provider: ${provider})...`);

if (provider === 'supabase') {
const tempFilePath = path.join(os.tmpdir(), `export_${projectId}_${collectionName}_${Date.now()}.json`);
const writeStream = fs.createWriteStream(tempFilePath);

try {
writeStream.write('{\n');
const Model = getCompiledModel(connection, col, projectId, project.resources.db.isExternal);

writeStream.write(` "${col.name}": [\n`);

const cursor = Model.find().lean().cursor();
let first = true;

for await (const doc of cursor) {
if (!first) writeStream.write(',\n');
writeStream.write(` ${JSON.stringify(doc)}`);
first = false;
}

writeStream.write('\n ]\n');
writeStream.write('}\n');
writeStream.end();

await new Promise((resolve, reject) => {
writeStream.on('finish', resolve);
writeStream.on('error', reject);
});

console.log(`[ExportWorker] Temp file created, uploading...`);
const fileBuffer = fs.readFileSync(tempFilePath);

const { error } = await client.storage.from(bucket).upload(storagePath, fileBuffer, {
contentType: 'application/json'
});

if (error) throw error;
} finally {
if (fs.existsSync(tempFilePath)) {
fs.unlinkSync(tempFilePath);
}
}

} else if (provider === 's3' || provider === 'cloudflare_r2') {
const passThrough = new PassThrough();

// Start the upload promise in parallel using the getStorage client
const uploadPromise = client.storage.from(bucket).upload(storagePath, passThrough, {
contentType: 'application/json'
});

try {
passThrough.write('{\n');

const Model = getCompiledModel(connection, col, projectId, project.resources.db.isExternal);

passThrough.write(` "${col.name}": [\n`);

const cursor = Model.find().lean().cursor();
let first = true;

for await (const doc of cursor) {
if (!first) passThrough.write(',\n');
passThrough.write(` ${JSON.stringify(doc)}`);
first = false;
}

passThrough.write('\n ]\n');

passThrough.write('}\n');
passThrough.end();

console.log(`[ExportWorker] Database stream ended. Awaiting final storage upload...`);
const { error } = await uploadPromise;
if (error) throw error;
} catch (error) {
passThrough.destroy(error);
throw error;
}
} else {
throw new Error(`Unknown storage provider: ${provider}`);
}

let downloadUrl;
if (provider === 'supabase') {
const { data, error } = await client.storage.from(bucket).createSignedUrl(storagePath, 86400);
if (error) throw error;
downloadUrl = data?.signedUrl;
} else {
const { s3Client } = await getS3CompatibleStorage(project);
const command = new GetObjectCommand({ Bucket: bucket, Key: storagePath });
downloadUrl = await getSignedUrl(s3Client, command, { expiresIn: 86400 });
}

// queue the email to be sent to the user
await emailQueue.add('send-export-email', { email, downloadUrl, projectName: project.name });
console.log(`[ExportWorker] Export completed! Email queued for ${email}`);
}, { connection: redis, concurrency: 2 });

worker.on('completed', (job) => {
console.log(`[ExportWorker] Job ${job.id} for project ${job.data.projectId} completed.`);
});

worker.on('failed', (job, err) => {
console.error(`[ExportWorker] Job ${job?.id} for project ${job?.data?.projectId} failed:`, err.message);
});

return worker;
};

module.exports = { initExportWorker };
2 changes: 2 additions & 0 deletions apps/dashboard-api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
"transform": {}
},
"dependencies": {
"@bull-board/api": "^7.1.5",
"@bull-board/express": "^7.1.5",
"@kiroo/sdk": "^0.1.2",
"@supabase/supabase-js": "^2.84.0",
"@urbackend/common": "*",
Expand Down
1 change: 0 additions & 1 deletion apps/dashboard-api/src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,6 @@ app.use('/api/admin/metrics', dashboardLimiter, adminMetricsRoute);




app.get('/api/server-ip', async (req, res) => {
const ip = await getPublicIp();
res.json({ ip });
Expand Down
66 changes: 66 additions & 0 deletions apps/dashboard-api/src/controllers/dbExport.controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
const { AppError } = require('@urbackend/common');
const { Developer } = require('@urbackend/common');
const { Project } = require('@urbackend/common');
const { exportQueue } = require('@urbackend/common');
const { redis } = require('@urbackend/common');
const { getProjectById, setProjectById } = require('@urbackend/common');

module.exports.dbExportHandler = async (req, res, next) => {
try {
const { projectId, collectionName } = req.params;
const { _id: userId } = req.user;

let project = await getProjectById(projectId);
if (!project) {
project = await Project.findById(projectId).lean();
if (!project) {
return next(new AppError(404, "Project not found."));
}
await setProjectById(projectId, project);
}

if (project.owner.toString() !== userId.toString()) {
return next(new AppError(403, "Access denied. You are not the owner of this project."));
}

if (!project.collections.some(c => c.name === collectionName)) {
return next(new AppError(404, "Collection not found in project."));
}


const developer = await Developer.findById(userId).select('email plan').lean();
if (!developer) {
return next(new AppError(404, "Authenticated developer not found."));
}
const { email, plan = 'free' } = developer;

console.log(`[Dashboard API] Received export request for collection ${collectionName} in project ${projectId} from user ${userId} (${email})`);


const maxExports = plan === 'pro' ? 5 : 1;
const today = new Date().toISOString().split('T')[0];
const key = `project:${projectId}:export_limit:${today}`;

const currentCount = await redis.get(key);
if (currentCount && Number(currentCount) >= maxExports) {
return next(new AppError(429, `Daily export limit reached (${maxExports}/${maxExports}). Please try again tomorrow.`));
}

const newCount = await redis.incr(key);
if (newCount === 1) {
await redis.expire(key, 86400); // Set expiry to 24 hours
}
Comment thread
yash-pouranik marked this conversation as resolved.

await exportQueue.add('export-database', { projectId, collectionName, userId, email });

return res.status(202).json({
success: true,
data: {},
message: `Collection export request received. You will receive an email with a download link shortly. Usage today: ${newCount}/${maxExports}.`,
});
Comment thread
yash-pouranik marked this conversation as resolved.
Comment thread
yash-pouranik marked this conversation as resolved.
Comment thread
yash-pouranik marked this conversation as resolved.
Comment thread
yash-pouranik marked this conversation as resolved.

} catch (err) {
console.error("[Dashboard API] Error handling export request for project - ", req.params.projectId, ": ", err);
return next(new AppError(500, err.message || "Failed to initiate database export."));
}
};
4 changes: 4 additions & 0 deletions apps/dashboard-api/src/routes/projects.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ const {

const { createAdminUser, resetPassword, getUserDetails, updateAdminUser, listUserSessions, revokeUserSession } = require('../controllers/userAuth.controller');

const exportController = require('../controllers/dbExport.controller');

// POST REQ FOR CREATE PROJECT
router.post('/', authMiddleware, verifyEmail, planEnforcement.checkProjectLimit, createProject);
Expand Down Expand Up @@ -152,4 +153,7 @@ router.put('/:projectId/admin/users/:userId', authMiddleware, loadProjectForAdmi
router.get('/:projectId/admin/users/:userId/sessions', authMiddleware, loadProjectForAdmin, checkAuthEnabled, listUserSessions);
router.delete('/:projectId/admin/users/:userId/sessions/:tokenId', authMiddleware, loadProjectForAdmin, checkAuthEnabled, revokeUserSession);

// POST req for DB EXPORT
router.post('/:projectId/collections/:collectionName/export', authMiddleware, exportController.dbExportHandler);

module.exports = router;
Loading
Loading