news-cms-module 0.0.1
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/config/index.js +34 -0
- package/controllers/NewsController.js +102 -0
- package/controllers/StatController.js +36 -0
- package/index.js +73 -0
- package/models/ContentNews.js +46 -0
- package/models/News.js +48 -0
- package/models/VisitorLog.js +35 -0
- package/models/index.js +61 -0
- package/package.json +20 -0
- package/routes/index.js +74 -0
- package/services/NewsService.js +55 -0
- package/services/StatService.js +55 -0
- package/services/index.js +17 -0
- package/views/admin/form_create.ejs +0 -0
- package/views/admin/form_edit.ejs +0 -0
- package/views/admin/list.ejs +0 -0
- package/views/detail.ejs +0 -0
- package/views/list.ejs +0 -0
package/config/index.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
const path = require('path');
|
|
2
|
+
|
|
3
|
+
// Konfigurasi Default untuk Package Anda
|
|
4
|
+
const defaultConfig = {
|
|
5
|
+
// Pengaturan Router
|
|
6
|
+
adminRoutePrefix: '/admin', // Prefix default untuk route CRUD admin
|
|
7
|
+
publicRoutePrefix: '/', // Prefix default untuk route publik
|
|
8
|
+
|
|
9
|
+
// Pengaturan Views
|
|
10
|
+
viewEngine: 'ejs', // Template engine default
|
|
11
|
+
baseLayout: 'layout', // Nama file layout utama (jika digunakan)
|
|
12
|
+
|
|
13
|
+
// Pengaturan Assets (CSS/JS)
|
|
14
|
+
assetsUrlPrefix: '/nc-assets', // Prefix default untuk URL aset statis
|
|
15
|
+
assetsPath: path.join(__dirname, '..', 'assets'), // Path fisik default ke folder assets
|
|
16
|
+
|
|
17
|
+
// Pengaturan Pelacakan (Tracking)
|
|
18
|
+
trendingTimeframeHours: 24, // Jendela waktu default untuk menghitung trending (24 jam)
|
|
19
|
+
maxTrendingPosts: 10, // Jumlah maksimum berita trending yang dikembalikan
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Menggabungkan konfigurasi default package dengan konfigurasi kustom dari pengguna.
|
|
24
|
+
* @param {object} userConfig - Konfigurasi yang disediakan oleh aplikasi yang menginstal package.
|
|
25
|
+
* @returns {object} Objek konfigurasi akhir yang lengkap.
|
|
26
|
+
*/
|
|
27
|
+
module.exports = (userConfig = {}) => {
|
|
28
|
+
// Menggunakan penyebaran (spread operator) untuk menggabungkan objek.
|
|
29
|
+
// userConfig akan menimpa (overwrite) defaultConfig jika ada properti yang sama.
|
|
30
|
+
return {
|
|
31
|
+
...defaultConfig,
|
|
32
|
+
...userConfig
|
|
33
|
+
};
|
|
34
|
+
};
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
class NewsController {
|
|
2
|
+
constructor(newsService, statService, config) {
|
|
3
|
+
this.newsService = newsService;
|
|
4
|
+
this.statService = statService;
|
|
5
|
+
this.config = config; // Digunakan untuk passing config ke view (e.g. baseUrl)
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
// --- Route Publik ---
|
|
9
|
+
async listPublic(req, res) {
|
|
10
|
+
try {
|
|
11
|
+
const { rows: posts, count: total } = await this.newsService.getAllPosts(req.query.page);
|
|
12
|
+
|
|
13
|
+
res.status(200).json({
|
|
14
|
+
success: true,
|
|
15
|
+
data: posts,
|
|
16
|
+
total: total,
|
|
17
|
+
page: req.query.page
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
// res.render('list', { posts, total, baseUrl: req.baseUrl });
|
|
21
|
+
} catch (error) {
|
|
22
|
+
res.status(500).json({
|
|
23
|
+
succses: false,
|
|
24
|
+
error: 'Error loading news list.'
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async getDetail(req, res) {
|
|
30
|
+
try {
|
|
31
|
+
const post = await this.newsService.getPostBySlug(req.params.slug);
|
|
32
|
+
if (!post) {
|
|
33
|
+
return res.status(404).json({
|
|
34
|
+
succses: false,
|
|
35
|
+
error: 'news tidak ditemukan'
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
res.status(200).json({
|
|
40
|
+
success: true,
|
|
41
|
+
data: post
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
// Render detail view
|
|
45
|
+
// res.render('detail', { post, baseUrl: req.baseUrl });
|
|
46
|
+
} catch (error) {
|
|
47
|
+
// res.status(500).send('Error loading post detail.');
|
|
48
|
+
res.status(500).json({
|
|
49
|
+
succses: false,
|
|
50
|
+
error: 'gagal melihat news'
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// --- Route Admin (CRUD) ---
|
|
56
|
+
async adminList(req, res) {
|
|
57
|
+
// Logic ambil data dengan status DRAFT atau PUBLISHED untuk admin
|
|
58
|
+
const { rows: posts } = await this.newsService.getAllPosts(req.query.page, 10, ['DRAFT', 'PUBLISHED']);
|
|
59
|
+
|
|
60
|
+
res.status(200).json({
|
|
61
|
+
success: true,
|
|
62
|
+
data: posts
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
//render
|
|
66
|
+
// res.render('admin/list', { posts, baseUrl: req.baseUrl });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async createPost(req, res) {
|
|
70
|
+
try {
|
|
71
|
+
// Asumsi req.body.content adalah array of blocks [{blockType, contentValue}, ...]
|
|
72
|
+
const { title, summary, authorId, status, contentBlocks } = req.body;
|
|
73
|
+
|
|
74
|
+
// Slug harus dibuat sebelum disimpan
|
|
75
|
+
const slug = title.toLowerCase().replace(/ /g, '-').replace(/[^\w-]+/g, '');
|
|
76
|
+
|
|
77
|
+
const newNews = await this.newsService.createPost(
|
|
78
|
+
{ title, slug, summary, authorId, status: status || 'DRAFT' },
|
|
79
|
+
contentBlocks
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
res.status(201).json({
|
|
83
|
+
success: true,
|
|
84
|
+
data: newNews
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
// Redirect ke halaman daftar admin atau detail admin
|
|
88
|
+
// res.redirect(this.config.adminRoutePrefix + '/');
|
|
89
|
+
} catch (error) {
|
|
90
|
+
console.error(error);
|
|
91
|
+
// res.status(500).send('Gagal membuat berita.');
|
|
92
|
+
res.status(500).json({
|
|
93
|
+
succses: false,
|
|
94
|
+
error: 'gagal membuat news'
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ... Tambahkan updatePost, deletePost, dll.
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
module.exports = NewsController;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// controllers/StatController.js
|
|
2
|
+
|
|
3
|
+
class StatController {
|
|
4
|
+
constructor(statService) {
|
|
5
|
+
this.statService = statService;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
// Middleware untuk pelacakan kunjungan
|
|
9
|
+
async trackVisitMiddleware(req, res, next) {
|
|
10
|
+
const { slug } = req.params;
|
|
11
|
+
|
|
12
|
+
// Ambil post ID (idealnya dilakukan oleh service sebelum dipanggil)
|
|
13
|
+
// Untuk demo, kita asumsikan kita punya newsId dari service atau middleware sebelumnya.
|
|
14
|
+
// **REAL-WORLD:** Anda harus mencari newsId berdasarkan slug di sini atau di service.
|
|
15
|
+
const newsId = 1; // Contoh: Asumsi newsId ditemukan
|
|
16
|
+
|
|
17
|
+
const sessionId = req.sessionID || req.ip; // Gunakan sesi atau IP untuk unique ID
|
|
18
|
+
|
|
19
|
+
if (newsId) {
|
|
20
|
+
await this.statService.trackVisit(newsId, sessionId);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
next(); // Lanjutkan ke controller NewsController.getDetail
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async getTrendingApi(req, res) {
|
|
27
|
+
try {
|
|
28
|
+
const posts = await this.statService.getTrendingPosts();
|
|
29
|
+
res.json(posts);
|
|
30
|
+
} catch (error) {
|
|
31
|
+
res.status(500).json({ error: 'Failed to retrieve trending data.' });
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
module.exports = StatController;
|
package/index.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
const express = require('express');
|
|
2
|
+
const initModels = require('./models'); // Mengimpor inisialisasi model dan koneksi DB
|
|
3
|
+
const initServices = require('./services'); // Mengimpor semua Services (CRUD, Stat)
|
|
4
|
+
const setupRoutes = require('./routes'); // Mengimpor fungsi setup router
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Fungsi utama package yang di-export.
|
|
8
|
+
* @param {object} dbConfig - Konfigurasi koneksi database dari aplikasi pengguna.
|
|
9
|
+
* @param {object} options - Opsi tambahan (misalnya, nama folder views pengguna).
|
|
10
|
+
* @returns {express.Router} Router Express yang sudah terkonfigurasi.
|
|
11
|
+
*/
|
|
12
|
+
// index.js (Revisi Akhir)
|
|
13
|
+
|
|
14
|
+
const express = require('express');
|
|
15
|
+
// Pastikan Anda telah mendefinisikan dan mengimpor fungsi-fungsi ini
|
|
16
|
+
const initModels = require('./models');
|
|
17
|
+
const initServices = require('./services');
|
|
18
|
+
const setupRoutes = require('./routes');
|
|
19
|
+
const mergeConfig = require('./config'); // Gunakan fungsi mergeConfig yang sudah kita buat
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Fungsi utama package yang di-export.
|
|
23
|
+
* @param {object} dbConfig - Konfigurasi koneksi database dari aplikasi pengguna.
|
|
24
|
+
* @param {object} userOptions - Opsi tambahan (misalnya, autoMigrate) dari pengguna.
|
|
25
|
+
* @returns {express.Router} Router Express yang sudah terkonfigurasi.
|
|
26
|
+
*/
|
|
27
|
+
module.exports = async (dbConfig, userOptions = {}) => { // <<< KUNCI 1: Jadikan ASYNC
|
|
28
|
+
|
|
29
|
+
// 1. Validasi dan Konfigurasi
|
|
30
|
+
if (!dbConfig || !dbConfig.database) {
|
|
31
|
+
throw new Error('News module requires database configuration (dbConfig).');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Gabungkan konfigurasi database dan opsi pengguna (walaupun di sini kita hanya menggunakan dbConfig)
|
|
35
|
+
const finalConfig = mergeConfig({
|
|
36
|
+
...userOptions,
|
|
37
|
+
db: dbConfig
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// 2. Inisialisasi Koneksi Database dan Model
|
|
41
|
+
const db = initModels(finalConfig.db);
|
|
42
|
+
|
|
43
|
+
// 3. Sinkronisasi Tabel (Pembuatan Tabel Otomatis)
|
|
44
|
+
// Secara default, kita asumsikan sinkronisasi ON. Pengguna bisa menonaktifkannya dengan { autoMigrate: false }
|
|
45
|
+
if (finalConfig.autoMigrate !== false) {
|
|
46
|
+
try {
|
|
47
|
+
console.log('News module: Synchronizing database tables...');
|
|
48
|
+
// Memanggil syncTables yang didefinisikan di models/index.js
|
|
49
|
+
await db.syncTables({ alter: true });
|
|
50
|
+
console.log('News module: Synchronization complete.');
|
|
51
|
+
} catch (error) {
|
|
52
|
+
console.error('ERROR: News module failed to synchronize database tables.', error);
|
|
53
|
+
// Anda bisa memilih untuk melempar error agar server tidak berjalan tanpa tabel
|
|
54
|
+
throw error;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// 4. Inisialisasi Services (Injeksi Dependencies)
|
|
59
|
+
const services = initServices(db);
|
|
60
|
+
|
|
61
|
+
// 5. Setup Router Express
|
|
62
|
+
const router = express.Router();
|
|
63
|
+
|
|
64
|
+
// Middleware dasar untuk parsing body (penting untuk CRUD)
|
|
65
|
+
router.use(express.json());
|
|
66
|
+
router.use(express.urlencoded({ extended: true }));
|
|
67
|
+
|
|
68
|
+
// 6. Setup Router dan Controllers
|
|
69
|
+
// Route diatur, Controllers menggunakan Services dan finalConfig
|
|
70
|
+
setupRoutes(router, services, finalConfig);
|
|
71
|
+
|
|
72
|
+
return router;
|
|
73
|
+
};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// models/ContentNews.js
|
|
2
|
+
|
|
3
|
+
const { DataTypes } = require('sequelize');
|
|
4
|
+
|
|
5
|
+
module.exports = (sequelize) => {
|
|
6
|
+
const ContentNews = sequelize.define('ContentNews', {
|
|
7
|
+
id: {
|
|
8
|
+
type: DataTypes.INTEGER,
|
|
9
|
+
primaryKey: true,
|
|
10
|
+
autoIncrement: true,
|
|
11
|
+
},
|
|
12
|
+
newsId: {
|
|
13
|
+
type: DataTypes.INTEGER,
|
|
14
|
+
allowNull: false,
|
|
15
|
+
},
|
|
16
|
+
order: {
|
|
17
|
+
type: DataTypes.INTEGER,
|
|
18
|
+
allowNull: false,
|
|
19
|
+
},
|
|
20
|
+
blockType: {
|
|
21
|
+
type: DataTypes.ENUM(
|
|
22
|
+
'PARAGRAPH',
|
|
23
|
+
'IMAGE',
|
|
24
|
+
'VIDEO',
|
|
25
|
+
'SUBHEADING'
|
|
26
|
+
),
|
|
27
|
+
allowNull: false,
|
|
28
|
+
},
|
|
29
|
+
contentValue: {
|
|
30
|
+
type: DataTypes.TEXT,
|
|
31
|
+
allowNull: false,
|
|
32
|
+
},
|
|
33
|
+
caption: {
|
|
34
|
+
type: DataTypes.STRING(255),
|
|
35
|
+
allowNull: true,
|
|
36
|
+
}
|
|
37
|
+
}, {
|
|
38
|
+
tableName: 'content_news',
|
|
39
|
+
timestamps: false,
|
|
40
|
+
indexes: [
|
|
41
|
+
{ fields: ['newsId', 'order'] }
|
|
42
|
+
]
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
return ContentNews;
|
|
46
|
+
};
|
package/models/News.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// models/News.js
|
|
2
|
+
|
|
3
|
+
const { DataTypes } = require('sequelize');
|
|
4
|
+
|
|
5
|
+
module.exports = (sequelize) => {
|
|
6
|
+
const News = sequelize.define('News', {
|
|
7
|
+
id: {
|
|
8
|
+
type: DataTypes.INTEGER,
|
|
9
|
+
primaryKey: true,
|
|
10
|
+
autoIncrement: true,
|
|
11
|
+
},
|
|
12
|
+
title: {
|
|
13
|
+
type: DataTypes.STRING(255),
|
|
14
|
+
allowNull: false,
|
|
15
|
+
},
|
|
16
|
+
slug: {
|
|
17
|
+
type: DataTypes.STRING(255),
|
|
18
|
+
allowNull: false,
|
|
19
|
+
unique: true,
|
|
20
|
+
},
|
|
21
|
+
authorName: {
|
|
22
|
+
type: DataTypes.STRING(50),
|
|
23
|
+
allowNull: false,
|
|
24
|
+
},
|
|
25
|
+
category: {
|
|
26
|
+
type: DataTypes.STRING(100),
|
|
27
|
+
allowNull: true,
|
|
28
|
+
},
|
|
29
|
+
imagePath: {
|
|
30
|
+
type: DataTypes.STRING(255),
|
|
31
|
+
allowNull: true,
|
|
32
|
+
},
|
|
33
|
+
status: {
|
|
34
|
+
type: DataTypes.ENUM('DRAFT', 'PUBLISHED', 'ARCHIVED'),
|
|
35
|
+
defaultValue: 'DRAFT',
|
|
36
|
+
allowNull: false,
|
|
37
|
+
},
|
|
38
|
+
publishedAt: {
|
|
39
|
+
type: DataTypes.DATE,
|
|
40
|
+
allowNull: true,
|
|
41
|
+
}
|
|
42
|
+
}, {
|
|
43
|
+
tableName: 'news',
|
|
44
|
+
timestamps: true,
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
return News;
|
|
48
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// models/VisitorLog.js
|
|
2
|
+
|
|
3
|
+
const { DataTypes } = require('sequelize');
|
|
4
|
+
|
|
5
|
+
module.exports = (sequelize) => {
|
|
6
|
+
const VisitorLog = sequelize.define('VisitorLog', {
|
|
7
|
+
id: {
|
|
8
|
+
type: DataTypes.INTEGER,
|
|
9
|
+
primaryKey: true,
|
|
10
|
+
autoIncrement: true,
|
|
11
|
+
},
|
|
12
|
+
newsId: {
|
|
13
|
+
type: DataTypes.INTEGER,
|
|
14
|
+
allowNull: false,
|
|
15
|
+
},
|
|
16
|
+
sessionId: {
|
|
17
|
+
type: DataTypes.STRING(100),
|
|
18
|
+
allowNull: false,
|
|
19
|
+
},
|
|
20
|
+
visitedAt: {
|
|
21
|
+
type: DataTypes.DATE,
|
|
22
|
+
defaultValue: DataTypes.NOW,
|
|
23
|
+
allowNull: false,
|
|
24
|
+
}
|
|
25
|
+
}, {
|
|
26
|
+
tableName: 'visitor_logs',
|
|
27
|
+
timestamps: false,
|
|
28
|
+
indexes: [
|
|
29
|
+
{ fields: ['visitedAt'] },
|
|
30
|
+
{ unique: true, fields: ['newsId', 'sessionId'] }
|
|
31
|
+
]
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
return VisitorLog;
|
|
35
|
+
};
|
package/models/index.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// models/index.js
|
|
2
|
+
|
|
3
|
+
const { Sequelize } = require('sequelize');
|
|
4
|
+
const NewsModel = require('./News');
|
|
5
|
+
const ContentNewsModel = require('./ContentNews');
|
|
6
|
+
const VisitorLogModel = require('./VisitorLog');
|
|
7
|
+
|
|
8
|
+
module.exports = (config) => {
|
|
9
|
+
// Inisialisasi Sequelize dengan konfigurasi dari aplikasi pengguna
|
|
10
|
+
const sequelize = new Sequelize(config.database, config.username, config.password, {
|
|
11
|
+
host: config.host,
|
|
12
|
+
dialect: 'mysql',
|
|
13
|
+
logging: false, // Matikan logging SQL
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
const db = {};
|
|
17
|
+
|
|
18
|
+
db.News = NewsModel(sequelize);
|
|
19
|
+
db.ContentNews = ContentNewsModel(sequelize);
|
|
20
|
+
db.VisitorLog = VisitorLogModel(sequelize);
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
db.News.hasMany(db.ContentNews, {
|
|
24
|
+
foreignKey: 'newsId',
|
|
25
|
+
as: 'blocks',
|
|
26
|
+
onDelete: 'CASCADE'
|
|
27
|
+
});
|
|
28
|
+
db.ContentNews.belongsTo(db.News, {
|
|
29
|
+
foreignKey: 'newsId',
|
|
30
|
+
as: 'newsItem'
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
db.News.hasMany(db.VisitorLog, {
|
|
35
|
+
foreignKey: 'newsId',
|
|
36
|
+
as: 'visits',
|
|
37
|
+
onDelete: 'CASCADE'
|
|
38
|
+
});
|
|
39
|
+
db.VisitorLog.belongsTo(db.News, {
|
|
40
|
+
foreignKey: 'newsId',
|
|
41
|
+
as: 'newsItem'
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
// --- PENAMBAHAN KUNCI UNTUK SINKRONISASI ---
|
|
46
|
+
/**
|
|
47
|
+
* Metode untuk mensinkronisasi model dengan tabel database.
|
|
48
|
+
* @param {object} options - Opsi sinkronisasi Sequelize (e.g., { alter: true } atau { force: true }).
|
|
49
|
+
*/
|
|
50
|
+
db.syncTables = async (options = { alter: true }) => {
|
|
51
|
+
// Kami menggunakan { alter: true } secara default, yang akan membuat tabel jika belum ada
|
|
52
|
+
// dan mencoba mengubah kolom yang ada agar sesuai dengan definisi model, tanpa menghapus data.
|
|
53
|
+
await sequelize.sync(options);
|
|
54
|
+
};
|
|
55
|
+
// ----------------------------------------------
|
|
56
|
+
|
|
57
|
+
db.sequelize = sequelize;
|
|
58
|
+
db.Sequelize = Sequelize;
|
|
59
|
+
|
|
60
|
+
return db;
|
|
61
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "news-cms-module",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"main": "index.js",
|
|
5
|
+
"scripts": {
|
|
6
|
+
"test": "echo \"Error: no test specified\" && exit 1"
|
|
7
|
+
},
|
|
8
|
+
"keywords": [],
|
|
9
|
+
"author": "",
|
|
10
|
+
"license": "ISC",
|
|
11
|
+
"description": "Package CRUD Berita modular dengan statistik",
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"dotenv": "^17.2.3",
|
|
14
|
+
"ejs": "^3.1.10",
|
|
15
|
+
"express": "^5.1.0",
|
|
16
|
+
"mysql2": "^3.15.1",
|
|
17
|
+
"path": "^0.12.7",
|
|
18
|
+
"sequelize": "^6.37.7"
|
|
19
|
+
}
|
|
20
|
+
}
|
package/routes/index.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// routes/index.js
|
|
2
|
+
|
|
3
|
+
const express = require('express');
|
|
4
|
+
const NewsController = require('../controllers/NewsController');
|
|
5
|
+
const StatController = require('../controllers/StatController');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Setup semua route untuk package.
|
|
10
|
+
* @param {express.Router} router - Router Express yang sudah ada.
|
|
11
|
+
* @param {object} services - Semua services yang telah diinisialisasi.
|
|
12
|
+
* @param {object} config - Konfigurasi package lengkap.
|
|
13
|
+
*/
|
|
14
|
+
module.exports = (router, services, config) => {
|
|
15
|
+
// Inisialisasi Controllers dengan services dan config yang dibutuhkan
|
|
16
|
+
const newsController = new NewsController(services.news, services.stat, config);
|
|
17
|
+
const statController = new StatController(services.stat);
|
|
18
|
+
|
|
19
|
+
// 1. Ekspos Aset Statis (CSS)
|
|
20
|
+
// Misalnya, package diakses di /berita-kami, maka aset diakses di /berita-kami/nc-assets
|
|
21
|
+
// router.use(config.assetsUrlPrefix, express.static(config.assetsPath));
|
|
22
|
+
|
|
23
|
+
// 2. Route Publik (Front-end)
|
|
24
|
+
// Route ini menggunakan prefix yang ditentukan oleh pengguna (default: '/')
|
|
25
|
+
// router.get(config.publicRoutePrefix, newsController.listPublic.bind(newsController));
|
|
26
|
+
|
|
27
|
+
// 2. Route Publik (READ-ONLY API)
|
|
28
|
+
// Gunakan endpoint yang jelas: /list, /trending, /:slug
|
|
29
|
+
router.get(config.publicRoutePrefix + 'list', newsController.listPublic.bind(newsController));
|
|
30
|
+
|
|
31
|
+
// Route detail berita, termasuk tracking middleware
|
|
32
|
+
router.get(`${config.publicRoutePrefix}post/:slug`,
|
|
33
|
+
statController.trackVisitMiddleware.bind(statController),
|
|
34
|
+
newsController.getDetail.bind(newsController));
|
|
35
|
+
|
|
36
|
+
// Route detail berita (termasuk tracking middleware)
|
|
37
|
+
// PERHATIAN: Di controller, Anda harus memastikan baseUrl dikirim ke view
|
|
38
|
+
// router.get(`${config.publicRoutePrefix}:slug`, statController.trackVisitMiddleware.bind(statController), newsController.getDetail.bind(newsController));
|
|
39
|
+
|
|
40
|
+
// 3. Route Admin (CRUD)
|
|
41
|
+
// Route ini menggunakan prefix admin yang ditentukan oleh pengguna (default: '/admin')
|
|
42
|
+
const adminRouter = express.Router();
|
|
43
|
+
// Middleware otentikasi admin bisa ditambahkan di sini (user harus menyediakannya!)
|
|
44
|
+
|
|
45
|
+
// GET /admin/list (Membaca semua post, termasuk DRAFT)
|
|
46
|
+
adminRouter.get('/list', newsController.adminList.bind(newsController));
|
|
47
|
+
|
|
48
|
+
// GET /admin/:id (Membaca detail post untuk editing)
|
|
49
|
+
adminRouter.get('/:id', newsController.getDetail.bind(newsController));
|
|
50
|
+
|
|
51
|
+
// POST /admin/create (Membuat berita baru)
|
|
52
|
+
adminRouter.post('/create', newsController.createPost.bind(newsController));
|
|
53
|
+
|
|
54
|
+
// PUT/PATCH /admin/update/:id (Memperbarui berita)
|
|
55
|
+
// adminRouter.put('/update/:id', newsController.updatePost.bind(newsController));
|
|
56
|
+
|
|
57
|
+
// DELETE /admin/delete/:id (Menghapus berita)
|
|
58
|
+
// adminRouter.delete('/delete/:id', newsController.deletePost.bind(newsController));
|
|
59
|
+
|
|
60
|
+
// Pasang router admin ke prefix yang ditentukan pengguna (default: '/admin')
|
|
61
|
+
router.use(config.adminRoutePrefix, adminRouter);
|
|
62
|
+
|
|
63
|
+
// adminRouter.get('/', newsController.adminList.bind(newsController));
|
|
64
|
+
// adminRouter.post('/create', newsController.createPost.bind(newsController));
|
|
65
|
+
// // ... Tambahkan route admin lain (/edit/:id, /delete/:id)
|
|
66
|
+
|
|
67
|
+
// router.use(config.adminRoutePrefix, adminRouter);
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
// 4. Route API (Untuk data trending)
|
|
71
|
+
router.get('/api/trending', statController.getTrendingApi.bind(statController));
|
|
72
|
+
|
|
73
|
+
// Router telah siap di file index.js
|
|
74
|
+
};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
class NewsService {
|
|
2
|
+
constructor(NewsModel, ContentNewsModel) {
|
|
3
|
+
this.News = NewsModel;
|
|
4
|
+
this.ContentNews = ContentNewsModel;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
// --- Operasi READ (Membaca) ---
|
|
8
|
+
async getAllPosts(page = 1, limit = 10, status = 'PUBLISHED') {
|
|
9
|
+
// Logika untuk mengambil daftar berita dengan pagination
|
|
10
|
+
const offset = (page - 1) * limit;
|
|
11
|
+
return this.News.findAndCountAll({
|
|
12
|
+
where: { status },
|
|
13
|
+
limit: limit,
|
|
14
|
+
offset: offset,
|
|
15
|
+
order: [['publishedAt', 'DESC']],
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async getPostBySlug(slug) {
|
|
20
|
+
// Mengambil berita beserta semua blok kontennya
|
|
21
|
+
return this.News.findOne({
|
|
22
|
+
where: { slug, status: 'PUBLISHED' },
|
|
23
|
+
include: [{
|
|
24
|
+
model: this.ContentNews,
|
|
25
|
+
as: 'blocks',
|
|
26
|
+
order: [['order', 'ASC']] // Pastikan konten diurutkan
|
|
27
|
+
}]
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// --- Operasi CREATE & UPDATE ---
|
|
32
|
+
async createPost(newsData, contentBlocks) {
|
|
33
|
+
return this.News.sequelize.transaction(async (t) => {
|
|
34
|
+
const newsItem = await this.News.create(newsData, { transaction: t });
|
|
35
|
+
|
|
36
|
+
const blocks = contentBlocks.map((block, index) => ({
|
|
37
|
+
...block,
|
|
38
|
+
newsId: newsItem.id,
|
|
39
|
+
order: index + 1
|
|
40
|
+
}));
|
|
41
|
+
|
|
42
|
+
await this.ContentNews.bulkCreate(blocks, { transaction: t });
|
|
43
|
+
return newsItem;
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// --- Operasi DELETE ---
|
|
48
|
+
async deletePost(id) {
|
|
49
|
+
// Karena kita menggunakan onDelete: 'CASCADE' di Models,
|
|
50
|
+
// menghapus News akan otomatis menghapus ContentNews dan VisitorLog
|
|
51
|
+
return this.News.destroy({ where: { id } });
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
module.exports = NewsService;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// services/StatService.js
|
|
2
|
+
|
|
3
|
+
const { Op, literal } = require('sequelize');
|
|
4
|
+
|
|
5
|
+
class StatService {
|
|
6
|
+
constructor(VisitorLogModel, NewsModel) {
|
|
7
|
+
this.VisitorLog = VisitorLogModel;
|
|
8
|
+
this.News = NewsModel;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
async trackVisit(newsId, sessionId) {
|
|
12
|
+
// Mencoba membuat entri unik (newsId, sessionId). Jika sudah ada, Sequelize akan melempar error unik.
|
|
13
|
+
try {
|
|
14
|
+
await this.VisitorLog.create({
|
|
15
|
+
newsId: newsId,
|
|
16
|
+
sessionId: sessionId,
|
|
17
|
+
visitedAt: new Date()
|
|
18
|
+
});
|
|
19
|
+
return true;
|
|
20
|
+
} catch (error) {
|
|
21
|
+
// Ini biasanya terjadi karena pelanggaran unique constraint (pengunjung yang sama mengklik lagi)
|
|
22
|
+
// Di sini kita bisa mengabaikan error atau logging.
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async getTrendingPosts(timeframeHours = 24, limit = 10) {
|
|
28
|
+
const cutOffTime = new Date(new Date() - timeframeHours * 60 * 60 * 1000);
|
|
29
|
+
|
|
30
|
+
// Menggunakan Sequelize untuk melakukan GROUP BY dan COUNT
|
|
31
|
+
const trendingLogs = await this.VisitorLog.findAll({
|
|
32
|
+
attributes: [
|
|
33
|
+
'newsId',
|
|
34
|
+
[literal('COUNT(DISTINCT sessionId)'), 'uniqueVisits'] // Hitung sesi unik
|
|
35
|
+
],
|
|
36
|
+
where: {
|
|
37
|
+
visitedAt: { [Op.gte]: cutOffTime } // Filter berdasarkan waktu
|
|
38
|
+
},
|
|
39
|
+
group: ['newsId'],
|
|
40
|
+
order: [[literal('uniqueVisits'), 'DESC']], // Urutkan berdasarkan kunjungan terbanyak
|
|
41
|
+
limit: limit
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const newsIds = trendingLogs.map(log => log.newsId);
|
|
45
|
+
|
|
46
|
+
// Ambil data berita lengkapnya
|
|
47
|
+
return this.News.findAll({
|
|
48
|
+
where: { id: newsIds, status: 'PUBLISHED' },
|
|
49
|
+
// Tambahkan order agar urutan trending tetap terjaga
|
|
50
|
+
order: [[literal(`FIELD(id, ${newsIds.join(',')})`)]], // MySQL specific ordering
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
module.exports = StatService;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// services/index.js
|
|
2
|
+
|
|
3
|
+
const NewsService = require('./NewsService');
|
|
4
|
+
const StatService = require('./StatService');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Menginisialisasi semua service dan menyuntikkan model database.
|
|
8
|
+
* @param {object} db - Objek yang berisi semua model Sequelize yang terkoneksi (News, ContentNews, VisitorLog).
|
|
9
|
+
* @returns {object} Objek yang berisi semua service yang siap digunakan.
|
|
10
|
+
*/
|
|
11
|
+
module.exports = (db) => {
|
|
12
|
+
return {
|
|
13
|
+
news: new NewsService(db.News, db.ContentNews), // NewsService butuh News dan ContentNews
|
|
14
|
+
stat: new StatService(db.VisitorLog, db.News), // StatService butuh VisitorLog dan News
|
|
15
|
+
db: db // Mungkin perlu mengakses Sequelize instance (opsional)
|
|
16
|
+
};
|
|
17
|
+
};
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
package/views/detail.ejs
ADDED
|
File without changes
|
package/views/list.ejs
ADDED
|
File without changes
|