diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..7698750 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +./node_modules +Dockerfile +.dockerignore +docker-compose.yml +.env.local +start.txt +.next \ No newline at end of file diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..30db46b --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +entrypoint.sh text eol=lf \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..142eeca --- /dev/null +++ b/Dockerfile @@ -0,0 +1,17 @@ +FROM docker:20-dind + +RUN apk add --no-cache nodejs npm + +WORKDIR /app + +COPY package*.json ./ + +RUN npm install + +COPY . . + +RUN chmod +x /app/entrypoint.sh + +EXPOSE 3000 + +ENTRYPOINT ["sh", "-c", "dockerd-entrypoint.sh &>/dev/null & /app/entrypoint.sh"] \ No newline at end of file diff --git a/README.md b/README.md index 423266b..038633f 100644 --- a/README.md +++ b/README.md @@ -6,21 +6,11 @@ The Virtual Event Starter Kit simplifies organizing and executing virtual events ![Alt text](https://i.imgur.com/odAVqks.png) -### TODO -*** -1. Database seeding script to populate database with sample data. -2. Unit tests for backend and frontend. -3. Live chat functionality. -4. Dockerize the app. - ### Features *** - **Multiple stages:** Customize and manage multiple event stages effortlessly, accommodating various sessions on each stage.
-- **Flexible stage configuration:** Configure each stage with either an embedded YouTube stream or a live interactive audio-video experience powered by a third-party service.
-- **Sponsor expo:** Showcase sponsors with individual virtual booths, allowing for enhanced visibility and engagement opportunities.
-- **Career Fair:** Facilitate networking and job discovery for attendees with dedicated career fair sections.
-- **Ticket registration and generation:** Simplify event registration and ticket issuance processes for attendees.
-- **Speaker pages and bios:** Highlight event speakers with dedicated pages featuring bios and contributions.
+- **Flexible stage configuration:** Configure each stage with an embedded YouTube video.
+- **Speaker pages:** Highlight event speakers with dedicated pages.
- **Schedule management:** Streamline event scheduling and coordination with a comprehensive schedule management system.
### Technologies Used @@ -61,53 +51,17 @@ The backend API sends the response back to the frontend, which then displays the Additionally, the backend API may also send responses directly to the user in certain scenarios, such as authentication or error messages. -## Installation - -Follow these steps to set up the project locally: - -### Prerequisites - -- Node.js and npm must be installed on your machine. -- Docker must be installed and running on your machine for the Supabase services. - -### Setup - -1. **Install dependencies** - - Open a terminal in the project directory and run the following command: - - ```bash - npm install - -1. **Create a local environment file** - - Create a file named ```.env.local``` in the root of your project directory. This file will - store your local environment variables. - -1. **Start Supabase locally** - - To start Supabase locally, ensure the Docker daemon is running, then execute the - following command: - - ```bash - npx supabase start - -1. **Configure environment variables** - - After starting Supabase, it will provide an **API_URL** and an **ANON_KEY**. You need - to add these values to your ```.env.local``` file as follows: - - NEXT_PUBLIC_SUPABASE_URL= - - NEXT_PUBLIC_SUPABASE_ANON_KEY= - -1. **Start the development server** - - Finally, to run your project locally, execute: +### Installation - ```bash - npm run dev +**Prerequisites** +- Docker -This will start the development server. Once running, you can access your project at [http://localhost:3000/](http://localhost:3000/). +**Run locally** +``` +git clone https://github.com/devzero-inc/virtual-event-starter-kit.git +cd virtual-event-starter-kit +docker compose up +``` -*Note* - You wont see any schedules or speakers detail on the website since it wont be there on your local database. +The app will be running on PORT:3000 -> http://localhost:3000/ +Now just go to http://localhost:3000/ and explore the application. diff --git a/app/api/events/route.ts b/app/api/events/route.ts index eb8e377..66de0b8 100644 --- a/app/api/events/route.ts +++ b/app/api/events/route.ts @@ -1,20 +1,18 @@ import { NextResponse } from "next/server"; -import { getEventsFromSupabase } from "@/utils/controller"; import { AccessDeniedError, SupabaseError, - UnhandledError, NotAuthenticatedError, } from "@/errors/databaseerror"; +import { getEventsFromSupabase } from "@/controller/controller"; -export async function GET() { +export async function GET(): Promise { try { - const data = await getEventsFromSupabase(); + const events = await getEventsFromSupabase(); return NextResponse.json({ - message: "retrieved data successfully", - data: data, status: 200, + events: events, }); } catch (err) { if (err instanceof AccessDeniedError) { @@ -27,16 +25,16 @@ export async function GET() { status: 503, message: err.message, }); - } else if (err instanceof UnhandledError) { - return NextResponse.json({ - status: 500, - message: err.message, - }); } else if (err instanceof NotAuthenticatedError) { return NextResponse.json({ status: 401, message: err.message, }); + } else { + return NextResponse.json({ + status: 500, + message: "Internal server error", + }); } } } diff --git a/app/api/routes.spec.ts b/app/api/routes.spec.ts new file mode 100644 index 0000000..6ffaa06 --- /dev/null +++ b/app/api/routes.spec.ts @@ -0,0 +1,160 @@ +import { GET as eventsGet } from "@/app/api/events/route"; +import { GET as speakersGet } from "@/app/api/speakers/route"; + +import { + getEventsFromSupabase, + getSpeakersFromSupabase, +} from "@/controller/controller"; +import { + SupabaseError, + NotAuthenticatedError, + AccessDeniedError, + UnhandledError, +} from "@/errors/databaseerror"; + +jest.mock("../../controller/controller", () => ({ + getEventsFromSupabase: jest.fn(), + getSpeakersFromSupabase: jest.fn(), +})); + +describe("GET api/events", () => { + it("should return status 200 if events are fetched successfully", async () => { + (getEventsFromSupabase as jest.Mock).mockResolvedValueOnce([ + { id: 1, title: "Test Events" }, + ]); + + const response = await eventsGet(); + const jsonResponse = await response.json(); + + expect(jsonResponse).toEqual({ + status: 200, + events: [{ id: 1, title: "Test Events" }], + }); + }); + + it("should return status 503 if table is not found", async () => { + const errMessage = "SUPABASE_ERR"; + (getEventsFromSupabase as jest.Mock).mockRejectedValueOnce( + new SupabaseError(errMessage) + ); + const response = await eventsGet(); + const jsonResponse = await response.json(); + + expect(jsonResponse).toEqual({ + status: 503, + message: errMessage, + }); + }); + + it("should return status 403 if the user is denied access to the server", async () => { + const errMessage = "ACCESS_DENIED"; + + (getEventsFromSupabase as jest.Mock).mockRejectedValueOnce( + new AccessDeniedError(errMessage) + ); + const response = await eventsGet(); + const jsonResponse = await response.json(); + + expect(jsonResponse).toEqual({ + status: 403, + message: errMessage, + }); + }); + + it("should return status 500 if it is an unhandled error", async () => { + const errMessage = "UNHANDLED_ERR"; + + (getEventsFromSupabase as jest.Mock).mockRejectedValueOnce( + new UnhandledError(errMessage) + ); + const response = await eventsGet(); + const jsonResponse = await response.json(); + + expect(jsonResponse).toEqual({ + status: 500, + message: 'Internal server error', + }); + }); + + it("should return status 401 if the user is unauthenticated", async () => { + const errMessage = "Not Authenticated Error"; + (getEventsFromSupabase as jest.Mock).mockRejectedValueOnce( + new NotAuthenticatedError(errMessage) + ); + const response = await eventsGet(); + const jsonResponse = await response.json(); + + expect(jsonResponse).toEqual({ + status: 401, + message: errMessage, + }); + }); +}); + +describe("GET api/speakers", () => { + it("should return status 200 if speakers are fetched successfully", async () => { + (getSpeakersFromSupabase as jest.Mock).mockResolvedValueOnce([ + { id: 1, title: "Test Speakers" }, + ]); + const response = await speakersGet(); + const jsonResponse = await response!.json(); + expect(jsonResponse).toEqual({ + status: 200, + speakers: [{ id: 1, title: "Test Speakers" }], + }); + }); + + it("should return status 503 if table is not found", async () => { + const errMessage = "SUPABASE_ERR"; + (getSpeakersFromSupabase as jest.Mock).mockRejectedValueOnce( + new SupabaseError(errMessage) + ); + const response = await speakersGet(); + const jsonResponse = await response!.json(); + expect(jsonResponse).toEqual({ + status: 503, + message: errMessage, + }); + }); + + it("should return status 403 if user is denied access to the server", async () => { + const errMessage = "ACCESS_DENIED"; + (getSpeakersFromSupabase as jest.Mock).mockRejectedValueOnce( + new AccessDeniedError(errMessage) + ); + const response = await speakersGet(); + const jsonResponse = await response!.json(); + + expect(jsonResponse).toEqual({ + status: 403, + message: errMessage, + }); + }); + + it("should return status 500 if it is an unhandled error", async () => { + const errMessage = "UNHANDLED_ERR"; + (getSpeakersFromSupabase as jest.Mock).mockRejectedValueOnce( + new UnhandledError(errMessage) + ); + const response = await speakersGet(); + const jsonResponse = await response!.json(); + + expect(jsonResponse).toEqual({ + status: 500, + message: 'Internal server error', + }); + }); + + it("should return status 401 if the user is unauthenticated", async () => { + const errMessage = "AUTH_DENIED"; + (getSpeakersFromSupabase as jest.Mock).mockRejectedValueOnce( + new NotAuthenticatedError(errMessage) + ); + const response = await speakersGet(); + const jsonResponse = await response!.json(); + expect(jsonResponse).toEqual({ + status: 401, + message: errMessage, + }); + }); +}); diff --git a/app/api/speakers/route.ts b/app/api/speakers/route.ts index e6feb3d..714d486 100644 --- a/app/api/speakers/route.ts +++ b/app/api/speakers/route.ts @@ -1,20 +1,19 @@ -import { NextResponse } from "next/server"; -import { getSpeakersFromSupabase } from "@/utils/controller"; import { AccessDeniedError, SupabaseError, UnhandledError, NotAuthenticatedError, } from "@/errors/databaseerror"; +import { getSpeakersFromSupabase } from "@/controller/controller"; +import { NextResponse } from "next/server"; export async function GET() { try { - const data = await getSpeakersFromSupabase(); + const speakers = await getSpeakersFromSupabase(); return NextResponse.json({ - message: "retrieved data successfully", - data: data, status: 200, + speakers: speakers, }); } catch (err) { if (err instanceof AccessDeniedError) { @@ -27,16 +26,16 @@ export async function GET() { status: 503, message: err.message, }); - } else if (err instanceof UnhandledError) { - return NextResponse.json({ - status: 500, - message: err.message, - }); } else if (err instanceof NotAuthenticatedError) { return NextResponse.json({ status: 401, message: err.message, }); + } else { + return NextResponse.json({ + status: 500, + message: "Internal server error", + }); } } } diff --git a/app/components/ChatContent.tsx b/app/components/ChatContent.tsx new file mode 100644 index 0000000..ff73204 --- /dev/null +++ b/app/components/ChatContent.tsx @@ -0,0 +1,104 @@ +"use client" +import { useEffect, useState, useRef } from "react"; +import { supabase } from "@/lib/supabaseClient"; +import { RealtimeChannel } from "@supabase/supabase-js"; + +interface Message { + content: string; + from: string; +} + +const ChatContent = () => { + const [channel, setChannel] = useState(); + const [message, setMessage] = useState(""); + const [messages, setMessages] = useState([]); + const [name, setName] = useState(""); + const messagesEndRef = useRef(null); + + useEffect(() => { + const name = localStorage.getItem("name"); + if (name) { + setName(name); + } + }, []); + + useEffect(() => { + if (name) { + const channel = supabase.channel(`room:a`, { + config: { + broadcast: { + self: true, + }, + }, + }); + + channel.on("broadcast", { event: "message" }, ({ payload }) => { + setMessages((prev) => [...prev, payload]); + }); + + channel.subscribe(); + + setChannel(channel); + + return () => { + channel.unsubscribe(); + setChannel(undefined); + }; + } + }, [name]); + + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [messages]); + + const sendMessage = async (e: React.FormEvent) => { + e.preventDefault(); + if (channel) { + await channel.send({ + type: "broadcast", + event: "message", + payload: { content: message, from: name }, + }); + } + setMessage(""); + }; + + return ( +
+

Welcome to the Chat Room,
{name}

+
+ {messages.map((message, index) => ( +
+

{message.from}

+

{message.content}

+
+ ))} +
+
+
+ setMessage(e.target.value)} + value={message} + /> + +
+
+ ); +}; + +export default ChatContent; diff --git a/app/components/DevzeroStageContent.tsx b/app/components/DevzeroStageContent.tsx index f50f1c1..873c901 100644 --- a/app/components/DevzeroStageContent.tsx +++ b/app/components/DevzeroStageContent.tsx @@ -13,7 +13,7 @@ const DevzeroStageContent = () => { useEffect(() => { getEvents() .then((data) => { - const eventArray:schedule[] = data.data; + const eventArray:schedule[] = data.events; const filteredArray:schedule[] = eventArray.filter((ele: schedule) => { return ele.event_type === "DevZero Stage"; }); @@ -24,7 +24,7 @@ const DevzeroStageContent = () => { return (
-
diff --git a/app/components/Footer.tsx b/app/components/Footer.tsx index 5425b0b..2659f68 100644 --- a/app/components/Footer.tsx +++ b/app/components/Footer.tsx @@ -6,10 +6,10 @@ const Footer = () => {

DevZero Stage

- +
) } diff --git a/app/components/LiveStageContent.tsx b/app/components/LiveStageContent.tsx new file mode 100644 index 0000000..fd886ba --- /dev/null +++ b/app/components/LiveStageContent.tsx @@ -0,0 +1,26 @@ +import UserEntry from "./UserEntry"; + +const LiveStageContent = () => { + return ( +
+
+

+ Welcome to DevZero + +  LIVE  + + Stage +

+

+ Engage in real-time dialogue and collaborate with professionals. Join + the conversation and share insights on the latest in development and + technology. +

+
+ + +
+ ); +}; + +export default LiveStageContent; diff --git a/app/components/Navbar.tsx b/app/components/Navbar.tsx index 2b6f651..da2823e 100644 --- a/app/components/Navbar.tsx +++ b/app/components/Navbar.tsx @@ -1,74 +1,135 @@ -"use client" +"use client"; import Image from "next/image"; import Link from "next/link"; import { usePathname } from "next/navigation"; import { useState } from "react"; const Navbar = () => { + const [dropdown, setDropdown] = useState(false); - const [dropdown, setDropdown] = useState(false); + const pathname = usePathname(); + const isActive = (href: string) => pathname === href; - const pathname = usePathname(); - const isActive = (href: string) => pathname === href; - - return ( -
-
- Logo -

DevZero

-
-
    - {/* - Live Stage - */} - - Devzero Stage - - - Schedule - - - Speakers - -
-
- -
-
    -
  • - - Devzero Stage - -
  • -
  • - - Schedule - -
  • -
  • - - Speakers - -
  • -
-
-
+ return ( +
+
+ Logo +

+ Dev + Zero +

+
+
    + + Live Stage + + + Devzero Stage + + + Schedule + + + Speakers + +
+
+ +
+
    +
  • + + Live Stage + +
  • +
  • + + Devzero Stage + +
  • +
  • + + Schedule + +
  • +
  • + + Speakers + +
  • +
- ) -} +
+
+ ); +}; export default Navbar; diff --git a/app/components/RoomContent.tsx b/app/components/RoomContent.tsx new file mode 100644 index 0000000..7b7eec5 --- /dev/null +++ b/app/components/RoomContent.tsx @@ -0,0 +1,19 @@ +import Video from "./Video"; +import Footer from "./Footer"; +import ChatContent from "./ChatContent"; + +const RoomContent = () => { + return ( +
+
+
+
+ +
+
+ ) +} + +export default RoomContent diff --git a/app/components/ScheduleContent.tsx b/app/components/ScheduleContent.tsx index 7be3a4a..7e38c32 100644 --- a/app/components/ScheduleContent.tsx +++ b/app/components/ScheduleContent.tsx @@ -11,7 +11,7 @@ const ScheduleContent = () => { useEffect(() => { getEvents().then((data) => { - const eventArray = data.data; + const eventArray = data.events; const liveTimingMap: timingMap = {}; const devzeroTimingMap: timingMap = {}; diff --git a/app/components/SpeakersContent.tsx b/app/components/SpeakersContent.tsx index b795812..813a152 100644 --- a/app/components/SpeakersContent.tsx +++ b/app/components/SpeakersContent.tsx @@ -10,7 +10,7 @@ const SpeakersContent = () => { useEffect(() => { getSpeakers().then((data) => { - setSpeakers(data.data); + setSpeakers(data.speakers); }); }, []); diff --git a/app/components/UserEntry.tsx b/app/components/UserEntry.tsx new file mode 100644 index 0000000..64bb86f --- /dev/null +++ b/app/components/UserEntry.tsx @@ -0,0 +1,35 @@ +"use client"; + +import React, { useState } from "react"; +import { useRouter } from "next/navigation"; + +const EntryComponent = () => { + const [name, setName] = useState(""); + const router = useRouter(); + + const enterChatRoom = (e:React.FormEvent) => { + e.preventDefault(); + localStorage.setItem("name", name); + router.push(`/live-stage/a`); + }; + + return ( +
+ setName(e.target.value)} + /> + +
+ ); +}; + +export default EntryComponent; diff --git a/app/live-stage/[roomId]/page.tsx b/app/live-stage/[roomId]/page.tsx new file mode 100644 index 0000000..dd5f670 --- /dev/null +++ b/app/live-stage/[roomId]/page.tsx @@ -0,0 +1,11 @@ +import RoomContent from "@/app/components/RoomContent"; + +const page = () => { + return ( +
+ +
+ ) +} + +export default page diff --git a/app/live-stage/page.tsx b/app/live-stage/page.tsx new file mode 100644 index 0000000..745d0d7 --- /dev/null +++ b/app/live-stage/page.tsx @@ -0,0 +1,11 @@ +import LiveStageContent from "../components/LiveStageContent" + +const page = () => { + return ( +
+ +
+ ) +} + +export default page diff --git a/controller/controller.spec.ts b/controller/controller.spec.ts new file mode 100644 index 0000000..1cf0dc5 --- /dev/null +++ b/controller/controller.spec.ts @@ -0,0 +1,174 @@ +import { + getEventsFromSupabase, + getSpeakersFromSupabase, +} from "@/controller/controller"; +import { supabase } from "@/lib/supabaseClient"; +import { PostgrestError } from "@supabase/supabase-js"; +import redis from "@/lib/redisClient"; +import { UnhandledError } from "@/errors/databaseerror"; + +jest.mock("../lib/supabaseClient", () => ({ + supabase: { + from: jest.fn().mockReturnThis(), + select: jest.fn(), + }, +})); + +jest.mock("../lib/redisClient", () => ({ + get: jest.fn(), + set: jest.fn(), +})); + +describe("getEventsFromSupabase", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("should return cached data if available", async () => { + const cachedData = [{ event_id: 1, event_title: "Event 1" }]; + jest.spyOn(redis, "get").mockResolvedValueOnce(JSON.stringify(cachedData)); + + const result = await getEventsFromSupabase(); + + expect(redis.get).toHaveBeenCalledWith("events"); + expect(supabase.from).not.toHaveBeenCalled(); + expect(result).toEqual(cachedData); + }); + + it("should fetch data from Supabase if cache is empty", async () => { + const supabaseData = [{ event_id: 1, event_title: "Event 1" }]; + const supabaseResponse = { data: supabaseData, error: null }; + jest.spyOn(redis, "get").mockResolvedValueOnce(null); + (supabase.from("events").select as jest.Mock).mockReturnValueOnce( + supabaseResponse + ); + + const result = await getEventsFromSupabase(); + + expect(redis.get).toHaveBeenCalledWith("events"); + expect(supabase.from).toHaveBeenCalledWith("events"); + expect(redis.set).toHaveBeenCalledWith( + "events", + JSON.stringify(supabaseData), + "EX", + 600 + ); + expect(result).toEqual(supabaseData); + }); + + it("should throw an error if Supabase returns an error", async () => { + const supabaseError: PostgrestError = { + code: "PGRST200", + message: "Table not found", + hint: "", + details: "", + }; + const supabaseResponse = { data: null, error: supabaseError }; + jest.spyOn(redis, "get").mockResolvedValueOnce(null); + (supabase.from("events").select as jest.Mock).mockReturnValueOnce( + supabaseResponse + ); + + try { + await getEventsFromSupabase(); + } catch (error) { + expect(error).toBeInstanceOf(UnhandledError); + } + + expect(redis.get).toHaveBeenCalledWith("events"); + expect(supabase.from).toHaveBeenCalledWith("events"); + expect(redis.set).not.toHaveBeenCalled(); + }); + + it("should throw an UnhandledError if an unexpected error occurs", async () => { + const unexpectedError = new Error("Unexpected error"); + jest.spyOn(redis, "get").mockRejectedValueOnce(unexpectedError); + + try { + await getEventsFromSupabase(); + } catch (error) { + expect(error).toBeInstanceOf(UnhandledError); + } + + expect(redis.get).toHaveBeenCalledWith("events"); + expect(supabase.from).not.toHaveBeenCalled(); + expect(redis.set).not.toHaveBeenCalled(); + }); +}); + +describe("getSpeakersFromSupabase", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("should return cached data if available", async () => { + const cachedData = [{ speaker_id: 1, speaker_name: "Speaker 1" }]; + jest.spyOn(redis, "get").mockResolvedValueOnce(JSON.stringify(cachedData)); + + const result = await getSpeakersFromSupabase(); + + expect(redis.get).toHaveBeenCalledWith("speakers"); + expect(supabase.from).not.toHaveBeenCalled(); + expect(result).toEqual(cachedData); + }); + + it("should fetch data from Supabase if cache is empty", async () => { + const supabaseData = [{ speaker_id: 1, speaker_name: "Speaker 1" }]; + const supabaseResponse = { data: supabaseData, error: null }; + jest.spyOn(redis, "get").mockResolvedValueOnce(null); + (supabase.from("speakers").select as jest.Mock).mockReturnValueOnce( + supabaseResponse + ); + + const result = await getSpeakersFromSupabase(); + + expect(redis.get).toHaveBeenCalledWith("speakers"); + expect(supabase.from).toHaveBeenCalledWith("speakers"); + expect(redis.set).toHaveBeenCalledWith( + "speakers", + JSON.stringify(supabaseData), + "EX", + 600 + ); + expect(result).toEqual(supabaseData); + }); + + it("should throw an error if Supabase returns an error", async () => { + const supabaseError: PostgrestError = { + code: "PGRST200", + message: "Table not found", + hint: "", + details: "", + }; + const supabaseResponse = { data: null, error: supabaseError }; + jest.spyOn(redis, "get").mockResolvedValueOnce(null); + (supabase.from("speakers").select as jest.Mock).mockReturnValueOnce( + supabaseResponse + ); + + try { + await getSpeakersFromSupabase(); + } catch (error) { + expect(error).toBeInstanceOf(UnhandledError); + } + + expect(redis.get).toHaveBeenCalledWith("speakers"); + expect(supabase.from).toHaveBeenCalledWith("speakers"); + expect(redis.set).not.toHaveBeenCalled(); + }); + + it("should throw an UnhandledError if an unexpected error occurs", async () => { + const unexpectedError = new Error("Unexpected error"); + jest.spyOn(redis, "get").mockRejectedValueOnce(unexpectedError); + + try { + await getSpeakersFromSupabase(); + } catch (error) { + expect(error).toBeInstanceOf(UnhandledError); + } + + expect(redis.get).toHaveBeenCalledWith("speakers"); + expect(supabase.from).not.toHaveBeenCalled(); + expect(redis.set).not.toHaveBeenCalled(); + }); +}); diff --git a/controller/controller.ts b/controller/controller.ts new file mode 100644 index 0000000..dc7a09f --- /dev/null +++ b/controller/controller.ts @@ -0,0 +1,84 @@ +import { supabase } from "@/lib/supabaseClient"; +import { + SupabaseError, + AccessDeniedError, + UnhandledError, + NotAuthenticatedError, +} from "@/errors/databaseerror"; +import redis from "@/lib/redisClient"; + +export async function getEventsFromSupabase() { + + const cacheKey = 'events'; + + try { + + let cachedData = await redis.get(cacheKey); + if(cachedData) { + return JSON.parse(cachedData); + } + + const { data, error } = await supabase.from("events").select(` + event_id, + event_title, + event_timing, + event_type, + created_at, + updated_at, + speakers ( + speaker_name + ) + `); + if (error) { + switch (error.code) { + case "PGRST200": + throw new SupabaseError("Table not found"); + case "PGRST302": + throw new AccessDeniedError("Access Denied"); + case "PGRST301": + throw new NotAuthenticatedError("Not Authenticated"); + default: + throw new UnhandledError("Internal server error"); + } + } else { + await redis.set(cacheKey, JSON.stringify(data), 'EX', 600); + } + return data; + } catch (error) { + console.error("Error fetching events from Supabase:", error); + throw new UnhandledError("Internal server error"); + } +} + +export async function getSpeakersFromSupabase() { + + const cacheKey = 'speakers'; + + try { + + let cachedData = await redis.get(cacheKey); + if(cachedData) { + return JSON.parse(cachedData); + } + + const { data, error } = await supabase.from('speakers').select('*'); + if (error) { + switch (error.code) { + case "PGRST200": + throw new SupabaseError("Table not found"); + case "PGRST302": + throw new AccessDeniedError("Access Denied"); + case "PGRST301": + throw new NotAuthenticatedError("Not Authenticated"); + default: + throw new UnhandledError("Internal server error"); + } + } else { + await redis.set(cacheKey, JSON.stringify(data), 'EX', 600); + } + return data; + } catch (error) { + console.error('Error fetching speakers from Supabase:', error); + throw new UnhandledError('Internal server error'); + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..9d690d0 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,22 @@ +version: '3.8' +services: + app: + build: . + container_name: nextjs-dind-app-virtual-event + privileged: true + environment: + - DOCKER_TLS_CERTDIR=/certs + - REDIS_URL=redis://redis-container:6379 + volumes: + - /var/lib/docker + ports: + - '3000:3000' + - '54321:54321' + depends_on: + - redis-container + + redis-container: + image: redis + container_name: nextjs-redis + ports: + - '6379:6379' diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 0000000..d703db6 --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,14 @@ +#!/bin/sh + +echo "Waiting for the Docker daemon to start..." +while ! docker info >/dev/null 2>&1; do + sleep 1 +done +echo "Docker daemon is up and running!" + +npx supabase start -x imgproxy,migra,studio,edge-runtime,logflare,vector,pgbouncer > start.txt + +echo "NEXT_PUBLIC_SUPABASE_URL=$(awk '/API URL:/ {print $3}' start.txt)" > .env.local +echo "NEXT_PUBLIC_SUPABASE_ANON_KEY=$(awk '/anon key:/ {print $3}' start.txt)" >> .env.local + +exec npm run startapp \ No newline at end of file diff --git a/errors/databaseerror.ts b/errors/databaseerror.ts index 3cff5ab..55c24bd 100644 --- a/errors/databaseerror.ts +++ b/errors/databaseerror.ts @@ -1,35 +1,32 @@ -class DatabaseErrror extends Error { - status: string; - message: string; - - constructor(status: string, message: string) { +class DatabaseError extends Error { + code: string; + + constructor(message: string, code: string) { super(message); - this.status = status; - this.message = message; + this.code = code; } } -class NotAuthenticatedError extends DatabaseErrror { - constructor() { - super('AUTH_DENIED', 'User not authenticated.'); +class NotAuthenticatedError extends DatabaseError { + constructor(message: string) { + super(message, 'AUTH_DENIED'); } } -class AccessDeniedError extends DatabaseErrror { - constructor() { - super('ACCESS_DENIED', 'Access to this resource denied.'); +class AccessDeniedError extends DatabaseError { + constructor(message: string) { + super(message, 'ACCESS_DENIED'); } } -class SupabaseError extends DatabaseErrror { - constructor() { - super('SUPABASE_ERR', ` Supabase error occurred.`); +class SupabaseError extends DatabaseError { + constructor(message: string) { + super(message, 'SUPABASE_ERR'); } } - -class UnhandledError extends DatabaseErrror { - constructor() { - super("UNHANDLED_ERR", 'Internal Server Error.'); +class UnhandledError extends DatabaseError { + constructor(message: string) { + super(message, 'UNHANDLED_ERROR'); } } diff --git a/__tests__/http-api.test.ts b/http/http-api.spec.ts similarity index 52% rename from __tests__/http-api.test.ts rename to http/http-api.spec.ts index 47cf054..9705627 100644 --- a/__tests__/http-api.test.ts +++ b/http/http-api.spec.ts @@ -1,4 +1,4 @@ -import { getEvents } from "@/http/api"; +import { getEvents, getSpeakers } from "@/http/api"; global.fetch = jest.fn(); @@ -42,3 +42,41 @@ describe("getEvents function", () => { } }); }); + +describe("getSpeakers function", () => { + it("should fetch speakers correctly", async () => { + const mockData = { + message: "retrieved data successfully", + data: [ + { + id: "a7efc63d-3e11-4bcb-b414-854235f7a5d6", + speaker_name: "John Doe", + position: "software engineer", + created_at: "2021-02-01T00:00:00.000Z", + updated_at: "2021-02-01T00:00:00.000Z", + }, + ], + status: 200, + }; + + const mockResponse = { json: jest.fn().mockResolvedValue(mockData) }; + (global.fetch as jest.Mock).mockResolvedValue(mockResponse); + + const result = await getSpeakers(); + + expect(result).toEqual(mockData); + expect(global.fetch).toHaveBeenCalledWith("/api/speakers"); + }); + + it("should throw an error if fetch fails", async () => { + const mockError = new Error("Failed to fetch"); + (global.fetch as jest.Mock).mockRejectedValue(mockError); + + try { + await getSpeakers(); + } catch (error) { + expect(error).toEqual(mockError); + } + }); +}); + diff --git a/lib/redisClient.ts b/lib/redisClient.ts new file mode 100644 index 0000000..6a81f91 --- /dev/null +++ b/lib/redisClient.ts @@ -0,0 +1,6 @@ +import Redis from "ioredis"; + +const redisUrl = process.env.REDIS_URL as string; +const redisClient = new Redis(redisUrl); + +export default redisClient; diff --git a/package-lock.json b/package-lock.json index 2af1c39..61f76e7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,8 @@ "@mui/material": "^5.15.10", "@supabase/ssr": "^0.1.0", "@supabase/supabase-js": "^2.39.6", + "dotenv": "^16.4.4", + "ioredis": "^5.3.2", "next": "14.1.0", "react": "^18", "react-dom": "^18", @@ -1026,6 +1028,11 @@ "integrity": "sha512-6EwiSjwWYP7pTckG6I5eyFANjPhmPjUX9JRLUSfNPC7FX7zK9gyZAfUEaECL6ALTpGX5AjnBq3C9XmVWPitNpw==", "dev": true }, + "node_modules/@ioredis/commands": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.2.0.tgz", + "integrity": "sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg==" + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -3484,6 +3491,14 @@ "node": ">=6" } }, + "node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/cmd-shim": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/cmd-shim/-/cmd-shim-6.0.2.tgz", @@ -3865,6 +3880,14 @@ "node": ">=0.4.0" } }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "engines": { + "node": ">=0.10" + } + }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -3973,6 +3996,17 @@ "node": ">=12" } }, + "node_modules/dotenv": { + "version": "16.4.4", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.4.tgz", + "integrity": "sha512-XvPXc8XAQThSjAbY6cQ/9PcBXmFoWuw1sQ3b8HqUCR6ziGXjkTi//kB9SWa2UwqlgdAIuRqAa/9hVljzPehbYg==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -5494,6 +5528,29 @@ "node": ">= 0.4" } }, + "node_modules/ioredis": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.3.2.tgz", + "integrity": "sha512-1DKMMzlIHM02eBBVOFQ1+AolGjs6+xEcM4PDL7NqOS6szq7H9jSaEkIUH6/a5Hl241LzW6JLSiAbNvTQjUupUA==", + "dependencies": { + "@ioredis/commands": "^1.1.1", + "cluster-key-slot": "^1.1.0", + "debug": "^4.3.4", + "denque": "^2.1.0", + "lodash.defaults": "^4.2.0", + "lodash.isarguments": "^3.1.0", + "redis-errors": "^1.2.0", + "redis-parser": "^3.0.0", + "standard-as-callback": "^2.1.0" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" + } + }, "node_modules/is-arguments": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.1.1.tgz", @@ -6912,6 +6969,16 @@ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "dev": true }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==" + }, + "node_modules/lodash.isarguments": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", + "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -8088,6 +8155,25 @@ "node": ">=8" } }, + "node_modules/redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", + "engines": { + "node": ">=4" + } + }, + "node_modules/redis-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", + "dependencies": { + "redis-errors": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.5.tgz", @@ -8524,6 +8610,11 @@ "node": ">=8" } }, + "node_modules/standard-as-callback": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", + "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==" + }, "node_modules/stop-iteration-iterator": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.0.0.tgz", diff --git a/package.json b/package.json index 53b0703..cde7e43 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "build": "next build", "start": "next start", "lint": "next lint", - "test": "jest" + "test": "jest", + "startapp": "node scripts/db-seed.js && npm run build && npm run start" }, "dependencies": { "@emotion/react": "^11.11.3", @@ -16,6 +17,8 @@ "@mui/material": "^5.15.10", "@supabase/ssr": "^0.1.0", "@supabase/supabase-js": "^2.39.6", + "dotenv": "^16.4.4", + "ioredis": "^5.3.2", "next": "14.1.0", "react": "^18", "react-dom": "^18", diff --git a/scripts/db-seed.js b/scripts/db-seed.js new file mode 100644 index 0000000..e4547f8 --- /dev/null +++ b/scripts/db-seed.js @@ -0,0 +1,167 @@ +const cl = require('@supabase/supabase-js'); +require('dotenv').config({ path: './.env.local' }); + +const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; +console.log(supabaseUrl) +const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY; +const supabase = cl.createClient(supabaseUrl, supabaseAnonKey); + +async function insertSpeakers() { + const speakers = [ + { + "id": "84b9179e-218d-4a4d-adc4-e69272e6769b", + "speaker_name": "Aaron", + "position": "Network Engineer" + }, + { + "id": "32445e61-acc6-4c6e-9b2a-c4cf563e3543", + "speaker_name": "Michael", + "position": "Database Administrator" + }, + { + "id": "8a2ccff7-e4a5-4304-a05d-6b6c22754c99", + "speaker_name": "Olive", + "position": "IT Consultant" + }, + { + "id": "0cc67de5-c421-405c-8c9e-013c71efcab9", + "speaker_name": "John", + "position": "Software Engineer" + }, + { + "id": "5db645c6-acd4-4dde-8c84-1a4d14cd7cfb", + "speaker_name": "Jamie", + "position": "Data Analyst" + }, + { + "id": "36b08404-b9c8-4c8d-9adc-e9697c2447ca", + "speaker_name": "Alex", + "position": "Web Developer" + }, + { + "id": "775ef0f0-f3ff-40a0-9ae8-8e4bfcf5cfca", + "speaker_name": "Ellum", + "position": "Network Engineer" + }, + { + "id": "8985f5b4-9fcf-44c4-8d9a-66b0a4bd038f", + "speaker_name": "William", + "position": "Cybersecurity Analyst" + }, + ]; + + for (const speaker of speakers) { + const { data, error } = await supabase + .from('speakers') + .insert([ + { + id: speaker.id, + speaker_name: speaker.speaker_name, + position: speaker.position, + created_at: new Date(), + updated_at: new Date() + } + ]); + + if (error) { + console.error('Error:', error); + break; + } + } +} + +async function insertEvents() { + const events = [ + { + speaker_id: "84b9179e-218d-4a4d-adc4-e69272e6769b", + event_title: "How to Create a Next.js App", + event_timing: "4:00 PM - 6:00 PM", + event_type: "Live Stage" + }, + { + speaker_id: "32445e61-acc6-4c6e-9b2a-c4cf563e3543", + event_title: "How to Create a React.js App", + event_timing: "4:00 PM - 6:00 PM", + event_type: "Live Stage" + }, + { + speaker_id: "8a2ccff7-e4a5-4304-a05d-6b6c22754c99", + event_title: "How to Create a CRUD application", + event_timing: "7:00 PM - 8:00 PM", + event_type: "Live Stage" + }, + { + speaker_id: "0cc67de5-c421-405c-8c9e-013c71efcab9", + event_title: "How to Dockerize Your Applications", + event_timing: "4:00 PM - 6:00 PM", + event_type: "DevZero Stage" + }, + { + speaker_id: "5db645c6-acd4-4dde-8c84-1a4d14cd7cfb", + event_title: "Mastering Kubernetes for DevOps", + event_timing: "4:00 PM - 6:00 PM", + event_type: "DevZero Stage" + }, + { + speaker_id: "36b08404-b9c8-4c8d-9adc-e9697c2447ca", + event_title: "Introduction to Machine Learning", + event_timing: "7:00 PM - 8:00 PM", + event_type: "DevZero Stage" + }, + { + speaker_id: "775ef0f0-f3ff-40a0-9ae8-8e4bfcf5cfca", + event_title: "Building RESTful APIs with Node.js", + event_timing: "7:00 PM - 8:00 PM", + event_type: "DevZero Stage" + }, + { + speaker_id: "8985f5b4-9fcf-44c4-8d9a-66b0a4bd038f", + event_title: "Effective Agile Project Management", + event_timing: "7:00 PM - 8:00 PM", + event_type: "DevZero Stage" + }, + { + speaker_id: "8985f5b4-9fcf-44c4-8d9a-66b0a4bd038f", + event_title: "Cloud Security Best Practices", + event_timing: "9:00 PM - 10:00 PM", + event_type: "DevZero Stage" + }, + { + speaker_id: "775ef0f0-f3ff-40a0-9ae8-8e4bfcf5cfca", + event_title: "Using GraphQL with React.js", + event_timing: "9:00 PM - 10:00 PM", + event_type: "DevZero Stage" + }, + ]; + + for (const event of events) { + const { data, error } = await supabase + .from('events') + .insert([ + { + speaker_id: event.speaker_id, + event_title: event.event_title, + event_timing: event.event_timing, + event_type: event.event_type, + created_at: new Date(), + updated_at: new Date() + } + ]); + + if (error) { + console.error('Error:', error); + break; + } + } +} + + +(async () => { + try { + await insertSpeakers(); + await insertEvents(); + console.log('Data inserted successfully'); + } catch (error) { + console.error('Error:', error); + } +})(); \ No newline at end of file diff --git a/tailwind.config.ts b/tailwind.config.ts index f54a0c6..c2f1f7d 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -12,12 +12,13 @@ const config: Config = { "gradient-radial": "radial-gradient(var(--tw-gradient-stops))", "gradient-conic": "conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))", + "text-gradient": "linear-gradient(to right, #e73c9f, #a032c6, #4125f8)", }, colors: { - "cus-purple": "#0f0b29", + "cus-purple": "#0f0b29", "cus-purple-light": "#412666", - "cus-text": '#bbb0ee', - } + "cus-text": "#bbb0ee", + }, }, }, plugins: [], diff --git a/utils/controller.ts b/utils/controller.ts deleted file mode 100644 index 3c12f2a..0000000 --- a/utils/controller.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { supabase } from "@/lib/supabaseClient"; - -export async function getEventsFromSupabase() { - const { data, error } = await supabase.from("events").select(` - event_id, - event_title, - event_timing, - event_type, - created_at, - updated_at, - speakers ( - speaker_name - ) - `); - return data; -} - -export async function getSpeakersFromSupabase() { - const { data, error } = await supabase.from("speakers").select("*"); - return data; -}