@magic.batua/account
Version:
The Account modules powers the user account management features of the Magic Batua platform.
405 lines • 17.1 kB
JavaScript
"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