src/api/admin.ts
import Bugsnag from "@bugsnag/js";
import firebase from "firebase/app";
import { omit } from "lodash";
import {
ACCEPTED_IMAGE_TYPES,
COLLECTION_WORLD_EVENTS,
DEFAULT_PORTAL_BOX,
DEFAULT_SECTIONS_AMOUNT,
DEFAULT_SHOW_REACTIONS,
DEFAULT_SHOW_SHOUTOUTS,
INVALID_SLUG_CHARS_REGEX,
} from "settings";
import { findSpaceBySlug } from "api/space";
import { PortalInput, Room, RoomInput } from "types/rooms";
import { ScreeningRoomVideo } from "types/screeningRoom";
import { Table } from "types/Table";
import {
SpaceSlug,
VenueAdvancedConfig,
VenuePlacement,
VenueTemplate,
WorldEvent,
} from "types/venues";
import { uploadFile } from "utils/file";
import { WithId, WithoutId, WithWorldId } from "utils/id";
import { generateAttendeeInsideUrl } from "utils/url";
import { fetchVenue } from "./venue";
import { fetchWorld } from "./world";
export interface EventInput {
name: string;
description: string;
start_date: string;
start_time: string;
duration_hours: number;
duration_minutes?: number;
host: string;
}
type ImageFileKeys =
| "bannerImageFile"
| "logoImageFile"
| "mapBackgroundImageFile";
type ImageUrlKeys = "bannerImageUrl" | "logoImageUrl" | "mapBackgroundImageUrl";
type RoomImageFileKeys = "image_file";
type RoomImageUrlKeys = "image_url";
type RoomImageUrls = Partial<Record<RoomImageUrlKeys, string>>;
export interface VenueInput_v2 extends WithId<VenueAdvancedConfig> {
name: string;
slug: string;
description?: string;
subtitle?: string;
bannerImageFile?: FileList;
bannerImageUrl?: string;
logoImageFile?: FileList;
logoImageUrl?: string;
rooms?: Room[];
mapBackgroundImageFile?: FileList;
mapBackgroundImageUrl?: string;
template?: VenueTemplate;
iframeUrl?: string;
autoPlay?: boolean;
parentId?: string;
start_utc_seconds?: number;
end_utc_seconds?: number;
showShoutouts?: boolean;
showReactions?: boolean;
tables?: Table[];
}
type FirestoreVenueInput_v2 = Omit<VenueInput_v2, ImageFileKeys> &
Partial<Record<ImageUrlKeys, string>> & {
template: VenueTemplate;
parentId?: string;
};
type FirestoreRoomInput = Omit<RoomInput, RoomImageFileKeys> & RoomImageUrls;
type FirestoreRoomInput_v2 = Omit<PortalInput, RoomImageFileKeys> &
RoomImageUrls & {
url?: string;
};
export type PlacementInput = {
addressText?: string;
notes?: string;
placement?: Omit<VenuePlacement, "state">;
width: number;
height: number;
};
export const createSlug = (name: string) =>
name.replace(INVALID_SLUG_CHARS_REGEX, "").toLowerCase();
export const getVenueOwners = async (venueId: string): Promise<string[]> => {
const owners = (
await firebase.firestore().collection("venues").doc(venueId).get()
).data()?.owners;
return owners;
};
/**
* This method creates the payload for an API call for creating/updating venues.
* It is only intended to be used in two places:
* * Creating a new venue (and so no ID is needed)
* * By createFirestoreVenueInputWithoutId_v2 which adds the venue ID
*/
const createFirestoreVenueInputWithoutId_v2 = async (
input: Omit<VenueInput_v2, "id">,
user: firebase.UserInfo
) => {
const storageRef = firebase.storage().ref();
type ImageNaming = {
fileKey: ImageFileKeys;
urlKey: ImageUrlKeys;
};
const imageKeys: Array<ImageNaming> = [
{
fileKey: "logoImageFile",
urlKey: "logoImageUrl",
},
{
fileKey: "bannerImageFile",
urlKey: "bannerImageUrl",
},
{
fileKey: "mapBackgroundImageFile",
urlKey: "mapBackgroundImageUrl",
},
];
let imageInputData = {};
// upload the files
for (const { fileKey, urlKey } of imageKeys) {
const files = input[fileKey];
const file = files?.[0];
if (!file) continue;
const type = file.type;
if (!ACCEPTED_IMAGE_TYPES.includes(type)) continue;
const fileExtension = file.type.split("/").pop();
// @debt this may cause missing or wrong image issues if two venues exchange their slugs, should take multiple steps to reproduce
const uploadFileRef = storageRef.child(
`users/${user.uid}/venues/${input.slug}/${urlKey}.${fileExtension}`
);
await uploadFileRef.put(file);
const downloadUrl: string = await uploadFileRef.getDownloadURL();
imageInputData = {
...imageInputData,
[urlKey]: downloadUrl,
};
}
const firestoreVenueInput: Omit<FirestoreVenueInput_v2, "id"> = {
...omit(
input,
imageKeys.map((entry) => entry.fileKey)
),
...imageInputData,
template: input.template ?? VenueTemplate.partymap,
parentId: input.parentId ?? "",
// While name is used as URL slug and there is possibility cloud functions might miss this step, canonicalize before saving
name: input.name,
slug: input.slug,
};
return firestoreVenueInput;
};
const createFirestoreVenueInput_v2 = async (
input: VenueInput_v2,
user: firebase.UserInfo
) => {
// We temporarily cast the result to unknown so that we can cast to the
// same type with the ID added, then we add the missing property.
const result = ((await createFirestoreVenueInputWithoutId_v2(
input,
user
)) as unknown) as FirestoreVenueInput_v2;
result.id = input.id;
return result;
};
export const createVenue_v2 = async (
// The default is for "doing something with a venue" to require a venue ID.
// Creating a venue is a special case and doesn't want a venue ID.
// This is preferred over having to remember to add "needs a venue ID" in
// many places.
input: WithWorldId<Omit<VenueInput_v2, "id">>,
user: firebase.UserInfo
) => {
const firestoreVenueInput = await createFirestoreVenueInputWithoutId_v2(
{
...input,
showShoutouts: input.showShoutouts ?? DEFAULT_SHOW_SHOUTOUTS,
showReactions: input.showReactions ?? DEFAULT_SHOW_REACTIONS,
rooms: [],
},
user
);
const worldId = input.worldId;
const spaceSlug = firestoreVenueInput.slug;
const venueResponse = await firebase
.functions()
.httpsCallable("venue-createVenue_v2")({
...firestoreVenueInput,
worldId,
});
const space = await findSpaceBySlug({
spaceSlug,
worldId,
});
if (input.template === VenueTemplate.auditorium) {
await firebase.functions().httpsCallable("venue-setAuditoriumSections")({
venueId: space?.id,
numberOfSections: DEFAULT_SECTIONS_AMOUNT,
});
}
return venueResponse;
};
export const updateVenue_v2 = async (
input: WithWorldId<VenueInput_v2>,
user: firebase.UserInfo
) => {
const firestoreVenueInput = await createFirestoreVenueInput_v2(input, user);
return firebase
.functions()
.httpsCallable("venue-updateVenue_v2")(firestoreVenueInput)
.catch((error) => {
const msg = `[updateVenue_v2] updating venue ${input.name}`;
const context = {
location: "api/admin::updateVenue_v2",
};
Bugsnag.notify(msg, (event) => {
event.severity = "warning";
event.addMetadata("context", context);
event.addMetadata("firestoreVenueInput", firestoreVenueInput);
});
throw error;
});
};
export const updateMapBackground = async (
input: WithWorldId<VenueInput_v2>,
user: firebase.UserInfo
) => {
const firestoreVenueInput = await createFirestoreVenueInput_v2(input, user);
return firebase
.functions()
.httpsCallable("venue-updateMapBackground")(firestoreVenueInput)
.catch((error) => {
const msg = `[updateMapBackground] updating venue ${input.name}`;
const context = {
location: "api/admin::updateMapBackground",
};
Bugsnag.notify(msg, (event) => {
event.severity = "warning";
event.addMetadata("context", context);
event.addMetadata("firestoreVenueInput", firestoreVenueInput);
});
throw error;
});
};
const createFirestoreRoomInput = async (
input: RoomInput,
venueId: string,
user: firebase.UserInfo
) => {
const urlPortalName = createSlug(
input.title + Math.random().toString() //room titles are not necessarily unique
);
let imageInputData = {};
// upload the files
if (input["image_file"]) {
const fileArr = input["image_file"];
const downloadUrl = await uploadFile(
`users/${user.uid}/venues/${venueId}/${urlPortalName}`,
fileArr
);
imageInputData = { image_url: downloadUrl };
} else {
imageInputData = { image_url: input.image_url };
}
const firestoreRoomInput: FirestoreRoomInput = {
...omit(input, "image_file"),
...imageInputData,
};
return firestoreRoomInput;
};
const createFirestoreRoomInput_v2 = async (
input: PortalInput,
venueId: string,
user: firebase.UserInfo
) => {
const storageRef = firebase.storage().ref();
const urlPortalName = createSlug(
input.title + Math.random().toString() //room titles are not necessarily unique
);
type ImageNaming = {
fileKey: RoomImageFileKeys;
urlKey: RoomImageUrlKeys;
};
const imageKeys: Array<ImageNaming> = [
{
fileKey: "image_file",
urlKey: "image_url",
},
];
let imageInputData = {};
const venue = await fetchVenue(venueId);
const world = await fetchWorld(venue.worldId);
// upload the files
for (const entry of imageKeys) {
const fileArr = input[entry.fileKey];
if (!fileArr || fileArr.length === 0) continue;
const file = fileArr[0];
const uploadFileRef = storageRef.child(
`users/${user.uid}/venues/${venueId}/${urlPortalName}/${file.name}`
);
await uploadFileRef.put(file);
const downloadUrl: string = await uploadFileRef.getDownloadURL();
imageInputData = { ...imageInputData, [entry.urlKey]: downloadUrl };
}
const firestoreRoomInput: FirestoreRoomInput_v2 = {
...omit(
input,
imageKeys.map((entry) => entry.fileKey)
),
url:
input.useUrl || !input.venueName
? input.url
: window.origin +
generateAttendeeInsideUrl({
worldSlug: world.slug,
spaceSlug: input.venueName as SpaceSlug,
}),
...imageInputData,
};
return firestoreRoomInput;
};
export const upsertRoom = async (
input: RoomInput,
venueId: string,
user: firebase.UserInfo,
roomIndex?: number
) => {
const firestoreVenueInput = await createFirestoreRoomInput(
input,
venueId,
user
);
return await firebase
.functions()
.httpsCallable("venue-upsertRoom")({
venueId,
roomIndex,
room: firestoreVenueInput,
})
.catch((e) => {
Bugsnag.notify(e, (event) => {
event.addMetadata("api/admin::upsertRoom", {
venueId,
roomIndex,
});
});
throw e;
});
};
export const deleteRoom = async (venueId: string, room: Room) => {
return await firebase
.functions()
.httpsCallable("venue-deleteRoom")({
venueId,
room,
})
.catch((e) => {
Bugsnag.notify(e, (event) => {
event.addMetadata("api/admin::deleteRoom", {
venueId,
room,
});
});
throw e;
});
};
export const updateRoom = async (
input: PortalInput,
venueId: string,
user: firebase.UserInfo,
roomIndex: number
) => {
const firestoreVenueInput = await createFirestoreRoomInput_v2(
input,
venueId,
user
);
return await firebase.functions().httpsCallable("venue-upsertRoom")({
venueId,
roomIndex,
room: firestoreVenueInput,
});
};
export const createRoom = async (
input: PortalInput,
venueId: string,
user: firebase.UserInfo
) => {
const firestoreVenueInput = await createFirestoreRoomInput_v2(
input,
venueId,
user
);
return await firebase.functions().httpsCallable("venue-upsertRoom")({
venueId,
room: {
...firestoreVenueInput,
// Initial positions and size
// TODO: As an alternative to the center positioning, maybe have a Math.random() to place rooms at random
...DEFAULT_PORTAL_BOX,
},
});
};
export const createEvent = async (event: WithoutId<WorldEvent>) => {
await firebase.firestore().collection(COLLECTION_WORLD_EVENTS).add(event);
};
export const updateEvent = async (event: WorldEvent) => {
await firebase
.firestore()
.doc(`${COLLECTION_WORLD_EVENTS}/${event.id}`)
.update(event);
};
export const deleteEvent = async (event: WorldEvent) => {
await firebase
.firestore()
.doc(`${COLLECTION_WORLD_EVENTS}/${event.id}`)
.delete();
};
export const addVenueOwner = async (venueId: string, newOwnerId: string) =>
await firebase.functions().httpsCallable("venue-addVenueOwner")({
venueId,
newOwnerId,
});
export const removeVenueOwner = async (venueId: string, ownerId: string) =>
firebase.functions().httpsCallable("venue-removeVenueOwner")({
venueId,
ownerId,
});
export const upsertScreeningRoomVideo = async (
video: ScreeningRoomVideo,
spaceId: string,
videoId?: string
) => {
return await firebase
.functions()
.httpsCallable("venue-upsertScreeningRoomVideo")({
video,
videoId,
spaceId,
})
.catch((e) => {
Bugsnag.notify(e, (event) => {
event.addMetadata("api/admin::upsertScreeningRoomVideo", {
spaceId,
video,
videoId,
});
});
throw e;
});
};
export const deleteScreeningRoomVideo = async (
videoId: string,
spaceId: string
) => {
return await firebase
.functions()
.httpsCallable("venue-deleteScreeningRoomVideo")({
spaceId,
videoId,
})
.catch((e) => {
Bugsnag.notify(e, (event) => {
event.addMetadata("api/admin::deleteScreeningRoomVideo", {
videoId,
spaceId,
});
});
throw e;
});
};