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:
@@ -13,7 +13,13 @@ import {
|
||||
RepairServiceTemplateCreateSchema,
|
||||
RepairServiceTemplateUpdateSchema,
|
||||
} from '@lunarfront/shared/schemas'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { z } from 'zod'
|
||||
import { RepairTicketService, RepairLineItemService, RepairBatchService, RepairNoteService, RepairServiceTemplateService } from '../../services/repair.service.js'
|
||||
import { accounts } from '../../db/schema/accounts.js'
|
||||
import { companies } from '../../db/schema/stores.js'
|
||||
import { EmailService } from '../../services/email.service.js'
|
||||
import { renderEstimateEmailHtml, renderEstimateEmailText } from '../../utils/email-templates.js'
|
||||
|
||||
export const repairRoutes: FastifyPluginAsync = async (app) => {
|
||||
// --- Repair Tickets ---
|
||||
@@ -59,7 +65,14 @@ export const repairRoutes: FastifyPluginAsync = async (app) => {
|
||||
const { id } = request.params as { id: string }
|
||||
const ticket = await RepairTicketService.getById(app.db, id)
|
||||
if (!ticket) return reply.status(404).send({ error: { message: 'Repair ticket not found', statusCode: 404 } })
|
||||
return reply.send(ticket)
|
||||
|
||||
let customerEmail: string | null = null
|
||||
if (ticket.accountId) {
|
||||
const [acct] = await app.db.select({ email: accounts.email }).from(accounts).where(eq(accounts.id, ticket.accountId)).limit(1)
|
||||
customerEmail = acct?.email ?? null
|
||||
}
|
||||
|
||||
return reply.send({ ...ticket, customerEmail })
|
||||
})
|
||||
|
||||
app.patch('/repair-tickets/:id', { preHandler: [app.authenticate, app.requirePermission('repairs.edit')] }, async (request, reply) => {
|
||||
@@ -84,6 +97,42 @@ export const repairRoutes: FastifyPluginAsync = async (app) => {
|
||||
return reply.send(ticket)
|
||||
})
|
||||
|
||||
app.post('/repair-tickets/:id/email-estimate', { preHandler: [app.authenticate, app.requirePermission('repairs.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 ticket per hour
|
||||
const rateKey = `email-estimate:${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 estimate. Try again later.', statusCode: 429 } })
|
||||
}
|
||||
|
||||
const ticket = await RepairTicketService.getById(app.db, id)
|
||||
if (!ticket) return reply.status(404).send({ error: { message: 'Repair ticket not found', statusCode: 404 } })
|
||||
|
||||
const lineItemsResult = await RepairLineItemService.listByTicket(app.db, id, { page: 1, limit: 500 })
|
||||
const [company] = await app.db.select().from(companies).limit(1)
|
||||
if (!company) return reply.status(500).send({ error: { message: 'Store not configured', statusCode: 500 } })
|
||||
|
||||
const html = renderEstimateEmailHtml(ticket as any, lineItemsResult.data as any[], { name: company.name, phone: company.phone, email: company.email, address: company.address as any })
|
||||
const text = renderEstimateEmailText(ticket as any, lineItemsResult.data as any[], { name: company.name, phone: company.phone, email: company.email, address: company.address as any })
|
||||
|
||||
await EmailService.send(app.db, {
|
||||
to: parsed.data.email,
|
||||
subject: `Repair Estimate — Ticket #${ticket.ticketNumber}`,
|
||||
html,
|
||||
text,
|
||||
})
|
||||
|
||||
request.log.info({ ticketId: id, email: parsed.data.email, userId: request.user.id }, 'Estimate email sent')
|
||||
return reply.send({ message: 'Estimate sent', sentTo: parsed.data.email })
|
||||
})
|
||||
|
||||
app.delete('/repair-tickets/:id', { preHandler: [app.authenticate, app.requirePermission('repairs.admin')] }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string }
|
||||
const ticket = await RepairTicketService.delete(app.db, id)
|
||||
|
||||
Reference in New Issue
Block a user