mirror of
https://github.com/MAGICGrants/campaign-site.git
synced 2026-01-09 12:27:59 -05:00
feat: queue fix and points redeem confirmation email
This commit is contained in:
@@ -1,3 +1,3 @@
|
||||
import { ConnectionOptions } from 'bullmq'
|
||||
|
||||
export const redisConnection: ConnectionOptions = { host: 'redis' }
|
||||
export const redisConnection: ConnectionOptions = { host: 'redis', port: 6379, url: 'redis:6379' }
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Queue } from 'bullmq'
|
||||
import { PerkPurchaseWorkerData } from './workers/perk'
|
||||
import { redisConnection } from '../config/redis'
|
||||
import './workers/perk'
|
||||
|
||||
export const perkPurchaseQueue = new Queue<PerkPurchaseWorkerData>('PerkPurchase', {
|
||||
connection: { host: 'redis' },
|
||||
connection: redisConnection,
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { z } from 'zod'
|
||||
import { protectedProcedure, publicProcedure, router } from '../trpc'
|
||||
import { QueueEvents } from 'bullmq'
|
||||
import { fundSlugs } from '../../utils/funds'
|
||||
import { keycloak, printfulApi, prisma, strapiApi } from '../services'
|
||||
import {
|
||||
@@ -22,6 +23,7 @@ import { AxiosResponse } from 'axios'
|
||||
import { POINTS_REDEEM_PRICE_USD } from '../../config'
|
||||
import { authenticateKeycloakClient } from '../utils/keycloak'
|
||||
import { perkPurchaseQueue } from '../queues'
|
||||
import { redisConnection } from '../../config/redis'
|
||||
|
||||
export const perkRouter = router({
|
||||
getBalance: protectedProcedure.query(async ({ ctx }) => {
|
||||
@@ -199,7 +201,7 @@ export const perkRouter = router({
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Insufficient balance.' })
|
||||
}
|
||||
|
||||
await perkPurchaseQueue.add('purchase', {
|
||||
const purchaseJob = await perkPurchaseQueue.add('purchase', {
|
||||
perk,
|
||||
perkPrintfulSyncVariantId: input.perkPrintfulSyncVariantId,
|
||||
shippingAddressLine1: input.shippingAddressLine1,
|
||||
@@ -214,5 +216,9 @@ export const perkRouter = router({
|
||||
userEmail: user.email,
|
||||
userFullname: user?.attributes?.name?.[0],
|
||||
})
|
||||
|
||||
await purchaseJob.waitUntilFinished(
|
||||
new QueueEvents('PerkPurchase', { connection: redisConnection })
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -113,3 +113,72 @@ ${htmlFromMarkdown}`
|
||||
html,
|
||||
})
|
||||
}
|
||||
|
||||
type SendPerkPurchaseConfirmationEmailParams = {
|
||||
to: string
|
||||
perkName: string
|
||||
address?: {
|
||||
address1: string
|
||||
address2?: string
|
||||
city: string
|
||||
state?: string
|
||||
country: string
|
||||
zip: string
|
||||
}
|
||||
pointsRedeemed: number
|
||||
}
|
||||
|
||||
export async function sendPerkPurchaseConfirmationEmail({
|
||||
to,
|
||||
perkName,
|
||||
address,
|
||||
pointsRedeemed,
|
||||
}: SendPerkPurchaseConfirmationEmailParams) {
|
||||
const markdown = `You redeemed points from MAGIC Grants!
|
||||
|
||||
Redeemed item: ${perkName}
|
||||
Number of points redeemed: ${pointsFormat.format(pointsRedeemed)}
|
||||
|
||||
${
|
||||
address
|
||||
? `Mailing address
|
||||
|
||||
Address line 1: ${address.address1}
|
||||
Address line 2: ${address.address2 ? address.address2 : '-'}
|
||||
City: ${address.city}
|
||||
State: ${address.state}
|
||||
Country: ${address.country}
|
||||
Zip: ${address.zip}`
|
||||
: ''
|
||||
}
|
||||
|
||||
If you did not make this redemption, please contact [info@magicgrants.org](info@magicgrants.org) immediately, since your account may be compromised. Points are not refundable once redeemed. If you have an issue with your order, please contact us.`
|
||||
|
||||
const htmlFromMarkdown = await markdownToHtml(markdown)
|
||||
|
||||
const html = `<style>
|
||||
html {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
body {
|
||||
max-width: 700px;
|
||||
padding: 20px;
|
||||
margin: 0 auto;
|
||||
font-family: sans-serif;
|
||||
background-color: #F1F5FF;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #3a76f0;
|
||||
}
|
||||
</style>
|
||||
|
||||
${htmlFromMarkdown}`
|
||||
|
||||
return transporter.sendMail({
|
||||
from: env.SES_VERIFIED_SENDER,
|
||||
to,
|
||||
html,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { Worker } from 'bullmq'
|
||||
import { AxiosResponse } from 'axios'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
|
||||
import { redisConnection } from '../../config/redis'
|
||||
import { estimatePrintfulOrderCost, getUserPointBalance } from '../utils/perks'
|
||||
import { POINTS_REDEEM_PRICE_USD } from '../../config'
|
||||
@@ -10,9 +13,8 @@ import {
|
||||
StrapiCreatePointBody,
|
||||
StrapiPerk,
|
||||
} from '../types'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import { printfulApi, strapiApi } from '../services'
|
||||
import { AxiosResponse } from 'axios'
|
||||
import { sendPerkPurchaseConfirmationEmail } from '../utils/mailing'
|
||||
|
||||
export type PerkPurchaseWorkerData = {
|
||||
perk: StrapiPerk
|
||||
@@ -30,96 +32,119 @@ export type PerkPurchaseWorkerData = {
|
||||
userFullname: string
|
||||
}
|
||||
|
||||
export const perkPurchaseWorker = new Worker<PerkPurchaseWorkerData>(
|
||||
'PerkPurchase',
|
||||
async (job) => {
|
||||
// Check if user has enough balance
|
||||
let deductionAmount = 0
|
||||
const globalForWorker = global as unknown as { hasInitializedWorkers: boolean }
|
||||
|
||||
if (job.data.perk.printfulProductId && job.data.perkPrintfulSyncVariantId) {
|
||||
const printfulCostEstimate = await estimatePrintfulOrderCost({
|
||||
address1: job.data.shippingAddressLine1!,
|
||||
address2: job.data.shippingAddressLine2 || '',
|
||||
city: job.data.shippingCity!,
|
||||
stateCode: job.data.shippingState!,
|
||||
countryCode: job.data.shippingCountry!,
|
||||
zip: job.data.shippingZip!,
|
||||
phone: job.data.shippingPhone!,
|
||||
name: job.data.userFullname,
|
||||
email: job.data.userEmail,
|
||||
tax_number: job.data.shippingTaxNumber,
|
||||
printfulSyncVariantId: job.data.perkPrintfulSyncVariantId,
|
||||
})
|
||||
if (!globalForWorker.hasInitializedWorkers) console.log('hey')
|
||||
|
||||
deductionAmount = Math.ceil(printfulCostEstimate.costs.total / POINTS_REDEEM_PRICE_USD)
|
||||
} else {
|
||||
deductionAmount = job.data.perk.price
|
||||
}
|
||||
if (!globalForWorker.hasInitializedWorkers)
|
||||
new Worker<PerkPurchaseWorkerData>(
|
||||
'PerkPurchase',
|
||||
async (job) => {
|
||||
// Check if user has enough balance
|
||||
let deductionAmount = 0
|
||||
|
||||
const currentBalance = await getUserPointBalance(job.data.userId)
|
||||
const balanceAfterPurchase = currentBalance - deductionAmount
|
||||
|
||||
if (balanceAfterPurchase < 0) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Insufficient balance.' })
|
||||
}
|
||||
|
||||
// Create printful order (if applicable)
|
||||
if (job.data.perk.printfulProductId && job.data.perkPrintfulSyncVariantId) {
|
||||
const result = await printfulApi.post<
|
||||
{},
|
||||
AxiosResponse<PrintfulCreateOrderRes>,
|
||||
PrintfulCreateOrderReq
|
||||
>(`/orders`, {
|
||||
recipient: {
|
||||
if (job.data.perk.printfulProductId && job.data.perkPrintfulSyncVariantId) {
|
||||
const printfulCostEstimate = await estimatePrintfulOrderCost({
|
||||
address1: job.data.shippingAddressLine1!,
|
||||
address2: job.data.shippingAddressLine2 || '',
|
||||
city: job.data.shippingCity!,
|
||||
state_code: job.data.shippingState!,
|
||||
country_code: job.data.shippingCountry!,
|
||||
stateCode: job.data.shippingState!,
|
||||
countryCode: job.data.shippingCountry!,
|
||||
zip: job.data.shippingZip!,
|
||||
phone: job.data.shippingPhone!,
|
||||
name: job.data.userFullname,
|
||||
email: job.data.userEmail,
|
||||
tax_number: job.data.shippingTaxNumber,
|
||||
},
|
||||
items: [{ quantity: 1, sync_variant_id: job.data.perkPrintfulSyncVariantId }],
|
||||
})
|
||||
}
|
||||
printfulSyncVariantId: job.data.perkPrintfulSyncVariantId,
|
||||
})
|
||||
|
||||
// Create strapi order
|
||||
const {
|
||||
data: { data: order },
|
||||
} = await strapiApi.post<StrapiCreateOrderRes, any, StrapiCreateOrderBody>('/orders', {
|
||||
data: {
|
||||
perk: job.data.perk.documentId,
|
||||
userId: job.data.userId,
|
||||
userEmail: job.data.userEmail,
|
||||
shippingAddressLine1: job.data.shippingAddressLine1,
|
||||
shippingAddressLine2: job.data.shippingAddressLine2,
|
||||
shippingCity: job.data.shippingCity,
|
||||
shippingState: job.data.shippingState,
|
||||
shippingCountry: job.data.shippingCountry,
|
||||
shippingZip: job.data.shippingZip,
|
||||
shippingPhone: job.data.shippingPhone,
|
||||
},
|
||||
})
|
||||
deductionAmount = Math.ceil(printfulCostEstimate.costs.total / POINTS_REDEEM_PRICE_USD)
|
||||
} else {
|
||||
deductionAmount = job.data.perk.price
|
||||
}
|
||||
|
||||
try {
|
||||
// Deduct points
|
||||
await strapiApi.post<any, any, StrapiCreatePointBody>('/points', {
|
||||
const currentBalance = await getUserPointBalance(job.data.userId)
|
||||
const balanceAfterPurchase = currentBalance - deductionAmount
|
||||
|
||||
if (balanceAfterPurchase < 0) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Insufficient balance.' })
|
||||
}
|
||||
|
||||
// Create printful order (if applicable)
|
||||
if (job.data.perk.printfulProductId && job.data.perkPrintfulSyncVariantId) {
|
||||
const result = await printfulApi.post<
|
||||
{},
|
||||
AxiosResponse<PrintfulCreateOrderRes>,
|
||||
PrintfulCreateOrderReq
|
||||
>(`/orders`, {
|
||||
recipient: {
|
||||
address1: job.data.shippingAddressLine1!,
|
||||
address2: job.data.shippingAddressLine2 || '',
|
||||
city: job.data.shippingCity!,
|
||||
state_code: job.data.shippingState!,
|
||||
country_code: job.data.shippingCountry!,
|
||||
zip: job.data.shippingZip!,
|
||||
phone: job.data.shippingPhone!,
|
||||
name: job.data.userFullname,
|
||||
email: job.data.userEmail,
|
||||
tax_number: job.data.shippingTaxNumber,
|
||||
},
|
||||
items: [{ quantity: 1, sync_variant_id: job.data.perkPrintfulSyncVariantId }],
|
||||
})
|
||||
}
|
||||
|
||||
// Create strapi order
|
||||
const {
|
||||
data: { data: order },
|
||||
} = await strapiApi.post<StrapiCreateOrderRes, any, StrapiCreateOrderBody>('/orders', {
|
||||
data: {
|
||||
balanceChange: (-deductionAmount).toString(),
|
||||
balance: balanceAfterPurchase.toString(),
|
||||
userId: job.data.userId,
|
||||
perk: job.data.perk.documentId,
|
||||
order: order.documentId,
|
||||
userId: job.data.userId,
|
||||
userEmail: job.data.userEmail,
|
||||
shippingAddressLine1: job.data.shippingAddressLine1,
|
||||
shippingAddressLine2: job.data.shippingAddressLine2,
|
||||
shippingCity: job.data.shippingCity,
|
||||
shippingState: job.data.shippingState,
|
||||
shippingCountry: job.data.shippingCountry,
|
||||
shippingZip: job.data.shippingZip,
|
||||
shippingPhone: job.data.shippingPhone,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
// If it fails, delete order
|
||||
await strapiApi.delete(`/orders/${order.documentId}`)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
{ connection: redisConnection, concurrency: 1 }
|
||||
)
|
||||
|
||||
try {
|
||||
// Deduct points
|
||||
await strapiApi.post<any, any, StrapiCreatePointBody>('/points', {
|
||||
data: {
|
||||
balanceChange: (-deductionAmount).toString(),
|
||||
balance: balanceAfterPurchase.toString(),
|
||||
userId: job.data.userId,
|
||||
perk: job.data.perk.documentId,
|
||||
order: order.documentId,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
// If it fails, delete order
|
||||
await strapiApi.delete(`/orders/${order.documentId}`)
|
||||
throw error
|
||||
}
|
||||
|
||||
sendPerkPurchaseConfirmationEmail({
|
||||
to: job.data.userEmail,
|
||||
perkName: job.data.perk.name,
|
||||
pointsRedeemed: deductionAmount,
|
||||
address: job.data.shippingAddressLine1
|
||||
? {
|
||||
address1: job.data.shippingAddressLine1,
|
||||
address2: job.data.shippingAddressLine2,
|
||||
state: job.data.shippingState,
|
||||
city: job.data.shippingCity!,
|
||||
country: job.data.shippingCountry!,
|
||||
zip: job.data.shippingZip!,
|
||||
}
|
||||
: undefined,
|
||||
})
|
||||
},
|
||||
{ connection: redisConnection, concurrency: 1 }
|
||||
)
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') globalForWorker.hasInitializedWorkers = true
|
||||
|
||||
Reference in New Issue
Block a user