From 2104c94eded0b2d57d7d7680c93b440dee7d13c5 Mon Sep 17 00:00:00 2001 From: Cramsan Date: Wed, 22 Jul 2026 11:21:21 -0700 Subject: [PATCH 1/7] [EDIFIKANA] Org-scope RLS policy for properties (#549) Replaces the blanket authenticated_all_properties policy with an org-scoped one matching the org_scoped_units/org_scoped_common_areas pattern, closing a cross-tenant read gap on the properties table. Co-Authored-By: Claude Sonnet 5 --- ...0260722120000_org_scope_properties_rls.sql | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 edifikana/back-end/supabase/migrations/20260722120000_org_scope_properties_rls.sql diff --git a/edifikana/back-end/supabase/migrations/20260722120000_org_scope_properties_rls.sql b/edifikana/back-end/supabase/migrations/20260722120000_org_scope_properties_rls.sql new file mode 100644 index 000000000..b07f5c25f --- /dev/null +++ b/edifikana/back-end/supabase/migrations/20260722120000_org_scope_properties_rls.sql @@ -0,0 +1,37 @@ +-- ============================================================================ +-- Migration: Org-scope RLS policy for properties +-- Issue: https://github.com/CodeHavenX/MonoRepo/issues/549 +-- ============================================================================ +-- This migration: +-- 1. Drops the blanket-allow "authenticated_all_properties" policy on `properties` +-- 2. Replaces it with an org-scoped policy matching the org_scoped_units / +-- org_scoped_common_areas pattern, so a property row is only visible to +-- authenticated users who are members of that property's organization +-- ============================================================================ + +DROP POLICY "authenticated_all_properties" ON properties; + +-- Policy is scoped by organization_id to prevent cross-tenant data leakage. +-- Users may only access rows belonging to an organization they are a member of. +CREATE POLICY "org_scoped_properties" +ON properties +FOR ALL +TO authenticated +USING ( + organization_id IN ( + SELECT organization_id + FROM user_organization_mapping + WHERE user_id = auth.uid() + ) +) +WITH CHECK ( + organization_id IN ( + SELECT organization_id + FROM user_organization_mapping + WHERE user_id = auth.uid() + ) +); + +-- ============================================================================ +-- END OF MIGRATION +-- ============================================================================ From dc22b0b2ecb05efc3a446baef0c8f25bd7913852 Mon Sep 17 00:00:00 2001 From: Cramsan Date: Wed, 22 Jul 2026 11:26:53 -0700 Subject: [PATCH 2/7] [EDIFIKANA] Repurpose getAssignedProperties to org-scoped getProperties; add getEmployeesForProperty (#549) PropertyApi.getAssignedProperties becomes getProperties(organizationId), returning all properties in an org instead of only the caller's individually-mapped ones. EmployeeApi gains getEmployeesForProperty for the property-detail assigned-staff feature. Both use an explicit `path` sub-segment (by-organization / by-property) to avoid colliding with the existing single-entity routes at the same base path, following the precedent in CommonAreaApi.getCommonAreasForProperty. Co-Authored-By: Claude Sonnet 5 --- .../com/cramsan/edifikana/api/EmployeeApi.kt | 17 +++++++++++++++++ .../com/cramsan/edifikana/api/PropertyApi.kt | 10 ++++++---- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/edifikana/api/src/commonMain/kotlin/com/cramsan/edifikana/api/EmployeeApi.kt b/edifikana/api/src/commonMain/kotlin/com/cramsan/edifikana/api/EmployeeApi.kt index 1725d2fac..eca810979 100644 --- a/edifikana/api/src/commonMain/kotlin/com/cramsan/edifikana/api/EmployeeApi.kt +++ b/edifikana/api/src/commonMain/kotlin/com/cramsan/edifikana/api/EmployeeApi.kt @@ -5,6 +5,7 @@ import com.cramsan.edifikana.lib.model.network.employee.CreateEmployeeNetworkReq import com.cramsan.edifikana.lib.model.network.employee.EmployeeListNetworkResponse import com.cramsan.edifikana.lib.model.network.employee.EmployeeNetworkResponse import com.cramsan.edifikana.lib.model.network.employee.UpdateEmployeeNetworkRequest +import com.cramsan.edifikana.lib.model.property.PropertyId import com.cramsan.framework.annotations.api.NoPathParam import com.cramsan.framework.annotations.api.NoQueryParam import com.cramsan.framework.annotations.api.NoRequestBody @@ -64,6 +65,22 @@ object EmployeeApi : Api("employee") { responses = UniversalResponsesOnly, ) + val getEmployeesForProperty = + operation< + NoRequestBody, + NoQueryParam, + PropertyId, + EmployeeListNetworkResponse, + >( + method = HttpMethod.Get, + path = "by-property", + summary = "List employees for a property", + description = + "Returns all employees (manager/security/cleaning) assigned to the given property. " + + "Requires the MANAGER role or higher on the property.", + responses = UniversalResponsesOnly, + ) + val updateEmployee = operation< UpdateEmployeeNetworkRequest, diff --git a/edifikana/api/src/commonMain/kotlin/com/cramsan/edifikana/api/PropertyApi.kt b/edifikana/api/src/commonMain/kotlin/com/cramsan/edifikana/api/PropertyApi.kt index bb45a4144..a5469a4dc 100644 --- a/edifikana/api/src/commonMain/kotlin/com/cramsan/edifikana/api/PropertyApi.kt +++ b/edifikana/api/src/commonMain/kotlin/com/cramsan/edifikana/api/PropertyApi.kt @@ -4,6 +4,7 @@ import com.cramsan.edifikana.lib.model.network.property.CreatePropertyNetworkReq import com.cramsan.edifikana.lib.model.network.property.PropertyListNetworkResponse import com.cramsan.edifikana.lib.model.network.property.PropertyNetworkResponse import com.cramsan.edifikana.lib.model.network.property.UpdatePropertyNetworkRequest +import com.cramsan.edifikana.lib.model.organization.OrganizationId import com.cramsan.edifikana.lib.model.property.PropertyId import com.cramsan.framework.annotations.api.NoPathParam import com.cramsan.framework.annotations.api.NoQueryParam @@ -49,16 +50,17 @@ object PropertyApi : Api("property") { }, ) - val getAssignedProperties = + val getProperties = operation< NoRequestBody, NoQueryParam, - NoPathParam, + OrganizationId, PropertyListNetworkResponse, >( method = HttpMethod.Get, - summary = "List assigned properties", - description = "Returns all properties the authenticated user has been assigned access to.", + path = "by-organization", + summary = "List properties for an organization", + description = "Returns all properties in the given organization. Requires membership in the organization.", responses = UniversalResponsesOnly, ) From d7748bd0640c167ea374b12da8ff42d16e125a57 Mon Sep 17 00:00:00 2001 From: Cramsan Date: Wed, 22 Jul 2026 11:29:09 -0700 Subject: [PATCH 3/7] [EDIFIKANA] Property backend: org-scoped getProperties (#549) Datastore/Service/Controller for Property now source getProperties from the raw properties table filtered by organization_id (instead of the v_user_properties view filtered by user_id), with an EMPLOYEE-role-or- higher RBAC gate the old user-assignment-scoped version didn't need. Removes the now-unused v_user_properties view usage and UserPropertyViewEntity. Updates existing controller/service/integration tests to match, adding a negative RBAC case and a cross-org isolation case. Co-Authored-By: Claude Sonnet 5 --- ...upabasePropertyDatastoreIntegrationTest.kt | 24 +++++++-- .../server/controller/PropertyController.kt | 18 ++++--- .../server/datastore/PropertyDatastore.kt | 4 +- .../supabase/SupabasePropertyDatastore.kt | 20 +++---- .../supabase/models/UserPropertyViewEntity.kt | 41 -------------- .../server/service/PropertyService.kt | 7 ++- .../controller/PropertyControllerTest.kt | 54 +++++++++++++++++-- .../server/service/PropertyServiceTest.kt | 8 +-- 8 files changed, 98 insertions(+), 78 deletions(-) delete mode 100644 edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/datastore/supabase/models/UserPropertyViewEntity.kt diff --git a/edifikana/back-end/src/integTest/kotlin/com/cramsan/edifikana/server/datastore/supabase/SupabasePropertyDatastoreIntegrationTest.kt b/edifikana/back-end/src/integTest/kotlin/com/cramsan/edifikana/server/datastore/supabase/SupabasePropertyDatastoreIntegrationTest.kt index 613fb46b9..98b0ccd77 100644 --- a/edifikana/back-end/src/integTest/kotlin/com/cramsan/edifikana/server/datastore/supabase/SupabasePropertyDatastoreIntegrationTest.kt +++ b/edifikana/back-end/src/integTest/kotlin/com/cramsan/edifikana/server/datastore/supabase/SupabasePropertyDatastoreIntegrationTest.kt @@ -81,8 +81,10 @@ class SupabasePropertyDatastoreIntegrationTest : SupabaseIntegrationTest() { } @Test - fun `getProperties should return all properties for user`() = runBlocking { + fun `getProperties should return all properties for the organization, not just the caller's own`() = runBlocking { // Arrange + val otherUserId = createTestUser("other-user-${test_prefix}@example.com") + val otherOrg = createTestOrganization("other-org-${test_prefix}", "") // Act val result1 = propertyDatastore.createProperty( @@ -92,24 +94,36 @@ class SupabasePropertyDatastoreIntegrationTest : SupabaseIntegrationTest() { organizationId = testOrg!!, imageUrl = "drawable:L_DEPA", ).registerPropertyForDeletion() + // Created by a different user, same org — proves this is org-scoped, not + // user-assignment-scoped like the old getAssignedProperties behavior. val result2 = propertyDatastore.createProperty( name = "${test_prefix}_PropertyB", address = "101 Sample Blvd, Testville, TV 13141", - creatorUserId = testUserId!!, + creatorUserId = otherUserId, organizationId = testOrg!!, imageUrl = "drawable:M_DEPA", ).registerPropertyForDeletion() + // Different org entirely — must not show up in testOrg's results. + val otherOrgResult = propertyDatastore.createProperty( + name = "${test_prefix}_PropertyOtherOrg", + address = "1 Other Org Way, Elsewhere, EO 99999", + creatorUserId = otherUserId, + organizationId = otherOrg, + imageUrl = "drawable:S_DEPA", + ).registerPropertyForDeletion() assertTrue(result1.isSuccess) assertTrue(result2.isSuccess) - val getAllResult = propertyDatastore.getProperties(userId = testUserId!!) + assertTrue(otherOrgResult.isSuccess) + val getAllResult = propertyDatastore.getProperties(organizationId = testOrg!!) // Assert assertTrue(getAllResult.isSuccess) val properties = getAllResult.getOrNull() assertNotNull(properties) val names = properties.map { it.name } - assertTrue(names.contains( "${test_prefix}_PropertyA")) - assertTrue(names.contains( "${test_prefix}_PropertyB")) + assertTrue(names.contains("${test_prefix}_PropertyA")) + assertTrue(names.contains("${test_prefix}_PropertyB")) + assertTrue(names.none { it == "${test_prefix}_PropertyOtherOrg" }) } @Test diff --git a/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/controller/PropertyController.kt b/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/controller/PropertyController.kt index d0039f726..52c4585b5 100644 --- a/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/controller/PropertyController.kt +++ b/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/controller/PropertyController.kt @@ -5,6 +5,7 @@ import com.cramsan.edifikana.lib.model.network.property.CreatePropertyNetworkReq import com.cramsan.edifikana.lib.model.network.property.PropertyListNetworkResponse import com.cramsan.edifikana.lib.model.network.property.PropertyNetworkResponse import com.cramsan.edifikana.lib.model.network.property.UpdatePropertyNetworkRequest +import com.cramsan.edifikana.lib.model.organization.OrganizationId import com.cramsan.edifikana.lib.model.property.PropertyId import com.cramsan.edifikana.server.controller.authentication.SupabaseContextPayload import com.cramsan.edifikana.server.service.PropertyService @@ -80,20 +81,23 @@ class PropertyController(private val propertyService: PropertyService, private v } /** - * Retrieves the list of properties assigned to the authenticated user. + * Retrieves the list of properties belonging to an organization. * Returns a list of properties as a network response. + * Throws [UnauthorizedException] if the user does not have EMPLOYEE role or higher in the organization. */ - suspend fun getAssignedProperties( + suspend fun getProperties( request: OperationRequest< NoRequestBody, NoQueryParam, - NoPathParam, + OrganizationId, ClientContext.AuthenticatedClientContext, >, ): PropertyListNetworkResponse { - val userId = request.context.payload.userId - val properties = propertyService.getProperties(userId = userId).map { it.toPropertyNetworkResponse() } + if (!rbacService.hasRoleOrHigher(request.context, request.pathParam, UserRole.EMPLOYEE)) { + throw UnauthorizedException(unauthorizedMsg) + } + val properties = propertyService.getProperties(request.pathParam).map { it.toPropertyNetworkResponse() } return PropertyListNetworkResponse(properties) } @@ -167,8 +171,8 @@ class PropertyController(private val propertyService: PropertyService, private v handler(api.getProperty) { request -> getProperty(request) } - handler(api.getAssignedProperties) { request -> - getAssignedProperties(request) + handler(api.getProperties) { request -> + getProperties(request) } handler(api.updateProperty) { request -> updateProperty(request) diff --git a/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/datastore/PropertyDatastore.kt b/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/datastore/PropertyDatastore.kt index 0b922142b..daada37f8 100644 --- a/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/datastore/PropertyDatastore.kt +++ b/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/datastore/PropertyDatastore.kt @@ -30,10 +30,10 @@ interface PropertyDatastore { ): Result /** - * Retrieves all properties for a user. Returns the [Result] of the operation with a list of [Property]. + * Retrieves all properties for an organization. Returns the [Result] of the operation with a list of [Property]. */ suspend fun getProperties( - userId: UserId, + organizationId: OrganizationId, ): Result> /** diff --git a/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/datastore/supabase/SupabasePropertyDatastore.kt b/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/datastore/supabase/SupabasePropertyDatastore.kt index 49ad9d50a..5e97c087a 100644 --- a/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/datastore/supabase/SupabasePropertyDatastore.kt +++ b/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/datastore/supabase/SupabasePropertyDatastore.kt @@ -7,7 +7,6 @@ import com.cramsan.edifikana.lib.model.user.UserId import com.cramsan.edifikana.server.datastore.PropertyDatastore import com.cramsan.edifikana.server.datastore.supabase.models.PropertyEntity import com.cramsan.edifikana.server.datastore.supabase.models.UserPropertyMappingEntity -import com.cramsan.edifikana.server.datastore.supabase.models.UserPropertyViewEntity import com.cramsan.edifikana.server.service.models.Property import com.cramsan.framework.annotations.BackendDatastore import com.cramsan.framework.core.runSuspendCatching @@ -92,22 +91,24 @@ class SupabasePropertyDatastore(private val postgrest: Postgrest, private val cl } /** - * Gets all properties accessible to the given user. - * Uses the v_user_properties view for single-query retrieval (eliminates N+1 pattern). + * Gets all non-deleted properties belonging to the given organization. */ override suspend fun getProperties( - userId: UserId, + organizationId: OrganizationId, ): Result> = runSuspendCatching(TAG) { - logD(TAG, "Getting all properties for user: %s", userId) + logD(TAG, "Getting all properties for organization: %s", organizationId) - // Use the v_user_properties view for single-query retrieval postgrest - .from(VIEW_USER_PROPERTIES) + .from(PropertyEntity.COLLECTION) .select { - filter { UserPropertyViewEntity::userId eq userId.userId } - }.decodeList() + filter { + PropertyEntity::organizationId eq organizationId.id + PropertyEntity::deletedAt isExact null + } + order("name", order = io.github.jan.supabase.postgrest.query.Order.ASCENDING) + }.decodeList() .map { it.toProperty() } } @@ -202,6 +203,5 @@ class SupabasePropertyDatastore(private val postgrest: Postgrest, private val cl companion object { const val TAG = "SupabasePropertyDatastore" - const val VIEW_USER_PROPERTIES = "v_user_properties" } } diff --git a/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/datastore/supabase/models/UserPropertyViewEntity.kt b/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/datastore/supabase/models/UserPropertyViewEntity.kt deleted file mode 100644 index a2d7559d4..000000000 --- a/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/datastore/supabase/models/UserPropertyViewEntity.kt +++ /dev/null @@ -1,41 +0,0 @@ -package com.cramsan.edifikana.server.datastore.supabase.models - -import com.cramsan.edifikana.lib.model.common.Url -import com.cramsan.edifikana.lib.model.organization.OrganizationId -import com.cramsan.edifikana.lib.model.property.PropertyId -import com.cramsan.edifikana.lib.model.user.UserId -import com.cramsan.edifikana.server.service.models.Property -import com.cramsan.framework.annotations.DatabaseModel -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -/** - * Entity for the v_user_properties view. - * Contains property data plus the user_id from the mapping. - */ -@Serializable -@DatabaseModel -data class UserPropertyViewEntity( - val id: PropertyId, - val name: String, - val address: String, - @SerialName("organization_id") - val organizationId: OrganizationId, - @SerialName("image_url") - val imageUrl: Url? = null, - @SerialName("user_id") - val userId: UserId, -) { - /** - * Converts this view entity to a Property domain model. - */ - fun toProperty(): Property { - return Property( - id = id, - name = name, - address = address, - organizationId = organizationId, - imageUrl = imageUrl?.url, - ) - } -} diff --git a/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/service/PropertyService.kt b/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/service/PropertyService.kt index be4f89024..4d5a2baf6 100644 --- a/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/service/PropertyService.kt +++ b/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/service/PropertyService.kt @@ -2,7 +2,6 @@ package com.cramsan.edifikana.server.service import com.cramsan.edifikana.lib.model.organization.OrganizationId import com.cramsan.edifikana.lib.model.property.PropertyId -import com.cramsan.edifikana.lib.model.user.UserId import com.cramsan.edifikana.server.controller.authentication.SupabaseContextPayload import com.cramsan.edifikana.server.datastore.PropertyDatastore import com.cramsan.edifikana.server.service.models.Property @@ -53,16 +52,16 @@ class PropertyService(private val propertyDatastore: PropertyDatastore) { } /** - * Retrieves all properties. + * Retrieves all properties belonging to the given organization. */ suspend fun getProperties( - userId: UserId, + organizationId: OrganizationId, ): List { logD(TAG, "getProperties") val properties = propertyDatastore .getProperties( - userId, + organizationId, ).getOrThrow() return properties } diff --git a/edifikana/back-end/src/test/kotlin/com/cramsan/edifikana/server/controller/PropertyControllerTest.kt b/edifikana/back-end/src/test/kotlin/com/cramsan/edifikana/server/controller/PropertyControllerTest.kt index bab030f0b..be6d07643 100644 --- a/edifikana/back-end/src/test/kotlin/com/cramsan/edifikana/server/controller/PropertyControllerTest.kt +++ b/edifikana/back-end/src/test/kotlin/com/cramsan/edifikana/server/controller/PropertyControllerTest.kt @@ -240,15 +240,16 @@ class PropertyControllerTest : assertEquals(expectedResponse, response.bodyAsText()) } - // TODO: Update this test and add a negative check test for ensuring user get only list of properties they're assigned @Test - fun `test getProperties`() = + fun `test getProperties succeeds when user has required role in organization`() = testBackEndApplication { client -> // Arrange val expectedResponse = readFileContent("requests/get_properties_response.json") val propertyService = get() + val rbacService = get() + val orgId = OrganizationId("org123") coEvery { - propertyService.getProperties(UserId("user123")) + propertyService.getProperties(orgId) }.answers { listOf( Property( @@ -268,22 +269,65 @@ class PropertyControllerTest : ) } val contextRetriever = get>() + val context = + ClientContext.AuthenticatedClientContext( + SupabaseContextPayload( + userInfo = mockk(), + userId = UserId("user123"), + ), + ) coEvery { contextRetriever.getContext(any()) }.answers { + context + } + coEvery { + rbacService.hasRoleOrHigher(context, orgId, UserRole.EMPLOYEE) + }.answers { + true + } + + // Act + val response = client.get("property/by-organization/org123") + + // Assert + assertEquals(HttpStatusCode.OK, response.status) + assertEquals(expectedResponse, response.bodyAsText()) + } + + @Test + fun `test getProperties fails when user doesn't have required role in organization`() = + testBackEndApplication { client -> + // Arrange + val expectedResponse = "You are not authorized to perform this action in your organization." + val propertyService = get() + val rbacService = get() + val orgId = OrganizationId("org123") + val contextRetriever = get>() + val context = ClientContext.AuthenticatedClientContext( SupabaseContextPayload( userInfo = mockk(), userId = UserId("user123"), ), ) + coEvery { + contextRetriever.getContext(any()) + }.answers { + context + } + coEvery { + rbacService.hasRoleOrHigher(context, orgId, UserRole.EMPLOYEE) + }.answers { + false } // Act - val response = client.get("property") + val response = client.get("property/by-organization/org123") // Assert - assertEquals(HttpStatusCode.OK, response.status) + coVerify { propertyService wasNot Called } + assertEquals(HttpStatusCode.Unauthorized, response.status) assertEquals(expectedResponse, response.bodyAsText()) } diff --git a/edifikana/back-end/src/test/kotlin/com/cramsan/edifikana/server/service/PropertyServiceTest.kt b/edifikana/back-end/src/test/kotlin/com/cramsan/edifikana/server/service/PropertyServiceTest.kt index 674036b9b..d9bef8a31 100644 --- a/edifikana/back-end/src/test/kotlin/com/cramsan/edifikana/server/service/PropertyServiceTest.kt +++ b/edifikana/back-end/src/test/kotlin/com/cramsan/edifikana/server/service/PropertyServiceTest.kt @@ -125,24 +125,24 @@ class PropertyServiceTest { } /** - * Tests that getProperties retrieves all properties and returns a list.q + * Tests that getProperties retrieves all properties for an organization and returns a list. */ @Test fun `getProperties should call propertyDatastore and return list`() = runTest { // Arrange val propertyList = listOf(mockk(), mockk()) - coEvery { propertyDatastore.getProperties(UserId("TestId1")) } returns + coEvery { propertyDatastore.getProperties(OrganizationId("org123")) } returns Result.success( propertyList, ) // Act - val result = propertyService.getProperties(UserId("TestId1")) + val result = propertyService.getProperties(OrganizationId("org123")) // Assert assertEquals(propertyList, result) - coVerify { propertyDatastore.getProperties(UserId("TestId1")) } + coVerify { propertyDatastore.getProperties(OrganizationId("org123")) } } /** From b77306ba2d88f08c7aad607a14a96eae9ffd7a47 Mon Sep 17 00:00:00 2001 From: Cramsan Date: Wed, 22 Jul 2026 11:32:14 -0700 Subject: [PATCH 4/7] [EDIFIKANA] Add getEmployeesForProperty for assigned-staff feature (#549) New Datastore/Service/Controller method returning all non-deleted employees assigned to a property, RBAC-gated at MANAGER role or higher to match the existing single-property detail gate. Backs the property-detail assigned-staff feature planned for the frontend slice. Co-Authored-By: Claude Sonnet 5 --- ...upabaseEmployeeDatastoreIntegrationTest.kt | 42 ++++++++++ .../server/controller/EmployeeController.kt | 26 +++++++ .../server/datastore/EmployeeDatastore.kt | 8 ++ .../supabase/SupabaseEmployeeDatastore.kt | 19 +++++ .../server/service/EmployeeService.kt | 13 ++++ .../controller/EmployeeControllerTest.kt | 77 +++++++++++++++++++ .../server/service/EmployeeServiceTest.kt | 19 +++++ .../get_employees_for_property_response.json | 20 +++++ 8 files changed, 224 insertions(+) create mode 100644 edifikana/back-end/src/test/resources/requests/get_employees_for_property_response.json diff --git a/edifikana/back-end/src/integTest/kotlin/com/cramsan/edifikana/server/datastore/supabase/SupabaseEmployeeDatastoreIntegrationTest.kt b/edifikana/back-end/src/integTest/kotlin/com/cramsan/edifikana/server/datastore/supabase/SupabaseEmployeeDatastoreIntegrationTest.kt index 731493793..2752b26ba 100644 --- a/edifikana/back-end/src/integTest/kotlin/com/cramsan/edifikana/server/datastore/supabase/SupabaseEmployeeDatastoreIntegrationTest.kt +++ b/edifikana/back-end/src/integTest/kotlin/com/cramsan/edifikana/server/datastore/supabase/SupabaseEmployeeDatastoreIntegrationTest.kt @@ -104,6 +104,48 @@ class SupabaseEmployeeDatastoreIntegrationTest : SupabaseIntegrationTest() { assertTrue(firstNames.contains("${test_prefix}_EmployeeB")) } + @Test + fun `getEmployeesForProperty should return only employees for that property`() = runBlocking { + // Arrange + val otherPropertyId = createTestProperty("${test_prefix}_OtherProperty", testUserId!!, orgId!!) + + // Act + val result1 = employeeDatastore.createEmployee( + firstName = "${test_prefix}_EmployeeManager", + lastName = "${test_prefix}_LastA", + role = EmployeeRole.MANAGER, + idType = IdType.DNI, + propertyId = propertyId!!, + ).registerEmployeeForDeletion() + val result2 = employeeDatastore.createEmployee( + firstName = "${test_prefix}_EmployeeCleaning", + lastName = "${test_prefix}_LastB", + role = EmployeeRole.CLEANING, + idType = IdType.PASSPORT, + propertyId = propertyId!!, + ).registerEmployeeForDeletion() + val otherPropertyResult = employeeDatastore.createEmployee( + firstName = "${test_prefix}_EmployeeOtherProperty", + lastName = "${test_prefix}_LastC", + role = EmployeeRole.SECURITY, + idType = IdType.DNI, + propertyId = otherPropertyId, + ).registerEmployeeForDeletion() + assertTrue(result1.isSuccess) + assertTrue(result2.isSuccess) + assertTrue(otherPropertyResult.isSuccess) + val getResult = employeeDatastore.getEmployeesForProperty(propertyId = propertyId!!) + + // Assert + assertTrue(getResult.isSuccess) + val employees = getResult.getOrNull() + assertNotNull(employees) + val firstNames = employees.map { it.firstName } + assertTrue(firstNames.contains("${test_prefix}_EmployeeManager")) + assertTrue(firstNames.contains("${test_prefix}_EmployeeCleaning")) + assertTrue(firstNames.none { it == "${test_prefix}_EmployeeOtherProperty" }) + } + @Test fun `updateEmployee should update employee fields`() = runBlocking { // Arrange diff --git a/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/controller/EmployeeController.kt b/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/controller/EmployeeController.kt index 1d5189756..99b447a91 100644 --- a/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/controller/EmployeeController.kt +++ b/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/controller/EmployeeController.kt @@ -6,6 +6,7 @@ import com.cramsan.edifikana.lib.model.network.employee.CreateEmployeeNetworkReq import com.cramsan.edifikana.lib.model.network.employee.EmployeeListNetworkResponse import com.cramsan.edifikana.lib.model.network.employee.EmployeeNetworkResponse import com.cramsan.edifikana.lib.model.network.employee.UpdateEmployeeNetworkRequest +import com.cramsan.edifikana.lib.model.property.PropertyId import com.cramsan.edifikana.server.controller.authentication.SupabaseContextPayload import com.cramsan.edifikana.server.service.EmployeeService import com.cramsan.edifikana.server.service.authorization.RBACService @@ -108,6 +109,28 @@ class EmployeeController(private val employeeService: EmployeeService, private v return EmployeeListNetworkResponse(employees) } + /** + * Retrieves all employees assigned to a property. + * Returns a list of employees as a network response. + * Throws [UnauthorizedException] if the user does not have MANAGER role or higher for the property. + */ + + suspend fun getEmployeesForProperty( + request: OperationRequest< + NoRequestBody, + NoQueryParam, + PropertyId, + ClientContext.AuthenticatedClientContext, + >, + ): EmployeeListNetworkResponse { + if (!rbacService.hasRoleOrHigher(request.context, request.pathParam, UserRole.MANAGER)) { + throw UnauthorizedException(unauthorizedMsg) + } + val employees = + employeeService.getEmployeesForProperty(request.pathParam).map { it.toEmployeeNetworkResponse() } + return EmployeeListNetworkResponse(employees) + } + /** * Updates an employee identified by [employeeId] with the provided request data. * Returns the updated employee as a network response. @@ -195,6 +218,9 @@ class EmployeeController(private val employeeService: EmployeeService, private v handler(api.getEmployees) { request -> getEmployees(request) } + handler(api.getEmployeesForProperty) { request -> + getEmployeesForProperty(request) + } handler(api.updateEmployee) { request -> updateEmployee(request) } diff --git a/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/datastore/EmployeeDatastore.kt b/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/datastore/EmployeeDatastore.kt index b0a75da85..f017105ef 100644 --- a/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/datastore/EmployeeDatastore.kt +++ b/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/datastore/EmployeeDatastore.kt @@ -38,6 +38,14 @@ interface EmployeeDatastore { currentUser: UserId, ): Result> + /** + * Retrieves all employee members assigned to a property. Returns the [Result] of the operation with a list of + * [Employee]. + */ + suspend fun getEmployeesForProperty( + propertyId: PropertyId, + ): Result> + /** * Updates an employee member with the given details. Returns the [Result] of the operation with the updated [Employee]. */ diff --git a/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/datastore/supabase/SupabaseEmployeeDatastore.kt b/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/datastore/supabase/SupabaseEmployeeDatastore.kt index e9e88ae41..fee67f341 100644 --- a/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/datastore/supabase/SupabaseEmployeeDatastore.kt +++ b/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/datastore/supabase/SupabaseEmployeeDatastore.kt @@ -93,6 +93,25 @@ class SupabaseEmployeeDatastore(private val postgrest: Postgrest, private val cl .map { it.toEmployee() } } + /** + * Gets all non-deleted employees assigned to the given property. + */ + + override suspend fun getEmployeesForProperty(propertyId: PropertyId): Result> = + runSuspendCatching(TAG) { + logD(TAG, "Getting all employees for property: %s", propertyId) + + postgrest + .from(EmployeeEntity.COLLECTION) + .select { + filter { + EmployeeEntity::propertyId eq propertyId.propertyId + EmployeeEntity::deletedAt isExact null + } + }.decodeList() + .map { it.toEmployee() } + } + /** * Updates an employee's properties. Only non-null parameters are updated. */ diff --git a/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/service/EmployeeService.kt b/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/service/EmployeeService.kt index ac1bdd7b5..d7af0427c 100644 --- a/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/service/EmployeeService.kt +++ b/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/service/EmployeeService.kt @@ -69,6 +69,19 @@ class EmployeeService(private val employeeDatastore: EmployeeDatastore, private return employees } + /** + * Retrieves all employees assigned to the given property. + */ + suspend fun getEmployeesForProperty( + propertyId: PropertyId, + ): List { + logD(TAG, "getEmployeesForProperty") + return employeeDatastore + .getEmployeesForProperty( + propertyId = propertyId, + ).getOrThrow() + } + /** * Updates an employee with the provided [id] and [name]. */ diff --git a/edifikana/back-end/src/test/kotlin/com/cramsan/edifikana/server/controller/EmployeeControllerTest.kt b/edifikana/back-end/src/test/kotlin/com/cramsan/edifikana/server/controller/EmployeeControllerTest.kt index 37a6b815c..d942a6548 100644 --- a/edifikana/back-end/src/test/kotlin/com/cramsan/edifikana/server/controller/EmployeeControllerTest.kt +++ b/edifikana/back-end/src/test/kotlin/com/cramsan/edifikana/server/controller/EmployeeControllerTest.kt @@ -256,6 +256,83 @@ class EmployeeControllerTest : assertEquals(expectedResponse, response.bodyAsText()) } + @Test + fun `test getEmployeesForProperty succeeds when user has required role or higher`() = + testBackEndApplication { client -> + // Arrange + val expectedResponse = readFileContent("requests/get_employees_for_property_response.json") + val employeeService = get() + val rbacService = get() + val propId = PropertyId("property123") + coEvery { + employeeService.getEmployeesForProperty(propId) + }.answers { + listOf( + Employee( + id = EmployeeId("emp123"), + firstName = "John", + lastName = "Doe", + idType = IdType.DNI, + role = EmployeeRole.MANAGER, + propertyId = propId, + ), + Employee( + id = EmployeeId("emp456"), + firstName = "Jane", + lastName = "Smith", + idType = IdType.PASSPORT, + role = EmployeeRole.CLEANING, + propertyId = propId, + ), + ) + } + val contextRetriever = get>() + val context = + ClientContext.AuthenticatedClientContext( + SupabaseContextPayload( + userInfo = mockk(), + userId = UserId("user123"), + ), + ) + coEvery { contextRetriever.getContext(any()) } returns context + coEvery { rbacService.hasRoleOrHigher(context, propId, UserRole.MANAGER) } returns true + + // Act + val response = client.get("employee/by-property/property123") + + // Assert + assertEquals(HttpStatusCode.OK, response.status) + assertEquals(expectedResponse, response.bodyAsText()) + } + + @Test + fun `test getEmployeesForProperty fails when user doesn't have required role or higher`() = + testBackEndApplication { client -> + // Arrange + val expectedResponse = "You are not authorized to perform this action in your organization." + val employeeService = get() + val rbacService = get() + val propId = PropertyId("property123") + val contextRetriever = get>() + val context = + ClientContext.AuthenticatedClientContext( + SupabaseContextPayload( + userInfo = mockk(), + userId = UserId("user123"), + ), + ) + coEvery { contextRetriever.getContext(any()) } returns context + coEvery { rbacService.hasRoleOrHigher(context, propId, UserRole.MANAGER) } returns false + + // Act + val response = client.get("employee/by-property/property123") + + // Assert + coVerify { employeeService wasNot Called } + assertEquals(HttpStatusCode.Unauthorized, response.status) + assertEquals(expectedResponse, response.bodyAsText()) + } + @Test fun `test updateEmployee succeeds when user has required role`() = testBackEndApplication { client -> diff --git a/edifikana/back-end/src/test/kotlin/com/cramsan/edifikana/server/service/EmployeeServiceTest.kt b/edifikana/back-end/src/test/kotlin/com/cramsan/edifikana/server/service/EmployeeServiceTest.kt index 1fd02cddb..e1058e22b 100644 --- a/edifikana/back-end/src/test/kotlin/com/cramsan/edifikana/server/service/EmployeeServiceTest.kt +++ b/edifikana/back-end/src/test/kotlin/com/cramsan/edifikana/server/service/EmployeeServiceTest.kt @@ -153,6 +153,25 @@ class EmployeeServiceTest { coVerify { employeeDatastore.getEmployees(request) } } + /** + * Tests that getEmployeesForProperty retrieves all employees for a property and returns a list. + */ + @Test + fun `getEmployeesForProperty should call employeeDatastore and return list`() = + runTest { + // Arrange + val employeeLists = listOf(mockk(), mockk()) + val propertyId = PropertyId("property-1") + coEvery { employeeDatastore.getEmployeesForProperty(propertyId) } returns Result.success(employeeLists) + + // Act + val result = employeeService.getEmployeesForProperty(propertyId) + + // Assert + assertEquals(employeeLists, result) + coVerify { employeeDatastore.getEmployeesForProperty(propertyId) } + } + /** * Tests that updateEmployee updates an employee and returns the updated employee. */ diff --git a/edifikana/back-end/src/test/resources/requests/get_employees_for_property_response.json b/edifikana/back-end/src/test/resources/requests/get_employees_for_property_response.json new file mode 100644 index 000000000..aa00005f5 --- /dev/null +++ b/edifikana/back-end/src/test/resources/requests/get_employees_for_property_response.json @@ -0,0 +1,20 @@ +{ + "content": [ + { + "id": "emp123", + "id_type": "DNI", + "first_name": "John", + "last_name": "Doe", + "role": "MANAGER", + "property_id": "property123" + }, + { + "id": "emp456", + "id_type": "PASSPORT", + "first_name": "Jane", + "last_name": "Smith", + "role": "CLEANING", + "property_id": "property123" + } + ] +} \ No newline at end of file From 2938449e52283b8c0ba4e922a97d0844e0769733 Mon Sep 17 00:00:00 2001 From: Cramsan Date: Wed, 22 Jul 2026 11:42:47 -0700 Subject: [PATCH 5/7] [EDIFIKANA] Thread organizationId through all front-end getPropertyList callers (#549) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Repurposing the backend's getProperties to be org-scoped breaks every front-end consumer of PropertyService/PropertyManager.getPropertyList(). Updates all of them found while implementing this slice — three more than the plan anticipated, since PropertiesOverviewScreen and MyOrganizationsViewModel landed in #548 after this issue was planned: - PropertyHomeViewModel (Properties tab) - resolves the active org via OrganizationManager, same pattern already used elsewhere. - PropertiesOverviewScreen/ViewModel (Dashboard tab's Properties sub-tab) - now takes organizationId from OrganizationHomeScreen, which already had it in scope but wasn't passing it to this screen. - PropertyManager.removeProperty - needed organizationId threaded from PropertyDetailViewModel (added to PropertyDetailUIState) to re-fetch the list after deletion. - MyOrganizationsViewModel - previously computed per-org property counts from a single cross-org query; now queries per-org (matches the org-scoped endpoint and, incidentally, fixes an undercount for any org where the current user didn't personally create every property). Also removes two pieces of dead test code discovered while fixing compilation: HomeViewModelTest.kt (a complete duplicate of PropertyHomeViewModelTest.kt testing the same ViewModel) and an unused propertyManager mock in SplashViewModelTest.kt (never wired into the ViewModel under test). Includes the regenerated openapi.yaml reflecting the Part 1 API changes (side effect of rebuilding after they'd already landed in an earlier commit). Co-Authored-By: Claude Sonnet 5 --- edifikana/back-end/docs/openapi.yaml | 104 ++++++++--- .../OrganizationHomeScreen.kt | 4 +- .../PropertiesOverviewScreen.kt | 4 +- .../PropertiesOverviewViewModel.kt | 5 +- .../propertydetail/PropertyDetailUIState.kt | 2 + .../propertydetail/PropertyDetailViewModel.kt | 5 +- .../propertyhome/PropertyHomeViewModel.kt | 14 +- .../MyOrganizationsViewModel.kt | 15 +- .../client/lib/managers/PropertyManager.kt | 12 +- .../client/lib/service/PropertyService.kt | 4 +- .../lib/service/impl/PropertyServiceImpl.kt | 6 +- .../features/home/home/HomeViewModelTest.kt | 161 ------------------ .../PropertiesOverviewViewModelTest.kt | 8 +- .../PropertyDetailViewModelTest.kt | 8 +- .../propertyhome/PropertyHomeViewModelTest.kt | 20 ++- .../MyOrganizationsViewModelTest.kt | 8 +- .../features/splash/SplashViewModelTest.kt | 4 - .../lib/managers/PropertyManagerTest.kt | 12 +- .../service/impl/PropertyServiceImplTest.kt | 4 +- 19 files changed, 163 insertions(+), 237 deletions(-) delete mode 100644 edifikana/front-end/app/src/jvmTest/kotlin/com/cramsan/edifikana/client/lib/features/home/home/HomeViewModelTest.kt diff --git a/edifikana/back-end/docs/openapi.yaml b/edifikana/back-end/docs/openapi.yaml index 78c08f623..3f1ad58e8 100644 --- a/edifikana/back-end/docs/openapi.yaml +++ b/edifikana/back-end/docs/openapi.yaml @@ -2837,31 +2837,31 @@ ] } }, - "/property": { - "post": { - "operationId": "post-property", + "/employee/by-property/{param}": { + "get": { + "operationId": "get-employee-by-property-param", "tags": [ - "property" + "employee" ], - "summary": "Create a property", - "description": "Creates a new property within the caller's organization. Requires the ADMIN role.", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreatePropertyNetworkRequest" - } + "summary": "List employees for a property", + "description": "Returns all employees (manager/security/cleaning) assigned to the given property. Requires the MANAGER role or higher on the property.", + "parameters": [ + { + "name": "param", + "in": "path", + "required": true, + "schema": { + "type": "string" } - }, - "required": true - }, + } + ], "responses": { "200": { "description": "Successful response.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PropertyNetworkResponse" + "$ref": "#/components/schemas/EmployeeListNetworkResponse" } } } @@ -2883,21 +2883,33 @@ ] } ] - }, - "get": { - "operationId": "get-property", + } + }, + "/property": { + "post": { + "operationId": "post-property", "tags": [ "property" ], - "summary": "List assigned properties", - "description": "Returns all properties the authenticated user has been assigned access to.", + "summary": "Create a property", + "description": "Creates a new property within the caller's organization. Requires the ADMIN role.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreatePropertyNetworkRequest" + } + } + }, + "required": true + }, "responses": { "200": { "description": "Successful response.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PropertyListNetworkResponse" + "$ref": "#/components/schemas/PropertyNetworkResponse" } } } @@ -3067,6 +3079,54 @@ ] } }, + "/property/by-organization/{param}": { + "get": { + "operationId": "get-property-by-organization-param", + "tags": [ + "property" + ], + "summary": "List properties for an organization", + "description": "Returns all properties in the given organization. Requires membership in the organization.", + "parameters": [ + { + "name": "param", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful response.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PropertyListNetworkResponse" + } + } + } + }, + "400": { + "description": "The request was malformed or failed validation." + }, + "401": { + "description": "Authentication is required or has failed." + }, + "500": { + "description": "An unexpected server error occurred." + } + }, + "security": [ + { + "bearerAuth": [ + + ] + } + ] + } + }, "/notification": { "get": { "operationId": "get-notification", diff --git a/edifikana/front-end/app/src/commonMain/kotlin/com/cramsan/edifikana/client/lib/features/home/organizationhome/OrganizationHomeScreen.kt b/edifikana/front-end/app/src/commonMain/kotlin/com/cramsan/edifikana/client/lib/features/home/organizationhome/OrganizationHomeScreen.kt index ef9865fdf..515e2f486 100644 --- a/edifikana/front-end/app/src/commonMain/kotlin/com/cramsan/edifikana/client/lib/features/home/organizationhome/OrganizationHomeScreen.kt +++ b/edifikana/front-end/app/src/commonMain/kotlin/com/cramsan/edifikana/client/lib/features/home/organizationhome/OrganizationHomeScreen.kt @@ -117,7 +117,9 @@ private fun HubContent( ) { when (it) { Tabs.Properties -> { - PropertiesOverviewScreen() + organizationId?.let { orgId -> + PropertiesOverviewScreen(organizationId = orgId) + } } Tabs.Employee -> { diff --git a/edifikana/front-end/app/src/commonMain/kotlin/com/cramsan/edifikana/client/lib/features/home/propertiesoverview/PropertiesOverviewScreen.kt b/edifikana/front-end/app/src/commonMain/kotlin/com/cramsan/edifikana/client/lib/features/home/propertiesoverview/PropertiesOverviewScreen.kt index 2c4681c43..9a71da575 100644 --- a/edifikana/front-end/app/src/commonMain/kotlin/com/cramsan/edifikana/client/lib/features/home/propertiesoverview/PropertiesOverviewScreen.kt +++ b/edifikana/front-end/app/src/commonMain/kotlin/com/cramsan/edifikana/client/lib/features/home/propertiesoverview/PropertiesOverviewScreen.kt @@ -30,6 +30,7 @@ import androidx.lifecycle.compose.LifecycleEventEffect import com.cramsan.edifikana.client.lib.features.home.shared.PropertyIconOptions import com.cramsan.edifikana.client.ui.components.EdifikanaImage import com.cramsan.edifikana.client.ui.components.ImageSource +import com.cramsan.edifikana.lib.model.organization.OrganizationId import com.cramsan.framework.core.compose.ui.ObserveViewModelEvents import com.cramsan.ui.components.LoadingAnimationOverlay import com.cramsan.ui.components.ScreenLayout @@ -50,6 +51,7 @@ import org.koin.compose.viewmodel.koinViewModel */ @Composable fun PropertiesOverviewScreen( + organizationId: OrganizationId, modifier: Modifier = Modifier, viewModel: PropertiesOverviewViewModel = koinViewModel(), ) { @@ -57,7 +59,7 @@ fun PropertiesOverviewScreen( // For other possible lifecycle events, see the [Lifecycle.Event] documentation. LifecycleEventEffect(Lifecycle.Event.ON_CREATE) { - viewModel.initialize() + viewModel.initialize(organizationId) } LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { // Call this feature's viewModel diff --git a/edifikana/front-end/app/src/commonMain/kotlin/com/cramsan/edifikana/client/lib/features/home/propertiesoverview/PropertiesOverviewViewModel.kt b/edifikana/front-end/app/src/commonMain/kotlin/com/cramsan/edifikana/client/lib/features/home/propertiesoverview/PropertiesOverviewViewModel.kt index 92cbdd166..983235d73 100644 --- a/edifikana/front-end/app/src/commonMain/kotlin/com/cramsan/edifikana/client/lib/features/home/propertiesoverview/PropertiesOverviewViewModel.kt +++ b/edifikana/front-end/app/src/commonMain/kotlin/com/cramsan/edifikana/client/lib/features/home/propertiesoverview/PropertiesOverviewViewModel.kt @@ -4,6 +4,7 @@ import com.cramsan.edifikana.client.lib.features.home.HomeDestination import com.cramsan.edifikana.client.lib.features.window.EdifikanaWindowsEvent import com.cramsan.edifikana.client.lib.managers.OrganizationManager import com.cramsan.edifikana.client.lib.managers.PropertyManager +import com.cramsan.edifikana.lib.model.organization.OrganizationId import com.cramsan.framework.annotations.FrontendViewModel import com.cramsan.framework.core.compose.BaseViewModel import com.cramsan.framework.core.compose.ViewModelDependencies @@ -25,13 +26,13 @@ class PropertiesOverviewViewModel( /** * Initialize the ViewModel. */ - fun initialize() { + fun initialize(organizationId: OrganizationId) { viewModelCoroutineScope.launch { updateUiState { it.copy(isLoading = true) } propertyManager - .getPropertyList() + .getPropertyList(organizationId) .onSuccess { resultList -> val uiModels = resultList.map { property -> diff --git a/edifikana/front-end/app/src/commonMain/kotlin/com/cramsan/edifikana/client/lib/features/home/propertydetail/PropertyDetailUIState.kt b/edifikana/front-end/app/src/commonMain/kotlin/com/cramsan/edifikana/client/lib/features/home/propertydetail/PropertyDetailUIState.kt index dc52ab7d0..0dba0b62f 100644 --- a/edifikana/front-end/app/src/commonMain/kotlin/com/cramsan/edifikana/client/lib/features/home/propertydetail/PropertyDetailUIState.kt +++ b/edifikana/front-end/app/src/commonMain/kotlin/com/cramsan/edifikana/client/lib/features/home/propertydetail/PropertyDetailUIState.kt @@ -1,6 +1,7 @@ package com.cramsan.edifikana.client.lib.features.home.propertydetail import com.cramsan.edifikana.client.ui.components.ImageOptionUIModel +import com.cramsan.edifikana.lib.model.organization.OrganizationId import com.cramsan.edifikana.lib.model.property.PropertyId import com.cramsan.framework.core.compose.ViewModelUIState @@ -25,6 +26,7 @@ sealed class PropertyDetailDialogState { data class PropertyDetailUIState( val isLoading: Boolean, val propertyId: PropertyId?, + val organizationId: OrganizationId? = null, val name: String, val address: String, val imageUrl: String?, diff --git a/edifikana/front-end/app/src/commonMain/kotlin/com/cramsan/edifikana/client/lib/features/home/propertydetail/PropertyDetailViewModel.kt b/edifikana/front-end/app/src/commonMain/kotlin/com/cramsan/edifikana/client/lib/features/home/propertydetail/PropertyDetailViewModel.kt index 5bb5a11da..d0f335247 100644 --- a/edifikana/front-end/app/src/commonMain/kotlin/com/cramsan/edifikana/client/lib/features/home/propertydetail/PropertyDetailViewModel.kt +++ b/edifikana/front-end/app/src/commonMain/kotlin/com/cramsan/edifikana/client/lib/features/home/propertydetail/PropertyDetailViewModel.kt @@ -48,6 +48,7 @@ class PropertyDetailViewModel( name = property.name, address = property.address, imageUrl = property.imageUrl, + organizationId = property.organizationId, ) } }.onFailure { throwable -> @@ -101,6 +102,7 @@ class PropertyDetailViewModel( name = property.name, address = property.address, imageUrl = property.imageUrl, + organizationId = property.organizationId, ) } }.onFailure { @@ -278,10 +280,11 @@ class PropertyDetailViewModel( fun deleteProperty() { viewModelCoroutineScope.launch { val propertyId = uiState.value.propertyId ?: return@launch + val organizationId = uiState.value.organizationId ?: return@launch updateUiState { it.copy(isLoading = true) } propertyManager - .removeProperty(propertyId) + .removeProperty(propertyId, organizationId) .onSuccess { emitWindowEvent( EdifikanaWindowsEvent.ShowSnackbar("Property deleted successfully"), diff --git a/edifikana/front-end/app/src/commonMain/kotlin/com/cramsan/edifikana/client/lib/features/home/propertyhome/PropertyHomeViewModel.kt b/edifikana/front-end/app/src/commonMain/kotlin/com/cramsan/edifikana/client/lib/features/home/propertyhome/PropertyHomeViewModel.kt index a7bac37d2..0ea0e7e1b 100644 --- a/edifikana/front-end/app/src/commonMain/kotlin/com/cramsan/edifikana/client/lib/features/home/propertyhome/PropertyHomeViewModel.kt +++ b/edifikana/front-end/app/src/commonMain/kotlin/com/cramsan/edifikana/client/lib/features/home/propertyhome/PropertyHomeViewModel.kt @@ -4,6 +4,7 @@ import com.cramsan.architecture.client.manager.PreferencesManager import com.cramsan.edifikana.client.lib.features.account.AccountDestination import com.cramsan.edifikana.client.lib.features.window.EdifikanaNavGraphDestination import com.cramsan.edifikana.client.lib.features.window.EdifikanaWindowsEvent +import com.cramsan.edifikana.client.lib.managers.OrganizationManager import com.cramsan.edifikana.client.lib.managers.PropertyManager import com.cramsan.edifikana.client.lib.settings.getLastSelectedPropertyId import com.cramsan.edifikana.client.lib.settings.setLastSelectedPropertyId @@ -22,6 +23,7 @@ class PropertyHomeViewModel( dependencies: ViewModelDependencies, private val propertyManager: PropertyManager, private val preferencesManager: PreferencesManager, + private val organizationManager: OrganizationManager, ) : BaseViewModel( dependencies, PropertyHomeUIModel.Empty, @@ -50,7 +52,17 @@ class PropertyHomeViewModel( } private suspend fun updatePropertyList() { - val properties = propertyManager.getPropertyList().getOrNull().orEmpty() + val organizationId = + organizationManager + .getOrganizations() + .getOrNull() + ?.firstOrNull() + ?.id + val properties = + organizationId + ?.let { + propertyManager.getPropertyList(it).getOrNull() + }.orEmpty() val selectedProperty = uiState.value.propertyId ?: preferencesManager.getLastSelectedPropertyId() diff --git a/edifikana/front-end/app/src/commonMain/kotlin/com/cramsan/edifikana/client/lib/features/settings/organizations/myorganizations/MyOrganizationsViewModel.kt b/edifikana/front-end/app/src/commonMain/kotlin/com/cramsan/edifikana/client/lib/features/settings/organizations/myorganizations/MyOrganizationsViewModel.kt index 33da5e762..2bb6bc4c5 100644 --- a/edifikana/front-end/app/src/commonMain/kotlin/com/cramsan/edifikana/client/lib/features/settings/organizations/myorganizations/MyOrganizationsViewModel.kt +++ b/edifikana/front-end/app/src/commonMain/kotlin/com/cramsan/edifikana/client/lib/features/settings/organizations/myorganizations/MyOrganizationsViewModel.kt @@ -42,13 +42,6 @@ class MyOrganizationsViewModel( val currentUserId = authManager.activeUser().value val activeOrgId = preferencesManager.getLastSelectedOrganizationId() - val propertyCountsByOrg = - propertyManager - .getPropertyList() - .getOrNull() - ?.groupingBy { it.organizationId } - ?.eachCount() - ?: emptyMap() organizationManager .getOrganizations() @@ -62,11 +55,17 @@ class MyOrganizationsViewModel( val myMembership = members.firstOrNull { it.userId == currentUserId } ?: return@mapNotNull null + // Property count is scoped per-org (matches the org-scoped + // property list endpoint) rather than computed from a single + // cross-org query, so counts reflect all of an org's properties, + // not just the ones the current user happens to be individually + // assigned to. + val propertyCount = propertyManager.getPropertyList(org.id).getOrNull()?.size ?: 0 OrgListItemUIModel( orgId = org.id, name = org.name, roleLabel = myMembership.role.toDisplayLabel(), - propertyCount = propertyCountsByOrg[org.id] ?: 0, + propertyCount = propertyCount, isActive = org.id == activeOrgId, ) }.sortedBy { it.name.lowercase() } diff --git a/edifikana/front-end/app/src/commonMain/kotlin/com/cramsan/edifikana/client/lib/managers/PropertyManager.kt b/edifikana/front-end/app/src/commonMain/kotlin/com/cramsan/edifikana/client/lib/managers/PropertyManager.kt index fa95c75d8..337e15ca1 100644 --- a/edifikana/front-end/app/src/commonMain/kotlin/com/cramsan/edifikana/client/lib/managers/PropertyManager.kt +++ b/edifikana/front-end/app/src/commonMain/kotlin/com/cramsan/edifikana/client/lib/managers/PropertyManager.kt @@ -24,12 +24,12 @@ class PropertyManager( private val dependencies: ManagerDependencies, ) { /** - * Get the list of properties. + * Get the list of properties belonging to the given organization. */ - suspend fun getPropertyList(): Result> = + suspend fun getPropertyList(organizationId: OrganizationId): Result> = dependencies.getOrCatch(TAG) { logI(TAG, "getPropertyList") - propertyService.getPropertyList().getOrThrow() + propertyService.getPropertyList(organizationId).getOrThrow() } /** @@ -131,13 +131,13 @@ class PropertyManager( } /** - * Remove the property with the given [propertyId]. + * Remove the property with the given [propertyId], belonging to [organizationId]. */ - suspend fun removeProperty(propertyId: PropertyId): Result = + suspend fun removeProperty(propertyId: PropertyId, organizationId: OrganizationId): Result = dependencies.getOrCatch(TAG) { logI(TAG, "removeProperty") propertyService.removeProperty(propertyId).requireSuccess() - propertyService.getPropertyList().requireSuccess() + propertyService.getPropertyList(organizationId).requireSuccess() } /** diff --git a/edifikana/front-end/app/src/commonMain/kotlin/com/cramsan/edifikana/client/lib/service/PropertyService.kt b/edifikana/front-end/app/src/commonMain/kotlin/com/cramsan/edifikana/client/lib/service/PropertyService.kt index 1cf905e2f..4311c618d 100644 --- a/edifikana/front-end/app/src/commonMain/kotlin/com/cramsan/edifikana/client/lib/service/PropertyService.kt +++ b/edifikana/front-end/app/src/commonMain/kotlin/com/cramsan/edifikana/client/lib/service/PropertyService.kt @@ -11,9 +11,9 @@ import com.cramsan.framework.annotations.FrontendService @FrontendService interface PropertyService { /** - * Get a list of properties associated with current user. + * Get a list of properties belonging to the given organization. */ - suspend fun getPropertyList(): Result> + suspend fun getPropertyList(organizationId: OrganizationId): Result> /** * Get the property with the given id. diff --git a/edifikana/front-end/app/src/commonMain/kotlin/com/cramsan/edifikana/client/lib/service/impl/PropertyServiceImpl.kt b/edifikana/front-end/app/src/commonMain/kotlin/com/cramsan/edifikana/client/lib/service/impl/PropertyServiceImpl.kt index ffbd6c093..c912a5fa3 100644 --- a/edifikana/front-end/app/src/commonMain/kotlin/com/cramsan/edifikana/client/lib/service/impl/PropertyServiceImpl.kt +++ b/edifikana/front-end/app/src/commonMain/kotlin/com/cramsan/edifikana/client/lib/service/impl/PropertyServiceImpl.kt @@ -20,12 +20,12 @@ import io.ktor.client.HttpClient */ @FrontendService class PropertyServiceImpl(private val http: HttpClient) : PropertyService { - override suspend fun getPropertyList(): Result> = + override suspend fun getPropertyList(organizationId: OrganizationId): Result> = runSuspendCatching(TAG) { val response = PropertyApi - .getAssignedProperties - .buildRequest() + .getProperties + .buildRequest(organizationId) .execute(http) val propertyList = response.properties.map { diff --git a/edifikana/front-end/app/src/jvmTest/kotlin/com/cramsan/edifikana/client/lib/features/home/home/HomeViewModelTest.kt b/edifikana/front-end/app/src/jvmTest/kotlin/com/cramsan/edifikana/client/lib/features/home/home/HomeViewModelTest.kt deleted file mode 100644 index 577d313e5..000000000 --- a/edifikana/front-end/app/src/jvmTest/kotlin/com/cramsan/edifikana/client/lib/features/home/home/HomeViewModelTest.kt +++ /dev/null @@ -1,161 +0,0 @@ -package com.cramsan.edifikana.client.lib.features.home.home - -import app.cash.turbine.turbineScope -import com.cramsan.framework.test.advanceUntilIdleAndAwaitComplete -import com.cramsan.architecture.client.manager.PreferencesManager -import com.cramsan.edifikana.client.lib.features.account.AccountDestination -import com.cramsan.edifikana.client.lib.features.home.propertyhome.PropertyHomeViewModel -import com.cramsan.edifikana.client.lib.features.window.EdifikanaNavGraphDestination -import com.cramsan.edifikana.client.lib.features.window.EdifikanaWindowsEvent -import com.cramsan.edifikana.client.lib.managers.PropertyManager -import com.cramsan.edifikana.client.lib.models.PropertyModel -import com.cramsan.edifikana.client.lib.settings.EdifikanaSettingKey -import com.cramsan.edifikana.lib.model.organization.OrganizationId -import com.cramsan.edifikana.lib.model.property.PropertyId -import com.cramsan.framework.core.UnifiedDispatcherProvider -import com.cramsan.framework.core.compose.ApplicationEvent -import com.cramsan.framework.core.compose.EventBus -import com.cramsan.framework.core.compose.ViewModelDependencies -import com.cramsan.framework.core.compose.WindowEvent -import com.cramsan.framework.logging.EventLogger -import com.cramsan.framework.logging.implementation.PassthroughEventLogger -import com.cramsan.framework.logging.implementation.StdOutEventLoggerDelegate -import com.cramsan.framework.test.CollectorCoroutineExceptionHandler -import com.cramsan.framework.test.CoroutineTest -import io.mockk.coEvery -import io.mockk.coVerify -import io.mockk.mockk -import org.junit.jupiter.api.BeforeEach -import kotlin.test.Test -import kotlin.test.assertEquals - -class HomeViewModelTest : CoroutineTest() { - - private lateinit var propertyManager: PropertyManager - private lateinit var viewModel: PropertyHomeViewModel - private lateinit var exceptionHandler: CollectorCoroutineExceptionHandler - private lateinit var applicationEventReceiver: EventBus - private lateinit var windowEventBus: EventBus - - private lateinit var preferencesManager: PreferencesManager - - @BeforeEach - fun setUp() { - EventLogger.setInstance(PassthroughEventLogger(StdOutEventLoggerDelegate())) - propertyManager = mockk() - exceptionHandler = CollectorCoroutineExceptionHandler() - applicationEventReceiver = EventBus() - windowEventBus = EventBus() - preferencesManager = mockk() - val dependencies = ViewModelDependencies( - appScope = testCoroutineScope, - dispatcherProvider = UnifiedDispatcherProvider(testCoroutineDispatcher), - coroutineExceptionHandler = exceptionHandler, - applicationEventReceiver = applicationEventReceiver, - windowEventReceiver = windowEventBus, - ) - viewModel = PropertyHomeViewModel(dependencies, propertyManager, preferencesManager) - } - - @Test - fun `test loadContent successfully loads properties`() = runCoroutineTest { - // Arrange - val organizationId = OrganizationId("org_1") - val properties = listOf( - PropertyModel( - id = PropertyId("1"), - name = "Property 1", - address = "Address 1", - organizationId = organizationId - ), - PropertyModel( - id = PropertyId("2"), - name = "Property 2", - address = "Address 2", - organizationId = organizationId - ), - ) - coEvery { propertyManager.getPropertyList() } returns Result.success(properties) - coEvery { - preferencesManager.getStringPreference(EdifikanaSettingKey.lastSelectedProperty) - } returns Result.success(null) - - // Act - viewModel.loadContent() - - // Assert - coVerify { propertyManager.getPropertyList() } - val uiState = viewModel.uiState.value - assertEquals("Property 1", uiState.label) - assertEquals(2, uiState.availableProperties.size) - } - - @Test - fun `test selectProperty updates active property`() = runCoroutineTest { - // Arrange - val propertyId = PropertyId("1") - coEvery { propertyManager.getPropertyList() } returns Result.success(emptyList()) - - // Act - viewModel.selectProperty(propertyId) - - // Assert - val uiState = viewModel.uiState.value - assertEquals(propertyId, uiState.propertyId) - } - - @Test - fun `test navigateBack emits NavigateBack event`() = runCoroutineTest { - turbineScope { - // Arrange - val turbine = windowEventBus.events.testIn(backgroundScope) - - // Act - viewModel.navigateBack() - - // Assert - assertEquals(EdifikanaWindowsEvent.NavigateBack, turbine.awaitItem()) - advanceUntilIdleAndAwaitComplete(turbine) - } - } - - @Test - fun `test navigateToAccount emits NavigateToNavGraph event`() = runCoroutineTest { - turbineScope { - // Arrange - val turbine = windowEventBus.events.testIn(backgroundScope) - - // Act - viewModel.navigateToAccount() - - // Assert - assertEquals( - EdifikanaWindowsEvent.NavigateToNavGraph( - EdifikanaNavGraphDestination.AccountNavGraphDestination - ), - turbine.awaitItem(), - ) - advanceUntilIdleAndAwaitComplete(turbine) - } - } - - @Test - fun `test navigateToNotifications emits NavigateToScreen event`() = runCoroutineTest { - turbineScope { - // Arrange - val turbine = windowEventBus.events.testIn(backgroundScope) - - // Act - viewModel.navigateToNotifications() - - // Assert - assertEquals( - EdifikanaWindowsEvent.NavigateToScreen( - AccountDestination.NotificationsDestination - ), - turbine.awaitItem(), - ) - advanceUntilIdleAndAwaitComplete(turbine) - } - } -} diff --git a/edifikana/front-end/app/src/jvmTest/kotlin/com/cramsan/edifikana/client/lib/features/home/propertiesoverview/PropertiesOverviewViewModelTest.kt b/edifikana/front-end/app/src/jvmTest/kotlin/com/cramsan/edifikana/client/lib/features/home/propertiesoverview/PropertiesOverviewViewModelTest.kt index d437403b8..8b8befecb 100644 --- a/edifikana/front-end/app/src/jvmTest/kotlin/com/cramsan/edifikana/client/lib/features/home/propertiesoverview/PropertiesOverviewViewModelTest.kt +++ b/edifikana/front-end/app/src/jvmTest/kotlin/com/cramsan/edifikana/client/lib/features/home/propertiesoverview/PropertiesOverviewViewModelTest.kt @@ -79,7 +79,7 @@ class PropertiesOverviewViewModelTest : CoroutineTest() { @Test fun `test initial ui state`() = runCoroutineTest { // Set up - coEvery { propertyManager.getPropertyList() } returns Result.success(listOf( + coEvery { propertyManager.getPropertyList(OrganizationId("org-1")) } returns Result.success(listOf( PropertyModel( id = PropertyId("property-1"), name = "Test Property 1", @@ -90,7 +90,7 @@ class PropertiesOverviewViewModelTest : CoroutineTest() { // Act val initialState = viewModel.uiState.value - viewModel.initialize() + viewModel.initialize(OrganizationId("org-1")) val loadedState = viewModel.uiState.value // Assert @@ -111,10 +111,10 @@ class PropertiesOverviewViewModelTest : CoroutineTest() { // Arrange val turbine = windowEventBus.events.testIn(backgroundScope) val error = RuntimeException("Network error") - coEvery { propertyManager.getPropertyList() } returns Result.failure(error) + coEvery { propertyManager.getPropertyList(OrganizationId("org-1")) } returns Result.failure(error) // Act - viewModel.initialize() + viewModel.initialize(OrganizationId("org-1")) // Assert val loadedState = viewModel.uiState.value diff --git a/edifikana/front-end/app/src/jvmTest/kotlin/com/cramsan/edifikana/client/lib/features/home/propertydetail/PropertyDetailViewModelTest.kt b/edifikana/front-end/app/src/jvmTest/kotlin/com/cramsan/edifikana/client/lib/features/home/propertydetail/PropertyDetailViewModelTest.kt index f0df78569..492fd398d 100644 --- a/edifikana/front-end/app/src/jvmTest/kotlin/com/cramsan/edifikana/client/lib/features/home/propertydetail/PropertyDetailViewModelTest.kt +++ b/edifikana/front-end/app/src/jvmTest/kotlin/com/cramsan/edifikana/client/lib/features/home/propertydetail/PropertyDetailViewModelTest.kt @@ -342,7 +342,7 @@ class PropertyDetailViewModelTest : CoroutineTest() { imageUrl = "drawable:CASA", ) coEvery { propertyManager.getProperty(propertyId) } returns Result.success(property) - coEvery { propertyManager.removeProperty(propertyId) } returns Result.success(Unit) + coEvery { propertyManager.removeProperty(propertyId, OrganizationId("org-1")) } returns Result.success(Unit) viewModel.initialize(propertyId) @@ -360,7 +360,7 @@ class PropertyDetailViewModelTest : CoroutineTest() { advanceUntilIdleAndAwaitComplete(turbine) } - coVerify { propertyManager.removeProperty(propertyId) } + coVerify { propertyManager.removeProperty(propertyId, OrganizationId("org-1")) } assertTrue(exceptionHandler.exceptions.isEmpty()) } @@ -375,7 +375,7 @@ class PropertyDetailViewModelTest : CoroutineTest() { imageUrl = "drawable:QUINTA", ) coEvery { propertyManager.getProperty(propertyId) } returns Result.success(property) - coEvery { propertyManager.removeProperty(propertyId) } returns Result.failure( + coEvery { propertyManager.removeProperty(propertyId, OrganizationId("org-1")) } returns Result.failure( Exception("Delete failed") ) @@ -402,6 +402,6 @@ class PropertyDetailViewModelTest : CoroutineTest() { fun `test deleteProperty without propertyId does nothing`() = runCoroutineTest { viewModel.deleteProperty() - coVerify(exactly = 0) { propertyManager.removeProperty(any()) } + coVerify(exactly = 0) { propertyManager.removeProperty(any(), any()) } } } diff --git a/edifikana/front-end/app/src/jvmTest/kotlin/com/cramsan/edifikana/client/lib/features/home/propertyhome/PropertyHomeViewModelTest.kt b/edifikana/front-end/app/src/jvmTest/kotlin/com/cramsan/edifikana/client/lib/features/home/propertyhome/PropertyHomeViewModelTest.kt index 7f522904f..803d98833 100644 --- a/edifikana/front-end/app/src/jvmTest/kotlin/com/cramsan/edifikana/client/lib/features/home/propertyhome/PropertyHomeViewModelTest.kt +++ b/edifikana/front-end/app/src/jvmTest/kotlin/com/cramsan/edifikana/client/lib/features/home/propertyhome/PropertyHomeViewModelTest.kt @@ -6,7 +6,9 @@ import com.cramsan.architecture.client.manager.PreferencesManager import com.cramsan.edifikana.client.lib.features.account.AccountDestination import com.cramsan.edifikana.client.lib.features.window.EdifikanaNavGraphDestination import com.cramsan.edifikana.client.lib.features.window.EdifikanaWindowsEvent +import com.cramsan.edifikana.client.lib.managers.OrganizationManager import com.cramsan.edifikana.client.lib.managers.PropertyManager +import com.cramsan.edifikana.client.lib.models.Organization import com.cramsan.edifikana.client.lib.models.PropertyModel import com.cramsan.edifikana.lib.model.organization.OrganizationId import com.cramsan.edifikana.lib.model.property.PropertyId @@ -36,6 +38,7 @@ class PropertyHomeViewModelTest : CoroutineTest() { private lateinit var viewModel: PropertyHomeViewModel private lateinit var propertyManager: PropertyManager private lateinit var preferencesManager: PreferencesManager + private lateinit var organizationManager: OrganizationManager private lateinit var exceptionHandler: CollectorCoroutineExceptionHandler private lateinit var applicationEventReceiver: EventBus private lateinit var windowEventBus: EventBus @@ -48,7 +51,11 @@ class PropertyHomeViewModelTest : CoroutineTest() { exceptionHandler = CollectorCoroutineExceptionHandler() propertyManager = mockk(relaxed = true) preferencesManager = mockk(relaxed = true) + organizationManager = mockk(relaxed = true) coEvery { preferencesManager.getStringPreference(any()) } returns Result.success(null) + coEvery { organizationManager.getOrganizations() } returns Result.success( + listOf(Organization(id = OrganizationId("org-1"), name = "Test Org", description = "")) + ) viewModel = PropertyHomeViewModel( dependencies = ViewModelDependencies( appScope = testCoroutineScope, @@ -59,6 +66,7 @@ class PropertyHomeViewModelTest : CoroutineTest() { ), propertyManager = propertyManager, preferencesManager = preferencesManager, + organizationManager = organizationManager, ) } @@ -80,7 +88,7 @@ class PropertyHomeViewModelTest : CoroutineTest() { organizationId = OrganizationId("org-1"), ) ) - coEvery { propertyManager.getPropertyList() } returns Result.success(properties) + coEvery { propertyManager.getPropertyList(OrganizationId("org-1")) } returns Result.success(properties) // Act viewModel.loadContent() @@ -95,7 +103,7 @@ class PropertyHomeViewModelTest : CoroutineTest() { @Test fun `test loadContent with no properties selects GoToOrganization tab`() = runCoroutineTest { // Set up - coEvery { propertyManager.getPropertyList() } returns Result.success(emptyList()) + coEvery { propertyManager.getPropertyList(OrganizationId("org-1")) } returns Result.success(emptyList()) // Act viewModel.loadContent() @@ -118,7 +126,7 @@ class PropertyHomeViewModelTest : CoroutineTest() { organizationId = OrganizationId("org-1"), ) ) - coEvery { propertyManager.getPropertyList() } returns Result.success(properties) + coEvery { propertyManager.getPropertyList(OrganizationId("org-1")) } returns Result.success(properties) // Act viewModel.selectProperty(propertyId) @@ -215,7 +223,7 @@ class PropertyHomeViewModelTest : CoroutineTest() { @Test fun `test loadContent with existing tab selection preserves tab`() = runCoroutineTest { // Set up - First load with no properties - coEvery { propertyManager.getPropertyList() } returns Result.success(emptyList()) + coEvery { propertyManager.getPropertyList(OrganizationId("org-1")) } returns Result.success(emptyList()) viewModel.loadContent() assertEquals(Tabs.GoToOrganization, viewModel.uiState.value.selectedTab) @@ -228,7 +236,7 @@ class PropertyHomeViewModelTest : CoroutineTest() { organizationId = OrganizationId("org-1"), ) ) - coEvery { propertyManager.getPropertyList() } returns Result.success(properties) + coEvery { propertyManager.getPropertyList(OrganizationId("org-1")) } returns Result.success(properties) // Act - load content again viewModel.loadContent() @@ -248,7 +256,7 @@ class PropertyHomeViewModelTest : CoroutineTest() { organizationId = OrganizationId("org-1"), ) ) - coEvery { propertyManager.getPropertyList() } returns Result.success(properties) + coEvery { propertyManager.getPropertyList(OrganizationId("org-1")) } returns Result.success(properties) // Act - first load viewModel.loadContent() diff --git a/edifikana/front-end/app/src/jvmTest/kotlin/com/cramsan/edifikana/client/lib/features/settings/organizations/myorganizations/MyOrganizationsViewModelTest.kt b/edifikana/front-end/app/src/jvmTest/kotlin/com/cramsan/edifikana/client/lib/features/settings/organizations/myorganizations/MyOrganizationsViewModelTest.kt index ce43ebf2d..47b12a29f 100644 --- a/edifikana/front-end/app/src/jvmTest/kotlin/com/cramsan/edifikana/client/lib/features/settings/organizations/myorganizations/MyOrganizationsViewModelTest.kt +++ b/edifikana/front-end/app/src/jvmTest/kotlin/com/cramsan/edifikana/client/lib/features/settings/organizations/myorganizations/MyOrganizationsViewModelTest.kt @@ -100,7 +100,7 @@ class MyOrganizationsViewModelTest : CoroutineTest() { coEvery { organizationManager.getOrganizations() } returns Result.success(listOf(org)) coEvery { membershipManager.listMembers(orgId) } returns Result.success(listOf(member)) coEvery { preferencesManager.getStringPreference(EdifikanaSettingKey.lastSelectedOrganization) } returns Result.success(orgId.id) - coEvery { propertyManager.getPropertyList() } returns Result.success(properties) + coEvery { propertyManager.getPropertyList(orgId) } returns Result.success(properties) viewModel.initialize() @@ -129,7 +129,7 @@ class MyOrganizationsViewModelTest : CoroutineTest() { coEvery { organizationManager.getOrganizations() } returns Result.success(listOf(org)) coEvery { membershipManager.listMembers(orgId) } returns Result.success(listOf(member)) coEvery { preferencesManager.getStringPreference(EdifikanaSettingKey.lastSelectedOrganization) } returns Result.success(orgId.id) - coEvery { propertyManager.getPropertyList() } returns Result.failure(RuntimeException("error")) + coEvery { propertyManager.getPropertyList(orgId) } returns Result.failure(RuntimeException("error")) viewModel.initialize() @@ -165,7 +165,7 @@ class MyOrganizationsViewModelTest : CoroutineTest() { coEvery { preferencesManager.getStringPreference(EdifikanaSettingKey.lastSelectedOrganization) } returns Result.success(activeOrgId.id) - coEvery { propertyManager.getPropertyList() } returns Result.success(emptyList()) + coEvery { propertyManager.getPropertyList(any()) } returns Result.success(emptyList()) viewModel.initialize() @@ -180,7 +180,7 @@ class MyOrganizationsViewModelTest : CoroutineTest() { fun `initialize shows snackbar on org load failure`() = runCoroutineTest { coEvery { organizationManager.getOrganizations() } returns Result.failure(RuntimeException("error")) coEvery { preferencesManager.getStringPreference(EdifikanaSettingKey.lastSelectedOrganization) } returns Result.success(null) - coEvery { propertyManager.getPropertyList() } returns Result.success(emptyList()) + coEvery { propertyManager.getPropertyList(any()) } returns Result.success(emptyList()) turbineScope { val turbine = windowEventBus.events.testIn(backgroundScope) diff --git a/edifikana/front-end/app/src/jvmTest/kotlin/com/cramsan/edifikana/client/lib/features/splash/SplashViewModelTest.kt b/edifikana/front-end/app/src/jvmTest/kotlin/com/cramsan/edifikana/client/lib/features/splash/SplashViewModelTest.kt index d77c117ca..dc6e2be59 100644 --- a/edifikana/front-end/app/src/jvmTest/kotlin/com/cramsan/edifikana/client/lib/features/splash/SplashViewModelTest.kt +++ b/edifikana/front-end/app/src/jvmTest/kotlin/com/cramsan/edifikana/client/lib/features/splash/SplashViewModelTest.kt @@ -5,7 +5,6 @@ import com.cramsan.edifikana.client.lib.features.window.EdifikanaNavGraphDestina import com.cramsan.edifikana.client.lib.features.window.EdifikanaWindowsEvent import com.cramsan.edifikana.client.lib.managers.AuthManager import com.cramsan.edifikana.client.lib.managers.OrganizationManager -import com.cramsan.edifikana.client.lib.managers.PropertyManager import com.cramsan.edifikana.lib.model.invite.InviteId import com.cramsan.framework.core.UnifiedDispatcherProvider import com.cramsan.framework.core.compose.ApplicationEvent @@ -35,7 +34,6 @@ class SplashViewModelTest : CoroutineTest() { private lateinit var viewModel: SplashViewModel private lateinit var authManager: AuthManager private lateinit var organizationManager: OrganizationManager - private lateinit var propertyManager: PropertyManager private lateinit var exceptionHandler: CollectorCoroutineExceptionHandler @@ -58,7 +56,6 @@ class SplashViewModelTest : CoroutineTest() { ) authManager = mockk() organizationManager = mockk() - propertyManager = mockk() viewModel = SplashViewModel( dependencies = dependencies, authManager = authManager, @@ -94,7 +91,6 @@ class SplashViewModelTest : CoroutineTest() { @Test fun `test enforceAuth when signed in`() = runCoroutineTest { coEvery { authManager.isSignedIn() } returns Result.success(true) - coEvery { propertyManager.getPropertyList() } returns Result.success(emptyList()) coEvery { organizationManager.getOrganizations() } returns Result.success(listOf(mockk())) viewModel.enforceAuth() diff --git a/edifikana/front-end/app/src/jvmTest/kotlin/com/cramsan/edifikana/client/lib/managers/PropertyManagerTest.kt b/edifikana/front-end/app/src/jvmTest/kotlin/com/cramsan/edifikana/client/lib/managers/PropertyManagerTest.kt index 61a096d4f..4eb313c2c 100644 --- a/edifikana/front-end/app/src/jvmTest/kotlin/com/cramsan/edifikana/client/lib/managers/PropertyManagerTest.kt +++ b/edifikana/front-end/app/src/jvmTest/kotlin/com/cramsan/edifikana/client/lib/managers/PropertyManagerTest.kt @@ -58,13 +58,14 @@ class PropertyManagerTest : CoroutineTest() { fun `getPropertyList returns property list`() = runCoroutineTest { // Arrange val propertyList = listOf(mockk(), mockk()) - coEvery { propertyService.getPropertyList() } returns Result.success(propertyList) + val organizationId = OrganizationId("org-1") + coEvery { propertyService.getPropertyList(organizationId) } returns Result.success(propertyList) // Act - val result = manager.getPropertyList() + val result = manager.getPropertyList(organizationId) // Assert assertTrue(result.isSuccess) assertEquals(propertyList, result.getOrNull()) - coVerify { propertyService.getPropertyList() } + coVerify { propertyService.getPropertyList(organizationId) } } /** @@ -213,11 +214,12 @@ class PropertyManagerTest : CoroutineTest() { fun `removeProperty calls service with correct arguments`() = runCoroutineTest { // Arrange val propertyId = PropertyId("property-1") + val organizationId = OrganizationId("org-1") coEvery { propertyService.removeProperty(propertyId) } returns Result.success(Unit) - coEvery { propertyService.getPropertyList() } returns Result.success(emptyList()) + coEvery { propertyService.getPropertyList(organizationId) } returns Result.success(emptyList()) // Act - val result = manager.removeProperty(propertyId) + val result = manager.removeProperty(propertyId, organizationId) // Assert assertTrue(result.isSuccess) diff --git a/edifikana/front-end/app/src/jvmTest/kotlin/com/cramsan/edifikana/client/lib/service/impl/PropertyServiceImplTest.kt b/edifikana/front-end/app/src/jvmTest/kotlin/com/cramsan/edifikana/client/lib/service/impl/PropertyServiceImplTest.kt index c53bf9c85..71525c074 100644 --- a/edifikana/front-end/app/src/jvmTest/kotlin/com/cramsan/edifikana/client/lib/service/impl/PropertyServiceImplTest.kt +++ b/edifikana/front-end/app/src/jvmTest/kotlin/com/cramsan/edifikana/client/lib/service/impl/PropertyServiceImplTest.kt @@ -76,7 +76,7 @@ class PropertyServiceImplTest { } // Act - val result = service.getPropertyList() + val result = service.getPropertyList(OrganizationId("org-1")) // Assert assertTrue(result.isSuccess) @@ -114,7 +114,7 @@ class PropertyServiceImplTest { } // Act - val result = service.getPropertyList() + val result = service.getPropertyList(OrganizationId("org-1")) // Assert assertTrue(result.isSuccess) From 0794e43f326286851a2dd1a58477b121886c4df1 Mon Sep 17 00:00:00 2001 From: Cramsan Date: Wed, 22 Jul 2026 12:01:21 -0700 Subject: [PATCH 6/7] [EDIFIKANA] Drop org-scoped properties RLS migration (#549) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The backend's Postgrest client is a single service_role-keyed singleton (DatastoreModule.kt) shared across all datastores, with no per-request JWT impersonation anywhere — every backend query already bypasses RLS by design (service_role has BYPASSRLS), and the front-end has no Postgrest client at all, so nothing ever queries these tables with a real user JWT. The RBAC check in PropertyController.getProperties is the actual and only enforcement for this path; the RLS policy added in d9efc6a8f had no effect on it. Reverting to the pre-existing blanket "authenticated_all_properties" policy, consistent with every other backend-only table today. Co-Authored-By: Claude Sonnet 5 --- ...0260722120000_org_scope_properties_rls.sql | 37 ------------------- 1 file changed, 37 deletions(-) delete mode 100644 edifikana/back-end/supabase/migrations/20260722120000_org_scope_properties_rls.sql diff --git a/edifikana/back-end/supabase/migrations/20260722120000_org_scope_properties_rls.sql b/edifikana/back-end/supabase/migrations/20260722120000_org_scope_properties_rls.sql deleted file mode 100644 index b07f5c25f..000000000 --- a/edifikana/back-end/supabase/migrations/20260722120000_org_scope_properties_rls.sql +++ /dev/null @@ -1,37 +0,0 @@ --- ============================================================================ --- Migration: Org-scope RLS policy for properties --- Issue: https://github.com/CodeHavenX/MonoRepo/issues/549 --- ============================================================================ --- This migration: --- 1. Drops the blanket-allow "authenticated_all_properties" policy on `properties` --- 2. Replaces it with an org-scoped policy matching the org_scoped_units / --- org_scoped_common_areas pattern, so a property row is only visible to --- authenticated users who are members of that property's organization --- ============================================================================ - -DROP POLICY "authenticated_all_properties" ON properties; - --- Policy is scoped by organization_id to prevent cross-tenant data leakage. --- Users may only access rows belonging to an organization they are a member of. -CREATE POLICY "org_scoped_properties" -ON properties -FOR ALL -TO authenticated -USING ( - organization_id IN ( - SELECT organization_id - FROM user_organization_mapping - WHERE user_id = auth.uid() - ) -) -WITH CHECK ( - organization_id IN ( - SELECT organization_id - FROM user_organization_mapping - WHERE user_id = auth.uid() - ) -); - --- ============================================================================ --- END OF MIGRATION --- ============================================================================ From 98953c1c0f9fa43d694eb343cb32d7598e2d8294 Mon Sep 17 00:00:00 2001 From: Cramsan Date: Wed, 22 Jul 2026 12:09:31 -0700 Subject: [PATCH 7/7] [EDIFIKANA] Redesign new list endpoints as query-param based (#549) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per review feedback: PropertyApi.getProperties and EmployeeApi.getEmployeesForProperty were each hard-shaped to search by exactly one field via a path param. Redesigned both as generic query-param-based list operations (GetPropertiesQueryParams, GetEmployeesForPropertyQueryParams), matching the existing GetQueryParams convention (GetTasksQueryParams, GetDocumentsQueryParams) — one required field today, room for more optional filters later without another route redesign. getProperties now lives at the bare GET /property (no custom path needed — query params don't factor into route matching, so it doesn't collide with getProperty's GET /property/{id}). getEmployeesForProperty keeps its `by-property` sub-path since the existing unfiltered getEmployees already claims GET /employee; kept it as its own endpoint rather than merging into getEmployees since the two have different authorization rules (unfiltered vs MANAGER+-gated) and merging would mean query-param-dependent auth branching in one handler. Co-Authored-By: Claude Sonnet 5 --- .../com/cramsan/edifikana/api/EmployeeApi.kt | 6 +- .../com/cramsan/edifikana/api/PropertyApi.kt | 13 +-- edifikana/back-end/docs/openapi.yaml | 102 +++++++++--------- .../server/controller/EmployeeController.kt | 11 +- .../server/controller/PropertyController.kt | 11 +- .../controller/EmployeeControllerTest.kt | 4 +- .../controller/PropertyControllerTest.kt | 4 +- .../lib/service/impl/PropertyServiceImpl.kt | 3 +- .../GetEmployeesForPropertyQueryParams.kt | 20 ++++ .../property/GetPropertiesQueryParams.kt | 20 ++++ 10 files changed, 118 insertions(+), 76 deletions(-) create mode 100644 edifikana/models/src/commonMain/kotlin/com/cramsan/edifikana/lib/model/network/employee/GetEmployeesForPropertyQueryParams.kt create mode 100644 edifikana/models/src/commonMain/kotlin/com/cramsan/edifikana/lib/model/network/property/GetPropertiesQueryParams.kt diff --git a/edifikana/api/src/commonMain/kotlin/com/cramsan/edifikana/api/EmployeeApi.kt b/edifikana/api/src/commonMain/kotlin/com/cramsan/edifikana/api/EmployeeApi.kt index eca810979..ce603cc19 100644 --- a/edifikana/api/src/commonMain/kotlin/com/cramsan/edifikana/api/EmployeeApi.kt +++ b/edifikana/api/src/commonMain/kotlin/com/cramsan/edifikana/api/EmployeeApi.kt @@ -4,8 +4,8 @@ import com.cramsan.edifikana.lib.model.employee.EmployeeId import com.cramsan.edifikana.lib.model.network.employee.CreateEmployeeNetworkRequest import com.cramsan.edifikana.lib.model.network.employee.EmployeeListNetworkResponse import com.cramsan.edifikana.lib.model.network.employee.EmployeeNetworkResponse +import com.cramsan.edifikana.lib.model.network.employee.GetEmployeesForPropertyQueryParams import com.cramsan.edifikana.lib.model.network.employee.UpdateEmployeeNetworkRequest -import com.cramsan.edifikana.lib.model.property.PropertyId import com.cramsan.framework.annotations.api.NoPathParam import com.cramsan.framework.annotations.api.NoQueryParam import com.cramsan.framework.annotations.api.NoRequestBody @@ -68,8 +68,8 @@ object EmployeeApi : Api("employee") { val getEmployeesForProperty = operation< NoRequestBody, - NoQueryParam, - PropertyId, + GetEmployeesForPropertyQueryParams, + NoPathParam, EmployeeListNetworkResponse, >( method = HttpMethod.Get, diff --git a/edifikana/api/src/commonMain/kotlin/com/cramsan/edifikana/api/PropertyApi.kt b/edifikana/api/src/commonMain/kotlin/com/cramsan/edifikana/api/PropertyApi.kt index a5469a4dc..655d53dc2 100644 --- a/edifikana/api/src/commonMain/kotlin/com/cramsan/edifikana/api/PropertyApi.kt +++ b/edifikana/api/src/commonMain/kotlin/com/cramsan/edifikana/api/PropertyApi.kt @@ -1,10 +1,10 @@ package com.cramsan.edifikana.api import com.cramsan.edifikana.lib.model.network.property.CreatePropertyNetworkRequest +import com.cramsan.edifikana.lib.model.network.property.GetPropertiesQueryParams import com.cramsan.edifikana.lib.model.network.property.PropertyListNetworkResponse import com.cramsan.edifikana.lib.model.network.property.PropertyNetworkResponse import com.cramsan.edifikana.lib.model.network.property.UpdatePropertyNetworkRequest -import com.cramsan.edifikana.lib.model.organization.OrganizationId import com.cramsan.edifikana.lib.model.property.PropertyId import com.cramsan.framework.annotations.api.NoPathParam import com.cramsan.framework.annotations.api.NoQueryParam @@ -53,14 +53,15 @@ object PropertyApi : Api("property") { val getProperties = operation< NoRequestBody, - NoQueryParam, - OrganizationId, + GetPropertiesQueryParams, + NoPathParam, PropertyListNetworkResponse, >( method = HttpMethod.Get, - path = "by-organization", - summary = "List properties for an organization", - description = "Returns all properties in the given organization. Requires membership in the organization.", + summary = "List properties", + description = + "Returns all properties in the organization given by the query params. " + + "Requires membership in the organization.", responses = UniversalResponsesOnly, ) diff --git a/edifikana/back-end/docs/openapi.yaml b/edifikana/back-end/docs/openapi.yaml index 3f1ad58e8..70df66f0a 100644 --- a/edifikana/back-end/docs/openapi.yaml +++ b/edifikana/back-end/docs/openapi.yaml @@ -2837,9 +2837,9 @@ ] } }, - "/employee/by-property/{param}": { + "/employee/by-property": { "get": { - "operationId": "get-employee-by-property-param", + "operationId": "get-employee-by-property", "tags": [ "employee" ], @@ -2847,8 +2847,8 @@ "description": "Returns all employees (manager/security/cleaning) assigned to the given property. Requires the MANAGER role or higher on the property.", "parameters": [ { - "name": "param", - "in": "path", + "name": "property_id", + "in": "query", "required": true, "schema": { "type": "string" @@ -2931,43 +2931,31 @@ ] } ] - } - }, - "/property/{param}": { - "put": { - "operationId": "put-property-param", + }, + "get": { + "operationId": "get-property", "tags": [ "property" ], - "summary": "Update a property", - "description": "Updates the mutable fields of an existing property. Requires the ADMIN role.", + "summary": "List properties", + "description": "Returns all properties in the organization given by the query params. Requires membership in the organization.", "parameters": [ { - "name": "param", - "in": "path", + "name": "organization_id", + "in": "query", "required": true, "schema": { "type": "string" } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdatePropertyNetworkRequest" - } - } - }, - "required": true - }, "responses": { "200": { "description": "Successful response.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PropertyNetworkResponse" + "$ref": "#/components/schemas/PropertyListNetworkResponse" } } } @@ -2989,14 +2977,16 @@ ] } ] - }, - "delete": { - "operationId": "delete-property-param", + } + }, + "/property/{param}": { + "put": { + "operationId": "put-property-param", "tags": [ "property" ], - "summary": "Delete a property", - "description": "Permanently deletes a property by its identifier. Requires the ADMIN role.", + "summary": "Update a property", + "description": "Updates the mutable fields of an existing property. Requires the ADMIN role.", "parameters": [ { "name": "param", @@ -3007,9 +2997,26 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdatePropertyNetworkRequest" + } + } + }, + "required": true + }, "responses": { "200": { - "description": "The operation completed successfully." + "description": "Successful response.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PropertyNetworkResponse" + } + } + } }, "400": { "description": "The request was malformed or failed validation." @@ -3029,13 +3036,13 @@ } ] }, - "get": { - "operationId": "get-property-param", + "delete": { + "operationId": "delete-property-param", "tags": [ "property" ], - "summary": "Get a property", - "description": "Retrieves a single property by its identifier. Requires the MANAGER role or higher.", + "summary": "Delete a property", + "description": "Permanently deletes a property by its identifier. Requires the ADMIN role.", "parameters": [ { "name": "param", @@ -3048,14 +3055,7 @@ ], "responses": { "200": { - "description": "Successful response.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PropertyNetworkResponse" - } - } - } + "description": "The operation completed successfully." }, "400": { "description": "The request was malformed or failed validation." @@ -3065,9 +3065,6 @@ }, "500": { "description": "An unexpected server error occurred." - }, - "404": { - "description": "No property exists for the given id." } }, "security": [ @@ -3077,16 +3074,14 @@ ] } ] - } - }, - "/property/by-organization/{param}": { + }, "get": { - "operationId": "get-property-by-organization-param", + "operationId": "get-property-param", "tags": [ "property" ], - "summary": "List properties for an organization", - "description": "Returns all properties in the given organization. Requires membership in the organization.", + "summary": "Get a property", + "description": "Retrieves a single property by its identifier. Requires the MANAGER role or higher.", "parameters": [ { "name": "param", @@ -3103,7 +3098,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PropertyListNetworkResponse" + "$ref": "#/components/schemas/PropertyNetworkResponse" } } } @@ -3116,6 +3111,9 @@ }, "500": { "description": "An unexpected server error occurred." + }, + "404": { + "description": "No property exists for the given id." } }, "security": [ diff --git a/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/controller/EmployeeController.kt b/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/controller/EmployeeController.kt index 99b447a91..8abd41069 100644 --- a/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/controller/EmployeeController.kt +++ b/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/controller/EmployeeController.kt @@ -5,8 +5,8 @@ import com.cramsan.edifikana.lib.model.employee.EmployeeId import com.cramsan.edifikana.lib.model.network.employee.CreateEmployeeNetworkRequest import com.cramsan.edifikana.lib.model.network.employee.EmployeeListNetworkResponse import com.cramsan.edifikana.lib.model.network.employee.EmployeeNetworkResponse +import com.cramsan.edifikana.lib.model.network.employee.GetEmployeesForPropertyQueryParams import com.cramsan.edifikana.lib.model.network.employee.UpdateEmployeeNetworkRequest -import com.cramsan.edifikana.lib.model.property.PropertyId import com.cramsan.edifikana.server.controller.authentication.SupabaseContextPayload import com.cramsan.edifikana.server.service.EmployeeService import com.cramsan.edifikana.server.service.authorization.RBACService @@ -118,16 +118,17 @@ class EmployeeController(private val employeeService: EmployeeService, private v suspend fun getEmployeesForProperty( request: OperationRequest< NoRequestBody, - NoQueryParam, - PropertyId, + GetEmployeesForPropertyQueryParams, + NoPathParam, ClientContext.AuthenticatedClientContext, >, ): EmployeeListNetworkResponse { - if (!rbacService.hasRoleOrHigher(request.context, request.pathParam, UserRole.MANAGER)) { + val propertyId = request.queryParam.propertyId + if (!rbacService.hasRoleOrHigher(request.context, propertyId, UserRole.MANAGER)) { throw UnauthorizedException(unauthorizedMsg) } val employees = - employeeService.getEmployeesForProperty(request.pathParam).map { it.toEmployeeNetworkResponse() } + employeeService.getEmployeesForProperty(propertyId).map { it.toEmployeeNetworkResponse() } return EmployeeListNetworkResponse(employees) } diff --git a/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/controller/PropertyController.kt b/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/controller/PropertyController.kt index 52c4585b5..5dea00e10 100644 --- a/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/controller/PropertyController.kt +++ b/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/controller/PropertyController.kt @@ -2,10 +2,10 @@ package com.cramsan.edifikana.server.controller import com.cramsan.edifikana.api.PropertyApi import com.cramsan.edifikana.lib.model.network.property.CreatePropertyNetworkRequest +import com.cramsan.edifikana.lib.model.network.property.GetPropertiesQueryParams import com.cramsan.edifikana.lib.model.network.property.PropertyListNetworkResponse import com.cramsan.edifikana.lib.model.network.property.PropertyNetworkResponse import com.cramsan.edifikana.lib.model.network.property.UpdatePropertyNetworkRequest -import com.cramsan.edifikana.lib.model.organization.OrganizationId import com.cramsan.edifikana.lib.model.property.PropertyId import com.cramsan.edifikana.server.controller.authentication.SupabaseContextPayload import com.cramsan.edifikana.server.service.PropertyService @@ -89,15 +89,16 @@ class PropertyController(private val propertyService: PropertyService, private v suspend fun getProperties( request: OperationRequest< NoRequestBody, - NoQueryParam, - OrganizationId, + GetPropertiesQueryParams, + NoPathParam, ClientContext.AuthenticatedClientContext, >, ): PropertyListNetworkResponse { - if (!rbacService.hasRoleOrHigher(request.context, request.pathParam, UserRole.EMPLOYEE)) { + val organizationId = request.queryParam.organizationId + if (!rbacService.hasRoleOrHigher(request.context, organizationId, UserRole.EMPLOYEE)) { throw UnauthorizedException(unauthorizedMsg) } - val properties = propertyService.getProperties(request.pathParam).map { it.toPropertyNetworkResponse() } + val properties = propertyService.getProperties(organizationId).map { it.toPropertyNetworkResponse() } return PropertyListNetworkResponse(properties) } diff --git a/edifikana/back-end/src/test/kotlin/com/cramsan/edifikana/server/controller/EmployeeControllerTest.kt b/edifikana/back-end/src/test/kotlin/com/cramsan/edifikana/server/controller/EmployeeControllerTest.kt index d942a6548..09ee476c0 100644 --- a/edifikana/back-end/src/test/kotlin/com/cramsan/edifikana/server/controller/EmployeeControllerTest.kt +++ b/edifikana/back-end/src/test/kotlin/com/cramsan/edifikana/server/controller/EmployeeControllerTest.kt @@ -298,7 +298,7 @@ class EmployeeControllerTest : coEvery { rbacService.hasRoleOrHigher(context, propId, UserRole.MANAGER) } returns true // Act - val response = client.get("employee/by-property/property123") + val response = client.get("employee/by-property?property_id=property123") // Assert assertEquals(HttpStatusCode.OK, response.status) @@ -325,7 +325,7 @@ class EmployeeControllerTest : coEvery { rbacService.hasRoleOrHigher(context, propId, UserRole.MANAGER) } returns false // Act - val response = client.get("employee/by-property/property123") + val response = client.get("employee/by-property?property_id=property123") // Assert coVerify { employeeService wasNot Called } diff --git a/edifikana/back-end/src/test/kotlin/com/cramsan/edifikana/server/controller/PropertyControllerTest.kt b/edifikana/back-end/src/test/kotlin/com/cramsan/edifikana/server/controller/PropertyControllerTest.kt index be6d07643..aafa85fec 100644 --- a/edifikana/back-end/src/test/kotlin/com/cramsan/edifikana/server/controller/PropertyControllerTest.kt +++ b/edifikana/back-end/src/test/kotlin/com/cramsan/edifikana/server/controller/PropertyControllerTest.kt @@ -288,7 +288,7 @@ class PropertyControllerTest : } // Act - val response = client.get("property/by-organization/org123") + val response = client.get("property?organization_id=org123") // Assert assertEquals(HttpStatusCode.OK, response.status) @@ -323,7 +323,7 @@ class PropertyControllerTest : } // Act - val response = client.get("property/by-organization/org123") + val response = client.get("property?organization_id=org123") // Assert coVerify { propertyService wasNot Called } diff --git a/edifikana/front-end/app/src/commonMain/kotlin/com/cramsan/edifikana/client/lib/service/impl/PropertyServiceImpl.kt b/edifikana/front-end/app/src/commonMain/kotlin/com/cramsan/edifikana/client/lib/service/impl/PropertyServiceImpl.kt index c912a5fa3..6525120f5 100644 --- a/edifikana/front-end/app/src/commonMain/kotlin/com/cramsan/edifikana/client/lib/service/impl/PropertyServiceImpl.kt +++ b/edifikana/front-end/app/src/commonMain/kotlin/com/cramsan/edifikana/client/lib/service/impl/PropertyServiceImpl.kt @@ -5,6 +5,7 @@ import com.cramsan.edifikana.client.lib.models.PropertyModel import com.cramsan.edifikana.client.lib.service.PropertyService import com.cramsan.edifikana.lib.model.common.Url import com.cramsan.edifikana.lib.model.network.property.CreatePropertyNetworkRequest +import com.cramsan.edifikana.lib.model.network.property.GetPropertiesQueryParams import com.cramsan.edifikana.lib.model.network.property.PropertyNetworkResponse import com.cramsan.edifikana.lib.model.network.property.UpdatePropertyNetworkRequest import com.cramsan.edifikana.lib.model.organization.OrganizationId @@ -25,7 +26,7 @@ class PropertyServiceImpl(private val http: HttpClient) : PropertyService { val response = PropertyApi .getProperties - .buildRequest(organizationId) + .buildRequest(queryParam = GetPropertiesQueryParams(organizationId)) .execute(http) val propertyList = response.properties.map { diff --git a/edifikana/models/src/commonMain/kotlin/com/cramsan/edifikana/lib/model/network/employee/GetEmployeesForPropertyQueryParams.kt b/edifikana/models/src/commonMain/kotlin/com/cramsan/edifikana/lib/model/network/employee/GetEmployeesForPropertyQueryParams.kt new file mode 100644 index 000000000..1e8bf4a22 --- /dev/null +++ b/edifikana/models/src/commonMain/kotlin/com/cramsan/edifikana/lib/model/network/employee/GetEmployeesForPropertyQueryParams.kt @@ -0,0 +1,20 @@ +package com.cramsan.edifikana.lib.model.network.employee + +import com.cramsan.edifikana.lib.model.property.PropertyId +import com.cramsan.framework.annotations.NetworkModel +import com.cramsan.framework.annotations.api.QueryParam +import io.ktor.openapi.JsonSchema +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Query parameters for listing employees assigned to a property. + */ +@NetworkModel +@Serializable +@JsonSchema.Description("Query parameters for listing employees assigned to a property, requiring a property id.") +data class GetEmployeesForPropertyQueryParams( + @SerialName("property_id") + @JsonSchema.Description("Identifier of the property to list employees for.") + val propertyId: PropertyId, +) : QueryParam diff --git a/edifikana/models/src/commonMain/kotlin/com/cramsan/edifikana/lib/model/network/property/GetPropertiesQueryParams.kt b/edifikana/models/src/commonMain/kotlin/com/cramsan/edifikana/lib/model/network/property/GetPropertiesQueryParams.kt new file mode 100644 index 000000000..8d864689a --- /dev/null +++ b/edifikana/models/src/commonMain/kotlin/com/cramsan/edifikana/lib/model/network/property/GetPropertiesQueryParams.kt @@ -0,0 +1,20 @@ +package com.cramsan.edifikana.lib.model.network.property + +import com.cramsan.edifikana.lib.model.organization.OrganizationId +import com.cramsan.framework.annotations.NetworkModel +import com.cramsan.framework.annotations.api.QueryParam +import io.ktor.openapi.JsonSchema +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Query parameters for listing properties. + */ +@NetworkModel +@Serializable +@JsonSchema.Description("Query parameters for listing properties, requiring an organization id.") +data class GetPropertiesQueryParams( + @SerialName("organization_id") + @JsonSchema.Description("Identifier of the organization to list properties for.") + val organizationId: OrganizationId, +) : QueryParam