Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

User sign up #205

Closed

Conversation

CSharpTeoMan911
Copy link

Description

The application was updated to facilitate the Sign-up functionality in both the development environment and the production environment. This change involved a challenge, and to be more specific it involved the CORS(Cross-Origin Resource Sharing) policy. This challenge was caused by the fact that in both the production environment and development environment, the Google Functions were served at a different origin (URL) than the web application (development e.g. Web app: https://localhost:2000 , Production: https://localhost:5001) (production e.g. Web app: https://web-app.com, Production: https://googlefunctions.com/function-name). To combat this issue the main entry point API structure of the Google Functions within the index.js file had to be re-implemented, as well as re-implementing the function calls within the frontend by switching to API requests made with Axios (e.g. GET, PUT, POST, DELETE).

Loom feature showcase: https://www.loom.com/share/672132200cf345b4b1821e0551ce0b0d?sid=c9faeb58-0e54-4873-8fe6-49b6cef3cf75

Related Issue

CORS(Cross-Origin Resource Sharing) policy does not allow the main app to call the Google Functions backend.

Type of Change

Please select the type(s) of change that apply and delete those that do not.

  • Bug fix: A non-breaking change that fixes an issue.
  • New feature: A non-breaking change that adds functionality.
  • Code style update: Changes that do not affect the meaning of the code (e.g., formatting).

Proposed Solution

index.js (NodeJS)

require('dotenv').config({ path: '../.env' }); // Ensure this is at the top
const { https } = require('firebase-functions/v2');
const admin = require('firebase-admin');

const express = require('express');
const cors = require('cors');

const app = express();

// Automatically allow cross-origin requests
app.use(cors({ origin: true }));


// Verify if Firebase front-end/back-end is deployed in production or development mode
if(process.env.NEXT_PUBLIC_FIREBASE_DEPLOYMENT_MODE === "dev")
{
    admin.initializeApp();
}
else
{
    // If the app is deployed in production mode authenticate with the `Firebase Admin SDK` private key.
    // Generate the key by pressing the `Generate new private key` button at `Project settings\Account settings\Service accounts`
    // within your Firebase project and add the generated file in the `controllers` directory.
    const serviceAccount = require("./controllers/marvel-platform-c3a0b-firebase-adminsdk-kplbb-dc41163a3e.json");
    admin.initializeApp({credential:admin.credential.cert(serviceAccount)});
}

const userController = require('./controllers/userController');
const marvelAIController = require('./controllers/marvelAIController');
const { seedDatabase } = require('./cloud_db_seed');

seedDatabase();


// Server functions served as a CRUD API interface
//
// [BEGIN]

app.get('/signUpUser', async (req, res) => {
    res.json(await userController.signUpUser(req.query));
});

app.post('/createChatSession', async(req, res)=>{
    res.json(await marvelAIController.createChatSession(req.body));
});

app.post('/chat', async(req, res)=>{
    res.json(await marvelAIController.chat(req.body));
});

// [END]


/* Migration Scripts */
// const {
// } = require("./migrationScripts/modifyChallengePlayersData");
const migrationScripts = {};

// Serve all the functions as a single Google Cloud Function in order to 
// enable 'CORS'(Cross-origin resource sharing) by passing an 'Express' 
// CORS enabled API app object, to the 'onRequest' firebase function. 
// This must be done because the front-end web-app and the server app
// are served at 2 different URLs (origins), and the server must be 
// configured to accept requests from different origins.
module.exports.functions = https.onRequest(app);

In order to facilitate CORS, the application's API served within the index.js file had to be modified by importing the Express routing API, importing the cors library, creating an Express API instance, and passing as an option to the Express API instance cors with the option to allow cross-origin requests. The Google Functions are served as a singleton function, which serves the formerly individually served Google Functions as sub-functions which in turned are served through a CRUD Rest API interface. Each of these sub-functions are served at the endpoints in the format [GOOGLE FUNCTION URL]/[FUNCTION NAME]. For the application to be able to serve the Firestore database in production mode, the app will check if it is either in development environment or in production environment, and subsequently, if the app is in production environment it will load the Firebase API key from a file when the app is initialised.




userController.js (NodeJS)

const admin = require('firebase-admin');


/**
 * Creates a new user document in the Firestore collection "users" with the provided data.
 *
 * @async
 * @function signUpUser
 * @param {Object} data - The data object containing the user's information.
 * @param {string} data.email - The email address of the user.
 * @param {string} data.fullName - The full name of the user.
 * @param {string} data.uid - The unique identifier for the user.
 * @param {Object} context - The context object containing information about the authenticated user.
 * @returns {Promise<Object>} A promise that resolves to an object indicating the success of the operation.
 * @returns {string} return.status - The status of the operation ('success').
 * @returns {string} return.message - A message describing the result of the operation.
 * @throws {https.HttpsError} If any of the required fields (email, fullName, uid) are missing in the data object.
 */


exports.signUpUser = async (data) => {
  try {
    const { email, fullName, uid } = data;

    if (!email || !fullName || !uid) {
      return { status: 'error', message: 'Please provide all required fields' };
    }

    const userRef = admin.firestore().collection('users').doc(uid);
    const userDoc = {
      id: uid,
      email,
      fullName,
      needsBoarding: true,
    };

    await userRef.set(userDoc);
    return { status: 'success', message: 'User document created successfully' };
  }
  catch(Error) 
  {
    console.log(Error);
    return { status: 'error', message: 'Internal server error' };
  }
};



The error handling was changed form using HttpsError to using an object that notifes the frontend if an error occured, due to the fact that the API structure had been changed. Another change is that the function is not served as an Firebase onCall event due to the fact that the calls are managed by Express.

marvelAIController.js (NodeJS)

const admin = require('firebase-admin');
const { default: axios } = require('axios');
const { logger } = require('firebase-functions/v1');
const { Timestamp } = require('firebase-admin/firestore');
const { BOT_TYPE, AI_ENDPOINTS } = require('../constants');

// const DEBUG = process.env.DEBUG;
const DEBUG = true;

/**
 * Simulates communication with the Marvel AI endpoint.
 *
 * @function marvelCommunicator
 * @param {object} payload - The properties of the communication.
 * @param {object} props.data - The payload data object used in the communication.
 *  @param {Array} props.data.messages - An array of messages for the current user chat session.
 *  @param {object} props.data.user - The user object.
 *    @param {string} props.data.user.id - The id of the current user.
 *    @param {string} props.data.user.fullName - The user's full name.
 *    @param {string} props.data.user.email - The users email.
 *  @param {object} props.data.toolData - The payload data object used in the communication.
 *    @param {string} props.data.toolData.toolId - The payload data object used in the communication.
 *    @param {Array} props.data.toolData.inputs - The different form input values sent for a tool.
 *  @param {string} props.data.type - The payload data object used in the communication.
 *
 * @return {object} The response from the AI service.
 */
const marvelCommunicator = async (payload) => {
  try {
    DEBUG && logger.log('marvelCommunicator started, data:', payload.data);

    const { messages, user, toolData, type } = payload.data;
    const isToolCommunicator = type === BOT_TYPE.TOOL;
    const MARVEL_API_KEY = process.env.MARVEL_API_KEY;
    const MARVEL_ENDPOINT = process.env.MARVEL_ENDPOINT;

    DEBUG &&
      logger.log(
        'Communicator variables:',
        `API_KEY: ${MARVEL_API_KEY}`,
        `ENDPOINT: ${MARVEL_ENDPOINT}`
      );

    const headers = {
      'API-Key': MARVEL_API_KEY,
      'Content-Type': 'application/json',
    };

    const marvelPayload = {
      user,
      type,
      ...(isToolCommunicator ? { tool_data: toolData } : { messages }),
    };

    DEBUG && logger.log('MARVEL_ENDPOINT', MARVEL_ENDPOINT);
    DEBUG && logger.log('marvelPayload', marvelPayload);

    console.log(`MARVEL_ENDPOINT: ${MARVEL_ENDPOINT}`)
    const resp = await axios.post(
      `${MARVEL_ENDPOINT}${AI_ENDPOINTS[type]}`,
      marvelPayload,
      {
        headers,
      }
    );

    DEBUG && logger.log('marvelCommunicator response:', resp.data);

    return { status: 'success', data: resp.data };
  } catch (error) {
    const {
      response: { data },
    } = error;
    const { message } = data;
    DEBUG && logger.error('marvelCommunicator error:', data);

    return { status: 'error', data: message};
  }
};

/**
 * Manages communications for a specific chat session with a chatbot, updating and retrieving messages.
 *
 * @function chat
 * @param {object} props - The properties of the communication.
 * @param {object} props.data - The data object containing the message and id.
 * @param {string} props.data.id - The id of the chat session.
 * @param {string} props.data.message - The message object.
 *
 * @return {object} The response object containing the status and data.
 */
const chat = async (props) => {
  try {
    DEBUG && logger.log('Chat started, data:', props);

    const { message, id } = props;

    DEBUG &&
      logger.log(
        'Chat variables:',
        `API_KEY: ${process.env.MARVEL_API_KEY}`,
        `ENDPOINT: ${process.env.MARVEL_ENDPOINT}`
      );

    const chatSession = await admin
      .firestore()
      .collection('chatSessions')
      .doc(id)
      .get();

    if (!chatSession.exists) {
      logger.log('Chat session not found: ', id);

      return { status: 'error', data: 'Chat session not found'};
    }

    const { user, type, messages } = chatSession.data();

    let truncatedMessages = messages;

    // Check if messages length exceeds 50, if so, truncate
    if (messages.length > 100) {
      truncatedMessages = messages.slice(messages.length - 65);
    }

    // Update message structure here
    const updatedMessages = truncatedMessages.concat([
      {
        ...message,
        timestamp: Timestamp.fromMillis(Date.now()), // ISO 8601 format string
      },
    ]);

    await chatSession.ref.update({ messages: updatedMessages });

    // Construct payload for the marvelCommunicator
    const marvelPayload = {
      messages: updatedMessages,
      type,
      user,
    };

    const response = await marvelCommunicator({
      data: marvelPayload,
    });

    DEBUG && logger.log('marvelCommunicator response:', response.data);

    // Process response and update Firestore
    const updatedResponseMessages = updatedMessages.concat(
      response.data?.data?.map((msg) => ({
        ...msg,
        timestamp: Timestamp.fromMillis(Date.now()), // ensure consistent timestamp format
      }))
    );

    // Update the chat session with the updated response messages and the current timestamp.
    await chatSession.ref.update({
      messages: updatedResponseMessages, // Update the messages array with the new messages and timestamps
      updatedAt: Timestamp.fromMillis(Date.now()), // Set the updatedAt timestamp to the current time
    });

    if (DEBUG) {
      logger.log(
        'Updated chat session: ',
        (await chatSession.ref.get()).data()
      );
    }

    return { status: 'success' };
  } catch (error) {
    DEBUG && logger.log('Chat error:', error);

    return { status: 'error', data: error.message};
  }
};

/**
 * This creates a chat session for a user.
 * If the chat session already exists, it will return the existing chat session.
 * Otherwise, it will create a new chat session and send the first message.
 *
 * @function createChatSession
 * @param {Object} props - The properties passed to the function.
 * @param {Object} props.data - The data object containing the user, challenge, message, and botType.
 * @param {Object} props.data.user - The user object.
 * @param {Object} props.data.message - The message object.
 * @param {Object} props.data.type - The bot type.
 *
 * @return {Promise<Object>} - A promise that resolves to an object containing the status and data of the chat sessions.
 * @throws {HttpsError} Throws an error if there is an internal error.
 */
const createChatSession = async (props) => {
  try {
    DEBUG && logger.log('Communicator started, data:', props);

    const { user, message, type, systemMessage } = props;

    if (!user || !message || !type) {
      logger.log('Missing required fields', props.data);

      return {
        status: 'error',
        data: 'Missing required fields',
      };
    }

    /**
     * If a system message is provided, sets the timestamp of the system message to the current time.
     * This is done to ensure that the timestamp of the system message is in the same format as the timestamp of user messages.
     *
     * @param {Object} systemMessage - The system message object, or null if no system message is provided.
     */
    if (systemMessage != null) {
      // Set the timestamp of the system message to the current time
      systemMessage.timestamp = Timestamp.fromMillis(Date.now());
    }

    const initialMessage = {
      ...message,
      timestamp: Timestamp.fromMillis(Date.now()),
    };

    // Create new chat session if it doesn't exist
    const chatSessionRef = await admin
      .firestore()
      .collection('chatSessions')
      .add({
        messages:
          systemMessage == null
            ? [initialMessage]
            : [systemMessage, initialMessage],
        user,
        type,
        createdAt: Timestamp.fromMillis(Date.now()),
        updatedAt: Timestamp.fromMillis(Date.now()),
      });

    // Send trigger message to Marvel AI
    const response = await marvelCommunicator({
      data: {
        messages:
          systemMessage == null
            ? [initialMessage]
            : [systemMessage, initialMessage],
        user,
        type,
      },
    });

    DEBUG && logger.log('response: ', response?.data, 'type', typeof response);

    const { messages } = (await chatSessionRef.get()).data();
    DEBUG && logger.log('updated messages: ', messages);

    // Add response to chat session
    const updatedResponseMessages = messages.concat(
      Array.isArray(response.data?.data)
        ? response.data?.data?.map((message) => ({
            ...message,
            timestamp: Timestamp.fromMillis(Date.now()),
          }))
        : [
            {
              ...response.data?.data,
              timestamp: Timestamp.fromMillis(Date.now()),
            },
          ]
    );

    await chatSessionRef.update({
      messages: updatedResponseMessages,
      id: chatSessionRef.id,
    });

    const updatedChatSession = await chatSessionRef.get();
    DEBUG && logger.log('Updated chat session: ', updatedChatSession.data());

    /**
     * Creates a new chat session object by extracting relevant data from the Firestore document. Converts Firestore timestamps to ISO strings and includes the document ID.
     * @param {Object} updatedChatSession The Firestore document containing the chat session data.
     * @return {Object} The new chat session object.
     */
    const createdChatSession = {
      ...updatedChatSession.data(), // Extract relevant data from Firestore document
      // Convert Firestore timestamps to ISO strings
      createdAt: updatedChatSession.data().createdAt.toDate().toISOString(),
      updatedAt: updatedChatSession.data().updatedAt.toDate().toISOString(),
      id: updatedChatSession.id, // Include the document ID
    };

    DEBUG && logger.log('Created chat session: ', createdChatSession);

    logger.log('Successfully communicated');
    return {
      status: 'created',
      data: createdChatSession,
    };
  } catch (error) {
    logger.error(error);
    return {
      status: 'error',
      data: error.message,
    };
  }
};

module.exports = {
  chat,
  createChatSession,
};

The error handling was changed form using HttpsError to using an object that notifes the frontend if an error occured, due to the fact that the API structure had been changed. Another change is that the function is not served as an Firebase onCall event due to the fact that the calls are managed by Express.




signUp.js (ReactJS)

import { createUserWithEmailAndPassword } from 'firebase/auth';

import { AUTH_ERROR_MESSAGES } from '@/libs/constants/auth';

import { auth, functions } from '@/libs/redux/store';

import { sendVerification } from './manageUser';

import axios from "axios"

import functions_urls from "../../constants/google_functions_url_selector"

const signUp = async (email, password, fullName) => {
  try {

    await createUserWithEmailAndPassword(auth, email, password)
      .then(async (auth_response) => {
        try {

          await sendVerification(auth_response.user);

          console.log(functions_urls().signUpUser);
          
          const response = await axios.get(`${functions_urls().signUpUser}`, {
            params: {
              email: email, 
              fullName: fullName,
              uid: auth_response.user.uid
            }
          });

          if (response.status == 200 && response.data.status == 'success') {
            return auth_response.user;
          }
          else {
            console.log(response.message);
            throw new Error(response.message);
          }

        } catch (error) {
          console.log(error);
          throw new Error(AUTH_ERROR_MESSAGES[error?.code]);
        }
      });

  } catch (error) {
    throw new Error(AUTH_ERROR_MESSAGES[error?.code]);
  }
};

export { signUp };

The function backend is now called using Axios and by passing URLs for the API functions using a function utility called functions_urls() that returns the functions URLs for either the development environment or production environment respectively.




createChatSession.js (ReactJS)

import {
  setError,
  setStreaming,
  setTyping,
} from '@/libs/redux/slices/chatSlice';
import axios from "axios"
import functions_urls from '@/libs/constants/google_functions_url_selector';

/**
 * Creates a chat session.
 *
 * @param {Object} payload - The payload for creating the chat session.
 * @param {function} dispatch - The dispatch function for managing state.
 * @return {Object} - An object containing a status and data containing the session.
 */
const createChatSession = async (payload, dispatch) => {
  try {
    const response = await axios.post(functions_urls().createChatSession, payload);

    if(response.status == 200 && response.data.status !== 'error'){
      return response.data;
    }
    else{
      throw new Error(response.data.data);
    }
  } catch (err) {
    dispatch(setError('Error! Couldn\u0027t send message'));
    dispatch(setStreaming(false));
    dispatch(setTyping(false));
    setTimeout(() => {
      dispatch(setError(null));
    }, 3000);
    throw new Error('Error could not send message');
  }
};

export default createChatSession;

The function backend is now called using Axios and by passing URLs for the API functions using a function utility called functions_urls() that returns the functions URLs for either the development environment or production environment respectively.




sendMessage.js (ReactJS)

import {
  setError,
  setStreaming,
  setTyping,
} from '@/libs/redux/slices/chatSlice';
import axios from "axios";
import functions_urls from '@/libs/constants/google_functions_url_selector';

const sendMessage = async (payload, dispatch) => {
  try {
    const response = await axios.post(functions_urls().chat, payload);
    
    if(response.status == 200 && response.data.status !== 'error'){
      return response.data;
    }
    else{
      throw new Error(response.data.data);
    }
  } catch (err) {
    dispatch(setError('Error! Couldn\u0027t send message'));
    dispatch(setStreaming(false));
    dispatch(setTyping(false));
    setTimeout(() => {
      dispatch(setError(null));
    }, 3000);

    // eslint-disable-next-line no-console
    console.error('Error could not send message', err);

    // eslint-disable-next-line no-alert
    alert('Error could not send message');
    return null;
  }
};

export default sendMessage;

The function backend is now called using Axios and by passing URLs for the API functions using a function utility called functions_urls() that returns the functions URLs for either the development environment or production environment respectively.




sample.env

NEXT_PUBLIC_FIREBASE_CLIENT_API_KEY=AIzaSyAw1OGrftrzGP1PaGdJaAd494FZBb1eSQg
NEXT_PUBLIC_FIREBASE_CLIENT_AUTH_DOMAIN=kai-platform-sandbox.firebaseapp.com
NEXT_PUBLIC_FIREBASE_CLIENT_PROJECT_ID=kai-platform-sandbox
NEXT_PUBLIC_FIREBASE_CLIENT_STORAGE_BUCKET=kai-platform-sandbox.appspot.com
NEXT_PUBLIC_FIREBASE_CLIENT_MESSAGING_SENDER_ID=297484662473
NEXT_PUBLIC_FIREBASE_CLIENT_APP_ID=1:297484662473:web:b85bca5ebf0ea9d1bcbf18
NEXT_PUBLIC_FIREBASE_MEASUREMENT_ID=G-44PQQ6WJG4
NEXT_PUBLIC_GOOGLE_CLOUD_FUNCTIONS_URL=REPLACE_WITH_DEPLOYED_CLOUD_FUNCTIONS_URL
NEXT_PUBLIC_FIREBASE_DEPLOYMENT_MODE=dev
NEXT_PUBLIC_MARVEL_ENDPOINT=https://marvel-ai-backend-sandbox-297484662473.us-east1.run.app/
MARVEL_API_KEY=dev 
MARVEL_ENDPOINT=https://marvel-ai-backend-sandbox-297484662473.us-east1.run.app/

The sample.env file was modified to include two fields and these are: NEXT_PUBLIC_GOOGLE_CLOUD_FUNCTIONS_URL and NEXT_PUBLIC_FIREBASE_DEPLOYMENT_MODE. NEXT_PUBLIC_GOOGLE_CLOUD_FUNCTIONS_URL variable stores the URL of the cloud function. NEXT_PUBLIC_FIREBASE_DEPLOYMENT_MODE variable stores the information that specifies if the app is deployed in production or development environment using the values 'dev' and 'prod', respectively.




functions.js (ReactJS)

const EMULATOR_HOST = 'localhost';

export const EMULATOR_FUNCTIONS ={
    signUpUser:`http://${EMULATOR_HOST}:5001/${process.env.NEXT_PUBLIC_FIREBASE_CLIENT_PROJECT_ID}/us-central1/functions/signUpUser/`,
    chat:`http://${EMULATOR_HOST}:5001/${process.env.NEXT_PUBLIC_FIREBASE_CLIENT_PROJECT_ID}/us-central1/functions/chat/`,
    createChatSession:`http://${EMULATOR_HOST}:5001/${process.env.NEXT_PUBLIC_FIREBASE_CLIENT_PROJECT_ID}/us-central1/functions/createChatSession/`
}

export const PRODUCTION_FUNCTIONS ={
    signUpUser:`${process.env.NEXT_PUBLIC_GOOGLE_CLOUD_FUNCTIONS_URL}/signUpUser/`,
    chat:`${process.env.NEXT_PUBLIC_GOOGLE_CLOUD_FUNCTIONS_URL}/chat/`,
    createChatSession:`${process.env.NEXT_PUBLIC_GOOGLE_CLOUD_FUNCTIONS_URL}/createChatSession/`
}

The file functions.js is an utility file that stores constants that contain the URLs for the Google Functions of the application's backend. These URLs are divided into 2 categories, production URLs and development URLs.




google_functions_url_selector.js (ReactJS)

import { EMULATOR_FUNCTIONS, PRODUCTION_FUNCTIONS } from "./functions"

const functions_urls = ()=> process.env.NEXT_PUBLIC_FIREBASE_DEPLOYMENT_MODE == "dev" ? EMULATOR_FUNCTIONS : PRODUCTION_FUNCTIONS;
export default functions_urls;

The file google_functions_url_selector.js contains an utility function that through a ternary operator is determining if the application is development or production environment by verifying the value of the environment variable that is holding this value and returns the collection of URLs that are holding the Google Functions for the production environment or development environment respectively.




How to Test

The test of this feature can be viewed on this Loom link.

Unit Tests

The unit test involved incremental changes and output validation using conditional statements in relation with different situations.

Documentation Updates

Indicate whether documentation needs to be updated due to this PR.

  • Yes

If yes, describe what documentation updates are needed and link to the relevant documentation.

Checklist

  • I have modified the sample.env file to add a functionality and as a result the contributors must be informed that the NEXT_PUBLIC_GOOGLE_CLOUD_FUNCTIONS_URL variable must be assigned the value of the production firebase function URL and the NEXT_PUBLIC_FIREBASE_DEPLOYMENT_MODE variable can have only 2 values ('dev' and 'prod' ) and these values are used to identify the deployment environment.

Additional Information

Add any other information that might be useful for the reviewers.

CSharpTeoMan911 and others added 23 commits December 18, 2024 14:18
…LOPERS TO NAME THEIR FIREBASE PROJECT INDEPENDENTLY
…NMENTS

* MODIFIED THE NodeJS API STRUCTURE TO FACILITATE THE FIRESTORE ADMIN SIGN-IN IN BOTH DEVELOPEMNT ENVIRONMENTS AND PRODUCTION ENVIRONMENTS
…RUCTURE

* UPDATED THE 'sample.dev' FILE TO INCLUDE THE `NEXT_PUBLIC_FIREBASE_DEPLOYMENT_MODE` WHICH POINTS IN WHICH TYPE OF DEPLOYMENT MODE THE FIREBASE APP IS DEPLOYED (development OR production) USING THE VALUES 'prod' or 'dev'

* FINISHED THE SIGN UP FUNCTION INTEGRATION (TESTED IN CONCATENATION WITH THE MARVEL AI BACKEND)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

1 participant