From 0a708c092d96cc446174984813473b3872c93bca Mon Sep 17 00:00:00 2001 From: Trey t Date: Mon, 1 Dec 2025 17:42:37 -0600 Subject: [PATCH] Add Apple Sign In welcome email, notification preferences on registration, and contractor sharing tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Send welcome email to new users who sign up via Apple Sign In - Create notification preferences (all enabled) when new accounts are created - Add comprehensive integration tests for contractor sharing: - Personal contractors only visible to creator - Residence-tied contractors visible to all users with residence access - Update/delete access control for shared contractors 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- internal/handlers/auth_handler.go | 9 + .../integration/contractor_sharing_test.go | 310 ++++++++++++++++++ internal/integration/integration_test.go | 120 ++++++- internal/router/router.go | 1 + internal/services/auth_service.go | 26 +- internal/services/contractor_service.go | 2 + internal/services/email_service.go | 70 ++++ 7 files changed, 527 insertions(+), 11 deletions(-) create mode 100644 internal/integration/contractor_sharing_test.go diff --git a/internal/handlers/auth_handler.go b/internal/handlers/auth_handler.go index 6497905..9c2f8fa 100644 --- a/internal/handlers/auth_handler.go +++ b/internal/handlers/auth_handler.go @@ -406,5 +406,14 @@ func (h *AuthHandler) AppleSignIn(c *gin.Context) { return } + // Send welcome email for new users (async) + if response.IsNewUser && h.emailService != nil && response.User.Email != "" { + go func() { + if err := h.emailService.SendAppleWelcomeEmail(response.User.Email, response.User.FirstName); err != nil { + log.Error().Err(err).Str("email", response.User.Email).Msg("Failed to send Apple welcome email") + } + }() + } + c.JSON(http.StatusOK, response) } diff --git a/internal/integration/contractor_sharing_test.go b/internal/integration/contractor_sharing_test.go new file mode 100644 index 0000000..7d989d8 --- /dev/null +++ b/internal/integration/contractor_sharing_test.go @@ -0,0 +1,310 @@ +package integration + +import ( + "encoding/json" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestIntegration_ContractorSharingFlow tests the complete contractor sharing scenario: +// - Personal contractors are only visible to their creator +// - Residence-tied contractors are visible to all users with access to that residence +func TestIntegration_ContractorSharingFlow(t *testing.T) { + app := setupContractorTest(t) + + // ========== Setup Users ========== + // Create user A + userAToken := app.registerAndLogin(t, "userA", "userA@test.com", "password123") + + // Create user B + userBToken := app.registerAndLogin(t, "userB", "userB@test.com", "password123") + + // ========== User A creates residence C ========== + residenceBody := map[string]interface{}{ + "name": "Residence C", + } + w := app.makeAuthenticatedRequest(t, "POST", "/api/residences", residenceBody, userAToken) + require.Equal(t, http.StatusCreated, w.Code, "User A should create residence C") + + var residenceResp map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &residenceResp) + require.NoError(t, err) + residenceCID := residenceResp["id"].(float64) + + // ========== User A shares residence C with User B ========== + // Generate share code + w = app.makeAuthenticatedRequest(t, "POST", "/api/residences/"+formatID(residenceCID)+"/generate-share-code", nil, userAToken) + require.Equal(t, http.StatusOK, w.Code, "User A should generate share code") + + var shareResp map[string]interface{} + err = json.Unmarshal(w.Body.Bytes(), &shareResp) + require.NoError(t, err) + shareCodeObj := shareResp["share_code"].(map[string]interface{}) + shareCode := shareCodeObj["code"].(string) + + // User B joins with code + joinBody := map[string]interface{}{ + "code": shareCode, + } + w = app.makeAuthenticatedRequest(t, "POST", "/api/residences/join-with-code", joinBody, userBToken) + require.Equal(t, http.StatusOK, w.Code, "User B should join residence C with share code") + + // Verify User B has access to residence C + w = app.makeAuthenticatedRequest(t, "GET", "/api/residences/"+formatID(residenceCID), nil, userBToken) + assert.Equal(t, http.StatusOK, w.Code, "User B should have access to residence C") + + // ========== User A creates personal contractor D (no residence) ========== + contractorDBody := map[string]interface{}{ + "name": "Personal Contractor D", + "phone": "555-1234", + "notes": "User A's personal contractor", + } + w = app.makeAuthenticatedRequest(t, "POST", "/api/contractors", contractorDBody, userAToken) + require.Equal(t, http.StatusCreated, w.Code, "User A should create personal contractor D") + + var contractorDResp map[string]interface{} + err = json.Unmarshal(w.Body.Bytes(), &contractorDResp) + require.NoError(t, err) + contractorDID := contractorDResp["id"].(float64) + assert.Nil(t, contractorDResp["residence_id"], "Contractor D should have no residence (personal)") + + // ========== User B cannot see User A's personal contractor D ========== + w = app.makeAuthenticatedRequest(t, "GET", "/api/contractors/"+formatID(contractorDID), nil, userBToken) + assert.Equal(t, http.StatusForbidden, w.Code, "User B should NOT have access to User A's personal contractor D") + + // ========== User A creates contractor E tied to residence C ========== + contractorEBody := map[string]interface{}{ + "name": "Shared Contractor E", + "phone": "555-5678", + "residence_id": uint(residenceCID), + "notes": "Contractor for residence C", + } + w = app.makeAuthenticatedRequest(t, "POST", "/api/contractors", contractorEBody, userAToken) + require.Equal(t, http.StatusCreated, w.Code, "User A should create contractor E tied to residence C") + + var contractorEResp map[string]interface{} + err = json.Unmarshal(w.Body.Bytes(), &contractorEResp) + require.NoError(t, err) + contractorEID := contractorEResp["id"].(float64) + assert.Equal(t, residenceCID, contractorEResp["residence_id"], "Contractor E should be tied to residence C") + + // ========== User B has access to contractor E ========== + w = app.makeAuthenticatedRequest(t, "GET", "/api/contractors/"+formatID(contractorEID), nil, userBToken) + assert.Equal(t, http.StatusOK, w.Code, "User B should have access to contractor E (tied to shared residence C)") + + // ========== User B creates personal contractor (also named E for clarity - different ID) ========== + contractorBPersonalBody := map[string]interface{}{ + "name": "User B Personal Contractor", + "phone": "555-9999", + "notes": "User B's personal contractor", + } + w = app.makeAuthenticatedRequest(t, "POST", "/api/contractors", contractorBPersonalBody, userBToken) + require.Equal(t, http.StatusCreated, w.Code, "User B should create personal contractor") + + var contractorBPersonalResp map[string]interface{} + err = json.Unmarshal(w.Body.Bytes(), &contractorBPersonalResp) + require.NoError(t, err) + contractorBPersonalID := contractorBPersonalResp["id"].(float64) + assert.Nil(t, contractorBPersonalResp["residence_id"], "User B's personal contractor should have no residence") + + // ========== User A cannot see User B's personal contractor ========== + w = app.makeAuthenticatedRequest(t, "GET", "/api/contractors/"+formatID(contractorBPersonalID), nil, userAToken) + assert.Equal(t, http.StatusForbidden, w.Code, "User A should NOT have access to User B's personal contractor") + + // ========== User B creates contractor F tied to residence C ========== + contractorFBody := map[string]interface{}{ + "name": "Shared Contractor F", + "phone": "555-4321", + "residence_id": uint(residenceCID), + "notes": "Another contractor for residence C, created by User B", + } + w = app.makeAuthenticatedRequest(t, "POST", "/api/contractors", contractorFBody, userBToken) + require.Equal(t, http.StatusCreated, w.Code, "User B should create contractor F tied to residence C") + + var contractorFResp map[string]interface{} + err = json.Unmarshal(w.Body.Bytes(), &contractorFResp) + require.NoError(t, err) + contractorFID := contractorFResp["id"].(float64) + assert.Equal(t, residenceCID, contractorFResp["residence_id"], "Contractor F should be tied to residence C") + + // ========== User A has access to contractor F ========== + w = app.makeAuthenticatedRequest(t, "GET", "/api/contractors/"+formatID(contractorFID), nil, userAToken) + assert.Equal(t, http.StatusOK, w.Code, "User A should have access to contractor F (tied to shared residence C)") + + // ========== Verify both users see shared contractors E and F ========== + // User A lists contractors + w = app.makeAuthenticatedRequest(t, "GET", "/api/contractors", nil, userAToken) + require.Equal(t, http.StatusOK, w.Code) + + var userAContractors []map[string]interface{} + err = json.Unmarshal(w.Body.Bytes(), &userAContractors) + require.NoError(t, err) + + // User A should see: personal contractor D, shared contractors E and F + userAContractorIDs := extractContractorIDs(userAContractors) + assert.Contains(t, userAContractorIDs, uint(contractorDID), "User A should see personal contractor D") + assert.Contains(t, userAContractorIDs, uint(contractorEID), "User A should see shared contractor E") + assert.Contains(t, userAContractorIDs, uint(contractorFID), "User A should see shared contractor F") + assert.NotContains(t, userAContractorIDs, uint(contractorBPersonalID), "User A should NOT see User B's personal contractor") + + // User B lists contractors + w = app.makeAuthenticatedRequest(t, "GET", "/api/contractors", nil, userBToken) + require.Equal(t, http.StatusOK, w.Code) + + var userBContractors []map[string]interface{} + err = json.Unmarshal(w.Body.Bytes(), &userBContractors) + require.NoError(t, err) + + // User B should see: personal contractor, shared contractors E and F + userBContractorIDs := extractContractorIDs(userBContractors) + assert.Contains(t, userBContractorIDs, uint(contractorBPersonalID), "User B should see their personal contractor") + assert.Contains(t, userBContractorIDs, uint(contractorEID), "User B should see shared contractor E") + assert.Contains(t, userBContractorIDs, uint(contractorFID), "User B should see shared contractor F") + assert.NotContains(t, userBContractorIDs, uint(contractorDID), "User B should NOT see User A's personal contractor D") + + // ========== Verify shared contractor count ========== + // Both users should see exactly 2 shared contractors (E and F) + sharedForA := countResidenceContractors(userAContractors, residenceCID) + sharedForB := countResidenceContractors(userBContractors, residenceCID) + assert.Equal(t, 2, sharedForA, "User A should see 2 contractors tied to residence C") + assert.Equal(t, 2, sharedForB, "User B should see 2 contractors tied to residence C") +} + +// TestIntegration_ContractorAccessWithoutResidenceShare tests that +// a user without residence access cannot see residence-tied contractors +func TestIntegration_ContractorAccessWithoutResidenceShare(t *testing.T) { + app := setupContractorTest(t) + + // Create two users + userAToken := app.registerAndLogin(t, "userA", "userA@test.com", "password123") + userBToken := app.registerAndLogin(t, "userB", "userB@test.com", "password123") + + // User A creates a residence + residenceBody := map[string]interface{}{ + "name": "Private Residence", + } + w := app.makeAuthenticatedRequest(t, "POST", "/api/residences", residenceBody, userAToken) + require.Equal(t, http.StatusCreated, w.Code) + + var residenceResp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &residenceResp) + residenceID := residenceResp["id"].(float64) + + // User A creates a contractor tied to the residence (NOT shared with User B) + contractorBody := map[string]interface{}{ + "name": "Private Contractor", + "residence_id": uint(residenceID), + } + w = app.makeAuthenticatedRequest(t, "POST", "/api/contractors", contractorBody, userAToken) + require.Equal(t, http.StatusCreated, w.Code) + + var contractorResp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &contractorResp) + contractorID := contractorResp["id"].(float64) + + // User B should NOT be able to access the contractor + w = app.makeAuthenticatedRequest(t, "GET", "/api/contractors/"+formatID(contractorID), nil, userBToken) + assert.Equal(t, http.StatusForbidden, w.Code, "User B should NOT have access to contractor in unshared residence") + + // User B lists contractors - should NOT include the private contractor + w = app.makeAuthenticatedRequest(t, "GET", "/api/contractors", nil, userBToken) + require.Equal(t, http.StatusOK, w.Code) + + var userBContractors []map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &userBContractors) + + userBContractorIDs := extractContractorIDs(userBContractors) + assert.NotContains(t, userBContractorIDs, uint(contractorID), "User B should NOT see contractor from unshared residence") +} + +// TestIntegration_ContractorUpdateAndDeleteAccess tests that only users with +// residence access can update/delete residence-tied contractors +func TestIntegration_ContractorUpdateAndDeleteAccess(t *testing.T) { + app := setupContractorTest(t) + + // Create users + userAToken := app.registerAndLogin(t, "userA", "userA@test.com", "password123") + userBToken := app.registerAndLogin(t, "userB", "userB@test.com", "password123") + userCToken := app.registerAndLogin(t, "userC", "userC@test.com", "password123") + + // User A creates residence and shares with User B (not User C) + residenceBody := map[string]interface{}{"name": "Shared Residence"} + w := app.makeAuthenticatedRequest(t, "POST", "/api/residences", residenceBody, userAToken) + require.Equal(t, http.StatusCreated, w.Code) + + var residenceResp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &residenceResp) + residenceID := residenceResp["id"].(float64) + + // Share with User B + w = app.makeAuthenticatedRequest(t, "POST", "/api/residences/"+formatID(residenceID)+"/generate-share-code", nil, userAToken) + require.Equal(t, http.StatusOK, w.Code) + + var shareResp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &shareResp) + shareCodeObj := shareResp["share_code"].(map[string]interface{}) + shareCode := shareCodeObj["code"].(string) + + w = app.makeAuthenticatedRequest(t, "POST", "/api/residences/join-with-code", map[string]interface{}{"code": shareCode}, userBToken) + require.Equal(t, http.StatusOK, w.Code) + + // User A creates contractor tied to residence + contractorBody := map[string]interface{}{ + "name": "Test Contractor", + "residence_id": uint(residenceID), + } + w = app.makeAuthenticatedRequest(t, "POST", "/api/contractors", contractorBody, userAToken) + require.Equal(t, http.StatusCreated, w.Code) + + var contractorResp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &contractorResp) + contractorID := contractorResp["id"].(float64) + + // User B (with access) can update the contractor + // Note: Must include residence_id to keep it tied to the residence + updateBody := map[string]interface{}{ + "name": "Updated by User B", + "residence_id": uint(residenceID), + } + w = app.makeAuthenticatedRequest(t, "PUT", "/api/contractors/"+formatID(contractorID), updateBody, userBToken) + assert.Equal(t, http.StatusOK, w.Code, "User B should be able to update contractor in shared residence") + + // User C (without access) cannot update the contractor + updateBody2 := map[string]interface{}{ + "name": "Hacked by User C", + "residence_id": uint(residenceID), + } + w = app.makeAuthenticatedRequest(t, "PUT", "/api/contractors/"+formatID(contractorID), updateBody2, userCToken) + assert.Equal(t, http.StatusForbidden, w.Code, "User C should NOT be able to update contractor") + + // User C cannot delete the contractor + w = app.makeAuthenticatedRequest(t, "DELETE", "/api/contractors/"+formatID(contractorID), nil, userCToken) + assert.Equal(t, http.StatusForbidden, w.Code, "User C should NOT be able to delete contractor") + + // User B (with access) can delete the contractor + w = app.makeAuthenticatedRequest(t, "DELETE", "/api/contractors/"+formatID(contractorID), nil, userBToken) + assert.Equal(t, http.StatusOK, w.Code, "User B should be able to delete contractor in shared residence") +} + +// Helper function to extract contractor IDs from response +func extractContractorIDs(contractors []map[string]interface{}) []uint { + ids := make([]uint, len(contractors)) + for i, c := range contractors { + ids[i] = uint(c["id"].(float64)) + } + return ids +} + +// Helper function to count contractors tied to a specific residence +func countResidenceContractors(contractors []map[string]interface{}, residenceID float64) int { + count := 0 + for _, c := range contractors { + if rid, ok := c["residence_id"].(float64); ok && rid == residenceID { + count++ + } + } + return count +} diff --git a/internal/integration/integration_test.go b/internal/integration/integration_test.go index 3bf0f4e..f5d4d25 100644 --- a/internal/integration/integration_test.go +++ b/internal/integration/integration_test.go @@ -24,15 +24,17 @@ import ( // TestApp holds all components for integration testing type TestApp struct { - DB *gorm.DB - Router *gin.Engine - AuthHandler *handlers.AuthHandler - ResidenceHandler *handlers.ResidenceHandler - TaskHandler *handlers.TaskHandler - UserRepo *repositories.UserRepository - ResidenceRepo *repositories.ResidenceRepository - TaskRepo *repositories.TaskRepository - AuthService *services.AuthService + DB *gorm.DB + Router *gin.Engine + AuthHandler *handlers.AuthHandler + ResidenceHandler *handlers.ResidenceHandler + TaskHandler *handlers.TaskHandler + ContractorHandler *handlers.ContractorHandler + UserRepo *repositories.UserRepository + ResidenceRepo *repositories.ResidenceRepository + TaskRepo *repositories.TaskRepository + ContractorRepo *repositories.ContractorRepository + AuthService *services.AuthService } func setupIntegrationTest(t *testing.T) *TestApp { @@ -713,3 +715,103 @@ func TestIntegration_ResponseStructure(t *testing.T) { func formatID(id float64) string { return fmt.Sprintf("%d", uint(id)) } + +// setupContractorTest sets up a test environment including contractor routes +func setupContractorTest(t *testing.T) *TestApp { + gin.SetMode(gin.TestMode) + + db := testutil.SetupTestDB(t) + testutil.SeedLookupData(t, db) + + // Create repositories + userRepo := repositories.NewUserRepository(db) + residenceRepo := repositories.NewResidenceRepository(db) + taskRepo := repositories.NewTaskRepository(db) + contractorRepo := repositories.NewContractorRepository(db) + + // Create config + cfg := &config.Config{ + Security: config.SecurityConfig{ + SecretKey: "test-secret-key-for-integration-tests", + PasswordResetExpiry: 15 * time.Minute, + ConfirmationExpiry: 24 * time.Hour, + MaxPasswordResetRate: 3, + }, + } + + // Create services + authService := services.NewAuthService(userRepo, cfg) + residenceService := services.NewResidenceService(residenceRepo, userRepo, cfg) + taskService := services.NewTaskService(taskRepo, residenceRepo) + contractorService := services.NewContractorService(contractorRepo, residenceRepo) + + // Create handlers + authHandler := handlers.NewAuthHandler(authService, nil, nil) + residenceHandler := handlers.NewResidenceHandler(residenceService, nil, nil) + taskHandler := handlers.NewTaskHandler(taskService, nil) + contractorHandler := handlers.NewContractorHandler(contractorService) + + // Create router with real middleware + router := gin.New() + + // Public routes + auth := router.Group("/api/auth") + { + auth.POST("/register", authHandler.Register) + auth.POST("/login", authHandler.Login) + } + + // Protected routes + authMiddleware := middleware.NewAuthMiddleware(db, nil) + api := router.Group("/api") + api.Use(authMiddleware.TokenAuth()) + { + api.GET("/auth/me", authHandler.CurrentUser) + api.POST("/auth/logout", authHandler.Logout) + + residences := api.Group("/residences") + { + residences.GET("", residenceHandler.ListResidences) + residences.POST("", residenceHandler.CreateResidence) + residences.GET("/:id", residenceHandler.GetResidence) + residences.PUT("/:id", residenceHandler.UpdateResidence) + residences.DELETE("/:id", residenceHandler.DeleteResidence) + residences.POST("/:id/generate-share-code", residenceHandler.GenerateShareCode) + residences.GET("/:id/users", residenceHandler.GetResidenceUsers) + residences.DELETE("/:id/users/:userId", residenceHandler.RemoveResidenceUser) + } + api.POST("/residences/join-with-code", residenceHandler.JoinWithCode) + + tasks := api.Group("/tasks") + { + tasks.GET("", taskHandler.ListTasks) + tasks.POST("", taskHandler.CreateTask) + tasks.GET("/:id", taskHandler.GetTask) + tasks.PUT("/:id", taskHandler.UpdateTask) + tasks.DELETE("/:id", taskHandler.DeleteTask) + } + + contractors := api.Group("/contractors") + { + contractors.GET("", contractorHandler.ListContractors) + contractors.POST("", contractorHandler.CreateContractor) + contractors.GET("/:id", contractorHandler.GetContractor) + contractors.PUT("/:id", contractorHandler.UpdateContractor) + contractors.DELETE("/:id", contractorHandler.DeleteContractor) + } + } + + return &TestApp{ + DB: db, + Router: router, + AuthHandler: authHandler, + ResidenceHandler: residenceHandler, + TaskHandler: taskHandler, + ContractorHandler: contractorHandler, + UserRepo: userRepo, + ResidenceRepo: residenceRepo, + TaskRepo: taskRepo, + ContractorRepo: contractorRepo, + AuthService: authService, + } +} diff --git a/internal/router/router.go b/internal/router/router.go index 9988f28..4f5d382 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -68,6 +68,7 @@ func SetupRouter(deps *Dependencies) *gin.Engine { // Initialize services authService := services.NewAuthService(userRepo, cfg) + authService.SetNotificationRepository(notificationRepo) // For creating notification preferences on registration userService := services.NewUserService(userRepo) residenceService := services.NewResidenceService(residenceRepo, userRepo, cfg) residenceService.SetTaskRepository(taskRepo) // Wire up task repo for statistics diff --git a/internal/services/auth_service.go b/internal/services/auth_service.go index c682464..4210d1d 100644 --- a/internal/services/auth_service.go +++ b/internal/services/auth_service.go @@ -33,8 +33,9 @@ var ( // AuthService handles authentication business logic type AuthService struct { - userRepo *repositories.UserRepository - cfg *config.Config + userRepo *repositories.UserRepository + notificationRepo *repositories.NotificationRepository + cfg *config.Config } // NewAuthService creates a new auth service @@ -45,6 +46,11 @@ func NewAuthService(userRepo *repositories.UserRepository, cfg *config.Config) * } } +// SetNotificationRepository sets the notification repository for creating notification preferences +func (s *AuthService) SetNotificationRepository(notificationRepo *repositories.NotificationRepository) { + s.notificationRepo = notificationRepo +} + // Login authenticates a user and returns a token func (s *AuthService) Login(req *requests.LoginRequest) (*responses.LoginResponse, error) { // Find user by username or email @@ -134,6 +140,14 @@ func (s *AuthService) Register(req *requests.RegisterRequest) (*responses.Regist fmt.Printf("Failed to create user profile: %v\n", err) } + // Create notification preferences with all options enabled + if s.notificationRepo != nil { + if _, err := s.notificationRepo.GetOrCreatePreferences(user.ID); err != nil { + // Log error but don't fail registration + fmt.Printf("Failed to create notification preferences: %v\n", err) + } + } + // Create auth token token, err := s.userRepo.GetOrCreateToken(user.ID) if err != nil { @@ -523,6 +537,14 @@ func (s *AuthService) AppleSignIn(ctx context.Context, appleAuth *AppleAuthServi _ = s.userRepo.SetProfileVerified(user.ID, true) } + // Create notification preferences with all options enabled + if s.notificationRepo != nil { + if _, err := s.notificationRepo.GetOrCreatePreferences(user.ID); err != nil { + // Log error but don't fail registration + fmt.Printf("Failed to create notification preferences: %v\n", err) + } + } + // Link Apple ID appleAuthRecord := &models.AppleSocialAuth{ UserID: user.ID, diff --git a/internal/services/contractor_service.go b/internal/services/contractor_service.go index a718a9a..bae55b0 100644 --- a/internal/services/contractor_service.go +++ b/internal/services/contractor_service.go @@ -198,6 +198,8 @@ func (s *ContractorService) UpdateContractor(contractorID, userID uint, req *req if req.IsFavorite != nil { contractor.IsFavorite = *req.IsFavorite } + // If residence_id is not sent in the request (nil), it means the user + // removed the residence association - contractor becomes personal contractor.ResidenceID = req.ResidenceID if err := s.contractorRepo.Update(contractor); err != nil { diff --git a/internal/services/email_service.go b/internal/services/email_service.go index ddae0eb..feb70c1 100644 --- a/internal/services/email_service.go +++ b/internal/services/email_service.go @@ -145,6 +145,76 @@ The Casera Team return s.SendEmail(to, subject, htmlBody, textBody) } +// SendAppleWelcomeEmail sends a welcome email for Apple Sign In users (no verification needed) +func (s *EmailService) SendAppleWelcomeEmail(to, firstName string) error { + subject := "Welcome to Casera!" + + name := firstName + if name == "" { + name = "there" + } + + htmlBody := fmt.Sprintf(` + + + + + + + +
+
+

Welcome to Casera!

+
+

Hi %s,

+

Thank you for joining Casera! Your account has been created and you're ready to start managing your properties.

+
+

Here's what you can do with Casera:

+
🏠 Manage Properties - Track all your homes and rentals in one place
+
Task Management - Never miss maintenance with smart scheduling
+
👷 Contractor Directory - Keep your trusted pros organized
+
📄 Document Storage - Store warranties, manuals, and important records
+
+

If you have any questions, feel free to reach out to us at support@casera.app.

+

Best regards,
The Casera Team

+ +
+ + +`, name, time.Now().Year()) + + textBody := fmt.Sprintf(` +Welcome to Casera! + +Hi %s, + +Thank you for joining Casera! Your account has been created and you're ready to start managing your properties. + +Here's what you can do with Casera: +- Manage Properties: Track all your homes and rentals in one place +- Task Management: Never miss maintenance with smart scheduling +- Contractor Directory: Keep your trusted pros organized +- Document Storage: Store warranties, manuals, and important records + +If you have any questions, feel free to reach out to us at support@casera.app. + +Best regards, +The Casera Team +`, name) + + return s.SendEmail(to, subject, htmlBody, textBody) +} + // SendVerificationEmail sends an email verification code func (s *EmailService) SendVerificationEmail(to, firstName, code string) error { subject := "Casera - Verify Your Email"