diff --git a/edifikana/api/src/commonMain/kotlin/com/cramsan/edifikana/api/CommonAreaApi.kt b/edifikana/api/src/commonMain/kotlin/com/cramsan/edifikana/api/CommonAreaApi.kt new file mode 100644 index 000000000..6dfb0f995 --- /dev/null +++ b/edifikana/api/src/commonMain/kotlin/com/cramsan/edifikana/api/CommonAreaApi.kt @@ -0,0 +1,61 @@ +package com.cramsan.edifikana.api + +import com.cramsan.edifikana.lib.model.CommonAreaId +import com.cramsan.edifikana.lib.model.PropertyId +import com.cramsan.edifikana.lib.model.network.CommonAreaListNetworkResponse +import com.cramsan.edifikana.lib.model.network.CommonAreaNetworkResponse +import com.cramsan.edifikana.lib.model.network.CreateCommonAreaNetworkRequest +import com.cramsan.edifikana.lib.model.network.UpdateCommonAreaNetworkRequest +import com.cramsan.framework.annotations.NetworkModel +import com.cramsan.framework.annotations.api.NoPathParam +import com.cramsan.framework.annotations.api.NoQueryParam +import com.cramsan.framework.annotations.api.NoRequestBody +import com.cramsan.framework.annotations.api.NoResponseBody +import com.cramsan.framework.networkapi.Api +import io.ktor.http.HttpMethod + +/** + * API definition for common area operations within a property. + * + * Common areas are shared spaces (e.g. Lobby, Pool, Gym) that belong to a single property. + * All operations require MANAGER role or higher in the property's organization, depending on + * the operation. In some cases it will be ADMIN+, in others it will be MANAGER+ + */ +@OptIn(NetworkModel::class) +object CommonAreaApi : Api("common-area") { + + val createCommonArea = operation< + CreateCommonAreaNetworkRequest, + NoQueryParam, + NoPathParam, + CommonAreaNetworkResponse + >(HttpMethod.Post) + + val getCommonArea = operation< + NoRequestBody, + NoQueryParam, + CommonAreaId, + CommonAreaNetworkResponse + >(HttpMethod.Get) + + val getCommonAreasForProperty = operation< + NoRequestBody, + NoQueryParam, + PropertyId, + CommonAreaListNetworkResponse + >(HttpMethod.Get, "by-property") + + val updateCommonArea = operation< + UpdateCommonAreaNetworkRequest, + NoQueryParam, + CommonAreaId, + CommonAreaNetworkResponse + >(HttpMethod.Put) + + val deleteCommonArea = operation< + NoRequestBody, + NoQueryParam, + CommonAreaId, + NoResponseBody + >(HttpMethod.Delete) +} diff --git a/edifikana/back-end/src/integTest/kotlin/com/cramsan/edifikana/server/datastore/supabase/SupabaseCommonAreaDatastoreIntegrationTest.kt b/edifikana/back-end/src/integTest/kotlin/com/cramsan/edifikana/server/datastore/supabase/SupabaseCommonAreaDatastoreIntegrationTest.kt new file mode 100644 index 000000000..3b6ae688d --- /dev/null +++ b/edifikana/back-end/src/integTest/kotlin/com/cramsan/edifikana/server/datastore/supabase/SupabaseCommonAreaDatastoreIntegrationTest.kt @@ -0,0 +1,217 @@ +package com.cramsan.edifikana.server.datastore.supabase + +import com.cramsan.edifikana.lib.model.CommonAreaId +import com.cramsan.edifikana.lib.model.CommonAreaType +import com.cramsan.edifikana.lib.model.OrganizationId +import com.cramsan.edifikana.lib.model.PropertyId +import com.cramsan.edifikana.lib.model.UserId +import com.cramsan.framework.utils.uuid.UUID +import kotlinx.coroutines.runBlocking +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class SupabaseCommonAreaDatastoreIntegrationTest : SupabaseIntegrationTest() { + + private lateinit var testPrefix: String + private var propertyId: PropertyId? = null + private var testUserId: UserId? = null + private var orgId: OrganizationId? = null + + @BeforeTest + fun setup() { + testPrefix = UUID.random() + runBlocking { + testUserId = createTestUser("user-${testPrefix}@test.com") + orgId = createTestOrganization("org_$testPrefix", "") + propertyId = createTestProperty("${testPrefix}_Property", testUserId!!, orgId!!) + } + } + + @Test + fun `createCommonArea should return created common area`() = runCoroutineTest { + // Arrange + + // Act + val result = commonAreaDatastore.createCommonArea( + propertyId = propertyId!!, + name = "${testPrefix}_Lobby", + type = CommonAreaType.LOBBY, + description = "Main entrance", + ).registerCommonAreaForDeletion() + + // Assert + assertTrue(result.isSuccess) + val area = result.getOrNull() + assertNotNull(area) + assertEquals("${testPrefix}_Lobby", area.name) + assertEquals(CommonAreaType.LOBBY, area.type) + assertEquals("Main entrance", area.description) + assertEquals(propertyId, area.propertyId) + } + + @Test + fun `getCommonArea should return created common area`() = runCoroutineTest { + // Arrange + val createResult = commonAreaDatastore.createCommonArea( + propertyId = propertyId!!, + name = "${testPrefix}_GetLobby", + type = CommonAreaType.LOBBY, + description = null, + ).registerCommonAreaForDeletion() + assertTrue(createResult.isSuccess) + val created = createResult.getOrNull()!! + + // Act + val getResult = commonAreaDatastore.getCommonArea(created.id) + + // Assert + assertTrue(getResult.isSuccess) + val fetched = getResult.getOrNull() + assertNotNull(fetched) + assertEquals("${testPrefix}_GetLobby", fetched.name) + assertEquals(created.id, fetched.id) + } + + @Test + fun `getCommonArea should return null when not found`() = runCoroutineTest { + // Arrange + val fakeId = CommonAreaId(UUID.random()) + + // Act + val result = commonAreaDatastore.getCommonArea(fakeId) + + // Assert + assertTrue(result.isSuccess) + assertNull(result.getOrNull()) + } + + @Test + fun `getCommonAreasForProperty should return all areas for property`() = runCoroutineTest { + // Arrange + + // Act + val result1 = commonAreaDatastore.createCommonArea( + propertyId = propertyId!!, + name = "${testPrefix}_Pool", + type = CommonAreaType.POOL, + description = null, + ).registerCommonAreaForDeletion() + val result2 = commonAreaDatastore.createCommonArea( + propertyId = propertyId!!, + name = "${testPrefix}_Gym", + type = CommonAreaType.GYM, + description = null, + ).registerCommonAreaForDeletion() + assertTrue(result1.isSuccess) + assertTrue(result2.isSuccess) + val listResult = commonAreaDatastore.getCommonAreasForProperty(propertyId!!) + + // Assert + assertTrue(listResult.isSuccess) + val areas = listResult.getOrNull() + assertNotNull(areas) + val names = areas.map { it.name } + assertTrue(names.contains("${testPrefix}_Pool")) + assertTrue(names.contains("${testPrefix}_Gym")) + } + + @Test + fun `getCommonAreasForProperty should not return deleted areas`() = runCoroutineTest { + // Arrange + val createResult = commonAreaDatastore.createCommonArea( + propertyId = propertyId!!, + name = "${testPrefix}_Rooftop", + type = CommonAreaType.ROOFTOP, + description = null, + ).registerCommonAreaForDeletion() + assertTrue(createResult.isSuccess) + val area = createResult.getOrNull()!! + val deleteResult = commonAreaDatastore.deleteCommonArea(area.id) + assertTrue(deleteResult.isSuccess) + + // Act + val listResult = commonAreaDatastore.getCommonAreasForProperty(propertyId!!) + + // Assert + assertTrue(listResult.isSuccess) + val names = listResult.getOrNull()!!.map { it.name } + assertTrue(!names.contains("${testPrefix}_Rooftop")) + } + + @Test + fun `updateCommonArea should update name and type`() = runCoroutineTest { + // Arrange + val createResult = commonAreaDatastore.createCommonArea( + propertyId = propertyId!!, + name = "${testPrefix}_OldName", + type = CommonAreaType.LOBBY, + description = "Original description", + ).registerCommonAreaForDeletion() + assertTrue(createResult.isSuccess) + val area = createResult.getOrNull()!! + + // Act + val updateResult = commonAreaDatastore.updateCommonArea( + commonAreaId = area.id, + name = "${testPrefix}_NewName", + type = CommonAreaType.GYM, + description = null, + ) + + // Assert + assertTrue(updateResult.isSuccess) + val updated = updateResult.getOrNull() + assertNotNull(updated) + assertEquals("${testPrefix}_NewName", updated.name) + assertEquals(CommonAreaType.GYM, updated.type) + } + + @Test + fun `deleteCommonArea should soft delete and make area invisible`() = runCoroutineTest { + // Arrange + val createResult = commonAreaDatastore.createCommonArea( + propertyId = propertyId!!, + name = "${testPrefix}_ToDelete", + type = CommonAreaType.PARKING, + description = null, + ).registerCommonAreaForDeletion() + assertTrue(createResult.isSuccess) + val area = createResult.getOrNull()!! + + // Act + val deleteResult = commonAreaDatastore.deleteCommonArea(area.id) + + // Assert + assertTrue(deleteResult.isSuccess) + assertTrue(deleteResult.getOrNull() == true) + val getResult = commonAreaDatastore.getCommonArea(area.id) + assertTrue(getResult.isSuccess) + assertNull(getResult.getOrNull()) + } + + @Test + fun `purgeCommonArea should hard delete the area`() = runCoroutineTest { + // Arrange + val createResult = commonAreaDatastore.createCommonArea( + propertyId = propertyId!!, + name = "${testPrefix}_ToPurge", + type = CommonAreaType.LAUNDRY, + description = null, + ) + assertTrue(createResult.isSuccess) + val area = createResult.getOrNull()!! + + // Act + val purgeResult = commonAreaDatastore.purgeCommonArea(area.id) + + // Assert + assertTrue(purgeResult.isSuccess) + val getResult = commonAreaDatastore.getCommonArea(area.id) + assertTrue(getResult.isSuccess) + assertNull(getResult.getOrNull()) + } +} diff --git a/edifikana/back-end/src/integTest/kotlin/com/cramsan/edifikana/server/datastore/supabase/SupabaseIntegrationTest.kt b/edifikana/back-end/src/integTest/kotlin/com/cramsan/edifikana/server/datastore/supabase/SupabaseIntegrationTest.kt index 933a479bc..5b68d2782 100644 --- a/edifikana/back-end/src/integTest/kotlin/com/cramsan/edifikana/server/datastore/supabase/SupabaseIntegrationTest.kt +++ b/edifikana/back-end/src/integTest/kotlin/com/cramsan/edifikana/server/datastore/supabase/SupabaseIntegrationTest.kt @@ -2,6 +2,7 @@ package com.cramsan.edifikana.server.datastore.supabase import com.cramsan.architecture.server.test.dependencyinjection.TestArchitectureModule import com.cramsan.architecture.server.test.dependencyinjection.integTestFrameworkModule +import com.cramsan.edifikana.lib.model.CommonAreaId import com.cramsan.edifikana.lib.model.EmployeeId import com.cramsan.edifikana.lib.model.EmployeeRole import com.cramsan.edifikana.lib.model.EventLogEntryId @@ -15,6 +16,7 @@ import com.cramsan.edifikana.lib.model.UserId import com.cramsan.edifikana.lib.utils.requireSuccess import com.cramsan.edifikana.server.dependencyinjection.DatastoreModule import com.cramsan.edifikana.server.dependencyinjection.IntegTestApplicationModule +import com.cramsan.edifikana.server.service.models.CommonArea import com.cramsan.edifikana.server.service.models.Employee import com.cramsan.edifikana.server.service.models.EventLogEntry import com.cramsan.edifikana.server.service.models.Invite @@ -52,8 +54,10 @@ abstract class SupabaseIntegrationTest : CoroutineTest(), KoinTest { protected val organizationDatastore: SupabaseOrganizationDatastore by inject() protected val notificationDatastore: SupabaseNotificationDatastore by inject() protected val membershipDatastore: SupabaseMembershipDatastore by inject() + protected val commonAreaDatastore: SupabaseCommonAreaDatastore by inject() private val eventLogResources = mutableSetOf() + private val commonAreaResources = mutableSetOf() private val propertyResources = mutableSetOf() private val employeeResources = mutableSetOf() private val timeCardResources = mutableSetOf() @@ -108,6 +112,10 @@ abstract class SupabaseIntegrationTest : CoroutineTest(), KoinTest { notificationResources.add(notificationId) } + private fun registerCommonAreaForDeletion(commonAreaId: CommonAreaId) { + commonAreaResources.add(commonAreaId) + } + protected fun createTestUser(email: String): UserId { val userId = runBlocking { userDatastore.createUser( @@ -217,6 +225,12 @@ abstract class SupabaseIntegrationTest : CoroutineTest(), KoinTest { } } + fun Result.registerCommonAreaForDeletion(): Result { + return this.onSuccess { commonArea -> + registerCommonAreaForDeletion(commonArea.id) + } + } + fun registerSupabaseUserForDeletion(userId: String) { supabaseUsers.add(userId) } @@ -271,6 +285,10 @@ abstract class SupabaseIntegrationTest : CoroutineTest(), KoinTest { results += employeeDatastore.deleteEmployee(it) results += employeeDatastore.purgeEmployee(it) } + commonAreaResources.forEach { + results += commonAreaDatastore.deleteCommonArea(it) + results += commonAreaDatastore.purgeCommonArea(it) + } propertyResources.forEach { results += propertyDatastore.deleteProperty(it) results += propertyDatastore.purgeProperty(it) @@ -293,6 +311,7 @@ abstract class SupabaseIntegrationTest : CoroutineTest(), KoinTest { eventLogResources.clear() timeCardResources.clear() employeeResources.clear() + commonAreaResources.clear() propertyResources.clear() userResources.clear() organizationResources.clear() diff --git a/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/controller/CommonAreaController.kt b/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/controller/CommonAreaController.kt new file mode 100644 index 000000000..f72677ec0 --- /dev/null +++ b/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/controller/CommonAreaController.kt @@ -0,0 +1,164 @@ +package com.cramsan.edifikana.server.controller + +import com.cramsan.edifikana.api.CommonAreaApi +import com.cramsan.edifikana.lib.model.CommonAreaId +import com.cramsan.edifikana.lib.model.PropertyId +import com.cramsan.edifikana.lib.model.network.CommonAreaListNetworkResponse +import com.cramsan.edifikana.lib.model.network.CommonAreaNetworkResponse +import com.cramsan.edifikana.lib.model.network.CreateCommonAreaNetworkRequest +import com.cramsan.edifikana.lib.model.network.UpdateCommonAreaNetworkRequest +import com.cramsan.edifikana.server.controller.authentication.SupabaseContextPayload +import com.cramsan.edifikana.server.service.CommonAreaService +import com.cramsan.edifikana.server.service.authorization.RBACService +import com.cramsan.edifikana.server.service.models.UserRole +import com.cramsan.framework.annotations.NetworkModel +import com.cramsan.framework.annotations.api.NoPathParam +import com.cramsan.framework.annotations.api.NoQueryParam +import com.cramsan.framework.annotations.api.NoRequestBody +import com.cramsan.framework.annotations.api.NoResponseBody +import com.cramsan.framework.core.ktor.Controller +import com.cramsan.framework.core.ktor.OperationHandler.register +import com.cramsan.framework.core.ktor.OperationRequest +import com.cramsan.framework.core.ktor.auth.ClientContext +import com.cramsan.framework.core.ktor.auth.ContextRetriever +import com.cramsan.framework.core.ktor.handler +import com.cramsan.framework.utils.exceptions.ClientRequestExceptions.NotFoundException +import com.cramsan.framework.utils.exceptions.ClientRequestExceptions.UnauthorizedException +import io.ktor.server.routing.Routing + +/** + * Controller for common area operations within a property. + * All operations require MANAGER role or higher. + */ +@OptIn(NetworkModel::class) +class CommonAreaController( + private val commonAreaService: CommonAreaService, + private val rbacService: RBACService, + private val contextRetriever: ContextRetriever, +) : Controller { + + private val unauthorizedMsg = "You are not authorized to perform this action in your organization." + + /** + * Creates a new common area. Requires MANAGER role or higher in the target property. + */ + suspend fun createCommonArea( + request: OperationRequest< + CreateCommonAreaNetworkRequest, + NoQueryParam, + NoPathParam, + ClientContext.AuthenticatedClientContext + > + ): CommonAreaNetworkResponse { + if (!rbacService.hasRoleOrHigher(request.context, request.requestBody.propertyId, UserRole.MANAGER)) { + throw UnauthorizedException(unauthorizedMsg) + } + return commonAreaService.createCommonArea( + propertyId = request.requestBody.propertyId, + name = request.requestBody.name, + type = request.requestBody.type, + description = request.requestBody.description, + ).toCommonAreaNetworkResponse() + } + + /** + * Retrieves a single common area by its [CommonAreaId]. Requires MANAGER role or higher. + * Returns 404 if the area does not exist or the caller is not authorized (to avoid leaking existence). + */ + suspend fun getCommonArea( + request: OperationRequest< + NoRequestBody, + NoQueryParam, + CommonAreaId, + ClientContext.AuthenticatedClientContext + > + ): CommonAreaNetworkResponse { + if (!rbacService.hasRoleOrHigher(request.context, request.pathParam, UserRole.MANAGER)) { + throw NotFoundException("Common area not found.") + } + return commonAreaService.getCommonArea(request.pathParam)?.toCommonAreaNetworkResponse() + ?: throw NotFoundException("Common area not found.") + } + + /** + * Lists all common areas for a property. Requires MANAGER role or higher. + */ + suspend fun getCommonAreasForProperty( + request: OperationRequest< + NoRequestBody, + NoQueryParam, + PropertyId, + ClientContext.AuthenticatedClientContext + > + ): CommonAreaListNetworkResponse { + if (!rbacService.hasRoleOrHigher(request.context, request.pathParam, UserRole.MANAGER)) { + throw UnauthorizedException(unauthorizedMsg) + } + val commonAreas = commonAreaService.getCommonAreasForProperty(request.pathParam) + .map { it.toCommonAreaNetworkResponse() } + return CommonAreaListNetworkResponse(commonAreas) + } + + /** + * Updates an existing common area. Requires MANAGER role or higher. + */ + suspend fun updateCommonArea( + request: OperationRequest< + UpdateCommonAreaNetworkRequest, + NoQueryParam, + CommonAreaId, + ClientContext.AuthenticatedClientContext + > + ): CommonAreaNetworkResponse { + if (!rbacService.hasRoleOrHigher(request.context, request.pathParam, UserRole.MANAGER)) { + throw UnauthorizedException(unauthorizedMsg) + } + return commonAreaService.updateCommonArea( + commonAreaId = request.pathParam, + name = request.requestBody.name, + type = request.requestBody.type, + description = request.requestBody.description, + ).toCommonAreaNetworkResponse() + } + + /** + * Soft-deletes a common area. Requires MANAGER role or higher. + */ + suspend fun deleteCommonArea( + request: OperationRequest< + NoRequestBody, + NoQueryParam, + CommonAreaId, + ClientContext.AuthenticatedClientContext + > + ): NoResponseBody { + if (!rbacService.hasRoleOrHigher(request.context, request.pathParam, UserRole.MANAGER)) { + throw UnauthorizedException(unauthorizedMsg) + } + commonAreaService.deleteCommonArea(request.pathParam) + return NoResponseBody + } + + /** + * Registers all common area routes. + */ + override fun registerRoutes(route: Routing) { + CommonAreaApi.register(route) { + handler(api.createCommonArea, contextRetriever) { request -> + createCommonArea(request) + } + handler(api.getCommonArea, contextRetriever) { request -> + getCommonArea(request) + } + handler(api.getCommonAreasForProperty, contextRetriever) { request -> + getCommonAreasForProperty(request) + } + handler(api.updateCommonArea, contextRetriever) { request -> + updateCommonArea(request) + } + handler(api.deleteCommonArea, contextRetriever) { request -> + deleteCommonArea(request) + } + } + } +} diff --git a/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/controller/NetworkMappers.kt b/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/controller/NetworkMappers.kt index 83a3c9165..9ac3cd041 100644 --- a/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/controller/NetworkMappers.kt +++ b/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/controller/NetworkMappers.kt @@ -4,6 +4,7 @@ package com.cramsan.edifikana.server.controller import com.cramsan.edifikana.lib.model.network.AssetNetworkResponse import com.cramsan.edifikana.lib.model.network.AuthMetadataNetworkResponse +import com.cramsan.edifikana.lib.model.network.CommonAreaNetworkResponse import com.cramsan.edifikana.lib.model.network.DocumentNetworkResponse import com.cramsan.edifikana.lib.model.network.EmployeeNetworkResponse import com.cramsan.edifikana.lib.model.network.EventLogEntryNetworkResponse @@ -15,6 +16,7 @@ import com.cramsan.edifikana.lib.model.network.PropertyNetworkResponse import com.cramsan.edifikana.lib.model.network.TimeCardEventNetworkResponse import com.cramsan.edifikana.lib.model.network.UserNetworkResponse import com.cramsan.edifikana.server.service.models.Asset +import com.cramsan.edifikana.server.service.models.CommonArea import com.cramsan.edifikana.server.service.models.Document import com.cramsan.edifikana.server.service.models.Employee import com.cramsan.edifikana.server.service.models.EventLogEntry @@ -199,3 +201,17 @@ fun Document.toDocumentNetworkResponse(): DocumentNetworkResponse { createdAt = createdAt, ) } + +/** + * Converts a [CommonArea] domain model to a [CommonAreaNetworkResponse] network model. + */ +@NetworkModel +fun CommonArea.toCommonAreaNetworkResponse(): CommonAreaNetworkResponse { + return CommonAreaNetworkResponse( + commonAreaId = id, + propertyId = propertyId, + name = name, + type = type, + description = description, + ) +} diff --git a/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/datastore/CommonAreaDatastore.kt b/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/datastore/CommonAreaDatastore.kt new file mode 100644 index 000000000..5e480a94e --- /dev/null +++ b/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/datastore/CommonAreaDatastore.kt @@ -0,0 +1,59 @@ +package com.cramsan.edifikana.server.datastore + +import com.cramsan.edifikana.lib.model.CommonAreaId +import com.cramsan.edifikana.lib.model.CommonAreaType +import com.cramsan.edifikana.lib.model.OrganizationId +import com.cramsan.edifikana.lib.model.PropertyId +import com.cramsan.edifikana.server.service.models.CommonArea + +/** + * Interface for the common area datastore. + */ +interface CommonAreaDatastore { + + /** + * Creates a new common area record. Returns the [Result] with the created [CommonArea]. + */ + suspend fun createCommonArea( + propertyId: PropertyId, + name: String, + type: CommonAreaType, + description: String?, + ): Result + + /** + * Retrieves a common area by [commonAreaId]. Returns null if not found or soft-deleted. + */ + suspend fun getCommonArea(commonAreaId: CommonAreaId): Result + + /** + * Retrieves all non-deleted common areas for the given [propertyId]. + */ + suspend fun getCommonAreasForProperty(propertyId: PropertyId): Result> + + /** + * Updates the [name], [type], and/or [description] of an existing common area. Returns the updated [CommonArea]. + */ + suspend fun updateCommonArea( + commonAreaId: CommonAreaId, + name: String?, + type: CommonAreaType?, + description: String?, + ): Result + + /** + * Soft-deletes the common area with the given [commonAreaId]. Returns true if the record was deleted. + */ + suspend fun deleteCommonArea(commonAreaId: CommonAreaId): Result + + /** + * Hard-deletes the common area with the given [commonAreaId]. For integration test cleanup only. + * Implementations SHOULD only purge records that have previously been soft-deleted via [deleteCommonArea]. + * + * The returned [Result] will be: + * - `Result.success(true)` if a matching, soft-deleted record existed and was hard-deleted. + * - `Result.success(false)` if no matching soft-deleted record exists (including when the record does not exist + * at all, or exists but has not been soft-deleted). + */ + suspend fun purgeCommonArea(commonAreaId: CommonAreaId): Result +} diff --git a/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/datastore/supabase/SupabaseCommonAreaDatastore.kt b/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/datastore/supabase/SupabaseCommonAreaDatastore.kt new file mode 100644 index 000000000..dddaed73c --- /dev/null +++ b/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/datastore/supabase/SupabaseCommonAreaDatastore.kt @@ -0,0 +1,134 @@ +package com.cramsan.edifikana.server.datastore.supabase + +import com.cramsan.edifikana.lib.model.CommonAreaId +import com.cramsan.edifikana.lib.model.CommonAreaType +import com.cramsan.edifikana.lib.model.OrganizationId +import com.cramsan.edifikana.lib.model.PropertyId +import com.cramsan.edifikana.server.datastore.CommonAreaDatastore +import com.cramsan.edifikana.server.datastore.supabase.models.CommonAreaEntity +import com.cramsan.edifikana.server.datastore.supabase.models.CommonAreaEntity.CreateCommonAreaEntity +import com.cramsan.edifikana.server.service.models.CommonArea +import com.cramsan.framework.annotations.SupabaseModel +import com.cramsan.framework.core.runSuspendCatching +import com.cramsan.framework.logging.logD +import io.github.jan.supabase.postgrest.Postgrest +import kotlin.time.Clock + +/** + * Supabase implementation of [CommonAreaDatastore]. + */ +class SupabaseCommonAreaDatastore( + private val postgrest: Postgrest, + private val clock: Clock, +) : CommonAreaDatastore { + + /** + * Inserts a new common area row and returns the created [CommonArea]. + */ + @OptIn(SupabaseModel::class) + override suspend fun createCommonArea( + propertyId: PropertyId, + name: String, + type: CommonAreaType, + description: String?, + ): Result = runSuspendCatching(TAG) { + logD(TAG, "Creating common area: %s", name) + val entity = CreateCommonAreaEntity( + propertyId = propertyId, + name = name, + type = type.name, + description = description, + ) + postgrest.from(CommonAreaEntity.COLLECTION).insert(entity) { + select() + }.decodeSingle().toCommonArea() + } + + /** + * Retrieves a single common area by [commonAreaId]. Returns null if not found or soft-deleted. + */ + @OptIn(SupabaseModel::class) + override suspend fun getCommonArea(commonAreaId: CommonAreaId): Result = runSuspendCatching(TAG) { + logD(TAG, "Getting common area: %s", commonAreaId) + postgrest.from(CommonAreaEntity.COLLECTION).select { + filter { + CommonAreaEntity::commonAreaId eq commonAreaId.commonAreaId + CommonAreaEntity::deletedAt isExact null + } + }.decodeSingleOrNull()?.toCommonArea() + } + + /** + * Lists all non-deleted common areas for the given [propertyId]. + */ + @OptIn(SupabaseModel::class) + override suspend fun getCommonAreasForProperty(propertyId: PropertyId): Result> = + runSuspendCatching(TAG) { + logD(TAG, "Getting common areas for property: %s", propertyId) + postgrest.from(CommonAreaEntity.COLLECTION).select { + filter { + CommonAreaEntity::propertyId eq propertyId.propertyId + CommonAreaEntity::deletedAt isExact null + } + }.decodeList().map { it.toCommonArea() } + } + + /** + * Updates the [name], [type], and/or [description] of an existing common area. + */ + @OptIn(SupabaseModel::class) + override suspend fun updateCommonArea( + commonAreaId: CommonAreaId, + name: String?, + type: CommonAreaType?, + description: String?, + ): Result = runSuspendCatching(TAG) { + logD(TAG, "Updating common area: %s", commonAreaId) + postgrest.from(CommonAreaEntity.COLLECTION).update({ + name?.let { value -> CommonAreaEntity::name setTo value } + type?.let { value -> CommonAreaEntity::type setTo value.name } + description?.let { value -> CommonAreaEntity::description setTo value } + }) { + select() + filter { + CommonAreaEntity::commonAreaId eq commonAreaId.commonAreaId + CommonAreaEntity::deletedAt isExact null + } + }.decodeSingle().toCommonArea() + } + + /** + * Soft-deletes a common area by setting [CommonAreaEntity.deletedAt]. Returns true if the record was found and deleted. + */ + @OptIn(SupabaseModel::class) + override suspend fun deleteCommonArea(commonAreaId: CommonAreaId): Result = runSuspendCatching(TAG) { + logD(TAG, "Soft deleting common area: %s", commonAreaId) + postgrest.from(CommonAreaEntity.COLLECTION).update({ + CommonAreaEntity::deletedAt setTo clock.now() + }) { + select() + filter { + CommonAreaEntity::commonAreaId eq commonAreaId.commonAreaId + CommonAreaEntity::deletedAt isExact null + } + }.decodeSingleOrNull() != null + } + + /** + * Hard-deletes a common area row. For integration test cleanup only. + */ + @OptIn(SupabaseModel::class) + override suspend fun purgeCommonArea(commonAreaId: CommonAreaId): Result = runSuspendCatching(TAG) { + logD(TAG, "Purging common area: %s", commonAreaId) + postgrest.from(CommonAreaEntity.COLLECTION).delete { + select() + filter { + CommonAreaEntity::commonAreaId eq commonAreaId.commonAreaId + } + }.decodeSingleOrNull() != null + } + + companion object { + const val TAG = "SupabaseCommonAreaDatastore" + } +} diff --git a/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/datastore/supabase/SupabaseMappers.kt b/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/datastore/supabase/SupabaseMappers.kt index e1774bbc7..0bccfb1b9 100644 --- a/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/datastore/supabase/SupabaseMappers.kt +++ b/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/datastore/supabase/SupabaseMappers.kt @@ -27,10 +27,14 @@ import com.cramsan.edifikana.server.datastore.supabase.models.EventLogEntryEntit import com.cramsan.edifikana.server.datastore.supabase.models.InviteEntity import com.cramsan.edifikana.server.datastore.supabase.models.NotificationEntity import com.cramsan.edifikana.server.datastore.supabase.models.OrgMemberViewEntity +import com.cramsan.edifikana.lib.model.CommonAreaId +import com.cramsan.edifikana.lib.model.CommonAreaType +import com.cramsan.edifikana.server.datastore.supabase.models.CommonAreaEntity import com.cramsan.edifikana.server.datastore.supabase.models.OrganizationEntity import com.cramsan.edifikana.server.datastore.supabase.models.PropertyEntity import com.cramsan.edifikana.server.datastore.supabase.models.TimeCardEventEntity import com.cramsan.edifikana.server.datastore.supabase.models.UserEntity +import com.cramsan.edifikana.server.service.models.CommonArea import com.cramsan.edifikana.server.service.models.Document import com.cramsan.edifikana.server.service.models.Employee import com.cramsan.edifikana.server.service.models.EventLogEntry @@ -434,3 +438,18 @@ fun OrgMemberViewEntity.toOrgMemberView(): OrgMemberView { lastName = this.lastName, ) } + +/** + * Maps a [CommonAreaEntity] to the [CommonArea] domain model. + */ +@OptIn(SupabaseModel::class) +fun CommonAreaEntity.toCommonArea(): CommonArea { + return CommonArea( + id = CommonAreaId(commonAreaId), + propertyId = propertyId, + name = name, + type = CommonAreaType.fromString(type), + description = description, + createdAt = createdAt, + ) +} diff --git a/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/datastore/supabase/models/CommonAreaEntity.kt b/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/datastore/supabase/models/CommonAreaEntity.kt new file mode 100644 index 000000000..bcccc96f7 --- /dev/null +++ b/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/datastore/supabase/models/CommonAreaEntity.kt @@ -0,0 +1,45 @@ +package com.cramsan.edifikana.server.datastore.supabase.models + +import com.cramsan.edifikana.lib.model.PropertyId +import com.cramsan.framework.annotations.SupabaseModel +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlin.time.ExperimentalTime +import kotlin.time.Instant + +/** + * Entity representing a common area row in the `common_areas` Supabase table. + */ +@OptIn(ExperimentalTime::class) +@Serializable +@SupabaseModel +data class CommonAreaEntity( + @SerialName("common_area_id") + val commonAreaId: String, + @SerialName("property_id") + val propertyId: PropertyId, + val name: String, + val type: String, + val description: String? = null, + @SerialName("created_at") + val createdAt: Instant, + @SerialName("deleted_at") + val deletedAt: Instant? = null, +) { + companion object { + const val COLLECTION = "common_areas" + } + + /** + * Entity used when inserting a new common area (no ID or timestamps — generated by DB). + */ + @Serializable + @SupabaseModel + data class CreateCommonAreaEntity( + @SerialName("property_id") + val propertyId: PropertyId, + val name: String, + val type: String, + val description: String? = null, + ) +} diff --git a/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/dependencyinjection/ControllerModule.kt b/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/dependencyinjection/ControllerModule.kt index 8588d102c..d1aeba10f 100644 --- a/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/dependencyinjection/ControllerModule.kt +++ b/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/dependencyinjection/ControllerModule.kt @@ -1,5 +1,6 @@ package com.cramsan.edifikana.server.dependencyinjection +import com.cramsan.edifikana.server.controller.CommonAreaController import com.cramsan.edifikana.server.controller.DocumentController import com.cramsan.edifikana.server.controller.EmployeeController import com.cramsan.edifikana.server.controller.EventLogController @@ -32,4 +33,5 @@ internal val ControllerModule = module { singleOf(::OrganizationController) { bind() } singleOf(::NotificationController) { bind() } singleOf(::DocumentController) { bind() } + singleOf(::CommonAreaController) { bind() } } diff --git a/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/dependencyinjection/DatastoreModule.kt b/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/dependencyinjection/DatastoreModule.kt index 3355a1525..45cfd40d0 100644 --- a/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/dependencyinjection/DatastoreModule.kt +++ b/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/dependencyinjection/DatastoreModule.kt @@ -1,6 +1,7 @@ package com.cramsan.edifikana.server.dependencyinjection import com.cramsan.architecture.server.settings.SettingsHolder +import com.cramsan.edifikana.server.datastore.CommonAreaDatastore import com.cramsan.edifikana.server.datastore.DocumentDatastore import com.cramsan.edifikana.server.datastore.EmployeeDatastore import com.cramsan.edifikana.server.datastore.EventLogDatastore @@ -11,6 +12,7 @@ import com.cramsan.edifikana.server.datastore.PropertyDatastore import com.cramsan.edifikana.server.datastore.StorageDatastore import com.cramsan.edifikana.server.datastore.TimeCardDatastore import com.cramsan.edifikana.server.datastore.UserDatastore +import com.cramsan.edifikana.server.datastore.supabase.SupabaseCommonAreaDatastore import com.cramsan.edifikana.server.datastore.supabase.SupabaseDocumentDatastore import com.cramsan.edifikana.server.datastore.supabase.SupabaseEmployeeDatastore import com.cramsan.edifikana.server.datastore.supabase.SupabaseEventLogDatastore @@ -116,4 +118,7 @@ val DatastoreModule = module { singleOf(::SupabaseMembershipDatastore) { bind() } + singleOf(::SupabaseCommonAreaDatastore) { + bind() + } } diff --git a/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/dependencyinjection/ServicesModule.kt b/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/dependencyinjection/ServicesModule.kt index 21345eeaa..e6d1888a3 100644 --- a/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/dependencyinjection/ServicesModule.kt +++ b/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/dependencyinjection/ServicesModule.kt @@ -1,5 +1,6 @@ package com.cramsan.edifikana.server.dependencyinjection +import com.cramsan.edifikana.server.service.CommonAreaService import com.cramsan.edifikana.server.service.DocumentService import com.cramsan.edifikana.server.service.EmployeeService import com.cramsan.edifikana.server.service.EventLogService @@ -29,4 +30,5 @@ internal val ServicesModule = module { singleOf(::OrganizationService) singleOf(::RBACService) singleOf(::DocumentService) + singleOf(::CommonAreaService) } diff --git a/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/service/CommonAreaService.kt b/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/service/CommonAreaService.kt new file mode 100644 index 000000000..1075d6132 --- /dev/null +++ b/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/service/CommonAreaService.kt @@ -0,0 +1,81 @@ +package com.cramsan.edifikana.server.service + +import com.cramsan.edifikana.lib.model.CommonAreaId +import com.cramsan.edifikana.lib.model.CommonAreaType +import com.cramsan.edifikana.lib.model.OrganizationId +import com.cramsan.edifikana.lib.model.PropertyId +import com.cramsan.edifikana.server.datastore.CommonAreaDatastore +import com.cramsan.edifikana.server.service.models.CommonArea +import com.cramsan.framework.logging.logD + +/** + * Service for managing common areas. Delegates persistence to [CommonAreaDatastore]. + */ +class CommonAreaService( + private val commonAreaDatastore: CommonAreaDatastore, +) { + + /** + * Creates a new common area record. + */ + suspend fun createCommonArea( + propertyId: PropertyId, + name: String, + type: CommonAreaType, + description: String?, + ): CommonArea { + logD(TAG, "createCommonArea") + return commonAreaDatastore.createCommonArea( + propertyId = propertyId, + name = name, + type = type, + description = description, + ).getOrThrow() + } + + /** + * Retrieves a single common area by [commonAreaId]. Returns null if not found. + */ + suspend fun getCommonArea(commonAreaId: CommonAreaId): CommonArea? { + logD(TAG, "getCommonArea") + return commonAreaDatastore.getCommonArea(commonAreaId).getOrNull() + } + + /** + * Lists all common areas for the given [propertyId]. + */ + suspend fun getCommonAreasForProperty(propertyId: PropertyId): List { + logD(TAG, "getCommonAreasForProperty") + return commonAreaDatastore.getCommonAreasForProperty(propertyId).getOrThrow() + } + + /** + * Updates an existing common area. Returns the updated [CommonArea]. + */ + suspend fun updateCommonArea( + commonAreaId: CommonAreaId, + name: String?, + type: CommonAreaType?, + description: String?, + ): CommonArea { + logD(TAG, "updateCommonArea") + return commonAreaDatastore.updateCommonArea( + commonAreaId = commonAreaId, + name = name, + type = type, + description = description, + ).getOrThrow() + } + + /** + * Soft-deletes a common area. Returns true if successfully deleted. + */ + suspend fun deleteCommonArea(commonAreaId: CommonAreaId): Boolean { + logD(TAG, "deleteCommonArea") + return commonAreaDatastore.deleteCommonArea(commonAreaId).getOrThrow() + } + + companion object { + private const val TAG = "CommonAreaService" + } +} diff --git a/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/service/authorization/RBACService.kt b/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/service/authorization/RBACService.kt index 02b29a1e1..2a4749143 100644 --- a/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/service/authorization/RBACService.kt +++ b/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/service/authorization/RBACService.kt @@ -1,5 +1,6 @@ package com.cramsan.edifikana.server.service.authorization +import com.cramsan.edifikana.lib.model.CommonAreaId import com.cramsan.edifikana.lib.model.DocumentId import com.cramsan.edifikana.lib.model.EmployeeId import com.cramsan.edifikana.lib.model.EventLogEntryId @@ -8,6 +9,7 @@ import com.cramsan.edifikana.lib.model.PropertyId import com.cramsan.edifikana.lib.model.TimeCardEventId import com.cramsan.edifikana.lib.model.UserId import com.cramsan.edifikana.server.controller.authentication.SupabaseContextPayload +import com.cramsan.edifikana.server.datastore.CommonAreaDatastore import com.cramsan.edifikana.server.datastore.DocumentDatastore import com.cramsan.edifikana.server.datastore.EmployeeDatastore import com.cramsan.edifikana.server.datastore.EventLogDatastore @@ -31,6 +33,7 @@ class RBACService( private val timeCardDatastore: TimeCardDatastore, private val eventLogDatastore: EventLogDatastore, private val documentDatastore: DocumentDatastore, + private val commonAreaDatastore: CommonAreaDatastore, ) { private val propertyNotFoundException = "ERROR: PROPERTY NOT FOUND!" @@ -260,6 +263,53 @@ class RBACService( return getUserRoleForOrganizationAction(context, document.orgId) } + /** + * Checks if the user has the required role to perform actions on the target common area. + * + * @param context The authenticated client context containing user information. + * @param targetCommonAreaId The ID of the target common area on which the action is to be performed. + * @param requiredRole The role required to perform the action. + * @return True if the user has the required role, false otherwise. + */ + suspend fun hasRole( + context: ClientContext.AuthenticatedClientContext, + targetCommonAreaId: CommonAreaId, + requiredRole: UserRole, + ): Boolean { + val userRole = getUserRoleForCommonAreaAction(context, targetCommonAreaId) + return userRole == requiredRole + } + + /** + * Checks if the user has the required role or higher to perform actions on the target common area. + * + * @param context The authenticated client context containing user information. + * @param targetCommonAreaId The ID of the target common area on which the action is to be performed. + * @param requiredRole The minimum role required to perform the action. + * @return True if the user has the required role or higher, false otherwise. + */ + suspend fun hasRoleOrHigher( + context: ClientContext.AuthenticatedClientContext, + targetCommonAreaId: CommonAreaId, + requiredRole: UserRole, + ): Boolean { + val userRole = getUserRoleForCommonAreaAction(context, targetCommonAreaId) + return userRole.level <= requiredRole.level + } + + /** + * Retrieves the user role for the action being performed on the target common area. + * Resolves through the common area's property, since common areas are property-level resources. + */ + private suspend fun getUserRoleForCommonAreaAction( + context: ClientContext.AuthenticatedClientContext, + targetCommonAreaId: CommonAreaId, + ): UserRole { + val commonArea = commonAreaDatastore.getCommonArea(targetCommonAreaId).getOrThrow() + ?: return UserRole.UNAUTHORIZED + return getUserRoleForPropertyAction(context, commonArea.propertyId) + } + /** * Checks if the user has the required role to perform actions on the target employee. * diff --git a/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/service/models/CommonArea.kt b/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/service/models/CommonArea.kt new file mode 100644 index 000000000..ca73997e5 --- /dev/null +++ b/edifikana/back-end/src/main/kotlin/com/cramsan/edifikana/server/service/models/CommonArea.kt @@ -0,0 +1,21 @@ +package com.cramsan.edifikana.server.service.models + +import com.cramsan.edifikana.lib.model.CommonAreaId +import com.cramsan.edifikana.lib.model.CommonAreaType +import com.cramsan.edifikana.lib.model.OrganizationId +import com.cramsan.edifikana.lib.model.PropertyId +import kotlin.time.ExperimentalTime +import kotlin.time.Instant + +/** + * Domain model representing a common area within a property (e.g. Lobby, Pool, Gym). + */ +@OptIn(ExperimentalTime::class) +data class CommonArea( + val id: CommonAreaId, + val propertyId: PropertyId, + val name: String, + val type: CommonAreaType, + val description: String?, + val createdAt: Instant, +) diff --git a/edifikana/back-end/src/test/kotlin/com/cramsan/edifikana/server/controller/CommonAreaControllerTest.kt b/edifikana/back-end/src/test/kotlin/com/cramsan/edifikana/server/controller/CommonAreaControllerTest.kt new file mode 100644 index 000000000..483a19b4f --- /dev/null +++ b/edifikana/back-end/src/test/kotlin/com/cramsan/edifikana/server/controller/CommonAreaControllerTest.kt @@ -0,0 +1,416 @@ +package com.cramsan.edifikana.server.controller + +import com.cramsan.architecture.server.test.startTestKoin +import com.cramsan.architecture.server.test.testBackEndApplication +import com.cramsan.edifikana.lib.model.CommonAreaId +import com.cramsan.edifikana.lib.model.CommonAreaType +import com.cramsan.edifikana.lib.model.PropertyId +import com.cramsan.edifikana.lib.model.UserId +import com.cramsan.edifikana.lib.serialization.createJson +import com.cramsan.edifikana.server.controller.authentication.SupabaseContextPayload +import com.cramsan.edifikana.server.dependencyinjection.TestControllerModule +import com.cramsan.edifikana.server.dependencyinjection.TestServiceModule +import com.cramsan.edifikana.server.dependencyinjection.testApplicationModule +import com.cramsan.edifikana.server.service.CommonAreaService +import com.cramsan.edifikana.server.service.authorization.RBACService +import com.cramsan.edifikana.server.service.models.CommonArea +import com.cramsan.edifikana.server.service.models.UserRole +import com.cramsan.edifikana.server.utils.readFileContent +import com.cramsan.framework.core.ktor.auth.ClientContext +import com.cramsan.framework.core.ktor.auth.ContextRetriever +import com.cramsan.framework.test.CoroutineTest +import io.ktor.client.request.delete +import io.ktor.client.request.get +import io.ktor.client.request.post +import io.ktor.client.request.put +import io.ktor.client.request.setBody +import io.ktor.client.statement.bodyAsText +import io.ktor.http.ContentType +import io.ktor.http.HttpStatusCode +import io.ktor.http.contentType +import io.mockk.Called +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlin.time.ExperimentalTime +import kotlin.time.Instant +import org.koin.core.context.stopKoin +import org.koin.test.KoinTest +import org.koin.test.get +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals + +@OptIn(ExperimentalTime::class) +class CommonAreaControllerTest : CoroutineTest(), KoinTest { + + @BeforeTest + fun setupTest() { + startTestKoin( + testApplicationModule(createJson()), + TestControllerModule, + TestServiceModule, + ) + } + + @AfterTest + fun cleanUp() { + stopKoin() + } + + // ------------------------------------------------------------------------- + // createCommonArea + // ------------------------------------------------------------------------- + + @Test + fun `test createCommonArea succeeds when user has MANAGER role`() = testBackEndApplication { + // Arrange + val requestBody = readFileContent("requests/create_common_area_request.json") + val expectedResponse = readFileContent("requests/create_common_area_response.json") + val commonAreaService = get() + val rbacService = get() + coEvery { + commonAreaService.createCommonArea( + propertyId = PropertyId("property123"), + name = "Main Lobby", + type = CommonAreaType.LOBBY, + description = "Main entrance lobby", + ) + }.answers { + commonArea(CommonAreaId("area123"), 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, PropertyId("property123"), UserRole.MANAGER) } returns true + + // Act + val response = client.post("common-area") { + setBody(requestBody) + contentType(ContentType.Application.Json) + } + + // Assert + assertEquals(HttpStatusCode.OK, response.status) + assertEquals(expectedResponse, response.bodyAsText()) + } + + @Test + fun `test createCommonArea fails when user lacks required role`() = testBackEndApplication { + // Arrange + val requestBody = readFileContent("requests/create_common_area_request.json") + val commonAreaService = get() + val rbacService = get() + val expectedResponse = "You are not authorized to perform this action in your organization." + val contextRetriever = get>() + val context = ClientContext.AuthenticatedClientContext( + SupabaseContextPayload( + userInfo = mockk(), + userId = UserId("user123"), + ) + ) + coEvery { contextRetriever.getContext(any()) } returns context + coEvery { rbacService.hasRoleOrHigher(context, PropertyId("property123"), UserRole.MANAGER) } returns false + + // Act + val response = client.post("common-area") { + setBody(requestBody) + contentType(ContentType.Application.Json) + } + + // Assert + coVerify { commonAreaService wasNot Called } + assertEquals(HttpStatusCode.Unauthorized, response.status) + assertEquals(expectedResponse, response.bodyAsText()) + } + + // ------------------------------------------------------------------------- + // getCommonArea + // ------------------------------------------------------------------------- + + @Test + fun `test getCommonArea returns 200 when found and user has MANAGER role`() = testBackEndApplication { + // Arrange + val expectedResponse = readFileContent("requests/get_common_area_response.json") + val commonAreaService = get() + val rbacService = get() + val areaId = CommonAreaId("area123") + coEvery { commonAreaService.getCommonArea(areaId) }.answers { + commonArea(areaId, 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, areaId, UserRole.MANAGER) } returns true + + // Act + val response = client.get("common-area/area123") + + // Assert + assertEquals(HttpStatusCode.OK, response.status) + assertEquals(expectedResponse, response.bodyAsText()) + } + + @Test + fun `test getCommonArea returns 404 when area is not found`() = testBackEndApplication { + // Arrange + val commonAreaService = get() + val rbacService = get() + val areaId = CommonAreaId("area123") + coEvery { commonAreaService.getCommonArea(areaId) } returns null + val contextRetriever = get>() + val context = ClientContext.AuthenticatedClientContext( + SupabaseContextPayload( + userInfo = mockk(), + userId = UserId("user123"), + ) + ) + coEvery { contextRetriever.getContext(any()) } returns context + coEvery { rbacService.hasRoleOrHigher(context, areaId, UserRole.MANAGER) } returns true + + // Act + val response = client.get("common-area/area123") + + // Assert + assertEquals(HttpStatusCode.NotFound, response.status) + } + + @Test + fun `test getCommonArea returns 404 when user is unauthorized`() = testBackEndApplication { + // Arrange + val commonAreaService = get() + val rbacService = get() + val areaId = CommonAreaId("area123") + val contextRetriever = get>() + val context = ClientContext.AuthenticatedClientContext( + SupabaseContextPayload( + userInfo = mockk(), + userId = UserId("user123"), + ) + ) + coEvery { contextRetriever.getContext(any()) } returns context + coEvery { rbacService.hasRoleOrHigher(context, areaId, UserRole.MANAGER) } returns false + + // Act + val response = client.get("common-area/area123") + + // Assert + coVerify { commonAreaService wasNot Called } + assertEquals(HttpStatusCode.NotFound, response.status) + } + + // ------------------------------------------------------------------------- + // getCommonAreasForProperty + // ------------------------------------------------------------------------- + + @Test + fun `test getCommonAreasForProperty succeeds when user has MANAGER role`() = testBackEndApplication { + // Arrange + val expectedResponse = readFileContent("requests/get_common_areas_for_property_response.json") + val commonAreaService = get() + val rbacService = get() + val propertyId = PropertyId("property123") + coEvery { commonAreaService.getCommonAreasForProperty(propertyId) }.answers { + listOf(commonArea(CommonAreaId("area123"), propertyId)) + } + val contextRetriever = get>() + val context = ClientContext.AuthenticatedClientContext( + SupabaseContextPayload( + userInfo = mockk(), + userId = UserId("user123"), + ) + ) + coEvery { contextRetriever.getContext(any()) } returns context + coEvery { rbacService.hasRoleOrHigher(context, propertyId, UserRole.MANAGER) } returns true + + // Act + val response = client.get("common-area/by-property/property123") + + // Assert + assertEquals(HttpStatusCode.OK, response.status) + assertEquals(expectedResponse, response.bodyAsText()) + } + + @Test + fun `test getCommonAreasForProperty fails when user is unauthorized`() = testBackEndApplication { + // Arrange + val commonAreaService = get() + val rbacService = get() + val propertyId = PropertyId("property123") + val expectedResponse = "You are not authorized to perform this action in your organization." + val contextRetriever = get>() + val context = ClientContext.AuthenticatedClientContext( + SupabaseContextPayload( + userInfo = mockk(), + userId = UserId("user123"), + ) + ) + coEvery { contextRetriever.getContext(any()) } returns context + coEvery { rbacService.hasRoleOrHigher(context, propertyId, UserRole.MANAGER) } returns false + + // Act + val response = client.get("common-area/by-property/property123") + + // Assert + coVerify { commonAreaService wasNot Called } + assertEquals(HttpStatusCode.Unauthorized, response.status) + assertEquals(expectedResponse, response.bodyAsText()) + } + + // ------------------------------------------------------------------------- + // updateCommonArea + // ------------------------------------------------------------------------- + + @Test + fun `test updateCommonArea succeeds when user has MANAGER role`() = testBackEndApplication { + // Arrange + val requestBody = readFileContent("requests/update_common_area_request.json") + val expectedResponse = readFileContent("requests/update_common_area_response.json") + val commonAreaService = get() + val rbacService = get() + val areaId = CommonAreaId("area123") + coEvery { + commonAreaService.updateCommonArea( + commonAreaId = areaId, + name = "Updated Lobby", + type = CommonAreaType.LOBBY, + description = null, + ) + }.answers { + commonArea(areaId, PropertyId("property123"), name = "Updated Lobby", description = null) + } + val contextRetriever = get>() + val context = ClientContext.AuthenticatedClientContext( + SupabaseContextPayload( + userInfo = mockk(), + userId = UserId("user123"), + ) + ) + coEvery { contextRetriever.getContext(any()) } returns context + coEvery { rbacService.hasRoleOrHigher(context, areaId, UserRole.MANAGER) } returns true + + // Act + val response = client.put("common-area/area123") { + setBody(requestBody) + contentType(ContentType.Application.Json) + } + + // Assert + assertEquals(HttpStatusCode.OK, response.status) + assertEquals(expectedResponse, response.bodyAsText()) + } + + @Test + fun `test updateCommonArea fails when user is unauthorized`() = testBackEndApplication { + // Arrange + val requestBody = readFileContent("requests/update_common_area_request.json") + val expectedResponse = "You are not authorized to perform this action in your organization." + val commonAreaService = get() + val rbacService = get() + val areaId = CommonAreaId("area123") + val contextRetriever = get>() + val context = ClientContext.AuthenticatedClientContext( + SupabaseContextPayload( + userInfo = mockk(), + userId = UserId("user123"), + ) + ) + coEvery { contextRetriever.getContext(any()) } returns context + coEvery { rbacService.hasRoleOrHigher(context, areaId, UserRole.MANAGER) } returns false + + // Act + val response = client.put("common-area/area123") { + setBody(requestBody) + contentType(ContentType.Application.Json) + } + + // Assert + coVerify { commonAreaService wasNot Called } + assertEquals(HttpStatusCode.Unauthorized, response.status) + assertEquals(expectedResponse, response.bodyAsText()) + } + + // ------------------------------------------------------------------------- + // deleteCommonArea + // ------------------------------------------------------------------------- + + @Test + fun `test deleteCommonArea succeeds when user has MANAGER role`() = testBackEndApplication { + // Arrange + val commonAreaService = get() + val rbacService = get() + val areaId = CommonAreaId("area123") + coEvery { commonAreaService.deleteCommonArea(areaId) } returns true + val contextRetriever = get>() + val context = ClientContext.AuthenticatedClientContext( + SupabaseContextPayload( + userInfo = mockk(), + userId = UserId("user123"), + ) + ) + coEvery { contextRetriever.getContext(any()) } returns context + coEvery { rbacService.hasRoleOrHigher(context, areaId, UserRole.MANAGER) } returns true + + // Act + val response = client.delete("common-area/area123") + + // Assert + assertEquals(HttpStatusCode.OK, response.status) + } + + @Test + fun `test deleteCommonArea fails when user is unauthorized`() = testBackEndApplication { + // Arrange + val expectedResponse = "You are not authorized to perform this action in your organization." + val commonAreaService = get() + val rbacService = get() + val areaId = CommonAreaId("area123") + val contextRetriever = get>() + val context = ClientContext.AuthenticatedClientContext( + SupabaseContextPayload( + userInfo = mockk(), + userId = UserId("user123"), + ) + ) + coEvery { contextRetriever.getContext(any()) } returns context + coEvery { rbacService.hasRoleOrHigher(context, areaId, UserRole.MANAGER) } returns false + + // Act + val response = client.delete("common-area/area123") + + // Assert + coVerify { commonAreaService wasNot Called } + assertEquals(HttpStatusCode.Unauthorized, response.status) + assertEquals(expectedResponse, response.bodyAsText()) + } + + // ------------------------------------------------------------------------- + // Private helpers + // ------------------------------------------------------------------------- + + private fun commonArea( + id: CommonAreaId, + propertyId: PropertyId, + name: String = "Main Lobby", + type: CommonAreaType = CommonAreaType.LOBBY, + description: String? = "Main entrance lobby", + ) = CommonArea( + id = id, + propertyId = propertyId, + name = name, + type = type, + description = description, + createdAt = Instant.fromEpochMilliseconds(0), + ) +} diff --git a/edifikana/back-end/src/test/kotlin/com/cramsan/edifikana/server/dependencyinjection/TestModule.kt b/edifikana/back-end/src/test/kotlin/com/cramsan/edifikana/server/dependencyinjection/TestModule.kt index 12dd7819c..45658cda3 100644 --- a/edifikana/back-end/src/test/kotlin/com/cramsan/edifikana/server/dependencyinjection/TestModule.kt +++ b/edifikana/back-end/src/test/kotlin/com/cramsan/edifikana/server/dependencyinjection/TestModule.kt @@ -1,5 +1,6 @@ package com.cramsan.edifikana.server.dependencyinjection +import com.cramsan.edifikana.server.controller.CommonAreaController import com.cramsan.edifikana.server.controller.EmployeeController import com.cramsan.edifikana.server.controller.EventLogController import com.cramsan.edifikana.server.controller.HealthCheckController @@ -10,6 +11,7 @@ import com.cramsan.edifikana.server.controller.PropertyController import com.cramsan.edifikana.server.controller.StorageController import com.cramsan.edifikana.server.controller.TimeCardController import com.cramsan.edifikana.server.controller.UserController +import com.cramsan.edifikana.server.service.CommonAreaService import com.cramsan.edifikana.server.service.EmployeeService import com.cramsan.edifikana.server.service.EventLogService import com.cramsan.edifikana.server.service.MembershipService @@ -42,6 +44,7 @@ internal val TestControllerModule = module { singleOf(::OrganizationController) { bind() } singleOf(::NotificationController) { bind() } singleOf(::MembershipController) { bind() } + singleOf(::CommonAreaController) { bind() } } /** @@ -58,6 +61,7 @@ internal val TestServiceModule = module { single { mockk() } single { mockk() } single { mockk() } + single { mockk() } } internal fun testApplicationModule(json: Json) = module { diff --git a/edifikana/back-end/src/test/kotlin/com/cramsan/edifikana/server/service/CommonAreaServiceTest.kt b/edifikana/back-end/src/test/kotlin/com/cramsan/edifikana/server/service/CommonAreaServiceTest.kt new file mode 100644 index 000000000..2bc01e2fc --- /dev/null +++ b/edifikana/back-end/src/test/kotlin/com/cramsan/edifikana/server/service/CommonAreaServiceTest.kt @@ -0,0 +1,226 @@ +@file:OptIn(ExperimentalTime::class) + +package com.cramsan.edifikana.server.service + +import com.cramsan.edifikana.lib.model.CommonAreaId +import com.cramsan.edifikana.lib.model.CommonAreaType +import com.cramsan.edifikana.lib.model.OrganizationId +import com.cramsan.edifikana.lib.model.PropertyId +import com.cramsan.edifikana.server.datastore.CommonAreaDatastore +import com.cramsan.edifikana.server.service.models.CommonArea +import com.cramsan.framework.logging.EventLogger +import com.cramsan.framework.logging.implementation.PassthroughEventLogger +import com.cramsan.framework.logging.implementation.StdOutEventLoggerDelegate +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.koin.core.context.stopKoin +import kotlin.test.AfterTest +import kotlin.time.ExperimentalTime +import kotlin.time.Instant + +/** + * Test class for [CommonAreaService]. + */ +@OptIn(ExperimentalTime::class) +class CommonAreaServiceTest { + + private lateinit var commonAreaDatastore: CommonAreaDatastore + private lateinit var commonAreaService: CommonAreaService + + /** + * Sets up the test environment by initializing mocks for [CommonAreaDatastore] and [CommonAreaService]. + */ + @BeforeEach + fun setUp() { + EventLogger.setInstance(PassthroughEventLogger(StdOutEventLoggerDelegate())) + commonAreaDatastore = mockk() + commonAreaService = CommonAreaService(commonAreaDatastore) + } + + /** + * Cleans up the test environment by stopping Koin. + */ + @AfterTest + fun cleanUp() { + stopKoin() + } + + // ------------------------------------------------------------------------- + // createCommonArea + // ------------------------------------------------------------------------- + + /** + * Tests that createCommonArea delegates to the datastore and returns the created area. + */ + @Test + fun `createCommonArea should delegate to datastore and return created area`() = runTest { + // Arrange + val propertyId = PropertyId("property123") + val name = "Main Lobby" + val type = CommonAreaType.LOBBY + val description = "Main entrance lobby" + val commonArea = commonArea(CommonAreaId("area123"), propertyId) + coEvery { + commonAreaDatastore.createCommonArea(propertyId, name, type, description) + } returns Result.success(commonArea) + + // Act + val result = commonAreaService.createCommonArea(propertyId, name, type, description) + + // Assert + assertEquals(commonArea, result) + coVerify { commonAreaDatastore.createCommonArea(propertyId, name, type, description) } + } + + // ------------------------------------------------------------------------- + // getCommonArea + // ------------------------------------------------------------------------- + + /** + * Tests that getCommonArea returns the common area when found. + */ + @Test + fun `getCommonArea should return area when found`() = runTest { + // Arrange + val commonAreaId = CommonAreaId("area123") + val commonArea = commonArea(commonAreaId, PropertyId("property123")) + coEvery { commonAreaDatastore.getCommonArea(commonAreaId) } returns Result.success(commonArea) + + // Act + val result = commonAreaService.getCommonArea(commonAreaId) + + // Assert + assertEquals(commonArea, result) + } + + /** + * Tests that getCommonArea returns null when the area is not found. + */ + @Test + fun `getCommonArea should return null when not found`() = runTest { + // Arrange + val commonAreaId = CommonAreaId("area123") + coEvery { commonAreaDatastore.getCommonArea(commonAreaId) } returns Result.success(null) + + // Act + val result = commonAreaService.getCommonArea(commonAreaId) + + // Assert + assertNull(result) + } + + // ------------------------------------------------------------------------- + // getCommonAreasForProperty + // ------------------------------------------------------------------------- + + /** + * Tests that getCommonAreasForProperty returns all areas for the given property. + */ + @Test + fun `getCommonAreasForProperty should return list from datastore`() = runTest { + // Arrange + val propertyId = PropertyId("property123") + val orgId = OrganizationId("org123") + val areas = listOf( + commonArea(CommonAreaId("area123"), propertyId), + commonArea(CommonAreaId("area456"), propertyId), + ) + coEvery { commonAreaDatastore.getCommonAreasForProperty(propertyId) } returns Result.success(areas) + + // Act + val result = commonAreaService.getCommonAreasForProperty(propertyId) + + // Assert + assertEquals(areas, result) + } + + // ------------------------------------------------------------------------- + // updateCommonArea + // ------------------------------------------------------------------------- + + /** + * Tests that updateCommonArea delegates to the datastore and returns the updated area. + */ + @Test + fun `updateCommonArea should delegate to datastore and return updated area`() = runTest { + // Arrange + val commonAreaId = CommonAreaId("area123") + val name = "Updated Lobby" + val type = CommonAreaType.LOBBY + val description: String? = null + val updatedArea = commonArea(commonAreaId, PropertyId("property123"), name = name) + coEvery { + commonAreaDatastore.updateCommonArea(commonAreaId, name, type, description) + } returns Result.success(updatedArea) + + // Act + val result = commonAreaService.updateCommonArea(commonAreaId, name, type, description) + + // Assert + assertEquals(updatedArea, result) + coVerify { commonAreaDatastore.updateCommonArea(commonAreaId, name, type, description) } + } + + // ------------------------------------------------------------------------- + // deleteCommonArea + // ------------------------------------------------------------------------- + + /** + * Tests that deleteCommonArea returns true when the area was successfully soft-deleted. + */ + @Test + fun `deleteCommonArea should return true when deleted`() = runTest { + // Arrange + val commonAreaId = CommonAreaId("area123") + coEvery { commonAreaDatastore.deleteCommonArea(commonAreaId) } returns Result.success(true) + + // Act + val result = commonAreaService.deleteCommonArea(commonAreaId) + + // Assert + assertTrue(result) + coVerify { commonAreaDatastore.deleteCommonArea(commonAreaId) } + } + + /** + * Tests that deleteCommonArea returns false when the area was not found. + */ + @Test + fun `deleteCommonArea should return false when not found`() = runTest { + // Arrange + val commonAreaId = CommonAreaId("area123") + coEvery { commonAreaDatastore.deleteCommonArea(commonAreaId) } returns Result.success(false) + + // Act + val result = commonAreaService.deleteCommonArea(commonAreaId) + + // Assert + org.junit.jupiter.api.Assertions.assertFalse(result) + } + + // ------------------------------------------------------------------------- + // Private helpers + // ------------------------------------------------------------------------- + + private fun commonArea( + id: CommonAreaId, + propertyId: PropertyId, + name: String = "Main Lobby", + type: CommonAreaType = CommonAreaType.LOBBY, + description: String? = "Main entrance lobby", + ) = CommonArea( + id = id, + propertyId = propertyId, + name = name, + type = type, + description = description, + createdAt = Instant.fromEpochMilliseconds(0), + ) +} diff --git a/edifikana/back-end/src/test/kotlin/com/cramsan/edifikana/server/service/authorization/RBACServiceTest.kt b/edifikana/back-end/src/test/kotlin/com/cramsan/edifikana/server/service/authorization/RBACServiceTest.kt index 4fdbb4263..fc949c0d2 100644 --- a/edifikana/back-end/src/test/kotlin/com/cramsan/edifikana/server/service/authorization/RBACServiceTest.kt +++ b/edifikana/back-end/src/test/kotlin/com/cramsan/edifikana/server/service/authorization/RBACServiceTest.kt @@ -6,6 +6,7 @@ import com.cramsan.edifikana.lib.model.OrganizationId import com.cramsan.edifikana.lib.model.PropertyId import com.cramsan.edifikana.lib.model.UserId import com.cramsan.edifikana.server.controller.authentication.SupabaseContextPayload +import com.cramsan.edifikana.server.datastore.CommonAreaDatastore import com.cramsan.edifikana.server.datastore.DocumentDatastore import com.cramsan.edifikana.server.datastore.EmployeeDatastore import com.cramsan.edifikana.server.datastore.EventLogDatastore @@ -44,6 +45,7 @@ class RBACServiceTest { private lateinit var timeCardDatastore: TimeCardDatastore private lateinit var eventLogDatastore: EventLogDatastore private lateinit var documentDatastore: DocumentDatastore + private lateinit var commonAreaDatastore: CommonAreaDatastore private lateinit var rbac: RBACService @BeforeEach @@ -55,6 +57,7 @@ class RBACServiceTest { timeCardDatastore = mockk() eventLogDatastore = mockk() documentDatastore = mockk() + commonAreaDatastore = mockk() rbac = RBACService( propertyDatastore, orgDatastore, @@ -62,6 +65,7 @@ class RBACServiceTest { timeCardDatastore, eventLogDatastore, documentDatastore, + commonAreaDatastore, ) } diff --git a/edifikana/back-end/src/test/resources/requests/create_common_area_request.json b/edifikana/back-end/src/test/resources/requests/create_common_area_request.json new file mode 100644 index 000000000..1291f04a8 --- /dev/null +++ b/edifikana/back-end/src/test/resources/requests/create_common_area_request.json @@ -0,0 +1,6 @@ +{ + "property_id": "property123", + "name": "Main Lobby", + "type": "LOBBY", + "description": "Main entrance lobby" +} diff --git a/edifikana/back-end/src/test/resources/requests/create_common_area_response.json b/edifikana/back-end/src/test/resources/requests/create_common_area_response.json new file mode 100644 index 000000000..e59f82614 --- /dev/null +++ b/edifikana/back-end/src/test/resources/requests/create_common_area_response.json @@ -0,0 +1,7 @@ +{ + "common_area_id": "area123", + "property_id": "property123", + "name": "Main Lobby", + "type": "LOBBY", + "description": "Main entrance lobby" +} diff --git a/edifikana/back-end/src/test/resources/requests/get_common_area_response.json b/edifikana/back-end/src/test/resources/requests/get_common_area_response.json new file mode 100644 index 000000000..e59f82614 --- /dev/null +++ b/edifikana/back-end/src/test/resources/requests/get_common_area_response.json @@ -0,0 +1,7 @@ +{ + "common_area_id": "area123", + "property_id": "property123", + "name": "Main Lobby", + "type": "LOBBY", + "description": "Main entrance lobby" +} diff --git a/edifikana/back-end/src/test/resources/requests/get_common_areas_for_property_response.json b/edifikana/back-end/src/test/resources/requests/get_common_areas_for_property_response.json new file mode 100644 index 000000000..76893da5d --- /dev/null +++ b/edifikana/back-end/src/test/resources/requests/get_common_areas_for_property_response.json @@ -0,0 +1,11 @@ +{ + "common_areas": [ + { + "common_area_id": "area123", + "property_id": "property123", + "name": "Main Lobby", + "type": "LOBBY", + "description": "Main entrance lobby" + } + ] +} diff --git a/edifikana/back-end/src/test/resources/requests/update_common_area_request.json b/edifikana/back-end/src/test/resources/requests/update_common_area_request.json new file mode 100644 index 000000000..937fac8b3 --- /dev/null +++ b/edifikana/back-end/src/test/resources/requests/update_common_area_request.json @@ -0,0 +1,5 @@ +{ + "name": "Updated Lobby", + "type": "LOBBY", + "description": null +} diff --git a/edifikana/back-end/src/test/resources/requests/update_common_area_response.json b/edifikana/back-end/src/test/resources/requests/update_common_area_response.json new file mode 100644 index 000000000..2e6a93041 --- /dev/null +++ b/edifikana/back-end/src/test/resources/requests/update_common_area_response.json @@ -0,0 +1,6 @@ +{ + "common_area_id": "area123", + "property_id": "property123", + "name": "Updated Lobby", + "type": "LOBBY" +} diff --git a/edifikana/back-end/supabase/migrations/20260310000001_create_tasks_table.sql b/edifikana/back-end/supabase/migrations/20260310000001_create_tasks_table.sql index 9bb90254b..8c9563b9b 100644 --- a/edifikana/back-end/supabase/migrations/20260310000001_create_tasks_table.sql +++ b/edifikana/back-end/supabase/migrations/20260310000001_create_tasks_table.sql @@ -1,4 +1,4 @@ --- ============================================================================ + -- ============================================================================ -- Migration: Create tasks table (Phase 0, DB-04) -- Issue: https://github.com/CodeHavenX/MonoRepo/issues/439 -- Depends on: 20260309000001_create_units_and_common_areas.sql diff --git a/edifikana/back-end/supabase/migrations/20260327000001_remove_org_id_from_units_common_areas_tasks.sql b/edifikana/back-end/supabase/migrations/20260327000001_remove_org_id_from_units_common_areas_tasks.sql new file mode 100644 index 000000000..479c82db0 --- /dev/null +++ b/edifikana/back-end/supabase/migrations/20260327000001_remove_org_id_from_units_common_areas_tasks.sql @@ -0,0 +1,24 @@ +-- ============================================================================ +-- Migration: Remove org_id from common_areas table +-- Issue: https://github.com/CodeHavenX/MonoRepo/issues/387 +-- ============================================================================ +-- This migration: +-- 1. Drops the org_id index from common_areas +-- 2. Drops the org_scoped_common_areas RLS policy (IF EXISTS — already removed +-- by 20260324000001_blanket_deny_rls_new_tables.sql; kept for safety) +-- 3. Removes the org_id column from common_areas +-- org membership is always derivable via property_id, making org_id redundant. +-- Authorization is enforced exclusively in the Kotlin BE layer (service role bypasses RLS). +-- +-- NOTE: org_id removal from units, tasks, unit_occupants, rent_config, and +-- payment_records is tracked separately — those tables have composite FK +-- dependencies that require a coordinated multi-table migration. +-- ============================================================================ + +DROP INDEX IF EXISTS idx_common_areas_org_id; +DROP POLICY IF EXISTS "org_scoped_common_areas" ON common_areas; +ALTER TABLE common_areas DROP COLUMN org_id; + +-- ============================================================================ +-- END OF MIGRATION +-- ============================================================================ diff --git a/edifikana/shared/src/commonMain/kotlin/com/cramsan/edifikana/lib/model/network/CommonAreaNetworkResponse.kt b/edifikana/shared/src/commonMain/kotlin/com/cramsan/edifikana/lib/model/network/CommonAreaNetworkResponse.kt index 62bfafb28..64199a60a 100644 --- a/edifikana/shared/src/commonMain/kotlin/com/cramsan/edifikana/lib/model/network/CommonAreaNetworkResponse.kt +++ b/edifikana/shared/src/commonMain/kotlin/com/cramsan/edifikana/lib/model/network/CommonAreaNetworkResponse.kt @@ -1,6 +1,7 @@ package com.cramsan.edifikana.lib.model.network import com.cramsan.edifikana.lib.model.CommonAreaId +import com.cramsan.edifikana.lib.model.CommonAreaType import com.cramsan.edifikana.lib.model.OrganizationId import com.cramsan.edifikana.lib.model.PropertyId import com.cramsan.framework.annotations.NetworkModel @@ -18,12 +19,10 @@ data class CommonAreaNetworkResponse( val commonAreaId: CommonAreaId, @SerialName("property_id") val propertyId: PropertyId, - @SerialName("org_id") - val orgId: OrganizationId, @SerialName("name") val name: String, @SerialName("type") - val type: String, + val type: CommonAreaType, @SerialName("description") val description: String? = null, ) : ResponseBody diff --git a/edifikana/shared/src/commonMain/kotlin/com/cramsan/edifikana/lib/model/network/CreateCommonAreaNetworkRequest.kt b/edifikana/shared/src/commonMain/kotlin/com/cramsan/edifikana/lib/model/network/CreateCommonAreaNetworkRequest.kt index 0e182c81b..de8a45560 100644 --- a/edifikana/shared/src/commonMain/kotlin/com/cramsan/edifikana/lib/model/network/CreateCommonAreaNetworkRequest.kt +++ b/edifikana/shared/src/commonMain/kotlin/com/cramsan/edifikana/lib/model/network/CreateCommonAreaNetworkRequest.kt @@ -1,5 +1,6 @@ package com.cramsan.edifikana.lib.model.network +import com.cramsan.edifikana.lib.model.CommonAreaType import com.cramsan.edifikana.lib.model.OrganizationId import com.cramsan.edifikana.lib.model.PropertyId import com.cramsan.framework.annotations.NetworkModel @@ -15,12 +16,10 @@ import kotlinx.serialization.Serializable data class CreateCommonAreaNetworkRequest( @SerialName("property_id") val propertyId: PropertyId, - @SerialName("org_id") - val orgId: OrganizationId, @SerialName("name") val name: String, @SerialName("type") - val type: String, + val type: CommonAreaType, @SerialName("description") val description: String? = null, ) : RequestBody diff --git a/edifikana/shared/src/commonMain/kotlin/com/cramsan/edifikana/lib/model/network/UpdateCommonAreaNetworkRequest.kt b/edifikana/shared/src/commonMain/kotlin/com/cramsan/edifikana/lib/model/network/UpdateCommonAreaNetworkRequest.kt index 2764a84e5..e138daa4a 100644 --- a/edifikana/shared/src/commonMain/kotlin/com/cramsan/edifikana/lib/model/network/UpdateCommonAreaNetworkRequest.kt +++ b/edifikana/shared/src/commonMain/kotlin/com/cramsan/edifikana/lib/model/network/UpdateCommonAreaNetworkRequest.kt @@ -1,5 +1,6 @@ package com.cramsan.edifikana.lib.model.network +import com.cramsan.edifikana.lib.model.CommonAreaType import com.cramsan.framework.annotations.NetworkModel import com.cramsan.framework.annotations.api.RequestBody import kotlinx.serialization.SerialName @@ -14,7 +15,7 @@ data class UpdateCommonAreaNetworkRequest( @SerialName("name") val name: String? = null, @SerialName("type") - val type: String? = null, + val type: CommonAreaType? = null, @SerialName("description") val description: String? = null, ) : RequestBody