Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/flan02/speak-english-now-app/llms.txt

Use this file to discover all available pages before exploring further.

Overview

The virtual class system enables students to book and attend English lessons through Google Meet. The platform supports both individual and group classes with real-time scheduling, access codes, and participant management.

Class Types

The platform supports two types of virtual classes defined in the database schema:
enum ClassType {
  individual
  grupal
}

enum Status {
  scheduled
  pending
  completed
  cancelled
}

enum HostType {
  anfitrion
  invitado
}

Individual Classes

  • One-on-one sessions between teacher and student
  • Maximum 1 participant
  • Base price: 15,000 ARS
  • Personalized learning focus

Group Classes (Grupal)

  • Multiple students in one session (2-5 participants)
  • Shared learning experience
  • Pricing per student count:
    • 2 students: 30,000 ARS
    • 3 students: 36,000 ARS
    • 4 students: 48,000 ARS
    • 5 students: 60,000 ARS
Pricing is configured in src/config/pricing.json and can be adjusted as needed.

Virtual Class Model

The VirtualClass model stores all class information and integrates with Google Calendar:
model VirtualClass {
  id                  String         @id @default(auto()) @map("_id") @db.ObjectId
  googleEventId       String?        @unique
  bookedById          String         @db.ObjectId
  accessCode          String?
  startTime           DateTime
  endTime             DateTime
  hostType            HostType       @default(anfitrion)
  currentParticipants Int            @default(1)
  maxParticipants     Int
  classType           ClassType
  classPrice          Int
  htmlLink            String?
  status              Status         @default(pending)
  summary             String?
  description         String?
  learningFocus       String?
  preferenceId        String?        @db.String
  activityStatus      ActivityStatus @default(pending)
  participantsIds     String[]       @default([])
  createdAt           DateTime       @default(now())
  updatedAt           DateTime       @updatedAt
}

Booking Flow

The booking process follows these steps:

1. Create Payment Preference

First, create a Mercado Pago preference to initiate payment:
export async function POST(request: NextRequest) {
  const body = await request.json();
  const session = await auth();
  const { type, studentsCount, price } = body;

  const client = new MercadoPagoConfig({ 
    accessToken: process.env.MERCADO_PAGO_ACCESS_TOKEN! 
  });
  const preference = new Preference(client);

  const mpBody = {
    items: [{
      id: `${session.user.id}-${Date.now()}`,
      title: `HablaInglesYa - Clase virtual para ${studentsCount} persona(s)`,
      quantity: 1,
      unit_price: price,
      currency_id: "ARS",
    }],
    notification_url: `${process.env.BASE_URL}api/mercado-pago/webhook`,
    back_urls: {
      success: `${process.env.BASE_URL}/checkout/callback/success`,
      failure: `${process.env.BASE_URL}/checkout/callback/failure`,
      pending: `${process.env.BASE_URL}/checkout/callback/pending`,
    },
    auto_return: "approved",
  };

  const result = await preference.create({ body: mpBody });
  
  // Save payment record
  const data: PaymentMP = {
    userId: session.user.id,
    preferenceId: result.id,
    amount: Number(price),
    type: type,
    maxParticipants: Number(studentsCount),
    status: 'pending',
  };
  
  await createPayment(data);
  return NextResponse.json({ 
    preferenceId: result.id, 
    initPoint: result.init_point 
  });
}

2. Create Booking

After payment initiation, create a pending booking:
export async function POST(request: Request) {
  const session = await auth();
  const userId = session.user.id;
  const { start, end, isGroupClass, studentsCount, text, price, preferenceId } = 
    await request.json();

  const bookingData = {
    start,
    end,
    classType: isGroupClass ? "grupal" : "individual",
    classPrice: Number(price),
    maxParticipants: isGroupClass ? studentsCount : 1,
    preferenceId,
    learningFocus: text
  };

  // Create booking with "pending" status
  const isbooked = await createVirtualClass(bookingData, userId);
  return NextResponse.json({ success: true });
}

3. Payment Confirmation

When payment is approved via webhook, the class is updated with Google Meet details:
export async function POST(req: Request) {
  const body = JSON.parse(await req.text());
  const topic = body?.topic;
  const resource = body?.resource;

  if (topic === "merchant_order") {
    const orderRes = await fetch(resource, {
      headers: {
        Authorization: `Bearer ${process.env.MERCADO_PAGO_ACCESS_TOKEN}`,
      },
    });
    const order = await orderRes.json();
    const payment = order.payments?.[0];

    if (payment.status === "approved") {
      const preferenceId = order.preference_id;
      await updatePayment(preferenceId);
      
      // Create Google Calendar event
      await KY(Method.POST, API_ROUTES.CALENDAR, {
        json: { preferenceId }
      });
    }
  }
  return Response.json({ ok: true });
}

Access Code System

Each scheduled class receives a unique 8-character access code for participants to join:
export async function updateVirtualClass(googleCalendarEvent: any, body: any) {
  // Generate random 8-character access code
  const randomCode = Math.random()
    .toString(36)
    .substring(2, 10)
    .toUpperCase();

  const saveCalendarEvent = {
    googleEventId: googleCalendarEvent.id,
    accessCode: randomCode,
    startTime: new Date(googleCalendarEvent.start.dateTime),
    endTime: new Date(googleCalendarEvent.end.dateTime),
    htmlLink: googleCalendarEvent.conferenceData.entryPoints[0].uri,
    status: "scheduled",
    // ... other fields
  };

  await db.virtualClass.update({
    where: { id: reservedClassFound.id },
    data: saveCalendarEvent
  });
}

Joining with Access Code

Participants can join group classes using the access code:
export async function POST(request: NextRequest) {
  const session = await auth();
  const body = await request.json();
  const response = await getGoogleMeetLink(body);

  // Validate host cannot join as participant
  if (response?.bookedById === session?.user?.id) {
    return NextResponse.json({ 
      response: { 
        message: "El creador de la clase no puede unirse como participante" 
      } 
    });
  }

  // Check if class has ended
  if (response?.endTime) {
    const now = new Date();
    const endTime = new Date(response.endTime);
    if (now > endTime) {
      return NextResponse.json({ 
        response: { message: "La clase ya ha finalizado." } 
      });
    }
  }

  // Add participant to class
  if (response) {
    const guestResponse = await addParticipant(response, session?.user?.id);
    return NextResponse.json({ response: guestResponse });
  }
}

Participant Management

The system tracks participants and enforces capacity limits:
export async function addParticipant(event: any, userId: string) {
  const result = await db.$transaction(async (tx) => {
    const existing = await tx.virtualClass.findUnique({
      where: { id: event.id },
      select: {
        participantsIds: true,
        currentParticipants: true,
        maxParticipants: true
      }
    });

    // Check if user already registered
    if (existing.participantsIds.includes(userId)) {
      return { message: "El usuario ya está registrado en la clase" };
    }

    // Check capacity
    if (existing.currentParticipants >= existing.maxParticipants) {
      return { message: "La clase ya alcanzó el número máximo de participantes" };
    }

    // Create user activity record
    await tx.userActivity.create({
      data: {
        userId,
        classId: event.id,
        taskId: null,
        rol: 'participante',
        completed: false,
      },
    });

    // Update class
    return await tx.virtualClass.update({
      where: { id: event.id },
      data: {
        currentParticipants: { increment: 1 },
        participantsIds: { push: userId },
      },
    });
  });
  return result;
}

Class Display

The virtual classes page shows upcoming and past classes:
const MisClasesVirtuales = async () => {
  const session = await auth();

  return (
    <>
      <div className='flex space-x-4 items-end'>
        <Computer className='mb-0.5' />
        <H1 title='Mis Clases Virtuales' />
      </div>
      <h2>Aqui veras las clases virtuales que tengas reservadas. 
          El boton para unirse a la clase aparecera 60 minutos antes 
          de que comience la clase.</h2>
      <GetCode />
      
      <Card className='border border-card'>
        {session?.user.id && (
          <AllClasses session={session} type={"upcoming"} />
        )}
      </Card>
    </>
  );
};

Individual Class Card

Each class displays comprehensive information:
const EachClass = async ({ classItem, index }: Props) => {
  const session = await auth();
  const estado = classItem.status === 'scheduled' 
    ? 'Reservada' 
    : classItem.status === 'completed' 
    ? 'Completada' 
    : 'Cancelada';

  return (
    <Card className='flex border border-card'>
      <p>Dia: {formatUTCDate(String(classItem.startTime))}</p>
      <p>Hora: {parsedStartTime} a {parsedEndTime} hs</p>
      <p>Tipo: {classItem.classType} 
        {classItem.classType == 'grupal' && 
          `(${classItem.currentParticipants}/${classItem.maxParticipants})`
        }
      </p>
      <p>Rol: {classItem.bookedById == session?.user?.id 
        ? 'anfitrion' 
        : 'invitado'
      }</p>
      <p>Estado: {estado}</p>
      
      {classItem.bookedById == session?.user?.id && (
        <AccessCodeClient 
          code={classItem.accessCode} 
          classType={classItem.classType} 
        />
      )}
      
      <JoinClass
        link={classItem.htmlLink}
        status={classItem.status}
        date={formatUTCDate(String(classItem.startTime))}
        time={{ start: parsedStartTime, end: parsedEndTime }}
      />
    </Card>
  );
};

User Activity Tracking

The system tracks class participation through UserActivity:
enum classRole {
  anfitrion
  participante
}

model UserActivity {
  id        String        @id @default(auto()) @map("_id") @db.ObjectId
  userId    String        @db.ObjectId
  taskId    String?       @db.ObjectId
  classId   String        @db.ObjectId
  rol       classRole
  completed Boolean?      @default(false)
  user      User?         @relation(fields: [userId], references: [id])
  task      Task?         @relation(fields: [taskId], references: [id])
  class     VirtualClass? @relation(fields: [classId], references: [id])
  createdAt DateTime      @default(now())
  updatedAt DateTime      @updatedAt
}
User activity records are created automatically when:
  • A class host books a new class (rol: anfitrion)
  • A participant joins a group class (rol: participante)

API Endpoints

Get Upcoming Classes

GET /api/upcoming-classes
Returns all upcoming classes ordered by start time.

Create Booking

POST /api/booking

Body:
{
  "start": "2026-03-15T10:00:00Z",
  "end": "2026-03-15T11:00:00Z",
  "isGroupClass": false,
  "studentsCount": 1,
  "text": "Focus on conversation skills",
  "price": 15000,
  "preferenceId": "162275027-fe8b544b"
}

Join with Access Code

POST /api/access-code

Body:
{
  "accessCode": "A3B5C7D9"
}

Response:
{
  "response": {
    "htmlLink": "https://meet.google.com/abc-defg-hij",
    "startTime": "2026-03-15T10:00:00Z",
    "endTime": "2026-03-15T11:00:00Z"
  }
}

Best Practices

Scheduling

  • Classes can only be joined 60 minutes before start time
  • System validates time zones (America/Argentina/Buenos_Aires)
  • Prevent double-booking through calendar integration

Capacity Management

  • Enforce participant limits before accepting new members
  • Track current vs. maximum participants in real-time
  • Use database transactions for concurrent join attempts

Access Control

  • Only class hosts can view access codes
  • Hosts cannot join their own classes as participants
  • Validate class status before allowing joins