bulltrackers-module 1.0.992 → 1.0.994

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.
@@ -24,6 +24,7 @@ const logsRoutes = require('./logs');
24
24
  const dashboardRoutes = require('./dashboard');
25
25
  const devRoutes = require('./dev');
26
26
  const accountRoutes = require('./account');
27
+ const tutorialRoutes = require('./tutorial');
27
28
 
28
29
  /**
29
30
  * Create and configure all API routes.
@@ -51,6 +52,7 @@ function createRoutes() {
51
52
  router.use('/logs', logsRoutes);
52
53
  router.use('/dev', devRoutes);
53
54
  router.use('/account', accountRoutes);
55
+ router.use('/tutorial', tutorialRoutes);
54
56
 
55
57
  // Legacy route compatibility: /user/:userId/sync
56
58
  router.post('/user/:userId/sync', async (req, res, next) => {
@@ -0,0 +1,256 @@
1
+ /**
2
+ * @fileoverview Tutorial Progress Routes
3
+ * Tracks per-user completion of onboarding/tutorial tasks in Firestore.
4
+ */
5
+
6
+ const express = require('express');
7
+ const { z } = require('zod');
8
+ const { requireVerifiedUser } = require('../middleware/identity');
9
+
10
+ const router = express.Router();
11
+
12
+ const completeSchema = z.object({
13
+ taskId: z.string().min(1).max(200),
14
+ });
15
+
16
+ const restartSchema = z.object({
17
+ taskId: z.string().min(1).max(200),
18
+ /** Optional hint about restart strategy from frontend; backend still validates. */
19
+ restartStrategy: z.enum(['tutorialStateOnly', 'fullReset']).optional(),
20
+ });
21
+
22
+ function getTutorialProgressRef(db, cid) {
23
+ return db
24
+ .collection('SignedInUsers')
25
+ .doc(String(cid))
26
+ .collection('tutorial')
27
+ .doc('progress');
28
+ }
29
+
30
+ async function getDerivedCompletions(db, cid) {
31
+ const completed = new Set();
32
+ const userDocRef = db.collection('SignedInUsers').doc(String(cid));
33
+
34
+ try {
35
+ const userDoc = await userDocRef.get();
36
+ if (userDoc.exists) {
37
+ completed.add('account_create');
38
+ }
39
+ } catch (e) {
40
+ // Non-fatal; ignore and continue.
41
+ }
42
+
43
+ try {
44
+ const verificationRef = userDocRef.collection('verification').doc('data');
45
+ const verificationSnap = await verificationRef.get();
46
+ if (verificationSnap.exists) {
47
+ const vData = verificationSnap.data() || {};
48
+ if (vData.etoroCID || vData.etoroUsername || vData.accountSetupComplete) {
49
+ completed.add('account_link_etoro');
50
+ }
51
+ }
52
+ } catch (e) {
53
+ // Non-fatal; ignore and continue.
54
+ }
55
+
56
+ try {
57
+ const subSnap = await userDocRef
58
+ .collection('alert_subscriptions')
59
+ .limit(1)
60
+ .get();
61
+ if (!subSnap.empty) {
62
+ completed.add('alerts_create_threshold');
63
+ }
64
+ } catch (e) {
65
+ // Non-fatal; ignore and continue.
66
+ }
67
+
68
+ try {
69
+ const alertsSnap = await userDocRef
70
+ .collection('alerts')
71
+ .limit(1)
72
+ .get();
73
+ if (!alertsSnap.empty) {
74
+ completed.add('alerts_receive_notification');
75
+ }
76
+ } catch (e) {
77
+ // Non-fatal; ignore and continue.
78
+ }
79
+
80
+ return completed;
81
+ }
82
+
83
+ async function getTutorialConfig(req) {
84
+ const config = (req.config && req.config.tutorial) || {};
85
+ return {
86
+ nonRestartable: new Set(config.nonRestartableTaskIds || []),
87
+ fullReset: new Set(config.fullResetTaskIds || []),
88
+ };
89
+ }
90
+
91
+ router.get('/progress', requireVerifiedUser, async (req, res, next) => {
92
+ try {
93
+ const { db } = req.services;
94
+ const cid = req.targetUserId;
95
+
96
+ const ref = getTutorialProgressRef(db, cid);
97
+ const doc = await ref.get();
98
+ const data = doc.exists ? doc.data() || {} : {};
99
+
100
+ const persistedCompleted = new Set(
101
+ Array.isArray(data.completedTaskIds) ? data.completedTaskIds : []
102
+ );
103
+ const derivedCompleted = await getDerivedCompletions(db, cid);
104
+ const mergedCompleted = Array.from(
105
+ new Set([...persistedCompleted, ...derivedCompleted])
106
+ );
107
+
108
+ if (mergedCompleted.length !== persistedCompleted.size) {
109
+ await ref.set(
110
+ {
111
+ completedTaskIds: mergedCompleted,
112
+ },
113
+ { merge: true }
114
+ );
115
+ }
116
+
117
+ res.json({
118
+ success: true,
119
+ data: {
120
+ completedTaskIds: mergedCompleted,
121
+ explicitlyDismissedTaskIds: Array.isArray(
122
+ data.explicitlyDismissedTaskIds
123
+ )
124
+ ? data.explicitlyDismissedTaskIds
125
+ : [],
126
+ taskMetadata: data.taskMetadata || {},
127
+ },
128
+ });
129
+ } catch (error) {
130
+ next(error);
131
+ }
132
+ });
133
+
134
+ router.post('/progress/complete', requireVerifiedUser, async (req, res, next) => {
135
+ try {
136
+ const parsed = completeSchema.parse(req.body || {});
137
+ const { db } = req.services;
138
+ const cid = req.targetUserId;
139
+
140
+ const ref = getTutorialProgressRef(db, cid);
141
+ await db.runTransaction(async (tx) => {
142
+ const snap = await tx.get(ref);
143
+ const existing = snap.exists ? snap.data() || {} : {};
144
+
145
+ const completed = new Set(
146
+ Array.isArray(existing.completedTaskIds)
147
+ ? existing.completedTaskIds
148
+ : []
149
+ );
150
+ completed.add(parsed.taskId);
151
+
152
+ const meta = existing.taskMetadata || {};
153
+ const now = new Date();
154
+ const current = meta[parsed.taskId] || {};
155
+
156
+ tx.set(
157
+ ref,
158
+ {
159
+ completedTaskIds: Array.from(completed),
160
+ taskMetadata: {
161
+ ...meta,
162
+ [parsed.taskId]: {
163
+ ...current,
164
+ lastCompletedAt: now,
165
+ runCount: (current.runCount || 0) + 1,
166
+ },
167
+ },
168
+ },
169
+ { merge: true }
170
+ );
171
+ });
172
+
173
+ res.json({ success: true });
174
+ } catch (error) {
175
+ next(error);
176
+ }
177
+ });
178
+
179
+ router.post('/progress/restart', requireVerifiedUser, async (req, res, next) => {
180
+ try {
181
+ const parsed = restartSchema.parse(req.body || {});
182
+ const { db } = req.services;
183
+ const cid = req.targetUserId;
184
+ const { nonRestartable, fullReset } = await getTutorialConfig(req);
185
+
186
+ if (nonRestartable.has(parsed.taskId)) {
187
+ return res.status(400).json({
188
+ success: false,
189
+ error: 'NonRestartable',
190
+ message: 'This tutorial task cannot be restarted once completed.',
191
+ });
192
+ }
193
+
194
+ const shouldFullReset =
195
+ parsed.restartStrategy === 'fullReset' || fullReset.has(parsed.taskId);
196
+
197
+ if (shouldFullReset) {
198
+ const collectionPath = `SignedInUsers/${cid}`;
199
+ if (parsed.taskId === 'alerts_create_threshold') {
200
+ const alertsRef = db
201
+ .collection(collectionPath)
202
+ .doc(String(cid))
203
+ .collection('alert_subscriptions');
204
+ const snap = await alertsRef.limit(50).get();
205
+ const batch = db.batch();
206
+ snap.docs.forEach((doc) => {
207
+ const data = doc.data() || {};
208
+ if (data.source === 'tutorial') {
209
+ batch.delete(doc.ref);
210
+ }
211
+ });
212
+ await batch.commit();
213
+ }
214
+ }
215
+
216
+ const ref = getTutorialProgressRef(db, cid);
217
+ await db.runTransaction(async (tx) => {
218
+ const snap = await tx.get(ref);
219
+ const existing = snap.exists ? snap.data() || {} : {};
220
+
221
+ const completed = new Set(
222
+ Array.isArray(existing.completedTaskIds)
223
+ ? existing.completedTaskIds
224
+ : []
225
+ );
226
+ completed.delete(parsed.taskId);
227
+
228
+ const meta = existing.taskMetadata || {};
229
+ const now = new Date();
230
+ const current = meta[parsed.taskId] || {};
231
+
232
+ tx.set(
233
+ ref,
234
+ {
235
+ completedTaskIds: Array.from(completed),
236
+ taskMetadata: {
237
+ ...meta,
238
+ [parsed.taskId]: {
239
+ ...current,
240
+ lastRestartedAt: now,
241
+ restartedCount: (current.restartedCount || 0) + 1,
242
+ },
243
+ },
244
+ },
245
+ { merge: true }
246
+ );
247
+ });
248
+
249
+ res.json({ success: true, fullResetApplied: shouldFullReset });
250
+ } catch (error) {
251
+ next(error);
252
+ }
253
+ });
254
+
255
+ module.exports = router;
256
+
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bulltrackers-module",
3
- "version": "1.0.992",
3
+ "version": "1.0.994",
4
4
  "description": "Helper Functions for Bulltrackers.",
5
5
  "main": "index.js",
6
6
  "files": [