Implementing a Web Frontend Interview System: Part 2 - Coding

Coding

Phew, developing a product from scratch is really complex, and the preliminary work took a very long time. Finally, we can start coding now. Please combine the following content with the code: Interview System

Setting up the Project

  1. Before starting to write code, we need to install MySQL and create a database named "interview".

  2. Use the nest-cli tool to create the basic structure of the NestJS project. Then, introduce sequelize-cli to manage data structure changes using migrations. Create the "user" table and "interview" table.

  3. Add dependencies such as sequelize, ws, y-websocket, passport, passport-jwt, passport-local, yjs, etc., to the project.

  4. In the main module of the project, import SequelizeModule and configure the database connection information.

// Configure the database connection information
SequelizeModule.forRoot({
  dialect: 'mysql', // Database type
  host: 'localhost', // Address
  port: 3306, // Port
  username: 'root', // Username
  password: '******', // Password
  database: 'interview', // Database
  models: [User, Interview], // Data models
}),
  1. Create a "client" directory in the project's root directory. Initialize a React frontend project using create-react-app. Add dependencies such as mobx, mobx-react, react-router, antd, yjs, axios, console-feed, monaco-editor, y-monaco, y-websocket, etc. Modify the webpack configuration file to ensure that the frontend code is bundled into the backend's static file directory. Also, configure the proxy settings for webpack-dev-server to proxy frontend development requests to the backend's port.

User Registration, Login, and Verification Module

This module mainly includes the implementation of registration, login, and verification functionalities. Passport is primarily used as the authentication tool during the implementation.

1. Backend Implementation of User Registration

Develop the user module, which consists of user.model, user.service, and user.controller.

(1) user.model represents the model of the user table, used for service operations on the user table in the database.

(2) user.service includes methods for various operations on the user data table. First, import user.model and add the register method to add new users. The process involves checking if there is a user with the same name in the user table. If yes, an error is thrown; otherwise, the password is encrypted using the uuid library and saved in the user table.

/**
 * Register user method
 * @param createUser User information
 * @returns Promise
 */
async register(createUser: { name: string; password: string }) {

  const { name, password } = createUser;

  // Check if there is a user with the same name in the user table, throw an error if exists
  const existUser = await this.userModel.findOne({
    where: { name },
  });
  if (existUser) {
    throw new HttpException('Username already exists', HttpStatus.BAD_REQUEST);
  }

  // Encrypt the password using the uuid library and save it in the user table
  return await this.userModel.create({
    ...createUser,
    id: v4(),
    password: encrypt(password),
  });
}

(3) The user.controller is used to handle various requests. First, add the @Controller('api/user') decorator to accept requests with /api/user. Then, add the register method and the @Post('register') decorator to handle POST requests with the path /api/user/register. Next, call the register method from user.service to save the user from the request parameters. This way, the user registration functionality is implemented.

/**
 * Handle registration requests
 * @param request Request object
 * @returns Promise<User> User information
 */
@SkipAuth()
@Post('register')
async register(@Body() request: any): Promise<User> {
  const res = await this.userService.register({
    name: request.name,
    password: request.password,
  });
  return res;
}

2. Backend Implementation of User Login

Develop the auth module, which includes auth.service, local.strategy, and local-authguard.

(1) First, implement the AuthService class. The AuthService injects the JwtService from @nestjs/jwt and has two methods. The validateUser method is used to check if the username and password match those saved in the database. The login method calls the sign method of the jwtService instance to encrypt the username and user ID information, generating a jwt token and returning it.

/**
 * Method to check if the username and password match those saved in the database
 * @param name Username
 * @param pass Password
 * @returns Promise<any>
 */
async validateUser(name: string, pass: string): Promise<any> {
  const user = await this.usersService.findOneByname(name);

  // Check if the passwords match
  if (user && user.password === encrypt(pass)) {
    const { password, ...result } = user.toJSON();
    return result;
  }

  return null;
}

// Call the jwtService's sign method to encrypt the username and user ID information, generating a jwt token and returning it
/**
 * Login method
 * @param user User information
 * @returns Object
 */
async login(user: any) {
  const payload = { username: user.name, sub: user.id };
  return {
    access_token: this.jwtService.sign(payload),
  };
}

(2) The second step is to implement the LocalStrategy class. The LocalStrategy class extends the PassportStrategy class to implement the local authentication strategy for passport. It mainly includes a validate method, which calls the validateUser method from AuthService to validate if the username and password are legitimate. If not, it throws an authentication error; if legitimate, it returns user information.

/**
 * Method to call the validateUser method from AuthService to validate if the username and password are legitimate.
 * @param name Username
 * @param password Password
 * @returns Promise<any>
 */
async validate(name: string, password: string): Promise<any> {
  const user = await this.authService.validateUser(name, password);

  // If not legitimate, throw an authentication error
  if (!user) {
    throw new UnauthorizedException();
  }

  // If legitimate, return user information
  return user;
}

(3) Implement the LocalAuthGuard class, which inherits from AuthGuard('local'). LocalAuthGuard will invoke the corresponding methods of localStrategy according to the default logic to validate the username and password in the parameters. After successful validation, it injects the user information into the request object for controller use.

(4) Implement the login method in user.controller. Add the @UseGuards(LocalAuthGuard) decorator to accept login requests and implement the login functionality.

/**
 * Handle login requests
 * @param req Request object
 * @returns token Return token
 */
@SkipAuth()
@UseGuards(LocalAuthGuard)
@Post('login')
async login(@Request() req) {
  const token = this.authService.login(req.user);
  return token;
}

3. Backend Implementation of Validation

In the auth module, add the files jwt-auth.guard.ts and jwt.strategy.ts to implement the JwtStrategy class and JwtAuthGuard class, respectively.

(1) The JwtStrategy class extends the PassportStrategy class to implement the jwt authentication strategy for passport. In its constructor, configurations like jwtFromRequest are passed to super, and jwtStrategy automatically retrieves and parses the jwt token from the request into user information. The validate method directly returns user information.

/**
 * Jwt authentication strategy
 */
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {

  // Pass configurations like jwtFromRequest to super in its constructor,
  // jwtStrategy automatically retrieves and parses the jwt token from the request into user information
  constructor() {
    super({
      jwtFromRequest: ExtractJwt.fromHeader('authorization'), // Token is retrieved from the 'authorization' request header
      ignoreExpiration: false, // Token can expire
      secretOrKey: jwtConstants.secret, // Secret key
    });
  }

  // Validate method directly returns user information
  async validate(payload: any) {
    return { userId: payload.sub, username: payload.username };
  }
}

(2) Implement the JwtAuthGuard class, which inherits from AuthGuard('jwt'). In it, implement the canActivate method: first, check if the current request is a public request that does not require authentication; if so, pass directly. Otherwise, continue to check if the current request is a WebSocket communication and if the token is valid; finally, call the canActivate method of the parent class to follow the default validation logic of jwtStrategy.

/**
 * Jwt authentication guard
 */
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {

  // Check if the current request is a public request that does not require authentication
  const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
    context.getHandler(),
    context.getClass(),
  ]);

  if (isPublic) return true;

  // Check if the current request is WebSocket communication
  if (context.contextType === 'ws') {
    // If it is, parse the token in the data, and if valid, pass
    const res = jwt.verify(context.args[1].token, jwtConstants.secret);
    if (res) {
      context.args[1].user = {
        username: res.username,
        userid: res.sub,
      };
      return true;
    }
  }

  return super.canActivate(context);
}

(3) Register JwtAuthGuard as a global guard in the authModule, so all paths except those marked as public requests will go through the verification of JwtAuthGuard. At this point, the backend validation functionality is completed.

@Module({
  providers: [
    {
      provide: APP_GUARD, // Register as a global guard
      useClass: JwtAuthGuard,
    },
  ],
  exports: [AuthService],
})
export class AuthModule {}

4. Implement Frontend Pages and Logic.

(1) Start by implementing the login page, adding username, password form fields, and login and register buttons. Upon button click, validate the input; if valid, make requests to the /api/user/register and /api/user/login endpoints for registration or login, respectively.

(2) Upon successful login request, store the token in localStorage.

(3) Upon successful registration, receive user information and then call the login method to log in.

(4) Refactor the unified request method to include the token from localStorage in the request header for every request, achieving login validation. If a request returns a 401 status code, redirect to the login page.

(5) Create a new profile module and implement the Profile class. In Profile, implement the queryUser method to fetch the current user's information and save it to the user property.

/**
 * User information model, used for handling user-related logic
 */
class Profile {

  // Save the current user information
  @observable.ref
  user: IUser | null = null;

  // Get user information
  async queryUser() {
    this.setLoading(true);
    const res = await getProfile();
    this.setUser(res);
    this.setLoading(false);
  }
}

(6) Call the queryUser method of the profile at the main entry point of the frontend program to obtain the current user's information. This ensures that entering the program from any entry point redirects to the login page if not logged in, and if logged in, retrieves the current user's information, providing the capability for other modules to use.

(II) Implementation of Interview Module

The implementation of the interview module is relatively complex and is divided into four modules: Interview Management, Code Editing and Synchronization, Video Calling, and Text Communication.

1. Interview Management Module

This module mainly includes the creation, joining, rejoining, and saving of interview information.

(1) Backend implementation of the interview module, including interview.model, interview.service, interview.controller.

interview.model represents the model of the interview table, used for service operations on the database table. interview.service includes methods for various operations on the interview data table, such as CRUD operations on the interview table. interview.controller is used to handle various interview requests. It is decorated with @Controller('api/interview') to accept requests at /api/interview, and methods are added to call the service to handle CRUD requests for interviews.

(2) The interview module only deals with HTTP requests related to interviews. Since the interview process is a highly interactive process, the system uses websockets to handle most communication tasks during the interview. Create an events module in the backend to handle websocket communication.

① Create the interviewManager.ts file. First, add the ConnectUser class, abstracting users joining the interview, including id, name, socket properties, and the send method to send information to that user.

Then, add the Interview class to abstract an interview, including communication between the two sides and various methods for creating interviews.

// Request to join the interview
requestInter(socket: Socket, name: string, id: string, msg: string) {
  const user = new ConnectUser(name, id, socket);
  this.waitingUser.push(user);

  this.interviewer.socket.send(
    JSON.stringify({
      event: 'request-inter',
      data: {
        name,
        msg,
        id,
      },
    }),
  );
}

// Forward information between the two sides of the interview
retransmission(originUserId: string, type: string, data: any);
retransmission(originSocket: Socket, type: string, data: any);
retransmission(query: any, type: string, data: any) {
  // Based on userId or socket, get the opposite User instance and then send the information
  if (typeof query === 'string') {
    const my = this.getRoleById(query);
    if (my) {
      const opposite = this.getOppositeById(query);
      opposite.send(type, data);
    }
  } else {
    const my = this.getRoleBySocket(query);
    if (my) {
      const opposite = this.getOppositeBySocket(query);
      opposite.send(type, data);
    }
  }
}

Finally, add the InterviewManager class to manage all interviews, mainly implemented as follows:

// Class to manage all interviews
export class InterviewManager {

  // All interviews mapped by interview ID
  interviews = new Map<string, Interview>();

  // All interviews mapped by participant socket
  sockInterviewMap = new Map<Socket, Interview>();

  server: Socket = null;

  // Create an interview
  createInterview(data: {
    id: string; // Interview ID
    socket: Socket; // Creator's socket
    user: { username: string; userid: string }; // Creator's information
  }) {
    const interviewer = new ConnectUser(
      data.user.username,
      data.user.userid,
      data.socket,
    );
    const interview = new Interview(data.id, interviewer, this.server);
    this.interviews.set(interview.id, interview);
    this.interviews.set(data.socket, interview);
  }
}

② Create the events.gateway.ts file and add the EventsGateway class to handle websocket communication. Include the onEvent method to handle information of event type received via websocket, specifically related to interview creation.

When a request to create an interview is received, call the interview module's service to create an interview in the interview table. Simultaneously, call the createInterview method of the interviewManager to create an instance of the Interview type with the requester as the interviewer and save it in the interviewManager.

When a request to join an interview is received, first call the interview.service to check if this interview exists. If it doesn't exist, an error is returned. If it exists, retrieve this interview from the interviewManager and name it currentInterview. Then, check if the requester is joining as an interviewer. If so, check if the interviewer for this interview in the database is the requester. If not, return an error message. If yes, check for the existence of currentInterview. If it exists, reset the interviewer for the interview; if not, create a new interview. If the requester is joining as an interviewee and there are no interviewees in this interview, execute the currentInterview.requestInter method, sending a request to the interviewer to join the interview and wait for confirmation. If the interviewer declines, return the information to the interviewee. If the interviewer agrees, create the interview and add the requester as an interviewee.

// Handle event type information related to interview creation received via websocket
@UseGuards(JwtAuthGuard)
@SubscribeMessage('events')
async onEvent(client: Socket, data: any): Promise<any> {
  switch (type) {
    // Create interview request
    case 'create-interview':
      const res = await this.interviewService.createOne(data.user.userid);
      manager.createInterview({
        socket: client,
        user: data.user,
        id: res.id,
      });
      return { event: 'create-interview-success', data: res };

    // Join interview request
    case 'inter-interview':
      const { role, id, user, msg } = data;
      const inter = await this.interviewService.findOne(id);
      if (!inter) {
        return {
          event: 'inter-interview-fail',
          data: {
            msg: 'no interview',
          },
        };
      }

      const interview = manager.getInterviewById(id);
      // Check the role of the requester in the interview, when it is an interviewer
      if (role === 'interviewer') {
        // Join the interview when it exists
        if (interview) {
          interview.addNewInterviewer(user.username, user.userid, client);
          manager.saveSocketInterview(client, interview);
          return {
            event: 'inter-interview-success',
            data: {
              interviewId: interview.id,
            },
          };
          // If the interview does not exist, create the interview
        } else {
          manager.createInterview({
            id: inter.id,
            socket: client,
            user,
          });
          return {
            event: 'inter-interview-success',
          };
        }
      } else if (role === 'interviewee') {
        // When the requester is an interviewee, and the interview exists, call the interview's requestInter method
        if (
          interview &&
          inter &&
          (!inter.intervieweeId || inter.intervieweeId === user.userid)
        ) {
          interview.requestInter(client, user.username, user.userid, msg);
        }
      }
  }
}

(3) The frontend part of the interview management module mainly involves creating the interview module. Create the Interview class, implement the initSocket method to create a websocket instance, the send method to abstract the common logic of using websocket to send information to the server, and then implement methods for creating interview requests, joining interview requests, etc. Add buttons and form to the user's profile page for creating an interview, joining an interview. Listen for button click events and call the respective methods of the interview to complete the corresponding functionality.

2. Implementation of Video Interview Module

The video interview module primarily utilizes webRTC technology. To facilitate the use of webRTC, a turn service is created using the node-turn library on the server.

(1) On the server, add the onWebrtc method to the events.gateway to listen for all communications related to webRTC. Find the corresponding interview from the interviewManager, then pass it to the retransmission method of the Interview class to forward the request to the other party in the interview.

// Listen for all communications related to webRTC, find the corresponding interview from interviewManager,
// then pass it to the retransmission method of the Interview class to forward the request to the other party in the interview.
@UseGuards(JwtAuthGuard)
@SubscribeMessage('webrtc')
async onWebrtc(data: any): Promise<any> {
  const { scope, user } = data;
  // Find the interview where the requester is located and call the interview's method to forward the data
  if (scope) {
    const interview2 = manager.getInterviewById(data.scope);
    interview2.retransmission(user.userid, 'webrtc', data.data);
  }
}

(2) On the frontend, create the Webrtc class within the interview module. Instantiate the Webrtc class by passing the websocket instance stored in the Interview class, used for exchanging various information required by the webRTC technology with the other party in the interview.

Firstly, implement the init method to initialize the RTCPeerConnection instance 'connection' and set the 'icecandidate' event listener of the connection to transmit the icecandidate to the other party in the interview:

// Initialize the RTCPeerConnection instance 'connection'
init() {
  this.connection = new RTCPeerConnection({
    iceServers: [
      {
        urls: 'turn:43.142.118.202:3478', // turn service
        username: 'ymrdf', // service username
        credential: '*****', // service password
      },
    ],
  });

  // Listen for the 'icecandidate' event and transmit the icecandidate to the other party in the interview
  this.connection.addEventListener('icecandidate', (event) => {
    if (event.candidate) {
      if (event.candidate.candidate === '') {
        return;
      }
      this.send({
        iceCandidate: event.candidate,
      });
    }
  });
}

Next, listen for the 'track' event of the connection and set to display the incoming video stream on the video tag on the page:

// Listen for the 'track' event of the connection and set to display the incoming video stream on the video tag on the page
this.connection.addEventListener('track', async (event) => {
  const [remoteStream] = event.streams;
  if (remoteVideo) {
    remoteVideo.srcObject = remoteStream;
  }
});

Then, listen for webrtc-related information coming from the websocket. If it is an 'offer' message, save the remote offer and return an 'answer' to the other party. If it is an 'icecandidate' message, set the remote icecandidate:

// Listen for webrtc-related information coming from the websocket
this.socket!.addEventListener('message', async (ev: MessageEvent) => {
  const message = JSON.parse(ev.data);

  const { event, data } = message;

  if (event !== 'webrtc') return;

  // If it is an 'answer' message, save the remote answer
  if (data.answer) {
    const remoteDesc = new RTCSessionDescription(data.answer);
    await this.connection!.setRemoteDescription(remoteDesc);
  }

  // If it is an 'offer' message, save the remote offer and return an 'answer' to the other party
  if (data.offer) {
    this.connection!.setRemoteDescription(
      new RTCSessionDescription(data.offer),
    );
    const answer = await this.connection!.createAnswer();
    await this.connection!.setLocalDescription(answer);
    this.send({ answer: answer });
  }

  // If it is an 'icecandidate' message, set the remote icecandidate
  if (data.iceCandidate) {
    try {
      await this.connection!.addIceCandidate(data.iceCandidate);
    } catch (e) {
      console.error('Error adding received ice candidate', e);
    }
  }
});

Additionally, the Webrtc class adds a callRemote method to create an 'offer' message and send it to the other party to initiate a connection request. A video tag and a button to initiate the video are added to the page, calling the respective methods of the Webrtc class.

3. Implementation of Code Editing, Execution, and Online Collaboration Module

(1) On the frontend, create a new Editor component, add a div tag with the id 'monaco-container', import the monaco-editor library, and create an editor instance in the useEffect function of the component to render the editor on the page. Listen for the content change event of the editor and save the changed input content in the state.

// Create an editor instance and render it on the page
const editor = monaco.editor.create(
  document.getElementById('monaco-container')!,
  {
    value: '',
    language: 'javascript',
    theme: 'vs-dark',
  },
);

// Listen for the content change event of the editor and save the changed input content in the state
editor.onDidChangeModelContent(() => {
  setValue(editor.getValue());
});

(2) Import the yjs and y-monaco repositories. Initialize a Y.Doc document and a WebsocketProvider instance in the useEffect function. Connect the document, WebsocketProvider instance, and editor instance through MonacoBinding to enable document collaboration.

// Initialize Y.Doc document and WebsocketProvider instance
const doc = new Y.Doc();
const type = doc.getText('monaco');
const wsProvider = new WebsocketProvider(
  'ws://192.168.10.217:3000/',
  `room?${interviewId}`,
  doc,
);

// Connect the document, WebsocketProvider instance, and editor instance through MonacoBinding for document collaboration
const monacoBinding = new MonacoBinding(
  type,
  editor.getModel()!,
  new Set([editor]),
  wsProvider.awareness,
);

On the backend, create a CollaborateGateway in the events module. After initializing the WebSocket in the gateway, use the setupWSConnection function from the y-websocket library to handle document collaboration.

// After initializing the WebSocket, handle document collaboration
afterInit(server: Server) {
  server.on('connection', (client: Socket, request: any) => {
    const docName = request.url.split('?')[1];
    if (!docName) return;

    // Use the setupWSConnection function from y-websocket library to handle document collaboration logic
    wutils.setupWSConnection(client, request, { docName, gc: true });
  });
}

(3) Create a Console component that receives a 'code' parameter as the code to execute and display the result. Implement the 'run' method, which first overrides various print methods in the console to store the result in the 'logs' variable. Then, use the eval function to execute the 'code' as JavaScript code. After execution, restore the console methods. Use the Console component from the console-feed library to display the contents of 'logs'. Add a 'run' button, listen for its click event, and execute the 'run' method to complete the code execution and result printing functionality.

// Code execution method
const run = () => {
  // Override various print methods in the console to store the result in the 'logs' variable
  Hook(
    window.console,
    (log) => setLogs((currLogs) => [...currLogs, log]),
    false,
  );

  // Use eval to execute the 'code' as JavaScript code
  eval(code);

  // After execution, restore the console methods
  Unhook(window.console);
};

4. Text Communication Module Implementation

(1) On the frontend, add a ChatManager class under the interview module. Implement the sendMsg method, which sends data to the server through the socket instantiated by the interview class. Also, listen for the 'message' event of the socket. When the data type is 'chat', save the data to the 'messages' property for display.

// Send data to the server through the interview class's instantiated socket
class ChatManager {
  sendMsg(data) {
    // ... (send data through the interview class's socket)
  }

  // Listen for the 'message' event of the socket
  // When the data type is 'chat', save the data to the 'messages' property
  listenForMessages() {
    // ... (listen for 'message' event and handle 'chat' data)
  }
}

(2) Create a Chat component to display the messages from the chatManager. Include a textarea and a send button. Clicking the button calls the chatManager's sendMsg method to send the user's input from the textarea to the server.

// Display messages and handle user input
const Chat = () => {
  // ... (render messages and user input elements)
};

(3) On the server, in the events module's events.gateway, add an onChat method to forward all data of type 'chat' and complete the text communication module.

// Forward all data of type 'chat'
@UseGuards(JwtAuthGuard)
@SubscribeMessage('chat')
async onChat(data: any): Promise<any> {
  const { scope, user } = data;

  // Find the interview the requester is in and call the interview method to forward data
  if (scope) {
    const interview2 = manager.getInterviewById(data.scope);
    interview2.retransmission(user.userid, 'chat', data.data);
  }
}

Summary

The difficulty of implementing a system from scratch exceeded my expectations. Up to this point, the system has only achieved basic functionality, and there are numerous bugs after simple testing. The UI design is also incomplete. Details will have to be addressed slowly when time permits.

The code repository for the project is available at: Interview System . Interested individuals are welcome to submit pull requests, collaborating to enhance the system specifically designed for front-end development engineer interviews.