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:
You can reference the GitHub repository for the full code.
Prerequisites
To follow along with this tutorial, you’ll need:
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
Then, you’ll install the Stream Node SDK:
npm install @stream-io/node-sdk
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
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);
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})
})
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;
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;
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>
);
};
Here’s what our classroom lobby looks like:
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;
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
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.
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;
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);
}
};
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)
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;
}
};
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);
}
});
};
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 }
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;
}
//...
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)