tutorbookapp/tutorbook

View on GitHub
lib/model/user.ts

Summary

Maintainability
C
1 day
Test Coverage
import {
  Account,
  AccountInterface,
  AccountJSON,
  isAccountJSON,
} from 'lib/model/account';
import {
  Availability,
  AvailabilityJSON,
  isAvailabilityJSON,
} from 'lib/model/availability';
import { DBDate, DBTimeslot } from 'lib/model/timeslot';
import { DBMeeting, MeetingJSON, Meeting, isMeetingJSON } from 'lib/model/meeting';
import { Subject, isSubject } from 'lib/model/subject';
import { caps, join, notTags } from 'lib/utils';
import { isArray, isJSON, isStringArray } from 'lib/model/json';
import clone from 'lib/utils/clone';
import construct from 'lib/model/construct';
import definedVals from 'lib/model/defined-vals';
import { getAlgoliaAvailability } from 'lib/utils/time';

export type Role = 'tutor' | 'tutee' | 'parent';
export function isRole(role: unknown): role is Role {
  if (typeof role !== 'string') return false;
  return ['tutor', 'tutee', 'parent'].includes(role);
}

/**
 * Various tags that are added to the Algolia users search during indexing (via
 * the `lib/api/algolia.ts` API back-end module). For in-depth explanations of
 * each one, reference the link included below.
 * @see {@link https://github.com/tutorbookapp/tutorbook/tree/TAGS.md}
 */
export type UserTag =
  | 'meeting' // Has at least one meeting.
  | Role; // Has this role in at least one match.

export type DBUserTag =
  | UserTag
  | 'not-tutor'
  | 'not-tutee'
  | 'not-parent'
  | 'not-meeting';

export const USER_TAGS: UserTag[] = [
  'tutor',
  'tutee',
  'parent',
  'meeting',
];

export function isUserTag(tag: unknown): tag is UserTag {
  if (typeof tag !== 'string') return false;
  return ['meeting'].includes(tag) || isRole(tag);
}

/**
 * Right now, we only support traditional K-12 grade levels (e.g. 'Freshman'
 * maps to the number 9).
 * @todo Perhaps support other grade levels and other educational systems (e.g.
 * research how other countries do grade levels).
 */
export type GradeAlias = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12;

/**
 * A user object.
 * @typedef {Object} UserInterface
 * @extends AccountInterface
 * @property [age] - The user's age (mostly used for students).
 * @property orgs - An array of the IDs of the orgs this user belongs to.
 * @property availability - An array of `Timeslot`'s when the user is free.
 * @property subjects - The subjects that the user can tutor for.
 * @property langs - The languages (as ISO codes) the user can speak fluently.
 * @property parents - The Firebase uIDs of linked parent accounts.
 * @property meetings - An array of the user's meetings (w/out people data).
 * @property visible - Whether or not this user appears in search results.
 * @property roles - Always empty unless in context of match or request.
 * @property tags - An array of user tags used for analytics and filtering.
 * @property reference - How the user heard about TB or the org they're joining.
 * @property timezone - The user's time zone (e.g. America/Los_Angeles). This is
 * collected by our front-end and used by our back-end when sending reminders.
 * @property [token] - The user's Firebase Authentication JWT `idToken`.
 * @property [hash] - The user's Intercom HMAC for identity verifications.
 */
export interface UserInterface extends AccountInterface {
  age?: number;
  orgs: string[];
  availability: Availability;
  subjects: Subject[];
  langs: string[];
  parents: string[];
  meetings: Meeting[];
  visible: boolean;
  roles: Role[];
  tags: UserTag[];
  reference: string;
  timezone: string;
  token?: string;
  hash?: string;
}

export interface DBSocial {
  type:
    | 'website'
    | 'linkedin'
    | 'twitter'
    | 'facebook'
    | 'instagram'
    | 'github'
    | 'indiehackers';
  url: string;
}
export interface DBUser {
  id: string;
  uid: string | null;
  name: string;
  photo: string | null;
  email: string | null;
  phone: string | null;
  bio: string;
  background: string | null;
  venue: string | null;
  socials: DBSocial[];
  availability: DBTimeslot[];
  langs: string[];
  visible: boolean;
  reference: string;
  timezone: string | null;
  age: number | null;
  tags: DBUserTag[];
  created: DBDate;
  updated: DBDate;
  times: number[];
}
export interface DBViewUser extends DBUser {
  subjects: Subject[];
  subject_ids: number[];
  orgs: string[];
  parents: string[];
  meetings: DBMeeting[];
  available: boolean;
}
export interface DBPerson extends DBUser {
  roles: Role[] | null;
}
export interface DBRelationUserSubject {
  user: string;
  subject: number;
}
export interface DBRelationParent {
  user: string;
  parent: string;
}
export interface DBRelationOrg {
  user: string;
  org: string;
}

export type UserJSON = Omit<
  UserInterface,
  keyof Account | 'availability' | 'meetings'
> &
  AccountJSON & {
    availability: AvailabilityJSON;
    meetings: MeetingJSON[];
  };

export function isUserJSON(json: unknown): json is UserJSON {
  if (!isAccountJSON(json)) return false;
  if (!isJSON(json)) return false;
  if (json.age && typeof json.age !== 'number') return false;
  if (!isStringArray(json.orgs)) return false;
  if (!isAvailabilityJSON(json.availability)) return false;
  if (!isArray(json.subjects, isSubject)) return false;
  if (!isStringArray(json.langs)) return false;
  if (!isStringArray(json.parents)) return false;
  if (!isArray(json.meetings, isMeetingJSON)) return false;
  if (typeof json.visible !== 'boolean') return false;
  if (!isArray(json.roles, isRole)) return false;
  if (!isArray(json.tags, isUserTag)) return false;
  if (typeof json.reference !== 'string') return false;
  if (typeof json.timezone !== 'string') return false;
  if (json.token && typeof json.token !== 'string') return false;
  if (json.hash && typeof json.hash !== 'string') return false;
  return true;
}

/**
 * Class that provides default values for our `UserInterface` data model.
 * @see {@link https://stackoverflow.com/a/54857125/10023158}
 */
export class User extends Account implements UserInterface {
  public age?: number;

  public orgs: string[] = [];

  public availability: Availability = new Availability();

  public subjects: Subject[] = [];

  public langs: string[] = ['en'];

  public parents: string[] = [];

  public meetings: Meeting[] = [];

  public visible = false;

  public roles: Role[] = [];

  public tags: UserTag[] = [];

  public reference = '';

  public timezone = 'America/Los_Angeles';

  public token?: string;

  public hash?: string;

  /**
   * Creates a new `User` object by overriding all of our default values w/ the
   * values contained in the given `UserInterface` object.
   *
   * Note that this constructor will also normalize any given `phone` values to
   * the standard [E.164 format]{@link https://en.wikipedia.org/wiki/E.164}.
   *
   * @todo Perhaps add an explicit check to ensure that the given `val`'s type
   * matches the default value at `this[key]`'s type; and then only update the
   * default value if the types match.
   */
  public constructor(user: Partial<UserInterface> = {}) {
    super(user);
    construct<UserInterface, AccountInterface>(this, user, new Account());
  }

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

  // TODO: Perform these name styling changes from our back-end when validating
  // the user's request data (i.e. capitalize it and then send it back).
  public get firstName(): string {
    return caps(this.name.split(' ')[0] || '');
  }

  public get lastName(): string {
    const parts: string[] = this.name.split(' ');
    return caps(parts[parts.length - 1] || '');
  }

  public toDB(): DBUser {
    return {
      id: this.id,
      uid: null,
      name: this.name.trim(),
      photo: this.photo?.trim() || null,
      email: this.email?.trim() || null,
      phone: this.phone?.trim() || null,
      bio: this.bio.trim(),
      background: this.background?.trim() || null,
      venue: this.venue?.trim() || null,
      socials: this.socials,
      availability: this.availability.toDB(),
      langs: this.langs,
      visible: this.visible,
      reference: this.reference.trim(),
      timezone: this.timezone || null,
      age: this.age || null,
      tags: [...this.tags, ...notTags(this.tags, USER_TAGS)],
      created: this.created.toISOString(),
      updated: this.updated.toISOString(),
      times: getAlgoliaAvailability(this.availability),
    };
  }

  public static fromDB(record: DBUser | DBViewUser | DBPerson): User {
    return new User({
      id: record.id,
      name: record.name,
      photo: record.photo || '',
      email: record.email || '',
      phone: record.phone || '',
      bio: record.bio,
      background: record.background || '',
      venue: record.venue || '',
      socials: record.socials,
      availability: Availability.fromDB(record.availability),
      langs: record.langs,
      visible: record.visible,
      reference: record.reference,
      timezone: record.timezone || '',
      age: record.age || undefined,
      tags: record.tags.filter(isUserTag),
      created: new Date(record.created),
      updated: new Date(record.updated),
      subjects: 'subjects' in record ? (record.subjects || []).map((s) => ({ name: s.name, id: s.id })) : [],
      orgs: 'orgs' in record ? record.orgs || [] : [],
      parents: 'parents' in record ? record.parents || [] : [],
      meetings: 'meetings' in record ? (record.meetings || []).map(Meeting.fromDB) : [],
      roles: 'roles' in record ? record.roles || [] : [],
    });
  }

  public toJSON(): UserJSON {
    const { availability, meetings, ...rest } = this;
    return definedVals({
      ...rest,
      ...super.toJSON(),
      availability: availability.toJSON(),
      meetings: meetings.map((m) => m.toJSON()),
      token: undefined,
      hash: undefined,
    });
  }

  public static fromJSON({
    availability,
    meetings = [],
    ...rest
  }: UserJSON): User {
    return new User({
      ...rest,
      ...Account.fromJSON(rest),
      availability: Availability.fromJSON(availability),
      meetings: meetings.map(Meeting.fromJSON),
    });
  }

  // TODO: Replace the language codes with their actual i18n names.
  public toCSV(): Record<string, string> {
    return {
      'User ID': this.id,
      'User Name': this.name,
      'User Email': this.email,
      'User Phone': this.phone,
      'User Bio': this.bio,
      'User Reference': this.reference,
      'User Languages': join(this.langs),
      'User Tags': join(this.tags),
      Subjects: join(this.subjects.map((s) => s.name)),
      'Profile Image URL': this.photo,
      'Banner Image URL': this.background,
      'Website URL': this.website,
      'LinkedIn URL': this.linkedin,
      'Twitter URL': this.twitter,
      'Facebook URL': this.facebook,
      'Instagram URL': this.instagram,
      'GitHub URL': this.github,
      'IndieHackers URL': this.indiehackers,
      'User Created': this.created.toString(),
      'User Last Updated': this.updated.toString(),
    };
  }
}