Add Apple Sign In welcome email, notification preferences on registration, and contractor sharing tests
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -406,5 +406,14 @@ func (h *AuthHandler) AppleSignIn(c *gin.Context) {
|
|||||||
return
|
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)
|
c.JSON(http.StatusOK, response)
|
||||||
}
|
}
|
||||||
|
|||||||
310
internal/integration/contractor_sharing_test.go
Normal file
310
internal/integration/contractor_sharing_test.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -24,15 +24,17 @@ import (
|
|||||||
|
|
||||||
// TestApp holds all components for integration testing
|
// TestApp holds all components for integration testing
|
||||||
type TestApp struct {
|
type TestApp struct {
|
||||||
DB *gorm.DB
|
DB *gorm.DB
|
||||||
Router *gin.Engine
|
Router *gin.Engine
|
||||||
AuthHandler *handlers.AuthHandler
|
AuthHandler *handlers.AuthHandler
|
||||||
ResidenceHandler *handlers.ResidenceHandler
|
ResidenceHandler *handlers.ResidenceHandler
|
||||||
TaskHandler *handlers.TaskHandler
|
TaskHandler *handlers.TaskHandler
|
||||||
UserRepo *repositories.UserRepository
|
ContractorHandler *handlers.ContractorHandler
|
||||||
ResidenceRepo *repositories.ResidenceRepository
|
UserRepo *repositories.UserRepository
|
||||||
TaskRepo *repositories.TaskRepository
|
ResidenceRepo *repositories.ResidenceRepository
|
||||||
AuthService *services.AuthService
|
TaskRepo *repositories.TaskRepository
|
||||||
|
ContractorRepo *repositories.ContractorRepository
|
||||||
|
AuthService *services.AuthService
|
||||||
}
|
}
|
||||||
|
|
||||||
func setupIntegrationTest(t *testing.T) *TestApp {
|
func setupIntegrationTest(t *testing.T) *TestApp {
|
||||||
@@ -713,3 +715,103 @@ func TestIntegration_ResponseStructure(t *testing.T) {
|
|||||||
func formatID(id float64) string {
|
func formatID(id float64) string {
|
||||||
return fmt.Sprintf("%d", uint(id))
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ func SetupRouter(deps *Dependencies) *gin.Engine {
|
|||||||
|
|
||||||
// Initialize services
|
// Initialize services
|
||||||
authService := services.NewAuthService(userRepo, cfg)
|
authService := services.NewAuthService(userRepo, cfg)
|
||||||
|
authService.SetNotificationRepository(notificationRepo) // For creating notification preferences on registration
|
||||||
userService := services.NewUserService(userRepo)
|
userService := services.NewUserService(userRepo)
|
||||||
residenceService := services.NewResidenceService(residenceRepo, userRepo, cfg)
|
residenceService := services.NewResidenceService(residenceRepo, userRepo, cfg)
|
||||||
residenceService.SetTaskRepository(taskRepo) // Wire up task repo for statistics
|
residenceService.SetTaskRepository(taskRepo) // Wire up task repo for statistics
|
||||||
|
|||||||
@@ -33,8 +33,9 @@ var (
|
|||||||
|
|
||||||
// AuthService handles authentication business logic
|
// AuthService handles authentication business logic
|
||||||
type AuthService struct {
|
type AuthService struct {
|
||||||
userRepo *repositories.UserRepository
|
userRepo *repositories.UserRepository
|
||||||
cfg *config.Config
|
notificationRepo *repositories.NotificationRepository
|
||||||
|
cfg *config.Config
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAuthService creates a new auth service
|
// 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
|
// Login authenticates a user and returns a token
|
||||||
func (s *AuthService) Login(req *requests.LoginRequest) (*responses.LoginResponse, error) {
|
func (s *AuthService) Login(req *requests.LoginRequest) (*responses.LoginResponse, error) {
|
||||||
// Find user by username or email
|
// 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)
|
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
|
// Create auth token
|
||||||
token, err := s.userRepo.GetOrCreateToken(user.ID)
|
token, err := s.userRepo.GetOrCreateToken(user.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -523,6 +537,14 @@ func (s *AuthService) AppleSignIn(ctx context.Context, appleAuth *AppleAuthServi
|
|||||||
_ = s.userRepo.SetProfileVerified(user.ID, true)
|
_ = 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
|
// Link Apple ID
|
||||||
appleAuthRecord := &models.AppleSocialAuth{
|
appleAuthRecord := &models.AppleSocialAuth{
|
||||||
UserID: user.ID,
|
UserID: user.ID,
|
||||||
|
|||||||
@@ -198,6 +198,8 @@ func (s *ContractorService) UpdateContractor(contractorID, userID uint, req *req
|
|||||||
if req.IsFavorite != nil {
|
if req.IsFavorite != nil {
|
||||||
contractor.IsFavorite = *req.IsFavorite
|
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
|
contractor.ResidenceID = req.ResidenceID
|
||||||
|
|
||||||
if err := s.contractorRepo.Update(contractor); err != nil {
|
if err := s.contractorRepo.Update(contractor); err != nil {
|
||||||
|
|||||||
@@ -145,6 +145,76 @@ The Casera Team
|
|||||||
return s.SendEmail(to, subject, htmlBody, textBody)
|
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(`
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<style>
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; }
|
||||||
|
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
|
||||||
|
.header { text-align: center; padding: 20px 0; }
|
||||||
|
.cta { background: #07A0C3; color: white; padding: 15px 30px; text-decoration: none; border-radius: 8px; display: inline-block; margin: 20px 0; }
|
||||||
|
.features { background: #f8f9fa; padding: 20px; border-radius: 8px; margin: 20px 0; }
|
||||||
|
.feature { margin: 10px 0; }
|
||||||
|
.footer { text-align: center; color: #666; font-size: 12px; margin-top: 40px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
<h1>Welcome to Casera!</h1>
|
||||||
|
</div>
|
||||||
|
<p>Hi %s,</p>
|
||||||
|
<p>Thank you for joining Casera! Your account has been created and you're ready to start managing your properties.</p>
|
||||||
|
<div class="features">
|
||||||
|
<h3>Here's what you can do with Casera:</h3>
|
||||||
|
<div class="feature">🏠 <strong>Manage Properties</strong> - Track all your homes and rentals in one place</div>
|
||||||
|
<div class="feature">✅ <strong>Task Management</strong> - Never miss maintenance with smart scheduling</div>
|
||||||
|
<div class="feature">👷 <strong>Contractor Directory</strong> - Keep your trusted pros organized</div>
|
||||||
|
<div class="feature">📄 <strong>Document Storage</strong> - Store warranties, manuals, and important records</div>
|
||||||
|
</div>
|
||||||
|
<p>If you have any questions, feel free to reach out to us at support@casera.app.</p>
|
||||||
|
<p>Best regards,<br>The Casera Team</p>
|
||||||
|
<div class="footer">
|
||||||
|
<p>© %d Casera. All rights reserved.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`, 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
|
// SendVerificationEmail sends an email verification code
|
||||||
func (s *EmailService) SendVerificationEmail(to, firstName, code string) error {
|
func (s *EmailService) SendVerificationEmail(to, firstName, code string) error {
|
||||||
subject := "Casera - Verify Your Email"
|
subject := "Casera - Verify Your Email"
|
||||||
|
|||||||
Reference in New Issue
Block a user