Geovation/photos

View on GitHub
src/features/firebase/dbFirebase.js

Summary

Maintainability
D
2 days
Test Coverage
import firebase from "firebase/app";
import _ from "lodash";
 
import * as localforage from "localforage";

import config from "custom/config";
import { getFirebaseApp, getFCMToken } from "./firebaseInit.js";

import * as axios from "axios";

const firebaseApp = getFirebaseApp();
const firestore = firebase.firestore();
const storageRef = firebase.storage().ref();
const uploadsQueueStore = localforage.createInstance({ name: "uploadsQueue" });
const uploadsProgress = {};

function extractPhoto(data, id) {
  // some data from Firebase cannot be stringified into json, so we need to convert it into other format first.
  const photo = _.mapValues(data, (fieldValue, fieldKey, doc) => {
    if (fieldValue instanceof firebase.firestore.DocumentReference) {
      return fieldValue.path;
    } else {
      return fieldValue;
    }
  });

  photo.thumbnail = buildStorageUrl(`photos/${id}/thumbnail.jpg`);
  photo.main = buildStorageUrl(`photos/${id}/1024.jpg`);
  photo.id = id;

  photo.updated =
    photo.updated instanceof firebase.firestore.Timestamp
      ? photo.updated.toDate()
      : new Date(photo.updated);

  photo.moderated =
    photo.moderated instanceof firebase.firestore.Timestamp
      ? photo.moderated.toDate()
      : new Date(photo.moderated);

  if (!(photo.location instanceof firebase.firestore.GeoPoint)) {
    // when comming from json, it looses the type
    photo.location = new firebase.firestore.GeoPoint(
      Number(photo.location._latitude) || 0,
      Number(photo.location._longitude) || 0
    );
  }

  return photo;
}

/**
 * Get the last photos in real time from the given date. If fromDate is not given, then it get the newest 100 photos.
 *
 * @param {*} addedFn
 * @param {*} modifiedFn
 * @param {*} removedFn
 * @param {*} errorFn
 * @param {*} fromDate
 */
function publishedPhotosRT(addedFn, modifiedFn, removedFn, errorFn, fromDate) {
  const publishedPhotosRef = firestore
    .collection("photos")
    .where("published", "==", true);
  let newPublishedRef;
  if (fromDate) {
    newPublishedRef = publishedPhotosRef.where(
      "updated",
      ">",
      firebase.firestore.Timestamp.fromDate(fromDate)
    );
  }

  // any published photo
  return photosFromRefRT(
    newPublishedRef,
    addedFn,
    modifiedFn,
    removedFn,
    errorFn
  );
}

const configObserver = (onNext, onError) => {
  localforage.getItem("config").then(onNext).catch(console.log);

  return firestore
    .collection("sys")
    .doc("config")
    .onSnapshot((snapshot) => {
      const config = snapshot.data();
      localforage.setItem("config", config);
      onNext(config);
    }, onError);
};

async function fetchStats() {
  return fetch(config.FIREBASE.apiURL + "/stats", {
    mode: "cors",
  }).then((response) => response.json());
}

/**
 * Open reload all the photos using the REST API. In this way it will laverage CDN caching saving firestore quota.
 *
 * @param {*} fromAPI if true it will get it from the API which is very usefull for caching.
 */
async function fetchPhotos(fromAPI = true, sinceDate = new Date(null)) {
  let photos = {};
  if (fromAPI) {
    const photosResponse = await axios.get(
      config.FIREBASE.apiURL + "/photos.json"
    );
    photos = photosResponse.data.photos;
  } else {
    // Without caching:
    const querySnapshot = await firestore
      .collection("photos")
      .where("published", "==", true)
      .where("updated", ">", sinceDate)
      .get();
    querySnapshot.forEach((doc) => {
      photos[doc.id] = convertFirebaseTimestampFieldsIntoDate(doc.data());
    });
  }

  const rtnPhotos = _.map(photos, (data, id) => extractPhoto(data, id));
  console.debug(`New photos: ${JSON.stringify(rtnPhotos)}`);

  return rtnPhotos;
}

function convertFirebaseTimestampFieldsIntoDate(photo) {
  const newPhoto = _.cloneDeep(photo);
  _.forEach(newPhoto, (value, field) => {
    if (value.toDate) {
      newPhoto[field] = value.toDate();
    }
  });
  return newPhoto;
}

function fetchFeedbacks(isShowAll) {
  let query = firestore
    .collection("feedbacks")
    .orderBy("updated", "desc")
    .limit((config.FEEDBACKS && config.FEEDBACKS.MAX) || 50);
  return query
    .get()
    .then((sn) =>
      sn.docs.map((doc) => {
        return { ...doc.data(), id: doc.id };
      })
    )
    .then((feedbacks) =>
      feedbacks.filter((feedback) => !feedback.resolved || isShowAll)
    );
}

function saveMetadata(data) {
  data.location = new firebase.firestore.GeoPoint(
    Number(data.latitude) || 0,
    Number(data.longitude) || 0
  );
  delete data.latitude;
  delete data.longitude;

  data.owner_id = firebase.auth().currentUser.uid;

  data.updated = firebase.firestore.FieldValue.serverTimestamp();
  data.moderated = null;

  let fieldsToSave = ["moderated", "updated", "location", "owner_id"];
  _.forEach(config.PHOTO_FIELDS, (field) => fieldsToSave.push(field.name));
  const dataToSave = _.pick(data, fieldsToSave);
  const photoRef = firestore.collection("photos").doc(data.id);
  return photoRef.set(dataToSave).then(() => photoRef);
}

/**
 * It add ann image and its metadata to a queue to then be uploaded to the DB.
 * 
 * @param {*} param0 TODO
 */
async function scheduleUpload({ location, imgSrc, fieldsValues } = {}) {
  // add to queue
  const id =
    Math.random().toString(36).substring(2, 15) +
    Math.random().toString(36).substring(2, 15);
  const uploadItem = {
    location,
    imgSrc,
    fieldsValues,
    id,
  };
  await uploadsQueueStore.setItem(id, uploadItem);
  return processScheduledUpload(uploadItem);
}

async function processScheduledUploads() {
  return uploadsQueueStore.iterate((uploadItem, id, iterationNumber) => {
    console.debug([id, uploadItem, iterationNumber]);
    processScheduledUpload({ ...uploadItem, id });
  });
}

function getUploadProgress(id) {
  return _.get(uploadsProgress, `${id}.progress`, 100);
}

function getUploadingPhoto(id, thumbnail) {
  return _.get(uploadsProgress, `${id}.imgSrc`, thumbnail);
}

async function processScheduledUpload(uploadItem) {
  uploadsProgress[uploadItem.id] = { ...uploadItem, progress: 0 };
  const { location, imgSrc, fieldsValues } = uploadItem;

  // upload it
  const fieldsJustValues = _.reduce(
    fieldsValues,
    (a, v, k) => {
      a[k] = v.value;
      return a;
    },
    {}
  );

  let filteredFields = {};
  Object.entries(fieldsJustValues).forEach(([key, value]) => {
    if (value) {
      filteredFields[key] = typeof value === "string" ? value.trim() : value;

      const fieldDefinition = config.PHOTO_FIELDS[key];
      if (fieldDefinition.sanitize) {
        fieldDefinition.sanitize(value);
      }
    }
  });

  const onProgress = (progress) => {
    console.log(`upload progress of ${uploadItem.id} is ${progress}`);
    uploadsProgress[uploadItem.id].progress = progress;
  };
  const data = { ...location, ...filteredFields, id: uploadItem.id };
  const { promise, cancel } = uploadPhotoRetryingIfError(
    data,
    imgSrc,
    onProgress
  );

  const promiseUploadedAndKeyDeleted = promise.then(() => {
    console.log("Photo uploaded");
    onProgress(100);
    return uploadsQueueStore.removeItem(uploadItem.id);
  });

  return { promise: promiseUploadedAndKeyDeleted, cancel };
}

/**
 * It upload the metadata and the image itself. It returns an observable so that to beable to track the progress
 * 
 * @param {*} data data to be saved
 * @param {*} imgSrc the image in string format
 * @param {*} onProgress A function that will be called with a number indicating the progress
 * 
 * @returns an object which contains a promise and a cancel function. The
 *  promise will resolves when completed and fails if there are any errors or the cancel function is called. 
 *  if the function cancel is called, the upload will be cancelled, the metadate will be deleted,
 *  and the promise will be rejected.
 */

function uploadPhoto(data, imgSrc, onProgress) {
  const rtn = {};
  let canceled = false;
  let uploadTask;
  let resolve;
  let reject;
  let photoRef;

  rtn.promise = new Promise(async (res, rej) => {
    resolve = res;
    reject = rej;
    onProgress(0);
    try {
      photoRef = await saveMetadata(data);
    } catch (error) {
      reject(error);

      // exit
      return;
    }
    onProgress(1);
    // upload the image only if the upload has not been cancelled
    if (!canceled) {
      const base64 = imgSrc.split(",")[1];
      uploadTask = savePhoto(photoRef.id, base64);

      uploadTask.on(firebase.storage.TaskEvent.STATE_CHANGED, (snapshot) => {
        const sendingProgress = Math.ceil(
          (snapshot.bytesTransferred / snapshot.totalBytes) * 98 + 1
        );
        onProgress(sendingProgress);
        console.log(snapshot.state);
      });

      try {
        await uploadTask;
      } catch (error) {
        reject(error);
      }

      resolve();
    } else {
      // the user has cancelled it but the metadata upload has succeded. Therefore we need to delete it.
      photoRef.delete();
      reject(); // not necessary but explicit.

      // exit
      return;
    }
  });

  rtn.cancel = () => {
    canceled = true;
    // If there is an uploadTask, that means that the image upload is in progress and therefore the metadata is already in the DB
    if (uploadTask) {
      uploadTask.cancel();
      photoRef.delete();
    }
    reject();
  };

  return rtn;
}

function uploadPhotoRetryingIfError(data, imgSrc, onProgress) {
  const cancel = () => {
    // TODO:
  };

  const promise = new Promise(async (res, rej) => {
    let done = false;
    while (!done) {
      const { promise, cancel } = uploadPhoto(data, imgSrc, onProgress);

      console.log("Need to do semting about it: ", cancel);

      try {
        await promise;
        done = true;
        res();
      } catch (error) {
        console.debug(error);
        console.log("Trying to upload again");
      }
    }
  });

  return { promise, cancel };
}

/**
 *
 * @param id
 * @param base64 image in base64 format
 * @returns {firebase.storage.UploadTask}
 */
function savePhoto(id, base64) {
  const originalJpgRef = storageRef
    .child("photos")
    .child(id)
    .child("original.jpg");
  return originalJpgRef.putString(base64, "base64", {
    contentType: "image/jpeg",
  });
}

async function saveProfileAvatar(base64) {
  const user = firebase.auth().currentUser;
  const originalJpgRef = storageRef
    .child("users")
    .child(user.uid)
    .child("avatar.jpg");

  const uploadTask = await originalJpgRef.putString(base64, "base64", {
    contentType: "image/jpeg",
  });
  const AvatarUrl = buildStorageUrl(uploadTask.ref.location.path);
  await updateProfile({ photoURL: AvatarUrl });
  return AvatarUrl;
}

/**
 * // TODO: move it to authFirebase ???
 * It store the user profile in firebase if possible. Otherwise in firestore.
 *
 * @param {*} fields an object with the fields and values to be updated
 */
async function updateProfile(fields) {
  const supportedFields = ["photoURL", "displayName"];
  const user = firebase.auth().currentUser;

  // pick those supported by firebase
  const fieldsSupported = _.pick(fields, supportedFields);
  const updatingProfile = user.updateProfile(fieldsSupported);

  // those not supported will be saved in firestore
  const fieldsNotSupported = _.omit(fields, supportedFields);
  const updatingFirestore = firestore
    .collection("users")
    .doc(user.uid)
    .set(fieldsNotSupported, { merge: true });

  return await Promise.all([updatingFirestore, updatingProfile]);
}

async function getUser(id) {
  const fbUser = await firestore.collection("users").doc(id).get();
  return fbUser.exists ? fbUser.data() : null;
}

async function updateUserFCMToken() {
  const userID = firebase.auth().currentUser && firebase.auth().currentUser.uid;
  const fcmToken = getFCMToken();

  if (userID) {
    return firestore
      .collection("users")
      .doc(userID)
      .set({ fcmToken: fcmToken || null }, { merge: true });
  }
}

async function getFeedbackByID(id) {
  const fbFeedback = await firestore.collection("feedbacks").doc(id).get();
  return fbFeedback.exists ? { id, ...fbFeedback.data() } : null;
}

async function getPhotoByID(id) {
  const fbPhoto = await firestore.collection("photos").doc(id).get();
  const photo = extractPhoto(fbPhoto.data(), fbPhoto.id);
  if (fbPhoto.exists) {
    return {
      type: "Feature",
      geometry: {
        type: "Point",
        coordinates: [photo.location.longitude, photo.location.latitude],
      },
      properties: photo,
    };
  }
  return null;
}

/**
 *
 * @param howMany
 * @param photos object to keep up to date
 * @returns {() => void}
 */
function photosToModerateRT(
  howMany,
  updatePhotoToModerate,
  removePhotoToModerate
) {
  const photosRef = firestore
    .collection("photos")
    .where("moderated", "==", null)
    .orderBy("updated", "desc")
    .limit(howMany);

  return photosFromRefRT(
    photosRef,
    updatePhotoToModerate,
    updatePhotoToModerate,
    removePhotoToModerate
  );
}

function photosFromRefRT(
  photosRef,
  onAdd,
  onUpdate,
  onRemove,
  onError = console.error
) {
  return photosRef.onSnapshot(
    (snapshot) => {
      snapshot.docChanges().forEach((change) => {
        const photo = extractPhoto(change.doc.data(), change.doc.id);
        if (change.type === "modified") {
          onUpdate(photo);
        } else if (change.type === "added") {
          onAdd ? onAdd(photo) : onUpdate(photo);
        } else if (change.type === "removed") {
          onRemove(photo);
        } else {
          console.error(`the photo ${photo.id} as type ${change.type}`);
          onError && onError(`the photo ${photo.id} as type ${change.type}`);
        }
      });
    },
    (e) => onError(e) || console.error(e)
  );
}

function ownPhotosRT(addedFn, modifiedFn, removedFn, errorFn) {
  // get also the photos that belong to the current user even if not published yet.
  const photosRef = firestore.collection("photos");
  const userId = firebase.auth().currentUser.uid;
  const ownPhotosRef = photosRef.where("owner_id", "==", userId);

  return photosFromRefRT(
    ownPhotosRef,
    // addUploadFieldsFn,
    addedFn,
    modifiedFn,
    removedFn,
    errorFn
  );
}

function writeModeration(photoId, userId, published) {
  console.log(`The photo ${photoId} will have field published = ${published}`);

  if (typeof published !== "boolean") {
    throw new Error("Only boolean pls");
  }
  return firestore.collection("photos").doc(photoId).update({
    moderated: firebase.firestore.FieldValue.serverTimestamp(),
    published: published,
    moderator_id: userId,
  });
}

async function disconnect() {
  return firebaseApp.delete();
}

function onConnectionStateChanged(fn) {
  const conRef = firebase.database().ref(".info/connected");

  function connectedCallBack(snapshot) {
    fn(Boolean(snapshot.val()));
  }
  conRef.on("value", connectedCallBack);

  return async () => conRef.off("value", connectedCallBack);
}

async function writeFeedback(data) {
  if (firebase.auth().currentUser) {
    data.owner_id = firebase.auth().currentUser.uid;
  }
  data.updated = firebase.firestore.FieldValue.serverTimestamp();
  if (data.latitude && data.longitude) {
    data.location = new firebase.firestore.GeoPoint(
      Number(data.latitude) || 0,
      Number(data.longitude) || 0
    );
  }

  delete data.latitude;
  delete data.longitude;

  return await firestore.collection("feedbacks").add(data);
}

async function toggleUnreadFeedback(id, resolved, userId) {
  return await firestore.collection("feedbacks").doc(id).update({
    resolved: !resolved,
    customerSupport_id: userId,
    updated: firebase.firestore.FieldValue.serverTimestamp(),
  });
}

function buildStorageUrl(path) {
  // see https://firebase.google.com/docs/storage/web/download-files
  const PREFIX = `${config.FIREBASE.storageApiURL}/b/${config.FIREBASE.config.storageBucket}/o/`;
  return `${PREFIX}${encodeURIComponent(path)}?alt=media`;
}

const rtn = {
  onConnectionStateChanged,
  publishedPhotosRT,
  fetchStats,
  fetchFeedbacks,
  fetchPhotos,
  getUser,
  getFeedbackByID,
  getPhotoByID,
  saveProfileAvatar,
  updateProfile,
  photosToModerateRT,
  ownPhotosRT,
  rejectPhoto: (photoId, userId) => writeModeration(photoId, userId, false),
  approvePhoto: (photoId, userId) => writeModeration(photoId, userId, true),
  disconnect,
  writeFeedback,
  toggleUnreadFeedback,
  configObserver,
  updateUserFCMToken,
  buildStorageUrl,
  scheduleUpload,
  processScheduledUploads,
  getUploadProgress,
  getUploadingPhoto,
};

export default rtn;