skapi-js 0.0.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.
package/src/skapi.ts ADDED
@@ -0,0 +1,3609 @@
1
+ import {
2
+ CognitoUserPool,
3
+ CognitoUserAttribute,
4
+ CognitoUser,
5
+ AuthenticationDetails,
6
+ CognitoUserSession
7
+ } from 'amazon-cognito-identity-js';
8
+
9
+ import {
10
+ RecordData,
11
+ User,
12
+ Form,
13
+ FormCallbacks,
14
+ UserProfile,
15
+ PostRecordParams,
16
+ FetchOptions,
17
+ SubscriberGroup,
18
+ SubscriberFetch,
19
+ FetchResponse,
20
+ GetRecordParams,
21
+ QueryParams,
22
+ Newsletters
23
+ } from './Types';
24
+ import SkapiError from './skapi_error';
25
+ import { formResponse } from './decorators';
26
+ import {
27
+ checkParams,
28
+ extractFormMetaData,
29
+ validateUserId,
30
+ validateBirthdate,
31
+ validateEmail,
32
+ validatePassword,
33
+ validatePhoneNumber,
34
+ validateUrl,
35
+ normalize_record_data,
36
+ checkWhiteSpaceAndSpecialChars,
37
+ sha256
38
+ } from './utils';
39
+
40
+ type StartKeys = {
41
+ /** List of startkeys */
42
+ [url: string]: {
43
+ [hashedParams: string]: string[];
44
+ };
45
+ };
46
+
47
+ type CachedRequests = {
48
+ /** Cached url requests */
49
+ [url: string]: {
50
+ /** Array of data stored in hashed params key */
51
+ [hashedParams: string]: FetchResponse;
52
+ };
53
+ };
54
+
55
+ type Connection = {
56
+ /** Connection locale */
57
+ locale: string;
58
+ /** Name of the connected service */
59
+ name: string;
60
+ /** Id of the service owner */
61
+ owner: string;
62
+ /** E-Mail address of the service owner */
63
+ owner_email: string;
64
+ /** Service id */
65
+ service: string;
66
+ /** 13 digits timestamp of the service creation */
67
+ timestamp: number;
68
+ /** hash string used for login */
69
+ hash?: string;
70
+ };
71
+ /**
72
+ * All the methods used in Skapi are promises.<br>
73
+ * Use async/await or Promise.then() to interact with backend.<br>
74
+ *
75
+ * <b>Example A: Using async await</b>
76
+ * ```
77
+ * async function app() {
78
+ * try {
79
+ * let skapi = await Skapi.connect('xxxxxxxxxxxxxx', 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx');
80
+ *
81
+ * console.log(skapi.connection); // connection to Skapi!
82
+ *
83
+ * // - Here is where all your code should be written -
84
+ *
85
+ * } catch(error) {
86
+ * console.log('Connection error');
87
+ * }
88
+ * }
89
+ *
90
+ * // Run your application
91
+ * app();
92
+ * ```
93
+ *
94
+ * <b>Example B: Using Promise.then()</b>
95
+ * ```
96
+ * Skapi.connect('xxxxxxxxxxxxxx', 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx').then(async skapi => {
97
+ *
98
+ * console.log(skapi.connection); // connection to Skapi!
99
+ *
100
+ * // - Here is where all your code should be written -
101
+ *
102
+ * }).catch(error => {
103
+ * console.log('Connection error');
104
+ * });
105
+ * ```
106
+ */
107
+ export default class Skapi {
108
+ // privates
109
+ private cognitoUser: CognitoUser | null = null;
110
+ private __disabledAccount: string | null = null;
111
+ private __serviceHash: Record<string, string> = {};
112
+ private __pendingRequest: Record<string, Promise<any>> = {};
113
+ private __cached_requests: CachedRequests = {};
114
+ private __startKey_keys: StartKeys = {};
115
+ private __request_signup_confirmation: string | null = null;
116
+ private __index_number_range = 4503599627370496;
117
+ private service: string;
118
+ private service_owner: string;
119
+
120
+ // true when session is stored successfully to session storage
121
+ // this property prevents duplicate stores when window closes on some device
122
+ private __class_properties_has_been_cached = false;
123
+
124
+ private session: Record<string, any> | null = null;
125
+
126
+ // public
127
+
128
+ /** Current logged in user object. null if not logged. */
129
+ user: User | null = null;
130
+ /** Connected service object. null if connection failed. */
131
+ connection: Connection | null = null;
132
+ host: string = 'skapi';
133
+ hostDomain: string = 'skapi.com';
134
+ userPool: CognitoUserPool | null = null;
135
+ admin_endpoint: Promise<Record<string, any>>;
136
+ record_endpoint: Promise<Record<string, any>>;
137
+
138
+ regex = {
139
+ validateUserId(val: string) {
140
+ try {
141
+ validateUserId(val);
142
+ return true;
143
+ } catch (err) {
144
+ return false;
145
+ }
146
+ },
147
+ validateUrl(val: string | string[]) {
148
+ try {
149
+ validateUrl(val);
150
+ return true;
151
+ } catch (err) {
152
+ return false;
153
+ }
154
+ },
155
+ validatePhoneNumber(val: string) {
156
+ try {
157
+ validatePhoneNumber(val);
158
+ return true;
159
+ } catch (err) {
160
+ return false;
161
+ }
162
+ },
163
+ validateBirthdate(val: string) {
164
+ try {
165
+ validateBirthdate(val);
166
+ return true;
167
+ } catch (err) {
168
+ return false;
169
+ }
170
+ },
171
+ validateEmail(val: string) {
172
+ try {
173
+ validateEmail(val);
174
+ return true;
175
+ } catch (err) {
176
+ return false;
177
+ }
178
+ }
179
+ };
180
+
181
+ /** @ignore */
182
+ __connection: Promise<Connection | null>;
183
+
184
+ // skapi int range -4503599627370545 ~ 4503599627370546
185
+
186
+ constructor(service_id: string, service_owner: string) {
187
+ if (typeof service_id !== 'string' || typeof service_owner !== 'string') {
188
+ throw new SkapiError('"service_id" and "service_owner" should be type <string>.', { code: 'INVALID_PARAMETER' });
189
+ }
190
+
191
+ if (!service_id || !service_owner) {
192
+ throw new SkapiError('"service_id" and "service_owner" is required', { code: 'INVALID_PARAMETER' });
193
+ }
194
+
195
+ if (service_owner !== this.host) {
196
+ validateUserId(service_owner, '"service_owner"');
197
+ }
198
+
199
+ this.service = service_id;
200
+ this.service_owner = service_owner;
201
+
202
+ // get endpoints
203
+ const cdn_domain = 'https://dkls9pxkgz855.cloudfront.net'; // don't change this
204
+ let sreg = service_id.substring(0, 4);
205
+ this.admin_endpoint = fetch(`${cdn_domain}/${sreg}/admin.json`)
206
+ .then(response => response.blob())
207
+ .then(blob => new Promise((resolve, reject) => {
208
+ const reader = new FileReader();
209
+ reader.onloadend = () => resolve(reader.result);
210
+ reader.onerror = reject;
211
+ reader.readAsDataURL(blob);
212
+ }))
213
+ .then(data => typeof data === 'string' ? JSON.parse(window.atob(data.split(',')[1])) : null);
214
+
215
+ this.record_endpoint = fetch(`${cdn_domain}/${sreg}/record.json`)
216
+ .then(response => response.blob())
217
+ .then(blob => new Promise((resolve, reject) => {
218
+ const reader = new FileReader();
219
+ reader.onloadend = () => resolve(reader.result);
220
+ reader.onerror = reject;
221
+ reader.readAsDataURL(blob);
222
+ }))
223
+ .then(data => typeof data === 'string' ? JSON.parse(window.atob(data.split(',')[1])) : null);
224
+
225
+ // connects to server
226
+ this.__connection = (async (skapi: Skapi): Promise<Connection | null> => {
227
+ if (!window.sessionStorage) {
228
+ throw new Error(`This browser does not support skapi.`);
229
+ }
230
+
231
+ const restore = JSON.parse(window.sessionStorage.getItem(`${service_id}#${service_owner}`) || 'null');
232
+
233
+ if (restore?.connection) {
234
+ // apply all data to class properties
235
+ for (let k in restore) {
236
+ skapi[k] = restore[k];
237
+ }
238
+ }
239
+
240
+ const admin_endpoint = await skapi.admin_endpoint;
241
+
242
+ skapi.userPool = new CognitoUserPool({
243
+ UserPoolId: admin_endpoint.userpool_id,
244
+ ClientId: admin_endpoint.userpool_client
245
+ });
246
+
247
+ await Promise.all([
248
+ skapi.updateServiceInformation(),
249
+ skapi.authentication().updateSession({ refreshToken: !!restore?.connection }).catch(err => {
250
+ skapi.user = null;
251
+ })
252
+ ]);
253
+
254
+ const storeClassProperties = () => {
255
+ if (skapi.__class_properties_has_been_cached) {
256
+ return;
257
+ }
258
+
259
+ let data: Record<string, any> = {};
260
+
261
+ const to_be_cached = [
262
+ '__startKey_keys', // startKey key : {}
263
+ '__cached_requests', // cached records : {}
264
+ '__request_signup_confirmation', // for resend signup confirmation : null
265
+ 'connection', // service info : null
266
+ ];
267
+
268
+ if (skapi.connection) {
269
+ for (let k of to_be_cached) {
270
+ data[k] = skapi[k];
271
+ }
272
+
273
+ sessionStorage.setItem(`${service_id}#${service_owner}`, JSON.stringify(data));
274
+ skapi.__class_properties_has_been_cached = true;
275
+ }
276
+ };
277
+
278
+ // attach event to save session on close
279
+ window.addEventListener('beforeunload', storeClassProperties);
280
+ window.addEventListener("visibilitychange", storeClassProperties);
281
+
282
+ return skapi.connection;
283
+
284
+ })(this).catch(err => { throw err; });
285
+ }
286
+
287
+ /**
288
+ * Connects to your Skapi web service.</br>
289
+ * Use your service ID and your user ID as an argument.<br>
290
+ * Once successful, Skapi class object will be returned.<br>
291
+ *
292
+ * <h5>IMPORTANT NOTE!</h5>
293
+ * When setting up your web services in Skapi, always set your service cors url on production.<br>
294
+ * If the cors is not set, other people can use your web service apis on their websites as well.<br>
295
+ * Refer: <a href='www.google.com'>Setting up cors</a>
296
+ *
297
+ * ```
298
+ * Skapi.connect('xxxxxxxxxxxxxx', 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx').then(async skapi => {
299
+ *
300
+ * console.log(skapi.connection); // connection to Skapi!
301
+ *
302
+ * if(skapi.user) {
303
+ * // user is logged in!
304
+ * }
305
+ *
306
+ * else {
307
+ * // user is not logged in.
308
+ * }
309
+ *
310
+ * // - code -
311
+ *
312
+ * }).catch(error => {
313
+ * console.log('Connection error'); //'connection failed.'
314
+ * });
315
+ * ```
316
+ * @category Connection
317
+ */
318
+ static async connect(
319
+ /** 22 character service id */
320
+ service_id: string,
321
+ /** 36 character user id */
322
+ service_owner: string
323
+ ): Promise<Skapi> {
324
+ const skapi = new Skapi(service_id, service_owner);
325
+ await skapi.__connection;
326
+ return skapi;
327
+ }
328
+
329
+ private authentication() {
330
+ if (!this.userPool) {
331
+ throw new SkapiError('User pool is missing', { code: 'INVALID_REQUEST' });
332
+ }
333
+
334
+ const updateSession = (option?: {
335
+ refreshToken?: boolean;
336
+ }): Promise<User> => {
337
+ // fetch session, update user info
338
+ let { refreshToken = false } = option || {};
339
+ // console.log('%cUpdate session', 'background-color:tomato;color:white;');
340
+ // console.log({ refreshToken, refreshUserData });
341
+
342
+ return new Promise((res, rej) => {
343
+ let currentUser: CognitoUser | null = this.userPool?.getCurrentUser() || null;
344
+ this.cognitoUser = currentUser;
345
+
346
+ if (currentUser === null) {
347
+ rej(null);
348
+ }
349
+
350
+ currentUser.getSession((err: any, session: CognitoUserSession) => {
351
+ // console.log('%cGet session', 'background-color:tomato;color:white;');
352
+ // console.log({ err, session });
353
+ if (err) {
354
+ rej(err);
355
+ }
356
+
357
+ const updateAttributes = async (sessionToMerge: Record<string, any>) => {
358
+ // console.log('%cUpdate attributes', 'background-color:tomato;color:white;');
359
+ // console.log({ sessionToMerge });
360
+ if (sessionToMerge.isValid() && currentUser) {
361
+ currentUser.getUserAttributes((attrErr, attributes) => {
362
+ // console.log('%cGet user attributes', 'background-color:tomato;color:white;');
363
+ // console.log({ attrErr, attributes });
364
+ if (attrErr) {
365
+ rej(attrErr);
366
+ }
367
+
368
+ else {
369
+ let normalized_attributes: Record<string, any> = {};
370
+ let user: any = {};
371
+
372
+ for (let i of (attributes as CognitoUserAttribute[])) {
373
+ normalized_attributes[i.Name] = i.Value;
374
+ let key_1 = i.Name.replace('custom:', '');
375
+ user[key_1] = i.Value;
376
+
377
+ if (i.Name === 'custom:service' && normalized_attributes[i.Name] !== this.service) {
378
+ rej(new SkapiError('The user is not registered to the service.', { code: 'INVALID_REQUEST' }));
379
+ }
380
+ }
381
+
382
+
383
+ for (let k of [
384
+ 'address_public',
385
+ 'birthdate_public',
386
+ 'email_public',
387
+ 'email_subscription',
388
+ 'gender_public',
389
+ 'phone_number_public'
390
+ ]) {
391
+ if (k.includes('_public')) {
392
+ if (user.hasOwnProperty(k.split('_')[0])) {
393
+ user[k] = user.hasOwnProperty(k) ? Number(user[k]) : 0;
394
+ }
395
+ else {
396
+ delete user[k];
397
+ }
398
+ } else {
399
+ user[k] = user.hasOwnProperty(k) ? Number(user[k]) : 0;
400
+ }
401
+ }
402
+
403
+ for (let k of [
404
+ 'email_verified',
405
+ 'phone_number_verified'
406
+ ]) {
407
+ if (user[k.split('_')[0]]) {
408
+ user[k] = user.hasOwnProperty(k) ? user[k] === 'true' : false;
409
+ }
410
+ else if (user.hasOwnProperty(k)) {
411
+ delete user[k];
412
+ }
413
+ }
414
+
415
+ // console.log('%cMerging attributes to session', 'background-color:tomato;color:white;');
416
+ sessionToMerge.attributes = normalized_attributes;
417
+ this.session = sessionToMerge;
418
+
419
+ user.access_group = Number(this.session.idToken.payload.access_group);
420
+ user.user_id = user.sub;
421
+ delete user.sub;
422
+ this.user = user;
423
+ res(user);
424
+ }
425
+ });
426
+ }
427
+
428
+ else {
429
+ // console.log('%cInvalid session', 'background-color:tomato;color:white;');
430
+ rej(new SkapiError('Invalid session.', { code: 'INVALID_REQUEST' }));
431
+ }
432
+ };
433
+
434
+ if (session) {
435
+ if (refreshToken) {
436
+ // console.log('%cRefresh token', 'background-color:tomato;color:white;');
437
+ // console.log({ refreshToken });
438
+ currentUser?.refreshSession(session.getRefreshToken(), (refreshErr, refreshedSession) => {
439
+ if (refreshErr) {
440
+ // console.log('%cRefresh token error', 'background-color:tomato;color:white;');
441
+ // console.log({ refreshErr });
442
+ rej(refreshErr);
443
+ }
444
+ updateAttributes(refreshedSession).catch(err => rej(err));
445
+ });
446
+ }
447
+
448
+ else {
449
+ // console.log('%cLoading session', 'background-color:tomato;color:white;');
450
+ // console.log({ refreshToken });
451
+ updateAttributes(session).catch(err => rej(err));
452
+ }
453
+
454
+ } else {
455
+ rej(new SkapiError('Current session does not exist.', { code: 'INVALID_REQUEST' }));
456
+ }
457
+ });
458
+ });
459
+ };
460
+
461
+ const createCognitoUser = async (email: string) => {
462
+ // console.log('%cCreate cognito user', 'background-color:tomato;color:white;');
463
+ let hash = null;
464
+
465
+ if (email) {
466
+ hash = this.__serviceHash[email] || (await this.updateServiceInformation({ request_hash: email })).hash;
467
+ }
468
+
469
+ else {
470
+ if (this.session) {
471
+ hash = this.session.idToken.payload['cognito:username'];
472
+ } else {
473
+ throw new SkapiError('E-Mail is required.', { code: 'INVALID_PARAMETER' });
474
+ }
475
+ }
476
+
477
+ return {
478
+ cognitoUser: new CognitoUser({
479
+ Username: hash,
480
+ Pool: this.userPool
481
+ }),
482
+ cognitoUsername: hash
483
+ };
484
+ };
485
+
486
+ const authenticateUser = (username: string, password: string): Promise<User> => {
487
+ // console.log('%cAuthenticate user', 'background-color:tomato;color:white;');
488
+ return new Promise(async (res, rej) => {
489
+ this.logout();
490
+ this.__request_signup_confirmation = null;
491
+ this.__disabledAccount = null;
492
+
493
+ let initUser = await createCognitoUser(username);
494
+ this.cognitoUser = initUser.cognitoUser;
495
+ username = initUser.cognitoUsername;
496
+
497
+ let authenticationDetails = new AuthenticationDetails({
498
+ Username: username,
499
+ Password: password
500
+ });
501
+
502
+ this.cognitoUser.authenticateUser(authenticationDetails, {
503
+ newPasswordRequired: (userAttributes, requiredAttributes) => {
504
+ this.__request_signup_confirmation = username;
505
+ rej(new SkapiError("User's signup confirmation is required.", { code: 'SIGNUP_CONFIRMATION_NEEDED' }));
506
+ },
507
+ onSuccess: (logged) => {
508
+ // console.log('%cAuthenticate user success', 'background-color:tomato;color:white;');
509
+ // console.log({ logged });
510
+ updateSession().then(session => {
511
+ res(session);
512
+ });
513
+ },
514
+ onFailure: (err: any) => {
515
+ let error: [string, string] = [err.message || 'Failed to authenticate user.', err?.code || 'INVALID_REQUEST'];
516
+
517
+ if (err.code === "NotAuthorizedException") {
518
+ if (err.message === "User is disabled.") {
519
+ this.__disabledAccount = username;
520
+ error = ['This account is disabled.', 'USER_IS_DISABLED'];
521
+ }
522
+
523
+ else {
524
+ error = ['Incorrect username or password.', 'INCORRECT_USERNAME_OR_PASSWORD'];
525
+ }
526
+ }
527
+
528
+ rej(new SkapiError(error[0], { code: error[1], cause: err }));
529
+ }
530
+ });
531
+ });
532
+ };
533
+
534
+ return { updateSession, authenticateUser, createCognitoUser };
535
+ }
536
+
537
+ /**
538
+ * @ignore
539
+ */
540
+ async requireAdmin(option?: { ignoreVerification?: boolean; throwError?: boolean; }) {
541
+ if (this.session?.attributes?.['custom:service'] === this.service) {
542
+ // logged in
543
+ if (this.session?.attributes?.['custom:service_owner'] === this.host) {
544
+ if (!option?.ignoreVerification && !this.session?.attributes?.email_verified) {
545
+ throw new SkapiError('Email verification is required.', { code: 'INVALID_REQUEST' });
546
+ }
547
+
548
+ return true;
549
+ }
550
+
551
+ if (option?.throwError) {
552
+ throw new SkapiError('Admin access is required.', { code: 'INVALID_REQUEST' });
553
+ }
554
+
555
+ } else {
556
+ // not logged
557
+ this.logout();
558
+
559
+ if (option?.throwError) {
560
+ throw new SkapiError('User login is required.', { code: 'INVALID_REQUEST' });
561
+ }
562
+ }
563
+
564
+ return false;
565
+ }
566
+
567
+ /**
568
+ * Fetch blob from url.</br>
569
+ * User can get saved file as blob from url.<br>
570
+ * getBlob can be used to fetch file that are user login is required.
571
+ *
572
+ * ```
573
+ * let fileBlob = await skapi.getBlob({url: 'http://blob.url'});
574
+ * ```
575
+ * @category Connection
576
+ * @param params.url - file url to get.
577
+ * @param _option.service - Service Id. Only works on admin account.
578
+ */
579
+ async getBlob(params: { url: string; }, option?: { service: string; }): Promise<Blob> {
580
+
581
+ let p = checkParams(params, {
582
+ url: (v: string) => validateUrl(v)
583
+ }, ['url']);
584
+
585
+ return await this.request(p.url, option || null, { method: 'get', auth: p.url.includes('/auth/'), contentType: null, responseType: 'blob' });
586
+ }
587
+
588
+ /**
589
+ * Sends post request to your custom server using Skapi's secure API layer.</br>
590
+ * You must set your secret API key from the Skapi's admin page.</br>
591
+ * On your server side, you must verify your secret API key.<br>
592
+ * Skapi API layer can process your requests both synchronously and asynchronously.<br>
593
+ * You can request multiple process using arrays.<br>
594
+ * Skapi will process your requests in order.</br>
595
+ * The sync process will be chained in order during process.<br>
596
+ * Refer: <a href='www.google.com'>Setting secret api key</a>
597
+ *
598
+ * <h6>Example</h6>
599
+ *
600
+ * ```
601
+ * let call = await skapi.secureRequest(
602
+ * url: 'http://my.website.com/myapi',
603
+ * data: {
604
+ * some_data: 'Hello'
605
+ * }
606
+ * )
607
+ *
608
+ * console.log(call)
609
+ * // {
610
+ * // response: <any>
611
+ * // statusCode: <number>
612
+ * // url: 'http://my.website.com/myapi'
613
+ * // }
614
+ * ```
615
+ *
616
+ *
617
+ * <h6>Nodejs Example</h6>
618
+ *
619
+ * ```
620
+ * const http = require('http');
621
+ * http.createServer(function (request, response) {
622
+ * if (request.url === '/myapi') {
623
+ * if (request.method === 'POST') {
624
+ * let body = '';
625
+ *
626
+ * request.on('data', function (data) {
627
+ * body += data;
628
+ * });
629
+ *
630
+ * request.on('end', function () {
631
+ * body = JSON.parse(body);
632
+ * console.log(body);
633
+ * // {
634
+ * // user: {
635
+ * // user_id: '',
636
+ * // address: '',
637
+ * // phone_number: '',
638
+ * // email: '',
639
+ * // name: '',
640
+ * // locale: '',
641
+ * // request_locale: ''
642
+ * // },
643
+ * // data: {
644
+ * // some_data: 'Hello',
645
+ * // },
646
+ * // api_key: 'your api secret key'
647
+ * // }
648
+ *
649
+ * if (body.api_key && body.api_key === 'your api secret key') {
650
+ * response.writeHead(200, {'Content-Type': 'text/html'});
651
+ * // do something
652
+ * response.end('success');
653
+ * } else {
654
+ * response.writeHead(401, {'Content-Type': 'text/html'});
655
+ * response.end("api key mismatch");
656
+ * }
657
+ * });
658
+ * }
659
+ * }
660
+ * }).listen(3000);
661
+ * ```
662
+ *
663
+ *
664
+ * <h6>Python Example</h6>
665
+ *
666
+ * ```
667
+ * from http.server import BaseHTTPRequestHandler, HTTPServer
668
+ * import json
669
+ *
670
+ * class MyServer(BaseHTTPRequestHandler):
671
+ * def do_request(self):
672
+ * if self.path == '/myapi':
673
+ * content_length = int(self.headers['Content-Length'])
674
+ * body = json.loads(self.rfile.read(content_length).decode('utf-8'))
675
+ * print(body)
676
+ * # {
677
+ * # 'user': {
678
+ * # 'user_id': '',
679
+ * # 'address': '',
680
+ * # 'phone_number': '',
681
+ * # 'email': '',
682
+ * # 'name': '',
683
+ * # 'locale': '',
684
+ * # 'request_locale': ''
685
+ * # },
686
+ * # 'data': {
687
+ * # 'some_data': 'Hello',
688
+ * # },
689
+ * # 'api_key': 'your api secret key'
690
+ * # }
691
+ *
692
+ * if 'api_key' in body and body['api_key'] == 'your api secret key':
693
+ * self.send_response(200)
694
+ * self.send_header("Content-type", "text/html")
695
+ * self.end_headers()
696
+ * self.wfile.write(b'\n success')
697
+ * else:
698
+ * self.send_response(401)
699
+ * self.send_header("Content-type", "text/html")
700
+ * self.end_headers()
701
+ * self.wfile.write(b'api key mismatch')
702
+ *
703
+ *
704
+ * myServer = HTTPServer(("", 3000), MyServer)
705
+ *
706
+ * try:
707
+ * myServer.serve_forever()
708
+ * except KeyboardInterrupt:
709
+ * myServer.server_close()
710
+ * ```
711
+ * @category Connection
712
+ */
713
+ async secureRequest<RequestParams = {
714
+ /** Request url */
715
+ url: string;
716
+ /** Request data */
717
+ data?: any;
718
+ /** requests are sync when true */
719
+ sync?: boolean;
720
+ }>(request: RequestParams | RequestParams[]): Promise<any> {
721
+ let paramsStruct = {
722
+ url: (v: string) => {
723
+ return validateUrl(v);
724
+ },
725
+ data: null,
726
+ sync: ['boolean', () => true]
727
+ };
728
+
729
+ if (Array.isArray(request)) {
730
+ for (let r of request) {
731
+ r = checkParams(r, paramsStruct);
732
+ }
733
+ }
734
+
735
+ else {
736
+ request = checkParams(request, paramsStruct);
737
+ }
738
+
739
+ return await this.request('post-secure', request, { auth: true });
740
+ }
741
+
742
+ /**
743
+ * Retrives respond data from form request.
744
+ *
745
+ * ```
746
+ * let respond = skapi.getResponse();
747
+ * ```
748
+ * @category Connection
749
+ */
750
+ getFormResponse(): any {
751
+ let params = new URLSearchParams(window.location.search);
752
+
753
+ for (let [key, val] of params.entries()) {
754
+ // key = response key sha256
755
+ // val = timestamp
756
+ if (key.substring(0, 5) !== 'form-') {
757
+ continue;
758
+ }
759
+
760
+ let stored = window.sessionStorage.getItem(key);
761
+
762
+ if (stored) {
763
+ window.sessionStorage.removeItem(key);
764
+ try {
765
+ stored = JSON.parse(stored);
766
+ } catch (err) { }
767
+ }
768
+
769
+ if (typeof stored === 'object' && (stored as Record<string, any>)[val]) {
770
+ return (stored as Record<string, any>)[val];
771
+ }
772
+
773
+ else {
774
+ continue;
775
+ }
776
+ }
777
+
778
+ return null;
779
+ }
780
+
781
+ // internals below
782
+
783
+ /**
784
+ * @ignore
785
+ */
786
+ async request(
787
+ url: string,
788
+ data: Form = null,
789
+ options: {
790
+ fetchOptions?: FetchOptions & FormCallbacks;
791
+ auth?: boolean;
792
+ method?: string;
793
+ meta?: Record<string, any>;
794
+ bypassAwaitConnection?: boolean;
795
+ responseType?: string;
796
+ contentType?: string;
797
+ } = {}): Promise<any> {
798
+
799
+ let {
800
+ auth = false,
801
+ method = 'post',
802
+ meta = null, // content meta
803
+ bypassAwaitConnection = false
804
+ } = options;
805
+
806
+ let __connection = bypassAwaitConnection ? null : (await this.__connection);
807
+ let token = auth ? this.session?.idToken?.jwtToken : null; // idToken
808
+
809
+ if (auth) {
810
+ if (!token) {
811
+ this.logout();
812
+ throw new SkapiError('User login is required.', { code: 'INVALID_REQUEST' });
813
+ }
814
+ }
815
+
816
+ let isExternalUrl = '';
817
+ try {
818
+ isExternalUrl = validateUrl(url);
819
+ } catch (err) {
820
+ // is not an external url
821
+ }
822
+
823
+ const getEndpoint = async (dest: string, auth: boolean) => {
824
+ const endpoints = await Promise.all([
825
+ this.admin_endpoint,
826
+ this.record_endpoint
827
+ ]);
828
+
829
+ const admin = endpoints[0];
830
+ const record = endpoints[1];
831
+ const get_ep = () => {
832
+ switch (dest) {
833
+ case 'get-serviceletters':
834
+ case 'delete-newsletter':
835
+ case 'block-account':
836
+ case 'register-service':
837
+ case 'get-users':
838
+ case 'post-userdata':
839
+ case 'remove-account':
840
+ case 'post-secure':
841
+ case 'get-newsletters':
842
+ case 'subscribe-newsletter':
843
+ case 'signup':
844
+ case 'confirm-signup':
845
+ case 'recover-account':
846
+ case 'service':
847
+ return {
848
+ public: admin.admin_public,
849
+ private: admin.admin_private
850
+ };
851
+
852
+ case 'post-record':
853
+ case 'get-records':
854
+ case 'subscription':
855
+ case 'get-subscription':
856
+ case 'del-records':
857
+ case 'get-table':
858
+ case 'get-tag':
859
+ case 'get-index':
860
+ case 'storage-info':
861
+ return {
862
+ private: record.record_private,
863
+ public: record.record_public
864
+ };
865
+ }
866
+ };
867
+
868
+ return get_ep()[auth ? 'private' : 'public'] + dest;
869
+ };
870
+
871
+ let endpoint = isExternalUrl || (await getEndpoint(url, !!auth));
872
+ let service = this.session?.attributes?.['custom:service'] || __connection?.service || this.service;
873
+ let service_owner = this.session?.attributes?.['custom:service_owner'] || __connection?.owner || this.service_owner;
874
+
875
+ if (meta) {
876
+ if (typeof meta === 'object' && !Array.isArray(meta)) {
877
+ meta = JSON.parse(JSON.stringify(meta));
878
+ }
879
+ else {
880
+ throw new SkapiError('Invalid meta data.', { code: 'INVALID_REQUEST' });
881
+ }
882
+ }
883
+
884
+ if (Array.isArray(data) || data && typeof data !== 'object') {
885
+ throw new SkapiError('Request data should be a JSON Object | FormData | HTMLFormElement.', { code: 'INVALID_REQUEST' });
886
+ }
887
+
888
+ /* compose meta to send */
889
+ let required = { service, service_owner };
890
+
891
+ // set fetch options
892
+ let fetchOptions = {};
893
+ let { refresh = false } = options.fetchOptions || {};
894
+
895
+ if (options.fetchOptions && Object.keys(options.fetchOptions).length) {
896
+ // record fetch options
897
+ let fetOpt = checkParams(
898
+ {
899
+ limit: options?.fetchOptions?.limit || 100,
900
+ startKey: options?.fetchOptions?.startKey || null,
901
+ ascending: typeof options?.fetchOptions?.ascending === 'boolean' ? options.fetchOptions.ascending : true
902
+ },
903
+ {
904
+ limit: ['number', () => 100],
905
+ startKey: null,
906
+ ascending: ['boolean', () => true]
907
+ }
908
+ );
909
+
910
+ if (fetOpt.hasOwnProperty('limit') && typeof fetOpt.limit === 'number') {
911
+ if (fetOpt.limit > 1000) {
912
+ throw new SkapiError('Fetch limit should be below 1000.', { code: 'INVALID_REQUEST' });
913
+ }
914
+ Object.assign(fetchOptions, { limit: fetOpt.limit });
915
+ }
916
+
917
+ if (fetOpt.hasOwnProperty('startKey') && typeof fetOpt.startKey === 'object' && fetOpt.startKey && Object.keys(fetOpt.startKey)) {
918
+ Object.assign(fetchOptions, { startKey: fetOpt.startKey });
919
+ }
920
+
921
+ if (fetOpt.hasOwnProperty('ascending') && typeof fetOpt.ascending === 'boolean') {
922
+ Object.assign(fetchOptions, { ascending: fetOpt.ascending });
923
+ }
924
+ }
925
+
926
+ Object.assign(required, fetchOptions);
927
+
928
+ // extract html form meta. null if not formdata.
929
+ let metaData = extractFormMetaData(data);
930
+ let isForm = metaData?.meta;
931
+ let fileKeys = metaData?.files || [];
932
+
933
+ let normalize_form = async (data: any, isForm: any, addRequired = false) => {
934
+ if (isForm === null) {
935
+ return data;
936
+ }
937
+
938
+ let isFormEl = false;
939
+ if (data instanceof HTMLFormElement) {
940
+ data = new FormData(data);
941
+ isFormEl = true;
942
+ }
943
+
944
+ if (!(data instanceof FormData)) {
945
+ return data;
946
+ }
947
+
948
+ if (addRequired) {
949
+ // !do not change the order of iterations below!
950
+ for (let k in required) {
951
+ if (required[k] !== undefined) {
952
+ data.set(k, typeof required[k] === 'string' ? required[k] : new Blob([JSON.stringify(required[k])], {
953
+ type: 'application/json'
954
+ }));
955
+ }
956
+ }
957
+ }
958
+
959
+ for (let k in isForm) {
960
+ if (isForm[k] !== undefined) {
961
+ data[fileKeys.includes(k) ? "append" : "set"](k, typeof isForm[k] === 'string' ? isForm[k] : new Blob([JSON.stringify(isForm[k])], {
962
+ type: 'application/json'
963
+ }));
964
+ }
965
+ }
966
+
967
+ if (options?.fetchOptions?.formData) {
968
+ if (typeof options?.fetchOptions?.formData === 'function') {
969
+ if (!isFormEl) {
970
+ throw new SkapiError('Form data should be HTMLFormElement when formData() callback is used.', { code: 'INVALID_PARAMETER' });
971
+ }
972
+
973
+ let cb = options.fetchOptions.formData((data as FormData));
974
+ if (cb instanceof Promise) {
975
+ cb = await cb;
976
+ }
977
+
978
+ if (cb instanceof FormData) {
979
+ data = cb;
980
+ }
981
+
982
+ else {
983
+ throw new SkapiError('Callback for extractFormData() should return FormData', { code: 'INVALID_PARAMETER' });
984
+ }
985
+ }
986
+
987
+ else {
988
+ throw new SkapiError('Callback "formData" should type: function.', { code: 'INVALID_PARAMETER' });
989
+ }
990
+ }
991
+
992
+ return data;
993
+ };
994
+
995
+ if (meta) {
996
+ // add required to meta
997
+ // when meta has data, form data will be nested in 'data' key by the backend
998
+ meta = Object.assign(required, meta);
999
+ data = await normalize_form(data, isForm);
1000
+ }
1001
+
1002
+ else {
1003
+ // add required to data
1004
+ if (!data && isForm === null) {
1005
+ data = required;
1006
+ }
1007
+ else {
1008
+ if (isForm) {
1009
+ // data is either HTMLFormElement | FormData
1010
+ data = await normalize_form(data, isForm, true);
1011
+ }
1012
+ else {
1013
+ data = Object.assign(required, data);
1014
+ }
1015
+ }
1016
+ }
1017
+
1018
+ let requestKey = this.load_startKey_keys({
1019
+ params: Object.assign(isForm || (data || {}), required || {}),
1020
+ url: isExternalUrl || url,
1021
+ refresh: isForm ? true : refresh // should not use startKey when post is a form
1022
+ }); // returns requrestKey | cached data
1023
+
1024
+ if (requestKey && typeof requestKey === 'object') {
1025
+ return requestKey;
1026
+ }
1027
+
1028
+ if (typeof requestKey === 'string') {
1029
+ if (!(this.__pendingRequest[requestKey] instanceof Promise)) {
1030
+ // new request
1031
+
1032
+ let headers: Record<string, any> = {
1033
+ 'Accept': '*/*'
1034
+ };
1035
+
1036
+ if (token) {
1037
+ headers.Authorization = token;
1038
+ }
1039
+
1040
+ if (meta) {
1041
+ headers["Content-Meta"] = JSON.stringify(meta);
1042
+ }
1043
+
1044
+ if (options.hasOwnProperty('contentType')) {
1045
+ if (options?.contentType) {
1046
+ headers["Content-Type"] = options.contentType;
1047
+ }
1048
+ }
1049
+
1050
+ else if (!(data instanceof FormData)) {
1051
+ headers["Content-Type"] = 'application/json';
1052
+ }
1053
+
1054
+ let opt: RequestInit & { responseType?: string | null, headers: Record<string, any>; } = { headers };
1055
+ if (options?.responseType) {
1056
+ opt.responseType = options.responseType;
1057
+ }
1058
+
1059
+ // pending call request
1060
+ // this prevents recursive calls
1061
+ if (method === 'post') {
1062
+ this.__pendingRequest[requestKey] = this._post(endpoint, data, opt);
1063
+ }
1064
+ else if (method === 'get') {
1065
+ this.__pendingRequest[requestKey] = this._get(endpoint, data, opt);
1066
+ }
1067
+ }
1068
+
1069
+ try {
1070
+ let response = await this.__pendingRequest[requestKey];
1071
+
1072
+ // should not use startKey when post is a form (is a post)
1073
+ if (isForm) {
1074
+ return response;
1075
+ }
1076
+
1077
+ else {
1078
+ return await this.update_startKey_keys({
1079
+ hashedParam: requestKey,
1080
+ url,
1081
+ response
1082
+ });
1083
+ }
1084
+
1085
+ } catch (err) {
1086
+ throw err;
1087
+ } finally {
1088
+ // remove promise
1089
+ if (requestKey && this.__pendingRequest.hasOwnProperty(requestKey)) {
1090
+ delete this.__pendingRequest[requestKey];
1091
+ }
1092
+ }
1093
+ }
1094
+ }
1095
+ // cache, handle database records
1096
+
1097
+ private load_startKey_keys(option: {
1098
+ params: Record<string, any>;
1099
+ url: string;
1100
+ refresh?: boolean;
1101
+ }): string | FetchResponse {
1102
+
1103
+ let { params = {}, url, refresh = false } = option || {};
1104
+
1105
+ if (params.hasOwnProperty('startKey')) {
1106
+ if (
1107
+ typeof params.startKey !== 'object' && !Object.keys(params.startKey).length ||
1108
+ // params.startKey !== 'start' && params.startKey !== 'end'
1109
+ params.startKey !== 'end'
1110
+ ) {
1111
+ throw new SkapiError(`"${params.startKey}" is invalid startKey key.`, { code: 'INVALID_PARAMETER' });
1112
+ }
1113
+
1114
+ switch (params.startKey) {
1115
+ case 'end':
1116
+ // end is always end
1117
+ return {
1118
+ list: [],
1119
+ startKey: 'end',
1120
+ endOfList: true
1121
+ };
1122
+
1123
+ case 'start':
1124
+ // deletes referenced object key
1125
+ delete params.startKey;
1126
+ }
1127
+ }
1128
+
1129
+ let toHash = (() => {
1130
+ if (params && typeof params === 'object' && Object.keys(params).length) {
1131
+ // hash request parameters
1132
+ let paramsHash = JSON.parse(JSON.stringify(params));
1133
+
1134
+ function orderObjectKeys(obj: Record<string, any>) {
1135
+ function sortObject(obj: Record<string, any>): Record<string, any> {
1136
+ if (typeof obj === 'object' && obj) {
1137
+ return Object.keys(obj).sort().reduce((res, key) => ((res as any)[key] = obj[key], res), {});
1138
+ }
1139
+ return obj;
1140
+ };
1141
+
1142
+ let _obj = sortObject(obj);
1143
+ for (let k in _obj) {
1144
+ if (_obj[k] && typeof _obj[k] === 'object') {
1145
+ _obj[k] = sortObject(obj[k]);
1146
+ }
1147
+ }
1148
+
1149
+ return _obj;
1150
+ }
1151
+
1152
+ return JSON.stringify(orderObjectKeys(paramsHash)) + url + this.service;
1153
+ }
1154
+
1155
+ return url + this.service;
1156
+ })();
1157
+
1158
+ // let hashedParams = createHash('sha256').update(toHash).digest('hex');
1159
+ let hashedParams = sha256((() => {
1160
+ if (params && typeof params === 'object' && Object.keys(params).length) {
1161
+ // hash request parameters
1162
+ let paramsHash = JSON.parse(JSON.stringify(params));
1163
+
1164
+ function orderObjectKeys(obj: Record<string, any>) {
1165
+ function sortObject(obj: Record<string, any>): Record<string, any> {
1166
+ if (typeof obj === 'object' && obj) {
1167
+ return Object.keys(obj).sort().reduce((res, key) => ((res as any)[key] = obj[key], res), {});
1168
+ }
1169
+ return obj;
1170
+ };
1171
+
1172
+ let _obj = sortObject(obj);
1173
+ for (let k in _obj) {
1174
+ if (_obj[k] && typeof _obj[k] === 'object') {
1175
+ _obj[k] = sortObject(obj[k]);
1176
+ }
1177
+ }
1178
+
1179
+ return _obj;
1180
+ }
1181
+
1182
+ return JSON.stringify(orderObjectKeys(paramsHash)) + url + this.service;
1183
+ }
1184
+
1185
+ return url + this.service;
1186
+ })());
1187
+
1188
+ if (refresh && this.__startKey_keys?.[url]?.[hashedParams]) {
1189
+ // init cache, init startKey
1190
+
1191
+ if (this.__cached_requests?.[url] && this.__cached_requests?.[url]?.[hashedParams]) {
1192
+ // delete cached data start
1193
+ delete this.__cached_requests[url][hashedParams];
1194
+ }
1195
+
1196
+ if (Array.isArray(this.__startKey_keys[url][hashedParams]) && this.__startKey_keys[url][hashedParams].length) {
1197
+ // delete cache of all startkeys
1198
+ for (let p of this.__startKey_keys[url][hashedParams]) {
1199
+ let hashedParams_cached = hashedParams + sha256(JSON.stringify(p));
1200
+ // let hashedParams_cached = hashedParams + createHash('sha256').update(JSON.stringify(p)).digest('hex');
1201
+
1202
+ if (this.__cached_requests?.[url] && this.__cached_requests?.[url]?.[hashedParams_cached]) {
1203
+ delete this.__cached_requests[url][hashedParams_cached];
1204
+ }
1205
+ }
1206
+ }
1207
+
1208
+ // delete start key lists
1209
+ delete this.__startKey_keys[url][hashedParams];
1210
+
1211
+ return hashedParams;
1212
+ }
1213
+
1214
+ if (!Array.isArray(this.__startKey_keys?.[url]?.[hashedParams])) {
1215
+ // startkey does not exists
1216
+ return hashedParams;
1217
+ }
1218
+
1219
+ // hashed params exists
1220
+ let list_of_startKeys = this.__startKey_keys[url][hashedParams]; // [{<startKey key>}, ...'end']
1221
+ let last_startKey_key = list_of_startKeys[list_of_startKeys.length - 1];
1222
+ let cache_hashedParams = hashedParams;
1223
+ // if (last_startKey_key !== 'start') {
1224
+ if (last_startKey_key) {
1225
+ // use last start key
1226
+
1227
+ if (last_startKey_key === 'end') {
1228
+ return {
1229
+ list: [],
1230
+ startKey: 'end',
1231
+ endOfList: true
1232
+ };
1233
+ }
1234
+
1235
+ else {
1236
+ // cache_hashedParams += createHash('sha256').update(last_startKey_key).digest('hex');
1237
+ cache_hashedParams += sha256(last_startKey_key);
1238
+ params.startKey = JSON.parse(last_startKey_key);
1239
+ }
1240
+ }
1241
+
1242
+ if (this.__cached_requests?.[url]?.[cache_hashedParams]) {
1243
+ // return data if there is cache
1244
+ return this.__cached_requests?.[url]?.[cache_hashedParams];
1245
+ }
1246
+
1247
+ return hashedParams;
1248
+ }
1249
+
1250
+ private update_startKey_keys = async (option: Record<string, any>) => {
1251
+ let { hashedParam, url, response } = option;
1252
+ let fetched = null;
1253
+
1254
+ if (response instanceof Promise) {
1255
+ fetched = await response;
1256
+ }
1257
+
1258
+ else {
1259
+ fetched = response;
1260
+ }
1261
+
1262
+ if (
1263
+ typeof fetched !== 'object' ||
1264
+ !fetched.hasOwnProperty('startKey') ||
1265
+ !hashedParam ||
1266
+ !url
1267
+ ) {
1268
+ // no startkey no caching
1269
+ return fetched;
1270
+ }
1271
+
1272
+ // has start key
1273
+ // startkey is key for next fetch
1274
+
1275
+ // this.__startKey_keys[url] = {
1276
+ // [hashedParam]: ['{<startKey key>}', ...'end'],
1277
+ // ...
1278
+ // }
1279
+
1280
+ // this.__cached_requests[url][hashsedParams + sha256(JSON.stringify(startKey))] = {
1281
+ // data
1282
+ // ...
1283
+ // }
1284
+
1285
+ if (!this.__startKey_keys.hasOwnProperty(url)) {
1286
+ // create url key to store startKey key list if it doesnt exists
1287
+ this.__startKey_keys[url] = {};
1288
+ }
1289
+
1290
+ if (!this.__cached_requests?.[url]) {
1291
+ this.__cached_requests[url] = {};
1292
+ }
1293
+
1294
+ this.__cached_requests[url][hashedParam] = fetched;
1295
+
1296
+ if (!this.__startKey_keys[url].hasOwnProperty(hashedParam)) {
1297
+ this.__startKey_keys[url][hashedParam] = [];
1298
+ }
1299
+
1300
+ let startKey_string = JSON.stringify(fetched.startKey);
1301
+ if (!this.__startKey_keys[url][hashedParam].includes(startKey_string)) {
1302
+ this.__startKey_keys[url][hashedParam].push(startKey_string);
1303
+ }
1304
+
1305
+ this.__cached_requests[url][hashedParam] = fetched;
1306
+
1307
+ return Object.assign({ startKey_list: this.__startKey_keys[url][hashedParam] }, fetched);
1308
+ };
1309
+
1310
+ private _fetch = async (url: string, opt: RequestInit, responseType: string) => {
1311
+
1312
+ let response: Record<string, any> = await fetch(url, opt);
1313
+
1314
+ if (responseType) {
1315
+ if (response.status === 200) {
1316
+ return await response[responseType]();
1317
+ } else {
1318
+ throw response;
1319
+ }
1320
+ }
1321
+
1322
+ let received = await response.text();
1323
+ try {
1324
+ received = JSON.parse(received);
1325
+ } catch (err) {
1326
+ }
1327
+
1328
+ if (response.status === 200) {
1329
+ if (typeof received === 'object' && opt.method === 'GET' && received.hasOwnProperty('body')) {
1330
+ try {
1331
+ received = JSON.parse(received.body);
1332
+ } catch (err) { }
1333
+ }
1334
+ return received;
1335
+ }
1336
+
1337
+ else {
1338
+ let status = response?.status;
1339
+ let errCode = [
1340
+ 'INVALID_CORS',
1341
+ 'INVALID_REQUEST',
1342
+ 'SERVICE_DISABLED',
1343
+ 'INVALID_PARAMETER',
1344
+ 'ERROR',
1345
+ 'EXISTS',
1346
+ 'NOT_EXISTS'
1347
+ ];
1348
+
1349
+ if (typeof received === 'object' && received?.message) {
1350
+ let code = ((status ? status.toString() : null) || 'ERROR');
1351
+ throw new SkapiError(received?.message, { code: code });
1352
+ }
1353
+
1354
+ else if (typeof received === 'string') {
1355
+ let errMsg = received.split(':');
1356
+ let code = errMsg.splice(0, 1)[0];
1357
+ throw new SkapiError(errMsg.join(''), { code: (errCode.includes(code) ? code : 'ERROR') });
1358
+ }
1359
+
1360
+ throw response;
1361
+ }
1362
+ };
1363
+
1364
+ private _post(url: string, params: Record<string, any>, option: RequestInit & { responseType?: string | null, headers: Record<string, any>; }) {
1365
+ let responseType = null;
1366
+ if (option.hasOwnProperty('responseType')) {
1367
+ responseType = option.responseType;
1368
+ delete option.responseType;
1369
+ }
1370
+
1371
+ let opt = Object.assign(
1372
+ {
1373
+ method: 'POST'
1374
+ },
1375
+ option,
1376
+ {
1377
+ body: params instanceof FormData ? params : JSON.stringify(params)
1378
+ }
1379
+ );
1380
+
1381
+ return this._fetch(url, opt, responseType);
1382
+ }
1383
+
1384
+ private _get = async (url: string, params: Record<string, any>, option: RequestInit & { responseType?: string | null, headers: Record<string, any>; }) => {
1385
+ if (params && typeof params === 'object' && Object.keys(params).length) {
1386
+ if (url.substring(url.length - 1) !== '?') {
1387
+ url = url + '?';
1388
+ }
1389
+
1390
+ let query = Object.keys(params)
1391
+ .map(k => {
1392
+ let value = params[k];
1393
+ if (typeof value !== 'string') {
1394
+ value = JSON.stringify(value);
1395
+ }
1396
+ return encodeURIComponent(k) + '=' + encodeURIComponent(value);
1397
+ })
1398
+ .join('&');
1399
+
1400
+ url += query;
1401
+ }
1402
+
1403
+ let responseType = null;
1404
+ if (option.hasOwnProperty('responseType')) {
1405
+ responseType = option.responseType;
1406
+ delete option.responseType;
1407
+ }
1408
+
1409
+ let opt = Object.assign(
1410
+ {
1411
+ method: 'GET'
1412
+ },
1413
+ option
1414
+ );
1415
+
1416
+ return this._fetch(url, opt, responseType);
1417
+ };
1418
+
1419
+ /**
1420
+ * Uploads data to your service database.<br>
1421
+ * We will call data in database as record.
1422
+ * <ul>
1423
+ * <li>
1424
+ * <b>About tables:</b><br>
1425
+ * When uploading new records, table setting is required.<br>
1426
+ * Database table are created automatically on upload.<br>
1427
+ * You can have infinite numbers of tables.<br>
1428
+ * You cannot change table settings once it's uploaded.<br>
1429
+ * Database table will be deleted automatically if there is no record in the table.<br>
1430
+ * <br>
1431
+ * <b>NOTE:</b> Whitespace or special characters are not allowed in table name.<br>
1432
+ * </li>
1433
+ * <li>
1434
+ * <b>About Index:</b><br>
1435
+ * Index help you to categorize records paired by "name" and "value".<br>
1436
+ * Index caculates total sum of values if the value is number or boolean.<br>
1437
+ * <b>NOTE:</b> Whitespace or special characters are not allowed in index name or value.<br>
1438
+ * </li>
1439
+ * <li>
1440
+ * <b>About Tags:</b><br>
1441
+ * Tags let's you categorize records by given tags.<br>
1442
+ * You can have multiple tags on single record.<br>
1443
+ * <b>NOTE:</b> Whitespace or special characters are not allowed in tags.<br>
1444
+ * </li>
1445
+ * </ul>
1446
+ * @category Database
1447
+ */
1448
+ @formResponse()
1449
+ async postRecord(
1450
+ /** Any type of data to store. If undefined, does not update the data. */
1451
+ form: Form | any,
1452
+ option?: PostRecordParams & FormCallbacks
1453
+ ): Promise<RecordData> {
1454
+
1455
+ let is_admin = await this.requireAdmin({ ignoreVerification: true });
1456
+
1457
+ let { formData } = option;
1458
+ let fetchOptions: Record<string, any> = {};
1459
+
1460
+ if (typeof formData === 'function') {
1461
+ fetchOptions.formData = formData;
1462
+ }
1463
+
1464
+ option = checkParams(option || {}, {
1465
+ record_id: 'string',
1466
+ access_group: ['number', 'private'],
1467
+ table: ['string', () => {
1468
+ if (!option?.record_id) {
1469
+ throw new SkapiError('Either "record_id" or "table" should have a value.', { code: 'INVALID_PARAMETER' });
1470
+ }
1471
+ }],
1472
+ subscription_group: 'number',
1473
+ reference: 'string',
1474
+ index: {
1475
+ name: 'string',
1476
+ value: ['string', 'number', 'boolean']
1477
+ },
1478
+ tags: (v: string | string[]) => {
1479
+ if (v === null) {
1480
+ return v;
1481
+ }
1482
+
1483
+ if (typeof v === 'string') {
1484
+ return [v];
1485
+ }
1486
+
1487
+ if (Array.isArray(v)) {
1488
+ for (let i of v) {
1489
+ if (typeof i !== 'string') {
1490
+ throw new SkapiError(`"tags" should be type: <string | string[]>`, { code: 'INVALID_PARAMETER' });
1491
+ }
1492
+ }
1493
+ return v;
1494
+ }
1495
+
1496
+ throw new SkapiError(`"tags" should be type: <string | string[]>`, { code: 'INVALID_PARAMETER' });
1497
+ },
1498
+ config: {
1499
+ reference_limit: ['number', null],
1500
+ allow_multiple_reference: 'boolean',
1501
+ private_access: (v: string | string[]) => {
1502
+ let param = 'config.private_access';
1503
+
1504
+ if (v && typeof v === 'string') {
1505
+ v = [v];
1506
+ }
1507
+
1508
+ if (Array.isArray(v)) {
1509
+ for (let u of v) {
1510
+ validateUserId(u, `User ID in "${param}"`);
1511
+
1512
+ if (this.user && u === this.user.user_id) {
1513
+ throw new SkapiError(`"${param}" should not be the uploader's user ID.`, { code: 'INVALID_PARAMETER' });
1514
+ }
1515
+ }
1516
+ }
1517
+
1518
+ else {
1519
+ throw new SkapiError(`"${param}" should be an array of user ID.`, { code: 'INVALID_PARAMETER' });
1520
+ }
1521
+
1522
+ return v;
1523
+ }
1524
+ }
1525
+ }, [], ['response', 'formData', 'onerror']);
1526
+
1527
+ // callbacks should be removed after checkparams
1528
+ delete option.response;
1529
+ delete option.formData;
1530
+ delete option.onerror;
1531
+
1532
+ if (option?.index) {
1533
+ // index name allows periods. white space is invalid.
1534
+ if (!option.index?.name || typeof option.index?.name !== 'string') {
1535
+ throw new SkapiError('"index.name" is required. type: string.', { code: 'INVALID_PARAMETER' });
1536
+ }
1537
+
1538
+ checkWhiteSpaceAndSpecialChars(option.index.name, 'index name', true);
1539
+
1540
+ if (!option.index.hasOwnProperty('value')) {
1541
+ throw new SkapiError('"index.value" is required.', { code: 'INVALID_PARAMETER' });
1542
+ }
1543
+
1544
+ if (typeof option.index.value === 'string') {
1545
+ // index name allows periods. white space is invalid.
1546
+ checkWhiteSpaceAndSpecialChars(option.index.value, 'index value', false, true);
1547
+ }
1548
+
1549
+ else if (typeof option.index.value === 'number') {
1550
+ if (option.index.value > this.__index_number_range || option.index.value < -this.__index_number_range) {
1551
+ throw new SkapiError(`Number value should be within range -${this.__index_number_range} ~ +${this.__index_number_range}`, { code: 'INVALID_PARAMETER' });
1552
+ }
1553
+ }
1554
+ }
1555
+
1556
+
1557
+ if (is_admin) {
1558
+ if (option?.access_group === 'private') {
1559
+ throw new SkapiError('Service owner cannot write private records.', { code: 'INVALID_REQUEST' });
1560
+ }
1561
+
1562
+ if (option.hasOwnProperty('subscription_group')) {
1563
+ throw new SkapiError('Service owner cannot write to subscription table.', { code: 'INVALID_REQUEST' });
1564
+ }
1565
+ }
1566
+
1567
+ let options = { auth: true };
1568
+ let postData = null;
1569
+
1570
+ if (form instanceof HTMLFormElement || form instanceof FormData) {
1571
+ Object.assign(options, { meta: option });
1572
+ }
1573
+
1574
+ else {
1575
+ postData = Object.assign({ data: form }, option);
1576
+ }
1577
+
1578
+ if (Object.keys(fetchOptions).length) {
1579
+ Object.assign(options, { fetchOptions });
1580
+ }
1581
+
1582
+ return normalize_record_data(await this.request('post-record', postData || form, options));
1583
+ }
1584
+
1585
+ /**
1586
+ * Get records from service database.
1587
+ * @category Database
1588
+ */
1589
+ async getRecords(params: GetRecordParams, fetchOptions?: FetchOptions): Promise<FetchResponse> {
1590
+ const indexTypes = {
1591
+ '$record_id': 'string',
1592
+ '$updated': 'number',
1593
+ '$uploaded': 'number',
1594
+ '$referenced_count': 'number'
1595
+ };
1596
+
1597
+ const struct = {
1598
+ table: 'string',
1599
+ reference: 'string',
1600
+ access_group: ['number', 'private'],
1601
+ subscription: {
1602
+ user_id: (v: string) => validateUserId(v, 'User ID in "subscription.user_id"'),
1603
+ group: 'number'
1604
+ },
1605
+ index: {
1606
+ name: (v: string) => {
1607
+ if (typeof v !== 'string') {
1608
+ throw new SkapiError('"index.name" should be type: string.', { code: 'INVALID_PARAMETER' });
1609
+ }
1610
+
1611
+ if (indexTypes.hasOwnProperty(v)) {
1612
+ return v;
1613
+ }
1614
+
1615
+ return checkWhiteSpaceAndSpecialChars(v, 'index.name', true, false);
1616
+ },
1617
+ value: (v: number | boolean | string) => {
1618
+ if (params.index?.name && indexTypes.hasOwnProperty(params.index.name)) {
1619
+ let tp = indexTypes[params.index.name];
1620
+
1621
+ if (typeof v === tp) {
1622
+ return v;
1623
+ }
1624
+
1625
+ else {
1626
+ throw new SkapiError(`"index.value" should be type: ${tp}.`, { code: 'INVALID_PARAMETER' });
1627
+ }
1628
+ }
1629
+
1630
+ if (typeof v === 'number') {
1631
+ if (v > this.__index_number_range || v < -this.__index_number_range) {
1632
+ throw new SkapiError(`Number value should be within range -${this.__index_number_range} ~ +${this.__index_number_range}`, { code: 'INVALID_PARAMETER' });
1633
+ }
1634
+ return v;
1635
+ }
1636
+
1637
+ else if (typeof v === 'boolean') {
1638
+ return v;
1639
+ }
1640
+
1641
+ else {
1642
+ // is string
1643
+ return checkWhiteSpaceAndSpecialChars((v as string), 'index.value', false, true);
1644
+ }
1645
+ },
1646
+ condition: ['gt', 'gte', 'lt', 'lte', '>', '>=', '<', '<=', '=', 'eq', '!=', 'ne'],
1647
+ range: (v: number | boolean | string) => {
1648
+ if (!('value' in params.index)) {
1649
+ throw new SkapiError('"index.value" is required.', { code: 'INVALID_PARAMETER' });
1650
+ }
1651
+
1652
+ if (params.index.name === '$record_id') {
1653
+ throw new SkapiError(`Cannot do "index.range" on ${params.index.name}`, { code: 'INVALID_PARAMETER' });
1654
+ }
1655
+
1656
+ if (typeof params.index.value !== typeof v) {
1657
+ throw new SkapiError('"index.range" type should match the type of "index.value".', { code: 'INVALID_PARAMETER' });
1658
+ }
1659
+
1660
+ if (typeof v === 'string') {
1661
+ return checkWhiteSpaceAndSpecialChars(v, 'index.value');
1662
+ }
1663
+
1664
+ return v;
1665
+ }
1666
+ },
1667
+ tags: (v: string | string[]) => {
1668
+ if (typeof v === 'string') {
1669
+ return [v];
1670
+ }
1671
+ else if (Array.isArray(v)) {
1672
+ if (v.length > 10) {
1673
+ throw new SkapiError('Cannot query more than 10 tags at once.', { code: 'INVALID_REQUEST' });
1674
+ }
1675
+ for (let s of v) {
1676
+ if (typeof s !== 'string') {
1677
+ throw new SkapiError('Tags should be type: <string | string[]>', { code: 'INVALID_PARAMETER' });
1678
+ }
1679
+ }
1680
+ return v;
1681
+ }
1682
+ }
1683
+ };
1684
+
1685
+ params = checkParams(params || {}, struct, ['table']);
1686
+
1687
+ if (params?.subscription && !this.session) {
1688
+ throw new SkapiError('Requires login.', { code: 'INVALID_REQUEST' });
1689
+ }
1690
+
1691
+ if (params?.tags) {
1692
+ let tagFetch = [];
1693
+ let getStartKey = fetchOptions?.startKey || null;
1694
+
1695
+ for (let t of params.tags) {
1696
+ let params_copy = JSON.parse(JSON.stringify(params));
1697
+ params_copy.tag = t;
1698
+ delete params_copy.tags;
1699
+
1700
+ let fetchOpt = fetchOptions ? JSON.parse(JSON.stringify(fetchOptions)) : null;
1701
+ if (fetchOpt) {
1702
+ delete fetchOpt.startKey;
1703
+
1704
+ if (getStartKey && getStartKey?.[t]) {
1705
+ fetchOpt.startKey = getStartKey[t];
1706
+ }
1707
+ }
1708
+
1709
+ tagFetch.push(this.request(
1710
+ 'get-records',
1711
+ params_copy,
1712
+ Object.assign(
1713
+ { auth: params.hasOwnProperty('access_group') && (params.access_group === 'private' || params.access_group > 0) ? true : !!this.session },
1714
+ { fetchOptions: fetchOpt }
1715
+ )));
1716
+ }
1717
+
1718
+ let list = [];
1719
+ let startKey = {};
1720
+ let res_all = await Promise.all(tagFetch);
1721
+
1722
+ for (let res of res_all) {
1723
+ for (let i in res.list) {
1724
+ if (tagFetch.includes(res.list[i].rec)) {
1725
+ continue;
1726
+ }
1727
+ tagFetch.push(res.list[i]);
1728
+ list.push(normalize_record_data(res.list[i]));
1729
+ };
1730
+ if (res.startKey) {
1731
+ if (Array.isArray(params.tags)) {
1732
+ let tag = params.tags.splice(0, 1);
1733
+ startKey[tag[0]] = res.startKey;
1734
+ }
1735
+ }
1736
+ }
1737
+
1738
+ let endOfList = true;
1739
+ for (let k in startKey) {
1740
+ if (startKey[k] && startKey[k] !== 'end') {
1741
+ endOfList = false;
1742
+ break;
1743
+ }
1744
+ }
1745
+
1746
+ return {
1747
+ list,
1748
+ endOfList,
1749
+ startKey
1750
+ };
1751
+ }
1752
+
1753
+ let result = await this.request(
1754
+ 'get-records',
1755
+ params,
1756
+ Object.assign(
1757
+ { auth: params.hasOwnProperty('access_group') && (params.access_group === 'private' || params.access_group > 0) ? true : !!this.user },
1758
+ { fetchOptions }
1759
+ )
1760
+ );
1761
+
1762
+ for (let i in result.list) { result.list[i] = normalize_record_data(result.list[i]); };
1763
+
1764
+ return result;
1765
+ }
1766
+
1767
+ /**
1768
+ * Retrieve table info of record database.
1769
+ * Get table info of record database.
1770
+ *
1771
+ * ```
1772
+ * // Get information in 'MyTable'.
1773
+ * let getTable = await skapi.getTable({
1774
+ * table: 'MyTable'
1775
+ * });
1776
+ *
1777
+ * // Get all list of tables in service in lexographical order.
1778
+ * let getTablePrivate = await skapi.getTable();
1779
+ *
1780
+ * // Get all list of tables in service in lexographical order.
1781
+ * let getTablePrivate = await skapi.getTable();
1782
+ * ```
1783
+ */
1784
+ async getTable(
1785
+ params: {
1786
+ /** Table name. If omitted fetch all list of tables. */
1787
+ table?: string;
1788
+ condition?: string;
1789
+ },
1790
+ fetchOptions?: FetchOptions
1791
+ ): Promise<FetchResponse> {
1792
+ let res = await this.request('get-table', checkParams(params || {}, {
1793
+ table: 'string',
1794
+ condition: ['gt', 'gte', 'lt', 'lte', '>', '>=', '<', '<=', '=', 'eq', '!=', 'ne']
1795
+ }), Object.assign({ auth: true }, { fetchOptions }));
1796
+
1797
+ let convert = {
1798
+ 'cnt_rec': 'number_of_records',
1799
+ 'tbl': 'table',
1800
+ 'srvc': 'service'
1801
+ };
1802
+
1803
+ if (Array.isArray(res.list)) {
1804
+ for (let t of res.list) {
1805
+ for (let k in convert) {
1806
+ if (t.hasOwnProperty(k)) {
1807
+ t[convert[k]] = t[k];
1808
+ delete t[k];
1809
+ }
1810
+ }
1811
+ }
1812
+ }
1813
+
1814
+ return res;
1815
+ }
1816
+ /**
1817
+ * Retrieve index info of record database.
1818
+ * Get index info of record database.
1819
+ *
1820
+ * ```
1821
+ *
1822
+ * // Get info of "Gold" index in "MyTable" table.
1823
+ * let getIndex = await skapi.getIndex({
1824
+ * index: 'Gold',
1825
+ * table: 'MyTable'
1826
+ * });
1827
+ *
1828
+ * // Get all index in average value order in "MyTable" table.
1829
+ * let getIndexAll = await skapi.getIndex({
1830
+ * order_by: {
1831
+ * name: 'average_number'
1832
+ * },
1833
+ * table: 'MyTable'
1834
+ * });
1835
+ *
1836
+ * ```
1837
+ * @category Database
1838
+ */
1839
+ async getIndex(
1840
+ params: {
1841
+ /** Table name */
1842
+ table: string;
1843
+ /** Index name. When period is at the end of name, querys nested index keys. */
1844
+ index?: string;
1845
+ /** Queries order by */
1846
+ order_by: {
1847
+ /** Key name to order. */
1848
+ name: 'average_number' | 'total_number' | 'number_count' | 'average_bool' | 'total_bool' | 'bool_count' | 'string_count' | 'index_name';
1849
+ /** Value to query. */
1850
+ value?: number | boolean | string;
1851
+ /** "order_by.value" is required for condition. */
1852
+ condition?: 'gt' | 'gte' | 'lt' | 'lte' | '>' | '>=' | '<' | '<=' | '=' | 'eq' | '!=' | 'ne';
1853
+ };
1854
+ },
1855
+ fetchOptions?: FetchOptions
1856
+ ): Promise<FetchResponse> {
1857
+
1858
+ let p = checkParams(
1859
+ params || {},
1860
+ {
1861
+ table: 'string',
1862
+ index: (v: string) => checkWhiteSpaceAndSpecialChars(v, 'index name', true, false),
1863
+ order_by: {
1864
+ name: [
1865
+ 'average_number',
1866
+ 'total_number',
1867
+ 'number_count',
1868
+ 'average_bool',
1869
+ 'total_bool',
1870
+ 'bool_count',
1871
+ 'string_count',
1872
+ 'index_name'
1873
+ ],
1874
+ value: ['string', 'number', 'boolean'],
1875
+ condition: ['gt', 'gte', 'lt', 'lte', '>', '>=', '<', '<=', '=', 'eq', '!=', 'ne']
1876
+ }
1877
+ },
1878
+ ['table']
1879
+ );
1880
+
1881
+ if (p.hasOwnProperty('order_by')) {
1882
+ if (p.order_by === 'index_name') {
1883
+ if (!p.hasOwnProperty('index')) {
1884
+ throw new SkapiError('"index" is required for ordered by "index_name".', { code: 'INVALID_PARAMETER' });
1885
+ }
1886
+
1887
+ if (p.index.substring(p.index.length - 1) !== '.') {
1888
+ throw new SkapiError('"index" should be parent "index name".', { code: 'INVALID_PARAMETER' });
1889
+ }
1890
+ }
1891
+
1892
+ if (p.order_by.hasOwnProperty('condition') && !p.order_by.hasOwnProperty('value')) {
1893
+ throw new SkapiError('"value" is required for "condition".', { code: 'INVALID_PARAMETER' });
1894
+ }
1895
+ }
1896
+
1897
+ let res = await this.request(
1898
+ 'get-index',
1899
+ p,
1900
+ Object.assign(
1901
+ { auth: true },
1902
+ { fetchOptions }
1903
+ )
1904
+ );
1905
+
1906
+ let convert = {
1907
+ 'cnt_bool': 'boolean_count',
1908
+ 'cnt_numb': 'number_count',
1909
+ 'totl_numb': 'total_number',
1910
+ 'totl_bool': 'total_bool',
1911
+ 'avrg_numb': 'average_number',
1912
+ 'avrg_bool': 'average_bool',
1913
+ 'cnt_str': 'string_count'
1914
+ };
1915
+
1916
+ if (Array.isArray(res.list)) {
1917
+ res.list = res.list.map((i: Record<string, any>) => {
1918
+ let iSplit = i.idx.split('/');
1919
+ let resolved: Record<string, any> = {
1920
+ table: iSplit[1],
1921
+ index: iSplit[2],
1922
+ number_of_records: i.cnt_rec
1923
+ };
1924
+
1925
+ for (let k in convert) {
1926
+ if (i?.[k]) {
1927
+ resolved[convert[k]] = i[k];
1928
+ }
1929
+
1930
+ if (resolved?.number_of_number_values) {
1931
+ resolved.average_of_number_values = i.totl_numb / i.cnt_numb;
1932
+ }
1933
+ if (resolved?.number_of_boolean_values) {
1934
+ resolved.average_of_boolean_values = i.totl_bool / i.cnt_bool;
1935
+ }
1936
+ }
1937
+
1938
+ return resolved;
1939
+ });
1940
+ }
1941
+
1942
+ return res;
1943
+ }
1944
+
1945
+
1946
+ /**
1947
+ * Retrieve filter info of database table.
1948
+ *
1949
+ * ```
1950
+ * // Get all tags from "MyTable" in record number orders.
1951
+ * let getTagAll = await skapi.getTag({
1952
+ * table: "MyTable"
1953
+ * });
1954
+ *
1955
+ * // Get info on 'Gold' from "MyTable".
1956
+ * let getTagInfo = await skapi.getTag({
1957
+ * table: "MyTable",
1958
+ * tag: 'Gold'
1959
+ * });
1960
+ * ```
1961
+ * @category Database
1962
+ */
1963
+ async getTag(
1964
+ params: {
1965
+ /** Table name */
1966
+ table: string;
1967
+ /** Tag name */
1968
+ tag: string;
1969
+ /** String query condition for tag name. */
1970
+ condition: 'gt' | 'gte' | 'lt' | 'lte' | '>' | '>=' | '<' | '<=' | '=' | 'eq' | '!=' | 'ne';
1971
+ },
1972
+ fetchOptions?: FetchOptions
1973
+ ): Promise<FetchResponse> {
1974
+
1975
+ let res = await this.request(
1976
+ 'get-tag',
1977
+ checkParams(params || {},
1978
+ {
1979
+ table: 'string',
1980
+ tag: 'string',
1981
+ condition: ['gt', 'gte', 'lt', 'lte', '>', '>=', '<', '<=', '=', 'eq', '!=', 'ne']
1982
+ }
1983
+ ),
1984
+ Object.assign({ auth: true }, { fetchOptions })
1985
+ );
1986
+
1987
+ if (Array.isArray(res.list)) {
1988
+ for (let i in res.list) {
1989
+ let item = res.list[i];
1990
+ let tSplit = item.tag.split('/');
1991
+ res.list[i] = {
1992
+ table: tSplit[1],
1993
+ tag: tSplit[0],
1994
+ number_of_records: item.cnt_rec
1995
+ };
1996
+ }
1997
+ }
1998
+
1999
+ return res;
2000
+ }
2001
+
2002
+ /**
2003
+ * Deletes specific records or bulk of records under certain table, index, tag.
2004
+ * <br>
2005
+ * <b>WARNING:</b> Deleted record cannot be restored.
2006
+ *
2007
+ * ```
2008
+ * // Delete record
2009
+ * // users wont be able to delete other users record.
2010
+ * await skapi.deleteRecords({
2011
+ * record_id: ['record id 1', 'record id 2']
2012
+ * });
2013
+ *
2014
+ * // Delete all my record in "MyTable" in access group 1.
2015
+ * await skapi.deleteRecords({
2016
+ * access_group: 1,
2017
+ * table: {
2018
+ * name: 'MyTable'
2019
+ * }
2020
+ * });
2021
+ *
2022
+ * // Delete all record in subscription table of group 1 in "MyTable" in access group 0.
2023
+ * await skapi.deleteRecords({
2024
+ * access_group: 0,
2025
+ * table: {
2026
+ * name: 'MyTable',
2027
+ * subscription_group: 1
2028
+ * }
2029
+ * });
2030
+ *
2031
+ * // (for admin) Delete all record in the service
2032
+ * await skapi.deleteRecords({
2033
+ * service: 'xxxxxxxx'
2034
+ * });
2035
+ *
2036
+ * // (for admin) Delete all record in "MyTable"
2037
+ * // admin can delete all records in table regardless access group or subscription tables.
2038
+ * await skapi.deleteRecords({
2039
+ * service: 'xxxxxxxx',
2040
+ * table: {
2041
+ * name: 'MyTable'
2042
+ * }
2043
+ * });
2044
+ * // (for admin) Delete all record in "MyTable"
2045
+ *
2046
+ * // admin can delete all records in users subscription table from target access group.
2047
+ * await skapi.deleteRecords({
2048
+ * service: 'xxxxxxxx',
2049
+ * access_group: 1,
2050
+ * table: {
2051
+ * name: 'MyTable',
2052
+ * subscription: 'user_id',
2053
+ * subscription_group: 1
2054
+ * }
2055
+ * });
2056
+ *
2057
+ * ```
2058
+ * @category Database
2059
+ */
2060
+ async deleteRecords(params: {
2061
+ /**
2062
+ * (only admin) Service ID.
2063
+ */
2064
+ service?: string;
2065
+ /**
2066
+ * Record ID(s) to delete.<br>
2067
+ * table parameter is not needed when record_id is given.
2068
+ */
2069
+ record_id?: string | string[];
2070
+ /**
2071
+ * Access group number.<br>
2072
+ */
2073
+ access_group: number | 'private';
2074
+ /**
2075
+ * Table to delete.<br>
2076
+ */
2077
+ table: {
2078
+ /** Table name. */
2079
+ name: string;
2080
+ /** @ignore */
2081
+ subscription: string;
2082
+ /**
2083
+ * Subscription group number.<br>
2084
+ * Access group is required.
2085
+ */
2086
+ subscription_group?: number;
2087
+ };
2088
+ }): Promise<string> {
2089
+ let isAdmin = await this.requireAdmin();
2090
+ if (isAdmin && !params?.service) {
2091
+ throw new SkapiError('Service ID is required.', { code: 'INVALID_PARAMETER' });
2092
+ }
2093
+
2094
+ if (!isAdmin && !params?.table) {
2095
+ throw new SkapiError('"table" is required.', { code: 'INVALID_PARAMETER' });
2096
+ }
2097
+
2098
+ if (params?.record_id) {
2099
+ return await this.request('del-records', {
2100
+ service: params.service,
2101
+ record_id: (v => {
2102
+ let id = checkWhiteSpaceAndSpecialChars(v, 'record_id', false);
2103
+ if (typeof id === 'string') {
2104
+ return [id];
2105
+ }
2106
+
2107
+ return id;
2108
+ })(params.record_id)
2109
+ }, { auth: true });
2110
+ }
2111
+
2112
+ else {
2113
+ if (!params?.table) {
2114
+ throw new SkapiError('Either "table" or "record_id" is required.', { code: 'INVALID_PARAMETER' });
2115
+ }
2116
+
2117
+ let struct = {
2118
+ access_group: ['number', 'private'],
2119
+ table: {
2120
+ name: 'string',
2121
+ subscription: (v: string) => {
2122
+ if (isAdmin) {
2123
+ // admin targets user id
2124
+ return validateUserId((v as string), 'User ID in "table.subscription"');
2125
+ }
2126
+
2127
+ throw new SkapiError('"table.subscription" is an invalid parameter key.', { code: 'INVALID_PARAMETER' });
2128
+ },
2129
+ subscription_group: (v: number) => {
2130
+ if (isAdmin && typeof params?.table?.subscription !== 'string') {
2131
+ throw new SkapiError('"table.subscription" is required.', { code: 'INVALID_PARAMETER' });
2132
+ }
2133
+
2134
+ if (typeof v === 'number') {
2135
+ if (v > 0 && v < 99) {
2136
+ return v;
2137
+ }
2138
+ }
2139
+
2140
+ throw new SkapiError('Subscription group should be between 0 ~ 99.', { code: 'INVALID_PARAMETER' });
2141
+ }
2142
+ }
2143
+ };
2144
+
2145
+ params = checkParams(params || {}, struct, isAdmin ? ['service'] : ['table', 'access_group']);
2146
+ }
2147
+
2148
+ return await this.request('del-records', params, { auth: true });
2149
+ }
2150
+
2151
+
2152
+ //<_subscriptions>
2153
+ /**
2154
+ * Anyone who submits their E-Mail address will receive newsletters from you.<br>
2155
+ * The newsletters you send out will have unsubscribe link at the bottom.<br>
2156
+ * Both Signed and unsigned users can subscribe to your newsletter.<br>
2157
+ * Refer: <a href='www.google.com'>Sending out newsletters</a>
2158
+ * ```
2159
+ * let params = {
2160
+ * email: 'visitors@email.com',
2161
+ * bypassWelcome: false // Send out welcome E-Mails on submit
2162
+ * };
2163
+ *
2164
+ * skapi.subscribeNewsletter(params);
2165
+ * ```
2166
+ * @category Subscriptions
2167
+ */
2168
+ @formResponse()
2169
+ async subscribeNewsletter(
2170
+ form: Form | {
2171
+ /** Newsletter subscriber's E-Mail. 64 character max. */
2172
+ email: string,
2173
+ /**
2174
+ * Subscriber will receive a welcome E-Mail if set to false.<br>
2175
+ * The welcome E-Mail is the same E-Mail that is sent when the new user successfully creates an account on your web services.<br>
2176
+ * To save your operation cost, it is advised to redirect the users to your welcome page once subscription is successful.<br>
2177
+ * Refer: <a href="www.google.com">Setting up E-Mail templates</a><br>
2178
+ */
2179
+ bypassWelcome: boolean;
2180
+ },
2181
+ option: FormCallbacks
2182
+ ): Promise<string> {
2183
+ let params = checkParams(
2184
+ form || {},
2185
+ {
2186
+ email: (v: string) => validateEmail(v),
2187
+ bypassWelcome: ['boolean', () => true]
2188
+ },
2189
+ ['email']
2190
+ );
2191
+
2192
+ return await this.request('subscribe-newsletter', params);
2193
+ }
2194
+
2195
+
2196
+ private async subscriptionGroupCheck(option: Record<string, any>) {
2197
+ await this.__connection;
2198
+ option = checkParams(option, {
2199
+ userId: (v: string) => validateUserId(v, '"userId"'),
2200
+ group: (v: number | string) => {
2201
+ if (v === '*') {
2202
+ return v;
2203
+ }
2204
+
2205
+ if (typeof v !== 'number') {
2206
+ throw new SkapiError('"group" should be type: number.', { code: 'INVALID_PARAMETER' });
2207
+ }
2208
+
2209
+ else if (v < 1 && v > 9) {
2210
+ throw new SkapiError('"group" should be within range 1 ~ 9.', { code: 'INVALID_PARAMETER' });
2211
+ }
2212
+
2213
+ return v;
2214
+ },
2215
+ emailSubscription: ['boolean']
2216
+ }, ['userId', 'group']);
2217
+
2218
+ if (this.user && option.userId === this.user.user_id) {
2219
+ throw new SkapiError(`"userId" cannot be the user's own ID.`, { code: 'INVALID_PARAMETER' });
2220
+ }
2221
+
2222
+ return option;
2223
+ }
2224
+ /**
2225
+ * Subscribes user's account to another account or updates email_subscription state.<br>
2226
+ * User cannot subscribe to email if they did not verify their email.<br>
2227
+ * This can be used for user following, content restrictions when building social media services.<br>
2228
+ * Refer: <a href='www.google.com'>How to use subscription systems</a><br>
2229
+ *
2230
+ * ```
2231
+ * // user subscribes to another user with email subscription
2232
+ * await skapi.subscribe({
2233
+ * userId: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx',
2234
+ * group: 1,
2235
+ * emailSubscription: true
2236
+ * })
2237
+ * ```
2238
+ * @category Subscriptions
2239
+ */
2240
+ @formResponse()
2241
+ async subscribe(
2242
+ option: SubscriberGroup
2243
+ ) {
2244
+ let { userId, group } = await this.subscriptionGroupCheck(option);
2245
+
2246
+ if (group === '*') {
2247
+ throw new SkapiError('Cannot subscribe to all groups at once.', { code: 'INVALID_PARAMETER' });
2248
+ }
2249
+
2250
+ return await this.request('subscription', {
2251
+ subscribe: userId,
2252
+ group
2253
+ }, { auth: true });
2254
+ }
2255
+
2256
+ /**
2257
+ * Unsubscribes user's account from another account or service.
2258
+ * ```
2259
+ * // user unsubscribes from another user
2260
+ * await skapi.unsubscribe({
2261
+ * userId: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx',
2262
+ * group: 2
2263
+ * })
2264
+ * ```
2265
+ * @category Subscriptions
2266
+ */
2267
+ @formResponse()
2268
+ async unsubscribe(option: SubscriberGroup) {
2269
+ let { userId, group } = await this.subscriptionGroupCheck(option);
2270
+
2271
+ return await this.request('subscription', {
2272
+ unsubscribe: userId,
2273
+ group
2274
+ }, { auth: true });
2275
+ }
2276
+
2277
+ /**
2278
+ * Account owner can block user from their account subscription.
2279
+ * ```
2280
+ * // account owner blocks user from group 2
2281
+ * await skapi.blockSubscriber({
2282
+ * userId: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx',
2283
+ * group: 2
2284
+ * })
2285
+ * // account owner blocks user from all group
2286
+ * await skapi.blockSubscriber({
2287
+ * userId: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'
2288
+ * })
2289
+ * ```
2290
+ * @category Subscriptions
2291
+ */
2292
+ @formResponse()
2293
+ async blockSubscriber(option: SubscriberGroup): Promise<string> {
2294
+ let { userId, group } = await this.subscriptionGroupCheck(option);
2295
+ return await this.request('subscription', { block: userId, group }, { auth: true });
2296
+ }
2297
+
2298
+ /**
2299
+ * Account owner can unblock user from their account subscription.
2300
+ * ```
2301
+ * // account owner unblocks user from group 2
2302
+ * await skapi.unblockSubscriber({
2303
+ * userId: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx',
2304
+ * group: 2
2305
+ * })
2306
+ *
2307
+ * // account owner unblocks user from all group
2308
+ * await skapi.unblockSubscriber({
2309
+ * userId: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'
2310
+ * })
2311
+ * ```
2312
+ * @category Subscriptions
2313
+ */
2314
+ @formResponse()
2315
+ async unblockSubscriber(option: SubscriberGroup): Promise<string> {
2316
+ let { userId, group } = await this.subscriptionGroupCheck(option);
2317
+ return await this.request('subscription', { unblock: userId, group }, { auth: true });
2318
+ }
2319
+
2320
+ /**
2321
+ * Get user's subscriptions
2322
+ * @ignore
2323
+ */
2324
+ async getUserSubscriptions(option: SubscriberFetch): Promise<FetchResponse> {
2325
+ await this.__connection;
2326
+ option = checkParams(option, {
2327
+ userId: (v: string) => {
2328
+ try {
2329
+ return validateUserId(v, '"userId"');
2330
+ } catch (err) {
2331
+ }
2332
+
2333
+ try {
2334
+ return validateEmail(v);
2335
+ } catch (err) {
2336
+ }
2337
+
2338
+ throw new SkapiError('"subscriber" should be either valid user ID or E-Mail.', { code: 'INVALID_PARAMETER' });
2339
+ },
2340
+ group: 'number',
2341
+ emailSubscription: 'boolean'
2342
+ }) || {};
2343
+
2344
+ return this.getSubscriptions({
2345
+ subscriber: option.userId || this.user?.user_id,
2346
+ group: option.group,
2347
+ emailSubscription: option?.emailSubscription || undefined
2348
+ });
2349
+ }
2350
+
2351
+
2352
+ /**
2353
+ * Get user's subscribers
2354
+ * @ignore
2355
+ */
2356
+ async getUserSubscribers(option: SubscriberFetch): Promise<FetchResponse> {
2357
+ await this.__connection;
2358
+ option = checkParams(option, {
2359
+ userId: (v: string) => validateUserId(v, '"userId"'),
2360
+ group: 'number',
2361
+ emailSubscription: 'boolean'
2362
+ }) || {};
2363
+
2364
+ let subParams = {
2365
+ subscription: option.userId || this.user?.user_id,
2366
+ group: option.group,
2367
+ emailSubscription: option.emailSubscription
2368
+ };
2369
+
2370
+ return this.getSubscriptions(subParams);
2371
+ }
2372
+
2373
+ /**
2374
+ * Get user's subscriptions / subscribers
2375
+ *
2376
+ * @category Subscriptions
2377
+ */
2378
+ async getSubscriptions(
2379
+ params: {
2380
+ /** Subscribers user id | E-Mail for newsletter subscribers. */
2381
+ subscriber?: string;
2382
+ /** Subscription id. User id that subscriber has subscribed to. */
2383
+ subscription?: string;
2384
+ /** subscription group. if omitted, will fetch all groups. */
2385
+ group?: number;
2386
+ /** True | False to fetch service email subscribers. If omitted, will fetch all subscribers. */
2387
+ emailSubscription?: boolean;
2388
+ /** Fetch blocked subscription when True */
2389
+ blocked?: boolean;
2390
+ },
2391
+ fetchOptions?: FetchOptions,
2392
+ /** @ignore */
2393
+ _mapper?: Function
2394
+ ): Promise<FetchResponse> {
2395
+ let isNewsletterSub = false;
2396
+
2397
+ params = checkParams(params, {
2398
+ subscriber: (v: string) => {
2399
+ try {
2400
+ return validateUserId(v, 'User ID in "subscriber"');
2401
+ } catch (err) {
2402
+ }
2403
+
2404
+ try {
2405
+ let isEmail = validateEmail(v);
2406
+ isNewsletterSub = true;
2407
+ return isEmail;
2408
+ } catch (err) {
2409
+ }
2410
+
2411
+ throw new SkapiError('"subscriber" should be either valid user ID or E-Mail.', { code: 'INVALID_PARAMETER' });
2412
+ },
2413
+ group: 'number',
2414
+ subscription: (v: string) => {
2415
+ // can be
2416
+ try {
2417
+ return validateUserId(v, 'User ID in "subscription"');
2418
+ } catch (err) {
2419
+ }
2420
+
2421
+ if (typeof v === 'string' && v.length === 14) {
2422
+ return v;
2423
+ }
2424
+
2425
+ throw new SkapiError('"subscriber" should be either valid service ID or user ID.', { code: 'INVALID_PARAMETER' });
2426
+ },
2427
+ emailSubscription: 'boolean',
2428
+ blocked: 'boolean'
2429
+ });
2430
+
2431
+ if (isNewsletterSub && !params?.subscription) {
2432
+ params.subscription = this.service;
2433
+ }
2434
+
2435
+ if (!params.subscriber && !params.subscription) {
2436
+ throw new SkapiError('At least either "subscriber" or "subscription" should have a value.', { code: 'INVALID_PARAMETER' });
2437
+ }
2438
+
2439
+ let response = await this.request('get-subscription', params, Object.assign({ auth: !isNewsletterSub }, { fetchOptions }));
2440
+
2441
+ response.list = response.list.map(_mapper || ((s: Record<string, any>) => {
2442
+ let subscription: Record<string, any> = {};
2443
+ let subSplit = s.sub.split('#');
2444
+ subscription.subscriber = subSplit[2];
2445
+ subscription.subscription = subSplit[0];
2446
+ subscription.group = parseInt(subSplit[1]);
2447
+ subscription.timestamp = s.stmp;
2448
+ subscription.blocked = s.grp.substring(0, 1) === 'N';
2449
+
2450
+ return subscription;
2451
+ }));
2452
+
2453
+ return response;
2454
+ }
2455
+
2456
+
2457
+ /**
2458
+ * Get newsletters and service letters that service owner sent out.
2459
+ * You can make use of your sent newsletters as an article for your web services.
2460
+ * ```
2461
+ * @category Subscriptions
2462
+ */
2463
+ async getNewsletters(
2464
+ params?: {
2465
+ /**
2466
+ * Search points.<br>
2467
+ * 'message_id' and 'subject' value should be string.<br>
2468
+ * Others numbers.
2469
+ */
2470
+ searchFor: 'message_id' | 'timestamp' | 'read' | 'complaint' | 'subject';
2471
+ value: string | number;
2472
+ range: string | number;
2473
+ /**
2474
+ * Defaults to '='
2475
+ */
2476
+ condition?: '>' | '>=' | '=' | '<' | '<=' | 'gt' | 'gte' | 'eq' | 'lt' | 'lte';
2477
+ /**
2478
+ * 'newsletter' for newsletters.<br>
2479
+ * Numbers for service letter sent to corresponding access groups.
2480
+ */
2481
+ group: 'newsletter' | number;
2482
+ },
2483
+ fetchOptions?: FetchOptions
2484
+ ): Promise<Newsletters> {
2485
+ let isAdmin = await this.requireAdmin();
2486
+
2487
+ let searchType = {
2488
+ 'message_id': 'string',
2489
+ 'timestamp': 'number',
2490
+ 'read': 'number',
2491
+ 'complaint': 'number',
2492
+ 'subject': 'string'
2493
+ };
2494
+
2495
+ if (!params) {
2496
+ if (!fetchOptions) {
2497
+ fetchOptions = {};
2498
+ }
2499
+ fetchOptions.ascending = false;
2500
+ }
2501
+
2502
+ let _params = params || {
2503
+ searchFor: 'timestamp',
2504
+ value: 0,
2505
+ condition: '>'
2506
+ };
2507
+
2508
+ params = checkParams(_params, {
2509
+ searchFor: ['message_id', 'timestamp', 'read', 'complaint', 'group', 'subject'],
2510
+ value: (v: number | string) => {
2511
+ if (typeof v !== searchType[_params.searchFor]) {
2512
+ throw new SkapiError(`"value" type does not match the type of "${_params.searchFor}" index.`, { code: 'INVALID_PARAMETER' });
2513
+ }
2514
+ else if (typeof v === 'string' && !v) {
2515
+ throw new SkapiError('"value" should not be empty string.', { code: 'INVALID_PARAMETER' });
2516
+ }
2517
+
2518
+ return v;
2519
+ },
2520
+ range: (v: number | string) => {
2521
+ if (!_params.hasOwnProperty('value') || typeof v !== typeof _params.value) {
2522
+ throw new SkapiError('"range" should match type of "value".', { code: 'INVALID_PARAMETER' });
2523
+ }
2524
+ return v;
2525
+ },
2526
+ condition: ['>', '>=', '=', '<', '<=', 'gt', 'gte', 'eq', 'lt', 'lte', () => '='],
2527
+ group: (x: string | number) => {
2528
+ if (x !== 'newsletter' && !this.session) {
2529
+ throw new SkapiError('User should be logged in.', { code: 'INVALID_REQUEST' });
2530
+ }
2531
+ if (typeof x === 'string' && x !== 'newsletter') {
2532
+ throw new SkapiError('Group should be either "newsletter" or access group number.', { code: 'INVALID_PARAMETER' });
2533
+ }
2534
+ if (!isAdmin && x > parseInt(this.session.idToken.payload.access_group)) {
2535
+ throw new SkapiError('User has no access.', { code: 'INVALID_REQUEST' });
2536
+ }
2537
+ if (x === 'newsletter') {
2538
+ return 0;
2539
+ }
2540
+
2541
+ return x;
2542
+ }
2543
+ }, ['searchFor', 'value', 'group']);
2544
+
2545
+ let mails = await this.request(
2546
+ params.group === 0 ? 'get-newsletters' : 'get-serviceletters',
2547
+ params,
2548
+ Object.assign({ method: 'get', auth: params.group !== 0 }, { fetchOptions })
2549
+ );
2550
+
2551
+ let remap = {
2552
+ 'message_id': 'mid',
2553
+ 'timestamp': 'stmp',
2554
+ 'complaint': 'cmpl',
2555
+ 'read': 'read',
2556
+ 'subject': 'subj',
2557
+ 'bounced': 'bnce',
2558
+ 'url': 'url'
2559
+ };
2560
+ let defaults = {
2561
+ 'message_id': '',
2562
+ 'timestamp': 0,
2563
+ 'complaint': 0,
2564
+ 'read': 0,
2565
+ 'subject': '',
2566
+ 'bounced': 0,
2567
+ 'url': ''
2568
+ };
2569
+
2570
+ mails.list = mails.list.map(m => {
2571
+ let remapped = {};
2572
+ for (let k in remap) {
2573
+ remapped[k] = m[remap[k]] || defaults[remap[k]];
2574
+ }
2575
+ return remapped;
2576
+ });
2577
+
2578
+ return mails;
2579
+ }
2580
+
2581
+
2582
+ //<_user>
2583
+ /**
2584
+ * Signup creates new user account to the service.<br>
2585
+ * You can let users confirm their signup by sending out signup confirmation E-Mail.<br>
2586
+ * Once the E-Mail is confirmed, user will receive your welcome E-Mail and will be able to login.<br>
2587
+ * The welcome E-Mail is the same E-Mail that is sent when visitor subscribes to your newsletters.<br>
2588
+ * It is advised to let your users to confirm their signup to prevent automated bots.<br>
2589
+ * Signup confirmation E-Mails will have a link that verifies the user.<br>
2590
+ * If option.confirmation is set to a url string, signup confirmation link will redirect the user to that url.<br>
2591
+ * Welcome emails will only be sent after a user logs in if option.confirmation is set to true.
2592
+ * Common pratice would be to setup a url to redihashedParamsrect users to your 'thankyou for signing up' page.<br>
2593
+ * If the parameter is set to true, user will be redirected to an empty html page that shows success message with your web service link below.<br>
2594
+ * Refer: <a href="www.google.com">Setting up E-Mail templates</a><br>
2595
+ *
2596
+ * ```
2597
+ * let params = {
2598
+ * email: 'login@email.com',
2599
+ * password: 'password',
2600
+ * name: 'Baksa',
2601
+ * phone_number: "+0012341234"
2602
+ * };
2603
+ *
2604
+ * let option = {
2605
+ * confirmation: "http://baksa.com/thankyouforsigningup"
2606
+ * };
2607
+ *
2608
+ * await skapi.signup(params, option);
2609
+ *
2610
+ * // signup confirmation E-Mail is sent
2611
+ * ```
2612
+ * @category User
2613
+ */
2614
+ @formResponse()
2615
+ async signup(
2616
+ form: Form | UserProfile | { email: String, password: String; },
2617
+ option?: {
2618
+ /**
2619
+ * When true, the service will send out confirmation E-Mail.
2620
+ * User will not be able to signin to their account unless they have confirm their email.
2621
+ */
2622
+ confirmation?: boolean;
2623
+ /**
2624
+ * Automatically login to account after signup. Will not work if E-Mail confirmation is required.
2625
+ */
2626
+ login?: boolean;
2627
+ } & FormCallbacks): Promise<User | "SUCCESS: The account has been created. User's email confirmation is required." | 'SUCCESS: The account has been created.'> {
2628
+
2629
+ this.logout();
2630
+
2631
+ let params = checkParams(form || {}, {
2632
+ email: (v: string) => validateEmail(v),
2633
+ password: (v: string) => validatePassword(v),
2634
+ name: 'string',
2635
+ address: 'string',
2636
+ gender: 'string',
2637
+ birthdate: (v: string) => validateBirthdate(v),
2638
+ phone_number: (v: string) => validatePhoneNumber(v),
2639
+ email_public: ['boolean', () => false],
2640
+ address_public: ['boolean', () => false],
2641
+ gender_public: ['boolean', () => false],
2642
+ birthdate_public: ['boolean', () => false],
2643
+ phone_number_public: ['boolean', () => false],
2644
+ email_subscription: ['boolean', () => false]
2645
+ }, ['email', 'password']);
2646
+
2647
+ option = checkParams(option || {}, {
2648
+ confirmation: (v: string | boolean) => {
2649
+ if (typeof v === 'string') {
2650
+ return validateUrl(v);
2651
+ }
2652
+ else if (typeof v === 'boolean') {
2653
+ return v;
2654
+ }
2655
+ else {
2656
+ throw new SkapiError('"option.confirmation" should be type: <string | boolean>.', { code: 'INVALID_PARAMETER' });
2657
+ }
2658
+ },
2659
+ login: (v: boolean) => {
2660
+ if (typeof v === 'boolean') {
2661
+ if (option.confirmation && v) {
2662
+ throw new SkapiError('"login" is not allowed when "option.confirmation" is true.', { code: 'INVALID_PARAMETER' });
2663
+ }
2664
+ return v;
2665
+ }
2666
+ throw new SkapiError('"option.login" should be type: boolean.', { code: 'INVALID_PARAMETER' });
2667
+ }
2668
+ });
2669
+
2670
+ let {
2671
+ login = false,
2672
+ confirmation = false
2673
+ } = option || {};
2674
+
2675
+ if (!confirmation && params.email_public) {
2676
+ throw new SkapiError('"option.confirmation" should be true if "email_public" is set to true.', { code: 'INVALID_PARAMETER' });
2677
+ }
2678
+
2679
+ await this.request("signup", params, { meta: option });
2680
+
2681
+ if (confirmation) {
2682
+ return "SUCCESS: The account has been created. User's email confirmation is required.";
2683
+ }
2684
+
2685
+ else if (login) {
2686
+ return await this.login({ email: params.email, password: params.password });
2687
+ }
2688
+
2689
+ else {
2690
+ return 'SUCCESS: The account has been created.';
2691
+ }
2692
+ }
2693
+
2694
+ /**
2695
+ * Logout user and delete user session data.
2696
+ *
2697
+ * ```
2698
+ * await skapi.logout();
2699
+ * ```
2700
+ *
2701
+ * @category User
2702
+ */
2703
+ logout(): 'SUCCESS: The user has been logged out.' {
2704
+ if (this.cognitoUser) {
2705
+ this.cognitoUser.signOut();
2706
+ }
2707
+
2708
+ let to_be_erased = {
2709
+ 'session': null,
2710
+ '__startKey_keys': {},
2711
+ '__cached_requests': {},
2712
+ 'user': null
2713
+ };
2714
+
2715
+ for (let k in to_be_erased) {
2716
+ this[k] = to_be_erased[k];
2717
+ }
2718
+
2719
+ return 'SUCCESS: The user has been logged out.';
2720
+ }
2721
+
2722
+ /**
2723
+ * Resends signup confirmation E-Mail.<br>
2724
+ * User needs at least one signin attempt.
2725
+ *
2726
+ * ```
2727
+ * // user tries to signin
2728
+ * try {
2729
+ * await skapi.login('baksa@email.com','password');
2730
+ * } catch(failed) {
2731
+ * console.log(failed); // SIGNUP_CONFIRMATION_NEEDED: ...
2732
+ *
2733
+ * // now you can resend signup confirmation E-Mail to the user.
2734
+ * await skapi.resendSignupConfirmation("http://baksa.com/thankyouforsigningup");
2735
+ * }
2736
+ *
2737
+ * ```
2738
+ *
2739
+ * @category User
2740
+ */
2741
+ async resendSignupConfirmation(
2742
+ /** Redirect url on confirmation success. */
2743
+ redirect: boolean | string = false
2744
+ ): Promise<string> {
2745
+ if (redirect && typeof redirect === 'string') {
2746
+ redirect = (validateUrl(redirect) as string);
2747
+ }
2748
+
2749
+ else if (typeof redirect !== 'boolean') {
2750
+ throw new SkapiError('Argument should be type: <boolean | string>.', { code: 'INVALID_REQUEST' });
2751
+ }
2752
+
2753
+ if (!this.__request_signup_confirmation) {
2754
+ throw new SkapiError('Least one signin attempt is required.', { code: 'INVALID_REQUEST' });
2755
+ }
2756
+
2757
+ let resend = await this.request("confirm-signup", {
2758
+ username: this.__request_signup_confirmation,
2759
+ redirect
2760
+ });
2761
+
2762
+ this.__request_signup_confirmation = null;
2763
+ return resend;
2764
+ }
2765
+
2766
+ /**
2767
+ * Recovers disabled account.<br>
2768
+ * User needs at least one signin attempt of the disabled account.</br>
2769
+ * User should have their E-Mail verified in their disabled account to receive account recovery E-Mail.<br>
2770
+ * It will not be possible to recover unverified accounts.
2771
+ *
2772
+ * ```
2773
+ * // user tries to signin
2774
+ * try {
2775
+ * await skapi.signin('baksa@email.com','password');
2776
+ * } catch(failed) {
2777
+ * console.log(failed); // USER_IS_DISABLED: ...
2778
+ *
2779
+ * // now you can send recover account E-Mail to the user.
2780
+ * await skapi.recoverAccount("http://baksa.com/welcomeback");
2781
+ * }
2782
+ *
2783
+ * ```
2784
+ *
2785
+ * @category User
2786
+ * @param redirect Redirect url on recover account success.
2787
+ */
2788
+ async recoverAccount(
2789
+ /** Redirect url on confirmation success. */
2790
+ redirect: boolean | string = false
2791
+ ): Promise<string> {
2792
+
2793
+ if (typeof redirect === 'string') {
2794
+ validateUrl(redirect);
2795
+ }
2796
+
2797
+ else if (typeof redirect !== 'boolean') {
2798
+ throw new SkapiError('Argument should be type: <boolean | string>.', { code: 'INVALID_REQUEST' });
2799
+ }
2800
+
2801
+ if (!this.__disabledAccount) {
2802
+ throw new SkapiError('Least one signin attempt of disabled account is required.', { code: 'INVALID_REQUEST' });
2803
+ }
2804
+
2805
+ let resend = await this.request("recover-account", { username: this.__disabledAccount, redirect });
2806
+ this.__disabledAccount = null;
2807
+ return resend;
2808
+ }
2809
+
2810
+
2811
+ /**
2812
+ * Logs user to the service.<br>
2813
+ * <h6>DO NOT LEAVE ANY EMAIL AND PASSWORD ON FRONTEND JAVASCRIPT</h6>
2814
+ * Always let users input their own signin information.<br>
2815
+ * <b>Note: User is automatically logged in.</b>
2816
+ *
2817
+ * ```
2818
+ * let user = await skapi.login({
2819
+ * email: 'user@email.com',
2820
+ * password: 'password'
2821
+ * }); // returns user information on success
2822
+ * ```
2823
+ *
2824
+ * @category User
2825
+ */
2826
+ @formResponse()
2827
+ login(
2828
+ form: Form | {
2829
+ /** E-Mail for signin. 64 character max. */
2830
+ email: string;
2831
+ /** Password for signin. Should be at least 6 characters. */
2832
+ password: string;
2833
+ },
2834
+ option?: FormCallbacks): Promise<User> {
2835
+ this.logout();
2836
+ let params = checkParams(form, {
2837
+ email: (v: string) => validateEmail(v),
2838
+ password: (v: string) => validatePassword(v)
2839
+ }, ['email', 'password']);
2840
+
2841
+ return this.authentication().authenticateUser(params.email, params.password);
2842
+ }
2843
+
2844
+ private verifyAttribute(attribute: string, code?: string): Promise<string> {
2845
+ if (!this.cognitoUser) {
2846
+ throw new SkapiError('The user has to be logged in.', { code: 'INVALID_REQUEST' });
2847
+ }
2848
+
2849
+ return new Promise((res, rej) => {
2850
+ let callback = {
2851
+ onSuccess: (result: any) => {
2852
+ if (code) {
2853
+ this.authentication().updateSession({ refreshToken: true }).then(
2854
+ () => {
2855
+ if (this.user) {
2856
+ this.user[attribute + '_verified'] = true;
2857
+ }
2858
+ res(`SUCCESS: "${attribute}" is verified.`);
2859
+ }
2860
+ ).catch(err => {
2861
+ rej(err);
2862
+ });
2863
+ }
2864
+
2865
+ else {
2866
+ res('SUCCESS: Verification code has been sent.');
2867
+ }
2868
+ },
2869
+ onFailure: (err: Record<string, any>) => {
2870
+ rej(
2871
+ new SkapiError(
2872
+ err.message || 'Failed to request verification code.',
2873
+ {
2874
+ code: err?.code
2875
+ }
2876
+ )
2877
+ );
2878
+ },
2879
+ inputVerificationCode: null
2880
+ };
2881
+
2882
+ if (code) {
2883
+ this.cognitoUser?.verifyAttribute(attribute, code, callback);
2884
+ }
2885
+ else {
2886
+ this.cognitoUser?.getAttributeVerificationCode(attribute, callback);
2887
+ }
2888
+ });
2889
+ }
2890
+
2891
+ /**
2892
+ * Verifies user's E-Mail.<br>
2893
+ * The account has to be signed in.<br>
2894
+ * For the verification E-Mail/SMS, template 'template_verification' will be used.<br>
2895
+ * Refer: <a href="www.google.com">Setting up E-Mail templates</a><br>
2896
+ *
2897
+ * ```
2898
+ * async skapi.verifyEmail();
2899
+ * // Signed in user receives verification code via E-Mail.
2900
+ *
2901
+ * // Execute the method again with verification code as a additional argument.
2902
+ * async skapi.verifyEmail({code});
2903
+ * // User E-Mail is now verified
2904
+ * ```
2905
+ *
2906
+ * @category User
2907
+ */
2908
+ @formResponse()
2909
+ verifyEmail(
2910
+ form?: Form | {
2911
+ /** Verification code. */
2912
+ code: string | number;
2913
+ },
2914
+ option?: FormCallbacks
2915
+ ): Promise<string> {
2916
+
2917
+ let code = (form ? checkParams(form, {
2918
+ code: ['number', 'string']
2919
+ }) : {}).code || '';
2920
+
2921
+ return this.verifyAttribute('email', code.toString());
2922
+ }
2923
+
2924
+
2925
+ /**
2926
+ * Verifies user's mobile phone number.<br>
2927
+ * The account has to be signed in.<br>
2928
+ * For the verification E-Mail/SMS, template 'template_verification' will be used.<br>
2929
+ * Refer: <a href="www.google.com">Setting up E-Mail templates</a><br>
2930
+ *
2931
+ * ```
2932
+ * async skapi.verifyMobile();
2933
+ * // Signed in user receives verification code via SMS.
2934
+ *
2935
+ * async skapi.verifyMobile({code});
2936
+ * // User phone is now verified
2937
+ * ```
2938
+ *
2939
+ * @category User
2940
+ */
2941
+ @formResponse()
2942
+ verifyMobile(
2943
+ form?: Form | {
2944
+ /** Verification code. */
2945
+ code: string | number;
2946
+ },
2947
+ option?: FormCallbacks): Promise<string> {
2948
+
2949
+ let code = (form ? checkParams(form, {
2950
+ code: ['number', 'string']
2951
+ }) : {}).code || null;
2952
+
2953
+ return this.verifyAttribute('phone_number', code.toString());
2954
+ }
2955
+
2956
+
2957
+ /**
2958
+ * Users can request password reset when password is forgotten.<br>
2959
+ * <h6>IMPORTANT</h6>
2960
+ * If the user's account does not have any verified E-Mail, user will not be able to receive any verification E-Mail.<br>
2961
+ * Advise users to verify their E-Mails.<br>
2962
+ *
2963
+ * ```
2964
+ * async skapi.forgotPassword({ email: 'your@email.com' });
2965
+ * // User receives verification E-Mail with verification code.
2966
+ *
2967
+ * // enter verification code and new password as arguments.
2968
+ * async skapi.resetPassword({ email: 'your@email.com', code, new_password });
2969
+ *
2970
+ * // users account password is now set to new_password.
2971
+ * ```
2972
+ *
2973
+ * @category User
2974
+ */
2975
+ @formResponse()
2976
+ async forgotPassword(
2977
+ form: Form | {
2978
+ /** Signin E-Mail. */
2979
+ email: string;
2980
+ },
2981
+ option?: FormCallbacks): Promise<string> {
2982
+
2983
+ await this.__connection;
2984
+
2985
+ let params = checkParams(form, {
2986
+ email: (v: string) => validateEmail(v)
2987
+ }, ['email']);
2988
+
2989
+ return new Promise(async (res, rej) => {
2990
+ let cognitoUser = (await this.authentication().createCognitoUser(params.email)).cognitoUser;
2991
+ cognitoUser.forgotPassword({
2992
+ onSuccess: result => {
2993
+ res("SUCCESS: Verification code has been sent.");
2994
+ },
2995
+ onFailure: (err: any) => {
2996
+ rej(new SkapiError(err?.message || 'Failed to send verification code.', { code: err?.code || 'ERROR' }));
2997
+ }
2998
+ });
2999
+ });
3000
+ }
3001
+
3002
+ /**
3003
+ * Users can request password reset when password is forgotten.<br>
3004
+ * <h6>IMPORTANT</h6>
3005
+ * If the user's account does not have any verified E-Mail, user will not be able to receive any verification E-Mail.<br>
3006
+ * Advise users to verify their E-Mails.<br>
3007
+ *
3008
+ * ```
3009
+ * async skapi.forgotPassword({ email: 'your@email.com' });
3010
+ * // User receives verification E-Mail with verification code.
3011
+ *
3012
+ * // enter verification code and new password as arguments.
3013
+ * async skapi.resetPassword({ email: 'your@email.com', code, new_password });
3014
+ *
3015
+ * // users account password is now set to new_password.
3016
+ * ```
3017
+ *
3018
+ * @category User
3019
+
3020
+ */
3021
+ @formResponse()
3022
+ resetPassword(form: Form | {
3023
+ /** Signin E-Mail */
3024
+ email: string;
3025
+ /** The verification code user has received. */
3026
+ code: string | number;
3027
+ /** New password to set. Verification code is required. */
3028
+ new_password: string;
3029
+ }, option?: FormCallbacks): Promise<string> {
3030
+
3031
+ let params = checkParams(form, {
3032
+ email: (v: string) => validateEmail(v),
3033
+ code: ['number', 'string'],
3034
+ new_password: (v: string) => validatePassword(v)
3035
+ }, ['email', 'code', 'new_password']);
3036
+
3037
+ let code = params.code, new_password = params.new_password;
3038
+
3039
+ if (typeof code === 'number') {
3040
+ code = code.toString();
3041
+ }
3042
+
3043
+ return new Promise(async (res, rej) => {
3044
+ let cognitoUser = (await this.authentication().createCognitoUser(params.email)).cognitoUser;
3045
+
3046
+ cognitoUser.confirmPassword(code, new_password, {
3047
+ onSuccess: result => {
3048
+ res("SUCCESS: New password has been set.");
3049
+ },
3050
+ onFailure: (err: any) => {
3051
+ rej(new SkapiError(err?.message || 'Failed to reset password.', { code: err?.code }));
3052
+ }
3053
+ });
3054
+ });
3055
+ }
3056
+
3057
+ /**
3058
+ * Disables user account.</br>
3059
+ * All the data related to the account will be deleted after 3 months.</br>
3060
+ * Within 3 months, user can recover their account.
3061
+ * ```
3062
+ * async skapi.disableAccount();
3063
+ * // Account is disabled.
3064
+ * ```
3065
+ * @category User
3066
+ */
3067
+ async disableAccount(): Promise<string> {
3068
+ await this.__connection;
3069
+
3070
+ if (this.user && Array.isArray(this.user.services)) {
3071
+ for (let s of this.user.services) {
3072
+ if (s.active) {
3073
+ throw new SkapiError('All services needs to be disabled.', { code: 'INVALID_REQUEST' });
3074
+ }
3075
+ }
3076
+ }
3077
+
3078
+ let result = await this.request('remove-account', { disable: true }, { auth: true });
3079
+ this.logout();
3080
+ return result;
3081
+ }
3082
+
3083
+ //usersetting
3084
+ /**
3085
+ * Updates user's settings.<br>
3086
+ * The user needs to be logged in.
3087
+ *
3088
+ * ```
3089
+ * await skapi.updateUserSettings({
3090
+ * name: 'John Lennon',
3091
+ * gender: 'Male',
3092
+ * address: 'Liver pool',
3093
+ * email_public: true
3094
+ * });
3095
+ *
3096
+ * // User setting is updated.
3097
+ * ```
3098
+ * @category User
3099
+ */
3100
+ @formResponse()
3101
+ async updateUserSettings(
3102
+ form: Form | UserProfile & {
3103
+ /** Set new password. *Current password is required. */
3104
+ new_password?: string;
3105
+ /** Current password. Only required when setting new password. */
3106
+ current_password?: string;
3107
+ },
3108
+ option?: FormCallbacks
3109
+ ): Promise<User> {
3110
+ await this.__connection;
3111
+
3112
+ let attr: CognitoUserAttribute = this.session?.attributes;
3113
+
3114
+ if (!attr || !this.cognitoUser) {
3115
+ throw new SkapiError('User has to be logged in.', { code: 'INVALID_REQUEST' });
3116
+ }
3117
+
3118
+ let params = checkParams(form || {}, {
3119
+ email: (v: string) => validateEmail(v),
3120
+ name: 'string',
3121
+ address: 'string',
3122
+ gender: 'string',
3123
+ birthdate: (v: string) => validateBirthdate(v),
3124
+ phone_number: (v: string) => validatePhoneNumber(v),
3125
+ email_public: 'boolean',
3126
+ phone_number_public: 'boolean',
3127
+ address_public: 'boolean',
3128
+ gender_public: 'boolean',
3129
+ birthdate_public: 'boolean',
3130
+ email_subscription: 'boolean',
3131
+ current_password: (v: string) => validatePassword(v),
3132
+ new_password: (v: string) => validatePassword(v)
3133
+ });
3134
+
3135
+ if (params && typeof params === 'object' && !Object.keys(params).length) {
3136
+ return this.user;
3137
+ }
3138
+
3139
+ if (params.new_password || params.current_password) {
3140
+ if (!params.current_password) {
3141
+ throw new SkapiError('"current_password" is needed to change the password.', { code: 'INVALID_PARAMETER' });
3142
+ }
3143
+
3144
+ if (!params.new_password) {
3145
+ throw new SkapiError('"new_password" is needed to change password.', { code: 'INVALID_PARAMETER' });
3146
+ }
3147
+
3148
+ if (params.new_password !== params.current_password) {
3149
+ await new Promise((res, rej) => {
3150
+ this.cognitoUser.changePassword(
3151
+ params.current_password,
3152
+ params.new_password,
3153
+ (err: any, result: any) => {
3154
+ if (err) {
3155
+ rej(new SkapiError(err?.message || 'Failed to change users password.', { code: err?.code || err?.name }));
3156
+ }
3157
+
3158
+ res(result);
3159
+ });
3160
+ });
3161
+ }
3162
+
3163
+ delete params.current_password;
3164
+ delete params.new_password;
3165
+ }
3166
+
3167
+ // set alternative signin email
3168
+ if (params.email) {
3169
+ let connect = await this.updateServiceInformation({ request_hash: params.email });
3170
+ params['preferred_username'] = connect.hash;
3171
+ }
3172
+
3173
+ let collision = [
3174
+ ['email_subscription', 'email_verified', "User's E-Mail should be verified to set"],
3175
+ ['email_public', 'email_verified', "User's E-Mail should be verified to set"],
3176
+ ['phone_number_public', 'phone_number_verified', "User's phone number should be verified to set"]
3177
+ ];
3178
+
3179
+ if (this.user) {
3180
+ for (let c of collision) {
3181
+ if (params[c[0]] && !this.user[c[1]]) {
3182
+ throw new SkapiError(`${c[2]} "${c[0]}" to true.`, { code: 'INVALID_PARAMETER' });
3183
+ }
3184
+ }
3185
+ }
3186
+
3187
+ let customAttr = [
3188
+ 'email_public',
3189
+ 'phone_number_public',
3190
+ 'address_public',
3191
+ 'gender_public',
3192
+ 'birthdate_public',
3193
+ 'email_subscription'
3194
+ ];
3195
+
3196
+ // delete unchanged values, convert key names to cognito attributes
3197
+ for (let k in params) {
3198
+ if (params[k] === attr[k]) {
3199
+ delete params[k];
3200
+ }
3201
+
3202
+ else if (customAttr.includes(k)) {
3203
+ let parseValue = params[k];
3204
+
3205
+ if (typeof parseValue === 'boolean')
3206
+ parseValue = parseValue ? '1' : '0';
3207
+
3208
+ params['custom:' + k] = parseValue;
3209
+ delete params[k];
3210
+ }
3211
+ }
3212
+
3213
+ if (params && typeof params === 'object' && Object.keys(params).length) {
3214
+ let toSet: Array<CognitoUserAttribute> = [];
3215
+ for (let key in params) {
3216
+ toSet.push(new CognitoUserAttribute({
3217
+ Name: key,
3218
+ Value: params[key]
3219
+ }));
3220
+ }
3221
+
3222
+ await new Promise((res, rej) => {
3223
+ this.cognitoUser?.updateAttributes(
3224
+ toSet,
3225
+ (err: any, result: any) => {
3226
+ if (err) {
3227
+ rej(
3228
+ [
3229
+ err?.code || err?.name,
3230
+ err?.message || `Failed to update user settings.`
3231
+ ]
3232
+ );
3233
+ }
3234
+ res(result);
3235
+ });
3236
+ });
3237
+
3238
+ await this.authentication().updateSession({ refreshToken: true });
3239
+ }
3240
+
3241
+ return this.user;
3242
+ }
3243
+
3244
+
3245
+ /**
3246
+ * Uploads user's account profile data.<br>
3247
+ * The account needs to be signed in.
3248
+ *
3249
+ * ```
3250
+ * await skapi.updateUserData(
3251
+ * {
3252
+ * whoAmI: 'John Lennon',
3253
+ * myFavoriteFruits: ['Strawberry', 'Apple'],
3254
+ * meAndYoko: {
3255
+ * pictures: ['traveling.jpg', 'in_the_studio.jpg'],
3256
+ * unreleased_videos: ['give_a_peace_a_chance.mpg']
3257
+ * },
3258
+ * secret: 'Walrus was paul'
3259
+ * },
3260
+ * {
3261
+ * private: ['meAndYoko', 'secret']
3262
+ * }
3263
+ * );
3264
+ *
3265
+ * // userdata.meAndYoko, userdata.secret will not be public
3266
+ * ```
3267
+ * @category User
3268
+ */
3269
+ @formResponse()
3270
+ async uploadUserData(
3271
+ /** Form element or object. */
3272
+ form: Form,
3273
+ option?: FormCallbacks & {
3274
+ /** Lists of key to make private. */
3275
+ private: string[];
3276
+ }): Promise<User> {
3277
+
3278
+ option = checkParams(option || {}, {
3279
+ private: 'array'
3280
+ });
3281
+
3282
+ let opt = {
3283
+ auth: true
3284
+ };
3285
+
3286
+ if (option.private) {
3287
+ Object.assign(opt, { meta: { '__private__': option.private } });
3288
+ }
3289
+
3290
+ await this.request('post-userdata', form, opt);
3291
+ await this.authentication().updateSession();
3292
+ return this.user;
3293
+ }
3294
+
3295
+ /**
3296
+ * Query and fetch user account database.<br>
3297
+ * Any attribute that user has set to private will not be searchable.<br>
3298
+ * If the fetched list of users exceeds 100 users, you can run the same method with the same parameters to get the next batch of list of users. (auto startKey)<br>
3299
+ * If the argument is empty, It will fetch all users in the service in order of signuped timestamp.<br>
3300
+ * User login is required.<br>
3301
+ * You can search for user's information based on:<br>
3302
+ * 'user_id' | 'email' | 'phone_number' | 'name' | 'address' | 'group' | 'email_subscription' | 'gender' | 'birthdate' | 'locale' | 'subscribers'</br>
3303
+ * User's will not be on the database if they did not login after signup.
3304
+ *
3305
+ * ```
3306
+ * let getPauls = await skapi.getUsers({
3307
+ * searchFor: 'name',
3308
+ * value: 'paul', // search all user profile names in the connection service that is/start's with 'paul'
3309
+ * condition: '>='
3310
+ * });
3311
+ *
3312
+ * let getAllUsers = await skapi.getUsers({
3313
+ * searchFor: 'group',
3314
+ * value: 1 // search all users in group 1
3315
+ * });
3316
+ *
3317
+ * // returns -
3318
+ * // {
3319
+ * // list: [<object | user objects>, ...],
3320
+ * // startKey: <object>,
3321
+ * // endOfList: false // if true, more list can be fetched when method is executed again.
3322
+ * // }
3323
+ * ```
3324
+ *
3325
+ * form.searchFor: Index to search.
3326
+ * Search for user's information based on:<br>
3327
+ * - 'user_id'<br>
3328
+ * - 'email'<br>
3329
+ * - 'phone_number'<br>
3330
+ * - 'name'<br>
3331
+ * - 'address'<br>
3332
+ * - 'group'<br>
3333
+ * - 'email_subscription'<br>
3334
+ * - 'gender'<br>
3335
+ * - 'birthdate'<br>
3336
+ * - 'locale'<br>
3337
+ * - 'subscribers'<br>
3338
+ * - 'timestamp'<br>
3339
+ * form.value: Value to "searchFor":<br>
3340
+ * If user has set certain information private, it will not be searchable.<br>
3341
+ * Certain columns have specific types:<br>
3342
+
3343
+ * Search for user's information based on:<br>
3344
+ * - 'user_id'<br>
3345
+ * 36 character unique user id. If omitted it queries profile for currently signed in account.<br>
3346
+ * Conditions don't apply.<br>
3347
+
3348
+ * - 'blocked'<br>
3349
+ * Boolean. Queries users blocked state.<br>
3350
+ * Conditions don't apply.<br>
3351
+
3352
+ * - 'email'<br>
3353
+ * User's email.
3354
+ * Conditions don't apply.
3355
+
3356
+ * - 'phone_number'<br>
3357
+ * User phone number including region code. ex) +0012341234
3358
+
3359
+ * - 'name'<br>
3360
+ * Name that user have set on their account.
3361
+
3362
+ * - 'address'<br>
3363
+ * User's address.
3364
+
3365
+ * - 'group'<br>
3366
+ * User's account group number.
3367
+
3368
+ * - 'email_subscription'<br>
3369
+ * E-Mail subscribed user's group number.
3370
+
3371
+ * - 'gender'<br>
3372
+ * User's gender.
3373
+
3374
+ * - 'birthdate'<br>
3375
+ * User's birthdate.
3376
+ fetch
3377
+ * - 'locale'<br>
3378
+ * User's locale.
3379
+
3380
+ * - 'subscribers'<br>
3381
+ * Number of user's subscribers.
3382
+
3383
+ * - 'timestamp'<br>
3384
+ * User's account creation timestamp.
3385
+ *
3386
+ * @category User
3387
+ */
3388
+ async getUsers(
3389
+ params?: QueryParams | null,
3390
+ fetchOptions?: FetchOptions
3391
+ ): Promise<User | FetchResponse> {
3392
+
3393
+ let isAdmin = await this.requireAdmin({
3394
+ ignoreVerification: true
3395
+ });
3396
+
3397
+ if (!params) {
3398
+ if (!fetchOptions) {
3399
+ fetchOptions = {};
3400
+ }
3401
+ fetchOptions.ascending = false;
3402
+ }
3403
+
3404
+ // set default value
3405
+ params = params || {
3406
+ searchFor: 'timestamp',
3407
+ condition: '>',
3408
+ value: 0
3409
+ };
3410
+
3411
+ const searchForTypes = {
3412
+ 'user_id': (v: string) => validateUserId(v),
3413
+ 'name': 'string',
3414
+ 'email': (v: string) => validateEmail(v),
3415
+ 'phone_number': (v: string) => validatePhoneNumber(v),
3416
+ 'address': 'string',
3417
+ 'gender': 'string',
3418
+ 'birthdate': (v: string) => validateBirthdate(v),
3419
+ 'locale': (v: string) => {
3420
+ if (typeof v !== 'string' || typeof v === 'string' && v.length > 5) {
3421
+ throw new SkapiError('Value of "locale" should be a country code.');
3422
+ }
3423
+ return v;
3424
+ },
3425
+ 'subscribers': 'number',
3426
+ 'timestamp': 'number',
3427
+ 'group': 'number',
3428
+ 'email_subscription': (v: number) => {
3429
+ if (!isAdmin) {
3430
+ throw new SkapiError('Only admin is allowed to search "email_subscription".', { code: 'INVALID_REQUEST' });
3431
+ }
3432
+ return v;
3433
+ },
3434
+ 'blocked': (v: boolean) => {
3435
+ if (v) {
3436
+ return 'by_admin:suspended';
3437
+ }
3438
+ else {
3439
+ return 'by_admin:approved';
3440
+ }
3441
+ }
3442
+ };
3443
+
3444
+ let required = ['searchFor', 'value'];
3445
+
3446
+ params = checkParams(params, {
3447
+ searchFor: [
3448
+ 'user_id',
3449
+ 'name',
3450
+ 'email',
3451
+ 'phone_number',
3452
+ 'address',
3453
+ 'gender',
3454
+ 'birthdate',
3455
+ 'locale',
3456
+ 'subscribers',
3457
+ 'timestamp',
3458
+ 'group',
3459
+ 'email_subscription',
3460
+ 'blocked'
3461
+ ],
3462
+ condition: ['>', '>=', '=', '<', '<=', 'gt', 'gte', 'eq', 'lt', 'lte', () => '='],
3463
+ value: (v: any) => {
3464
+ let checker = searchForTypes[params.searchFor];
3465
+ if (typeof checker === 'function') {
3466
+ return checker(v);
3467
+ }
3468
+
3469
+ else if (typeof v !== checker) {
3470
+ throw new SkapiError(`Value does not match the type of "${params.searchFor}" index.`, { code: 'INVALID_PARAMETER' });
3471
+ }
3472
+
3473
+ return v;
3474
+ },
3475
+ range: (v: any) => {
3476
+ let checker = searchForTypes[params.searchFor];
3477
+ if (typeof checker === 'function') {
3478
+ return checker(v);
3479
+ }
3480
+
3481
+ else if (typeof v !== checker) {
3482
+ throw new SkapiError(`Range does not match the type of "${params.searchFor}" index.`, { code: 'INVALID_PARAMETER' });
3483
+ }
3484
+
3485
+ return v;
3486
+ }
3487
+ }, required);
3488
+
3489
+ if (params?.condition && params?.condition !== '=' && params.hasOwnProperty('range')) {
3490
+ throw new SkapiError('Conditions does not apply on range search.', { code: 'INVALID_PARAMETER' });
3491
+ }
3492
+
3493
+ if (['user_id', 'blocked'].includes(params.searchFor) && params.condition !== '=') {
3494
+ throw new SkapiError(`Condition is not allowed on "${params.searchFor}"`, { code: 'INVALID_PARAMETER' });
3495
+ }
3496
+
3497
+ if (params.searchFor === 'blocked') {
3498
+ // backend uses 'suspended' for column name
3499
+ params.searchFor = 'suspended';
3500
+ }
3501
+
3502
+ if (typeof params?.value === 'string' && !params?.value) {
3503
+ throw new SkapiError('Value should not be an empty string.', { code: 'INVALID_PARAMETER' });
3504
+ }
3505
+
3506
+ if (typeof params?.searchFor === 'string' && !params?.searchFor) {
3507
+ throw new SkapiError('"searchFor" should not be an empty string.', { code: 'INVALID_PARAMETER' });
3508
+ }
3509
+
3510
+ let isSelfProfile = params.searchFor === 'user_id' && params.value === this.session.idToken.payload.sub;
3511
+
3512
+ if (isAdmin && !isSelfProfile && !(params as any).service) {
3513
+ throw new SkapiError('Service ID is required.', { code: 'INVALID_PARAMETER' });
3514
+ }
3515
+
3516
+ let result = await this.request(
3517
+ 'get-users',
3518
+ params,
3519
+ { auth: true, fetchOptions }
3520
+ );
3521
+
3522
+ if (!result.list[0] || !isSelfProfile || result.list.length > 1) {
3523
+ return result;
3524
+ }
3525
+
3526
+ let user = result.list[0];
3527
+ // append user session data
3528
+ Object.assign(user, this.user);
3529
+
3530
+ user._what_public_see = JSON.parse(JSON.stringify(user));
3531
+
3532
+ let public_keys = [
3533
+ 'service',
3534
+ 'user_id',
3535
+ 'name',
3536
+ 'locale',
3537
+ 'address',
3538
+ 'birthdate',
3539
+ 'phone_number',
3540
+ 'email',
3541
+ 'gender',
3542
+ 'subscribers',
3543
+ 'timestamp',
3544
+ 'group',
3545
+ 'log',
3546
+ 'user_data'
3547
+ ];
3548
+
3549
+ // remove unnecessary keys in _what_public_see
3550
+ for (let k in user._what_public_see) {
3551
+ if (!public_keys.includes(k)) {
3552
+ delete user._what_public_see[k];
3553
+ }
3554
+ }
3555
+
3556
+ return user;
3557
+ }
3558
+
3559
+ private async updateServiceInformation(params?: { request_hash: string; }): Promise<Connection> {
3560
+
3561
+ let request = null;
3562
+ let connectedService: Record<string, any> = {};
3563
+
3564
+ if (params?.request_hash) {
3565
+ // hash request
3566
+
3567
+ if (this.__serviceHash[params.request_hash]) {
3568
+ // has hash
3569
+ Object.assign(connectedService, { hash: this.__serviceHash[params.request_hash] });
3570
+ }
3571
+
3572
+ else {
3573
+ // request signin hash
3574
+ request = {
3575
+ request_hash: validateEmail(params.request_hash)
3576
+ };
3577
+ }
3578
+ }
3579
+
3580
+ if (!this.connection || this.connection.service !== this.service || request) {
3581
+ // has hash request or need new connection request
3582
+
3583
+ if (request === null) {
3584
+ request = {};
3585
+ }
3586
+
3587
+ // assign service id and owner to request
3588
+ Object.assign(request, {
3589
+ service: this.service,
3590
+ service_owner: this.service_owner
3591
+ });
3592
+ }
3593
+
3594
+ // post request if needed
3595
+ Object.assign(connectedService, (request ? await this.request('service', request, { bypassAwaitConnection: true }) : this.connection));
3596
+
3597
+ if (params?.request_hash) {
3598
+ // cache hash if needed
3599
+ this.__serviceHash[params.request_hash] = connectedService.hash;
3600
+ }
3601
+
3602
+ // deep copy, save connection service info without hash
3603
+ let connection = JSON.parse(JSON.stringify(connectedService));
3604
+ delete connection.hash;
3605
+ this.connection = connection;
3606
+
3607
+ return connectedService as Connection;
3608
+ }
3609
+ }