Skip to content

Commit eaaacd1

Browse files
authored
Merge pull request #146 from dahlia/web-next/invite
2 parents 95553a2 + aff5ea6 commit eaaacd1

File tree

20 files changed

+1404
-31
lines changed

20 files changed

+1404
-31
lines changed

deno.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

graphql/invite.ts

Lines changed: 368 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,368 @@
1+
import { normalizeEmail } from "@hackerspub/models/account";
2+
import { negotiateLocale } from "@hackerspub/models/i18n";
3+
import {
4+
type Account as AccountTable,
5+
accountTable,
6+
type Actor,
7+
} from "@hackerspub/models/schema";
8+
import { createSignupToken, type SignupToken } from "@hackerspub/models/signup";
9+
import type { Uuid } from "@hackerspub/models/uuid";
10+
import { getLogger } from "@logtape/logtape";
11+
import { expandGlob } from "@std/fs";
12+
import { join } from "@std/path";
13+
import { createMessage, type Message } from "@upyo/core";
14+
import { and, eq, gt, sql } from "drizzle-orm";
15+
import { parseTemplate } from "url-template";
16+
import { Account } from "./account.ts";
17+
import { builder } from "./builder.ts";
18+
import { EMAIL_FROM } from "./email.ts";
19+
20+
const logger = getLogger(["hackerspub", "graphql", "invite"]);
21+
22+
interface Invitation {
23+
inviterId: Uuid;
24+
email: string;
25+
locale: Intl.Locale;
26+
message?: string;
27+
}
28+
29+
const InvitationRef = builder.objectRef<Invitation>("Invitation");
30+
31+
InvitationRef.implement({
32+
description: "An invitation that has been created.",
33+
fields: (t) => ({
34+
inviter: t.field({
35+
type: Account,
36+
async resolve(invitation, _, ctx) {
37+
const account = await ctx.db.query.accountTable.findFirst({
38+
where: { id: invitation.inviterId },
39+
with: { actor: true },
40+
});
41+
if (account == null) {
42+
throw new Error(
43+
`Account with ID ${invitation.inviterId} not found.`,
44+
);
45+
}
46+
return account;
47+
},
48+
}),
49+
email: t.field({
50+
type: "Email",
51+
resolve(invitation) {
52+
return invitation.email;
53+
},
54+
}),
55+
locale: t.field({
56+
type: "Locale",
57+
resolve(invitation) {
58+
return invitation.locale;
59+
},
60+
}),
61+
message: t.field({
62+
type: "Markdown",
63+
nullable: true,
64+
resolve(invitation) {
65+
return invitation.message ?? null;
66+
},
67+
}),
68+
}),
69+
});
70+
71+
const InviteInviterError = builder.enumType("InviteInviterError", {
72+
values: [
73+
"INVITER_NOT_AUTHENTICATED",
74+
"INVITER_NO_INVITATIONS_LEFT",
75+
"INVITER_EMAIL_SEND_FAILED",
76+
] as const,
77+
});
78+
79+
const InviteEmailError = builder.enumType("InviteEmailError", {
80+
values: ["EMAIL_INVALID", "EMAIL_ALREADY_TAKEN"] as const,
81+
});
82+
83+
const InviteVerifyUrlError = builder.enumType("InviteVerifyUrlError", {
84+
values: ["VERIFY_URL_NO_TOKEN", "VERIFY_URL_NO_CODE"] as const,
85+
});
86+
87+
interface InviteValidationErrors {
88+
inviter?: typeof InviteInviterError.$inferType;
89+
email?: typeof InviteEmailError.$inferType;
90+
verifyUrl?: typeof InviteVerifyUrlError.$inferType;
91+
emailOwnerId?: Uuid;
92+
}
93+
94+
const InviteValidationErrorsRef = builder.objectRef<InviteValidationErrors>(
95+
"InviteValidationErrors",
96+
);
97+
98+
InviteValidationErrorsRef.implement({
99+
description: "Validation errors that occurred during the invitation process.",
100+
fields: (t) => ({
101+
inviter: t.field({
102+
type: InviteInviterError,
103+
nullable: true,
104+
resolve: (errors) => errors.inviter ?? null,
105+
}),
106+
email: t.field({
107+
type: InviteEmailError,
108+
nullable: true,
109+
resolve: (errors) => errors.email ?? null,
110+
}),
111+
verifyUrl: t.field({
112+
type: InviteVerifyUrlError,
113+
nullable: true,
114+
resolve: (errors) => errors.verifyUrl ?? null,
115+
}),
116+
emailOwner: t.field({
117+
type: Account,
118+
nullable: true,
119+
resolve(errors, _, ctx) {
120+
if (errors.emailOwnerId == null) return null;
121+
return ctx.db.query.accountTable.findFirst({
122+
where: { id: errors.emailOwnerId },
123+
});
124+
},
125+
}),
126+
}),
127+
});
128+
129+
const InviteResultRef = builder.unionType("InviteResult", {
130+
types: [InvitationRef, InviteValidationErrorsRef],
131+
resolveType(obj) {
132+
if ("inviterId" in obj) return InvitationRef;
133+
return InviteValidationErrorsRef;
134+
},
135+
});
136+
137+
export const EXPIRATION = Temporal.Duration.from({ hours: 48 });
138+
139+
builder.mutationField("invite", (t) =>
140+
t.field({
141+
type: InviteResultRef,
142+
args: {
143+
email: t.arg({ type: "Email", required: true }),
144+
locale: t.arg({ type: "Locale", required: true }),
145+
message: t.arg({ type: "Markdown" }),
146+
verifyUrl: t.arg({
147+
type: "URITemplate",
148+
required: true,
149+
description:
150+
"The RFC 6570-compliant URI Template for the verification link. Available variables: `{token}` and `{code}`.",
151+
}),
152+
},
153+
async resolve(_root, args, ctx) {
154+
const errors = {} as InviteValidationErrors;
155+
if (ctx.account == null) errors.inviter = "INVITER_NOT_AUTHENTICATED";
156+
else if (ctx.account.leftInvitations < 1) {
157+
errors.inviter = "INVITER_NO_INVITATIONS_LEFT";
158+
}
159+
let email: string | undefined;
160+
try {
161+
email = normalizeEmail(args.email);
162+
} catch {
163+
errors.email = "EMAIL_INVALID";
164+
}
165+
if (email != null) {
166+
const existingEmail = await ctx.db.query.accountEmailTable.findFirst({
167+
where: { email },
168+
});
169+
if (existingEmail != null) {
170+
errors.email = "EMAIL_ALREADY_TAKEN";
171+
errors.emailOwnerId = existingEmail.accountId;
172+
}
173+
}
174+
const verifyUrlTemplate = parseTemplate(args.verifyUrl);
175+
const a = verifyUrlTemplate.expand({
176+
token: "00000000-0000-0000-0000-000000000000",
177+
code: "AAAAAA",
178+
});
179+
const b = verifyUrlTemplate.expand({
180+
token: "ffffffff-ffff-ffff-ffff-ffffffffffff",
181+
code: "AAAAAA",
182+
});
183+
if (a === b) {
184+
errors.verifyUrl = "VERIFY_URL_NO_TOKEN";
185+
}
186+
const c = verifyUrlTemplate.expand({
187+
token: "00000000-0000-0000-0000-000000000000",
188+
code: "BBBBBB",
189+
});
190+
if (a === c) {
191+
errors.verifyUrl = "VERIFY_URL_NO_CODE";
192+
}
193+
if (
194+
errors.inviter != null || errors.email != null ||
195+
errors.email != null || ctx.account == null || email == null
196+
) {
197+
return errors;
198+
}
199+
const updated = await ctx.db.update(accountTable).set({
200+
leftInvitations: sql`${accountTable.leftInvitations} - 1`,
201+
}).where(
202+
and(
203+
eq(accountTable.id, ctx.account.id),
204+
gt(accountTable.leftInvitations, 0),
205+
),
206+
).returning();
207+
if (updated.length < 1) {
208+
return {
209+
inviter: "INVITER_NO_INVITATIONS_LEFT",
210+
} satisfies InviteValidationErrors;
211+
}
212+
const token = await createSignupToken(ctx.kv, email, {
213+
inviterId: ctx.account.id,
214+
expiration: EXPIRATION,
215+
});
216+
const message = await getEmailMessage({
217+
locale: args.locale,
218+
inviter: ctx.account,
219+
verifyUrlTemplate: args.verifyUrl,
220+
to: email,
221+
token,
222+
message: args.message ?? undefined,
223+
});
224+
const receipt = await ctx.email.send(message);
225+
if (!receipt.successful) {
226+
logger.error(
227+
"Failed to send invitation email: {errors}",
228+
{ errors: receipt.errorMessages },
229+
);
230+
// Credit back the invitation on email send failure
231+
await ctx.db.update(accountTable).set({
232+
leftInvitations: sql`${accountTable.leftInvitations} + 1`,
233+
}).where(eq(accountTable.id, ctx.account.id));
234+
235+
// Return validation error to inform the user
236+
return {
237+
inviter: "INVITER_EMAIL_SEND_FAILED",
238+
} satisfies InviteValidationErrors;
239+
}
240+
return {
241+
inviterId: ctx.account.id,
242+
email,
243+
locale: args.locale,
244+
message: args.message ?? undefined,
245+
};
246+
},
247+
}));
248+
249+
const LOCALES_DIR = join(import.meta.dirname!, "locales");
250+
251+
// Cache for email templates
252+
let cachedTemplates:
253+
| Map<
254+
string,
255+
{ subject: string; emailContent: string; emailContentWithMessage: string }
256+
>
257+
| null = null;
258+
let cachedAvailableLocales: Record<string, string> | null = null;
259+
260+
async function loadEmailTemplates(): Promise<void> {
261+
if (cachedTemplates && cachedAvailableLocales) return;
262+
263+
const availableLocales: Record<string, string> = {};
264+
const templates = new Map<
265+
string,
266+
{ subject: string; emailContent: string; emailContentWithMessage: string }
267+
>();
268+
269+
const files = expandGlob(join(LOCALES_DIR, "*.json"), {
270+
includeDirs: false,
271+
});
272+
273+
for await (const file of files) {
274+
if (!file.isFile) continue;
275+
const match = file.name.match(/^(.+)\.json$/);
276+
if (match == null) continue;
277+
const localeName = match[1];
278+
availableLocales[localeName] = file.path;
279+
280+
try {
281+
const json = await Deno.readTextFile(file.path);
282+
const data = JSON.parse(json);
283+
templates.set(localeName, {
284+
subject: data.invite.emailSubject,
285+
emailContent: data.invite.emailContent,
286+
emailContentWithMessage: data.invite.emailContentWithMessage,
287+
});
288+
} catch (error) {
289+
console.warn(
290+
`Failed to load email template for locale ${localeName}:`,
291+
error,
292+
);
293+
}
294+
}
295+
296+
cachedTemplates = templates;
297+
cachedAvailableLocales = availableLocales;
298+
}
299+
300+
async function getEmailTemplate(
301+
locale: Intl.Locale,
302+
message: boolean,
303+
): Promise<{ subject: string; content: string }> {
304+
await loadEmailTemplates();
305+
306+
const selectedLocale =
307+
negotiateLocale(locale, Object.keys(cachedAvailableLocales!)) ??
308+
new Intl.Locale("en");
309+
310+
const template = cachedTemplates!.get(selectedLocale.baseName);
311+
if (!template) {
312+
throw new Error(
313+
`No email template found for locale ${selectedLocale.baseName}`,
314+
);
315+
}
316+
317+
return {
318+
subject: template.subject,
319+
content: message ? template.emailContentWithMessage : template.emailContent,
320+
};
321+
}
322+
323+
async function getEmailMessage(
324+
{ locale, inviter, to, verifyUrlTemplate, token, message }: {
325+
locale: Intl.Locale;
326+
inviter: AccountTable & { actor: Actor };
327+
to: string;
328+
verifyUrlTemplate: string;
329+
token: SignupToken;
330+
message?: string;
331+
},
332+
): Promise<Message> {
333+
const verifyUrl = parseTemplate(verifyUrlTemplate).expand({
334+
token: token.token,
335+
code: token.code,
336+
});
337+
const expiration = EXPIRATION.toLocaleString(locale.baseName, {
338+
// @ts-ignore: DurationFormatOptions, not DateTimeFormatOptions
339+
style: "long",
340+
});
341+
const template = await getEmailTemplate(locale, message != null);
342+
function substitute(template: string): string {
343+
return template.replaceAll(
344+
/\{\{(verifyUrl|code|expiration|inviter|inviterName|message)\}\}/g,
345+
(m) => {
346+
return m === "{{verifyUrl}}"
347+
? verifyUrl
348+
: m === "{{code}}"
349+
? token.code
350+
: m === "{{expiration}}"
351+
? expiration
352+
: m === "{{inviter}}"
353+
? `${inviter.name} (${inviter.actor.handle})`
354+
: m === "{{inviterName}}"
355+
? inviter.name
356+
: (message ?? "");
357+
},
358+
);
359+
}
360+
return createMessage({
361+
from: EMAIL_FROM,
362+
to,
363+
subject: substitute(template.subject),
364+
content: {
365+
text: substitute(template.content),
366+
},
367+
});
368+
}

graphql/locales/en.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,10 @@
22
"login": {
33
"emailSubject": "Sign in to Hackers' Pub",
44
"emailContent": "Welcome back to Hackers' Pub! To sign in, click the following link:\n\n{{verifyUrl}}\n\nor submit this code through the sign-in page:\n\n{{code}}\n\nThis link and code will expire in {{expiration}}.\n\nIf you didn't request this email, you can safely ignore it.\n"
5+
},
6+
"invite": {
7+
"emailSubject": "{{inviterName}} invites you to Hackers' Pub!",
8+
"emailContent": "{{inviter}} invites you to Hackers' Pub!\n\nHackers' Pub is a place for software engineers to share their knowledge and experience with each other. It's also an ActivityPub-enabled social network, so you can follow your favorite hackers in the fediverse and get their latest posts in your feed.\n\nTo accept the invitation, click the following link:\n\n{{verifyUrl}}\n\nThis link will expire in {{expiration}}.\n",
9+
"emailContentWithMessage": "{{inviter}} invites you to Hackers' Pub! Here's a message from {{inviterName}}:\n\n{{message}}\n\nTo accept the invitation, click the following link:\n\n{{verifyUrl}}\n\nThis link will expire in {{expiration}}.\n"
510
}
611
}

graphql/locales/ja.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,10 @@
22
"login": {
33
"emailSubject": "Hackers' Pubへのログイン",
44
"emailContent": "Hackers' Pubへお帰りなさい!ログインするには以下のリンクをクリックしてください:\n\n{{verifyUrl}}\n\n他には下のコードをページに入力して下さい:\n\n{{code}}\n\nこのリンクとコードは{{expiration}}後に有効期限が切れます。\n\nこのメールに心当たりがない場合は、無視していただいて構いません。\n"
5+
},
6+
"invite": {
7+
"emailSubject": "{{inviterName}}さんがHackers' Pubに招待しています!",
8+
"emailContent": "{{inviter}}さんがHackers' Pubに招待しています!\n\nHackers' Pubは、ソフトウェアエンジニアが知識と経験を共有する場所です。また、ActivityPubに対応したソーシャルネットワークでもあり、フェディバース(fediverse)で気に入ったハッカーをフォローして、最新のコンテンツをフィードで受け取ることができます。\n\n招待を受け入れるには、以下のリンクをクリックしてください:\n\n{{verifyUrl}}\n\nこのリンクは{{expiration}}後に有効期限が切れます。\n",
9+
"emailContentWithMessage": "{{inviter}}さんがHackers' Pubに招待しています!{{inviterName}}さんからのメッセージです:\n\n{{message}}\n\n招待を受け入れるには、以下のリンクをクリックしてください:\n\n{{verifyUrl}}\n\nこのリンクは{{expiration}}後に有効期限が切れます。\n"
510
}
611
}

0 commit comments

Comments
 (0)