diff --git a/package.json b/package.json
index 45dc2a0..cd71005 100644
--- a/package.json
+++ b/package.json
@@ -6,6 +6,7 @@
"start": "node server.js",
"dev": "nodemon server.js",
"test": "jest --forceExit --detectOpenHandles",
+ "admin-audit": "node tools/adminAudit.js",
"docker:up": "docker compose up --build",
"docker:down": "docker compose down"
},
@@ -22,7 +23,7 @@
"express": "^5.2.1",
"express-rate-limit": "^8.5.2",
"helmet": "^8.2.0",
- "jsdom": "^29.1.1",
+ "jsdom": "^22.1.0",
"jsonwebtoken": "^9.0.3",
"mongodb": "^6.21.0",
"swagger-jsdoc": "^6.3.0",
diff --git a/public/admin.html b/public/admin.html
new file mode 100644
index 0000000..f1fbb89
--- /dev/null
+++ b/public/admin.html
@@ -0,0 +1,36 @@
+
+
+
+
+
+ Admin — Audit & Recompute
+
+
+
+
+
+
Admin: Audit & Recompute
+
Paste an admin JWT below, then run the audit or recompute operations.
+
+
+
+
+
+
+
+
+
+
+
Result
+
+
+
+
+
+
diff --git a/public/admin.js b/public/admin.js
new file mode 100644
index 0000000..d11a130
--- /dev/null
+++ b/public/admin.js
@@ -0,0 +1,31 @@
+async function callApi(path, method='GET', token=null) {
+ const headers = { 'Content-Type': 'application/json' };
+ if (token) headers['Authorization'] = token.startsWith('Bearer ') ? token : `Bearer ${token}`;
+ const res = await fetch(path, { method, headers });
+ const text = await res.text();
+ try { return JSON.parse(text); } catch (e) { return { status: res.status, body: text }; }
+}
+
+const out = document.getElementById('output');
+const tokenInput = document.getElementById('token');
+
+document.getElementById('btn-audit').addEventListener('click', async () => {
+ out.value = 'Running audit...';
+ const t = tokenInput.value.trim();
+ const result = await callApi('/api/admin/audit-indexes', 'GET', t || null);
+ out.value = JSON.stringify(result, null, 2);
+});
+
+document.getElementById('btn-recompute').addEventListener('click', async () => {
+ out.value = 'Running recompute...';
+ const t = tokenInput.value.trim();
+ const result = await callApi('/api/admin/recompute-like-counts', 'POST', t || null);
+ out.value = JSON.stringify(result, null, 2);
+});
+
+document.getElementById('btn-history').addEventListener('click', async () => {
+ out.value = 'Loading history...';
+ const t = tokenInput.value.trim();
+ const result = await callApi('/api/admin/audits', 'GET', t || null);
+ out.value = JSON.stringify(result, null, 2);
+});
diff --git a/server.js b/server.js
index 49089b3..a8ad0c3 100644
--- a/server.js
+++ b/server.js
@@ -543,14 +543,14 @@ createCrudRoutes({
title: body.title,
imageUrl: body.imageUrl,
category: body.category,
- likeCount: body.likeCount || 0,
+ likeCount: 0,
likedBy: []
}),
- buildUpdate: (body) => {
- const data = { title: body.title, imageUrl: body.imageUrl, category: body.category };
- if (body.likeCount !== undefined) data.likeCount = body.likeCount;
- return data;
- },
+ buildUpdate: (body) => ({
+ title: body.title,
+ imageUrl: body.imageUrl,
+ category: body.category
+ }),
extraRoutes: (app, basePath, collection, label, col) => {
addLikeRoute(app, basePath, collection, label, col);
}
@@ -680,6 +680,125 @@ app.delete('/posts/:postId/comments/:id', verifyToken, async (req, res) => {
}
});
+// -------------------- Admin Utilities: Recompute & Audit --------------------
+
+async function recomputeLikeCountsForCollections(collectionNames) {
+ const summary = {};
+ for (const name of collectionNames) {
+ const col = db.collection(name);
+ const cursor = col.find();
+ let matched = 0, modified = 0, anomalies = 0;
+ const bulk = col.initializeUnorderedBulkOp();
+
+ while (await cursor.hasNext()) {
+ const doc = await cursor.next();
+ const likedBy = Array.isArray(doc.likedBy) ? doc.likedBy : [];
+ const desired = likedBy.length;
+ const current = (typeof doc.likeCount === 'number' && Number.isFinite(doc.likeCount)) ? doc.likeCount : null;
+
+ // Consider as anomaly if likeCount differs from desired, is missing, negative, non-integer, or extremely large
+ const isAnomaly = current !== desired || current === null || current < 0 || !Number.isInteger(current) || Math.abs(current) > 1e12;
+ if (isAnomaly) {
+ anomalies++;
+ matched++;
+ bulk.find({ _id: doc._id }).updateOne({ $set: { likedBy: likedBy, likeCount: desired } });
+ }
+ }
+
+ if (matched > 0) {
+ const result = await bulk.execute();
+ modified = result.nModified || result.nModified === undefined ? (result.nModified || Object.values(result).reduce((s, v) => s + (v.nModified || 0), 0)) : result.nModified;
+ }
+
+ summary[name] = { checked: true, matched, modified, anomalies };
+ }
+ return summary;
+}
+
+async function auditIndexes() {
+ const info = {};
+ const collections = await db.listCollections().toArray();
+ for (const c of collections) {
+ try {
+ const idx = await db.collection(c.name).indexes();
+ info[c.name] = idx;
+ } catch (err) {
+ info[c.name] = { error: err.message };
+ }
+ }
+ return info;
+}
+
+/**
+ * @swagger
+ * /api/admin/recompute-like-counts:
+ * post:
+ * summary: Recompute like counts across collections (Admin only)
+ * tags: [Admin]
+ * security: [{ bearerAuth: [] }]
+ */
+app.post('/api/admin/recompute-like-counts', verifyToken, async (req, res) => {
+ try {
+ if (!isAdmin(req)) return sendError(res, HTTP_STATUS.FORBIDDEN, 'Only admins can perform this action');
+
+ // Collections to target — only those that may carry likeCount/likedBy fields
+ const targets = ['posts', 'designItems', 'staticBlogItems', 'achievers'];
+ const result = await recomputeLikeCountsForCollections(targets);
+ console.log('🛠️ Admin recompute performed by', req.user.username);
+
+ // Persist audit record for history and UI
+ try {
+ const auditsCol = db.collection('adminAudits');
+ await auditsCol.insertOne({
+ action: 'recompute-like-counts',
+ user: req.user.username,
+ timestamp: new Date(),
+ result
+ });
+ } catch (err) {
+ console.error('❌ Failed to persist admin audit record:', err.message);
+ }
+
+ sendOk(res, HTTP_STATUS.OK, { result });
+ } catch (err) {
+ console.error('❌ Admin recompute error:', err);
+ sendError(res, HTTP_STATUS.INTERNAL_SERVER_ERROR, 'Server error occurred');
+ }
+});
+
+/**
+ * @swagger
+ * /api/admin/audit-indexes:
+ * get:
+ * summary: Audit DB indexes across collections (Admin only)
+ * tags: [Admin]
+ * security: [{ bearerAuth: [] }]
+ */
+app.get('/api/admin/audit-indexes', verifyToken, async (req, res) => {
+ try {
+ if (!isAdmin(req)) return sendError(res, HTTP_STATUS.FORBIDDEN, 'Only admins can perform this action');
+ const idx = await auditIndexes();
+ sendOk(res, HTTP_STATUS.OK, { indexes: idx });
+ } catch (err) {
+ console.error('❌ Admin audit indexes error:', err);
+ sendError(res, HTTP_STATUS.INTERNAL_SERVER_ERROR, 'Server error occurred');
+ }
+});
+
+// Return recent admin audit records
+app.get('/api/admin/audits', verifyToken, async (req, res) => {
+ try {
+ if (!isAdmin(req)) return sendError(res, HTTP_STATUS.FORBIDDEN, 'Only admins can perform this action');
+ const col = db.collection('adminAudits');
+ const rows = await col.find().sort({ timestamp: -1 }).limit(100).toArray();
+ sendOk(res, HTTP_STATUS.OK, { audits: rows });
+ } catch (err) {
+ console.error('❌ Error fetching admin audits:', err);
+ sendError(res, HTTP_STATUS.INTERNAL_SERVER_ERROR, 'Server error occurred');
+ }
+});
+
+
// ==================== Authentication ====================
/**
diff --git a/tools/adminAudit.js b/tools/adminAudit.js
new file mode 100644
index 0000000..e6bb099
--- /dev/null
+++ b/tools/adminAudit.js
@@ -0,0 +1,77 @@
+require('dotenv').config();
+
+const { MongoClient } = require('mongodb');
+
+const uri = process.env.MONGO_URI || 'mongodb://localhost:27017/blogDB';
+const DB_NAME = process.env.DB_NAME || 'blogDB';
+
+async function recomputeLikeCountsForCollections(db, collectionNames) {
+ const summary = {};
+ for (const name of collectionNames) {
+ const col = db.collection(name);
+ const cursor = col.find();
+ let matched = 0, modified = 0, anomalies = 0;
+ const bulk = col.initializeUnorderedBulkOp();
+
+ while (await cursor.hasNext()) {
+ const doc = await cursor.next();
+ const likedBy = Array.isArray(doc.likedBy) ? doc.likedBy : [];
+ const desired = likedBy.length;
+ const current = (typeof doc.likeCount === 'number' && Number.isFinite(doc.likeCount)) ? doc.likeCount : null;
+
+ const isAnomaly = current !== desired || current === null || current < 0 || !Number.isInteger(current) || Math.abs(current) > 1e12;
+ if (isAnomaly) {
+ anomalies++;
+ matched++;
+ bulk.find({ _id: doc._id }).updateOne({ $set: { likedBy: likedBy, likeCount: desired } });
+ }
+ }
+
+ if (matched > 0) {
+ const result = await bulk.execute();
+ modified = result.nModified || result.nModified === undefined ? (result.nModified || Object.values(result).reduce((s, v) => s + (v.nModified || 0), 0)) : result.nModified;
+ }
+
+ summary[name] = { checked: true, matched, modified, anomalies };
+ }
+ return summary;
+}
+
+async function auditIndexes(db) {
+ const info = {};
+ const collections = await db.listCollections().toArray();
+ for (const c of collections) {
+ try {
+ const idx = await db.collection(c.name).indexes();
+ info[c.name] = idx;
+ } catch (err) {
+ info[c.name] = { error: err.message };
+ }
+ }
+ return info;
+}
+
+async function main() {
+ const client = new MongoClient(uri);
+ try {
+ await client.connect();
+ const db = client.db(DB_NAME);
+ console.log('Connected to', uri, 'db:', DB_NAME);
+
+ const targets = ['posts', 'designItems', 'staticBlogItems', 'achievers'];
+ console.log('Recomputing like counts for:', targets.join(', '));
+ const recompute = await recomputeLikeCountsForCollections(db, targets);
+ console.log('Recompute result:', JSON.stringify(recompute, null, 2));
+
+ console.log('\nAuditing indexes...');
+ const idx = await auditIndexes(db);
+ console.log('Indexes summary:', JSON.stringify(idx, null, 2));
+
+ } catch (err) {
+ console.error('Error running admin audit:', err);
+ } finally {
+ await client.close();
+ }
+}
+
+if (require.main === module) main();
diff --git a/tools/adminForceRecompute.js b/tools/adminForceRecompute.js
new file mode 100644
index 0000000..4e4b8fd
--- /dev/null
+++ b/tools/adminForceRecompute.js
@@ -0,0 +1,58 @@
+require('dotenv').config();
+const { MongoClient } = require('mongodb');
+
+const uri = process.env.MONGO_URI || 'mongodb://localhost:27017/blogDB';
+const DB_NAME = process.env.DB_NAME || 'blogDB';
+
+async function forceRecompute(db, name) {
+ const col = db.collection(name);
+
+ // Build filter: documents where likeCount != size(likedBy) OR likeCount missing/invalid
+ const filter = {
+ $expr: {
+ $ne: [
+ { $ifNull: ["$likeCount", null] },
+ { $size: { $ifNull: ["$likedBy", []] } }
+ ]
+ }
+ };
+
+ // Update using aggregation pipeline to set likeCount to size(likedBy)
+ const update = [
+ {
+ $set: {
+ likedBy: { $ifNull: ["$likedBy", []] },
+ likeCount: { $size: { $ifNull: ["$likedBy", []] } }
+ }
+ }
+ ];
+
+ const result = await col.updateMany(filter, update);
+ return { matched: result.matchedCount, modified: result.modifiedCount };
+}
+
+async function main() {
+ const client = new MongoClient(uri);
+ try {
+ await client.connect();
+ const db = client.db(DB_NAME);
+ console.log('Connected to', uri, 'db:', DB_NAME);
+
+ const targets = ['posts', 'designItems', 'staticBlogItems', 'achievers'];
+ const summary = {};
+ for (const t of targets) {
+ console.log('Processing', t);
+ const res = await forceRecompute(db, t);
+ summary[t] = res;
+ console.log(` -> matched=${res.matched}, modified=${res.modified}`);
+ }
+
+ console.log('\nForce recompute summary:', JSON.stringify(summary, null, 2));
+ } catch (err) {
+ console.error('Error:', err);
+ } finally {
+ await client.close();
+ }
+}
+
+if (require.main === module) main();