Skip to content

Commit d3da05a

Browse files
Merge branch 'main' into fix/dx-env-cleanup-207
2 parents 76bebe2 + 1cf5a6e commit d3da05a

24 files changed

Lines changed: 848 additions & 30 deletions

File tree

.env.example

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,4 +55,15 @@ RAZORPAY_KEY_SECRET=xxxxxxxxxxxxxxxxxxxx
5555
RAZORPAY_PLAN_ID=plan_xxxxxxxxxxxx
5656

5757
# Get from: https://dashboard.razorpay.com → Settings → Webhooks → Add Endpoint
58-
RAZORPAY_WEBHOOK_SECRET=whsec_xxxxxxxxxxxx
58+
RAZORPAY_WEBHOOK_SECRET=whsec_xxxxxxxxxxxx
59+
# Set from: Razorpay Dashboard → Settings → Webhooks → Add Endpoint → copy secret
60+
RAZORPAY_KEY_ID=rzp_testx_xxxx
61+
RAZORPAY_KEY_SECRET=xxxx
62+
RAZORPAY_PLAN_ID=xxxx
63+
RAZORPAY_WEBHOOK_SECRET=xxxx
64+
65+
# ── Internal Microservices (AI & Python) ──────────────────────────────────────
66+
# Required for AI Query Builder and Python microservice communication
67+
PYTHON_SERVICE_URL=http://localhost:8000
68+
INTERNAL_SECRET=generate_a_random_32_char_secret_here
69+
GROQ_API_KEY=gsk_your_groq_api_key_here

.github/workflows/weekly-changelog.yml

Lines changed: 19 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -72,13 +72,11 @@ jobs:
7272
7373
# Create monthly file if it doesn't exist yet
7474
if [ ! -f "$FILE" ]; then
75-
cat > "$FILE" << MDXEOF
76-
---
77-
title: Changelog
78-
description: What's new in urBackend — new features, improvements, and fixes.
79-
---
80-
81-
MDXEOF
75+
echo "---" > "$FILE"
76+
echo "title: Changelog" >> "$FILE"
77+
echo "description: What's new in urBackend — new features, improvements, and fixes." >> "$FILE"
78+
echo "---" >> "$FILE"
79+
echo "" >> "$FILE"
8280
fi
8381
8482
# Insert new <Update> entry right after the closing --- of frontmatter
@@ -98,23 +96,17 @@ MDXEOF
9896
9997
echo "Updated: $FILE"
10098
101-
- name: Open Pull Request
102-
env:
103-
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
104-
run: |
105-
BRANCH="changelog/week-of-$(date +%Y-%m-%d)-${{ github.run_id }}"
106-
107-
git config user.name "github-actions[bot]"
108-
git config user.email "github-actions[bot]@users.noreply.github.com"
109-
git checkout -b "$BRANCH"
110-
git add mintlify/docs/changelog/
111-
git diff --staged --quiet && echo "No changes to commit" && exit 0
112-
git commit -m "docs: weekly changelog update $(date +%Y-%m-%d)"
113-
git push origin "$BRANCH"
114-
gh pr create \
115-
--repo ${{ github.repository }} \
116-
--base main \
117-
--head "$BRANCH" \
118-
--title "docs: weekly changelog update $(date +%Y-%m-%d)" \
119-
--body "Auto-generated weekly changelog entry. Please review before merging." \
120-
--label "documentation"
99+
- name: Create Pull Request
100+
uses: peter-evans/create-pull-request@v7
101+
with:
102+
# Use a PAT if available. This bypasses repos where GITHUB_TOKEN is
103+
# restricted from creating PRs via GitHub Actions settings.
104+
token: ${{ secrets.CHANGELOG_PR_TOKEN || secrets.GITHUB_TOKEN }}
105+
branch: changelog/week-of-${{ github.run_id }}
106+
base: main
107+
commit-message: docs: weekly changelog update
108+
title: docs: weekly changelog update
109+
body: Auto-generated weekly changelog entry. Please review before merging.
110+
labels: documentation
111+
add-paths: |
112+
mintlify/docs/changelog/

.gitignore

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,12 @@ coverage/
99
.turbo/
1010
plan.md
1111
/.kiroo
12-
/xtemp/
12+
/xtemp/
13+
14+
# Python
15+
__pycache__/
16+
*.py[cod]
17+
*$py.class
18+
.pytest_cache/
19+
venv/
20+
env/

.vscode/settings.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
{
2+
"git.ignoreLimitWarning": true
23
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
const axios = require('axios');
2+
const crypto = require('crypto');
3+
const { forwardToPythonService } = require('../utils/internalPythonClient');
4+
5+
describe('internalPythonClient', () => {
6+
let originalEnv;
7+
8+
beforeEach(() => {
9+
originalEnv = process.env;
10+
process.env = { ...originalEnv };
11+
jest.clearAllMocks();
12+
13+
// Mock Date.now to freeze timestamp for assertions
14+
jest.spyOn(Date, 'now').mockImplementation(() => 1609459200000); // 2021-01-01T00:00:00.000Z
15+
jest.spyOn(axios, 'post').mockResolvedValue({ data: { success: true } });
16+
});
17+
18+
afterEach(() => {
19+
process.env = originalEnv;
20+
jest.restoreAllMocks();
21+
});
22+
23+
test('throws error if INTERNAL_SECRET is missing', async () => {
24+
delete process.env.INTERNAL_SECRET;
25+
26+
await expect(forwardToPythonService('/test', {}))
27+
.rejects
28+
.toThrow("INTERNAL_SECRET is not defined in environment");
29+
});
30+
31+
test('generates correct HMAC signature and calls axios', async () => {
32+
process.env.INTERNAL_SECRET = 'test-secret';
33+
process.env.PYTHON_SERVICE_URL = 'http://test-python.local';
34+
35+
const path = '/ai/query-builder';
36+
const payload = { prompt: "test prompt" };
37+
const payloadString = JSON.stringify(payload);
38+
const timestamp = "1609459200000";
39+
40+
// Calculate expected signature manually
41+
const expectedSignature = crypto
42+
.createHmac('sha256', 'test-secret')
43+
.update(`${timestamp}.${payloadString}`)
44+
.digest('hex');
45+
46+
const result = await forwardToPythonService(path, payload);
47+
48+
expect(axios.post).toHaveBeenCalledTimes(1);
49+
expect(axios.post).toHaveBeenCalledWith(
50+
`http://test-python.local${path}`,
51+
payloadString,
52+
{
53+
headers: {
54+
'X-Internal-Signature': expectedSignature,
55+
'X-Timestamp': timestamp,
56+
'Content-Type': 'application/json'
57+
}
58+
}
59+
);
60+
expect(result).toEqual({ success: true });
61+
});
62+
});

apps/dashboard-api/src/app.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,9 +105,11 @@ const analyticsRoute = require('./routes/analytics');
105105
const billingRoute = require('./routes/billing');
106106
const eventsRoute = require('./routes/events');
107107
const adminMetricsRoute = require('./routes/admin.metrics');
108+
const aiRoute = require('./routes/ai.routes');
108109

109110
app.use('/api/auth', authRoute);
110111
app.use('/api/projects', dashboardLimiter, projectRoute);
112+
app.use('/api/projects/:projectId/ai', dashboardLimiter, aiRoute);
111113
app.use('/api/projects', dashboardLimiter, webhookRoute);
112114
app.use('/api/releases', releaseRoute);
113115
app.use('/api/analytics', dashboardLimiter, analyticsRoute);
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
const { Project } = require('@urbackend/common/src/models');
2+
const { forwardToPythonService } = require('../utils/internalPythonClient');
3+
const { AppError } = require('@urbackend/common');
4+
5+
/**
6+
* Controller to handle AI Query Builder requests.
7+
*/
8+
const queryBuilder = async (req, res, next) => {
9+
try {
10+
const { projectId } = req.params;
11+
const { collectionName, prompt } = req.body;
12+
13+
const mongoose = require('mongoose');
14+
if (!mongoose.Types.ObjectId.isValid(projectId)) {
15+
throw new AppError(400, "Invalid project ID");
16+
}
17+
18+
if (typeof collectionName !== 'string' || typeof prompt !== 'string') {
19+
throw new AppError(400, "Collection name and prompt must be strings");
20+
}
21+
22+
const safeCollectionName = collectionName.trim();
23+
const safePrompt = prompt.trim();
24+
25+
if (!safeCollectionName || !safePrompt) {
26+
throw new AppError(400, "Collection name and prompt are required");
27+
}
28+
29+
if (safeCollectionName === 'users') {
30+
throw new AppError(403, "Cannot query the users collection via AI");
31+
}
32+
33+
// 1. Fetch the project and specifically the requested collection schema
34+
const project = await Project.findOne(
35+
{ _id: projectId, owner: req.user._id, "collections.name": safeCollectionName },
36+
{ "collections.$": 1 }
37+
);
38+
39+
if (!project || !project.collections || project.collections.length === 0) {
40+
throw new AppError(404, "Collection not found or access denied");
41+
}
42+
43+
const collection = project.collections[0];
44+
const allowedFields = new Set([
45+
...collection.model.map(field => field.key),
46+
'_id',
47+
'createdAt',
48+
'updatedAt'
49+
]);
50+
51+
// 2. Extract simplified schema fields for the LLM
52+
// We only send key and type to save tokens and prevent confusion
53+
const schemaFields = collection.model.map(field => ({
54+
key: field.key,
55+
type: field.type
56+
}));
57+
58+
// Add implicit MongoDB fields
59+
schemaFields.push(
60+
{ key: "_id", type: "OBJECTID" },
61+
{ key: "createdAt", type: "DATE" },
62+
{ key: "updatedAt", type: "DATE" }
63+
);
64+
65+
// 3. Forward request to Python Service
66+
const aiResponse = await forwardToPythonService('/ai/query-builder', {
67+
prompt: safePrompt,
68+
schema_fields: schemaFields
69+
});
70+
71+
// 4. Return the structured JSON to the frontend
72+
// Ensure filters is always an array to prevent frontend crash
73+
const rawFilters = Array.isArray(aiResponse.filters) ? aiResponse.filters : [];
74+
const allowedOperators = new Set(['=', '_gt', '_lt', '_gte', '_lte', '_ne', '_regex']);
75+
const safeFilters = rawFilters.filter(f => {
76+
const isPrimitiveValue = ['string', 'number', 'boolean'].includes(typeof f?.value);
77+
return (
78+
f &&
79+
typeof f.field === 'string' &&
80+
typeof f.operator === 'string' &&
81+
allowedFields.has(f.field) &&
82+
allowedOperators.has(f.operator) &&
83+
isPrimitiveValue
84+
);
85+
});
86+
87+
res.status(200).json({
88+
success: true,
89+
data: {
90+
filters: safeFilters,
91+
sort: typeof aiResponse.sort === 'string' ? aiResponse.sort : '-createdAt'
92+
},
93+
message: "Query built successfully"
94+
});
95+
96+
} catch (error) {
97+
// Forward expected AppErrors
98+
if (error instanceof AppError) {
99+
return next(error);
100+
}
101+
102+
// Wrap Python/Axios errors
103+
if (error.response && error.response.data) {
104+
console.error("AI Service returned error:", error.response.status, error.response.data);
105+
106+
let errorMessage = "AI Service Error";
107+
if (typeof error.response.data === 'string') {
108+
errorMessage = error.response.data;
109+
} else if (error.response.data.detail) {
110+
errorMessage = typeof error.response.data.detail === 'string' ? error.response.data.detail : JSON.stringify(error.response.data.detail);
111+
} else {
112+
errorMessage = JSON.stringify(error.response.data);
113+
}
114+
115+
return next(new AppError(error.response.status || 500, errorMessage));
116+
}
117+
118+
next(new AppError(500, "Failed to build query via AI"));
119+
}
120+
};
121+
122+
module.exports = {
123+
queryBuilder
124+
};
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
const express = require('express');
2+
const router = express.Router({ mergeParams: true }); // mergeParams is crucial to access :projectId
3+
const aiController = require('../controllers/ai.controller');
4+
const authMiddleware = require('../middlewares/authMiddleware');
5+
6+
// All AI routes require the user to be authenticated
7+
router.use(authMiddleware);
8+
9+
/**
10+
* @route POST /api/projects/:projectId/ai/query-builder
11+
* @desc Generate MongoDB filters from natural language
12+
* @access Private
13+
*/
14+
router.post('/query-builder', aiController.queryBuilder);
15+
16+
module.exports = router;
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
const crypto = require('crypto');
2+
const axios = require('axios');
3+
4+
/**
5+
* Forwards a request to the internal Python microservice with HMAC-SHA256 signature.
6+
* @param {string} path - The path on the Python service (e.g., "/ai/query-builder")
7+
* @param {object} payload - The JSON payload to send
8+
* @returns {Promise<any>} The response data from Python service
9+
*/
10+
const forwardToPythonService = async (path, payload) => {
11+
const pythonUrl = process.env.PYTHON_SERVICE_URL || 'http://localhost:8000';
12+
const secret = process.env.INTERNAL_SECRET;
13+
14+
if (!secret) {
15+
throw new Error("INTERNAL_SECRET is not defined in environment");
16+
}
17+
18+
const payloadString = JSON.stringify(payload);
19+
const timestamp = Date.now().toString();
20+
21+
// Generate HMAC-SHA256 signature
22+
const signature = crypto
23+
.createHmac('sha256', secret)
24+
.update(`${timestamp}.${payloadString}`)
25+
.digest('hex');
26+
27+
try {
28+
const response = await axios.post(`${pythonUrl}${path}`, payloadString, {
29+
headers: {
30+
'X-Internal-Signature': signature,
31+
'X-Timestamp': timestamp,
32+
'Content-Type': 'application/json'
33+
}
34+
});
35+
return response.data;
36+
} catch (error) {
37+
console.error("Error communicating with Python Service:", error.response?.data || error.message);
38+
throw error;
39+
}
40+
};
41+
42+
module.exports = { forwardToPythonService };

apps/dashboard-api/test_python.js

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
const axios = require('axios');
2+
const crypto = require('crypto');
3+
4+
const run = async () => {
5+
const timestamp = Date.now().toString();
6+
const payload = JSON.stringify({ prompt: "test", schema_fields: [] });
7+
8+
const secret = process.env.TEST_SECRET || process.env.INTERNAL_SECRET;
9+
const apiUrl = process.env.AI_API_URL || 'http://127.0.0.1:8000/ai/query-builder';
10+
11+
if (!secret) {
12+
console.error("Missing TEST_SECRET or INTERNAL_SECRET in environment variables.");
13+
process.exit(1);
14+
}
15+
16+
const signature = crypto.createHmac('sha256', secret).update(`${timestamp}.${payload}`).digest('hex');
17+
18+
try {
19+
const res = await axios.post(apiUrl, payload, {
20+
headers: {
21+
'X-Internal-Signature': signature,
22+
'X-Timestamp': timestamp,
23+
'Content-Type': 'application/json'
24+
}
25+
});
26+
console.log("Success:", res.data);
27+
} catch (e) {
28+
if (e.response) {
29+
console.log("Error status:", e.response.status);
30+
console.log("Error data:", e.response.data);
31+
} else {
32+
console.log("No response:", e.message);
33+
}
34+
}
35+
};
36+
37+
run();

0 commit comments

Comments
 (0)