diff --git a/backend/Dockerfile b/backend/Dockerfile index 833ecd2..5362640 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -9,16 +9,21 @@ RUN apk add --no-cache dumb-init git curl # Set working directory WORKDIR /app -# Copy package files -COPY package*.json ./ +# Copy package files from backend directory +COPY backend/package*.json ./ # Install all dependencies (including dev for building) RUN npm install && npm cache clean --force -# Copy source code -COPY . . +# Copy logo from frontend for email templates (needed for build) +RUN mkdir -p frontend/public/images/logos +COPY frontend/public/images/logos/motovaultpro-logo-title.png frontend/public/images/logos/ -# Build the application +# Copy backend source code +COPY backend/ . + +# Build the application (prebuild will encode logo) +ENV DOCKER_BUILD=true RUN npm run build # Stage 2: Production runtime @@ -31,7 +36,7 @@ RUN apk add --no-cache dumb-init curl postgresql-client WORKDIR /app # Copy package files and any lock file generated in builder stage -COPY package*.json ./ +COPY backend/package*.json ./ COPY --from=builder /app/package-lock.json ./ # Install only production dependencies @@ -52,7 +57,7 @@ COPY --from=builder /app/src/features /app/migrations/features COPY --from=builder /app/src/core /app/migrations/core # Copy entrypoint script for permission checks -COPY scripts/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh +COPY backend/scripts/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh RUN chmod 755 /usr/local/bin/docker-entrypoint.sh # Change ownership to non-root user diff --git a/backend/src/features/notifications/domain/email-layout/base-layout.ts b/backend/src/features/notifications/domain/email-layout/base-layout.ts new file mode 100644 index 0000000..2322cb6 --- /dev/null +++ b/backend/src/features/notifications/domain/email-layout/base-layout.ts @@ -0,0 +1,85 @@ +/** + * Base HTML Email Layout + * @ai-summary Main email wrapper with MotoVaultPro branding + * @ai-context Uses table-based layout for email client compatibility + */ + +import { EMAIL_STYLES } from './email-styles'; + +// External logo URL - hosted on GitHub for reliability +const LOGO_URL = 'https://raw.githubusercontent.com/ericgullickson/images/c58b0e4773e8395b532f97f6ab529e38ea4dc8be/motovaultpro-auth0-small.png'; + +/** + * Renders the complete HTML email layout with branding + * @param content - The rendered email body content (HTML) + * @returns Complete HTML email string with DOCTYPE and layout + */ +export function renderEmailLayout(content: string): string { + return ` + + + + + + + MotoVaultPro + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + +
+ +`; +} diff --git a/backend/src/features/notifications/domain/email-layout/email-styles.ts b/backend/src/features/notifications/domain/email-layout/email-styles.ts new file mode 100644 index 0000000..17e3068 --- /dev/null +++ b/backend/src/features/notifications/domain/email-layout/email-styles.ts @@ -0,0 +1,39 @@ +/** + * Email Template Inline Styles + * @ai-summary Reusable inline CSS constants for email templates + * @ai-context Email clients require inline styles for proper rendering + */ + +export const EMAIL_STYLES = { + // Layout containers + wrapper: 'width: 100%; background-color: #f8f9fa; padding: 20px 0;', + container: 'max-width: 600px; margin: 0 auto; background-color: #ffffff;', + innerContainer: 'width: 100%;', + + // Header + header: 'background-color: #7A212A; padding: 30px 20px; text-align: center;', + logo: 'max-width: 280px; height: auto; display: block; margin: 0 auto;', + + // Content area + content: 'padding: 40px 30px; font-family: Arial, Helvetica, sans-serif; color: #1e293b; line-height: 1.6; font-size: 16px;', + + // Typography + heading: 'color: #7A212A; font-size: 24px; font-weight: bold; margin: 0 0 20px 0; font-family: Arial, Helvetica, sans-serif;', + subheading: 'color: #1e293b; font-size: 18px; font-weight: bold; margin: 0 0 16px 0; font-family: Arial, Helvetica, sans-serif;', + paragraph: 'margin: 0 0 16px 0; font-size: 16px; color: #1e293b; font-family: Arial, Helvetica, sans-serif; line-height: 1.6;', + strong: 'font-weight: bold; color: #7A212A;', + + // Footer + footer: 'background-color: #f1f5f9; padding: 30px 20px; text-align: center; border-top: 2px solid #7A212A;', + footerText: 'font-size: 14px; color: #64748b; margin: 8px 0; font-family: Arial, Helvetica, sans-serif;', + footerLink: 'color: #7A212A; text-decoration: none; font-weight: bold;', + footerLinkHover: 'color: #9c2a36; text-decoration: underline;', + copyright: 'font-size: 12px; color: #94a3b8; margin: 16px 0 0 0; font-family: Arial, Helvetica, sans-serif;', + + // Divider + divider: 'border: 0; border-top: 1px solid #e2e8f0; margin: 20px 0;', + + // Table cells + cell: 'padding: 0;', + cellWithPadding: 'padding: 20px;', +} as const; diff --git a/backend/src/features/notifications/domain/email.service.ts b/backend/src/features/notifications/domain/email.service.ts index 2610bdf..31b1a22 100644 --- a/backend/src/features/notifications/domain/email.service.ts +++ b/backend/src/features/notifications/domain/email.service.ts @@ -23,7 +23,7 @@ export class EmailService { * Send an email using Resend * @param to Recipient email address * @param subject Email subject line - * @param html Email body (HTML format) + * @param html Email body (HTML format with inline styles for email client compatibility) * @returns Promise that resolves when email is sent */ async send(to: string, subject: string, html: string): Promise { @@ -39,16 +39,4 @@ export class EmailService { throw new Error(`Failed to send email: ${errorMessage}`); } } - - /** - * Send an email with plain text body (converted to HTML) - * @param to Recipient email address - * @param subject Email subject line - * @param text Email body (plain text) - */ - async sendText(to: string, subject: string, text: string): Promise { - // Convert plain text to HTML with proper line breaks - const html = text.split('\n').map(line => `

${line}

`).join(''); - await this.send(to, subject, html); - } } diff --git a/backend/src/features/notifications/domain/notifications.service.ts b/backend/src/features/notifications/domain/notifications.service.ts index 5ccfbe9..550a162 100644 --- a/backend/src/features/notifications/domain/notifications.service.ts +++ b/backend/src/features/notifications/domain/notifications.service.ts @@ -94,10 +94,15 @@ export class NotificationsService { subject: string, body: string, variables: Record - ): Promise<{ subject: string; body: string }> { + ): Promise<{ subject: string; body: string; html: string }> { + const renderedSubject = this.templateService.render(subject, variables); + const renderedBody = this.templateService.render(body, variables); + const renderedHtml = this.templateService.renderEmailHtml(body, variables); + return { - subject: this.templateService.render(subject, variables), - body: this.templateService.render(body, variables), + subject: renderedSubject, + body: renderedBody, + html: renderedHtml, }; } @@ -130,10 +135,10 @@ export class NotificationsService { }; const subject = this.templateService.render(template.subject, variables); - const body = this.templateService.render(template.body, variables); + const htmlBody = this.templateService.renderEmailHtml(template.body, variables); try { - await this.emailService.sendText(userEmail, subject, body); + await this.emailService.send(userEmail, subject, htmlBody); await this.repository.insertNotificationLog({ user_id: userId, @@ -188,10 +193,10 @@ export class NotificationsService { }; const subject = this.templateService.render(template.subject, variables); - const body = this.templateService.render(template.body, variables); + const htmlBody = this.templateService.renderEmailHtml(template.body, variables); try { - await this.emailService.sendText(userEmail, subject, body); + await this.emailService.send(userEmail, subject, htmlBody); await this.repository.insertNotificationLog({ user_id: userId, @@ -249,9 +254,10 @@ export class NotificationsService { const subject = this.templateService.render(template.subject, sampleVariables); const body = this.templateService.render(template.body, sampleVariables); + const htmlBody = this.templateService.renderEmailHtml(template.body, sampleVariables); try { - await this.emailService.sendText(recipientEmail, `[TEST] ${subject}`, body); + await this.emailService.send(recipientEmail, `[TEST] ${subject}`, htmlBody); return { subject, diff --git a/backend/src/features/notifications/domain/template.service.ts b/backend/src/features/notifications/domain/template.service.ts index 68dd16f..dbc11ce 100644 --- a/backend/src/features/notifications/domain/template.service.ts +++ b/backend/src/features/notifications/domain/template.service.ts @@ -3,6 +3,9 @@ * @ai-context Replaces {{variableName}} with values */ +import { renderEmailLayout } from './email-layout/base-layout'; +import { EMAIL_STYLES } from './email-layout/email-styles'; + export class TemplateService { /** * Render a template string by replacing {{variableName}} with values @@ -22,6 +25,38 @@ export class TemplateService { return result; } + /** + * Render a template as HTML email with branded layout + * @param template Template string with {{variable}} placeholders + * @param variables Object mapping variable names to values + * @returns Complete HTML email string with layout wrapper + */ + renderEmailHtml(template: string, variables: Record): string { + // 1. Replace variables in template body + const renderedContent = this.render(template, variables); + + // 2. Escape HTML special characters to prevent XSS + const escapeHtml = (text: string): string => { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + }; + + // 3. Convert plain text line breaks to HTML paragraphs + const htmlContent = renderedContent + .split('\n') + .map(line => line.trim()) + .filter(line => line.length > 0) + .map(line => `

${escapeHtml(line)}

`) + .join('\n'); + + // 4. Wrap in branded email layout + return renderEmailLayout(htmlContent); + } + /** * Extract variable names from a template string * @param template Template string with {{variable}} placeholders diff --git a/backend/src/features/notifications/migrations/005_update_email_templates_html.sql b/backend/src/features/notifications/migrations/005_update_email_templates_html.sql new file mode 100644 index 0000000..4688e8c --- /dev/null +++ b/backend/src/features/notifications/migrations/005_update_email_templates_html.sql @@ -0,0 +1,16 @@ +/** + * Migration: Add HTML body column to email templates + * @ai-summary Non-breaking migration for future HTML template support + * @ai-context Existing plain text templates auto-convert to HTML + */ + +-- Add optional html_body column for custom HTML templates (future enhancement) +ALTER TABLE email_templates +ADD COLUMN html_body TEXT DEFAULT NULL; + +-- Add comment explaining the column purpose +COMMENT ON COLUMN email_templates.html_body IS + 'Optional custom HTML body. If NULL, the plain text body will be auto-converted to HTML with base layout.'; + +-- No data updates needed - existing templates continue to work +-- The system will auto-convert plain text body to HTML using renderEmailHtml() diff --git a/docker-compose.yml b/docker-compose.yml index d2d0121..b3ce799 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -86,8 +86,8 @@ services: # Application Services - Backend API mvp-backend: build: - context: ./backend - dockerfile: Dockerfile + context: . + dockerfile: backend/Dockerfile cache_from: - node:lts-alpine container_name: mvp-backend diff --git a/docs/PROMPTS.md b/docs/PROMPTS.md index 01dc660..5594580 100644 --- a/docs/PROMPTS.md +++ b/docs/PROMPTS.md @@ -48,16 +48,19 @@ You are a senior software engineer specializsing in NodeJS, Typescript, front en - Make no assumptions. - Ask clarifying questions. - Ultrathink -- The initial data load for this applicaiton during the CI/CD process in gitlab needs to be updated +- This application is ready to go into production. +- Analysis needs to be done on the CI/CD pipeline *** CONTEXT *** - Read README.md CLAUDE.md and AI-INDEX.md and follow relevant instructions to understand this code repository in the context of this change. -- The current deployment database load needs to be thoroughly critiqued and scrutenized. +- The current deployment does not take into account no downtime or miniimal downtime updates. +- The same runner's build the software that run the software +- There needs to be a balance of uptime and complexity +- production will run on a single server to start *** ACTION - CHANGES TO IMPLEMENT *** -- The vehicle catalog currently loaded into the local mvp-postres container needs to be exported into a SQL file and saved as a source of truth -- Whatever process is running today only goes up to model year 2022. -- All the existing SQL files setup for import can be replaced with new ones created from the running mvp-postres data. +- Research this code base and ask iterative questions to compile a complete plan. +- We will pair plan this. Ask me for options for various levels of redundancy and automation @@ -100,14 +103,17 @@ You are a senior software engineer specializsing in NodeJS, Typescript, front en - Make no assumptions. - Ask clarifying questions. - Ultrathink -- You will be making changes to the color theme of this application. +- You will be making changes to email templates of this application. *** CONTEXT *** - This is a modern web app for managing a vehicle fleet. It has both a desktop and mobile versions of the site that both need to maintain feature parity. It's currently deployed via docker compose but in the future will be deployed via k8s. - Read README.md CLAUDE.md and AI-INDEX.md and follow relevant instructions to understand this code repository in the context of this change. -- Currently the onboarding drop downs are washed out when using the light theme. See image. -- The colors need to change to have more contrast but retain the MUI theme for drop down. +- Start your research at this route https://motovaultpro.com/garage/settings/admin/email-templates +- The email templates are currently plain text. +- The templates need to be improved with colors and the company logo +- The company log should be base64 encoded in the email so end users don't need to download anything. +- The theme should match the website light theme +- A screenshot showing the colors is attached *** CHANGES TO IMPLEMENT *** -- Research this code base and ask iterative questions to compile a complete plan. -- The URL is here. https://motovaultpro.com/onboarding \ No newline at end of file +- Research this code base and ask iterative questions to compile a complete plan. \ No newline at end of file diff --git a/frontend/src/features/admin/api/admin.api.ts b/frontend/src/features/admin/api/admin.api.ts index 4c2bd48..f68307a 100644 --- a/frontend/src/features/admin/api/admin.api.ts +++ b/frontend/src/features/admin/api/admin.api.ts @@ -30,6 +30,7 @@ import { CascadeDeleteResult, EmailTemplate, UpdateEmailTemplateRequest, + PreviewTemplateResponse, // User management types ManagedUser, ListUsersResponse, @@ -278,15 +279,15 @@ export const adminApi = { const response = await apiClient.put(`/admin/email-templates/${key}`, data); return response.data; }, - preview: async (key: string, variables: Record): Promise<{ subject: string; body: string }> => { - const response = await apiClient.post<{ subject: string; body: string }>( + preview: async (key: string, variables: Record): Promise => { + const response = await apiClient.post( `/admin/email-templates/${key}/preview`, { variables } ); return response.data; }, - sendTest: async (key: string): Promise<{ message?: string; error?: string; subject: string; body: string }> => { - const response = await apiClient.post<{ message?: string; error?: string; subject: string; body: string }>( + sendTest: async (key: string): Promise<{ message?: string; error?: string }> => { + const response = await apiClient.post<{ message?: string; error?: string }>( `/admin/email-templates/${key}/test` ); return response.data; diff --git a/frontend/src/features/admin/mobile/AdminEmailTemplatesMobileScreen.tsx b/frontend/src/features/admin/mobile/AdminEmailTemplatesMobileScreen.tsx index 94c9ba0..89a17e2 100644 --- a/frontend/src/features/admin/mobile/AdminEmailTemplatesMobileScreen.tsx +++ b/frontend/src/features/admin/mobile/AdminEmailTemplatesMobileScreen.tsx @@ -38,6 +38,8 @@ export const AdminEmailTemplatesMobileScreen: React.FC = () => { const [editIsActive, setEditIsActive] = useState(true); const [previewSubject, setPreviewSubject] = useState(''); const [previewBody, setPreviewBody] = useState(''); + const [previewHtml, setPreviewHtml] = useState(''); + const [showHtmlPreview, setShowHtmlPreview] = useState(false); // Queries const { data: templates, isLoading } = useQuery({ @@ -66,6 +68,7 @@ export const AdminEmailTemplatesMobileScreen: React.FC = () => { onSuccess: (data) => { setPreviewSubject(data.subject); setPreviewBody(data.body); + setPreviewHtml(data.html || ''); }, onError: () => { toast.error('Failed to generate preview'); @@ -117,6 +120,8 @@ export const AdminEmailTemplatesMobileScreen: React.FC = () => { setPreviewTemplate(null); setPreviewSubject(''); setPreviewBody(''); + setPreviewHtml(''); + setShowHtmlPreview(false); }, []); const handleSave = useCallback(() => { @@ -304,6 +309,23 @@ export const AdminEmailTemplatesMobileScreen: React.FC = () => { ) : ( <> + {/* Toggle HTML/Text Preview */} +
+ Show HTML Preview + +
+
-
- -
- {previewBody} + {showHtmlPreview ? ( +
+ +
+