Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ 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.framework.annotations.api.NoPathParam
import com.cramsan.framework.annotations.api.NoQueryParam
Expand Down Expand Up @@ -64,6 +65,22 @@ object EmployeeApi : Api("employee") {
responses = UniversalResponsesOnly,
)

val getEmployeesForProperty =
operation<
NoRequestBody,
GetEmployeesForPropertyQueryParams,
NoPathParam,
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,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
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
Expand Down Expand Up @@ -49,16 +50,18 @@ object PropertyApi : Api("property") {
},
)

val getAssignedProperties =
val getProperties =
operation<
NoRequestBody,
NoQueryParam,
GetPropertiesQueryParams,
NoPathParam,
PropertyListNetworkResponse,
>(
method = HttpMethod.Get,
summary = "List assigned properties",
description = "Returns all properties the authenticated user has been assigned access to.",
summary = "List properties",
description =
"Returns all properties in the organization given by the query params. " +
"Requires membership in the organization.",
responses = UniversalResponsesOnly,
)

Expand Down
62 changes: 60 additions & 2 deletions edifikana/back-end/docs/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2837,6 +2837,54 @@
]
}
},
"/employee/by-property": {
"get": {
"operationId": "get-employee-by-property",
"tags": [
"employee"
],
"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": "property_id",
"in": "query",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Successful response.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/EmployeeListNetworkResponse"
}
}
}
},
"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": [

]
}
]
}
},
"/property": {
"post": {
"operationId": "post-property",
Expand Down Expand Up @@ -2889,8 +2937,18 @@
"tags": [
"property"
],
"summary": "List assigned properties",
"description": "Returns all properties the authenticated user has been assigned access to.",
"summary": "List properties",
"description": "Returns all properties in the organization given by the query params. Requires membership in the organization.",
"parameters": [
{
"name": "organization_id",
"in": "query",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Successful response.",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ 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.server.controller.authentication.SupabaseContextPayload
import com.cramsan.edifikana.server.service.EmployeeService
Expand Down Expand Up @@ -108,6 +109,29 @@ 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,
GetEmployeesForPropertyQueryParams,
NoPathParam,
ClientContext.AuthenticatedClientContext<SupabaseContextPayload>,
>,
): EmployeeListNetworkResponse {
val propertyId = request.queryParam.propertyId
if (!rbacService.hasRoleOrHigher(request.context, propertyId, UserRole.MANAGER)) {
throw UnauthorizedException(unauthorizedMsg)
}
val employees =
employeeService.getEmployeesForProperty(propertyId).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.
Expand Down Expand Up @@ -195,6 +219,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)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ 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
Expand Down Expand Up @@ -80,20 +81,24 @@ 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,
GetPropertiesQueryParams,
NoPathParam,
ClientContext.AuthenticatedClientContext<SupabaseContextPayload>,
>,
): PropertyListNetworkResponse {
val userId = request.context.payload.userId
val properties = propertyService.getProperties(userId = userId).map { it.toPropertyNetworkResponse() }
val organizationId = request.queryParam.organizationId
if (!rbacService.hasRoleOrHigher(request.context, organizationId, UserRole.EMPLOYEE)) {
throw UnauthorizedException(unauthorizedMsg)
}
val properties = propertyService.getProperties(organizationId).map { it.toPropertyNetworkResponse() }
return PropertyListNetworkResponse(properties)
}

Expand Down Expand Up @@ -167,8 +172,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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,14 @@ interface EmployeeDatastore {
currentUser: UserId,
): Result<List<Employee>>

/**
* 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<List<Employee>>

/**
* Updates an employee member with the given details. Returns the [Result] of the operation with the updated [Employee].
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,10 @@ interface PropertyDatastore {
): Result<Property?>

/**
* 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<List<Property>>

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<List<Employee>> =
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<EmployeeEntity>()
.map { it.toEmployee() }
}

/**
* Updates an employee's properties. Only non-null parameters are updated.
*/
Expand Down
Loading