Skip to content

Commit a0355c9

Browse files
authored
Merge pull request #1154 from jboolean/orders-emails
Orders emails & resending magic links
2 parents 0a5618b + f93075b commit a0355c9

File tree

7 files changed

+119
-13
lines changed

7 files changed

+119
-13
lines changed

backend/src/api/AuthenticationController.ts

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import * as express from 'express';
2-
import { BadRequest, Unauthorized } from 'http-errors';
2+
import { BadRequest } from 'http-errors';
33
import {
44
Body,
55
Controller,
@@ -38,6 +38,9 @@ type UserResponse = {
3838
email: string | null;
3939
};
4040

41+
const getApiBase = (req: express.Request): string =>
42+
`${req.protocol}://${required(req.get('host'), 'host')}`;
43+
4144
@Route('authentication')
4245
export class AuthenticationController extends Controller {
4346
@Security('user-token')
@@ -56,7 +59,7 @@ export class AuthenticationController extends Controller {
5659
): Promise<LoginResponse> {
5760
const userId = await getUserFromRequestOrCreateAndSetCookie(req);
5861
const { requestedEmail, returnToPath, requireVerifiedEmail } = loginRequest;
59-
const apiBase = `${req.protocol}://${required(req.get('host'), 'host')}`;
62+
const apiBase = getApiBase(req);
6063
const result = await UserService.processLoginRequest(
6164
requestedEmail,
6265
userId,
@@ -74,7 +77,7 @@ export class AuthenticationController extends Controller {
7477
}
7578

7679
/**
77-
* We take the temporary token from the query string and set a permenant token in a cookie.
80+
* We take the temporary token from the query string and set a permanent token in a cookie.
7881
* @param magicToken A temporary token sent in a magic link
7982
* @param res
8083
* @param returnToPath The path to attach to the frontend base URL and redirect to after login
@@ -97,7 +100,23 @@ export class AuthenticationController extends Controller {
97100
const userId = UserService.getUserIdFromToken(magicToken);
98101

99102
if (!userId) {
100-
throw new Unauthorized('The link contains in invalid or expired token');
103+
res.status(401).type('text/plain');
104+
105+
// If it's just expired, send a new link
106+
const userIdExpired = UserService.getUserIdFromToken(magicToken, {
107+
ignoreExpiration: true,
108+
});
109+
if (typeof userIdExpired !== 'undefined') {
110+
await UserService.sendMagicLinkToUser(
111+
userIdExpired,
112+
getApiBase(req),
113+
returnToPath
114+
);
115+
res.send('This link has expired. A new one has been emailed to you.');
116+
return;
117+
}
118+
res.send('This link contains an invalid token');
119+
return;
101120
}
102121

103122
await UserService.markEmailVerified(userId);

backend/src/business/email/templates/MagicLinkTemplate.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,17 @@
11
import EmailTemplate from '../EmailTemplate';
22
import EmailStreamType from './EmailStreamType';
3-
import Senders from './Senders';
43
import {
5-
MagicLinkTemplateData,
64
MagicLinkMetadata,
5+
MagicLinkTemplateData,
76
} from './MagicLinkEmailTemplateData';
7+
import Senders from './Senders';
88

99
class MagicLinkTemplate extends EmailTemplate<
1010
MagicLinkTemplateData,
1111
MagicLinkMetadata
1212
> {
1313
alias = 'magic-link';
14-
from = Senders.PERSONAL;
14+
from = Senders.SYSTEM;
1515
streamType = EmailStreamType.TRANSACTIONAL;
1616
}
1717

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import EmailTemplate from '../EmailTemplate';
2+
import EmailStreamType from './EmailStreamType';
3+
import { OrderMetadata, OrderTemplateData } from './OrderEmailTemplateData';
4+
import Senders from './Senders';
5+
6+
class OrderCustomizeTemplate extends EmailTemplate<
7+
OrderTemplateData,
8+
OrderMetadata
9+
> {
10+
alias = 'order-customize';
11+
from = Senders.SYSTEM;
12+
streamType = EmailStreamType.TRANSACTIONAL;
13+
}
14+
15+
export default new OrderCustomizeTemplate();
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
export interface OrderTemplateData {
2+
ordersUrl: string;
3+
}
4+
5+
export interface OrderMetadata {
6+
userId: string;
7+
orderId: string;
8+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
const Senders = {
22
PERSONAL: 'Julian from 1940s.nyc <[email protected]>',
3+
SYSTEM: '1940s.nyc <[email protected]>',
34
} as const;
45
export default Senders;

backend/src/business/merch/MerchOrderService.ts

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,18 @@ import MerchInternalVariant from '../../enum/MerchInternalVariant';
77
import MerchOrderFulfillmentState from '../../enum/MerchOrderFulfillmentState';
88
import MerchOrderState from '../../enum/MerchOrderState';
99
import MerchProvider from '../../enum/MerchProvider';
10+
import EmailService from '../email/EmailService';
11+
import OrderCustomizeTemplate from '../email/templates/OrderCustomizeTemplate';
12+
import * as UserService from '../users/UserService';
1013
import absurd from '../utils/absurd';
14+
import isProduction from '../utils/isProduction';
1115
import required from '../utils/required';
1216
import * as PrintfulService from './PrintfulService';
1317

18+
const API_BASE = isProduction()
19+
? 'http://api.1940s.nyc'
20+
: 'http://dev.1940s.nyc:3000';
21+
1422
export async function createMerchOrder(
1523
userId: number,
1624
stripeCheckoutSessionId: string,
@@ -48,7 +56,32 @@ export async function createMerchOrder(
4856
});
4957
await itemRepository.save(items);
5058

51-
return orderRepository.findOneByOrFail({ id: order.id });
59+
order = await orderRepository.findOneByOrFail({ id: order.id });
60+
61+
const user = await UserService.getUser(userId);
62+
63+
if (user.email) {
64+
// There really should be an email at this point, set earlier in the Stripe webhook
65+
66+
const orderCustomizeEmail = OrderCustomizeTemplate.createTemplatedEmail({
67+
to: user.email,
68+
templateContext: {
69+
ordersUrl: UserService.createMagicLinkUrl(
70+
API_BASE,
71+
userId,
72+
'/orders'
73+
).toString(),
74+
},
75+
metadata: {
76+
orderId: order.id.toString(),
77+
userId: user.id.toString(),
78+
},
79+
});
80+
81+
await EmailService.sendTemplateEmail(orderCustomizeEmail);
82+
}
83+
84+
return order;
5285
});
5386
}
5487

backend/src/business/users/UserService.ts

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,11 +35,19 @@ function createUserTokenForMagicLink(userId: number): string {
3535
});
3636
}
3737

38-
export function getUserIdFromToken(token: string): number | undefined {
38+
export function getUserIdFromToken(
39+
token: string,
40+
{
41+
ignoreExpiration = false,
42+
}: {
43+
ignoreExpiration?: boolean;
44+
} = {}
45+
): number | undefined {
3946
try {
4047
const { sub } = jwt.verify(token, JWT_SECRET, {
4148
algorithms: ['HS256'],
4249
issuer: ISSUER,
50+
ignoreExpiration,
4351
}) as { sub: string };
4452

4553
if (!sub.startsWith(SUBJECT_PREFIX)) {
@@ -127,12 +135,11 @@ export async function createUser(
127135
return { token, userId: user.id };
128136
}
129137

130-
async function sendMagicLink(
131-
emailAddress: string,
132-
userId: number,
138+
export function createMagicLinkUrl(
133139
apiBase: string,
140+
userId: number,
134141
returnToPath?: string
135-
): Promise<void> {
142+
): URL {
136143
const loginUrl: URL = new URL(
137144
'/authentication/login-with-magic-link',
138145
apiBase
@@ -147,6 +154,16 @@ async function sendMagicLink(
147154
}
148155

149156
loginUrl.search = params.toString();
157+
return loginUrl;
158+
}
159+
160+
async function sendMagicLink(
161+
emailAddress: string,
162+
userId: number,
163+
apiBase: string,
164+
returnToPath?: string
165+
): Promise<void> {
166+
const loginUrl = createMagicLinkUrl(apiBase, userId, returnToPath);
150167

151168
const emailMessage = MagicLinkTemplate.createTemplatedEmail({
152169
to: emailAddress,
@@ -164,6 +181,19 @@ async function sendMagicLink(
164181
await EmailService.sendTemplateEmail(emailMessage);
165182
}
166183

184+
export async function sendMagicLinkToUser(
185+
userId: number,
186+
apiBase: string,
187+
returnToPath?: string
188+
): Promise<void> {
189+
const user = await getUser(userId);
190+
if (!user.email) {
191+
return;
192+
}
193+
194+
return sendMagicLink(user.email, userId, apiBase, returnToPath);
195+
}
196+
167197
/**
168198
* The user wishes to log into the account with email, which may or may not exist.
169199
* This determines what to do, which ultimately drives the UI.

0 commit comments

Comments
 (0)