diff --git a/nuxt.config.ts b/nuxt.config.ts index 3249141..84bc947 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -35,6 +35,14 @@ export default defineNuxtConfig({ stripeSecretKey: '', grpcHost: '', grpcApiKey: '', + mongoConnectionString: '', + smtpHost: '', + smtpPort: 587, + smtpUser: '', + smtpPassword: '', + smtpSecure: true, + smtpFromEmail: '', + smtpFromName: '', public: { apiBase: 'https://api.pretendo.cc', diff --git a/package-lock.json b/package-lock.json index 29ad511..a31a04b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,9 +20,12 @@ "better-sqlite3": "^12.11.1", "eslint": "^9.39.5", "feed": "^6.0.0", + "mongodb": "^7.5.0", "nice-grpc": "^2.1.17", + "nodemailer": "^9.0.5", "nuxt": "^4.5.2", "octokit": "^5.0.5", + "papr": "^17.1.1", "stripe": "^22.4.0", "vue": "^3.5.13", "vue-router": "^5.2.0", @@ -32,6 +35,7 @@ "@iconify-json/fa7-brands": "^1.2.4", "@iconify-json/ph": "^1.2.2", "@pretendonetwork/eslint-config": "^0.1.4", + "@types/nodemailer": "^8.0.1", "eslint-plugin-vue": "^10.0.0", "sass-embedded": "^1.86.3" } @@ -2566,6 +2570,15 @@ "rollup": "^1.20.0 || ^2.0.0 || ^3.0.0 || ^4.0.0" } }, + "node_modules/@mongodb-js/saslprep": { + "version": "1.4.13", + "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.4.13.tgz", + "integrity": "sha512-E3Sv4eCYAlKYUTx8S3ioQcDUscOif+8zZ5OnW1IzJ+Tt+EO+ke8mn+Y3FX6N1H79picwbdOavVOb1jPi2EOyrg==", + "license": "MIT", + "dependencies": { + "sparse-bitfield": "^3.0.3" + } + }, "node_modules/@napi-rs/lzma-linux-x64-gnu": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", @@ -6308,6 +6321,16 @@ "undici-types": "~8.3.0" } }, + "node_modules/@types/nodemailer": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.1.tgz", + "integrity": "sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/parse-path": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/@types/parse-path/-/parse-path-7.0.3.tgz", @@ -6326,6 +6349,21 @@ "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", "license": "MIT" }, + "node_modules/@types/webidl-conversions": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz", + "integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==", + "license": "MIT" + }, + "node_modules/@types/whatwg-url": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-13.0.0.tgz", + "integrity": "sha512-N8WXpbE6Wgri7KUSvrmQcqrMllKZ9uxkYWMt+mCSGwNc0Hsw9VQTW7ApqI4XNrx6/SaM2QQJCzMPDEXE058s+Q==", + "license": "MIT", + "dependencies": { + "@types/webidl-conversions": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.66.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.66.0.tgz", @@ -8217,6 +8255,15 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/bson": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/bson/-/bson-7.3.1.tgz", + "integrity": "sha512-h/C0qe6857pQhcSJHLfsR1uYGj98Ge3wKAD3Ed9KqH3wcVh+BM4Jq4xISD7vs9OPuT07n+q3QQVjslJ286j6ag==", + "license": "Apache-2.0", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/buffer": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", @@ -14506,6 +14553,12 @@ "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", "license": "CC0-1.0" }, + "node_modules/memory-pager": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz", + "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==", + "license": "MIT" + }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", @@ -15274,6 +15327,100 @@ "integrity": "sha512-aF7yRQr/Q0O2/4pIXm6PZ5G+jAd7QS4Yu8m+WEeEHGnbo+7mE36CbLSDQiXYV8bVL3NfmdeqPJct0tUlnjVSnA==", "license": "MIT" }, + "node_modules/mongodb": { + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-7.5.0.tgz", + "integrity": "sha512-5FnrEDLnvp6ycUOGLNLLU33BfCx2qmp2mJjGPDwKLruYsVzXVSK5fsGpoDXvsXJwBfBsD7ebMRdawbDxC2814g==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@mongodb-js/saslprep": "^1.4.11", + "bson": "^7.2.0", + "mongodb-connection-string-url": "^7.0.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@aws-sdk/credential-providers": "^3.806.0", + "@mongodb-js/zstd": "^7.0.0", + "gcp-metadata": "^7.0.1", + "kerberos": "^7.0.0", + "mongodb-client-encryption": "^7.2.0", + "snappy": "^7.3.2", + "socks": "^2.8.6" + }, + "peerDependenciesMeta": { + "@aws-sdk/credential-providers": { + "optional": true + }, + "@mongodb-js/zstd": { + "optional": true + }, + "gcp-metadata": { + "optional": true + }, + "kerberos": { + "optional": true + }, + "mongodb-client-encryption": { + "optional": true + }, + "snappy": { + "optional": true + }, + "socks": { + "optional": true + } + } + }, + "node_modules/mongodb-connection-string-url": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-7.0.2.tgz", + "integrity": "sha512-ZoS07RoFqpKYQwAk59qmrx8+jJHNHU30UjlU96QktiGn1ltvDr+vCznLX5DiUBLEpMAHatHNWV1nM/74ul66kA==", + "license": "Apache-2.0", + "dependencies": { + "@types/whatwg-url": "^13.0.0", + "whatwg-url": "^14.1.0" + }, + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/mongodb-connection-string-url/node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/mongodb-connection-string-url/node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/mongodb-connection-string-url/node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/mrmime": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", @@ -15619,6 +15766,15 @@ "node": ">=18" } }, + "node_modules/nodemailer": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.5.tgz", + "integrity": "sha512-wvjiKvjczmsN7U/8006JOdXubgBk2XFAbioDMbT+sM7cPs0QrhJTa6KBRX7P5REGGkDcLUz/EarWidb8G8C1jQ==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/nopt": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/nopt/-/nopt-8.1.0.tgz", @@ -16827,6 +16983,18 @@ "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", "license": "(MIT AND Zlib)" }, + "node_modules/papr": { + "version": "17.1.1", + "resolved": "https://registry.npmjs.org/papr/-/papr-17.1.1.tgz", + "integrity": "sha512-5WL99VUNcbUqR+FCZk554iJRoB4vB32mNHAlZEJuBsZuTjBIQA/uJszWuJ8cEstGUIN0Ad7gjlB1RkZ4aA2hOw==", + "license": "MIT", + "engines": { + "node": ">=22.12.0" + }, + "peerDependencies": { + "mongodb": "^7.0.0" + } + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -19487,6 +19655,15 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/sparse-bitfield": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz", + "integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==", + "license": "MIT", + "dependencies": { + "memory-pager": "^1.0.2" + } + }, "node_modules/spdx-exceptions": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", diff --git a/package.json b/package.json index 1953ed9..30ce948 100644 --- a/package.json +++ b/package.json @@ -24,9 +24,12 @@ "better-sqlite3": "^12.11.1", "eslint": "^9.39.5", "feed": "^6.0.0", + "mongodb": "^7.5.0", "nice-grpc": "^2.1.17", + "nodemailer": "^9.0.5", "nuxt": "^4.5.2", "octokit": "^5.0.5", + "papr": "^17.1.1", "stripe": "^22.4.0", "vue": "^3.5.13", "vue-router": "^5.2.0", @@ -36,6 +39,7 @@ "@iconify-json/fa7-brands": "^1.2.4", "@iconify-json/ph": "^1.2.2", "@pretendonetwork/eslint-config": "^0.1.4", + "@types/nodemailer": "^8.0.1", "eslint-plugin-vue": "^10.0.0", "sass-embedded": "^1.86.3" } diff --git a/server/api/auth/login.post.ts b/server/api/auth/login.post.ts index 865f412..75538dd 100644 --- a/server/api/auth/login.post.ts +++ b/server/api/auth/login.post.ts @@ -21,13 +21,13 @@ export default defineEventHandler(async (event): Promise => { if (error.details === 'INVALID_ARGUMENT: User not found') { throw createError({ status: 400, - statusText: 'User not found' + message: 'User not found' }); } if (error.details === 'INVALID_ARGUMENT: Password is incorrect') { throw createError({ status: 400, - statusText: 'Password was incorrect' + message: 'Password was incorrect' }); } } diff --git a/server/utils/enforceAuth.ts b/server/utils/enforceAuth.ts index b518204..73988ea 100644 --- a/server/utils/enforceAuth.ts +++ b/server/utils/enforceAuth.ts @@ -1,3 +1,5 @@ +import type { H3Event } from 'h3' + export type AuthContext = { pid: number; }; @@ -15,7 +17,7 @@ export function enforceLoggedIn(event: H3Event): AuthContext { if (!context) { throw createError({ status: 401, - statusText: 'This action requires authentication' + message: 'This action requires authentication' }); } return context; diff --git a/server/utils/handleStripeWebhook.ts b/server/utils/handleStripeWebhook.ts index 206fd9f..318403f 100644 --- a/server/utils/handleStripeWebhook.ts +++ b/server/utils/handleStripeWebhook.ts @@ -1,7 +1,13 @@ import { Stripe } from "stripe"; +import { useMailer } from "./mailer"; +import { PnidDocument, usePapr } from "./papr"; +import { Transporter } from "nodemailer"; +import { PaprMatchKeysAndValues } from "papr"; +import type { H3Event } from 'h3'; -async function sendEmailToCustomer(customer: Stripe.Customer, ops: { pid: number, title: String, body: string }): Promise { +async function sendEmailToCustomer(mailer: Transporter, customer: Stripe.Customer, ops: { pid: number, title: string, body: string }): Promise { try { + if (!customer.email) throw new Error("Customer does not have an email"); await mailer.sendMail({ to: customer.email, subject: ops.title, @@ -12,7 +18,7 @@ async function sendEmailToCustomer(customer: Stripe.Customer, ops: { pid: number } } -async function sendToNotificationEmails(notificationEmails: string[], ops: { title: String, body: string }): Promise { +async function sendToNotificationEmails(mailer: Transporter, notificationEmails: string[], ops: { title: string, body: string }): Promise { for (const email of notificationEmails) { // * Send notification emails for new sub try { @@ -28,6 +34,14 @@ async function sendToNotificationEmails(notificationEmails: string[], ops: { tit } export async function handleStripeEvent(event: H3Event, stripe: Stripe, webhook: Stripe.Event, notificationEmails: string[]) { + const mailer = useMailer(event); + const papr = await usePapr(event); + + if (!mailer || !papr) { + console.warn('Mailer/database not configured, ignoring stripe webhook'); + return; + } + if (webhook.type === 'customer.subscription.updated' || webhook.type === 'customer.subscription.deleted') { const subscription = webhook.data.object; const subscriptionItem = subscription.items.data[0]; @@ -59,7 +73,7 @@ export async function handleStripeEvent(event: H3Event, stripe: Stripe, webhook: console.error(`Error refunding subscription | ${customer.id}, ${subscription.id}`, error); } - await sendEmailToCustomer(customer, { + await sendEmailToCustomer(mailer, customer, { pid: 0, title: 'Pretendo Network Subscription Failed - No Linked PNID', body: `Your recent subscription to Pretendo Network has failed.\nThis is due to no PNID PID being linked to the Stripe customer account used. The subscription has been canceled and refunded. Please contact Jon immediately.\nStripe Customer ID: ${customer.id}` @@ -72,7 +86,7 @@ export async function handleStripeEvent(event: H3Event, stripe: Stripe, webhook: } const pid = Number(customer.metadata.pnid_pid); - const pnid = await database.PNID.findOne({ pid }); + const pnid = await papr.Pnid.findOne({ pid }); if (!pnid) { // PNID does not exist @@ -95,7 +109,7 @@ export async function handleStripeEvent(event: H3Event, stripe: Stripe, webhook: console.error(`Error refunding subscription | ${customer.id}, ${subscription.id} |`, error); } - await sendEmailToCustomer(customer, { + await sendEmailToCustomer(mailer, customer, { pid: 0, title: 'Pretendo Network Subscription Failed - PNID Not Found', body: `Your recent subscription to Pretendo Network has failed.\nThis is due to the provided PNID not being found. The subscription has been canceled and refunded. Please contact Jon immediately.\nStripe Customer ID: ${customer.id}\nPNID PID: ${pid}` @@ -107,47 +121,47 @@ export async function handleStripeEvent(event: H3Event, stripe: Stripe, webhook: return; } - const latestWebhookTimestamp = pnid.get('connections.stripe.latest_webhook_timestamp'); + const latestWebhookTimestamp = pnid.connections?.stripe?.latest_webhook_timestamp; if (latestWebhookTimestamp && latestWebhookTimestamp >= webhook.created) { // Do nothing, this webhook is older than the latest seen return; } - const currentSubscriptionId = pnid.get('connections.stripe.subscription_id'); - const discordId = pnid.get('connections.discord.id'); + const currentSubscriptionId = pnid.connections?.stripe?.subscription_id; + const discordId = pnid.connections?.discord?.id; if (subscription.status === 'canceled' && currentSubscriptionId && subscription.id !== currentSubscriptionId) { // Canceling old subscription, do nothing but update webhook date and remove Discord roles if (product.metadata.beta === 'true') { - util.removeDiscordMemberTesterRole(discordId).catch((error) => { + await util.removeDiscordMemberTesterRole(discordId).catch((error) => { console.error(`Error removing user Discord tester role | ${customer.id}, ${discordId}, ${pid} |`, error); }); } - util.removeDiscordMemberSupporterRole(discordId, product.metadata.discord_role_id).catch((error) => { + await util.removeDiscordMemberSupporterRole(discordId, product.metadata.discord_role_id).catch((error) => { console.error(`Error removing user Discord supporter role | ${customer.id}, ${discordId}, ${pid}, ${product.metadata.discord_role_id} |`, error); }); - const updateData = { - 'connections.stripe.latest_webhook_timestamp': webhook.created - }; - - await database.PNID.updateOne({ + await papr.Pnid.updateOne({ pid, 'connections.stripe.latest_webhook_timestamp': { $lte: webhook.created } - }, { $set: updateData }).exec(); + }, { + $set: { + 'connections.stripe.latest_webhook_timestamp': webhook.created + } + }); return; } - const updateData = { - 'connections.stripe.subscription_id': subscription.status === 'active' ? subscription.id : null, - 'connections.stripe.price_id': subscription.status === 'active' ? subscriptionItem.plan.id : null, + const updateData: PaprMatchKeysAndValues = { + 'connections.stripe.subscription_id': subscription.status === 'active' ? subscription.id : undefined, + 'connections.stripe.price_id': subscription.status === 'active' ? subscriptionItem.plan.id : undefined, 'connections.stripe.tier_level': subscription.status === 'active' ? Number(product.metadata.tier_level || 0) : 0, - 'connections.stripe.tier_name': subscription.status === 'active' ? product.name : null, + 'connections.stripe.tier_name': subscription.status === 'active' ? product.name : undefined, 'connections.stripe.latest_webhook_timestamp': webhook.created }; @@ -158,7 +172,7 @@ export async function handleStripeEvent(event: H3Event, stripe: Stripe, webhook: updateData.server_access_level = 'test'; } - util.assignDiscordMemberTesterRole(discordId).catch((error) => { + await util.assignDiscordMemberTesterRole(discordId).catch((error) => { console.error(`Error assigning user Discord tester role | ${customer.id}, ${discordId}, ${pid} |`, error); }); } else { @@ -169,18 +183,20 @@ export async function handleStripeEvent(event: H3Event, stripe: Stripe, webhook: updateData.server_access_level = 'prod'; } - util.removeDiscordMemberTesterRole(discordId).catch((error) => { + await util.removeDiscordMemberTesterRole(discordId).catch((error) => { console.error(`Error removing user Discord tester role | ${customer.id}, ${discordId}, ${pid} |`, error); }); } } - await database.PNID.updateOne({ + await papr.Pnid.updateOne({ pid, 'connections.stripe.latest_webhook_timestamp': { $lte: webhook.created } - }, { $set: updateData }).exec(); + }, { + $set: updateData + }); if (subscription.status === 'active') { // Get all the customers active subscriptions @@ -203,62 +219,62 @@ export async function handleStripeEvent(event: H3Event, stripe: Stripe, webhook: } } - await sendEmailToCustomer(customer, { + await sendEmailToCustomer(mailer, customer, { pid, title: `Pretendo Network ${product.name} Subscription - Active`, body: `Thank you for purchasing the ${product.name} tier! We greatly value your support, thank you for helping keep Pretendo Network alive!\nIt may take a moment for your account dashboard to reflect these changes. Please wait a moment and refresh the dashboard to see them!` }) - util.assignDiscordMemberSupporterRole(discordId, product.metadata.discord_role_id).catch((error) => { + await util.assignDiscordMemberSupporterRole(discordId, product.metadata.discord_role_id).catch((error) => { console.error(`Error assigning user Discord supporter role | ${customer.id}, ${discordId}, ${pid}, ${product.metadata.discord_role_id} |`, error); }); - await sendToNotificationEmails(notificationEmails, { + await sendToNotificationEmails(mailer, notificationEmails, { title: `New ${product.name} subscription`, - body: `${pnid.get('username')} just became a ${product.name} tier subscriber`, + body: `${pnid.username} just became a ${product.name} tier subscriber`, }) } else if (subscription.status === 'canceled') { - await sendEmailToCustomer(customer, { + await sendEmailToCustomer(mailer, customer, { pid, title: `Pretendo Network ${product.name} Subscription - Canceled`, body: `Your subscription for the ${product.name} tier has been canceled. We thank for your previous support, and hope you still enjoy the network! ` }) - util.removeDiscordMemberSupporterRole(discordId, product.metadata.discord_role_id).catch((error) => { + await util.removeDiscordMemberSupporterRole(discordId, product.metadata.discord_role_id).catch((error) => { console.error(`Error removing user Discord supporter role | ${customer.id}, ${discordId}, ${pid}, ${product.metadata.discord_role_id} |`, error); }); - await sendToNotificationEmails(notificationEmails, { + await sendToNotificationEmails(mailer, notificationEmails, { title: `Canceled ${product.name} subscription`, - body: `${pnid.get('username')} just canceled their ${product.name} tier subscription`, + body: `${pnid.username} just canceled their ${product.name} tier subscription`, }) } else if (subscription.status === 'unpaid') { - await sendEmailToCustomer(customer, { + await sendEmailToCustomer(mailer, customer, { pid, title: `Pretendo Network ${product.name} Subscription - Unpaid`, body: `Your subscription for the ${product.name} tier has been canceled due to non payment. We thank for your previous support, and hope you still enjoy the network! ` }) - util.removeDiscordMemberSupporterRole(discordId, product.metadata.discord_role_id).catch((error) => { + await util.removeDiscordMemberSupporterRole(discordId, product.metadata.discord_role_id).catch((error) => { console.error(`Error removing user Discord supporter role | ${customer.id}, ${discordId}, ${pid}, ${product.metadata.discord_role_id} |`, error); }); - await sendToNotificationEmails(notificationEmails, { + await sendToNotificationEmails(mailer, notificationEmails, { title: `Removed ${product.name} subscription`, - body: `${pnid.get('username')}'s ${product.name} tier subscription has been canceled due to non payment`, + body: `${pnid.username}'s ${product.name} tier subscription has been canceled due to non payment`, }) } else { - await sendEmailToCustomer(customer, { + await sendEmailToCustomer(mailer, customer, { pid, title: `Pretendo Network ${product.name} Subscription - ${subscription.status}`, body: `Your subscription for the ${product.name} tier has changed status to ${subscription.status}. This is usually caused by payment failure. Your account has been reverted back to default until payment resumes. If you believe this to be an error, please reach out for support on our Discord server, and we thank you for your previous support!` }) - util.removeDiscordMemberSupporterRole(discordId, product.metadata.discord_role_id).catch((error) => { + await util.removeDiscordMemberSupporterRole(discordId, product.metadata.discord_role_id).catch((error) => { console.error(`Error removing user Discord supporter role | ${customer.id}, ${discordId}, ${pid}, ${product.metadata.discord_role_id} |`, error); }); - await sendToNotificationEmails(notificationEmails, { + await sendToNotificationEmails(mailer, notificationEmails, { title: `Removed ${product.name} subscription`, body: `${pnid.username}'s ${product.name} tier subscription status has been changed to ${subscription.status}`, }) diff --git a/server/utils/mailer.ts b/server/utils/mailer.ts new file mode 100644 index 0000000..ddb81bd --- /dev/null +++ b/server/utils/mailer.ts @@ -0,0 +1,27 @@ +import { createTransport, Transporter } from "nodemailer"; +import type { H3Event } from 'h3' + +let transport: Transporter | null = null; + +export function useMailer(event: H3Event): Transporter | null { + if (!transport) { + const config = useRuntimeConfig(event); + if (config.smtpHost && config.smtpPort && config.smtpFromEmail) { + transport = createTransport({ + from: { + address: config.smtpFromEmail, + name: config.smtpFromName ? config.smtpFromName : undefined, + }, + host: config.smtpHost, + port: config.smtpPort, + secure: config.smtpSecure, + auth: { + user: config.smtpUser ? config.smtpUser : undefined, + pass: config.smtpPassword ? config.smtpPassword : undefined + }, + }); + } + } + + return transport; +} diff --git a/server/utils/octokit.ts b/server/utils/octokit.ts index a25bc7d..faa77fe 100644 --- a/server/utils/octokit.ts +++ b/server/utils/octokit.ts @@ -1,4 +1,5 @@ import { Octokit } from "octokit" +import type { H3Event } from 'h3' let octokit: Octokit | null = null; diff --git a/server/utils/papr.ts b/server/utils/papr.ts new file mode 100644 index 0000000..15aea13 --- /dev/null +++ b/server/utils/papr.ts @@ -0,0 +1,46 @@ +import { MongoClient } from "mongodb"; +import Papr, { schema, types } from "papr" +import type { H3Event } from 'h3' + +const papr = new Papr(); +let conn: MongoClient | null = null; + +const pnidSchema = schema({ + pid: types.number({ required: true }), + username: types.string({ required: true }), + access_level: types.number({ required: true }), + server_access_level: types.enum(['prod', 'test', 'dev'] as const, { required: true }), + connections: types.object({ + stripe: types.object({ + subscription_id: types.string(), + price_id: types.string(), + tier_level: types.number(), + tier_name: types.string(), + latest_webhook_timestamp: types.number(), + }), + discord: types.object({ + id: types.string(), + }) + }) +}); +const Pnid = papr.model('pnids', pnidSchema); +export type PnidDocument = (typeof pnidSchema)[0]; + +const paprInstance = { + papr, + Pnid, +} as const; + +export type PaprInstance = typeof paprInstance; + +export async function usePapr(event: H3Event): Promise { + if (!conn) { + const config = useRuntimeConfig(event); + if (config.mongoConnectionString) { + conn = await MongoClient.connect(config.mongoConnectionString); + papr.initialize(conn.db()); + } + } + + return conn ? paprInstance : null; +} diff --git a/server/utils/readZodBody.ts b/server/utils/readZodBody.ts index 5872e4c..3126680 100644 --- a/server/utils/readZodBody.ts +++ b/server/utils/readZodBody.ts @@ -1,11 +1,12 @@ import type { z, ZodType } from 'zod'; +import type { H3Event } from 'h3' export async function readZodBody(event: H3Event, schema: T): Promise> { const body = await readValidatedBody(event, schema.safeParse); if (!body.success) { throw createError({ status: 400, - statusText: 'Invalid input' + message: 'Invalid input' }); } return body.data; diff --git a/server/utils/stripe.ts b/server/utils/stripe.ts index 29c4527..ea0792b 100644 --- a/server/utils/stripe.ts +++ b/server/utils/stripe.ts @@ -1,4 +1,5 @@ import { Stripe } from "stripe" +import type { H3Event } from 'h3' let stripe: Stripe | null = null; diff --git a/server/utils/useGrpc.ts b/server/utils/useGrpc.ts index 1e30122..22bc561 100644 --- a/server/utils/useGrpc.ts +++ b/server/utils/useGrpc.ts @@ -2,6 +2,7 @@ import { createChannel, createClient, Metadata } from 'nice-grpc'; import { APIDefinition } from '@pretendonetwork/grpc/api/api_service'; import { AccountServiceDefinition } from '@pretendonetwork/grpc/account/v2/account_service'; import type { Channel, Client, CompatServiceDefinition } from 'nice-grpc'; +import type { H3Event } from 'h3' let grpc: { channel: Channel } | null = null; diff --git a/src/pages/account/login/index.vue b/src/pages/account/login/index.vue index 567c0dc..435153c 100644 --- a/src/pages/account/login/index.vue +++ b/src/pages/account/login/index.vue @@ -30,7 +30,7 @@ async function loginSubmission() { } } catch (error: unknown) { if (error instanceof FetchError) { - errorMessage.value = error.statusText; + errorMessage.value = error.message; } else { errorMessage.value = `Error during login: ${error}`; // TODO: localize } diff --git a/src/pages/account/register/index.vue b/src/pages/account/register/index.vue index 3f19ce1..6471607 100644 --- a/src/pages/account/register/index.vue +++ b/src/pages/account/register/index.vue @@ -27,7 +27,7 @@ async function registerSubmission() { } } catch (error: unknown) { if (error instanceof FetchError) { - errorMessage.value = error.statusText; + errorMessage.value = error.message; } else { if (error === 'challenge-closed') { // Thrown if the captcha is closed, can be safely ignored return;