diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 667a84b..ce28b41 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1,4 +1,4 @@ -lockfileVersion: '6.0' +lockfileVersion: '6.1' settings: autoInstallPeers: true @@ -336,7 +336,8 @@ packages: /@panva/hkdf@1.1.1: resolution: {integrity: sha512-dhPeilub1NuIG0X5Kvhh9lH4iW3ZsHlnzwgwbOlgwQ2wG1IqFzsgHqmKPk3WzsdWAeaxKJxgM0+W433RmN45GA==} - + dev: false + /@radix-ui/number@1.0.1: resolution: {integrity: sha512-T5gIdVO2mmPW3NNhjNgEP3cqMXjXL9UbO0BzWcXfvdBs+BohbQxvd/K5hSVKmn9/lbTdsQVKbUcP5WLCwvUbBg==} dependencies: diff --git a/src/app/api/crm_example/route.ts b/src/app/api/crm_example/route.ts index 635996b..20665c8 100644 --- a/src/app/api/crm_example/route.ts +++ b/src/app/api/crm_example/route.ts @@ -20,7 +20,13 @@ export async function GET(request: Request) { const url = new URL(request.url); const queryParams = new URLSearchParams(url.search); - const phone: string = queryParams.get("phone") || "1234567890"; + const phone: string | null = queryParams.get("phone"); + + if (!phone) { + return new Response("Invalid phone number", { + status: 400, + }); + } const clientInfo = await table.getClientByPhone(phone); diff --git a/src/app/api/eventcallback/route.ts b/src/app/api/eventcallback/route.ts new file mode 100644 index 0000000..f95e546 --- /dev/null +++ b/src/app/api/eventcallback/route.ts @@ -0,0 +1,14 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { twilioClient } from '@/lib/twilioClient'; + +export async function POST(req: NextRequest) { + + + console.log("RAAAAA") + // console.log(req) + + const params = req.nextUrl.searchParams; + console.log(Array.from(params.entries())); + + return new NextResponse(null, { status: 204, headers: { 'Content-Type': 'application/json' } }); +} \ No newline at end of file diff --git a/src/app/api/reservations/route.ts b/src/app/api/reservations/route.ts new file mode 100644 index 0000000..e3f2e1c --- /dev/null +++ b/src/app/api/reservations/route.ts @@ -0,0 +1,57 @@ +import { NextRequest } from 'next/server'; +import { twilioClient } from '@/lib/twilioClient'; + +const workspaceSid = process.env.TWILIO_WORKSPACE_SID || ""; +const accountSid = process.env.TWILIO_ACCOUNT_SID; +const authToken = process.env.TWILIO_AUTH_TOKEN; + +const client = require('twilio')(accountSid, authToken); + +export async function GET(req: NextRequest) { + const workerSid = req.nextUrl.searchParams.get('workerSid'); + + if (!workspaceSid) { + throw new Error('TWILIO_WORKSPACE_SID is not set'); + } + + if (!workerSid) { + throw new Error('Worker sid is not provided'); + } + + try { + const reservations = await twilioClient.taskrouter.v1.workspaces(workspaceSid) + .workers(workerSid) + .reservations + .list(); + + const tasks = await Promise.all(reservations.map(async (reservation) => { + const task = await twilioClient.taskrouter.v1.workspaces(workspaceSid) + .tasks(reservation.taskSid) + .fetch(); + return { task: task, reservation: reservation } + })); + + return new Response(JSON.stringify(tasks), { status: 200 }); + } catch (error) { + return new Response("something went wrong", { status: 500 }); + } +} + +export async function POST(req: NextRequest) { + + try { + const task = req.nextUrl.searchParams.get('taskSid'); + const status = req.nextUrl.searchParams.get('status'); + const reservationSid = req.nextUrl.searchParams.get('reservationSid'); + + client.taskrouter.v1.workspaces(workspaceSid) + .tasks(task) + .reservations(reservationSid) + .update({ reservationStatus: status }) + + return new Response(`updated to ${status}`, { status: 200 }); + + } catch (error) { + return new Response("something went wrong", { status: 500 }); + } +} \ No newline at end of file diff --git a/src/app/api/tasks/route.ts b/src/app/api/tasks/route.ts new file mode 100644 index 0000000..bab1e2f --- /dev/null +++ b/src/app/api/tasks/route.ts @@ -0,0 +1,36 @@ +import { NextRequest } from 'next/server'; +import { twilioClient } from '@/lib/twilioClient'; + + +const accountSid = process.env.TWILIO_ACCOUNT_SID; +const authToken = process.env.TWILIO_AUTH_TOKEN; +const workspaceSid = process.env.TWILIO_WORKSPACE_SID || ''; +const client = require('twilio')(accountSid, authToken); + +export async function POST(req: NextRequest) { + try { + const worker = req.nextUrl.searchParams.get('client'); + const reservation = req.nextUrl.searchParams.get('reservationSid'); + const task = req.nextUrl.searchParams.get('taskSid'); + + console.log(" worker:", worker); + console.log(" reservation:", reservation); + console.log(" task:", task) + + client.taskrouter.v1.workspaces(workspaceSid) + .tasks(task) + .reservations(reservation) + .update({ + instruction: 'dequeue', + dequeueFrom: '+16134002002', // The phone number the call is connected from + // to: 'client:atsarapk@uwaterloo.ca' // The client to connect the call to + }) + .then((reservation: { reservationStatus: any; }) => console.log(reservation.reservationStatus)) + .catch((error: any) => console.error(error)); + + //{"contact_uri":"client:atsarapk@uwaterloo.ca"} + return new Response("dequeued", { status: 200 }); + } catch (error) { + return new Response("something went wrong", { status: 500 }); + } +} \ No newline at end of file diff --git a/src/app/api/voice/route.ts b/src/app/api/voice/route.ts index e4e7aa4..8462cda 100644 --- a/src/app/api/voice/route.ts +++ b/src/app/api/voice/route.ts @@ -3,6 +3,7 @@ import VoiceResponse from "twilio/lib/twiml/VoiceResponse"; export async function POST(req: NextRequest) { const callerId = process.env.TWILIO_CALLER_ID; + const workflowSid = process.env.TWILIO_WORKFLOW_SID; const resp = new VoiceResponse(); @@ -10,12 +11,17 @@ export async function POST(req: NextRequest) { const queryString = await req.text(); const params = new URLSearchParams(queryString); const bodyTo = params.get("To"); + const bodyFrom = params.get("From") || undefined; // If the request to the /voice endpoint is TO your Twilio Number, // then it is an incoming call towards your Twilio.Device. if (bodyTo == callerId) { // Incoming call - const dial = resp.dial(); + resp.say("Please hold"); + resp.enqueue({ workflowSid: workflowSid }); + // const dial = resp.dial({ callerId: bodyFrom }); + // dial.client('atsarapk@uwaterloo.ca'); + } else if (bodyTo) { // Outgoing call const dial = resp.dial({ callerId }); diff --git a/src/app/dashboard/layout.tsx b/src/app/dashboard/layout.tsx index 73c62af..ed3f94a 100644 --- a/src/app/dashboard/layout.tsx +++ b/src/app/dashboard/layout.tsx @@ -17,11 +17,16 @@ export default function Layout({ if (status === 'loading') { return Loading...; } else if (session) { - console.log(session) - console.log("ASDASDAS") const isProgramManager = true; // TODO - const initials = "AA"; // TODO get initials from user name - + let initials = "AA"; + if (session.user?.name) { + const parts = session.user.name.split(' '); + if (parts.length > 1) { + initials = (parts[0][0] + parts[1][0]).toUpperCase(); + } else if (parts.length > 0) { + initials = parts[0][0].toUpperCase(); + } + } return (
diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index 013e5ca..da358f5 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -1,7 +1,13 @@ +"use client" +import IncomingCallModal from "@/components/(dashboard)/tasks/IncomingCallModal"; +import NotificationsCard from "@/components/appbar/NotificationsCard"; + export default function Dashboard() { return (

Dashboard

+ {/* {}} rejectCall={() => {}} /> */} + {/* */}
); } diff --git a/src/app/dashboard/tasks/page.tsx b/src/app/dashboard/tasks/page.tsx index 0e539f3..ca6556b 100644 --- a/src/app/dashboard/tasks/page.tsx +++ b/src/app/dashboard/tasks/page.tsx @@ -1,7 +1,125 @@ +"use client" + +import { useState, useEffect } from 'react'; +import { useSession } from "next-auth/react"; +import { Button } from '@/components/ui/button'; +import useCalls from '@/lib/hooks/useCalls'; + +function formatTime(seconds: number) { + const days = Math.floor(seconds / (24 * 60 * 60)); + seconds -= days * 24 * 60 * 60; + const hrs = Math.floor(seconds / (60 * 60)); + seconds -= hrs * 60 * 60; + const mnts = Math.floor(seconds / 60); + seconds -= mnts * 60; + + if (days) return days + (days > 1 ? " days" : " day") + " ago"; + if (hrs) return hrs + (hrs > 1 ? " hours" : " hour") + " ago"; + if (mnts) return mnts + (mnts > 1 ? " minutes" : " minute") + " ago"; + if (seconds) return seconds + (seconds > 1 ? " second" : " second") + " ago"; + +} + export default function Tasks() { + const [tasks, setTasks] = useState([]); + const [activeTasks, setActiveTasks] = useState([]); + const { data: session } = useSession(); + + const fetchTasks = () => { + fetch('/api/reservations?workerSid=' + session?.employeeNumber) + .then(response => response.json()) + .then(data => { + setTasks(data); + setActiveTasks(data.filter((task: any) => task.reservation.reservationStatus === 'accepted' || task.reservation.reservationStatus === 'pending')); + }); + + }; + + const updateReservation = (reservation: any, status: string) => { + try { + fetch(`/api/reservations?taskSid=${reservation.taskSid}&status=${status}&reservationSid=${reservation.sid}`, { + method: 'POST' + }) + } catch (error) { + console.error("Error updating reservation", error) + } + fetchTasks() + } + + const dequeueTask = (reservation: any) => { + try { + fetch(`/api/tasks?taskSid=${reservation.taskSid}&client=${session?.user?.email}&reservationSid=${reservation.sid}`, { + method: 'POST' + }) + } catch (error) { + console.error("Error dequeing reservation", error) + } + fetchTasks() + } + + useEffect(() => { + // Fetch tasks immediately and then every 5 seconds + fetchTasks(); + const intervalId = setInterval(fetchTasks, 5000); + + // Clear interval on component unmount + return () => clearInterval(intervalId); + }, []); + return (
-

Tasks

+

Tasks

+

See all unresolved communications with clients here.

+

{activeTasks.length} outstanding task{activeTasks.length == 1 ? "" : "s"}

+ + + + + + + + + + {activeTasks && Array.isArray(activeTasks) && activeTasks + .map((task: any) => ( + + + + + + ))} + +
TaskInitiatedActions
+ {task.task.taskChannelUniqueName === 'voice' ? ( + <>Call {JSON.parse(task.task.attributes).from || "unknown"} + ) : task.task.taskChannelUniqueName === 'chat' ? ( + <>Respond to message from {JSON.parse(task.task.attributes).from || "unknown"} + ) : null} + {formatTime(task.task.age)} + {task.task.taskChannelUniqueName === 'voice' ? ( + + ) : task.task.taskChannelUniqueName === 'chat' ? ( + + ) : null} + + {/* */} +
); -} +} \ No newline at end of file diff --git a/src/components/(dashboard)/tasks/IncomingCallModal.tsx b/src/components/(dashboard)/tasks/IncomingCallModal.tsx new file mode 100644 index 0000000..2544280 --- /dev/null +++ b/src/components/(dashboard)/tasks/IncomingCallModal.tsx @@ -0,0 +1,46 @@ +import { Button } from '@/components/ui/button'; +import { formatPhoneNumber } from '@/lib/utils'; +import { CircleUser } from 'lucide-react'; + +export default function IncomingCallModal({ + number, + acceptCall, + rejectCall, +}: { + number: string; + acceptCall: () => void; + rejectCall: () => void; +}) { + return ( +
+
+
+
+ +
+ {formatPhoneNumber(number)} +
+
+ Incoming Call +
+
+ +
+ + +
+
+
+
+ ); +} diff --git a/src/components/appbar/Dialpad.tsx b/src/components/appbar/Dialpad.tsx index df556e0..abb15f8 100644 --- a/src/components/appbar/Dialpad.tsx +++ b/src/components/appbar/Dialpad.tsx @@ -4,6 +4,8 @@ import { useEffect, useState } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; +import { ClientProfile } from "@/components/ui/client-profile"; + import { Phone, Delete, @@ -14,6 +16,7 @@ import { Hash, StickyNote, } from "lucide-react"; +import { CRMEntry } from "@/lib/crm/types"; export default function DialPad({ number, @@ -29,6 +32,27 @@ export default function DialPad({ endCall: () => void; }) { const onPress = (value: string) => setNumber(number.concat(value)); + const [name, setName] = useState(""); + const [notes, setNotes] = useState(""); + const [clientData, setClientData] = useState({}); + + const isFullNumber = number.length === 10; + + // hook to fetch data associated with the number + useEffect(() => { + console.log("Number changing...") + if (isFullNumber) { + fetch(`/api/crm_example?phone=${number}`) + .then((res) => res.json()) + .then((data) => { + console.log("Data fetched", data); + setClientData(data); + }); + } else { + setClientData(null); + } + }, [number]); + return (
@@ -48,6 +72,9 @@ export default function DialPad({ } />
+ {clientData && ( + + )}
@@ -78,9 +105,9 @@ export default function DialPad({ - + {/* - + */} @@ -91,6 +118,9 @@ export default function DialPad({
+ {clientData && ( + + )} endCall()} /> diff --git a/src/components/appbar/index.tsx b/src/components/appbar/index.tsx index 85cb3fc..be0d319 100644 --- a/src/components/appbar/index.tsx +++ b/src/components/appbar/index.tsx @@ -37,7 +37,8 @@ import NotificationsCard from "./NotificationsCard"; import { signOut } from "next-auth/react"; import useCalls from "@/lib/hooks/useCalls"; -// import { useSession } from "next-auth/react"; +import { useSession } from "next-auth/react"; +import IncomingCallModal from "../(dashboard)/tasks/IncomingCallModal"; interface AppbarProps extends React.HTMLAttributes { initials: string; @@ -49,12 +50,21 @@ export default function Appbar({ initials, isProgramManager, }: AppbarProps) { - // const { data: session, status } = useSession(); + const { data: session, status } = useSession(); - const { inCall, number, makeCall, setNumber, endCall } = useCalls({ - email: "michelleshx462@gmail.com", // TODO replace with okta auth info - workerSid: "WK3b277b4e6a1d67f2240477fa33f75ea4", // session?.employeeNumber, - friendlyName: "michelleshx462", // session?.user.name ?? '', + const { + inCall, + number, + makeCall, + setNumber, + endCall, + incomingCall, + acceptCall, + rejectCall + } = useCalls({ + email: session?.user?.email || '', + workerSid: session?.employeeNumber || '', + friendlyName: session?.user?.name || '', }); return ( @@ -114,7 +124,7 @@ export default function Appbar({ My Account - + Settings @@ -123,7 +133,7 @@ export default function Appbar({ <> - + Agents @@ -143,6 +153,7 @@ export default function Appbar({ + {incomingCall && ()} ); } diff --git a/src/components/ui/client-profile.tsx b/src/components/ui/client-profile.tsx new file mode 100644 index 0000000..99725fc --- /dev/null +++ b/src/components/ui/client-profile.tsx @@ -0,0 +1,39 @@ +import { CRMEntry } from "@/lib/crm/types"; +import { useEffect, useState } from "react"; + +export function ClientProfile({ clientData }: { clientData: CRMEntry }) { + const [seeMore, setSeeMore] = useState(false); + + const imgUrl = clientData.img_src + + + return ( +
+

View profile: {clientData.name}

+ {seeMore && ( +
+ + + + + + + + + + + +
Notes: {clientData.notes} 🖊️
Address: {clientData.Address}
+ +
+ )} +
+ {seeMore ? ( + + ) : ( + + )} +
+
+ ); +} \ No newline at end of file diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 311ebf8..eed8171 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -17,14 +17,13 @@ export const authOptions: NextAuthOptions = { callbacks: { async jwt({ token, account }: any) { if (account) { - token.accessToken = account.access_token; token.idToken = account.id_token; token.oktaId = account.providerAccountId; token.groups = account.groups; + token.employeeNumber = account.employeeNumber; } - // Decrypting JWT to check if expired var tokenParsed = JSON.parse( Buffer.from(token.idToken.split('.')[1], 'base64').toString() @@ -34,8 +33,41 @@ export const authOptions: NextAuthOptions = { throw Error('expired token'); } - // Add fields to token that are useful - token.employeeNumber = tokenParsed.employeeNumber; + // Request token from Okta API to get user's employeeNumber + if (!token.employeeNumber) { + const response = await fetch(`${process.env.OKTA_OAUTH2_ISSUER}/api/v1/users/${tokenParsed.sub}`, { + headers: { + 'Authorization': `SSWS ${process.env.OKTA_API_KEY}` + } + }); + const userData = await response.json(); + token.employeeNumber = userData.profile.employeeNumber; + } + + // If employeeNumber is still not found, create a new Twilio worker and assign ID + if (!token.employeeNumber) { + const accountSid = process.env.TWILIO_ACCOUNT_SID; + const authToken = process.env.TWILIO_AUTH_TOKEN; + const client = require('twilio')(accountSid, authToken); + + client.taskrouter.v1.workspaces(process.env.TWILIO_WORKSPACE_SID) + .workers + .create({friendlyName: tokenParsed.email}) + .then(async (worker: { sid: any; }) => { + token.employeeNumber = worker.sid; + const profile = {'profile':{"employeeNumber": worker.sid}}; + + const response = await fetch(`${process.env.OKTA_OAUTH2_ISSUER}/api/v1/users/${tokenParsed.sub}`, { + method: 'POST', + headers: { + 'Authorization': `SSWS ${process.env.OKTA_API_KEY}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify(profile) + }); + }) + } + token.groups = tokenParsed.groups; return token; @@ -47,6 +79,8 @@ export const authOptions: NextAuthOptions = { session.userType = token.userType; session.employeeNumber = token.employeeNumber; session.groups = token.groups; + session.user.email = token.email + session.user.name = token.name return session; }, diff --git a/src/lib/hooks/useCalls.ts b/src/lib/hooks/useCalls.ts index 6a285a3..2fb7775 100644 --- a/src/lib/hooks/useCalls.ts +++ b/src/lib/hooks/useCalls.ts @@ -119,7 +119,7 @@ export default function useCalls({ device.current.on("registered", function () { console.log("Twilio.Device Ready to make and receive calls!"); }); - + device.current.on("error", function (error: { message: string }) { console.log("Twilio.Device Error: " + error.message); }); @@ -171,7 +171,7 @@ export default function useCalls({ checkEmail.current = email; const initializeCalls = async () => { await Promise.all([ - await initializeDevice(email).then((newDevice) => { + await initializeDevice(email, workerSid).then((newDevice) => { device.current = newDevice; initializeDeviceListeners(); }), @@ -221,12 +221,13 @@ export default function useCalls({ const makeCall = async (number: string) => { if (!device.current) return; - const params = { // get the phone number to call from the DOM To: number, }; + console.log("Making a call to", number); + const newCall = await device.current.connect({ params }); call.current = newCall; @@ -333,9 +334,10 @@ export default function useCalls({ * this is the same value as the "client_url" in the worker's attribute * */ -async function initializeDevice(client: string) { +async function initializeDevice(client: string, workerSid: string) { + console.log("Initializing device", client) const token = await fetch( - `${process.env.NEXT_PUBLIC_URL}/api/token?client=${client}` + `http://localhost:3000/api/token?client=${client}` ); const value = await token.json(); @@ -346,6 +348,7 @@ async function initializeDevice(client: string) { }); await device.register(); + return device; } @@ -360,32 +363,33 @@ async function initializeDevice(client: string) { * @param friendlyName - the friendly name found in Okta and Twliio * */ -const initializeWorker = async ( - workerSid: string | undefined, - email: string, - friendlyName: string -) => { - try { - if (!workerSid) { - throw `The user ${friendlyName} with email ${email} does not have an employeeNumber in Okta`; - } - const tokenResponse = await fetch( - `${process.env.NEXT_PUBLIC_URL}/api/workerToken?email=${email}&workerSid=${workerSid}` - ); - - if (tokenResponse.status !== 200) { - throw `Failed to generate valid token for ${friendlyName} with email ${email}`; - } - - const token = await tokenResponse.json(); - - const worker = new Worker(token); - await timeout(1000); // For some reason, this is some much needed black magic - return worker; - } catch (e) { - console.error(e); - } -}; +// const initializeWorker = async ( +// workerSid: string | undefined, +// email: string, +// friendlyName: string +// ) => { +// try { +// if (!workerSid) { +// throw `The user ${friendlyName} with email ${email} does not have an employeeNumber in Okta`; +// } +// const tokenResponse = await fetch( +// `${process.env.NEXT_PUBLIC_URL}/api/workerToken?email=${email}&workerSid=${workerSid}` +// ); + +// if (tokenResponse.status !== 200) { +// throw `Failed to generate valid token for ${friendlyName} with email ${email}`; +// } + +// const token = await tokenResponse.json(); + +// const worker = new Worker(token); +// console.log("WORKER IS", worker) +// await timeout(1000); // For some reason, this is some much needed black magic +// return worker; +// } catch (e) { +// console.error(e); +// } +// }; function timeout(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)); diff --git a/src/types/next-auth.d.ts b/src/types/next-auth.d.ts new file mode 100644 index 0000000..cd54a93 --- /dev/null +++ b/src/types/next-auth.d.ts @@ -0,0 +1,14 @@ +import NextAuth, { DefaultSession } from "next-auth" + +declare module "next-auth" { + +// extend session interface + interface Session { + employeeNumber: string, + groups: string[], + user: { + email: string, + name: string + } + } +} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index e59724b..ed67f38 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -22,6 +22,6 @@ "@/*": ["./src/*"] } }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", "src/app/api/worker"], "exclude": ["node_modules"] }