nodebb-plugin-calendar-onekite 1.3.9 → 1.4.2
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
|
@@ -1,758 +1,856 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
:
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
await db.
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
const
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
)
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
if (!
|
|
162
|
-
|
|
163
|
-
const
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
if (!
|
|
174
|
-
|
|
175
|
-
const
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
const
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
const
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
});
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
const eid = req.params.eid;
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
const event = await getEvent(eid);
|
|
369
|
-
if (!event) return res.status(404).json({ error: 'Événement introuvable' });
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
res.json({
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
const
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
const
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
res.
|
|
633
|
-
}
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
const
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
.
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
}
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* NodeBB v4 plugin: Calendar OneKite
|
|
5
|
+
* - Events + booking items (with pickupLocation per item)
|
|
6
|
+
* - Multi-day reservations
|
|
7
|
+
* - Admin validation -> HelloAsso checkout link -> payment webhook -> mark paid
|
|
8
|
+
* - Admin planning view + "My reservations" page
|
|
9
|
+
* - Settings:
|
|
10
|
+
* - Settings key: "calendar-onekite"
|
|
11
|
+
* - Admin page: /admin/plugins/calendar-onekite
|
|
12
|
+
* - Admin API: /api/admin/plugins/calendar-onekite
|
|
13
|
+
*
|
|
14
|
+
* Templates expected:
|
|
15
|
+
* - templates/calendar.tpl
|
|
16
|
+
* - templates/calendar-my-reservations.tpl
|
|
17
|
+
* - templates/admin/plugins/calendar-onekite.tpl
|
|
18
|
+
* - templates/admin/calendar-planning.tpl
|
|
19
|
+
* - templates/widgets/calendar-upcoming.tpl
|
|
20
|
+
* - templates/emails/calendar-reservation-created.tpl
|
|
21
|
+
* - templates/emails/calendar-reservation-approved.tpl
|
|
22
|
+
* - templates/emails/calendar-payment-confirmed.tpl
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const db = require.main.require('./src/database');
|
|
26
|
+
const user = require.main.require('./src/user');
|
|
27
|
+
const meta = require.main.require('./src/meta');
|
|
28
|
+
const emailer = require.main.require('./src/emailer');
|
|
29
|
+
|
|
30
|
+
const Settings = meta.settings;
|
|
31
|
+
const helloAsso = require('./helloasso');
|
|
32
|
+
|
|
33
|
+
const Plugin = {};
|
|
34
|
+
let appRef = null;
|
|
35
|
+
|
|
36
|
+
const SETTINGS_KEY = 'calendar-onekite';
|
|
37
|
+
const EVENTS_SET_KEY = 'calendar:events:start';
|
|
38
|
+
const EVENT_KEY_PREFIX = 'calendar:event:';
|
|
39
|
+
|
|
40
|
+
function getEventKey(eid) {
|
|
41
|
+
return EVENT_KEY_PREFIX + eid;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function nextReservationId() {
|
|
45
|
+
const rid = await db.incrObjectField('global', 'nextCalendarRid');
|
|
46
|
+
return String(rid);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// number of days between two yyyy-mm-dd dates (inclusive)
|
|
50
|
+
function daysBetween(start, end) {
|
|
51
|
+
const d1 = new Date(start);
|
|
52
|
+
const d2 = new Date(end);
|
|
53
|
+
if (isNaN(d1.getTime()) || isNaN(d2.getTime())) return 1;
|
|
54
|
+
|
|
55
|
+
const t1 = Date.UTC(d1.getFullYear(), d1.getMonth(), d1.getDate());
|
|
56
|
+
const t2 = Date.UTC(d2.getFullYear(), d2.getMonth(), d2.getDate());
|
|
57
|
+
const diff = Math.round((t2 - t1) / (1000 * 60 * 60 * 24));
|
|
58
|
+
return Math.max(1, diff + 1);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/* -----------------------------
|
|
62
|
+
* Events
|
|
63
|
+
* ---------------------------- */
|
|
64
|
+
|
|
65
|
+
async function createEvent(data, uid) {
|
|
66
|
+
const eid = await db.incrObjectField('global', 'nextCalendarEid');
|
|
67
|
+
const key = getEventKey(eid);
|
|
68
|
+
|
|
69
|
+
const now = Date.now();
|
|
70
|
+
const startTs = Number(new Date(data.start).getTime()) || now;
|
|
71
|
+
const endTs = Number(new Date(data.end).getTime()) || startTs;
|
|
72
|
+
|
|
73
|
+
const bookingItems = Array.isArray(data.bookingItems) ? data.bookingItems : [];
|
|
74
|
+
|
|
75
|
+
const eventObj = {
|
|
76
|
+
eid: String(eid),
|
|
77
|
+
title: String(data.title || ''),
|
|
78
|
+
description: String(data.description || ''),
|
|
79
|
+
start: new Date(startTs).toISOString(),
|
|
80
|
+
end: new Date(endTs).toISOString(),
|
|
81
|
+
allDay: data.allDay ? 1 : 0,
|
|
82
|
+
location: String(data.location || ''),
|
|
83
|
+
createdByUid: String(uid || 0),
|
|
84
|
+
createdAt: String(now),
|
|
85
|
+
updatedAt: String(now),
|
|
86
|
+
|
|
87
|
+
rsvpYes: '[]',
|
|
88
|
+
rsvpMaybe: '[]',
|
|
89
|
+
rsvpNo: '[]',
|
|
90
|
+
|
|
91
|
+
visibility: String(data.visibility || 'public'),
|
|
92
|
+
|
|
93
|
+
bookingEnabled: data.bookingEnabled ? 1 : 0,
|
|
94
|
+
bookingItems: JSON.stringify(bookingItems),
|
|
95
|
+
reservations: JSON.stringify([]),
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
await db.setObject(key, eventObj);
|
|
99
|
+
await db.sortedSetAdd(EVENTS_SET_KEY, startTs, eid);
|
|
100
|
+
return eventObj;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function getEvent(eid) {
|
|
104
|
+
return db.getObject(getEventKey(eid));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function updateEvent(eid, data) {
|
|
108
|
+
const key = getEventKey(eid);
|
|
109
|
+
const existing = await db.getObject(key);
|
|
110
|
+
if (!existing) throw new Error('Event not found');
|
|
111
|
+
|
|
112
|
+
const startTs = data.start ? new Date(data.start).getTime() : new Date(existing.start).getTime();
|
|
113
|
+
const endTs = data.end ? new Date(data.end).getTime() : new Date(existing.end).getTime();
|
|
114
|
+
|
|
115
|
+
const bookingItems = Array.isArray(data.bookingItems)
|
|
116
|
+
? data.bookingItems
|
|
117
|
+
: JSON.parse(existing.bookingItems || '[]');
|
|
118
|
+
|
|
119
|
+
const updated = {
|
|
120
|
+
...existing,
|
|
121
|
+
title: data.title !== undefined ? String(data.title) : existing.title,
|
|
122
|
+
description: data.description !== undefined ? String(data.description) : existing.description,
|
|
123
|
+
start: new Date(startTs).toISOString(),
|
|
124
|
+
end: new Date(endTs).toISOString(),
|
|
125
|
+
allDay: data.allDay !== undefined ? (data.allDay ? 1 : 0) : existing.allDay,
|
|
126
|
+
location: data.location !== undefined ? String(data.location) : existing.location,
|
|
127
|
+
visibility: data.visibility !== undefined ? String(data.visibility) : existing.visibility,
|
|
128
|
+
bookingEnabled: data.bookingEnabled !== undefined ? (data.bookingEnabled ? 1 : 0) : (existing.bookingEnabled || 0),
|
|
129
|
+
bookingItems: JSON.stringify(bookingItems),
|
|
130
|
+
updatedAt: String(Date.now()),
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
await db.setObject(key, updated);
|
|
134
|
+
await db.sortedSetAdd(EVENTS_SET_KEY, startTs, eid);
|
|
135
|
+
return updated;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function deleteEvent(eid) {
|
|
139
|
+
await db.sortedSetRemove(EVENTS_SET_KEY, eid);
|
|
140
|
+
await db.delete(getEventKey(eid));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function getEventsBetween(start, end) {
|
|
144
|
+
const startTs = new Date(start).getTime();
|
|
145
|
+
const endTs = new Date(end).getTime();
|
|
146
|
+
const eids = await db.getSortedSetRangeByScore(EVENTS_SET_KEY, 0, -1, startTs, endTs);
|
|
147
|
+
if (!eids || !eids.length) return [];
|
|
148
|
+
|
|
149
|
+
const keys = eids.map(eid => getEventKey(eid));
|
|
150
|
+
const events = await db.getObjects(keys);
|
|
151
|
+
|
|
152
|
+
return (events || []).filter(Boolean).map(ev => ({
|
|
153
|
+
...ev,
|
|
154
|
+
bookingItems: JSON.parse(ev.bookingItems || '[]'),
|
|
155
|
+
}));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function getUpcomingEvents(limit = 5) {
|
|
159
|
+
const now = Date.now();
|
|
160
|
+
const eids = await db.getSortedSetRangeByScore(EVENTS_SET_KEY, 0, limit - 1, now, '+inf');
|
|
161
|
+
if (!eids || !eids.length) return [];
|
|
162
|
+
|
|
163
|
+
const keys = eids.map(eid => getEventKey(eid));
|
|
164
|
+
const events = await db.getObjects(keys);
|
|
165
|
+
return (events || []).filter(Boolean);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/* -----------------------------
|
|
169
|
+
* Permissions
|
|
170
|
+
* ---------------------------- */
|
|
171
|
+
|
|
172
|
+
async function userCanCreate(uid) {
|
|
173
|
+
if (!uid || uid === 0) return false;
|
|
174
|
+
|
|
175
|
+
const settings = await Settings.get(SETTINGS_KEY);
|
|
176
|
+
if (!settings || !settings.allowedGroups) return false;
|
|
177
|
+
|
|
178
|
+
const allowedSet = new Set(
|
|
179
|
+
settings.allowedGroups
|
|
180
|
+
.split(',')
|
|
181
|
+
.map(g => g.trim().toLowerCase())
|
|
182
|
+
.filter(Boolean)
|
|
183
|
+
);
|
|
184
|
+
if (!allowedSet.size) return false;
|
|
185
|
+
|
|
186
|
+
const userGroupsArr = await user.getUserGroups([uid]);
|
|
187
|
+
const groups = (userGroupsArr[0] || []).map(g => (g.name || '').toLowerCase());
|
|
188
|
+
|
|
189
|
+
return groups.some(g => allowedSet.has(g));
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function userCanBook(uid) {
|
|
193
|
+
if (!uid || uid === 0) return false;
|
|
194
|
+
|
|
195
|
+
const settings = await Settings.get(SETTINGS_KEY);
|
|
196
|
+
// if not configured -> allow any logged-in user
|
|
197
|
+
if (!settings || !settings.allowedBookingGroups) return true;
|
|
198
|
+
|
|
199
|
+
const allowedSet = new Set(
|
|
200
|
+
settings.allowedBookingGroups
|
|
201
|
+
.split(',')
|
|
202
|
+
.map(g => g.trim().toLowerCase())
|
|
203
|
+
.filter(Boolean)
|
|
204
|
+
);
|
|
205
|
+
if (!allowedSet.size) return true;
|
|
206
|
+
|
|
207
|
+
const userGroupsArr = await user.getUserGroups([uid]);
|
|
208
|
+
const groups = (userGroupsArr[0] || []).map(g => (g.name || '').toLowerCase());
|
|
209
|
+
|
|
210
|
+
return groups.some(g => allowedSet.has(g));
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/* -----------------------------
|
|
214
|
+
* RSVP
|
|
215
|
+
* ---------------------------- */
|
|
216
|
+
|
|
217
|
+
async function setRsvp(eid, uid, status) {
|
|
218
|
+
const key = getEventKey(eid);
|
|
219
|
+
const event = await db.getObject(key);
|
|
220
|
+
if (!event) throw new Error('Event not found');
|
|
221
|
+
|
|
222
|
+
const parseList = (str) => {
|
|
223
|
+
try { return JSON.parse(str || '[]'); } catch (e) { return []; }
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
let yes = parseList(event.rsvpYes);
|
|
227
|
+
let maybe = parseList(event.rsvpMaybe);
|
|
228
|
+
let no = parseList(event.rsvpNo);
|
|
229
|
+
const u = String(uid);
|
|
230
|
+
|
|
231
|
+
yes = yes.filter(id => id !== u);
|
|
232
|
+
maybe = maybe.filter(id => id !== u);
|
|
233
|
+
no = no.filter(id => id !== u);
|
|
234
|
+
|
|
235
|
+
if (status === 'yes') yes.push(u);
|
|
236
|
+
if (status === 'maybe') maybe.push(u);
|
|
237
|
+
if (status === 'no') no.push(u);
|
|
238
|
+
|
|
239
|
+
const updated = {
|
|
240
|
+
...event,
|
|
241
|
+
rsvpYes: JSON.stringify(yes),
|
|
242
|
+
rsvpMaybe: JSON.stringify(maybe),
|
|
243
|
+
rsvpNo: JSON.stringify(no),
|
|
244
|
+
updatedAt: String(Date.now()),
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
await db.setObject(key, updated);
|
|
248
|
+
return updated;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/* -----------------------------
|
|
252
|
+
* Pricing
|
|
253
|
+
* ---------------------------- */
|
|
254
|
+
|
|
255
|
+
function computePrice(event, reservation) {
|
|
256
|
+
const items = JSON.parse(event.bookingItems || '[]');
|
|
257
|
+
const item = items.find(i => i.id === reservation.itemId);
|
|
258
|
+
if (!item) return 0;
|
|
259
|
+
|
|
260
|
+
const unit = Number(item.price || 0);
|
|
261
|
+
const days = Number(reservation.days || 1);
|
|
262
|
+
return unit * Number(reservation.quantity || 0) * days;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/* -----------------------------
|
|
266
|
+
* Renderers (pages)
|
|
267
|
+
* ---------------------------- */
|
|
268
|
+
|
|
269
|
+
function renderCalendarPage(req, res) {
|
|
270
|
+
res.render('calendar', { title: 'Calendrier' });
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function renderMyReservationsPage(req, res) {
|
|
274
|
+
res.render('calendar-my-reservations', { title: 'Mes réservations' });
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function renderPlanningPage(req, res) {
|
|
278
|
+
res.render('admin/calendar-planning', { title: 'Planning des réservations' });
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async function renderAdminPage(req, res) {
|
|
282
|
+
try {
|
|
283
|
+
const settings = (await Settings.get(SETTINGS_KEY)) || {};
|
|
284
|
+
res.render('admin/plugins/calendar-onekite', {
|
|
285
|
+
title: 'Calendar OneKite',
|
|
286
|
+
settings,
|
|
287
|
+
});
|
|
288
|
+
} catch (err) {
|
|
289
|
+
if (req.path && req.path.startsWith('/api/')) {
|
|
290
|
+
return res.status(500).json({ error: err.message });
|
|
291
|
+
}
|
|
292
|
+
res.status(500).send(err.message);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/* -----------------------------
|
|
297
|
+
* Plugin init
|
|
298
|
+
* ---------------------------- */
|
|
299
|
+
|
|
300
|
+
Plugin.init = async function (params) {
|
|
301
|
+
const { router, middleware } = params;
|
|
302
|
+
appRef = params.app;
|
|
303
|
+
|
|
304
|
+
// Public pages
|
|
305
|
+
router.get('/calendar', middleware.buildHeader, renderCalendarPage);
|
|
306
|
+
router.get('/api/calendar', renderCalendarPage);
|
|
307
|
+
|
|
308
|
+
router.get('/calendar/my-reservations', middleware.buildHeader, renderMyReservationsPage);
|
|
309
|
+
router.get('/api/calendar/my-reservations/page', renderMyReservationsPage);
|
|
310
|
+
|
|
311
|
+
// Admin planning page
|
|
312
|
+
router.get('/admin/calendar/planning', middleware.admin.buildHeader, renderPlanningPage);
|
|
313
|
+
router.get('/api/admin/calendar/planning/page', renderPlanningPage);
|
|
314
|
+
|
|
315
|
+
/* -----------------------------
|
|
316
|
+
* Events API
|
|
317
|
+
* ---------------------------- */
|
|
318
|
+
|
|
319
|
+
router.get('/api/calendar/events', async (req, res) => {
|
|
320
|
+
try {
|
|
321
|
+
const { start, end } = req.query;
|
|
322
|
+
if (!start || !end) return res.status(400).json({ error: 'Missing start/end' });
|
|
323
|
+
const events = await getEventsBetween(start, end);
|
|
324
|
+
res.json(events);
|
|
325
|
+
} catch (err) {
|
|
326
|
+
res.status(500).json({ error: err.message });
|
|
327
|
+
}
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
router.post('/api/calendar/event', middleware.ensureLoggedIn, async (req, res) => {
|
|
331
|
+
try {
|
|
332
|
+
const uid = req.user?.uid || 0;
|
|
333
|
+
if (!await userCanCreate(uid)) return res.status(403).json({ error: 'Permission refusée' });
|
|
334
|
+
const event = await createEvent(req.body, uid);
|
|
335
|
+
res.json(event);
|
|
336
|
+
} catch (err) {
|
|
337
|
+
res.status(500).json({ error: err.message });
|
|
338
|
+
}
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
router.put('/api/calendar/event/:eid', middleware.ensureLoggedIn, async (req, res) => {
|
|
342
|
+
try {
|
|
343
|
+
const uid = req.user?.uid || 0;
|
|
344
|
+
if (!await userCanCreate(uid)) return res.status(403).json({ error: 'Permission refusée' });
|
|
345
|
+
const eid = req.params.eid;
|
|
346
|
+
const event = await updateEvent(eid, req.body);
|
|
347
|
+
res.json(event);
|
|
348
|
+
} catch (err) {
|
|
349
|
+
res.status(500).json({ error: err.message });
|
|
350
|
+
}
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
router.delete('/api/calendar/event/:eid', middleware.ensureLoggedIn, async (req, res) => {
|
|
354
|
+
try {
|
|
355
|
+
const uid = req.user?.uid || 0;
|
|
356
|
+
if (!await userCanCreate(uid)) return res.status(403).json({ error: 'Permission refusée' });
|
|
357
|
+
const eid = req.params.eid;
|
|
358
|
+
await deleteEvent(eid);
|
|
359
|
+
res.json({ status: 'ok' });
|
|
360
|
+
} catch (err) {
|
|
361
|
+
res.status(500).json({ error: err.message });
|
|
362
|
+
}
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
router.get('/api/calendar/event/:eid', async (req, res) => {
|
|
366
|
+
try {
|
|
367
|
+
const eid = req.params.eid;
|
|
368
|
+
const event = await getEvent(eid);
|
|
369
|
+
if (!event) return res.status(404).json({ error: 'Événement introuvable' });
|
|
370
|
+
const items = JSON.parse(event.bookingItems || '[]');
|
|
371
|
+
const reservations = JSON.parse(event.reservations || '[]');
|
|
372
|
+
res.json({ ...event, bookingItems: items, reservations });
|
|
373
|
+
} catch (err) {
|
|
374
|
+
res.status(500).json({ error: err.message });
|
|
375
|
+
}
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
/* -----------------------------
|
|
379
|
+
* RSVP
|
|
380
|
+
* ---------------------------- */
|
|
381
|
+
|
|
382
|
+
router.post('/api/calendar/event/:eid/rsvp', middleware.ensureLoggedIn, async (req, res) => {
|
|
383
|
+
try {
|
|
384
|
+
const eid = req.params.eid;
|
|
385
|
+
const uid = req.user?.uid || 0;
|
|
386
|
+
const status = req.body.status;
|
|
387
|
+
const event = await setRsvp(eid, uid, status);
|
|
388
|
+
res.json(event);
|
|
389
|
+
} catch (err) {
|
|
390
|
+
res.status(500).json({ error: err.message });
|
|
391
|
+
}
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
/* -----------------------------
|
|
395
|
+
* Client permissions endpoints
|
|
396
|
+
* ---------------------------- */
|
|
397
|
+
|
|
398
|
+
router.get('/api/calendar/permissions/create', async (req, res) => {
|
|
399
|
+
const uid = req.user?.uid || 0;
|
|
400
|
+
const allow = await userCanCreate(uid);
|
|
401
|
+
res.json({ allow });
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
router.get('/api/calendar/permissions/book', async (req, res) => {
|
|
405
|
+
const uid = req.user?.uid || 0;
|
|
406
|
+
const allow = await userCanBook(uid);
|
|
407
|
+
res.json({ allow });
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
/* -----------------------------
|
|
411
|
+
* Multi-day reservation request
|
|
412
|
+
* ---------------------------- */
|
|
413
|
+
|
|
414
|
+
router.post('/api/calendar/event/:eid/book', middleware.ensureLoggedIn, async (req, res) => {
|
|
415
|
+
try {
|
|
416
|
+
const uid = req.user?.uid || 0;
|
|
417
|
+
const eid = req.params.eid;
|
|
418
|
+
const { itemId, quantity, dateStart, dateEnd } = req.body;
|
|
419
|
+
|
|
420
|
+
if (!await userCanBook(uid)) {
|
|
421
|
+
return res.status(403).json({ error: 'Vous n’êtes pas autorisé à réserver du matériel.' });
|
|
422
|
+
}
|
|
423
|
+
if (!dateStart || !dateEnd) {
|
|
424
|
+
return res.status(400).json({ error: 'Dates de début et de fin obligatoires.' });
|
|
425
|
+
}
|
|
426
|
+
if (String(dateEnd) < String(dateStart)) {
|
|
427
|
+
return res.status(400).json({ error: 'La date de fin doit être ≥ la date de début.' });
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const event = await getEvent(eid);
|
|
431
|
+
if (!event) return res.status(404).json({ error: 'Événement introuvable' });
|
|
432
|
+
if (!Number(event.bookingEnabled)) return res.status(400).json({ error: 'Réservation désactivée' });
|
|
433
|
+
|
|
434
|
+
const items = JSON.parse(event.bookingItems || '[]');
|
|
435
|
+
const item = items.find(i => i.id === itemId);
|
|
436
|
+
if (!item) return res.status(400).json({ error: 'Matériel introuvable' });
|
|
437
|
+
|
|
438
|
+
const q = Number(quantity);
|
|
439
|
+
if (!q || q <= 0) return res.status(400).json({ error: 'Quantité invalide' });
|
|
440
|
+
|
|
441
|
+
const allRes = JSON.parse(event.reservations || '[]');
|
|
442
|
+
|
|
443
|
+
// Stock check: sum overlapping reservations (pending_admin/awaiting_payment/paid) for the same item
|
|
444
|
+
const overlapping = allRes.filter(r => {
|
|
445
|
+
if (r.itemId !== itemId) return false;
|
|
446
|
+
if (r.status === 'cancelled') return false;
|
|
447
|
+
|
|
448
|
+
const startR = new Date(r.dateStart);
|
|
449
|
+
const endR = new Date(r.dateEnd);
|
|
450
|
+
const startN = new Date(dateStart);
|
|
451
|
+
const endN = new Date(dateEnd);
|
|
452
|
+
|
|
453
|
+
return !(endN < startR || startN > endR);
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
const used = overlapping.reduce((sum, r) => sum + Number(r.quantity || 0), 0);
|
|
457
|
+
const available = Number(item.total || 0) - used;
|
|
458
|
+
if (q > available) return res.status(400).json({ error: 'Matériel indisponible pour ces dates.' });
|
|
459
|
+
|
|
460
|
+
const rid = await nextReservationId();
|
|
461
|
+
const now = Date.now();
|
|
462
|
+
const nbDays = daysBetween(dateStart, dateEnd);
|
|
463
|
+
|
|
464
|
+
const reservation = {
|
|
465
|
+
rid,
|
|
466
|
+
eid: String(eid),
|
|
467
|
+
uid: String(uid),
|
|
468
|
+
itemId: String(itemId),
|
|
469
|
+
quantity: q,
|
|
470
|
+
dateStart,
|
|
471
|
+
dateEnd,
|
|
472
|
+
days: nbDays,
|
|
473
|
+
status: 'pending_admin',
|
|
474
|
+
helloAssoOrderId: null,
|
|
475
|
+
createdAt: now,
|
|
476
|
+
pickupLocation: String(item.pickupLocation || ''),
|
|
477
|
+
};
|
|
478
|
+
|
|
479
|
+
allRes.push(reservation);
|
|
480
|
+
event.bookingItems = JSON.stringify(items);
|
|
481
|
+
event.reservations = JSON.stringify(allRes);
|
|
482
|
+
|
|
483
|
+
await db.setObject(getEventKey(eid), event);
|
|
484
|
+
|
|
485
|
+
// Email: request created
|
|
486
|
+
try {
|
|
487
|
+
await emailer.send('calendar-reservation-created', uid, {
|
|
488
|
+
subject: 'Votre demande de réservation a été envoyée',
|
|
489
|
+
eventTitle: event.title,
|
|
490
|
+
item: item.name,
|
|
491
|
+
quantity: reservation.quantity,
|
|
492
|
+
date: new Date().toLocaleString('fr-FR'),
|
|
493
|
+
dateStart,
|
|
494
|
+
dateEnd,
|
|
495
|
+
days: nbDays,
|
|
496
|
+
pickupLocation: reservation.pickupLocation || 'Non précisé',
|
|
497
|
+
});
|
|
498
|
+
} catch (e) {
|
|
499
|
+
console.warn('[calendar-onekite] email reservation-created error:', e.message);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
res.json({
|
|
503
|
+
success: true,
|
|
504
|
+
status: 'pending_admin',
|
|
505
|
+
message: 'Votre demande de réservation a été envoyée. Elle doit être validée par un administrateur.',
|
|
506
|
+
});
|
|
507
|
+
} catch (err) {
|
|
508
|
+
res.status(500).json({ error: err.message });
|
|
509
|
+
}
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
/* -----------------------------
|
|
513
|
+
* Admin: pending list
|
|
514
|
+
* ---------------------------- */
|
|
515
|
+
|
|
516
|
+
router.get('/api/admin/calendar/pending', middleware.admin.checkPrivileges, async (req, res) => {
|
|
517
|
+
try {
|
|
518
|
+
const result = [];
|
|
519
|
+
const eids = await db.getSortedSetRange(EVENTS_SET_KEY, 0, -1);
|
|
520
|
+
|
|
521
|
+
for (const eid of eids) {
|
|
522
|
+
const event = await getEvent(eid);
|
|
523
|
+
if (!event) continue;
|
|
524
|
+
|
|
525
|
+
const reservations = JSON.parse(event.reservations || '[]');
|
|
526
|
+
const pending = reservations.filter(r => r.status === 'pending_admin');
|
|
527
|
+
if (pending.length) result.push({ event, reservations: pending });
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
res.json(result);
|
|
531
|
+
} catch (err) {
|
|
532
|
+
res.status(500).json({ error: err.message });
|
|
533
|
+
}
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
/* -----------------------------
|
|
537
|
+
* Admin: validate reservation -> awaiting_payment + HelloAsso checkout
|
|
538
|
+
* ---------------------------- */
|
|
539
|
+
|
|
540
|
+
router.post('/api/admin/calendar/reservation/:rid/validate', middleware.admin.checkPrivileges, async (req, res) => {
|
|
541
|
+
try {
|
|
542
|
+
const rid = req.params.rid;
|
|
543
|
+
const eids = await db.getSortedSetRange(EVENTS_SET_KEY, 0, -1);
|
|
544
|
+
|
|
545
|
+
let targetEvent = null;
|
|
546
|
+
let reservation = null;
|
|
547
|
+
|
|
548
|
+
for (const eid of eids) {
|
|
549
|
+
const event = await getEvent(eid);
|
|
550
|
+
if (!event) continue;
|
|
551
|
+
|
|
552
|
+
const reservations = JSON.parse(event.reservations || '[]');
|
|
553
|
+
const r = reservations.find(rr => rr.rid === rid);
|
|
554
|
+
if (r) {
|
|
555
|
+
targetEvent = event;
|
|
556
|
+
reservation = r;
|
|
557
|
+
break;
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
if (!reservation) return res.status(404).json({ error: 'Réservation introuvable' });
|
|
562
|
+
if (reservation.status !== 'pending_admin') return res.status(400).json({ error: 'Réservation déjà traitée' });
|
|
563
|
+
|
|
564
|
+
reservation.status = 'awaiting_payment';
|
|
565
|
+
|
|
566
|
+
const resList = JSON.parse(targetEvent.reservations || '[]');
|
|
567
|
+
const idx = resList.findIndex(r => r.rid === rid);
|
|
568
|
+
resList[idx] = reservation;
|
|
569
|
+
targetEvent.reservations = JSON.stringify(resList);
|
|
570
|
+
|
|
571
|
+
await db.setObject(getEventKey(targetEvent.eid), targetEvent);
|
|
572
|
+
|
|
573
|
+
const amount = computePrice(targetEvent, reservation);
|
|
574
|
+
const checkoutUrl = await helloAsso.createHelloAssoCheckoutIntent({
|
|
575
|
+
eid: reservation.eid,
|
|
576
|
+
rid,
|
|
577
|
+
uid: reservation.uid,
|
|
578
|
+
itemId: reservation.itemId,
|
|
579
|
+
quantity: reservation.quantity,
|
|
580
|
+
amount,
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
try {
|
|
584
|
+
await emailer.send('calendar-reservation-approved', reservation.uid, {
|
|
585
|
+
subject: 'Votre réservation a été validée',
|
|
586
|
+
eventTitle: targetEvent.title,
|
|
587
|
+
itemName: reservation.itemId,
|
|
588
|
+
quantity: reservation.quantity,
|
|
589
|
+
checkoutUrl,
|
|
590
|
+
pickupLocation: reservation.pickupLocation || 'Non précisé',
|
|
591
|
+
dateStart: reservation.dateStart,
|
|
592
|
+
dateEnd: reservation.dateEnd,
|
|
593
|
+
days: reservation.days || 1,
|
|
594
|
+
});
|
|
595
|
+
} catch (e) {
|
|
596
|
+
console.warn('[calendar-onekite] email reservation-approved error:', e.message);
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
res.json({ success: true, checkoutUrl });
|
|
600
|
+
} catch (err) {
|
|
601
|
+
res.status(500).json({ error: err.message });
|
|
602
|
+
}
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
/* -----------------------------
|
|
606
|
+
* Admin: cancel reservation
|
|
607
|
+
* ---------------------------- */
|
|
608
|
+
|
|
609
|
+
router.post('/api/admin/calendar/reservation/:rid/cancel', middleware.admin.checkPrivileges, async (req, res) => {
|
|
610
|
+
try {
|
|
611
|
+
const rid = req.params.rid;
|
|
612
|
+
const eids = await db.getSortedSetRange(EVENTS_SET_KEY, 0, -1);
|
|
613
|
+
|
|
614
|
+
let found = false;
|
|
615
|
+
|
|
616
|
+
for (const eid of eids) {
|
|
617
|
+
const event = await getEvent(eid);
|
|
618
|
+
if (!event) continue;
|
|
619
|
+
|
|
620
|
+
const reservations = JSON.parse(event.reservations || '[]');
|
|
621
|
+
const rIndex = reservations.findIndex(rr => rr.rid === rid);
|
|
622
|
+
if (rIndex !== -1) {
|
|
623
|
+
reservations[rIndex].status = 'cancelled';
|
|
624
|
+
event.reservations = JSON.stringify(reservations);
|
|
625
|
+
await db.setObject(getEventKey(event.eid), event);
|
|
626
|
+
found = true;
|
|
627
|
+
break;
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
if (!found) return res.status(404).json({ error: 'Réservation introuvable' });
|
|
632
|
+
res.json({ success: true });
|
|
633
|
+
} catch (err) {
|
|
634
|
+
res.status(500).json({ error: err.message });
|
|
635
|
+
}
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
/* -----------------------------
|
|
639
|
+
* HelloAsso webhook (marks reservation paid)
|
|
640
|
+
* ---------------------------- */
|
|
641
|
+
|
|
642
|
+
router.post('/api/calendar/helloasso/webhook', async (req, res) => {
|
|
643
|
+
try {
|
|
644
|
+
const payload = req.body;
|
|
645
|
+
const order = payload.order || payload;
|
|
646
|
+
|
|
647
|
+
const orderId = String(order.id || '');
|
|
648
|
+
const state = order.state || order.status || '';
|
|
649
|
+
if (state !== 'Paid') return res.json({ ignored: true });
|
|
650
|
+
|
|
651
|
+
const custom = order.customFields || {};
|
|
652
|
+
const eid = String(custom.eid || '');
|
|
653
|
+
const rid = String(custom.rid || '');
|
|
654
|
+
|
|
655
|
+
if (!eid || !rid) return res.status(400).json({ error: 'Missing eid/rid in customFields' });
|
|
656
|
+
|
|
657
|
+
const event = await getEvent(eid);
|
|
658
|
+
if (!event) throw new Error('Event not found');
|
|
659
|
+
|
|
660
|
+
let reservations = JSON.parse(event.reservations || '[]');
|
|
661
|
+
const rIndex = reservations.findIndex(r => r.rid === rid);
|
|
662
|
+
if (rIndex === -1) throw new Error('Reservation not found');
|
|
663
|
+
|
|
664
|
+
const reservation = reservations[rIndex];
|
|
665
|
+
if (reservation.status === 'paid') return res.json({ ok: true });
|
|
666
|
+
|
|
667
|
+
// Update optional aggregate reserved count
|
|
668
|
+
const items = JSON.parse(event.bookingItems || '[]');
|
|
669
|
+
const itemIndex = items.findIndex(i => i.id === reservation.itemId);
|
|
670
|
+
if (itemIndex !== -1) {
|
|
671
|
+
const it = items[itemIndex];
|
|
672
|
+
it.reserved = (Number(it.reserved || 0) + Number(reservation.quantity || 0));
|
|
673
|
+
items[itemIndex] = it;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
reservation.status = 'paid';
|
|
677
|
+
reservation.helloAssoOrderId = orderId;
|
|
678
|
+
reservations[rIndex] = reservation;
|
|
679
|
+
|
|
680
|
+
event.bookingItems = JSON.stringify(items);
|
|
681
|
+
event.reservations = JSON.stringify(reservations);
|
|
682
|
+
|
|
683
|
+
await db.setObject(getEventKey(eid), event);
|
|
684
|
+
|
|
685
|
+
try {
|
|
686
|
+
await emailer.send('calendar-payment-confirmed', reservation.uid, {
|
|
687
|
+
subject: 'Votre paiement a été confirmé',
|
|
688
|
+
eventTitle: event.title,
|
|
689
|
+
itemName: reservation.itemId,
|
|
690
|
+
quantity: reservation.quantity,
|
|
691
|
+
pickupLocation: reservation.pickupLocation || 'Non précisé',
|
|
692
|
+
dateStart: reservation.dateStart,
|
|
693
|
+
dateEnd: reservation.dateEnd,
|
|
694
|
+
days: reservation.days || 1,
|
|
695
|
+
});
|
|
696
|
+
} catch (e) {
|
|
697
|
+
console.warn('[calendar-onekite] email payment-confirmed error:', e.message);
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
res.json({ ok: true });
|
|
701
|
+
} catch (err) {
|
|
702
|
+
console.error('[calendar-onekite] HelloAsso webhook error:', err);
|
|
703
|
+
res.status(500).json({ error: err.message });
|
|
704
|
+
}
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
/* -----------------------------
|
|
708
|
+
* Admin plugin page + settings API
|
|
709
|
+
* IMPORTANT: keep these routes EXACTLY aligned with your admin.js
|
|
710
|
+
* ---------------------------- */
|
|
711
|
+
|
|
712
|
+
router.get('/admin/plugins/calendar-onekite', middleware.admin.buildHeader, renderAdminPage);
|
|
713
|
+
router.get('/api/admin/plugins/calendar-onekite', renderAdminPage);
|
|
714
|
+
|
|
715
|
+
router.put('/api/admin/plugins/calendar-onekite', middleware.admin.checkPrivileges, async (req, res) => {
|
|
716
|
+
try {
|
|
717
|
+
await Settings.set(SETTINGS_KEY, req.body);
|
|
718
|
+
res.json({ status: 'ok' });
|
|
719
|
+
} catch (err) {
|
|
720
|
+
res.status(500).json({ error: err.message });
|
|
721
|
+
}
|
|
722
|
+
});
|
|
723
|
+
|
|
724
|
+
/* -----------------------------
|
|
725
|
+
* My reservations API
|
|
726
|
+
* ---------------------------- */
|
|
727
|
+
|
|
728
|
+
router.get('/api/calendar/my-reservations', middleware.ensureLoggedIn, async (req, res) => {
|
|
729
|
+
try {
|
|
730
|
+
const uid = String(req.user?.uid || 0);
|
|
731
|
+
if (!uid || uid === '0') return res.status(403).json({ error: 'Non connecté' });
|
|
732
|
+
|
|
733
|
+
const eids = await db.getSortedSetRange(EVENTS_SET_KEY, 0, -1);
|
|
734
|
+
const result = [];
|
|
735
|
+
|
|
736
|
+
for (const eid of eids) {
|
|
737
|
+
const event = await getEvent(eid);
|
|
738
|
+
if (!event) continue;
|
|
739
|
+
|
|
740
|
+
const items = JSON.parse(event.bookingItems || '[]');
|
|
741
|
+
const reservations = JSON.parse(event.reservations || '[]');
|
|
742
|
+
|
|
743
|
+
reservations
|
|
744
|
+
.filter(r => String(r.uid) === uid)
|
|
745
|
+
.forEach(r => {
|
|
746
|
+
const item = items.find(i => i.id === r.itemId);
|
|
747
|
+
result.push({
|
|
748
|
+
...r,
|
|
749
|
+
eventTitle: event.title,
|
|
750
|
+
eventStart: event.start,
|
|
751
|
+
eventEnd: event.end,
|
|
752
|
+
itemName: item ? item.name : r.itemId,
|
|
753
|
+
});
|
|
754
|
+
});
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
result.sort((a, b) => new Date(a.dateStart) - new Date(b.dateStart));
|
|
758
|
+
res.json(result);
|
|
759
|
+
} catch (err) {
|
|
760
|
+
res.status(500).json({ error: err.message });
|
|
761
|
+
}
|
|
762
|
+
});
|
|
763
|
+
|
|
764
|
+
/* -----------------------------
|
|
765
|
+
* Admin planning API (future reservations)
|
|
766
|
+
* ---------------------------- */
|
|
767
|
+
|
|
768
|
+
router.get('/api/admin/calendar/planning', middleware.admin.checkPrivileges, async (req, res) => {
|
|
769
|
+
try {
|
|
770
|
+
const now = new Date();
|
|
771
|
+
const eids = await db.getSortedSetRangeByScore(EVENTS_SET_KEY, 0, -1, now.getTime(), '+inf');
|
|
772
|
+
const rows = [];
|
|
773
|
+
|
|
774
|
+
for (const eid of eids) {
|
|
775
|
+
const event = await getEvent(eid);
|
|
776
|
+
if (!event) continue;
|
|
777
|
+
|
|
778
|
+
const items = JSON.parse(event.bookingItems || '[]');
|
|
779
|
+
const reservations = JSON.parse(event.reservations || '[]');
|
|
780
|
+
|
|
781
|
+
reservations
|
|
782
|
+
.filter(r => r.status === 'pending_admin' || r.status === 'awaiting_payment' || r.status === 'paid')
|
|
783
|
+
.forEach(r => {
|
|
784
|
+
const item = items.find(i => i.id === r.itemId);
|
|
785
|
+
rows.push({
|
|
786
|
+
eid: event.eid,
|
|
787
|
+
eventTitle: event.title,
|
|
788
|
+
itemId: r.itemId,
|
|
789
|
+
itemName: item ? item.name : r.itemId,
|
|
790
|
+
uid: r.uid,
|
|
791
|
+
quantity: r.quantity,
|
|
792
|
+
dateStart: r.dateStart,
|
|
793
|
+
dateEnd: r.dateEnd,
|
|
794
|
+
days: r.days || daysBetween(r.dateStart, r.dateEnd),
|
|
795
|
+
status: r.status,
|
|
796
|
+
pickupLocation: r.pickupLocation || 'Non précisé',
|
|
797
|
+
});
|
|
798
|
+
});
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
rows.sort((a, b) => new Date(a.dateStart) - new Date(b.dateStart));
|
|
802
|
+
res.json(rows);
|
|
803
|
+
} catch (err) {
|
|
804
|
+
res.status(500).json({ error: err.message });
|
|
805
|
+
}
|
|
806
|
+
});
|
|
807
|
+
};
|
|
808
|
+
|
|
809
|
+
/* -----------------------------
|
|
810
|
+
* ACP nav entry
|
|
811
|
+
* ---------------------------- */
|
|
812
|
+
|
|
813
|
+
Plugin.addAdminNavigation = function (header) {
|
|
814
|
+
header.plugins.push({
|
|
815
|
+
// NodeBB ACP will resolve this under /admin
|
|
816
|
+
route: '/plugins/calendar-onekite',
|
|
817
|
+
icon: 'fa fa-calendar',
|
|
818
|
+
name: 'Calendar OneKite',
|
|
819
|
+
});
|
|
820
|
+
return header;
|
|
821
|
+
};
|
|
822
|
+
|
|
823
|
+
/* -----------------------------
|
|
824
|
+
* Widget
|
|
825
|
+
* ---------------------------- */
|
|
826
|
+
|
|
827
|
+
Plugin.defineWidgets = async function (widgets) {
|
|
828
|
+
widgets.push({
|
|
829
|
+
widget: 'calendarUpcoming',
|
|
830
|
+
name: 'Prochains événements',
|
|
831
|
+
description: 'Affiche la liste des prochains événements du calendrier.',
|
|
832
|
+
content: '',
|
|
833
|
+
});
|
|
834
|
+
return widgets;
|
|
835
|
+
};
|
|
836
|
+
|
|
837
|
+
Plugin.renderUpcomingWidget = async function (widget, callback) {
|
|
838
|
+
try {
|
|
839
|
+
const settings = (await Settings.get(SETTINGS_KEY)) || {};
|
|
840
|
+
const limit = Number((widget && widget.data && widget.data.limit) || settings.limit || 5);
|
|
841
|
+
const events = await getUpcomingEvents(limit);
|
|
842
|
+
const html = await appRef.renderAsync('widgets/calendar-upcoming', { events });
|
|
843
|
+
widget.html = html;
|
|
844
|
+
if (typeof callback === 'function') {
|
|
845
|
+
return callback(null, widget);
|
|
846
|
+
}
|
|
847
|
+
return widget;
|
|
848
|
+
} catch (err) {
|
|
849
|
+
if (typeof callback === 'function') {
|
|
850
|
+
return callback(err);
|
|
851
|
+
}
|
|
852
|
+
throw err;
|
|
853
|
+
}
|
|
854
|
+
};
|
|
855
|
+
|
|
856
|
+
module.exports = Plugin;
|