Align document handlers/repo with contract updates

This commit is contained in:
Trey t
2026-02-18 21:37:38 -06:00
parent e7c23bdeb1
commit 09a35c0b99
5 changed files with 156 additions and 131 deletions

View File

@@ -1571,6 +1571,34 @@ paths:
summary: List all documents accessible to the user
security:
- tokenAuth: []
parameters:
- in: query
name: residence
description: Filter by residence ID
schema:
type: integer
format: uint
- in: query
name: document_type
description: Filter by document type
schema:
type: string
- in: query
name: is_active
description: Filter by active/inactive status (default true)
schema:
type: boolean
- in: query
name: expiring_soon
description: Return warranties expiring in N days
schema:
type: integer
minimum: 1
- in: query
name: search
description: Case-insensitive search over title/description
schema:
type: string
responses:
'200':
description: List of documents

View File

@@ -36,16 +36,13 @@ func NewDocumentHandler(documentService *services.DocumentService, storageServic
func (h *DocumentHandler) ListDocuments(c echo.Context) error {
user := c.Get(middleware.AuthUserKey).(*models.User)
// Build filter from query params (matching KMP DocumentApi parameters)
// Build filter from supported query params.
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") != "" {
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 != "" {
@@ -54,12 +51,6 @@ func (h *DocumentHandler) ListDocuments(c echo.Context) error {
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

View File

@@ -31,7 +31,9 @@ func TestDocumentHandler_ListDocuments(t *testing.T) {
handler, e, db := setupDocumentHandler(t)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
testutil.CreateTestDocument(t, db, residence.ID, user.ID, "Test Doc")
activeDoc := testutil.CreateTestDocument(t, db, residence.ID, user.ID, "Test Doc")
inactiveDoc := testutil.CreateTestDocument(t, db, residence.ID, user.ID, "Old Doc")
require.NoError(t, db.Model(&inactiveDoc).Update("is_active", false).Error)
authGroup := e.Group("/api/documents")
authGroup.Use(testutil.MockAuthMiddleware(user))
@@ -45,7 +47,18 @@ func TestDocumentHandler_ListDocuments(t *testing.T) {
err := json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
assert.Len(t, response, 1)
assert.Equal(t, "Test Doc", response[0]["title"])
assert.Equal(t, activeDoc.Title, response[0]["title"])
})
t.Run("can list inactive documents when requested", func(t *testing.T) {
w := testutil.MakeRequest(e, "GET", "/api/documents/?is_active=false", nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusOK)
var response []map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
assert.Len(t, response, 1)
assert.Equal(t, inactiveDoc.Title, response[0]["title"])
})
}

View File

@@ -162,9 +162,9 @@ type classMapping struct {
var schemaToKMPClass = map[string]classMapping{
// Auth
"LoginRequest": {kmpClassName: "LoginRequest"},
"RegisterRequest": {kmpClassName: "RegisterRequest"},
"ForgotPasswordRequest": {kmpClassName: "ForgotPasswordRequest"},
"LoginRequest": {kmpClassName: "LoginRequest"},
"RegisterRequest": {kmpClassName: "RegisterRequest"},
"ForgotPasswordRequest": {kmpClassName: "ForgotPasswordRequest"},
"VerifyResetCodeRequest": {kmpClassName: "VerifyResetCodeRequest"},
"ResetPasswordRequest": {kmpClassName: "ResetPasswordRequest"},
"UpdateProfileRequest": {kmpClassName: "UpdateProfileRequest"},
@@ -180,41 +180,41 @@ var schemaToKMPClass = map[string]classMapping{
"VerifyResetCodeResponse": {kmpClassName: "VerifyResetCodeResponse"},
// Lookups
"ResidenceTypeResponse": {kmpClassName: "ResidenceType"},
"TaskCategoryResponse": {kmpClassName: "TaskCategory"},
"TaskPriorityResponse": {kmpClassName: "TaskPriority"},
"TaskFrequencyResponse": {kmpClassName: "TaskFrequency"},
"ContractorSpecialtyResponse": {kmpClassName: "ContractorSpecialty"},
"SeededDataResponse": {kmpClassName: "SeededDataResponse"},
"TaskTemplateResponse": {kmpClassName: "TaskTemplate"},
"TaskTemplateCategoryGroup": {kmpClassName: "TaskTemplateCategoryGroup"},
"ResidenceTypeResponse": {kmpClassName: "ResidenceType"},
"TaskCategoryResponse": {kmpClassName: "TaskCategory"},
"TaskPriorityResponse": {kmpClassName: "TaskPriority"},
"TaskFrequencyResponse": {kmpClassName: "TaskFrequency"},
"ContractorSpecialtyResponse": {kmpClassName: "ContractorSpecialty"},
"SeededDataResponse": {kmpClassName: "SeededDataResponse"},
"TaskTemplateResponse": {kmpClassName: "TaskTemplate"},
"TaskTemplateCategoryGroup": {kmpClassName: "TaskTemplateCategoryGroup"},
"TaskTemplatesGroupedResponse": {kmpClassName: "TaskTemplatesGroupedResponse"},
// Residence
"CreateResidenceRequest": {kmpClassName: "ResidenceCreateRequest"},
"UpdateResidenceRequest": {kmpClassName: "ResidenceUpdateRequest"},
"JoinWithCodeRequest": {kmpClassName: "JoinResidenceRequest"},
"GenerateShareCodeRequest": {kmpClassName: "GenerateShareCodeRequest"},
"ResidenceUserResponse": {kmpClassName: "ResidenceUserResponse"},
"ResidenceResponse": {kmpClassName: "ResidenceResponse"},
"TotalSummary": {kmpClassName: "TotalSummary"},
"MyResidencesResponse": {kmpClassName: "MyResidencesResponse"},
"ShareCodeResponse": {kmpClassName: "ShareCodeResponse"},
"JoinResidenceResponse": {kmpClassName: "JoinResidenceResponse"},
"CreateResidenceRequest": {kmpClassName: "ResidenceCreateRequest"},
"UpdateResidenceRequest": {kmpClassName: "ResidenceUpdateRequest"},
"JoinWithCodeRequest": {kmpClassName: "JoinResidenceRequest"},
"GenerateShareCodeRequest": {kmpClassName: "GenerateShareCodeRequest"},
"ResidenceUserResponse": {kmpClassName: "ResidenceUserResponse"},
"ResidenceResponse": {kmpClassName: "ResidenceResponse"},
"TotalSummary": {kmpClassName: "TotalSummary"},
"MyResidencesResponse": {kmpClassName: "MyResidencesResponse"},
"ShareCodeResponse": {kmpClassName: "ShareCodeResponse"},
"JoinResidenceResponse": {kmpClassName: "JoinResidenceResponse"},
"GenerateShareCodeResponse": {kmpClassName: "GenerateShareCodeResponse"},
// Task
"CreateTaskRequest": {kmpClassName: "TaskCreateRequest"},
"UpdateTaskRequest": {kmpClassName: "TaskUpdateRequest"},
"TaskUserResponse": {kmpClassName: "TaskUserResponse"},
"TaskResponse": {kmpClassName: "TaskResponse"},
"CreateTaskRequest": {kmpClassName: "TaskCreateRequest"},
"UpdateTaskRequest": {kmpClassName: "TaskUpdateRequest"},
"TaskUserResponse": {kmpClassName: "TaskUserResponse"},
"TaskResponse": {kmpClassName: "TaskResponse"},
"KanbanColumnResponse": {kmpClassName: "TaskColumn"},
"KanbanBoardResponse": {kmpClassName: "TaskColumnsResponse"},
// Task Completion
"CreateTaskCompletionRequest": {kmpClassName: "TaskCompletionCreateRequest"},
"TaskCompletionImageResponse": {kmpClassName: "TaskCompletionImage"},
"TaskCompletionResponse": {kmpClassName: "TaskCompletionResponse"},
"CreateTaskCompletionRequest": {kmpClassName: "TaskCompletionCreateRequest"},
"TaskCompletionImageResponse": {kmpClassName: "TaskCompletionImage"},
"TaskCompletionResponse": {kmpClassName: "TaskCompletionResponse"},
// Contractor
"CreateContractorRequest": {kmpClassName: "ContractorCreateRequest"},
@@ -228,15 +228,16 @@ var schemaToKMPClass = map[string]classMapping{
"DocumentResponse": {kmpClassName: "Document"},
// Notification
"RegisterDeviceRequest": {kmpClassName: "DeviceRegistrationRequest"},
"DeviceResponse": {kmpClassName: "DeviceRegistrationResponse"},
"NotificationPreference": {kmpClassName: "NotificationPreference"},
"UpdatePreferencesRequest": {kmpClassName: "UpdateNotificationPreferencesRequest"},
"Notification": {kmpClassName: "Notification"},
"NotificationListResponse": {kmpClassName: "NotificationListResponse"},
"RegisterDeviceRequest": {kmpClassName: "DeviceRegistrationRequest"},
"DeviceResponse": {kmpClassName: "DeviceRegistrationResponse"},
"NotificationPreference": {kmpClassName: "NotificationPreference"},
"UpdatePreferencesRequest": {kmpClassName: "UpdateNotificationPreferencesRequest"},
"Notification": {kmpClassName: "Notification"},
"NotificationListResponse": {kmpClassName: "NotificationListResponse"},
// Subscription
"SubscriptionStatusResponse": {kmpClassName: "SubscriptionStatus"},
"SubscriptionResponse": {kmpClassName: "VerificationSubscriptionInfo"},
"UsageResponse": {kmpClassName: "UsageStats"},
"TierLimitsClientResponse": {kmpClassName: "TierLimits"},
"FeatureBenefit": {kmpClassName: "FeatureBenefit"},
@@ -249,43 +250,42 @@ var schemaToKMPClass = map[string]classMapping{
// excludedSchemas are spec schemas intentionally not mapped to KMP classes.
var excludedSchemas = map[string]string{
"TaskWithSummaryResponse": "KMP uses generic WithSummaryResponse<T>",
"DeleteWithSummaryResponse": "KMP uses generic WithSummaryResponse<T>",
"ResidenceWithSummaryResponse": "KMP uses generic WithSummaryResponse<T>",
"TaskWithSummaryResponse": "KMP uses generic WithSummaryResponse<T>",
"DeleteWithSummaryResponse": "KMP uses generic WithSummaryResponse<T>",
"ResidenceWithSummaryResponse": "KMP uses generic WithSummaryResponse<T>",
"ResidenceDeleteWithSummaryResponse": "KMP uses generic WithSummaryResponse<T>",
"TaskCompletionWithSummaryResponse": "KMP uses generic WithSummaryResponse<T>",
"ProcessPurchaseRequest": "KMP splits into platform-specific requests",
"CurrentUserResponse": "KMP unifies into User class",
"DocumentType": "Enum — KMP uses DocumentType enum class",
"NotificationType": "Enum — KMP uses String",
"ToggleFavoriteResponse": "Simple message+bool, not worth a dedicated mapping",
"SharePackageResponse": "Used for .casera file sharing, handled by SharedContractor",
"UnregisterDeviceRequest": "Simple oneoff request",
"UpdateTaskCompletionRequest": "Not yet used in KMP",
"SubscriptionResponse": "Inline in purchase/restore handler — KMP maps via VerificationResponse.subscription",
"UpgradeTriggerResponse": "Shape differs from KMP UpgradeTriggerData",
"UploadResult": "Handled inline in upload response parsing",
"TaskCompletionWithSummaryResponse": "KMP uses generic WithSummaryResponse<T>",
"ProcessPurchaseRequest": "KMP splits into platform-specific requests",
"CurrentUserResponse": "KMP unifies into User class",
"DocumentType": "Enum — KMP uses DocumentType enum class",
"NotificationType": "Enum — KMP uses String",
"ToggleFavoriteResponse": "Simple message+bool, not worth a dedicated mapping",
"SharePackageResponse": "Used for .casera file sharing, handled by SharedContractor",
"UnregisterDeviceRequest": "Simple oneoff request",
"UpdateTaskCompletionRequest": "Not yet used in KMP",
"UpgradeTriggerResponse": "Shape differs from KMP UpgradeTriggerData",
"UploadResult": "Handled inline in upload response parsing",
}
// knownTypeOverrides documents intentional type differences.
var knownTypeOverrides = map[string]string{
// Spec says string (decimal), KMP uses Double for form binding
"TaskResponse.estimated_cost": "KMP uses Double for numeric form binding",
"TaskResponse.actual_cost": "KMP uses Double for numeric form binding",
"CreateTaskRequest.estimated_cost": "KMP uses Double for numeric form binding",
"UpdateTaskRequest.estimated_cost": "KMP uses Double for numeric form binding",
"UpdateTaskRequest.actual_cost": "KMP uses Double for numeric form binding",
"ResidenceResponse.bathrooms": "KMP uses Double for numeric form binding",
"ResidenceResponse.lot_size": "KMP uses Double for numeric form binding",
"ResidenceResponse.purchase_price": "KMP uses Double for numeric form binding",
"CreateResidenceRequest.bathrooms": "KMP uses Double for numeric form binding",
"CreateResidenceRequest.lot_size": "KMP uses Double for numeric form binding",
"CreateResidenceRequest.purchase_price": "KMP uses Double for numeric form binding",
"UpdateResidenceRequest.bathrooms": "KMP uses Double for numeric form binding",
"UpdateResidenceRequest.lot_size": "KMP uses Double for numeric form binding",
"UpdateResidenceRequest.purchase_price": "KMP uses Double for numeric form binding",
"TaskResponse.estimated_cost": "KMP uses Double for numeric form binding",
"TaskResponse.actual_cost": "KMP uses Double for numeric form binding",
"CreateTaskRequest.estimated_cost": "KMP uses Double for numeric form binding",
"UpdateTaskRequest.estimated_cost": "KMP uses Double for numeric form binding",
"UpdateTaskRequest.actual_cost": "KMP uses Double for numeric form binding",
"ResidenceResponse.bathrooms": "KMP uses Double for numeric form binding",
"ResidenceResponse.lot_size": "KMP uses Double for numeric form binding",
"ResidenceResponse.purchase_price": "KMP uses Double for numeric form binding",
"CreateResidenceRequest.bathrooms": "KMP uses Double for numeric form binding",
"CreateResidenceRequest.lot_size": "KMP uses Double for numeric form binding",
"CreateResidenceRequest.purchase_price": "KMP uses Double for numeric form binding",
"UpdateResidenceRequest.bathrooms": "KMP uses Double for numeric form binding",
"UpdateResidenceRequest.lot_size": "KMP uses Double for numeric form binding",
"UpdateResidenceRequest.purchase_price": "KMP uses Double for numeric form binding",
"CreateTaskCompletionRequest.actual_cost": "KMP uses Double for numeric form binding",
"TaskCompletionResponse.actual_cost": "KMP uses Double for numeric form binding",
"TaskCompletionResponse.actual_cost": "KMP uses Double for numeric form binding",
// Spec says nullable Boolean, KMP uses non-nullable Boolean (defaults to false)
"CreateContractorRequest.is_favorite": "KMP defaults is_favorite to false, not nullable",
@@ -308,50 +308,50 @@ var knownTypeOverrides = map[string]string{
// knownMissingFromKMP: spec fields intentionally absent from KMP.
var knownMissingFromKMP = map[string]string{
"ErrorResponse.details": "KMP uses 'errors' field with different type",
"TaskTemplateResponse.created_at": "KMP doesn't use template timestamps",
"TaskTemplateResponse.updated_at": "KMP doesn't use template timestamps",
"Notification.user_id": "KMP doesn't need user_id on notifications",
"Notification.error_message": "KMP doesn't surface notification error messages",
"Notification.updated_at": "KMP doesn't use notification updated_at",
"NotificationPreference.id": "KMP doesn't need preference record ID",
"NotificationPreference.user_id": "KMP doesn't need user_id on preferences",
"FeatureBenefit.id": "KMP doesn't use benefit record ID",
"FeatureBenefit.display_order": "KMP doesn't use benefit display order",
"FeatureBenefit.is_active": "KMP doesn't filter by active status",
"Promotion.id": "KMP uses promotion_id string instead",
"Promotion.start_date": "KMP doesn't filter by promotion dates",
"Promotion.end_date": "KMP doesn't filter by promotion dates",
"Promotion.target_tier": "KMP doesn't filter by target tier",
"Promotion.is_active": "KMP doesn't filter by active status",
"LoginRequest.email": "Spec allows email login, KMP only sends username",
"ErrorResponse.details": "KMP uses 'errors' field with different type",
"TaskTemplateResponse.created_at": "KMP doesn't use template timestamps",
"TaskTemplateResponse.updated_at": "KMP doesn't use template timestamps",
"Notification.user_id": "KMP doesn't need user_id on notifications",
"Notification.error_message": "KMP doesn't surface notification error messages",
"Notification.updated_at": "KMP doesn't use notification updated_at",
"NotificationPreference.id": "KMP doesn't need preference record ID",
"NotificationPreference.user_id": "KMP doesn't need user_id on preferences",
"FeatureBenefit.id": "KMP doesn't use benefit record ID",
"FeatureBenefit.display_order": "KMP doesn't use benefit display order",
"FeatureBenefit.is_active": "KMP doesn't filter by active status",
"Promotion.id": "KMP uses promotion_id string instead",
"Promotion.start_date": "KMP doesn't filter by promotion dates",
"Promotion.end_date": "KMP doesn't filter by promotion dates",
"Promotion.target_tier": "KMP doesn't filter by target tier",
"Promotion.is_active": "KMP doesn't filter by active status",
"LoginRequest.email": "Spec allows email login, KMP only sends username",
// Document create/update file fields — KMP handles file upload via multipart, not JSON
"CreateDocumentRequest.file_name": "KMP handles file upload via multipart, not JSON fields",
"CreateDocumentRequest.file_size": "KMP handles file upload via multipart, not JSON fields",
"CreateDocumentRequest.mime_type": "KMP handles file upload via multipart, not JSON fields",
"CreateDocumentRequest.file_url": "KMP handles file upload via multipart, not JSON fields",
"UpdateDocumentRequest.file_name": "KMP handles file upload via multipart, not JSON fields",
"UpdateDocumentRequest.file_size": "KMP handles file upload via multipart, not JSON fields",
"UpdateDocumentRequest.mime_type": "KMP handles file upload via multipart, not JSON fields",
"UpdateDocumentRequest.file_url": "KMP handles file upload via multipart, not JSON fields",
"CreateDocumentRequest.file_name": "KMP handles file upload via multipart, not JSON fields",
"CreateDocumentRequest.file_size": "KMP handles file upload via multipart, not JSON fields",
"CreateDocumentRequest.mime_type": "KMP handles file upload via multipart, not JSON fields",
"CreateDocumentRequest.file_url": "KMP handles file upload via multipart, not JSON fields",
"UpdateDocumentRequest.file_name": "KMP handles file upload via multipart, not JSON fields",
"UpdateDocumentRequest.file_size": "KMP handles file upload via multipart, not JSON fields",
"UpdateDocumentRequest.mime_type": "KMP handles file upload via multipart, not JSON fields",
"UpdateDocumentRequest.file_url": "KMP handles file upload via multipart, not JSON fields",
}
// knownExtraInKMP: KMP fields not in the spec (client-side additions).
var knownExtraInKMP = map[string]string{
// Document client-side fields
"DocumentResponse.category": "Client-side field for UI grouping",
"DocumentResponse.tags": "Client-side field",
"DocumentResponse.notes": "Client-side field",
"DocumentResponse.item_name": "Client-side warranty field",
"DocumentResponse.provider": "Client-side warranty field",
"DocumentResponse.provider_contact": "Client-side warranty field",
"DocumentResponse.claim_phone": "Client-side warranty field",
"DocumentResponse.claim_email": "Client-side warranty field",
"DocumentResponse.claim_website": "Client-side warranty field",
"DocumentResponse.start_date": "Client-side warranty field",
"DocumentResponse.category": "Client-side field for UI grouping",
"DocumentResponse.tags": "Client-side field",
"DocumentResponse.notes": "Client-side field",
"DocumentResponse.item_name": "Client-side warranty field",
"DocumentResponse.provider": "Client-side warranty field",
"DocumentResponse.provider_contact": "Client-side warranty field",
"DocumentResponse.claim_phone": "Client-side warranty field",
"DocumentResponse.claim_email": "Client-side warranty field",
"DocumentResponse.claim_website": "Client-side warranty field",
"DocumentResponse.start_date": "Client-side warranty field",
"DocumentResponse.days_until_expiration": "Client-side computed field",
"DocumentResponse.warranty_status": "Client-side computed field",
"DocumentResponse.warranty_status": "Client-side computed field",
// DocumentImage extra fields
"DocumentImageResponse.uploaded_at": "KMP has uploaded_at for display",
@@ -387,11 +387,11 @@ type specSchema struct {
}
type specField struct {
typeName string
format string
nullable bool
isRef bool
isArray bool
typeName string
format string
nullable bool
isRef bool
isArray bool
hasAdditionalProperties bool
}

View File

@@ -12,11 +12,8 @@ import (
type DocumentFilter struct {
ResidenceID *uint
DocumentType string
Category string
ContractorID *uint
IsActive *bool
ExpiringSoon *int
Tags string
Search string
}
@@ -72,18 +69,17 @@ func (r *DocumentRepository) FindByUserFiltered(residenceIDs []uint, filter *Doc
query := r.db.Preload("CreatedBy").
Preload("Residence").
Preload("Images").
Where("residence_id IN ? AND is_active = ?", residenceIDs, true)
Where("residence_id IN ?", residenceIDs)
// Default behavior is active-only unless explicitly overridden.
if filter == nil || filter.IsActive == nil {
query = query.Where("is_active = ?", 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)
}
@@ -92,9 +88,6 @@ func (r *DocumentRepository) FindByUserFiltered(residenceIDs []uint, filter *Doc
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)