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:
@@ -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"`
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user