feat: email receipts and repair estimates
Backend: - Server-side HTML email templates (receipt + estimate) with inline CSS - POST /v1/transactions/:id/email-receipt with per-transaction rate limiting - POST /v1/repair-tickets/:id/email-estimate with per-ticket rate limiting - customerEmail field added to receipt and ticket detail responses - Test email provider for API tests (logs instead of sending) Frontend: - POS payment dialog Email button enabled with inline email input - Pre-fills customer email from linked account - Repair ticket detail page has Email Estimate button with dialog - Pre-fills from account email Tests: - 12 unit tests for email template renderers - 8 API tests for email receipt/estimate endpoints and validation Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -7,7 +7,11 @@ import {
|
||||
ApplyDiscountSchema,
|
||||
CompleteTransactionSchema,
|
||||
} from '@lunarfront/shared/schemas'
|
||||
import { inArray } from 'drizzle-orm'
|
||||
import { TransactionService } from '../../services/transaction.service.js'
|
||||
import { appConfig } from '../../db/schema/stores.js'
|
||||
import { EmailService } from '../../services/email.service.js'
|
||||
import { renderReceiptEmailHtml, renderReceiptEmailText } from '../../utils/email-templates.js'
|
||||
|
||||
const FromRepairBodySchema = z.object({
|
||||
locationId: z.string().uuid().optional(),
|
||||
@@ -64,6 +68,45 @@ export const transactionRoutes: FastifyPluginAsync = async (app) => {
|
||||
return reply.send(receipt)
|
||||
})
|
||||
|
||||
app.post('/transactions/:id/email-receipt', { preHandler: [app.authenticate, app.requirePermission('pos.view')] }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string }
|
||||
const parsed = z.object({ email: z.string().email() }).safeParse(request.body)
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({ error: { message: 'Valid email is required', statusCode: 400 } })
|
||||
}
|
||||
|
||||
// Rate limit: max 5 emails per transaction per hour
|
||||
const rateKey = `email-receipt:${id}`
|
||||
const count = await app.redis.incr(rateKey)
|
||||
if (count === 1) await app.redis.expire(rateKey, 3600)
|
||||
if (count > 5) {
|
||||
return reply.status(429).send({ error: { message: 'Too many emails for this receipt. Try again later.', statusCode: 429 } })
|
||||
}
|
||||
|
||||
const receipt = await TransactionService.getReceipt(app.db, id)
|
||||
const storeName = receipt.company?.name ?? 'Store'
|
||||
|
||||
// Fetch receipt config
|
||||
const configRows = await app.db.select({ key: appConfig.key, value: appConfig.value })
|
||||
.from(appConfig)
|
||||
.where(inArray(appConfig.key, ['receipt_header', 'receipt_footer', 'receipt_return_policy', 'receipt_social']))
|
||||
const config: Record<string, string> = {}
|
||||
for (const row of configRows) if (row.value) config[row.key] = row.value
|
||||
|
||||
const html = renderReceiptEmailHtml(receipt as any, config)
|
||||
const text = renderReceiptEmailText(receipt as any, config)
|
||||
|
||||
await EmailService.send(app.db, {
|
||||
to: parsed.data.email,
|
||||
subject: `Your receipt from ${storeName} — ${receipt.transaction.transactionNumber}`,
|
||||
html,
|
||||
text,
|
||||
})
|
||||
|
||||
request.log.info({ transactionId: id, email: parsed.data.email, userId: request.user.id }, 'Receipt email sent')
|
||||
return reply.send({ message: 'Receipt sent', sentTo: parsed.data.email })
|
||||
})
|
||||
|
||||
app.post('/transactions/:id/line-items', { preHandler: [app.authenticate, app.requirePermission('pos.edit')] }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string }
|
||||
const parsed = TransactionLineItemCreateSchema.safeParse(request.body)
|
||||
|
||||
Reference in New Issue
Block a user