news-cms-module 0.1.2 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/routes/index.js CHANGED
@@ -1,7 +1,14 @@
1
1
  const express = require('express');
2
+ const multer = require('multer');
3
+ const path = require('path');
4
+ const upload = require('../middlewares/multerMiddleware');
5
+ const {CreateNewsValidationRules, UpdateNewsValidationRules} = require('../validations/newsValidations');
6
+ const {validate} = require('../validations/mainValidation');
7
+ const {parseContentBlocks} = require('../middlewares/parseForm');
8
+ const { isAdmin } = require('../middlewares/authAdminMiddleware');
9
+
2
10
  const NewsController = require('../controllers/NewsController');
3
11
  const StatController = require('../controllers/StatController');
4
- const path = require('path');
5
12
 
6
13
  /**
7
14
  * Setup semua route untuk package.
@@ -12,7 +19,11 @@ const path = require('path');
12
19
  module.exports = (router, services, config) => {
13
20
  // Inisialisasi Controllers dengan services dan config yang dibutuhkan
14
21
  const newsController = new NewsController(services.news, services.stat, config);
15
- const statController = new StatController(services.stat);
22
+ const statController = new StatController(services.news, services.stat);
23
+ const beritaUpload = upload.fields([
24
+ { name: 'thumbnailImage', maxCount: 1 },
25
+ { name: 'contentImages', maxCount: 10 }
26
+ ]);
16
27
 
17
28
  // 1. Ekspos Aset Statis (CSS)
18
29
  // Misalnya, package diakses di /berita-kami, maka aset diakses di /berita-kami/nc-assets
@@ -22,43 +33,45 @@ module.exports = (router, services, config) => {
22
33
  // Route ini menggunakan prefix yang ditentukan oleh pengguna (default: '/')
23
34
  // router.get(config.publicRoutePrefix, newsController.listPublic.bind(newsController));
24
35
 
25
- // 2. Route Publik (READ-ONLY API)
26
- // Gunakan endpoint yang jelas: /list, /trending, /:slug
27
36
  router.get(config.publicRoutePrefix + 'list', newsController.listPublic.bind(newsController));
28
37
 
29
- // Route detail berita, termasuk tracking middleware
30
38
  router.get(`${config.publicRoutePrefix}post/:slug`,
31
39
  statController.trackVisitMiddleware.bind(statController),
32
40
  newsController.getDetail.bind(newsController));
33
41
 
34
- // Route detail berita (termasuk tracking middleware)
35
- // PERHATIAN: Di controller, Anda harus memastikan baseUrl dikirim ke view
36
- // router.get(`${config.publicRoutePrefix}:slug`, statController.trackVisitMiddleware.bind(statController), newsController.getDetail.bind(newsController));
37
-
38
- // 3. Route Admin (CRUD)
39
- // Route ini menggunakan prefix admin yang ditentukan oleh pengguna (default: '/admin')
40
42
  const adminRouter = express.Router();
41
- // Middleware otentikasi admin bisa ditambahkan di sini (user harus menyediakannya!)
42
-
43
- // GET /admin/list (Membaca semua post, termasuk DRAFT)
44
- adminRouter.get('/list', newsController.adminList.bind(newsController));
43
+ adminRouter.use(isAdmin);
45
44
 
46
- // GET /admin/:id (Membaca detail post untuk editing)
47
- adminRouter.get('/:id', newsController.getDetail.bind(newsController));
48
-
49
- // POST /admin/create (Membuat berita baru)
50
- adminRouter.post('/create', newsController.createPost.bind(newsController));
45
+ // adminRouter.get('/create', (req, res) => {
46
+ // res.render(path.join(__dirname, '../views/admin/create_news.ejs'));
47
+ // });
48
+
49
+ router.get('/cms-admin/create', (req, res) => {
50
+ const appBaseUrl = config.baseUrl;
51
+ const newsPrefix = config.newsPrefix;
52
+ const adminPrefix = config.adminRoutePrefix;
53
+
54
+ const fullApiUrl = `${appBaseUrl}${adminPrefix}/create`;
55
+ const nextUrl = `${newsPrefix}${adminPrefix}/list`
56
+
57
+ res.render(path.join(__dirname, "../views/admin/create_news.ejs"), {
58
+ title: 'Buat Berita Baru',
59
+
60
+ apiBaseUrl: fullApiUrl,
61
+ nextUrl
62
+ });
63
+ });
64
+ adminRouter.post('/create', beritaUpload, parseContentBlocks, CreateNewsValidationRules, validate, newsController.createPost.bind(newsController));
65
+
66
+ adminRouter.get('/update/:slug', newsController.getEditForAdmin.bind(newsController));
67
+ adminRouter.patch('/update/:id', newsController.updateStatusNews.bind(newsController));
68
+ adminRouter.put('/update/:id', beritaUpload, parseContentBlocks, UpdateNewsValidationRules, validate, newsController.updatePost.bind(newsController));
51
69
 
52
- // PUT/PATCH /admin/update/:id (Memperbarui berita)
53
- adminRouter.put('/update/:id', newsController.updatePost.bind(newsController));
70
+ adminRouter.get('/dashboard', newsController.dashboardAdmin.bind(newsController));
71
+ adminRouter.get('/list', newsController.adminList.bind(newsController));
72
+ adminRouter.get('/:slug', newsController.getDetailForAdmin.bind(newsController));
54
73
 
55
- // DELETE /admin/delete/:id (Menghapus berita)
56
74
  adminRouter.delete('/delete/:id', newsController.deletePost.bind(newsController));
57
-
58
- // Pasang router admin ke prefix yang ditentukan pengguna (default: '/admin')
59
75
  router.use(config.adminRoutePrefix, adminRouter);
60
-
61
- // 4. Route API (Untuk data trending)
62
76
  router.get('/api/trending', statController.getTrendingApi.bind(statController));
63
-
64
77
  };
@@ -1,79 +1,244 @@
1
+ const { Op, fn, col, where } = require("sequelize");
2
+
1
3
  class NewsService {
2
- constructor(NewsModel, ContentNewsModel) {
4
+ constructor(NewsModel, ContentNewsModel, VisitorLogModel) {
3
5
  this.News = NewsModel;
4
6
  this.ContentNews = ContentNewsModel;
7
+ this.VisitorLog = VisitorLogModel
8
+ }
9
+
10
+ async getTrendingNews() {
11
+ try {
12
+ const last24Hours = new Date(new Date() - 24 * 60 * 60 * 1000);
13
+
14
+ const trending = await this.News.findAll({
15
+ attributes: [
16
+ 'id',
17
+ 'title',
18
+ 'slug',
19
+ 'category',
20
+ 'imagePath',
21
+ 'authorName',
22
+ 'createdAt',
23
+ [fn('COUNT', col('visits.id')), 'totalViews']
24
+ ],
25
+ include: [{
26
+ model: this.VisitorLog,
27
+ as: 'visits',
28
+ attributes: [],
29
+ where: {
30
+ visitedAt: {
31
+ [Op.gt]: last24Hours
32
+ }
33
+ },
34
+ required: true
35
+ }],
36
+ group: ['News.id'],
37
+ order: [[fn('COUNT', col('visits.id')), 'DESC']],
38
+ limit: 10,
39
+ subQuery: false
40
+ });
41
+
42
+ return trending;
43
+ } catch (error) {
44
+ console.error("Error fetching trending news:", error);
45
+ throw error;
46
+ }
5
47
  }
6
48
 
7
- // --- Operasi READ (Membaca) ---
8
- async getAllPosts(page = 1, limit = 10, status = 'PUBLISHED') {
9
- const offset = (page - 1) * limit;
10
- return this.News.findAndCountAll({
11
- where: { status },
49
+ async getUniqueCategories() {
50
+ const categories = await this.News.findAll({
51
+ attributes: [
52
+ [fn('DISTINCT', col('category')), 'category']
53
+ ],
54
+ raw: true
55
+ });
56
+ return categories.map(item => item.category).filter(Boolean);
57
+ }
58
+
59
+ async getRecommendationNews(category) {
60
+ return this.News.findAll({
61
+ where: {
62
+ category
63
+ },
64
+ limit: 5,
65
+ order: [['createdAt', 'DESC']]
66
+ })
67
+ }
68
+
69
+ //for all
70
+ async getAllPosts({ offset = 0, limit = 10, title = '', category = '', status = '' }) {
71
+ const validStatuses = ['PUBLISHED', 'ARCHIVED', 'DRAFT'];
72
+ const where = {};
73
+
74
+ if (status && validStatuses.includes(status)) {
75
+ where.status = status;
76
+ }
77
+ if (title) {
78
+ where.title = { [Op.like]: `%${title}%` };
79
+ }
80
+ if (category) {
81
+ where.category = category;
82
+ }
83
+
84
+ return await this.News.findAndCountAll({
85
+ where: where,
12
86
  limit: limit,
13
87
  offset: offset,
14
- order: [['publishedAt', 'DESC']],
88
+ order: [['createdAt', 'DESC']]
15
89
  });
16
90
  }
17
91
 
92
+ //forUser
18
93
  async getPostBySlug(slug) {
19
94
  return this.News.findOne({
20
95
  where: { slug, status: 'PUBLISHED' },
21
96
  include: [{
22
97
  model: this.ContentNews,
23
- as: 'blocks',
98
+ as: 'contentBlocks',
24
99
  order: [['order', 'ASC']]
25
100
  }]
26
101
  });
27
102
  }
28
103
 
29
- // --- Operasi CREATE & UPDATE ---
30
- async createPost(newsData, contentBlocks) {
104
+ //forAdmin
105
+
106
+ async getPostBySlugForAdmin(slug) {
107
+ return this.News.findOne({
108
+ where: { slug },
109
+ include: [{
110
+ model: this.ContentNews,
111
+ as: 'contentBlocks',
112
+ order: [['order', 'ASC']]
113
+ }]
114
+ });
115
+ }
116
+
117
+ async createPost(newsData, contentBlocks, files) {
31
118
  return this.News.sequelize.transaction(async (t) => {
119
+ const rawPath = files['thumbnailImage']?.[0]?.path;
120
+ newsData.imagePath = rawPath
121
+ ? rawPath.replace(/\\/g, '/').replace(/^public/, '')
122
+ : null;
32
123
  const newsItem = await this.News.create(newsData, { transaction: t });
33
124
 
34
- const blocks = contentBlocks.map((block, index) => ({
35
- ...block,
36
- newsId: newsItem.id,
37
- order: index + 1
38
- }));
125
+ let blocks = [];
126
+ let count = 0;
127
+ contentBlocks.forEach((element, index) => {
128
+ console.log(element);
129
+ if (element.blockType == "IMAGE") {
130
+ const rawPathE = files['contentImages']?.[count]?.path;
131
+ element.contentValue = rawPathE.replace(/\\/g, '/').replace(/^public/, '');
132
+ count++;
133
+ }
134
+ element.newsId = newsItem.id;
135
+ element.order = index + 1;
136
+ blocks.push(element);
137
+ });
138
+
39
139
 
40
140
  await this.ContentNews.bulkCreate(blocks, { transaction: t });
41
141
  return newsItem;
42
142
  });
43
143
  }
44
144
 
45
- async updatePost(id, newsData, contentBlocks) {
46
- const existingNews = await this.News.findByPk(id);
47
- if (!existingNews) {
48
- return null;
49
- }
50
-
145
+ async updatePost(id, newsData, contentBlocks, files) {
51
146
  return this.News.sequelize.transaction(async (t) => {
52
- await existingNews.update(newsData, { transaction: t });
147
+ const oldNews = await this.News.findByPk(id, {
148
+ include: [{ model: this.ContentNews, as: 'contentBlocks' }],
149
+ transaction: t
150
+ });
151
+
152
+ if (!oldNews) throw new Error("Berita tidak ditemukan");
153
+
154
+ let filesToDelete = [];
53
155
 
54
- await this.ContentNews.destroy({
156
+ if (files['thumbnailImage']?.[0]) {
157
+ if (oldNews.imagePath) filesToDelete.push(oldNews.imagePath);
158
+ const rawPath = files['thumbnailImage'][0].path;
159
+ // newsData.imagePath = rawPath.replace(/\\/g, '/');
160
+ newsData.imagePath = rawPath.replace(/\\/g, '/').replace(/^public/, '');
161
+ } else {
162
+ newsData.imagePath = oldNews.imagePath;
163
+ }
164
+
165
+ await oldNews.update(newsData, { transaction: t });
166
+
167
+ const oldBlocks = oldNews.contentBlocks || [];
168
+
169
+ await this.ContentNews.destroy({
55
170
  where: { newsId: id },
56
- transaction: t
171
+ transaction: t
57
172
  });
58
173
 
59
- const blocks = contentBlocks.map((block, index) => ({
60
- ...block,
61
- newsId: id,
62
- order: index + 1
63
- }));
174
+ let blocks = [];
175
+ let imageCount = 0;
64
176
 
65
- await this.ContentNews.bulkCreate(blocks, { transaction: t });
66
-
67
- return this.News.findByPk(id, {
68
- include: [{ model: this.ContentNews, as: 'blocks', order: [['order', 'ASC']] }],
69
- transaction: t
177
+ contentBlocks.forEach((element, index) => {
178
+ if (element.blockType === "IMAGE") {
179
+ const newFile = files['contentImages']?.[imageCount];
180
+
181
+ if (newFile) {
182
+ const rawPathE = newFile.path;
183
+ // element.contentValue = rawPathE.replace(/\\/g, '/');
184
+ element.contentValue = rawPathE.replace(/\\/g, '/').replace(/^public/, '');
185
+ imageCount++;
186
+ } else {
187
+ element.contentValue = element.contentValue;
188
+ }
189
+ }
190
+
191
+ element.newsId = id;
192
+ element.order = index + 1;
193
+ blocks.push(element);
70
194
  });
195
+
196
+ await this.ContentNews.bulkCreate(blocks, { transaction: t });
197
+
198
+ return { newsItem: oldNews, filesToDelete };
71
199
  });
72
200
  }
73
201
 
202
+ async updateStatusNews(id, status) {
203
+ const news = await this.News.findByPk(id);
204
+ if (!news) throw new Error("Berita tidak ditemukan");
205
+
206
+ await news.update({
207
+ status: status
208
+ });
209
+
210
+ return news;
211
+ }
212
+
213
+ //belum selesai
74
214
  async deletePost(id) {
75
215
  return this.News.destroy({ where: { id } });
76
216
  }
217
+
218
+ async dashboardAdmin(currentYear) {
219
+ const newsCount = await this.News.count();
220
+
221
+ const monthlyVisitors = await this.VisitorLog.findAll({
222
+ attributes: [
223
+ [fn('MONTH', col('visitedAt')), 'month'],
224
+ [fn('COUNT', col('id')), 'total']
225
+ ],
226
+ where: where(fn('YEAR', col('visitedAt')), currentYear),
227
+ group: [fn('MONTH', col('visitedAt'))],
228
+ raw: true
229
+ });
230
+
231
+ const totalYearlyVisitors = await this.VisitorLog.count({
232
+ where: where(fn('YEAR', col('visitedAt')), currentYear)
233
+ });
234
+
235
+ const categoryCount = await this.News.count({
236
+ distinct: true,
237
+ col: 'category'
238
+ });
239
+
240
+ return { newsCount, categoryCount, monthlyVisitors, totalYearlyVisitors };
241
+ }
77
242
  }
78
243
 
79
244
  module.exports = NewsService;
@@ -7,15 +7,24 @@ class StatService {
7
7
  }
8
8
 
9
9
  async trackVisit(newsId, sessionId) {
10
- try {
11
- await this.VisitorLog.create({
10
+ const TWENTY_FOUR_HOURS_AGO = new Date(new Date() - 24 * 60 * 60 * 1000);
11
+
12
+ const existingVisit = await this.VisitorLog.findOne({
13
+ where: {
12
14
  newsId: newsId,
13
15
  sessionId: sessionId,
16
+ visitedAt: {
17
+ [Op.gt]: TWENTY_FOUR_HOURS_AGO
18
+ }
19
+ }
20
+ });
21
+
22
+ if (!existingVisit) {
23
+ return await this.VisitorLog.create({
24
+ newsId,
25
+ sessionId,
14
26
  visitedAt: new Date()
15
27
  });
16
- return true;
17
- } catch (error) {
18
- return false;
19
28
  }
20
29
  }
21
30
 
package/services/index.js CHANGED
@@ -8,8 +8,8 @@ const StatService = require('./StatService');
8
8
  */
9
9
  module.exports = (db) => {
10
10
  return {
11
- news: new NewsService(db.News, db.ContentNews), // NewsService butuh News dan ContentNews
12
- stat: new StatService(db.VisitorLog, db.News), // StatService butuh VisitorLog dan News
13
- db: db // Mungkin perlu mengakses Sequelize instance (opsional)
11
+ news: new NewsService(db.News, db.ContentNews, db.VisitorLog),
12
+ stat: new StatService(db.VisitorLog, db.News),
13
+ db: db
14
14
  };
15
15
  };
@@ -0,0 +1,25 @@
1
+ const { body, validationResult } = require('express-validator');
2
+
3
+ const validate = (req, res, next) => {
4
+ console.log("di validate");
5
+ const errors = validationResult(req);
6
+ if (errors.isEmpty()) {
7
+ console.log("validate berhasil");
8
+ return next();
9
+ }
10
+
11
+ console.log("=== DETAIL ERROR VALIDASI ===");
12
+ console.log(JSON.stringify(errors.array(), null, 2));
13
+
14
+ return res.status(400).json({
15
+ status: 'error',
16
+ errors: errors.array().map(err => ({
17
+ field: err.path,
18
+ message: err.msg
19
+ }))
20
+ });
21
+ };
22
+
23
+ module.exports = {
24
+ validate
25
+ };
@@ -0,0 +1,84 @@
1
+ const { body, validationResult } = require('express-validator');
2
+
3
+ const CreateNewsValidationRules = [
4
+ body('title')
5
+ .notEmpty().withMessage('title wajib diisi')
6
+ .isLength({ min: 3 }).withMessage('title minimal 3 karakter')
7
+ .trim(),
8
+ body('authorName')
9
+ .notEmpty().withMessage('authorName wajib diisi')
10
+ .isLength({ min: 3 }).withMessage('authorName minimal 3 karakter')
11
+ .trim(),
12
+ body('category')
13
+ .notEmpty().withMessage('category wajib diisi')
14
+ .isLength({ min: 3 }).withMessage('category minimal 3 karakter')
15
+ .trim(),
16
+ body('status')
17
+ .notEmpty().withMessage('status wajib dipilih antara DRAFT, PUBLISHED')
18
+ .isLength({ min: 3 }).withMessage('status minimal 3 karakter')
19
+ .isIn(['DRAFT', 'PUBLISHED']).withMessage('status tidak sesuai')
20
+ .trim(),
21
+
22
+ body('contentBlocks')
23
+ .isArray({ min: 1 }).withMessage('contentBlocks harus berupa array dan minimal berisi 1 block'),
24
+ body('contentBlocks.*.blockType')
25
+ .notEmpty().withMessage('blockType wajib diisi')
26
+ .isIn(['PARAGRAPH', 'IMAGE', 'VIDEO', 'SUBHEADING']).withMessage('Tipe block tidak valid'),
27
+ body('contentBlocks.*.contentValue')
28
+ .custom((value, { req, path }) => {
29
+ const index = path.match(/\d+/)[0];
30
+ const blockType = req.body.contentBlocks[index].blockType;
31
+
32
+ if (blockType !== 'IMAGE') {
33
+ if (!value || value.trim() === '') {
34
+ throw new Error(`contentValue untuk ${blockType} wajib diisi`);
35
+ }
36
+ }
37
+
38
+ if (( blockType === 'VIDEO') && !value.startsWith('http')) {
39
+ throw new Error(`contentValue untuk ${blockType} harus berupa URL yang valid`);
40
+ }
41
+ return true;
42
+ }),
43
+ body('contentBlocks.*.caption')
44
+ .optional({ nullable: true })
45
+ .isString().withMessage('caption harus berupa string')
46
+ .isLength({ max: 255 }).withMessage('caption maksimal 255 karakter')
47
+ ]
48
+
49
+ const UpdateNewsValidationRules = [
50
+ body('title').optional().isLength({ min: 3 }).trim(),
51
+ body('authorName').optional().isLength({ min: 3 }).trim(),
52
+ body('category').optional().trim(),
53
+ body('status').optional().isIn(['DRAFT', 'PUBLISHED']),
54
+
55
+ // body('contentBlocks')
56
+ // .isArray().withMessage('contentBlocks harus berupa array'),
57
+ body('contentBlocks')
58
+ .isArray({ min: 1 }).withMessage('contentBlocks harus berupa array dan minimal berisi 1 block'),
59
+
60
+ body('contentBlocks.*.blockType')
61
+ .notEmpty().withMessage('blockType wajib diisi'),
62
+
63
+ body('contentBlocks.*.contentValue')
64
+ .custom((value, { req, path }) => {
65
+ const index = path.match(/\d+/)[0];
66
+ const block = req.body.contentBlocks[index];
67
+
68
+ // Jika IMAGE, cek apakah ada file baru ATAU path lama
69
+ if (block.blockType === 'IMAGE') {
70
+ const hasNewFile = req.files && req.files['contentImages'] && req.files['contentImages'].length > 0;
71
+ if (!hasNewFile && (!value || value.trim() === '')) {
72
+ throw new Error(`Blok gambar index ${index} wajib memiliki file baru atau path lama`);
73
+ }
74
+ } else {
75
+ if (!value || value.trim() === '') throw new Error('Konten wajib diisi');
76
+ }
77
+ return true;
78
+ })
79
+ ];
80
+
81
+ module.exports = {
82
+ CreateNewsValidationRules,
83
+ UpdateNewsValidationRules
84
+ };