FRE-5335 Hook waitlist signup to send confirmation email via Resend
- Added @shieldai/shared-notifications, bullmq, ioredis deps to API - POST /api/waitlist/signup now sends waitlist_confirmation email via EmailService - Schedules welcome sequence (day1 intro, day3 features, day7 launch teaser) via BullMQ delayed jobs - Added waitlist email worker in @shieldai/jobs to process delayed welcome sequence emails - Templates already in place: waitlist_confirmation, waitlist_intro, waitlist_features, waitlist_launch_teaser with dark-themed HTML layouts Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -20,9 +20,12 @@
|
|||||||
"@shieldai/db": "workspace:*",
|
"@shieldai/db": "workspace:*",
|
||||||
"@shieldai/monitoring": "workspace:*",
|
"@shieldai/monitoring": "workspace:*",
|
||||||
"@shieldai/report": "workspace:*",
|
"@shieldai/report": "workspace:*",
|
||||||
|
"@shieldai/shared-notifications": "workspace:*",
|
||||||
"@shieldai/types": "workspace:*",
|
"@shieldai/types": "workspace:*",
|
||||||
"@shieldai/voiceprint": "workspace:*",
|
"@shieldai/voiceprint": "workspace:*",
|
||||||
"fastify": "^5.2.0"
|
"bullmq": "^5.24.0",
|
||||||
|
"fastify": "^5.2.0",
|
||||||
|
"ioredis": "^5.4.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitest/coverage-v8": "^4.1.5",
|
"@vitest/coverage-v8": "^4.1.5",
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
||||||
import { prisma } from '@shieldai/db';
|
import { prisma } from '@shieldai/db';
|
||||||
|
import { EmailService } from '@shieldai/shared-notifications';
|
||||||
|
import { Queue } from 'bullmq';
|
||||||
|
import { Redis } from 'ioredis';
|
||||||
|
|
||||||
|
const redisUrl = process.env.REDIS_URL || 'redis://localhost:6379';
|
||||||
|
const connection = new Redis(redisUrl);
|
||||||
|
const waitlistEmailQueue = new Queue('waitlist-emails', { connection });
|
||||||
|
|
||||||
interface WaitlistSignupBody {
|
interface WaitlistSignupBody {
|
||||||
email: string;
|
email: string;
|
||||||
@@ -10,6 +17,13 @@ interface WaitlistSignupBody {
|
|||||||
utmCampaign?: string;
|
utmCampaign?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getPosition(entryId: string): string {
|
||||||
|
const hash = entryId.split('').reduce((acc, c) => acc + c.charCodeAt(0), 0);
|
||||||
|
return String(10000 + (hash % 90000));
|
||||||
|
}
|
||||||
|
|
||||||
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
export async function waitlistRoutes(fastify: FastifyInstance) {
|
export async function waitlistRoutes(fastify: FastifyInstance) {
|
||||||
fastify.post('/waitlist/signup', async (request: FastifyRequest, reply: FastifyReply) => {
|
fastify.post('/waitlist/signup', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||||
const body = request.body as WaitlistSignupBody;
|
const body = request.body as WaitlistSignupBody;
|
||||||
@@ -48,6 +62,47 @@ export async function waitlistRoutes(fastify: FastifyInstance) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const name = body.name?.trim() || 'there';
|
||||||
|
const position = getPosition(entry.id);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const emailService = EmailService.getInstance();
|
||||||
|
const result = await emailService.sendWithTemplate(email, {
|
||||||
|
templateId: 'waitlist_confirmation',
|
||||||
|
variables: { name, position },
|
||||||
|
});
|
||||||
|
if (result.status === 'failed') {
|
||||||
|
request.log.warn({ error: result.error }, 'Failed to send waitlist confirmation email');
|
||||||
|
} else {
|
||||||
|
request.log.info({ email }, 'Waitlist confirmation email sent');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
request.log.error({ err }, 'Error sending waitlist confirmation email');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Promise.all([
|
||||||
|
waitlistEmailQueue.add(
|
||||||
|
'send-waitlist-intro',
|
||||||
|
{ email, name, entryId: entry.id, tier },
|
||||||
|
{ delay: 1 * DAY_MS, attempts: 3, backoff: { type: 'exponential', delay: 5000 } }
|
||||||
|
),
|
||||||
|
waitlistEmailQueue.add(
|
||||||
|
'send-waitlist-features',
|
||||||
|
{ email, name, entryId: entry.id, tier },
|
||||||
|
{ delay: 3 * DAY_MS, attempts: 3, backoff: { type: 'exponential', delay: 5000 } }
|
||||||
|
),
|
||||||
|
waitlistEmailQueue.add(
|
||||||
|
'send-waitlist-launch-teaser',
|
||||||
|
{ email, name, entryId: entry.id, tier },
|
||||||
|
{ delay: 7 * DAY_MS, attempts: 3, backoff: { type: 'exponential', delay: 5000 } }
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
request.log.info({ email }, 'Welcome sequence scheduled');
|
||||||
|
} catch (err) {
|
||||||
|
request.log.error({ err }, 'Failed to schedule welcome sequence emails');
|
||||||
|
}
|
||||||
|
|
||||||
return reply.code(201).send({
|
return reply.code(201).send({
|
||||||
message: 'Welcome to the ShieldAI waitlist',
|
message: 'Welcome to the ShieldAI waitlist',
|
||||||
id: entry.id,
|
id: entry.id,
|
||||||
|
|||||||
@@ -134,6 +134,47 @@ export async function scheduleWebhookProcessor() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Waitlist email worker
|
||||||
|
import { EmailService } from '@shieldai/shared-notifications';
|
||||||
|
|
||||||
|
const waitlistEmailWorker = new Worker(
|
||||||
|
"waitlist-emails",
|
||||||
|
async (job) => {
|
||||||
|
const { email, name, entryId } = job.data;
|
||||||
|
const templateIdMap: Record<string, string> = {
|
||||||
|
'send-waitlist-intro': 'waitlist_intro',
|
||||||
|
'send-waitlist-features': 'waitlist_features',
|
||||||
|
'send-waitlist-launch-teaser': 'waitlist_launch_teaser',
|
||||||
|
};
|
||||||
|
|
||||||
|
const templateId = templateIdMap[job.name];
|
||||||
|
if (!templateId) {
|
||||||
|
throw new Error(`Unknown waitlist email job: ${job.name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const emailService = EmailService.getInstance();
|
||||||
|
const result = await emailService.sendWithTemplate(email, {
|
||||||
|
templateId,
|
||||||
|
variables: { name, entryId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.status === 'failed') {
|
||||||
|
throw new Error(`Failed to send ${templateId} to ${email}: ${result.error}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { templateId, email, deliveredAt: result.deliveredAt };
|
||||||
|
},
|
||||||
|
{ connection, concurrency: 5 }
|
||||||
|
);
|
||||||
|
|
||||||
|
waitlistEmailWorker.on("completed", (job) => {
|
||||||
|
console.log(`[WaitlistEmail] Job ${job?.id} (${job?.name}) completed for ${job?.data?.email}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
waitlistEmailWorker.on("failed", (job, err) => {
|
||||||
|
console.error(`[WaitlistEmail] Job ${job?.id} (${job?.name}) failed: ${err.message}`);
|
||||||
|
});
|
||||||
|
|
||||||
console.log("Job workers started");
|
console.log("Job workers started");
|
||||||
|
|
||||||
// Report generation workers
|
// Report generation workers
|
||||||
|
|||||||
Reference in New Issue
Block a user