news-cms-module 0.1.2 → 1.0.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,29 @@ 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));
45
+ adminRouter.get('/create', (req, res) => {
46
+ res.render(path.join(__dirname, '../views/admin/create_news.ejs'));
47
+ });
48
+ adminRouter.post('/create', beritaUpload, parseContentBlocks, CreateNewsValidationRules, validate, newsController.createPost.bind(newsController));
49
+
50
+ adminRouter.get('/update/:slug', newsController.getEditForAdmin.bind(newsController));
51
+ adminRouter.patch('/update/:id', newsController.updateStatusNews.bind(newsController));
52
+ adminRouter.put('/update/:id', beritaUpload, parseContentBlocks, UpdateNewsValidationRules, validate, newsController.updatePost.bind(newsController));
48
53
 
49
- // POST /admin/create (Membuat berita baru)
50
- adminRouter.post('/create', newsController.createPost.bind(newsController));
51
-
52
- // PUT/PATCH /admin/update/:id (Memperbarui berita)
53
- adminRouter.put('/update/:id', newsController.updatePost.bind(newsController));
54
+ adminRouter.get('/dashboard', newsController.dashboardAdmin.bind(newsController));
55
+ adminRouter.get('/list', newsController.adminList.bind(newsController));
56
+ adminRouter.get('/:slug', newsController.getDetailForAdmin.bind(newsController));
54
57
 
55
- // DELETE /admin/delete/:id (Menghapus berita)
56
58
  adminRouter.delete('/delete/:id', newsController.deletePost.bind(newsController));
57
-
58
- // Pasang router admin ke prefix yang ditentukan pengguna (default: '/admin')
59
59
  router.use(config.adminRoutePrefix, adminRouter);
60
-
61
- // 4. Route API (Untuk data trending)
62
60
  router.get('/api/trending', statController.getTrendingApi.bind(statController));
63
-
64
61
  };
@@ -1,79 +1,247 @@
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
+ // 1. Tentukan batas waktu (24 jam yang lalu dari sekarang)
13
+ const last24Hours = new Date(new Date() - 24 * 60 * 60 * 1000);
14
+
15
+ // 2. Query untuk menghitung views per berita
16
+ const trending = await this.News.findAll({
17
+ attributes: [
18
+ 'id',
19
+ 'title',
20
+ 'slug',
21
+ 'category',
22
+ 'imagePath',
23
+ 'authorName',
24
+ 'createdAt',
25
+ // Membuat kolom virtual 'totalViews' dari hasil hitung (COUNT)
26
+ [fn('COUNT', col('visits.id')), 'totalViews']
27
+ ],
28
+ include: [{
29
+ model: this.VisitorLog,
30
+ as: 'visits', // SESUAI dengan alias relasi Anda
31
+ attributes: [], // Kita tidak butuh kolom detail dari VisitorLog
32
+ where: {
33
+ visitedAt: {
34
+ [Op.gt]: last24Hours // Hanya log dalam 24 jam terakhir
35
+ }
36
+ },
37
+ required: true // Menggunakan INNER JOIN agar hanya berita yang ada view-nya yang muncul
38
+ }],
39
+ group: ['News.id'], // Kelompokkan berdasarkan ID berita
40
+ order: [[fn('COUNT', col('visits.id')), 'DESC']], // Urutkan terbanyak ke terendah
41
+ limit: 10, // Ambil 10 teratas
42
+ subQuery: false // WAJIB false agar LIMIT dan GROUP BY bekerja benar dengan JOIN
43
+ });
44
+
45
+ return trending;
46
+ } catch (error) {
47
+ console.error("Error fetching trending news:", error);
48
+ throw error;
49
+ }
5
50
  }
6
51
 
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 },
52
+ async getUniqueCategories() {
53
+ const categories = await this.News.findAll({
54
+ attributes: [
55
+ [fn('DISTINCT', col('category')), 'category']
56
+ ],
57
+ raw: true
58
+ });
59
+ return categories.map(item => item.category).filter(Boolean);
60
+ }
61
+
62
+ async getRecommendationNews(category) {
63
+ return this.News.findAll({
64
+ where: {
65
+ category
66
+ },
67
+ limit: 5,
68
+ order: [['createdAt', 'DESC']]
69
+ })
70
+ }
71
+
72
+ //for all
73
+ async getAllPosts({ offset = 0, limit = 10, title = '', category = '', status = '' }) {
74
+ const validStatuses = ['PUBLISHED', 'ARCHIVED', 'DRAFT'];
75
+ const where = {};
76
+
77
+ if (status && validStatuses.includes(status)) {
78
+ where.status = status;
79
+ }
80
+ if (title) {
81
+ where.title = { [Op.like]: `%${title}%` };
82
+ }
83
+ if (category) {
84
+ where.category = category;
85
+ }
86
+
87
+ return await this.News.findAndCountAll({
88
+ where: where,
12
89
  limit: limit,
13
90
  offset: offset,
14
- order: [['publishedAt', 'DESC']],
91
+ order: [['createdAt', 'DESC']]
15
92
  });
16
93
  }
17
94
 
95
+ //forUser
18
96
  async getPostBySlug(slug) {
19
97
  return this.News.findOne({
20
98
  where: { slug, status: 'PUBLISHED' },
21
99
  include: [{
22
100
  model: this.ContentNews,
23
- as: 'blocks',
101
+ as: 'contentBlocks',
24
102
  order: [['order', 'ASC']]
25
103
  }]
26
104
  });
27
105
  }
28
106
 
29
- // --- Operasi CREATE & UPDATE ---
30
- async createPost(newsData, contentBlocks) {
107
+ //forAdmin
108
+
109
+ async getPostBySlugForAdmin(slug) {
110
+ return this.News.findOne({
111
+ where: { slug },
112
+ include: [{
113
+ model: this.ContentNews,
114
+ as: 'contentBlocks',
115
+ order: [['order', 'ASC']]
116
+ }]
117
+ });
118
+ }
119
+
120
+ async createPost(newsData, contentBlocks, files) {
31
121
  return this.News.sequelize.transaction(async (t) => {
122
+ const rawPath = files['thumbnailImage']?.[0]?.path;
123
+ newsData.imagePath = rawPath
124
+ ? rawPath.replace(/\\/g, '/').replace(/^public/, '')
125
+ : null;
32
126
  const newsItem = await this.News.create(newsData, { transaction: t });
33
127
 
34
- const blocks = contentBlocks.map((block, index) => ({
35
- ...block,
36
- newsId: newsItem.id,
37
- order: index + 1
38
- }));
128
+ let blocks = [];
129
+ let count = 0;
130
+ contentBlocks.forEach((element, index) => {
131
+ console.log(element);
132
+ if (element.blockType == "IMAGE") {
133
+ const rawPathE = files['contentImages']?.[count]?.path;
134
+ element.contentValue = rawPathE.replace(/\\/g, '/').replace(/^public/, '');
135
+ count++;
136
+ }
137
+ element.newsId = newsItem.id;
138
+ element.order = index + 1;
139
+ blocks.push(element);
140
+ });
141
+
39
142
 
40
143
  await this.ContentNews.bulkCreate(blocks, { transaction: t });
41
144
  return newsItem;
42
145
  });
43
146
  }
44
147
 
45
- async updatePost(id, newsData, contentBlocks) {
46
- const existingNews = await this.News.findByPk(id);
47
- if (!existingNews) {
48
- return null;
49
- }
50
-
148
+ async updatePost(id, newsData, contentBlocks, files) {
51
149
  return this.News.sequelize.transaction(async (t) => {
52
- await existingNews.update(newsData, { transaction: t });
150
+ const oldNews = await this.News.findByPk(id, {
151
+ include: [{ model: this.ContentNews, as: 'contentBlocks' }],
152
+ transaction: t
153
+ });
154
+
155
+ if (!oldNews) throw new Error("Berita tidak ditemukan");
156
+
157
+ let filesToDelete = [];
53
158
 
54
- await this.ContentNews.destroy({
159
+ if (files['thumbnailImage']?.[0]) {
160
+ if (oldNews.imagePath) filesToDelete.push(oldNews.imagePath);
161
+ const rawPath = files['thumbnailImage'][0].path;
162
+ // newsData.imagePath = rawPath.replace(/\\/g, '/');
163
+ newsData.imagePath = rawPath.replace(/\\/g, '/').replace(/^public/, '');
164
+ } else {
165
+ newsData.imagePath = oldNews.imagePath;
166
+ }
167
+
168
+ await oldNews.update(newsData, { transaction: t });
169
+
170
+ const oldBlocks = oldNews.contentBlocks || [];
171
+
172
+ await this.ContentNews.destroy({
55
173
  where: { newsId: id },
56
- transaction: t
174
+ transaction: t
57
175
  });
58
176
 
59
- const blocks = contentBlocks.map((block, index) => ({
60
- ...block,
61
- newsId: id,
62
- order: index + 1
63
- }));
177
+ let blocks = [];
178
+ let imageCount = 0;
64
179
 
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
180
+ contentBlocks.forEach((element, index) => {
181
+ if (element.blockType === "IMAGE") {
182
+ const newFile = files['contentImages']?.[imageCount];
183
+
184
+ if (newFile) {
185
+ const rawPathE = newFile.path;
186
+ // element.contentValue = rawPathE.replace(/\\/g, '/');
187
+ element.contentValue = rawPathE.replace(/\\/g, '/').replace(/^public/, '');
188
+ imageCount++;
189
+ } else {
190
+ element.contentValue = element.contentValue;
191
+ }
192
+ }
193
+
194
+ element.newsId = id;
195
+ element.order = index + 1;
196
+ blocks.push(element);
70
197
  });
198
+
199
+ await this.ContentNews.bulkCreate(blocks, { transaction: t });
200
+
201
+ return { newsItem: oldNews, filesToDelete };
71
202
  });
72
203
  }
73
204
 
205
+ async updateStatusNews(id, status) {
206
+ const news = await this.News.findByPk(id);
207
+ if (!news) throw new Error("Berita tidak ditemukan");
208
+
209
+ await news.update({
210
+ status: status
211
+ });
212
+
213
+ return news;
214
+ }
215
+
216
+ //belum selesai
74
217
  async deletePost(id) {
75
218
  return this.News.destroy({ where: { id } });
76
219
  }
220
+
221
+ async dashboardAdmin(currentYear) {
222
+ const newsCount = await this.News.count();
223
+
224
+ const monthlyVisitors = await this.VisitorLog.findAll({
225
+ attributes: [
226
+ [fn('MONTH', col('visitedAt')), 'month'],
227
+ [fn('COUNT', col('id')), 'total']
228
+ ],
229
+ where: where(fn('YEAR', col('visitedAt')), currentYear),
230
+ group: [fn('MONTH', col('visitedAt'))],
231
+ raw: true
232
+ });
233
+
234
+ const totalYearlyVisitors = await this.VisitorLog.count({
235
+ where: where(fn('YEAR', col('visitedAt')), currentYear)
236
+ });
237
+
238
+ const categoryCount = await this.News.count({
239
+ distinct: true,
240
+ col: 'category'
241
+ });
242
+
243
+ return { newsCount, categoryCount, monthlyVisitors, totalYearlyVisitors };
244
+ }
77
245
  }
78
246
 
79
247
  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
+ };