Skip to content
Merged
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
11 changes: 10 additions & 1 deletion apps/api/src/routers/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -504,7 +504,7 @@ async def rsvp(
) -> RedirectResponse:
"""Change user status for RSVP"""
user_record = await mongodb_handler.retrieve_one(
Collection.USERS, {"_id": user.uid}, ["status", "decision"]
Collection.USERS, {"_id": user.uid}, ["status", "decision", "first_name"]
)

if not user_record or "status" not in user_record:
Expand Down Expand Up @@ -550,6 +550,15 @@ async def rsvp(
old_status = user_record["status"]
log.info(f"User {user.uid} changed status from {old_status} to {new_status}.")

if new_status == Status.CONFIRMED:
try:
await email_handler.send_rsvp_confirmation_email(
user.email, user_record.get("first_name", user.email.split("@")[0])
)
except RuntimeError:
log.error("Could not send RSVP email with SendGrid to %s.", user.uid)
raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR)

return RedirectResponse("/portal", status.HTTP_303_SEE_OTHER)


Expand Down
10 changes: 7 additions & 3 deletions apps/api/src/services/sendgrid_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@

class Template(str, Enum):
# IH 2026
CONFIRMATION_EMAIL = "d-5041cae29f974fe18d18976a9381020c"
SUBMISSION_CONFIRMATION_EMAIL = "d-5041cae29f974fe18d18976a9381020c"
RSVP_CONFIRMATION_EMAIL = "d-48b73d68a0d74da2ba653bbb9efd937c"
WITHDRAWAL_CONFIRMATION_EMAIL = "d-23b3155f63904ac987d4dac3d91f294f"
GUEST_TOKEN = "d-19a126a867294a56b8db9d94a23f7b5d"
WAITLIST_QUEUED_EMAIL = "d-a6de2014ad854658bead73a2718a1152"
WAITLIST_CLOSED_EMAIL = "d-061b28174c9140a095fa99e81e0e7e9c"
Expand Down Expand Up @@ -87,6 +89,8 @@ class LateArrivalRejectionPersonalization(PersonalizationData):
Template.WAITLIST_TRANSFER_EMAIL,
Template.WAITLIST_QUEUED_EMAIL,
Template.WAITLIST_CLOSED_EMAIL,
Template.RSVP_CONFIRMATION_EMAIL,
Template.WITHDRAWAL_CONFIRMATION_EMAIL,
]

LogisticsTemplates: TypeAlias = Literal[
Expand All @@ -104,7 +108,7 @@ class LateArrivalRejectionPersonalization(PersonalizationData):

@overload
async def send_email(
template_id: Literal[Template.CONFIRMATION_EMAIL],
template_id: Literal[Template.SUBMISSION_CONFIRMATION_EMAIL],
sender_email: Tuple[str, str],
receiver_data: ConfirmationPersonalization,
send_to_multiple: Literal[False] = False,
Expand All @@ -124,7 +128,7 @@ async def send_email(

@overload
async def send_email(
template_id: Literal[Template.CONFIRMATION_EMAIL],
template_id: Literal[Template.SUBMISSION_CONFIRMATION_EMAIL],
sender_email: Tuple[str, str],
receiver_data: Iterable[ConfirmationPersonalization],
send_to_multiple: Literal[True],
Expand Down
13 changes: 12 additions & 1 deletion apps/api/src/utils/email_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ async def send_application_confirmation_email(
"""Send a confirmation email after a user submits an application.
Will propagate exceptions from SendGrid."""
await sendgrid_handler.send_email(
Template.CONFIRMATION_EMAIL,
Template.SUBMISSION_CONFIRMATION_EMAIL,
IH_SENDER,
{
"email": email,
Expand All @@ -60,6 +60,17 @@ async def send_application_confirmation_email(
)


async def send_rsvp_confirmation_email(email: EmailStr, first_name: str) -> None:
"""Send a confirmation email after a user submits an RSVP.
Will propagate exceptions from SendGrid."""
await sendgrid_handler.send_email(
Template.RSVP_CONFIRMATION_EMAIL,
IH_SENDER,
ApplicationUpdatePersonalization(email=email, first_name=first_name),
False,
)


async def send_guest_login_email(email: EmailStr, passphrase: str) -> None:
"""Email login passphrase to guest."""
await sendgrid_handler.send_email(
Expand Down
14 changes: 7 additions & 7 deletions apps/api/tests/test_sendgrid_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ async def test_send_single_email(mock_AsyncClient: AsyncMock) -> None:
recipient_data = SAMPLE_RECIPIENTS[0]

await sendgrid_handler.send_email(
Template.CONFIRMATION_EMAIL, SAMPLE_SENDER, recipient_data
Template.SUBMISSION_CONFIRMATION_EMAIL, SAMPLE_SENDER, recipient_data
)
mock_client.send_mail_v3.assert_awaited_once_with(
body={
Expand All @@ -48,7 +48,7 @@ async def test_send_single_email(mock_AsyncClient: AsyncMock) -> None:
"dynamic_template_data": recipient_data,
}
],
"template_id": Template.CONFIRMATION_EMAIL,
"template_id": Template.SUBMISSION_CONFIRMATION_EMAIL,
}
)

Expand All @@ -63,7 +63,7 @@ async def test_send_single_email_with_reply_to(mock_AsyncClient: AsyncMock) -> N
recipient_data = SAMPLE_RECIPIENTS[0]

await sendgrid_handler.send_email(
Template.CONFIRMATION_EMAIL,
Template.SUBMISSION_CONFIRMATION_EMAIL,
SAMPLE_SENDER,
recipient_data,
reply_to=SAMPLE_SENDER,
Expand All @@ -80,7 +80,7 @@ async def test_send_single_email_with_reply_to(mock_AsyncClient: AsyncMock) -> N
"dynamic_template_data": recipient_data,
}
],
"template_id": Template.CONFIRMATION_EMAIL,
"template_id": Template.SUBMISSION_CONFIRMATION_EMAIL,
"reply_to": {
"name": SAMPLE_SENDER[1],
"email": SAMPLE_SENDER[0],
Expand All @@ -97,7 +97,7 @@ async def test_send_multiple_emails(mock_AsyncClient: AsyncMock) -> None:
mock_AsyncClient.return_value.__aenter__.return_value = mock_client

await sendgrid_handler.send_email(
Template.CONFIRMATION_EMAIL,
Template.SUBMISSION_CONFIRMATION_EMAIL,
SAMPLE_SENDER,
SAMPLE_RECIPIENTS,
True,
Expand All @@ -115,7 +115,7 @@ async def test_send_multiple_emails(mock_AsyncClient: AsyncMock) -> None:
"dynamic_template_data": SAMPLE_RECIPIENTS[0],
},
],
"template_id": Template.CONFIRMATION_EMAIL,
"template_id": Template.SUBMISSION_CONFIRMATION_EMAIL,
}
)

Expand All @@ -134,7 +134,7 @@ async def test_sendgrid_error_causes_runtime_error(mock_AsyncClient: AsyncMock)

with pytest.raises(RuntimeError):
await sendgrid_handler.send_email(
Template.CONFIRMATION_EMAIL,
Template.SUBMISSION_CONFIRMATION_EMAIL,
SAMPLE_SENDER,
SAMPLE_RECIPIENTS,
True,
Expand Down
62 changes: 46 additions & 16 deletions apps/api/tests/test_user.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,18 @@
from routers import user
from services.mongodb_handler import Collection

USER_EMAIL = "tree@stanford.edu"

app = FastAPI()
app.include_router(user.router)

client = TestClient(app, follow_redirects=False)


def test_login_as_uci_redirects_to_saml() -> None:
"""Tests that logging in with UCI email redirects to SAML for UCI SSO"""
"""Tests that logging in with UCI email redirects to SAML for UCI SSO."""
res = client.post("/login", data={"email": "hack@uci.edu"}, follow_redirects=False)

assert res.status_code == status.HTTP_303_SEE_OTHER
assert res.headers["location"] == "/api/saml/login?return_to=%2Fportal"

Expand All @@ -30,13 +33,15 @@ def test_login_as_non_uci_redirects_to_guest_login() -> None:
data={"email": "jeff@amazon.com"},
follow_redirects=False,
)

assert res.status_code == status.HTTP_307_TEMPORARY_REDIRECT
assert res.headers["location"] == "/api/guest/login?return_to=%2Fbooks"


def test_logout() -> None:
"""Test that logging out removes the authentication cookie."""
res = client.get("/logout")

assert res.status_code == status.HTTP_303_SEE_OTHER
assert res.headers["location"] == "/"
assert res.headers["Set-Cookie"].startswith('irvinehacks_auth=""; Max-Age=0;')
Expand All @@ -46,6 +51,7 @@ def test_no_identity_when_unauthenticated() -> None:
"""Test that identity is empty when not authenticated."""
res = client.get("/me")
data = res.json()

assert data == {
"uid": None,
"status": None,
Expand All @@ -60,12 +66,16 @@ def test_plain_identity_when_no_user_record(
) -> None:
"""Test that identity contains just uid when there is no associated user record."""
mock_mongodb_handler_retrieve_one.return_value = None
client = UserTestClient(GuestUser(email="tree@stanford.edu"), app)
res = client.get("/me")

auth_client = UserTestClient(GuestUser(email=USER_EMAIL), app)
res = auth_client.get("/me")

mock_mongodb_handler_retrieve_one.assert_awaited_once_with(
Collection.USERS, {"_id": "edu.stanford.tree"}, ["roles", "status", "decision"]
Collection.USERS,
{"_id": "edu.stanford.tree"},
["roles", "status", "decision"],
)

data = res.json()
assert data == {
"uid": "edu.stanford.tree",
Expand All @@ -75,35 +85,50 @@ def test_plain_identity_when_no_user_record(
}


@patch("utils.email_handler.send_rsvp_confirmation_email", autospec=True)
@patch("services.mongodb_handler.update_one", autospec=True)
@patch("services.mongodb_handler.retrieve_one", autospec=True)
def test_user_with_status_waiver_signed_rsvp_changes_status_to_confirmed(
mock_mongodb_handler_retrieve_one: AsyncMock,
mock_mongodb_handler_update_one: AsyncMock,
mock_send_rsvp_confirmation_email: AsyncMock,
) -> None:
"""Test user with WAIVER_SIGNED status has new status of CONFIRMED after RSVP."""
mock_mongodb_handler_retrieve_one.return_value = {"status": Status.WAIVER_SIGNED}
mock_mongodb_handler_retrieve_one.return_value = {
"status": Status.WAIVER_SIGNED,
"first_name": "tree",
}

client = UserTestClient(GuestUser(email="tree@stanford.edu"), app)
res = client.post("/rsvp", follow_redirects=False)
auth_client = UserTestClient(GuestUser(email=USER_EMAIL), app)
res = auth_client.post("/rsvp", follow_redirects=False)

mock_mongodb_handler_update_one.assert_awaited_once_with(
Collection.USERS,
{"_id": "edu.stanford.tree"},
{"status": Status.CONFIRMED, "arrival_time": "17:00"},
)

assert res.status_code == 303
mock_send_rsvp_confirmation_email.assert_awaited_once_with(
USER_EMAIL,
"tree",
)

assert res.status_code == status.HTTP_303_SEE_OTHER


@patch("utils.email_handler.send_rsvp_confirmation_email", autospec=True)
@patch("services.mongodb_handler.update_one", autospec=True)
@patch("services.mongodb_handler.retrieve_one", autospec=True)
def test_user_with_late_arrival_rsvp_saves_reason(
mock_mongodb_handler_retrieve_one: AsyncMock,
mock_mongodb_handler_update_one: AsyncMock,
mock_send_rsvp_confirmation_email: AsyncMock,
) -> None:
"""Test RSVP with late arrival stores the arrival time and reason."""
mock_mongodb_handler_retrieve_one.return_value = {"status": Status.WAIVER_SIGNED}
mock_mongodb_handler_retrieve_one.return_value = {
"status": Status.WAIVER_SIGNED,
"first_name": "tree",
}

client = UserTestClient(GuestUser(email="tree@stanford.edu"), app)
res = client.post(
Expand All @@ -125,6 +150,11 @@ def test_user_with_late_arrival_rsvp_saves_reason(
},
)

mock_send_rsvp_confirmation_email.assert_awaited_once_with(
USER_EMAIL,
"tree",
)

assert res.status_code == 303


Expand All @@ -134,19 +164,19 @@ def test_user_with_status_confirmed_un_rsvp_changes_status_to_waiver_signed(
mock_mongodb_handler_retrieve_one: AsyncMock,
mock_mongodb_handler_update_one: AsyncMock,
) -> None:
"""Test user with WAIVER_SIGNED status has new status of CONFIRMED after RSVP."""
"""Test user with CONFIRMED status has new status of WAIVER_SIGNED after un-RSVP."""
mock_mongodb_handler_retrieve_one.return_value = {"status": Status.CONFIRMED}

client = UserTestClient(GuestUser(email="tree@stanford.edu"), app)
res = client.post("/rsvp", follow_redirects=False)
auth_client = UserTestClient(GuestUser(email=USER_EMAIL), app)
res = auth_client.post("/rsvp", follow_redirects=False)

mock_mongodb_handler_update_one.assert_awaited_once_with(
Collection.USERS,
{"_id": "edu.stanford.tree"},
{"status": Status.WAIVER_SIGNED, "arrival_time": "17:00"},
)

assert res.status_code == 303
assert res.status_code == status.HTTP_303_SEE_OTHER


@patch("services.mongodb_handler.update_one", autospec=True)
Expand All @@ -170,15 +200,15 @@ def test_user_with_status_accepted_rsvp_returns_403(
def test_user_me_route_returns_correct_type(
mock_mongodb_handler_retrieve_one: AsyncMock,
) -> None:
"""Test user me route returns correct fields as listed in user.IdentityResponse"""
"""Test user me route returns correct fields as listed in user.IdentityResponse."""
mock_mongodb_handler_retrieve_one.return_value = {
"status": Status.WAIVER_SIGNED,
"roles": [Role.VOLUNTEER],
"decision": None,
}

client = UserTestClient(GuestUser(email="tree@stanford.edu"), app)
res = client.get("/me")
auth_client = UserTestClient(GuestUser(email=USER_EMAIL), app)
res = auth_client.get("/me")
data = res.json()

assert data == {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,8 @@ function Portal() {
</h2>
<AvatarDisplay />
<VerticalTimeline
status={identity?.decision ? identity?.decision : (status as Status)}
status={status as Status}
decision={identity?.decision as Decision | null}
/>
<Message
status={status as Status}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,21 +1,24 @@
import React from "react";
import { Status } from "@/lib/userRecord";
import { Decision, Status } from "@/lib/userRecord";
import { SubmissionComponent } from "./SubmissionComponent";
import { VerdictComponent } from "./VerdictComponent";
import { RSVPComponent } from "./RSVPComponent";
// import { WaiverComponent } from "./WaiverComponent";
// import { RSVPComponent } from "./RSVPComponent";

interface VerticalTimelineProps {
status: Status;
decision?: Decision | null;
}

function VerticalTimeline({ status }: VerticalTimelineProps) {
function VerticalTimeline({ status, decision }: VerticalTimelineProps) {
const verdictStatus = decision ?? status;

return (
<div className="flex flex-col gap-6 md:gap-10">
<SubmissionComponent />
<VerdictComponent status={status} />
{/* <WaiverComponent status={status} />
<RSVPComponent status={status} /> */}
<VerdictComponent status={verdictStatus} />
<RSVPComponent status={status} />
{/* <WaiverComponent status={status} /> */}
</div>
);
}
Expand Down
Loading