Skip to content

Commit

Permalink
Merge pull request #63 from parkerdavis1/email-notifications
Browse files Browse the repository at this point in the history
Email notifications pt. 2
  • Loading branch information
gregv authored Dec 22, 2023
2 parents a095416 + b1af2d6 commit 161c913
Show file tree
Hide file tree
Showing 10 changed files with 201 additions and 102 deletions.
2 changes: 1 addition & 1 deletion app/root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +231,7 @@ function UserDropdown() {
<DropdownMenuTrigger asChild>
<Button asChild variant="secondary" className="min-w-[8rem]">
<Link
to={`/users/${user.username}`}
to={`/settings/profile`}
// this is for progressive enhancement
onClick={e => e.preventDefault()}
>
Expand Down
33 changes: 4 additions & 29 deletions app/routes/_auth+/onboarding.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -118,16 +118,8 @@ export async function action({ request }: DataFunctionArgs) {
{ status: 400 },
)
}
const {
username,
name,
password,
phone,
// TODO: add user to mailing list if they agreed to it
// agreeToMailingList,
remember,
redirectTo,
} = submission.value
const { username, name, password, phone, remember, redirectTo } =
submission.value

const session = await signup({ email, username, password, name, phone })

Expand Down Expand Up @@ -175,11 +167,7 @@ export default function OnboardingPage() {
</p>
</div>
<Spacer size="xs" />
<Form
method="POST"
className="mx-auto w-full max-w-sm"
{...form.props}
>
<Form method="POST" className="mx-auto w-full max-w-sm" {...form.props}>
<Field
labelProps={{ htmlFor: fields.username.id, children: 'Username' }}
inputProps={{
Expand Down Expand Up @@ -233,15 +221,14 @@ export default function OnboardingPage() {
htmlFor: fields.agreeToTermsOfServiceAndPrivacyPolicy.id,
children: (
<>
Do you agree to our{' '}
I agree to the{' '}
<Link to="/tos" target="_blank" className="underline">
Terms of Service
</Link>{' '}
and{' '}
<Link to="/privacy" target="_blank" className="underline">
Privacy Policy
</Link>
?
</>
),
}}
Expand All @@ -252,18 +239,6 @@ export default function OnboardingPage() {
errors={fields.agreeToTermsOfServiceAndPrivacyPolicy.errors}
/>

<CheckboxField
labelProps={{
htmlFor: fields.agreeToMailingList.id,
children:
'Would you like to recieve emails about upcoming opportunities to volunteer?',
}}
buttonProps={conform.input(fields.agreeToMailingList, {
type: 'checkbox',
})}
errors={fields.agreeToMailingList.errors}
/>

<CheckboxField
labelProps={{
htmlFor: fields.remember.id,
Expand Down
30 changes: 21 additions & 9 deletions app/routes/_auth+/signup/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,12 @@ export const verificationType = 'onboarding'

const signupSchema = z.object({
email: emailSchema,
signupPassword: z.string().min(1, { message: "Please fill this in with the password given to you by the volunteer coordinator." }),
signupPassword: z
.string()
.min(1, {
message:
'Please fill this in with the password given to you by the volunteer coordinator.',
}),
})

export async function action({ request }: DataFunctionArgs) {
Expand All @@ -52,12 +57,14 @@ export async function action({ request }: DataFunctionArgs) {
return
}

const validSignupPassword = await verifySignupPassword(data.signupPassword)
const validSignupPassword = await verifySignupPassword(
data.signupPassword,
)
if (!validSignupPassword) {
ctx.addIssue({
path: ['signupPassword'],
code: z.ZodIssueCode.custom,
message: 'Incorrect signup password'
message: 'Incorrect signup password',
})
return
}
Expand Down Expand Up @@ -118,11 +125,12 @@ export async function action({ request }: DataFunctionArgs) {
if (response.status === 'success') {
return redirect(redirectTo.pathname + redirectTo.search)
} else {
submission.error[''] = 'There was an error sending the email.' + response.error;
if ( response.error?.message ){
submission.error[''] += ' ' + response.error.message;
submission.error[''] =
'There was an error sending the email.' + response.error
if (response.error?.message) {
submission.error[''] += ' ' + response.error.message
}

return json(
{
status: 'error',
Expand Down Expand Up @@ -158,7 +166,8 @@ export default function SignupRoute() {
<div className="text-center">
<h1 className="text-4xl sm:text-h1">Let's saddle up!</h1>
<p className="mt-3 text-body-md text-muted-foreground">
Please enter your email and the secret password given to you by the volunteer coordinator.
Please enter your email and the secret password given to you by the
volunteer coordinator.
</p>
</div>
<Form
Expand All @@ -179,7 +188,10 @@ export default function SignupRoute() {
htmlFor: fields.signupPassword.id,
children: 'Secret',
}}
inputProps={{ ...conform.input(fields.signupPassword), autoFocus: true, type: "password" }}
inputProps={{
...conform.input(fields.signupPassword),
autoFocus: true,
}}
errors={fields.signupPassword.errors}
/>
<ErrorList errors={form.errors} id={form.errorId} />
Expand Down
114 changes: 86 additions & 28 deletions app/routes/admin+/_email+/email.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import { getFieldsetConstraint, parse } from '@conform-to/zod'
import { type DataFunctionArgs, json } from '@remix-run/node'
import { Form, useActionData } from '@remix-run/react'
import { type DataFunctionArgs, json, type LoaderArgs } from '@remix-run/node'
import {
Form,
useActionData,
useFormAction,
useNavigation,
} from '@remix-run/react'
import { z } from 'zod'
import { Button } from '~/components/ui/button.tsx'
import { requireAdmin } from '~/utils/permissions.server.ts'
import { useToast } from '~/components/ui/use-toast.ts'
import { checkboxSchema } from '~/utils/zod-extensions.ts'
Expand All @@ -18,14 +22,15 @@ import {
} from '~/components/forms.tsx'
import { conform, useForm } from '@conform-to/react'
import { CustomEmail } from './CustomEmail.server.tsx'
import { StatusButton } from '~/components/ui/status-button.tsx'
import { useRef } from 'react'

const emailFormSchema = z
.object({
allVolunteers: checkboxSchema(),
lessonAssistant: checkboxSchema(),
horseLeader: checkboxSchema(),
instructor: checkboxSchema(),
admin: checkboxSchema(),
subject: z
.string()
.min(1, { message: 'Your email must include a subject' }),
Expand All @@ -37,25 +42,41 @@ const emailFormSchema = z
message: 'Must check at least one checkbox',
})

export const loader = async ({ request }: LoaderArgs) => {
await requireAdmin(request)
return null
}

export async function action({ request, params }: DataFunctionArgs) {
await requireAdmin(request)
const formData = await request.formData()
const submission = parse(formData, { schema: emailFormSchema })
if (!submission.value) {
return json({ status: 'error', submission } as const, { status: 400 })
return json({ status: 'error', submission, error: 'error' } as const, {
status: 400,
})
}
console.log('submission', submission)
// Get list of people to email
const roles = [
'allVolunteers',
'lessonAssistant',
'horseLeader',
'instructor',
'admin',
]
const selectedRoles = roles.filter(role => submission.payload[role] === 'on')
const recipients = await getRecipientsFromRoles(selectedRoles)
console.log('resulting list of recipients', recipients)

if (recipients.length === 0) {
return json(
{
status: 'error',
error: 'no-recipients',
submission,
recipients,
} as const,
{ status: 400 },
)
}

for (let recipient of recipients) {
sendEmail({
Expand All @@ -68,20 +89,28 @@ export async function action({ request, params }: DataFunctionArgs) {
'There was an error sending emails',
JSON.stringify(result.error),
)
return json({ status: 'error', result }, { status: 400 })
return json({ status: 'error', error: 'error', result } as const, {
status: 400,
})
}
})
}
return json(
{
status: 'ok',
status: 'success',
submission,
},
error: null,
recipients,
} as const,
{ status: 200 },
)
}

export default function Email() {
const formAction = useFormAction()
const navigation = useNavigation()
const isSubmitting = navigation.formAction === formAction
const formRef = useRef<HTMLFormElement>(null)
const actionData = useActionData<typeof action>()
const [form, fields] = useForm({
id: 'email-form',
Expand All @@ -94,10 +123,22 @@ export default function Email() {
const { toast } = useToast()
useResetCallback(actionData, () => {
if (!actionData) return
if (actionData?.status === 'ok') {
if (actionData?.status === 'success') {
formRef.current?.reset()
const recipients = actionData.recipients
const plural = recipients.length > 1
toast({
title: 'Success',
description: 'Emails sent',
description: `Sent email${plural ? 's' : ''} to ${
recipients.length
} recipient${plural ? 's' : ''}`,
})
} else if (actionData?.error === 'no-recipients') {
toast({
variant: 'destructive',
title: 'No recipients',
description:
'There are no users with that role that are accepting emails',
})
} else {
toast({
Expand All @@ -112,7 +153,12 @@ export default function Email() {
<div>
<h1 className="text-center text-5xl">Email</h1>
<div className="container pt-10">
<Form method="POST" {...form.props} className="mx-auto max-w-lg">
<Form
method="POST"
{...form.props}
className="mx-auto max-w-lg"
ref={formRef}
>
<section className="flex flex-col gap-2">
<Label>To:</Label>
<CheckboxField
Expand Down Expand Up @@ -155,16 +201,6 @@ export default function Email() {
}}
errors={fields.instructor.errors}
/>
<CheckboxField
labelProps={{
htmlFor: fields.admin.id,
children: 'Administrators',
}}
buttonProps={{
...conform.input(fields.admin, { type: 'checkbox' }),
}}
errors={fields.admin.errors}
/>
<div className="min-h-[32px] px-4">
<ErrorList id={form.errorId} errors={form.errors} />
</div>
Expand All @@ -190,7 +226,14 @@ export default function Email() {
/>
</section>
<section className="mt-4">
<Button type="submit">Send</Button>
<StatusButton
className="w-full"
status={isSubmitting ? 'pending' : actionData?.status ?? 'idle'}
type="submit"
disabled={isSubmitting}
>
Submit
</StatusButton>
</section>
</Form>
</div>
Expand All @@ -202,14 +245,29 @@ async function getRecipientsFromRoles(roles: string[]) {
const recipients = new Set<string>()
if (roles.includes('allVolunteers')) {
const users = await prisma.user.findMany()
users.map(user => user.email).forEach(email => recipients.add(email))
users
.filter(user => user.mailingList)
.map(user => user.email)
.forEach(email => recipients.add(email))
} else {
for (let role of roles) {
const users = await prisma.user.findMany({
where: { roles: { some: { name: role } } },
})
users.map(user => user.email).forEach(email => recipients.add(email))
users
.filter(user => user.mailingList)
.map(user => user.email)
.forEach(email => recipients.add(email))

// Include admin on all emails
const admin = await prisma.user.findMany({
where: { roles: { some: { name: 'admin' } } },
})
admin
.filter(user => user.mailingList)
.map(user => user.email)
.forEach(email => recipients.add(email))
}
}
return recipients
return Array.from(recipients)
}
Loading

0 comments on commit 161c913

Please sign in to comment.