From 2b20184cb05f851a5e118a1b2e9fadaf588857df Mon Sep 17 00:00:00 2001 From: Elliot Saha Date: Thu, 21 Mar 2024 01:25:02 -0700 Subject: [PATCH 1/2] feat(events): created internal create and delete operations for events from contentful --- package-lock.json | 41 ++- package.json | 6 +- src/app/(pages)/events/page.tsx | 238 +++++++++--------- .../api/auth/email-reset/callback/route.ts | 32 +-- .../api/auth/login/google/callback/route.ts | 28 +-- .../api/auth/signup/google/callback/route.ts | 32 +-- src/app/api/emails/index.ts | 9 +- src/app/api/events/create/route.ts | 59 +++++ src/app/api/events/delete/route.ts | 41 ++- src/app/api/events/detail/route.ts | 7 +- src/app/api/events/get-all/route.ts | 14 +- src/app/api/events/ticket/purchase/route.ts | 6 +- src/lib/resend.ts | 2 +- src/models/AttendeeList.ts | 7 +- src/types/contentful/TypeAdmin.ts | 9 + src/types/contentful/TypeEvent.ts | 29 +-- src/types/contentful/TypeTeam.ts | 21 +- src/types/contentful/index.ts | 5 +- src/types/event.ts | 1 + 19 files changed, 338 insertions(+), 249 deletions(-) create mode 100644 src/types/contentful/TypeAdmin.ts diff --git a/package-lock.json b/package-lock.json index d15b128..96e7553 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,6 +29,7 @@ "contentful": "^10.8.4", "date-fns": "^3.3.1", "dotenv-cli": "^7.3.0", + "encoding": "^0.1.13", "framer-motion": "^10.16.4", "ioredis": "^5.3.2", "keyv": "^4.5.4", @@ -41,7 +42,7 @@ "react": "^18", "react-dom": "^18", "react-dropzone": "^14.2.3", - "react-email": "^1.10.0", + "react-email": "^1.10.1", "react-hook-form": "^7.49.3", "react-icons": "^5.0.1", "resend": "^2.0.0", @@ -5868,6 +5869,25 @@ "resolved": "https://registry.npmjs.org/enabled/-/enabled-2.0.0.tgz", "integrity": "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==" }, + "node_modules/encoding": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", + "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", + "dependencies": { + "iconv-lite": "^0.6.2" + } + }, + "node_modules/encoding/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/end-of-stream": { "version": "1.4.4", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", @@ -10450,14 +10470,14 @@ } }, "node_modules/react-email": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/react-email/-/react-email-1.10.0.tgz", - "integrity": "sha512-IrLs28p3Iqyx9JASSrdEoTC+TQeb3jDcJ++2xzVS71yR6U8GYAqff7NKPGZJIA6z5oGtwRFv6GCViR4JiGdmXg==", + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/react-email/-/react-email-1.10.1.tgz", + "integrity": "sha512-nK92iY5TT2aD+YQNNQhnUbqy4dVd8jkRNmEAASbrkPU0/5btIP8o9YWlp1BNY1k26GU8qLiAAXm9TiWokYtbGA==", "dependencies": { "@commander-js/extra-typings": "9.4.1", "@manypkg/find-root": "2.2.1", "@octokit/rest": "19.0.7", - "@react-email/render": "0.0.10", + "@react-email/render": "0.0.11", "chokidar": "3.5.3", "commander": "9.4.1", "detect-package-manager": "2.0.1", @@ -10487,12 +10507,12 @@ } }, "node_modules/react-email/node_modules/@react-email/render": { - "version": "0.0.10", - "resolved": "https://registry.npmjs.org/@react-email/render/-/render-0.0.10.tgz", - "integrity": "sha512-FdLhg/E5PH5qZU/jf9NbvRi5v5134kbX7o8zIhOJIk/TALxB18ggprnH5tQX96dGQFqlLob8OLReaRwrpEF7YA==", + "version": "0.0.11", + "resolved": "https://registry.npmjs.org/@react-email/render/-/render-0.0.11.tgz", + "integrity": "sha512-Ec4vLkVbxoQhThBK1H++FdO4NgCeucg57qmwQ8A9xbozA2hWJiT2jJb5IA/bLE0YdixK8BeucXghJp84YZIG7A==", "dependencies": { "html-to-text": "9.0.5", - "pretty": "2.0.0", + "js-beautify": "^1.14.11", "react": "18.2.0", "react-dom": "18.2.0" }, @@ -11251,8 +11271,7 @@ "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" }, "node_modules/scheduler": { "version": "0.23.0", diff --git a/package.json b/package.json index e8421fc..b48662d 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "dev": "next dev", "build": "next build", "start": "next start", + "email": "email dev --port 4000 --dir src/app/api/emails", "prepare": "npm run compile", "compile": "tsc", "fix": "next lint", @@ -13,7 +14,7 @@ "posttest": "npm run lint", "cf-type-gen": "dotenv -- bash -c 'cf-content-types-generator -s $NEXT_CONTENTFUL_SPACE_ID -t $NEXT_CONTENTFUL_CMA_TOKEN -X -o ./src/types/contentful' && npm run fix", "gen-example-env": "sed 's/=.*/=/' .env > .env.example", - "ngrok": "ngrok http --domain=https://initially-cosmic-panther.ngrok-free.app 3000" + "ngrok": "ngrok http --domain=initially-cosmic-panther.ngrok-free.app 3000" }, "dependencies": { "@chakra-ui/icons": "^2.1.1", @@ -37,6 +38,7 @@ "contentful": "^10.8.4", "date-fns": "^3.3.1", "dotenv-cli": "^7.3.0", + "encoding": "^0.1.13", "framer-motion": "^10.16.4", "ioredis": "^5.3.2", "keyv": "^4.5.4", @@ -49,7 +51,7 @@ "react": "^18", "react-dom": "^18", "react-dropzone": "^14.2.3", - "react-email": "^1.10.0", + "react-email": "^1.10.1", "react-hook-form": "^7.49.3", "react-icons": "^5.0.1", "resend": "^2.0.0", diff --git a/src/app/(pages)/events/page.tsx b/src/app/(pages)/events/page.tsx index c611a3c..ac94848 100644 --- a/src/app/(pages)/events/page.tsx +++ b/src/app/(pages)/events/page.tsx @@ -78,103 +78,127 @@ const Events = () => { {data && data.length !== 0 && ( - {data.map((i, idx) => ( - - Caffe Latte + + {data.map((i, idx) => ( + + Caffe Latte - - - - {i.name} - + + + + {i.name} + - - - - - Date: - + + + + + Date: + + {format(parseISO(i.date), "E, MMM d yyyy")} + + + {format(parseISO(i.date), "E, MMM d yyyy")} - - {format(parseISO(i.date), "E, MMM d yyyy")} - - - - - - Location: - + + + + Location: + + {i.location} + + + {i.location} - - {i.location} - - - - - - Hosted by: - + + + + Hosted by: + + UBC Tennis Circle + + + UBC Tennis Circle - - UBC Tennis Circle - - - - - - Attendees: + + + + Attendees: + + + + + + + { /> - - - - - - - - - - - - ))} + + + + + ))} + )} diff --git a/src/app/api/auth/email-reset/callback/route.ts b/src/app/api/auth/email-reset/callback/route.ts index 2059019..a7ce2ef 100644 --- a/src/app/api/auth/email-reset/callback/route.ts +++ b/src/app/api/auth/email-reset/callback/route.ts @@ -1,17 +1,17 @@ -import {connectToDatabase} from '@lib/mongoose'; -import {logger} from '@lib/winston'; -import {auth} from '@lib/lucia'; -import {isWithinExpiration} from 'lucia/utils'; -import {EmailToken} from '@models/EmailToken'; -import {NextRequest, NextResponse} from 'next/server'; -import {createKeyId} from 'lucia'; -import {Key} from '@models'; +import { connectToDatabase } from "@lib/mongoose"; +import { logger } from "@lib/winston"; +import { auth } from "@lib/lucia"; +import { isWithinExpiration } from "lucia/utils"; +import { EmailToken } from "@models/EmailToken"; +import { NextRequest, NextResponse } from "next/server"; +import { createKeyId } from "lucia"; +import { Key } from "@models"; export const GET = async (request: NextRequest) => { await connectToDatabase(); try { - const token = request.nextUrl.searchParams.get('token'); + const token = request.nextUrl.searchParams.get("token"); const storedToken = await EmailToken.findOne({ _id: token, @@ -19,7 +19,7 @@ export const GET = async (request: NextRequest) => { if (!storedToken) { return NextResponse.redirect( - new URL('/login?confirmation-status=false', request.url) + `${process.env.NEXT_PUBLIC_HOSTNAME}/login?confirmation-status=false`, ); } @@ -27,7 +27,7 @@ export const GET = async (request: NextRequest) => { if (!isWithinExpiration(tokenExpires)) { return NextResponse.redirect( - new URL('/login?confirmation-status=false', request.url) + `${process.env.NEXT_PUBLIC_HOSTNAME}/login?confirmation-status=false`, ); } @@ -41,25 +41,25 @@ export const GET = async (request: NextRequest) => { }); // const oldKey = await auth.getKey('email_address', userToUpdate.user_id); - const newKeyId = createKeyId('email_address', storedToken.email_address); + const newKeyId = createKeyId("email_address", storedToken.email_address); const key = await Key.findOne({ user_id: storedToken.user_id, }).lean(); - await Key.deleteOne({user_id: storedToken.user_id}); + await Key.deleteOne({ user_id: storedToken.user_id }); - const newKey = {...key, _id: newKeyId}; + const newKey = { ...key, _id: newKeyId }; await Key.create(newKey); return NextResponse.redirect( - new URL('/login?confirmation-status=true&invalidate=true', request.url) + `${process.env.NEXT_PUBLIC_HOSTNAME}/login?confirmation-status=true&invalidate=true`, ); } catch (e) { logger.error(e); return NextResponse.redirect( - new URL('/login?confirmation-status=false', request.url) + `${process.env.NEXT_PUBLIC_HOSTNAME}/login?confirmation-status=false`, ); } }; diff --git a/src/app/api/auth/login/google/callback/route.ts b/src/app/api/auth/login/google/callback/route.ts index c68ec41..5285faf 100644 --- a/src/app/api/auth/login/google/callback/route.ts +++ b/src/app/api/auth/login/google/callback/route.ts @@ -1,20 +1,20 @@ -import {ServerResponse} from '@helpers'; -import {auth, googleLogin} from '@lib/lucia'; -import {OAuthRequestError} from '@lucia-auth/oauth'; -import {cookies, headers} from 'next/headers'; -import {NextRequest, NextResponse} from 'next/server'; +import { ServerResponse } from "@helpers"; +import { auth, googleLogin } from "@lib/lucia"; +import { OAuthRequestError } from "@lucia-auth/oauth"; +import { cookies, headers } from "next/headers"; +import { NextRequest, NextResponse } from "next/server"; export const GET = async (request: NextRequest) => { - const state = request.nextUrl.searchParams.get('state'); - const code = request.nextUrl.searchParams.get('code'); + const state = request.nextUrl.searchParams.get("state"); + const code = request.nextUrl.searchParams.get("code"); if (!state || !code) { - return ServerResponse.serverError('Could not process request'); + return ServerResponse.serverError("Could not process request"); } try { - const {getExistingUser, googleUser} = await googleLogin.validateCallback( - code! + const { getExistingUser, googleUser } = await googleLogin.validateCallback( + code!, ); const userAttributes = { @@ -25,7 +25,7 @@ export const GET = async (request: NextRequest) => { skill: 1, instagram: null, profile: googleUser?.picture, - provider: 'google', + provider: "google", }; const getUser = async () => { @@ -38,7 +38,7 @@ export const GET = async (request: NextRequest) => { if (!user) { return NextResponse.redirect( - new URL('/login?bad-oauth=true', request.url) + `${process.env.NEXT_PUBLIC_HOSTNAME}/login?bad-oauth=true`, ); } @@ -54,10 +54,10 @@ export const GET = async (request: NextRequest) => { authRequest.setSession(session); - return NextResponse.redirect(new URL('/', request.url)); + return NextResponse.redirect(process.env.NEXT_PUBLIC_HOSTNAME); } catch (e) { if (e instanceof OAuthRequestError) { - return ServerResponse.userError('Bad oauth request'); + return ServerResponse.userError("Bad oauth request"); } return ServerResponse.serverError(); } diff --git a/src/app/api/auth/signup/google/callback/route.ts b/src/app/api/auth/signup/google/callback/route.ts index d84e19d..4eea0b7 100644 --- a/src/app/api/auth/signup/google/callback/route.ts +++ b/src/app/api/auth/signup/google/callback/route.ts @@ -1,20 +1,22 @@ -import {ServerResponse} from '@helpers'; -import {auth, googleSignup} from '@lib/lucia'; -import {OAuthRequestError} from '@lucia-auth/oauth'; -import {cookies, headers} from 'next/headers'; -import {NextResponse, NextRequest} from 'next/server'; -import {User} from '@models/User'; +import { ServerResponse } from "@helpers"; +import { auth, googleSignup } from "@lib/lucia"; +import { OAuthRequestError } from "@lucia-auth/oauth"; +import { cookies, headers } from "next/headers"; +import { NextResponse, NextRequest } from "next/server"; +import { User } from "@models/User"; export const GET = async (request: NextRequest) => { - const state = request.nextUrl.searchParams.get('state'); - const code = request.nextUrl.searchParams.get('code'); + const state = request.nextUrl.searchParams.get("state"); + const code = request.nextUrl.searchParams.get("code"); if (!state || !code) { - return ServerResponse.serverError('Could not process request'); + return ServerResponse.serverError("Could not process request"); } try { - const {createUser, googleUser} = await googleSignup.validateCallback(code!); + const { createUser, googleUser } = await googleSignup.validateCallback( + code!, + ); const userAttributes = { first_name: googleUser.given_name, @@ -24,7 +26,7 @@ export const GET = async (request: NextRequest) => { skill: 1, instagram: null, profile: googleUser.picture, - provider: 'google', + provider: "google", }; const sameEmailUser = await User.findOne({ @@ -33,7 +35,7 @@ export const GET = async (request: NextRequest) => { if (sameEmailUser) { return NextResponse.redirect( - new URL('/signup?same-google-email=true', request.url) + `${process.env.NEXT_PUBLIC_HOSTNAME}/signup?same-google-email=true`, ); } @@ -53,10 +55,12 @@ export const GET = async (request: NextRequest) => { headers, }); authRequest.setSession(session); - return NextResponse.redirect(new URL('/profile/setup', request.url)); + return NextResponse.redirect( + `${process.env.NEXT_PUBLIC_HOSTNAME}/profile/setup`, + ); } catch (e) { if (e instanceof OAuthRequestError) { - return ServerResponse.userError('Bad oauth request'); + return ServerResponse.userError("Bad oauth request"); } return ServerResponse.serverError(); } diff --git a/src/app/api/emails/index.ts b/src/app/api/emails/index.ts index 6b469d0..662886b 100644 --- a/src/app/api/emails/index.ts +++ b/src/app/api/emails/index.ts @@ -1,4 +1,5 @@ -export * from './ConfirmEmail'; -export * from './ContactEmail'; -export * from './ResetPasswordEmail'; -export * from './ResetEmail'; +export * from "./ConfirmEmail"; +export * from "./ContactEmail"; +export * from "./ResetPasswordEmail"; +export * from "./ResetEmail"; +export * from "./DeleteEventEmail"; diff --git a/src/app/api/events/create/route.ts b/src/app/api/events/create/route.ts index e69de29..1d5bf99 100644 --- a/src/app/api/events/create/route.ts +++ b/src/app/api/events/create/route.ts @@ -0,0 +1,59 @@ +import { NextRequest } from "next/server"; +import { ServerResponse } from "@helpers"; +import z from "zod"; +import { AttendeeList } from "@models"; +import axios from "axios"; +import { logger } from "@lib"; + +const createSchema = z.object({ + event_id: z.string({ required_error: "Event is required" }), + secret: z.string({ required_error: "Invalid webhook secret" }), +}); + +export const POST = async (request: NextRequest) => { + const body = await request.json(); + + const validation = createSchema.safeParse(body); + + if (validation.success) { + try { + if (body.secret !== process.env.NEXT_CONTENTFUL_SECRET) { + return ServerResponse.unauthorizedError("Invalid contentful request"); + } + + const event = await axios.post( + `${process.env.NEXT_PUBLIC_HOSTNAME}/api/events/detail`, + { + id: body.event_id, + }, + ); + + const res = event.data; + + const existingList = await AttendeeList.findOneAndUpdate( + { event_id: res.id }, + { event_name: res.name, ticket_price: res.ticket_price }, // name and ticket price of the event could change + ); + + if (existingList) { + return ServerResponse.success(existingList); + } + + const attendeeList = await AttendeeList.create({ + event_id: res.id, + event_name: res.name, + ticket_price: res.ticket_price, + available_tickets: res.initial_tickets, + reserved_tickets: [], + attendees: [], + }); + + return ServerResponse.success(attendeeList); + } catch (e) { + logger.error(e); + return ServerResponse.serverError(e); + } + } else { + return ServerResponse.validationError(validation); + } +}; diff --git a/src/app/api/events/delete/route.ts b/src/app/api/events/delete/route.ts index dee5581..9f59d2d 100644 --- a/src/app/api/events/delete/route.ts +++ b/src/app/api/events/delete/route.ts @@ -3,14 +3,14 @@ import { logger } from "@lib/winston"; import { ServerResponse } from "@helpers/serverResponse"; import z from "zod"; import { AttendeeList, User } from "@models"; -import axios from "axios"; -import { resend, sendMail } from "@lib"; -import DeleteEmail from "@emails/DeleteEventEmail"; +import { sendMail } from "@lib"; +import { DeleteEmail } from "@emails"; const deleteSchema = z.object({ event_id: z.string({ required_error: "Event is required" }), secret: z.string({ required_error: "Invalid webhook secret" }), }); + export const POST = async (request: NextRequest) => { const body = await request.json(); @@ -22,37 +22,32 @@ export const POST = async (request: NextRequest) => { return ServerResponse.unauthorizedError("Invalid contentful request"); } - const event = await axios.post( - `${process.env.NEXT_PUBLIC_HOSTNAME}/api/events/detail`, - { - id: body.event_id, - }, - ); - - const eventName = event.data.name; - - const attendeeList = await AttendeeList.findOne({ + const attendeeList = await AttendeeList.findOneAndDelete({ event_id: body.event_id, }).lean(); - const emailList: string[] = await User.find({ - _id: attendeeList.attendees, - }) - .select("email_address") - .lean(); + if (!attendeeList) { + return ServerResponse.success("No Attendee List Found"); + } + + const emailList: Array<{ _id: string; email_address: string }> = + await User.find({ + _id: attendeeList.attendees, + }) + .select("email_address") + .lean(); await sendMail({ - to: emailList, - subject: `Cancellation of ${eventName}`, + to: emailList.map((i) => i.email_address), + subject: `Cancellation of ${attendeeList.event_name}`, emailComponent: DeleteEmail({ - event_name: eventName, + event_name: attendeeList.event_name, }), }); - return ServerResponse.success("success"); + return ServerResponse.success({ emails_sent: emailList }); } catch (e) { logger.error(e); - console.log(e); return ServerResponse.serverError(); } } else { diff --git a/src/app/api/events/detail/route.ts b/src/app/api/events/detail/route.ts index 9f49000..85d7611 100644 --- a/src/app/api/events/detail/route.ts +++ b/src/app/api/events/detail/route.ts @@ -18,6 +18,10 @@ export const POST = async (request: NextRequest) => { if (validation.success) { const res = await contentfulClient.getEntry(id); + if (res.fields.openingStatus === "Coming Soon") { + return ServerResponse.userError("Invalid event ID"); + } + const event = { id: res.sys.id, name: res.fields.name, @@ -27,6 +31,7 @@ export const POST = async (request: NextRequest) => { cover_image: `https:${(res.fields.coverImage as Asset)?.fields?.file ?.url}`, description: res.fields.description, + initial_tickets: res.fields.amountOfTickets, }; return ServerResponse.success(event); @@ -34,6 +39,6 @@ export const POST = async (request: NextRequest) => { return ServerResponse.userError("Invalid event ID"); } } catch (e) { - return ServerResponse.serverError(); + return ServerResponse.userError("Event not found"); } }; diff --git a/src/app/api/events/get-all/route.ts b/src/app/api/events/get-all/route.ts index 9245784..74cb46c 100644 --- a/src/app/api/events/get-all/route.ts +++ b/src/app/api/events/get-all/route.ts @@ -1,16 +1,17 @@ -import {contentfulClient} from '@lib'; -import {ServerResponse} from '@helpers'; -import {TypeEventSkeleton} from '@types'; -import {Asset} from 'contentful'; +import { contentfulClient } from "@lib"; +import { ServerResponse } from "@helpers"; +import { TypeEventSkeleton } from "@types"; +import { Asset } from "contentful"; export const GET = async () => { try { const res = await contentfulClient.getEntries({ - content_type: 'event', + content_type: "event", include: 2, + order: ["-fields.openingStatus", "fields.date"], }); - const events = res.items.map(i => ({ + const events = res.items.map((i) => ({ id: i.sys.id, name: i.fields.name, location: i.fields.location, @@ -18,6 +19,7 @@ export const GET = async () => { date: i.fields.date, cover_image: `https:${(i.fields.coverImage as Asset)?.fields?.file?.url}`, description: i.fields.description, + opening_status: i.fields.openingStatus, })); return ServerResponse.success(events); diff --git a/src/app/api/events/ticket/purchase/route.ts b/src/app/api/events/ticket/purchase/route.ts index 269912c..25e5643 100644 --- a/src/app/api/events/ticket/purchase/route.ts +++ b/src/app/api/events/ticket/purchase/route.ts @@ -18,9 +18,13 @@ export const POST = async (request: NextRequest) => { const { session } = await getSession(request); + const url = new URL(request.url); + + const pathname = url.pathname; + if (!session) { return ServerResponse.success({ - url: `${process.env.NEXT_PUBLIC_HOSTNAME}/login?redirect=${request.url}`, + url: `${process.env.NEXT_PUBLIC_HOSTNAME}/login?redirect=${pathname}`, }); } diff --git a/src/lib/resend.ts b/src/lib/resend.ts index ba7774d..cc660cc 100644 --- a/src/lib/resend.ts +++ b/src/lib/resend.ts @@ -5,7 +5,7 @@ import { renderAsync } from "@react-email/render"; import { logger } from "./winston"; import { ServerResponse } from "@helpers"; -export const resend = new Resend(process.env.NEXT_RESEND_API); +const resend = new Resend(process.env.NEXT_RESEND_API); interface SendMailProps { to: string | string[]; diff --git a/src/models/AttendeeList.ts b/src/models/AttendeeList.ts index bc60766..5d219b7 100644 --- a/src/models/AttendeeList.ts +++ b/src/models/AttendeeList.ts @@ -10,8 +10,9 @@ export type UserId = string; export interface AttendeeList { _id: string; event_id: string; + event_name: string; + ticket_price: number; available_tickets: number; - initial_tickets: number; reserved_tickets: Array; attendees: Array; } @@ -20,8 +21,9 @@ mongoose.Promise = global.Promise; const schema = new Schema({ event_id: { type: String, required: true }, + event_name: { type: String, required: true }, + ticket_price: { type: Number, required: true }, available_tickets: { type: Number, required: true }, - initial_tickets: { type: Number, requried: true }, reserved_tickets: { type: [ { @@ -30,7 +32,6 @@ const schema = new Schema({ }, ], default: [], - requried: true, }, // list of user_ids attendees: [{ type: String, required: true }], diff --git a/src/types/contentful/TypeAdmin.ts b/src/types/contentful/TypeAdmin.ts new file mode 100644 index 0000000..2ad35cd --- /dev/null +++ b/src/types/contentful/TypeAdmin.ts @@ -0,0 +1,9 @@ +import type { ChainModifiers, Entry, EntryFieldTypes, EntrySkeletonType, LocaleCode } from "contentful"; + +export interface TypeAdminFields { + name: EntryFieldTypes.Symbol; + emailAddress: EntryFieldTypes.Symbol; +} + +export type TypeAdminSkeleton = EntrySkeletonType; +export type TypeAdmin = Entry; diff --git a/src/types/contentful/TypeEvent.ts b/src/types/contentful/TypeEvent.ts index cd37d7d..cf6d52b 100644 --- a/src/types/contentful/TypeEvent.ts +++ b/src/types/contentful/TypeEvent.ts @@ -1,22 +1,15 @@ -import type { - ChainModifiers, - Entry, - EntryFieldTypes, - EntrySkeletonType, - LocaleCode, -} from 'contentful'; +import type { ChainModifiers, Entry, EntryFieldTypes, EntrySkeletonType, LocaleCode } from "contentful"; export interface TypeEventFields { - name: EntryFieldTypes.Symbol; - ticketPrice: EntryFieldTypes.Number; - date: EntryFieldTypes.Date; - coverImage: EntryFieldTypes.AssetLink; - location: EntryFieldTypes.Symbol; - description: EntryFieldTypes.Text; + name: EntryFieldTypes.Symbol; + openingStatus: EntryFieldTypes.Symbol<"Coming Soon" | "Open">; + ticketPrice: EntryFieldTypes.Number; + date: EntryFieldTypes.Date; + coverImage: EntryFieldTypes.AssetLink; + location: EntryFieldTypes.Symbol; + description: EntryFieldTypes.Text; + amountOfTickets: EntryFieldTypes.Integer; } -export type TypeEventSkeleton = EntrySkeletonType; -export type TypeEvent< - Modifiers extends ChainModifiers, - Locales extends LocaleCode, -> = Entry; +export type TypeEventSkeleton = EntrySkeletonType; +export type TypeEvent = Entry; diff --git a/src/types/contentful/TypeTeam.ts b/src/types/contentful/TypeTeam.ts index 20aa044..5bca2b6 100644 --- a/src/types/contentful/TypeTeam.ts +++ b/src/types/contentful/TypeTeam.ts @@ -1,19 +1,10 @@ -import type { - ChainModifiers, - Entry, - EntryFieldTypes, - EntrySkeletonType, - LocaleCode, -} from 'contentful'; +import type { ChainModifiers, Entry, EntryFieldTypes, EntrySkeletonType, LocaleCode } from "contentful"; export interface TypeTeamFields { - name: EntryFieldTypes.Symbol; - role: EntryFieldTypes.Symbol; - headshot: EntryFieldTypes.AssetLink; + name: EntryFieldTypes.Symbol; + role: EntryFieldTypes.Symbol; + headshot: EntryFieldTypes.AssetLink; } -export type TypeTeamSkeleton = EntrySkeletonType; -export type TypeTeam< - Modifiers extends ChainModifiers, - Locales extends LocaleCode, -> = Entry; +export type TypeTeamSkeleton = EntrySkeletonType; +export type TypeTeam = Entry; diff --git a/src/types/contentful/index.ts b/src/types/contentful/index.ts index ed0a572..4d0f911 100644 --- a/src/types/contentful/index.ts +++ b/src/types/contentful/index.ts @@ -1,2 +1,3 @@ -export type {TypeEvent, TypeEventFields, TypeEventSkeleton} from './TypeEvent'; -export type {TypeTeam, TypeTeamFields, TypeTeamSkeleton} from './TypeTeam'; +export type { TypeAdmin, TypeAdminFields, TypeAdminSkeleton } from "./TypeAdmin"; +export type { TypeEvent, TypeEventFields, TypeEventSkeleton } from "./TypeEvent"; +export type { TypeTeam, TypeTeamFields, TypeTeamSkeleton } from "./TypeTeam"; diff --git a/src/types/event.ts b/src/types/event.ts index 95c27c7..6aeed41 100644 --- a/src/types/event.ts +++ b/src/types/event.ts @@ -6,4 +6,5 @@ export interface TennisEvent { date: string; cover_image: string; description: string; + initial_tickets: number; } From e53ed8eaa0832320d6f0abbcaa4fbb9e7aa7c077 Mon Sep 17 00:00:00 2001 From: Elliot Saha Date: Thu, 21 Mar 2024 22:08:15 -0700 Subject: [PATCH 2/2] feat(payment): created proper routes to pay using stripe --- src/app/(pages)/events/detail/[id]/page.tsx | 61 +++++++- src/app/(pages)/events/page.tsx | 135 ++++++++++++------ src/app/api/events/create/route.ts | 4 +- src/app/api/events/detail/route.ts | 41 +++++- .../events/ticket/expire-reservation/route.ts | 54 +++++++ src/app/api/events/ticket/purchase/route.ts | 92 ++++++++++-- .../events/ticket/purchase/success/route.ts | 98 +++++++++++++ src/models/AttendeeList.ts | 27 ++-- src/types/event.ts | 5 +- 9 files changed, 440 insertions(+), 77 deletions(-) create mode 100644 src/app/api/events/ticket/expire-reservation/route.ts diff --git a/src/app/(pages)/events/detail/[id]/page.tsx b/src/app/(pages)/events/detail/[id]/page.tsx index 5f0a377..0f7f2e4 100644 --- a/src/app/(pages)/events/detail/[id]/page.tsx +++ b/src/app/(pages)/events/detail/[id]/page.tsx @@ -1,7 +1,8 @@ "use client"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { Box, + useToast, Container, Button, Text, @@ -19,15 +20,58 @@ import { TennisEvent } from "@types"; import { useQuery } from "@tanstack/react-query"; import { useRouter } from "next/navigation"; import { format, parseISO } from "date-fns"; +import { useSearchParams } from "next/navigation"; import { LocationPinIcon, ClockIcon, SingleUserIcon, UserFriendsIcon, } from "@icons"; +import { getClientSession } from "@utils"; const EventDetail = ({ params }: { params: { id: string } }) => { const router = useRouter(); + const statusToast = useToast(); + const searchParams = useSearchParams(); + + const soldOut = searchParams.get("sold-out"); + const unsuccessfulPayment = searchParams.get("unsuccessful-payment"); + const successfulPayment = searchParams.get("successful-payment"); + const purchased = searchParams.get("purchased"); + + useEffect(() => { + if (soldOut === "true") { + statusToast({ + id: "sold_out", + title: "Unfortunately, this event just sold out.", + status: "error", + }); + } + + if (purchased === "true") { + statusToast({ + id: "purchased", + title: "You have already purchased this ticket.", + status: "error", + }); + } + + if (unsuccessfulPayment === "true") { + statusToast({ + id: "unsuccessful_payment", + title: "An error has occurred and you will be issued a refund.", + status: "error", + }); + } + + if (successfulPayment === "true") { + statusToast({ + id: "successful_payment", + title: "Successfully purchased ticket.", + status: "success", + }); + } + }, [soldOut, statusToast, unsuccessfulPayment, successfulPayment, purchased]); const getEvent = async () => { const event = await axios.post( @@ -53,8 +97,13 @@ const EventDetail = ({ params }: { params: { id: string } }) => { const [purchaseLoading, setPurchaseLoading] = useState(false); const purchaseTicket = async () => { + const session = await getClientSession(); try { setPurchaseLoading(true); + + if (!session) { + router.push(`/login/?redirect=/events/detail/${params.id}`); + } const res = await axios.post( `${process.env.NEXT_PUBLIC_HOSTNAME}/api/events/ticket/purchase`, { event_id: params.id }, @@ -87,8 +136,16 @@ const EventDetail = ({ params }: { params: { id: string } }) => { colorScheme="brand" onClick={purchaseTicket} isLoading={purchaseLoading} + loadingText="Reserving Ticket" + isDisabled={ + (!data.reserved && data.available_tickets <= 0) || data.purchased + } > - Buy Ticket + {data.purchased + ? "Purchased Ticket" + : !data.reserved && data.available_tickets <= 0 + ? "Sold out" + : "Buy Ticket"} ); diff --git a/src/app/(pages)/events/page.tsx b/src/app/(pages)/events/page.tsx index ac94848..77d11fd 100644 --- a/src/app/(pages)/events/page.tsx +++ b/src/app/(pages)/events/page.tsx @@ -1,6 +1,8 @@ "use client"; import { + useToast, Container, + Box, Img, Text, Heading, @@ -15,6 +17,7 @@ import { Button, Avatar, AvatarGroup, + Badge, } from "@chakra-ui/react"; import { useQuery } from "@tanstack/react-query"; import axios from "axios"; @@ -28,6 +31,8 @@ import { UserFriendsIcon, } from "@icons"; import Link from "next/link"; +import { useSearchParams } from "next/navigation"; +import { useEffect } from "react"; // get all events const getEvents = async () => { @@ -43,6 +48,20 @@ const Events = () => { queryFn: getEvents, }); + const searchParams = useSearchParams(); + const unsuccessfulPayment = searchParams.get("unsuccessful-payment"); + + const statusToast = useToast(); + + useEffect(() => { + if (unsuccessfulPayment === "true") { + statusToast({ + id: "unsuccessful_payment", + title: "An error has occurred and you were not charged", + status: "error", + }); + } + }, [unsuccessfulPayment, statusToast]); return ( @@ -81,6 +100,7 @@ const Events = () => { {data.map((i, idx) => ( { - - - - Attendees: + {i.opening_status === "Open" && ( + + + + Attendees: + + + + + + + { /> - - - - - - - + )} - + {i.opening_status === "Open" ? ( + + ) : ( + + + OPENING SOON + + + )} ))} diff --git a/src/app/api/events/create/route.ts b/src/app/api/events/create/route.ts index 1d5bf99..081bc76 100644 --- a/src/app/api/events/create/route.ts +++ b/src/app/api/events/create/route.ts @@ -43,13 +43,15 @@ export const POST = async (request: NextRequest) => { event_id: res.id, event_name: res.name, ticket_price: res.ticket_price, - available_tickets: res.initial_tickets, + available_tickets: res.available_tickets, reserved_tickets: [], attendees: [], + reservation_expire_tasks: [], }); return ServerResponse.success(attendeeList); } catch (e) { + console.log(e); logger.error(e); return ServerResponse.serverError(e); } diff --git a/src/app/api/events/detail/route.ts b/src/app/api/events/detail/route.ts index 85d7611..4234c5b 100644 --- a/src/app/api/events/detail/route.ts +++ b/src/app/api/events/detail/route.ts @@ -1,15 +1,18 @@ -import { contentfulClient } from "@lib"; -import { ServerResponse } from "@helpers"; +import { contentfulClient, connectToDatabase } from "@lib"; +import { ServerResponse, getSession } from "@helpers"; import { TypeEventSkeleton } from "@types"; import { Asset } from "contentful"; import z from "zod"; import { NextRequest } from "next/server"; +import { AttendeeList } from "@models"; const detailSchema = z.object({ id: z.string({ required_error: "Event ID is required" }), }); export const POST = async (request: NextRequest) => { + await connectToDatabase(); + try { const { id } = await request.json(); @@ -22,6 +25,35 @@ export const POST = async (request: NextRequest) => { return ServerResponse.userError("Invalid event ID"); } + const attendeeList = await AttendeeList.findOne({ + event_id: res.sys.id, + }); + + let availableTickets = res.fields.amountOfTickets; + + if (attendeeList) { + availableTickets = attendeeList?.available_tickets; + } + + let reserved = false; + let purchased = false; + + const { session } = await getSession(request); + + if (session && attendeeList) { + if ( + attendeeList.reservation_expire_tasks.find( + (e) => e.user_id === session.user.userId, + ) + ) { + reserved = true; + } + + if (attendeeList.attendees.includes(session.user.userId)) { + purchased = true; + } + } + const event = { id: res.sys.id, name: res.fields.name, @@ -31,7 +63,9 @@ export const POST = async (request: NextRequest) => { cover_image: `https:${(res.fields.coverImage as Asset)?.fields?.file ?.url}`, description: res.fields.description, - initial_tickets: res.fields.amountOfTickets, + available_tickets: availableTickets, + reserved, + purchased, }; return ServerResponse.success(event); @@ -39,6 +73,7 @@ export const POST = async (request: NextRequest) => { return ServerResponse.userError("Invalid event ID"); } } catch (e) { + console.log(e); return ServerResponse.userError("Event not found"); } }; diff --git a/src/app/api/events/ticket/expire-reservation/route.ts b/src/app/api/events/ticket/expire-reservation/route.ts new file mode 100644 index 0000000..525df18 --- /dev/null +++ b/src/app/api/events/ticket/expire-reservation/route.ts @@ -0,0 +1,54 @@ +import { connectToDatabase, logger } from "@lib"; +import { NextRequest } from "next/server"; +import z from "zod"; +import { ServerResponse } from "@helpers"; +import { AttendeeList } from "@models/AttendeeList"; + +const expireReservationSchema = z.object({ + reservation_secret: z.string({ + required_error: "Reservation secret is required", + }), + event_id: z.string({ required_error: "Event ID is required" }), + user_id: z.string({ required_error: "User ID to expire is required" }), +}); + +export const POST = async (request: NextRequest) => { + await connectToDatabase(); + + const body = await request.json(); + + const validation = expireReservationSchema.safeParse(body); + + try { + if (validation.success) { + if (body.reservation_secret !== process.env.NEXT_RESERVATION_SECRET) { + return ServerResponse.userError("Invalid reservation secret"); + } + + const attendeeList = await AttendeeList.findOneAndUpdate( + { + event_id: body.event_id, + reserved_tickets: { $in: [body.user_id] }, + }, + { + $pull: { + reserved_tickets: body.user_id, + reservation_expire_tasks: { user_id: body.user_id }, + }, + $inc: { available_tickets: 1 }, + }, + ); + + if (!attendeeList) { + return ServerResponse.success("No user to expire"); + } + + return ServerResponse.success("Expired user"); + } else { + return ServerResponse.userError("Invalid schema"); + } + } catch (e) { + logger.error(e); + return ServerResponse.serverError(e); + } +}; diff --git a/src/app/api/events/ticket/purchase/route.ts b/src/app/api/events/ticket/purchase/route.ts index 25e5643..b8e07b7 100644 --- a/src/app/api/events/ticket/purchase/route.ts +++ b/src/app/api/events/ticket/purchase/route.ts @@ -4,6 +4,9 @@ import { NextRequest } from "next/server"; import z from "zod"; import { getSession, ServerResponse } from "@helpers"; import { TennisEvent } from "@types"; +import { logger, mergent } from "@lib"; +import { AttendeeList } from "@models/AttendeeList"; +import { addMinutes, getUnixTime } from "date-fns"; const purchaseSchema = z.object({ event_id: z.string({ required_error: "Event token is required" }), @@ -18,13 +21,11 @@ export const POST = async (request: NextRequest) => { const { session } = await getSession(request); - const url = new URL(request.url); - - const pathname = url.pathname; + const eventURLPath = `/events/detail/${event_id}`; if (!session) { return ServerResponse.success({ - url: `${process.env.NEXT_PUBLIC_HOSTNAME}/login?redirect=${pathname}`, + url: `${process.env.NEXT_PUBLIC_HOSTNAME}/login?redirect=${eventURLPath}`, }); } @@ -37,7 +38,78 @@ export const POST = async (request: NextRequest) => { }, ); - const session = await stripe.checkout.sessions.create({ + const attendeeList = await AttendeeList.findOne({ + event_id: event.data.id, + }); + + if (attendeeList.attendees.includes(session.user.userId)) { + return ServerResponse.success({ + url: `${process.env.NEXT_PUBLIC_HOSTNAME}${eventURLPath}?purchased=true`, + }); + } + + if (!attendeeList.reserved_tickets.includes(session.user.userId)) { + if (attendeeList.available_tickets <= 0) { + return ServerResponse.success({ + url: `${process.env.NEXT_PUBLIC_HOSTNAME}${eventURLPath}?sold-out=true`, + }); + } else { + await AttendeeList.findOneAndUpdate( + { + event_id: event.data.id, + }, + { + $push: { + reserved_tickets: session.user.userId, + }, + $inc: { available_tickets: -1 }, + }, + ); + + const mergentTask = await mergent.tasks.create({ + request: { + url: `${process.env.NEXT_PUBLIC_HOSTNAME}/api/events/ticket/expire-reservation`, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + reservation_secret: process.env.NEXT_RESERVATION_SECRET, + event_id: event.data.id, + user_id: session.user.userId, + }), + }, + scheduledFor: addMinutes(new Date(), 30), + }); + + await AttendeeList.findOneAndUpdate( + { + event_id: event.data.id, + }, + { + $push: { + reservation_expire_tasks: { + user_id: session.user.userId, + task_id: mergentTask.id, + }, + }, + }, + ); + } + } else { + const dBexistingTask = attendeeList.reservation_expire_tasks.find( + (e) => e.user_id === session.user.userId, + ); + + const task = await mergent.tasks.retrieve(dBexistingTask.task_id); + + if (task.status === "queued") { + await mergent.tasks.update(dBexistingTask.task_id, { + scheduledFor: addMinutes(new Date(), 30), + }); + } + } + + const expires = getUnixTime(addMinutes(new Date(), 30)); + + const stripeSession = await stripe.checkout.sessions.create({ line_items: [ { price_data: { @@ -52,17 +124,19 @@ export const POST = async (request: NextRequest) => { ], mode: "payment", metadata: { - event_id, + event_id: event.data.id, + user_id: session.user.userId, }, - //TODO: MAKE THESE ROUTES - success_url: `${process.env.NEXT_PUBLIC_HOSTNAME}/api/ticket/purchase/success`, + success_url: `${process.env.NEXT_PUBLIC_HOSTNAME}/api/events/ticket/purchase/success?session_id={CHECKOUT_SESSION_ID}`, cancel_url: `${process.env.NEXT_PUBLIC_HOSTNAME}/events/detail/${event_id}`, automatic_tax: { enabled: true }, + expires_at: expires, }); - return ServerResponse.success({ url: session.url }); + return ServerResponse.success({ url: stripeSession.url }); } catch (e) { console.log(e); + logger.error(e); return ServerResponse.serverError(); } } diff --git a/src/app/api/events/ticket/purchase/success/route.ts b/src/app/api/events/ticket/purchase/success/route.ts index e69de29..4b80b37 100644 --- a/src/app/api/events/ticket/purchase/success/route.ts +++ b/src/app/api/events/ticket/purchase/success/route.ts @@ -0,0 +1,98 @@ +import { connectToDatabase, stripe, logger, mergent } from "@lib"; +import { NextRequest, NextResponse } from "next/server"; +import { AttendeeList } from "@models/AttendeeList"; + +export const GET = async (request: NextRequest) => { + await connectToDatabase(); + + const sessionId = request.nextUrl.searchParams.get("session_id"); + + const session = await stripe.checkout.sessions.retrieve(sessionId); + + if ( + session && + session.status === "complete" && + session.payment_status === "paid" + ) { + const { event_id, user_id } = session.metadata; + + const attendeeList = await AttendeeList.findOne({ + event_id, + }); + + // standard case where user is in reserved, + // if the user is reserved, it means that the mergent task + // has not processed yet + if (attendeeList.reserved_tickets.includes(user_id)) { + const task = attendeeList.reservation_expire_tasks.find( + (e) => e.user_id === user_id, + ); + + await mergent.tasks.delete(task.task_id); + + await AttendeeList.findOneAndUpdate( + { + event_id, + }, + { + $pull: { + reserved_tickets: user_id, + reservation_expire_tasks: { user_id }, + }, + $push: { + attendees: user_id, + }, + }, + ); + + return NextResponse.redirect( + new URL( + `${process.env.NEXT_PUBLIC_HOSTNAME}/events/detail/${event_id}?successful_payment=true`, + ), + ); + } else { + // mergent task could theoretically occur right after payment but right before + // success api route is called meaning that user is not reserved but has paid + + // in the case where the event still has space + + if (attendeeList.available_tickets >= 1) { + await AttendeeList.findOneAndUpdate( + { event_id }, + { + $push: { + attendees: user_id, + }, + $inc: { available_tickets: -1 }, + }, + ); + + return NextResponse.redirect( + new URL( + `${process.env.NEXT_PUBLIC_HOSTNAME}/events/detail/${event_id}?successful-payment=true`, + ), + ); + } else { + // worst edge case where user pays for last ticket at last possible second before session closes, + // it gets unreserved as the user takes too long, and someone else buys before their payment gets processed + const refund = await stripe.refunds.create({ + payment_intent: session.payment_intent as string, + }); + + logger.info(refund); + + return NextResponse.redirect( + new URL( + `${process.env.NEXT_PUBLIC_HOSTNAME}/events/detail/${event_id}?unsuccessful-payment=true`, + ), + ); + } + } + } + + return NextResponse.redirect( + new URL( + `${process.env.NEXT_PUBLIC_HOSTNAME}/events?unsuccessful-payment=true`, + ), + ); +}; diff --git a/src/models/AttendeeList.ts b/src/models/AttendeeList.ts index 5d219b7..919cad6 100644 --- a/src/models/AttendeeList.ts +++ b/src/models/AttendeeList.ts @@ -1,10 +1,5 @@ import mongoose, { Schema } from "mongoose"; -export interface TicketReservation { - user_id: string; - expires: string; -} - export type UserId = string; export interface AttendeeList { @@ -13,8 +8,12 @@ export interface AttendeeList { event_name: string; ticket_price: number; available_tickets: number; - reserved_tickets: Array; + reserved_tickets: Array; attendees: Array; + reservation_expire_tasks: Array<{ + user_id: string; + task_id: string; + }>; } mongoose.Promise = global.Promise; @@ -24,17 +23,15 @@ const schema = new Schema({ event_name: { type: String, required: true }, ticket_price: { type: Number, required: true }, available_tickets: { type: Number, required: true }, - reserved_tickets: { - type: [ - { - user_id: String, - expires: String, - }, - ], - default: [], - }, // list of user_ids + reserved_tickets: [{ type: String, required: true }], attendees: [{ type: String, required: true }], + reservation_expire_tasks: [ + { + user_id: { type: String }, + task_id: { type: String }, + }, + ], }); export const AttendeeList = diff --git a/src/types/event.ts b/src/types/event.ts index 6aeed41..59224e8 100644 --- a/src/types/event.ts +++ b/src/types/event.ts @@ -6,5 +6,8 @@ export interface TennisEvent { date: string; cover_image: string; description: string; - initial_tickets: number; + available_tickets: number; + opening_status: string; + reserved: boolean; + purchased: boolean; }