diff --git a/app/admin/import/import-client.tsx b/app/admin/import/import-client.tsx new file mode 100644 index 00000000..6a3cbf16 --- /dev/null +++ b/app/admin/import/import-client.tsx @@ -0,0 +1,236 @@ +'use client' +import { BiAlarmExclamation, BiCheckCircle } from "react-icons/bi"; +import { useSearchParams } from "next/navigation" +import {useEffect, useState} from 'react'; +import React from "react"; +import { TicketRow } from '@components/admin/lists/importRow'; +import Papa from 'papaparse'; + +type Attendee = { + name: string + email: string + phone: string + checkin_at: string + passes: string[] + purchased_at: string + ticket_number: string | null + active: boolean + status: string + student_ticket: boolean + transferred_in: boolean + transferred_out: boolean + name_changed: boolean + transferred: boolean + history: any[] + unit_amount: number + cs_id: string +} + +const emailOptions = [ + { value: 'everyone', label: 'Everyone' }, + { value: 'new', label: 'New Ticket Numbers Only' }, + { value: 'none', label: 'None' }, +] +export const optionsDefault = { + sendTicketEmails: 'none', + +} + +export default function ImportPageClient() { + const [data, setData] = useState([]); + const [attendeesData, setAttendeesData] = useState([]); + const [options, setOptions] = useState(optionsDefault); + const [error] = useState(false as boolean | string) + const [messageShown, setMessageShown] = useState(true) + const params = useSearchParams() + + const message = params.get('message') || error + const messageType = params.get('messageType') ? params.get('messageType') : error ? 'bad' : 'good' + + useEffect(() => { + if(message && messageType == 'good') { + setTimeout(() => { + setMessageShown(false) + }, 3000) + } + }, []) + + const handleFileUpload = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) { + setData([]) + Papa.parse(file, { + complete: (result) => { + const transformedData = result.data.map((row: any) => transformAttendee(row)); + setAttendeesData(transformedData); + console.log(transformedData) + }, + header: true, + }) + } + } + + const transformAttendee = (row: any) => { + const isStudentTicket = row.type.includes(' (Student)'); + + return { + name: row.name || '', + email: row.email || '', + phone: row.telephone, + checkin_at: row.ticket_used || '', + passes: isStudentTicket ? [row.type.replace(" (Student)", "")] : [row.type || ''], + purchased_at: row.purchase_date ? new Date(parseInt(row.purchase_date_unix) * 1000).toISOString() : '', + ticket_number: row.ticket_number || null, + active: true, + status: 'paid_legacy', + student_ticket: isStudentTicket, + transferred_in: false, + transferred_out: false, + name_changed: false, + transferred: null, + history: [], + unit_amount: Number(row.unit_amount.substring(1))*100, + cs_id: row.cs_id + }; + }; + + const handleSaveChanges = (updatedAttendee) => { + setAttendeesData((prevData) => + prevData.map((attendee) => + attendee.ticket_number === updatedAttendee.ticket_number + ? updatedAttendee + : attendee + ) + ) + console.log(data) + } + + const handleOptionChange = (e: React.ChangeEvent) => { + const { value } = e.target; + setOptions((prevOptions) => ({ + ...prevOptions, + sendTicketEmails: value, + })) + } + + const handleSubmit = async (e) => { + e.preventDefault(); + + const payload = { + attendees: attendeesData, + options: options, + } + + const response = await fetch('/api/admin/import', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload), + }) + console.log(response) + + } + + const headerClassNames = "p-0 text-left text-sm font-semibold text-white " + const headerContainerClassNames = "flex justify-between" + const labelClassNames = "py-3.5 pl-4 block" + + const messageClassesBase = "message py-2 pl-4 pr-2 text-white rounded-md flex justify-between items-center transition ease-in-out delay-150 duration-500" + const messageClassType = messageType =='good' ? 'bg-green-600' : 'bg-red-600' + const messageIconClasses = "w-6 h-6" + const messageClassIcon = messageType =='good' ? () : + const messageClasses = [messageClassesBase,messageClassType].join(' ') + + return ( +
+ { message ? (
setMessageShown(false)}>{message} {messageClassIcon}
) : null } + + {attendeesData.length > 0 ? ( +
+
+
+
+

Send Ticket Emails:

+ + {/* Dynamically generate radio buttons from the emailOptions array */} + {emailOptions.map((option) => ( +
+ + +
+ ))} + +
+
+

+ + + + + + + + + + + + + + {attendeesData.map((row) => + + )} + +
+ + Name + & Details + & Email + + + + Status + {} + + Edit + {} +
+
+ ): ''} +
+ ); + +} \ No newline at end of file diff --git a/app/admin/import/page.tsx b/app/admin/import/page.tsx new file mode 100644 index 00000000..ce82717c --- /dev/null +++ b/app/admin/import/page.tsx @@ -0,0 +1,36 @@ +import { + SignedIn, +} from '@clerk/nextjs' +import React from "react"; +import Layout from "@components/layout/layout"; +import { Container } from "@components/layout/container"; +import Navigation from "@components/admin/navigation"; +import ImportPageClient from './import-client'; + +export default async function AdminImportPage() { + + const pages = [ + { name: 'Dashboard', href: '/admin', current: true }, + { name: 'Import', href: '/admin/import', current: true }, + ] + return ( + +
+ {" "} + + + + + +
+

Import

+
+ + + +
+
+
+
+ ) +} diff --git a/app/api/admin/import/route.ts b/app/api/admin/import/route.ts new file mode 100644 index 00000000..49511ee7 --- /dev/null +++ b/app/api/admin/import/route.ts @@ -0,0 +1,25 @@ +import { redirect } from 'next/navigation' + +export async function POST(request: Request) { + const body = await request.json(); + const { attendees, options } = body; + + const apiRequestBody = { + options: options, + attendees: attendees, + } + console.log("POST -> Connor: ",apiRequestBody) + console.log("API", process.env.LAMBDA_IMPORT_TICKETS) + const apiResponse = await fetch(process.env.LAMBDA_IMPORT_TICKETS, { + method: 'POST', + body: JSON.stringify(apiRequestBody) + }) + const responseData = await apiResponse.json() + console.log("<- Connor POST", responseData, apiResponse.statusText, apiResponse.status) + + const allGood = apiResponse.ok && !responseData.error + if(!allGood) { console.error("Error:",responseData.error) } + const message = allGood ? "Tickets imported" : "Tickets Not Saved : We are getting the gremlins on it" + const messageType = allGood ? "good" : "bad" + redirect(`/admin/import?message=${message}&messageType=${messageType}`) +} \ No newline at end of file diff --git a/components/admin/hub.tsx b/components/admin/hub.tsx index b5df7cb3..2198b847 100644 --- a/components/admin/hub.tsx +++ b/components/admin/hub.tsx @@ -10,7 +10,8 @@ import { // CalendarDaysIcon, ShoppingCartIcon, CakeIcon, - LightBulbIcon + LightBulbIcon, + FolderPlusIcon, } from '@heroicons/react/24/solid' const actions = [ @@ -86,6 +87,15 @@ const actions = [ iconBackground: 'bg-green-200', state: 'unreleased', }, + { + title: 'Import', + href: '/admin/import', + description: "Import tickets from csv", + icon: FolderPlusIcon, + iconForeground: 'text-green-800', + iconBackground: 'bg-green-200', + state: 'unreleased', + }, ] function classNames(...classes) { diff --git a/components/admin/lists/importRow.tsx b/components/admin/lists/importRow.tsx new file mode 100644 index 00000000..99fa29ae --- /dev/null +++ b/components/admin/lists/importRow.tsx @@ -0,0 +1,122 @@ +import { BiCreditCard, BiLogoSketch, BiLeftArrowCircle, BiSolidRightArrowSquare, } from 'react-icons/bi'; +import {Button, Select} from '@headlessui/react' +import {CurrencyPoundIcon, ClipboardIcon, ExclamationTriangleIcon, AcademicCapIcon} from '@heroicons/react/24/solid' +import { useState } from 'react'; + +const TicketStatusIcon = ({attendee}) => { + const PaymentIcon = attendee.status === 'paid_stripe' ? + : attendee.status === 'paid_cash' ? : + attendee.status === 'gratis' ? : null //TODO should have a icon for wtf paid for this + const trasnferOutIcon = attendee.transferred_out ? : null + const transferInIcon = attendee.transferred_in ? : null + const namechangeIcon = attendee.name_changed ? : null + const studentIcon = attendee.student_ticket ? : null + const wtfIcon = attendee.transferred_in && attendee.transferred_out ? : null + return {PaymentIcon}{trasnferOutIcon}{transferInIcon}{namechangeIcon}{wtfIcon}{studentIcon} +} + +export const TicketRow = ({attendee, handleSaveChanges}) => { + const [isEditing, setIsEditing] = useState(false) + const [editedAttendee, setEditedAttendee] = useState(attendee); + const handleEdit = () => setIsEditing(!isEditing); + + const handleChange = (e) => { + const { name, value } = e.target; + setEditedAttendee({...editedAttendee, [name]: value}); + }; + + const handleSave = () => { + setIsEditing(false); + handleSaveChanges(editedAttendee); + }; + + const passString = attendee.passes.join(', ') + return ( + + + {isEditing ? ( + + ) : ( +
+ {attendee.name} +
+ + #{attendee.ticket_number ? attendee.ticket_number : will be generated on import} + +
+ )} + + + {isEditing ? ( + + ) : ( + {attendee.email} + )} + + + {isEditing ? ( + + ) : ( + {attendee.phone} + )} + + {passString} + + {isEditing ? ( + + ) : ( + £{attendee.unit_amount/100} + )} + + + {isEditing ? ( + + ) : ( + + + + )} + + + {isEditing ? ( + + ) : ( + + )} + + + + ) +} \ No newline at end of file diff --git a/functions/import_tickets/lambda_function.py b/functions/import_tickets/lambda_function.py new file mode 100644 index 00000000..54df3c14 --- /dev/null +++ b/functions/import_tickets/lambda_function.py @@ -0,0 +1,112 @@ +import json +import boto3 +from boto3.dynamodb.conditions import Key, Attr +from random import randint + +import logging +from decimal import Decimal +from shared import DecimalEncoder as shared +from json.decoder import JSONDecodeError +import os +from datetime import datetime +import time + + +logger = logging.getLogger() +logger.setLevel(logging.INFO) + +lambda_client = boto3.client('lambda') +db = boto3.resource('dynamodb') +table = db.Table(os.environ.get("ATTENDEES_TABLE_NAME")) + +def err(msg:str, code=400, logmsg=None, **kwargs): + logmsg = logmsg if logmsg else msg + logger.error(logmsg) + for k in kwargs: + logger.error(k+":"+kwargs[k]) + return { + 'statusCode': code, + 'body': json.dumps({'error': msg}) + } + +def send_email(name, email, ticket_number, line_items): + logger.info(os.environ.get("SEND_EMAIL_LAMBDA")) + # send the email with these details + logger.info("Invoking send_email lambda") + response = lambda_client.invoke( + FunctionName=os.environ.get("SEND_EMAIL_LAMBDA"), + InvocationType='Event', + Payload=json.dumps({ + 'email_type':"standard_ticket", + 'name':name, + 'email':email, + 'ticket_number': ticket_number, + 'line_items':line_items, + 'heading_message': "A REMINDER OF YOUR TICKET" + }, cls=shared.DecimalEncoder), + ) + logger.info(response) + +def get_line_items(passes, amount_total): + return [{"amount_total": amount_total, "description": i,"price_id": "","prod_id": ""} for i in passes] + +def get_ticket_number(email, student_ticket): + search = True + # generate a new ticket number if it is already used in the table + while search: + ticketnumber = str(randint(1000000000, 9999999999)) if student_ticket == False else str(55)+str(randint(1000000000, 9999999999))[:-2] + response = table.query(KeyConditionExpression=Key('ticket_number').eq(ticketnumber) & Key('email').eq(email)) + if response['Count'] == 0: + search = False + logger.info("ticket number generated: "+str(ticketnumber)) + return ticketnumber + +def update_ddb(Item): + table.put_item(Item=Item) + return True + +def lambda_handler(event, context): + try: + data = json.loads(event['body']) + logger.info(data) + except (TypeError, JSONDecodeError) as e: + logger.error(e) + return err("An error has occured with the input you have provied.", event_body=event['body']) + + attendees = data['attendees'] + options = data['options'] + + for attendee in attendees: + line_items = get_line_items(attendee['passes'], attendee['unit_amount']*100) + ticket_number = str(attendee['ticket_number']) if attendee['ticket_number'] is not None else str(get_ticket_number(attendee['email'], attendee['student_ticket'])) + purchased_at = int(time.mktime(datetime.strptime(attendee['purchased_at'], '%Y-%m-%dT%H:%M:%S.000Z').timetuple())) + input = { + 'ticket_number': ticket_number, + 'email': attendee['email'], + 'full_name':attendee['name'], + 'phone': attendee['phone'], + 'active': True, + 'purchase_date':purchased_at, + 'line_items': line_items, + 'access':[1,1,1,1,1,1] if 'Full Pass' in attendee['passes'] else [0,0,0,0,0,0], + 'ticket_used': False, + 'status':attendee['status'], + 'student_ticket': attendee['student_ticket'], + 'schedule': None, + 'checkout_session':attendee['cs_id'] if 'cs_id' in attendee else None, + 'meal_preferences': None, + 'promo_code': None, + 'history': None, + } + + respponse = update_ddb(input) + + if options['sendTicketEmails'] == "everyone": + send_email(attendee['name'], attendee['email'], ticket_number, line_items) + elif options['sendTicketEmails'] == "new": + if str(attendee['ticket_number']) is None: + send_email(attendee['name'], attendee['email'], ticket_number, line_items) + + return { + 'statusCode': 200, + } diff --git a/functions/serverless.yml b/functions/serverless.yml index 18a52c23..b7339246 100644 --- a/functions/serverless.yml +++ b/functions/serverless.yml @@ -394,6 +394,8 @@ functions: path: /transfer_owner method: post +#-------------------- + MailDev: runtime: python3.11 handler: maildev/lambda_function.maildev @@ -412,3 +414,30 @@ functions: - httpApi: path: /maildev method: get + + +#-------------------- + + ImportTickets: + runtime: python3.11 + handler: import_tickets/lambda_function.lambda_handler + name: "${sls:stage}-import_tickets" + package: + patterns: + - '!**/**' + - "import_tickets/**" + - "shared/**" + environment: + STAGE_NAME: ${sls:stage} + ATTENDEES_TABLE_NAME: ${param:attendeesTableName} + SEND_EMAIL_LAMBDA: "${sls:stage}-send_email" + events: + - httpApi: + path: /import_tickets + method: post + + + + + +