feat: add links to cancel anonymous participations in emails

Signed-off-by: Thomas Citharel <tcit@tcit.fr>
This commit is contained in:
Thomas Citharel 2023-12-01 09:52:28 +01:00
parent b315e1d7ff
commit 9e6b232a78
No known key found for this signature in database
GPG Key ID: A061B9DDE0CA0773
12 changed files with 244 additions and 24 deletions

View File

@ -176,7 +176,9 @@ defmodule Mobilizon.GraphQL.Resolvers.Participant do
case Participations.leave(event, actor, %{local: false, cancellation_token: token}) do case Participations.leave(event, actor, %{local: false, cancellation_token: token}) do
{:ok, _activity, %Participant{id: participant_id} = _participant} -> {:ok, _activity, %Participant{id: participant_id} = _participant} ->
{:ok, %{event: %{id: event_id}, actor: %{id: actor_id}, id: participant_id}} {:ok, %Event{} = event} = Events.get_event_with_preload(event_id)
%Actor{} = actor = Actors.get_actor_with_preload!(actor_id)
{:ok, %{event: event, actor: actor, id: participant_id}}
{:error, :is_only_organizer} -> {:error, :is_only_organizer} ->
{:error, {:error,
@ -202,8 +204,11 @@ defmodule Mobilizon.GraphQL.Resolvers.Participant do
with {:is_owned, %Actor{} = actor} <- User.owns_actor(user, actor_id), with {:is_owned, %Actor{} = actor} <- User.owns_actor(user, actor_id),
{:has_event, {:ok, %Event{} = event}} <- {:has_event, {:ok, %Event{} = event}} <-
{:has_event, Events.get_event_with_preload(event_id)}, {:has_event, Events.get_event_with_preload(event_id)},
{:ok, _activity, _participant} <- Participations.leave(event, actor) do {:ok, _activity, %Participant{id: participant_id} = _participant} <-
{:ok, %{event: %{id: event_id}, actor: %{id: actor_id}}} Participations.leave(event, actor) do
{:ok, %Event{} = event} = Events.get_event_with_preload(event_id)
%Actor{} = actor = Actors.get_actor_with_preload!(actor_id)
{:ok, %{event: event, actor: actor, id: participant_id}}
else else
{:has_event, _} -> {:has_event, _} ->
{:error, "Event with this ID #{inspect(event_id)} doesn't exist"} {:error, "Event with this ID #{inspect(event_id)} doesn't exist"}

View File

@ -90,8 +90,8 @@ defmodule Mobilizon.GraphQL.Schema.Events.ParticipantType do
object :deleted_participant do object :deleted_participant do
meta(:authorize, :all) meta(:authorize, :all)
field(:id, :id, description: "The participant ID") field(:id, :id, description: "The participant ID")
field(:event, :deleted_object, description: "The participant's event") field(:event, :event, description: "The participant's event")
field(:actor, :deleted_object, description: "The participant's actor") field(:actor, :actor, description: "The participant's actor")
end end
object :participant_mutations do object :participant_mutations do

View File

@ -26,6 +26,8 @@ defmodule Mobilizon.Web.PageController do
defdelegate moderation_report(conn, params), to: PageController, as: :index defdelegate moderation_report(conn, params), to: PageController, as: :index
@spec participation_email_confirmation(Plug.Conn.t(), any) :: Plug.Conn.t() @spec participation_email_confirmation(Plug.Conn.t(), any) :: Plug.Conn.t()
defdelegate participation_email_confirmation(conn, params), to: PageController, as: :index defdelegate participation_email_confirmation(conn, params), to: PageController, as: :index
@spec participation_email_cancellation(Plug.Conn.t(), any) :: Plug.Conn.t()
defdelegate participation_email_cancellation(conn, params), to: PageController, as: :index
@spec user_email_validation(Plug.Conn.t(), any) :: Plug.Conn.t() @spec user_email_validation(Plug.Conn.t(), any) :: Plug.Conn.t()
defdelegate user_email_validation(conn, params), to: PageController, as: :index defdelegate user_email_validation(conn, params), to: PageController, as: :index
@spec my_groups(Plug.Conn.t(), any) :: Plug.Conn.t() @spec my_groups(Plug.Conn.t(), any) :: Plug.Conn.t()

View File

@ -99,6 +99,7 @@ defmodule Mobilizon.Web.Email.Participation do
locale: locale, locale: locale,
event: event, event: event,
jsonLDMetadata: json_ld(participant), jsonLDMetadata: json_ld(participant),
participant: participant,
subject: subject subject: subject
}) })
end end
@ -123,6 +124,7 @@ defmodule Mobilizon.Web.Email.Participation do
locale: locale, locale: locale,
event: event, event: event,
jsonLDMetadata: json_ld(participant), jsonLDMetadata: json_ld(participant),
participant: participant,
subject: subject subject: subject
}) })
end end

View File

@ -195,6 +195,12 @@ defmodule Mobilizon.Web.Router do
get("/participation/email/confirm/:token", PageController, :participation_email_confirmation) get("/participation/email/confirm/:token", PageController, :participation_email_confirmation)
get(
"/participation/email/cancel/:uuid/:token",
PageController,
:participation_email_cancellation
)
get("/validate/email/:token", PageController, :user_email_validation) get("/validate/email/:token", PageController, :user_email_validation)
get("/groups/me", PageController, :my_groups) get("/groups/me", PageController, :my_groups)

View File

@ -87,14 +87,39 @@
style="padding: 20px 30px 40px 30px; color: #474467; font-family: 'Roboto', Helvetica, Arial, sans-serif; font-size: 18px; font-weight: 400; line-height: 25px;" style="padding: 20px 30px 40px 30px; color: #474467; font-family: 'Roboto', Helvetica, Arial, sans-serif; font-size: 18px; font-weight: 400; line-height: 25px;"
> >
<p style="margin: 0"> <p style="margin: 0">
<%= ngettext( <%= gettext(
"Would you wish to cancel your attendance, visit the event page through the link above and click the « Attending » button.", "If you wish to cancel your participation, simply click on the link below."
"Would you wish to cancel your attendance to one or several events, visit the event pages through the links above and click the « Attending » button.",
1
) %> ) %>
</p> </p>
</td> </td>
</tr> </tr>
<tr>
<td bgcolor="#ffffff" align="left">
<table width="100%" border="0" cellspacing="0" cellpadding="0">
<tr>
<td bgcolor="#ffffff" align="center" style="padding: 20px 30px 60px 30px;">
<table border="0" cellspacing="0" cellpadding="0">
<tr>
<td align="center" style="border-radius: 3px;">
<a
href={
~p"/participation/email/cancel/#{@event.uuid}/#{@participant.metadata.cancellation_token}"
|> url()
|> URI.decode()
}
target="_blank"
style="font-size: 20px; font-family: 'Roboto', Helvetica, Arial, sans-serif; color: #3C376E; text-decoration: none; padding: 15px 25px; border-radius: 2px; border: 1px solid #3C376E; display: inline-block;"
>
<%= gettext("Cancel my attendance") %>
</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
<tr> <tr>
<td <td
bgcolor="#ffffff" bgcolor="#ffffff"

View File

@ -88,6 +88,48 @@
</table> </table>
</td> </td>
</tr> </tr>
<%= if @participant.metadata.cancellation_token do %>
<tr>
<td
bgcolor="#ffffff"
align="left"
style="padding: 20px 30px 40px 30px; color: #474467; font-family: 'Roboto', Helvetica, Arial, sans-serif; font-size: 18px; font-weight: 400; line-height: 25px;"
>
<p style="margin: 0">
<%= gettext(
"If you wish to cancel your participation, simply click on the link below."
) %>
</p>
</td>
</tr>
<tr>
<td bgcolor="#ffffff" align="left">
<table width="100%" border="0" cellspacing="0" cellpadding="0">
<tr>
<td bgcolor="#ffffff" align="center" style="padding: 20px 30px 60px 30px;">
<table border="0" cellspacing="0" cellpadding="0">
<tr>
<td align="center" style="border-radius: 3px;">
<a
href={
~p"/participation/email/cancel/#{@event.uuid}/#{@participant.metadata.cancellation_token}"
|> url()
|> URI.decode()
}
target="_blank"
style="font-size: 20px; font-family: 'Roboto', Helvetica, Arial, sans-serif; color: #3C376E; text-decoration: none; padding: 15px 25px; border-radius: 2px; border: 1px solid #3C376E; display: inline-block;"
>
<%= gettext("Cancel my attendance") %>
</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
<% else %>
<tr> <tr>
<td <td
bgcolor="#ffffff" bgcolor="#ffffff"
@ -101,6 +143,7 @@
</p> </p>
</td> </td>
</tr> </tr>
<% end %>
</table> </table>
<!--[if (gte mso 9)|(IE)]> <!--[if (gte mso 9)|(IE)]>
</td> </td>

View File

@ -0,0 +1,107 @@
<template>
<section class="container mx-auto">
<h1 class="title" v-if="loading">
{{ t("Your participation is being cancelled") }}
</h1>
<div v-else>
<div v-if="failed && !leftEvent">
<o-notification
:title="t('Error while cancelling your participation')"
variant="danger"
>
{{
t(
"Either your participation has already been cancelled, either the validation token is incorrect."
)
}}
</o-notification>
</div>
<div v-else-if="leftEvent">
<h1 class="title">
{{ t("Your participation has been cancelled") }}
</h1>
<div class="columns has-text-centered">
<div class="column">
<o-button
tag="router-link"
variant="primary"
size="large"
:to="{
name: RouteName.EVENT,
params: { uuid: leftEvent.uuid },
}"
>{{ t("Return to the event page") }}</o-button
>
</div>
</div>
</div>
</div>
</section>
</template>
<script lang="ts" setup>
import RouteName from "../../router/name";
import { LEAVE_EVENT } from "../../graphql/event";
import { computed, ref, watchEffect } from "vue";
import { useMutation } from "@vue/apollo-composable";
import { useI18n } from "vue-i18n";
import { useHead } from "@unhead/vue";
import { IActor } from "@/types/actor";
import { IEvent } from "@/types/event.model";
import { useAnonymousActorId } from "@/composition/apollo/config";
import { useFetchEventBasic } from "@/composition/apollo/event";
const { t } = useI18n({ useScope: "global" });
useHead({
title: computed(() => t("Confirm participation")),
});
const props = defineProps<{
uuid: string;
token: string;
}>();
const participationToken = computed(() => props.token);
const eventUUID = computed(() => props.uuid);
const loading = ref(true);
const failed = ref(false);
const leftEvent = ref<IEvent | null>(null);
const { anonymousActorId } = useAnonymousActorId();
const { event } = useFetchEventBasic(eventUUID);
const { onDone, onError, mutate } = useMutation<{
leaveEvent: {
id: string;
actor: IActor;
event: IEvent;
};
}>(LEAVE_EVENT);
watchEffect(() => {
if (participationToken.value && event.value) {
mutate({
token: participationToken.value,
eventId: event.value.id,
actorId: anonymousActorId.value,
});
}
});
onDone(async ({ data }) => {
if (data?.leaveEvent.event) {
leftEvent.value = data?.leaveEvent.event;
} else {
failed.value = true;
}
loading.value = false;
});
onError((err) => {
console.error(err);
failed.value = true;
loading.value = false;
});
</script>

View File

@ -322,9 +322,14 @@ export const JOIN_EVENT = gql`
export const LEAVE_EVENT = gql` export const LEAVE_EVENT = gql`
mutation LeaveEvent($eventId: ID!, $actorId: ID!, $token: String) { mutation LeaveEvent($eventId: ID!, $actorId: ID!, $token: String) {
leaveEvent(eventId: $eventId, actorId: $actorId, token: $token) { leaveEvent(eventId: $eventId, actorId: $actorId, token: $token) {
id
actor { actor {
id id
} }
event {
id
uuid
}
} }
} }
`; `;

View File

@ -1630,5 +1630,11 @@
"This announcement will be send to all participants with the statuses selected below. They will not be allowed to reply to your announcement, but they can create a new conversation with you.": "This announcement will be send to all participants with the statuses selected below. They will not be allowed to reply to your announcement, but they can create a new conversation with you.", "This announcement will be send to all participants with the statuses selected below. They will not be allowed to reply to your announcement, but they can create a new conversation with you.": "This announcement will be send to all participants with the statuses selected below. They will not be allowed to reply to your announcement, but they can create a new conversation with you.",
"The following participants are groups, which means group members are able to reply to this conversation:": "The following participants are groups, which means group members are able to reply to this conversation:", "The following participants are groups, which means group members are able to reply to this conversation:": "The following participants are groups, which means group members are able to reply to this conversation:",
"Sent to {count} participants": "Sent to no participants|Sent to one participant|Sent to {count} participants", "Sent to {count} participants": "Sent to no participants|Sent to one participant|Sent to {count} participants",
"There's no announcements yet": "There's no announcements yet" "There's no announcements yet": "There's no announcements yet",
"Your participation is being cancelled": "Your participation is being cancelled",
"Error while cancelling your participation": "Error while cancelling your participation",
"Either your participation has already been cancelled, either the validation token is incorrect.": "Either your participation has already been cancelled, either the validation token is incorrect.",
"Your participation has been cancelled": "Your participation has been cancelled",
"Return to the event page": "Return to the event page",
"Cancel participation": "Cancel participation"
} }

View File

@ -1624,5 +1624,11 @@
"{user}'s follow request was accepted": "La demande de suivi de {user} a été acceptée", "{user}'s follow request was accepted": "La demande de suivi de {user} a été acceptée",
"{user}'s follow request was rejected": "La demande de suivi de {user} a été rejetée", "{user}'s follow request was rejected": "La demande de suivi de {user} a été rejetée",
"© The OpenStreetMap Contributors": "© Les Contributeur·ices OpenStreetMap", "© The OpenStreetMap Contributors": "© Les Contributeur·ices OpenStreetMap",
"There's no announcements yet": "Il n'y a encore aucune annonce" "There's no announcements yet": "Il n'y a encore aucune annonce",
"Your participation is being cancelled": "Votre participation est en cours d'annulation",
"Error while cancelling your participation": "Erreur lors de l'annulation de votre participation",
"Either your participation has already been cancelled, either the validation token is incorrect.": "Soit votre participation a déjà été annulée, soit le jeton de validation est incorrect.",
"Your participation has been cancelled": "Votre participation a été annulée",
"Return to the event page": "Retourner à la page de l'événement",
"Cancel participation": "Annuler la participation"
} }

View File

@ -20,6 +20,7 @@ export enum EventRouteName {
EVENT_PARTICIPATE_WITHOUT_ACCOUNT = "EVENT_PARTICIPATE_WITHOUT_ACCOUNT", EVENT_PARTICIPATE_WITHOUT_ACCOUNT = "EVENT_PARTICIPATE_WITHOUT_ACCOUNT",
EVENT_PARTICIPATE_LOGGED_OUT = "EVENT_PARTICIPATE_LOGGED_OUT", EVENT_PARTICIPATE_LOGGED_OUT = "EVENT_PARTICIPATE_LOGGED_OUT",
EVENT_PARTICIPATE_CONFIRM = "EVENT_PARTICIPATE_CONFIRM", EVENT_PARTICIPATE_CONFIRM = "EVENT_PARTICIPATE_CONFIRM",
EVENT_PARTICIPATE_CANCEL = "EVENT_PARTICIPATE_CANCEL",
TAG = "Tag", TAG = "Tag",
} }
@ -124,6 +125,18 @@ export const eventRoutes: RouteRecordRaw[] = [
}, },
props: true, props: true,
}, },
{
path: "/participation/email/cancel/:uuid/:token",
name: EventRouteName.EVENT_PARTICIPATE_CANCEL,
component: () =>
import("../components/Participation/CancelParticipation.vue"),
meta: {
announcer: {
message: (): string => t("Cancel participation") as string,
},
},
props: true,
},
{ {
path: "/tag/:tag", path: "/tag/:tag",
name: EventRouteName.TAG, name: EventRouteName.TAG,