diff --git a/apps/bff/.env.example b/apps/bff/.env.example index 72f3b6ae..200a1865 100644 --- a/apps/bff/.env.example +++ b/apps/bff/.env.example @@ -74,6 +74,14 @@ AUTH_RATE_LIMIT_LIMIT=10 # FREEBIT_BASE_URL= # FREEBIT_OEM_KEY= -# --- SendGrid (Email) --- -# SENDGRID_API_KEY= -# EMAIL_FROM=no-reply@example.com +# --- Email (SendGrid) --- +# SENDGRID_API_KEY= # Required: Your SendGrid API key +# EMAIL_FROM=no-reply@example.com # Required: Sender email address +# EMAIL_FROM_NAME=Customer Portal # Optional: Sender display name +# EMAIL_ENABLED=true # Enable/disable email sending +# EMAIL_USE_QUEUE=true # Use BullMQ queue (recommended) +# SENDGRID_SANDBOX=false # Enable sandbox mode for testing + +# --- Email Templates (Optional - SendGrid Dynamic Templates) --- +# EMAIL_TEMPLATE_OTP_VERIFICATION= # Template ID for OTP emails +# EMAIL_TEMPLATE_RESET= # Template ID for password reset diff --git a/apps/bff/src/infra/cache/cache.service.ts b/apps/bff/src/infra/cache/cache.service.ts index ddc68fcc..8bd15523 100644 --- a/apps/bff/src/infra/cache/cache.service.ts +++ b/apps/bff/src/infra/cache/cache.service.ts @@ -149,6 +149,21 @@ export class CacheService { return (await this.redis.exists(key)) === 1; } + /** + * Set a key only if it does not exist (atomic) + * Useful for idempotency checks and deduplication + * @param key Cache key + * @param value Value to cache (will be JSON serialized) + * @param ttlSeconds TTL in seconds + * @returns true if key was set (didn't exist), false if key already exists + */ + async setIfNotExists(key: string, value: unknown, ttlSeconds: number): Promise { + const serialized = JSON.stringify(value); + const ttl = Math.max(1, Math.floor(ttlSeconds)); + const result = await this.redis.set(key, serialized, "EX", ttl, "NX"); + return result === "OK"; + } + /** * Build a structured cache key * @param prefix Key prefix (e.g., "orders", "catalog") diff --git a/apps/bff/src/infra/email/email.module.ts b/apps/bff/src/infra/email/email.module.ts index 14d4115b..5d7a0b7f 100644 --- a/apps/bff/src/infra/email/email.module.ts +++ b/apps/bff/src/infra/email/email.module.ts @@ -1,13 +1,15 @@ -import { Module } from "@nestjs/common"; +import { Global, Module } from "@nestjs/common"; import { ConfigModule } from "@nestjs/config"; import { EmailService } from "./email.service.js"; import { SendGridEmailProvider } from "./providers/sendgrid.provider.js"; import { LoggingModule } from "@bff/core/logging/logging.module.js"; +import { QueueModule } from "@bff/infra/queue/queue.module.js"; import { EmailQueueService } from "./queue/email.queue.js"; import { EmailProcessor } from "./queue/email.processor.js"; +@Global() @Module({ - imports: [ConfigModule, LoggingModule], + imports: [ConfigModule, LoggingModule, QueueModule], providers: [EmailService, SendGridEmailProvider, EmailQueueService, EmailProcessor], exports: [EmailService, EmailQueueService], }) diff --git a/apps/bff/src/infra/email/email.service.ts b/apps/bff/src/infra/email/email.service.ts index f7ce10be..0f80df3e 100644 --- a/apps/bff/src/infra/email/email.service.ts +++ b/apps/bff/src/infra/email/email.service.ts @@ -13,31 +13,173 @@ export interface SendEmailOptions { html?: string; templateId?: string; dynamicTemplateData?: Record; + /** Category for tracking (e.g., 'otp', 'password-reset', 'welcome') */ + category?: EmailCategory; +} + +/** Predefined email categories for consistent tracking */ +export type EmailCategory = + | "otp-verification" + | "password-reset" + | "welcome" + | "eligibility-submitted" + | "account-notification" + | "transactional"; + +export interface SendEmailResult { + success: boolean; + queued: boolean; + error?: string; } @Injectable() export class EmailService { + private readonly emailEnabled: boolean; + private readonly useQueue: boolean; + constructor( private readonly config: ConfigService, private readonly provider: SendGridEmailProvider, private readonly queue: EmailQueueService, @Inject(Logger) private readonly logger: Logger - ) {} + ) { + this.emailEnabled = this.config.get("EMAIL_ENABLED", "true") === "true"; + this.useQueue = this.config.get("EMAIL_USE_QUEUE", "true") === "true"; - async sendEmail(options: SendEmailOptions): Promise { - const enabled = this.config.get("EMAIL_ENABLED", "true") === "true"; - if (!enabled) { - this.logger.log("Email sending disabled; skipping", { - to: options.to, - subject: options.subject, + this.logger.log("EmailService initialized", { + service: "email", + enabled: this.emailEnabled, + useQueue: this.useQueue, + }); + } + + async sendEmail(options: SendEmailOptions): Promise { + const emailContext = { + service: "email", + to: this.getRecipientCount(options.to), + subject: options.subject, + category: options.category || "transactional", + hasTemplate: !!options.templateId, + }; + + // Validate required fields + const validationError = this.validateOptions(options); + if (validationError) { + this.logger.error("Email validation failed", { + ...emailContext, + error: validationError, }); - return; + return { success: false, queued: false, error: validationError }; } - const useQueue = this.config.get("EMAIL_USE_QUEUE", "true") === "true"; - if (useQueue) { - await this.queue.enqueueEmail(options as EmailJobData); - } else { - await this.provider.send(options); + + // Check if email is enabled + if (!this.emailEnabled) { + this.logger.log("Email sending disabled - skipping", emailContext); + return { success: true, queued: false }; + } + + // Add category to options for downstream tracking + const enrichedOptions: EmailJobData = { + ...options, + category: options.category || "transactional", + }; + + try { + if (this.useQueue) { + await this.queue.enqueueEmail(enrichedOptions); + this.logger.log("Email queued for delivery", emailContext); + return { success: true, queued: true }; + } + + // Direct send (synchronous) + await this.provider.send(enrichedOptions); + this.logger.log("Email sent directly", emailContext); + return { success: true, queued: false }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + + this.logger.error("Failed to send/queue email", { + ...emailContext, + error: errorMessage, + mode: this.useQueue ? "queued" : "direct", + }); + + return { success: false, queued: false, error: errorMessage }; } } + + /** + * Send email directly without queue (useful for critical emails) + * Use sparingly - prefer queued delivery for reliability + */ + async sendEmailDirect(options: SendEmailOptions): Promise { + const emailContext = { + service: "email", + mode: "direct-forced", + to: this.getRecipientCount(options.to), + subject: options.subject, + category: options.category || "transactional", + }; + + const validationError = this.validateOptions(options); + if (validationError) { + this.logger.error("Email validation failed", { ...emailContext, error: validationError }); + return { success: false, queued: false, error: validationError }; + } + + if (!this.emailEnabled) { + this.logger.log("Email sending disabled - skipping direct send", emailContext); + return { success: true, queued: false }; + } + + try { + await this.provider.send({ + ...options, + category: options.category || "transactional", + }); + this.logger.log("Email sent directly (forced)", emailContext); + return { success: true, queued: false }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + this.logger.error("Direct email send failed", { ...emailContext, error: errorMessage }); + return { success: false, queued: false, error: errorMessage }; + } + } + + private validateOptions(options: SendEmailOptions): string | null { + if (!options.to || (Array.isArray(options.to) && options.to.length === 0)) { + return "Recipient (to) is required"; + } + + if (!options.subject || options.subject.trim().length === 0) { + return "Subject is required"; + } + + // Must have either template or content + if (!options.templateId && !options.html && !options.text) { + return "Email must have templateId, html, or text content"; + } + + // Basic email format validation + const emails = Array.isArray(options.to) ? options.to : [options.to]; + for (const email of emails) { + if (!this.isValidEmailFormat(email)) { + return `Invalid email format: ${email}`; + } + } + + return null; + } + + private isValidEmailFormat(email: string): boolean { + // Stricter email validation to prevent queue pollution with invalid emails + // Catches: multiple @, dots at start/end, consecutive dots, invalid domain + const emailRegex = + /^[a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+)*@(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/; + return emailRegex.test(email); + } + + private getRecipientCount(to: string | string[]): number { + return Array.isArray(to) ? to.length : 1; + } } diff --git a/apps/bff/src/infra/email/providers/sendgrid.provider.ts b/apps/bff/src/infra/email/providers/sendgrid.provider.ts index 25df0c55..74931351 100644 --- a/apps/bff/src/infra/email/providers/sendgrid.provider.ts +++ b/apps/bff/src/infra/email/providers/sendgrid.provider.ts @@ -1,8 +1,9 @@ import { Injectable, Inject } from "@nestjs/common"; +import type { OnModuleInit } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { Logger } from "nestjs-pino"; import sgMail from "@sendgrid/mail"; -import type { MailDataRequired } from "@sendgrid/mail"; +import type { MailDataRequired, ResponseError } from "@sendgrid/mail"; export interface ProviderSendOptions { to: string | string[]; @@ -12,44 +13,213 @@ export interface ProviderSendOptions { html?: string; templateId?: string; dynamicTemplateData?: Record; + /** Optional category for tracking/analytics */ + category?: string; } +export interface SendGridErrorDetail { + message: string; + field?: string; + help?: string; +} + +export interface ParsedSendGridError { + statusCode: number; + message: string; + errors: SendGridErrorDetail[]; + isRetryable: boolean; +} + +/** HTTP status codes that indicate transient failures worth retrying */ +const RETRYABLE_STATUS_CODES = new Set([408, 429, 500, 502, 503, 504]); + @Injectable() -export class SendGridEmailProvider { +export class SendGridEmailProvider implements OnModuleInit { + private isConfigured = false; + private readonly sandboxMode: boolean; + private readonly defaultFrom: string | undefined; + private readonly defaultFromName: string | undefined; + constructor( private readonly config: ConfigService, @Inject(Logger) private readonly logger: Logger ) { - const apiKey = this.config.get("SENDGRID_API_KEY"); - if (apiKey) { - sgMail.setApiKey(apiKey); - } + this.sandboxMode = this.config.get("SENDGRID_SANDBOX", "false") === "true"; + this.defaultFrom = this.config.get("EMAIL_FROM"); + this.defaultFromName = this.config.get("EMAIL_FROM_NAME"); } - async send(options: ProviderSendOptions): Promise { - const from = options.from || this.config.get("EMAIL_FROM"); - if (!from) { - this.logger.warn("EMAIL_FROM is not configured; email not sent"); + onModuleInit(): void { + const apiKey = this.config.get("SENDGRID_API_KEY"); + + if (!apiKey) { + this.logger.warn("SendGrid API key not configured - email sending will fail", { + provider: "sendgrid", + }); return; } + sgMail.setApiKey(apiKey); + this.isConfigured = true; + + this.logger.log("SendGrid email provider initialized", { + provider: "sendgrid", + sandboxMode: this.sandboxMode, + defaultFrom: this.defaultFrom, + }); + } + + async send(options: ProviderSendOptions): Promise { + const emailContext = { + provider: "sendgrid", + to: this.maskEmail(options.to), + subject: options.subject, + category: options.category, + hasTemplate: !!options.templateId, + templateId: options.templateId, + }; + + if (!this.isConfigured) { + this.logger.error("SendGrid not configured - cannot send email", emailContext); + throw new Error("SendGrid API key not configured"); + } + + const from = this.buildFromAddress(options.from); + if (!from) { + this.logger.error("No sender address configured - cannot send email", emailContext); + throw new Error("EMAIL_FROM is not configured"); + } + + const message = this.buildMessage(options, from); + + this.logger.log("Sending email via SendGrid", emailContext); + + try { + const [response] = await sgMail.send(message); + + this.logger.log("Email sent successfully via SendGrid", { + ...emailContext, + statusCode: response.statusCode, + messageId: (response.headers as Record)["x-message-id"], + }); + } catch (error) { + const parsed = this.parseError(error); + + this.logger.error("Failed to send email via SendGrid", { + ...emailContext, + statusCode: parsed.statusCode, + errorMessage: parsed.message, + errors: parsed.errors, + isRetryable: parsed.isRetryable, + }); + + // Attach metadata to error for upstream handling + const enrichedError = new Error(parsed.message); + (enrichedError as Error & { sendgrid: ParsedSendGridError }).sendgrid = parsed; + throw enrichedError; + } + } + + private buildFromAddress( + overrideFrom?: string + ): string | { email: string; name: string } | undefined { + const email = overrideFrom || this.defaultFrom; + if (!email) return undefined; + + // Always include sender name when configured (regardless of email override) + if (this.defaultFromName) { + return { email, name: this.defaultFromName }; + } + return email; + } + + private buildMessage( + options: ProviderSendOptions, + from: string | { email: string; name: string } + ): MailDataRequired { const message: MailDataRequired = { to: options.to, from, subject: options.subject, - text: options.text, - html: options.html, - templateId: options.templateId, - dynamicTemplateData: options.dynamicTemplateData, + mailSettings: { + sandboxMode: { enable: this.sandboxMode }, + }, } as MailDataRequired; - try { - await sgMail.send(message); - } catch (error) { - this.logger.error("Failed to send email via SendGrid", { - error: error instanceof Error ? error.message : String(error), - }); - throw error; + // Content: template or direct HTML/text + if (options.templateId) { + message.templateId = options.templateId; + if (options.dynamicTemplateData) { + message.dynamicTemplateData = options.dynamicTemplateData; + } + } else { + if (options.html) message.html = options.html; + if (options.text) message.text = options.text; } + + // Categories for SendGrid analytics + if (options.category) { + message.categories = [options.category]; + } + + return message; + } + + private parseError(error: unknown): ParsedSendGridError { + // SendGrid errors have a specific structure + if (this.isSendGridError(error)) { + const statusCode = error.code || 500; + const body = error.response?.body; + const responseErrors = this.extractErrorsFromBody(body); + + return { + statusCode, + message: responseErrors[0]?.message || error.message || "SendGrid error", + errors: responseErrors, + isRetryable: RETRYABLE_STATUS_CODES.has(statusCode), + }; + } + + // Generic error fallback + const message = error instanceof Error ? error.message : String(error); + return { + statusCode: 500, + message, + errors: [{ message }], + isRetryable: false, + }; + } + + private extractErrorsFromBody(body: unknown): SendGridErrorDetail[] { + if (!body || typeof body !== "object") { + return [{ message: "Unknown error" }]; + } + + // SendGrid returns { errors: [...] } in the body + const bodyObj = body as { errors?: Array<{ message?: string; field?: string; help?: string }> }; + if (Array.isArray(bodyObj.errors)) { + return bodyObj.errors.map(e => ({ + message: e.message || "Unknown error", + field: e.field, + help: e.help, + })); + } + + return [{ message: "Unknown error" }]; + } + + private isSendGridError(error: unknown): error is ResponseError { + return typeof error === "object" && error !== null && "code" in error && "response" in error; + } + + private maskEmail(email: string | string[]): string | string[] { + const mask = (e: string): string => { + const [local, domain] = e.split("@"); + if (!domain) return "***"; + const maskedLocal = local.length > 2 ? `${local[0]}***${local[local.length - 1]}` : "***"; + return `${maskedLocal}@${domain}`; + }; + + return Array.isArray(email) ? email.map(mask) : mask(email); } } diff --git a/apps/bff/src/infra/email/queue/email.processor.ts b/apps/bff/src/infra/email/queue/email.processor.ts index 3be3df03..b2fc8d0a 100644 --- a/apps/bff/src/infra/email/queue/email.processor.ts +++ b/apps/bff/src/infra/email/queue/email.processor.ts @@ -1,22 +1,167 @@ -import { Processor, WorkerHost } from "@nestjs/bullmq"; +import { Processor, WorkerHost, OnWorkerEvent } from "@nestjs/bullmq"; import { Injectable, Inject } from "@nestjs/common"; import { Logger } from "nestjs-pino"; -import { EmailService } from "../email.service.js"; +import { Job } from "bullmq"; +import { SendGridEmailProvider } from "../providers/sendgrid.provider.js"; +import { CacheService } from "../../cache/cache.service.js"; import type { EmailJobData } from "./email.queue.js"; import { QUEUE_NAMES } from "../../queue/queue.constants.js"; +interface ParsedSendGridError { + statusCode: number; + message: string; + errors: { message: string; field?: string; help?: string }[]; + isRetryable: boolean; +} + +/** TTL for idempotency keys (24 hours) - prevents duplicate sends on delayed retries */ +const IDEMPOTENCY_TTL_SECONDS = 86400; +const IDEMPOTENCY_PREFIX = "email:sent:"; + @Processor(QUEUE_NAMES.EMAIL) @Injectable() export class EmailProcessor extends WorkerHost { constructor( - private readonly emailService: EmailService, + private readonly provider: SendGridEmailProvider, + private readonly cache: CacheService, @Inject(Logger) private readonly logger: Logger ) { super(); } - async process(job: { data: EmailJobData }): Promise { - await this.emailService.sendEmail(job.data); - this.logger.debug("Processed email job"); + async process(job: Job): Promise { + const jobContext = { + service: "email-processor", + jobId: job.id, + attempt: job.attemptsMade + 1, + maxAttempts: job.opts.attempts || 3, + category: job.data.category || "transactional", + subject: job.data.subject, + recipientCount: Array.isArray(job.data.to) ? job.data.to.length : 1, + }; + + this.logger.log("Processing email job", jobContext); + + // Idempotency check: prevent duplicate sends on retries + const idempotencyKey = `${IDEMPOTENCY_PREFIX}${job.id}`; + const isFirstAttempt = await this.cache.setIfNotExists( + idempotencyKey, + { sentAt: Date.now(), subject: job.data.subject }, + IDEMPOTENCY_TTL_SECONDS + ); + + if (!isFirstAttempt) { + this.logger.warn("Email already sent - skipping duplicate", { + ...jobContext, + idempotencyKey, + }); + return; // Job completes successfully without sending again + } + + try { + // Send directly via provider (bypasses EmailService to avoid recursion) + await this.provider.send(job.data); + + this.logger.log("Email job completed successfully", { + ...jobContext, + duration: Date.now() - job.timestamp, + }); + } catch (error) { + // Clear idempotency key on failure to allow retry + await this.cache.del(idempotencyKey); + + const errorInfo = this.extractErrorInfo(error); + + this.logger.error("Email job failed", { + ...jobContext, + error: errorInfo.message, + statusCode: errorInfo.statusCode, + isRetryable: errorInfo.isRetryable, + willRetry: errorInfo.isRetryable && jobContext.attempt < jobContext.maxAttempts, + }); + + // Re-throw to let BullMQ handle retry logic + throw error; + } + } + + @OnWorkerEvent("completed") + onCompleted(job: Job): void { + this.logger.debug("Email job marked complete", { + service: "email-processor", + jobId: job.id, + category: job.data.category, + }); + } + + @OnWorkerEvent("failed") + onFailed(job: Job | undefined, error: Error): void { + if (!job) { + this.logger.error("Email job failed (job undefined)", { + service: "email-processor", + error: error.message, + }); + return; + } + + const errorInfo = this.extractErrorInfo(error); + const isLastAttempt = job.attemptsMade >= (job.opts.attempts || 3); + + if (isLastAttempt) { + this.logger.error("Email job permanently failed - all retries exhausted", { + service: "email-processor", + jobId: job.id, + category: job.data.category, + subject: job.data.subject, + totalAttempts: job.attemptsMade, + error: errorInfo.message, + statusCode: errorInfo.statusCode, + }); + } else { + this.logger.warn("Email job failed - will retry", { + service: "email-processor", + jobId: job.id, + category: job.data.category, + attempt: job.attemptsMade, + maxAttempts: job.opts.attempts || 3, + error: errorInfo.message, + isRetryable: errorInfo.isRetryable, + }); + } + } + + @OnWorkerEvent("error") + onError(error: Error): void { + this.logger.error("Email processor worker error", { + service: "email-processor", + error: error.message, + stack: error.stack, + }); + } + + private extractErrorInfo(error: unknown): { + message: string; + statusCode?: number; + isRetryable: boolean; + } { + // Check for SendGrid-enriched error + if ( + error instanceof Error && + "sendgrid" in error && + typeof (error as Error & { sendgrid?: ParsedSendGridError }).sendgrid === "object" + ) { + const sgError = (error as Error & { sendgrid: ParsedSendGridError }).sendgrid; + return { + message: sgError.message, + statusCode: sgError.statusCode, + isRetryable: sgError.isRetryable, + }; + } + + // Fallback for generic errors + return { + message: error instanceof Error ? error.message : String(error), + isRetryable: false, + }; } } diff --git a/apps/bff/src/infra/email/queue/email.queue.ts b/apps/bff/src/infra/email/queue/email.queue.ts index 6edec587..d13bbceb 100644 --- a/apps/bff/src/infra/email/queue/email.queue.ts +++ b/apps/bff/src/infra/email/queue/email.queue.ts @@ -1,11 +1,30 @@ import { Injectable, Inject } from "@nestjs/common"; import { InjectQueue } from "@nestjs/bullmq"; -import { Queue } from "bullmq"; +import { Queue, Job } from "bullmq"; import { Logger } from "nestjs-pino"; -import type { SendEmailOptions } from "../email.service.js"; +import type { SendEmailOptions, EmailCategory } from "../email.service.js"; import { QUEUE_NAMES } from "../../queue/queue.constants.js"; -export type EmailJobData = SendEmailOptions & { category?: string }; +export type EmailJobData = SendEmailOptions & { + category?: EmailCategory; +}; + +export interface EmailJobResult { + jobId: string; + queued: boolean; +} + +/** Queue configuration constants */ +const QUEUE_CONFIG = { + /** Keep last N completed jobs for debugging */ + REMOVE_ON_COMPLETE: 100, + /** Keep last N failed jobs for investigation */ + REMOVE_ON_FAIL: 200, + /** Max retry attempts */ + MAX_ATTEMPTS: 3, + /** Initial backoff delay in ms */ + BACKOFF_DELAY: 2000, +} as const; @Injectable() export class EmailQueueService { @@ -14,17 +33,92 @@ export class EmailQueueService { @Inject(Logger) private readonly logger: Logger ) {} - async enqueueEmail(data: EmailJobData): Promise { - await this.queue.add("send", data, { - removeOnComplete: 50, - removeOnFail: 50, - attempts: 3, - backoff: { type: "exponential", delay: 2000 }, - }); - this.logger.debug("Queued email", { - to: data.to, - subject: data.subject, - category: data.category, + async enqueueEmail(data: EmailJobData): Promise { + const jobContext = { + service: "email-queue", + queue: QUEUE_NAMES.EMAIL, + category: data.category || "transactional", + recipientCount: Array.isArray(data.to) ? data.to.length : 1, + hasTemplate: !!data.templateId, + }; + + try { + const job = await this.queue.add("send", data, { + removeOnComplete: QUEUE_CONFIG.REMOVE_ON_COMPLETE, + removeOnFail: QUEUE_CONFIG.REMOVE_ON_FAIL, + attempts: QUEUE_CONFIG.MAX_ATTEMPTS, + backoff: { + type: "exponential", + delay: QUEUE_CONFIG.BACKOFF_DELAY, + }, + }); + + this.logger.log("Email job queued", { + ...jobContext, + jobId: job.id, + subject: data.subject, + }); + + return { jobId: job.id || "unknown", queued: true }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + + this.logger.error("Failed to queue email job", { + ...jobContext, + subject: data.subject, + error: errorMessage, + }); + + throw new Error(`Failed to queue email: ${errorMessage}`); + } + } + + /** + * Get queue health metrics for monitoring + */ + async getQueueHealth(): Promise<{ + waiting: number; + active: number; + completed: number; + failed: number; + delayed: number; + }> { + const [waiting, active, completed, failed, delayed] = await Promise.all([ + this.queue.getWaitingCount(), + this.queue.getActiveCount(), + this.queue.getCompletedCount(), + this.queue.getFailedCount(), + this.queue.getDelayedCount(), + ]); + + return { waiting, active, completed, failed, delayed }; + } + + /** + * Get failed jobs for debugging/retry + */ + async getFailedJobs(limit = 10): Promise[]> { + return this.queue.getFailed(0, limit - 1); + } + + /** + * Retry a specific failed job + */ + async retryJob(jobId: string): Promise { + const job = await this.queue.getJob(jobId); + if (!job) { + this.logger.warn("Job not found for retry", { + service: "email-queue", + jobId, + }); + return; + } + + await job.retry(); + this.logger.log("Job retry triggered", { + service: "email-queue", + jobId, + category: job.data.category, }); } } diff --git a/apps/bff/src/integrations/japanpost/services/japanpost-address.service.ts b/apps/bff/src/integrations/japanpost/services/japanpost-address.service.ts index 3b352647..e989d917 100644 --- a/apps/bff/src/integrations/japanpost/services/japanpost-address.service.ts +++ b/apps/bff/src/integrations/japanpost/services/japanpost-address.service.ts @@ -28,11 +28,15 @@ export class JapanPostAddressService { * Lookup address by ZIP code * * @param zipCode - ZIP code (with or without hyphen, e.g., "100-0001" or "1000001") + * @param clientIp - Client IP address for API request (defaults to 127.0.0.1) * @returns Domain AddressLookupResult with Japanese and romanized address data * @throws BadRequestException if ZIP code format is invalid * @throws ServiceUnavailableException if Japan Post API is unavailable */ - async lookupByZipCode(zipCode: string): Promise { + async lookupByZipCode( + zipCode: string, + clientIp: string = "127.0.0.1" + ): Promise { // Normalize ZIP code (remove hyphen) const normalizedZip = zipCode.replace(/-/g, ""); @@ -57,8 +61,27 @@ export class JapanPostAddressService { const startTime = Date.now(); + let rawResponse: unknown; + try { - const rawResponse = await this.connection.searchByZipCode(normalizedZip); + rawResponse = await this.connection.searchByZipCode(normalizedZip, clientIp); + + // Check if response is an API error (returned with 200 status) + if (this.isApiErrorResponse(rawResponse)) { + const errorResponse = rawResponse as { + error_code?: string; + message?: string; + request_id?: string; + }; + this.logger.error("Japan Post API returned error in response body", { + zipCode: normalizedZip, + durationMs: Date.now() - startTime, + errorCode: errorResponse.error_code, + message: errorResponse.message, + requestId: errorResponse.request_id, + }); + throw new ServiceUnavailableException("Address lookup service error. Please try again."); + } // Use domain mapper for transformation (single transformation point) const result = JapanPost.transformJapanPostSearchResponse(rawResponse); @@ -91,13 +114,27 @@ export class JapanPostAddressService { errorMessage.includes("HTTP") || errorMessage.includes("timed out"); + // Check if this is a Zod validation error + const isZodError = error instanceof Error && error.name === "ZodError"; + if (!isConnectionError) { // Only log unexpected errors (e.g., transformation failures) + // Include raw response for debugging schema mismatches this.logger.error("Address lookup transformation error", { zipCode: normalizedZip, durationMs, errorType: error instanceof Error ? error.constructor.name : "Unknown", + errorName: error instanceof Error ? error.name : "Unknown", + isZodError, error: errorMessage, + // For Zod errors, show the full error for debugging + zodIssues: isZodError + ? JSON.stringify((error as { issues?: unknown }).issues) + : undefined, + // Log raw response to debug schema mismatch (truncated for safety) + rawResponse: rawResponse + ? JSON.stringify(rawResponse).slice(0, 500) + : "undefined (error before API response)", }); } @@ -111,4 +148,16 @@ export class JapanPostAddressService { isAvailable(): boolean { return this.connection.isConfigured(); } + + /** + * Check if response is an API error (has error_code field instead of addresses) + */ + private isApiErrorResponse(response: unknown): boolean { + if (!response || typeof response !== "object") { + return false; + } + const obj = response as Record; + // API error responses have error_code field, valid responses have addresses array + return "error_code" in obj || ("message" in obj && !("addresses" in obj)); + } } diff --git a/apps/bff/src/integrations/japanpost/services/japanpost-connection.service.ts b/apps/bff/src/integrations/japanpost/services/japanpost-connection.service.ts index c5b6f983..5f40578f 100644 --- a/apps/bff/src/integrations/japanpost/services/japanpost-connection.service.ts +++ b/apps/bff/src/integrations/japanpost/services/japanpost-connection.service.ts @@ -295,9 +295,10 @@ export class JapanPostConnectionService implements OnModuleInit { * Search addresses by ZIP code * * @param zipCode - 7-digit ZIP code (no hyphen) + * @param clientIp - Client IP address for x-forwarded-for header * @returns Raw Japan Post API response */ - async searchByZipCode(zipCode: string): Promise { + async searchByZipCode(zipCode: string, clientIp: string = "127.0.0.1"): Promise { const token = await this.getAccessToken(); const controller = new AbortController(); @@ -313,6 +314,7 @@ export class JapanPostConnectionService implements OnModuleInit { headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", + "x-forwarded-for": clientIp, }, signal: controller.signal, }); diff --git a/apps/bff/src/modules/address/address.controller.ts b/apps/bff/src/modules/address/address.controller.ts index bb7a9f4e..83861b24 100644 --- a/apps/bff/src/modules/address/address.controller.ts +++ b/apps/bff/src/modules/address/address.controller.ts @@ -8,13 +8,16 @@ import { Controller, Get, Param, + Req, UseGuards, UseInterceptors, ClassSerializerInterceptor, } from "@nestjs/common"; +import type { Request } from "express"; import { createZodDto } from "nestjs-zod"; import { Public } from "@bff/modules/auth/decorators/public.decorator.js"; import { RateLimitGuard, RateLimit } from "@bff/core/rate-limiting/index.js"; +import { extractClientIp } from "@bff/core/http/request-context.util.js"; import { JapanPostAddressService } from "@bff/integrations/japanpost/services/japanpost-address.service.js"; import { addressLookupResultSchema, @@ -75,8 +78,12 @@ export class AddressController { @Get("lookup/zip/:zipCode") @UseGuards(RateLimitGuard) @RateLimit({ limit: 30, ttl: 60 }) // 30 requests per minute - async lookupByZipCode(@Param() params: ZipCodeParamDto): Promise { - return this.japanPostService.lookupByZipCode(params.zipCode); + async lookupByZipCode( + @Param() params: ZipCodeParamDto, + @Req() req: Request + ): Promise { + const clientIp = extractClientIp(req); + return this.japanPostService.lookupByZipCode(params.zipCode, clientIp); } /** diff --git a/apps/bff/src/modules/auth/auth.module.ts b/apps/bff/src/modules/auth/auth.module.ts index d6197929..a646cd46 100644 --- a/apps/bff/src/modules/auth/auth.module.ts +++ b/apps/bff/src/modules/auth/auth.module.ts @@ -12,7 +12,6 @@ import { TokenBlacklistService } from "./infra/token/token-blacklist.service.js" import { TokenStorageService } from "./infra/token/token-storage.service.js"; import { TokenRevocationService } from "./infra/token/token-revocation.service.js"; import { PasswordResetTokenService } from "./infra/token/password-reset-token.service.js"; -import { EmailModule } from "@bff/infra/email/email.module.js"; import { CacheModule } from "@bff/infra/cache/cache.module.js"; import { AuthTokenService } from "./infra/token/token.service.js"; import { JoseJwtService } from "./infra/token/jose-jwt.service.js"; @@ -33,7 +32,7 @@ import { GetStartedWorkflowService } from "./infra/workflows/get-started-workflo import { GetStartedController } from "./presentation/http/get-started.controller.js"; @Module({ - imports: [UsersModule, MappingsModule, IntegrationsModule, EmailModule, CacheModule], + imports: [UsersModule, MappingsModule, IntegrationsModule, CacheModule], controllers: [AuthController, GetStartedController], providers: [ // Application services diff --git a/apps/bff/src/modules/subscriptions/internet-management/internet-management.module.ts b/apps/bff/src/modules/subscriptions/internet-management/internet-management.module.ts index e7fc296a..cbb5b54f 100644 --- a/apps/bff/src/modules/subscriptions/internet-management/internet-management.module.ts +++ b/apps/bff/src/modules/subscriptions/internet-management/internet-management.module.ts @@ -4,11 +4,10 @@ import { InternetController } from "./internet.controller.js"; import { WhmcsModule } from "@bff/integrations/whmcs/whmcs.module.js"; import { MappingsModule } from "@bff/modules/id-mappings/mappings.module.js"; import { SalesforceModule } from "@bff/integrations/salesforce/salesforce.module.js"; -import { EmailModule } from "@bff/infra/email/email.module.js"; import { NotificationsModule } from "@bff/modules/notifications/notifications.module.js"; @Module({ - imports: [WhmcsModule, MappingsModule, SalesforceModule, EmailModule, NotificationsModule], + imports: [WhmcsModule, MappingsModule, SalesforceModule, NotificationsModule], controllers: [InternetController], providers: [InternetCancellationService], exports: [InternetCancellationService], diff --git a/apps/bff/src/modules/subscriptions/sim-management/sim-management.module.ts b/apps/bff/src/modules/subscriptions/sim-management/sim-management.module.ts index 9f408550..e55e6d3e 100644 --- a/apps/bff/src/modules/subscriptions/sim-management/sim-management.module.ts +++ b/apps/bff/src/modules/subscriptions/sim-management/sim-management.module.ts @@ -3,7 +3,6 @@ import { FreebitModule } from "@bff/integrations/freebit/freebit.module.js"; import { WhmcsModule } from "@bff/integrations/whmcs/whmcs.module.js"; import { SalesforceModule } from "@bff/integrations/salesforce/salesforce.module.js"; import { MappingsModule } from "@bff/modules/id-mappings/mappings.module.js"; -import { EmailModule } from "@bff/infra/email/email.module.js"; import { SftpModule } from "@bff/integrations/sftp/sftp.module.js"; import { SecurityModule } from "@bff/core/security/security.module.js"; import { SimUsageStoreService } from "../sim-usage-store.service.js"; @@ -39,7 +38,6 @@ import { VoiceOptionsModule, VoiceOptionsService } from "@bff/modules/voice-opti WhmcsModule, SalesforceModule, MappingsModule, - EmailModule, ServicesModule, SftpModule, NotificationsModule, diff --git a/apps/bff/src/modules/subscriptions/subscriptions.module.ts b/apps/bff/src/modules/subscriptions/subscriptions.module.ts index 2e21b6e2..dcd7865e 100644 --- a/apps/bff/src/modules/subscriptions/subscriptions.module.ts +++ b/apps/bff/src/modules/subscriptions/subscriptions.module.ts @@ -9,7 +9,6 @@ import { SecurityModule } from "@bff/core/security/security.module.js"; import { WhmcsModule } from "@bff/integrations/whmcs/whmcs.module.js"; import { MappingsModule } from "@bff/modules/id-mappings/mappings.module.js"; import { FreebitModule } from "@bff/integrations/freebit/freebit.module.js"; -import { EmailModule } from "@bff/infra/email/email.module.js"; import { SimManagementModule } from "./sim-management/sim-management.module.js"; import { InternetManagementModule } from "./internet-management/internet-management.module.js"; import { CallHistoryModule } from "./call-history/call-history.module.js"; @@ -25,7 +24,6 @@ import { CancellationController } from "./cancellation/cancellation.controller.j WhmcsModule, MappingsModule, FreebitModule, - EmailModule, SimManagementModule, InternetManagementModule, CallHistoryModule, diff --git a/apps/portal/src/app/(public)/(site)/services/page.tsx b/apps/portal/src/app/(public)/(site)/services/page.tsx index 3c4d2bde..b90594d8 100644 --- a/apps/portal/src/app/(public)/(site)/services/page.tsx +++ b/apps/portal/src/app/(public)/(site)/services/page.tsx @@ -12,102 +12,43 @@ import { Wrench, Tv, } from "lucide-react"; -import { cn } from "@/shared/utils"; - -interface ServiceCardProps { - href: string; - icon: React.ReactNode; - title: string; - description: string; - price?: string; - badge?: string; - accentColor?: "blue" | "green" | "purple" | "orange" | "cyan" | "pink"; -} - -function ServiceCard({ - href, - icon, - title, - description, - price, - badge, - accentColor = "blue", -}: ServiceCardProps) { - const accentStyles = { - blue: "bg-blue-500/10 text-blue-600 dark:text-blue-400 border-blue-500/20", - green: "bg-green-500/10 text-green-600 dark:text-green-400 border-green-500/20", - purple: "bg-purple-500/10 text-purple-600 dark:text-purple-400 border-purple-500/20", - orange: "bg-orange-500/10 text-orange-600 dark:text-orange-400 border-orange-500/20", - cyan: "bg-cyan-500/10 text-cyan-600 dark:text-cyan-400 border-cyan-500/20", - pink: "bg-pink-500/10 text-pink-600 dark:text-pink-400 border-pink-500/20", - }; - - return ( - -
- {badge && ( - - {badge} - - )} - -
-
- {icon} -
-
-

{title}

- {price && ( - - From {price} - - )} -
-
- -

{description}

- -
- Learn more - -
-
- - ); -} +import { ServiceCard } from "@/components/molecules/ServiceCard"; export default function ServicesPage() { return (
{/* Hero */}
- - - Full English Support - +
+ + + Full English Support + +
-

+

Our Services

-

+

Connectivity and support solutions for Japan's international community.

{/* Value Props - Compact */} -
+
One provider, all services @@ -123,7 +64,10 @@ export default function ServicesPage() {
{/* All Services - Clean Grid */} -
+
} @@ -178,7 +122,10 @@ export default function ServicesPage() {
{/* CTA */} -
+

Need help choosing?

Our bilingual team can help you find the right solution. @@ -195,8 +142,9 @@ export default function ServicesPage() { - +

diff --git a/apps/portal/src/components/molecules/ServiceCard/ServiceCard.tsx b/apps/portal/src/components/molecules/ServiceCard/ServiceCard.tsx new file mode 100644 index 00000000..0016a84d --- /dev/null +++ b/apps/portal/src/components/molecules/ServiceCard/ServiceCard.tsx @@ -0,0 +1,515 @@ +import Link from "next/link"; +import { cn } from "@/shared/utils"; +import { ArrowRight } from "lucide-react"; +import type { ReactNode } from "react"; +import React from "react"; + +/** + * Accent color options for ServiceCard + */ +export type ServiceCardAccentColor = + | "blue" + | "green" + | "purple" + | "orange" + | "cyan" + | "pink" + | "amber" + | "rose"; + +/** + * Variant options for ServiceCard + * - default: Standard card with icon, title, description, price + * - featured: Premium styling with gradient accents and more lift + * - minimal: Compact icon + title only (no description) + * - bento-sm: Small bento grid card + * - bento-md: Medium bento grid card + * - bento-lg: Large bento grid card with decorative elements + */ +export type ServiceCardVariant = + | "default" + | "featured" + | "minimal" + | "bento-sm" + | "bento-md" + | "bento-lg"; + +export interface ServiceCardProps { + /** Link destination */ + href: string; + /** + * Icon element to display. + * Pass a pre-styled JSX element: `icon={}` + * Or a component reference (className will be applied): `icon={Wifi}` + */ + icon: ReactNode; + /** Card title */ + title: string; + /** Card description (optional for minimal variant) */ + description?: string; + /** Starting price text e.g. "¥3,200/mo" */ + price?: string; + /** Badge text shown at top-right e.g. "1st month free" */ + badge?: string; + /** Highlight text shown as a pill (alternative to badge) */ + highlight?: string; + /** Accent color for icon background */ + accentColor?: ServiceCardAccentColor; + /** Card variant */ + variant?: ServiceCardVariant; + /** Additional CSS classes */ + className?: string; +} + +/** + * Accent color styles mapping + * All classes must be complete strings (no dynamic interpolation) for Tailwind JIT + */ +const accentColorStyles: Record< + ServiceCardAccentColor, + { + bg: string; + text: string; + border: string; + gradient: string; + hoverBorder: string; + cardBg: string; + } +> = { + blue: { + bg: "bg-blue-500/10", + text: "text-blue-600 dark:text-blue-400", + border: "border-blue-500/20", + gradient: "from-blue-500/30 to-transparent", + hoverBorder: "hover:border-blue-500/40", + cardBg: "from-blue-500/10 via-card to-card", + }, + green: { + bg: "bg-green-500/10", + text: "text-green-600 dark:text-green-400", + border: "border-green-500/20", + gradient: "from-green-500/30 to-transparent", + hoverBorder: "hover:border-green-500/40", + cardBg: "from-green-500/10 via-card to-card", + }, + purple: { + bg: "bg-purple-500/10", + text: "text-purple-600 dark:text-purple-400", + border: "border-purple-500/20", + gradient: "from-purple-500/30 to-transparent", + hoverBorder: "hover:border-purple-500/40", + cardBg: "from-purple-500/10 via-card to-card", + }, + orange: { + bg: "bg-orange-500/10", + text: "text-orange-600 dark:text-orange-400", + border: "border-orange-500/20", + gradient: "from-orange-500/30 to-transparent", + hoverBorder: "hover:border-orange-500/40", + cardBg: "from-orange-500/10 via-card to-card", + }, + cyan: { + bg: "bg-cyan-500/10", + text: "text-cyan-600 dark:text-cyan-400", + border: "border-cyan-500/20", + gradient: "from-cyan-500/30 to-transparent", + hoverBorder: "hover:border-cyan-500/40", + cardBg: "from-cyan-500/10 via-card to-card", + }, + pink: { + bg: "bg-pink-500/10", + text: "text-pink-600 dark:text-pink-400", + border: "border-pink-500/20", + gradient: "from-pink-500/30 to-transparent", + hoverBorder: "hover:border-pink-500/40", + cardBg: "from-pink-500/10 via-card to-card", + }, + amber: { + bg: "bg-amber-500/10", + text: "text-amber-600 dark:text-amber-400", + border: "border-amber-500/20", + gradient: "from-amber-500/30 to-transparent", + hoverBorder: "hover:border-amber-500/40", + cardBg: "from-amber-500/10 via-card to-card", + }, + rose: { + bg: "bg-rose-500/10", + text: "text-rose-600 dark:text-rose-400", + border: "border-rose-500/20", + gradient: "from-rose-500/30 to-transparent", + hoverBorder: "hover:border-rose-500/40", + cardBg: "from-rose-500/10 via-card to-card", + }, +}; + +/** + * Render icon - handles both component references and pre-styled JSX elements + * @param icon - Either a component reference (function) or a ReactNode (JSX element) + * @param className - Applied only when icon is a component reference + */ +function renderIcon(icon: ReactNode, className: string): ReactNode { + // If icon is a component reference (function), instantiate it with className + if (typeof icon === "function") { + const IconComponent = icon as React.ComponentType<{ className?: string }>; + return ; + } + // Otherwise, return the pre-styled JSX element as-is + return icon; +} + +/** + * Default variant - Standard service card + */ +function DefaultCard({ + href, + icon, + title, + description, + price, + badge, + accentColor = "blue", + className, +}: ServiceCardProps) { + const colors = accentColorStyles[accentColor]; + + return ( + +
+ {badge && ( + + {badge} + + )} + +
+
+ {renderIcon(icon, "h-6 w-6")} +
+
+

{title}

+ {price && ( + + From {price} + + )} +
+
+ + {description && ( +

{description}

+ )} + +
+ Learn more + +
+
+ + ); +} + +/** + * Featured variant - Premium styling with enhanced shadows + */ +function FeaturedCard({ href, icon, title, description, highlight, className }: ServiceCardProps) { + return ( + +
+ {/* Icon with solid primary background */} +
+ {renderIcon(icon, "h-6 w-6 text-primary-foreground")} +
+ + {/* Content */} +

{title}

+ {description && ( +

{description}

+ )} + + {/* Highlight badge */} + {highlight && ( + + {highlight} + + )} + + {/* Link indicator */} +
+ Learn more + +
+
+ + ); +} + +/** + * Minimal variant - Compact icon + title only + */ +function MinimalCard({ href, icon, title, className }: ServiceCardProps) { + return ( + +
+
+ {renderIcon(icon, "h-6 w-6 text-primary")} +
+

{title}

+
+ + ); +} + +/** + * Bento Small variant - Compact inline card + */ +function BentoSmallCard({ href, icon, title, accentColor = "blue", className }: ServiceCardProps) { + const colors = accentColorStyles[accentColor]; + + return ( + +
+
+ {renderIcon(icon, "h-5 w-5")} +
+ {title} +
+ + ); +} + +/** + * Bento Medium variant - Standard bento card + */ +function BentoMediumCard({ + href, + icon, + title, + description, + accentColor = "blue", + className, +}: ServiceCardProps) { + const colors = accentColorStyles[accentColor]; + + return ( + +
+ {renderIcon(icon, "h-6 w-6")} +
+ +

{title}

+ {description && ( +

{description}

+ )} + + ); +} + +/** + * Bento Large variant - Hero-style bento card with decorative elements + */ +function BentoLargeCard({ + href, + icon, + title, + description, + accentColor = "blue", + className, +}: ServiceCardProps) { + const colors = accentColorStyles[accentColor]; + + return ( + + {/* Decorative gradient orb */} +