From 3b076c4ff0fe5006b4f1bacc32c41c92e8db8bf8 Mon Sep 17 00:00:00 2001 From: mohamed mahmoud Date: Fri, 24 Apr 2026 09:08:16 +0300 Subject: [PATCH 1/2] add a isdraft field to particuler schemas and create a geniric check to manage outdoing data and who has a permissions (LOGIC) --- .migration_draft_done | 1 + config/middlewares.js | 1 + .../article/content-types/article/schema.json | 6 +- src/api/blog/content-types/blog/schema.json | 6 +- .../course/content-types/course/schema.json | 6 +- src/api/event/content-types/event/schema.json | 6 +- .../lesson/content-types/lesson/schema.json | 6 +- .../problem/content-types/problem/schema.json | 11 ++- .../roadmap/content-types/roadmap/schema.json | 6 +- src/api/week/content-types/week/schema.json | 6 +- src/index.js | 43 +++++++++ src/middlewares/draft-visibility.js | 90 +++++++++++++++++++ types/generated/contentTypes.d.ts | 28 ++++-- 13 files changed, 200 insertions(+), 16 deletions(-) create mode 100644 .migration_draft_done create mode 100644 src/middlewares/draft-visibility.js diff --git a/.migration_draft_done b/.migration_draft_done new file mode 100644 index 0000000..8ae19a7 --- /dev/null +++ b/.migration_draft_done @@ -0,0 +1 @@ +2026-04-23T04:25:20.786Z \ No newline at end of file diff --git a/config/middlewares.js b/config/middlewares.js index b795446..3453890 100644 --- a/config/middlewares.js +++ b/config/middlewares.js @@ -57,6 +57,7 @@ module.exports = ({ env }) => { { name: "global::jwt-cookie" }, "strapi::poweredBy", "strapi::query", + { name: "global::draft-visibility" }, "strapi::body", { name: "strapi::session", diff --git a/src/api/article/content-types/article/schema.json b/src/api/article/content-types/article/schema.json index 9b95f26..909be7c 100644 --- a/src/api/article/content-types/article/schema.json +++ b/src/api/article/content-types/article/schema.json @@ -8,7 +8,7 @@ "description": "Articles with dynamic content blocks (text, image, diagram)" }, "options": { - "draftAndPublish": true + "draftAndPublish": false }, "pluginOptions": {}, "attributes": { @@ -31,6 +31,10 @@ "engagement_score": { "type": "integer", "default": 0 + }, + "isDraft": { + "type": "boolean", + "default": true } } } \ No newline at end of file diff --git a/src/api/blog/content-types/blog/schema.json b/src/api/blog/content-types/blog/schema.json index c4d0f42..be86b86 100644 --- a/src/api/blog/content-types/blog/schema.json +++ b/src/api/blog/content-types/blog/schema.json @@ -8,7 +8,7 @@ "description": "Blog posts with rich text content and images" }, "options": { - "draftAndPublish": true + "draftAndPublish": false }, "pluginOptions": {}, "attributes": { @@ -35,6 +35,10 @@ "engagement_score": { "type": "integer", "default": 0 + }, + "isDraft": { + "type": "boolean", + "default": true } } } \ No newline at end of file diff --git a/src/api/course/content-types/course/schema.json b/src/api/course/content-types/course/schema.json index af8f2a3..66f1a03 100644 --- a/src/api/course/content-types/course/schema.json +++ b/src/api/course/content-types/course/schema.json @@ -8,7 +8,7 @@ "description": "" }, "options": { - "draftAndPublish": true + "draftAndPublish": false }, "pluginOptions": {}, "attributes": { @@ -64,6 +64,10 @@ "engagement_score": { "type": "integer", "default": 0 + }, + "isDraft": { + "type": "boolean", + "default": true } } } diff --git a/src/api/event/content-types/event/schema.json b/src/api/event/content-types/event/schema.json index 4cc5c66..4876152 100644 --- a/src/api/event/content-types/event/schema.json +++ b/src/api/event/content-types/event/schema.json @@ -7,7 +7,7 @@ "displayName": "event" }, "options": { - "draftAndPublish": true + "draftAndPublish": false }, "pluginOptions": {}, "attributes": { @@ -71,6 +71,10 @@ "engagement_score": { "type": "integer", "default": 0 + }, + "isDraft": { + "type": "boolean", + "default": true } } } diff --git a/src/api/lesson/content-types/lesson/schema.json b/src/api/lesson/content-types/lesson/schema.json index 0c04afb..8b1c424 100644 --- a/src/api/lesson/content-types/lesson/schema.json +++ b/src/api/lesson/content-types/lesson/schema.json @@ -8,10 +8,14 @@ "description": "" }, "options": { - "draftAndPublish": true + "draftAndPublish": false }, "pluginOptions": {}, "attributes": { + "isDraft": { + "type": "boolean", + "default": true + }, "duration": { "type": "integer", "default": 0 diff --git a/src/api/problem/content-types/problem/schema.json b/src/api/problem/content-types/problem/schema.json index f287762..9c9a0cd 100644 --- a/src/api/problem/content-types/problem/schema.json +++ b/src/api/problem/content-types/problem/schema.json @@ -8,7 +8,7 @@ "description": "Algorithm problems for practice" }, "options": { - "draftAndPublish": true + "draftAndPublish": false }, "pluginOptions": {}, "attributes": { @@ -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" } } } \ No newline at end of file diff --git a/src/api/roadmap/content-types/roadmap/schema.json b/src/api/roadmap/content-types/roadmap/schema.json index 0fa0abd..5eead7f 100644 --- a/src/api/roadmap/content-types/roadmap/schema.json +++ b/src/api/roadmap/content-types/roadmap/schema.json @@ -7,7 +7,7 @@ "displayName": "Roadmap" }, "options": { - "draftAndPublish": true + "draftAndPublish": false }, "pluginOptions": {}, "attributes": { @@ -36,6 +36,10 @@ "type": "relation", "relation": "manyToOne", "target": "plugin::users-permissions.user" + }, + "isDraft": { + "type": "boolean", + "default": true } } } \ No newline at end of file diff --git a/src/api/week/content-types/week/schema.json b/src/api/week/content-types/week/schema.json index 37908e6..b7d6738 100644 --- a/src/api/week/content-types/week/schema.json +++ b/src/api/week/content-types/week/schema.json @@ -7,10 +7,14 @@ "displayName": "week" }, "options": { - "draftAndPublish": true + "draftAndPublish": false }, "pluginOptions": {}, "attributes": { + "isDraft": { + "type": "boolean", + "default": true + }, "lessons": { "type": "relation", "relation": "oneToMany", diff --git a/src/index.js b/src/index.js index dd4e623..b9f520a 100644 --- a/src/index.js +++ b/src/index.js @@ -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}`); + } + })(); }, }; diff --git a/src/middlewares/draft-visibility.js b/src/middlewares/draft-visibility.js new file mode 100644 index 0000000..3847420 --- /dev/null +++ b/src/middlewares/draft-visibility.js @@ -0,0 +1,90 @@ +'use strict'; + +/** + * Global Draft Visibility Middleware + * Ensures content marked with isDraft: true is only returned if the requester is the publisher/owner. + */ + +module.exports = (config, { strapi }) => { + return async (ctx, next) => { + // Only apply to GET requests for our content types + const path = ctx.fullPath || ctx.path; + const isApiRequest = path.startsWith('/api/'); + + // We only care about GET (find/findOne) requests to our core APIs + if (!isApiRequest || ctx.method !== 'GET') { + return await next(); + } + + // Extract pluralName from path (e.g., /api/courses/:id?populate=* -> courses) + const segments = path.split('/').filter(Boolean); // [api, courses, id] + const pluralName = segments[1]; + + // Find the corresponding UID by pluralName + const contentType = Object.values(strapi.contentTypes).find( + (ct) => ct.info.pluralName === pluralName && ct.uid.startsWith('api::') + ); + + if (!contentType) { + return await next(); + } + + const uid = contentType.uid; + + // Map of UIDs to their owner relation field names + const ownerFields = { + 'api::course.course': 'users_permissions_user', + 'api::article.article': 'author', + 'api::blog.blog': 'publisher', + 'api::problem.problem': 'publisher', + 'api::roadmap.roadmap': 'author', + 'api::event.event': 'users_permissions_user', + 'api::lesson.lesson': 'users_permissions_user', + 'api::week.week': 'users_permissions_user', + }; + + const ownerField = ownerFields[uid]; + if (!ownerField) { + return await next(); + } + + // Capture the current user from state (populated by auth middleware) + const user = ctx.state.user; + + /** + * Logic: + * - Show if isDraft is false (or not set, though we have default: true now) + * - OR Show if isDraft is true AND (User is Authenticated AND User.id matches Owner.id) + */ + const draftFilter = { + $or: [ + { isDraft: { $eq: false } }, + user ? { + $and: [ + { isDraft: { $eq: true } }, + { [ownerField]: { id: { $eq: user.id } } } + ] + } : null + ].filter(Boolean) + }; + + // Inject filter into query + if (!ctx.query.filters) { + ctx.query.filters = {}; + } + + // Apply the filter using $and to preserve any user-provided filters + if (Object.keys(ctx.query.filters).length > 0) { + ctx.query.filters = { + $and: [ + ctx.query.filters, + draftFilter + ] + }; + } else { + ctx.query.filters = draftFilter; + } + + await next(); + }; +}; diff --git a/types/generated/contentTypes.d.ts b/types/generated/contentTypes.d.ts index 084454b..fad2f54 100644 --- a/types/generated/contentTypes.d.ts +++ b/types/generated/contentTypes.d.ts @@ -484,7 +484,7 @@ export interface ApiArticleArticle extends Struct.CollectionTypeSchema { singularName: 'article'; }; options: { - draftAndPublish: true; + draftAndPublish: false; }; attributes: { author: Schema.Attribute.Relation< @@ -496,6 +496,7 @@ export interface ApiArticleArticle extends Struct.CollectionTypeSchema { createdBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> & Schema.Attribute.Private; engagement_score: Schema.Attribute.Integer & Schema.Attribute.DefaultTo<0>; + isDraft: Schema.Attribute.Boolean & Schema.Attribute.DefaultTo; locale: Schema.Attribute.String & Schema.Attribute.Private; localizations: Schema.Attribute.Relation< 'oneToMany', @@ -520,7 +521,7 @@ export interface ApiBlogBlog extends Struct.CollectionTypeSchema { singularName: 'blog'; }; options: { - draftAndPublish: true; + draftAndPublish: false; }; attributes: { createdAt: Schema.Attribute.DateTime; @@ -529,6 +530,7 @@ export interface ApiBlogBlog extends Struct.CollectionTypeSchema { description: Schema.Attribute.Blocks & Schema.Attribute.Required; engagement_score: Schema.Attribute.Integer & Schema.Attribute.DefaultTo<0>; image: Schema.Attribute.Media<'images'>; + isDraft: Schema.Attribute.Boolean & Schema.Attribute.DefaultTo; locale: Schema.Attribute.String & Schema.Attribute.Private; localizations: Schema.Attribute.Relation<'oneToMany', 'api::blog.blog'> & Schema.Attribute.Private; @@ -666,7 +668,7 @@ export interface ApiCourseCourse extends Struct.CollectionTypeSchema { singularName: 'course'; }; options: { - draftAndPublish: true; + draftAndPublish: false; }; attributes: { course_types: Schema.Attribute.Relation< @@ -679,6 +681,7 @@ export interface ApiCourseCourse extends Struct.CollectionTypeSchema { description: Schema.Attribute.Blocks; difficulty: Schema.Attribute.Enumeration<['Easy', 'Medium', 'Advanced']>; engagement_score: Schema.Attribute.Integer & Schema.Attribute.DefaultTo<0>; + isDraft: Schema.Attribute.Boolean & Schema.Attribute.DefaultTo; locale: Schema.Attribute.String & Schema.Attribute.Private; localizations: Schema.Attribute.Relation< 'oneToMany', @@ -819,7 +822,7 @@ export interface ApiEventEvent extends Struct.CollectionTypeSchema { singularName: 'event'; }; options: { - draftAndPublish: true; + draftAndPublish: false; }; attributes: { createdAt: Schema.Attribute.DateTime; @@ -837,6 +840,7 @@ export interface ApiEventEvent extends Struct.CollectionTypeSchema { 'images' | 'files' | 'videos' | 'audios', true >; + isDraft: Schema.Attribute.Boolean & Schema.Attribute.DefaultTo; locale: Schema.Attribute.String & Schema.Attribute.Private; localizations: Schema.Attribute.Relation<'oneToMany', 'api::event.event'> & Schema.Attribute.Private; @@ -953,7 +957,7 @@ export interface ApiLessonLesson extends Struct.CollectionTypeSchema { singularName: 'lesson'; }; options: { - draftAndPublish: true; + draftAndPublish: false; }; attributes: { course_types: Schema.Attribute.Relation< @@ -966,6 +970,7 @@ export interface ApiLessonLesson extends Struct.CollectionTypeSchema { description: Schema.Attribute.Blocks; duration: Schema.Attribute.Integer & Schema.Attribute.DefaultTo<0>; isCompleted: Schema.Attribute.Boolean & Schema.Attribute.DefaultTo; + isDraft: Schema.Attribute.Boolean & Schema.Attribute.DefaultTo; locale: Schema.Attribute.String & Schema.Attribute.Private; localizations: Schema.Attribute.Relation< 'oneToMany', @@ -1155,7 +1160,7 @@ export interface ApiProblemProblem extends Struct.CollectionTypeSchema { singularName: 'problem'; }; options: { - draftAndPublish: true; + draftAndPublish: false; }; attributes: { code_templates: Schema.Attribute.Relation< @@ -1176,6 +1181,7 @@ export interface ApiProblemProblem extends Struct.CollectionTypeSchema { functionName: Schema.Attribute.String & Schema.Attribute.Required; functionParams: Schema.Attribute.JSON; hints: Schema.Attribute.JSON; + isDraft: Schema.Attribute.Boolean & Schema.Attribute.DefaultTo; locale: Schema.Attribute.String & Schema.Attribute.Private; localizations: Schema.Attribute.Relation< 'oneToMany', @@ -1189,6 +1195,10 @@ export interface ApiProblemProblem extends Struct.CollectionTypeSchema { >; public: Schema.Attribute.Boolean & Schema.Attribute.DefaultTo; publishedAt: Schema.Attribute.DateTime; + publisher: Schema.Attribute.Relation< + 'manyToOne', + 'plugin::users-permissions.user' + >; returnType: Schema.Attribute.String; slug: Schema.Attribute.UID<'title'>; submissions: Schema.Attribute.Relation< @@ -1405,7 +1415,7 @@ export interface ApiRoadmapRoadmap extends Struct.CollectionTypeSchema { singularName: 'roadmap'; }; options: { - draftAndPublish: true; + draftAndPublish: false; }; attributes: { author: Schema.Attribute.Relation< @@ -1419,6 +1429,7 @@ export interface ApiRoadmapRoadmap extends Struct.CollectionTypeSchema { description: Schema.Attribute.Blocks; flowData: Schema.Attribute.JSON; icon: Schema.Attribute.String & Schema.Attribute.DefaultTo<'faMapSigns'>; + isDraft: Schema.Attribute.Boolean & Schema.Attribute.DefaultTo; locale: Schema.Attribute.String & Schema.Attribute.Private; localizations: Schema.Attribute.Relation< 'oneToMany', @@ -1732,13 +1743,14 @@ export interface ApiWeekWeek extends Struct.CollectionTypeSchema { singularName: 'week'; }; options: { - draftAndPublish: true; + draftAndPublish: false; }; attributes: { course: Schema.Attribute.Relation<'manyToOne', 'api::course.course'>; createdAt: Schema.Attribute.DateTime; createdBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> & Schema.Attribute.Private; + isDraft: Schema.Attribute.Boolean & Schema.Attribute.DefaultTo; lessons: Schema.Attribute.Relation<'oneToMany', 'api::lesson.lesson'>; locale: Schema.Attribute.String & Schema.Attribute.Private; localizations: Schema.Attribute.Relation<'oneToMany', 'api::week.week'> & From dee18d94ab8392549bb7f5f0232c6ecfe09247fd Mon Sep 17 00:00:00 2001 From: mohamed mahmoud Date: Sat, 25 Apr 2026 09:23:39 +0300 Subject: [PATCH 2/2] add analytics feature in minimum vable version of it --- .../controllers/cms-analytics.js | 41 +++++++++ src/api/cms-analytics/routes/cms-analytics.js | 14 ++++ .../cms-analytics/services/cms-analytics.js | 84 +++++++++++++++++++ 3 files changed, 139 insertions(+) create mode 100644 src/api/cms-analytics/controllers/cms-analytics.js create mode 100644 src/api/cms-analytics/routes/cms-analytics.js create mode 100644 src/api/cms-analytics/services/cms-analytics.js diff --git a/src/api/cms-analytics/controllers/cms-analytics.js b/src/api/cms-analytics/controllers/cms-analytics.js new file mode 100644 index 0000000..eee2747 --- /dev/null +++ b/src/api/cms-analytics/controllers/cms-analytics.js @@ -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 }); + } + } +}; diff --git a/src/api/cms-analytics/routes/cms-analytics.js b/src/api/cms-analytics/routes/cms-analytics.js new file mode 100644 index 0000000..9334b84 --- /dev/null +++ b/src/api/cms-analytics/routes/cms-analytics.js @@ -0,0 +1,14 @@ +module.exports = { + routes: [ + { + method: 'GET', + path: '/cms-analytics', + handler: 'cms-analytics.fetchAll', + config: { + auth: false, + policies: [], + middlewares: [], + }, + }, + ], +}; diff --git a/src/api/cms-analytics/services/cms-analytics.js b/src/api/cms-analytics/services/cms-analytics.js new file mode 100644 index 0000000..b508707 --- /dev/null +++ b/src/api/cms-analytics/services/cms-analytics.js @@ -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 }; + } +};