@vue-skuilder/db 0.1.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.
Files changed (62) hide show
  1. package/README.md +26 -0
  2. package/dist/core/index.d.mts +3 -0
  3. package/dist/core/index.d.ts +3 -0
  4. package/dist/core/index.js +7906 -0
  5. package/dist/core/index.js.map +1 -0
  6. package/dist/core/index.mjs +7886 -0
  7. package/dist/core/index.mjs.map +1 -0
  8. package/dist/index-QMtzQI65.d.mts +734 -0
  9. package/dist/index-QMtzQI65.d.ts +734 -0
  10. package/dist/index.d.mts +133 -0
  11. package/dist/index.d.ts +133 -0
  12. package/dist/index.js +8726 -0
  13. package/dist/index.js.map +1 -0
  14. package/dist/index.mjs +8699 -0
  15. package/dist/index.mjs.map +1 -0
  16. package/eslint.config.mjs +20 -0
  17. package/package.json +47 -0
  18. package/src/core/bulkImport/cardProcessor.ts +165 -0
  19. package/src/core/bulkImport/index.ts +2 -0
  20. package/src/core/bulkImport/types.ts +27 -0
  21. package/src/core/index.ts +9 -0
  22. package/src/core/interfaces/adminDB.ts +27 -0
  23. package/src/core/interfaces/classroomDB.ts +75 -0
  24. package/src/core/interfaces/contentSource.ts +64 -0
  25. package/src/core/interfaces/courseDB.ts +139 -0
  26. package/src/core/interfaces/dataLayerProvider.ts +46 -0
  27. package/src/core/interfaces/index.ts +7 -0
  28. package/src/core/interfaces/navigationStrategyManager.ts +46 -0
  29. package/src/core/interfaces/userDB.ts +183 -0
  30. package/src/core/navigators/elo.ts +76 -0
  31. package/src/core/navigators/index.ts +57 -0
  32. package/src/core/readme.md +9 -0
  33. package/src/core/types/contentNavigationStrategy.ts +21 -0
  34. package/src/core/types/db.ts +7 -0
  35. package/src/core/types/types-legacy.ts +155 -0
  36. package/src/core/types/user.ts +70 -0
  37. package/src/core/util/index.ts +42 -0
  38. package/src/factory.ts +86 -0
  39. package/src/impl/pouch/PouchDataLayerProvider.ts +102 -0
  40. package/src/impl/pouch/adminDB.ts +91 -0
  41. package/src/impl/pouch/auth.ts +48 -0
  42. package/src/impl/pouch/classroomDB.ts +306 -0
  43. package/src/impl/pouch/clientCache.ts +19 -0
  44. package/src/impl/pouch/courseAPI.ts +245 -0
  45. package/src/impl/pouch/courseDB.ts +772 -0
  46. package/src/impl/pouch/courseLookupDB.ts +135 -0
  47. package/src/impl/pouch/index.ts +235 -0
  48. package/src/impl/pouch/pouchdb-setup.ts +16 -0
  49. package/src/impl/pouch/types.ts +7 -0
  50. package/src/impl/pouch/updateQueue.ts +89 -0
  51. package/src/impl/pouch/user-course-relDB.ts +73 -0
  52. package/src/impl/pouch/userDB.ts +1097 -0
  53. package/src/index.ts +8 -0
  54. package/src/study/SessionController.ts +401 -0
  55. package/src/study/SpacedRepetition.ts +128 -0
  56. package/src/study/getCardDataShape.ts +34 -0
  57. package/src/study/index.ts +2 -0
  58. package/src/util/Loggable.ts +11 -0
  59. package/src/util/index.ts +1 -0
  60. package/src/util/logger.ts +55 -0
  61. package/tsconfig.json +12 -0
  62. package/tsup.config.ts +17 -0
@@ -0,0 +1,1097 @@
1
+ import { getCardHistoryID } from '@/core/util';
2
+ import { CourseElo, Status } from '@vue-skuilder/common';
3
+ import { ENV } from '@/factory';
4
+ import moment, { Moment } from 'moment';
5
+ import { GuestUsername } from '../../core/types/types-legacy';
6
+ import pouch from './pouchdb-setup';
7
+ import { logger } from '../../util/logger';
8
+
9
+ import {
10
+ ClassroomRegistrationDesignation,
11
+ ClassroomRegistrationDoc,
12
+ UserCourseSetting,
13
+ UserDBInterface,
14
+ UsrCrsDataInterface,
15
+ } from '@/core';
16
+ import {
17
+ ActivityRecord,
18
+ CourseRegistration,
19
+ CourseRegistrationDoc,
20
+ ScheduledCard,
21
+ UserConfig,
22
+ } from '@/core/types/user';
23
+ import { DocumentUpdater } from '@/study';
24
+ import { CardHistory, CardRecord } from '../../core/types/types-legacy';
25
+ import {
26
+ filterAllDocsByPrefix,
27
+ getCredentialledCourseConfig,
28
+ getStartAndEndKeys,
29
+ hexEncode,
30
+ pouchDBincludeCredentialsConfig,
31
+ removeScheduledCardReview,
32
+ REVIEW_PREFIX,
33
+ REVIEW_TIME_FORMAT,
34
+ scheduleCardReview,
35
+ updateGuestAccountExpirationDate,
36
+ } from './index';
37
+ import { PouchError } from './types';
38
+ import UpdateQueue, { Update } from './updateQueue';
39
+ import { UsrCrsData } from './user-course-relDB';
40
+
41
+ const log = (s: any) => {
42
+ logger.debug(s);
43
+ };
44
+
45
+ const cardHistoryPrefix = 'cardH-';
46
+
47
+ // console.log(`Connecting to remote: ${remoteStr}`);
48
+
49
+ function getRemoteCouchRootDB(): PouchDB.Database {
50
+ const remoteStr: string =
51
+ ENV.COUCHDB_SERVER_PROTOCOL + '://' + ENV.COUCHDB_SERVER_URL + 'skuilder';
52
+ let remoteCouchRootDB: PouchDB.Database;
53
+ try {
54
+ remoteCouchRootDB = new pouch(remoteStr, {
55
+ skip_setup: true,
56
+ });
57
+ } catch (error) {
58
+ logger.error('Failed to initialize remote CouchDB connection:', error);
59
+ throw new Error(`Failed to initialize CouchDB: ${JSON.stringify(error)}`);
60
+ }
61
+ return remoteCouchRootDB;
62
+ }
63
+
64
+ interface DesignDoc {
65
+ _id: string;
66
+ views: {
67
+ [viewName: string]: {
68
+ map: string; // String representation of the map function
69
+ };
70
+ };
71
+ }
72
+
73
+ /**
74
+ * The current logged-in user, with pouch / couch functionality.
75
+ *
76
+ * @package This concrete class should not be directly exported from the `db` package,
77
+ * but should be created at runtime by the exported dataLayerProviderFactory.
78
+ */
79
+ export class User implements UserDBInterface, DocumentUpdater {
80
+ private static _instance: User;
81
+ private static _initialized: boolean = false;
82
+
83
+ public static Dummy(): User {
84
+ return new User('DummyUser');
85
+ }
86
+
87
+ static readonly DOC_IDS = {
88
+ CONFIG: 'CONFIG',
89
+ COURSE_REGISTRATIONS: 'CourseRegistrations',
90
+ CLASSROOM_REGISTRATIONS: 'ClassroomRegistrations',
91
+ };
92
+
93
+ // private email: string;
94
+ private _username: string;
95
+ public getUsername(): string {
96
+ return this._username;
97
+ }
98
+
99
+ public isLoggedIn(): boolean {
100
+ return !this._username.startsWith(GuestUsername);
101
+ }
102
+
103
+ private remoteDB!: PouchDB.Database;
104
+ public remote(): PouchDB.Database {
105
+ return this.remoteDB;
106
+ }
107
+ private localDB!: PouchDB.Database;
108
+ private updateQueue!: UpdateQueue;
109
+
110
+ public async createAccount(username: string, password: string) {
111
+ const ret = {
112
+ status: Status.ok,
113
+ error: '',
114
+ };
115
+
116
+ if (!this._username.startsWith(GuestUsername)) {
117
+ throw new Error(
118
+ `Cannot create a new account while logged in:
119
+ Currently logged-in as ${this._username}.`
120
+ );
121
+ } else {
122
+ try {
123
+ const signupRequest = await getRemoteCouchRootDB().signUp(username, password);
124
+
125
+ if (signupRequest.ok) {
126
+ log(`CREATEACCOUNT: logging out of ${this.getUsername()}`);
127
+ const logoutResult = await getRemoteCouchRootDB().logOut();
128
+ log(`CREATEACCOUNT: logged out: ${logoutResult.ok}`);
129
+ const loginResult = await getRemoteCouchRootDB().logIn(username, password);
130
+ log(`CREATEACCOUNT: logged in as new user: ${loginResult.ok}`);
131
+ const newLocal = getLocalUserDB(username);
132
+ const newRemote = getUserDB(username);
133
+ this._username = username;
134
+
135
+ void this.localDB.replicate.to(newLocal).on('complete', () => {
136
+ void newLocal.replicate.to(newRemote).on('complete', async () => {
137
+ log('CREATEACCOUNT: Attempting to destroy guest localDB');
138
+ await clearLocalGuestDB();
139
+
140
+ // reset this.local & this.remote DBs
141
+ void this.init();
142
+ });
143
+ });
144
+ } else {
145
+ ret.status = Status.error;
146
+ ret.error = '';
147
+ logger.warn(`Signup not OK: ${JSON.stringify(signupRequest)}`);
148
+ // throw signupRequest;
149
+ return ret;
150
+ }
151
+ } catch (e) {
152
+ const err = e as PouchError;
153
+ if (err.reason === 'Document update conflict.') {
154
+ ret.error = 'This username is taken!';
155
+ ret.status = Status.error;
156
+ }
157
+ logger.error(`Error on signup: ${JSON.stringify(e)}`);
158
+ return ret;
159
+ }
160
+ }
161
+
162
+ return ret;
163
+ }
164
+ public async login(username: string, password: string) {
165
+ if (!this._username.startsWith(GuestUsername)) {
166
+ throw new Error(`Cannot change accounts while logged in.
167
+ Log out of account ${this.getUsername()} before logging in as ${username}.`);
168
+ }
169
+
170
+ const loginResult = await getRemoteCouchRootDB().logIn(username, password);
171
+ if (loginResult.ok) {
172
+ log(`Logged in as ${username}`);
173
+ this._username = username;
174
+ localStorage.removeItem('dbUUID');
175
+ await this.init();
176
+ } else {
177
+ log(`Login ERROR as ${username}`);
178
+ }
179
+ return loginResult;
180
+ }
181
+ public async logout() {
182
+ // end session w/ couchdb
183
+ const ret = await this.remoteDB.logOut();
184
+ // return to 'guest' mode
185
+ this._username = GuestUsername;
186
+ await this.init();
187
+
188
+ return ret;
189
+ }
190
+
191
+ public update<T extends PouchDB.Core.Document<object>>(id: string, update: Update<T>) {
192
+ return this.updateQueue.update(id, update);
193
+ }
194
+
195
+ public async getCourseRegistrationsDoc(): Promise<
196
+ CourseRegistrationDoc & PouchDB.Core.IdMeta & PouchDB.Core.GetMeta
197
+ > {
198
+ logger.debug(`Fetching courseRegistrations for ${this.getUsername()}`);
199
+
200
+ let ret;
201
+
202
+ try {
203
+ ret = await this.localDB.get<CourseRegistrationDoc>(userCoursesDoc);
204
+ } catch (e) {
205
+ const err = e as PouchError;
206
+ if (err.status === 404) {
207
+ // doc does not exist. Create it and then run this fcn again.
208
+ await this.localDB.put<CourseRegistrationDoc>({
209
+ _id: userCoursesDoc,
210
+ courses: [],
211
+ studyWeight: {},
212
+ });
213
+ ret = await this.getCourseRegistrationsDoc();
214
+ } else {
215
+ throw new Error(
216
+ `Unexpected error ${JSON.stringify(e)} in getOrCreateCourseRegistrationDoc...`
217
+ );
218
+ }
219
+ }
220
+
221
+ return ret;
222
+ }
223
+
224
+ public async getActiveCourses() {
225
+ const reg = await this.getCourseRegistrationsDoc();
226
+ return reg.courses.filter((c) => {
227
+ return c.status === undefined || c.status === 'active';
228
+ });
229
+ }
230
+
231
+ /**
232
+ * Returns a promise of the card IDs that the user has
233
+ * a scheduled review for.
234
+ *
235
+ */
236
+ public async getActiveCards() {
237
+ const keys = getStartAndEndKeys(REVIEW_PREFIX);
238
+
239
+ const reviews = await this.remoteDB.allDocs<ScheduledCard>({
240
+ startkey: keys.startkey,
241
+ endkey: keys.endkey,
242
+ include_docs: true,
243
+ });
244
+
245
+ return reviews.rows.map((r) => `${r.doc!.courseId}-${r.doc!.cardId}`);
246
+ }
247
+
248
+ public async getActivityRecords(): Promise<ActivityRecord[]> {
249
+ try {
250
+ const hist = await this.getHistory();
251
+
252
+ const allRecords: ActivityRecord[] = [];
253
+ if (!Array.isArray(hist)) {
254
+ logger.error('getHistory did not return an array:', hist);
255
+ return allRecords;
256
+ }
257
+
258
+ // Sample the first few records to understand structure
259
+ let sampleCount = 0;
260
+
261
+ for (let i = 0; i < hist.length; i++) {
262
+ try {
263
+ if (hist[i] && Array.isArray(hist[i]!.records)) {
264
+ hist[i]!.records.forEach((record: CardRecord) => {
265
+ try {
266
+ // Skip this record if timeStamp is missing
267
+ if (!record.timeStamp) {
268
+ return;
269
+ }
270
+
271
+ let timeStamp;
272
+
273
+ // Handle different timestamp formats
274
+ if (typeof record.timeStamp === 'object') {
275
+ // It's likely a Moment object
276
+ if (typeof record.timeStamp.toDate === 'function') {
277
+ // It's definitely a Moment object
278
+ timeStamp = record.timeStamp.toISOString();
279
+ } else if (record.timeStamp instanceof Date) {
280
+ // It's a Date object
281
+ timeStamp = record.timeStamp.toISOString();
282
+ } else {
283
+ // Log a sample of unknown object types, but don't flood console
284
+ if (sampleCount < 3) {
285
+ logger.warn('Unknown timestamp object type:', record.timeStamp);
286
+ sampleCount++;
287
+ }
288
+ return;
289
+ }
290
+ } else if (typeof record.timeStamp === 'string') {
291
+ // It's already a string, but make sure it's a valid date
292
+ const date = new Date(record.timeStamp);
293
+ if (isNaN(date.getTime())) {
294
+ return; // Invalid date string
295
+ }
296
+ timeStamp = record.timeStamp;
297
+ } else if (typeof record.timeStamp === 'number') {
298
+ // Assume it's a Unix timestamp (milliseconds since epoch)
299
+ timeStamp = new Date(record.timeStamp).toISOString();
300
+ } else {
301
+ // Unknown type, skip
302
+ return;
303
+ }
304
+
305
+ allRecords.push({
306
+ timeStamp,
307
+ courseID: record.courseID || 'unknown',
308
+ cardID: record.cardID || 'unknown',
309
+ timeSpent: record.timeSpent || 0,
310
+ type: 'card_view',
311
+ });
312
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
313
+ } catch (err) {
314
+ // Silently skip problematic records to avoid flooding logs
315
+ }
316
+ });
317
+ }
318
+ } catch (err) {
319
+ logger.error('Error processing history item:', err);
320
+ }
321
+ }
322
+
323
+ logger.debug(`Found ${allRecords.length} activity records`);
324
+ return allRecords;
325
+ } catch (err) {
326
+ logger.error('Error in getActivityRecords:', err);
327
+ return [];
328
+ }
329
+ }
330
+
331
+ private async getReviewstoDate(targetDate: Moment, course_id?: string) {
332
+ const keys = getStartAndEndKeys(REVIEW_PREFIX);
333
+
334
+ const reviews = await this.remoteDB.allDocs<ScheduledCard>({
335
+ startkey: keys.startkey,
336
+ endkey: keys.endkey,
337
+ include_docs: true,
338
+ });
339
+
340
+ log(
341
+ `Fetching ${this._username}'s scheduled reviews${
342
+ course_id ? ` for course ${course_id}` : ''
343
+ }.`
344
+ );
345
+ return reviews.rows
346
+ .filter((r) => {
347
+ if (r.id.startsWith(REVIEW_PREFIX)) {
348
+ const date = moment.utc(r.id.substr(REVIEW_PREFIX.length), REVIEW_TIME_FORMAT);
349
+ if (targetDate.isAfter(date)) {
350
+ if (course_id === undefined || r.doc!.courseId === course_id) {
351
+ return true;
352
+ }
353
+ }
354
+ }
355
+ })
356
+ .map((r) => r.doc!);
357
+ }
358
+
359
+ public async getReviewsForcast(daysCount: number) {
360
+ const time = moment.utc().add(daysCount, 'days');
361
+ return this.getReviewstoDate(time);
362
+ }
363
+
364
+ public async getPendingReviews(course_id?: string) {
365
+ const now = moment.utc();
366
+ return this.getReviewstoDate(now, course_id);
367
+ }
368
+
369
+ public async getScheduledReviewCount(course_id: string): Promise<number> {
370
+ return (await this.getPendingReviews(course_id)).length;
371
+ }
372
+
373
+ public async getRegisteredCourses() {
374
+ const regDoc = await this.getCourseRegistrationsDoc();
375
+ return regDoc.courses.filter((c) => {
376
+ return !c.status || c.status === 'active' || c.status === 'maintenance-mode';
377
+ });
378
+ }
379
+
380
+ public async getCourseRegDoc(courseID: string) {
381
+ const regDocs = await this.getCourseRegistrationsDoc();
382
+ const ret = regDocs.courses.find((c) => c.courseID === courseID);
383
+ if (ret) {
384
+ return ret;
385
+ } else {
386
+ throw new Error(`Course registration not found for course ID: ${courseID}`);
387
+ }
388
+ }
389
+
390
+ public async registerForCourse(course_id: string, previewMode: boolean = false) {
391
+ return this.getCourseRegistrationsDoc()
392
+ .then((doc: CourseRegistrationDoc) => {
393
+ const status = previewMode ? 'preview' : 'active';
394
+ logger.debug(`Registering for ${course_id} with status: ${status}`);
395
+
396
+ const regItem: CourseRegistration = {
397
+ status: status,
398
+ courseID: course_id,
399
+ user: true,
400
+ admin: false,
401
+ moderator: false,
402
+ elo: {
403
+ global: {
404
+ score: 1000,
405
+ count: 0,
406
+ },
407
+ tags: {},
408
+ misc: {},
409
+ },
410
+ };
411
+
412
+ if (
413
+ doc.courses.filter((course) => {
414
+ return course.courseID === regItem.courseID;
415
+ }).length === 0
416
+ ) {
417
+ log(`It's a new course registration!`);
418
+ doc.courses.push(regItem);
419
+ doc.studyWeight[course_id] = 1;
420
+ } else {
421
+ doc.courses.forEach((c) => {
422
+ log(`Found the previously registered course!`);
423
+ if (c.courseID === course_id) {
424
+ c.status = status;
425
+ }
426
+ });
427
+ }
428
+
429
+ return this.localDB.put<CourseRegistrationDoc>(doc);
430
+ })
431
+ .catch((e) => {
432
+ log(`Registration failed because of: ${JSON.stringify(e)}`);
433
+ throw e;
434
+ });
435
+ }
436
+ public async dropCourse(course_id: string, dropStatus: CourseRegistration['status'] = 'dropped') {
437
+ return this.getCourseRegistrationsDoc().then((doc) => {
438
+ let index: number = -1;
439
+ for (let i = 0; i < doc.courses.length; i++) {
440
+ if (doc.courses[i].courseID === course_id) {
441
+ index = i;
442
+ }
443
+ }
444
+
445
+ if (index !== -1) {
446
+ // remove from the relative-weighting of course study
447
+ delete doc.studyWeight[course_id];
448
+ // set drop status
449
+ doc.courses[index].status = dropStatus;
450
+ } else {
451
+ throw new Error(
452
+ `User ${this.getUsername()} is not currently registered for course ${course_id}`
453
+ );
454
+ }
455
+
456
+ return this.localDB.put<CourseRegistrationDoc>(doc);
457
+ });
458
+ }
459
+
460
+ public async getCourseInterface(courseId: string): Promise<UsrCrsDataInterface> {
461
+ return new UsrCrsData(this, courseId);
462
+ }
463
+
464
+ public async getUserEditableCourses() {
465
+ let courseIDs: string[] = [];
466
+
467
+ const registeredCourses = await this.getCourseRegistrationsDoc();
468
+
469
+ courseIDs = courseIDs.concat(
470
+ registeredCourses.courses.map((course) => {
471
+ return course.courseID;
472
+ })
473
+ );
474
+
475
+ const cfgs = await Promise.all(
476
+ courseIDs.map(async (id) => {
477
+ return await getCredentialledCourseConfig(id);
478
+ })
479
+ );
480
+ return cfgs;
481
+ }
482
+
483
+ public async getConfig(): Promise<UserConfig & PouchDB.Core.IdMeta & PouchDB.Core.GetMeta> {
484
+ const defaultConfig: PouchDB.Core.Document<UserConfig> = {
485
+ _id: User.DOC_IDS.CONFIG,
486
+ darkMode: false,
487
+ likesConfetti: false,
488
+ };
489
+
490
+ try {
491
+ const cfg = await this.localDB.get<UserConfig>(User.DOC_IDS.CONFIG);
492
+ logger.debug('Raw config from DB:', cfg);
493
+
494
+ return cfg;
495
+ } catch (e) {
496
+ const err = e as PouchError;
497
+ if (err.name && err.name === 'not_found') {
498
+ await this.localDB.put<UserConfig>(defaultConfig);
499
+ return this.getConfig();
500
+ } else {
501
+ logger.error(`Error setting user default config:`, e);
502
+ throw new Error(`Error returning the user's configuration: ${JSON.stringify(e)}`);
503
+ }
504
+ }
505
+ }
506
+
507
+ public async setConfig(items: Partial<UserConfig>) {
508
+ logger.debug(`Setting Config items ${JSON.stringify(items)}`);
509
+
510
+ const c = await this.getConfig();
511
+ const put = await this.localDB.put<UserConfig>({
512
+ ...c,
513
+ ...items,
514
+ });
515
+
516
+ if (put.ok) {
517
+ logger.debug(`Config items set: ${JSON.stringify(items)}`);
518
+ } else {
519
+ logger.error(`Error setting config items: ${JSON.stringify(put)}`);
520
+ }
521
+ }
522
+
523
+ /**
524
+ *
525
+ * This function should be called *only* by the pouchdb datalayer provider
526
+ * auth store.
527
+ *
528
+ *
529
+ * Anyone else seeking the current user should use the auth store's
530
+ * exported `getCurrentUser` method.
531
+ *
532
+ */
533
+ public static async instance(username?: string): Promise<User> {
534
+ if (username) {
535
+ User._instance = new User(username);
536
+ await User._instance.init();
537
+ return User._instance;
538
+ } else if (User._instance && User._initialized) {
539
+ // log(`USER.instance() returning user ${User._instance._username}`);
540
+ return User._instance;
541
+ } else if (User._instance) {
542
+ return new Promise((resolve) => {
543
+ (function waitForUser() {
544
+ if (User._initialized) {
545
+ return resolve(User._instance);
546
+ } else {
547
+ setTimeout(waitForUser, 50);
548
+ }
549
+ })();
550
+ });
551
+ } else {
552
+ User._instance = new User(GuestUsername);
553
+ await User._instance.init();
554
+ return User._instance;
555
+ }
556
+ }
557
+
558
+ private constructor(username: string) {
559
+ User._initialized = false;
560
+ this._username = username;
561
+ this.setDBandQ();
562
+ }
563
+
564
+ private setDBandQ() {
565
+ this.localDB = getLocalUserDB(this._username);
566
+ if (this._username === GuestUsername) {
567
+ this.remoteDB = getLocalUserDB(this._username);
568
+ } else {
569
+ this.remoteDB = getUserDB(this._username);
570
+ }
571
+ this.updateQueue = new UpdateQueue(this.localDB);
572
+ }
573
+
574
+ private async init() {
575
+ User._initialized = false;
576
+ this.setDBandQ();
577
+
578
+ void pouch.sync(this.localDB, this.remoteDB, {
579
+ live: true,
580
+ retry: true,
581
+ });
582
+ void this.applyDesignDocs();
583
+ void this.deduplicateReviews();
584
+ User._initialized = true;
585
+ }
586
+
587
+ private static designDocs: DesignDoc[] = [
588
+ {
589
+ _id: '_design/reviewCards',
590
+ views: {
591
+ reviewCards: {
592
+ map: function (doc: PouchDB.Core.Document<object>) {
593
+ if (doc._id.indexOf('card_review') === 0) {
594
+ type ReviewCard = {
595
+ _id: string;
596
+ courseId: string;
597
+ cardId: string;
598
+ };
599
+
600
+ const copy: ReviewCard = doc as ReviewCard;
601
+ emit(copy._id, copy.courseId + '-' + copy.cardId);
602
+ }
603
+ }.toString(),
604
+ },
605
+ },
606
+ },
607
+ ];
608
+
609
+ private async applyDesignDocs() {
610
+ for (const doc of User.designDocs) {
611
+ try {
612
+ // Try to get existing doc
613
+ try {
614
+ const existingDoc = await this.remoteDB.get(doc._id);
615
+ // Update existing doc
616
+ await this.remoteDB.put({
617
+ ...doc,
618
+ _rev: existingDoc._rev,
619
+ });
620
+ } catch (e: unknown) {
621
+ if (e instanceof Error && e.name === 'not_found') {
622
+ // Create new doc
623
+ await this.remoteDB.put(doc);
624
+ } else {
625
+ throw e; // Re-throw unexpected errors
626
+ }
627
+ }
628
+ } catch (error: unknown) {
629
+ if (error instanceof Error && error.name === 'conflict') {
630
+ logger.warn(`Design doc ${doc._id} update conflict - will retry`);
631
+ // Wait a bit and try again
632
+ await new Promise((resolve) => setTimeout(resolve, 1000));
633
+ await this.applyDesignDoc(doc); // Recursive retry
634
+ } else {
635
+ logger.error(`Failed to apply design doc ${doc._id}:`, error);
636
+ throw error;
637
+ }
638
+ }
639
+ }
640
+ }
641
+
642
+ // Helper method for single doc update with retry
643
+ private async applyDesignDoc(doc: DesignDoc, retries = 3): Promise<void> {
644
+ try {
645
+ const existingDoc = await this.remoteDB.get(doc._id);
646
+ await this.remoteDB.put({
647
+ ...doc,
648
+ _rev: existingDoc._rev,
649
+ });
650
+ } catch (e: unknown) {
651
+ if (e instanceof Error && e.name === 'conflict' && retries > 0) {
652
+ await new Promise((resolve) => setTimeout(resolve, 1000));
653
+ return this.applyDesignDoc(doc, retries - 1);
654
+ }
655
+ throw e;
656
+ }
657
+ }
658
+
659
+ /**
660
+ * Logs a record of the user's interaction with the card and returns the card's
661
+ * up-to-date history
662
+ *
663
+ * // [ ] #db-refactor extract to a smaller scope - eg, UserStudySession
664
+ *
665
+ * @param record the recent recorded interaction between user and card
666
+ * @returns The updated state of the card's CardHistory data
667
+ */
668
+
669
+ public async putCardRecord<T extends CardRecord>(record: T): Promise<CardHistory<CardRecord>> {
670
+ const cardHistoryID = getCardHistoryID(record.courseID, record.cardID);
671
+ // stringify the current record to make it writable to couchdb
672
+ record.timeStamp = moment.utc(record.timeStamp).toString() as unknown as Moment;
673
+
674
+ try {
675
+ const cardHistory = await this.update<CardHistory<T>>(
676
+ cardHistoryID,
677
+ function (h: CardHistory<T>) {
678
+ h.records.push(record);
679
+ h.bestInterval = h.bestInterval || 0;
680
+ h.lapses = h.lapses || 0;
681
+ h.streak = h.streak || 0;
682
+ return h;
683
+ }
684
+ );
685
+
686
+ momentifyCardHistory<T>(cardHistory);
687
+ return cardHistory;
688
+ } catch (e) {
689
+ const reason = e as Reason;
690
+ if (reason.status === 404) {
691
+ const initCardHistory: CardHistory<T> = {
692
+ _id: cardHistoryID,
693
+ cardID: record.cardID,
694
+ courseID: record.courseID,
695
+ records: [record],
696
+ lapses: 0,
697
+ streak: 0,
698
+ bestInterval: 0,
699
+ };
700
+ void getUserDB(this.getUsername()).put<CardHistory<T>>(initCardHistory);
701
+ return initCardHistory;
702
+ } else {
703
+ throw new Error(`putCardRecord failed because of:
704
+ name:${reason.name}
705
+ error: ${reason.error}
706
+ id: ${reason.id}
707
+ message: ${reason.message}`);
708
+ }
709
+ }
710
+ }
711
+
712
+ private async deduplicateReviews() {
713
+ /**
714
+ * Maps the qualified-id of a scheduled review card to
715
+ * the docId of the same scheduled review.
716
+ *
717
+ * EG: {
718
+ * courseId-cardId: 'card_review_2021-06--17:12:165
719
+ * }
720
+ */
721
+ const reviewsMap: { [index: string]: string } = {};
722
+
723
+ const scheduledReviews = await this.remoteDB.query<{
724
+ id: string;
725
+ value: string;
726
+ }>('reviewCards');
727
+
728
+ scheduledReviews.rows.forEach((r) => {
729
+ if (reviewsMap[r.value]) {
730
+ // this card is scheduled more than once! delete this scheduled review
731
+ log(`Removing duplicate scheduled review for card: ${r.value}`);
732
+ log(`Replacing review ${reviewsMap[r.value]} with ${r.key}`);
733
+ void this.remoteDB
734
+ .get(reviewsMap[r.value])
735
+ .then((doc) => {
736
+ // remove the already-hashed review, since it is the earliest one
737
+ // (prevents continual loop of short-scheduled reviews)
738
+ return this.remoteDB.remove(doc);
739
+ })
740
+ .then(() => {
741
+ // replace with the later-dated scheduled review
742
+ reviewsMap[r.value] = r.key;
743
+ });
744
+ } else {
745
+ // note that this card is scheduled for review
746
+ reviewsMap[r.value] = r.key;
747
+ }
748
+ });
749
+ }
750
+
751
+ /**
752
+ * Returns a promise of the card IDs that the user has
753
+ * encountered in the past.
754
+ *
755
+ * @param course_id optional specification of individual course
756
+ */
757
+ async getSeenCards(course_id?: string) {
758
+ let prefix = cardHistoryPrefix;
759
+ if (course_id) {
760
+ prefix += course_id;
761
+ }
762
+ const docs = await filterAllDocsByPrefix(this.localDB, prefix, {
763
+ include_docs: false,
764
+ });
765
+ // const docs = await this.localDB.allDocs({});
766
+ const ret: PouchDB.Core.DocumentId[] = [];
767
+ docs.rows.forEach((row) => {
768
+ if (row.id.startsWith(cardHistoryPrefix)) {
769
+ ret.push(row.id.substr(cardHistoryPrefix.length));
770
+ }
771
+ });
772
+ return ret;
773
+ }
774
+
775
+ /**
776
+ *
777
+ * @returns A promise of the cards that the user has seen in the past.
778
+ */
779
+ async getHistory() {
780
+ const cards = await filterAllDocsByPrefix<CardHistory<CardRecord>>(
781
+ this.remoteDB,
782
+ cardHistoryPrefix,
783
+ {
784
+ include_docs: true,
785
+ attachments: false,
786
+ }
787
+ );
788
+ return cards.rows.map((r) => r.doc);
789
+ }
790
+
791
+ async updateCourseSettings(course_id: string, settings: UserCourseSetting[]) {
792
+ void this.getCourseRegistrationsDoc().then((doc) => {
793
+ const crs = doc.courses.find((c) => c.courseID === course_id);
794
+ if (crs) {
795
+ if (crs.settings === null || crs.settings === undefined) {
796
+ crs.settings = {};
797
+ }
798
+ settings.forEach((setting) => {
799
+ crs!.settings![setting.key] = setting.value;
800
+ });
801
+ }
802
+
803
+ return this.localDB.put(doc);
804
+ });
805
+ }
806
+ async getCourseSettings(course_id: string) {
807
+ const regDoc = await this.getCourseRegistrationsDoc();
808
+ const crsDoc = regDoc.courses.find((c) => c.courseID === course_id);
809
+
810
+ if (crsDoc) {
811
+ return crsDoc.settings;
812
+ } else {
813
+ throw new Error(`getCourseSettings Failed:
814
+ User is not registered for course ${course_id}`);
815
+ }
816
+ }
817
+
818
+ private async getOrCreateClassroomRegistrationsDoc(): Promise<
819
+ ClassroomRegistrationDoc & PouchDB.Core.IdMeta & PouchDB.Core.GetMeta
820
+ > {
821
+ let ret;
822
+
823
+ try {
824
+ ret = await getUserDB(this._username).get<ClassroomRegistrationDoc>(userClassroomsDoc);
825
+ } catch (e) {
826
+ const err = e as PouchError;
827
+ if (err.status === 404) {
828
+ // doc does not exist. Create it and then run this fcn again.
829
+ await getUserDB(this._username).put<ClassroomRegistrationDoc>({
830
+ _id: userClassroomsDoc,
831
+ registrations: [],
832
+ });
833
+ ret = await this.getOrCreateClassroomRegistrationsDoc();
834
+ } else {
835
+ throw new Error(
836
+ `Unexpected error ${JSON.stringify(e)} in getOrCreateClassroomRegistrationDoc...`
837
+ );
838
+ }
839
+ }
840
+
841
+ logger.debug(`Returning classroom registrations doc: ${JSON.stringify(ret)}`);
842
+ return ret;
843
+ }
844
+
845
+ public async getActiveClasses(): Promise<string[]> {
846
+ return (await this.getOrCreateClassroomRegistrationsDoc()).registrations
847
+ .filter((c) => c.registeredAs === 'student')
848
+ .map((c) => c.classID);
849
+ }
850
+
851
+ public async scheduleCardReview(review: {
852
+ user: string;
853
+ course_id: string;
854
+ card_id: PouchDB.Core.DocumentId;
855
+ time: Moment;
856
+ scheduledFor: ScheduledCard['scheduledFor'];
857
+ schedulingAgentId: ScheduledCard['schedulingAgentId'];
858
+ }) {
859
+ return scheduleCardReview(review);
860
+ }
861
+ public async removeScheduledCardReview(reviewId: string): Promise<void> {
862
+ return removeScheduledCardReview(this._username, reviewId);
863
+ }
864
+
865
+ public async registerForClassroom(
866
+ classId: string,
867
+ registerAs: 'student' | 'teacher' | 'aide' | 'admin'
868
+ ): Promise<PouchDB.Core.Response> {
869
+ return registerUserForClassroom(this._username, classId, registerAs);
870
+ }
871
+
872
+ public async dropFromClassroom(classId: string): Promise<PouchDB.Core.Response> {
873
+ return dropUserFromClassroom(this._username, classId);
874
+ }
875
+ public async getUserClassrooms(): Promise<ClassroomRegistrationDoc> {
876
+ return getUserClassrooms(this._username);
877
+ }
878
+
879
+ public async updateUserElo(courseId: string, elo: CourseElo): Promise<PouchDB.Core.Response> {
880
+ return updateUserElo(this._username, courseId, elo);
881
+ }
882
+ }
883
+
884
+ export function getLocalUserDB(username: string): PouchDB.Database {
885
+ return new pouch(`userdb-${username}`, {
886
+ adapter: 'idb',
887
+ });
888
+ }
889
+
890
+ async function clearLocalGuestDB() {
891
+ const docs = await getLocalUserDB(GuestUsername).allDocs({
892
+ limit: 1000,
893
+ include_docs: true,
894
+ });
895
+
896
+ docs.rows.forEach((r) => {
897
+ log(`CREATEACCOUNT: Deleting ${r.id}`);
898
+ void getLocalUserDB(GuestUsername).remove(r.doc!);
899
+ });
900
+ delete localStorage.dbUUID;
901
+ }
902
+
903
+ export function getUserDB(username: string): PouchDB.Database {
904
+ const guestAccount: boolean = false;
905
+ // console.log(`Getting user db: ${username}`);
906
+
907
+ const hexName = hexEncode(username);
908
+ const dbName = `userdb-${hexName}`;
909
+ log(`Fetching user database: ${dbName} (${username})`);
910
+
911
+ // odd construction here the result of a bug in the
912
+ // interaction between pouch, pouch-auth.
913
+ // see: https://github.com/pouchdb-community/pouchdb-authentication/issues/239
914
+ const ret = new pouch(
915
+ ENV.COUCHDB_SERVER_PROTOCOL + '://' + ENV.COUCHDB_SERVER_URL + dbName,
916
+ pouchDBincludeCredentialsConfig
917
+ );
918
+ if (guestAccount) {
919
+ updateGuestAccountExpirationDate(ret);
920
+ }
921
+
922
+ return ret;
923
+ }
924
+
925
+ // function accomodateGuest(): {
926
+ // username: string;
927
+ // firstVisit: boolean;
928
+ // } {
929
+ // const dbUUID = 'dbUUID';
930
+ // let firstVisit: boolean;
931
+
932
+ // if (localStorage.getItem(dbUUID) !== null) {
933
+ // firstVisit = false;
934
+ // console.log(`Returning guest ${localStorage.getItem(dbUUID)} "logging in".`);
935
+ // } else {
936
+ // firstVisit = true;
937
+ // const uuid = generateUUID();
938
+ // localStorage.setItem(dbUUID, uuid);
939
+ // console.log(`Accommodating a new guest with account: ${uuid}`);
940
+ // }
941
+
942
+ // return {
943
+ // username: GuestUsername + localStorage.getItem(dbUUID),
944
+ // firstVisit: firstVisit,
945
+ // };
946
+
947
+ // // pilfered from https://stackoverflow.com/a/8809472/1252649
948
+ // function generateUUID() {
949
+ // let d = new Date().getTime();
950
+ // if (typeof performance !== 'undefined' && typeof performance.now === 'function') {
951
+ // d += performance.now(); // use high-precision timer if available
952
+ // }
953
+ // return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
954
+ // // tslint:disable-next-line:no-bitwise
955
+ // const r = (d + Math.random() * 16) % 16 | 0;
956
+ // d = Math.floor(d / 16);
957
+ // // tslint:disable-next-line:no-bitwise
958
+ // return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16);
959
+ // });
960
+ // }
961
+ // }
962
+
963
+ const userCoursesDoc = 'CourseRegistrations';
964
+ const userClassroomsDoc = 'ClassroomRegistrations';
965
+
966
+ async function getOrCreateClassroomRegistrationsDoc(
967
+ user: string
968
+ ): Promise<ClassroomRegistrationDoc & PouchDB.Core.IdMeta & PouchDB.Core.GetMeta> {
969
+ let ret;
970
+
971
+ try {
972
+ ret = await getUserDB(user).get<ClassroomRegistrationDoc>(userClassroomsDoc);
973
+ } catch (e) {
974
+ const err = e as PouchError;
975
+
976
+ if (err.status === 404) {
977
+ // doc does not exist. Create it and then run this fcn again.
978
+ await getUserDB(user).put<ClassroomRegistrationDoc>({
979
+ _id: userClassroomsDoc,
980
+ registrations: [],
981
+ });
982
+ ret = await getOrCreateClassroomRegistrationsDoc(user);
983
+ } else {
984
+ throw new Error(
985
+ `Unexpected error ${JSON.stringify(e)} in getOrCreateClassroomRegistrationDoc...`
986
+ );
987
+ }
988
+ }
989
+
990
+ return ret;
991
+ }
992
+
993
+ async function getOrCreateCourseRegistrationsDoc(
994
+ user: string
995
+ ): Promise<CourseRegistrationDoc & PouchDB.Core.IdMeta & PouchDB.Core.GetMeta> {
996
+ let ret;
997
+
998
+ try {
999
+ ret = await getUserDB(user).get<CourseRegistrationDoc>(userCoursesDoc);
1000
+ } catch (e) {
1001
+ const err = e as PouchError;
1002
+ if (err.status === 404) {
1003
+ // doc does not exist. Create it and then run this fcn again.
1004
+ await getUserDB(user).put<CourseRegistrationDoc>({
1005
+ _id: userCoursesDoc,
1006
+ courses: [],
1007
+ studyWeight: {},
1008
+ });
1009
+ ret = await getOrCreateCourseRegistrationsDoc(user);
1010
+ } else {
1011
+ throw new Error(
1012
+ `Unexpected error ${JSON.stringify(e)} in getOrCreateCourseRegistrationDoc...`
1013
+ );
1014
+ }
1015
+ }
1016
+
1017
+ return ret;
1018
+ }
1019
+
1020
+ export async function updateUserElo(user: string, course_id: string, elo: CourseElo) {
1021
+ const regDoc = await getOrCreateCourseRegistrationsDoc(user);
1022
+ const course = regDoc.courses.find((c) => c.courseID === course_id)!;
1023
+ course.elo = elo;
1024
+ return getUserDB(user).put(regDoc);
1025
+ }
1026
+
1027
+ export async function registerUserForClassroom(
1028
+ user: string,
1029
+ classID: string,
1030
+ registerAs: ClassroomRegistrationDesignation
1031
+ ) {
1032
+ log(`Registering user: ${user} in course: ${classID}`);
1033
+ return getOrCreateClassroomRegistrationsDoc(user).then((doc) => {
1034
+ const regItem = {
1035
+ classID: classID,
1036
+ registeredAs: registerAs,
1037
+ };
1038
+
1039
+ if (
1040
+ doc.registrations.filter((reg) => {
1041
+ return reg.classID === regItem.classID && reg.registeredAs === regItem.registeredAs;
1042
+ }).length === 0
1043
+ ) {
1044
+ doc.registrations.push(regItem);
1045
+ } else {
1046
+ log(`User ${user} is already registered for class ${classID}`);
1047
+ }
1048
+
1049
+ return getUserDB(user).put(doc);
1050
+ });
1051
+ }
1052
+
1053
+ /**
1054
+ * This noop exists to facilitate writing couchdb filter fcns
1055
+ */
1056
+ function emit(x: unknown, y: unknown): void {
1057
+ logger.debug(`noop:`, x, y);
1058
+ }
1059
+
1060
+ export async function dropUserFromClassroom(user: string, classID: string) {
1061
+ return getOrCreateClassroomRegistrationsDoc(user).then((doc) => {
1062
+ let index: number = -1;
1063
+
1064
+ for (let i = 0; i < doc.registrations.length; i++) {
1065
+ if (doc.registrations[i].classID === classID) {
1066
+ index = i;
1067
+ }
1068
+ }
1069
+
1070
+ if (index !== -1) {
1071
+ doc.registrations.splice(index, 1);
1072
+ }
1073
+ return getUserDB(user).put(doc);
1074
+ });
1075
+ }
1076
+
1077
+ export async function getUserClassrooms(user: string) {
1078
+ return getOrCreateClassroomRegistrationsDoc(user);
1079
+ }
1080
+
1081
+ function momentifyCardHistory<T extends CardRecord>(cardHistory: CardHistory<T>) {
1082
+ cardHistory.records = cardHistory.records.map<T>((record) => {
1083
+ const ret: T = {
1084
+ ...(record as object),
1085
+ } as T;
1086
+ ret.timeStamp = moment.utc(record.timeStamp);
1087
+ return ret;
1088
+ });
1089
+ }
1090
+
1091
+ interface Reason {
1092
+ status: number;
1093
+ name: string;
1094
+ error: string;
1095
+ id: string;
1096
+ message: string;
1097
+ }