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:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "無効な認証コードです",
|
||||
|
||||
@@ -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": "유효하지 않은 인증 코드입니다",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "验证码无效",
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user