UNPKG

@magic.batua/account

Version:

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

481 lines (435 loc) 18.8 kB
/** * @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. */ import * as Database from "./Source/Database" import * as Mongo from "mongodb" import { Account } from "./Source/Account" import { ClientError } from "@magic.batua/error" import { Code } from "@magic.batua/error" import { ExternalError } from "@magic.batua/error" import { TextMessage } from "@magic.batua/messaging" import { Messaging } from "@magic.batua/messaging" import { Source } from "./Source/Source" import { Points } from "@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. */ export class Registry { private db: Mongo.Db private messaging: Messaging /** * 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 */ public constructor(db: Mongo.Db, messaging: 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 */ public async IsDuplicate(input: any): Promise<boolean> { let account = new Account(input, 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 */ public async AwardPoints(id: string, service: string) { let account = await this.RetrieveByID(id) // Award points account.AddPoints(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.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 */ public async RedeemPoints(id: string, points: number, reason: string) { 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 * }) */ public async Create(input: SignupQuery): Promise<string> { // Initialise a new Account let newAccount = new Account(input, Source.Client) // Check for duplicate let isDuplicate = await Database.IsDuplicate(newAccount, this.db) if(isDuplicate) { throw new ClientError(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 ClientError(Code.BadRequest, "Invalid invite code.") } referrer!.AddReferral(newAccount._id.toHexString()) // Award points to referrer referrer!.AddPoints(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" * }) */ public async Retrieve(query: LoginQuery): Promise<string> { let account = await Database.Find(query.phone, this.db) if (account == null) { throw new ClientError(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 ClientError(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 ClientError(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") */ private async RetrieveByID(id: string): Promise<Account> { let account = await Database.GetAccountByID(id, this.db) if(account == null) { throw new ClientError(Code.NotFound, "User ID not found.") } if(account.isDeleted) { throw new ClientError(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" * }) */ public async Modify(id: string, query: any) { // Check if the user account exists and is active let exists = await Database.GetAccountByID(id, this.db) if (exists == null) { throw new ClientError(Code.NotFound, "Given ID is not registered with us.")} else if (exists.isDeleted && exists.recoverBy! <= Date.now()) { throw new ClientError(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 ClientError(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 */ public async Remove(id: string) { let account = await Database.GetAccountByID(id, this.db) if (account == null) { throw new ClientError(Code.NotFound, "No such records exist in our database.") } if (account!.isDeleted) { throw new ClientError(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 */ public async DidSendOTP(phone: string): Promise<boolean> { // See if the phone number exists in the records let account = await Database.Find(phone, this.db) if (account == null) { throw new ClientError(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 */ public async HasVerified(phone: string, pin: number): Promise<boolean> { // See if the phone number exists in the records let account = await Database.Find(phone, this.db) if (account == null) { throw new ClientError(Code.NotFound, "The given phone number doesn't exist in our records.") } if(account!.otp! != pin) { throw new ClientError(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 */ public async DidResetPassword(phone: string, newPass: string, pin: number): Promise<boolean>{ 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 } } /** * Describes the parameters expected by the Signup API to successfully * register a new Magic Batua account. */ export interface SignupQuery { /** Full name of the account owner */ name: string, /** * Mobile number without the international dialling code or preceding 0. * User must have access to this number as an OTP is sent to this number * during signup for identity verification. We'll refer to this number * as the registered mobile number through the documentation. */ phone: string, /** Email address of the user. Is not verified at the time of writing. */ email: string, /** * Can be anything. Even empty. No password policy is maintained server-side * at the time of writing. This should be fixed as soon as possible. * The password is salted and hashed before storing in the database. */ password: string, /** * Referral code of the person who has referred the user to Magic Batua. Can be * empty. */ inviteCode?: string } /** * Describes the parameters expect by the Login API to successfully authenticate * a login request. */ export interface LoginQuery { /** Registered mobile number without the international dialling code or preceding 0. */ phone: string, /** User's password */ password: string }