tutorbookapp/tutorbook

View on GitHub
lib/model/account.ts

Summary

Maintainability
A
1 hr
Test Coverage
import {
  Resource,
  ResourceInterface,
  ResourceJSON,
  isResourceJSON,
} from 'lib/model/resource';
import { isArray, isJSON } from 'lib/model/json';
import clone from 'lib/utils/clone';
import construct from 'lib/model/construct';
import definedVals from 'lib/model/defined-vals';

/**
 * Represents a user verification to provide social proof. Supported types are:
 * - A background check or UK DBS on file
 * - A verified academic email address (e.g. `ac.uk` or `stanford.edu`)
 * - A verified social media account (i.e. LinkedIn, Twitter, FB, Insta)
 * - A personal website (mostly just an easy way to link to a resume site)
 *
 * These "socials" are then shown directly beneath the user's name in the
 * `UserDialog` making it easy for students (and/or their parents) to view and
 * feel assured about a potential tutor's qualifications.
 */
export type SocialTypeAlias =
  | 'website'
  | 'linkedin'
  | 'twitter'
  | 'facebook'
  | 'instagram'
  | 'github'
  | 'indiehackers';

export interface SocialInterface {
  type: SocialTypeAlias;
  url: string;
}

function getSocial(socials: SocialInterface[], type: SocialTypeAlias): string {
  return (socials.find((s) => s.type === type) || { url: '' }).url;
}

export function isSocial(json: unknown): json is SocialInterface {
  const socialTypes = [
    'website',
    'linkedin',
    'twitter',
    'facebook',
    'instagram',
    'github',
    'indiehackers',
  ];

  if (!isJSON(json)) return false;
  if (typeof json.type !== 'string') return false;
  if (!socialTypes.includes(json.type)) return false;
  if (typeof json.url !== 'string') return false;
  return true;
}

/**
 * An account object that both orgs and users extend.
 * @typedef {Object} AccountInterface
 * @property id - The account's Firebase Authentication identifier.
 * @property name - Display name (e.g. "Nicholas Chiang").
 * @property photo - Profile photo URL (i.e. the account's avatar).
 * @property email - Email address (e.g. "nicholas@tutorbook.org").
 * @property bio - A description of the org or user.
 * @property background - An optional background or banner image shown on the
 * org landing page and user display page.
 * @property venue - The account's default meeting venue (e.g. a Zoom link).
 * @property socials - An array of the account's social media links.
 */
export interface AccountInterface extends ResourceInterface {
  id: string;
  name: string;
  photo: string;
  email: string;
  phone: string;
  bio: string;
  background: string;
  venue: string;
  socials: SocialInterface[];
}

export type AccountJSON = Omit<AccountInterface, keyof Resource> & ResourceJSON;

export function isAccountJSON(json: unknown): json is AccountJSON {
  const stringFields = [
    'id',
    'name',
    'photo',
    'email',
    'phone',
    'bio',
    'background',
    'venue',
  ];

  if (!isResourceJSON(json)) return false;
  if (!isJSON(json)) return false;
  if (stringFields.some((key) => typeof json[key] !== 'string')) return false;
  if (!isArray(json.socials, isSocial)) return false;
  return true;
}

export class Account extends Resource implements AccountInterface {
  public id = '';

  public name = '';

  public photo = '';

  public email = '';

  public phone = '';

  public bio = '';

  public background = '';

  public venue = '';

  public socials: SocialInterface[] = [];

  public constructor(account: Partial<AccountInterface> = {}) {
    super(account);
    construct<AccountInterface, ResourceInterface>(
      this,
      account,
      new Resource()
    );
    this.socials = this.socials.filter((s: SocialInterface) => !!s.url);
    void this.validatePhone();
  }

  public get clone(): Account {
    return new Account(clone(this));
  }

  public get website(): string {
    return getSocial(this.socials, 'website');
  }

  public get linkedin(): string {
    return getSocial(this.socials, 'linkedin');
  }

  public get twitter(): string {
    return getSocial(this.socials, 'twitter');
  }

  public get facebook(): string {
    return getSocial(this.socials, 'facebook');
  }

  public get instagram(): string {
    return getSocial(this.socials, 'instagram');
  }

  public get github(): string {
    return getSocial(this.socials, 'github');
  }

  public get indiehackers(): string {
    return getSocial(this.socials, 'indiehackers');
  }

  public async validatePhone(): Promise<void> {
    const { default: phone } = await import('phone');
    this.phone = phone(this.phone).phoneNumber || '';
  }

  public toJSON(): AccountJSON {
    return definedVals({ ...this, ...super.toJSON() });
  }

  public static fromJSON(json: AccountJSON): Account {
    return new Account({ ...json, ...Resource.fromJSON(json) });
  }

  public toString(): string {
    return `${this.name}${this.id ? ` (${this.id})` : ''}`;
  }

  public toSegment(): Record<string, string> {
    return {
      id: this.id,
      name: this.name,
      email: this.email,
      phone: this.phone,
      avatar: this.photo,
      // We have to specify the `$avatar` trait separately for Mixpanel because
      // Segment doesn't translate it's `avatar` trait to the special Mixpanel
      // one. This is a limitation of theirs that shouldn't exist.
      $avatar: this.photo,
      description: this.bio,
      website: this.website,
    };
  }
}