Include completion photos in task completed emails

- Add EmbeddedImage struct and SendEmailWithEmbeddedImages method for inline images
- Update SendTaskCompletedEmail to accept and display completion photos
- Read images from disk via StorageService and embed with Content-ID references
- Wire StorageService to TaskService for image access
- Photos display inline in HTML email body, works across all email clients

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Trey t
2025-12-17 15:27:45 -06:00
parent b62da4e303
commit 7a57a902bb
3 changed files with 177 additions and 7 deletions

View File

@@ -3,6 +3,10 @@ package services
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/rs/zerolog/log"
@@ -32,6 +36,7 @@ type TaskService struct {
residenceService *ResidenceService
notificationService *NotificationService
emailService *EmailService
storageService *StorageService
}
// NewTaskService creates a new task service
@@ -57,6 +62,11 @@ func (s *TaskService) SetResidenceService(rs *ResidenceService) {
s.residenceService = rs
}
// SetStorageService sets the storage service (for reading completion images for emails)
func (s *TaskService) SetStorageService(ss *StorageService) {
s.storageService = ss
}
// getSummaryForUser returns an empty summary placeholder.
// DEPRECATED: Summary calculation has been removed from CRUD responses for performance.
// Clients should calculate summary from kanban data instead (which already includes all tasks).
@@ -721,6 +731,12 @@ func (s *TaskService) sendTaskCompletedNotification(task *models.Task, completio
completedByName = completion.CompletedBy.GetFullName()
}
// Load completion images for email (only if storage service is available)
var emailImages []EmbeddedImage
if s.storageService != nil && len(completion.Images) > 0 {
emailImages = s.loadCompletionImagesForEmail(completion.Images)
}
// Notify all users
for _, user := range users {
isCompleter := user.ID == completion.CompletedByID
@@ -746,24 +762,96 @@ func (s *TaskService) sendTaskCompletedNotification(task *models.Task, completio
prefs, err := s.notificationService.GetPreferences(user.ID)
if err != nil || (prefs != nil && prefs.EmailTaskCompleted) {
// Send email if we couldn't get prefs (fail-open) or if email notifications are enabled
go func(u models.User) {
go func(u models.User, images []EmbeddedImage) {
if err := s.emailService.SendTaskCompletedEmail(
u.Email,
u.GetFullName(),
task.Title,
completedByName,
residenceName,
images,
); err != nil {
log.Error().Err(err).Str("email", u.Email).Uint("task_id", task.ID).Msg("Failed to send task completion email")
} else {
log.Info().Str("email", u.Email).Uint("task_id", task.ID).Msg("Task completion email sent")
log.Info().Str("email", u.Email).Uint("task_id", task.ID).Int("images", len(images)).Msg("Task completion email sent")
}
}(user)
}(user, emailImages)
}
}
}
}
// loadCompletionImagesForEmail reads completion images from disk and prepares them for email embedding
func (s *TaskService) loadCompletionImagesForEmail(images []models.TaskCompletionImage) []EmbeddedImage {
var emailImages []EmbeddedImage
uploadDir := s.storageService.GetUploadDir()
for i, img := range images {
// Resolve file path from stored URL
filePath := s.resolveImageFilePath(img.ImageURL, uploadDir)
if filePath == "" {
log.Warn().Str("image_url", img.ImageURL).Msg("Could not resolve image file path")
continue
}
// Read file from disk
data, err := os.ReadFile(filePath)
if err != nil {
log.Warn().Err(err).Str("path", filePath).Msg("Failed to read completion image for email")
continue
}
// Determine content type from extension
contentType := s.getContentTypeFromPath(filePath)
// Create embedded image with unique Content-ID
emailImages = append(emailImages, EmbeddedImage{
ContentID: fmt.Sprintf("completion-image-%d", i+1),
Filename: filepath.Base(filePath),
ContentType: contentType,
Data: data,
})
log.Debug().Str("path", filePath).Int("size", len(data)).Msg("Loaded completion image for email")
}
return emailImages
}
// resolveImageFilePath converts a stored URL to an actual file path
func (s *TaskService) resolveImageFilePath(storedURL, uploadDir string) string {
if storedURL == "" {
return ""
}
// Handle /uploads/... URLs
if strings.HasPrefix(storedURL, "/uploads/") {
relativePath := strings.TrimPrefix(storedURL, "/uploads/")
return filepath.Join(uploadDir, relativePath)
}
// Handle relative paths
return filepath.Join(uploadDir, storedURL)
}
// getContentTypeFromPath returns the MIME type based on file extension
func (s *TaskService) getContentTypeFromPath(path string) string {
ext := strings.ToLower(filepath.Ext(path))
switch ext {
case ".jpg", ".jpeg":
return "image/jpeg"
case ".png":
return "image/png"
case ".gif":
return "image/gif"
case ".webp":
return "image/webp"
default:
return "application/octet-stream"
}
}
// GetCompletion gets a task completion by ID
func (s *TaskService) GetCompletion(completionID, userID uint) (*responses.TaskCompletionResponse, error) {
completion, err := s.taskRepo.FindCompletionByID(completionID)