UNPKG

@magic.batua/account

Version:

The Account modules powers the user account management features of the Magic Batua platform.

405 lines 17.1 kB
"use strict"; /** * @module @magic.batua/account * @overview Defines the {@link Registry} class that provides the interface to consume * account management features provided by the {@link Account} class. * * @author Animesh Mishra <hello@animesh.ltd> * @copyright © 2018 Animesh Ltd. All Rights Reserved. */ Object.defineProperty(exports, "__esModule", { value: true }); const Database = require("./Source/Database"); const Account_1 = require("./Source/Account"); const error_1 = require("@magic.batua/error"); const error_2 = require("@magic.batua/error"); const Source_1 = require("./Source/Source"); const points_1 = require("@magic.batua/points"); /** * The `Registry` class provides the interface between the API server and the `Account` * module. This class defines the methods and interfaces responsible for new account * registration, login authentication, profile updates and account deletion/undeletion * requests. * * - See [Create()]{@link Registry#Create} to understand signup logic. * - See [Retrieve()]{@link Registry#Retrieve} to understand login logic. * - See [Modify()]{@link Registry#Modify} to understand update profile logic. * - See [Remove()]{@link Registry#Remove} to understand account deletion logic. * * * In addition to these functionalities, the `Registry` class also defines several utility * methods that carry out key responsibilities. [DidSendOTP()]{@link Registry#DidSendOTP} * and [HasVerified()]{@link Registry#HasVerified} ensure smooth OTP-based verification. * While [DidResetPassword()]{@link Registry#DidResetPassword} carries out the seemingly * straightforward but actually quite complex task of resetting a user's password. */ class Registry { /** * A `Registry` instance needs access to the messaging API and database for proper * functioning. This constructor initialises an instance with fully-configured * `Messaging` and `Mongo.Db` instances. * * @param {Mongo.Db} db A MongoDB database instance * @param {Messaging} messaging A `Messaging` instance as defined in `@magic.batua/messaging` package. * * @class */ constructor(db, messaging) { this.db = db; this.messaging = messaging; } /** * Checks whether the given account parameters already exists in our records. Used * to prevent duplicate registrations. * * @param {SignupQuery} input See `index.ts` for definition of `SignupQuery`. * * @function IsDuplicate * @memberof Registry * @instance */ async IsDuplicate(input) { let account = new Account_1.Account(input, Source_1.Source.Client); let isDuplicate = await Database.IsDuplicate(account, this.db); return isDuplicate; } /** * Awards Magic Points as per the points schedule defined by the `@magic.batua/points` package. * If the account has a referrer, the referrer account is awarded referrer points too. * * @param {string} id User ID of the awardee * @param {string} service Service for which points should be awarded (e.g. "Mobile Recharge") * * @function AwardPoints * @memberof Registry * @instance */ async AwardPoints(id, service) { let account = await this.RetrieveByID(id); // Award points account.AddPoints(points_1.Points.ToSelfFor(service), service); // Update database await Database.FindAndReplace(account, this.db); // Award points to the referrer if (account.referredBy) { let referrer = await this.RetrieveByID(account.referredBy); if (referrer != null) { referrer.AddPoints(points_1.Points.ToReferrerFor(service), service + " by " + account.name); await Database.FindAndReplace(referrer, this.db); } } } /** * Redeems the given number of Magic Points. * * @param {string} id User ID of the awardee * @param {number} points Number of points to be redeemed * @param {string} reason Reason for which points were redeemed (e.g. "Mobile Recharge") * * @function RedeemPoints * @memberof Registry * @instance */ async RedeemPoints(id, points, reason) { let account = await this.RetrieveByID(id); account.RedeemPoints(points, reason); await Database.FindAndReplace(account, this.db); } /** * Registers a new Magic Batua account and returns a stringified version of the * new `Account` object. The registration process is as follows: * * 1. Initialise a new `Account` object using the given `input`. * 2. Check for duplicate account * 3. Send a verification SMS * 4. If an `inviteCode` is provided in the `input` query, find the referrer. * - Add a new referral to the `referrer` account and award them Magic Points for a referral. * 5. Issue Magic Points to the new account. * 6. Write the account to the database. * * @param {SignupQuery} input See `index.ts` for definition of `SignupQuery`. * * @returns A stringified version of the `Account` object * * @function Create * @memberof Registry * @instance * * @example * let registry = new Registry(...) * registry.Create({ * name: "Godzilla" * phone: 1234567890, * email: "god@zilla.com", * password: "Password", * inviteCode: "BigInJapan" // Optional * }) */ async Create(input) { // Initialise a new Account let newAccount = new Account_1.Account(input, Source_1.Source.Client); // Check for duplicate let isDuplicate = await Database.IsDuplicate(newAccount, this.db); if (isDuplicate) { throw new error_1.ClientError(error_2.Code.Conflict, "Phone number already exists.", ""); } // Send SMS newAccount.SetOTP(); await this.messaging.SendSMS({ recipient: newAccount.phone, text: "Your Magic Batua verification pin is " + newAccount.otp + "." }); // Find and update referrer (if any) if (input.inviteCode) { let referrer = await Database.GetReferrer(input.inviteCode, this.db); if (referrer == null) { throw new error_1.ClientError(error_2.Code.BadRequest, "Invalid invite code."); } referrer.AddReferral(newAccount._id.toHexString()); // Award points to referrer referrer.AddPoints(points_1.Points.ToReferrerFor("Signup"), "Referral"); await Database.FindAndReplace(referrer, this.db); newAccount.SetReferrer(referrer._id.toHexString()); } // Write to database newAccount = await Database.Insert(newAccount, this.db); return newAccount.Export(); } /** * Returns a stringified version of the `Account` object that matches the given `query`. * If the account requested had been marked for deletion earlier, and account `recoverBy` * date is in the future, the deletion hold on the account is lifted and the account is * restored to its former glory. * * There is no separate function to lift the deletion hold on an account. After requesting * a deletion, a user has 14 days to cancel it by logging back into their account. After * the 14th day, the account is soft-deleted and can't be recovered. * * @param {LoginQuery} query See `index.ts` for definition of `LoginQuery` * * @returns A stringified `Account` object * * @function Retrieve * @memberof Registry * @instance * * @example * let registry = new Registry(...) * registry.Retrieve({ * phone: "1234567890", * password: "Godzilla" * }) */ async Retrieve(query) { let account = await Database.Find(query.phone, this.db); if (account == null) { throw new error_1.ClientError(error_2.Code.NotFound, "Given mobile number isn't registered with us."); } if (account.CanAuthenticateUsing(query.password)) { if (!account.isDeleted) { return account.Export(); } else if (account.recoverBy <= Date.now()) { throw new error_1.ClientError(error_2.Code.NotFound, "Given mobile number isn't registered with us."); } account.Undelete(); await Database.FindAndReplace(account, this.db); let updated = await Database.GetAccountByID(account._id.toHexString(), this.db); return updated.Export(); } else { throw new error_1.ClientError(error_2.Code.Unauthorised, "Incorrect phone number or password."); } } /** * Returns the stringified version of the `Account` object that has the given `id`. * * @param {string} id The user ID to search for in the database * * @returns A stringified `Account` object * @throws - A "404 Not Found" `ClientError` if the `id` is not found. * - A "410 Gone" `ClientError` if the account has been deleted * * * @function RetrieveByID * @memberof Registry * @instance * * @example * let registry = new Registry(...) * let account = registry.RetrieveByID("abcdefgh") */ async RetrieveByID(id) { let account = await Database.GetAccountByID(id, this.db); if (account == null) { throw new error_1.ClientError(error_2.Code.NotFound, "User ID not found."); } if (account.isDeleted) { throw new error_1.ClientError(error_2.Code.Gone, "User account has been deleted."); } return account; } /** * Modifies profile information for the given account `_id` as instructed by the `query` * parameter. At the time of writing, only email, phone and name could be updated. For * changing/resetting password, use [DidResetPassword()]{@link Registry#DidResetPassword} * instead. * * **This method doesn't perform validation on input data. So you could very well set the * phone as "0000" and it wouldn't bat an eye. This should be improved in the next version.**. * * @param {string} id Magic Batua user `_id` * @param {any} query Key-value pairs to be updated * * @function Modify * @memberof Registry * @instance * * @example * let registry = new Registry(...) * registry.Modify("abcdefgh", { * phone: "1234567890", * name: "Godzilla" * }) */ async Modify(id, query) { // Check if the user account exists and is active let exists = await Database.GetAccountByID(id, this.db); if (exists == null) { throw new error_1.ClientError(error_2.Code.NotFound, "Given ID is not registered with us."); } else if (exists.isDeleted && exists.recoverBy <= Date.now()) { throw new error_1.ClientError(error_2.Code.NotFound, "Given ID is not registered with us."); } // User exists, but has been deleted and is within the 14-day account recovery period if (exists.isDeleted && exists.recoverBy > Date.now()) { throw new error_1.ClientError(error_2.Code.Conflict, "This account is marked for deletion on " + new Date(exists.recoverBy) + "."); } // All checks passed, now we can update the database record await Database.UpdateInPlace(id, query, this.db); } /** * Puts the account with ID `_id` under a 14-day deletion hold. If the account owner * doesn't logs into their account within this 14-day period, the account is permanently * *soft-deleted* and can't be recovered. * * If a user does log in within the 14-day window, the deletion hold is lifted and the * account is restored back to normal. See [Retrieve()]{@link Registry#Retrieve} for * the logic that removes the deletion hold. * * @param {string} id `_id` of the user to be deleted * * @function Remove * @memberof Registry * @instance */ async Remove(id) { let account = await Database.GetAccountByID(id, this.db); if (account == null) { throw new error_1.ClientError(error_2.Code.NotFound, "No such records exist in our database."); } if (account.isDeleted) { throw new error_1.ClientError(error_2.Code.NotFound, "No such records exists in our database."); } if (account.referredBy) { let referrer = await Database.GetAccountByID(account.referredBy, this.db); if (referrer) { referrer.RemoveReferral(account._id.toHexString()); await Database.FindAndReplace(referrer, this.db); } } account.Delete(); await Database.FindAndReplace(account, this.db); } /** * Generates a random one-time verification pin and sends it to the given `phone` * number. The method is designed such that if the `phone` number is not registered * with us, the method will throw an error and refuse to send the SMS. * * This could be problematic in some cases, so if a solid reason can be found to remove * this caveat, you should edit out the part of code in the beginning of the method. * * @param {string} phone A mobile number registered with us. * * @returns `true` if the SMS was sent successfully, otherwise throws an error. * * @function DidSendOTP * @memberof Registry * @instance */ async DidSendOTP(phone) { // See if the phone number exists in the records let account = await Database.Find(phone, this.db); if (account == null) { throw new error_1.ClientError(error_2.Code.NotFound, "The given phone number doesn't exist in our records."); } // Send verification SMS account.SetOTP(); await this.messaging.SendSMS({ recipient: account.phone, text: `Your Magic Batua verification pin is ${account.otp}.` }); // Update OTP in the database await Database.FindAndReplace(account, this.db); return true; } /** * Marks an account as verified if the given `pin` matches the one sent to the * account's registered mobile number. * * @param {string} phone Registered mobile number * @param {number} pin OTP sent for verification * * @returns `true` if verification is successful, otherwise throws an error. * * @function HasVerified * @memberof Registry * @instance */ async HasVerified(phone, pin) { // See if the phone number exists in the records let account = await Database.Find(phone, this.db); if (account == null) { throw new error_1.ClientError(error_2.Code.NotFound, "The given phone number doesn't exist in our records."); } if (account.otp != pin) { throw new error_1.ClientError(error_2.Code.Unauthorised, "Incorrect verification pin."); } // Pins match, set the phone as verified and unset the OTP account.UnsetOTP(); account.isPhoneVerified = true; await Database.FindAndReplace(account, this.db); return true; } /** * Before a user can submit a reset password request, they need to verify their * identity via a one-time pin sent to their registered mobile number. This method * expects that `pin` as well as the `newPassword` as the input. * * If OTP-verification succeeds, the `newPassword` is salted using a new randomly * generated salt and then hashed before being stored in the database. So in * effect, this method resets both the `salt` and the `password`. * * If OTP-verification fails, password is not reset and an error is thrown instead. * * @param {string} phone Registered mobile number * @param {string} newPass New password * @param {pin} pin OTP sent during verification * * @returns `true` if password reset is successful, otherwise throws an error. * * @function DidResetPassword * @memberof Registry * @instance */ async DidResetPassword(phone, newPass, pin) { if (await this.HasVerified(phone, pin)) { let account = await Database.Find(phone, this.db); account.ResetPassword(newPass); await Database.FindAndReplace(account, this.db); return true; } return false; } } exports.Registry = Registry; //# sourceMappingURL=index.js.map