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
47 changes: 47 additions & 0 deletions app/broadcast_hackers/BroadcastForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
"use client";

import { useState } from "react";
import { broadcastAll } from "./actions";
import styles from "./styles.module.css";

const BODY_LIMIT = 160;

export default function BroadcastForm({ hackerCount }: { hackerCount: number }) {
const [bodyLength, setBodyLength] = useState(0);

return (
<form
action={broadcastAll}
onSubmit={(e) => {
if (!confirm(`This will alert all ${hackerCount} hackers. Are you sure?`)) {
e.preventDefault();
}
}}
className={styles.form}
>
<label>
Subject (email only)
<br />
<input name="subject" required className={styles.input} />
</label>
<label>
Body
<br />
<textarea
name="body"
rows={4}
required
maxLength={BODY_LIMIT}
className={styles.textarea}
onChange={(e) => setBodyLength(e.target.value.length)}
/>
<span>
{bodyLength} / {BODY_LIMIT}
</span>
</label>
<button type="submit" className={styles.button}>
Send to All Hackers
</button>
</form>
);
}
49 changes: 49 additions & 0 deletions app/broadcast_hackers/actions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
"use server";

import { redirect } from "next/navigation";
import { eq } from "drizzle-orm";
import { db } from "@/lib/db";
import { users } from "@/lib/db/schema/users";
import { hackerApplicants } from "@/lib/db/schema/applications";
import { broadcastLogs } from "@/lib/db/schema/broadcasts";
import { getSessionUser } from "@/lib/auth/session";
import { sendBulkEmail } from "@/lib/aws/ses.placeholder";
import { sendBulkSMS } from "@/lib/aws/sms.placeholder";

export async function broadcastAll(formData: FormData) {
const sessionUser = await getSessionUser();
if (!sessionUser || sessionUser.role !== "organizer") {
throw new Error("Unauthorized");
}

const subject = formData.get("subject") as string;
const body = formData.get("body") as string;

const emailRows = await db
.select({ email: users.email })
.from(users)
.where(eq(users.role, "hacker"));
const smsRows = await db
.select({ phoneNumber: hackerApplicants.phoneNumber })
.from(hackerApplicants);

const emails = emailRows.map((r) => r.email).filter((e) => e.length > 0);
const phoneNumbers = smsRows
.map((r) => r.phoneNumber)
.filter((p) => p.length > 0);

const [emailResults, smsResults] = await Promise.all([
sendBulkEmail(emails, subject, body),
sendBulkSMS(phoneNumbers, body),
]);

await db.insert(broadcastLogs).values({
subject,
body,
sentBy: sessionUser.id,
broadcastedToEmail: emailResults.succeeded,
broadcastedToText: smsResults.succeeded,
});

redirect("/broadcast_hackers/success");
}
37 changes: 37 additions & 0 deletions app/broadcast_hackers/logs/[id]/recipients/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { NextRequest, NextResponse } from "next/server";
import { eq } from "drizzle-orm";
import { db } from "@/lib/db";
import { broadcastLogs } from "@/lib/db/schema/broadcasts";
import { getSessionUser } from "@/lib/auth/session";

export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const user = await getSessionUser();
if (!user || user.role !== "organizer") {
return new NextResponse("Unauthorized", { status: 401 });
}

const { id } = await params;
const type = request.nextUrl.searchParams.get("type");

const [log] = await db
.select()
.from(broadcastLogs)
.where(eq(broadcastLogs.id, id))
.limit(1);

if (!log) {
return new NextResponse("Not found", { status: 404 });
}

const isEmail = type === "email";
const items: string[] = isEmail
? (log.broadcastedToEmail as string[]) ?? []
: (log.broadcastedToText as string[]) ?? [];

return new NextResponse(items.join("\n"), {
headers: { "Content-Type": "text/plain; charset=utf-8" },
});
}
96 changes: 96 additions & 0 deletions app/broadcast_hackers/logs/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { redirect } from "next/navigation";
import { desc, eq } from "drizzle-orm";
import { getSessionUser } from "@/lib/auth/session";
import { db } from "@/lib/db";
import { broadcastLogs } from "@/lib/db/schema/broadcasts";
import { users } from "@/lib/db/schema/users";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import styles from "../styles.module.css";

export default async function BroadcastLogsPage() {
const user = await getSessionUser();
if (!user || user.role !== "organizer") {
redirect("/");
}

const logs = await db
.select({
id: broadcastLogs.id,
subject: broadcastLogs.subject,
body: broadcastLogs.body,
sentAt: broadcastLogs.sentAt,
broadcastedToEmail: broadcastLogs.broadcastedToEmail,
broadcastedToText: broadcastLogs.broadcastedToText,
operatorEmail: users.email,
})
.from(broadcastLogs)
.leftJoin(users, eq(broadcastLogs.sentBy, users.id))
.orderBy(desc(broadcastLogs.sentAt));

return (
<div className={styles.container} style={{ maxWidth: 900 }}>
<h1>Broadcast Logs</h1>
<a href="/broadcast_hackers" className={styles.button} style={{ marginBottom: 16, display: "inline-block" }}>
← Back
</a>
<Table>
<TableHeader>
<TableRow>
<TableHead>Time</TableHead>
<TableHead>Title</TableHead>
<TableHead>Body</TableHead>
<TableHead>Sent to Email</TableHead>
<TableHead>Sent to Text</TableHead>
<TableHead>Operator</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{logs.map((log) => {
const emailCount = (log.broadcastedToEmail as string[])?.length ?? 0;
const textCount = (log.broadcastedToText as string[])?.length ?? 0;
return (
<TableRow key={log.id}>
<TableCell className="whitespace-nowrap">
{new Date(log.sentAt).toLocaleString()}
</TableCell>
<TableCell>{log.subject}</TableCell>
<TableCell className="max-w-xs truncate">{log.body}</TableCell>
<TableCell>
<a
href={`/broadcast_hackers/logs/${log.id}/recipients?type=email`}
className="underline"
>
{emailCount} addresses
</a>
</TableCell>
<TableCell>
<a
href={`/broadcast_hackers/logs/${log.id}/recipients?type=text`}
className="underline"
>
{textCount} numbers
</a>
</TableCell>
<TableCell>{log.operatorEmail ?? "—"}</TableCell>
</TableRow>
);
})}
{logs.length === 0 && (
<TableRow>
<TableCell colSpan={6} className="text-center text-muted-foreground">
No broadcasts yet.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
);
}
30 changes: 30 additions & 0 deletions app/broadcast_hackers/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { redirect } from "next/navigation";
import { count } from "drizzle-orm";
import { getSessionUser } from "@/lib/auth/session";
import { db } from "@/lib/db";
import { hackerApplicants } from "@/lib/db/schema/applications";
import BroadcastForm from "./BroadcastForm";
import styles from "./styles.module.css";

export default async function BroadcastPage() {
const user = await getSessionUser();
if (!user || user.role !== "organizer") {
redirect("/");
}

const [{ value: hackerCount }] = await db
.select({ value: count() })
.from(hackerApplicants);

return (
<div className={styles.container}>
<h1>Broadcast Announcements</h1>
<p>Warning! This broadcasts to all {hackerCount} hackers through email and SMS.</p>
<BroadcastForm hackerCount={hackerCount} />
<hr className={styles.divider} />
<a href="/broadcast_hackers/logs" className={styles.button}>
View Logs
</a>
</div>
);
}
32 changes: 32 additions & 0 deletions app/broadcast_hackers/styles.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
.container {
max-width: 480px;
margin: 40px auto;
font-family: sans-serif;
}

.form {
display: flex;
flex-direction: column;
gap: 8px;
}

.input,
.textarea {
width: 100%;
padding: 4px;
border: 1px solid #999;
box-sizing: border-box;
}

.button {
padding: 6px 12px;
border: 1px solid #333;
background: #eee;
cursor: pointer;
text-decoration: none;
display: inline-block;
}

.divider {
margin: 32px 0;
}
17 changes: 17 additions & 0 deletions app/broadcast_hackers/success/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import styles from "../styles.module.css";

export default function BroadcastSuccessPage() {
return (
<div className={styles.container}>
<h1>Broadcast sent successfully.</h1>
<div className={styles.form}>
<a href="/broadcast_hackers" className={styles.button}>
Send another
</a>
<a href="/broadcast_hackers/logs" className={styles.button}>
View logs
</a>
</div>
</div>
);
}
10 changes: 10 additions & 0 deletions lib/aws/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
// TODO: populate these in .env.local before deploying
export const AWS_REGION = process.env.AWS_REGION ?? "us-east-1";
export const AWS_ACCESS_KEY_ID = process.env.AWS_ACCESS_KEY_ID ?? "";
export const AWS_SECRET_ACCESS_KEY = process.env.AWS_SECRET_ACCESS_KEY ?? "";

// TODO: verified SES sender address (must be verified in AWS SES console)
export const SES_FROM_ADDRESS = process.env.SES_FROM_ADDRESS ?? "";

// TODO: End User Messaging origination identity (phone number or pool ARN from AWS console)
export const EUM_ORIGINATION_NUMBER = process.env.EUM_ORIGINATION_NUMBER ?? "";
8 changes: 8 additions & 0 deletions lib/aws/ses.placeholder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export async function sendBulkEmail(
emails: string[],
subject: string,
body: string
): Promise<{ succeeded: string[] }> {
console.log("[placeholder] sendBulkEmail", { emails, subject, body });
return { succeeded: emails };
}
38 changes: 38 additions & 0 deletions lib/aws/ses.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { SESClient, SendEmailCommand } from "@aws-sdk/client-ses";
import {
AWS_REGION,
AWS_ACCESS_KEY_ID,
AWS_SECRET_ACCESS_KEY,
SES_FROM_ADDRESS,
} from "./config";

const client = new SESClient({
region: AWS_REGION,
credentials: {
accessKeyId: AWS_ACCESS_KEY_ID,
secretAccessKey: AWS_SECRET_ACCESS_KEY,
},
});

export async function sendBulkEmail(
emails: string[],
subject: string,
body: string
): Promise<{ succeeded: string[] }> {
const results = await Promise.allSettled(
emails.map((email) =>
client.send(
new SendEmailCommand({
Source: SES_FROM_ADDRESS,
Destination: { ToAddresses: [email] },
Message: {
Subject: { Data: subject },
Body: { Text: { Data: body } },
},
})
)
)
);
const succeeded = emails.filter((_, i) => results[i].status === "fulfilled");
return { succeeded };
}
7 changes: 7 additions & 0 deletions lib/aws/sms.placeholder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export async function sendBulkSMS(
phoneNumbers: string[],
message: string
): Promise<{ succeeded: string[] }> {
console.log("[placeholder] sendBulkSMS", { phoneNumbers, message });
return { succeeded: phoneNumbers };
}
Loading
Loading