tutorbook
Version:
Web app connecting students with expert mentors and tutors.
350 lines (313 loc) • 11.5 kB
text/typescript
import * as admin from 'firebase-admin';
import firebase from '@tutorbook/firebase';
import url from 'url';
import { ObjectWithObjectID } from '@algolia/client-search';
import {
Availability,
AvailabilitySearchHitAlias,
AvailabilityJSON,
} from './availability';
import { Account, AccountInterface } from './account';
import { RoleAlias } from './appt';
export type Aspect = 'mentoring' | 'tutoring';
/**
* Type aliases so that we don't have to type out the whole type. We could try
* importing these directly from the `@firebase/firestore-types` or the
* `@google-cloud/firestore` packages, but that's not recommended.
* @todo Perhaps figure out a way to **only** import the type defs we need.
*/
type DocumentData = firebase.firestore.DocumentData;
type DocumentSnapshot = firebase.firestore.DocumentSnapshot;
type SnapshotOptions = firebase.firestore.SnapshotOptions;
type AdminDocumentSnapshot = admin.firestore.DocumentSnapshot;
/**
* Duplicate definition from the `@tutorbook/react-intercom` package. These are
* all the valid datatypes for custom Intercom user attributes.
* @see {@link https://www.intercom.com/help/en/articles/179-send-custom-user-attributes-to-intercom}
*/
type IntercomCustomAttribute = string | boolean | number | Date;
/**
* 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;
/**
* 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;
}
/**
* A check is a single aspect of a verification.
* @example
* - A verified university email address (e.g. `@stanford.edu`).
* - A verified normal email address.
* - A verified social (@see {@link SocialTypeAlias}) account (e.g. LinkedIn).
* - A DBS check on file.
*/
export type Check =
| SocialTypeAlias
| 'email'
| 'dbs'
| 'academic-email'
| 'training';
interface Resource {
created: Date;
updated: Date;
}
/**
* A verification is run by a non-profit organization (the `org`) by a member of
* that organization (the `user`). The non-profit takes full responsibility for
* their verification and liability for the user's actions.
* @property user - The uID of the user who ran the verification.
* @property org - The id of the non-profit org that the `user` belongs to.
* @property notes - Any notes about the verification (e.g. what happened).
* @property checks - An array of checks (@see {@link Check}) passed.
*/
export interface Verification extends Resource {
user: string;
org: string;
notes: string;
checks: Check[];
}
/**
* A user object (that is stored in their Firestore profile document by uID).
* @typedef {Object} UserInterface
* @property orgs - An array of the IDs of the orgs this user is a member of.
* @property owners - 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 mentoring - The subjects that the user wants a and can mentor for.
* @property tutoring - The subjects that the user wants a and 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 socials - An array of the user's socials (e.g. LinkedIn, Facebook).
* @property token - The user's Firebase Authentication JWT `idToken`.
*/
export interface UserInterface extends AccountInterface {
orgs: string[];
owners: string[];
availability: Availability;
mentoring: { subjects: string[]; searches: string[] };
tutoring: { subjects: string[]; searches: string[] };
langs: string[];
parents: string[];
socials: SocialInterface[];
verifications: Verification[];
token?: string;
}
/**
* What results from searching our users Algolia index.
*/
export interface SearchHit extends ObjectWithObjectID {
name: string;
photo: string;
bio: string;
owners: string[];
availability: AvailabilitySearchHitAlias;
mentoring: { subjects: string[]; searches: string[] };
tutoring: { subjects: string[]; searches: string[] };
langs: string[];
featured: Aspect[];
socials: SocialInterface[];
}
export type UserWithRoles = User & { roles: RoleAlias[] };
export type UserJSON = Omit<UserInterface, 'availability'> & {
availability: AvailabilityJSON;
};
export function isUserJSON(json: any): json is UserJSON {
return (json as UserJSON).availability !== undefined;
}
/**
* 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 orgs: string[] = [];
public owners: string[] = [];
public availability: Availability = new Availability();
public mentoring: { subjects: string[]; searches: string[] } = {
subjects: [],
searches: [],
};
public tutoring: { subjects: string[]; searches: string[] } = {
subjects: [],
searches: [],
};
public langs: string[] = [];
public parents: string[] = [];
public socials: SocialInterface[] = [];
public verifications: Verification[] = [];
public token?: 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);
Object.entries(user).forEach(([key, val]: [string, any]) => {
if (val && key in this && !(key in new Account()))
/* eslint-disable-next-line @typescript-eslint/no-unsafe-assignment */
(this as Record<string, any>)[key] = val;
});
this.socials = this.socials.filter((s: SocialInterface) => !!s.url);
}
public get firstName(): string {
return this.name.split(' ')[0];
}
public get lastName(): string {
const parts: string[] = this.name.split(' ');
return parts[parts.length - 1];
}
/**
* Converts this `User` object into a `Record<string, any>` that Intercom can
* understand.
* @see {@link https://developers.intercom.com/installing-intercom/docs/javascript-api-attributes-objects#section-data-attributes}
*/
public toIntercom(): Record<string, IntercomCustomAttribute> {
const { id, photo, token, ref, ...rest } = this;
const isFilled: (val: any) => boolean = (val: any) => {
switch (typeof val) {
case 'string':
return val !== '';
case 'boolean':
return true;
case 'number':
return true;
case 'undefined':
return false;
case 'object':
return Object.values(val).filter(isFilled).length > 0;
default:
return !!val;
}
};
const isValid: (val: any) => boolean = (val: any) => {
if (typeof val === 'string') return true;
if (typeof val === 'boolean') return true;
if (typeof val === 'number') return true;
if (val instanceof Date) return true;
return false;
};
const intercomValues: Record<string, any> = Object.fromEntries(
Object.entries(rest)
.filter(([key, val]) => isFilled(val))
.map(([key, val]) => [key, isValid(val) ? val : JSON.stringify(val)])
);
return { ...intercomValues, ...super.toIntercom() };
}
public static fromSearchHit(hit: SearchHit): User {
const { availability, objectID, ...rest } = hit;
const user: Partial<UserInterface> = {
...rest,
availability: Availability.fromSearchHit(availability),
id: objectID,
};
return new User(user);
}
public static fromFirestore(
snapshot: DocumentSnapshot | AdminDocumentSnapshot,
options?: SnapshotOptions
): User {
const userData: DocumentData | undefined = snapshot.data(options);
if (userData) {
const { availability, ...rest } = userData;
return new User({
...rest,
availability: Availability.fromFirestore(availability),
ref: snapshot.ref,
id: snapshot.id,
});
}
console.warn(
`[WARNING] Tried to create user (${snapshot.ref.id}) from ` +
'non-existent Firestore document.'
);
return new User();
}
/**
* Converts a `User` object into a JSON-like format for adding to a
* Firestore document.
* @see {@link https://firebase.google.com/docs/firestore/manage-data/add-data#custom_objects}
* @see {@link https://firebase.google.com/docs/reference/js/firebase.firestore.FirestoreDataConverter}
*/
public toFirestore(): DocumentData {
const { availability, token, ref, ...rest } = this;
const allDefinedValues = Object.fromEntries(
Object.entries(rest).filter(([key, val]) => val !== undefined)
);
const allFilledValues = Object.fromEntries(
Object.entries(allDefinedValues).filter(([key, val]) => {
if (!val) return false;
if (typeof val === 'object' && !Object.keys(val).length) return false;
return true;
})
);
return {
...allFilledValues,
availability: availability.toFirestore(),
};
}
public static fromJSON(json: UserJSON): User {
const { availability, ...rest } = json;
return new User({
...rest,
availability: Availability.fromJSON(availability),
});
}
/**
* Note that right now, we're sending the `token` property along with a user
* JSON object in the `/api/user` REST API endpoint. But that's the **only
* case** where we'd ever want to serialize and send a Firebase Auth JWT.
* @todo Perhaps remove the `token` from the JSON object and add it manually
* in the `/api/user` REST API endpoint.
*/
public toJSON(): UserJSON {
const { availability, ref, ...rest } = this;
return {
...rest,
availability: availability.toJSON(),
};
}
/**
* Gets the search URL where the URL parameters are determined by this user's
* `searches` and `availability` fields.
*
* @todo Ensure this works on the server-side (i.e. when it doesn't know what
* hostname or protocol to use).
*/
public get searchURL(): string {
return url.format({
pathname: '/search',
query: {
subjects: encodeURIComponent(JSON.stringify(this.tutoring.searches)),
availability: this.availability.toURLParam(),
},
});
}
}