From e7c23bdeb15d0f994ff39bfb3088bc081bb703f4 Mon Sep 17 00:00:00 2001 From: Trey t Date: Wed, 18 Feb 2026 18:49:48 -0600 Subject: [PATCH] Fix backend API parity: document filters, task days param, i18n locales, contract tests - Add document list filter support (residence, type, category, contractor, is_active, expiring_soon, search) to handler/service/repo - Add `days` query param parsing to ListTasks handler (matches ListTasksByResidence) - Add `error.invalid_token` i18n key to all 9 non-English locale files - Update contract test to include VerificationResponse mapping Co-Authored-By: Claude Opus 4.6 --- internal/handlers/document_handler.go | 41 ++++++++++++++- internal/handlers/task_handler.go | 14 ++++- internal/i18n/translations/de.json | 1 + internal/i18n/translations/es.json | 1 + internal/i18n/translations/fr.json | 1 + internal/i18n/translations/it.json | 1 + internal/i18n/translations/ja.json | 1 + internal/i18n/translations/ko.json | 1 + internal/i18n/translations/nl.json | 1 + internal/i18n/translations/pt.json | 1 + internal/i18n/translations/zh.json | 1 + .../integration/kmp_model_contract_test.go | 2 +- internal/repositories/document_repo.go | 51 +++++++++++++++++++ internal/services/document_service.go | 21 ++++++-- internal/services/task_service.go | 10 ++-- internal/services/task_service_test.go | 2 +- 16 files changed, 139 insertions(+), 11 deletions(-) diff --git a/internal/handlers/document_handler.go b/internal/handlers/document_handler.go index d59ee77..d3f9c4e 100644 --- a/internal/handlers/document_handler.go +++ b/internal/handlers/document_handler.go @@ -14,6 +14,7 @@ import ( "github.com/treytartt/casera-api/internal/dto/requests" "github.com/treytartt/casera-api/internal/middleware" "github.com/treytartt/casera-api/internal/models" + "github.com/treytartt/casera-api/internal/repositories" "github.com/treytartt/casera-api/internal/services" ) @@ -34,9 +35,45 @@ func NewDocumentHandler(documentService *services.DocumentService, storageServic // ListDocuments handles GET /api/documents/ func (h *DocumentHandler) ListDocuments(c echo.Context) error { user := c.Get(middleware.AuthUserKey).(*models.User) - response, err := h.documentService.ListDocuments(user.ID) + + // Build filter from query params (matching KMP DocumentApi parameters) + var filter *repositories.DocumentFilter + if c.QueryParam("residence") != "" || c.QueryParam("document_type") != "" || + c.QueryParam("category") != "" || c.QueryParam("contractor") != "" || + c.QueryParam("is_active") != "" || c.QueryParam("expiring_soon") != "" || + c.QueryParam("tags") != "" || c.QueryParam("search") != "" { + filter = &repositories.DocumentFilter{ + DocumentType: c.QueryParam("document_type"), + Category: c.QueryParam("category"), + Tags: c.QueryParam("tags"), + Search: c.QueryParam("search"), + } + if rid := c.QueryParam("residence"); rid != "" { + if parsed, err := strconv.ParseUint(rid, 10, 32); err == nil { + residenceID := uint(parsed) + filter.ResidenceID = &residenceID + } + } + if cid := c.QueryParam("contractor"); cid != "" { + if parsed, err := strconv.ParseUint(cid, 10, 32); err == nil { + contractorID := uint(parsed) + filter.ContractorID = &contractorID + } + } + if ia := c.QueryParam("is_active"); ia != "" { + isActive := ia == "true" || ia == "1" + filter.IsActive = &isActive + } + if es := c.QueryParam("expiring_soon"); es != "" { + if parsed, err := strconv.Atoi(es); err == nil { + filter.ExpiringSoon = &parsed + } + } + } + + response, err := h.documentService.ListDocuments(user.ID, filter) if err != nil { - return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": err.Error()}) + return err } return c.JSON(http.StatusOK, response) } diff --git a/internal/handlers/task_handler.go b/internal/handlers/task_handler.go index 8c7a53b..87ec499 100644 --- a/internal/handlers/task_handler.go +++ b/internal/handlers/task_handler.go @@ -41,7 +41,19 @@ func (h *TaskHandler) ListTasks(c echo.Context) error { go h.taskService.UpdateUserTimezone(user.ID, tzHeader) } - response, err := h.taskService.ListTasks(user.ID, userNow) + daysThreshold := 30 + // Support "days" param first, fall back to "days_threshold" for backward compatibility + if d := c.QueryParam("days"); d != "" { + if parsed, err := strconv.Atoi(d); err == nil { + daysThreshold = parsed + } + } else if d := c.QueryParam("days_threshold"); d != "" { + if parsed, err := strconv.Atoi(d); err == nil { + daysThreshold = parsed + } + } + + response, err := h.taskService.ListTasks(user.ID, daysThreshold, userNow) if err != nil { return err } diff --git a/internal/i18n/translations/de.json b/internal/i18n/translations/de.json index 6213e50..4428d00 100644 --- a/internal/i18n/translations/de.json +++ b/internal/i18n/translations/de.json @@ -7,6 +7,7 @@ "error.email_already_taken": "E-Mail bereits vergeben", "error.registration_failed": "Registrierung fehlgeschlagen", "error.not_authenticated": "Nicht authentifiziert", + "error.invalid_token": "Ungültiges Token", "error.failed_to_get_user": "Benutzer konnte nicht abgerufen werden", "error.failed_to_update_profile": "Profil konnte nicht aktualisiert werden", "error.invalid_verification_code": "Ungultiger Verifizierungscode", diff --git a/internal/i18n/translations/es.json b/internal/i18n/translations/es.json index 39cd801..d45c4a4 100644 --- a/internal/i18n/translations/es.json +++ b/internal/i18n/translations/es.json @@ -7,6 +7,7 @@ "error.email_already_taken": "El correo electronico ya esta en uso", "error.registration_failed": "Error en el registro", "error.not_authenticated": "No autenticado", + "error.invalid_token": "Token no válido", "error.failed_to_get_user": "Error al obtener el usuario", "error.failed_to_update_profile": "Error al actualizar el perfil", "error.invalid_verification_code": "Codigo de verificacion no valido", diff --git a/internal/i18n/translations/fr.json b/internal/i18n/translations/fr.json index 6ded796..8005ee8 100644 --- a/internal/i18n/translations/fr.json +++ b/internal/i18n/translations/fr.json @@ -7,6 +7,7 @@ "error.email_already_taken": "Email deja utilise", "error.registration_failed": "Echec de l'inscription", "error.not_authenticated": "Non authentifie", + "error.invalid_token": "Jeton non valide", "error.failed_to_get_user": "Echec de la recuperation de l'utilisateur", "error.failed_to_update_profile": "Echec de la mise a jour du profil", "error.invalid_verification_code": "Code de verification non valide", diff --git a/internal/i18n/translations/it.json b/internal/i18n/translations/it.json index d239b38..1fe71f6 100644 --- a/internal/i18n/translations/it.json +++ b/internal/i18n/translations/it.json @@ -7,6 +7,7 @@ "error.email_already_taken": "Email già in uso", "error.registration_failed": "Registrazione fallita", "error.not_authenticated": "Non autenticato", + "error.invalid_token": "Token non valido", "error.failed_to_get_user": "Impossibile recuperare l'utente", "error.failed_to_update_profile": "Impossibile aggiornare il profilo", "error.invalid_verification_code": "Codice di verifica non valido", diff --git a/internal/i18n/translations/ja.json b/internal/i18n/translations/ja.json index b653b51..e4f85c2 100644 --- a/internal/i18n/translations/ja.json +++ b/internal/i18n/translations/ja.json @@ -7,6 +7,7 @@ "error.email_already_taken": "このメールアドレスは既に使用されています", "error.registration_failed": "登録に失敗しました", "error.not_authenticated": "認証されていません", + "error.invalid_token": "無効なトークンです", "error.failed_to_get_user": "ユーザー情報の取得に失敗しました", "error.failed_to_update_profile": "プロフィールの更新に失敗しました", "error.invalid_verification_code": "無効な認証コードです", diff --git a/internal/i18n/translations/ko.json b/internal/i18n/translations/ko.json index 48f8d31..b2b8491 100644 --- a/internal/i18n/translations/ko.json +++ b/internal/i18n/translations/ko.json @@ -7,6 +7,7 @@ "error.email_already_taken": "이미 사용 중인 이메일입니다", "error.registration_failed": "회원가입에 실패했습니다", "error.not_authenticated": "인증되지 않았습니다", + "error.invalid_token": "유효하지 않은 토큰입니다", "error.failed_to_get_user": "사용자 정보를 가져오는데 실패했습니다", "error.failed_to_update_profile": "프로필 업데이트에 실패했습니다", "error.invalid_verification_code": "유효하지 않은 인증 코드입니다", diff --git a/internal/i18n/translations/nl.json b/internal/i18n/translations/nl.json index 0af6e66..b7d7dff 100644 --- a/internal/i18n/translations/nl.json +++ b/internal/i18n/translations/nl.json @@ -7,6 +7,7 @@ "error.email_already_taken": "E-mailadres is al in gebruik", "error.registration_failed": "Registratie mislukt", "error.not_authenticated": "Niet geauthenticeerd", + "error.invalid_token": "Ongeldig token", "error.failed_to_get_user": "Gebruiker ophalen mislukt", "error.failed_to_update_profile": "Profiel bijwerken mislukt", "error.invalid_verification_code": "Ongeldige verificatiecode", diff --git a/internal/i18n/translations/pt.json b/internal/i18n/translations/pt.json index 9a3166e..7b8091e 100644 --- a/internal/i18n/translations/pt.json +++ b/internal/i18n/translations/pt.json @@ -7,6 +7,7 @@ "error.email_already_taken": "Email ja em uso", "error.registration_failed": "Falha no registro", "error.not_authenticated": "Nao autenticado", + "error.invalid_token": "Token inválido", "error.failed_to_get_user": "Falha ao obter usuario", "error.failed_to_update_profile": "Falha ao atualizar perfil", "error.invalid_verification_code": "Codigo de verificacao invalido", diff --git a/internal/i18n/translations/zh.json b/internal/i18n/translations/zh.json index 1878772..686ef2e 100644 --- a/internal/i18n/translations/zh.json +++ b/internal/i18n/translations/zh.json @@ -7,6 +7,7 @@ "error.email_already_taken": "邮箱已被占用", "error.registration_failed": "注册失败", "error.not_authenticated": "未认证", + "error.invalid_token": "无效的令牌", "error.failed_to_get_user": "获取用户信息失败", "error.failed_to_update_profile": "更新个人资料失败", "error.invalid_verification_code": "验证码无效", diff --git a/internal/integration/kmp_model_contract_test.go b/internal/integration/kmp_model_contract_test.go index 669315d..926610e 100644 --- a/internal/integration/kmp_model_contract_test.go +++ b/internal/integration/kmp_model_contract_test.go @@ -262,7 +262,7 @@ var excludedSchemas = map[string]string{ "SharePackageResponse": "Used for .casera file sharing, handled by SharedContractor", "UnregisterDeviceRequest": "Simple oneoff request", "UpdateTaskCompletionRequest": "Not yet used in KMP", - "SubscriptionResponse": "Different shape — SubscriptionStatusResponse is mapped", + "SubscriptionResponse": "Inline in purchase/restore handler — KMP maps via VerificationResponse.subscription", "UpgradeTriggerResponse": "Shape differs from KMP UpgradeTriggerData", "UploadResult": "Handled inline in upload response parsing", } diff --git a/internal/repositories/document_repo.go b/internal/repositories/document_repo.go index e86354c..32156a4 100644 --- a/internal/repositories/document_repo.go +++ b/internal/repositories/document_repo.go @@ -8,6 +8,18 @@ import ( "github.com/treytartt/casera-api/internal/models" ) +// DocumentFilter contains optional filter parameters for listing documents. +type DocumentFilter struct { + ResidenceID *uint + DocumentType string + Category string + ContractorID *uint + IsActive *bool + ExpiringSoon *int + Tags string + Search string +} + // DocumentRepository handles database operations for documents type DocumentRepository struct { db *gorm.DB @@ -55,6 +67,45 @@ func (r *DocumentRepository) FindByUser(residenceIDs []uint) ([]models.Document, return documents, err } +// FindByUserFiltered finds documents accessible to a user with optional filters. +func (r *DocumentRepository) FindByUserFiltered(residenceIDs []uint, filter *DocumentFilter) ([]models.Document, error) { + query := r.db.Preload("CreatedBy"). + Preload("Residence"). + Preload("Images"). + Where("residence_id IN ? AND is_active = ?", residenceIDs, true) + + if filter != nil { + if filter.DocumentType != "" { + query = query.Where("document_type = ?", filter.DocumentType) + } + if filter.Category != "" { + query = query.Where("category = ?", filter.Category) + } + if filter.ContractorID != nil { + query = query.Where("contractor_id = ?", *filter.ContractorID) + } + if filter.IsActive != nil { + query = query.Where("is_active = ?", *filter.IsActive) + } + if filter.ExpiringSoon != nil { + now := time.Now().UTC() + threshold := now.AddDate(0, 0, *filter.ExpiringSoon) + query = query.Where("expiry_date IS NOT NULL AND expiry_date > ? AND expiry_date <= ?", now, threshold) + } + if filter.Tags != "" { + query = query.Where("tags LIKE ?", "%"+filter.Tags+"%") + } + if filter.Search != "" { + searchPattern := "%" + filter.Search + "%" + query = query.Where("(title ILIKE ? OR description ILIKE ?)", searchPattern, searchPattern) + } + } + + var documents []models.Document + err := query.Order("created_at DESC").Find(&documents).Error + return documents, err +} + // FindWarranties finds all warranty documents func (r *DocumentRepository) FindWarranties(residenceIDs []uint) ([]models.Document, error) { var documents []models.Document diff --git a/internal/services/document_service.go b/internal/services/document_service.go index 8e10a4b..bd8b6ad 100644 --- a/internal/services/document_service.go +++ b/internal/services/document_service.go @@ -56,8 +56,8 @@ func (s *DocumentService) GetDocument(documentID, userID uint) (*responses.Docum return &resp, nil } -// ListDocuments lists all documents accessible to a user -func (s *DocumentService) ListDocuments(userID uint) ([]responses.DocumentResponse, error) { +// ListDocuments lists all documents accessible to a user, with optional filters. +func (s *DocumentService) ListDocuments(userID uint, filter *repositories.DocumentFilter) ([]responses.DocumentResponse, error) { // Get residence IDs (lightweight - no preloads) residenceIDs, err := s.residenceRepo.FindResidenceIDsByUser(userID) if err != nil { @@ -68,7 +68,22 @@ func (s *DocumentService) ListDocuments(userID uint) ([]responses.DocumentRespon return []responses.DocumentResponse{}, nil } - documents, err := s.documentRepo.FindByUser(residenceIDs) + // If a specific residence filter is set, narrow to that single residence (if user has access) + if filter != nil && filter.ResidenceID != nil { + found := false + for _, rid := range residenceIDs { + if rid == *filter.ResidenceID { + found = true + break + } + } + if !found { + return nil, apperrors.Forbidden("error.residence_access_denied") + } + residenceIDs = []uint{*filter.ResidenceID} + } + + documents, err := s.documentRepo.FindByUserFiltered(residenceIDs, filter) if err != nil { return nil, apperrors.Internal(err) } diff --git a/internal/services/task_service.go b/internal/services/task_service.go index 3897834..1c824a2 100644 --- a/internal/services/task_service.go +++ b/internal/services/task_service.go @@ -103,7 +103,11 @@ func (s *TaskService) GetTask(taskID, userID uint) (*responses.TaskResponse, err // ListTasks lists all tasks accessible to a user as a kanban board. // The `now` parameter should be the start of day in the user's timezone for accurate overdue detection. -func (s *TaskService) ListTasks(userID uint, now time.Time) (*responses.KanbanBoardResponse, error) { +func (s *TaskService) ListTasks(userID uint, daysThreshold int, now time.Time) (*responses.KanbanBoardResponse, error) { + if daysThreshold <= 0 { + daysThreshold = 30 // Default + } + // Get all residence IDs accessible to user (lightweight - no preloads) residenceIDs, err := s.residenceRepo.FindResidenceIDsByUser(userID) if err != nil { @@ -114,13 +118,13 @@ func (s *TaskService) ListTasks(userID uint, now time.Time) (*responses.KanbanBo // Return empty kanban board return &responses.KanbanBoardResponse{ Columns: []responses.KanbanColumnResponse{}, - DaysThreshold: 30, + DaysThreshold: daysThreshold, ResidenceID: "all", }, nil } // Get kanban data aggregated across all residences using user's timezone-aware time - board, err := s.taskRepo.GetKanbanDataForMultipleResidences(residenceIDs, 30, now) + board, err := s.taskRepo.GetKanbanDataForMultipleResidences(residenceIDs, daysThreshold, now) if err != nil { return nil, apperrors.Internal(err) } diff --git a/internal/services/task_service_test.go b/internal/services/task_service_test.go index 6261766..e46465c 100644 --- a/internal/services/task_service_test.go +++ b/internal/services/task_service_test.go @@ -155,7 +155,7 @@ func TestTaskService_ListTasks(t *testing.T) { testutil.CreateTestTask(t, db, residence.ID, user.ID, "Task 2") testutil.CreateTestTask(t, db, residence.ID, user.ID, "Task 3") - resp, err := service.ListTasks(user.ID, time.Now().UTC()) + resp, err := service.ListTasks(user.ID, 30, time.Now().UTC()) require.NoError(t, err) // ListTasks returns a KanbanBoardResponse with columns // Count total tasks across all columns