DEV Community

Cover image for Build an Attendance Tracker for Virtual Classrooms with Stream
Emmanuel Aiyenigba
Emmanuel Aiyenigba

Posted on

Build an Attendance Tracker for Virtual Classrooms with Stream

Introduction

Virtual meetings are now a core part of education, business, and everyday collaboration. As more interactions move online, organizations and individuals need reliable ways to track what goes on in meetings, whether for analytics, attendance, or reporting. 

Take a virtual classroom, for example. Teachers need to know how consistently students attend and engage in lessons. While several existing extensions on platforms like Google Meet and Zoom automate this process, building your own attendance tracker gives you control over your meeting data. It allows you to customize a workflow that suits your needs. This is especially powerful when integrated directly into your own video platform.

In this tutorial, we will build an automated attendance tracker for your own video classroom using the Stream Video SDK.

Here’s What We’re Building

Our automated attendance tracker will:

  • Track when participants join or leave the class.
  • Calculate the attendance duration for each user at the end of the class session.

  • Generate a detailed AI summary of the class. The summary will include: overall attendance statistics, engagement level based on duration, notable patterns (e.g, early leavers and participants who joined multiple times), and recommendations for improving attendance. 

  • Send an email containing the PDF of the class attendance report to the teacher at the end of the class.

At the end of the tutorial, here is what our attendance report will look like:

attendance tracker report

You can reference the  GitHub repository for the full code.

Prerequisites

To follow along with this tutorial, you’ll need:

  • A free Stream account 

  • Some knowledge of React and Node.js

  • Supabase for storing attendance data 

  • OpenAI for generating a detailed AI summary of the class

We will use Resend for email and PDFkit for PDF generation.

Setting Up Our Application

Now, let’s create our application, set up the Stream environment, and build our classroom. 

Creating Our Express Backend

First,let us bootstrap our Express backend

npx express-generator class-backend
Enter fullscreen mode Exit fullscreen mode

Then, you’ll install the Stream Node SDK:

npm install @stream-io/node-sdk
Enter fullscreen mode Exit fullscreen mode

Let’s also install all the other dependencies (Supabase, OpenAI, PDFkit, and Resend) that we’ll need to make our app fully functional:

npm install @supabase/supabase-js openai pdfkit resend
Enter fullscreen mode Exit fullscreen mode

We now have all the necessary dependencies on the backend. Now, let’s initialize Stream.

utils.js

const { StreamClient } = require("@stream-io/node-sdk");

const apiKey = process.env.STREAM_KEY;
const secret = process.env.STREAM_TOKEN;

client = new StreamClient(apiKey, secret);
Enter fullscreen mode Exit fullscreen mode

Next, we will create a file with a /createclass route. (You can choose any name). We want to make a one-time request to this route to create our classroom.

routes/auth.js

const express = require('express');
const auth = express.Router();
const { client } = require('../utils')


const callType = 'default'; 
const callId = 'class_101';

auth.get("/createclass", async (req, res) => {

    const call = client.video.call(callType, callId)
    const data = {
        created_by_id: "1111",
        custom: {
            classroom_name: "psy_101",
            subject: "Psychology"
        }

    }

    await call.create({data}) 

})

Enter fullscreen mode Exit fullscreen mode

Creating and Authenticating Users

Before a person can join the class, they must first be registered as a user, authenticated, and added to the class. 
routes/auth.js

auth.post("/auth", async (req, res) => {

    try{

        const {fullName, role, userId } = await req.body;

        const newUser = {
            id: userId,
            role: role === "Student" ? "user" : "admin",
            custom: {
                full_name: fullName,
                userId: `${role} ${userId}`
            }  
        };
        await client.upsertUsers([newUser]);

        const token = await client.generateUserToken({ user_id: userId});


        const call = client.video.call(callType, callId);
        await call.updateCallMembers({
            update_members: [{ 
                user_id: userId, 
                role: role === "Student" ? "user" : "admin"
            }]
        });


        res.json({token: token, userId: userId, callId: callId, callType: callType })
    }catch(err){
        console.error("Error occurred:", err)
    }


});

module.exports = auth;
Enter fullscreen mode Exit fullscreen mode

This route receives the fullName, role, and userId of a user, generates an authentication token, and sends the token, userId, callId, and callType back to the frontend, where they use them to join the class. 

Creating a Lobby

To join a classroom, we want the user to enter their full name, role (student or teacher), and ID number in the lobby.
Lobby.jsx


import { useState } from 'react';
import './App.css';
import { useUser } from './UserContext';

const Lobby = ({ onJoin }) => {
  const [fullName, setFullName] = useState('');
  const [role, setRole] = useState('Student');
  const [userId, setUserId] = useState('');
  const [isLoading, setIsLoading] = useState(false);
  const { setUser } = useUser();

  const handleSubmit = async (e) => {
    e.preventDefault();

    if (!fullName.trim()) {
      alert('Please enter your full name');
      return;
    }

    if (!userId.trim()) {
      alert(`Please enter your ${role === 'Student' ? 'student' : 'teacher'} ID`);
      return;
    }

    if (!/^\d+$/.test(userId)) {
      alert('ID must contain numbers only');
      return;
    }

    setIsLoading(true);

    try {
      const payload = {
        fullName,
        role,
        userId
      };

      const response = await fetch('https://9b621ead7.ngrok-free.app/auth', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify(payload),
      });

      if (!response.ok) {
        throw new Error(`Server error: ${response.status}`);
      }

      const data = await response.json();
      console.log("data from backend", data)

      // Store the token, id, and type in the context state
      setUser({
        token: data.token,
        id: data.userId,
        callType: data.callType,
        callId: data.callId
      });

      // Navigate to Call component
      onJoin();
    } catch (error) {
      console.error('Authentication error:', error);
      alert(`Failed to authenticate: ${error.message}`);
    } finally {
      setIsLoading(false);
    }
  };

  return (
    <div className="lobby">
      <h1>Join Classroom</h1>
      <form onSubmit={handleSubmit}>
        <div className="form-group">
          <label htmlFor="fullName">Full Name</label>
          <input
            type="text"
            id="fullName"
            value={fullName}
            onChange={(e) => setFullName(e.target.value)}
            placeholder="Enter your full name"
            required
          />
        </div>

        <div className="form-group">
          <label htmlFor="role">Role</label>
          <select
            id="role"
            value={role}
            onChange={(e) => setRole(e.target.value)}
            required
          >
            <option value="Student">Student</option>
            <option value="Teacher">Teacher</option>
          </select>
        </div>

        <div className="form-group">
          <label htmlFor="userId">
            {role === 'Student' ? 'Student ID' : 'Teacher ID'}
          </label>
          <input
            type="text"
            id="userId"
            value={userId}
            onChange={(e) => setUserId(e.target.value)}
            placeholder={`Enter your ${role === 'Student' ? 'student' : 'teacher'} ID (numbers only)`}
            pattern="\d+"
            required
          />
        </div>

        <button type="submit" disabled={isLoading}>
          {isLoading ? 'Joining...' : 'Join Call'}
        </button>
      </form>
    </div>
  );
};

export default Lobby;
Enter fullscreen mode Exit fullscreen mode

handleSubmit sends the user’s full name, role, and ID to the backend, where they are created and authenticated. token, userId, callId, and callType received from the server are stored inside our UserContext state. Upon successful authentication, onJoin() sends the user to the classroom. 

Let’s set up UserContext.

UserContext.jsx

import { createContext, useContext, useState } from 'react';

const UserContext = createContext();

export const useUser = () => {
  const context = useContext(UserContext);
  if (!context) {
    throw new Error('useUser must be used within a UserProvider');
  }
  return context;
};

export const UserProvider = ({ children }) => {
  const [user, setUser] = useState(null);

  return (
    <UserContext.Provider value={{ user, setUser }}>
      {children}
    </UserContext.Provider>
  );
};

Enter fullscreen mode Exit fullscreen mode

Here’s what our classroom lobby looks like:

Lobby

Joining the Class

Once the user is authenticated, we want to take them into the classroom. Let’s set up the classroom.

Call.jsx

import { useEffect, useState, useRef } from 'react';
import {
  CallControls,
  SpeakerLayout,
  StreamCall,
  StreamTheme,
  StreamVideo,
  StreamVideoClient
} from "@stream-io/video-react-sdk";
import "@stream-io/video-react-sdk/dist/css/styles.css";
import { useUser } from './UserContext';

const apiKey = import.meta.env.VITE_STREAM_KEY;

const Call = () => {
  const { user } = useUser();
  const [client, setClient] = useState(null);
  const [call, setCall] = useState(null);
  const [isLoading, setIsLoading] = useState(true);
  const [error, setError] = useState(null);
  const clientRef = useRef(null);
  const callRef = useRef(null);

  useEffect(() => {
    if (!user) {
      setError('No user data available');
      setIsLoading(false);
      return;
    }
    if(!user?.id || !user?.token){
        setError("User Id or User token not found");
        setIsLoading(false)
        return;
    }

    if (!user.callType || !user.callId) {
      setError('Missing call information');
      setIsLoading(false);
      return;
    }

    if (!apiKey) {
      setError('Missing Stream API key');
      setIsLoading(false);
      return;
    }

    let isMounted = true;

    const initializeCall = async () => {
      try {
        const videoClient = new StreamVideoClient({
          apiKey,
        });

        await videoClient.connectUser({id: user.id}, user.token);

        const videoCall = videoClient.call(user.callType, user.callId);

        await videoCall.join({create: false});

        if (isMounted) {
          clientRef.current = videoClient;
          callRef.current = videoCall;
          setClient(videoClient);
          setCall(videoCall);
          setIsLoading(false);
        } else {
          // If component unmounted during initialization, clean up
          await videoCall.leave().catch((err) => console.error("Failed to leave:", err));
          await videoClient.disconnectUser().catch((err) => console.error("Failed to disconnect:", err));
        }
      } catch (err) {
        console.error('Error initializing call:', err);
        if (isMounted) {
          setError(err.message || 'Failed to initialize call');
          setIsLoading(false);
        }
      }
    };

    initializeCall();

    return () => {
      isMounted = false;

      if (callRef.current) {
        callRef.current.leave().catch((err) => console.error("Failed to leave the call:", err));
      }
      if (clientRef.current) {
        clientRef.current.disconnectUser().catch((err) => console.error("Failed to disconnect:", err));
      }

      clientRef.current = null;
      callRef.current = null;
      setClient(undefined);
      setCall(undefined);
    };
  }, [user?.id, user?.token, apiKey]);

  if (error) {
    return (
      <div className="error-container">
        <h2>Error</h2>
        <p>{error}</p>
      </div>
    );
  }

  if (isLoading || !client || !call) {
    return (
      <div className="loading-container">
        <p>Loading call...</p>
      </div>
    );
  }

  return (
    <StreamVideo client={client}>
    <StreamTheme>
      <StreamCall call={call}>
          <SpeakerLayout />
          <CallControls />
      </StreamCall>
    </StreamTheme>
    </StreamVideo>
  );
};

export default Call;
Enter fullscreen mode Exit fullscreen mode

Once the call component mounts, initializeCall() connects the user to the classroom via the Stream Video SDK.

Inside App.jsx, let’s toggle between the Lobby.jsx and Call.jsx components:

import { useState } from 'react'
import './App.css'
import Call from './Call'
import Lobby from './Lobby'
import { UserProvider } from './UserContext'

function App() {
  const [showCall, setShowCall] = useState(false)

  const handleJoinCall = () => setShowCall(true);

  return (
    <UserProvider>
      {showCall ? (
        <Call />
      ) : (
        <Lobby onJoin={handleJoinCall} />
      )}
    </UserProvider>
  )
}

export default App
Enter fullscreen mode Exit fullscreen mode

Capturing Join and Leave Events

To track class attendance, we need to listen for join and leave events from Stream’s webhook. Additionally, we also want the call_session_ended hook.

Go over to your Stream dashboard and configure your webhook to listen to these events: 
call.session_ended, call.session_participant_joined, and call.session_participant_left

webhooks config

Next, let’s set up our webhooks endpoint.

routes/webhook.js

const express = require('express');
const crypto = require('crypto');
const { supabase } = require(../utils)
const router = express.Router();

// In-memory deduplication store. You should use redis in production
const processedWebhooks = new Set();

// Cleanup old webhook IDs every 24 hours
setInterval(() => {
  if (processedWebhooks.size > 10000) processedWebhooks.clear();
}, 24 * 60 * 60 * 1000);


// Verify webhook signature
const verifySignature = (req, res, next) => {
  const { 'x-signature': signature, 'x-api-key': apiKey } = req.headers;

  if (!signature || !apiKey) {
    console.error(' Missing authentication headers');
    return res.status(401).json({ error: 'Missing authentication headers' });
  }

  if (apiKey !== process.env.STREAM_KEY) {
    console.error(' Invalid API key');
    return res.status(401).json({ error: 'Invalid API key' });
  }

  try {
    const bodyToVerify = req.rawBody || JSON.stringify(req.body);

    const expectedSignature = crypto
      .createHmac('sha256', process.env.STREAM_TOKEN)
      .update(bodyToVerify)
      .digest('hex');

    if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expectedSignature))) {
      console.error('Signature mismatch');
      return res.status(401).json({ error: 'Invalid signature' });
    }

    next();
  } catch (error) {
    console.error(' Signature verification error:', error);
    return res.status(401).json({ error: 'Signature verification failed' });
  }
};

router.post('/webhook',
  express.json({
    verify: (req, res, buf) => {
      req.rawBody = buf.toString('utf8');
    }
  }),
  verifySignature,
  (req, res) => {
  const webhookId = req.headers['x-webhook-id'];
  const webhookAttempt = req.headers['x-webhook-attempt'];
  const { type, ...payload } = req.body;

  // Fast response
  res.status(200).json({ received: true });

  // Deduplication check
  if (processedWebhooks.has(webhookId)) {
    console.log(`Duplicate webhook: ${webhookId}`);
    return;
  }

  processedWebhooks.add(webhookId);

  if (!type) {
    console.error('Missing type field:', req.body);
    return;
  }

  // Process asynchronously
  setImmediate(async () => {
    try {
      await handleEvent(type, payload);
    } catch (error) {
      console.error(`Error processing webhook ${webhookId}:`, error);
    }
  });
});

module.exports = router;
Enter fullscreen mode Exit fullscreen mode

When we receive a webhook event, the verifySignature middleware will verify that the webhook is coming from Stream. Status code 200 is then sent back immediately before processing the event.

The handleEvent function processes the webhook based on the received event.

 


// Event handlers
const handleEvent = async (type, payload) => {

  switch (type) {
    case 'call.session_ended':
      break;
    case 'call.session_participant_joined':
      const { data: joinData, error: joinError } = await supabase
        .from('participants')
        .insert([{
          call_id: payload.call_cid,
          session_id: payload.session_id,
          user_session_id: payload.participant.user_session_id,
          user_id: payload.participant.user.userId,
          full_name: payload.participant.user.full_name,
          joined_at: payload.participant.joined_at,
          left_at: null,
          duration_seconds: null
        }])
        .select();

      if (joinError) {
        console.error('Error storing join data:', joinError);
      } 
      break;

    case 'call.session_participant_left':

      const { data: sessionData, error: fetchError } = await supabase
        .from('participants')
        .select('*')
        .eq('user_session_id', payload.participant.user_session_id)
        .select();

      if (fetchError) {
        console.error('Error fetching session:', fetchError);
        break;
      }

      if (sessionData && sessionData.length > 0) {
        // Process the most recent session
        const session = sessionData[0];
        const leftAt = new Date(payload.participant.joined_at);
        const joinedAt = new Date(session.joined_at);
        const durationSeconds = payload.participant.duration_seconds

        const { data: updateData, error: updateError } = await supabase
          .from('participants')
          .update({
            left_at: payload.participant.joined_at,
            duration_seconds: durationSeconds
          })
          .eq('user_session_id', payload.participant.user_session_id)
          .select();

        if (updateError) {
          console.error('Error updating leave data:', updateError);
        } 
      }
      break;

    default:
      console.log(`Unhandled event: ${type}`, payload);
  }
};

Enter fullscreen mode Exit fullscreen mode

When a user joins the classroom, Stream sends a call.session_participant_joined event. We extract participant data from this event and save it to our Supabase database. 

Similarly, when a user leaves the classroom, we receive a call.session_participant_left event, which updates the record of the user inside our database.

Don’t forget to initialize Supabase inside utils.js:

const supabaseUrl = process.env.SUPABASE_URL
const supabaseKey = process.env.SUPABASE_KEY
const supabase = createClient(supabaseUrl, supabaseKey)

Enter fullscreen mode Exit fullscreen mode

Generating Attendance Report

We want to generate an attendance report when the class ends.

utils.js

const OpenAI = require(‘openai’);

const openai = new OpenAI({
  apiKey: process.env.OPENAI_API_KEY
});


const generateAttendanceSummary = async (callId) => {
  try {
    // Fetch all attendance records for this call
    const { data: attendanceRecords, error: fetchError } = await supabase
      .from('participants')
      .select('*')
      .eq('call_id', callId);

    if (fetchError) {
      console.error('Error fetching attendance data:', fetchError);
      return null;
    }

    if (!attendanceRecords || attendanceRecords.length === 0) {
      console.log('No attendance records found for call:', callId);
      return null;
    }

    // Prepare attendance data for AI
    const attendanceData = attendanceRecords.map(record => {
      const duration = record.duration_seconds || 0;
      const minutes = Math.floor(duration / 60);
      const seconds = duration % 60;

      return {
        name: record.full_name,
        user_id: record.user_id,
        joined_at: new Date(record.joined_at).toLocaleString(),
        left_at: record.left_at ? new Date(record.left_at).toLocaleString() : 'Still in call',
        duration: `${minutes}m ${seconds}s`,
        duration_seconds: duration
      };
    });

    // Calculate total participants and average duration
    const totalParticipants = new Set(attendanceRecords.map(r => r.user_id)).size;
    const totalSessions = attendanceRecords.length;
    const avgDuration = attendanceRecords.reduce((sum, r) => sum + (r.duration_seconds || 0), 0) / attendanceRecords.length;

    // Create AI prompt
    const prompt = `You are an educational assistant analyzing class attendance data. Generate a detailed, professional attendance summary report.

Call ID: ${callId}
Total Unique Participants: ${totalParticipants}
Total Sessions: ${totalSessions}
Average Duration: ${Math.floor(avgDuration / 60)} minutes

Attendance Details:
${JSON.stringify(attendanceData, null, 2)}

Generate a comprehensive summary that includes:
1. Overall attendance statistics
2. Engagement level analysis (based on duration)
3. Notable patterns (e.g., participants who joined multiple times, early leavers, full attendees)
4. Recommendations for improving attendance/engagement

Keep the tone professional and constructive.`;

    // Call OpenAI API
    const completion = await openai.chat.completions.create({
      model: "gpt-4o-mini",
      messages: [
        { role: "system", content: "You are an expert educational analyst providing detailed attendance reports." },
        { role: "user", content: prompt }
      ],
      temperature: 0.7,
      max_tokens: 1000
    });

    const summary = completion.choices[0].message.content;

    // Store summary in database
    const { data: summaryData, error: summaryError } = await supabase
      .from('call_summaries')
      .insert([{
        call_id: callId,
        summary: summary,
        total_participants: totalParticipants,
        total_sessions: totalSessions,
        average_duration_seconds: Math.floor(avgDuration),
        created_at: new Date().toISOString()
      }])
      .select();

    if (summaryError) {
      console.error('Error storing summary:', summaryError);
    } else {
      console.log('Summary stored successfully:', summaryData);
    }

    return {
      summary,
      attendanceData,
      stats: {
        totalParticipants,
        totalSessions,
        avgDuration
      }
    };
  } catch (error) {
    console.error('Error generating attendance summary:', error);
    return null;
  }
};
Enter fullscreen mode Exit fullscreen mode

First, we fetch all the attendance records for the class, then we calculate the total number of participants and the average duration. After that, OpenAI generates a detailed summary of the attendance for us.

Creating a PDF From the Attendance Summary

Now that we have the attendance summary, let’s create a PDF version of it. 

utils.js

const PDFDocument = require('pdfkit');

const generatePDF = async (callId, summary, attendanceData, stats) => {
  return new Promise((resolve, reject) => {
    try {
      // Create PDF directory if it doesn't exist
      const pdfDir = path.join(__dirname, '../pdfs');
      if (!fs.existsSync(pdfDir)) {
        fs.mkdirSync(pdfDir, { recursive: true });
      }

      const fileName = `attendance-${callId}-${Date.now()}.pdf`;
      const filePath = path.join(pdfDir, fileName);

      const doc = new PDFDocument({ margin: 50 });
      const stream = fs.createWriteStream(filePath);

      doc.pipe(stream);

      // Header
      doc.fontSize(24).font('Helvetica-Bold').text('Attendance Summary Report', { align: 'center' });
      doc.moveDown();

      // Class details
      doc.fontSize(12).font('Helvetica');
      doc.text(`Call ID: ${callId}`);
      doc.text(`Generated: ${new Date().toLocaleString()}`);
      doc.moveDown();

      // Attendance statistics
      doc.fontSize(16).font('Helvetica-Bold').text('Statistics');
      doc.fontSize(11).font('Helvetica');
      doc.text(`Total Unique Participants: ${stats.totalParticipants}`);
      doc.text(`Total Sessions: ${stats.totalSessions}`);
      doc.text(`Average Duration: ${Math.floor(stats.avgDuration / 60)} minutes`);
      doc.moveDown();

      // AI Summary
      doc.fontSize(16).font('Helvetica-Bold').text('AI Analysis');
      doc.fontSize(10).font('Helvetica');
      doc.text(summary, { align: 'justify' });
      doc.moveDown();

      // Attendance Details Table
      doc.fontSize(16).font('Helvetica-Bold').text('Detailed Attendance');
      doc.moveDown(0.5);

      doc.fontSize(9).font('Helvetica');
      attendanceData.forEach((record, index) => {
        if (doc.y > 700) {
          doc.addPage();
        }

        doc.fontSize(10).font('Helvetica-Bold').text(`${index + 1}. ${record.name}`);
        doc.fontSize(9).font('Helvetica');
        doc.text(`   User ID: ${record.user_id}`);
        doc.text(`   Joined: ${record.joined_at}`);
        doc.text(`   Left: ${record.left_at}`);
        doc.text(`   Duration: ${record.duration}`);
        doc.moveDown(0.5);
      });

      // Footer
      doc.fontSize(8).text('Generated by AI-Powered Attendance System using Stream', {
        align: 'center'
      });

      doc.end();

      stream.on('finish', () => {
        resolve(filePath);
      });

      stream.on('error', (error) => {
        reject(error);
      });
    } catch (error) {
      reject(error);
    }
  });
};

Enter fullscreen mode Exit fullscreen mode

Sending Email With PDF Attached

We also want to send an email using Resend with the attendance report PDF attached to the teacher when the class ends.
utils.js

const { Resend } = require('resend');

const sendEmailWithPDF = async (callId, summary, pdfPath) => {
  try {
    const pdfBuffer = fs.readFileSync(pdfPath);
    const pdfBase64 = pdfBuffer.toString('base64');
    const fileName = path.basename(pdfPath);

    const { data, error } = await resend.emails.send({
      from: 'Attendance System <onboarding@resend.dev>',
      to: 'loyaltysamuel001@gmail.com', // this should be the email of the class teacher from your database
      subject: `Attendance Summary - Call ${callId}`,
      html: `
        <h2>Attendance Summary Report</h2>
        <p>The class session has ended. Please find the detailed attendance summary attached.</p>
        <h3>Quick Overview:</h3>
        <div style="background-color: #f5f5f5; padding: 15px; border-radius: 5px;">
          ${summary.split('\n').slice(0, 5).join('<br/>')}
        </div>

        <p><strong>Full report is attached as PDF.</strong></p>

        <hr/>
        <p style="color: #666; font-size: 12px;">Generated by AI-Powered Attendance System</p>
      `,
      attachments: [
        {
          filename: fileName,
          content: pdfBase64
        }
      ]
    });

    if (error) {
      console.error('Error sending email:', error);
      return false;
    }

    console.log('Email sent successfully:', data);

    // Clean up PDF file after sending (optional)
    fs.unlinkSync(pdfPath);
    console.log('PDF file cleaned up:', pdfPath);

    return true;
  } catch (error) {
    console.error('Error in email sending process:', error);
    return false;
  }
};

module.exports = { generatePDF, sendEmailWithPDF, generateAttendanceSummary }

Enter fullscreen mode Exit fullscreen mode

Finally, let’s call generateAttendanceSummary, generatePDF, and sendEmailWithPDF functions whenever we receive the call.session_ended webhook event.
routes/webhook.js

const { generatePDF, sendEmailWithPDF, generateAttendanceSummary } = require("../utils");


const handleEvent = async (type, payload) => {

  switch (type) {
    case 'call.session_ended':

      // Generate AI attendance summary
      const callId = payload.call_cid;

      const result = await generateAttendanceSummary(callId);

      if (result) { 

        try {
          // Generate PDF
          const pdfPath = await generatePDF(
            callId,
            result.summary,
            result.attendanceData,
            result.stats
          );

          // Send email with PDF attachment
          const emailSent = await sendEmailWithPDF(callId, result.summary, pdfPath);

          if (emailSent) {
            console.log('Attendance summary email sent successfully!');
          } else {
            console.log('Failed to send attendance summary email');
          }
        } catch (error) {
          console.error('Error in PDF/Email process:', error);
        }
      } else {
        console.log('Failed to generate attendance summary');
      }
      break;
}

//...
Enter fullscreen mode Exit fullscreen mode

Run the code and see the result. We now have a fully functional attendance tracker for our video classroom. 🎉

Summary

In this tutorial, we built a video classroom with automated attendance tracking using the Stream Video SDK, OpenAI, and Supabase. 

We started by creating a functional classroom with the Node.js Video SDK, and then we utilized Stream’s webhook to track participants' real-time join and leave events. After that, we used OpenAI to generate a detailed attendance report. Finally, the report was converted into a PDF and sent to the teacher at the end of each session. 

Want to keep building? Check out these tutorials next: 

Top comments (0)