|
| 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 | +} |
0 commit comments