Before You Begin

  • Node.js 20+ and Rust 1.90+ (2024 edition)
  • Agora account with App ID and App Certificate
  • PostgreSQL (or any SQL database)
  • Basic familiarity with Next.js App Router, Rust (async-graphql), and Agora SDK concepts

1. Project Architecture & Data Model

The system has three layers: a Next.js frontend (server components + client components for chat), a Rust GraphQL backend (with async-graphql and sqlx), and Agora cloud for real-time messaging and video. Tenant isolation is enforced at the database level via a tenant_id column on every table.

Core entities:

  • Tenant: Travel agency (e.g., "Sapphire Travels")
  • Agent: Support staff with role (admin/agent), assigned to a tenant
  • Shift: agentid, tenantid, dayofweek, starttimeutc, endtimeutc, timezone
  • Ticket: id, tenantid, customername, subject, status (open/assigned/resolved), priority (low/medium/high)
  • Message: ticketid, senderid, content, messagetype (chat/videoescalation), timestamp
  • Escalation: ticketid, channelname, agentid, startedat, ended_at

The shift scheduler runs as a background job in Rust (using tokio::spawn) and also on-demand via a GraphQL query.

2. Rust GraphQL Backend: Shift Scheduling & Token Generation

We use async-graphql for the GraphQL API and sqlx for PostgreSQL. The backend handles authentication (JWT with tenant context), shift queries, ticket assignment, and Agora token generation.

Agora Token Server (Rust)

Generate Agora tokens using the hmac and sha1 crates. Never expose the App Certificate client-side.

// src/agora.rs
use hmac::{Hmac, Mac};
use sha1::Sha1;
use std::time::{SystemTime, UNIX_EPOCH};

type HmacSha1 = Hmac<Sha1>;

pub fn generate_agora_token(app_id: &str, app_certificate: &str, channel_name: &str, uid: u64, role: u32) -> String {
    let now = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap()
        .as_secs();
    let expiry = now + 3600; // 1 hour
    let token_version = "006";
    // Simplified: actual Agora token generation involves building a JSON-like string
    // For brevity, we use the Agora recommended approach: use the `agora_token` crate or manual HMAC
    // Here we show the HMAC signing part
    let sign_key = format!("{}{}", app_certificate, app_id);
    let mut mac = HmacSha1::new_from_slice(sign_key.as_bytes()).expect("HMAC key");
    let payload = format!("{}{}{}{}{}", app_id, channel_name, uid, role, expiry);
    mac.update(payload.as_bytes());
    let result = mac.finalize();
    let code = result.into_bytes();
    hex::encode(code) // Real token would combine version, app_id, channel, etc.
}

In production, use the `agora_token` crate from crates.io. This example demonstrates the core signing logic.

### GraphQL Schema for Shift Scheduling

Define types and queries:

type Agent { id: ID! name: String! role: AgentRole! currentShift: Shift tickets: [Ticket!]! }

type Shift { id: ID! agent: Agent! dayOfWeek: Int! startTime: String! # UTC endTime: String! # UTC timezone: String! }

type Ticket { id: ID! customerName: String! status: TicketStatus! assignedAgent: Agent messages: [Message!]! }

type Query { availableAgents(tenantId: ID!, time: DateTime!): [Agent!]! activeTickets(tenantId: ID!): [Ticket!]! }

type Mutation { assignTicket(ticketId: ID!, agentId: ID!, tenantId: ID!): Ticket! reassignAgent(ticketId: ID!, newAgentId: ID!, reason: String!): Ticket! generateAgoraToken(channelName: String!, uid: UInt64!, role: AgoraRole!): String! }

Smart Scheduler Logic (Rust):

// src/scheduler.rs
use sqlx::PgPool;
use chrono::{Utc, Datelike, Timelike};

pub async fn find_available_agent(pool: &PgPool, tenant_id: &str) -> Option<String> {
    let now = Utc::now();
    let day_of_week = now.weekday().num_days_from_monday() as i32;
    let time_secs = now.hour() * 3600 + now.minute() * 60 + now.second();

    // Query agents on shift now, ordered by last assignment time (round robin)
    let agent = sqlx::query!(
        r#"
        SELECT a.id, a.name
        FROM agents a
        JOIN shifts s ON a.id = s.agent_id
        WHERE a.tenant_id = $1
          AND s.day_of_week = $2
          AND s.start_time_utc <= $3
          AND s.end_time_utc > $3
        ORDER BY a.last_assignment_at ASC NULLS FIRST
        LIMIT 1
        "#,
        tenant_id,
        day_of_week,
        time_secs
    )
    .fetch_optional(pool)
    .await
    .expect("DB error");

    agent.map(|a| a.id)
}

## 3. Next.js Frontend: Agora RTM Chat Integration

Create a client component that connects to Agora RTM for messaging. Use `agora-rtm-sdk` (or `agora-rtm-react` if available).

// components/ChatPanel.tsx 'use client'; import AgoraRTM from 'agora-rtm-sdk'; import { useEffect, useRef, useState } from 'react'; import { useMutation } from '@apollo/client';

export default function ChatPanel({ ticketId, tenantId, userId }: { ticketId: string; tenantId: string; userId: string }) { const [messages, setMessages] = useState<Array<{sender: string; text: string}>>([]); const [client, setClient] = useState<any>(null); const [input, setInput] = useState(''); const channelRef = useRef<any>(null); const [generateToken] = useMutation(GENERATEAGORATOKEN);

useEffect(() => { const initChat = async () => { const rtmClient = AgoraRTM.createInstance(process.env.NEXTPUBLICAGORAAPPID!); const { data } = await generateToken({ variables: { channelName: ticketId, uid: parseInt(userId), role: 'PUBLISHER' }}); await rtmClient.login({ uid: userId, token: data.generateAgoraToken }); const channel = rtmClient.createChannel(ticketId); await channel.join();

channel.on('ChannelMessage', (message: any, memberId: string) => { setMessages(prev => [...prev, { sender: memberId, text: message.text }]); });

setClient(rtmClient); channelRef.current = channel; };

initChat();

return () => { channelRef.current?.leave(); client?.logout(); client?.destroy(); }; }, [ticketId]);

const sendMessage = async () => { if (!input.trim()) return; await channelRef.current?.sendMessage({ text: input }); setMessages(prev => [...prev, { sender: userId, text: input }]); setInput(''); };

return ( <div> <div className="messages"> {messages.map((msg, i) => ( <div key={i} className={msg.sender === userId ? 'self' : 'other'}> <strong>{msg.sender}</strong>: {msg.text} </div> ))} </div> <input value={input} onChange={e => setInput(e.target.value)} onKeyDown={e => e.key === 'Enter' && sendMessage()} /> <button onClick={sendMessage}>Send</button> </div> ); }

Why this works: The RTM channel is scoped to the ticket ID, ensuring privacy. The token is fetched from the Rust backend, which validates the user's tenant and role before issuing.

4. Smart Scheduler & Agent Assignment Logic

When a new ticket arrives, the backend triggers the scheduler. The algorithm:

  1. Check if any agent is on shift now (UTC-based).
  2. Among those, pick the one with the oldest lastassignmentat (round-robin).
  3. If none, set status to queued with a priority field; a background job retries every 30 seconds.

GraphQL mutation for assignment:

// src/graphql/mutations.rs
#[derive(InputObject)]
pub struct AssignTicketInput {
    ticket_id: String,
    tenant_id: String,
    override_agent_id: Option<String>, // for admin override
}

#[Object]
impl Mutation {
    async fn assign_ticket(&self, ctx: &Context<'_>, input: AssignTicketInput) -> Result<Ticket> {
        let pool = ctx.data::<PgPool>().unwrap();
        let (agent_id, reason) = if let Some(override_id) = input.override_agent_id {
            (override_id, "admin_override")
        } else {
            let available = find_available_agent(pool, &input.tenant_id).await
                .ok_or(anyhow!("No agent available"))?;
            (available, "scheduler")
        };
        // Update ticket and log assignment
        sqlx::query!(
            "UPDATE tickets SET assigned_agent_id = $1, status = 'assigned', updated_at = NOW() WHERE id = $2",
            agent_id, input.ticket_id
        ).execute(pool).await?;
        // Update last_assignment_at for agent
        sqlx::query!(
            "UPDATE agents SET last_assignment_at = NOW() WHERE id = $1",
            agent_id
        ).execute(pool).await?;
        // Return updated ticket
        get_ticket(pool, &input.ticket_id).await
    }
}

## 5. Video Escalation with Agora RTC

When an agent or customer escalates a chat to video, the system creates an RTC channel and generates a token. The frontend uses `agora-rtc-sdk-ng`.

// components/VideoCall.tsx 'use client'; import { useEffect, useRef } from 'react'; import AgoraRTC from 'agora-rtc-sdk-ng';

export default function VideoCall({ channelName, uid, token }: { channelName: string; uid: string; token: string }) { const localRef = useRef<HTMLDivElement>(null); const remoteRef = useRef<HTMLDivElement>(null);

useEffect(() => { const client = AgoraRTC.createClient({ mode: 'rtc', codec: 'vp8' }); let tracks: any[] = [];

const init = async () => { await client.join(process.env.NEXTPUBLICAGORAAPPID!, channelName, token, uid); tracks = await AgoraRTC.createMicrophoneAndCameraTracks(); localRef.current?.appendChild(tracks[1].play()); await client.publish(tracks);

client.on('user-published', async (user: any, mediaType: string) => { await client.subscribe(user, mediaType); if (mediaType === 'video') { user.videoTrack?.play(remoteRef.current!); } }); }; init();

return () => { client.leave(); tracks.forEach(track => track.close()); client.removeAllListeners(); }; }, [channelName, uid, token]);

return ( <div> <div ref={localRef} style={{ width: 200, height: 150 }} /> <div ref={remoteRef} style={{ width: 400, height: 300 }} /> </div> ); }

Escalation workflow: The agent clicks "Escalate to Video" โ†’ a GraphQL mutation creates an escalation record and returns the channel name and token โ†’ the frontend opens the VideoCall component with that data.

6. Admin Override & Agent Reassignment

Admins can reassign tickets to any agent, even if that agent is off shift. This is a simple mutation that updates assignedagentid and logs reassign_reason. Agents can also reassign to another agent, but only if they are currently on shift (validated on the backend).