@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
text/typescript
/**
* @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
}