diff --git a/Dockerfile b/Dockerfile index 97cd5d3..956ad07 100644 --- a/Dockerfile +++ b/Dockerfile @@ -58,6 +58,9 @@ COPY --from=builder /app/worker /app/worker # Copy templates directory COPY --from=builder /app/templates /app/templates +# Copy static landing page files +COPY --from=builder /app/static /app/static + # Copy migrations and seeds for production use COPY --from=builder /app/migrations /app/migrations COPY --from=builder /app/seeds /app/seeds @@ -116,6 +119,9 @@ COPY --from=builder /app/worker /app/worker # Copy templates directory COPY --from=builder /app/templates /app/templates +# Copy static landing page files +COPY --from=builder /app/static /app/static + # Copy migrations and seeds COPY --from=builder /app/migrations /app/migrations COPY --from=builder /app/seeds /app/seeds diff --git a/docs/Secure Media Access Control.md b/docs/Secure Media Access Control.md new file mode 100644 index 0000000..3ab24ab --- /dev/null +++ b/docs/Secure Media Access Control.md @@ -0,0 +1,281 @@ + # Secure Media Access Control Plan + +## Problem Statement + +Users upload private images and documents (task completion photos, warranties, documents) that may contain sensitive information. Currently, these files are **publicly accessible** via predictable URLs (`/uploads/{category}/{timestamp}_{uuid}.{ext}`). Anyone who knows or guesses a URL can access the file without authentication. + +**Goal**: Ensure only users with access to a residence can view media files associated with that residence. + +## User Decisions + +| Decision | Choice | +|----------|--------| +| **Security Method** | Token-based proxy endpoint | +| **Protected Media** | All uploaded media (completions, documents, warranties) | + +--- + +## Current Architecture (Problem) + +``` +Mobile App Go API Disk + │ │ │ + ├─→ POST /api/documents/ ─────────→│ Save to /uploads/docs/ │ + │ (authenticated) │────────────────────────────→│ + │ │ │ + │←─ { file_url: "/uploads/..." } ←─│ │ + │ │ │ + ├─→ GET /uploads/docs/file.jpg ───→│ Static middleware │ + │ (NO AUTH CHECK!) │←───────────────────────────→│ + │←─ file bytes ←──────────────────←│ │ +``` + +**Issues**: +1. `r.Static("/uploads", cfg.Storage.UploadDir)` serves files without authentication +2. File URLs are predictable (timestamp + short UUID) +3. URLs can be shared/leaked, granting permanent access +4. No audit trail for file access + +--- + +## Recommended Solution: Authenticated Media Proxy + +### Architecture + +``` +Mobile App Go API Disk + │ │ │ + ├─→ POST /api/documents/ ─────────→│ Save to /uploads/docs/ │ + │ (authenticated) │────────────────────────────→│ + │ │ │ + │←─ { file_url: "/api/media/..." }←│ Return proxy URL │ + │ │ │ + ├─→ GET /api/media/{type}/{id}────→│ Media Handler: │ + │ Authorization: Token xxx │ 1. Verify token │ + │ │ 2. Get file record │ + │ │ 3. Check residence access │ + │ │ 4. Stream file │ + │←─ file bytes ←──────────────────←│←───────────────────────────→│ +``` + +### Key Changes + +1. **Remove public static file serving** - No more `r.Static("/uploads", ...)` +2. **Create authenticated media endpoint** - `GET /api/media/{type}/{id}` +3. **Update stored URLs** - Store internal paths, return proxy URLs in API responses +4. **Add access control** - Verify user has residence access before serving + +--- + +## Implementation Plan + +### Phase 1: Create Media Handler + +**New File: `/internal/handlers/media_handler.go`** + +```go +type MediaHandler struct { + documentRepo repositories.DocumentRepository + taskRepo repositories.TaskRepository + residenceRepo repositories.ResidenceRepository + storageSvc *services.StorageService +} + +// GET /api/media/document/{id} +// GET /api/media/document-image/{id} +// GET /api/media/completion-image/{id} +func (h *MediaHandler) ServeMedia(c *gin.Context) { + // 1. Extract user from auth context + user := c.MustGet(middleware.AuthUserKey).(*models.User) + + // 2. Get media type and ID from path + mediaType := c.Param("type") // "document", "document-image", "completion-image" + mediaID := c.Param("id") + + // 3. Look up the file record and get residence ID + residenceID, filePath, err := h.getFileInfo(mediaType, mediaID) + + // 4. Check residence access + hasAccess, _ := h.residenceRepo.HasAccess(residenceID, user.ID) + if !hasAccess { + c.JSON(403, gin.H{"error": "Access denied"}) + return + } + + // 5. Stream the file + c.File(filePath) +} +``` + +### Phase 2: Update Models & Responses + +**Change how URLs are stored and returned:** + +| Model | Current Storage | New Storage | API Response | +|-------|-----------------|-------------|--------------| +| `Document.FileURL` | `/uploads/docs/file.pdf` | `docs/file.pdf` (relative) | `/api/media/document/{id}` | +| `DocumentImage.ImageURL` | `/uploads/images/img.jpg` | `images/img.jpg` (relative) | `/api/media/document-image/{id}` | +| `TaskCompletionImage.ImageURL` | `/uploads/completions/img.jpg` | `completions/img.jpg` (relative) | `/api/media/completion-image/{id}` | + +**Update Response DTOs:** + +```go +// /internal/dto/responses/document_response.go +type DocumentResponse struct { + ID uint `json:"id"` + // Don't expose FileURL directly + // FileURL string `json:"file_url"` // REMOVE + MediaURL string `json:"media_url"` // NEW: "/api/media/document/123" + // ... +} + +type DocumentImageResponse struct { + ID uint `json:"id"` + MediaURL string `json:"media_url"` // "/api/media/document-image/456" + Caption string `json:"caption"` +} +``` + +### Phase 3: Update Router + +**File: `/internal/router/router.go`** + +```go +// REMOVE this line: +// r.Static("/uploads", cfg.Storage.UploadDir) + +// ADD authenticated media routes: +media := api.Group("/media") +media.Use(middleware.AuthRequired(cfg, userService)) +{ + media.GET("/document/:id", mediaHandler.ServeDocument) + media.GET("/document-image/:id", mediaHandler.ServeDocumentImage) + media.GET("/completion-image/:id", mediaHandler.ServeCompletionImage) +} +``` + +### Phase 4: Update Mobile Clients + +**iOS - Update image loading to include auth headers:** + +```swift +// Before: Direct URL access +AsyncImage(url: URL(string: document.fileUrl)) + +// After: Use authenticated request +AuthenticatedImage(url: document.mediaUrl) + +// New component that adds auth header +struct AuthenticatedImage: View { + let url: String + + var body: some View { + // Use URLSession with auth header, or + // use a library like Kingfisher with request modifier + } +} +``` + +**KMM - Update Ktor client for image requests:** + +```kotlin +// Add auth header to image requests +suspend fun fetchMedia(mediaUrl: String): ByteArray { + val token = TokenStorage.getToken() + return httpClient.get(mediaUrl) { + header("Authorization", "Token $token") + }.body() +} +``` + +--- + +## Files to Modify + +### Go API (myCribAPI-go) + +| File | Change | +|------|--------| +| `/internal/handlers/media_handler.go` | **NEW** - Authenticated media serving | +| `/internal/router/router.go` | Remove static serving, add media routes | +| `/internal/dto/responses/document_response.go` | Add `MediaURL` field, remove direct file URL | +| `/internal/dto/responses/task_response.go` | Add `MediaURL` to completion images | +| `/internal/services/document_service.go` | Update to generate proxy URLs | +| `/internal/services/task_service.go` | Update to generate proxy URLs for completions | + +### iOS (MyCribKMM/iosApp) + +| File | Change | +|------|--------| +| `iosApp/Components/AuthenticatedImage.swift` | **NEW** - Image view with auth headers | +| `iosApp/Task/TaskCompletionDetailView.swift` | Use AuthenticatedImage | +| `iosApp/Documents/DocumentDetailView.swift` | Use AuthenticatedImage | + +### KMM/Android + +| File | Change | +|------|--------| +| `network/MediaApi.kt` | **NEW** - Authenticated media fetching | +| `ui/components/AuthenticatedImage.kt` | **NEW** - Compose image with auth | + +--- + +## Migration Strategy + +### For Existing Data + +Option A: **Update URLs in database** (Recommended) +```sql +-- Strip /uploads prefix from existing URLs +UPDATE documents SET file_url = REPLACE(file_url, '/uploads/', '') WHERE file_url LIKE '/uploads/%'; +UPDATE document_images SET image_url = REPLACE(image_url, '/uploads/', '') WHERE image_url LIKE '/uploads/%'; +UPDATE task_completion_images SET image_url = REPLACE(image_url, '/uploads/', '') WHERE image_url LIKE '/uploads/%'; +``` + +Option B: **Handle both formats in code** (Temporary) +```go +func (h *MediaHandler) getFilePath(storedURL string) string { + // Handle legacy /uploads/... URLs + if strings.HasPrefix(storedURL, "/uploads/") { + return filepath.Join(h.uploadDir, strings.TrimPrefix(storedURL, "/uploads/")) + } + // Handle new relative paths + return filepath.Join(h.uploadDir, storedURL) +} +``` + +--- + +## Security Considerations + +### Pros of This Approach +- ✓ All media access requires valid auth token +- ✓ Access control tied to residence membership +- ✓ No URL guessing possible (URLs are opaque IDs) +- ✓ Can add audit logging easily +- ✓ Can add rate limiting per user + +### Cons / Trade-offs +- ✗ Every image request hits the API (increased server load) +- ✗ Can't use CDN caching for images +- ✗ Mobile apps need to handle auth for image loading + +### Mitigations +1. **Add caching headers** - `Cache-Control: private, max-age=3600` +2. **Consider short-lived signed URLs** later if performance becomes an issue +3. **Add response compression** for images if not already enabled + +--- + +## Implementation Order + +1. **Create MediaHandler** with access control logic +2. **Update router** - add media routes, keep static for now +3. **Update response DTOs** - add MediaURL fields +4. **Update services** - generate proxy URLs +5. **Test with Postman** - verify access control works +6. **Update iOS** - AuthenticatedImage component +7. **Update Android/KMM** - AuthenticatedImage component +8. **Run migration** - update existing URLs in database +9. **Remove static serving** - final step after clients updated +10. **Add audit logging** (optional) diff --git a/docs/TASK_KANBAN_LOGIC.md b/docs/TASK_KANBAN_LOGIC.md index 5097c4b..1a7921e 100644 --- a/docs/TASK_KANBAN_LOGIC.md +++ b/docs/TASK_KANBAN_LOGIC.md @@ -6,16 +6,16 @@ This document describes how tasks are categorized into kanban columns for displa Tasks are organized into 6 kanban columns based on their state and due date. The categorization logic is implemented in `internal/repositories/task_repo.go` in the `GetKanbanData` and `GetKanbanDataForMultipleResidences` functions. -## Columns +## Columns Summary -| Column | Name | Color | Description | -|--------|------|-------|-------------| -| 1 | **Overdue** | `#FF3B30` (Red) | Tasks past their due date | -| 2 | **Due Soon** | `#FF9500` (Orange) | Tasks due within the threshold (default 30 days) | -| 3 | **Upcoming** | `#007AFF` (Blue) | Tasks due beyond the threshold or with no due date | -| 4 | **In Progress** | `#5856D6` (Purple) | Tasks with status "In Progress" | -| 5 | **Completed** | `#34C759` (Green) | Tasks with at least one completion record | -| 6 | **Cancelled** | `#8E8E93` (Gray) | Tasks marked as cancelled | +| Column | Name | Color | Button Types | Description | +|--------|------|-------|--------------|-------------| +| 1 | **Overdue** | `#FF3B30` (Red) | edit, complete, cancel, mark_in_progress | Tasks past their due date | +| 2 | **Due Soon** | `#FF9500` (Orange) | edit, complete, cancel, mark_in_progress | Tasks due within the threshold (default 30 days) | +| 3 | **Upcoming** | `#007AFF` (Blue) | edit, complete, cancel, mark_in_progress | Tasks due beyond the threshold or with no due date | +| 4 | **In Progress** | `#5856D6` (Purple) | edit, complete, cancel | Tasks with status "In Progress" | +| 5 | **Completed** | `#34C759` (Green) | view | Tasks with at least one completion record | +| 6 | **Cancelled** | `#8E8E93` (Gray) | uncancel, delete | Tasks marked as cancelled | ## Categorization Flow @@ -86,7 +86,8 @@ if task.IsCancelled { } ``` - **Condition**: `is_cancelled = true` -- **Actions Available**: `uncancel`, `delete` +- **Button Types**: `uncancel`, `delete` +- **Rationale**: Cancelled tasks can be restored (uncancel) or permanently removed (delete) ### 2. Completed ```go @@ -97,7 +98,8 @@ if len(task.Completions) > 0 { ``` - **Condition**: Task has at least one `TaskCompletion` record - **Note**: A task is considered completed based on having completion records, NOT based on status -- **Actions Available**: `view` +- **Button Types**: `view` +- **Rationale**: Completed tasks are read-only for historical purposes; users can view completion details and photos ### 3. In Progress ```go @@ -107,7 +109,8 @@ if task.Status != nil && task.Status.Name == "In Progress" { } ``` - **Condition**: Task's status name is exactly `"In Progress"` -- **Actions Available**: `edit`, `complete` +- **Button Types**: `edit`, `complete`, `cancel` +- **Rationale**: In-progress tasks can be edited, marked complete, or cancelled if work is abandoned ### 4. Due Date Based Categories (only for tasks not cancelled, completed, or in progress) @@ -118,7 +121,8 @@ if task.DueDate.Before(now) { } ``` - **Condition**: `due_date < current_time` -- **Actions Available**: `edit`, `cancel`, `mark_in_progress` +- **Button Types**: `edit`, `complete`, `cancel`, `mark_in_progress` +- **Rationale**: Overdue tasks need urgent attention - can complete immediately, start working on them, edit the due date, or cancel if no longer needed #### Due Soon ```go @@ -128,14 +132,28 @@ if task.DueDate.Before(threshold) { ``` - **Condition**: `current_time <= due_date < (current_time + days_threshold)` - **Default threshold**: 30 days -- **Actions Available**: `edit`, `complete`, `mark_in_progress` +- **Button Types**: `edit`, `complete`, `cancel`, `mark_in_progress` +- **Rationale**: Due soon tasks are approaching their deadline - users can complete early, start working, reschedule, or cancel #### Upcoming ```go upcoming = append(upcoming, task) ``` - **Condition**: `due_date >= (current_time + days_threshold)` OR `due_date IS NULL` -- **Actions Available**: `edit`, `cancel` +- **Button Types**: `edit`, `complete`, `cancel`, `mark_in_progress` +- **Rationale**: Future tasks may need to be completed early (ahead of schedule), started, rescheduled, or cancelled + +## Button Types Reference + +| Button Type | Action | Description | +|-------------|--------|-------------| +| `edit` | Update task | Modify task details (title, description, due date, category, etc.) | +| `complete` | Create completion | Mark task as done with optional notes and photos | +| `cancel` | Cancel task | Mark task as cancelled (soft delete, can be uncancelled) | +| `mark_in_progress` | Start work | Change status to "In Progress" to indicate work has begun | +| `view` | View details | View task and completion details (read-only) | +| `uncancel` | Restore task | Restore a cancelled task to its previous state | +| `delete` | Hard delete | Permanently remove task (only for cancelled tasks) | ## Column Metadata @@ -145,7 +163,7 @@ Each column includes metadata for the mobile clients: { Name: "overdue_tasks", // Internal identifier DisplayName: "Overdue", // User-facing label - ButtonTypes: []string{"edit", "cancel", "mark_in_progress"}, // Available actions + ButtonTypes: []string{"edit", "complete", "cancel", "mark_in_progress"}, Icons: map[string]string{ // Platform-specific icons "ios": "exclamationmark.triangle", "android": "Warning" @@ -156,6 +174,17 @@ Each column includes metadata for the mobile clients: } ``` +### Icons by Column + +| Column | iOS Icon | Android Icon | +|--------|----------|--------------| +| Overdue | `exclamationmark.triangle` | `Warning` | +| Due Soon | `clock` | `Schedule` | +| Upcoming | `calendar` | `Event` | +| In Progress | `hammer` | `Build` | +| Completed | `checkmark.circle` | `CheckCircle` | +| Cancelled | `xmark.circle` | `Cancel` | + ## Days Threshold Parameter The `daysThreshold` parameter (default: 30) determines the boundary between "Due Soon" and "Upcoming": @@ -185,7 +214,7 @@ The following tasks are excluded from the kanban board entirely: { "name": "overdue_tasks", "display_name": "Overdue", - "button_types": ["edit", "cancel", "mark_in_progress"], + "button_types": ["edit", "complete", "cancel", "mark_in_progress"], "icons": { "ios": "exclamationmark.triangle", "android": "Warning" @@ -194,13 +223,86 @@ The following tasks are excluded from the kanban board entirely: "tasks": [...], "count": 2 }, - // ... other columns + { + "name": "due_soon_tasks", + "display_name": "Due Soon", + "button_types": ["edit", "complete", "cancel", "mark_in_progress"], + "icons": { + "ios": "clock", + "android": "Schedule" + }, + "color": "#FF9500", + "tasks": [...], + "count": 5 + }, + { + "name": "upcoming_tasks", + "display_name": "Upcoming", + "button_types": ["edit", "complete", "cancel", "mark_in_progress"], + "icons": { + "ios": "calendar", + "android": "Event" + }, + "color": "#007AFF", + "tasks": [...], + "count": 10 + }, + { + "name": "in_progress_tasks", + "display_name": "In Progress", + "button_types": ["edit", "complete", "cancel"], + "icons": { + "ios": "hammer", + "android": "Build" + }, + "color": "#5856D6", + "tasks": [...], + "count": 3 + }, + { + "name": "completed_tasks", + "display_name": "Completed", + "button_types": ["view"], + "icons": { + "ios": "checkmark.circle", + "android": "CheckCircle" + }, + "color": "#34C759", + "tasks": [...], + "count": 15 + }, + { + "name": "cancelled_tasks", + "display_name": "Cancelled", + "button_types": ["uncancel", "delete"], + "icons": { + "ios": "xmark.circle", + "android": "Cancel" + }, + "color": "#8E8E93", + "tasks": [...], + "count": 1 + } ], "days_threshold": 30, "residence_id": "123" } ``` +## Design Decisions + +### Why "In Progress" doesn't have "mark_in_progress" +Tasks already in the "In Progress" column are already marked as in progress, so this action doesn't make sense. + +### Why "Completed" only has "view" +Completed tasks represent historical records. Users can view them but shouldn't be able to uncomplete or modify them. If a task was completed incorrectly, the user should delete the completion record through the completion detail view. + +### Why "Cancelled" has "delete" but others don't +Hard deletion is only available for cancelled tasks as a final cleanup action. For active tasks, users should cancel first (soft delete), then delete if truly needed. This prevents accidental data loss. + +### Why all active columns have "cancel" +Users should always be able to abandon a task they no longer need, regardless of its due date or progress state. + ## Code Location - **Repository**: `internal/repositories/task_repo.go` diff --git a/internal/config/config.go b/internal/config/config.go index 7347cfb..cfb862d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -29,6 +29,7 @@ type ServerConfig struct { Debug bool AllowedHosts []string Timezone string + StaticDir string // Directory for static landing page files } type DatabaseConfig struct { @@ -148,6 +149,7 @@ func Load() (*Config, error) { Debug: viper.GetBool("DEBUG"), AllowedHosts: strings.Split(viper.GetString("ALLOWED_HOSTS"), ","), Timezone: viper.GetString("TIMEZONE"), + StaticDir: viper.GetString("STATIC_DIR"), }, Database: dbConfig, Redis: RedisConfig{ @@ -217,6 +219,7 @@ func setDefaults() { viper.SetDefault("DEBUG", false) viper.SetDefault("ALLOWED_HOSTS", "localhost,127.0.0.1") viper.SetDefault("TIMEZONE", "UTC") + viper.SetDefault("STATIC_DIR", "/app/static") // Database defaults viper.SetDefault("DB_HOST", "localhost") diff --git a/internal/dto/responses/task.go b/internal/dto/responses/task.go index e6383c0..17996d7 100644 --- a/internal/dto/responses/task.go +++ b/internal/dto/responses/task.go @@ -73,6 +73,7 @@ type TaskCompletionResponse struct { Rating *int `json:"rating"` Images []TaskCompletionImageResponse `json:"images"` CreatedAt time.Time `json:"created_at"` + Task *TaskResponse `json:"task,omitempty"` // Updated task after completion } // TaskResponse represents a task in the API response @@ -99,10 +100,11 @@ type TaskResponse struct { ContractorID *uint `json:"contractor_id"` IsCancelled bool `json:"is_cancelled"` IsArchived bool `json:"is_archived"` - ParentTaskID *uint `json:"parent_task_id"` - CompletionCount int `json:"completion_count"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ParentTaskID *uint `json:"parent_task_id"` + CompletionCount int `json:"completion_count"` + KanbanColumn string `json:"kanban_column,omitempty"` // Which kanban column this task belongs to + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } // Note: Pagination removed - list endpoints now return arrays directly @@ -340,3 +342,55 @@ func NewTaskCompletionListResponse(completions []models.TaskCompletion) []TaskCo } return results } + +// NewTaskCompletionWithTaskResponse creates a TaskCompletionResponse with the updated task included +func NewTaskCompletionWithTaskResponse(c *models.TaskCompletion, task *models.Task, daysThreshold int) TaskCompletionResponse { + resp := NewTaskCompletionResponse(c) + + if task != nil { + taskResp := NewTaskResponse(task) + taskResp.KanbanColumn = DetermineKanbanColumn(task, daysThreshold) + resp.Task = &taskResp + } + + return resp +} + +// DetermineKanbanColumn determines which kanban column a task belongs to +// Uses the same logic as task_repo.go GetKanbanData +func DetermineKanbanColumn(task *models.Task, daysThreshold int) string { + if daysThreshold <= 0 { + daysThreshold = 30 // Default + } + + now := time.Now().UTC() + threshold := now.AddDate(0, 0, daysThreshold) + + // Priority order (same as GetKanbanData): + // 1. Cancelled + if task.IsCancelled { + return "cancelled_tasks" + } + + // 2. Completed (has completions) + if len(task.Completions) > 0 { + return "completed_tasks" + } + + // 3. In Progress + if task.Status != nil && task.Status.Name == "In Progress" { + return "in_progress_tasks" + } + + // 4. Due date based + if task.DueDate != nil { + if task.DueDate.Before(now) { + return "overdue_tasks" + } else if task.DueDate.Before(threshold) { + return "due_soon_tasks" + } + } + + // Default: upcoming + return "upcoming_tasks" +} diff --git a/internal/repositories/task_repo.go b/internal/repositories/task_repo.go index cd2b50c..6ff1952 100644 --- a/internal/repositories/task_repo.go +++ b/internal/repositories/task_repo.go @@ -197,7 +197,7 @@ func (r *TaskRepository) GetKanbanData(residenceID uint, daysThreshold int) (*mo { Name: "overdue_tasks", DisplayName: "Overdue", - ButtonTypes: []string{"edit", "cancel", "mark_in_progress"}, + ButtonTypes: []string{"edit", "complete", "cancel", "mark_in_progress"}, Icons: map[string]string{"ios": "exclamationmark.triangle", "android": "Warning"}, Color: "#FF3B30", Tasks: overdue, @@ -206,7 +206,7 @@ func (r *TaskRepository) GetKanbanData(residenceID uint, daysThreshold int) (*mo { Name: "due_soon_tasks", DisplayName: "Due Soon", - ButtonTypes: []string{"edit", "complete", "mark_in_progress"}, + ButtonTypes: []string{"edit", "complete", "cancel", "mark_in_progress"}, Icons: map[string]string{"ios": "clock", "android": "Schedule"}, Color: "#FF9500", Tasks: dueSoon, @@ -215,7 +215,7 @@ func (r *TaskRepository) GetKanbanData(residenceID uint, daysThreshold int) (*mo { Name: "upcoming_tasks", DisplayName: "Upcoming", - ButtonTypes: []string{"edit", "cancel"}, + ButtonTypes: []string{"edit", "complete", "cancel", "mark_in_progress"}, Icons: map[string]string{"ios": "calendar", "android": "Event"}, Color: "#007AFF", Tasks: upcoming, @@ -224,7 +224,7 @@ func (r *TaskRepository) GetKanbanData(residenceID uint, daysThreshold int) (*mo { Name: "in_progress_tasks", DisplayName: "In Progress", - ButtonTypes: []string{"edit", "complete"}, + ButtonTypes: []string{"edit", "complete", "cancel"}, Icons: map[string]string{"ios": "hammer", "android": "Build"}, Color: "#5856D6", Tasks: inProgress, @@ -324,7 +324,7 @@ func (r *TaskRepository) GetKanbanDataForMultipleResidences(residenceIDs []uint, { Name: "overdue_tasks", DisplayName: "Overdue", - ButtonTypes: []string{"edit", "cancel", "mark_in_progress"}, + ButtonTypes: []string{"edit", "complete", "cancel", "mark_in_progress"}, Icons: map[string]string{"ios": "exclamationmark.triangle", "android": "Warning"}, Color: "#FF3B30", Tasks: overdue, @@ -333,7 +333,7 @@ func (r *TaskRepository) GetKanbanDataForMultipleResidences(residenceIDs []uint, { Name: "due_soon_tasks", DisplayName: "Due Soon", - ButtonTypes: []string{"edit", "complete", "mark_in_progress"}, + ButtonTypes: []string{"edit", "complete", "cancel", "mark_in_progress"}, Icons: map[string]string{"ios": "clock", "android": "Schedule"}, Color: "#FF9500", Tasks: dueSoon, @@ -342,7 +342,7 @@ func (r *TaskRepository) GetKanbanDataForMultipleResidences(residenceIDs []uint, { Name: "upcoming_tasks", DisplayName: "Upcoming", - ButtonTypes: []string{"edit", "cancel"}, + ButtonTypes: []string{"edit", "complete", "cancel", "mark_in_progress"}, Icons: map[string]string{"ios": "calendar", "android": "Event"}, Color: "#007AFF", Tasks: upcoming, @@ -351,7 +351,7 @@ func (r *TaskRepository) GetKanbanDataForMultipleResidences(residenceIDs []uint, { Name: "in_progress_tasks", DisplayName: "In Progress", - ButtonTypes: []string{"edit", "complete"}, + ButtonTypes: []string{"edit", "complete", "cancel"}, Icons: map[string]string{"ios": "hammer", "android": "Build"}, Color: "#5856D6", Tasks: inProgress, diff --git a/internal/router/router.go b/internal/router/router.go index 34b66a0..cdfe974 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -51,6 +51,20 @@ func SetupRouter(deps *Dependencies) *gin.Engine { r.Use(corsMiddleware(cfg)) r.Use(i18n.Middleware()) + // Serve landing page static files (if static directory is configured) + staticDir := cfg.Server.StaticDir + if staticDir != "" { + r.Static("/css", staticDir+"/css") + r.Static("/js", staticDir+"/js") + r.Static("/images", staticDir+"/images") + r.StaticFile("/favicon.ico", staticDir+"/images/favicon.svg") + + // Serve index.html at root + r.GET("/", func(c *gin.Context) { + c.File(staticDir + "/index.html") + }) + } + // Health check endpoint (no auth required) r.GET("/api/health/", healthCheck) diff --git a/internal/services/email_service.go b/internal/services/email_service.go index feb70c1..d0adca4 100644 --- a/internal/services/email_service.go +++ b/internal/services/email_service.go @@ -84,6 +84,74 @@ func (s *EmailService) SendEmailWithAttachment(to, subject, htmlBody, textBody s return nil } +// baseEmailTemplate returns the styled email wrapper +func baseEmailTemplate() string { + return ` + +
+ + + +| + + | +
|
+ |
+
%s
+© %d Casera. All rights reserved.
+Never miss home maintenance again.
+Hi %s,
-Thank you for creating a Casera account. To complete your registration, please verify your email address by entering the following code:
-This code will expire in 24 hours.
-If you didn't create a Casera account, you can safely ignore this email.
-Best regards,
The Casera Team
Hi %s,
+Thank you for creating a Casera account! To complete your registration, please verify your email address by entering the code below:
+ + +|
+ %s +This code will expire in 24 hours + |
+
If you didn't create a Casera account, you can safely ignore this email.
+ +Best regards,
The Casera Team
Hi %s,
-Thank you for joining Casera! Your account has been created and you're ready to start managing your properties.
-If you have any questions, feel free to reach out to us at support@casera.app.
-Best regards,
The Casera Team
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
Hi %s,
-Please use the following code to verify your email address:
-This code will expire in 24 hours.
-If you didn't request this, you can safely ignore this email.
-Best regards,
The Casera Team
Hi %s,
+Please use the following code to verify your email address:
+ + +|
+ %s +This code will expire in 24 hours + |
+
If you didn't request this, you can safely ignore this email.
+ +Best regards,
The Casera Team
Hi %s,
-We received a request to reset your password. Use the following code to complete the reset:
-This code will expire in 15 minutes.
-Best regards,
The Casera Team
Hi %s,
+We received a request to reset your password. Use the following code to complete the reset:
+ + +|
+ %s +This code will expire in 15 minutes + |
+
|
+ Security Notice +If you didn't request a password reset, please ignore this email. Your password will remain unchanged. + |
+
Best regards,
The Casera Team
Hi %s,
-Your Casera password was successfully changed on %s.
-Best regards,
The Casera Team
Hi %s,
+Your Casera password was successfully changed on %s.
+ + +|
+ Didn't make this change? +If you didn't change your password, please contact us immediately at support@casera.app or reset your password. + |
+
Best regards,
The Casera Team
Hi %s,
-A task has been completed at %s:
-%s
- -Best regards,
The Casera Team
Hi %s,
+A task has been completed at %s:
+ + +|
+ %s +Completed by: %s |
+
Best regards,
The Casera Team
%s
-Hi %s,
-Here's your tasks report for %s. The full report is attached as a PDF.
-|
- %d
- Total Tasks
- |
-
- %d
- Completed
- |
-
- %d
- Pending
- |
-
- %d
- Overdue
- |
-
Open the attached PDF for the complete list of tasks with details.
-Best regards,
The Casera Team
Hi %s,
+Here's your tasks report for %s. The full report is attached as a PDF.
+ + +|
+ Summary +
|
+
Open the attached PDF for the complete list of tasks with details.
+ +Best regards,
The Casera Team
Manage properties, tasks, and costs all in one place. Track maintenance, organize contractors, and keep your home running smoothly.
+ + + +Free to download • No credit card required
+Manage homes, rentals, and investments
+Recurring tasks that never slip
+Know exactly what you spend
+Manage unlimited residential and commercial properties. Track details like bedrooms, bathrooms, square footage, and more. Perfect for homeowners, landlords, and property managers.
+Create one-time or recurring tasks. Organize by category — plumbing, electrical, HVAC, landscaping. Get reminders before things break. View everything in a visual kanban board.
+Keep a directory of service providers with contact info, specialties, ratings, and work history. Quickly call, email, or get directions to any contractor. Never lose a business card again.
+Store warranties, manuals, and maintenance records. Track expiration dates and get alerts before warranties expire. Generate PDF reports for insurance claims or property sales.
+Available for iPhone, iPad, and Android. Your data syncs seamlessly across all your devices.
+Join homeowners who never miss maintenance again.
+ + + +Free download • Works offline • Your data stays private
+