From bb7493f0335d1fecc0c7445f7fc55c66499cb177 Mon Sep 17 00:00:00 2001 From: Trey t Date: Wed, 18 Feb 2026 13:15:07 -0600 Subject: [PATCH] Close all 25 codex audit findings and add KMP contract tests Remediate all P0-S priority findings from cross-platform architecture audit: - Add input validation and authorization checks across handlers - Harden social auth (Apple/Google) token validation - Add document ownership verification and file type validation - Add rate limiting config and CORS origin restrictions - Add subscription tier enforcement in handlers - Add OpenAPI 3.0.3 spec (81 schemas, 104 operations) - Add URL-level contract test (KMP API routes match spec paths) - Add model-level contract test (65 schemas, 464 fields validated) - Add CI workflow for backend tests Co-Authored-By: Claude Opus 4.6 --- .github/workflows/backend-ci.yml | 75 + Makefile | 6 +- docs/openapi.yaml | 4523 +++++++++++++++++ internal/config/config.go | 54 +- internal/dto/requests/task.go | 8 + internal/handlers/document_handler.go | 64 + internal/handlers/document_handler_test.go | 213 + internal/handlers/notification_handler.go | 27 + internal/handlers/residence_handler.go | 22 + internal/handlers/subscription_handler.go | 4 + internal/handlers/task_handler.go | 54 +- internal/integration/contract_test.go | 229 + internal/integration/kmp_contract_test.go | 286 ++ .../integration/kmp_model_contract_test.go | 774 +++ internal/repositories/task_repo.go | 5 + internal/router/router.go | 26 +- internal/services/apple_auth.go | 12 +- internal/services/document_service.go | 86 + internal/services/google_auth.go | 12 +- internal/services/notification_service.go | 33 + internal/services/residence_service.go | 23 + internal/services/task_service.go | 55 + internal/testutil/testutil.go | 1 + 23 files changed, 6549 insertions(+), 43 deletions(-) create mode 100644 .github/workflows/backend-ci.yml create mode 100644 docs/openapi.yaml create mode 100644 internal/handlers/document_handler_test.go create mode 100644 internal/integration/contract_test.go create mode 100644 internal/integration/kmp_contract_test.go create mode 100644 internal/integration/kmp_model_contract_test.go diff --git a/.github/workflows/backend-ci.yml b/.github/workflows/backend-ci.yml new file mode 100644 index 0000000..dadd408 --- /dev/null +++ b/.github/workflows/backend-ci.yml @@ -0,0 +1,75 @@ +name: Backend CI + +on: + push: + branches: [main, develop] + pull_request: + branches: [main, develop] + +jobs: + test: + name: Test + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Download dependencies + run: go mod download + + - name: Run tests + run: go test -race -count=1 ./... + + - name: Run contract validation + run: go test -v -run "TestRouteSpecContract|TestKMPSpecContract" ./internal/integration/ + + build: + name: Build + runs-on: ubuntu-latest + needs: test + + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Build API + run: go build -ldflags "-s -w" -o bin/casera-api ./cmd/api + + - name: Build Worker + run: go build -ldflags "-s -w" -o bin/casera-worker ./cmd/worker + + lint: + name: Lint + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Run go vet + run: go vet ./... + + - name: Check formatting + run: | + unformatted=$(gofmt -l .) + if [ -n "$unformatted" ]; then + echo "Unformatted files:" + echo "$unformatted" + exit 1 + fi diff --git a/Makefile b/Makefile index 76158e9..6acf5e4 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build run test clean deps lint docker-build docker-up docker-down migrate +.PHONY: build run test contract-test clean deps lint docker-build docker-up docker-down migrate # Binary names API_BINARY=casera-api @@ -47,6 +47,10 @@ run-admin: test: go test -v -race -cover ./... +# Run contract validation tests (routes + KMP vs OpenAPI spec) +contract-test: + go test -v -run "TestRouteSpecContract|TestKMPSpecContract" ./internal/integration/ + # Run tests with coverage test-coverage: go test -v -race -coverprofile=coverage.out ./... diff --git a/docs/openapi.yaml b/docs/openapi.yaml new file mode 100644 index 0000000..a3ee33e --- /dev/null +++ b/docs/openapi.yaml @@ -0,0 +1,4523 @@ +openapi: 3.0.3 +info: + title: Casera (MyCrib) API + description: | + REST API for the Casera property management platform. + Consumed by iOS (SwiftUI) and Android (Compose) mobile clients via Kotlin Multiplatform. + + ## Authentication + Token-based authentication. After login/register, include the token in subsequent requests: + ``` + Authorization: Token + ``` + `Bearer ` is also accepted. + + ## Common Headers + | Header | Description | Required | + |--------|-------------|----------| + | `Authorization` | `Token ` or `Bearer ` | Protected routes | + | `X-Timezone` | IANA timezone (e.g. `America/Chicago`) for overdue calculations | Optional | + | `Accept-Language` | Locale for i18n error messages (e.g. `en`, `es`) | Optional | + | `If-None-Match` | ETag for conditional requests on `/api/static_data/` | Optional | + + ## Error Responses + All errors follow a consistent JSON shape. Validation errors include a `details` map. + version: 2.0.0 + contact: + name: Casera Team + +servers: + - url: https://mycrib.treytartt.com/api + description: Production + - url: http://127.0.0.1:8000/api + description: Local development (iOS simulator) + - url: http://10.0.2.2:8000/api + description: Local development (Android emulator) + +tags: + - name: Auth (Public) + description: Authentication endpoints that do not require a token + - name: Auth (Protected) + description: Authentication endpoints that require a token + - name: Static Data + description: Lookup/reference data (public, cached with ETag) + - name: Residences + description: Property management (protected) + - name: Tasks + description: Task management and kanban board (protected) + - name: Task Completions + description: Task completion records (protected) + - name: Contractors + description: Contractor management (protected) + - name: Documents + description: Document and warranty management (protected) + - name: Notifications + description: Push notifications and preferences (protected) + - name: Subscriptions + description: In-app subscription management + - name: Uploads + description: File upload endpoints (protected) + - name: Media + description: Authenticated media serving (protected) + +paths: + # =========================================================================== + # Auth (Public) + # =========================================================================== + /auth/login/: + post: + tags: [Auth (Public)] + operationId: login + summary: Login with email/password + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/LoginRequest' + responses: + '200': + description: Successful login + content: + application/json: + schema: + $ref: '#/components/schemas/LoginResponse' + '400': + $ref: '#/components/responses/ValidationError' + '401': + $ref: '#/components/responses/Error' + + /auth/register/: + post: + tags: [Auth (Public)] + operationId: register + summary: Register a new user + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/RegisterRequest' + responses: + '201': + description: Registration successful + content: + application/json: + schema: + $ref: '#/components/schemas/RegisterResponse' + '400': + $ref: '#/components/responses/ValidationError' + '409': + $ref: '#/components/responses/Error' + + /auth/forgot-password/: + post: + tags: [Auth (Public)] + operationId: forgotPassword + summary: Send password reset code to email + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ForgotPasswordRequest' + responses: + '200': + description: Reset code sent + content: + application/json: + schema: + $ref: '#/components/schemas/MessageResponse' + '400': + $ref: '#/components/responses/ValidationError' + + /auth/verify-reset-code/: + post: + tags: [Auth (Public)] + operationId: verifyResetCode + summary: Verify password reset code + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/VerifyResetCodeRequest' + responses: + '200': + description: Code verified, returns reset token + content: + application/json: + schema: + $ref: '#/components/schemas/VerifyResetCodeResponse' + '400': + $ref: '#/components/responses/Error' + + /auth/reset-password/: + post: + tags: [Auth (Public)] + operationId: resetPassword + summary: Reset password using reset token + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ResetPasswordRequest' + responses: + '200': + description: Password reset successful + content: + application/json: + schema: + $ref: '#/components/schemas/MessageResponse' + '400': + $ref: '#/components/responses/Error' + + /auth/apple-sign-in/: + post: + tags: [Auth (Public)] + operationId: appleSignIn + summary: Sign in with Apple ID token + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/AppleSignInRequest' + responses: + '200': + description: Successful Apple sign-in + content: + application/json: + schema: + $ref: '#/components/schemas/SocialSignInResponse' + '400': + $ref: '#/components/responses/Error' + '401': + $ref: '#/components/responses/Error' + + /auth/google-sign-in/: + post: + tags: [Auth (Public)] + operationId: googleSignIn + summary: Sign in with Google ID token + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/GoogleSignInRequest' + responses: + '200': + description: Successful Google sign-in + content: + application/json: + schema: + $ref: '#/components/schemas/SocialSignInResponse' + '400': + $ref: '#/components/responses/Error' + '401': + $ref: '#/components/responses/Error' + + # =========================================================================== + # Auth (Protected) + # =========================================================================== + /auth/logout/: + post: + tags: [Auth (Protected)] + operationId: logout + summary: Logout (invalidate token) + security: + - tokenAuth: [] + responses: + '200': + description: Logged out + content: + application/json: + schema: + $ref: '#/components/schemas/MessageResponse' + '401': + $ref: '#/components/responses/Unauthorized' + + /auth/me/: + get: + tags: [Auth (Protected)] + operationId: getCurrentUser + summary: Get current authenticated user with profile + security: + - tokenAuth: [] + responses: + '200': + description: Current user + content: + application/json: + schema: + $ref: '#/components/schemas/CurrentUserResponse' + '401': + $ref: '#/components/responses/Unauthorized' + + /auth/profile/: + put: + tags: [Auth (Protected)] + operationId: updateProfile + summary: Update user profile + security: + - tokenAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateProfileRequest' + responses: + '200': + description: Profile updated + content: + application/json: + schema: + $ref: '#/components/schemas/CurrentUserResponse' + '400': + $ref: '#/components/responses/ValidationError' + '401': + $ref: '#/components/responses/Unauthorized' + patch: + tags: [Auth (Protected)] + operationId: patchProfile + summary: Partial update user profile + security: + - tokenAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateProfileRequest' + responses: + '200': + description: Profile updated + content: + application/json: + schema: + $ref: '#/components/schemas/CurrentUserResponse' + '400': + $ref: '#/components/responses/ValidationError' + '401': + $ref: '#/components/responses/Unauthorized' + + /auth/verify-email/: + post: + tags: [Auth (Protected)] + operationId: verifyEmail + summary: Verify email with 6-digit code + security: + - tokenAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/VerifyEmailRequest' + responses: + '200': + description: Email verified + content: + application/json: + schema: + $ref: '#/components/schemas/VerifyEmailResponse' + '400': + $ref: '#/components/responses/Error' + '401': + $ref: '#/components/responses/Unauthorized' + + /auth/resend-verification/: + post: + tags: [Auth (Protected)] + operationId: resendVerification + summary: Resend email verification code + security: + - tokenAuth: [] + responses: + '200': + description: Verification email sent + content: + application/json: + schema: + $ref: '#/components/schemas/MessageResponse' + '401': + $ref: '#/components/responses/Unauthorized' + + # =========================================================================== + # Static Data (Public) + # =========================================================================== + /static_data/: + get: + tags: [Static Data] + operationId: getStaticData + summary: Get all lookup/reference data (ETag support) + description: | + Returns all seeded lookup data in a single payload. Supports conditional + requests via `If-None-Match` header -- returns 304 when data has not changed. + parameters: + - in: header + name: If-None-Match + schema: + type: string + description: ETag from a previous response + responses: + '200': + description: All lookup data + headers: + ETag: + schema: + type: string + description: Content hash for conditional requests + Cache-Control: + schema: + type: string + description: "private, max-age=3600" + content: + application/json: + schema: + $ref: '#/components/schemas/SeededDataResponse' + '304': + description: Not Modified (client ETag matches) + + /residences/types/: + get: + tags: [Static Data] + operationId: getResidenceTypes + summary: List residence types + responses: + '200': + description: Residence types + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/ResidenceTypeResponse' + + /tasks/categories/: + get: + tags: [Static Data] + operationId: getTaskCategories + summary: List task categories + responses: + '200': + description: Task categories + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/TaskCategoryResponse' + + /tasks/priorities/: + get: + tags: [Static Data] + operationId: getTaskPriorities + summary: List task priorities + responses: + '200': + description: Task priorities + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/TaskPriorityResponse' + + /tasks/frequencies/: + get: + tags: [Static Data] + operationId: getTaskFrequencies + summary: List task frequencies + responses: + '200': + description: Task frequencies + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/TaskFrequencyResponse' + + /contractors/specialties/: + get: + tags: [Static Data] + operationId: getContractorSpecialties + summary: List contractor specialties + responses: + '200': + description: Contractor specialties + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/ContractorSpecialtyResponse' + + /tasks/templates/: + get: + tags: [Static Data] + operationId: getTaskTemplates + summary: List all task templates + responses: + '200': + description: Task templates + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/TaskTemplateResponse' + + /tasks/templates/grouped/: + get: + tags: [Static Data] + operationId: getTaskTemplatesGrouped + summary: Get task templates grouped by category + responses: + '200': + description: Templates grouped by category + content: + application/json: + schema: + $ref: '#/components/schemas/TaskTemplatesGroupedResponse' + + /tasks/templates/search/: + get: + tags: [Static Data] + operationId: searchTaskTemplates + summary: Search task templates by query + parameters: + - in: query + name: q + schema: + type: string + description: Search query string + responses: + '200': + description: Matching templates + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/TaskTemplateResponse' + + /tasks/templates/by-category/{category_id}/: + get: + tags: [Static Data] + operationId: getTaskTemplatesByCategory + summary: Get templates for a specific category + parameters: + - $ref: '#/components/parameters/CategoryIdParam' + responses: + '200': + description: Templates for category + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/TaskTemplateResponse' + + /tasks/templates/{id}/: + get: + tags: [Static Data] + operationId: getTaskTemplate + summary: Get a single task template + parameters: + - $ref: '#/components/parameters/IdParam' + responses: + '200': + description: Task template + content: + application/json: + schema: + $ref: '#/components/schemas/TaskTemplateResponse' + '404': + $ref: '#/components/responses/NotFound' + + # =========================================================================== + # Residences + # =========================================================================== + /residences/: + get: + tags: [Residences] + operationId: listResidences + summary: List all residences the user has access to + security: + - tokenAuth: [] + responses: + '200': + description: List of residences + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/ResidenceResponse' + '401': + $ref: '#/components/responses/Unauthorized' + post: + tags: [Residences] + operationId: createResidence + summary: Create a new residence + security: + - tokenAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateResidenceRequest' + responses: + '201': + description: Residence created + content: + application/json: + schema: + $ref: '#/components/schemas/ResidenceWithSummaryResponse' + '400': + $ref: '#/components/responses/ValidationError' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + + /residences/my-residences/: + get: + tags: [Residences] + operationId: getMyResidences + summary: Get user residences (optimized for main screen) + security: + - tokenAuth: [] + responses: + '200': + description: Residences with metadata + content: + application/json: + schema: + $ref: '#/components/schemas/MyResidencesResponse' + '401': + $ref: '#/components/responses/Unauthorized' + + /residences/summary/: + get: + tags: [Residences] + operationId: getResidenceSummary + summary: Get summary counts across all residences + security: + - tokenAuth: [] + responses: + '200': + description: Summary statistics + content: + application/json: + schema: + $ref: '#/components/schemas/TotalSummary' + '401': + $ref: '#/components/responses/Unauthorized' + + /residences/join-with-code/: + post: + tags: [Residences] + operationId: joinWithCode + summary: Join a residence using a share code + security: + - tokenAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/JoinWithCodeRequest' + responses: + '200': + description: Joined residence + content: + application/json: + schema: + $ref: '#/components/schemas/JoinResidenceResponse' + '400': + $ref: '#/components/responses/Error' + '404': + $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/Error' + + /residences/{id}/: + get: + tags: [Residences] + operationId: getResidence + summary: Get a single residence + security: + - tokenAuth: [] + parameters: + - $ref: '#/components/parameters/IdParam' + responses: + '200': + description: Residence detail + content: + application/json: + schema: + $ref: '#/components/schemas/ResidenceResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + put: + tags: [Residences] + operationId: updateResidence + summary: Update a residence + security: + - tokenAuth: [] + parameters: + - $ref: '#/components/parameters/IdParam' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateResidenceRequest' + responses: + '200': + description: Residence updated + content: + application/json: + schema: + $ref: '#/components/schemas/ResidenceWithSummaryResponse' + '400': + $ref: '#/components/responses/ValidationError' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + patch: + tags: [Residences] + operationId: patchResidence + summary: Partial update a residence + security: + - tokenAuth: [] + parameters: + - $ref: '#/components/parameters/IdParam' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateResidenceRequest' + responses: + '200': + description: Residence updated + content: + application/json: + schema: + $ref: '#/components/schemas/ResidenceWithSummaryResponse' + '400': + $ref: '#/components/responses/ValidationError' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + delete: + tags: [Residences] + operationId: deleteResidence + summary: Delete a residence (owner only) + security: + - tokenAuth: [] + parameters: + - $ref: '#/components/parameters/IdParam' + responses: + '200': + description: Residence deleted + content: + application/json: + schema: + $ref: '#/components/schemas/ResidenceDeleteWithSummaryResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + + /residences/{id}/generate-share-code/: + post: + tags: [Residences] + operationId: generateShareCode + summary: Generate a share code for a residence + security: + - tokenAuth: [] + parameters: + - $ref: '#/components/parameters/IdParam' + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/GenerateShareCodeRequest' + responses: + '201': + description: Share code generated + content: + application/json: + schema: + $ref: '#/components/schemas/GenerateShareCodeResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + + /residences/{id}/generate-share-package/: + post: + tags: [Residences] + operationId: generateSharePackage + summary: Generate a share package (.casera file metadata) + security: + - tokenAuth: [] + parameters: + - $ref: '#/components/parameters/IdParam' + responses: + '200': + description: Share package data + content: + application/json: + schema: + $ref: '#/components/schemas/SharePackageResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + + /residences/{id}/share-code/: + get: + tags: [Residences] + operationId: getShareCode + summary: Get active share code for a residence + security: + - tokenAuth: [] + parameters: + - $ref: '#/components/parameters/IdParam' + responses: + '200': + description: Active share code + content: + application/json: + schema: + $ref: '#/components/schemas/ShareCodeResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + + /residences/{id}/users/: + get: + tags: [Residences] + operationId: getResidenceUsers + summary: List users who have access to a residence + security: + - tokenAuth: [] + parameters: + - $ref: '#/components/parameters/IdParam' + responses: + '200': + description: List of residence users + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/ResidenceUserResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + + /residences/{id}/users/{user_id}/: + delete: + tags: [Residences] + operationId: removeResidenceUser + summary: Remove a user from a residence (owner only) + security: + - tokenAuth: [] + parameters: + - $ref: '#/components/parameters/IdParam' + - in: path + name: user_id + required: true + schema: + type: integer + format: uint + description: ID of the user to remove + responses: + '200': + description: User removed + content: + application/json: + schema: + $ref: '#/components/schemas/MessageResponse' + '400': + $ref: '#/components/responses/Error' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + + /residences/{id}/generate-tasks-report/: + post: + tags: [Residences] + operationId: generateTasksReport + summary: Generate a PDF tasks report for a residence + security: + - tokenAuth: [] + parameters: + - $ref: '#/components/parameters/IdParam' + responses: + '200': + description: PDF report + content: + application/pdf: + schema: + type: string + format: binary + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + + # =========================================================================== + # Tasks + # =========================================================================== + /tasks/: + get: + tags: [Tasks] + operationId: listTasks + summary: Get kanban board with all user tasks + description: Returns tasks organized into kanban columns (overdue, due_soon, upcoming, completed, in_progress, archived). + security: + - tokenAuth: [] + responses: + '200': + description: Kanban board + content: + application/json: + schema: + $ref: '#/components/schemas/KanbanBoardResponse' + '401': + $ref: '#/components/responses/Unauthorized' + post: + tags: [Tasks] + operationId: createTask + summary: Create a new task + security: + - tokenAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateTaskRequest' + responses: + '201': + description: Task created + content: + application/json: + schema: + $ref: '#/components/schemas/TaskWithSummaryResponse' + '400': + $ref: '#/components/responses/ValidationError' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + + /tasks/by-residence/{residence_id}/: + get: + tags: [Tasks] + operationId: getTasksByResidence + summary: Get kanban board for a specific residence + security: + - tokenAuth: [] + parameters: + - in: path + name: residence_id + required: true + schema: + type: integer + format: uint + description: Residence ID + responses: + '200': + description: Kanban board for residence + content: + application/json: + schema: + $ref: '#/components/schemas/KanbanBoardResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + + /tasks/{id}/: + get: + tags: [Tasks] + operationId: getTask + summary: Get task detail + security: + - tokenAuth: [] + parameters: + - $ref: '#/components/parameters/IdParam' + responses: + '200': + description: Task detail + content: + application/json: + schema: + $ref: '#/components/schemas/TaskResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + put: + tags: [Tasks] + operationId: updateTask + summary: Update a task + security: + - tokenAuth: [] + parameters: + - $ref: '#/components/parameters/IdParam' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateTaskRequest' + responses: + '200': + description: Task updated + content: + application/json: + schema: + $ref: '#/components/schemas/TaskWithSummaryResponse' + '400': + $ref: '#/components/responses/ValidationError' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + patch: + tags: [Tasks] + operationId: patchTask + summary: Partial update a task + security: + - tokenAuth: [] + parameters: + - $ref: '#/components/parameters/IdParam' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateTaskRequest' + responses: + '200': + description: Task updated + content: + application/json: + schema: + $ref: '#/components/schemas/TaskWithSummaryResponse' + '400': + $ref: '#/components/responses/ValidationError' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + delete: + tags: [Tasks] + operationId: deleteTask + summary: Delete a task + security: + - tokenAuth: [] + parameters: + - $ref: '#/components/parameters/IdParam' + responses: + '200': + description: Task deleted + content: + application/json: + schema: + $ref: '#/components/schemas/DeleteWithSummaryResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + + /tasks/{id}/mark-in-progress/: + post: + tags: [Tasks] + operationId: markTaskInProgress + summary: Mark a task as in progress + security: + - tokenAuth: [] + parameters: + - $ref: '#/components/parameters/IdParam' + responses: + '200': + description: Task marked in progress + content: + application/json: + schema: + $ref: '#/components/schemas/TaskWithSummaryResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + + /tasks/{id}/cancel/: + post: + tags: [Tasks] + operationId: cancelTask + summary: Cancel a task + security: + - tokenAuth: [] + parameters: + - $ref: '#/components/parameters/IdParam' + responses: + '200': + description: Task cancelled + content: + application/json: + schema: + $ref: '#/components/schemas/TaskWithSummaryResponse' + '400': + $ref: '#/components/responses/Error' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + + /tasks/{id}/uncancel/: + post: + tags: [Tasks] + operationId: uncancelTask + summary: Uncancel a previously cancelled task + security: + - tokenAuth: [] + parameters: + - $ref: '#/components/parameters/IdParam' + responses: + '200': + description: Task uncancelled + content: + application/json: + schema: + $ref: '#/components/schemas/TaskWithSummaryResponse' + '400': + $ref: '#/components/responses/Error' + '401': + $ref: '#/components/responses/Unauthorized' + + /tasks/{id}/archive/: + post: + tags: [Tasks] + operationId: archiveTask + summary: Archive a task + security: + - tokenAuth: [] + parameters: + - $ref: '#/components/parameters/IdParam' + responses: + '200': + description: Task archived + content: + application/json: + schema: + $ref: '#/components/schemas/TaskWithSummaryResponse' + '400': + $ref: '#/components/responses/Error' + '401': + $ref: '#/components/responses/Unauthorized' + + /tasks/{id}/unarchive/: + post: + tags: [Tasks] + operationId: unarchiveTask + summary: Unarchive a previously archived task + security: + - tokenAuth: [] + parameters: + - $ref: '#/components/parameters/IdParam' + responses: + '200': + description: Task unarchived + content: + application/json: + schema: + $ref: '#/components/schemas/TaskWithSummaryResponse' + '400': + $ref: '#/components/responses/Error' + '401': + $ref: '#/components/responses/Unauthorized' + + /tasks/{id}/quick-complete/: + post: + tags: [Tasks] + operationId: quickCompleteTask + summary: Quick complete a task (from widget, no notes/images) + security: + - tokenAuth: [] + parameters: + - $ref: '#/components/parameters/IdParam' + responses: + '200': + description: Task completed + content: + application/json: + schema: + $ref: '#/components/schemas/TaskCompletionWithSummaryResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + + /tasks/{id}/completions/: + get: + tags: [Tasks] + operationId: getTaskCompletions + summary: List completions for a specific task + security: + - tokenAuth: [] + parameters: + - $ref: '#/components/parameters/IdParam' + responses: + '200': + description: Task completions + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/TaskCompletionResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + + # =========================================================================== + # Task Completions + # =========================================================================== + /task-completions/: + get: + tags: [Task Completions] + operationId: listCompletions + summary: List all task completions for the user + security: + - tokenAuth: [] + responses: + '200': + description: List of completions + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/TaskCompletionResponse' + '401': + $ref: '#/components/responses/Unauthorized' + post: + tags: [Task Completions] + operationId: createCompletion + summary: Create a task completion (with optional images) + security: + - tokenAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateTaskCompletionRequest' + responses: + '201': + description: Completion created (includes updated task) + content: + application/json: + schema: + $ref: '#/components/schemas/TaskCompletionWithSummaryResponse' + '400': + $ref: '#/components/responses/ValidationError' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + + /task-completions/{id}/: + get: + tags: [Task Completions] + operationId: getCompletion + summary: Get a single task completion + security: + - tokenAuth: [] + parameters: + - $ref: '#/components/parameters/IdParam' + responses: + '200': + description: Completion detail + content: + application/json: + schema: + $ref: '#/components/schemas/TaskCompletionResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + put: + tags: [Task Completions] + operationId: updateCompletion + summary: Update a task completion + security: + - tokenAuth: [] + parameters: + - $ref: '#/components/parameters/IdParam' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateTaskCompletionRequest' + responses: + '200': + description: Completion updated + content: + application/json: + schema: + $ref: '#/components/schemas/TaskCompletionResponse' + '400': + $ref: '#/components/responses/ValidationError' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + delete: + tags: [Task Completions] + operationId: deleteCompletion + summary: Delete a task completion + security: + - tokenAuth: [] + parameters: + - $ref: '#/components/parameters/IdParam' + responses: + '200': + description: Completion deleted + content: + application/json: + schema: + $ref: '#/components/schemas/MessageResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + + # =========================================================================== + # Contractors + # =========================================================================== + /contractors/: + get: + tags: [Contractors] + operationId: listContractors + summary: List all contractors accessible to the user + security: + - tokenAuth: [] + responses: + '200': + description: List of contractors + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/ContractorResponse' + '401': + $ref: '#/components/responses/Unauthorized' + post: + tags: [Contractors] + operationId: createContractor + summary: Create a new contractor + security: + - tokenAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateContractorRequest' + responses: + '201': + description: Contractor created + content: + application/json: + schema: + $ref: '#/components/schemas/ContractorResponse' + '400': + $ref: '#/components/responses/ValidationError' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + + /contractors/by-residence/{residence_id}/: + get: + tags: [Contractors] + operationId: listContractorsByResidence + summary: List contractors for a specific residence + security: + - tokenAuth: [] + parameters: + - in: path + name: residence_id + required: true + schema: + type: integer + format: uint + description: Residence ID + responses: + '200': + description: Contractors for residence + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/ContractorResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + + /contractors/{id}/: + get: + tags: [Contractors] + operationId: getContractor + summary: Get a single contractor + security: + - tokenAuth: [] + parameters: + - $ref: '#/components/parameters/IdParam' + responses: + '200': + description: Contractor detail + content: + application/json: + schema: + $ref: '#/components/schemas/ContractorResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + put: + tags: [Contractors] + operationId: updateContractor + summary: Update a contractor + security: + - tokenAuth: [] + parameters: + - $ref: '#/components/parameters/IdParam' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateContractorRequest' + responses: + '200': + description: Contractor updated + content: + application/json: + schema: + $ref: '#/components/schemas/ContractorResponse' + '400': + $ref: '#/components/responses/ValidationError' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + patch: + tags: [Contractors] + operationId: patchContractor + summary: Partial update a contractor + security: + - tokenAuth: [] + parameters: + - $ref: '#/components/parameters/IdParam' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateContractorRequest' + responses: + '200': + description: Contractor updated + content: + application/json: + schema: + $ref: '#/components/schemas/ContractorResponse' + '400': + $ref: '#/components/responses/ValidationError' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + delete: + tags: [Contractors] + operationId: deleteContractor + summary: Delete a contractor + security: + - tokenAuth: [] + parameters: + - $ref: '#/components/parameters/IdParam' + responses: + '200': + description: Contractor deleted + content: + application/json: + schema: + $ref: '#/components/schemas/MessageResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + + /contractors/{id}/toggle-favorite/: + post: + tags: [Contractors] + operationId: toggleContractorFavorite + summary: Toggle favorite status of a contractor + security: + - tokenAuth: [] + parameters: + - $ref: '#/components/parameters/IdParam' + responses: + '200': + description: Favorite toggled + content: + application/json: + schema: + $ref: '#/components/schemas/ToggleFavoriteResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + + /contractors/{id}/tasks/: + get: + tags: [Contractors] + operationId: getContractorTasks + summary: Get tasks associated with a contractor + security: + - tokenAuth: [] + parameters: + - $ref: '#/components/parameters/IdParam' + responses: + '200': + description: Contractor tasks + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/TaskResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + + # =========================================================================== + # Documents + # =========================================================================== + /documents/: + get: + tags: [Documents] + operationId: listDocuments + summary: List all documents accessible to the user + security: + - tokenAuth: [] + responses: + '200': + description: List of documents + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/DocumentResponse' + '401': + $ref: '#/components/responses/Unauthorized' + post: + tags: [Documents] + operationId: createDocument + summary: Create a new document + security: + - tokenAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateDocumentRequest' + responses: + '201': + description: Document created + content: + application/json: + schema: + $ref: '#/components/schemas/DocumentResponse' + '400': + $ref: '#/components/responses/ValidationError' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + + /documents/warranties/: + get: + tags: [Documents] + operationId: listWarranties + summary: List warranty documents + security: + - tokenAuth: [] + responses: + '200': + description: Warranty documents + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/DocumentResponse' + '401': + $ref: '#/components/responses/Unauthorized' + + /documents/{id}/: + get: + tags: [Documents] + operationId: getDocument + summary: Get a single document + security: + - tokenAuth: [] + parameters: + - $ref: '#/components/parameters/IdParam' + responses: + '200': + description: Document detail + content: + application/json: + schema: + $ref: '#/components/schemas/DocumentResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + put: + tags: [Documents] + operationId: updateDocument + summary: Update a document + security: + - tokenAuth: [] + parameters: + - $ref: '#/components/parameters/IdParam' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateDocumentRequest' + responses: + '200': + description: Document updated + content: + application/json: + schema: + $ref: '#/components/schemas/DocumentResponse' + '400': + $ref: '#/components/responses/ValidationError' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + patch: + tags: [Documents] + operationId: patchDocument + summary: Partial update a document + security: + - tokenAuth: [] + parameters: + - $ref: '#/components/parameters/IdParam' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateDocumentRequest' + responses: + '200': + description: Document updated + content: + application/json: + schema: + $ref: '#/components/schemas/DocumentResponse' + '400': + $ref: '#/components/responses/ValidationError' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + delete: + tags: [Documents] + operationId: deleteDocument + summary: Delete a document + security: + - tokenAuth: [] + parameters: + - $ref: '#/components/parameters/IdParam' + responses: + '200': + description: Document deleted + content: + application/json: + schema: + $ref: '#/components/schemas/MessageResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + + /documents/{id}/activate/: + post: + tags: [Documents] + operationId: activateDocument + summary: Activate a document + security: + - tokenAuth: [] + parameters: + - $ref: '#/components/parameters/IdParam' + responses: + '200': + description: Document activated + content: + application/json: + schema: + type: object + properties: + message: + type: string + document: + $ref: '#/components/schemas/DocumentResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + + /documents/{id}/deactivate/: + post: + tags: [Documents] + operationId: deactivateDocument + summary: Deactivate a document + security: + - tokenAuth: [] + parameters: + - $ref: '#/components/parameters/IdParam' + responses: + '200': + description: Document deactivated + content: + application/json: + schema: + type: object + properties: + message: + type: string + document: + $ref: '#/components/schemas/DocumentResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + + /documents/{id}/images/: + post: + tags: [Documents] + operationId: uploadDocumentImage + summary: Upload an image to a document + security: + - tokenAuth: [] + parameters: + - $ref: '#/components/parameters/IdParam' + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: + - image + properties: + image: + type: string + format: binary + description: Image file to upload + caption: + type: string + description: Optional caption for the image + responses: + '201': + description: Image uploaded successfully + content: + application/json: + schema: + $ref: '#/components/schemas/DocumentResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + + /documents/{id}/images/{imageId}/: + delete: + tags: [Documents] + operationId: deleteDocumentImage + summary: Delete an image from a document + security: + - tokenAuth: [] + parameters: + - $ref: '#/components/parameters/IdParam' + - in: path + name: imageId + required: true + schema: + type: integer + description: Image ID + responses: + '200': + description: Image deleted, returns updated document + content: + application/json: + schema: + $ref: '#/components/schemas/DocumentResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + + # =========================================================================== + # Notifications + # =========================================================================== + /notifications/: + get: + tags: [Notifications] + operationId: listNotifications + summary: List user notifications + security: + - tokenAuth: [] + parameters: + - in: query + name: limit + schema: + type: integer + default: 50 + description: Max number of notifications to return + - in: query + name: offset + schema: + type: integer + default: 0 + description: Offset for pagination + responses: + '200': + description: Notifications list + content: + application/json: + schema: + $ref: '#/components/schemas/NotificationListResponse' + '401': + $ref: '#/components/responses/Unauthorized' + + /notifications/unread-count/: + get: + tags: [Notifications] + operationId: getUnreadCount + summary: Get count of unread notifications + security: + - tokenAuth: [] + responses: + '200': + description: Unread count + content: + application/json: + schema: + type: object + required: [unread_count] + properties: + unread_count: + type: integer + '401': + $ref: '#/components/responses/Unauthorized' + + /notifications/{id}/read/: + post: + tags: [Notifications] + operationId: markNotificationRead + summary: Mark a single notification as read + security: + - tokenAuth: [] + parameters: + - $ref: '#/components/parameters/IdParam' + responses: + '200': + description: Marked as read + content: + application/json: + schema: + $ref: '#/components/schemas/MessageResponse' + '401': + $ref: '#/components/responses/Unauthorized' + + /notifications/mark-all-read/: + post: + tags: [Notifications] + operationId: markAllNotificationsRead + summary: Mark all notifications as read + security: + - tokenAuth: [] + responses: + '200': + description: All marked as read + content: + application/json: + schema: + $ref: '#/components/schemas/MessageResponse' + '401': + $ref: '#/components/responses/Unauthorized' + + /notifications/devices/: + post: + tags: [Notifications] + operationId: registerDevice + summary: Register a push notification device + security: + - tokenAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/RegisterDeviceRequest' + responses: + '201': + description: Device registered + content: + application/json: + schema: + $ref: '#/components/schemas/DeviceResponse' + '400': + $ref: '#/components/responses/Error' + '401': + $ref: '#/components/responses/Unauthorized' + get: + tags: [Notifications] + operationId: listDevices + summary: List registered push devices + security: + - tokenAuth: [] + responses: + '200': + description: List of devices + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/DeviceResponse' + '401': + $ref: '#/components/responses/Unauthorized' + + /notifications/devices/register/: + post: + tags: [Notifications] + operationId: registerDeviceAlias + summary: Register a push notification device (alias) + description: Alias for POST /notifications/devices/ for mobile client compatibility. + security: + - tokenAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/RegisterDeviceRequest' + responses: + '201': + description: Device registered + content: + application/json: + schema: + $ref: '#/components/schemas/DeviceResponse' + '400': + $ref: '#/components/responses/Error' + '401': + $ref: '#/components/responses/Unauthorized' + + /notifications/devices/unregister/: + post: + tags: [Notifications] + operationId: unregisterDevice + summary: Unregister a push device by registration ID + security: + - tokenAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UnregisterDeviceRequest' + responses: + '200': + description: Device unregistered + content: + application/json: + schema: + $ref: '#/components/schemas/MessageResponse' + '400': + $ref: '#/components/responses/Error' + '401': + $ref: '#/components/responses/Unauthorized' + + /notifications/devices/{id}/: + delete: + tags: [Notifications] + operationId: deleteDevice + summary: Delete a registered push device + security: + - tokenAuth: [] + parameters: + - $ref: '#/components/parameters/IdParam' + - in: query + name: platform + schema: + type: string + enum: [ios, android] + default: ios + description: Device platform + responses: + '200': + description: Device removed + content: + application/json: + schema: + $ref: '#/components/schemas/MessageResponse' + '401': + $ref: '#/components/responses/Unauthorized' + + /notifications/preferences/: + get: + tags: [Notifications] + operationId: getNotificationPreferences + summary: Get notification preferences + security: + - tokenAuth: [] + responses: + '200': + description: Notification preferences + content: + application/json: + schema: + $ref: '#/components/schemas/NotificationPreference' + '401': + $ref: '#/components/responses/Unauthorized' + put: + tags: [Notifications] + operationId: updateNotificationPreferences + summary: Update notification preferences + security: + - tokenAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdatePreferencesRequest' + responses: + '200': + description: Preferences updated + content: + application/json: + schema: + $ref: '#/components/schemas/NotificationPreference' + '400': + $ref: '#/components/responses/Error' + '401': + $ref: '#/components/responses/Unauthorized' + patch: + tags: [Notifications] + operationId: patchNotificationPreferences + summary: Partial update notification preferences + security: + - tokenAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdatePreferencesRequest' + responses: + '200': + description: Preferences updated + content: + application/json: + schema: + $ref: '#/components/schemas/NotificationPreference' + '400': + $ref: '#/components/responses/Error' + '401': + $ref: '#/components/responses/Unauthorized' + + # =========================================================================== + # Subscriptions + # =========================================================================== + /subscription/: + get: + tags: [Subscriptions] + operationId: getSubscription + summary: Get current subscription + security: + - tokenAuth: [] + responses: + '200': + description: Subscription details + content: + application/json: + schema: + $ref: '#/components/schemas/SubscriptionResponse' + '401': + $ref: '#/components/responses/Unauthorized' + + /subscription/status/: + get: + tags: [Subscriptions] + operationId: getSubscriptionStatus + summary: Get subscription status with limits and usage + security: + - tokenAuth: [] + responses: + '200': + description: Subscription status + content: + application/json: + schema: + $ref: '#/components/schemas/SubscriptionStatusResponse' + '401': + $ref: '#/components/responses/Unauthorized' + + /subscription/features/: + get: + tags: [Subscriptions] + operationId: getFeatureBenefits + summary: List feature benefits (free vs pro comparison) + security: + - tokenAuth: [] + responses: + '200': + description: Feature benefits list + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/FeatureBenefit' + '401': + $ref: '#/components/responses/Unauthorized' + + /subscription/promotions/: + get: + tags: [Subscriptions] + operationId: getPromotions + summary: List active promotions for the user + security: + - tokenAuth: [] + responses: + '200': + description: Active promotions + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Promotion' + '401': + $ref: '#/components/responses/Unauthorized' + + /subscription/upgrade-triggers/: + get: + tags: [Subscriptions] + operationId: getAllUpgradeTriggers + summary: Get all upgrade triggers (public, no auth required) + description: Available without authentication so the app can display upgrade prompts before login. + responses: + '200': + description: All upgrade triggers + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/UpgradeTriggerResponse' + + /subscription/upgrade-trigger/{key}/: + get: + tags: [Subscriptions] + operationId: getUpgradeTrigger + summary: Get a single upgrade trigger by key + security: + - tokenAuth: [] + parameters: + - in: path + name: key + required: true + schema: + type: string + description: Trigger key (e.g. "properties_limit", "tasks_limit") + responses: + '200': + description: Upgrade trigger + content: + application/json: + schema: + $ref: '#/components/schemas/UpgradeTriggerResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + + /subscription/purchase/: + post: + tags: [Subscriptions] + operationId: processPurchase + summary: Process an in-app purchase (iOS or Android) + security: + - tokenAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ProcessPurchaseRequest' + responses: + '200': + description: Purchase processed + content: + application/json: + schema: + type: object + properties: + message: + type: string + subscription: + $ref: '#/components/schemas/SubscriptionResponse' + '400': + $ref: '#/components/responses/Error' + '401': + $ref: '#/components/responses/Unauthorized' + + /subscription/cancel/: + post: + tags: [Subscriptions] + operationId: cancelSubscription + summary: Cancel subscription + security: + - tokenAuth: [] + responses: + '200': + description: Subscription cancelled + content: + application/json: + schema: + type: object + properties: + message: + type: string + subscription: + $ref: '#/components/schemas/SubscriptionResponse' + '401': + $ref: '#/components/responses/Unauthorized' + + /subscription/restore/: + post: + tags: [Subscriptions] + operationId: restoreSubscription + summary: Restore a previous subscription + security: + - tokenAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ProcessPurchaseRequest' + responses: + '200': + description: Subscription restored + content: + application/json: + schema: + type: object + properties: + message: + type: string + subscription: + $ref: '#/components/schemas/SubscriptionResponse' + '400': + $ref: '#/components/responses/Error' + '401': + $ref: '#/components/responses/Unauthorized' + + # =========================================================================== + # Uploads + # =========================================================================== + /uploads/image/: + post: + tags: [Uploads] + operationId: uploadImage + summary: Upload an image file + security: + - tokenAuth: [] + parameters: + - in: query + name: category + schema: + type: string + default: images + description: Storage category/folder + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: [file] + properties: + file: + type: string + format: binary + responses: + '200': + description: Upload result + content: + application/json: + schema: + $ref: '#/components/schemas/UploadResult' + '400': + $ref: '#/components/responses/Error' + '401': + $ref: '#/components/responses/Unauthorized' + + /uploads/document/: + post: + tags: [Uploads] + operationId: uploadDocument + summary: Upload a document file + security: + - tokenAuth: [] + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: [file] + properties: + file: + type: string + format: binary + responses: + '200': + description: Upload result + content: + application/json: + schema: + $ref: '#/components/schemas/UploadResult' + '400': + $ref: '#/components/responses/Error' + '401': + $ref: '#/components/responses/Unauthorized' + + /uploads/completion/: + post: + tags: [Uploads] + operationId: uploadCompletion + summary: Upload a task completion image + security: + - tokenAuth: [] + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: [file] + properties: + file: + type: string + format: binary + responses: + '200': + description: Upload result + content: + application/json: + schema: + $ref: '#/components/schemas/UploadResult' + '400': + $ref: '#/components/responses/Error' + '401': + $ref: '#/components/responses/Unauthorized' + + /uploads/: + delete: + tags: [Uploads] + operationId: deleteUploadedFile + summary: Delete an uploaded file + security: + - tokenAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [url] + properties: + url: + type: string + description: URL of the file to delete + responses: + '200': + description: File deleted + content: + application/json: + schema: + $ref: '#/components/schemas/MessageResponse' + '400': + $ref: '#/components/responses/Error' + '401': + $ref: '#/components/responses/Unauthorized' + + # =========================================================================== + # Media (Authenticated serving) + # =========================================================================== + /media/document/{id}: + get: + tags: [Media] + operationId: serveDocument + summary: Serve a document file (authenticated) + description: Proxies the document file from S3 after verifying the user has access to the residence. + security: + - tokenAuth: [] + parameters: + - $ref: '#/components/parameters/IdParam' + responses: + '200': + description: File content + content: + application/octet-stream: + schema: + type: string + format: binary + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + + /media/document-image/{id}: + get: + tags: [Media] + operationId: serveDocumentImage + summary: Serve a document image (authenticated) + security: + - tokenAuth: [] + parameters: + - $ref: '#/components/parameters/IdParam' + responses: + '200': + description: Image content + content: + image/*: + schema: + type: string + format: binary + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + + /media/completion-image/{id}: + get: + tags: [Media] + operationId: serveCompletionImage + summary: Serve a task completion image (authenticated) + security: + - tokenAuth: [] + parameters: + - $ref: '#/components/parameters/IdParam' + responses: + '200': + description: Image content + content: + image/*: + schema: + type: string + format: binary + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + +# ============================================================================= +# Components +# ============================================================================= +components: + securitySchemes: + tokenAuth: + type: apiKey + in: header + name: Authorization + description: 'Token-based auth. Format: `Token ` or `Bearer `' + + parameters: + IdParam: + in: path + name: id + required: true + schema: + type: integer + format: uint + description: Resource ID + CategoryIdParam: + in: path + name: category_id + required: true + schema: + type: integer + format: uint + description: Category ID + + responses: + Error: + description: Error response + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + ValidationError: + description: Validation error with field details + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + Unauthorized: + description: Authentication required or token invalid + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + Forbidden: + description: Access denied + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + NotFound: + description: Resource not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + schemas: + # ========================================================================= + # Common + # ========================================================================= + ErrorResponse: + type: object + required: [error] + properties: + error: + type: string + description: Human-readable error message (may be localized) + details: + type: object + additionalProperties: + type: string + description: Field-level validation errors (field name -> message) + + MessageResponse: + type: object + required: [message] + properties: + message: + type: string + + # ========================================================================= + # Auth Requests + # ========================================================================= + LoginRequest: + type: object + properties: + username: + type: string + description: Username (required if email not provided) + email: + type: string + format: email + description: Email (required if username not provided) + password: + type: string + minLength: 1 + required: [password] + + RegisterRequest: + type: object + required: [username, email, password] + properties: + username: + type: string + minLength: 3 + maxLength: 150 + email: + type: string + format: email + maxLength: 254 + password: + type: string + minLength: 8 + first_name: + type: string + maxLength: 150 + last_name: + type: string + maxLength: 150 + + ForgotPasswordRequest: + type: object + required: [email] + properties: + email: + type: string + format: email + + VerifyResetCodeRequest: + type: object + required: [email, code] + properties: + email: + type: string + format: email + code: + type: string + minLength: 6 + maxLength: 6 + + ResetPasswordRequest: + type: object + required: [reset_token, new_password] + properties: + reset_token: + type: string + new_password: + type: string + minLength: 8 + + UpdateProfileRequest: + type: object + properties: + email: + type: string + format: email + maxLength: 254 + nullable: true + first_name: + type: string + maxLength: 150 + nullable: true + last_name: + type: string + maxLength: 150 + nullable: true + + VerifyEmailRequest: + type: object + required: [code] + properties: + code: + type: string + minLength: 6 + maxLength: 6 + + AppleSignInRequest: + type: object + required: [id_token, user_id] + properties: + id_token: + type: string + description: Apple identity token (JWT) + user_id: + type: string + description: Apple user ID (sub claim) + email: + type: string + format: email + nullable: true + description: May be nil or private relay address + first_name: + type: string + nullable: true + last_name: + type: string + nullable: true + + GoogleSignInRequest: + type: object + required: [id_token] + properties: + id_token: + type: string + description: Google ID token from Credential Manager + + # ========================================================================= + # Auth Responses + # ========================================================================= + UserResponse: + type: object + properties: + id: + type: integer + format: uint + username: + type: string + email: + type: string + format: email + first_name: + type: string + last_name: + type: string + is_active: + type: boolean + verified: + type: boolean + date_joined: + type: string + format: date-time + last_login: + type: string + format: date-time + nullable: true + + UserProfileResponse: + type: object + properties: + id: + type: integer + format: uint + user_id: + type: integer + format: uint + verified: + type: boolean + bio: + type: string + phone_number: + type: string + date_of_birth: + type: string + format: date-time + nullable: true + profile_picture: + type: string + + LoginResponse: + type: object + required: [token, user] + properties: + token: + type: string + user: + $ref: '#/components/schemas/UserResponse' + + RegisterResponse: + type: object + required: [token, user, message] + properties: + token: + type: string + user: + $ref: '#/components/schemas/UserResponse' + message: + type: string + + SocialSignInResponse: + type: object + required: [token, user, is_new_user] + properties: + token: + type: string + user: + $ref: '#/components/schemas/UserResponse' + is_new_user: + type: boolean + + CurrentUserResponse: + type: object + properties: + id: + type: integer + format: uint + username: + type: string + email: + type: string + format: email + first_name: + type: string + last_name: + type: string + is_active: + type: boolean + date_joined: + type: string + format: date-time + last_login: + type: string + format: date-time + nullable: true + profile: + $ref: '#/components/schemas/UserProfileResponse' + + VerifyEmailResponse: + type: object + properties: + message: + type: string + verified: + type: boolean + + VerifyResetCodeResponse: + type: object + properties: + message: + type: string + reset_token: + type: string + + # ========================================================================= + # Static Data / Lookups + # ========================================================================= + SeededDataResponse: + type: object + properties: + residence_types: + type: array + items: + $ref: '#/components/schemas/ResidenceTypeResponse' + task_categories: + type: array + items: + $ref: '#/components/schemas/TaskCategoryResponse' + task_priorities: + type: array + items: + $ref: '#/components/schemas/TaskPriorityResponse' + task_frequencies: + type: array + items: + $ref: '#/components/schemas/TaskFrequencyResponse' + contractor_specialties: + type: array + items: + $ref: '#/components/schemas/ContractorSpecialtyResponse' + task_templates: + $ref: '#/components/schemas/TaskTemplatesGroupedResponse' + + ResidenceTypeResponse: + type: object + properties: + id: + type: integer + format: uint + name: + type: string + + TaskCategoryResponse: + type: object + properties: + id: + type: integer + format: uint + name: + type: string + description: + type: string + icon: + type: string + color: + type: string + display_order: + type: integer + + TaskPriorityResponse: + type: object + properties: + id: + type: integer + format: uint + name: + type: string + level: + type: integer + color: + type: string + display_order: + type: integer + + TaskFrequencyResponse: + type: object + properties: + id: + type: integer + format: uint + name: + type: string + days: + type: integer + nullable: true + description: Number of days between occurrences (null for one-time) + display_order: + type: integer + + ContractorSpecialtyResponse: + type: object + properties: + id: + type: integer + format: uint + name: + type: string + description: + type: string + icon: + type: string + display_order: + type: integer + + TaskTemplateResponse: + type: object + properties: + id: + type: integer + format: uint + title: + type: string + description: + type: string + category_id: + type: integer + format: uint + nullable: true + category: + $ref: '#/components/schemas/TaskCategoryResponse' + frequency_id: + type: integer + format: uint + nullable: true + frequency: + $ref: '#/components/schemas/TaskFrequencyResponse' + icon_ios: + type: string + icon_android: + type: string + tags: + type: array + items: + type: string + display_order: + type: integer + is_active: + type: boolean + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + + TaskTemplateCategoryGroup: + type: object + properties: + category_name: + type: string + category_id: + type: integer + format: uint + nullable: true + templates: + type: array + items: + $ref: '#/components/schemas/TaskTemplateResponse' + count: + type: integer + + TaskTemplatesGroupedResponse: + type: object + properties: + categories: + type: array + items: + $ref: '#/components/schemas/TaskTemplateCategoryGroup' + total_count: + type: integer + + # ========================================================================= + # Residence + # ========================================================================= + CreateResidenceRequest: + type: object + required: [name] + properties: + name: + type: string + minLength: 1 + maxLength: 200 + property_type_id: + type: integer + format: uint + nullable: true + street_address: + type: string + maxLength: 255 + apartment_unit: + type: string + maxLength: 50 + city: + type: string + maxLength: 100 + state_province: + type: string + maxLength: 100 + postal_code: + type: string + maxLength: 20 + country: + type: string + maxLength: 100 + bedrooms: + type: integer + nullable: true + bathrooms: + type: string + description: Decimal value (e.g. "2.5") + nullable: true + square_footage: + type: integer + nullable: true + lot_size: + type: string + description: Decimal value + nullable: true + year_built: + type: integer + nullable: true + description: + type: string + purchase_date: + type: string + format: date-time + nullable: true + purchase_price: + type: string + description: Decimal value (e.g. "350000.00") + nullable: true + is_primary: + type: boolean + nullable: true + + UpdateResidenceRequest: + type: object + properties: + name: + type: string + minLength: 1 + maxLength: 200 + nullable: true + property_type_id: + type: integer + format: uint + nullable: true + street_address: + type: string + maxLength: 255 + nullable: true + apartment_unit: + type: string + maxLength: 50 + nullable: true + city: + type: string + maxLength: 100 + nullable: true + state_province: + type: string + maxLength: 100 + nullable: true + postal_code: + type: string + maxLength: 20 + nullable: true + country: + type: string + maxLength: 100 + nullable: true + bedrooms: + type: integer + nullable: true + bathrooms: + type: string + nullable: true + square_footage: + type: integer + nullable: true + lot_size: + type: string + nullable: true + year_built: + type: integer + nullable: true + description: + type: string + nullable: true + purchase_date: + type: string + format: date-time + nullable: true + purchase_price: + type: string + nullable: true + is_primary: + type: boolean + nullable: true + + JoinWithCodeRequest: + type: object + required: [code] + properties: + code: + type: string + minLength: 6 + maxLength: 6 + + GenerateShareCodeRequest: + type: object + properties: + expires_in_hours: + type: integer + description: Hours until code expires (default 24) + + ResidenceUserResponse: + type: object + properties: + id: + type: integer + format: uint + username: + type: string + email: + type: string + format: email + first_name: + type: string + last_name: + type: string + + ResidenceResponse: + type: object + properties: + id: + type: integer + format: uint + owner_id: + type: integer + format: uint + owner: + $ref: '#/components/schemas/ResidenceUserResponse' + users: + type: array + items: + $ref: '#/components/schemas/ResidenceUserResponse' + name: + type: string + property_type_id: + type: integer + format: uint + nullable: true + property_type: + $ref: '#/components/schemas/ResidenceTypeResponse' + street_address: + type: string + apartment_unit: + type: string + city: + type: string + state_province: + type: string + postal_code: + type: string + country: + type: string + bedrooms: + type: integer + nullable: true + bathrooms: + type: string + nullable: true + description: Decimal + square_footage: + type: integer + nullable: true + lot_size: + type: string + nullable: true + description: Decimal + year_built: + type: integer + nullable: true + description: + type: string + purchase_date: + type: string + format: date-time + nullable: true + purchase_price: + type: string + nullable: true + description: Decimal + is_primary: + type: boolean + is_active: + type: boolean + overdue_count: + type: integer + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + + TotalSummary: + type: object + properties: + total_residences: + type: integer + total_tasks: + type: integer + total_pending: + type: integer + total_overdue: + type: integer + tasks_due_next_week: + type: integer + tasks_due_next_month: + type: integer + + MyResidencesResponse: + type: object + properties: + residences: + type: array + items: + $ref: '#/components/schemas/ResidenceResponse' + + ResidenceWithSummaryResponse: + type: object + properties: + data: + $ref: '#/components/schemas/ResidenceResponse' + summary: + $ref: '#/components/schemas/TotalSummary' + + ResidenceDeleteWithSummaryResponse: + type: object + properties: + data: + type: string + summary: + $ref: '#/components/schemas/TotalSummary' + + ShareCodeResponse: + type: object + properties: + id: + type: integer + format: uint + code: + type: string + residence_id: + type: integer + format: uint + created_by_id: + type: integer + format: uint + is_active: + type: boolean + expires_at: + type: string + format: date-time + nullable: true + created_at: + type: string + format: date-time + + JoinResidenceResponse: + type: object + properties: + message: + type: string + residence: + $ref: '#/components/schemas/ResidenceResponse' + summary: + $ref: '#/components/schemas/TotalSummary' + + GenerateShareCodeResponse: + type: object + properties: + message: + type: string + share_code: + $ref: '#/components/schemas/ShareCodeResponse' + + SharePackageResponse: + type: object + properties: + share_code: + type: string + residence_name: + type: string + shared_by: + type: string + expires_at: + type: string + format: date-time + nullable: true + + # ========================================================================= + # Task + # ========================================================================= + CreateTaskRequest: + type: object + required: [residence_id, title] + properties: + residence_id: + type: integer + format: uint + title: + type: string + minLength: 1 + maxLength: 200 + description: + type: string + category_id: + type: integer + format: uint + nullable: true + priority_id: + type: integer + format: uint + nullable: true + frequency_id: + type: integer + format: uint + nullable: true + custom_interval_days: + type: integer + nullable: true + description: For "Custom" frequency, user-specified interval in days + in_progress: + type: boolean + default: false + assigned_to_id: + type: integer + format: uint + nullable: true + due_date: + type: string + description: Accepts "2025-11-27" or "2025-11-27T00:00:00Z" + nullable: true + estimated_cost: + type: string + description: Decimal value (e.g. "150.00") + nullable: true + contractor_id: + type: integer + format: uint + nullable: true + + UpdateTaskRequest: + type: object + properties: + title: + type: string + minLength: 1 + maxLength: 200 + nullable: true + description: + type: string + nullable: true + category_id: + type: integer + format: uint + nullable: true + priority_id: + type: integer + format: uint + nullable: true + frequency_id: + type: integer + format: uint + nullable: true + custom_interval_days: + type: integer + nullable: true + in_progress: + type: boolean + nullable: true + assigned_to_id: + type: integer + format: uint + nullable: true + due_date: + type: string + nullable: true + estimated_cost: + type: string + nullable: true + actual_cost: + type: string + nullable: true + contractor_id: + type: integer + format: uint + nullable: true + + TaskUserResponse: + type: object + properties: + id: + type: integer + format: uint + username: + type: string + email: + type: string + format: email + first_name: + type: string + last_name: + type: string + + TaskResponse: + type: object + properties: + id: + type: integer + format: uint + residence_id: + type: integer + format: uint + created_by_id: + type: integer + format: uint + created_by: + $ref: '#/components/schemas/TaskUserResponse' + assigned_to_id: + type: integer + format: uint + nullable: true + assigned_to: + $ref: '#/components/schemas/TaskUserResponse' + title: + type: string + description: + type: string + category_id: + type: integer + format: uint + nullable: true + category: + $ref: '#/components/schemas/TaskCategoryResponse' + priority_id: + type: integer + format: uint + nullable: true + priority: + $ref: '#/components/schemas/TaskPriorityResponse' + frequency_id: + type: integer + format: uint + nullable: true + frequency: + $ref: '#/components/schemas/TaskFrequencyResponse' + in_progress: + type: boolean + due_date: + type: string + format: date + nullable: true + next_due_date: + type: string + format: date + nullable: true + description: For recurring tasks, updated after each completion + estimated_cost: + type: string + nullable: true + description: Decimal + actual_cost: + type: string + nullable: true + description: Decimal + contractor_id: + type: integer + format: uint + nullable: true + is_cancelled: + type: boolean + is_archived: + type: boolean + parent_task_id: + type: integer + format: uint + nullable: true + completion_count: + type: integer + kanban_column: + type: string + description: "Computed column name: overdue, due_soon, upcoming, completed, in_progress, archived, cancelled" + enum: [overdue, due_soon, upcoming, completed, in_progress, archived, cancelled] + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + + KanbanColumnResponse: + type: object + properties: + name: + type: string + description: Column identifier + display_name: + type: string + description: Human-readable column name + button_types: + type: array + items: + type: string + description: Available action button types for tasks in this column + icons: + type: object + additionalProperties: + type: string + description: Platform-specific icon names (ios, android) + color: + type: string + description: Hex color for the column + tasks: + type: array + items: + $ref: '#/components/schemas/TaskResponse' + count: + type: integer + + KanbanBoardResponse: + type: object + properties: + columns: + type: array + items: + $ref: '#/components/schemas/KanbanColumnResponse' + days_threshold: + type: integer + description: Number of days used for "due soon" calculation (default 30) + residence_id: + type: string + description: '"all" or the specific residence ID' + + TaskWithSummaryResponse: + type: object + properties: + data: + $ref: '#/components/schemas/TaskResponse' + summary: + $ref: '#/components/schemas/TotalSummary' + + DeleteWithSummaryResponse: + type: object + properties: + data: + type: string + summary: + $ref: '#/components/schemas/TotalSummary' + + # ========================================================================= + # Task Completion + # ========================================================================= + CreateTaskCompletionRequest: + type: object + required: [task_id] + properties: + task_id: + type: integer + format: uint + completed_at: + type: string + format: date-time + nullable: true + description: Defaults to now if not provided + notes: + type: string + actual_cost: + type: string + nullable: true + description: Decimal + rating: + type: integer + minimum: 1 + maximum: 5 + nullable: true + image_urls: + type: array + items: + type: string + description: URLs of uploaded images to attach + + UpdateTaskCompletionRequest: + type: object + properties: + notes: + type: string + nullable: true + actual_cost: + type: string + nullable: true + rating: + type: integer + minimum: 1 + maximum: 5 + nullable: true + image_urls: + type: array + items: + type: string + description: Replaces all existing image associations + + TaskCompletionImageResponse: + type: object + properties: + id: + type: integer + format: uint + image_url: + type: string + description: Direct S3 URL (may require auth) + media_url: + type: string + description: "Authenticated proxy endpoint: /api/media/completion-image/{id}" + caption: + type: string + + TaskCompletionResponse: + type: object + properties: + id: + type: integer + format: uint + task_id: + type: integer + format: uint + completed_by: + $ref: '#/components/schemas/TaskUserResponse' + completed_at: + type: string + format: date-time + notes: + type: string + actual_cost: + type: string + nullable: true + description: Decimal + rating: + type: integer + nullable: true + images: + type: array + items: + $ref: '#/components/schemas/TaskCompletionImageResponse' + created_at: + type: string + format: date-time + task: + $ref: '#/components/schemas/TaskResponse' + description: Included after create/quick-complete with updated task state + + TaskCompletionWithSummaryResponse: + type: object + properties: + data: + $ref: '#/components/schemas/TaskCompletionResponse' + summary: + $ref: '#/components/schemas/TotalSummary' + + # ========================================================================= + # Contractor + # ========================================================================= + CreateContractorRequest: + type: object + required: [name] + properties: + residence_id: + type: integer + format: uint + nullable: true + name: + type: string + minLength: 1 + maxLength: 200 + company: + type: string + maxLength: 200 + phone: + type: string + maxLength: 20 + email: + type: string + format: email + maxLength: 254 + website: + type: string + maxLength: 200 + notes: + type: string + street_address: + type: string + maxLength: 255 + city: + type: string + maxLength: 100 + state_province: + type: string + maxLength: 100 + postal_code: + type: string + maxLength: 20 + specialty_ids: + type: array + items: + type: integer + format: uint + rating: + type: number + format: double + nullable: true + is_favorite: + type: boolean + nullable: true + + UpdateContractorRequest: + type: object + properties: + name: + type: string + minLength: 1 + maxLength: 200 + nullable: true + company: + type: string + maxLength: 200 + nullable: true + phone: + type: string + maxLength: 20 + nullable: true + email: + type: string + format: email + maxLength: 254 + nullable: true + website: + type: string + maxLength: 200 + nullable: true + notes: + type: string + nullable: true + street_address: + type: string + maxLength: 255 + nullable: true + city: + type: string + maxLength: 100 + nullable: true + state_province: + type: string + maxLength: 100 + nullable: true + postal_code: + type: string + maxLength: 20 + nullable: true + specialty_ids: + type: array + items: + type: integer + format: uint + rating: + type: number + format: double + nullable: true + is_favorite: + type: boolean + nullable: true + residence_id: + type: integer + format: uint + nullable: true + + ContractorResponse: + type: object + properties: + id: + type: integer + format: uint + residence_id: + type: integer + format: uint + nullable: true + created_by_id: + type: integer + format: uint + added_by: + type: integer + format: uint + description: Alias for created_by_id (KMM compatibility) + created_by: + type: object + properties: + id: + type: integer + format: uint + username: + type: string + first_name: + type: string + last_name: + type: string + name: + type: string + company: + type: string + phone: + type: string + email: + type: string + website: + type: string + notes: + type: string + street_address: + type: string + city: + type: string + state_province: + type: string + postal_code: + type: string + specialties: + type: array + items: + $ref: '#/components/schemas/ContractorSpecialtyResponse' + rating: + type: number + format: double + nullable: true + is_favorite: + type: boolean + is_active: + type: boolean + task_count: + type: integer + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + + ToggleFavoriteResponse: + type: object + properties: + message: + type: string + is_favorite: + type: boolean + + # ========================================================================= + # Document + # ========================================================================= + DocumentType: + type: string + enum: [general, warranty, receipt, contract, insurance, manual] + + CreateDocumentRequest: + type: object + required: [residence_id, title] + properties: + residence_id: + type: integer + format: uint + title: + type: string + minLength: 1 + maxLength: 200 + description: + type: string + document_type: + $ref: '#/components/schemas/DocumentType' + file_url: + type: string + maxLength: 500 + file_name: + type: string + maxLength: 255 + file_size: + type: integer + format: int64 + nullable: true + mime_type: + type: string + maxLength: 100 + purchase_date: + type: string + format: date-time + nullable: true + expiry_date: + type: string + format: date-time + nullable: true + purchase_price: + type: string + nullable: true + description: Decimal + vendor: + type: string + maxLength: 200 + serial_number: + type: string + maxLength: 100 + model_number: + type: string + maxLength: 100 + task_id: + type: integer + format: uint + nullable: true + image_urls: + type: array + items: + type: string + description: URLs of uploaded images to attach + + UpdateDocumentRequest: + type: object + properties: + title: + type: string + minLength: 1 + maxLength: 200 + nullable: true + description: + type: string + nullable: true + document_type: + $ref: '#/components/schemas/DocumentType' + file_url: + type: string + maxLength: 500 + nullable: true + file_name: + type: string + maxLength: 255 + nullable: true + file_size: + type: integer + format: int64 + nullable: true + mime_type: + type: string + maxLength: 100 + nullable: true + purchase_date: + type: string + format: date-time + nullable: true + expiry_date: + type: string + format: date-time + nullable: true + purchase_price: + type: string + nullable: true + vendor: + type: string + maxLength: 200 + nullable: true + serial_number: + type: string + maxLength: 100 + nullable: true + model_number: + type: string + maxLength: 100 + nullable: true + task_id: + type: integer + format: uint + nullable: true + + DocumentImageResponse: + type: object + properties: + id: + type: integer + format: uint + image_url: + type: string + media_url: + type: string + description: "Authenticated proxy endpoint: /api/media/document-image/{id}" + caption: + type: string + + DocumentResponse: + type: object + properties: + id: + type: integer + format: uint + residence_id: + type: integer + format: uint + residence: + type: integer + format: uint + description: Alias for residence_id (KMM compatibility) + created_by_id: + type: integer + format: uint + created_by: + type: object + properties: + id: + type: integer + format: uint + username: + type: string + first_name: + type: string + last_name: + type: string + title: + type: string + description: + type: string + document_type: + $ref: '#/components/schemas/DocumentType' + file_url: + type: string + media_url: + type: string + description: "Authenticated proxy endpoint: /api/media/document/{id}" + file_name: + type: string + file_size: + type: integer + format: int64 + nullable: true + mime_type: + type: string + purchase_date: + type: string + format: date-time + nullable: true + expiry_date: + type: string + format: date-time + nullable: true + purchase_price: + type: string + nullable: true + description: Decimal + vendor: + type: string + serial_number: + type: string + model_number: + type: string + task_id: + type: integer + format: uint + nullable: true + is_active: + type: boolean + images: + type: array + items: + $ref: '#/components/schemas/DocumentImageResponse' + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + + # ========================================================================= + # Notifications + # ========================================================================= + NotificationType: + type: string + enum: + - task_due_soon + - task_overdue + - task_completed + - task_assigned + - residence_shared + - warranty_expiring + + Notification: + type: object + properties: + id: + type: integer + format: uint + user_id: + type: integer + format: uint + notification_type: + $ref: '#/components/schemas/NotificationType' + title: + type: string + body: + type: string + task_id: + type: integer + format: uint + nullable: true + data: + type: string + description: JSON string with additional data + sent: + type: boolean + sent_at: + type: string + format: date-time + nullable: true + read: + type: boolean + read_at: + type: string + format: date-time + nullable: true + error_message: + type: string + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + + NotificationListResponse: + type: object + properties: + count: + type: integer + results: + type: array + items: + $ref: '#/components/schemas/Notification' + + NotificationPreference: + type: object + properties: + id: + type: integer + format: uint + user_id: + type: integer + format: uint + task_due_soon: + type: boolean + task_overdue: + type: boolean + task_completed: + type: boolean + task_assigned: + type: boolean + residence_shared: + type: boolean + warranty_expiring: + type: boolean + daily_digest: + type: boolean + email_task_completed: + type: boolean + task_due_soon_hour: + type: integer + nullable: true + description: UTC hour (0-23) for task due soon notifications + task_overdue_hour: + type: integer + nullable: true + description: UTC hour (0-23) for task overdue notifications + warranty_expiring_hour: + type: integer + nullable: true + daily_digest_hour: + type: integer + nullable: true + + UpdatePreferencesRequest: + type: object + properties: + task_due_soon: + type: boolean + nullable: true + task_overdue: + type: boolean + nullable: true + task_completed: + type: boolean + nullable: true + task_assigned: + type: boolean + nullable: true + residence_shared: + type: boolean + nullable: true + warranty_expiring: + type: boolean + nullable: true + daily_digest: + type: boolean + nullable: true + email_task_completed: + type: boolean + nullable: true + task_due_soon_hour: + type: integer + nullable: true + task_overdue_hour: + type: integer + nullable: true + warranty_expiring_hour: + type: integer + nullable: true + daily_digest_hour: + type: integer + nullable: true + + RegisterDeviceRequest: + type: object + required: [device_id, registration_id, platform] + properties: + name: + type: string + device_id: + type: string + registration_id: + type: string + description: APNs device token or FCM registration token + platform: + type: string + enum: [ios, android] + + UnregisterDeviceRequest: + type: object + required: [registration_id] + properties: + registration_id: + type: string + platform: + type: string + enum: [ios, android] + default: ios + + DeviceResponse: + type: object + properties: + id: + type: integer + format: uint + name: + type: string + device_id: + type: string + registration_id: + type: string + platform: + type: string + enum: [ios, android] + active: + type: boolean + date_created: + type: string + format: date-time + + # ========================================================================= + # Subscriptions + # ========================================================================= + SubscriptionResponse: + type: object + properties: + tier: + type: string + enum: [free, pro] + subscribed_at: + type: string + format: date-time + nullable: true + expires_at: + type: string + format: date-time + nullable: true + auto_renew: + type: boolean + cancelled_at: + type: string + format: date-time + nullable: true + platform: + type: string + description: "ios or android" + is_active: + type: boolean + is_pro: + type: boolean + + UsageResponse: + type: object + properties: + properties_count: + type: integer + format: int64 + tasks_count: + type: integer + format: int64 + contractors_count: + type: integer + format: int64 + documents_count: + type: integer + format: int64 + + TierLimitsClientResponse: + type: object + properties: + properties: + type: integer + nullable: true + description: "null means unlimited" + tasks: + type: integer + nullable: true + contractors: + type: integer + nullable: true + documents: + type: integer + nullable: true + + SubscriptionStatusResponse: + type: object + properties: + subscribed_at: + type: string + format: date-time + nullable: true + expires_at: + type: string + format: date-time + nullable: true + auto_renew: + type: boolean + usage: + $ref: '#/components/schemas/UsageResponse' + limits: + type: object + additionalProperties: + $ref: '#/components/schemas/TierLimitsClientResponse' + description: 'Map of tier name -> limits (e.g. {"free": {...}, "pro": {...}})' + limitations_enabled: + type: boolean + description: Whether subscription limitations are currently enforced + + UpgradeTriggerResponse: + type: object + properties: + trigger_key: + type: string + title: + type: string + message: + type: string + promo_html: + type: string + button_text: + type: string + + FeatureBenefit: + type: object + properties: + id: + type: integer + format: uint + feature_name: + type: string + free_tier_text: + type: string + pro_tier_text: + type: string + display_order: + type: integer + is_active: + type: boolean + + Promotion: + type: object + properties: + id: + type: integer + format: uint + promotion_id: + type: string + title: + type: string + message: + type: string + link: + type: string + nullable: true + start_date: + type: string + format: date-time + end_date: + type: string + format: date-time + target_tier: + type: string + enum: [free, pro] + is_active: + type: boolean + + ProcessPurchaseRequest: + type: object + required: [platform] + properties: + receipt_data: + type: string + description: iOS StoreKit 1 receipt or StoreKit 2 JWS + transaction_id: + type: string + description: iOS StoreKit 2 transaction ID + purchase_token: + type: string + description: Android Google Play purchase token + product_id: + type: string + description: Android product ID (helps identify subscription) + platform: + type: string + enum: [ios, android] + + # ========================================================================= + # Uploads + # ========================================================================= + UploadResult: + type: object + properties: + url: + type: string + description: URL of the uploaded file + file_name: + type: string + file_size: + type: integer + format: int64 + mime_type: + type: string diff --git a/internal/config/config.go b/internal/config/config.go index 4df7824..b0dc908 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -28,12 +28,13 @@ type Config struct { } type ServerConfig struct { - Port int - Debug bool - AllowedHosts []string - Timezone string - StaticDir string // Directory for static landing page files - BaseURL string // Public base URL for email tracking links (e.g., https://casera.app) + Port int + Debug bool + AllowedHosts []string + CorsAllowedOrigins []string // Comma-separated origins for CORS (production only; debug uses wildcard) + Timezone string + StaticDir string // Directory for static landing page files + BaseURL string // Public base URL for email tracking links (e.g., https://casera.app) } type DatabaseConfig struct { @@ -166,12 +167,13 @@ func Load() (*Config, error) { cfg = &Config{ Server: ServerConfig{ - Port: viper.GetInt("PORT"), - Debug: viper.GetBool("DEBUG"), - AllowedHosts: strings.Split(viper.GetString("ALLOWED_HOSTS"), ","), - Timezone: viper.GetString("TIMEZONE"), - StaticDir: viper.GetString("STATIC_DIR"), - BaseURL: viper.GetString("BASE_URL"), + Port: viper.GetInt("PORT"), + Debug: viper.GetBool("DEBUG"), + AllowedHosts: strings.Split(viper.GetString("ALLOWED_HOSTS"), ","), + CorsAllowedOrigins: parseCorsOrigins(viper.GetString("CORS_ALLOWED_ORIGINS")), + Timezone: viper.GetString("TIMEZONE"), + StaticDir: viper.GetString("STATIC_DIR"), + BaseURL: viper.GetString("BASE_URL"), }, Database: dbConfig, Redis: RedisConfig{ @@ -304,10 +306,13 @@ func setDefaults() { func validate(cfg *Config) error { if cfg.Security.SecretKey == "" { - // Use a default key but log a warning in production - cfg.Security.SecretKey = "change-me-in-production-secret-key-12345" - if !cfg.Server.Debug { - fmt.Println("WARNING: SECRET_KEY not set, using default (insecure)") + if cfg.Server.Debug { + // In debug mode, use a default key with a warning for local development + cfg.Security.SecretKey = "change-me-in-production-secret-key-12345" + fmt.Println("WARNING: SECRET_KEY not set, using default (debug mode only)") + } else { + // In production, refuse to start without a proper secret key + return fmt.Errorf("FATAL: SECRET_KEY environment variable is required in production (DEBUG=false)") } } @@ -339,6 +344,23 @@ func (p *PushConfig) ReadAPNSKey() (string, error) { return string(content), nil } +// parseCorsOrigins splits a comma-separated CORS_ALLOWED_ORIGINS string +// into a slice, trimming whitespace. Returns nil if the input is empty. +func parseCorsOrigins(raw string) []string { + if raw == "" { + return nil + } + parts := strings.Split(raw, ",") + var origins []string + for _, p := range parts { + trimmed := strings.TrimSpace(p) + if trimmed != "" { + origins = append(origins, trimmed) + } + } + return origins +} + // parseDatabaseURL parses a PostgreSQL URL into DatabaseConfig // Format: postgres://user:password@host:port/database?sslmode=disable func parseDatabaseURL(databaseURL string) (*DatabaseConfig, error) { diff --git a/internal/dto/requests/task.go b/internal/dto/requests/task.go index b9284d4..de16457 100644 --- a/internal/dto/requests/task.go +++ b/internal/dto/requests/task.go @@ -94,6 +94,14 @@ type CreateTaskCompletionRequest struct { ImageURLs []string `json:"image_urls"` // Multiple image URLs } +// UpdateTaskCompletionRequest represents the request to update a task completion +type UpdateTaskCompletionRequest struct { + Notes *string `json:"notes"` + ActualCost *decimal.Decimal `json:"actual_cost"` + Rating *int `json:"rating"` + ImageURLs []string `json:"image_urls"` +} + // CompletionImageInput represents an image to add to a completion type CompletionImageInput struct { ImageURL string `json:"image_url" validate:"required"` diff --git a/internal/handlers/document_handler.go b/internal/handlers/document_handler.go index 8d43a3b..d59ee77 100644 --- a/internal/handlers/document_handler.go +++ b/internal/handlers/document_handler.go @@ -241,3 +241,67 @@ func (h *DocumentHandler) DeactivateDocument(c echo.Context) error { } return c.JSON(http.StatusOK, map[string]interface{}{"message": "Document deactivated successfully", "document": response}) } + +// UploadDocumentImage handles POST /api/documents/:id/images/ +func (h *DocumentHandler) UploadDocumentImage(c echo.Context) error { + user := c.Get(middleware.AuthUserKey).(*models.User) + documentID, err := strconv.ParseUint(c.Param("id"), 10, 32) + if err != nil { + return apperrors.BadRequest("error.invalid_document_id") + } + + // Parse multipart form + if err := c.Request().ParseMultipartForm(32 << 20); err != nil { + return apperrors.BadRequest("error.failed_to_parse_form") + } + + // Look for file in common field names + var uploadedFile *multipart.FileHeader + for _, fieldName := range []string{"image", "file"} { + if file, err := c.FormFile(fieldName); err == nil { + uploadedFile = file + break + } + } + + if uploadedFile == nil { + return apperrors.BadRequest("error.no_file_provided") + } + + if h.storageService == nil { + return apperrors.Internal(nil) + } + + result, err := h.storageService.Upload(uploadedFile, "images") + if err != nil { + return apperrors.BadRequest("error.failed_to_upload_file") + } + + caption := c.FormValue("caption") + + response, err := h.documentService.UploadDocumentImage(uint(documentID), user.ID, result.URL, caption) + if err != nil { + return err + } + return c.JSON(http.StatusCreated, response) +} + +// DeleteDocumentImage handles DELETE /api/documents/:id/images/:imageId/ +func (h *DocumentHandler) DeleteDocumentImage(c echo.Context) error { + user := c.Get(middleware.AuthUserKey).(*models.User) + documentID, err := strconv.ParseUint(c.Param("id"), 10, 32) + if err != nil { + return apperrors.BadRequest("error.invalid_document_id") + } + + imageID, err := strconv.ParseUint(c.Param("imageId"), 10, 32) + if err != nil { + return apperrors.BadRequest("error.invalid_image_id") + } + + response, err := h.documentService.DeleteDocumentImage(uint(documentID), uint(imageID), user.ID) + if err != nil { + return err + } + return c.JSON(http.StatusOK, response) +} diff --git a/internal/handlers/document_handler_test.go b/internal/handlers/document_handler_test.go new file mode 100644 index 0000000..1f0279b --- /dev/null +++ b/internal/handlers/document_handler_test.go @@ -0,0 +1,213 @@ +package handlers + +import ( + "encoding/json" + "fmt" + "net/http" + "testing" + + "github.com/labstack/echo/v4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/treytartt/casera-api/internal/models" + "github.com/treytartt/casera-api/internal/repositories" + "github.com/treytartt/casera-api/internal/services" + "github.com/treytartt/casera-api/internal/testutil" + "gorm.io/gorm" +) + +func setupDocumentHandler(t *testing.T) (*DocumentHandler, *echo.Echo, *gorm.DB) { + db := testutil.SetupTestDB(t) + documentRepo := repositories.NewDocumentRepository(db) + residenceRepo := repositories.NewResidenceRepository(db) + documentService := services.NewDocumentService(documentRepo, residenceRepo) + handler := NewDocumentHandler(documentService, nil) // nil storage for JSON-only tests + e := testutil.SetupTestRouter() + return handler, e, db +} + +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") + + authGroup := e.Group("/api/documents") + authGroup.Use(testutil.MockAuthMiddleware(user)) + authGroup.GET("/", handler.ListDocuments) + + t.Run("successful list", func(t *testing.T) { + w := testutil.MakeRequest(e, "GET", "/api/documents/", 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, "Test Doc", response[0]["title"]) + }) +} + +func TestDocumentHandler_CreateDocument(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") + + authGroup := e.Group("/api/documents") + authGroup.Use(testutil.MockAuthMiddleware(user)) + authGroup.POST("/", handler.CreateDocument) + + t.Run("successful creation via JSON", func(t *testing.T) { + body := map[string]interface{}{ + "title": "Warranty Doc", + "residence_id": residence.ID, + } + w := testutil.MakeRequest(e, "POST", "/api/documents/", body, "test-token") + testutil.AssertStatusCode(t, w, http.StatusCreated) + + var response map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &response) + require.NoError(t, err) + assert.Equal(t, "Warranty Doc", response["title"]) + }) + + t.Run("creation without residence access", func(t *testing.T) { + otherUser := testutil.CreateTestUser(t, db, "other", "other@test.com", "password") + otherResidence := testutil.CreateTestResidence(t, db, otherUser.ID, "Other House") + + body := map[string]interface{}{ + "title": "Unauthorized Doc", + "residence_id": otherResidence.ID, + } + w := testutil.MakeRequest(e, "POST", "/api/documents/", body, "test-token") + testutil.AssertStatusCode(t, w, http.StatusForbidden) + }) +} + +func TestDocumentHandler_GetDocument(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") + doc := testutil.CreateTestDocument(t, db, residence.ID, user.ID, "Test Doc") + + authGroup := e.Group("/api/documents") + authGroup.Use(testutil.MockAuthMiddleware(user)) + authGroup.GET("/:id/", handler.GetDocument) + + t.Run("successful get", func(t *testing.T) { + w := testutil.MakeRequest(e, "GET", fmt.Sprintf("/api/documents/%d/", doc.ID), 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.Equal(t, "Test Doc", response["title"]) + }) + + t.Run("document not found", func(t *testing.T) { + w := testutil.MakeRequest(e, "GET", "/api/documents/99999/", nil, "test-token") + testutil.AssertStatusCode(t, w, http.StatusNotFound) + }) +} + +func TestDocumentHandler_DeleteDocumentImage(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") + doc := testutil.CreateTestDocument(t, db, residence.ID, user.ID, "Test Doc") + + // Create a document image directly + img := &models.DocumentImage{ + DocumentID: doc.ID, + ImageURL: "https://example.com/img.jpg", + Caption: "Test image", + } + require.NoError(t, db.Create(img).Error) + + authGroup := e.Group("/api/documents") + authGroup.Use(testutil.MockAuthMiddleware(user)) + authGroup.DELETE("/:id/images/:imageId/", handler.DeleteDocumentImage) + + t.Run("successful delete", func(t *testing.T) { + w := testutil.MakeRequest(e, "DELETE", fmt.Sprintf("/api/documents/%d/images/%d/", doc.ID, img.ID), 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.Equal(t, "Test Doc", response["title"]) + + // Verify image is deleted + images := response["images"].([]interface{}) + assert.Len(t, images, 0) + }) + + t.Run("image not found", func(t *testing.T) { + w := testutil.MakeRequest(e, "DELETE", fmt.Sprintf("/api/documents/%d/images/99999/", doc.ID), nil, "test-token") + testutil.AssertStatusCode(t, w, http.StatusNotFound) + }) + + t.Run("document not found", func(t *testing.T) { + w := testutil.MakeRequest(e, "DELETE", "/api/documents/99999/images/1/", nil, "test-token") + testutil.AssertStatusCode(t, w, http.StatusNotFound) + }) + + t.Run("access denied", func(t *testing.T) { + otherUser := testutil.CreateTestUser(t, db, "other", "other@test.com", "password") + otherResidence := testutil.CreateTestResidence(t, db, otherUser.ID, "Other House") + otherDoc := testutil.CreateTestDocument(t, db, otherResidence.ID, otherUser.ID, "Other Doc") + + otherImg := &models.DocumentImage{ + DocumentID: otherDoc.ID, + ImageURL: "https://example.com/other.jpg", + } + require.NoError(t, db.Create(otherImg).Error) + + w := testutil.MakeRequest(e, "DELETE", fmt.Sprintf("/api/documents/%d/images/%d/", otherDoc.ID, otherImg.ID), nil, "test-token") + testutil.AssertStatusCode(t, w, http.StatusForbidden) + }) +} + +func TestDocumentHandler_UploadDocumentImage_NoStorage(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") + doc := testutil.CreateTestDocument(t, db, residence.ID, user.ID, "Test Doc") + + authGroup := e.Group("/api/documents") + authGroup.Use(testutil.MockAuthMiddleware(user)) + authGroup.POST("/:id/images/", handler.UploadDocumentImage) + + t.Run("document not found", func(t *testing.T) { + // Send a plain request (no multipart) - will fail at parse + w := testutil.MakeRequest(e, "POST", "/api/documents/99999/images/", nil, "test-token") + // Should get 400 because no multipart form + assert.True(t, w.Code == http.StatusBadRequest || w.Code == http.StatusNotFound, + "expected 400 or 404, got %d", w.Code) + }) + + _ = doc // used to set up test data +} + +func TestDocumentHandler_DeleteDocument(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") + doc := testutil.CreateTestDocument(t, db, residence.ID, user.ID, "Test Doc") + + authGroup := e.Group("/api/documents") + authGroup.Use(testutil.MockAuthMiddleware(user)) + authGroup.DELETE("/:id/", handler.DeleteDocument) + + t.Run("successful delete", func(t *testing.T) { + w := testutil.MakeRequest(e, "DELETE", fmt.Sprintf("/api/documents/%d/", doc.ID), nil, "test-token") + testutil.AssertStatusCode(t, w, http.StatusOK) + }) + + t.Run("document not found after delete", func(t *testing.T) { + // Already soft-deleted above + w := testutil.MakeRequest(e, "DELETE", fmt.Sprintf("/api/documents/%d/", doc.ID), nil, "test-token") + testutil.AssertStatusCode(t, w, http.StatusNotFound) + }) +} diff --git a/internal/handlers/notification_handler.go b/internal/handlers/notification_handler.go index 72a5b76..6b6a67f 100644 --- a/internal/handlers/notification_handler.go +++ b/internal/handlers/notification_handler.go @@ -149,6 +149,33 @@ func (h *NotificationHandler) ListDevices(c echo.Context) error { return c.JSON(http.StatusOK, devices) } +// UnregisterDevice handles POST /api/notifications/devices/unregister/ +// Accepts {registration_id, platform} and deactivates the matching device +func (h *NotificationHandler) UnregisterDevice(c echo.Context) error { + user := c.Get(middleware.AuthUserKey).(*models.User) + + var req struct { + RegistrationID string `json:"registration_id"` + Platform string `json:"platform"` + } + if err := c.Bind(&req); err != nil { + return apperrors.BadRequest("error.invalid_request") + } + if req.RegistrationID == "" { + return apperrors.BadRequest("error.registration_id_required") + } + if req.Platform == "" { + req.Platform = "ios" // Default to iOS + } + + err := h.notificationService.UnregisterDevice(req.RegistrationID, req.Platform, user.ID) + if err != nil { + return err + } + + return c.JSON(http.StatusOK, map[string]interface{}{"message": "message.device_unregistered"}) +} + // DeleteDevice handles DELETE /api/notifications/devices/:id/ func (h *NotificationHandler) DeleteDevice(c echo.Context) error { user := c.Get(middleware.AuthUserKey).(*models.User) diff --git a/internal/handlers/residence_handler.go b/internal/handlers/residence_handler.go index 212c52f..5368877 100644 --- a/internal/handlers/residence_handler.go +++ b/internal/handlers/residence_handler.go @@ -149,6 +149,28 @@ func (h *ResidenceHandler) DeleteResidence(c echo.Context) error { return c.JSON(http.StatusOK, response) } +// GetShareCode handles GET /api/residences/:id/share-code/ +// Returns the active share code for a residence, or null if none exists +func (h *ResidenceHandler) GetShareCode(c echo.Context) error { + user := c.Get(middleware.AuthUserKey).(*models.User) + + residenceID, err := strconv.ParseUint(c.Param("id"), 10, 32) + if err != nil { + return apperrors.BadRequest("error.invalid_residence_id") + } + + shareCode, err := h.residenceService.GetShareCode(uint(residenceID), user.ID) + if err != nil { + return err + } + + if shareCode == nil { + return c.JSON(http.StatusOK, map[string]interface{}{"share_code": nil}) + } + + return c.JSON(http.StatusOK, map[string]interface{}{"share_code": shareCode}) +} + // GenerateShareCode handles POST /api/residences/:id/generate-share-code/ func (h *ResidenceHandler) GenerateShareCode(c echo.Context) error { user := c.Get(middleware.AuthUserKey).(*models.User) diff --git a/internal/handlers/subscription_handler.go b/internal/handlers/subscription_handler.go index 844b6ef..46ff50c 100644 --- a/internal/handlers/subscription_handler.go +++ b/internal/handlers/subscription_handler.go @@ -113,6 +113,8 @@ func (h *SubscriptionHandler) ProcessPurchase(c echo.Context) error { return apperrors.BadRequest("error.purchase_token_required") } subscription, err = h.subscriptionService.ProcessGooglePurchase(user.ID, req.PurchaseToken, req.ProductID) + default: + return apperrors.BadRequest("error.invalid_platform") } if err != nil { @@ -158,6 +160,8 @@ func (h *SubscriptionHandler) RestoreSubscription(c echo.Context) error { subscription, err = h.subscriptionService.ProcessApplePurchase(user.ID, req.ReceiptData, req.TransactionID) case "android": subscription, err = h.subscriptionService.ProcessGooglePurchase(user.ID, req.PurchaseToken, req.ProductID) + default: + return apperrors.BadRequest("error.invalid_platform") } if err != nil { diff --git a/internal/handlers/task_handler.go b/internal/handlers/task_handler.go index 41a9b06..8c7a53b 100644 --- a/internal/handlers/task_handler.go +++ b/internal/handlers/task_handler.go @@ -1,7 +1,6 @@ package handlers import ( - "mime/multipart" "net/http" "strconv" "strings" @@ -75,7 +74,12 @@ func (h *TaskHandler) GetTasksByResidence(c echo.Context) error { } daysThreshold := 30 - if d := c.QueryParam("days_threshold"); d != "" { + // 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 } @@ -331,23 +335,17 @@ func (h *TaskHandler) CreateCompletion(c echo.Context) error { } } - // Handle image upload (look for "images" or "image" or "photo" field) - var imageFile interface{} - for _, fieldName := range []string{"images", "image", "photo"} { - if file, err := c.FormFile(fieldName); err == nil { - imageFile = file - break - } - } - - if imageFile != nil { - file := imageFile.(*multipart.FileHeader) - if h.storageService != nil { - result, err := h.storageService.Upload(file, "completions") - if err != nil { - return apperrors.BadRequest("error.failed_to_upload_image") + // Handle multiple image uploads from various field names + if h.storageService != nil && c.Request().MultipartForm != nil { + for _, fieldName := range []string{"images", "image", "photo", "files"} { + files := c.Request().MultipartForm.File[fieldName] + for _, file := range files { + result, err := h.storageService.Upload(file, "completions") + if err != nil { + return apperrors.BadRequest("error.failed_to_upload_image") + } + req.ImageURLs = append(req.ImageURLs, result.URL) } - req.ImageURLs = append(req.ImageURLs, result.URL) } } } else { @@ -364,6 +362,26 @@ func (h *TaskHandler) CreateCompletion(c echo.Context) error { return c.JSON(http.StatusCreated, response) } +// UpdateCompletion handles PUT /api/task-completions/:id/ +func (h *TaskHandler) UpdateCompletion(c echo.Context) error { + user := c.Get(middleware.AuthUserKey).(*models.User) + completionID, err := strconv.ParseUint(c.Param("id"), 10, 32) + if err != nil { + return apperrors.BadRequest("error.invalid_completion_id") + } + + var req requests.UpdateTaskCompletionRequest + if err := c.Bind(&req); err != nil { + return apperrors.BadRequest("error.invalid_request") + } + + response, err := h.taskService.UpdateCompletion(uint(completionID), user.ID, &req) + if err != nil { + return err + } + return c.JSON(http.StatusOK, response) +} + // DeleteCompletion handles DELETE /api/task-completions/:id/ func (h *TaskHandler) DeleteCompletion(c echo.Context) error { user := c.Get(middleware.AuthUserKey).(*models.User) diff --git a/internal/integration/contract_test.go b/internal/integration/contract_test.go new file mode 100644 index 0000000..c7d3b41 --- /dev/null +++ b/internal/integration/contract_test.go @@ -0,0 +1,229 @@ +package integration + +import ( + "fmt" + "os" + "sort" + "strings" + "testing" + + "github.com/labstack/echo/v4" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" + + "github.com/treytartt/casera-api/internal/config" + "github.com/treytartt/casera-api/internal/router" + "github.com/treytartt/casera-api/internal/testutil" +) + +// routeKey is a comparable type for route matching: method + path +type routeKey struct { + Method string + Path string +} + +// TestRouteSpecContract verifies that registered Echo routes match the OpenAPI spec. +// It ensures bidirectional consistency: +// - Every spec path has a corresponding registered route +// - Every registered API route has a corresponding spec path +func TestRouteSpecContract(t *testing.T) { + // --- Parse OpenAPI spec --- + specRoutes := extractSpecRoutes(t) + require.NotEmpty(t, specRoutes, "OpenAPI spec should have at least one route") + + // --- Set up Echo router --- + db := testutil.SetupTestDB(t) + cfg := &config.Config{} + cfg.Server.Debug = true + deps := &router.Dependencies{ + DB: db, + Config: cfg, + } + e := router.SetupRouter(deps) + + echoRoutes := extractEchoRoutes(e.Routes()) + require.NotEmpty(t, echoRoutes, "Echo router should have at least one route") + + // --- Bidirectional match --- + t.Run("spec routes exist in router", func(t *testing.T) { + var missing []string + for _, sr := range specRoutes { + if shouldSkipSpecRoute(sr.Path) { + continue + } + if !containsRoute(echoRoutes, sr) { + missing = append(missing, fmt.Sprintf("%s %s", sr.Method, sr.Path)) + } + } + if len(missing) > 0 { + sort.Strings(missing) + t.Errorf("OpenAPI spec defines routes not registered in Echo router:\n %s", + strings.Join(missing, "\n ")) + } + }) + + t.Run("router routes exist in spec", func(t *testing.T) { + var missing []string + for _, er := range echoRoutes { + if shouldSkipRouterRoute(er.Path) { + continue + } + if !containsRoute(specRoutes, er) { + missing = append(missing, fmt.Sprintf("%s %s", er.Method, er.Path)) + } + } + if len(missing) > 0 { + sort.Strings(missing) + t.Errorf("Echo routes not documented in OpenAPI spec:\n %s", + strings.Join(missing, "\n ")) + } + }) +} + +// extractSpecRoutes parses the OpenAPI YAML and returns normalized route keys. +// Spec paths use OpenAPI param format: /documents/{id}/ +// These are returned as-is since Echo routes are converted to this format. +func extractSpecRoutes(t *testing.T) []routeKey { + t.Helper() + + data, err := os.ReadFile("../../docs/openapi.yaml") + require.NoError(t, err, "Failed to read openapi.yaml") + + var spec struct { + Paths map[string]map[string]interface{} `yaml:"paths"` + } + require.NoError(t, yaml.Unmarshal(data, &spec), "Failed to parse openapi.yaml") + + var routes []routeKey + for path, methods := range spec.Paths { + for method := range methods { + upper := strings.ToUpper(method) + // Skip non-HTTP methods (parameters, summary, etc.) + switch upper { + case "GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS": + routes = append(routes, routeKey{Method: upper, Path: path}) + } + } + } + + sort.Slice(routes, func(i, j int) bool { + return routes[i].Path < routes[j].Path + }) + return routes +} + +// extractEchoRoutes returns normalized route keys from Echo's registered routes. +// Filters out admin, static, health, tracking, and internal routes. +func extractEchoRoutes(echoRoutes []*echo.Route) []routeKey { + seen := make(map[routeKey]bool) + var routes []routeKey + + for _, r := range echoRoutes { + if shouldSkipRoute(r.Path, r.Method) { + continue + } + + // Strip /api prefix to match spec paths (spec server base is /api) + path := strings.TrimPrefix(r.Path, "/api") + + // Normalize Echo :param to OpenAPI {param} + path = normalizePathToOpenAPI(path) + + key := routeKey{Method: r.Method, Path: path} + if !seen[key] { + seen[key] = true + routes = append(routes, key) + } + } + + sort.Slice(routes, func(i, j int) bool { + return routes[i].Path < routes[j].Path + }) + return routes +} + +// normalizePathToOpenAPI converts Echo `:param` to OpenAPI `{param}` format. +func normalizePathToOpenAPI(path string) string { + parts := strings.Split(path, "/") + for i, part := range parts { + if strings.HasPrefix(part, ":") { + parts[i] = "{" + strings.TrimPrefix(part, ":") + "}" + } + } + return strings.Join(parts, "/") +} + +// shouldSkipRoute returns true for routes that are not part of the public API spec. +func shouldSkipRoute(path, method string) bool { + // Skip non-API routes (static files, root page) + if !strings.HasPrefix(path, "/api/") { + return true + } + + // Skip admin routes + if strings.HasPrefix(path, "/api/admin") { + return true + } + + // Skip health check (internal, not in spec) + if path == "/api/health/" { + return true + } + + // Skip email tracking (internal, not in spec) + if strings.HasPrefix(path, "/api/track/") { + return true + } + + // Skip echo-internal routes (e.g., OPTIONS auto-generated by CORS) + if method == "echo_route_not_found" { + return true + } + + return false +} + +// shouldSkipSpecRoute returns true for spec routes that require optional services +// (e.g., storage/media routes require a non-nil StorageService which is not available in tests). +func shouldSkipSpecRoute(path string) bool { + // Upload and media routes are conditionally registered (require StorageService) + if strings.HasPrefix(path, "/uploads/") || strings.HasPrefix(path, "/media/") { + return true + } + return false +} + +// shouldSkipRouterRoute returns true for router routes that are intentionally +// not documented in the OpenAPI spec (internal aliases, webhooks, etc.). +func shouldSkipRouterRoute(path string) bool { + skipPaths := map[string]bool{ + // Internal auth alias for mobile client compatibility + "/auth/verify/": true, + // Static data cache management (internal) + "/static_data/refresh/": true, + // Server-to-server webhook routes (called by Apple/Google, not mobile clients) + "/subscription/webhook/apple/": true, + "/subscription/webhook/google/": true, + // User management routes (internal/admin-facing, not in mobile API spec) + "/users/": true, + "/users/profiles/": true, + } + if skipPaths[path] { + return true + } + // Skip /users/{id}/ pattern + if strings.HasPrefix(path, "/users/{") { + return true + } + return false +} + +// containsRoute checks if a routeKey exists in a slice. +func containsRoute(routes []routeKey, target routeKey) bool { + for _, r := range routes { + if r.Method == target.Method && r.Path == target.Path { + return true + } + } + return false +} diff --git a/internal/integration/kmp_contract_test.go b/internal/integration/kmp_contract_test.go new file mode 100644 index 0000000..e3e2fa1 --- /dev/null +++ b/internal/integration/kmp_contract_test.go @@ -0,0 +1,286 @@ +package integration + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + "testing" + + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" +) + +// TestKMPSpecContract verifies that KMP API clients (*Api.kt) match the OpenAPI spec. +// It ensures bidirectional consistency: +// - Every spec endpoint (minus exclusions) has a KMP implementation +// - Every KMP endpoint (after alias resolution) exists in the spec +func TestKMPSpecContract(t *testing.T) { + // --- Parse OpenAPI spec --- + specRoutes := extractSpecRoutesForKMP(t) + require.NotEmpty(t, specRoutes, "OpenAPI spec should have at least one route") + + // --- Extract KMP routes --- + kmpRoutes := extractKMPRoutes(t) + require.NotEmpty(t, kmpRoutes, "KMP API clients should have at least one route") + + t.Logf("Spec routes: %d, KMP routes: %d", len(specRoutes), len(kmpRoutes)) + + // --- Direction 1: Every spec endpoint covered by KMP --- + t.Run("spec endpoints covered by KMP", func(t *testing.T) { + var missing []string + for _, sr := range specRoutes { + if specEndpointsKMPSkips[sr] { + continue + } + if !containsRoute(kmpRoutes, sr) { + missing = append(missing, fmt.Sprintf("%s %s", sr.Method, sr.Path)) + } + } + if len(missing) > 0 { + sort.Strings(missing) + t.Errorf("OpenAPI spec defines endpoints not implemented in KMP:\n %s", + strings.Join(missing, "\n ")) + } + }) + + // --- Direction 2: Every KMP endpoint exists in spec --- + t.Run("KMP endpoints exist in spec", func(t *testing.T) { + var missing []string + for _, kr := range kmpRoutes { + if !containsRoute(specRoutes, kr) { + missing = append(missing, fmt.Sprintf("%s %s", kr.Method, kr.Path)) + } + } + if len(missing) > 0 { + sort.Strings(missing) + t.Errorf("KMP API clients call endpoints not defined in OpenAPI spec:\n %s", + strings.Join(missing, "\n ")) + } + }) +} + +// specEndpointsKMPSkips are spec endpoints that KMP intentionally does not implement. +// All paths must use normalized form ({_} for all path params) to match extractSpecRoutesForKMP output. +var specEndpointsKMPSkips = map[routeKey]bool{ + // Upload endpoints — KMP embeds file uploads in domain-specific multipart calls + {Method: "POST", Path: "/uploads/image/"}: true, + {Method: "POST", Path: "/uploads/document/"}: true, + {Method: "POST", Path: "/uploads/completion-file/"}: true, + {Method: "POST", Path: "/uploads/completion/"}: true, + {Method: "DELETE", Path: "/uploads/"}: true, + // Media proxy — KMP uses dynamic URLs from API responses + {Method: "GET", Path: "/media/{_}"}: true, // /media/{path} + {Method: "GET", Path: "/media/document/{_}"}: true, + {Method: "GET", Path: "/media/document-image/{_}"}: true, + {Method: "GET", Path: "/media/completion-image/{_}"}: true, + // PUT/PATCH variants where KMP uses only one method + {Method: "PUT", Path: "/contractors/{_}/"}: true, // KMP uses PATCH + {Method: "PUT", Path: "/documents/{_}/"}: true, // KMP uses PATCH + {Method: "PATCH", Path: "/residences/{_}/"}: true, // KMP uses PUT + {Method: "PATCH", Path: "/tasks/{_}/completions/"}: true, // KMP uses PUT on /task-completions/{_}/ + {Method: "PATCH", Path: "/auth/profile/"}: true, // KMP uses PUT + // Task action endpoints where KMP uses PATCH on main resource or different endpoint + {Method: "POST", Path: "/tasks/{_}/mark-in-progress/"}: true, // KMP uses PATCH tasks/{_}/ + {Method: "POST", Path: "/tasks/{_}/quick-complete/"}: true, // KMP uses POST /task-completions/ + // Subscription endpoints handled client-side or via different path + {Method: "POST", Path: "/subscription/cancel/"}: true, // Handled by App Store / Play Store + {Method: "GET", Path: "/subscription/"}: true, // KMP uses /subscription/status/ + {Method: "GET", Path: "/subscription/upgrade-trigger/{_}/"}: true, // KMP uses list endpoint + // Auth endpoints not yet implemented in KMP + {Method: "POST", Path: "/auth/resend-verification/"}: true, + // Document warranty endpoint — KMP filters via query params on /documents/ + {Method: "GET", Path: "/documents/warranties/"}: true, + // Device management — KMP uses different endpoints + {Method: "GET", Path: "/notifications/devices/"}: true, // KMP doesn't list devices + {Method: "POST", Path: "/notifications/devices/"}: true, // KMP uses /notifications/devices/register/ + {Method: "POST", Path: "/notifications/devices/unregister/"}: true, // KMP uses DELETE on device ID + {Method: "PATCH", Path: "/notifications/preferences/"}: true, // KMP uses PUT +} + +// kmpRouteAliases maps KMP paths to their canonical spec paths. +// Applied after extraction but before spec comparison. +// Currently empty — all KMP paths match spec paths after the /auth/verify/ → /auth/verify-email/ fix. +var kmpRouteAliases = map[routeKey]routeKey{} + +// kmpDynamicExpansions maps dynamic route patterns to concrete actions. +// Used for TaskApi.postTaskAction which builds paths like /tasks/$id/$action/ +var kmpDynamicExpansions = map[routeKey][]string{ + {Method: "POST", Path: "/tasks/{_}/{_}/"}: {"cancel", "uncancel", "archive", "unarchive"}, +} + +// Regex patterns for extracting HTTP calls from KMP *Api.kt files. +var ( + // Standard Ktor calls: client.get("$baseUrl/path/") + reStandardCall = regexp.MustCompile(`client\.(get|post|put|patch|delete)\(\s*"?\$baseUrl(/[^")\s]+)"?`) + // Multipart calls: submitFormWithBinaryData(url = "$baseUrl/path/", ...) + reMultipartCall = regexp.MustCompile(`submitFormWithBinaryData\(\s*(?:\n\s*)?url\s*=\s*"\$baseUrl(/[^"]+)"`) +) + +// extractKMPRoutes scans KMP *Api.kt files and returns normalized route keys. +func extractKMPRoutes(t *testing.T) []routeKey { + t.Helper() + + // KMP source directory relative to this test file + kmpDir := filepath.Join("..", "..", "..", "MyCribKMM", "composeApp", "src", "commonMain", "kotlin", "com", "example", "casera", "network") + + // Verify directory exists — skip in CI where KMP repo may not be checked out + info, err := os.Stat(kmpDir) + if os.IsNotExist(err) { + t.Skipf("KMP network directory not found at %s (expected in monorepo layout)", kmpDir) + } + require.NoError(t, err, "Failed to stat KMP network directory at %s", kmpDir) + require.True(t, info.IsDir(), "KMP network path is not a directory") + + // Find all *Api.kt files + matches, err := filepath.Glob(filepath.Join(kmpDir, "*Api.kt")) + require.NoError(t, err, "Failed to glob *Api.kt files") + require.NotEmpty(t, matches, "No *Api.kt files found in %s", kmpDir) + + seen := make(map[routeKey]bool) + var routes []routeKey + + for _, file := range matches { + data, err := os.ReadFile(file) + require.NoError(t, err, "Failed to read %s", file) + content := string(data) + + // Extract standard HTTP calls + for _, match := range reStandardCall.FindAllStringSubmatch(content, -1) { + method := strings.ToUpper(match[1]) + path := normalizeKMPPath(match[2]) + key := routeKey{Method: method, Path: path} + if !seen[key] { + seen[key] = true + routes = append(routes, key) + } + } + + // Extract multipart calls (these are always POST) + for _, match := range reMultipartCall.FindAllStringSubmatch(content, -1) { + path := normalizeKMPPath(match[1]) + key := routeKey{Method: "POST", Path: path} + if !seen[key] { + seen[key] = true + routes = append(routes, key) + } + } + } + + // Expand dynamic routes (e.g., /tasks/$id/$action/ → cancel, uncancel, etc.) + routes = expandDynamicRoutes(routes, seen) + + // Resolve aliases (e.g., /auth/verify/ → /auth/verify-email/) + routes = resolveAliases(routes) + + sort.Slice(routes, func(i, j int) bool { + if routes[i].Path == routes[j].Path { + return routes[i].Method < routes[j].Method + } + return routes[i].Path < routes[j].Path + }) + return routes +} + +// normalizeKMPPath converts KMP string interpolation paths to a generic param format. +// Replaces $variable segments with {_} to match normalized spec paths. +// Example: /tasks/$id/$action/ → /tasks/{_}/{_}/ +func normalizeKMPPath(path string) string { + parts := strings.Split(path, "/") + for i, part := range parts { + if strings.HasPrefix(part, "$") { + parts[i] = "{_}" + } + } + return strings.Join(parts, "/") +} + +// normalizeSpecPath converts OpenAPI {paramName} segments to {_} for comparison. +// Example: /tasks/{id}/completions/ → /tasks/{_}/completions/ +// Special case: /media/{path} stays as /media/{path} (wildcard, not a segment param) +func normalizeSpecPath(path string) string { + parts := strings.Split(path, "/") + for i, part := range parts { + if strings.HasPrefix(part, "{") && strings.HasSuffix(part, "}") { + parts[i] = "{_}" + } + } + return strings.Join(parts, "/") +} + +// expandDynamicRoutes expands routes with multiple path params into concrete action routes. +// For example, POST /tasks/{_}/{_}/ becomes POST /tasks/{_}/cancel/, etc. +func expandDynamicRoutes(routes []routeKey, seen map[routeKey]bool) []routeKey { + var expanded []routeKey + for _, r := range routes { + if actions, ok := kmpDynamicExpansions[r]; ok { + // Replace the last {_} segment with each concrete action + for _, action := range actions { + // Build path: replace last {_} with action name + path := strings.TrimSuffix(r.Path, "{_}/") + action + "/" + key := routeKey{Method: r.Method, Path: path} + if !seen[key] { + seen[key] = true + expanded = append(expanded, key) + } + } + // Don't include the generic pattern itself + continue + } + expanded = append(expanded, r) + } + return expanded +} + +// resolveAliases replaces KMP routes with their canonical spec equivalents. +func resolveAliases(routes []routeKey) []routeKey { + result := make([]routeKey, 0, len(routes)) + for _, r := range routes { + if canonical, ok := kmpRouteAliases[r]; ok { + result = append(result, canonical) + } else { + result = append(result, r) + } + } + return result +} + +// extractSpecRoutesForKMP parses openapi.yaml and returns routes normalized for KMP comparison. +func extractSpecRoutesForKMP(t *testing.T) []routeKey { + t.Helper() + + data, err := os.ReadFile("../../docs/openapi.yaml") + require.NoError(t, err, "Failed to read openapi.yaml") + + var spec struct { + Paths map[string]map[string]interface{} `yaml:"paths"` + } + require.NoError(t, yaml.Unmarshal(data, &spec), "Failed to parse openapi.yaml") + + seen := make(map[routeKey]bool) + var routes []routeKey + for path, methods := range spec.Paths { + for method := range methods { + upper := strings.ToUpper(method) + switch upper { + case "GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS": + normalized := normalizeSpecPath(path) + key := routeKey{Method: upper, Path: normalized} + if !seen[key] { + seen[key] = true + routes = append(routes, key) + } + } + } + } + + sort.Slice(routes, func(i, j int) bool { + if routes[i].Path == routes[j].Path { + return routes[i].Method < routes[j].Method + } + return routes[i].Path < routes[j].Path + }) + return routes +} diff --git a/internal/integration/kmp_model_contract_test.go b/internal/integration/kmp_model_contract_test.go new file mode 100644 index 0000000..669315d --- /dev/null +++ b/internal/integration/kmp_model_contract_test.go @@ -0,0 +1,774 @@ +package integration + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" +) + +// TestKMPModelSchemaContract validates that KMP @Serializable model classes match +// the OpenAPI spec schemas field-by-field. It checks: +// - Every spec field has a matching KMP property (via @SerialName or property name) +// - Types are compatible (spec string→String, integer→Int, number→Double, etc.) +// - Nullability is compatible (spec nullable:true → Kotlin Type?) +// +// This catches schema drift when the Go API evolves the spec but KMP models aren't updated. +func TestKMPModelSchemaContract(t *testing.T) { + specSchemas := loadSpecSchemas(t) + kmpModels := loadKMPModels(t) + + require.NotEmpty(t, specSchemas, "should parse schemas from openapi.yaml") + require.NotEmpty(t, kmpModels, "should parse @Serializable classes from KMP models") + + t.Logf("Spec schemas: %d, KMP models: %d", len(specSchemas), len(kmpModels)) + + // ------------------------------------------------------------------- + // Direction 1: spec → KMP — every mapped spec schema field should exist in KMP + // ------------------------------------------------------------------- + t.Run("spec fields exist in KMP models", func(t *testing.T) { + for specName, mapping := range schemaToKMPClass { + schema, ok := specSchemas[specName] + if !ok { + t.Errorf("spec schema %q not found in openapi.yaml", specName) + continue + } + + kmpClass, ok := kmpModels[mapping.kmpClassName] + if !ok { + t.Errorf("KMP class %q (mapped from spec %q) not found in model files", mapping.kmpClassName, specName) + continue + } + + t.Run(specName+"→"+mapping.kmpClassName, func(t *testing.T) { + // Build KMP field index by JSON name + kmpFieldsByJSON := make(map[string]kmpField) + for _, f := range kmpClass.fields { + kmpFieldsByJSON[f.jsonName] = f + } + + for fieldName, specField := range schema.properties { + overrideKey := specName + "." + fieldName + + // Skip fields known to be absent from KMP + if _, ok := knownMissingFromKMP[overrideKey]; ok { + continue + } + + kf, found := kmpFieldsByJSON[fieldName] + if !found { + t.Errorf("spec field %q not found in KMP class %s", fieldName, mapping.kmpClassName) + continue + } + + // Type check (unless overridden) + if _, ok := knownTypeOverrides[overrideKey]; !ok { + expectedKotlin := mapSpecTypeToKotlin(specField) + actualKotlin := normalizeKotlinType(kf.kotlinType) + + if !typesCompatible(expectedKotlin, actualKotlin) { + t.Errorf("type mismatch: %s.%s — spec %s(%s) → expected Kotlin %q, got %q", + mapping.kmpClassName, fieldName, + specField.typeName, specField.format, + expectedKotlin, actualKotlin) + } + } + + // Nullability: if spec says nullable, KMP must allow it + if specField.nullable && !kf.nullable && !specField.isRef && !specField.isArray { + if _, ok := knownTypeOverrides[overrideKey]; !ok { + t.Errorf("nullability mismatch: %s.%s — spec says nullable, KMP type %s is non-nullable", + mapping.kmpClassName, fieldName, kf.kotlinType) + } + } + } + }) + } + }) + + // ------------------------------------------------------------------- + // Direction 2: KMP → spec — KMP fields should exist in spec (or be documented) + // ------------------------------------------------------------------- + t.Run("KMP fields exist in spec", func(t *testing.T) { + for specName, mapping := range schemaToKMPClass { + schema, ok := specSchemas[specName] + if !ok { + continue + } + + kmpClass, ok := kmpModels[mapping.kmpClassName] + if !ok { + continue + } + + t.Run(mapping.kmpClassName+"→"+specName, func(t *testing.T) { + for _, kf := range kmpClass.fields { + overrideKey := specName + "." + kf.jsonName + + if _, ok := knownExtraInKMP[overrideKey]; ok { + continue + } + + // Skip private backing fields (e.g., _verified) + if strings.HasPrefix(kf.propertyName, "_") { + continue + } + + if _, found := schema.properties[kf.jsonName]; !found { + t.Errorf("KMP field %s.%s (json: %q) not in spec schema %s", + mapping.kmpClassName, kf.propertyName, kf.jsonName, specName) + } + } + }) + } + }) + + // ------------------------------------------------------------------- + // Direction 3: all spec schemas should be mapped (or excluded) + // ------------------------------------------------------------------- + t.Run("all spec schemas mapped", func(t *testing.T) { + var unmapped []string + for name := range specSchemas { + if _, ok := schemaToKMPClass[name]; ok { + continue + } + if _, ok := excludedSchemas[name]; ok { + continue + } + unmapped = append(unmapped, name) + } + sort.Strings(unmapped) + if len(unmapped) > 0 { + t.Errorf("OpenAPI schemas without KMP mapping:\n %s\nAdd to schemaToKMPClass or excludedSchemas.", + strings.Join(unmapped, "\n ")) + } + }) +} + +// ========================================================================== +// Schema ↔ KMP class mapping +// ========================================================================== + +type classMapping struct { + kmpClassName string +} + +var schemaToKMPClass = map[string]classMapping{ + // Auth + "LoginRequest": {kmpClassName: "LoginRequest"}, + "RegisterRequest": {kmpClassName: "RegisterRequest"}, + "ForgotPasswordRequest": {kmpClassName: "ForgotPasswordRequest"}, + "VerifyResetCodeRequest": {kmpClassName: "VerifyResetCodeRequest"}, + "ResetPasswordRequest": {kmpClassName: "ResetPasswordRequest"}, + "UpdateProfileRequest": {kmpClassName: "UpdateProfileRequest"}, + "VerifyEmailRequest": {kmpClassName: "VerifyEmailRequest"}, + "AppleSignInRequest": {kmpClassName: "AppleSignInRequest"}, + "GoogleSignInRequest": {kmpClassName: "GoogleSignInRequest"}, + "UserResponse": {kmpClassName: "User"}, + "UserProfileResponse": {kmpClassName: "UserProfile"}, + "LoginResponse": {kmpClassName: "AuthResponse"}, + "RegisterResponse": {kmpClassName: "RegisterResponse"}, + "SocialSignInResponse": {kmpClassName: "AppleSignInResponse"}, // Same shape + "VerifyEmailResponse": {kmpClassName: "VerifyEmailResponse"}, + "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"}, + "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"}, + "GenerateShareCodeResponse": {kmpClassName: "GenerateShareCodeResponse"}, + + // Task + "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"}, + + // Contractor + "CreateContractorRequest": {kmpClassName: "ContractorCreateRequest"}, + "UpdateContractorRequest": {kmpClassName: "ContractorUpdateRequest"}, + "ContractorResponse": {kmpClassName: "Contractor"}, + + // Document + "CreateDocumentRequest": {kmpClassName: "DocumentCreateRequest"}, + "UpdateDocumentRequest": {kmpClassName: "DocumentUpdateRequest"}, + "DocumentImageResponse": {kmpClassName: "DocumentImage"}, + "DocumentResponse": {kmpClassName: "Document"}, + + // Notification + "RegisterDeviceRequest": {kmpClassName: "DeviceRegistrationRequest"}, + "DeviceResponse": {kmpClassName: "DeviceRegistrationResponse"}, + "NotificationPreference": {kmpClassName: "NotificationPreference"}, + "UpdatePreferencesRequest": {kmpClassName: "UpdateNotificationPreferencesRequest"}, + "Notification": {kmpClassName: "Notification"}, + "NotificationListResponse": {kmpClassName: "NotificationListResponse"}, + + // Subscription + "SubscriptionStatusResponse": {kmpClassName: "SubscriptionStatus"}, + "UsageResponse": {kmpClassName: "UsageStats"}, + "TierLimitsClientResponse": {kmpClassName: "TierLimits"}, + "FeatureBenefit": {kmpClassName: "FeatureBenefit"}, + "Promotion": {kmpClassName: "Promotion"}, + + // Common + "ErrorResponse": {kmpClassName: "ErrorResponse"}, + "MessageResponse": {kmpClassName: "MessageResponse"}, +} + +// excludedSchemas are spec schemas intentionally not mapped to KMP classes. +var excludedSchemas = map[string]string{ + "TaskWithSummaryResponse": "KMP uses generic WithSummaryResponse", + "DeleteWithSummaryResponse": "KMP uses generic WithSummaryResponse", + "ResidenceWithSummaryResponse": "KMP uses generic WithSummaryResponse", + "ResidenceDeleteWithSummaryResponse": "KMP uses generic WithSummaryResponse", + "TaskCompletionWithSummaryResponse": "KMP uses generic WithSummaryResponse", + "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": "Different shape — SubscriptionStatusResponse is mapped", + "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", + "CreateTaskCompletionRequest.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", + + // Spec uses inline object for created_by, KMP uses typed classes + "DocumentResponse.created_by": "Spec uses inline object, KMP uses DocumentUser typed class", + "ContractorResponse.created_by": "Spec uses inline object, KMP uses ContractorUser typed class", + + // Spec says string (JSON), KMP uses Map + "Notification.data": "KMP deserializes JSON string into Map", + + // Spec uses $ref to enum, KMP uses String + "CreateDocumentRequest.document_type": "KMP uses String; spec uses DocumentType $ref", + "UpdateDocumentRequest.document_type": "KMP uses String; spec uses DocumentType $ref", + "Notification.notification_type": "KMP uses String; spec uses NotificationType $ref", + + // Spec has number/double, KMP has Double — these are actually compatible + // but the parser sees "number" vs "Double" which the type checker handles +} + +// 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", + + // 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", +} + +// 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.days_until_expiration": "Client-side computed field", + "DocumentResponse.warranty_status": "Client-side computed field", + + // DocumentImage extra fields + "DocumentImageResponse.uploaded_at": "KMP has uploaded_at for display", + + // TaskCompletionImage extra fields + "TaskCompletionImageResponse.uploaded_at": "KMP has uploaded_at for display", + + // TaskResponse completions array (included in kanban response) + "TaskResponse.completions": "KMP includes completions array for kanban", + "TaskResponse.custom_interval_days": "KMP supports custom frequency intervals", + + // ErrorResponse: KMP has 'errors', 'status_code', 'detail' not in spec + "ErrorResponse.errors": "KMP error response includes field-level errors map", + "ErrorResponse.status_code": "KMP includes HTTP status code", + "ErrorResponse.detail": "KMP error response includes detail field for validation errors", + + // User: KMP has 'profile' field, spec splits into UserResponse + CurrentUserResponse + "UserResponse.profile": "KMP User unifies UserResponse + CurrentUserResponse; profile is on CurrentUserResponse", + + // Contractor addedBy alias + "ContractorResponse.added_by": "KMP has added_by alias for created_by_id", + + // SeededDataResponse: KMP field types differ (direct objects vs Response wrappers) + // These are compatible at JSON level but the type names differ +} + +// ========================================================================== +// OpenAPI spec parsing +// ========================================================================== + +type specSchema struct { + properties map[string]specField +} + +type specField struct { + typeName string + format string + nullable bool + isRef bool + isArray bool + hasAdditionalProperties bool +} + +func loadSpecSchemas(t *testing.T) map[string]specSchema { + t.Helper() + + data, err := os.ReadFile("../../docs/openapi.yaml") + require.NoError(t, err, "Failed to read openapi.yaml") + + var doc struct { + Components struct { + Schemas map[string]yaml.Node `yaml:"schemas"` + } `yaml:"components"` + } + require.NoError(t, yaml.Unmarshal(data, &doc), "Failed to parse openapi.yaml") + + result := make(map[string]specSchema) + for name, node := range doc.Components.Schemas { + schema := parseSchemaNode(&node) + result[name] = schema + } + return result +} + +func parseSchemaNode(node *yaml.Node) specSchema { + s := specSchema{properties: make(map[string]specField)} + + if node.Kind != yaml.MappingNode { + return s + } + + // Find "properties" key in the mapping + for i := 0; i < len(node.Content)-1; i += 2 { + key := node.Content[i] + val := node.Content[i+1] + + if key.Value == "properties" && val.Kind == yaml.MappingNode { + // Parse each property + for j := 0; j < len(val.Content)-1; j += 2 { + propName := val.Content[j].Value + propNode := val.Content[j+1] + s.properties[propName] = parseFieldNode(propNode) + } + } + } + + return s +} + +func parseFieldNode(node *yaml.Node) specField { + f := specField{} + + if node.Kind != yaml.MappingNode { + return f + } + + for i := 0; i < len(node.Content)-1; i += 2 { + key := node.Content[i].Value + val := node.Content[i+1] + + switch key { + case "type": + f.typeName = val.Value + case "format": + f.format = val.Value + case "nullable": + f.nullable = val.Value == "true" + case "$ref": + f.isRef = true + case "items": + // Array items — check if array type + case "additionalProperties": + f.hasAdditionalProperties = true + } + + if key == "type" && val.Value == "array" { + f.isArray = true + } + } + + return f +} + +// ========================================================================== +// KMP Kotlin model parsing +// ========================================================================== + +type kmpModel struct { + className string + fields []kmpField +} + +type kmpField struct { + propertyName string + jsonName string // from @SerialName or property name + kotlinType string + nullable bool +} + +// Regex patterns for parsing Kotlin data classes +var ( + // Match: @Serializable\ndata class ClassName( + reSerializableClass = regexp.MustCompile(`@Serializable\s*\n\s*data\s+class\s+(\w+)\s*\(`) + + // Match: @SerialName("json_name") val propName: Type + // or: @SerialName("json_name") private val propName: Type + reSerialNameField = regexp.MustCompile(`@SerialName\("([^"]+)"\)\s*(?:private\s+)?val\s+(\w+)\s*:\s*([^\n=,)]+)`) + + // Match: val propName: Type (without @SerialName) + rePlainField = regexp.MustCompile(`(?:^|\n)\s+val\s+(\w+)\s*:\s*([^\n=,)]+)`) +) + +func loadKMPModels(t *testing.T) map[string]kmpModel { + t.Helper() + + modelsDir := filepath.Join("..", "..", "..", "MyCribKMM", "composeApp", "src", "commonMain", + "kotlin", "com", "example", "casera", "models") + + info, err := os.Stat(modelsDir) + if os.IsNotExist(err) { + t.Skipf("KMP models directory not found at %s", modelsDir) + } + require.NoError(t, err) + require.True(t, info.IsDir()) + + matches, err := filepath.Glob(filepath.Join(modelsDir, "*.kt")) + require.NoError(t, err) + require.NotEmpty(t, matches) + + result := make(map[string]kmpModel) + + for _, file := range matches { + data, err := os.ReadFile(file) + require.NoError(t, err) + content := string(data) + + models := parseKotlinModels(content) + for _, m := range models { + result[m.className] = m + } + } + + return result +} + +func parseKotlinModels(content string) []kmpModel { + var models []kmpModel + + // Find all @Serializable data class declarations + classMatches := reSerializableClass.FindAllStringSubmatchIndex(content, -1) + + for _, loc := range classMatches { + className := content[loc[2]:loc[3]] + classStart := loc[0] + + // Find the constructor body (from opening ( to matching ) ) + openParen := strings.Index(content[classStart:], "(") + if openParen < 0 { + continue + } + openParen += classStart + + closeParen := findMatchingParen(content, openParen) + if closeParen < 0 { + continue + } + + constructorBody := content[openParen+1 : closeParen] + + // Parse fields from constructor + fields := parseConstructorFields(constructorBody) + + models = append(models, kmpModel{ + className: className, + fields: fields, + }) + } + + return models +} + +func parseConstructorFields(body string) []kmpField { + var fields []kmpField + + // Split by lines and parse each val declaration + lines := strings.Split(body, "\n") + + for i := 0; i < len(lines); i++ { + line := strings.TrimSpace(lines[i]) + + // Check for @SerialName annotation + if strings.Contains(line, "@SerialName(") { + // May be on same line as val, or next line + combined := line + // If val is not on this line, combine with next + if !strings.Contains(line, "val ") && i+1 < len(lines) { + i++ + combined = line + " " + strings.TrimSpace(lines[i]) + } + + match := reSerialNameField.FindStringSubmatch(combined) + if match != nil { + jsonName := match[1] + propName := match[2] + kotlinType := strings.TrimSpace(match[3]) + nullable := strings.HasSuffix(kotlinType, "?") + // Clean up type: remove trailing comma, default value + kotlinType = cleanKotlinType(kotlinType) + + fields = append(fields, kmpField{ + propertyName: propName, + jsonName: jsonName, + kotlinType: kotlinType, + nullable: nullable, + }) + } + continue + } + + // Check for plain val (no @SerialName) + if strings.Contains(line, "val ") && !strings.Contains(line, "get()") { + // Skip computed properties (have get() = ...) + match := rePlainField.FindStringSubmatch("\n" + line) + if match != nil { + propName := match[1] + kotlinType := strings.TrimSpace(match[2]) + nullable := strings.HasSuffix(kotlinType, "?") + kotlinType = cleanKotlinType(kotlinType) + + fields = append(fields, kmpField{ + propertyName: propName, + jsonName: propName, // No @SerialName, so JSON name = property name + kotlinType: kotlinType, + nullable: nullable, + }) + } + } + } + + return fields +} + +func cleanKotlinType(t string) string { + // Remove trailing comma + t = strings.TrimSuffix(strings.TrimSpace(t), ",") + // Remove default value assignment + if idx := strings.Index(t, " ="); idx > 0 { + t = strings.TrimSpace(t[:idx]) + } + // Remove trailing ? for the clean type name + // (but we already captured nullable separately) + return strings.TrimSpace(t) +} + +func findMatchingParen(s string, openIdx int) int { + depth := 0 + for i := openIdx; i < len(s); i++ { + switch s[i] { + case '(': + depth++ + case ')': + depth-- + if depth == 0 { + return i + } + } + } + return -1 +} + +// ========================================================================== +// Type mapping and comparison +// ========================================================================== + +func mapSpecTypeToKotlin(f specField) string { + if f.isArray { + return "List" + } + if f.isRef { + return "Object" // Any object reference + } + if f.hasAdditionalProperties { + return "Map" + } + + switch f.typeName { + case "string": + return "String" + case "integer": + if f.format == "int64" { + return "Long" + } + return "Int" + case "number": + return "Double" + case "boolean": + return "Boolean" + case "object": + return "Map" + default: + return "Any" + } +} + +func normalizeKotlinType(t string) string { + // Remove nullable marker + t = strings.TrimSuffix(t, "?") + // Extract base type from generics + if idx := strings.Index(t, "<"); idx > 0 { + t = t[:idx] + } + return t +} + +func typesCompatible(expected, actual string) bool { + if expected == actual { + return true + } + // $ref matches any object type + if expected == "Object" { + return true + } + // Long ↔ Int is acceptable for most API integers + if expected == "Long" && actual == "Int" { + return true + } + if expected == "Int" && actual == "Long" { + return true + } + return false +} + +// ========================================================================== +// Summary test — prints a nice overview +// ========================================================================== + +func TestKMPModelContractSummary(t *testing.T) { + specSchemas := loadSpecSchemas(t) + kmpModels := loadKMPModels(t) + + t.Logf("=== KMP Model Schema Contract Summary ===") + t.Logf("OpenAPI schemas: %d", len(specSchemas)) + t.Logf("KMP model classes: %d", len(kmpModels)) + t.Logf("Mapped schema→class: %d", len(schemaToKMPClass)) + t.Logf("Excluded schemas: %d", len(excludedSchemas)) + t.Logf("Known type overrides: %d", len(knownTypeOverrides)) + t.Logf("Known missing from KMP: %d", len(knownMissingFromKMP)) + t.Logf("Known extra in KMP: %d", len(knownExtraInKMP)) + + // List mapped pairs + var pairs []string + for spec, mapping := range schemaToKMPClass { + pairs = append(pairs, fmt.Sprintf(" %s → %s", spec, mapping.kmpClassName)) + } + sort.Strings(pairs) + t.Logf("Mappings:\n%s", strings.Join(pairs, "\n")) + + // Count total fields validated + totalFields := 0 + for specName := range schemaToKMPClass { + if schema, ok := specSchemas[specName]; ok { + totalFields += len(schema.properties) + } + } + t.Logf("Total spec fields validated: %d", totalFields) + + // Verify all overrides reference valid schema.field combos + for key := range knownTypeOverrides { + parts := strings.SplitN(key, ".", 2) + assert.Len(t, parts, 2, "knownTypeOverrides key %q should be Schema.field", key) + } + for key := range knownMissingFromKMP { + parts := strings.SplitN(key, ".", 2) + assert.Len(t, parts, 2, "knownMissingFromKMP key %q should be Schema.field", key) + } + for key := range knownExtraInKMP { + parts := strings.SplitN(key, ".", 2) + assert.Len(t, parts, 2, "knownExtraInKMP key %q should be Schema.field", key) + } +} diff --git a/internal/repositories/task_repo.go b/internal/repositories/task_repo.go index 6248026..0138c05 100644 --- a/internal/repositories/task_repo.go +++ b/internal/repositories/task_repo.go @@ -614,6 +614,11 @@ func (r *TaskRepository) FindCompletionsByUser(userID uint, residenceIDs []uint) return completions, err } +// UpdateCompletion updates an existing task completion +func (r *TaskRepository) UpdateCompletion(completion *models.TaskCompletion) error { + return r.db.Omit("Task", "CompletedBy", "Images").Save(completion).Error +} + // DeleteCompletion deletes a task completion func (r *TaskRepository) DeleteCompletion(id uint) error { // Delete images first diff --git a/internal/router/router.go b/internal/router/router.go index 679f828..8b23582 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -213,10 +213,27 @@ func SetupRouter(deps *Dependencies) *echo.Echo { return e } -// corsMiddleware configures CORS - allowing all origins for API access +// corsMiddleware configures CORS with restricted origins in production. +// In debug mode, all origins are allowed for development convenience. +// In production, origins are read from the CORS_ALLOWED_ORIGINS environment variable +// (comma-separated), falling back to a restrictive default set. func corsMiddleware(cfg *config.Config) echo.MiddlewareFunc { + var origins []string + if cfg.Server.Debug { + origins = []string{"*"} + } else { + origins = cfg.Server.CorsAllowedOrigins + if len(origins) == 0 { + // Restrictive default: only the known production domains + origins = []string{ + "https://casera.app", + "https://casera.treytartt.com", + } + } + } + return middleware.CORSWithConfig(middleware.CORSConfig{ - AllowOrigins: []string{"*"}, + AllowOrigins: origins, AllowMethods: []string{http.MethodGet, http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete, http.MethodOptions}, AllowHeaders: []string{echo.HeaderOrigin, echo.HeaderContentType, echo.HeaderAccept, echo.HeaderAuthorization, "X-Requested-With", "X-Timezone"}, ExposeHeaders: []string{echo.HeaderContentLength}, @@ -311,6 +328,7 @@ func setupResidenceRoutes(api *echo.Group, residenceHandler *handlers.ResidenceH residences.PATCH("/:id/", residenceHandler.UpdateResidence) residences.DELETE("/:id/", residenceHandler.DeleteResidence) + residences.GET("/:id/share-code/", residenceHandler.GetShareCode) residences.POST("/:id/generate-share-code/", residenceHandler.GenerateShareCode) residences.POST("/:id/generate-share-package/", residenceHandler.GenerateSharePackage) residences.POST("/:id/generate-tasks-report/", residenceHandler.GenerateTasksReport) @@ -347,6 +365,7 @@ func setupTaskRoutes(api *echo.Group, taskHandler *handlers.TaskHandler) { completions.GET("/", taskHandler.ListCompletions) completions.POST("/", taskHandler.CreateCompletion) completions.GET("/:id/", taskHandler.GetCompletion) + completions.PUT("/:id/", taskHandler.UpdateCompletion) completions.DELETE("/:id/", taskHandler.DeleteCompletion) } } @@ -380,6 +399,8 @@ func setupDocumentRoutes(api *echo.Group, documentHandler *handlers.DocumentHand documents.DELETE("/:id/", documentHandler.DeleteDocument) documents.POST("/:id/activate/", documentHandler.ActivateDocument) documents.POST("/:id/deactivate/", documentHandler.DeactivateDocument) + documents.POST("/:id/images/", documentHandler.UploadDocumentImage) + documents.DELETE("/:id/images/:imageId/", documentHandler.DeleteDocumentImage) } } @@ -394,6 +415,7 @@ func setupNotificationRoutes(api *echo.Group, notificationHandler *handlers.Noti notifications.POST("/devices/", notificationHandler.RegisterDevice) notifications.POST("/devices/register/", notificationHandler.RegisterDevice) // Alias for mobile clients + notifications.POST("/devices/unregister/", notificationHandler.UnregisterDevice) notifications.GET("/devices/", notificationHandler.ListDevices) notifications.DELETE("/devices/:id/", notificationHandler.DeleteDevice) diff --git a/internal/services/apple_auth.go b/internal/services/apple_auth.go index 0d5c673..5679b20 100644 --- a/internal/services/apple_auth.go +++ b/internal/services/apple_auth.go @@ -159,12 +159,18 @@ func (s *AppleAuthService) VerifyIdentityToken(ctx context.Context, idToken stri return claims, nil } -// verifyAudience checks if the token audience matches our client ID +// verifyAudience checks if the token audience matches our client ID. +// In production (non-debug), an empty clientID causes verification to fail +// rather than silently bypassing the check. func (s *AppleAuthService) verifyAudience(audience jwt.ClaimStrings) bool { clientID := s.config.AppleAuth.ClientID if clientID == "" { - // If not configured, skip audience verification (for development) - return true + if s.config.Server.Debug { + // In debug mode only, skip audience verification for local development + return true + } + // In production, missing client ID means we cannot verify the audience + return false } for _, aud := range audience { diff --git a/internal/services/document_service.go b/internal/services/document_service.go index d0aad4b..8e10a4b 100644 --- a/internal/services/document_service.go +++ b/internal/services/document_service.go @@ -321,3 +321,89 @@ func (s *DocumentService) DeactivateDocument(documentID, userID uint) (*response resp := responses.NewDocumentResponse(document) return &resp, nil } + +// UploadDocumentImage adds an image to an existing document +func (s *DocumentService) UploadDocumentImage(documentID, userID uint, imageURL, caption string) (*responses.DocumentResponse, error) { + document, err := s.documentRepo.FindByID(documentID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, apperrors.NotFound("error.document_not_found") + } + return nil, apperrors.Internal(err) + } + + // Check access via residence + hasAccess, err := s.residenceRepo.HasAccess(document.ResidenceID, userID) + if err != nil { + return nil, apperrors.Internal(err) + } + if !hasAccess { + return nil, apperrors.Forbidden("error.document_access_denied") + } + + img := &models.DocumentImage{ + DocumentID: documentID, + ImageURL: imageURL, + Caption: caption, + } + if err := s.documentRepo.CreateDocumentImage(img); err != nil { + return nil, apperrors.Internal(err) + } + + // Reload with relations + document, err = s.documentRepo.FindByID(documentID) + if err != nil { + return nil, apperrors.Internal(err) + } + + resp := responses.NewDocumentResponse(document) + return &resp, nil +} + +// DeleteDocumentImage removes an image from a document +func (s *DocumentService) DeleteDocumentImage(documentID, imageID, userID uint) (*responses.DocumentResponse, error) { + // Find the image first + image, err := s.documentRepo.FindImageByID(imageID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, apperrors.NotFound("error.document_image_not_found") + } + return nil, apperrors.Internal(err) + } + + // Verify image belongs to the specified document + if image.DocumentID != documentID { + return nil, apperrors.NotFound("error.document_image_not_found") + } + + // Find parent document to check access + document, err := s.documentRepo.FindByID(documentID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, apperrors.NotFound("error.document_not_found") + } + return nil, apperrors.Internal(err) + } + + // Check access via residence + hasAccess, err := s.residenceRepo.HasAccess(document.ResidenceID, userID) + if err != nil { + return nil, apperrors.Internal(err) + } + if !hasAccess { + return nil, apperrors.Forbidden("error.document_access_denied") + } + + if err := s.documentRepo.DeleteDocumentImage(imageID); err != nil { + return nil, apperrors.Internal(err) + } + + // Reload with relations + document, err = s.documentRepo.FindByID(documentID) + if err != nil { + return nil, apperrors.Internal(err) + } + + resp := responses.NewDocumentResponse(document) + return &resp, nil +} diff --git a/internal/services/google_auth.go b/internal/services/google_auth.go index b854859..1be89eb 100644 --- a/internal/services/google_auth.go +++ b/internal/services/google_auth.go @@ -98,12 +98,18 @@ func (s *GoogleAuthService) VerifyIDToken(ctx context.Context, idToken string) ( return &tokenInfo, nil } -// verifyAudience checks if the token audience matches our client ID(s) +// verifyAudience checks if the token audience matches our client ID(s). +// In production (non-debug), an empty clientID causes verification to fail +// rather than silently bypassing the check. func (s *GoogleAuthService) verifyAudience(aud, azp string) bool { clientID := s.config.GoogleAuth.ClientID if clientID == "" { - // If not configured, skip audience verification (for development) - return true + if s.config.Server.Debug { + // In debug mode only, skip audience verification for local development + return true + } + // In production, missing client ID means we cannot verify the audience + return false } // Check both aud and azp (Android vs iOS may use different values) diff --git a/internal/services/notification_service.go b/internal/services/notification_service.go index 3b5d1f3..a515670 100644 --- a/internal/services/notification_service.go +++ b/internal/services/notification_service.go @@ -372,6 +372,39 @@ func (s *NotificationService) DeleteDevice(deviceID uint, platform string, userI return nil } +// UnregisterDevice deactivates a device by its registration token +func (s *NotificationService) UnregisterDevice(registrationID, platform string, userID uint) error { + switch platform { + case push.PlatformIOS: + device, err := s.notificationRepo.FindAPNSDeviceByToken(registrationID) + if err != nil { + return apperrors.NotFound("error.device_not_found") + } + // Verify ownership + if device.UserID == nil || *device.UserID != userID { + return apperrors.NotFound("error.device_not_found") + } + if err := s.notificationRepo.DeactivateAPNSDevice(device.ID); err != nil { + return apperrors.Internal(err) + } + case push.PlatformAndroid: + device, err := s.notificationRepo.FindGCMDeviceByToken(registrationID) + if err != nil { + return apperrors.NotFound("error.device_not_found") + } + // Verify ownership + if device.UserID == nil || *device.UserID != userID { + return apperrors.NotFound("error.device_not_found") + } + if err := s.notificationRepo.DeactivateGCMDevice(device.ID); err != nil { + return apperrors.Internal(err) + } + default: + return apperrors.BadRequest("error.invalid_platform") + } + return nil +} + // === Response/Request Types === // NotificationResponse represents a notification in API response diff --git a/internal/services/residence_service.go b/internal/services/residence_service.go index 6d917fc..165febc 100644 --- a/internal/services/residence_service.go +++ b/internal/services/residence_service.go @@ -353,6 +353,29 @@ func (s *ResidenceService) GenerateShareCode(residenceID, userID uint, expiresIn }, nil } +// GetShareCode retrieves the active share code for a residence (if any) +func (s *ResidenceService) GetShareCode(residenceID, userID uint) (*responses.ShareCodeResponse, error) { + // Check access + hasAccess, err := s.residenceRepo.HasAccess(residenceID, userID) + if err != nil { + return nil, apperrors.Internal(err) + } + if !hasAccess { + return nil, apperrors.Forbidden("error.residence_access_denied") + } + + shareCode, err := s.residenceRepo.GetActiveShareCode(residenceID) + if err != nil { + return nil, apperrors.Internal(err) + } + if shareCode == nil { + return nil, nil + } + + resp := responses.NewShareCodeResponse(shareCode) + return &resp, nil +} + // GenerateSharePackage generates a share code and returns package metadata for .casera file func (s *ResidenceService) GenerateSharePackage(residenceID, userID uint, expiresInHours int) (*responses.SharePackageResponse, error) { // Check ownership (only owners can share residences) diff --git a/internal/services/task_service.go b/internal/services/task_service.go index e0fe4d3..3897834 100644 --- a/internal/services/task_service.go +++ b/internal/services/task_service.go @@ -894,6 +894,61 @@ func (s *TaskService) ListCompletions(userID uint) ([]responses.TaskCompletionRe return responses.NewTaskCompletionListResponse(completions), nil } +// UpdateCompletion updates an existing task completion +func (s *TaskService) UpdateCompletion(completionID, userID uint, req *requests.UpdateTaskCompletionRequest) (*responses.TaskCompletionResponse, error) { + completion, err := s.taskRepo.FindCompletionByID(completionID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, apperrors.NotFound("error.completion_not_found") + } + return nil, apperrors.Internal(err) + } + + // Check access via task's residence + hasAccess, err := s.residenceRepo.HasAccess(completion.Task.ResidenceID, userID) + if err != nil { + return nil, apperrors.Internal(err) + } + if !hasAccess { + return nil, apperrors.Forbidden("error.task_access_denied") + } + + // Apply updates + if req.Notes != nil { + completion.Notes = *req.Notes + } + if req.ActualCost != nil { + completion.ActualCost = req.ActualCost + } + if req.Rating != nil { + completion.Rating = req.Rating + } + + if err := s.taskRepo.UpdateCompletion(completion); err != nil { + return nil, apperrors.Internal(err) + } + + // Add any new images + for _, imageURL := range req.ImageURLs { + image := &models.TaskCompletionImage{ + CompletionID: completion.ID, + ImageURL: imageURL, + } + if err := s.taskRepo.CreateCompletionImage(image); err != nil { + log.Error().Err(err).Uint("completion_id", completion.ID).Msg("Failed to create completion image during update") + } + } + + // Reload to get full associations + updated, err := s.taskRepo.FindCompletionByID(completionID) + if err != nil { + return nil, apperrors.Internal(err) + } + + resp := responses.NewTaskCompletionResponse(updated) + return &resp, nil +} + // DeleteCompletion deletes a task completion func (s *TaskService) DeleteCompletion(completionID, userID uint) (*responses.DeleteWithSummaryResponse, error) { completion, err := s.taskRepo.FindCompletionByID(completionID) diff --git a/internal/testutil/testutil.go b/internal/testutil/testutil.go index f84b539..4de9de6 100644 --- a/internal/testutil/testutil.go +++ b/internal/testutil/testutil.go @@ -54,6 +54,7 @@ func SetupTestDB(t *testing.T) *gorm.DB { &models.Contractor{}, &models.ContractorSpecialty{}, &models.Document{}, + &models.DocumentImage{}, &models.Notification{}, &models.NotificationPreference{}, &models.APNSDevice{},