Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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
13 changes: 13 additions & 0 deletions app/create/VoteForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ import { createVote } from "@/lib/actions/vote";
import { Textarea } from "../../components/ui/textarea";
import { IVoteOptions } from "@/lib/types";

import { useAvailablePhoneNumbers } from "@/lib/hook";
import PhoneNumberDropdown from "@/components/phone/phone-number-dropdown";

const FormSchema = z
.object({
vote_options: z
Expand All @@ -43,6 +46,7 @@ const FormSchema = z
.min(5, { message: "Title has a minimum characters of 5" }),
description: z.string().optional(),
end_date: z.date(),
phone_number: z.string(),
})
.refine(
(data) => {
Expand All @@ -62,6 +66,7 @@ export default function VoteForm() {
defaultValues: {
title: "",
vote_options: [],
phone_number: "",
},
});

Expand Down Expand Up @@ -105,6 +110,8 @@ export default function VoteForm() {
});
}

const { data: availablePhoneNumbers } = useAvailablePhoneNumbers();

return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
Expand Down Expand Up @@ -256,6 +263,12 @@ export default function VoteForm() {
)}
/>

<PhoneNumberDropdown
name="phone_number"
control={form.control}
phoneNumbers={availablePhoneNumbers}
/>

<Button
type="submit"
className="w-full"
Expand Down
13 changes: 13 additions & 0 deletions app/edit/[id]/EditVoteForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,14 @@ import { Textarea } from "@/components/ui/textarea";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { AlertCircle } from "lucide-react";

import { useAvailablePhoneNumbers } from "@/lib/hook";
import PhoneNumberDropdown from "@/components/phone/phone-number-dropdown";

const FormSchema = z.object({
title: z.string().min(5, { message: "Title has a minimum characters of 5" }),
end_date: z.date(),
description: z.string().optional(),
phone_number: z.string(),
});

export default function EditVoteForm({ vote }: { vote: IVote }) {
Expand All @@ -48,6 +52,7 @@ export default function EditVoteForm({ vote }: { vote: IVote }) {
title: vote.title,
end_date: new Date(vote.end_date),
description: vote.description || "",
phone_number: vote.phone_number || "",
},
});

Expand All @@ -59,6 +64,8 @@ export default function EditVoteForm({ vote }: { vote: IVote }) {
});
}

const { data: availablePhoneNumbers } = useAvailablePhoneNumbers();

return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
Expand Down Expand Up @@ -148,6 +155,12 @@ export default function EditVoteForm({ vote }: { vote: IVote }) {
)}
/>

<PhoneNumberDropdown
control={form.control}
phoneNumbers={availablePhoneNumbers}
name="phone_number"
/>

<Button
type="submit"
className="w-full"
Expand Down
3 changes: 3 additions & 0 deletions app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import { ThemeProvider } from "@/components/theme-provider";
import QueryProvider from "@/components/QueryProvider";
import Config from "@/config";

import RegisterPhone from "@/components/phone/register-phone";

const inter = Space_Grotesk({ subsets: ["latin"] });

export const metadata: Metadata = {
Expand Down Expand Up @@ -54,6 +56,7 @@ export default async function RootLayout({
<QueryProvider>
<main className="flex flex-col max-w-7xl mx-auto min-h-screen space-y-10 p-5">
<Navbar />
<RegisterPhone />
<div className="w-full flex-1 ">{children}</div>
<Footer />
</main>
Expand Down
11 changes: 11 additions & 0 deletions app/vote/components/Info.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
"use client";

import React from "react";
import dynamic from "next/dynamic";
import { IVote } from "@/lib/types";
import { toDisplayedPhoneNumberFormat } from "@/lib/utils";

const TimeCountDown = dynamic(() => import("./TimeCountDown"), { ssr: false });

Expand All @@ -13,6 +15,15 @@ export default function Info({ vote }: { vote: IVote }) {
<div className="space-y-3 w-full">
<h2 className="text-3xl font-bold break-words">{vote.title}</h2>
<TimeCountDown targetDate={tomorrow} />
{vote.phone_number && (
<div className="mt-12 text-2xl ">
Vote by sending an SMS:{" "}
<span className="bg-zinc-600 p-1">#choice</span> to{" "}
<span className="font-extrabold">
{toDisplayedPhoneNumberFormat(vote.phone_number)}
</span>
</div>
)}
</div>
);
}
225 changes: 225 additions & 0 deletions app/webhooks/vote/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
import { NextResponse } from "next/server";
import { verifyWebhookSignature } from "@hookdeck/sdk/webhooks/helpers";

import createSupabaseServerAdmin from "@/lib/supabase/admin";
import { toStoredPhoneNumberFormat } from "@/lib/utils";

import { createClient } from "@supabase/supabase-js";
import { createSigner } from "fast-jwt";

import { IVoteOptions } from "@/lib/types";

import { Twilio } from "twilio";

const twilioAccountSid = process.env.TWILIO_ACCOUNT_SID;
const twilioAuthToken = process.env.TWILIO_AUTH_TOKEN;
const twilioClient = new Twilio(twilioAccountSid, twilioAuthToken);

const signer = createSigner({
key: process.env.SUPABASE_JWT_SECRET,
algorithm: "HS256",
});

function createSupabaseToken(userEmail: string, userId: string) {
const ONE_HOUR = 60 * 60;
const exp = Math.round(Date.now() / 1000) + ONE_HOUR;
const payload = {
aud: "authenticated",
exp,
sub: userId,
email: userEmail,
role: "authenticated",
};
return signer(payload);
}

interface Body {
ToCountry: string;
ToState: string;
SmsMessageSid: string;
NumMedia: string;
ToCity: string;
FromZip: string;
SmsSid: string;
FromState: string;
SmsStatus: string;
FromCity: string;
Body: string;
FromCountry: string;
To: string;
ToZip: string;
NumSegments: string;
MessageSid: string;
AccountSid: string;
From: string;
ApiVersion: string;
_voteNumber: string;
_voteAdditionalText: string;
}

export async function POST(request: Request) {
const headers: { [key: string]: string } = {};
request.headers.forEach((value, key) => {
headers[key] = value;
});

const rawBody = await request.text();

const verificationResult = await verifyWebhookSignature({
headers,
rawBody,
signingSecret: process.env.HOOKDECK_SIGNING_SECRET!,
config: { checkSourceVerification: true },
});

if (!verificationResult.isValidSignature) {
return new NextResponse(
JSON.stringify({ error: "Could not verify the webhook signature" }),
{ status: 401 },
);
}

const body: Body = JSON.parse(rawBody);

// Find the user associated with the phone number the SMS has come "From"
const supabase = await createSupabaseServerAdmin();

// Supabase does not store the "+" sign in the phone number, so remove it for the lookup
const from = toStoredPhoneNumberFormat(body.From).replace("+", "");

const { data: voter, error: profileError } = await supabase
.from("profile")
.select("*")
.eq("phone", from)
.single();

if (profileError) {
const errorMessage =
"Error: could not find a user with the provided 'from' phone number";
console.error(errorMessage, profileError);

return new NextResponse(
JSON.stringify({
error: errorMessage,
}),
{ status: 404 },
);
}

console.log("Found profile", voter);

// Lookup the vote associated with the SMS "To" number

// - Act as a Supabase User
const token = createSupabaseToken(
voter.email as string,
voter.id,
);

const userClient = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
global: { headers: { Authorization: `Bearer ${token}` } },
},
);

// can be used normally with RLS!
const user = await userClient.auth.getUser();
console.log("Mapped to user", user);

// - Perform the Vote lookup
const votePhoneNumber = toStoredPhoneNumberFormat(body.To);

const { data: vote, error: voteError } = await userClient
.from("vote")
.select("*")
.eq("phone_number", votePhoneNumber)
.single();

if (voteError) {
return new NextResponse(
JSON.stringify({
error:
"Error: could not find a poll with the provided 'to' phone number",
}),
{ status: 404 },
);
}

console.log("Found vote", vote);

// Check that the vote option sent in the message is a valid option in the vote
const { data: voteOptions, error: voteOptionsError } = await userClient
.from("vote_options")
.select("options")
.eq("vote_id", vote.id)
.single();

if (voteOptionsError) {
console.error(voteOptionsError);

return new NextResponse(
JSON.stringify({
error:
`Error: could not find vote options for the poll with a poll with id "${vote.id}"`,
}),
{ status: 404 },
);
}

const options = voteOptions.options as unknown as IVoteOptions;
const votedForOption = body._voteNumber;

console.log("options", options);

let selectedOptionText = null;
const optionKeys = Object.keys(options);
for (let i = 0; i < optionKeys.length; ++i) {
const key = optionKeys[i];
if (Number(options[key].position) === Number(votedForOption)) {
console.log("Found matching option", key, options[key]);
selectedOptionText = key;
break;
}
}

if (!options || !selectedOptionText) {
return new NextResponse(
JSON.stringify({
error:
`Error: could not find vote option sent in the body of the SMS message: "${votedForOption}" for poll with id "${vote.id}"`,
}),
{ status: 404 },
);
}

// Register the user's vote to the poll
const { error, data } = await userClient.rpc("update_vote", {
update_id: vote.id,
option: selectedOptionText,
});

if (error) {
const errorMessage =
`Error: could not update the vote: "${votedForOption}" for poll with id "${vote.id}"`;
console.error(errorMessage, error);

return new NextResponse(
JSON.stringify({
error: errorMessage,
}),
{ status: 500 },
);
}

console.log("Updated vote", data);

await twilioClient.messages.create({
to: body.From,
from: body.To,
body: `Thanks for your vote for ${selectedOptionText} 🎉`,
});

return NextResponse.json({ vote_success: true });
}
Loading