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
18 changes: 16 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ npx wrangler d1 execute data --local --file scripts/testing.sql
- `/login` — email-based magic link login via BetterAuth
- `/settings` — user settings: avatar upload, course info display (auth required)
- `/api/auth/[...all]` — BetterAuth catch-all API route
- `/api/feedback` — POST (public): submit feedback; GET (auth): retrieve feedback for current user
- `/api/feedback` — POST (public): submit feedback, and for student mid-/end-of-quarter submissions email the submitter a confirmation; GET (auth): retrieve feedback for current user
- `/api/la` — GET (public): list all LAs with name, course, position, image
- `/api/la/self` — GET (auth): get current user's course positions
- `/api/settings/avatar` — POST (auth): upload and transform avatar image
Expand All @@ -60,9 +60,19 @@ npx wrangler d1 execute data --local --file scripts/testing.sql
- Uses a single Cloudflare D1 database (`data` binding) for all storage — auth tables (`user`, `session`, `account`, `verification`) and app tables (`course`, `feedback`) share one DB.
- The `user` table includes BetterAuth admin fields (`role`, `banned`, `banReason`, `banExpires`) and an `impersonatedBy` field on `session`.
- The `auth.ts` module calls `getCloudflareContext()` to access the D1 binding at runtime — this is async, so a singleton pattern wraps the auth instance.
- Magic links are sent via AWS SES (`lib/email.ts`). Before sending, the `user` table is checked — if no account exists for the email, a "no account found" email is sent instead of the magic link.
- Magic links are sent via AWS SES (`lib/email/`). Before sending, the `user` table is checked — if no account exists for the email, nothing is sent (the attempt is logged instead), so the login form can't be used to probe for accounts.
- Pages requiring auth (`/settings`, `/feedback/view`) wrap their client component in a server component that checks the session via `getAuth()` and redirects to `/login`. The client component does not handle auth checks.

#### Email (`lib/email/`)

All outbound mail goes through **AWS SES**, called as a signed REST request rather than through the AWS SDK — `@aws-sdk/client-sesv2` is far too large for a Worker bundle. `aws4fetch` does SigV4 signing with WebCrypto in a few KB.

- `ses.ts` — the transport. `sendEmail()` POSTs to the SES v2 `outbound-emails` endpoint with both an HTML and a text part, so SES builds a `multipart/alternative` message. It returns a boolean instead of throwing: email is never load-bearing for a request. In `NODE_ENV=development` it logs the message to the console instead of sending, so local dev needs no AWS credentials.
- `render.tsx` — an email is authored **once** as a list of blocks (`heading`, `text`, `button`, `note`, `facts`, `bullets`, …) and `renderEmail()` renders it twice: to HTML and to plain text. Single-sourcing means the text fallback can't drift from the HTML. Mark a block `htmlOnly: true` for HTML-only affordances (e.g. a copy-paste link fallback) so it's dropped from the text version.
- HTML is built from `@react-email/components`, which carries the email-client workarounds worth having (Outlook conditional wrapper tables, the MSO padding hack on buttons, the preheader whitespace trick). Rendering uses `renderToStaticMarkup` from `react-dom/server` — **do not** import `@react-email/render`: it pulls in `html-to-text` and `js-beautify` (~130 KB gzipped) for features this app doesn't use. Components alone cost under 5 KB gzipped.
- `templates.ts` — one function per email returning blocks. Add new emails here, not in route handlers.
- Callers should send inside `ctx.waitUntil()` so a slow SES call doesn't delay the user's response.

#### Feedback form (`app/feedback/`)

The feedback form is the most complex part of the frontend. It conditionally renders different sections based on role (`student`, `la`, `ta`) and feedback type.
Expand Down Expand Up @@ -150,6 +160,10 @@ Wrangler supports all resources used in this project (Workers, D1, KV, R2, secre
- `BETTER_AUTH_URL` — base URL of the app. **Must match the port you're running on:** `http://localhost:3000` for `npm run dev`, `http://localhost:8787` for `npm run preview`. Update this when switching between the two. Use the production domain for prod.
- `NEXT_PUBLIC_BUCKET_URL` — public URL of the R2 bucket for avatar images.
- `NEXTJS_ENV` — set in `.dev.vars` for local dev (`development`).
- `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` — IAM credentials for SES email sending. The IAM user only needs `ses:SendEmail`.
- `AWS_REGION` — SES region (defaults to `us-east-1`). Must be the region where the sending domain is verified.
- `SES_FROM_ADDRESS` — verified sender address (defaults to `admin@laprogramucla.com`).
- `SES_CONFIGURATION_SET` — optional SES configuration set for bounce/complaint tracking.
- Copy `.env.example` to `.env` and fill in values for local development.

### Database Schema
Expand Down
9 changes: 7 additions & 2 deletions laprogram/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,13 @@
BETTER_AUTH_SECRET=
TURNSTILE_SECRET_KEY=

# secret for postmark email service
POSTMARK_SERVER_TOKEN=
# aws ses (email sending)
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_REGION=us-east-1
SES_FROM_ADDRESS=admin@laprogramucla.com
# optional: SES configuration set for open/click/bounce tracking
SES_CONFIGURATION_SET=

# airtable
AIRTABLE_API_KEY=
Expand Down
32 changes: 12 additions & 20 deletions laprogram/app/api/cron/process-withdraws/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,26 +194,18 @@ export async function POST(request: Request) {
}

/*
if (affectedObservers.size > 0 && process.env.POSTMARK_SERVER_TOKEN) {
await fetch("https://api.postmarkapp.com/email/batchWithTemplates", {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
"X-Postmark-Server-Token": process.env.POSTMARK_SERVER_TOKEN,
},
body: JSON.stringify({
Messages: [...affectedObservers].map(([email, withdrawnNames]) => ({
From: "admin@laprogramucla.com",
To: email,
TemplateId: 44230508,
TemplateModel: {
name: observerNames.get(email),
la_name: withdrawnNames.join(", "),
},
})),
}),
});
if (affectedObservers.size > 0) {
await sendEmails(
[...affectedObservers].map(([email, withdrawnNames]) => ({
to: email,
...renderEmail(
observationCancelledEmail(
observerNames.get(email) ?? "there",
withdrawnNames,
),
),
})),
);
}
*/

Expand Down
22 changes: 21 additions & 1 deletion laprogram/app/api/feedback/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { Id } from "@/types/db";
import { headers } from "next/headers";
import { anonFeedbackSchema } from "@/app/feedback/view/columns";
import { sortBy } from "lodash";
import { isQuarterFeedbackType, sendFeedbackConfirmation } from "@/lib/email";

export async function POST(request: Request) {
const request_json = await request.json();
Expand All @@ -17,8 +18,8 @@ export async function POST(request: Request) {
}

const feedback = parsed.data;
const { env, ctx } = getCloudflareContext();
try {
const { env } = getCloudflareContext();
const recipient = await env.data
?.prepare(
`SELECT user.id AS id
Expand All @@ -40,6 +41,25 @@ export async function POST(request: Request) {
return new Response("Encountered database error.", { status: 500 });
}

// Confirmation only goes out for the two student feedback types that carry a
// UID. Sent after the response so a slow or failing SES call can't turn a
// successful submission into an error for the student.
if (
feedback.role === "student" &&
isQuarterFeedbackType(feedback.feedback_type)
) {
ctx.waitUntil(
sendFeedbackConfirmation({
email: feedback.email,
name: feedback.name,
feedbackType: feedback.feedback_type,
course: feedback.course,
la: feedback.la,
uid: feedback.uid,
}),
);
}

return new Response(null, { status: 200 });
}

Expand Down
48 changes: 0 additions & 48 deletions laprogram/lib/email.ts

This file was deleted.

64 changes: 64 additions & 0 deletions laprogram/lib/email/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import "server-only";

import { getCloudflareContext } from "@opennextjs/cloudflare";
import { renderEmail } from "./render";
import { sendEmail } from "./ses";
import {
feedbackConfirmationEmail,
magicLinkEmail,
type QuarterFeedbackType,
} from "./templates";

export { sendEmail, sendEmails } from "./ses";
export { renderEmail } from "./render";
export {
isQuarterFeedbackType,
observationCancelledEmail,
type QuarterFeedbackType,
} from "./templates";

export async function sendMagicLink(email: string, url: string) {
const { env } = await getCloudflareContext({ async: true });

const name = await env.data
?.prepare("SELECT id, name FROM user WHERE email = ?")
.bind(email)
.first("name");

if (!name) {
console.log(
`User with email ${email} attempted to log in; no such user found`,
);
return;
}

await sendEmail({
to: email,
...renderEmail(magicLinkEmail(String(name), url)),
});
}

/**
* Confirms a mid-/end-of-quarter submission back to the student, calling out
* whether they included a UID (the only way a submission can be credited).
*/
export async function sendFeedbackConfirmation(params: {
email: string;
name: string;
feedbackType: QuarterFeedbackType;
course: string;
la: string;
uid?: string;
}) {
const { email, ...content } = params;

await sendEmail({
to: email,
...renderEmail(
feedbackConfirmationEmail({
...content,
uid: content.uid?.trim() ? content.uid.trim() : undefined,
}),
),
});
}
Loading