Add PDF reports, file uploads, admin auth, and comprehensive tests

Features:
- PDF service for generating task reports with ReportLab-style formatting
- Storage service for file uploads (local and S3-compatible)
- Admin authentication middleware with JWT support
- Admin user model and repository

Infrastructure:
- Updated Docker configuration for admin panel builds
- Email service enhancements for task notifications
- Updated router with admin and file upload routes
- Environment configuration updates

Tests:
- Unit tests for handlers (auth, residence, task)
- Unit tests for models (user, residence, task)
- Unit tests for repositories (user, residence, task)
- Unit tests for services (residence, task)
- Integration test setup
- Test utilities for mocking database and services

Database:
- Admin user seed data
- Updated test data seeds

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Trey t
2025-11-27 23:36:20 -06:00
parent 2817deee3c
commit 469f21a833
50 changed files with 6795 additions and 582 deletions

View File

@@ -1,11 +1,57 @@
package requests
import (
"encoding/json"
"strings"
"time"
"github.com/shopspring/decimal"
)
// FlexibleDate handles both "2025-11-27" and "2025-11-27T00:00:00Z" formats
type FlexibleDate struct {
time.Time
}
func (fd *FlexibleDate) UnmarshalJSON(data []byte) error {
// Remove quotes
s := strings.Trim(string(data), "\"")
if s == "" || s == "null" {
return nil
}
// Try RFC3339 first (full datetime)
t, err := time.Parse(time.RFC3339, s)
if err == nil {
fd.Time = t
return nil
}
// Try date-only format
t, err = time.Parse("2006-01-02", s)
if err == nil {
fd.Time = t
return nil
}
return err
}
func (fd FlexibleDate) MarshalJSON() ([]byte, error) {
if fd.Time.IsZero() {
return json.Marshal(nil)
}
return json.Marshal(fd.Time.Format(time.RFC3339))
}
// ToTimePtr returns a pointer to the underlying time, or nil if zero
func (fd *FlexibleDate) ToTimePtr() *time.Time {
if fd == nil || fd.Time.IsZero() {
return nil
}
return &fd.Time
}
// CreateTaskRequest represents the request to create a task
type CreateTaskRequest struct {
ResidenceID uint `json:"residence_id" binding:"required"`
@@ -16,7 +62,7 @@ type CreateTaskRequest struct {
StatusID *uint `json:"status_id"`
FrequencyID *uint `json:"frequency_id"`
AssignedToID *uint `json:"assigned_to_id"`
DueDate *time.Time `json:"due_date"`
DueDate *FlexibleDate `json:"due_date"`
EstimatedCost *decimal.Decimal `json:"estimated_cost"`
ContractorID *uint `json:"contractor_id"`
}
@@ -30,7 +76,7 @@ type UpdateTaskRequest struct {
StatusID *uint `json:"status_id"`
FrequencyID *uint `json:"frequency_id"`
AssignedToID *uint `json:"assigned_to_id"`
DueDate *time.Time `json:"due_date"`
DueDate *FlexibleDate `json:"due_date"`
EstimatedCost *decimal.Decimal `json:"estimated_cost"`
ActualCost *decimal.Decimal `json:"actual_cost"`
ContractorID *uint `json:"contractor_id"`

View File

@@ -14,6 +14,7 @@ type UserResponse struct {
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
IsActive bool `json:"is_active"`
Verified bool `json:"verified"`
DateJoined time.Time `json:"date_joined"`
LastLogin *time.Time `json:"last_login,omitempty"`
}
@@ -90,6 +91,10 @@ type ErrorResponse struct {
// NewUserResponse creates a UserResponse from a User model
func NewUserResponse(user *models.User) UserResponse {
verified := false
if user.Profile != nil {
verified = user.Profile.Verified
}
return UserResponse{
ID: user.ID,
Username: user.Username,
@@ -97,6 +102,7 @@ func NewUserResponse(user *models.User) UserResponse {
FirstName: user.FirstName,
LastName: user.LastName,
IsActive: user.IsActive,
Verified: verified,
DateJoined: user.DateJoined,
LastLogin: user.LastLogin,
}

View File

@@ -28,6 +28,7 @@ type ContractorResponse struct {
ID uint `json:"id"`
ResidenceID uint `json:"residence_id"`
CreatedByID uint `json:"created_by_id"`
AddedBy uint `json:"added_by"` // Alias for created_by_id (KMM compatibility)
CreatedBy *ContractorUserResponse `json:"created_by,omitempty"`
Name string `json:"name"`
Company string `json:"company"`
@@ -48,13 +49,7 @@ type ContractorResponse struct {
UpdatedAt time.Time `json:"updated_at"`
}
// ContractorListResponse represents a paginated list of contractors
type ContractorListResponse struct {
Count int `json:"count"`
Next *string `json:"next"`
Previous *string `json:"previous"`
Results []ContractorResponse `json:"results"`
}
// Note: Pagination removed - list endpoints now return arrays directly
// ToggleFavoriteResponse represents the response after toggling favorite
type ToggleFavoriteResponse struct {
@@ -94,6 +89,7 @@ func NewContractorResponse(c *models.Contractor) ContractorResponse {
ID: c.ID,
ResidenceID: c.ResidenceID,
CreatedByID: c.CreatedByID,
AddedBy: c.CreatedByID, // Alias for KMM compatibility
Name: c.Name,
Company: c.Company,
Phone: c.Phone,
@@ -124,16 +120,11 @@ func NewContractorResponse(c *models.Contractor) ContractorResponse {
return resp
}
// NewContractorListResponse creates a ContractorListResponse from a slice of contractors
func NewContractorListResponse(contractors []models.Contractor) ContractorListResponse {
// NewContractorListResponse creates a list of contractor responses
func NewContractorListResponse(contractors []models.Contractor) []ContractorResponse {
results := make([]ContractorResponse, len(contractors))
for i, c := range contractors {
results[i] = NewContractorResponse(&c)
}
return ContractorListResponse{
Count: len(contractors),
Next: nil,
Previous: nil,
Results: results,
}
return results
}

View File

@@ -20,6 +20,7 @@ type DocumentUserResponse struct {
type DocumentResponse struct {
ID uint `json:"id"`
ResidenceID uint `json:"residence_id"`
Residence uint `json:"residence"` // Alias for residence_id (KMM compatibility)
CreatedByID uint `json:"created_by_id"`
CreatedBy *DocumentUserResponse `json:"created_by,omitempty"`
Title string `json:"title"`
@@ -41,13 +42,7 @@ type DocumentResponse struct {
UpdatedAt time.Time `json:"updated_at"`
}
// DocumentListResponse represents a paginated list of documents
type DocumentListResponse struct {
Count int `json:"count"`
Next *string `json:"next"`
Previous *string `json:"previous"`
Results []DocumentResponse `json:"results"`
}
// Note: Pagination removed - list endpoints now return arrays directly
// === Factory Functions ===
@@ -69,6 +64,7 @@ func NewDocumentResponse(d *models.Document) DocumentResponse {
resp := DocumentResponse{
ID: d.ID,
ResidenceID: d.ResidenceID,
Residence: d.ResidenceID, // Alias for KMM compatibility
CreatedByID: d.CreatedByID,
Title: d.Title,
Description: d.Description,
@@ -96,16 +92,11 @@ func NewDocumentResponse(d *models.Document) DocumentResponse {
return resp
}
// NewDocumentListResponse creates a DocumentListResponse from a slice of documents
func NewDocumentListResponse(documents []models.Document) DocumentListResponse {
// NewDocumentListResponse creates a list of document responses
func NewDocumentListResponse(documents []models.Document) []DocumentResponse {
results := make([]DocumentResponse, len(documents))
for i, d := range documents {
results[i] = NewDocumentResponse(&d)
}
return DocumentListResponse{
Count: len(documents),
Next: nil,
Previous: nil,
Results: results,
}
return results
}

View File

@@ -52,12 +52,20 @@ type ResidenceResponse struct {
UpdatedAt time.Time `json:"updated_at"`
}
// ResidenceListResponse represents the paginated list of residences
type ResidenceListResponse struct {
Count int `json:"count"`
Next *string `json:"next"`
Previous *string `json:"previous"`
Results []ResidenceResponse `json:"results"`
// TotalSummary represents summary statistics for all residences
type TotalSummary struct {
TotalResidences int `json:"total_residences"`
TotalTasks int `json:"total_tasks"`
TotalPending int `json:"total_pending"`
TotalOverdue int `json:"total_overdue"`
TasksDueNextWeek int `json:"tasks_due_next_week"`
TasksDueNextMonth int `json:"tasks_due_next_month"`
}
// MyResidencesResponse represents the response for my-residences endpoint
type MyResidencesResponse struct {
Residences []ResidenceResponse `json:"residences"`
Summary TotalSummary `json:"summary"`
}
// ShareCodeResponse represents a share code in the API response
@@ -160,19 +168,13 @@ func NewResidenceResponse(residence *models.Residence) ResidenceResponse {
return resp
}
// NewResidenceListResponse creates a paginated list response
func NewResidenceListResponse(residences []models.Residence) ResidenceListResponse {
// NewResidenceListResponse creates a list of residence responses
func NewResidenceListResponse(residences []models.Residence) []ResidenceResponse {
results := make([]ResidenceResponse, len(residences))
for i, r := range residences {
results[i] = NewResidenceResponse(&r)
}
return ResidenceListResponse{
Count: len(residences),
Next: nil, // Pagination not implemented yet
Previous: nil,
Results: results,
}
return results
}
// NewShareCodeResponse creates a ShareCodeResponse from a ResidenceShareCode model

View File

@@ -68,41 +68,36 @@ type TaskCompletionResponse struct {
// TaskResponse represents a task in the API response
type TaskResponse struct {
ID uint `json:"id"`
ResidenceID uint `json:"residence_id"`
CreatedByID uint `json:"created_by_id"`
CreatedBy *TaskUserResponse `json:"created_by,omitempty"`
AssignedToID *uint `json:"assigned_to_id"`
AssignedTo *TaskUserResponse `json:"assigned_to,omitempty"`
Title string `json:"title"`
Description string `json:"description"`
CategoryID *uint `json:"category_id"`
Category *TaskCategoryResponse `json:"category,omitempty"`
PriorityID *uint `json:"priority_id"`
Priority *TaskPriorityResponse `json:"priority,omitempty"`
StatusID *uint `json:"status_id"`
Status *TaskStatusResponse `json:"status,omitempty"`
FrequencyID *uint `json:"frequency_id"`
Frequency *TaskFrequencyResponse `json:"frequency,omitempty"`
DueDate *time.Time `json:"due_date"`
EstimatedCost *decimal.Decimal `json:"estimated_cost"`
ActualCost *decimal.Decimal `json:"actual_cost"`
ContractorID *uint `json:"contractor_id"`
IsCancelled bool `json:"is_cancelled"`
IsArchived bool `json:"is_archived"`
ParentTaskID *uint `json:"parent_task_id"`
Completions []TaskCompletionResponse `json:"completions,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ID uint `json:"id"`
ResidenceID uint `json:"residence_id"`
CreatedByID uint `json:"created_by_id"`
CreatedBy *TaskUserResponse `json:"created_by,omitempty"`
AssignedToID *uint `json:"assigned_to_id"`
AssignedTo *TaskUserResponse `json:"assigned_to,omitempty"`
Title string `json:"title"`
Description string `json:"description"`
CategoryID *uint `json:"category_id"`
Category *TaskCategoryResponse `json:"category,omitempty"`
PriorityID *uint `json:"priority_id"`
Priority *TaskPriorityResponse `json:"priority,omitempty"`
StatusID *uint `json:"status_id"`
Status *TaskStatusResponse `json:"status,omitempty"`
FrequencyID *uint `json:"frequency_id"`
Frequency *TaskFrequencyResponse `json:"frequency,omitempty"`
DueDate *time.Time `json:"due_date"`
EstimatedCost *decimal.Decimal `json:"estimated_cost"`
ActualCost *decimal.Decimal `json:"actual_cost"`
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"`
Completions []TaskCompletionResponse `json:"completions,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// TaskListResponse represents a paginated list of tasks
type TaskListResponse struct {
Count int `json:"count"`
Next *string `json:"next"`
Previous *string `json:"previous"`
Results []TaskResponse `json:"results"`
}
// Note: Pagination removed - list endpoints now return arrays directly
// KanbanColumnResponse represents a kanban column
type KanbanColumnResponse struct {
@@ -122,13 +117,7 @@ type KanbanBoardResponse struct {
ResidenceID string `json:"residence_id"`
}
// TaskCompletionListResponse represents a list of completions
type TaskCompletionListResponse struct {
Count int `json:"count"`
Next *string `json:"next"`
Previous *string `json:"previous"`
Results []TaskCompletionResponse `json:"results"`
}
// Note: TaskCompletionListResponse pagination removed - returns arrays directly
// === Factory Functions ===
@@ -222,25 +211,26 @@ func NewTaskCompletionResponse(c *models.TaskCompletion) TaskCompletionResponse
// NewTaskResponse creates a TaskResponse from a Task model
func NewTaskResponse(t *models.Task) TaskResponse {
resp := TaskResponse{
ID: t.ID,
ResidenceID: t.ResidenceID,
CreatedByID: t.CreatedByID,
Title: t.Title,
Description: t.Description,
CategoryID: t.CategoryID,
PriorityID: t.PriorityID,
StatusID: t.StatusID,
FrequencyID: t.FrequencyID,
AssignedToID: t.AssignedToID,
DueDate: t.DueDate,
EstimatedCost: t.EstimatedCost,
ActualCost: t.ActualCost,
ContractorID: t.ContractorID,
IsCancelled: t.IsCancelled,
IsArchived: t.IsArchived,
ParentTaskID: t.ParentTaskID,
CreatedAt: t.CreatedAt,
UpdatedAt: t.UpdatedAt,
ID: t.ID,
ResidenceID: t.ResidenceID,
CreatedByID: t.CreatedByID,
Title: t.Title,
Description: t.Description,
CategoryID: t.CategoryID,
PriorityID: t.PriorityID,
StatusID: t.StatusID,
FrequencyID: t.FrequencyID,
AssignedToID: t.AssignedToID,
DueDate: t.DueDate,
EstimatedCost: t.EstimatedCost,
ActualCost: t.ActualCost,
ContractorID: t.ContractorID,
IsCancelled: t.IsCancelled,
IsArchived: t.IsArchived,
ParentTaskID: t.ParentTaskID,
CompletionCount: len(t.Completions),
CreatedAt: t.CreatedAt,
UpdatedAt: t.UpdatedAt,
}
if t.CreatedBy.ID != 0 {
@@ -270,18 +260,13 @@ func NewTaskResponse(t *models.Task) TaskResponse {
return resp
}
// NewTaskListResponse creates a TaskListResponse from a slice of tasks
func NewTaskListResponse(tasks []models.Task) TaskListResponse {
// NewTaskListResponse creates a list of task responses
func NewTaskListResponse(tasks []models.Task) []TaskResponse {
results := make([]TaskResponse, len(tasks))
for i, t := range tasks {
results[i] = NewTaskResponse(&t)
}
return TaskListResponse{
Count: len(tasks),
Next: nil,
Previous: nil,
Results: results,
}
return results
}
// NewKanbanBoardResponse creates a KanbanBoardResponse from a KanbanBoard model
@@ -309,16 +294,36 @@ func NewKanbanBoardResponse(board *models.KanbanBoard, residenceID uint) KanbanB
}
}
// NewTaskCompletionListResponse creates a TaskCompletionListResponse
func NewTaskCompletionListResponse(completions []models.TaskCompletion) TaskCompletionListResponse {
// NewKanbanBoardResponseForAll creates a KanbanBoardResponse for all residences (no specific residence ID)
func NewKanbanBoardResponseForAll(board *models.KanbanBoard) KanbanBoardResponse {
columns := make([]KanbanColumnResponse, len(board.Columns))
for i, col := range board.Columns {
tasks := make([]TaskResponse, len(col.Tasks))
for j, t := range col.Tasks {
tasks[j] = NewTaskResponse(&t)
}
columns[i] = KanbanColumnResponse{
Name: col.Name,
DisplayName: col.DisplayName,
ButtonTypes: col.ButtonTypes,
Icons: col.Icons,
Color: col.Color,
Tasks: tasks,
Count: col.Count,
}
}
return KanbanBoardResponse{
Columns: columns,
DaysThreshold: board.DaysThreshold,
ResidenceID: "all",
}
}
// NewTaskCompletionListResponse creates a list of task completion responses
func NewTaskCompletionListResponse(completions []models.TaskCompletion) []TaskCompletionResponse {
results := make([]TaskCompletionResponse, len(completions))
for i, c := range completions {
results[i] = NewTaskCompletionResponse(&c)
}
return TaskCompletionListResponse{
Count: len(completions),
Next: nil,
Previous: nil,
Results: results,
}
return results
}