Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions .migration_draft_done
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
2026-04-23T04:25:20.786Z
1 change: 1 addition & 0 deletions config/middlewares.js
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ module.exports = ({ env }) => {
{ name: "global::jwt-cookie" },
"strapi::poweredBy",
"strapi::query",
{ name: "global::draft-visibility" },
"strapi::body",
{
name: "strapi::session",
Expand Down
6 changes: 5 additions & 1 deletion src/api/article/content-types/article/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
"description": "Articles with dynamic content blocks (text, image, diagram)"
},
"options": {
"draftAndPublish": true
"draftAndPublish": false
},
"pluginOptions": {},
"attributes": {
Expand All @@ -31,6 +31,10 @@
"engagement_score": {
"type": "integer",
"default": 0
},
"isDraft": {
"type": "boolean",
"default": true
}
}
}
6 changes: 5 additions & 1 deletion src/api/blog/content-types/blog/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
"description": "Blog posts with rich text content and images"
},
"options": {
"draftAndPublish": true
"draftAndPublish": false
},
"pluginOptions": {},
"attributes": {
Expand All @@ -35,6 +35,10 @@
"engagement_score": {
"type": "integer",
"default": 0
},
"isDraft": {
"type": "boolean",
"default": true
}
}
}
41 changes: 41 additions & 0 deletions src/api/cms-analytics/controllers/cms-analytics.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
'use strict';

/**
* Controller: cms-analytics
* Orchestrates the collection of symmetrical metrics for the Insight Hub.
*/
module.exports = {
async fetchAll(ctx) {
try {
const service = strapi.service('api::cms-analytics.cms-analytics');

// Dynamic time range from query (default to 60 days)
const days = parseInt(ctx.query.days) || 60;

const [users, courses, events, reports, authors, organizers] = await Promise.all([
service.getResourceMetrics('plugin::users-permissions.user', days),
service.getResourceMetrics('api::course.course', days),
service.getResourceMetrics('api::event.event', days),
service.getResourceMetrics('api::report.report', days, { review_status: 'pending' }),
service.getContributorMetrics('api::course.course', 'users_permissions_user', days),
service.getContributorMetrics('api::event.event', 'users_permissions_user', days),
]);

ctx.body = {
data: {
users,
courses,
events,
reports,
contributors: {
authors,
organizers,
},
},
};
} catch (err) {
strapi.log.error('[Analytics] Fetch Error:', err);
ctx.badRequest('Analytics fetch failed', { error: err.message });
}
}
};
14 changes: 14 additions & 0 deletions src/api/cms-analytics/routes/cms-analytics.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
module.exports = {
routes: [
{
method: 'GET',
path: '/cms-analytics',
handler: 'cms-analytics.fetchAll',
config: {
auth: false,
policies: [],
middlewares: [],
},
},
],
};
84 changes: 84 additions & 0 deletions src/api/cms-analytics/services/cms-analytics.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
'use strict';

/**
* Service: cms-analytics
* Provides granular, time-series metrics for the Insight Hub.
*/
module.exports = {
/**
* Helper to generate a zero-filled timeline for a collection
*/
async getTimeline(uid, days = 60, dateField = 'createdAt') {
const startDate = new Date();
startDate.setDate(startDate.getDate() - days);

const items = await strapi.db.query(uid).findMany({
where: { [dateField]: { $gte: startDate } },
select: [dateField, 'id'],
});

const countsByDate = items.reduce((acc, item) => {
const dateString = new Date(item[dateField]).toISOString().split('T')[0];
acc[dateString] = (acc[dateString] || 0) + 1;
return acc;
}, {});

const timeline = [];
for (let i = days - 1; i >= 0; i--) {
const d = new Date();
d.setDate(d.getDate() - i);
const dayStr = d.toISOString().split('T')[0];
timeline.push({ time: dayStr, count: countsByDate[dayStr] || 0 });
}
return timeline;
},

/**
* Fetches total counts and daily timeline for a resource
*/
async getResourceMetrics(uid, days = 60, extraFilter = {}) {
const [total, timeline] = await Promise.all([
strapi.db.query(uid).count({ where: extraFilter }),
this.getTimeline(uid, days),
]);
return { total, timeline };
},

/**
* Generates a timeline of NEW unique contributors per day
*/
async getContributorMetrics(uid, relationField, days = 60) {
const items = await strapi.db.query(uid).findMany({
populate: [relationField],
select: ['id', 'createdAt'],
});

const firstContribMap = {};
items.forEach(item => {
const userId = item[relationField]?.id;
if (!userId) return;
const date = new Date(item.createdAt).toISOString().split('T')[0];
// Store the EARLIEST date this user contributed
if (!firstContribMap[userId] || date < firstContribMap[userId]) {
firstContribMap[userId] = date;
}
});

const countsByDate = Object.values(firstContribMap).reduce((acc, date) => {
acc[date] = (acc[date] || 0) + 1;
return acc;
}, {});

const timeline = [];
for (let i = days - 1; i >= 0; i--) {
const d = new Date();
d.setDate(d.getDate() - i);
const dayStr = d.toISOString().split('T')[0];
timeline.push({ time: dayStr, count: countsByDate[dayStr] || 0 });
}

const totalUnique = Object.keys(firstContribMap).length;

return { total: totalUnique, timeline };
}
};
6 changes: 5 additions & 1 deletion src/api/course/content-types/course/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
"description": ""
},
"options": {
"draftAndPublish": true
"draftAndPublish": false
},
"pluginOptions": {},
"attributes": {
Expand Down Expand Up @@ -64,6 +64,10 @@
"engagement_score": {
"type": "integer",
"default": 0
},
"isDraft": {
"type": "boolean",
"default": true
}
}
}
6 changes: 5 additions & 1 deletion src/api/event/content-types/event/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"displayName": "event"
},
"options": {
"draftAndPublish": true
"draftAndPublish": false
},
"pluginOptions": {},
"attributes": {
Expand Down Expand Up @@ -71,6 +71,10 @@
"engagement_score": {
"type": "integer",
"default": 0
},
"isDraft": {
"type": "boolean",
"default": true
}
}
}
6 changes: 5 additions & 1 deletion src/api/lesson/content-types/lesson/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,14 @@
"description": ""
},
"options": {
"draftAndPublish": true
"draftAndPublish": false
},
"pluginOptions": {},
"attributes": {
"isDraft": {
"type": "boolean",
"default": true
},
"duration": {
"type": "integer",
"default": 0
Expand Down
11 changes: 10 additions & 1 deletion src/api/problem/content-types/problem/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
"description": "Algorithm problems for practice"
},
"options": {
"draftAndPublish": true
"draftAndPublish": false
},
"pluginOptions": {},
"attributes": {
Expand Down Expand Up @@ -99,6 +99,15 @@
"engagement_score": {
"type": "integer",
"default": 0
},
"isDraft": {
"type": "boolean",
"default": true
},
"publisher": {
"type": "relation",
"relation": "manyToOne",
"target": "plugin::users-permissions.user"
}
}
}
6 changes: 5 additions & 1 deletion src/api/roadmap/content-types/roadmap/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"displayName": "Roadmap"
},
"options": {
"draftAndPublish": true
"draftAndPublish": false
},
"pluginOptions": {},
"attributes": {
Expand Down Expand Up @@ -36,6 +36,10 @@
"type": "relation",
"relation": "manyToOne",
"target": "plugin::users-permissions.user"
},
"isDraft": {
"type": "boolean",
"default": true
}
}
}
6 changes: 5 additions & 1 deletion src/api/week/content-types/week/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,14 @@
"displayName": "week"
},
"options": {
"draftAndPublish": true
"draftAndPublish": false
},
"pluginOptions": {},
"attributes": {
"isDraft": {
"type": "boolean",
"default": true
},
"lessons": {
"type": "relation",
"relation": "oneToMany",
Expand Down
43 changes: 43 additions & 0 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -129,5 +129,48 @@ module.exports = {
strapi.service('api::notification.notification-socket').initialize(io);

strapi.log.info('[Socket.io] WebSocket server initialized (submission + notification)');

// ── Layer 7: isDraft Migration ──
// This script ensures legacy records are marked as NOT drafts (isDraft: false)
// to maintain visibility after disabling draftAndPublish.
(async () => {
try {
const fs = require('fs');
const path = require('path');
const lockFile = path.join(process.cwd(), '.migration_draft_done');

if (fs.existsSync(lockFile)) return;

const entities = [
'api::article.article',
'api::blog.blog',
'api::course.course',
'api::event.event',
'api::lesson.lesson',
'api::problem.problem',
'api::roadmap.roadmap',
'api::week.week',
];

strapi.log.info('[Migration] Starting isDraft migration for existing records...');

for (const uid of entities) {
try {
const result = await strapi.db.query(uid).updateMany({
where: { isDraft: null },
data: { isDraft: false },
});
strapi.log.info(`[Migration] Updated ${result?.count || 0} records for ${uid}`);
} catch (err) {
strapi.log.error(`[Migration] Failed to migrate ${uid}: ${err.message}`);
}
}

fs.writeFileSync(lockFile, new Date().toISOString());
strapi.log.info('[Migration] isDraft migration completed manually via lock file.');
} catch (globalErr) {
strapi.log.error(`[Migration] Global error in Draft Migration: ${globalErr.message}`);
}
})();
},
};
Loading
Loading