nodebb-plugin-niki-loyalty 1.0.17 → 1.0.20
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/library.js +252 -188
- package/package.json +1 -1
package/library.js
CHANGED
|
@@ -3,248 +3,312 @@
|
|
|
3
3
|
const db = require.main.require('./src/database');
|
|
4
4
|
const user = require.main.require('./src/user');
|
|
5
5
|
const routeHelpers = require.main.require('./src/controllers/helpers');
|
|
6
|
+
const nconf = require.main.require('nconf');
|
|
6
7
|
|
|
7
8
|
const Plugin = {};
|
|
8
9
|
|
|
10
|
+
// =========================
|
|
11
|
+
// AYARLAR
|
|
12
|
+
// =========================
|
|
9
13
|
const SETTINGS = {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
14
|
+
pointsPerHeartbeat: 500,
|
|
15
|
+
dailyCap: 2500000,
|
|
16
|
+
coffeeCost: 250,
|
|
13
17
|
};
|
|
14
18
|
|
|
19
|
+
// ✅ TEST: sınırsız kullanım (puan kontrolünü kapatmak için true)
|
|
20
|
+
const TEST_MODE_UNLIMITED = false;
|
|
21
|
+
|
|
15
22
|
// =========================
|
|
16
23
|
// JSON SAFE HELPERS
|
|
17
24
|
// =========================
|
|
18
25
|
function safeParseMaybeJson(x) {
|
|
19
|
-
|
|
26
|
+
if (x == null) return null;
|
|
20
27
|
|
|
21
|
-
|
|
22
|
-
|
|
28
|
+
// bazı DB’lerde object dönebilir
|
|
29
|
+
if (typeof x === 'object') return x;
|
|
23
30
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
}
|
|
31
|
+
if (typeof x === 'string') {
|
|
32
|
+
try {
|
|
33
|
+
return JSON.parse(x);
|
|
34
|
+
} catch (e) {
|
|
35
|
+
// "[object Object]" gibi bozuk kayıtları atla
|
|
36
|
+
return null;
|
|
31
37
|
}
|
|
32
|
-
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
33
40
|
}
|
|
34
41
|
|
|
35
42
|
function safeStringify(obj) {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
43
|
+
try {
|
|
44
|
+
return JSON.stringify(obj);
|
|
45
|
+
} catch (e) {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
41
48
|
}
|
|
42
49
|
|
|
43
|
-
|
|
50
|
+
function makeProfileUrl(userslug) {
|
|
51
|
+
const rp = nconf.get('relative_path') || '';
|
|
52
|
+
if (!userslug) return '';
|
|
53
|
+
return `${rp}/user/${userslug}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// =========================
|
|
57
|
+
// LOG FONKSİYONLARI
|
|
58
|
+
// =========================
|
|
44
59
|
async function addUserLog(uid, type, amount, desc) {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
await db.listTrim(`niki:activity:${uid}`, -50, -1);
|
|
60
|
+
const logEntry = {
|
|
61
|
+
ts: Date.now(),
|
|
62
|
+
type, // 'earn' | 'spend'
|
|
63
|
+
amt: amount,
|
|
64
|
+
txt: desc,
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const payload = safeStringify(logEntry);
|
|
68
|
+
if (!payload) return;
|
|
69
|
+
|
|
70
|
+
await db.listAppend(`niki:activity:${uid}`, payload);
|
|
71
|
+
await db.listTrim(`niki:activity:${uid}`, -50, -1);
|
|
58
72
|
}
|
|
59
73
|
|
|
60
74
|
async function addKasaLog(staffUid, customerName, customerUid) {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
await db.listTrim('niki:kasa:history', -100, -1);
|
|
75
|
+
const logEntry = {
|
|
76
|
+
ts: Date.now(),
|
|
77
|
+
staff: staffUid,
|
|
78
|
+
cust: customerName, // bazen eski kayıtlarda boş olabilir, endpoint tamamlayacak
|
|
79
|
+
cuid: customerUid,
|
|
80
|
+
amt: SETTINGS.coffeeCost,
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const payload = safeStringify(logEntry);
|
|
84
|
+
if (!payload) return;
|
|
85
|
+
|
|
86
|
+
await db.listAppend('niki:kasa:history', payload);
|
|
87
|
+
await db.listTrim('niki:kasa:history', -100, -1);
|
|
75
88
|
}
|
|
76
89
|
|
|
90
|
+
// =========================
|
|
91
|
+
// INIT
|
|
92
|
+
// =========================
|
|
77
93
|
Plugin.init = async function (params) {
|
|
78
|
-
|
|
79
|
-
|
|
94
|
+
const router = params.router;
|
|
95
|
+
const middleware = params.middleware;
|
|
80
96
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
97
|
+
// 1) HEARTBEAT (puan kazanma)
|
|
98
|
+
router.post('/api/niki-loyalty/heartbeat', middleware.ensureLoggedIn, async (req, res) => {
|
|
99
|
+
try {
|
|
100
|
+
const uid = req.uid;
|
|
101
|
+
const today = new Date().toISOString().slice(0, 10).replace(/-/g, '');
|
|
102
|
+
const dailyKey = `niki:daily:${uid}:${today}`;
|
|
87
103
|
|
|
88
|
-
|
|
104
|
+
const currentDailyScore = parseInt((await db.getObjectField(dailyKey, 'score')) || 0, 10);
|
|
89
105
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
106
|
+
if (currentDailyScore >= SETTINGS.dailyCap) {
|
|
107
|
+
return res.json({ earned: false, reason: 'daily_cap' });
|
|
108
|
+
}
|
|
93
109
|
|
|
94
|
-
|
|
95
|
-
|
|
110
|
+
await user.incrementUserFieldBy(uid, 'niki_points', SETTINGS.pointsPerHeartbeat);
|
|
111
|
+
await db.incrObjectFieldBy(dailyKey, 'score', SETTINGS.pointsPerHeartbeat);
|
|
96
112
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
113
|
+
const newBalance = await user.getUserField(uid, 'niki_points');
|
|
114
|
+
return res.json({ earned: true, points: SETTINGS.pointsPerHeartbeat, total: newBalance });
|
|
115
|
+
} catch (err) {
|
|
116
|
+
return res.status(500).json({ earned: false, reason: 'server_error' });
|
|
117
|
+
}
|
|
118
|
+
});
|
|
103
119
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
120
|
+
// 2) WALLET DATA (cüzdan + geçmiş)
|
|
121
|
+
router.get('/api/niki-loyalty/wallet-data', middleware.ensureLoggedIn, async (req, res) => {
|
|
122
|
+
try {
|
|
123
|
+
const uid = req.uid;
|
|
124
|
+
const today = new Date().toISOString().slice(0, 10).replace(/-/g, '');
|
|
125
|
+
|
|
126
|
+
const [userData, dailyData, historyRaw] = await Promise.all([
|
|
127
|
+
user.getUserFields(uid, ['niki_points']),
|
|
128
|
+
db.getObject(`niki:daily:${uid}:${today}`),
|
|
129
|
+
db.getListRange(`niki:activity:${uid}`, 0, -1),
|
|
130
|
+
]);
|
|
131
|
+
|
|
132
|
+
const currentPoints = parseInt(userData?.niki_points || 0, 10);
|
|
133
|
+
const dailyScore = parseInt(dailyData?.score || 0, 10);
|
|
134
|
+
|
|
135
|
+
let dailyPercent = (dailyScore / SETTINGS.dailyCap) * 100;
|
|
136
|
+
if (dailyPercent > 100) dailyPercent = 100;
|
|
137
|
+
|
|
138
|
+
// ✅ history parse güvenli
|
|
139
|
+
const history = (historyRaw || [])
|
|
140
|
+
.map(safeParseMaybeJson)
|
|
141
|
+
.filter(Boolean)
|
|
142
|
+
.reverse();
|
|
143
|
+
|
|
144
|
+
return res.json({
|
|
145
|
+
points: currentPoints,
|
|
146
|
+
dailyScore,
|
|
147
|
+
dailyCap: SETTINGS.dailyCap,
|
|
148
|
+
dailyPercent,
|
|
149
|
+
history,
|
|
150
|
+
});
|
|
151
|
+
} catch (err) {
|
|
152
|
+
return res.status(500).json({
|
|
153
|
+
points: 0,
|
|
154
|
+
dailyScore: 0,
|
|
155
|
+
dailyCap: SETTINGS.dailyCap,
|
|
156
|
+
dailyPercent: 0,
|
|
157
|
+
history: [],
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// 3) KASA HISTORY (admin/mod)
|
|
163
|
+
router.get('/api/niki-loyalty/kasa-history', middleware.ensureLoggedIn, async (req, res) => {
|
|
164
|
+
try {
|
|
165
|
+
const isAdmin = await user.isAdministrator(req.uid);
|
|
166
|
+
const isMod = await user.isGlobalModerator(req.uid);
|
|
167
|
+
if (!isAdmin && !isMod) return res.status(403).json([]);
|
|
168
|
+
|
|
169
|
+
const raw = await db.getListRange('niki:kasa:history', 0, -1);
|
|
170
|
+
|
|
171
|
+
// Kayıtlar bazen JSON string, bazen bozuk olabilir → güvenli parse
|
|
172
|
+
const rows = (raw || [])
|
|
173
|
+
.map((x) => {
|
|
174
|
+
if (!x) return null;
|
|
175
|
+
if (typeof x === 'object') return x;
|
|
176
|
+
if (typeof x === 'string') {
|
|
177
|
+
try { return JSON.parse(x); } catch (e) { return null; }
|
|
142
178
|
}
|
|
179
|
+
return null;
|
|
180
|
+
})
|
|
181
|
+
.filter(Boolean)
|
|
182
|
+
.reverse();
|
|
183
|
+
|
|
184
|
+
// cuid’lerden uid listesi çıkar
|
|
185
|
+
const uids = rows
|
|
186
|
+
.map(r => parseInt(r.cuid, 10))
|
|
187
|
+
.filter(n => Number.isFinite(n) && n > 0);
|
|
188
|
+
|
|
189
|
+
// NodeBB core user datası (profile-looks mantığı)
|
|
190
|
+
const users = await user.getUsersFields(uids, [
|
|
191
|
+
'uid', 'username', 'userslug', 'picture', 'icon:bgColor',
|
|
192
|
+
]);
|
|
193
|
+
|
|
194
|
+
const userMap = {};
|
|
195
|
+
(users || []).forEach(u => { userMap[u.uid] = u; });
|
|
196
|
+
|
|
197
|
+
const rp = nconf.get('relative_path') || '';
|
|
198
|
+
|
|
199
|
+
const enriched = rows.map(r => {
|
|
200
|
+
const uid = parseInt(r.cuid, 10);
|
|
201
|
+
const u = userMap[uid];
|
|
202
|
+
if (!u) return r;
|
|
203
|
+
|
|
204
|
+
return {
|
|
205
|
+
...r,
|
|
206
|
+
cust: u.username || r.cust || 'Bilinmeyen',
|
|
207
|
+
userslug: u.userslug || r.userslug || '',
|
|
208
|
+
picture: u.picture || r.picture || '',
|
|
209
|
+
iconBg: u['icon:bgColor'] || r.iconBg || '#4b5563',
|
|
210
|
+
profileUrl: (u.userslug ? `${rp}/user/${u.userslug}` : ''),
|
|
211
|
+
};
|
|
143
212
|
});
|
|
144
213
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
if (!isAdmin && !isMod) return res.status(403).json([]);
|
|
152
|
-
|
|
153
|
-
const historyRaw = await db.getListRange('niki:kasa:history', 0, -1);
|
|
154
|
-
|
|
155
|
-
const rows = (historyRaw || [])
|
|
156
|
-
.map(safeParseMaybeJson)
|
|
157
|
-
.filter(Boolean)
|
|
158
|
-
.reverse();
|
|
159
|
-
|
|
160
|
-
// Kullanıcı resimlerini de ekleyerek gönder (tek kayıt bozulsa bile endpoint patlamasın)
|
|
161
|
-
const enrichedHistory = await Promise.all(rows.map(async (item) => {
|
|
162
|
-
if (!item || !item.cuid) return item;
|
|
163
|
-
|
|
164
|
-
try {
|
|
165
|
-
const uData = await user.getUserFields(item.cuid, ['picture']);
|
|
166
|
-
return { ...item, picture: uData?.picture };
|
|
167
|
-
} catch (e) {
|
|
168
|
-
return item;
|
|
169
|
-
}
|
|
170
|
-
}));
|
|
171
|
-
|
|
172
|
-
res.json(enrichedHistory);
|
|
173
|
-
} catch (err) {
|
|
174
|
-
// ✅ asla crash yok
|
|
175
|
-
return res.status(500).json([]);
|
|
176
|
-
}
|
|
177
|
-
});
|
|
214
|
+
return res.json(enriched);
|
|
215
|
+
} catch (err) {
|
|
216
|
+
return res.status(500).json([]);
|
|
217
|
+
}
|
|
218
|
+
});
|
|
178
219
|
|
|
179
|
-
// 4. QR OLUŞTUR
|
|
180
|
-
router.post('/api/niki-loyalty/generate-qr', middleware.ensureLoggedIn, async (req, res) => {
|
|
181
|
-
try {
|
|
182
|
-
const uid = req.uid;
|
|
183
|
-
const points = parseInt(await user.getUserField(uid, 'niki_points')) || 0;
|
|
184
220
|
|
|
185
|
-
if (points < SETTINGS.coffeeCost) return res.json({ success: false, message: 'Yetersiz Puan' });
|
|
186
221
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
222
|
+
// 4) QR OLUŞTUR
|
|
223
|
+
router.post('/api/niki-loyalty/generate-qr', middleware.ensureLoggedIn, async (req, res) => {
|
|
224
|
+
try {
|
|
225
|
+
const uid = req.uid;
|
|
226
|
+
const points = parseInt((await user.getUserField(uid, 'niki_points')) || 0, 10);
|
|
190
227
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
}
|
|
195
|
-
});
|
|
228
|
+
if (!TEST_MODE_UNLIMITED && points < SETTINGS.coffeeCost) {
|
|
229
|
+
return res.json({ success: false, message: 'Yetersiz Puan' });
|
|
230
|
+
}
|
|
196
231
|
|
|
197
|
-
|
|
198
|
-
router.post('/api/niki-loyalty/scan-qr', middleware.ensureLoggedIn, async (req, res) => {
|
|
199
|
-
try {
|
|
200
|
-
const { token } = req.body;
|
|
232
|
+
const token = Math.random().toString(36).substring(2) + Date.now().toString(36);
|
|
201
233
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
if (!isAdmin && !isMod) return res.status(403).json({ success: false, message: 'Yetkisiz' });
|
|
234
|
+
await db.set(`niki:qr:${token}`, uid);
|
|
235
|
+
await db.expire(`niki:qr:${token}`, 120);
|
|
205
236
|
|
|
206
|
-
|
|
207
|
-
|
|
237
|
+
return res.json({ success: true, token });
|
|
238
|
+
} catch (err) {
|
|
239
|
+
return res.status(500).json({ success: false, message: 'Sunucu hatası' });
|
|
240
|
+
}
|
|
241
|
+
});
|
|
208
242
|
|
|
209
|
-
|
|
210
|
-
|
|
243
|
+
// 5) QR OKUT (admin/mod)
|
|
244
|
+
router.post('/api/niki-loyalty/scan-qr', middleware.ensureLoggedIn, async (req, res) => {
|
|
245
|
+
try {
|
|
246
|
+
const token = (req.body && req.body.token) ? String(req.body.token) : '';
|
|
211
247
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
248
|
+
const isAdmin = await user.isAdministrator(req.uid);
|
|
249
|
+
const isMod = await user.isGlobalModerator(req.uid);
|
|
250
|
+
if (!isAdmin && !isMod) return res.status(403).json({ success: false, message: 'Yetkisiz' });
|
|
215
251
|
|
|
216
|
-
|
|
217
|
-
|
|
252
|
+
const customerUid = await db.get(`niki:qr:${token}`);
|
|
253
|
+
if (!customerUid) return res.json({ success: false, message: 'Geçersiz Kod' });
|
|
218
254
|
|
|
219
|
-
|
|
220
|
-
|
|
255
|
+
const pts = parseInt((await user.getUserField(customerUid, 'niki_points')) || 0, 10);
|
|
256
|
+
if (!TEST_MODE_UNLIMITED && pts < SETTINGS.coffeeCost) {
|
|
257
|
+
return res.json({ success: false, message: 'Yetersiz Bakiye' });
|
|
258
|
+
}
|
|
221
259
|
|
|
222
|
-
|
|
223
|
-
|
|
260
|
+
// ✅ puan düşür
|
|
261
|
+
if (!TEST_MODE_UNLIMITED) {
|
|
262
|
+
await user.decrementUserFieldBy(customerUid, 'niki_points', SETTINGS.coffeeCost);
|
|
263
|
+
}
|
|
224
264
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
return res.status(500).json({ success: false, message: 'Sunucu hatası' });
|
|
228
|
-
}
|
|
229
|
-
});
|
|
265
|
+
// token tek kullanımlık
|
|
266
|
+
await db.delete(`niki:qr:${token}`);
|
|
230
267
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
268
|
+
// müşteri bilgisi
|
|
269
|
+
const customerData = await user.getUserFields(customerUid, ['username', 'picture', 'userslug']);
|
|
270
|
+
|
|
271
|
+
// user log
|
|
272
|
+
await addUserLog(customerUid, 'spend', SETTINGS.coffeeCost, 'Kahve Keyfi ☕');
|
|
273
|
+
|
|
274
|
+
// kasa log
|
|
275
|
+
await addKasaLog(req.uid, customerData?.username || 'Bilinmeyen', customerUid);
|
|
276
|
+
|
|
277
|
+
return res.json({
|
|
278
|
+
success: true,
|
|
279
|
+
customer: customerData,
|
|
280
|
+
message: 'Onaylandı!',
|
|
281
|
+
});
|
|
282
|
+
} catch (err) {
|
|
283
|
+
return res.status(500).json({ success: false, message: 'Sunucu hatası' });
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
// 6) SAYFA ROTASI (kasa sayfası)
|
|
288
|
+
routeHelpers.setupPageRoute(router, '/niki-kasa', middleware, [], async (req, res) => {
|
|
289
|
+
const isAdmin = await user.isAdministrator(req.uid);
|
|
290
|
+
const isMod = await user.isGlobalModerator(req.uid);
|
|
291
|
+
if (!isAdmin && !isMod) return res.render('403', {});
|
|
292
|
+
return res.render('niki-kasa', { title: 'Niki Kasa' });
|
|
293
|
+
});
|
|
238
294
|
};
|
|
239
295
|
|
|
296
|
+
// client.js inject
|
|
240
297
|
Plugin.addScripts = async function (scripts) {
|
|
241
|
-
|
|
242
|
-
|
|
298
|
+
scripts.push('plugins/nodebb-plugin-niki-loyalty/static/lib/client.js');
|
|
299
|
+
return scripts;
|
|
243
300
|
};
|
|
244
301
|
|
|
302
|
+
// navigation
|
|
245
303
|
Plugin.addNavigation = async function (nav) {
|
|
246
|
-
|
|
247
|
-
|
|
304
|
+
nav.push({
|
|
305
|
+
route: '/niki-wallet',
|
|
306
|
+
title: 'Niki Cüzdan',
|
|
307
|
+
enabled: true,
|
|
308
|
+
iconClass: 'fa-coffee',
|
|
309
|
+
text: 'Niki Cüzdan',
|
|
310
|
+
});
|
|
311
|
+
return nav;
|
|
248
312
|
};
|
|
249
313
|
|
|
250
314
|
module.exports = Plugin;
|