Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Create authenticated reminder endpoint #169

Merged
merged 17 commits into from
Aug 18, 2022
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 25 additions & 6 deletions lib/data.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import { filter, orderBy } from 'lodash'

export const getJSON = url => fetch(url).then(r => r.json())

const normalizeWebsite = url => {
Expand All @@ -12,11 +10,13 @@ const normalizeWebsite = url => {
}
}

export const getEvents = async () => {
export const getEvents = async (formula='TRUE()') => {
const filterByFormula = `AND(approved = TRUE(), ${formula})`
const select = { filterByFormula, sort: [{ field: 'start', direction: 'asc' }] }

let events = await getJSON(
'https://api2.hackclub.com/v0.1/hackathons.hackclub.com/applications?select%7B%22filterByFormula%22:%22approved=1%22%7D'
`https://api2.hackclub.com/v0.1/hackathons.hackclub.com/applications?select=${JSON.stringify(select)}`
)
events = filter(events, 'fields.approved')
events = events.map(
({ id, fields: { name, website, start, end, ...fields } }) => ({
id,
Expand Down Expand Up @@ -56,7 +56,6 @@ export const getEvents = async () => {
})
return e
})
events = orderBy(events, 'start')
return events
}

Expand All @@ -67,3 +66,23 @@ export const getGroupingData = async () => ({
events: (await getEvents()) || [],
emailStats: (await getEmailStats()) || {}
})

const subscriberTable = process.env.VERCEL_ENV === 'production' ?
'subscribers' :
'subscribers_development'

export const getSubscriber = async id => {
const select = { filterByFormula: `{id} = '${id}'`, maxRecords: 1 }
let subscribers = await getJSON(
`https://api2.hackclub.com/v0.1/hackathons.hackclub.com/${subscriberTable}?select=${JSON.stringify(select)}&authKey=${process.env.AIRTABLE_API_KEY}`
)
return subscribers[0]
}

export const updateSubscriber = async (id, fields) => {
return await fetch(`https://api2.hackclub.com/v0.1/hackathons.hackclub.com/${subscriberTable}?authKey=${process.env.AIRTABLE_API_KEY}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', },
body: JSON.stringify({ id, fields }),
}).then(r => r.json())
}
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,13 @@
"dependencies": {
"@emotion/react": "^11.10.0",
"@emotion/styled": "^11.10.0",
"@googlemaps/google-maps-services-js": "^3.3.16",
"@hackclub/icons": "^0.0.9",
"@hackclub/meta": "^1.1.32",
"@hackclub/theme": "^0.3.3",
"@mdx-js/loader": "^1.6.22",
"@next/mdx": "^10.0.4",
"@sendgrid/mail": "^7.7.0",
"airtable-plus": "^1.0.4",
"lodash": "^4.17.21",
"next": "^12.2.4",
Expand Down
123 changes: 123 additions & 0 deletions pages/api/events/[id]/remind.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import sgMail from '@sendgrid/mail'
import AirtablePlus from 'airtable-plus'
sgMail.setApiKey(process.env.SENDGRID_API_KEY)

const airtable = new AirtablePlus({
baseID: process.env.AIRTABLE_BASE_ID,
apiKey: process.env.AIRTABLE_API_KEY,
tableName: 'applications'
})

function calculateLatLngDistance(lat1, lng1, lat2, lng2) {
// this is 100% gh copilot and i have no idea how this works
// ^ that comment was also made by copilot
const R = 6371e3 // metres
const φ1 = (lat1 * Math.PI) / 180
const φ2 = (lat2 * Math.PI) / 180
const Δφ = ((lat2 - lat1) * Math.PI) / 180
const Δλ = ((lng2 - lng1) * Math.PI) / 180

const a =
Math.sin(Δφ / 2) * Math.sin(Δφ / 2) +
Math.cos(φ1) * Math.cos(φ2) * Math.sin(Δλ / 2) * Math.sin(Δλ / 2)
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))

return R * c
}

async function nearbySubscribers(lat, lng) {
const subscribers = await airtable.read(
{
// this code is broken and i have no idea how it works
// filterByFormula: `AND(DISTANCE(latitude, longitude, ${lat}, ${lng}) < 1, DISTANCE(latitude, longitude, ${lat}, ${lng}) > 0)`
},
{
tableName:
process.env.VERCEL_ENV === 'production'
? 'subscribers'
: 'subscribers_development'
}
)
subscribers.filter(subscriber => {
const distance = calculateLatLngDistance(
subscriber.fields.latitude,
subscriber.fields.longitude,
lat,
lng
)
return distance < 1000 * 100 // 10km
})
return subscribers.map(s => s.fields['email'])
}

export default async (req, res) => {
const token = process.env.TOKEN
if (!token) {
return res
.status(200)
.json({ msg: 'No token set, are you in dev/preview?' })
}

const authed = req.headers['authorization'] == 'Bearer ' + token

if (!authed) {
return res.status(401).json({ error: 'Unauthorized' })
}

// gib email (for event)
const { id } = req.params

const event = await airtable.find(id)

const emails = await nearbySubscribers(
event.fields.latitude,
event.fields.longitude
)

console.log(emails)

const msg = {
to: emails,
from: '[email protected]',
subject: `${event.fields.name} is coming up! - High School Hackathons`,
text: `${event.fields.name}, a high school hackathon, is coming up near you. Register at ${event.fields.website}.`,
html: `
<p>Hey hacker 👋</p>
<p>Word on the street is that there's a new in-person high school hackathon coming up near
you. Here are the details:</p>
<p>
<strong>${event.fields.name}</strong>
<br />
📍 ${event.fields.full_location}
<br />
🗓️ ${event.fields.start} to ${event.fields.end}
<br />
🌐 <a href="${event.fields.website}">${event.fields.website}</a>
</p>

<p>Cheers,<br />The Hack Club Bank Team</p>

<small>PS: If you recently moved, just reply to this email with your new location or ZIP code!</small>
`
}

sgMail.sendMultiple(msg).then(
() => {
console.log('Updating event in Airtable')
airtable.updateWhere(`{rec_id} = '${id}'`, {
subscriber_email_sent: true
})
},
error => {
console.error(error)

if (error.response) {
console.error(error.response.body)
}
}
)

res.status(200).json({
message: `Email sent to ${emails.length} subscribers.`
})
}
5 changes: 2 additions & 3 deletions pages/api/events/upcoming.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import { getEvents } from '../../../lib/data'
import { filter } from 'lodash'

export default async (req, res) => {
let events = await getEvents()
events = filter(events, e => new Date(e.start) >= new Date())
let events = await getEvents('DATETIME_DIFF(start,TODAY()) > 0')
events = events.filter(e => (new Date(e.start) > new Date()))
res.json(events)
}
50 changes: 50 additions & 0 deletions pages/api/subscribers/[id]/geocode.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
// given an "id" parameter, geocode the given airtable user and return {ok: true} if successful
import {Client} from "@googlemaps/google-maps-services-js"
import { getSubscriber, updateSubscriber } from "../../../../lib/data"

async function geocode(address) {
const client = new Client({key: process.env.GOOGLE_MAPS_API_KEY})
const { data } = await client.geocode({
params: {
address,
key: process.env.GOOGLE_MAPS_API_KEY
}
})
const result = data.results[0]
return {
latitude: result?.geometry?.location?.lat?.toString(),
longitude: result?.geometry?.location?.lng?.toString(),
parsed_address: result?.formatted_address,
parsed_city: result?.address_components?.find(c => c.types.includes('locality'))?.long_name,
parsed_state: result?.address_components?.find(c => c.types.includes('administrative_area_level_1'))?.long_name,
parsed_country: result?.address_components?.find(c => c.types.includes('country'))?.long_name,
parsed_state_code: result?.address_components?.find(c => c.types.includes('administrative_area_level_1'))?.short_name,
parsed_postal_code: result?.address_components?.find(c => c.types.includes('postal_code'))?.long_name?.toString(),
parsed_country_code: result?.address_components?.find(c => c.types.includes('country'))?.short_name,
}
}

export default async (req, res) => {
const token = process.env.TOKEN
if (!token) {
return res
.status(200)
.json({ msg: 'No token set, are you in dev/preview?' })
}

const authed = req.headers['authorization'] == 'Bearer ' + token

if (!authed) {
return res.status(401).json({ error: 'Unauthorized' })
}

const { id } = req.query

const subscriber = await getSubscriber(id)

const { location } = subscriber.fields
const fields = await geocode(location)

const result = await updateSubscriber(id, fields)
res.json({ok: true, result})
}
21 changes: 21 additions & 0 deletions pages/api/subscribers/unsubscribe.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import AirtablePlus from 'airtable-plus'

const airtable = new AirtablePlus({
baseID: process.env.AIRTABLE_BASE_ID,
apiKey: process.env.AIRTABLE_API_KEY,
tableName:
process.env.VERCEL_ENV === 'production'
? 'subscribers'
: 'subscribers_development'
})

export default async (req, res) => {
const { id } = req.query

const subscriber = await airtable.find(id)
airtable.updateWhere(`{id} = '${id}'`, {
unsubscribed_at: Date.now()
})

res.status(200).json(subscriber)
}
30 changes: 30 additions & 0 deletions pages/api/trigger-reminder.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { getEvents } from "../../lib/data"

export default async (req, res) => {
const token = process.env.TOKEN
if (!token) {
return res.status(200).json({msg: 'No token set, are you in dev/preview?'})
}

const authed = req.headers['authorization'] == 'Bearer ' + token

if (!authed) {
return res.status(401).json({ error: 'Unauthorized' })
}

// get events that are upcoming and 2 weeks out
const events = await getEvents("AND(DATETIME_DIFF(start,TODAY(), 'days') < 14, DATETIME_DIFF(start,TODAY(), 'days') > 0)")

const eventPromises = events.map(event =>
fetch(`/api/events/${event.id}/remind`, {
method: 'POST',
headers: {
'authorization': req.headers['authorization']
}
})
)

await Promise.all(eventPromises)

return res.status(200).json({msg: 'OK'})
}
Loading