Skip to content

Commit

Permalink
Email subscription management. #518
Browse files Browse the repository at this point in the history
  • Loading branch information
mdirolf committed May 23, 2024
1 parent 914b491 commit 6deff62
Show file tree
Hide file tree
Showing 6 changed files with 369 additions and 3 deletions.
4 changes: 3 additions & 1 deletion app/lib/prefs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export const UnsubscribeFlags = {
comments: null, // comments on your puzzles or replies to your comments
featured: null, // one of your puzzles is featured or set as daily mini
newpuzzles: null, // one of your followed authors published a new puzzle
weekly: null, // weekly email
};

const AccountPrefsFlagsV = t.partial({
Expand All @@ -21,8 +22,9 @@ export type AccountPrefsFlagsT = t.TypeOf<typeof AccountPrefsFlagsV>;
export const AccountPrefsV = t.intersection([
AccountPrefsFlagsV,
t.partial({
/** user id receiving the notification */
unsubs: t.array(t.keyof(UnsubscribeFlags)),
/** we've gotten bounces / reports for this email so we no longer msg it */
bounced: t.boolean,
following: t.array(t.string),
rtg: GlickoScoreV,
rtgs: t.array(GlickoScoreV),
Expand Down
30 changes: 30 additions & 0 deletions app/lib/subscriptions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import crypto from 'node:crypto';
import { SecretManagerServiceClient } from '@google-cloud/secret-manager';
import * as t from 'io-ts';
import { UnsubscribeFlags } from './prefs';

export const SubscriptionParamsV = t.type({
/** signature */
s: t.string,
/** user id */
u: t.string,
/** new setting for unsubs */
f: t.union([
t.undefined,
t.keyof(UnsubscribeFlags),
t.array(t.keyof(UnsubscribeFlags)),
]),
});

export async function getSig(userId: string): Promise<string> {
const secretmanagerClient = new SecretManagerServiceClient();
const [secretVersion] = await secretmanagerClient.accessSecretVersion({
name: 'projects/603173482014/secrets/subscription-management-key/versions/latest',
});
const secret = secretVersion.payload?.data?.toString();
if (!secret) {
throw new Error('Failed to load secret');
}

return crypto.createHmac('sha256', secret).update(userId).digest('base64url');
}
5 changes: 3 additions & 2 deletions app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,14 +34,15 @@
"engines": {
"node": "18"
},
"resolutions": {
"@types/mime": "3.0.4"
"resolutions": {
"@types/mime": "3.0.4"
},
"resolutionsComments": {
"@types/mime": "https://github.com/firebase/firebase-admin-node/issues/2512"
},
"dependencies": {
"@floating-ui/react": "^0.26.10",
"@google-cloud/secret-manager": "^5.6.0",
"@juggle/resize-observer": "^3.4.0",
"@lingui/format-po-gettext": "^4.7.2",
"@lingui/react": "^4.7.2",
Expand Down
48 changes: 48 additions & 0 deletions app/pages/api/subscription.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { NextApiRequest, NextApiResponse } from 'next';
import { getCollection } from '../../lib/firebaseAdminWrapper';
import { PathReporter } from '../../lib/pathReporter';
import { UnsubscribeFlags } from '../../lib/prefs';
import { SubscriptionParamsV, getSig } from '../../lib/subscriptions';

export default async function subscription(
req: NextApiRequest,
res: NextApiResponse
) {
if (req.method !== 'POST') {
res.status(405).json({ statusCode: 405, message: 'POST only' });
return;
}

const validationResult = SubscriptionParamsV.decode(req.query);
if (validationResult._tag !== 'Right') {
console.error(PathReporter.report(validationResult).join(','));
res.status(400).json({ statusCode: 400, message: 'Bad params' });
return;
}

const params = validationResult.right;
const sig = await getSig(params.u);
if (sig !== params.s) {
res.status(403).json({ statusCode: 403, message: 'Bad sig' });
return;
}

const unsubs: (keyof typeof UnsubscribeFlags)[] = [];
if (typeof params.f === 'string') {
unsubs.push(params.f);
} else if (params.f) {
unsubs.push(...params.f);
}

await getCollection('prefs').doc(params.u).set(
{
unsubs,
},
{ merge: true }
);

res.redirect(
303,
`https://crosshare.org/subscription?u=${params.u}&s=${params.s}&m=1`
);
}
242 changes: 242 additions & 0 deletions app/pages/subscription.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,242 @@
import Head from 'next/head';
import Link from 'next/link';
import { GetServerSideProps } from 'next/types';
import { FormEvent, useState } from 'react';
import { ErrorPage } from '../components/ErrorPage';
import { useSnackbar } from '../components/Snackbar';
import { getCollection, getUser } from '../lib/firebaseAdminWrapper';
import { PathReporter } from '../lib/pathReporter';
import { AccountPrefsV, UnsubscribeFlags } from '../lib/prefs';
import { SubscriptionParamsV, getSig } from '../lib/subscriptions';
import { logAsyncErrors } from '../lib/utils';

interface SuccessProps {
userId: string;
sig: string;
email: string;
unsubs: (keyof typeof UnsubscribeFlags)[];
message?: boolean;
}

interface ErrorProps {
error: string;
}

type PageProps = SuccessProps | ErrorProps;

// TODO unify w/ functions/queueEmails.ts
async function getEmail(userId: string): Promise<string | undefined> {
try {
const user = await getUser(userId);
return user.email;
} catch (e) {
console.log(e);
console.warn('error getting user ', userId);
return undefined;
}
}

export const getServerSideProps: GetServerSideProps<PageProps> = async ({
res,
query,
}) => {
const validationResult = SubscriptionParamsV.decode(query);
if (validationResult._tag !== 'Right') {
console.error(PathReporter.report(validationResult).join(','));
res.statusCode = 400;
return { props: { error: 'Bad params' } };
}

const params = validationResult.right;
const sig = await getSig(params.u);
if (sig !== params.s) {
res.statusCode = 400;
return { props: { error: 'Bad sig' } };
}

const email = await getEmail(params.u);
if (!email) {
res.statusCode = 500;
return { props: { error: 'Missing email' } };
}

const accountPrefsDoc = await getCollection('prefs').doc(params.u).get();
if (!accountPrefsDoc.exists) {
return { props: { userId: params.u, sig: params.s, email, unsubs: [] } };
}

const prefsValidationResult = AccountPrefsV.decode(accountPrefsDoc.data());
if (prefsValidationResult._tag !== 'Right') {
console.error(PathReporter.report(prefsValidationResult).join(','));
res.statusCode = 500;
return { props: { error: 'Invalid prefs' } };
}

return {
props: {
userId: params.u,
sig: params.s,
email,
unsubs: prefsValidationResult.right.unsubs ?? [],
message: Boolean(query.m),
},
};
};

export default function ManageSubscriptions(props: PageProps) {
if ('error' in props) {
return (
<ErrorPage title="Error loading subscriptions">
<p>We&apos;re sorry, there was an error: {props.error}</p>
<p>
Try the <Link href="/">homepage</Link>.
</p>
</ErrorPage>
);
}
return <Success {...props} />;
}

function Success(props: SuccessProps) {
const [unsubs, setUnsubs] = useState(props.unsubs);
const [showMessage, setShowMessage] = useState(props.message);
const [submitting, setSubmitting] = useState(false);
const { showSnackbar } = useSnackbar();

async function submitForm(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
await postUpdate(unsubs).then(() => {
showSnackbar('Updated preferences');
});
}

async function postUpdate(newUnsubs: (keyof typeof UnsubscribeFlags)[]) {
setShowMessage(false);
setSubmitting(true);
const fs = newUnsubs.length
? '&' + newUnsubs.map((n) => `f=${n}`).join('&')
: '';
return fetch(`/api/subscription?u=${props.userId}&s=${props.sig}${fs}`, {
method: 'POST',
redirect: 'manual',
}).then(() => {
setSubmitting(false);
});
}

async function unsubAll() {
setUnsubs(['all', 'weekly']);
await postUpdate(['all', 'weekly']).then(() => {
showSnackbar('Unsubscribed from all');
});
}

const toggle = (unsub: keyof typeof UnsubscribeFlags) => {
const newUnsubs = [...unsubs];
const index = newUnsubs.indexOf(unsub);
if (index === -1) {
newUnsubs.push(unsub);
} else {
newUnsubs.splice(index, 1);
}
setUnsubs(newUnsubs);
};

return (
<div className="margin1em">
<Head>
<title>{`Manage Subscriptions | Crosshare Crossword Constructor and Puzzles`}</title>
</Head>
{showMessage ? (
<p className="colorBlue">Your preferences have been updated!</p>
) : (
''
)}
<form onSubmit={logAsyncErrors(submitForm)}>
<h3>Newsletter</h3>
<p>Email me (to {props.email}, at most once per week):</p>
<label>
<input
checked={!unsubs.includes('weekly')}
type="checkbox"
onChange={() => {
toggle('weekly');
}}
/>{' '}
A write up of the most popular puzzles in the previous week along with
any Crosshare announcements
</label>

<h3 className="marginTop2em">Notifications</h3>
<p>Email me (to {props.email}, at most once per day):</p>
<p>
<label>
<input
disabled={unsubs.includes('all')}
checked={!unsubs.includes('comments')}
type="checkbox"
onChange={() => {
toggle('comments');
}}
/>{' '}
I have unseen comments on my puzzles or replies to my comments
</label>
</p>
<p>
<label>
<input
disabled={unsubs.includes('all')}
checked={!unsubs.includes('newpuzzles')}
type="checkbox"
onChange={() => {
toggle('newpuzzles');
}}
/>{' '}
A constructor I follow publishes a new puzzle
</label>
</p>
<p>
<label>
<input
disabled={unsubs.includes('all')}
checked={!unsubs.includes('featured')}
type="checkbox"
onChange={() => {
toggle('featured');
}}
/>{' '}
One of my puzzles is chosen as a Crosshare featured puzzle or daily
mini
</label>
</p>
<p>
<label>
<input
checked={unsubs.includes('all')}
type="checkbox"
onChange={() => {
toggle('all');
}}
/>{' '}
Never notify me by email (even for any future notification types)
</label>
</p>

<p>
<input
disabled={submitting}
type="submit"
value="Update Preferences"
/>
<input
disabled={submitting}
onClick={logAsyncErrors(unsubAll)}
className="marginLeft1em"
type="button"
value="Unsubscribe From All Crosshare Emails"
/>
</p>
</form>
</div>
);
}
Loading

0 comments on commit 6deff62

Please sign in to comment.