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

Allow rostering directly from email #375

Merged
11 changes: 11 additions & 0 deletions frontend/src/components/App.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@ const SeriesRoster = lazy(() => import("./series/SeriesRoster"));
// const EditStats = lazy(() => import("./match/stats/EditStats"));
const EditStatsMin = lazy(() => import("./match/stats/EditMinStats"));
const ViewStats = lazy(() => import("./match/stats/ViewStats"));
const EmailInvitationHandler = lazy(() =>
import("./series/InvitationViaEmail")
);

const filters = {
id: /^\d+$/ // only allow numbers
Expand Down Expand Up @@ -117,6 +120,14 @@ export default function App() {
path="/tournament/:tournamentSlug/match/:matchId/live"
component={ViewStats}
/>
<Route
path="/invitation/accept"
component={EmailInvitationHandler}
/>
<Route
path="/invitation/decline"
component={EmailInvitationHandler}
/>
{/* Team Public Routes */}
<Route path={"/teams"} component={Teams} />
<Route path={"/team/:slug"} component={ViewTeam} />
Expand Down
163 changes: 163 additions & 0 deletions frontend/src/components/series/InvitationViaEmail.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import { useNavigate, useSearchParams } from "@solidjs/router";
import { createMutation } from "@tanstack/solid-query";
import { createEffect, createSignal, onMount, Show } from "solid-js";

import {
acceptSeriesInvitationFromEmail,
declineSeriesInvitationFromEmail
} from "../../queries";

const EmailInvitationHandler = () => {
const [searchParams] = useSearchParams();
const navigate = useNavigate();

const [token, setToken] = createSignal("");
const [responseMessage, setResponseMessage] = createSignal("");
const [setIsAccepting] = createSignal(false);
const [teamName, setTeamName] = createSignal("");
const [teamLogo, setTeamLogo] = createSignal("");
const [fromUser, setFromUser] = createSignal(null);
const [toPlayer, setToPlayer] = createSignal(null);
const [countdown, setCountdown] = createSignal(20);
const [isSuccess, setIsSuccess] = createSignal(false);

onMount(() => {
const urlToken = searchParams.token;
if (urlToken) {
setToken(urlToken);
const action = window.location.pathname.includes("invitation/accept")
? "accept"
: "decline";
setIsAccepting(action === "accept");
handleInvitation(action);
} else {
setResponseMessage("Invalid invitation link. No token found.");
}
});

createEffect(() => {
const timer = setInterval(() => {
setCountdown(c => {
if (c <= 1) {
clearInterval(timer);
navigate("/");
return 0;
}
return c - 1;
});
}, 1000);

return () => clearInterval(timer);
});

const acceptMutation = createMutation({
mutationFn: acceptSeriesInvitationFromEmail,
onSuccess: data => {
setIsSuccess(true);
setResponseMessage("Invitation accepted successfully");
setTeamName(data.team.name);
setTeamLogo(data.team.image_url);
setFromUser(data.from_user);
setToPlayer(data.to_player);
},
onError: error => {
setIsSuccess(false);
setResponseMessage(
error.message || "An error occurred while processing your invitation."
);
}
});

const declineMutation = createMutation({
mutationFn: declineSeriesInvitationFromEmail,
onSuccess: data => {
setIsSuccess(true);
setResponseMessage("Invitation declined successfully");
setTeamName(data.team.name);
setTeamLogo(data.team.image_url);
setFromUser(data.from_user);
setToPlayer(data.to_player);
},
onError: error => {
setIsSuccess(false);
setResponseMessage(
error.message || "An error occurred while processing your invitation."
);
}
});

const handleInvitation = action => {
if (action === "accept") {
acceptMutation.mutate({ token: token() });
} else {
declineMutation.mutate({ token: token() });
}
};

return (
<div class="pt-30 flex min-h-screen flex-col items-center bg-gray-100 px-4 py-12 sm:px-6 lg:px-8">
<div class="w-full max-w-md space-y-8 rounded-xl bg-white p-8 shadow-xl">
<Show
when={!acceptMutation.isLoading && !declineMutation.isLoading}
fallback={
<p class="text-center text-xl font-semibold text-gray-700">
Processing your invitation...
</p>
}
>
<Show
when={isSuccess()}
fallback={
<div class="text-center">
<p class="mb-4 text-xl font-semibold text-red-600">
{responseMessage()}
</p>
</div>
}
>
<div class="mb-6 flex items-center space-x-4">
<img
src={teamLogo()}
alt={`${teamName()} logo`}
class="h-16 w-16 rounded-full object-cover"
/>
<div>
<h2 class="text-2xl font-bold text-gray-900">{teamName()}</h2>
<Show when={fromUser()}>
<p class="text-sm text-gray-600">
Invitation from: {fromUser().full_name}
</p>
</Show>
<Show when={toPlayer()}>
<p class="text-sm text-gray-600">
To: {toPlayer().full_name}
</p>
</Show>
</div>
</div>

<div class="mb-6 rounded-lg bg-gray-50 p-4">
<p class="mb-2 text-lg font-medium text-gray-900">
{responseMessage()}
</p>
</div>
</Show>
</Show>
<div class="text-center">
<p class="mb-2 text-gray-600">
Redirecting to homepage in{" "}
<span class="font-bold">{countdown()}</span> seconds...
</p>
<button
onClick={() => navigate("/")}
class="w-full rounded-lg bg-blue-600 px-4 py-2 font-bold text-white transition duration-150 ease-in-out hover:bg-blue-700"
>
Go to Homepage
</button>
</div>
</div>
</div>
);
};

export default EmailInvitationHandler;
33 changes: 33 additions & 0 deletions frontend/src/queries.js
Original file line number Diff line number Diff line change
Expand Up @@ -743,6 +743,39 @@ export const revokeInvitation = async ({ invitation_id }) => {
return data;
};

export const acceptSeriesInvitationFromEmail = async ({ token }) => {
const response = await fetch(`/api/series/invitation/accept?token=${token}`, {
method: "GET",
headers: {
"Content-Type": "application/json"
}
});
const data = await response.json();

if (!response.ok) {
throw new Error(data?.message || JSON.stringify(data));
}
return data;
};

export const declineSeriesInvitationFromEmail = async ({ token }) => {
const response = await fetch(
`/api/series/invitation/decline?token=${token}`,
{
method: "GET",
headers: {
"Content-Type": "application/json"
}
}
);
const data = await response.json();

if (!response.ok) {
throw new Error(data?.message || JSON.stringify(data));
}
return data;
};

export const acceptSeriesInvitation = async ({ invitation_id }) => {
const response = await fetch(
`/api/series/invitation/${invitation_id}/accept`,
Expand Down
6 changes: 6 additions & 0 deletions hub/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,12 @@
EMAIL_HOST_PASSWORD = os.environ.get("EMAIL_HOST_PASSWORD")
EMAIL_PORT = 587
EMAIL_USE_TLS = True
EMAIL_SECRET_KEY = os.environ.get("EMAIL_SECRET_KEY")

if DEBUG:
EMAIL_INVITATION_BASE_URL = f"http://localhost:{os.environ.get('WEBPACK_SERVER_PORT', 3000)}"
else:
EMAIL_INVITATION_BASE_URL = "https://hub.indiaultimate.org"

# OTP settings
OTP_EMAIL_HASH_KEY = os.environ.get("OTP_EMAIL_HASH_KEY", "")
Expand Down
116 changes: 115 additions & 1 deletion server/series/api.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from typing import Any

from django.conf import settings
from django.db.models import QuerySet
from django.http import HttpRequest
from ninja import Router
Expand All @@ -23,6 +24,8 @@
from .utils import (
can_invite_player_to_series_roster,
can_register_player_to_series_roster,
generate_invitation_token,
get_details_from_invitation_token,
register_player,
send_invitation_email,
)
Expand Down Expand Up @@ -233,11 +236,122 @@ def send_series_invitation(

invitation.save()

send_invitation_email(from_user=request.user, to_player=to_player, team=team, series=series)
invitation_token = generate_invitation_token(invitation.id)

accept_invitation_link = (
f"{settings.EMAIL_INVITATION_BASE_URL}/invitation/accept?token={invitation_token}"
)
decline_invitation_link = (
f"{settings.EMAIL_INVITATION_BASE_URL}/invitation/decline?token={invitation_token}"
)

send_invitation_email(
from_user=request.user,
to_player=to_player,
team=team,
series=series,
accept_invitation_link=accept_invitation_link,
decline_invitation_link=decline_invitation_link,
)

return 200, invitation


@router.get(
"/invitation/accept",
response={200: SeriesRosterInvitationSchema, 400: Response},
auth=None,
)
def accept_series_invitation_via_mail(
request: HttpRequest,
token: str,
) -> tuple[int, SeriesRegistration | message_response]:
valid, invitation_id = get_details_from_invitation_token(token)

if not valid:
return 400, {"message": "Invitation link is invalid"}

try:
invitation = SeriesRosterInvitation.objects.get(id=invitation_id)
except SeriesRosterInvitation.DoesNotExist:
return 400, {"messsage": "Invitation does not exist"}

match invitation.status:
case SeriesRosterInvitation.Status.EXPIRED:
return 400, {"message": "This invitation has expired 😔"}

case SeriesRosterInvitation.Status.DECLINED:
return 400, {"message": "You cannot accept an invitation that was declined"}

case SeriesRosterInvitation.Status.ACCEPTED:
return 400, {
"message": f"You already accepted this invitation on {invitation.rsvp_date}"
}

case SeriesRosterInvitation.Status.REVOKED:
return 400, {"message": "This invitation has been revoked"}

case SeriesRosterInvitation.Status.PENDING:
series_registration, error = register_player(
series=invitation.series, team=invitation.team, player=invitation.to_player
)
if error:
return 400, error

if series_registration is None:
return 400, {"message": "Couldn't register player"}

invitation.status = SeriesRosterInvitation.Status.ACCEPTED
invitation.rsvp_date = today()
invitation.save()
return 200, invitation

return 400, {"message": f"'{invitation.status}' is not a valid invitation status"}


@router.get(
"/invitation/decline",
response={200: SeriesRosterInvitationSchema, 400: Response},
auth=None,
)
def decline_series_invitation_via_mail(
request: HttpRequest,
token: str,
) -> tuple[int, SeriesRosterInvitation | message_response]:
valid, invitation_id = get_details_from_invitation_token(token)

if not valid:
return 400, {"message": "Invitation link is invalid"}

try:
invitation = SeriesRosterInvitation.objects.get(id=invitation_id)
except SeriesRosterInvitation.DoesNotExist:
return 400, {"messsage": "Invitation does not exist"}

match invitation.status:
case SeriesRosterInvitation.Status.EXPIRED:
return 400, {"message": "This invitation has expired 😔"}

case SeriesRosterInvitation.Status.ACCEPTED:
return 400, {"message": "You cannot decline an invitation that was accepted"}

case SeriesRosterInvitation.Status.DECLINED:
return 400, {
"message": f"You already declined this invitation on {invitation.rsvp_date}"
}

case SeriesRosterInvitation.Status.REVOKED:
return 400, {"message": "This invitation has been revoked"}

case SeriesRosterInvitation.Status.PENDING:
invitation.status = SeriesRosterInvitation.Status.DECLINED
invitation.rsvp_date = today()
invitation.save()
return 200, invitation

return 400, {"message": f"'{invitation.status}' is not a valid invitation status"}


@router.delete(
"/invitation/{invitation_id}",
response={200: SeriesRosterInvitationSchema, 400: Response, 401: Response},
Expand Down
Loading
Loading