src/api/world.ts
import Bugsnag from "@bugsnag/js";
import firebase from "firebase/app";
import { isEmpty, omit, pick } from "lodash";
import { ACCEPTED_IMAGE_TYPES, COLLECTION_WORLDS, FIELD_SLUG } from "settings";
import { createSlug } from "api/admin";
import { EntranceStepConfig } from "types/EntranceStep";
import { Question } from "types/Question";
import { UserStatus } from "types/User";
import {
WorldAdvancedFormInput,
WorldEntranceFormInput,
WorldGeneralFormInput,
WorldSlug,
} from "types/world";
import { generateFirestoreId, WithId, withId } from "utils/id";
import { isDefined } from "utils/types";
// NOTE: world might have many fields, please keep them in alphabetic order
export interface World {
adultContent?: boolean;
attendeesTitle?: string;
config: {
landingPageConfig: {
coverImageUrl: string;
description?: string;
subtitle?: string;
};
};
createdAt: Date;
entrance?: EntranceStepConfig[];
host: {
icon: string;
};
name: string;
owners: string[];
questions?: {
code?: Question[];
profile?: Question[];
};
radioStations?: string[];
requiresDateOfBirth?: boolean;
showBadges?: boolean;
showRadio?: boolean;
showSchedule?: boolean;
showUserStatus?: boolean;
slug: WorldSlug;
updatedAt: Date;
userStatuses?: UserStatus[];
hasSocialLoginEnabled?: boolean;
}
export const createFirestoreWorldCreateInput: (
input: WorldGeneralFormInput
) => Promise<Partial<World>> = async (input) => {
const name = input.name;
const slug = createSlug(name) as WorldSlug;
return { name, slug };
};
export const createFirestoreWorldStartInput: (
input: WithId<WorldGeneralFormInput>,
user: firebase.UserInfo
) => Promise<Partial<World>> = async (input, user) => {
// NOTE: id is needed before world is created to upload the images
const id = input?.id ?? generateFirestoreId({ emulated: true });
const slug = createSlug(input.name) as WorldSlug;
const storageRef = firebase.storage().ref();
const imageInputData: Record<string, string> = {};
const imageInputs = {
logoImageUrl: input.logoImageFile,
bannerImageUrl: input.bannerImageFile,
};
// upload the files
for (const [key, value] of Object.entries(imageInputs)) {
const file = value?.[0];
if (!file) continue;
const type = file.type;
if (!ACCEPTED_IMAGE_TYPES.includes(type)) continue;
const extension = type.split("/").pop();
const uploadFileRef = storageRef.child(
`users/${user.uid}/worlds/${id}/${key}.${extension}`
);
await uploadFileRef.put(file);
imageInputData[key] = await uploadFileRef.getDownloadURL();
}
const worldUpdateData: Partial<WithId<World>> = {
...omit(input, Object.keys(imageInputs)),
...imageInputData,
id,
slug,
};
return worldUpdateData;
};
export const createFirestoreWorldEntranceInput: (
input: WithId<WorldEntranceFormInput>,
user: firebase.UserInfo
) => Promise<Partial<World>> = async (input, user) => {
const worldUpdateData: Partial<WithId<World>> = {
id: input.id,
adultContent: input?.adultContent,
requiresDateOfBirth: input?.requiresDateOfBirth,
questions: {
code: input?.code ?? [],
profile: input?.profile ?? [],
},
entrance: isEmpty(input.entrance) ? [] : input.entrance,
};
return worldUpdateData;
};
export const createFirestoreWorldAdvancedInput: (
input: WithId<WorldAdvancedFormInput>,
user: firebase.UserInfo
) => Promise<Partial<World>> = async (input, user) => {
// mapping is mostly 1:1, so just filtering out unintended extra fields
const picked = pick(input, [
"id",
"attendeesTitle",
"showBadges",
"showRadio",
"showSchedule",
"showUserStatus",
"userStatuses",
"hasSocialLoginEnabled",
]);
// Form input is just a single string, but DB structure is string[]
const radioStations = isDefined(input.radioStation)
? [input.radioStation]
: undefined;
return { ...picked, radioStations };
};
export const createWorld: (
world: WorldGeneralFormInput,
user: firebase.UserInfo
) => Promise<{
worldId?: string;
error?: Error | unknown;
}> = async (world, user) => {
// a way to share value between try and catch blocks
let worldId = "";
try {
// NOTE: due to interdependence on id and upload files' URLs:
// 1. first a world stub is created
const stubInput = await createFirestoreWorldCreateInput(world);
const newWorld = (
await firebase.functions().httpsCallable("world-createWorld")(stubInput)
)?.data;
worldId = newWorld.id;
// 2. then world is properly updated, having necessary id
const fullInput = await createFirestoreWorldStartInput(
withId(world, worldId),
user
);
await firebase.functions().httpsCallable("world-updateWorld")(fullInput);
// 3. initial venue is created
// Temporary disabled due to possible complications and edge cases.
// What if the inital venue has to be a template of choice
// What if the venue already exists and it collides with the world name
// etc..
// await firebase.functions().httpsCallable("venue-createVenue_v2")({
// ...fullInput,
// worldId,
// });
// worldId might be useful for caller
return { worldId };
} catch (error) {
// in order to prevent new worlds getting created due to subsequent errors
// if an error is thrown here, but a world stub actually did get created
// return the id along with the error so that caller can proceed with update instead
return worldId ? { worldId, error } : { error };
}
};
export const updateWorldStartSettings = async (
world: WithId<WorldGeneralFormInput>,
user: firebase.UserInfo
) => {
return await firebase.functions().httpsCallable("world-updateWorld")(
await createFirestoreWorldStartInput(world, user)
);
};
export const updateWorldEntranceSettings = async (
world: WithId<WorldEntranceFormInput>,
user: firebase.UserInfo
) => {
return await firebase.functions().httpsCallable("world-updateWorld")(
await createFirestoreWorldEntranceInput(world, user)
);
};
export const updateWorldAdvancedSettings = async (
world: WithId<WorldAdvancedFormInput>,
user: firebase.UserInfo
) => {
return await firebase.functions().httpsCallable("world-updateWorld")(
await createFirestoreWorldAdvancedInput(world, user)
);
};
export type FindWorldBySlugOptions = {
worldSlug: string;
};
export const fetchWorld = async (worldId: string) => {
const venueDoc = await firebase
.firestore()
.collection(COLLECTION_WORLDS)
.doc(worldId)
.get();
return venueDoc.data() as World;
};
export const findWorldBySlug = async ({
worldSlug,
}: FindWorldBySlugOptions) => {
if (!worldSlug) {
throw new Error("The worldSlug should be provided");
}
const worldsRef = await firebase
.firestore()
.collection(COLLECTION_WORLDS)
.where("isHidden", "==", false)
.where(FIELD_SLUG, "==", worldSlug)
.get();
const worlds = worldsRef.docs;
if (worlds.length > 1) {
Bugsnag.notify(
`Multiple worlds have been found with the following slug: ${worldSlug}.`,
(event) => {
event.severity = "warning";
event.addMetadata("api/world::findWorldBySlug", {
worldSlug,
worlds,
});
}
);
}
return worlds?.[0];
};