nodebb-plugin-niki-loyalty 1.0.21 → 1.0.23

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 CHANGED
@@ -13,9 +13,19 @@ const Plugin = {};
13
13
  const SETTINGS = {
14
14
  pointsPerHeartbeat: 5,
15
15
  dailyCap: 250,
16
- coffeeCost: 250,
16
+
17
+ barMax: 250,
18
+ rewards: [
19
+ { id: 'cookie', at: 60, title: 'Ücretsiz Kurabiye', type: 'free_item', meta: { item: 'cookie' } },
20
+ { id: 'c35', at: 120, title: '%35 İndirimli Kahve', type: 'discount', meta: { product: 'coffee', percent: 35 } },
21
+ { id: 'c60', at: 180, title: '%60 İndirimli Kahve', type: 'discount', meta: { product: 'coffee', percent: 60 } },
22
+ { id: 'coffee', at: 250, title: 'Ücretsiz Kahve', type: 'free_item', meta: { item: 'coffee' } },
23
+ ],
24
+
25
+ couponTTLSeconds: 10 * 60, // 10 dk
17
26
  };
18
27
 
28
+
19
29
  // ✅ TEST: sınırsız kullanım (puan kontrolünü kapatmak için true)
20
30
  const TEST_MODE_UNLIMITED = false;
21
31
 
@@ -24,15 +34,12 @@ const TEST_MODE_UNLIMITED = false;
24
34
  // =========================
25
35
  function safeParseMaybeJson(x) {
26
36
  if (x == null) return null;
27
-
28
- // bazı DB’lerde object dönebilir
29
37
  if (typeof x === 'object') return x;
30
38
 
31
39
  if (typeof x === 'string') {
32
40
  try {
33
41
  return JSON.parse(x);
34
42
  } catch (e) {
35
- // "[object Object]" gibi bozuk kayıtları atla
36
43
  return null;
37
44
  }
38
45
  }
@@ -53,15 +60,36 @@ function makeProfileUrl(userslug) {
53
60
  return `${rp}/user/${userslug}`;
54
61
  }
55
62
 
63
+ function makeToken() {
64
+ return Math.random().toString(36).substring(2) + Date.now().toString(36);
65
+ }
66
+
67
+ function getTodayKey() {
68
+ return new Date().toISOString().slice(0, 10).replace(/-/g, '');
69
+ }
70
+
71
+ function findRewardById(id) {
72
+ return SETTINGS.rewards.find(r => r.id === id) || null;
73
+ }
74
+
75
+ async function isStaff(uid) {
76
+ const [isAdmin, isMod] = await Promise.all([
77
+ user.isAdministrator(uid),
78
+ user.isGlobalModerator(uid),
79
+ ]);
80
+ return isAdmin || isMod;
81
+ }
82
+
56
83
  // =========================
57
84
  // LOG FONKSİYONLARI
58
85
  // =========================
59
- async function addUserLog(uid, type, amount, desc) {
86
+ async function addUserLog(uid, type, amount, desc, extra) {
60
87
  const logEntry = {
61
88
  ts: Date.now(),
62
89
  type, // 'earn' | 'spend'
63
90
  amt: amount,
64
91
  txt: desc,
92
+ ...(extra ? { extra } : {}),
65
93
  };
66
94
 
67
95
  const payload = safeStringify(logEntry);
@@ -71,13 +99,15 @@ async function addUserLog(uid, type, amount, desc) {
71
99
  await db.listTrim(`niki:activity:${uid}`, -50, -1);
72
100
  }
73
101
 
74
- async function addKasaLog(staffUid, customerName, customerUid) {
102
+ async function addKasaLog(staffUid, customerName, customerUid, amount, rewardId, rewardTitle) {
75
103
  const logEntry = {
76
104
  ts: Date.now(),
77
105
  staff: staffUid,
78
- cust: customerName, // bazen eski kayıtlarda boş olabilir, endpoint tamamlayacak
106
+ cust: customerName,
79
107
  cuid: customerUid,
80
- amt: SETTINGS.coffeeCost,
108
+ amt: amount,
109
+ rewardId: rewardId || '',
110
+ rewardTitle: rewardTitle || '',
81
111
  };
82
112
 
83
113
  const payload = safeStringify(logEntry);
@@ -94,34 +124,41 @@ Plugin.init = async function (params) {
94
124
  const router = params.router;
95
125
  const middleware = params.middleware;
96
126
 
127
+ // -------------------------
97
128
  // 1) HEARTBEAT (puan kazanma)
129
+ // -------------------------
98
130
  router.post('/api/niki-loyalty/heartbeat', middleware.ensureLoggedIn, async (req, res) => {
99
131
  try {
100
132
  const uid = req.uid;
101
- const today = new Date().toISOString().slice(0, 10).replace(/-/g, '');
133
+ const today = getTodayKey();
102
134
  const dailyKey = `niki:daily:${uid}:${today}`;
103
135
 
104
136
  const currentDailyScore = parseInt((await db.getObjectField(dailyKey, 'score')) || 0, 10);
105
137
 
106
- if (currentDailyScore >= SETTINGS.dailyCap) {
138
+ if (!TEST_MODE_UNLIMITED && currentDailyScore >= SETTINGS.dailyCap) {
107
139
  return res.json({ earned: false, reason: 'daily_cap' });
108
140
  }
109
141
 
110
142
  await user.incrementUserFieldBy(uid, 'niki_points', SETTINGS.pointsPerHeartbeat);
111
143
  await db.incrObjectFieldBy(dailyKey, 'score', SETTINGS.pointsPerHeartbeat);
112
144
 
113
- const newBalance = await user.getUserField(uid, 'niki_points');
145
+ const newBalance = parseInt((await user.getUserField(uid, 'niki_points')) || 0, 10);
146
+
147
+ await addUserLog(uid, 'earn', SETTINGS.pointsPerHeartbeat, 'Aktiflik Puanı ⚡');
148
+
114
149
  return res.json({ earned: true, points: SETTINGS.pointsPerHeartbeat, total: newBalance });
115
150
  } catch (err) {
116
151
  return res.status(500).json({ earned: false, reason: 'server_error' });
117
152
  }
118
153
  });
119
154
 
120
- // 2) WALLET DATA (cüzdan + geçmiş)
155
+ // -------------------------
156
+ // 2) WALLET DATA (cüzdan + geçmiş + rewards)
157
+ // -------------------------
121
158
  router.get('/api/niki-loyalty/wallet-data', middleware.ensureLoggedIn, async (req, res) => {
122
159
  try {
123
160
  const uid = req.uid;
124
- const today = new Date().toISOString().slice(0, 10).replace(/-/g, '');
161
+ const today = getTodayKey();
125
162
 
126
163
  const [userData, dailyData, historyRaw] = await Promise.all([
127
164
  user.getUserFields(uid, ['niki_points']),
@@ -135,17 +172,33 @@ Plugin.init = async function (params) {
135
172
  let dailyPercent = (dailyScore / SETTINGS.dailyCap) * 100;
136
173
  if (dailyPercent > 100) dailyPercent = 100;
137
174
 
138
- // history parse güvenli
175
+ const barPercent = Math.min(100, (currentPoints / SETTINGS.barMax) * 100);
176
+
139
177
  const history = (historyRaw || [])
140
178
  .map(safeParseMaybeJson)
141
179
  .filter(Boolean)
142
180
  .reverse();
143
181
 
182
+ const rewards = SETTINGS.rewards.map(r => ({
183
+ id: r.id,
184
+ at: r.at,
185
+ title: r.title,
186
+ type: r.type,
187
+ meta: r.meta,
188
+ unlocked: currentPoints >= r.at,
189
+ }));
190
+
144
191
  return res.json({
145
192
  points: currentPoints,
193
+
146
194
  dailyScore,
147
195
  dailyCap: SETTINGS.dailyCap,
148
196
  dailyPercent,
197
+
198
+ barMax: SETTINGS.barMax,
199
+ barPercent,
200
+
201
+ rewards,
149
202
  history,
150
203
  });
151
204
  } catch (err) {
@@ -154,72 +207,203 @@ Plugin.init = async function (params) {
154
207
  dailyScore: 0,
155
208
  dailyCap: SETTINGS.dailyCap,
156
209
  dailyPercent: 0,
210
+ barMax: SETTINGS.barMax,
211
+ barPercent: 0,
212
+ rewards: SETTINGS.rewards.map(r => ({ id: r.id, at: r.at, title: r.title, type: r.type, meta: r.meta, unlocked: false })),
157
213
  history: [],
158
214
  });
159
215
  }
160
216
  });
161
217
 
162
- // 3) KASA HISTORY (admin/mod)
163
- router.get('/api/niki-loyalty/kasa-history', middleware.ensureLoggedIn, async (req, res) => {
218
+ // -------------------------
219
+ // 3) CLAIM REWARD (Seçenek B: claim = puan düşer + kupon token üretir)
220
+ // Client bu endpoint'i çağıracak.
221
+ // -------------------------
222
+ router.post('/api/niki-loyalty/claim', middleware.ensureLoggedIn, async (req, res) => {
164
223
  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; }
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
- };
224
+ const uid = req.uid;
225
+ const rewardId = (req.body && req.body.rewardId) ? String(req.body.rewardId) : '';
226
+ const reward = findRewardById(rewardId);
227
+ if (!reward) return res.json({ success: false, message: 'Geçersiz ödül' });
228
+
229
+ const points = parseInt((await user.getUserField(uid, 'niki_points')) || 0, 10);
230
+ if (!TEST_MODE_UNLIMITED && points < reward.at) {
231
+ return res.json({ success: false, message: 'Yetersiz puan' });
232
+ }
233
+
234
+ // Aynı ödülden aktif kupon varsa tekrar üretme (spam engeli)
235
+ const reserveKey = `niki:reserve:${uid}:${reward.id}`;
236
+ const existingToken = await db.get(reserveKey);
237
+ if (existingToken) {
238
+ return res.json({
239
+ success: true,
240
+ token: existingToken,
241
+ reward: { id: reward.id, at: reward.at, title: reward.title, type: reward.type, meta: reward.meta },
242
+ message: 'Zaten aktif bir kuponun var. Kasada okutabilirsin.',
243
+ });
244
+ }
245
+
246
+ // token oluştur
247
+ const token = makeToken();
248
+ const couponKey = `niki:coupon:${token}`;
249
+
250
+ const couponPayload = safeStringify({
251
+ token,
252
+ ts: Date.now(),
253
+ rewardId: reward.id,
254
+ cost: reward.at, // puan düşülecek tutar
255
+ title: reward.title,
256
+ type: reward.type,
257
+ meta: reward.meta,
258
+ ownerUid: uid,
212
259
  });
213
260
 
214
- return res.json(enriched);
261
+ await db.set(couponKey, couponPayload);
262
+ await db.expire(couponKey, SETTINGS.couponTTLSeconds);
263
+
264
+ // ✅ rezervasyonu da tut
265
+ await db.set(reserveKey, token);
266
+ await db.expire(reserveKey, SETTINGS.couponTTLSeconds);
267
+
268
+ // user log (claim logu - puan düşmeden)
269
+ await addUserLog(uid, 'spend', 0, `Kupon oluşturuldu: ${reward.title}`, { rewardId: reward.id, token });
270
+
271
+ return res.json({
272
+ success: true,
273
+ token,
274
+ reward: { id: reward.id, at: reward.at, title: reward.title, type: reward.type, meta: reward.meta },
275
+ message: 'Kupon hazır! Kasada okutunca puanın düşer.',
276
+ });
215
277
  } catch (err) {
216
- return res.status(500).json([]);
278
+ return res.status(500).json({ success: false, message: 'Sunucu hatası' });
217
279
  }
218
280
  });
219
281
 
220
282
 
283
+ // -------------------------
284
+ // 4) KASA HISTORY (admin/mod)
285
+ // -------------------------
286
+ router.get('/api/niki-loyalty/kasa-history', middleware.ensureLoggedIn, async (req, res) => {
287
+ try {
288
+ const staffOk = await isStaff(req.uid);
289
+ if (!staffOk) return res.status(403).json([]);
290
+
291
+ const raw = await db.getListRange('niki:kasa:history', 0, -1);
292
+
293
+ const rows = (raw || [])
294
+ .map((x) => {
295
+ if (!x) return null;
296
+ if (typeof x === 'object') return x;
297
+ if (typeof x === 'string') {
298
+ try { return JSON.parse(x); } catch (e) { return null; }
299
+ }
300
+ return null;
301
+ })
302
+ .filter(Boolean)
303
+ .reverse();
304
+
305
+ const uids = rows
306
+ .map(r => parseInt(r.cuid, 10))
307
+ .filter(n => Number.isFinite(n) && n > 0);
308
+
309
+ const users = await user.getUsersFields(uids, [
310
+ 'uid', 'username', 'userslug', 'picture', 'icon:bgColor',
311
+ ]);
312
+
313
+ const userMap = {};
314
+ (users || []).forEach(u => { userMap[u.uid] = u; });
315
+
316
+ const rp = nconf.get('relative_path') || '';
317
+
318
+ const enriched = rows.map(r => {
319
+ const uid = parseInt(r.cuid, 10);
320
+ const u = userMap[uid];
321
+ if (!u) return r;
322
+
323
+ return {
324
+ ...r,
325
+ cust: u.username || r.cust || 'Bilinmeyen',
326
+ userslug: u.userslug || r.userslug || '',
327
+ picture: u.picture || r.picture || '',
328
+ iconBg: u['icon:bgColor'] || r.iconBg || '#4b5563',
329
+ profileUrl: (u.userslug ? `${rp}/user/${u.userslug}` : ''),
330
+ };
331
+ });
332
+
333
+ return res.json(enriched);
334
+ } catch (err) {
335
+ return res.status(500).json([]);
336
+ }
337
+ });
338
+
339
+ // -------------------------
340
+ // 5) KASA: COUPON SCAN (admin/mod) ✅ yeni akış
341
+ // Claim ile üretilen token kasada okutulur.
342
+ // -------------------------
343
+ router.post('/api/niki-loyalty/scan-coupon', middleware.ensureLoggedIn, async (req, res) => {
344
+ try {
345
+ const token = (req.body && req.body.token) ? String(req.body.token) : '';
346
+
347
+ const staffOk = await isStaff(req.uid);
348
+ if (!staffOk) return res.status(403).json({ success: false, message: 'Yetkisiz' });
221
349
 
222
- // 4) QR OLUŞTUR
350
+ const raw = await db.get(`niki:coupon:${token}`);
351
+ if (!raw) return res.json({ success: false, message: 'Geçersiz / Süresi dolmuş kupon' });
352
+
353
+ const coupon = safeParseMaybeJson(raw);
354
+ if (!coupon || !coupon.ownerUid || !coupon.rewardId) {
355
+ await db.delete(`niki:coupon:${token}`);
356
+ return res.json({ success: false, message: 'Kupon bozuk' });
357
+ }
358
+
359
+ const customerUid = parseInt(coupon.ownerUid, 10);
360
+ const cost = parseInt(coupon.cost || 0, 10);
361
+
362
+ // ✅ puan yeter mi? (scan anında kontrol)
363
+ const pts = parseInt((await user.getUserField(customerUid, 'niki_points')) || 0, 10);
364
+ if (!TEST_MODE_UNLIMITED && pts < cost) {
365
+ return res.json({ success: false, message: 'Yetersiz bakiye (puan değişmiş olabilir)' });
366
+ }
367
+
368
+ // ✅ puan düş (asıl düşüş burada)
369
+ if (!TEST_MODE_UNLIMITED && cost > 0) {
370
+ await user.decrementUserFieldBy(customerUid, 'niki_points', cost);
371
+ }
372
+
373
+ // ✅ tek kullanımlık: kuponu sil
374
+ await db.delete(`niki:coupon:${token}`);
375
+ // ✅ rezervasyonu da sil
376
+ await db.delete(`niki:reserve:${customerUid}:${coupon.rewardId}`);
377
+
378
+ const customerData = await user.getUserFields(customerUid, ['username', 'picture', 'userslug']);
379
+
380
+ // user log (gerçek spend burada)
381
+ await addUserLog(customerUid, 'spend', cost, `Kullanıldı: ${coupon.title}`, { rewardId: coupon.rewardId });
382
+
383
+ // kasa log
384
+ await addKasaLog(req.uid, customerData?.username || 'Bilinmeyen', customerUid, cost, coupon.rewardId, coupon.title);
385
+
386
+ return res.json({
387
+ success: true,
388
+ customer: customerData,
389
+ coupon: {
390
+ rewardId: coupon.rewardId,
391
+ title: coupon.title,
392
+ type: coupon.type,
393
+ meta: coupon.meta,
394
+ cost,
395
+ },
396
+ message: 'Kupon onaylandı!',
397
+ });
398
+ } catch (err) {
399
+ return res.status(500).json({ success: false, message: 'Sunucu hatası' });
400
+ }
401
+ });
402
+
403
+ // -------------------------
404
+ // 6) (ESKİ) QR OLUŞTUR / OKUT — opsiyonel uyumluluk
405
+ // İstersen tamamen kaldırırız. Şimdilik 250 "Ücretsiz Kahve" gibi düşünebilirsin.
406
+ // -------------------------
223
407
  router.post('/api/niki-loyalty/generate-qr', middleware.ensureLoggedIn, async (req, res) => {
224
408
  try {
225
409
  const uid = req.uid;
@@ -229,7 +413,7 @@ router.get('/api/niki-loyalty/kasa-history', middleware.ensureLoggedIn, async (r
229
413
  return res.json({ success: false, message: 'Yetersiz Puan' });
230
414
  }
231
415
 
232
- const token = Math.random().toString(36).substring(2) + Date.now().toString(36);
416
+ const token = makeToken();
233
417
 
234
418
  await db.set(`niki:qr:${token}`, uid);
235
419
  await db.expire(`niki:qr:${token}`, 120);
@@ -240,14 +424,12 @@ router.get('/api/niki-loyalty/kasa-history', middleware.ensureLoggedIn, async (r
240
424
  }
241
425
  });
242
426
 
243
- // 5) QR OKUT (admin/mod)
244
427
  router.post('/api/niki-loyalty/scan-qr', middleware.ensureLoggedIn, async (req, res) => {
245
428
  try {
246
429
  const token = (req.body && req.body.token) ? String(req.body.token) : '';
247
430
 
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' });
431
+ const staffOk = await isStaff(req.uid);
432
+ if (!staffOk) return res.status(403).json({ success: false, message: 'Yetkisiz' });
251
433
 
252
434
  const customerUid = await db.get(`niki:qr:${token}`);
253
435
  if (!customerUid) return res.json({ success: false, message: 'Geçersiz Kod' });
@@ -257,22 +439,16 @@ router.get('/api/niki-loyalty/kasa-history', middleware.ensureLoggedIn, async (r
257
439
  return res.json({ success: false, message: 'Yetersiz Bakiye' });
258
440
  }
259
441
 
260
- // ✅ puan düşür
261
442
  if (!TEST_MODE_UNLIMITED) {
262
443
  await user.decrementUserFieldBy(customerUid, 'niki_points', SETTINGS.coffeeCost);
263
444
  }
264
445
 
265
- // token tek kullanımlık
266
446
  await db.delete(`niki:qr:${token}`);
267
447
 
268
- // müşteri bilgisi
269
448
  const customerData = await user.getUserFields(customerUid, ['username', 'picture', 'userslug']);
270
449
 
271
- // user log
272
450
  await addUserLog(customerUid, 'spend', SETTINGS.coffeeCost, 'Kahve Keyfi ☕');
273
-
274
- // kasa log
275
- await addKasaLog(req.uid, customerData?.username || 'Bilinmeyen', customerUid);
451
+ await addKasaLog(req.uid, customerData?.username || 'Bilinmeyen', customerUid, SETTINGS.coffeeCost, 'coffee', 'Ücretsiz Kahve (QR)');
276
452
 
277
453
  return res.json({
278
454
  success: true,
@@ -284,15 +460,25 @@ router.get('/api/niki-loyalty/kasa-history', middleware.ensureLoggedIn, async (r
284
460
  }
285
461
  });
286
462
 
287
- // 6) SAYFA ROTASI (kasa sayfası)
463
+ // -------------------------
464
+ // 7) SAYFA ROTASI (kasa sayfası)
465
+ // -------------------------
288
466
  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', {});
467
+ const staffOk = await isStaff(req.uid);
468
+ if (!staffOk) return res.render('403', {});
292
469
  return res.render('niki-kasa', { title: 'Niki Kasa' });
293
470
  });
294
471
  };
295
472
 
473
+ // -------------------------
474
+ // ✅ plugin.json'da action:topic.get -> checkTopicVisit vardı.
475
+ // Method yoksa NodeBB şikayet eder. Şimdilik NO-OP koydum.
476
+ // Sonra istersen gerçekten "topic view" ile puan ekleriz.
477
+ // -------------------------
478
+ Plugin.checkTopicVisit = async function () {
479
+ return;
480
+ };
481
+
296
482
  // client.js inject
297
483
  Plugin.addScripts = async function (scripts) {
298
484
  scripts.push('plugins/nodebb-plugin-niki-loyalty/static/lib/client.js');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-niki-loyalty",
3
- "version": "1.0.21",
3
+ "version": "1.0.23",
4
4
  "description": "Niki The Cat Coffee Loyalty System - Earn points while studying on IEU Forum.",
5
5
  "main": "library.js",
6
6
  "nbbpm": {