index.js

"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;
    }
    /**
     *  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.");
        }
    }
    /**
     *  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