From 74bc5bef27545e7bc90f176269f5ee38ed168027 Mon Sep 17 00:00:00 2001 From: simonredfern Date: Sat, 18 Apr 2026 07:51:54 +0200 Subject: [PATCH 1/6] GET Users v6.0.0 --- .../SwaggerDefinitionsJSON.scala | 29 +++- .../main/scala/code/api/util/APIUtil.scala | 18 ++- .../main/scala/code/api/util/OBPParam.scala | 3 + .../scala/code/api/v6_0_0/APIMethods600.scala | 27 +++- .../code/api/v6_0_0/JSONFactory6.0.0.scala | 76 ++++++++-- .../scala/code/api/v7_0_0/Http4s700.scala | 4 +- .../scala/code/users/DoobieUserQueries.scala | 141 ++++++++++++++++++ .../src/main/scala/code/users/LiftUsers.scala | 61 +++++++- obp-api/src/main/scala/code/users/Users.scala | 11 ++ 9 files changed, 338 insertions(+), 32 deletions(-) create mode 100644 obp-api/src/main/scala/code/users/DoobieUserQueries.scala diff --git a/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/SwaggerDefinitionsJSON.scala b/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/SwaggerDefinitionsJSON.scala index 2771c7ce76..10959b48e8 100644 --- a/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/SwaggerDefinitionsJSON.scala +++ b/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/SwaggerDefinitionsJSON.scala @@ -2643,14 +2643,38 @@ object SwaggerDefinitionsJSON { is_deleted = false, last_marketing_agreement_signed_date = Some(DateWithDayExampleObject), is_locked = false, - last_activity_date = Some(DateWithDayExampleObject), - recent_operation_ids = List("obp.getBank", "obp.getAccounts", "obp.getTransactions", "obp.getUser", "obp.getCustomer") + created_date = Some(DateWithDayExampleObject), + updated_date = Some(DateWithDayExampleObject), + email_validated = Some(true), + last_used_locale = Some("en_GB") ) lazy val usersInfoJsonV600 = UsersInfoJsonV600( users = List(userInfoJsonV600) ) + lazy val userInfoDetailJsonV600 = UserInfoDetailJsonV600( + user_id = ExampleValue.userIdExample.value, + email = ExampleValue.emailExample.value, + provider_id = providerIdValueExample.value, + provider = providerValueExample.value, + username = usernameExample.value, + first_name = ExampleValue.firstNameExample.value, + last_name = ExampleValue.lastNameExample.value, + entitlements = entitlementJSONs, + views = Some(viewsJSON300), + agreements = Some(List(userAgreementJson)), + is_deleted = false, + last_marketing_agreement_signed_date = Some(DateWithDayExampleObject), + is_locked = false, + created_date = Some(DateWithDayExampleObject), + updated_date = Some(DateWithDayExampleObject), + email_validated = Some(true), + last_used_locale = Some("en_GB"), + last_activity_date = Some(DateWithDayExampleObject), + recent_operation_ids = List("obp.getBank", "obp.getAccounts", "obp.getTransactions", "obp.getUser", "obp.getCustomer") + ) + lazy val userWithNamesJsonV510 = UserWithNamesJsonV510( user_id = ExampleValue.userIdExample.value, email = ExampleValue.emailExample.value, @@ -2667,6 +2691,7 @@ object SwaggerDefinitionsJSON { is_locked = false ) + lazy val migrationScriptLogJsonV600 = MigrationScriptLogJsonV600( migration_script_log_id = "550e8400-e29b-41d4-a716-446655440000", name = "addUniqueIndexOnResourceUserUserId", diff --git a/obp-api/src/main/scala/code/api/util/APIUtil.scala b/obp-api/src/main/scala/code/api/util/APIUtil.scala index bb8544adfa..58da796c36 100644 --- a/obp-api/src/main/scala/code/api/util/APIUtil.scala +++ b/obp-api/src/main/scala/code/api/util/APIUtil.scala @@ -1220,6 +1220,9 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{ case "customer_id" => Full(OBPCustomerId(values.head)) case "locked_status" => Full(OBPLockedStatus(values.head)) case "role_name" => Full(OBPRoleName(values.head)) + case "provider" => Full(OBPProvider(values.head)) + case "username" => Full(OBPUsername(values.head)) + case "email" => Full(OBPEmail(values.head)) case _ => Full(OBPEmpty()) } } yield @@ -1269,6 +1272,9 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{ customerId <- getHttpParamValuesByName(httpParams, "customer_id") lockedStatus <- getHttpParamValuesByName(httpParams, "locked_status") roleName <- getHttpParamValuesByName(httpParams, "role_name") + provider <- getHttpParamValuesByName(httpParams, "provider") + username <- getHttpParamValuesByName(httpParams, "username") + email <- getHttpParamValuesByName(httpParams, "email") httpStatusCode <- getHttpParamValuesByName(httpParams, "http_status_code") }yield{ // Extract the sort field name from the sort_by query param (e.g. "url", "date"). @@ -1282,8 +1288,8 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{ List(limit, offset, ordering, sortBy, fromDate, toDate, anon, status, consumerId, azp, iss, consentId, userId, providerProviderId, url, appName, implementedByPartialFunction, implementedInVersion, verb, correlationId, duration, httpStatusCode, excludeAppNames, excludeUrlPattern, excludeImplementedByPartialfunctions, - includeAppNames, includeUrlPattern, includeImplementedByPartialfunctions, - connectorName,functionName, bankId, accountId, customerId, lockedStatus, roleName, deletedStatus + includeAppNames, includeUrlPattern, includeImplementedByPartialfunctions, + connectorName,functionName, bankId, accountId, customerId, lockedStatus, roleName, provider, username, email, deletedStatus ).filter(_ != OBPEmpty()) } } @@ -1338,6 +1344,9 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{ val customerId = getHttpRequestUrlParam(httpRequestUrl, "customer_id") val lockedStatus = getHttpRequestUrlParam(httpRequestUrl, "locked_status") val roleName = getHttpRequestUrlParam(httpRequestUrl, "role_name") + val provider = getHttpRequestUrlParam(httpRequestUrl, "provider") + val username = getHttpRequestUrlParam(httpRequestUrl, "username") + val email = getHttpRequestUrlParam(httpRequestUrl, "email") //The following three are not a string, it should be List of String //eg: exclude_app_names=A,B,C --> List(A,B,C) @@ -1371,7 +1380,10 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{ HTTPParam("customer_id", customerId), HTTPParam("is_deleted", isDeleted), HTTPParam("locked_status", lockedStatus), - HTTPParam("role_name", roleName) + HTTPParam("role_name", roleName), + HTTPParam("provider", provider), + HTTPParam("username", username), + HTTPParam("email", email) ).filter(_.values.head != ""))//Here filter the field when value = "". } diff --git a/obp-api/src/main/scala/code/api/util/OBPParam.scala b/obp-api/src/main/scala/code/api/util/OBPParam.scala index 4a8cf115ba..2e20724939 100644 --- a/obp-api/src/main/scala/code/api/util/OBPParam.scala +++ b/obp-api/src/main/scala/code/api/util/OBPParam.scala @@ -57,6 +57,9 @@ case class OBPCustomerId(value: String) extends OBPQueryParam case class OBPLockedStatus(value: String) extends OBPQueryParam case class OBPIsDeleted(value: Boolean) extends OBPQueryParam case class OBPRoleName(value: String) extends OBPQueryParam +case class OBPProvider(value: String) extends OBPQueryParam +case class OBPUsername(value: String) extends OBPQueryParam +case class OBPEmail(value: String) extends OBPQueryParam object OBPQueryParam { val LIMIT = "limit" diff --git a/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala b/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala index 22ee0b16b4..f2e651e8b9 100644 --- a/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala +++ b/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala @@ -1775,18 +1775,26 @@ trait APIMethods600 { "GET", "/users", "Get all Users", - s"""Get all users + s"""Get all users, optionally filtered. + | + |All query parameters are optional and may be combined. | |${userAuthenticationMessage(true)} | - |CanGetAnyUser entitlement is required, + |CanGetAnyUser entitlement is required. | |${urlParametersDocument(false, false)} - |* locked_status (if null ignore) + |* provider (if null ignore) - filter by identity provider, exact match + |* username (if null ignore) - filter by username, exact match + |* email (if null ignore) - filter by email, exact match (may return multiple users — duplicate emails are allowed in OBP by design) + |* user_id (if null ignore) - filter by user_id, exact match + |* locked_status (if null ignore) - "active" or "locked" |* is_deleted (default: false) |* role_name (if null ignore) - filter by entitlement/role name e.g. CanCreateAccount |* bank_id (if null ignore) - when used with role_name, filter entitlements by bank_id | + |Returns an empty list (not 404) when no users match. + | """.stripMargin, EmptyBody, usersInfoJsonV600, @@ -1802,15 +1810,20 @@ trait APIMethods600 { lazy val getUsers: OBPEndpoint = { case "users" :: Nil JsonGet _ => { cc => implicit val ec = EndpointContext(Some(cc)) + logger.info(s"getUsers says: GET /users called, url=${cc.url}") for { + (Full(u), callContext) <- authenticatedAccess(cc) + _ = logger.info(s"getUsers says: authenticated user_id=${u.userId} provider=${u.provider}") + _ <- NewStyle.function.hasEntitlement("", u.userId, ApiRole.canGetAnyUser, callContext) httpParams <- NewStyle.function.extractHttpParamsFromUrl(cc.url) (obpQueryParams, callContext) <- createQueriesByHttpParamsFuture( httpParams, - cc.callContext + callContext ) - users <- code.users.Users.users.vend.getUsers(obpQueryParams) + rows <- code.users.Users.users.vend.getUsersV600F(obpQueryParams) + _ = logger.info(s"getUsers says: returning ${rows.size} user(s) to user_id=${u.userId}") } yield { - (JSONFactory600.createUsersInfoJsonV600(users), HttpCode.`200`(callContext)) + (JSONFactory600.createUsersInfoJsonV600(rows), HttpCode.`200`(callContext)) } } } @@ -1830,7 +1843,7 @@ trait APIMethods600 { | """.stripMargin, EmptyBody, - userInfoJsonV600, + userInfoDetailJsonV600, List( $AuthenticatedUserIsRequired, UserHasMissingRoles, diff --git a/obp-api/src/main/scala/code/api/v6_0_0/JSONFactory6.0.0.scala b/obp-api/src/main/scala/code/api/v6_0_0/JSONFactory6.0.0.scala index ced72459ea..227f6c9f73 100644 --- a/obp-api/src/main/scala/code/api/v6_0_0/JSONFactory6.0.0.scala +++ b/obp-api/src/main/scala/code/api/v6_0_0/JSONFactory6.0.0.scala @@ -305,12 +305,36 @@ case class UserInfoJsonV600( is_deleted: Boolean, last_marketing_agreement_signed_date: Option[Date], is_locked: Boolean, - last_activity_date: Option[Date], - recent_operation_ids: List[String] + created_date: Option[Date], + updated_date: Option[Date], + email_validated: Option[Boolean], + last_used_locale: Option[String] ) case class UsersInfoJsonV600(users: List[UserInfoJsonV600]) +case class UserInfoDetailJsonV600( + user_id: String, + email: String, + provider_id: String, + provider: String, + username: String, + first_name: String, + last_name: String, + entitlements: EntitlementJSONs, + views: Option[ViewsJSON300], + agreements: Option[List[UserAgreementJson]], + is_deleted: Boolean, + last_marketing_agreement_signed_date: Option[Date], + is_locked: Boolean, + created_date: Option[Date], + updated_date: Option[Date], + email_validated: Option[Boolean], + last_used_locale: Option[String], + last_activity_date: Option[Date], + recent_operation_ids: List[String] +) + case class CreateUserJsonV600( email: String, username: String, @@ -1389,8 +1413,9 @@ object JSONFactory600 extends CustomJsonFormats with MdcLoggable { isLocked: Boolean, lastActivityDate: Option[Date], recentOperationIds: List[String] - ): UserInfoJsonV600 = { - UserInfoJsonV600( + ): UserInfoDetailJsonV600 = { + val authUser = AuthUser.find(By(AuthUser.user, user.userPrimaryKey.value)) + UserInfoDetailJsonV600( user_id = user.userId, email = user.emailAddress, username = stringOrNull(user.name), @@ -1409,28 +1434,47 @@ object JSONFactory600 extends CustomJsonFormats with MdcLoggable { last_marketing_agreement_signed_date = user.lastMarketingAgreementSignedDate, is_locked = isLocked, + created_date = authUser.map(_.createdAt.get), + updated_date = authUser.map(_.updatedAt.get), + email_validated = authUser.map(_.validated.get), + last_used_locale = user.lastUsedLocale, last_activity_date = lastActivityDate, recent_operation_ids = recentOperationIds ) } + /** + * Build UsersInfoJsonV600 from Doobie-joined rows (single-SQL path). + * + * The LEFT JOIN in DoobieUserQueries.searchUsers already gave us + * first_name / last_name / is_locked / metadata dates in a single + * round-trip, so there are no per-user AuthUser lookups here. + */ def createUsersInfoJsonV600( users: List[ - (ResourceUser, Box[List[Entitlement]], Option[List[UserAgreement]]) + (code.users.DoobieUserQueries.UserSearchRow, List[Entitlement], List[UserAgreement]) ] ): UsersInfoJsonV600 = { UsersInfoJsonV600( - users.map { t => - val authUser = AuthUser.find(By(AuthUser.user, t._1.id.get)) - createUserInfoJsonV600( - t._1, - authUser.map(_.firstName.get).getOrElse(""), - authUser.map(_.lastName.get).getOrElse(""), - t._2.getOrElse(Nil), - t._3, - LoginAttempt.userIsLocked(t._1.provider, t._1.name), - None, - List.empty + users.map { case (row, entitlements, agreements) => + UserInfoJsonV600( + user_id = row.userId, + email = row.email.getOrElse(""), + username = stringOrNull(row.username.orNull), + provider_id = row.providerId.getOrElse(""), + provider = stringOrNull(row.provider.orNull), + first_name = row.firstName.getOrElse(""), + last_name = row.lastName.getOrElse(""), + entitlements = JSONFactory200.createEntitlementJSONs(entitlements), + views = None, + agreements = Some(agreements.map(a => UserAgreementJson(`type` = a.agreementType, text = a.agreementText))), + is_deleted = row.isDeleted.getOrElse(false), + last_marketing_agreement_signed_date = row.lastMarketingAgreementSignedDate.map(d => new Date(d.getTime)), + is_locked = row.isLocked, + created_date = row.createdDate.map(t => new Date(t.getTime)), + updated_date = row.updatedDate.map(t => new Date(t.getTime)), + email_validated = row.emailValidated, + last_used_locale = row.lastUsedLocale ) } ) diff --git a/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala b/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala index f3ca840ff2..668e6b6f8f 100644 --- a/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala +++ b/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala @@ -645,8 +645,8 @@ object Http4s700 { for { httpParams <- NewStyle.function.extractHttpParamsFromUrl(req.uri.renderString) (obpQueryParams, _) <- createQueriesByHttpParamsFuture(httpParams, cc.callContext) - users <- UserVend.users.vend.getUsers(obpQueryParams) - } yield JSONFactory600.createUsersInfoJsonV600(users) + rows <- UserVend.users.vend.getUsersV600F(obpQueryParams) + } yield JSONFactory600.createUsersInfoJsonV600(rows) } } diff --git a/obp-api/src/main/scala/code/users/DoobieUserQueries.scala b/obp-api/src/main/scala/code/users/DoobieUserQueries.scala new file mode 100644 index 0000000000..99e79609c4 --- /dev/null +++ b/obp-api/src/main/scala/code/users/DoobieUserQueries.scala @@ -0,0 +1,141 @@ +package code.users + +import code.api.util.DoobieUtil +import code.loginattempts.LoginAttempt +import doobie._ +import doobie.implicits._ +import doobie.implicits.javasql._ + +import java.sql.{Date => SqlDate, Timestamp} + +/** + * Doobie queries for user search in v6.0.0. + * + * Uses a LEFT JOIN between resourceuser, authuser, and mappedbadloginattempt to + * return all the fields needed for UserJsonV600 in a single SQL round-trip + * (rather than N+1 per-user AuthUser/MappedBadLoginAttempt lookups). + * + * Entitlements and agreements remain separate queries handled by the caller. + */ +object DoobieUserQueries { + + /** + * Row returned by [[getUsers]]. Fields sourced from AuthUser are Option + * because external-IdP users may not have an AuthUser row. ResourceUser + * string columns also use Option since legacy rows may have NULLs even + * for fields that modern defaults would populate. + */ + case class UserSearchRow( + userId: String, + email: Option[String], + providerId: Option[String], + provider: Option[String], + username: Option[String], + firstName: Option[String], + lastName: Option[String], + isDeleted: Option[Boolean], + lastMarketingAgreementSignedDate: Option[SqlDate], + createdDate: Option[Timestamp], + updatedDate: Option[Timestamp], + emailValidated: Option[Boolean], + lastUsedLocale: Option[String], + isLocked: Boolean + ) + + private val selectColumns: Fragment = fr""" + SELECT + ru.userid_ AS user_id, + ru.email AS email, + ru.providerid AS provider_id, + ru.provider_ AS provider, + ru.name_ AS username, + au.firstname AS first_name, + au.lastname AS last_name, + ru.isdeleted AS is_deleted, + ru.lastmarketingagreementsigneddate AS last_marketing_agreement_signed_date, + au.createdat AS created_date, + au.updatedat AS updated_date, + au.validated AS email_validated, + ru.lastusedlocale AS last_used_locale, + CASE WHEN bad_login_attempts.mbadattemptssincelastsuccessorreset IS NOT NULL + AND bad_login_attempts.mbadattemptssincelastsuccessorreset > """ + + private val joinTables: Fragment = fr""" + FROM resourceuser ru + LEFT JOIN authuser au ON au.user_c = ru.id + LEFT JOIN mappedbadloginattempt bad_login_attempts ON bad_login_attempts.provider = ru.provider_ + AND bad_login_attempts.musername = ru.name_ + """ + + /** + * Find users with optional filters. All filters combine with AND. + * + * @param provider exact match on the identity provider (ResourceUser.provider_) + * @param username exact match on the username (ResourceUser.name_) + * @param email exact match on email (ResourceUser.email) + * @param userId exact match on userId (ResourceUser.userid_) + * @param isDeleted match on is_deleted (default: false) + * @param lockedStatus "active" / "locked" — filters via MappedBadLoginAttempt + * @param roleName require EXISTS an entitlement with this role + * @param bankId when roleName is given, scope it to this bank + * @param limit max rows (server cap applied by caller) + * @param offset rows to skip + */ + def getUsers( + provider: Option[String], + username: Option[String], + email: Option[String], + userId: Option[String], + isDeleted: Option[Boolean], + lockedStatus: Option[String], + roleName: Option[String], + bankId: Option[String], + limit: Int, + offset: Int + ): List[UserSearchRow] = { + val maxBadLogin: Int = LoginAttempt.maxBadLoginAttempts.toInt + + val providerFilter: Fragment = provider.map(v => fr"AND ru.provider_ = $v").getOrElse(Fragment.empty) + val usernameFilter: Fragment = username.map(v => fr"AND ru.name_ = $v").getOrElse(Fragment.empty) + val emailFilter: Fragment = email.map(v => fr"AND ru.email = $v").getOrElse(Fragment.empty) + val userIdFilter: Fragment = userId.map(v => fr"AND ru.userid_ = $v").getOrElse(Fragment.empty) + val deletedFlag: Boolean = isDeleted.getOrElse(false) + val deletedFilter: Fragment = fr"AND (ru.isdeleted = $deletedFlag OR ru.isdeleted IS NULL AND $deletedFlag = false)" + + val lockedFilter: Fragment = lockedStatus.map(_.toLowerCase) match { + case Some("locked") => + fr"AND bad_login_attempts.mbadattemptssincelastsuccessorreset IS NOT NULL AND bad_login_attempts.mbadattemptssincelastsuccessorreset > $maxBadLogin" + case Some("active") => + fr"AND (bad_login_attempts.mbadattemptssincelastsuccessorreset IS NULL OR bad_login_attempts.mbadattemptssincelastsuccessorreset <= $maxBadLogin)" + case _ => + Fragment.empty + } + + // role_name/bank_id via EXISTS subquery against mappedentitlement — keeps + // the outer row count correct (no duplicates when a user has many entitlements). + val roleFilter: Fragment = roleName match { + case Some(rn) => + val bankClause: Fragment = bankId.map(b => fr"AND ent.mbankid = $b").getOrElse(Fragment.empty) + fr"AND EXISTS (SELECT 1 FROM mappedentitlement ent WHERE ent.muserid = ru.userid_ AND ent.mrolename = $rn" ++ bankClause ++ fr")" + case None => Fragment.empty + } + + val query: ConnectionIO[List[UserSearchRow]] = + (selectColumns ++ fr"$maxBadLogin THEN true ELSE false END AS is_locked" ++ + joinTables ++ + fr"WHERE 1=1" ++ + providerFilter ++ + usernameFilter ++ + emailFilter ++ + userIdFilter ++ + deletedFilter ++ + lockedFilter ++ + roleFilter ++ + fr"ORDER BY ru.id ASC" ++ + fr"LIMIT $limit OFFSET $offset") + .query[UserSearchRow] + .to[List] + + DoobieUtil.runQuery(query) + } +} diff --git a/obp-api/src/main/scala/code/users/LiftUsers.scala b/obp-api/src/main/scala/code/users/LiftUsers.scala index f7ebd0b64c..c274979353 100644 --- a/obp-api/src/main/scala/code/users/LiftUsers.scala +++ b/obp-api/src/main/scala/code/users/LiftUsers.scala @@ -4,7 +4,7 @@ import code.api.util.Consent.logger import java.util.Date import code.api.util._ -import code.entitlement.Entitlement +import code.entitlement.{Entitlement, MappedEntitlement} import code.loginattempts.LoginAttempt.maxBadLoginAttempts import code.loginattempts.MappedBadLoginAttempt import code.model.dataAccess.{AuthUser, ResourceUser} @@ -221,7 +221,64 @@ object LiftUsers extends Users with MdcLoggable{ } } } - + + override def getUsersV600F(queryParams: List[OBPQueryParam]) + : Future[List[(DoobieUserQueries.UserSearchRow, List[Entitlement], List[UserAgreement])]] = Future { + + val provider: Option[String] = queryParams.collectFirst { case OBPProvider(v) => v } + val username: Option[String] = queryParams.collectFirst { case OBPUsername(v) => v } + val email: Option[String] = queryParams.collectFirst { case OBPEmail(v) => v } + val userId: Option[String] = queryParams.collectFirst { case OBPUserId(v) => v } + val isDeleted: Option[Boolean] = queryParams.collectFirst { case OBPIsDeleted(v) => v } + val lockedStat: Option[String] = queryParams.collectFirst { case OBPLockedStatus(v) => v } + val roleName: Option[String] = queryParams.collectFirst { case OBPRoleName(v) => v } + val bankId: Option[String] = queryParams.collectFirst { case OBPBankId(v) => v } + val limit: Int = queryParams.collectFirst { case OBPLimit(v) => v }.getOrElse(100) + val offset: Int = queryParams.collectFirst { case OBPOffset(v) => v }.getOrElse(0) + + logger.info( + s"getUsersV600F says: filters provider=$provider username=$username email=$email userId=$userId " + + s"isDeleted=$isDeleted lockedStatus=$lockedStat roleName=$roleName bankId=$bankId limit=$limit offset=$offset" + ) + + val started = System.currentTimeMillis() + val rows = DoobieUserQueries.getUsers(provider, username, email, userId, isDeleted, lockedStat, roleName, bankId, limit, offset) + logger.info(s"getUsersV600F says: DoobieUserQueries.getUsers returned ${rows.size} row(s) in ${System.currentTimeMillis() - started}ms") + + if (rows.isEmpty) Nil + else { + val userIds = rows.map(_.userId) + + // Batch-fetch entitlements for all returned users (single IN query). + val entitlementsByUserId: Map[String, List[Entitlement]] = + MappedEntitlement.findAll(ByList(MappedEntitlement.mUserId, userIds)) + .groupBy(_.userId) + .map { case (uid, ents) => uid -> ents.sortBy(_.roleName).toList } + + // Batch-fetch agreements, then reduce to most-recent per (userId, agreementType). + val agreementsByUserId: Map[String, List[UserAgreement]] = + UserAgreement.findAll(ByList(UserAgreement.UserId, userIds)) + .groupBy(_.userId) + .map { case (uid, all) => + uid -> all.groupBy(_.agreementType) + .values + .flatMap(_.sortBy(_.Date.get)(Ordering[Date].reverse).headOption) + .toList + } + + val totalEntitlements = entitlementsByUserId.values.map(_.size).sum + val totalAgreements = agreementsByUserId.values.map(_.size).sum + logger.info( + s"getUsersV600F says: batched $totalEntitlements entitlement(s) and $totalAgreements agreement(s) across ${userIds.size} user(s)" + ) + + rows.map { r => + (r, entitlementsByUserId.getOrElse(r.userId, Nil), agreementsByUserId.getOrElse(r.userId, Nil)) + } + } + } + + override def createResourceUser(provider: String, providerId: Option[String], diff --git a/obp-api/src/main/scala/code/users/Users.scala b/obp-api/src/main/scala/code/users/Users.scala index f9b75138d1..56bc3b4b00 100644 --- a/obp-api/src/main/scala/code/users/Users.scala +++ b/obp-api/src/main/scala/code/users/Users.scala @@ -51,6 +51,17 @@ trait Users { def getUsers(queryParams: List[OBPQueryParam]): Future[List[(ResourceUser, Box[List[Entitlement]], Option[List[UserAgreement]])]] + /** + * Get users via a Doobie-based SQL JOIN across resourceuser, authuser and + * mappedbadloginattempt. Returns pre-joined rows plus the user's entitlements + * and most-recent-per-type agreements, fetched in batch. + * + * Supported OBPQueryParam filters: OBPProvider, OBPUsername, OBPIsDeleted, + * OBPLockedStatus, OBPRoleName, OBPBankId, OBPLimit, OBPOffset. + */ + def getUsersV600F(queryParams: List[OBPQueryParam]) + : Future[List[(DoobieUserQueries.UserSearchRow, List[code.entitlement.Entitlement], List[UserAgreement])]] + def createResourceUser(provider: String, providerId: Option[String], createdByConsentId: Option[String], From 3ed8843333d84d773155151e0c9d43b603d998f0 Mon Sep 17 00:00:00 2001 From: simonredfern Date: Sat, 18 Apr 2026 08:54:54 +0200 Subject: [PATCH 2/6] sort_by global whitelist --- obp-api/src/main/scala/code/api/util/APIUtil.scala | 4 +++- obp-api/src/main/scala/code/api/util/ErrorMessages.scala | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/obp-api/src/main/scala/code/api/util/APIUtil.scala b/obp-api/src/main/scala/code/api/util/APIUtil.scala index 58da796c36..2efb806bd9 100644 --- a/obp-api/src/main/scala/code/api/util/APIUtil.scala +++ b/obp-api/src/main/scala/code/api/util/APIUtil.scala @@ -1188,7 +1188,9 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{ value <- tryo(values.head.toBoolean) ?~! FilterIsDeletedFormatError deleted = OBPIsDeleted(value) } yield deleted - case "sort_by" => Full(OBPSortBy(values.head)) + case "sort_by" => + if (SortFields.All.contains(values.head)) Full(OBPSortBy(values.head)) + else Failure(FilterSortByError) case "status" => Full(OBPStatus(values.head)) case "consumer_id" => Full(OBPConsumerId(values.head)) case "azp" => Full(OBPAzp(values.head)) diff --git a/obp-api/src/main/scala/code/api/util/ErrorMessages.scala b/obp-api/src/main/scala/code/api/util/ErrorMessages.scala index 63d4033ebc..87a4ccbd1f 100644 --- a/obp-api/src/main/scala/code/api/util/ErrorMessages.scala +++ b/obp-api/src/main/scala/code/api/util/ErrorMessages.scala @@ -117,6 +117,7 @@ object ErrorMessages { // General Sort and Paging val FilterSortDirectionError = "OBP-10023: obp_sort_direction parameter can only take two values: DESC or ASC!" // was OBP-20023 + val FilterSortByError = s"OBP-10042: sort_by parameter value is not in the global whitelist. Allowed values are: ${SortFields.All.toSeq.sorted.mkString(", ")}." val FilterOffersetError = "OBP-10024: wrong value for obp_offset parameter. Please send a positive integer (=>0)!" // was OBP-20024 val FilterLimitError = "OBP-10025: wrong value for obp_limit parameter. Please send a positive integer (=>1)!" // was OBP-20025 val FilterDateFormatError = s"OBP-10026: Failed to parse date string. Please use this format ${DateWithMsFormat.toPattern}!" // OBP-20026 From 6de28f1d4dd98c564832c183cf1bd9ae7a884767 Mon Sep 17 00:00:00 2001 From: simonredfern Date: Sat, 18 Apr 2026 09:30:33 +0200 Subject: [PATCH 3/6] sort by fields. explicit allowed sort by fields for GET /users --- .../scala/code/api/util/ErrorMessages.scala | 3 + .../main/scala/code/api/util/SortFields.scala | 67 +++++++++++++++++++ .../scala/code/api/v6_0_0/APIMethods600.scala | 19 +++++- .../scala/code/users/DoobieUserQueries.scala | 31 ++++++++- .../src/main/scala/code/users/LiftUsers.scala | 12 +++- 5 files changed, 128 insertions(+), 4 deletions(-) create mode 100644 obp-api/src/main/scala/code/api/util/SortFields.scala diff --git a/obp-api/src/main/scala/code/api/util/ErrorMessages.scala b/obp-api/src/main/scala/code/api/util/ErrorMessages.scala index 87a4ccbd1f..7842c09b44 100644 --- a/obp-api/src/main/scala/code/api/util/ErrorMessages.scala +++ b/obp-api/src/main/scala/code/api/util/ErrorMessages.scala @@ -118,6 +118,9 @@ object ErrorMessages { // General Sort and Paging val FilterSortDirectionError = "OBP-10023: obp_sort_direction parameter can only take two values: DESC or ASC!" // was OBP-20023 val FilterSortByError = s"OBP-10042: sort_by parameter value is not in the global whitelist. Allowed values are: ${SortFields.All.toSeq.sorted.mkString(", ")}." + val FilterSortByNotAllowedForEndpoint = "OBP-10043: sort_by value is not allowed for this endpoint." + def filterSortByNotAllowedForEndpointDetail(endpoint: String, requested: String, allowed: Iterable[String]): String = + s"$FilterSortByNotAllowedForEndpoint Endpoint: $endpoint. Value requested: '$requested'. Allowed values: ${allowed.toSeq.sorted.mkString(", ")}." val FilterOffersetError = "OBP-10024: wrong value for obp_offset parameter. Please send a positive integer (=>0)!" // was OBP-20024 val FilterLimitError = "OBP-10025: wrong value for obp_limit parameter. Please send a positive integer (=>1)!" // was OBP-20025 val FilterDateFormatError = s"OBP-10026: Failed to parse date string. Please use this format ${DateWithMsFormat.toPattern}!" // OBP-20026 diff --git a/obp-api/src/main/scala/code/api/util/SortFields.scala b/obp-api/src/main/scala/code/api/util/SortFields.scala new file mode 100644 index 0000000000..713688406e --- /dev/null +++ b/obp-api/src/main/scala/code/api/util/SortFields.scala @@ -0,0 +1,67 @@ +package code.api.util + +/** + * Global whitelist of field names accepted by the `sort_by` query parameter. + * + * Constants are spelled to match the wire value — `USER_ID` holds the string + * "user_id" — so `SortFields.USER_ID` reads naturally as "the user_id sort field". + * + * `All` is the union used by the central validator in + * `APIUtil.getHttpParamValuesByName`. Endpoints may narrow further via their + * own per-endpoint whitelists. + */ +object SortFields { + + // Temporal + val CREATED_DATE = "created_date" + val UPDATED_DATE = "updated_date" + val DATE = "date" + + // Identity + val USER_ID = "user_id" + val CONSUMER_ID = "consumer_id" + val BANK_ID = "bank_id" + val ACCOUNT_ID = "account_id" + val CUSTOMER_ID = "customer_id" + val CARD_ID = "card_id" + val TRANSACTION_ID = "transaction_id" + val COUNTERPARTY_ID = "counterparty_id" + val CONSENT_ID = "consent_id" + val CORRELATION_ID = "correlation_id" + + // Descriptive + val USERNAME = "username" + val EMAIL = "email" + val PROVIDER = "provider" + val NAME = "name" + val SHORT_NAME = "short_name" + val FULL_NAME = "full_name" + val LABEL = "label" + val STATUS = "status" + val APP_NAME = "app_name" + val DEVELOPER_EMAIL = "developer_email" + + // Financial + val AMOUNT = "amount" + val CURRENCY = "currency" + val BALANCE = "balance" + + // Request / metrics + val URL = "url" + val VERB = "verb" + val HTTP_STATUS_CODE = "http_status_code" + val DURATION = "duration" + val IMPLEMENTED_BY_PARTIAL_FUNCTION = "implemented_by_partial_function" + val IMPLEMENTED_IN_VERSION = "implemented_in_version" + + val All: Set[String] = Set( + CREATED_DATE, UPDATED_DATE, DATE, + USER_ID, CONSUMER_ID, BANK_ID, ACCOUNT_ID, CUSTOMER_ID, CARD_ID, + TRANSACTION_ID, COUNTERPARTY_ID, CONSENT_ID, CORRELATION_ID, + USERNAME, EMAIL, PROVIDER, NAME, SHORT_NAME, FULL_NAME, LABEL, STATUS, + APP_NAME, DEVELOPER_EMAIL, + AMOUNT, CURRENCY, BALANCE, + URL, VERB, HTTP_STATUS_CODE, DURATION, + IMPLEMENTED_BY_PARTIAL_FUNCTION, IMPLEMENTED_IN_VERSION + ) +} diff --git a/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala b/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala index f2e651e8b9..70630cf30d 100644 --- a/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala +++ b/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala @@ -15,7 +15,7 @@ import code.api.util.FutureUtil.EndpointContext import code.api.util.{CertificateUtil, Glossary} import code.api.util.JsonSchemaGenerator import code.api.util.NewStyle.HttpCode -import code.api.util.{APIUtil, ApiVersionUtils, CallContext, DiagnosticDynamicEntityCheck, ErrorMessages, NewStyle, OBPLimit, OBPOffset, RateLimitingUtil} +import code.api.util.{APIUtil, ApiVersionUtils, CallContext, DiagnosticDynamicEntityCheck, ErrorMessages, NewStyle, OBPLimit, OBPOffset, OBPSortBy, RateLimitingUtil} import net.liftweb.json import code.api.util.NewStyle.function.extractQueryParams import code.api.util.newstyle.ViewNewStyle @@ -1792,6 +1792,10 @@ trait APIMethods600 { |* is_deleted (default: false) |* role_name (if null ignore) - filter by entitlement/role name e.g. CanCreateAccount |* bank_id (if null ignore) - when used with role_name, filter entitlements by bank_id + |* sort_by (if null ignore) - sort by field; allowed values: ${code.users.DoobieUserQueries.SortableColumns.keySet.toSeq.sorted.mkString(", ")} + |* sort_direction (if null defaults to DESC) - "asc" or "desc" (case-insensitive) + | + |When sort_by is omitted, results are ordered by insertion order ascending (stable pagination). | |Returns an empty list (not 404) when no users match. | @@ -1801,6 +1805,9 @@ trait APIMethods600 { List( $AuthenticatedUserIsRequired, UserHasMissingRoles, + FilterSortByError, + FilterSortByNotAllowedForEndpoint, + FilterSortDirectionError, UnknownError ), List(apiTagUser), @@ -1820,6 +1827,16 @@ trait APIMethods600 { httpParams, callContext ) + _ <- Future { + val requestedSort = obpQueryParams.collectFirst { case OBPSortBy(v) => v } + val allowed = code.users.DoobieUserQueries.SortableColumns.keySet + val valid: Box[Unit] = requestedSort match { + case Some(v) if !allowed.contains(v) => + Failure(ErrorMessages.filterSortByNotAllowedForEndpointDetail("GET /users", v, allowed)) + case _ => Full(()) + } + unboxFullOrFail(valid, callContext, ErrorMessages.FilterSortByNotAllowedForEndpoint, 400) + } rows <- code.users.Users.users.vend.getUsersV600F(obpQueryParams) _ = logger.info(s"getUsers says: returning ${rows.size} user(s) to user_id=${u.userId}") } yield { diff --git a/obp-api/src/main/scala/code/users/DoobieUserQueries.scala b/obp-api/src/main/scala/code/users/DoobieUserQueries.scala index 99e79609c4..0bd3f4304a 100644 --- a/obp-api/src/main/scala/code/users/DoobieUserQueries.scala +++ b/obp-api/src/main/scala/code/users/DoobieUserQueries.scala @@ -19,6 +19,24 @@ import java.sql.{Date => SqlDate, Timestamp} */ object DoobieUserQueries { + /** + * Per-endpoint whitelist of sort_by field names accepted by [[getUsers]], + * mapping the wire name to a hardcoded SQL column expression. + * + * Keys must all appear in `SortFields.All` so the global validator passes + * them through; the map itself is the per-endpoint narrowing. Values are + * spliced via `Fragment.const` — never accept untrusted strings here, the + * map constrains that to a known-safe set. + */ + val SortableColumns: Map[String, String] = Map( + "user_id" -> "ru.userid_", + "username" -> "ru.name_", + "email" -> "ru.email", + "provider" -> "ru.provider_", + "created_date" -> "au.createdat", + "updated_date" -> "au.updatedat" + ) + /** * Row returned by [[getUsers]]. Fields sourced from AuthUser are Option * because external-IdP users may not have an AuthUser row. ResourceUser @@ -78,6 +96,9 @@ object DoobieUserQueries { * @param lockedStatus "active" / "locked" — filters via MappedBadLoginAttempt * @param roleName require EXISTS an entitlement with this role * @param bankId when roleName is given, scope it to this bank + * @param sortBy wire name of the column to sort by (must be a key in + * [[SortableColumns]]); unknown or None falls back to `ru.id` + * @param sortAsc true = ASC, false = DESC * @param limit max rows (server cap applied by caller) * @param offset rows to skip */ @@ -90,6 +111,8 @@ object DoobieUserQueries { lockedStatus: Option[String], roleName: Option[String], bankId: Option[String], + sortBy: Option[String], + sortAsc: Boolean, limit: Int, offset: Int ): List[UserSearchRow] = { @@ -120,6 +143,12 @@ object DoobieUserQueries { case None => Fragment.empty } + // sortBy is looked up in SortableColumns, an internal-only whitelist — never + // interpolate an arbitrary caller-supplied string into Fragment.const. + val sortColumn: String = sortBy.flatMap(SortableColumns.get).getOrElse("ru.id") + val sortDir: String = if (sortAsc) "ASC" else "DESC" + val orderBy: Fragment = Fragment.const(s"ORDER BY $sortColumn $sortDir") + val query: ConnectionIO[List[UserSearchRow]] = (selectColumns ++ fr"$maxBadLogin THEN true ELSE false END AS is_locked" ++ joinTables ++ @@ -131,7 +160,7 @@ object DoobieUserQueries { deletedFilter ++ lockedFilter ++ roleFilter ++ - fr"ORDER BY ru.id ASC" ++ + orderBy ++ fr"LIMIT $limit OFFSET $offset") .query[UserSearchRow] .to[List] diff --git a/obp-api/src/main/scala/code/users/LiftUsers.scala b/obp-api/src/main/scala/code/users/LiftUsers.scala index c274979353..60163767d9 100644 --- a/obp-api/src/main/scala/code/users/LiftUsers.scala +++ b/obp-api/src/main/scala/code/users/LiftUsers.scala @@ -233,16 +233,24 @@ object LiftUsers extends Users with MdcLoggable{ val lockedStat: Option[String] = queryParams.collectFirst { case OBPLockedStatus(v) => v } val roleName: Option[String] = queryParams.collectFirst { case OBPRoleName(v) => v } val bankId: Option[String] = queryParams.collectFirst { case OBPBankId(v) => v } + val ordering: Option[OBPOrdering] = queryParams.collectFirst { case o: OBPOrdering => o } + val sortBy: Option[String] = ordering.flatMap(_.field) + // When no sort_by is supplied we fall back to `ru.id ASC` for stable pagination. + // When sort_by IS supplied we honour sort_direction, which defaults to DESC per OBP convention. + val sortAsc: Boolean = + if (sortBy.isEmpty) true + else ordering.exists(_.order == OBPAscending) val limit: Int = queryParams.collectFirst { case OBPLimit(v) => v }.getOrElse(100) val offset: Int = queryParams.collectFirst { case OBPOffset(v) => v }.getOrElse(0) logger.info( s"getUsersV600F says: filters provider=$provider username=$username email=$email userId=$userId " + - s"isDeleted=$isDeleted lockedStatus=$lockedStat roleName=$roleName bankId=$bankId limit=$limit offset=$offset" + s"isDeleted=$isDeleted lockedStatus=$lockedStat roleName=$roleName bankId=$bankId " + + s"sortBy=$sortBy sortAsc=$sortAsc limit=$limit offset=$offset" ) val started = System.currentTimeMillis() - val rows = DoobieUserQueries.getUsers(provider, username, email, userId, isDeleted, lockedStat, roleName, bankId, limit, offset) + val rows = DoobieUserQueries.getUsers(provider, username, email, userId, isDeleted, lockedStat, roleName, bankId, sortBy, sortAsc, limit, offset) logger.info(s"getUsersV600F says: DoobieUserQueries.getUsers returned ${rows.size} row(s) in ${System.currentTimeMillis() - started}ms") if (rows.isEmpty) Nil From 22d6c93fbeabcaf487f7cfea6eb77bb26ff3fc41 Mon Sep 17 00:00:00 2001 From: simonredfern Date: Sat, 18 Apr 2026 11:17:10 +0200 Subject: [PATCH 4/6] GetUsersTest + query string parsing --- .../main/scala/code/api/util/APIUtil.scala | 18 +- .../scala/code/api/v6_0_0/GetUsersTest.scala | 256 ++++++++++++++++++ 2 files changed, 272 insertions(+), 2 deletions(-) create mode 100644 obp-api/src/test/scala/code/api/v6_0_0/GetUsersTest.scala diff --git a/obp-api/src/main/scala/code/api/util/APIUtil.scala b/obp-api/src/main/scala/code/api/util/APIUtil.scala index 2efb806bd9..743bdf2ebc 100644 --- a/obp-api/src/main/scala/code/api/util/APIUtil.scala +++ b/obp-api/src/main/scala/code/api/util/APIUtil.scala @@ -1402,8 +1402,20 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{ */ def getHttpRequestUrlParam(httpRequestUrl: String, name: String): String = { val urlAndQueryString = if (httpRequestUrl.contains("?")) httpRequestUrl.split("\\?",2)(1) else "" // Full(from_date=$DateWithMsExampleString&to_date=$DateWithMsExampleString) - val queryStrings = urlAndQueryString.split("&").map(_.split("=")).flatten //Full(from_date, $DateWithMsExampleString, to_date, $DateWithMsExampleString) - if (queryStrings.contains(name)&& queryStrings.length > queryStrings.indexOf(name)+1) queryStrings(queryStrings.indexOf(name)+1) else ""//Full($DateWithMsExampleString) + // Parse each `k=v` pair individually. The previous implementation flattened everything into a single + // array and then used indexOf(name), which collided when a param VALUE happened to equal another + // param's NAME (e.g. `?sort_by=email` would cause lookups for `email` to return the next element). + val pairs: Map[String, String] = urlAndQueryString.split("&").iterator.flatMap { pair => + pair.split("=", 2) match { + case Array(k, v) if k.nonEmpty => Some(k -> v) + case Array(k) if k.nonEmpty => Some(k -> "") + case _ => None + } + }.toMap + val raw = pairs.getOrElse(name, "") + // URL-decode percent-escaped values (e.g. %40 → @, %20 → space). Without this, ?email=simon%40tesobe.com + // reached filter code as the literal 18-char string containing `%40`, which never matched any DB row. + if (raw.isEmpty) raw else java.net.URLDecoder.decode(raw, "UTF-8") } //ended -- Filtering and Paging relevant methods //////////////////////////// @@ -2182,6 +2194,8 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{ |* offset=NUMBER ==> default value: 0 | |eg1:?limit=100&offset=0 + | + |**URL encoding:** query parameter values containing `&`, `=`, `+`, `#`, `%`, or spaces must be URL-encoded (e.g. `+` → `%2B`, space → `%20`). Most other characters (including `@` in emails) are safe unencoded, but encoding is always permitted. |""". stripMargin val sortDirectionParameters = if (containsSortDirection) { diff --git a/obp-api/src/test/scala/code/api/v6_0_0/GetUsersTest.scala b/obp-api/src/test/scala/code/api/v6_0_0/GetUsersTest.scala new file mode 100644 index 0000000000..5990e8051b --- /dev/null +++ b/obp-api/src/test/scala/code/api/v6_0_0/GetUsersTest.scala @@ -0,0 +1,256 @@ +package code.api.v6_0_0 + +import code.api.util.APIUtil.OAuth._ +import code.api.util.ApiRole.CanGetAnyUser +import code.api.util.ErrorMessages +import code.api.util.ErrorMessages.UserHasMissingRoles +import code.api.v6_0_0.APIMethods600.Implementations6_0_0 +import code.entitlement.Entitlement +import code.setup.DefaultUsers +import com.github.dwickern.macros.NameOf.nameOf +import com.openbankproject.commons.model.ErrorMessage +import com.openbankproject.commons.util.ApiVersion +import org.scalatest.Tag +import net.liftweb.common.Full + +/** + * Validation / error-path tests for `GET /obp/v6.0.0/users`. + * + * These cover auth, role, and query-parameter validation. They deliberately + * do NOT exercise the Doobie+PostgreSQL search path — data-driven tests + * (filters actually matching rows, sort ordering, pagination results) need + * a real PostgreSQL test DB and should live in a separate suite. + */ +class GetUsersTest extends V600ServerSetup with DefaultUsers { + + object VersionOfApi extends Tag(ApiVersion.v6_0_0.toString) + object ApiEndpoint extends Tag(nameOf(Implementations6_0_0.getUsers)) + + feature(s"Get all Users - GET /obp/v6.0.0/users - $VersionOfApi") { + + scenario("Anonymous access should fail with 401", ApiEndpoint, VersionOfApi) { + When("We make the request without authentication") + val request = (v6_0_0_Request / "users").GET + val response = makeGetRequest(request) + + Then("We should get a 401") + response.code should equal(401) + And("The error message should indicate authentication is required") + response.body.extract[ErrorMessage].message should equal(ErrorMessages.AuthenticatedUserIsRequired) + } + + scenario("Authenticated user without CanGetAnyUser role should fail with 403", ApiEndpoint, VersionOfApi) { + When("We make the request as an authenticated user without the required role") + val request = (v6_0_0_Request / "users").GET <@ (user1) + val response = makeGetRequest(request) + + Then("We should get a 403") + response.code should equal(403) + And("The error message should indicate missing role") + response.body.extract[ErrorMessage].message should equal(UserHasMissingRoles + CanGetAnyUser) + } + + scenario("sort_by not in global whitelist returns 400 OBP-10042", ApiEndpoint, VersionOfApi) { + val addedEntitlement = Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanGetAnyUser.toString) + + When("We make the request with a bogus sort_by value") + val request = (v6_0_0_Request / "users").GET <@ (user1) < + When(s"We make the request with sort_by=$sortBy") + val request = (v6_0_0_Request / "users").GET <@ (user1) < (u \ "user_id").extract[String]) + returned should contain allOf (resourceUser1.userId, resourceUser2.userId) + returned should equal(returned.sorted) + } + + scenario("role_name + username narrows to a single user", ApiEndpoint, VersionOfApi) { + val callerEnt = Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanGetAnyUser.toString) + val extraEnt = Entitlement.entitlement.vend.addEntitlement("", resourceUser2.userId, CanGetAnyUser.toString) + + When("We filter by role_name AND username (matching only one of the two role-holders)") + val request = (v6_0_0_Request / "users").GET <@ (user1) < (u \ "user_id").extract[String]) + val descIds = (desc.body \ "users").children.map(u => (u \ "user_id").extract[String]) + ascIds should not be empty + descIds should equal(ascIds.reverse) + } finally { + Entitlement.entitlement.vend.deleteEntitlement(callerEnt) + Entitlement.entitlement.vend.deleteEntitlement(extraEnt) + } + } + } +} From 8e9b606bbc31235d3dda059daefafc1da8e52c63 Mon Sep 17 00:00:00 2001 From: simonredfern Date: Sat, 18 Apr 2026 17:04:32 +0200 Subject: [PATCH 5/6] url encoding tests, Chat glossary, run_all_tests.sh tweak --- .../main/scala/code/api/util/APIUtil.scala | 5 ++- .../main/scala/code/api/util/Glossary.scala | 43 +++++++++++++++++++ .../AccountInformationServiceAISApiTest.scala | 4 +- run_all_tests.sh | 8 ++-- 4 files changed, 53 insertions(+), 7 deletions(-) diff --git a/obp-api/src/main/scala/code/api/util/APIUtil.scala b/obp-api/src/main/scala/code/api/util/APIUtil.scala index 743bdf2ebc..dbe641bddf 100644 --- a/obp-api/src/main/scala/code/api/util/APIUtil.scala +++ b/obp-api/src/main/scala/code/api/util/APIUtil.scala @@ -1415,7 +1415,10 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{ val raw = pairs.getOrElse(name, "") // URL-decode percent-escaped values (e.g. %40 → @, %20 → space). Without this, ?email=simon%40tesobe.com // reached filter code as the literal 18-char string containing `%40`, which never matched any DB row. - if (raw.isEmpty) raw else java.net.URLDecoder.decode(raw, "UTF-8") + // Fall back to raw on malformed escapes so SQL-LIKE wildcard values (e.g. `%users%`) still work. + if (raw.isEmpty) raw + else try java.net.URLDecoder.decode(raw, "UTF-8") + catch { case _: IllegalArgumentException => raw } } //ended -- Filtering and Paging relevant methods //////////////////////////// diff --git a/obp-api/src/main/scala/code/api/util/Glossary.scala b/obp-api/src/main/scala/code/api/util/Glossary.scala index fe90ba0fce..9dcdd92db9 100644 --- a/obp-api/src/main/scala/code/api/util/Glossary.scala +++ b/obp-api/src/main/scala/code/api/util/Glossary.scala @@ -5211,6 +5211,49 @@ object Glossary extends MdcLoggable { | """) + glossaryItems += GlossaryItem( + title = "Chat Room", + description = + s""" + |# Chat Room + | + |A **Chat Room** is a named space where users and consumers (apps/bots) exchange messages. Each room is either **system-level** or scoped to a single **bank**. + | + |See also the broader [Chat](/glossary#Chat) entry, which covers messages, threads, reactions, mentions, typing indicators, gRPC streaming, and the full permissions model. + | + |## Identity and scope + |- **chat_room_id** — UUID identifying the room. + |- **bank_id** — non-empty for bank-scoped rooms, empty string for system-level rooms. + |- **name** — unique within scope (per bank, or globally for system-level). + | + |## Open vs Closed rooms + |The **is_open_room** flag controls how membership works: + | + |- **Closed room** (`is_open_room = false`): only users with an explicit Participant record can read or post. New members must present the room's joining_key. + |- **Open room** (`is_open_room = true`): every authenticated user is treated as an **implicit participant** (see `ChatPermissions.isParticipant`). They can read and post without a Participant record, but have no special permissions. Open rooms also appear in `GET /chat-rooms` for everyone, not just existing members. + | + |The auto-created system room **general** is open by default. + | + |## Joining keys + |Each Chat Room has a **joining_key** (UUID). To join a room explicitly, a user calls `POST /chat-room-participants` with `{ joining_key }` — the key alone identifies the room. + | + |- For closed rooms, the key is the only way in. It is exposed in `GET /chat-rooms` and `GET /chat-rooms/{id}` to existing participants only, who then share it out-of-band (chat, email, link). + |- For open rooms, the key still exists but is rarely needed, since users are already implicit participants. Joining explicitly creates a Participant record so the user can be granted permissions, mute the room, or track last_read_at. + |- The key can be rotated by a participant with the **can_refresh_joining_key** permission, via `PUT /chat-rooms/{id}/joining-key`. The old key becomes invalid. + | + |## Lifecycle flags + |- **is_archived** — archived rooms reject new messages and new participants but remain readable for audit. + |- **created_by / created_by_username / created_by_provider** — identifies the room creator. The creator is granted all participant permissions. + | + |## Endpoints + |Each Chat Room operation has both a system-level and bank-scoped variant: + |- System-level: `/obp/v6.0.0/chat-rooms/...` + |- Bank-scoped: `/obp/v6.0.0/banks/BANK_ID/chat-rooms/...` + | + |See the API Explorer with the **Chat** tag for the full list. + | +""") + /////////////////////////////////////////////////////////////////// // NOTE! Some glossary items are generated in ExampleValue.scala ////////////////////////////////////////////////////////////////// diff --git a/obp-api/src/test/scala/code/api/berlin/group/v1_3/AccountInformationServiceAISApiTest.scala b/obp-api/src/test/scala/code/api/berlin/group/v1_3/AccountInformationServiceAISApiTest.scala index cfc8427e46..13f5190039 100644 --- a/obp-api/src/test/scala/code/api/berlin/group/v1_3/AccountInformationServiceAISApiTest.scala +++ b/obp-api/src/test/scala/code/api/berlin/group/v1_3/AccountInformationServiceAISApiTest.scala @@ -351,8 +351,8 @@ class AccountInformationServiceAISApiTest extends BerlinGroupServerSetupV1_3 wit PostViewJsonV400(view_id = Constant.SYSTEM_READ_TRANSACTIONS_BERLIN_GROUP_VIEW_ID, is_system = true) ) - // Test with URL encoded values that should still be invalid - val encodedInvalidStatuses = List("book%65d", "pend%69ng", "bot%68") + // Values decode to "bad", "bookedx", "pendingz" — none of booked/pending/both. + val encodedInvalidStatuses = List("%62%61%64", "boo%6b%65%64x", "pend%69ngz") encodedInvalidStatuses.foreach { encodedStatus => val requestGetWithEncodedStatus = (V1_3_BG / "accounts" / testAccountId.value / "transactions").GET <@ (user1) < Date: Sat, 18 Apr 2026 21:16:23 +0200 Subject: [PATCH 6/6] created_by_user_id in Chat Room --- .../code/api/util/migration/Migration.scala | 8 ++ ...hatRoomCreatedByAndLastMessageSender.scala | 101 ++++++++++++++++++ .../scala/code/api/v6_0_0/APIMethods600.scala | 56 +++++----- .../code/api/v6_0_0/JSONFactory6.0.0.scala | 10 +- .../main/scala/code/chat/ChatRoomTrait.scala | 6 +- .../main/scala/code/chat/MappedChatRoom.scala | 18 ++-- 6 files changed, 155 insertions(+), 44 deletions(-) create mode 100644 obp-api/src/main/scala/code/api/util/migration/MigrationOfChatRoomCreatedByAndLastMessageSender.scala diff --git a/obp-api/src/main/scala/code/api/util/migration/Migration.scala b/obp-api/src/main/scala/code/api/util/migration/Migration.scala index 7128c44ded..4a5e162ede 100644 --- a/obp-api/src/main/scala/code/api/util/migration/Migration.scala +++ b/obp-api/src/main/scala/code/api/util/migration/Migration.scala @@ -116,6 +116,7 @@ object Migration extends MdcLoggable { updateConsentViewAddJwtExpiresAt(startedBeforeSchemifier) updateAccountAccessWithViewsViewUnionAll(startedBeforeSchemifier) migrateChatRoomIsOpenRoom() + migrateChatRoomCreatedByAndLastMessageSender() } private def dummyScript(): Boolean = { @@ -657,6 +658,13 @@ object Migration extends MdcLoggable { MigrationOfChatRoomIsOpenRoom.migrateColumn(name) } } + + private def migrateChatRoomCreatedByAndLastMessageSender(): Boolean = { + val name = nameOf(migrateChatRoomCreatedByAndLastMessageSender) + runOnce(name) { + MigrationOfChatRoomCreatedByAndLastMessageSender.migrateColumns(name) + } + } } /** diff --git a/obp-api/src/main/scala/code/api/util/migration/MigrationOfChatRoomCreatedByAndLastMessageSender.scala b/obp-api/src/main/scala/code/api/util/migration/MigrationOfChatRoomCreatedByAndLastMessageSender.scala new file mode 100644 index 0000000000..56dbc3ba7c --- /dev/null +++ b/obp-api/src/main/scala/code/api/util/migration/MigrationOfChatRoomCreatedByAndLastMessageSender.scala @@ -0,0 +1,101 @@ +package code.api.util.migration + +import code.api.util.APIUtil +import code.api.util.migration.Migration.{DbFunction, saveLog} +import code.chat.ChatRoom +import net.liftweb.common.Full +import net.liftweb.db.DB +import net.liftweb.mapper.Schemifier +import net.liftweb.util.DefaultConnectionIdentifier + +object MigrationOfChatRoomCreatedByAndLastMessageSender { + + /** + * Migrate the old ambiguously-named columns to their renamed, explicit counterparts: + * createdby -> createdbyuserid (already was a user_id; name made explicit) + * lastmessagesender -> lastmessagesenderusername (always stored a username; name made explicit) + * + * Schemifier will have already created the new columns (defaulting to empty). + * This migration copies data from the old columns and then drops them. + * + * If an old column does not exist (fresh install), that half is skipped. + */ + def migrateColumns(name: String): Boolean = { + DbFunction.tableExists(ChatRoom) match { + case true => + val startDate = System.currentTimeMillis() + val commitId: String = APIUtil.gitCommit + + val oldCreatedByExists = columnExists("createdby") + val oldLastMessageSenderExists = columnExists("lastmessagesender") + + if (!oldCreatedByExists && !oldLastMessageSenderExists) { + val endDate = System.currentTimeMillis() + val comment = "Old columns createdby and lastmessagesender do not exist (fresh install). No migration needed." + saveLog(name, commitId, true, startDate, endDate, comment) + return true + } + + var isSuccessful = false + + val executedSql = + DbFunction.maybeWrite(true, Schemifier.infoF _) { + APIUtil.getPropsValue("db.driver") match { + case Full(dbDriver) if dbDriver.contains("com.microsoft.sqlserver.jdbc.SQLServerDriver") => + () => buildSql(oldCreatedByExists, oldLastMessageSenderExists) + case _ => + // PostgreSQL and MySQL + () => buildSql(oldCreatedByExists, oldLastMessageSenderExists) + } + } + + val endDate = System.currentTimeMillis() + val comment: String = + s"""Executed SQL: + |$executedSql + |""".stripMargin + isSuccessful = true + saveLog(name, commitId, isSuccessful, startDate, endDate, comment) + isSuccessful + + case false => + val startDate = System.currentTimeMillis() + val commitId: String = APIUtil.gitCommit + val isSuccessful = false + val endDate = System.currentTimeMillis() + val comment: String = + s"""${ChatRoom._dbTableNameLC} table does not exist""" + saveLog(name, commitId, isSuccessful, startDate, endDate, comment) + isSuccessful + } + } + + private def columnExists(columnName: String): Boolean = { + try { + DB.use(DefaultConnectionIdentifier) { conn => + val rs = conn.getMetaData.getColumns(null, null, "chatroom", columnName) + val exists = rs.next() + rs.close() + exists + } + } catch { + case _: Throwable => false + } + } + + private def buildSql(migrateCreatedBy: Boolean, migrateLastMessageSender: Boolean): String = { + val createdByPart = + if (migrateCreatedBy) + """UPDATE chatroom SET createdbyuserid = createdby WHERE createdby IS NOT NULL; + |ALTER TABLE chatroom DROP COLUMN createdby; + |""".stripMargin + else "" + val lastMessageSenderPart = + if (migrateLastMessageSender) + """UPDATE chatroom SET lastmessagesenderusername = lastmessagesender WHERE lastmessagesender IS NOT NULL; + |ALTER TABLE chatroom DROP COLUMN lastmessagesender; + |""".stripMargin + else "" + createdByPart + lastMessageSenderPart + } +} diff --git a/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala b/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala index 70630cf30d..77494fe69f 100644 --- a/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala +++ b/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala @@ -12855,14 +12855,14 @@ trait APIMethods600 { name = "General Discussion", description = "A place to discuss general topics", joining_key = "abc123key", - created_by = "user-id-123", + created_by_user_id = "user-id-123", created_by_username = "robert.x.0.gh", created_by_provider = "https://github.com", is_open_room = false, is_archived = false, last_message_at = Some(new java.util.Date()), last_message_preview = Some("Hello everyone!"), - last_message_sender = Some("robert.x.0.gh"), + last_message_sender_username =Some("robert.x.0.gh"), unread_count = Some(3), created_at = new java.util.Date(), updated_at = new java.util.Date() @@ -12926,14 +12926,14 @@ trait APIMethods600 { name = "General Discussion", description = "A place to discuss general topics", joining_key = "abc123key", - created_by = "user-id-123", + created_by_user_id = "user-id-123", created_by_username = "robert.x.0.gh", created_by_provider = "https://github.com", is_open_room = false, is_archived = false, last_message_at = Some(new java.util.Date()), last_message_preview = Some("Hello everyone!"), - last_message_sender = Some("robert.x.0.gh"), + last_message_sender_username =Some("robert.x.0.gh"), unread_count = Some(3), created_at = new java.util.Date(), updated_at = new java.util.Date() @@ -12996,14 +12996,14 @@ trait APIMethods600 { name = "General Discussion", description = "A place to discuss general topics", joining_key = "abc123key", - created_by = "user-id-123", + created_by_user_id = "user-id-123", created_by_username = "robert.x.0.gh", created_by_provider = "https://github.com", is_open_room = false, is_archived = false, last_message_at = Some(new java.util.Date()), last_message_preview = Some("Hello everyone!"), - last_message_sender = Some("robert.x.0.gh"), + last_message_sender_username =Some("robert.x.0.gh"), unread_count = Some(3), created_at = new java.util.Date(), updated_at = new java.util.Date() @@ -13058,14 +13058,14 @@ trait APIMethods600 { name = "General Discussion", description = "A place to discuss general topics", joining_key = "abc123key", - created_by = "user-id-123", + created_by_user_id = "user-id-123", created_by_username = "robert.x.0.gh", created_by_provider = "https://github.com", is_open_room = false, is_archived = false, last_message_at = Some(new java.util.Date()), last_message_preview = Some("Hello everyone!"), - last_message_sender = Some("robert.x.0.gh"), + last_message_sender_username =Some("robert.x.0.gh"), unread_count = Some(3), created_at = new java.util.Date(), updated_at = new java.util.Date() @@ -13120,14 +13120,14 @@ trait APIMethods600 { name = "General Discussion", description = "A place to discuss general topics", joining_key = "abc123key", - created_by = "user-id-123", + created_by_user_id = "user-id-123", created_by_username = "robert.x.0.gh", created_by_provider = "https://github.com", is_open_room = false, is_archived = false, last_message_at = Some(new java.util.Date()), last_message_preview = Some("Hello everyone!"), - last_message_sender = Some("robert.x.0.gh"), + last_message_sender_username =Some("robert.x.0.gh"), unread_count = Some(3), created_at = new java.util.Date(), updated_at = new java.util.Date() @@ -13183,14 +13183,14 @@ trait APIMethods600 { name = "General Discussion", description = "A place to discuss general topics", joining_key = "abc123key", - created_by = "user-id-123", + created_by_user_id = "user-id-123", created_by_username = "robert.x.0.gh", created_by_provider = "https://github.com", is_open_room = false, is_archived = false, last_message_at = Some(new java.util.Date()), last_message_preview = Some("Hello everyone!"), - last_message_sender = Some("robert.x.0.gh"), + last_message_sender_username =Some("robert.x.0.gh"), unread_count = Some(3), created_at = new java.util.Date(), updated_at = new java.util.Date() @@ -13246,14 +13246,14 @@ trait APIMethods600 { name = "Updated Name", description = "Updated description", joining_key = "abc123key", - created_by = "user-id-123", + created_by_user_id = "user-id-123", created_by_username = "robert.x.0.gh", created_by_provider = "https://github.com", is_open_room = false, is_archived = false, last_message_at = Some(new java.util.Date()), last_message_preview = Some("Hello everyone!"), - last_message_sender = Some("robert.x.0.gh"), + last_message_sender_username =Some("robert.x.0.gh"), unread_count = Some(3), created_at = new java.util.Date(), updated_at = new java.util.Date() @@ -13319,14 +13319,14 @@ trait APIMethods600 { name = "Updated Name", description = "Updated description", joining_key = "abc123key", - created_by = "user-id-123", + created_by_user_id = "user-id-123", created_by_username = "robert.x.0.gh", created_by_provider = "https://github.com", is_open_room = false, is_archived = false, last_message_at = Some(new java.util.Date()), last_message_preview = Some("Hello everyone!"), - last_message_sender = Some("robert.x.0.gh"), + last_message_sender_username =Some("robert.x.0.gh"), unread_count = Some(3), created_at = new java.util.Date(), updated_at = new java.util.Date() @@ -13487,14 +13487,14 @@ trait APIMethods600 { name = "General Discussion", description = "A place to discuss general topics", joining_key = "abc123key", - created_by = "user-id-123", + created_by_user_id = "user-id-123", created_by_username = "robert.x.0.gh", created_by_provider = "https://github.com", is_open_room = false, is_archived = true, last_message_at = Some(new java.util.Date()), last_message_preview = Some("Hello everyone!"), - last_message_sender = Some("robert.x.0.gh"), + last_message_sender_username =Some("robert.x.0.gh"), unread_count = Some(3), created_at = new java.util.Date(), updated_at = new java.util.Date() @@ -13552,14 +13552,14 @@ trait APIMethods600 { name = "General Discussion", description = "A place to discuss general topics", joining_key = "abc123key", - created_by = "user-id-123", + created_by_user_id = "user-id-123", created_by_username = "robert.x.0.gh", created_by_provider = "https://github.com", is_open_room = false, is_archived = true, last_message_at = Some(new java.util.Date()), last_message_preview = Some("Hello everyone!"), - last_message_sender = Some("robert.x.0.gh"), + last_message_sender_username =Some("robert.x.0.gh"), unread_count = Some(3), created_at = new java.util.Date(), updated_at = new java.util.Date() @@ -13620,14 +13620,14 @@ trait APIMethods600 { name = "General Discussion", description = "A place to discuss general topics", joining_key = "abc123key", - created_by = "user-id-123", + created_by_user_id = "user-id-123", created_by_username = "username", created_by_provider = "provider", is_open_room = true, is_archived = false, last_message_at = Some(new java.util.Date()), last_message_preview = Some("Hello everyone!"), - last_message_sender = Some("robert.x.0.gh"), + last_message_sender_username =Some("robert.x.0.gh"), unread_count = Some(3), created_at = new java.util.Date(), updated_at = new java.util.Date() @@ -13689,14 +13689,14 @@ trait APIMethods600 { name = "General Discussion", description = "A place to discuss general topics", joining_key = "abc123key", - created_by = "user-id-123", + created_by_user_id = "user-id-123", created_by_username = "username", created_by_provider = "provider", is_open_room = true, is_archived = false, last_message_at = Some(new java.util.Date()), last_message_preview = Some("Hello everyone!"), - last_message_sender = Some("robert.x.0.gh"), + last_message_sender_username =Some("robert.x.0.gh"), unread_count = Some(3), created_at = new java.util.Date(), updated_at = new java.util.Date() @@ -16337,14 +16337,14 @@ trait APIMethods600 { name = "General Discussion", description = "A place to discuss general topics", joining_key = "abc123key", - created_by = "user-id-123", + created_by_user_id = "user-id-123", created_by_username = "robert.x.0.gh", created_by_provider = "https://github.com", is_open_room = false, is_archived = false, last_message_at = Some(new java.util.Date()), last_message_preview = Some("Hello everyone!"), - last_message_sender = Some("robert.x.0.gh"), + last_message_sender_username =Some("robert.x.0.gh"), unread_count = Some(3), created_at = new java.util.Date(), updated_at = new java.util.Date() @@ -16426,14 +16426,14 @@ trait APIMethods600 { name = "DM with robert.x.0.gh", description = "", joining_key = "abc123key", - created_by = "user-id-456", + created_by_user_id = "user-id-456", created_by_username = "alice", created_by_provider = "https://github.com", is_open_room = false, is_archived = false, last_message_at = Some(new java.util.Date()), last_message_preview = Some("Hello!"), - last_message_sender = Some("alice"), + last_message_sender_username =Some("alice"), unread_count = Some(0), created_at = new java.util.Date(), updated_at = new java.util.Date() diff --git a/obp-api/src/main/scala/code/api/v6_0_0/JSONFactory6.0.0.scala b/obp-api/src/main/scala/code/api/v6_0_0/JSONFactory6.0.0.scala index 227f6c9f73..e56c797d30 100644 --- a/obp-api/src/main/scala/code/api/v6_0_0/JSONFactory6.0.0.scala +++ b/obp-api/src/main/scala/code/api/v6_0_0/JSONFactory6.0.0.scala @@ -1215,14 +1215,14 @@ case class ChatRoomJsonV600( name: String, description: String, joining_key: String, - created_by: String, + created_by_user_id: String, created_by_username: String, created_by_provider: String, is_open_room: Boolean, is_archived: Boolean, last_message_at: Option[java.util.Date], last_message_preview: Option[String], - last_message_sender: Option[String], + last_message_sender_username: Option[String], unread_count: Option[Long], created_at: java.util.Date, updated_at: java.util.Date, @@ -3022,7 +3022,7 @@ object JSONFactory600 extends CustomJsonFormats with MdcLoggable { unreadCount: Option[Long] = None, participantCount: Long = 0L ): ChatRoomJsonV600 = { - val creator = code.users.Users.users.vend.getUserByUserId(room.createdBy) + val creator = code.users.Users.users.vend.getUserByUserId(room.createdByUserId) val hasLastMessage = room.lastMessageAt.isDefined ChatRoomJsonV600( chat_room_id = room.chatRoomId, @@ -3030,14 +3030,14 @@ object JSONFactory600 extends CustomJsonFormats with MdcLoggable { name = room.name, description = room.description, joining_key = room.joiningKey, - created_by = room.createdBy, + created_by_user_id = room.createdByUserId, created_by_username = creator.map(_.name).getOrElse(""), created_by_provider = creator.map(_.provider).getOrElse(""), is_open_room = room.isOpenRoom, is_archived = room.isArchived, last_message_at = room.lastMessageAt, last_message_preview = if (hasLastMessage) Some(room.lastMessagePreview) else None, - last_message_sender = if (hasLastMessage) Some(room.lastMessageSender) else None, + last_message_sender_username = if (hasLastMessage) Some(room.lastMessageSenderUsername) else None, unread_count = unreadCount, created_at = room.createdDate, updated_at = room.updatedDate, diff --git a/obp-api/src/main/scala/code/chat/ChatRoomTrait.scala b/obp-api/src/main/scala/code/chat/ChatRoomTrait.scala index 1a4b2a80f5..0bf6836652 100644 --- a/obp-api/src/main/scala/code/chat/ChatRoomTrait.scala +++ b/obp-api/src/main/scala/code/chat/ChatRoomTrait.scala @@ -14,7 +14,7 @@ trait ChatRoomProvider { bankId: String, name: String, description: String, - createdBy: String + createdByUserId: String ): Box[ChatRoomTrait] def getChatRoom(chatRoomId: String): Box[ChatRoomTrait] @@ -56,13 +56,13 @@ trait ChatRoomTrait { def name: String def description: String def joiningKey: String - def createdBy: String + def createdByUserId: String /** Whether this is an "open room" where all users are implicit participants. */ def isOpenRoom: Boolean def isArchived: Boolean def lastMessageAt: Option[Date] def lastMessagePreview: String - def lastMessageSender: String + def lastMessageSenderUsername: String def createdDate: Date def updatedDate: Date } diff --git a/obp-api/src/main/scala/code/chat/MappedChatRoom.scala b/obp-api/src/main/scala/code/chat/MappedChatRoom.scala index 3d61728625..684009c152 100644 --- a/obp-api/src/main/scala/code/chat/MappedChatRoom.scala +++ b/obp-api/src/main/scala/code/chat/MappedChatRoom.scala @@ -13,14 +13,14 @@ object MappedChatRoomProvider extends ChatRoomProvider { bankId: String, name: String, description: String, - createdBy: String + createdByUserId: String ): Box[ChatRoomTrait] = { tryo { ChatRoom.create .BankId(bankId) .Name(name) .Description(description) - .CreatedBy(createdBy) + .CreatedByUserId(createdByUserId) .IsArchived(false) .saveMe() } @@ -130,7 +130,7 @@ object MappedChatRoomProvider extends ChatRoomProvider { tryo { room.LastMessageAt(lastMessageAt) .LastMessagePreview(if (preview.length > 100) preview.substring(0, 100) else preview) - .LastMessageSender(senderUsername) + .LastMessageSenderUsername(senderUsername) .saveMe() } } @@ -165,11 +165,13 @@ object MappedChatRoomProvider extends ChatRoomProvider { case Full(room) => Full(room) case _ => tryo { + // "system" here is a sentinel, not a real user_id — the default room is + // auto-provisioned and has no human creator. Every other caller passes a real user_id. ChatRoom.create .BankId("") .Name("general") .Description("Default system-wide chat room for all users") - .CreatedBy("system") + .CreatedByUserId("system") .IsOpenRoom(true) .IsArchived(false) .saveMe() @@ -187,24 +189,24 @@ class ChatRoom extends ChatRoomTrait with LongKeyedMapper[ChatRoom] with IdPK wi object Name extends MappedString(this, 255) object Description extends MappedText(this) object JoiningKey extends MappedUUID(this) - object CreatedBy extends MappedString(this, 36) + object CreatedByUserId extends MappedString(this, 36) object IsOpenRoom extends MappedBoolean(this) object IsArchived extends MappedBoolean(this) object LastMessageAt extends MappedDateTime(this) object LastMessagePreview extends MappedString(this, 100) - object LastMessageSender extends MappedString(this, 255) + object LastMessageSenderUsername extends MappedString(this, 255) override def chatRoomId: String = ChatRoomId.get override def bankId: String = BankId.get override def name: String = Name.get override def description: String = Description.get override def joiningKey: String = JoiningKey.get - override def createdBy: String = CreatedBy.get + override def createdByUserId: String = CreatedByUserId.get override def isOpenRoom: Boolean = IsOpenRoom.get override def isArchived: Boolean = IsArchived.get override def lastMessageAt: Option[Date] = Option(LastMessageAt.get) override def lastMessagePreview: String = LastMessagePreview.get - override def lastMessageSender: String = LastMessageSender.get + override def lastMessageSenderUsername: String = LastMessageSenderUsername.get override def createdDate: Date = createdAt.get override def updatedDate: Date = updatedAt.get }