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 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-02-18 18:49:48 -06:00
parent bb7493f033
commit e7c23bdeb1
16 changed files with 139 additions and 11 deletions

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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": "無効な認証コードです",

View File

@@ -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": "유효하지 않은 인증 코드입니다",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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": "验证码无效",

View File

@@ -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",
}

View File

@@ -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

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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