From 1b0d1642dcb69d7312b091ac9219ed69747b5ae5 Mon Sep 17 00:00:00 2001 From: simonredfern Date: Mon, 30 Mar 2026 09:51:42 +0100 Subject: [PATCH 1/3] Customer links --- .../main/scala/bootstrap/liftweb/Boot.scala | 2 + .../SwaggerDefinitionsJSON.scala | 69 ++++ .../main/scala/code/api/util/ApiRole.scala | 20 +- .../src/main/scala/code/api/util/ApiTag.scala | 1 + .../scala/code/api/util/ErrorMessages.scala | 5 + .../main/scala/code/api/util/NewStyle.scala | 30 ++ .../scala/code/api/v6_0_0/APIMethods600.scala | 356 ++++++++++++++++++ .../code/api/v6_0_0/JSONFactory6.0.0.scala | 149 ++++++++ .../scala/code/bankconnectors/Connector.scala | 14 +- .../bankconnectors/LocalMappedConnector.scala | 24 ++ .../code/customerlinks/CustomerLink.scala | 38 ++ .../customerlinks/MappedCustomerLink.scala | 93 +++++ 12 files changed, 799 insertions(+), 2 deletions(-) create mode 100644 obp-api/src/main/scala/code/customerlinks/CustomerLink.scala create mode 100644 obp-api/src/main/scala/code/customerlinks/MappedCustomerLink.scala diff --git a/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala b/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala index a67bb12d19..c7ff44a784 100644 --- a/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala +++ b/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala @@ -131,6 +131,7 @@ import code.transaction_types.MappedTransactionType import code.transactionattribute.MappedTransactionAttribute import code.transactionrequests.{MappedTransactionRequest, MappedTransactionRequestTypeCharge, TransactionRequestReasons} import code.usercustomerlinks.MappedUserCustomerLink +import code.customerlinks.MappedCustomerLink import code.userlocks.UserLocks import code.users._ import code.util.Helper.{MdcLoggable, ObpS, SILENCE_IS_GOLDEN} @@ -1161,6 +1162,7 @@ object ToSchemify { MappedNarrative, MappedCustomer, MappedUserCustomerLink, + MappedCustomerLink, Consumer, Token, OpenIDConnectToken, 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 86848b97ae..2771c7ce76 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 @@ -6103,6 +6103,75 @@ object SwaggerDefinitionsJSON { List(counterpartyAttributeResponseJsonV600) ) + lazy val postCustomerLinkJsonV600 = PostCustomerLinkJsonV600( + customer_id = customerIdExample.value, + other_bank_id = bankIdExample.value, + other_customer_id = customerIdExample.value, + relationship_to = "spouse" + ) + + lazy val putCustomerLinkJsonV600 = PutCustomerLinkJsonV600( + relationship_to = "close_associate" + ) + + lazy val customerLinkJsonV600 = CustomerLinkJsonV600( + customer_link_id = "613c83ea-80f9-4560-8404-b9cd4ec42a7f", + bank_id = bankIdExample.value, + customer_id = customerIdExample.value, + other_bank_id = bankIdExample.value, + other_customer_id = customerIdExample.value, + relationship_to = "spouse", + date_inserted = DateWithDayExampleObject, + date_updated = DateWithDayExampleObject + ) + + lazy val customerLinksJsonV600 = CustomerLinksJsonV600( + List(customerLinkJsonV600) + ) + + lazy val investigationTransactionJsonV600 = InvestigationTransactionJsonV600( + transaction_id = transactionIdExample.value, + account_id = accountIdExample.value, + amount = "1250", + currency = currencyExample.value, + transaction_type = "DEBIT", + description = "Payment for consulting services", + start_date = DateWithDayExampleObject, + finish_date = DateWithDayExampleObject, + counterparty_name = "ACME Corp", + counterparty_account = "DE89370400440532013000", + counterparty_bank_name = "Deutsche Bank" + ) + + lazy val investigationAccountJsonV600 = InvestigationAccountJsonV600( + account_id = accountIdExample.value, + bank_id = bankIdExample.value, + currency = currencyExample.value, + balance = "150000", + account_name = "Current Account", + account_type = "CURRENT", + transactions = List(investigationTransactionJsonV600) + ) + + lazy val investigationCustomerLinkJsonV600 = InvestigationCustomerLinkJsonV600( + customer_link_id = "613c83ea-80f9-4560-8404-b9cd4ec42a7f", + other_customer_id = customerIdExample.value, + other_bank_id = bankIdExample.value, + relationship = "spouse", + other_legal_name = "Jane Doe" + ) + + lazy val investigationReportJsonV600 = InvestigationReportJsonV600( + customer_id = customerIdExample.value, + legal_name = "John Doe", + bank_id = bankIdExample.value, + accounts = List(investigationAccountJsonV600), + related_customers = List(investigationCustomerLinkJsonV600), + from_date = DateWithDayExampleObject, + to_date = DateWithDayExampleObject, + data_source = "mapped_database" + ) + lazy val bankAccountBalanceRequestJsonV510 = BankAccountBalanceRequestJsonV510( balance_type = balanceTypeExample.value, balance_amount = balanceAmountExample.value diff --git a/obp-api/src/main/scala/code/api/util/ApiRole.scala b/obp-api/src/main/scala/code/api/util/ApiRole.scala index a12bc5254a..22507687e0 100644 --- a/obp-api/src/main/scala/code/api/util/ApiRole.scala +++ b/obp-api/src/main/scala/code/api/util/ApiRole.scala @@ -317,7 +317,25 @@ object ApiRole extends MdcLoggable{ case class CanGetCustomerAccountLinks(requiresBankId: Boolean = true) extends ApiRole lazy val canGetCustomerAccountLinks = CanGetCustomerAccountLinks() - + + case class CanCreateCustomerLink(requiresBankId: Boolean = true) extends ApiRole + lazy val canCreateCustomerLink = CanCreateCustomerLink() + + case class CanUpdateCustomerLink(requiresBankId: Boolean = true) extends ApiRole + lazy val canUpdateCustomerLink = CanUpdateCustomerLink() + + case class CanDeleteCustomerLink(requiresBankId: Boolean = true) extends ApiRole + lazy val canDeleteCustomerLink = CanDeleteCustomerLink() + + case class CanGetCustomerLink(requiresBankId: Boolean = true) extends ApiRole + lazy val canGetCustomerLink = CanGetCustomerLink() + + case class CanGetCustomerLinks(requiresBankId: Boolean = true) extends ApiRole + lazy val canGetCustomerLinks = CanGetCustomerLinks() + + case class CanGetInvestigationReport(requiresBankId: Boolean = true) extends ApiRole + lazy val canGetInvestigationReport = CanGetInvestigationReport() + case class CanCreateBranch(requiresBankId: Boolean = true) extends ApiRole lazy val canCreateBranch = CanCreateBranch() diff --git a/obp-api/src/main/scala/code/api/util/ApiTag.scala b/obp-api/src/main/scala/code/api/util/ApiTag.scala index f014ad9dbf..f957019e12 100644 --- a/obp-api/src/main/scala/code/api/util/ApiTag.scala +++ b/obp-api/src/main/scala/code/api/util/ApiTag.scala @@ -164,6 +164,7 @@ object ApiTag { val apiTagSignal = ResourceDocTag("Signal") val apiTagSignalling = ResourceDocTag("Signalling") val apiTagChannel = ResourceDocTag("Channel") + val apiTagFinancialCrime = ResourceDocTag("Financial-Crime") private[this] val tagNameSymbolMapTag: MutableMap[String, ResourceDocTag] = MutableMap() 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 61bf2c577c..2d2aa11046 100644 --- a/obp-api/src/main/scala/code/api/util/ErrorMessages.scala +++ b/obp-api/src/main/scala/code/api/util/ErrorMessages.scala @@ -389,6 +389,11 @@ object ErrorMessages { val DefaultBankIdNotSet = "OBP-30044: Default BankId is not set on this instance. Please set defaultBank.bank_id in props files. " val ExcludeParametersNotSupported = "OBP-30146: The exclude_* parameters are not supported in v6.0.0+. Please use the corresponding include_* parameters instead (include_app_names, include_url_patterns, include_implemented_by_partial_functions). " + val CustomerLinkNotFound = "OBP-30147: Customer Link not found. Please specify a valid value for CUSTOMER_LINK_ID." + val CreateCustomerLinkError = "OBP-30148: Could not create the Customer Link." + val UpdateCustomerLinkError = "OBP-30149: Could not update the Customer Link." + val InvestigationReportNotAvailable = "OBP-30150: Investigation Report is only available in mapped mode (connector=mapped)." + val CreateWebhookError = "OBP-30047: Cannot create Webhook" val GetWebhooksError = "OBP-30048: Cannot get Webhooks" val UpdateWebhookError = "OBP-30049: Cannot create Webhook" diff --git a/obp-api/src/main/scala/code/api/util/NewStyle.scala b/obp-api/src/main/scala/code/api/util/NewStyle.scala index 05dbfc2d64..5f0b02b3db 100644 --- a/obp-api/src/main/scala/code/api/util/NewStyle.scala +++ b/obp-api/src/main/scala/code/api/util/NewStyle.scala @@ -4401,6 +4401,36 @@ object NewStyle extends MdcLoggable{ i => (unboxFullOrFail(i._1, callContext, UpdateCustomerAccountLinkError), i._2) } + def createCustomerLink(bankId: String, customerId: String, otherBankId: String, otherCustomerId: String, relationshipTo: String, callContext: Option[CallContext]): OBPReturnType[code.customerlinks.CustomerLink] = + Connector.connector.vend.createCustomerLink(bankId, customerId, otherBankId, otherCustomerId, relationshipTo, callContext) map { + i => (unboxFullOrFail(i._1, callContext, CreateCustomerLinkError), i._2) + } + + def getCustomerLinkById(customerLinkId: String, callContext: Option[CallContext]): OBPReturnType[code.customerlinks.CustomerLink] = + Connector.connector.vend.getCustomerLinkById(customerLinkId, callContext) map { + i => (unboxFullOrFail(i._1, callContext, CustomerLinkNotFound), i._2) + } + + def getCustomerLinksByBankId(bankId: String, callContext: Option[CallContext]): OBPReturnType[List[code.customerlinks.CustomerLink]] = + Connector.connector.vend.getCustomerLinksByBankId(bankId, callContext) map { + i => (unboxFullOrFail(i._1, callContext, CustomerLinkNotFound), i._2) + } + + def getCustomerLinksByCustomerId(customerId: String, callContext: Option[CallContext]): OBPReturnType[List[code.customerlinks.CustomerLink]] = + Connector.connector.vend.getCustomerLinksByCustomerId(customerId, callContext) map { + i => (unboxFullOrFail(i._1, callContext, CustomerLinkNotFound), i._2) + } + + def updateCustomerLinkById(customerLinkId: String, relationshipTo: String, callContext: Option[CallContext]): OBPReturnType[code.customerlinks.CustomerLink] = + Connector.connector.vend.updateCustomerLinkById(customerLinkId, relationshipTo, callContext) map { + i => (unboxFullOrFail(i._1, callContext, UpdateCustomerLinkError), i._2) + } + + def deleteCustomerLinkById(customerLinkId: String, callContext: Option[CallContext]): OBPReturnType[Boolean] = + Connector.connector.vend.deleteCustomerLinkById(customerLinkId, callContext) map { + i => (unboxFullOrFail(i._1, callContext, CustomerLinkNotFound), i._2) + } + def getConsentImplicitSCA(user: User, callContext: Option[CallContext]): OBPReturnType[ConsentImplicitSCAT] = Connector.connector.vend.getConsentImplicitSCA(user: User, callContext: Option[CallContext]) map { i => (unboxFullOrFail(i._1, callContext, GetConsentImplicitSCAError), i._2) 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 e6e09ce346..774e8fb19e 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 @@ -12443,6 +12443,362 @@ trait APIMethods600 { } } + staticResourceDocs += ResourceDoc( + createCustomerLink, + implementedInApiVersion, + nameOf(createCustomerLink), + "POST", + "/banks/BANK_ID/customer-links", + "Create Customer Link", + s"""Link a Customer to another Customer (e.g. spouse, parent, close_associate). + | + |Authentication is Required + | + |""", + postCustomerLinkJsonV600, + customerLinkJsonV600, + List( + $AuthenticatedUserIsRequired, + $BankNotFound, + InvalidJsonFormat, + CustomerNotFoundByCustomerId, + UserHasMissingRoles, + CreateCustomerLinkError, + UnknownError + ), + List(apiTagCustomer), + Some(List(canCreateCustomerLink))) + + lazy val createCustomerLink: OBPEndpoint = { + case "banks" :: BankId(bankId) :: "customer-links" :: Nil JsonPost json -> _ => { + cc => implicit val ec = EndpointContext(Some(cc)) + for { + (_, _, callContext) <- SS.userBank + postedData <- NewStyle.function.tryons(s"$InvalidJsonFormat The Json body should be the $PostCustomerLinkJsonV600 ", 400, callContext) { + json.extract[PostCustomerLinkJsonV600] + } + (customer, callContext) <- NewStyle.function.getCustomerByCustomerId(postedData.customer_id, callContext) + _ <- Helper.booleanToFuture(s"Bank of the customer specified by the CUSTOMER_ID(${customer.bankId}) has to match BANK_ID(${bankId.value}) in URL", 400, callContext) { + customer.bankId == bankId.value + } + (_, callContext) <- NewStyle.function.getBank(BankId(postedData.other_bank_id), callContext) + (otherCustomer, callContext) <- NewStyle.function.getCustomerByCustomerId(postedData.other_customer_id, callContext) + _ <- Helper.booleanToFuture(s"Bank of the other customer specified by the OTHER_CUSTOMER_ID(${otherCustomer.bankId}) has to match OTHER_BANK_ID(${postedData.other_bank_id})", 400, callContext) { + otherCustomer.bankId == postedData.other_bank_id + } + (customerLink, callContext) <- NewStyle.function.createCustomerLink(bankId.value, postedData.customer_id, postedData.other_bank_id, postedData.other_customer_id, postedData.relationship_to, callContext) + } yield { + (JSONFactory600.createCustomerLinkJson(customerLink), HttpCode.`201`(callContext)) + } + } + } + + staticResourceDocs += ResourceDoc( + getCustomerLinksByCustomerId, + implementedInApiVersion, + nameOf(getCustomerLinksByCustomerId), + "GET", + "/banks/BANK_ID/customers/CUSTOMER_ID/customer-links", + "Get Customer Links by CUSTOMER_ID", + s"""Get Customer Links by CUSTOMER_ID. + | + |Authentication is Required + | + |""", + EmptyBody, + customerLinksJsonV600, + List( + $AuthenticatedUserIsRequired, + $BankNotFound, + CustomerNotFoundByCustomerId, + UserHasMissingRoles, + UnknownError + ), + List(apiTagCustomer), + Some(List(canGetCustomerLinks))) + + lazy val getCustomerLinksByCustomerId: OBPEndpoint = { + case "banks" :: BankId(bankId) :: "customers" :: customerId :: "customer-links" :: Nil JsonGet _ => { + cc => implicit val ec = EndpointContext(Some(cc)) + for { + (_, _, callContext) <- SS.userBank + (_, callContext) <- NewStyle.function.getCustomerByCustomerId(customerId, callContext) + (customerLinks, callContext) <- NewStyle.function.getCustomerLinksByCustomerId(customerId, callContext) + } yield { + (JSONFactory600.createCustomerLinksJson(customerLinks), HttpCode.`200`(callContext)) + } + } + } + + staticResourceDocs += ResourceDoc( + getCustomerLinksByBankId, + implementedInApiVersion, + nameOf(getCustomerLinksByBankId), + "GET", + "/banks/BANK_ID/customer-links", + "Get Customer Links at Bank", + s"""Get all Customer Links at a Bank. + | + |Authentication is Required + | + |""", + EmptyBody, + customerLinksJsonV600, + List( + $AuthenticatedUserIsRequired, + $BankNotFound, + UserHasMissingRoles, + UnknownError + ), + List(apiTagCustomer), + Some(List(canGetCustomerLinks))) + + lazy val getCustomerLinksByBankId: OBPEndpoint = { + case "banks" :: BankId(bankId) :: "customer-links" :: Nil JsonGet _ => { + cc => implicit val ec = EndpointContext(Some(cc)) + for { + (_, _, callContext) <- SS.userBank + (customerLinks, callContext) <- NewStyle.function.getCustomerLinksByBankId(bankId.value, callContext) + } yield { + (JSONFactory600.createCustomerLinksJson(customerLinks), HttpCode.`200`(callContext)) + } + } + } + + staticResourceDocs += ResourceDoc( + getCustomerLinkById, + implementedInApiVersion, + nameOf(getCustomerLinkById), + "GET", + "/banks/BANK_ID/customer-links/CUSTOMER_LINK_ID", + "Get Customer Link by CUSTOMER_LINK_ID", + s"""Get Customer Link by CUSTOMER_LINK_ID. + | + |Authentication is Required + | + |""", + EmptyBody, + customerLinkJsonV600, + List( + $AuthenticatedUserIsRequired, + $BankNotFound, + CustomerLinkNotFound, + UserHasMissingRoles, + UnknownError + ), + List(apiTagCustomer), + Some(List(canGetCustomerLink))) + + lazy val getCustomerLinkById: OBPEndpoint = { + case "banks" :: BankId(bankId) :: "customer-links" :: customerLinkId :: Nil JsonGet _ => { + cc => implicit val ec = EndpointContext(Some(cc)) + for { + (_, _, callContext) <- SS.userBank + (customerLink, callContext) <- NewStyle.function.getCustomerLinkById(customerLinkId, callContext) + } yield { + (JSONFactory600.createCustomerLinkJson(customerLink), HttpCode.`200`(callContext)) + } + } + } + + staticResourceDocs += ResourceDoc( + updateCustomerLink, + implementedInApiVersion, + nameOf(updateCustomerLink), + "PUT", + "/banks/BANK_ID/customer-links/CUSTOMER_LINK_ID", + "Update Customer Link", + s"""Update an existing Customer Link. + | + |Authentication is Required + | + |""", + putCustomerLinkJsonV600, + customerLinkJsonV600, + List( + $AuthenticatedUserIsRequired, + $BankNotFound, + InvalidJsonFormat, + CustomerLinkNotFound, + UserHasMissingRoles, + UpdateCustomerLinkError, + UnknownError + ), + List(apiTagCustomer), + Some(List(canUpdateCustomerLink))) + + lazy val updateCustomerLink: OBPEndpoint = { + case "banks" :: BankId(bankId) :: "customer-links" :: customerLinkId :: Nil JsonPut json -> _ => { + cc => implicit val ec = EndpointContext(Some(cc)) + for { + (_, _, callContext) <- SS.userBank + postedData <- NewStyle.function.tryons(s"$InvalidJsonFormat The Json body should be the $PutCustomerLinkJsonV600 ", 400, callContext) { + json.extract[PutCustomerLinkJsonV600] + } + (customerLink, callContext) <- NewStyle.function.updateCustomerLinkById(customerLinkId, postedData.relationship_to, callContext) + } yield { + (JSONFactory600.createCustomerLinkJson(customerLink), HttpCode.`200`(callContext)) + } + } + } + + staticResourceDocs += ResourceDoc( + deleteCustomerLink, + implementedInApiVersion, + nameOf(deleteCustomerLink), + "DELETE", + "/banks/BANK_ID/customer-links/CUSTOMER_LINK_ID", + "Delete Customer Link", + s"""Delete a Customer Link. + | + |Authentication is Required + | + |""", + EmptyBody, + EmptyBody, + List( + $AuthenticatedUserIsRequired, + $BankNotFound, + CustomerLinkNotFound, + UserHasMissingRoles, + UnknownError + ), + List(apiTagCustomer), + Some(List(canDeleteCustomerLink))) + + lazy val deleteCustomerLink: OBPEndpoint = { + case "banks" :: BankId(bankId) :: "customer-links" :: customerLinkId :: Nil JsonDelete _ => { + cc => implicit val ec = EndpointContext(Some(cc)) + for { + (_, _, callContext) <- SS.userBank + (_, callContext) <- NewStyle.function.deleteCustomerLinkById(customerLinkId, callContext) + } yield { + (Full(true), HttpCode.`204`(callContext)) + } + } + } + + + staticResourceDocs += ResourceDoc( + getCustomerInvestigationReport, + implementedInApiVersion, + nameOf(getCustomerInvestigationReport), + "GET", + "/banks/BANK_ID/customers/CUSTOMER_ID/investigation-report", + "Get Customer Investigation Report", + s"""Get a Customer Investigation Report for fraud detection, AML (Anti-Money Laundering), and financial crime analysis. + | + |This endpoint assembles a comprehensive data package for a customer in a single API call, + |designed for use by AI agents, compliance officers, and financial crime investigators. + | + |**Use Cases:** + | + |* Fraud Detection - identify suspicious transaction patterns + |* AML / Anti-Money Laundering - trace fund flows and flag anomalies + |* KYC Enhanced Due Diligence - deep-dive into customer activity + |* Suspicious Activity Report (SAR) preparation + |* Financial crime investigation and evidence gathering + | + |**Data Returned:** + | + |* Customer details (legal name, KYC status) + |* All accounts linked to the customer (with balances) + |* Transaction history for those accounts (within the specified date range) + |* Related customers (via customer links) — spouses, associates, business partners + | + |**Suspicious Patterns This Data Supports Detecting:** + | + |* Money flowing through intermediary companies (A to B to C patterns) + |* Payments inconsistent with known income or salary + |* Transfers to related parties (spouses, associates) shortly after large inflows + |* Round-tripping — money returning to origin via indirect paths + |* Vague or generic transaction descriptions on large amounts + |* Structuring — multiple transactions just below reporting thresholds + |* Rapid movement of funds across accounts (layering) + | + |**Query Parameters:** + | + |* from_date: Start date for transactions (ISO format, e.g. $DateWithMsExampleString). Defaults to 1 year ago. + |* to_date: End date for transactions (ISO format, e.g. $DateWithMsExampleString). Defaults to now. + |* limit: Maximum number of transactions per account (default 500). + | + |**Note:** This endpoint is only available in mapped mode (connector=mapped). + |For other connector configurations, use the individual endpoints to retrieve + |customer, account, transaction, and customer link data separately. + | + |Authentication is Required + | + |""", + EmptyBody, + investigationReportJsonV600, + List( + $AuthenticatedUserIsRequired, + $BankNotFound, + CustomerNotFoundByCustomerId, + InvestigationReportNotAvailable, + UserHasMissingRoles, + UnknownError + ), + List(apiTagCustomer, apiTagKyc, apiTagTransaction, apiTagAccount, apiTagFinancialCrime, apiTagAiAgent), + Some(List(canGetInvestigationReport))) + + lazy val getCustomerInvestigationReport: OBPEndpoint = { + case "banks" :: BankId(bankId) :: "customers" :: customerId :: "investigation-report" :: Nil JsonGet _ => { + cc => implicit val ec = EndpointContext(Some(cc)) + for { + (_, _, callContext) <- SS.userBank + // Check connector is mapped + connectorName = code.api.Constant.CONNECTOR.openOrThrowException("connector not set") + _ <- Helper.booleanToFuture(failMsg = InvestigationReportNotAvailable, cc = callContext) { + connectorName == "mapped" + } + // Validate customer exists + (customer, callContext) <- NewStyle.function.getCustomerByCustomerId(customerId, callContext) + _ <- Helper.booleanToFuture(failMsg = s"Customer bank (${customer.bankId}) does not match BANK_ID (${bankId.value})", 400, callContext) { + customer.bankId == bankId.value + } + // Parse query params + fromDateStr = ObpS.param("from_date") + toDateStr = ObpS.param("to_date") + limitStr = ObpS.param("limit") + fromDate = fromDateStr.flatMap(d => APIUtil.parseDate(d)).getOrElse { + new java.util.Date(System.currentTimeMillis() - 365L * 24 * 60 * 60 * 1000) + } + toDate = toDateStr.flatMap(d => APIUtil.parseDate(d)).getOrElse { + new java.util.Date() + } + limit = limitStr.flatMap(s => tryo(s.toInt)).getOrElse(500) + // Run Doobie queries + accounts <- Future { + code.investigation.DoobieInvestigationQueries.getAccountsForCustomer(customerId) + } + accountIds = accounts.map(_.accountId) + transactions <- Future { + code.investigation.DoobieInvestigationQueries.getTransactionsForAccounts( + accountIds, bankId.value, + new java.sql.Timestamp(fromDate.getTime), + new java.sql.Timestamp(toDate.getTime), + limit + ) + } + customerLinks <- Future { + code.investigation.DoobieInvestigationQueries.getCustomerLinks(customerId) + } + customerRow = code.investigation.DoobieInvestigationQueries.CustomerRow( + customerId = customer.customerId, + legalName = customer.legalName, + email = customer.email, + mobileNumber = customer.mobileNumber, + kycStatus = customer.kycStatus + ) + } yield { + (JSONFactory600.createInvestigationReportJson( + customerRow, bankId.value, accounts, transactions, customerLinks, fromDate, toDate + ), HttpCode.`200`(callContext)) + } + } + } + } } 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 8b0288c740..cd4b3cef3d 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 @@ -86,6 +86,32 @@ case class CounterpartyAttributesJsonV600( attributes: List[CounterpartyAttributeResponseJsonV600] ) +case class PostCustomerLinkJsonV600( + customer_id: String, + other_bank_id: String, + other_customer_id: String, + relationship_to: String +) + +case class PutCustomerLinkJsonV600( + relationship_to: String +) + +case class CustomerLinkJsonV600( + customer_link_id: String, + bank_id: String, + customer_id: String, + other_bank_id: String, + other_customer_id: String, + relationship_to: String, + date_inserted: Date, + date_updated: Date +) + +case class CustomerLinksJsonV600( + customer_links: List[CustomerLinkJsonV600] +) + case class CardanoPaymentJsonV600( address: String, amount: CardanoAmountJsonV600, @@ -1102,6 +1128,50 @@ case class SignalChannelDeletedJsonV600( deleted: Boolean ) +// Investigation Report +case class InvestigationTransactionJsonV600( + transaction_id: String, + account_id: String, + amount: String, + currency: String, + transaction_type: String, + description: String, + start_date: java.util.Date, + finish_date: java.util.Date, + counterparty_name: String, + counterparty_account: String, + counterparty_bank_name: String +) + +case class InvestigationAccountJsonV600( + account_id: String, + bank_id: String, + currency: String, + balance: String, + account_name: String, + account_type: String, + transactions: List[InvestigationTransactionJsonV600] +) + +case class InvestigationCustomerLinkJsonV600( + customer_link_id: String, + other_customer_id: String, + other_bank_id: String, + relationship: String, + other_legal_name: String +) + +case class InvestigationReportJsonV600( + customer_id: String, + legal_name: String, + bank_id: String, + accounts: List[InvestigationAccountJsonV600], + related_customers: List[InvestigationCustomerLinkJsonV600], + from_date: java.util.Date, + to_date: java.util.Date, + data_source: String +) + object JSONFactory600 extends CustomJsonFormats with MdcLoggable { def createRedisCallCountersJson( @@ -2730,4 +2800,83 @@ object JSONFactory600 extends CustomJsonFormats with MdcLoggable { ) } + def createCustomerLinkJson(customerLink: code.customerlinks.CustomerLink): CustomerLinkJsonV600 = { + CustomerLinkJsonV600( + customer_link_id = customerLink.customerLinkId, + bank_id = customerLink.bankId, + customer_id = customerLink.customerId, + other_bank_id = customerLink.otherBankId, + other_customer_id = customerLink.otherCustomerId, + relationship_to = customerLink.relationshipTo, + date_inserted = customerLink.dateInserted, + date_updated = customerLink.dateUpdated + ) + } + + def createCustomerLinksJson(customerLinks: List[code.customerlinks.CustomerLink]): CustomerLinksJsonV600 = { + CustomerLinksJsonV600( + customerLinks.map(createCustomerLinkJson) + ) + } + + def createInvestigationReportJson( + customer: code.investigation.DoobieInvestigationQueries.CustomerRow, + bankId: String, + accounts: List[code.investigation.DoobieInvestigationQueries.AccountRow], + transactions: List[code.investigation.DoobieInvestigationQueries.TransactionRow], + customerLinks: List[code.investigation.DoobieInvestigationQueries.CustomerLinkRow], + fromDate: java.util.Date, + toDate: java.util.Date + ): InvestigationReportJsonV600 = { + val transactionsByAccount = transactions.groupBy(_.accountId) + + val accountJsons = accounts.map { acc => + val txns = transactionsByAccount.getOrElse(acc.accountId, Nil) + InvestigationAccountJsonV600( + account_id = acc.accountId, + bank_id = acc.bankId, + currency = acc.currency, + balance = acc.balance.toString, + account_name = acc.accountName, + account_type = acc.accountType, + transactions = txns.map { t => + InvestigationTransactionJsonV600( + transaction_id = t.transactionId, + account_id = t.accountId, + amount = t.amount.toString, + currency = t.currency, + transaction_type = t.transactionType, + description = t.description, + start_date = t.startDate, + finish_date = t.finishDate, + counterparty_name = t.counterpartyName, + counterparty_account = t.counterpartyAccount, + counterparty_bank_name = t.counterpartyBankName + ) + } + ) + } + + val relatedCustomerJsons = customerLinks.map { cl => + InvestigationCustomerLinkJsonV600( + customer_link_id = cl.customerLinkId, + other_customer_id = cl.otherCustomerId, + other_bank_id = cl.otherBankId, + relationship = cl.relationship, + other_legal_name = cl.otherLegalName + ) + } + + InvestigationReportJsonV600( + customer_id = customer.customerId, + legal_name = customer.legalName, + bank_id = bankId, + accounts = accountJsons, + related_customers = relatedCustomerJsons, + from_date = fromDate, + to_date = toDate, + data_source = "mapped_database" + ) + } + } diff --git a/obp-api/src/main/scala/code/bankconnectors/Connector.scala b/obp-api/src/main/scala/code/bankconnectors/Connector.scala index 86e14edf90..fbe5bcd286 100644 --- a/obp-api/src/main/scala/code/bankconnectors/Connector.scala +++ b/obp-api/src/main/scala/code/bankconnectors/Connector.scala @@ -1876,7 +1876,19 @@ trait Connector extends MdcLoggable { def createAgentAccountLink(agentId: String, bankId: String, accountId: String, callContext: Option[CallContext]): OBPReturnType[Box[AgentAccountLinkTrait]] = Future{(Failure(setUnimplementedError(nameOf(createAgentAccountLink _))), callContext)} def updateCustomerAccountLinkById(customerAccountLinkId: String, relationshipType: String, callContext: Option[CallContext]): OBPReturnType[Box[CustomerAccountLinkTrait]] = Future{(Failure(setUnimplementedError(nameOf(updateCustomerAccountLinkById _))), callContext)} - + + def createCustomerLink(bankId: String, customerId: String, otherBankId: String, otherCustomerId: String, relationshipTo: String, callContext: Option[CallContext]): OBPReturnType[Box[code.customerlinks.CustomerLink]] = Future{(Failure(setUnimplementedError(nameOf(createCustomerLink _))), callContext)} + + def getCustomerLinkById(customerLinkId: String, callContext: Option[CallContext]): OBPReturnType[Box[code.customerlinks.CustomerLink]] = Future{(Failure(setUnimplementedError(nameOf(getCustomerLinkById _))), callContext)} + + def getCustomerLinksByBankId(bankId: String, callContext: Option[CallContext]): OBPReturnType[Box[List[code.customerlinks.CustomerLink]]] = Future{(Failure(setUnimplementedError(nameOf(getCustomerLinksByBankId _))), callContext)} + + def getCustomerLinksByCustomerId(customerId: String, callContext: Option[CallContext]): OBPReturnType[Box[List[code.customerlinks.CustomerLink]]] = Future{(Failure(setUnimplementedError(nameOf(getCustomerLinksByCustomerId _))), callContext)} + + def updateCustomerLinkById(customerLinkId: String, relationshipTo: String, callContext: Option[CallContext]): OBPReturnType[Box[code.customerlinks.CustomerLink]] = Future{(Failure(setUnimplementedError(nameOf(updateCustomerLinkById _))), callContext)} + + def deleteCustomerLinkById(customerLinkId: String, callContext: Option[CallContext]): OBPReturnType[Box[Boolean]] = Future{(Failure(setUnimplementedError(nameOf(deleteCustomerLinkById _))), callContext)} + def getConsentImplicitSCA(user: User, callContext: Option[CallContext]): OBPReturnType[Box[ConsentImplicitSCAT]] = Future{(Failure(setUnimplementedError(nameOf(getConsentImplicitSCA _))), callContext)} def createOrUpdateCounterpartyLimit( diff --git a/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala b/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala index 4be9a1723c..a06c978dd2 100644 --- a/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala +++ b/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala @@ -5508,6 +5508,30 @@ object LocalMappedConnector extends Connector with MdcLoggable { } } + override def createCustomerLink(bankId: String, customerId: String, otherBankId: String, otherCustomerId: String, relationshipTo: String, callContext: Option[CallContext]): OBPReturnType[Box[code.customerlinks.CustomerLink]] = Future{ + (code.customerlinks.CustomerLinkX.customerLink.vend.createCustomerLink(bankId, customerId, otherBankId, otherCustomerId, relationshipTo), callContext) + } + + override def getCustomerLinkById(customerLinkId: String, callContext: Option[CallContext]): OBPReturnType[Box[code.customerlinks.CustomerLink]] = Future{ + (code.customerlinks.CustomerLinkX.customerLink.vend.getCustomerLinkById(customerLinkId), callContext) + } + + override def getCustomerLinksByBankId(bankId: String, callContext: Option[CallContext]): OBPReturnType[Box[List[code.customerlinks.CustomerLink]]] = Future{ + (code.customerlinks.CustomerLinkX.customerLink.vend.getCustomerLinksByBankId(bankId), callContext) + } + + override def getCustomerLinksByCustomerId(customerId: String, callContext: Option[CallContext]): OBPReturnType[Box[List[code.customerlinks.CustomerLink]]] = Future{ + (code.customerlinks.CustomerLinkX.customerLink.vend.getCustomerLinksByCustomerId(customerId), callContext) + } + + override def updateCustomerLinkById(customerLinkId: String, relationshipTo: String, callContext: Option[CallContext]): OBPReturnType[Box[code.customerlinks.CustomerLink]] = Future{ + (code.customerlinks.CustomerLinkX.customerLink.vend.updateCustomerLinkById(customerLinkId, relationshipTo), callContext) + } + + override def deleteCustomerLinkById(customerLinkId: String, callContext: Option[CallContext]): OBPReturnType[Box[Boolean]] = { + code.customerlinks.CustomerLinkX.customerLink.vend.deleteCustomerLinkById(customerLinkId).map{(_, callContext)} + } + override def getConsentImplicitSCA(user: User, callContext: Option[CallContext]): OBPReturnType[Box[ConsentImplicitSCAT]] = Future { //find the email from the user, and the OBP Implicit SCA is email (Full(ConsentImplicitSCA( diff --git a/obp-api/src/main/scala/code/customerlinks/CustomerLink.scala b/obp-api/src/main/scala/code/customerlinks/CustomerLink.scala new file mode 100644 index 0000000000..bf8544a90d --- /dev/null +++ b/obp-api/src/main/scala/code/customerlinks/CustomerLink.scala @@ -0,0 +1,38 @@ +package code.customerlinks + +import java.util.Date + +import net.liftweb.common.Box +import net.liftweb.util.SimpleInjector + +import scala.concurrent.Future + + +object CustomerLinkX extends SimpleInjector { + + val customerLink = new Inject(buildOne _) {} + + def buildOne: CustomerLinkProvider = MappedCustomerLinkProvider + +} + +trait CustomerLinkProvider { + def createCustomerLink(bankId: String, customerId: String, otherBankId: String, otherCustomerId: String, relationshipTo: String): Box[CustomerLink] + def getCustomerLinkById(customerLinkId: String): Box[CustomerLink] + def getCustomerLinksByBankId(bankId: String): Box[List[CustomerLink]] + def getCustomerLinksByCustomerId(customerId: String): Box[List[CustomerLink]] + def updateCustomerLinkById(customerLinkId: String, relationshipTo: String): Box[CustomerLink] + def deleteCustomerLinkById(customerLinkId: String): Future[Box[Boolean]] + def bulkDeleteCustomerLinks(): Boolean +} + +trait CustomerLink { + def customerLinkId: String + def bankId: String + def customerId: String + def otherBankId: String + def otherCustomerId: String + def relationshipTo: String + def dateInserted: Date + def dateUpdated: Date +} diff --git a/obp-api/src/main/scala/code/customerlinks/MappedCustomerLink.scala b/obp-api/src/main/scala/code/customerlinks/MappedCustomerLink.scala new file mode 100644 index 0000000000..c587d37a69 --- /dev/null +++ b/obp-api/src/main/scala/code/customerlinks/MappedCustomerLink.scala @@ -0,0 +1,93 @@ +package code.customerlinks + +import java.util.Date + +import code.api.util.ErrorMessages +import code.util.{MappedUUID, UUIDString} +import net.liftweb.common.{Box, Empty, Failure, Full} +import net.liftweb.mapper._ +import net.liftweb.util.Helpers.tryo + +import scala.concurrent.Future +import com.openbankproject.commons.ExecutionContext.Implicits.global + +object MappedCustomerLinkProvider extends CustomerLinkProvider { + override def createCustomerLink(bankId: String, customerId: String, otherBankId: String, otherCustomerId: String, relationshipTo: String): Box[CustomerLink] = { + tryo { + MappedCustomerLink.create + .mBankId(bankId) + .mCustomerId(customerId) + .mOtherBankId(otherBankId) + .mOtherCustomerId(otherCustomerId) + .mRelationshipTo(relationshipTo) + .saveMe() + } + } + + override def getCustomerLinkById(customerLinkId: String): Box[CustomerLink] = { + MappedCustomerLink.find( + By(MappedCustomerLink.mCustomerLinkId, customerLinkId) + ) + } + + override def getCustomerLinksByBankId(bankId: String): Box[List[CustomerLink]] = { + tryo { + MappedCustomerLink.findAll( + By(MappedCustomerLink.mBankId, bankId)) + } + } + + override def getCustomerLinksByCustomerId(customerId: String): Box[List[CustomerLink]] = { + tryo { + MappedCustomerLink.findAll( + By(MappedCustomerLink.mCustomerId, customerId)) + } + } + + override def updateCustomerLinkById(customerLinkId: String, relationshipTo: String): Box[CustomerLink] = { + MappedCustomerLink.find(By(MappedCustomerLink.mCustomerLinkId, customerLinkId)) match { + case Full(t) => Full(t.mRelationshipTo(relationshipTo).saveMe()) + case Empty => Empty ?~! ErrorMessages.CustomerLinkNotFound + case Failure(msg, exception, chain) => Failure(msg, exception, chain) + } + } + + override def deleteCustomerLinkById(customerLinkId: String): Future[Box[Boolean]] = { + Future { + MappedCustomerLink.find(By(MappedCustomerLink.mCustomerLinkId, customerLinkId)) match { + case Full(t) => Full(t.delete_!) + case Empty => Empty ?~! ErrorMessages.CustomerLinkNotFound + case Failure(msg, exception, chain) => Failure(msg, exception, chain) + } + } + } + + override def bulkDeleteCustomerLinks(): Boolean = { + MappedCustomerLink.bulkDelete_!!() + } +} + +class MappedCustomerLink extends CustomerLink with LongKeyedMapper[MappedCustomerLink] with IdPK with CreatedUpdated { + + def getSingleton = MappedCustomerLink + + object mCustomerLinkId extends MappedUUID(this) + object mBankId extends MappedString(this, 255) + object mCustomerId extends UUIDString(this) + object mOtherBankId extends MappedString(this, 255) + object mOtherCustomerId extends UUIDString(this) + object mRelationshipTo extends MappedString(this, 255) + + override def customerLinkId: String = mCustomerLinkId.get + override def bankId: String = mBankId.get + override def customerId: String = mCustomerId.get + override def otherBankId: String = mOtherBankId.get + override def otherCustomerId: String = mOtherCustomerId.get + override def relationshipTo: String = mRelationshipTo.get + override def dateInserted: Date = createdAt.get + override def dateUpdated: Date = updatedAt.get +} + +object MappedCustomerLink extends MappedCustomerLink with LongKeyedMetaMapper[MappedCustomerLink] { + override def dbIndexes = UniqueIndex(mCustomerLinkId) :: Index(mCustomerId) :: Index(mOtherCustomerId) :: super.dbIndexes +} From eaf6bec147f78c6cc32d2527426b440f2064d6fa Mon Sep 17 00:00:00 2001 From: simonredfern Date: Thu, 2 Apr 2026 16:35:55 +0100 Subject: [PATCH 2/3] Improved error on delete entitlement --- obp-api/src/main/scala/code/api/util/ErrorMessages.scala | 2 +- obp-api/src/main/scala/code/api/v2_0_0/APIMethods200.scala | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 2d2aa11046..fa688327f1 100644 --- a/obp-api/src/main/scala/code/api/util/ErrorMessages.scala +++ b/obp-api/src/main/scala/code/api/util/ErrorMessages.scala @@ -921,7 +921,7 @@ object ErrorMessages { MeetingApiKeyNotConfigured -> 403, MeetingApiSecretNotConfigured -> 403, EntitlementNotFound -> 404, - EntitlementCannotBeDeleted -> 404, + EntitlementCannotBeDeleted -> 500, ConsentStatusIssue -> 401, ConsentDisabled -> 401, InternalServerError -> 500, diff --git a/obp-api/src/main/scala/code/api/v2_0_0/APIMethods200.scala b/obp-api/src/main/scala/code/api/v2_0_0/APIMethods200.scala index 0e702593f7..2270a84946 100644 --- a/obp-api/src/main/scala/code/api/v2_0_0/APIMethods200.scala +++ b/obp-api/src/main/scala/code/api/v2_0_0/APIMethods200.scala @@ -1921,7 +1921,7 @@ trait APIMethods200 { } map { unboxFull(_) } _ <- Helper.booleanToFuture(UserDoesNotHaveEntitlement, cc=callContext) { entitlement.userId == userId } deleted <- Future(Entitlement.entitlement.vend.deleteEntitlement(Some(entitlement))) map { - x => fullBoxOrException(x ~> APIFailureNewStyle(EntitlementCannotBeDeleted, 404, callContext.map(_.toLight))) + x => fullBoxOrException(x ~> APIFailureNewStyle(EntitlementCannotBeDeleted, 500, callContext.map(_.toLight))) } map { unboxFull(_) } } yield (deleted, HttpCode.`204`(cc.callContext)) } From 30f606812d143afc274779ec555b2239e0ecbc18 Mon Sep 17 00:00:00 2001 From: simonredfern Date: Sun, 5 Apr 2026 22:56:28 +0200 Subject: [PATCH 3/3] REST and gRPC chat room endpoints and service. AuthHeaderParser.scala --- flushall_build_and_run.sh | 14 +- obp-api/pom.xml | 19 +- obp-api/src/main/protobuf/chat.proto | 68 + .../resources/props/sample.props.template | 9 +- .../main/scala/bootstrap/liftweb/Boot.scala | 16 +- .../main/scala/code/api/util/ApiRole.scala | 14 + .../src/main/scala/code/api/util/ApiTag.scala | 1 + .../code/api/util/AuthHeaderParser.scala | 87 + .../scala/code/api/util/ErrorMessages.scala | 16 + .../main/scala/code/api/util/Glossary.scala | 77 + .../main/scala/code/api/util/NewStyle.scala | 10 +- .../code/api/util/http4s/Http4sSupport.scala | 63 +- .../code/api/v3_1_0/JSONFactory3.1.0.scala | 4 +- .../scala/code/api/v6_0_0/APIMethods600.scala | 3654 +++++++++++++++++ .../code/api/v6_0_0/JSONFactory6.0.0.scala | 212 +- .../scala/code/bankconnectors/Connector.scala | 10 +- .../bankconnectors/LocalMappedConnector.scala | 10 +- .../code/bankconnectors/grpc/GrpcUtils.scala | 4 +- .../main/scala/code/chat/ChatEventBus.scala | 156 + .../scala/code/chat/ChatEventPublisher.scala | 112 + .../scala/code/chat/ChatMessageTrait.scala | 47 + .../scala/code/chat/ChatPermissions.scala | 65 + .../main/scala/code/chat/ChatRoomTrait.scala | 50 + .../code/chat/DoobieChatMessageQueries.scala | 96 + .../scala/code/chat/MappedChatMessage.scala | 135 + .../main/scala/code/chat/MappedChatRoom.scala | 158 + .../scala/code/chat/MappedParticipant.scala | 154 + .../main/scala/code/chat/MappedReaction.scala | 67 + .../scala/code/chat/ParticipantTrait.scala | 53 + .../main/scala/code/chat/ReactionTrait.scala | 25 + .../code/customerlinks/CustomerLink.scala | 12 +- .../customerlinks/MappedCustomerLink.scala | 75 +- .../DoobieInvestigationQueries.scala | 176 + .../src/main/scala/code/obp/grpc/Client.scala | 2 +- ...oWorldServer.scala => ObpGrpcServer.scala} | 23 +- .../code/obp/grpc/chat/AuthInterceptor.scala | 97 + .../obp/grpc/chat/ChatStreamServiceImpl.scala | 324 ++ .../obp/grpc/chat/api/ChatMessageEvent.scala | 437 ++ .../code/obp/grpc/chat/api/ChatProto.scala | 148 + .../grpc/chat/api/ChatStreamServiceGrpc.scala | 114 + .../obp/grpc/chat/api/PresenceEvent.scala | 164 + .../grpc/chat/api/StreamMessagesRequest.scala | 98 + .../grpc/chat/api/StreamPresenceRequest.scala | 98 + .../chat/api/StreamUnreadCountsRequest.scala | 77 + .../code/obp/grpc/chat/api/TypingEvent.scala | 120 + .../obp/grpc/chat/api/TypingIndicator.scala | 186 + .../obp/grpc/chat/api/UnreadCountEvent.scala | 120 + 47 files changed, 7536 insertions(+), 141 deletions(-) create mode 100644 obp-api/src/main/protobuf/chat.proto create mode 100644 obp-api/src/main/scala/code/api/util/AuthHeaderParser.scala create mode 100644 obp-api/src/main/scala/code/chat/ChatEventBus.scala create mode 100644 obp-api/src/main/scala/code/chat/ChatEventPublisher.scala create mode 100644 obp-api/src/main/scala/code/chat/ChatMessageTrait.scala create mode 100644 obp-api/src/main/scala/code/chat/ChatPermissions.scala create mode 100644 obp-api/src/main/scala/code/chat/ChatRoomTrait.scala create mode 100644 obp-api/src/main/scala/code/chat/DoobieChatMessageQueries.scala create mode 100644 obp-api/src/main/scala/code/chat/MappedChatMessage.scala create mode 100644 obp-api/src/main/scala/code/chat/MappedChatRoom.scala create mode 100644 obp-api/src/main/scala/code/chat/MappedParticipant.scala create mode 100644 obp-api/src/main/scala/code/chat/MappedReaction.scala create mode 100644 obp-api/src/main/scala/code/chat/ParticipantTrait.scala create mode 100644 obp-api/src/main/scala/code/chat/ReactionTrait.scala create mode 100644 obp-api/src/main/scala/code/investigation/DoobieInvestigationQueries.scala rename obp-api/src/main/scala/code/obp/grpc/{HelloWorldServer.scala => ObpGrpcServer.scala} (85%) create mode 100644 obp-api/src/main/scala/code/obp/grpc/chat/AuthInterceptor.scala create mode 100644 obp-api/src/main/scala/code/obp/grpc/chat/ChatStreamServiceImpl.scala create mode 100644 obp-api/src/main/scala/code/obp/grpc/chat/api/ChatMessageEvent.scala create mode 100644 obp-api/src/main/scala/code/obp/grpc/chat/api/ChatProto.scala create mode 100644 obp-api/src/main/scala/code/obp/grpc/chat/api/ChatStreamServiceGrpc.scala create mode 100644 obp-api/src/main/scala/code/obp/grpc/chat/api/PresenceEvent.scala create mode 100644 obp-api/src/main/scala/code/obp/grpc/chat/api/StreamMessagesRequest.scala create mode 100644 obp-api/src/main/scala/code/obp/grpc/chat/api/StreamPresenceRequest.scala create mode 100644 obp-api/src/main/scala/code/obp/grpc/chat/api/StreamUnreadCountsRequest.scala create mode 100644 obp-api/src/main/scala/code/obp/grpc/chat/api/TypingEvent.scala create mode 100644 obp-api/src/main/scala/code/obp/grpc/chat/api/TypingIndicator.scala create mode 100644 obp-api/src/main/scala/code/obp/grpc/chat/api/UnreadCountEvent.scala diff --git a/flushall_build_and_run.sh b/flushall_build_and_run.sh index 11356f45a1..d37c73782b 100755 --- a/flushall_build_and_run.sh +++ b/flushall_build_and_run.sh @@ -149,19 +149,23 @@ JAVA_OPTS="--add-opens java.base/java.lang=ALL-UNNAMED \ --add-opens java.base/java.util.jar=ALL-UNNAMED \ --add-opens java.base/sun.reflect.generics.reflectiveObjects=ALL-UNNAMED" +RUNTIME_LOG=/tmp/obp-api.log + if [ "$RUN_BACKGROUND" = true ]; then - # Run in background with output to log file - nohup java $JAVA_OPTS -jar obp-api/target/obp-api.jar > http4s-server.log 2>&1 & + # Run in background with output to log file (tee'd to /tmp as well) + nohup java $JAVA_OPTS -jar obp-api/target/obp-api.jar > >(tee "$RUNTIME_LOG") 2>&1 & SERVER_PID=$! echo "✓ HTTP4S server started in background" echo " PID: $SERVER_PID" - echo " Log: http4s-server.log" + echo " Log: http4s-server.log (also $RUNTIME_LOG)" echo "" echo "To stop the server: kill $SERVER_PID" echo "To view logs: tail -f http4s-server.log" else - # Run in foreground (Ctrl+C to stop) + # Run in foreground (Ctrl+C to stop). Also tee output to /tmp so it can be + # tailed from another terminal without taking over this one. echo "Press Ctrl+C to stop the server" + echo "Runtime log also written to: $RUNTIME_LOG" echo "" - java $JAVA_OPTS -jar obp-api/target/obp-api.jar + java $JAVA_OPTS -jar obp-api/target/obp-api.jar 2>&1 | tee "$RUNTIME_LOG" fi diff --git a/obp-api/pom.xml b/obp-api/pom.xml index c107e3a3c7..6d859854a0 100644 --- a/obp-api/pom.xml +++ b/obp-api/pom.xml @@ -323,16 +323,25 @@ scalapb-runtime-grpc_${scala.version} 0.8.4 - io.grpc - grpc-all + grpc-netty-shaded 1.48.1 - io.netty - netty-tcnative-boringssl-static - 2.0.27.Final + io.grpc + grpc-protobuf + 1.48.1 + + + io.grpc + grpc-stub + 1.48.1 + + + io.grpc + grpc-services + 1.48.1 org.asynchttpclient diff --git a/obp-api/src/main/protobuf/chat.proto b/obp-api/src/main/protobuf/chat.proto new file mode 100644 index 0000000000..fed1bbf7f5 --- /dev/null +++ b/obp-api/src/main/protobuf/chat.proto @@ -0,0 +1,68 @@ +syntax = "proto3"; +package code.obp.grpc.chat.g1; + +import "google/protobuf/timestamp.proto"; + +message StreamMessagesRequest { + string chat_room_id = 1; +} + +// Fields match ChatMessageJsonV600 exactly, plus event_type for stream events +message ChatMessageEvent { + string event_type = 1; + string chat_message_id = 2; + string chat_room_id = 3; + string sender_user_id = 4; + string sender_consumer_id = 5; + string sender_username = 6; + string sender_provider = 7; + string sender_consumer_name = 8; + string content = 9; + string message_type = 10; + repeated string mentioned_user_ids = 11; + string reply_to_message_id = 12; + string thread_id = 13; + bool is_deleted = 14; + google.protobuf.Timestamp created_at = 15; + google.protobuf.Timestamp updated_at = 16; +} + +message TypingEvent { + string chat_room_id = 1; + bool is_typing = 2; +} + +// Fields match TypingUserJsonV600 +message TypingIndicator { + string chat_room_id = 1; + string user_id = 2; + string username = 3; + string provider = 4; + bool is_typing = 5; +} + +message StreamPresenceRequest { + string chat_room_id = 1; +} + +message PresenceEvent { + string user_id = 1; + string username = 2; + string provider = 3; + bool is_online = 4; +} + +message StreamUnreadCountsRequest { +} + +message UnreadCountEvent { + string chat_room_id = 1; + int64 unread_count = 2; +} + +service ChatStreamService { + rpc StreamMessages(StreamMessagesRequest) returns (stream ChatMessageEvent); + rpc StreamTyping(stream TypingEvent) returns (stream TypingIndicator); + rpc StreamPresence(StreamPresenceRequest) returns (stream PresenceEvent); + rpc StreamUnreadCounts(StreamUnreadCountsRequest) returns (stream UnreadCountEvent); +} diff --git a/obp-api/src/main/resources/props/sample.props.template b/obp-api/src/main/resources/props/sample.props.template index 699e92a6ec..0c5c811125 100644 --- a/obp-api/src/main/resources/props/sample.props.template +++ b/obp-api/src/main/resources/props/sample.props.template @@ -1196,10 +1196,13 @@ database_messages_scheduler_interval=3600 # GRPC # the default GRPC is disabled # grpc.server.enabled = false -# If do not set this props, the grpc port will be set randomly when OBP starts. -# And you can call `Get API Configuration` endpoint to see the `grpc_port` there. -# When you set this props, need to make sure this port is available. +# The default gRPC port is 50051. Override if needed. # grpc.server.port = 50051 +# When gRPC is enabled, chat streaming services (StreamMessages, StreamTyping, +# StreamPresence, StreamUnreadCounts) are available on the same port. +# Clients authenticate via the "authorization" metadata key using the same +# DirectLogin or OAuth tokens as the REST API. +# See src/main/protobuf/chat.proto for the service contract. # Create System Views At Boot ----------------------------------------------- # In case is not defined default value is true diff --git a/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala b/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala index c7ff44a784..5c79a617a7 100644 --- a/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala +++ b/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala @@ -105,7 +105,7 @@ import code.migration.MigrationScriptLog import code.model._ import code.model.dataAccess._ import code.model.dataAccess.internalMapping.AccountIdMapping -import code.obp.grpc.HelloWorldServer +import code.obp.grpc.ObpGrpcServer import code.productAttributeattribute.MappedProductAttribute import code.productcollection.MappedProductCollection import code.productcollectionitem.MappedProductCollectionItem @@ -131,7 +131,7 @@ import code.transaction_types.MappedTransactionType import code.transactionattribute.MappedTransactionAttribute import code.transactionrequests.{MappedTransactionRequest, MappedTransactionRequestTypeCharge, TransactionRequestReasons} import code.usercustomerlinks.MappedUserCustomerLink -import code.customerlinks.MappedCustomerLink +import code.customerlinks.CustomerLink import code.userlocks.UserLocks import code.users._ import code.util.Helper.{MdcLoggable, ObpS, SILENCE_IS_GOLDEN} @@ -762,6 +762,8 @@ class Boot extends MdcLoggable { def schemifyAll() = { Schemifier.schemify(true, Schemifier.infoF _, ToSchemify.models: _*) + // Create default system-level "general" chat room (all_users_are_participants = true) + code.chat.ChatRoomTrait.chatRoomProvider.vend.getOrCreateDefaultRoom() } private def showExceptionAtJson(error: Throwable): String = { @@ -1162,7 +1164,7 @@ object ToSchemify { MappedNarrative, MappedCustomer, MappedUserCustomerLink, - MappedCustomerLink, + CustomerLink, Consumer, Token, OpenIDConnectToken, @@ -1205,12 +1207,16 @@ object ToSchemify { CounterpartyAttributeMapper, BankAccountBalance, Group, - AccountAccessRequest + AccountAccessRequest, + code.chat.ChatRoom, + code.chat.Participant, + code.chat.ChatMessage, + code.chat.Reaction ) // start grpc server if (APIUtil.getPropsAsBoolValue("grpc.server.enabled", false)) { - val server = new HelloWorldServer(ExecutionContext.global) + val server = new ObpGrpcServer(ExecutionContext.global) server.start() LiftRules.unloadHooks.append(server.stop) } diff --git a/obp-api/src/main/scala/code/api/util/ApiRole.scala b/obp-api/src/main/scala/code/api/util/ApiRole.scala index 22507687e0..026dbbf765 100644 --- a/obp-api/src/main/scala/code/api/util/ApiRole.scala +++ b/obp-api/src/main/scala/code/api/util/ApiRole.scala @@ -1370,6 +1370,20 @@ object ApiRole extends MdcLoggable{ case class CanGetAccountDirectoryAtOneBank(requiresBankId: Boolean = true) extends ApiRole lazy val canGetAccountDirectoryAtOneBank = CanGetAccountDirectoryAtOneBank() + // Chat Room roles + case class CanDeleteBankChatRoom(requiresBankId: Boolean = true) extends ApiRole + lazy val canDeleteBankChatRoom = CanDeleteBankChatRoom() + case class CanDeleteSystemChatRoom(requiresBankId: Boolean = false) extends ApiRole + lazy val canDeleteSystemChatRoom = CanDeleteSystemChatRoom() + case class CanArchiveBankChatRoom(requiresBankId: Boolean = true) extends ApiRole + lazy val canArchiveBankChatRoom = CanArchiveBankChatRoom() + case class CanArchiveSystemChatRoom(requiresBankId: Boolean = false) extends ApiRole + lazy val canArchiveSystemChatRoom = CanArchiveSystemChatRoom() + case class CanSetBankChatRoomAUAP(requiresBankId: Boolean = true) extends ApiRole + lazy val canSetBankChatRoomAUAP = CanSetBankChatRoomAUAP() + case class CanSetSystemChatRoomAUAP(requiresBankId: Boolean = false) extends ApiRole + lazy val canSetSystemChatRoomAUAP = CanSetSystemChatRoomAUAP() + private val dynamicApiRoles = new ConcurrentHashMap[String, ApiRole] private case class DynamicApiRole(role: String, requiresBankId: Boolean = false) extends ApiRole{ diff --git a/obp-api/src/main/scala/code/api/util/ApiTag.scala b/obp-api/src/main/scala/code/api/util/ApiTag.scala index f957019e12..fb92047c66 100644 --- a/obp-api/src/main/scala/code/api/util/ApiTag.scala +++ b/obp-api/src/main/scala/code/api/util/ApiTag.scala @@ -89,6 +89,7 @@ object ApiTag { val apiTagAggregateMetrics = ResourceDocTag("Aggregate-Metrics") val apiTagSystemIntegrity = ResourceDocTag("System-Integrity") val apiTagBalance = ResourceDocTag("Balance") + val apiTagChat = ResourceDocTag("Chat") val apiTagGroup = ResourceDocTag("Group") val apiTagWebhook = ResourceDocTag("Webhook") val apiTagMockedData = ResourceDocTag("Mocked-Data") diff --git a/obp-api/src/main/scala/code/api/util/AuthHeaderParser.scala b/obp-api/src/main/scala/code/api/util/AuthHeaderParser.scala new file mode 100644 index 0000000000..989b6eb6d2 --- /dev/null +++ b/obp-api/src/main/scala/code/api/util/AuthHeaderParser.scala @@ -0,0 +1,87 @@ +package code.api.util + +import net.liftweb.common.{Box, Empty, Full} + +/** + * Transport-independent parsing of the HTTP `Authorization` header value + * into the subset of CallContext fields used by the authentication chain. + * + * The auth chain in [[APIUtil.getUserAndSessionContextFuture]] identifies which + * scheme to use (OAuth 2, OAuth 1.0a, DirectLogin, Gateway Login, DAuth) by + * reading CallContext.authReqHeaderField / directLoginParams / oAuthParams — + * *not* requestHeaders. Every transport that supports authentication (REST + * via http4s, gRPC, etc.) must populate these three fields identically, + * otherwise schemes will silently fail to match and the chain will fall through + * to "OBP-20080 Authorization Header format is not supported". + * + * This helper is the single source of truth for that parsing so that all + * transports stay in sync. + */ +object AuthHeaderParser { + + /** Result of parsing an Authorization header value. */ + final case class ParsedAuthHeader( + authReqHeaderField: Box[String], + directLoginParams: Map[String, String], + oAuthParams: Map[String, String] + ) + + private val EmptyParsed: ParsedAuthHeader = + ParsedAuthHeader(Empty, Map.empty, Map.empty) + + private val DirectLoginAllowedParameters: List[String] = + List("consumer_key", "token", "username", "password") + + /** + * Parse an Authorization header value (e.g. "Bearer eyJ...", "DirectLogin token=...", + * 'OAuth oauth_consumer_key="..."') into the auth-related CallContext fields. + * + * Returns empty fields when no header value is present. + */ + def parseAuthorizationHeader(authHeaderValue: Option[String]): ParsedAuthHeader = + authHeaderValue match { + case None => EmptyParsed + case Some(value) => + ParsedAuthHeader( + authReqHeaderField = Full(value), + directLoginParams = if (value.contains("DirectLogin")) parseDirectLoginHeader(value) else Map.empty, + oAuthParams = if (value.startsWith("OAuth ")) parseOAuthHeader(value) else Map.empty + ) + } + + /** + * Parse a DirectLogin header value into its named parameters. + * Accepts both: + * - `DirectLogin token="xxx", username="yyy"` (old Authorization header format, with prefix) + * - `token="xxx", username="yyy"` (new dedicated `DirectLogin:` header, no prefix) + * + * Only the whitelisted parameters (`consumer_key`, `token`, `username`, `password`) + * are kept. Mirrors Lift's getAllParameters in directlogin.scala. + */ + def parseDirectLoginHeader(headerValue: String): Map[String, String] = { + val cleanedParameterList = headerValue.stripPrefix("DirectLogin").split(",").map(_.trim).toList + cleanedParameterList.flatMap { input => + if (input.contains("=")) { + val split = input.split("=", 2) + val paramName = split(0).trim + val paramValue = split(1).replaceAll("^\"|\"$", "").trim + if (DirectLoginAllowedParameters.contains(paramName) && paramValue.nonEmpty) + Some(paramName -> paramValue) + else + None + } else { + None + } + }.toMap + } + + /** + * Parse an OAuth 1.0a Authorization header value into its named parameters. + * Format: `OAuth oauth_consumer_key="xxx", oauth_token="yyy", ...` + */ + def parseOAuthHeader(headerValue: String): Map[String, String] = { + val oauthPart = headerValue.stripPrefix("OAuth ").trim + val pattern = """(\w+)="([^"]*)"""".r + pattern.findAllMatchIn(oauthPart).map(m => m.group(1) -> m.group(2)).toMap + } +} 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 fa688327f1..63d4033ebc 100644 --- a/obp-api/src/main/scala/code/api/util/ErrorMessages.scala +++ b/obp-api/src/main/scala/code/api/util/ErrorMessages.scala @@ -697,6 +697,22 @@ object ErrorMessages { val AbacRuleTooPermissive = "OBP-38010: ABAC rule is too permissive. The rule code contains a tautological expression (e.g. 'true', '1==1') that would always grant access. Please write a rule that checks specific attributes." val AbacRuleStatisticallyTooPermissive = "OBP-38011: ABAC rule is statistically too permissive. When evaluated against a sample of system users with no resource context, the rule grants access to more than 50% of users. Please write a more selective rule that checks specific attributes." + // Chat / Messaging related messages (OBP-39XXX) + val ChatRoomNotFound = "OBP-39001: Chat Room not found. Please specify a valid value for CHAT_ROOM_ID." + val ChatRoomAlreadyExists = "OBP-39002: Chat Room with this name already exists in this bank." + val ChatRoomIsArchived = "OBP-39003: Chat Room is archived. No new messages or participants can be added." + val NotChatRoomParticipant = "OBP-39004: Current user is not a participant of this Chat Room." + val ChatMessageNotFound = "OBP-39005: Chat Message not found. Please specify a valid value for CHAT_MESSAGE_ID." + val ChatRoomParticipantAlreadyExists = "OBP-39006: User is already a participant of this Chat Room." + val ChatRoomParticipantNotFound = "OBP-39007: Participant not found in this Chat Room." + val InsufficientChatPermission = "OBP-39008: You do not have the required permission for this Chat Room action." + val CannotEditOthersMessage = "OBP-39009: You can only edit your own messages." + val CannotDeleteMessage = "OBP-39010: You do not have permission to delete this message." + val InvalidJoiningKey = "OBP-39011: Invalid joining key. The key may have been refreshed." + val ReactionAlreadyExists = "OBP-39012: You have already added this reaction to this message." + val ReactionNotFound = "OBP-39013: Reaction not found." + val MustSpecifyUserIdOrConsumerId = "OBP-39014: Must specify either user_id or consumer_id, but not both." + // Transaction Request related messages (OBP-40XXX) val InvalidTransactionRequestType = "OBP-40001: Invalid value for TRANSACTION_REQUEST_TYPE" val InsufficientAuthorisationToCreateTransactionRequest = "OBP-40002: Insufficient authorisation to create TransactionRequest. " + 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 2da89d8df3..3edca50c3f 100644 --- a/obp-api/src/main/scala/code/api/util/Glossary.scala +++ b/obp-api/src/main/scala/code/api/util/Glossary.scala @@ -5125,6 +5125,83 @@ object Glossary extends MdcLoggable { | """) + glossaryItems += GlossaryItem( + title = "Chat", + description = + s""" + |# Chat + | + |OBP provides a built-in Chat / Messaging API that allows users and applications to communicate within the platform. + | + |Chat Rooms can be scoped to a specific Bank (bank-level) or be system-wide (system-level). + | + |## Key Concepts + | + |### Chat Rooms + |A Chat Room is a named space where participants exchange messages. + | + |A system-level room called **general** is created automatically at startup with **all_users_are_participants = true** — meaning every authenticated user can read and send messages without needing an explicit Participant record. + | + |Each room has: + |- A unique **joining key** (UUID) that can be shared to invite others. The key can be refreshed to revoke access. + |- A **name** that is unique within its scope (per bank, or globally for system-level rooms). + |- An optional **bank_id** — if set, the room is scoped to that bank. If empty, it is a system-level room. + |- An **all_users_are_participants** flag — if true, all authenticated users are treated as implicit participants without needing a database record. They can read and send messages but have no special permissions. + | + |### Participants + |A Participant is a user or consumer (application/bot) that belongs to a Chat Room. Participants can: + |- Send and read messages. + |- Have a granular **permissions** list that controls what management actions they can perform. + |- Optionally specify a **webhook_url** to receive HTTP POST notifications for room events (new messages, mentions, etc.). + | + |Participants join rooms by presenting the room's joining key. The room creator automatically receives all permissions. + | + |### Participant Permissions + |Permissions are stored as a list on each Participant record. Possible values: + |- **can_delete_message** — delete any message in the room + |- **can_remove_participant** — remove other participants from the room + |- **can_refresh_joining_key** — regenerate the room's joining key + |- **can_update_room** — edit the room name and description + |- **can_manage_permissions** — grant or revoke permissions for other participants, and add participants directly + | + |Any participant can send messages, read messages, and add emoji reactions without special permissions. A participant can also remove themselves (leave the room) without needing the can_remove_participant permission. + | + |### OBP-Level Roles + |In addition to room-level permissions, OBP Roles provide platform-wide moderation: + |- **CanDeleteBankChatRoom** — delete any chat room within a bank + |- **CanDeleteSystemChatRoom** — delete any system-level chat room + |- **CanArchiveBankChatRoom** — archive any chat room within a bank + |- **CanArchiveSystemChatRoom** — archive any system-level chat room + | + |Bank-scoped roles apply per bank; system-scoped roles apply to system-level chat rooms. Both kinds apply regardless of room-level permissions. + | + |### Consumer / Bot Participation + |API Consumers (applications) can participate in chat rooms alongside human users. A Participant record stores either a user_id or a consumer_id (not both). This enables automated assistants, notification bots, and integrations. + | + |### Messages + |Messages support: + |- **@mentions** — the mentioned_user_ids field tracks which users are referenced in a message. + |- **Threading** — a message can reference a thread_id (the root message) to form a conversation thread. + |- **Editing** — only the sender can edit their own message. + |- **Soft deletion** — messages are marked as deleted rather than removed, preserving audit trails. + |- **Emoji reactions** — participants can react to messages with emoji. Each user can add a given emoji to a message only once. + | + |### Typing Indicators + |Typing state is ephemeral and stored in Redis with a short TTL (5 seconds). No database records are created. + | + |### Polling + |Clients retrieve new messages by polling the GET messages endpoint with a **since** parameter (timestamp). This avoids the complexity of WebSocket infrastructure while providing a simple, reliable mechanism for near-real-time updates. + | + |## API Endpoints + | + |All chat endpoints are available in two forms: + |- **Bank-scoped**: /banks/BANK_ID/chat-rooms/... + |- **System-level**: /chat-rooms/... + | + |See the API Explorer for the full list of Chat endpoints, tagged with **Chat**. + | +""") + /////////////////////////////////////////////////////////////////// // NOTE! Some glossary items are generated in ExampleValue.scala ////////////////////////////////////////////////////////////////// diff --git a/obp-api/src/main/scala/code/api/util/NewStyle.scala b/obp-api/src/main/scala/code/api/util/NewStyle.scala index 5f0b02b3db..d9e2337b43 100644 --- a/obp-api/src/main/scala/code/api/util/NewStyle.scala +++ b/obp-api/src/main/scala/code/api/util/NewStyle.scala @@ -4401,27 +4401,27 @@ object NewStyle extends MdcLoggable{ i => (unboxFullOrFail(i._1, callContext, UpdateCustomerAccountLinkError), i._2) } - def createCustomerLink(bankId: String, customerId: String, otherBankId: String, otherCustomerId: String, relationshipTo: String, callContext: Option[CallContext]): OBPReturnType[code.customerlinks.CustomerLink] = + def createCustomerLink(bankId: String, customerId: String, otherBankId: String, otherCustomerId: String, relationshipTo: String, callContext: Option[CallContext]): OBPReturnType[code.customerlinks.CustomerLinkTrait] = Connector.connector.vend.createCustomerLink(bankId, customerId, otherBankId, otherCustomerId, relationshipTo, callContext) map { i => (unboxFullOrFail(i._1, callContext, CreateCustomerLinkError), i._2) } - def getCustomerLinkById(customerLinkId: String, callContext: Option[CallContext]): OBPReturnType[code.customerlinks.CustomerLink] = + def getCustomerLinkById(customerLinkId: String, callContext: Option[CallContext]): OBPReturnType[code.customerlinks.CustomerLinkTrait] = Connector.connector.vend.getCustomerLinkById(customerLinkId, callContext) map { i => (unboxFullOrFail(i._1, callContext, CustomerLinkNotFound), i._2) } - def getCustomerLinksByBankId(bankId: String, callContext: Option[CallContext]): OBPReturnType[List[code.customerlinks.CustomerLink]] = + def getCustomerLinksByBankId(bankId: String, callContext: Option[CallContext]): OBPReturnType[List[code.customerlinks.CustomerLinkTrait]] = Connector.connector.vend.getCustomerLinksByBankId(bankId, callContext) map { i => (unboxFullOrFail(i._1, callContext, CustomerLinkNotFound), i._2) } - def getCustomerLinksByCustomerId(customerId: String, callContext: Option[CallContext]): OBPReturnType[List[code.customerlinks.CustomerLink]] = + def getCustomerLinksByCustomerId(customerId: String, callContext: Option[CallContext]): OBPReturnType[List[code.customerlinks.CustomerLinkTrait]] = Connector.connector.vend.getCustomerLinksByCustomerId(customerId, callContext) map { i => (unboxFullOrFail(i._1, callContext, CustomerLinkNotFound), i._2) } - def updateCustomerLinkById(customerLinkId: String, relationshipTo: String, callContext: Option[CallContext]): OBPReturnType[code.customerlinks.CustomerLink] = + def updateCustomerLinkById(customerLinkId: String, relationshipTo: String, callContext: Option[CallContext]): OBPReturnType[code.customerlinks.CustomerLinkTrait] = Connector.connector.vend.updateCustomerLinkById(customerLinkId, relationshipTo, callContext) map { i => (unboxFullOrFail(i._1, callContext, UpdateCustomerLinkError), i._2) } diff --git a/obp-api/src/main/scala/code/api/util/http4s/Http4sSupport.scala b/obp-api/src/main/scala/code/api/util/http4s/Http4sSupport.scala index 89cae07d3e..c8b9a3941c 100644 --- a/obp-api/src/main/scala/code/api/util/http4s/Http4sSupport.scala +++ b/obp-api/src/main/scala/code/api/util/http4s/Http4sSupport.scala @@ -2,7 +2,7 @@ package code.api.util.http4s import cats.effect._ import code.api.util.APIUtil.ResourceDoc -import code.api.util.CallContext +import code.api.util.{AuthHeaderParser, CallContext} import com.openbankproject.commons.model.{Bank, User} import net.liftweb.common.{Box, Empty, Full} import net.liftweb.http.provider.HTTPParam @@ -256,75 +256,36 @@ object Http4sCallContextBuilder { } /** - * Extract DirectLogin header parameters if present + * Extract DirectLogin header parameters if present. * Supports two formats: - * 1. New format (2021): DirectLogin: token=xxx - * 2. Old format (deprecated): Authorization: DirectLogin token=xxx + * 1. New format (2021): `DirectLogin: token=xxx` (dedicated header) + * 2. Old format (deprecated): `Authorization: DirectLogin token=xxx` + * + * Delegates the actual parsing of header values to [[AuthHeaderParser]] so + * it stays consistent with other transports (e.g. gRPC). */ private def extractDirectLoginParams(request: Request[IO]): Map[String, String] = { // Try new format first: DirectLogin header request.headers.get(CIString("DirectLogin")) - .map(h => parseDirectLoginHeader(h.head.value)) + .map(h => AuthHeaderParser.parseDirectLoginHeader(h.head.value)) .getOrElse { // Fall back to old format: Authorization: DirectLogin token=xxx request.headers.get(CIString("Authorization")) .filter(_.head.value.contains("DirectLogin")) - .map(h => parseDirectLoginHeader(h.head.value)) + .map(h => AuthHeaderParser.parseDirectLoginHeader(h.head.value)) .getOrElse(Map.empty) } } - - /** - * Parse DirectLogin header value into parameter map - * Matches Lift's parsing logic in directlogin.scala getAllParameters - * Supports formats: - * - DirectLogin token="xxx" - * - DirectLogin token=xxx - * - token="xxx", username="yyy" - */ - private def parseDirectLoginHeader(headerValue: String): Map[String, String] = { - val directLoginPossibleParameters = List("consumer_key", "token", "username", "password") - - // Strip "DirectLogin" prefix and split by comma, then trim each part (matches Lift logic) - val cleanedParameterList = headerValue.stripPrefix("DirectLogin").split(",").map(_.trim).toList - - cleanedParameterList.flatMap { input => - if (input.contains("=")) { - val split = input.split("=", 2) - val paramName = split(0).trim - // Remove surrounding quotes if present - val paramValue = split(1).replaceAll("^\"|\"$", "").trim - if (directLoginPossibleParameters.contains(paramName) && paramValue.nonEmpty) - Some(paramName -> paramValue) - else - None - } else { - None - } - }.toMap - } - + /** - * Extract OAuth parameters from Authorization header if OAuth + * Extract OAuth 1.0a parameters from the Authorization header if it uses the OAuth scheme. */ private def extractOAuthParams(request: Request[IO]): Map[String, String] = { request.headers.get(CIString("Authorization")) .filter(_.head.value.startsWith("OAuth ")) - .map(h => parseOAuthHeader(h.head.value)) + .map(h => AuthHeaderParser.parseOAuthHeader(h.head.value)) .getOrElse(Map.empty) } - - /** - * Parse OAuth Authorization header value into parameter map - * Format: OAuth oauth_consumer_key="xxx", oauth_token="yyy", ... - */ - private def parseOAuthHeader(headerValue: String): Map[String, String] = { - val oauthPart = headerValue.stripPrefix("OAuth ").trim - val pattern = """(\w+)="([^"]*)"""".r - pattern.findAllMatchIn(oauthPart).map { m => - m.group(1) -> m.group(2) - }.toMap - } } /** diff --git a/obp-api/src/main/scala/code/api/v3_1_0/JSONFactory3.1.0.scala b/obp-api/src/main/scala/code/api/v3_1_0/JSONFactory3.1.0.scala index 0f25cb7c53..33361752e4 100644 --- a/obp-api/src/main/scala/code/api/v3_1_0/JSONFactory3.1.0.scala +++ b/obp-api/src/main/scala/code/api/v3_1_0/JSONFactory3.1.0.scala @@ -49,7 +49,7 @@ import code.entitlement.Entitlement import code.loginattempts.BadLoginAttempt import code.metrics.{TopApi, TopConsumer} import code.model.{Consumer, ModeratedBankAccount, ModeratedBankAccountCore, UserX} -import code.obp.grpc.HelloWorldServer +import code.obp.grpc.ObpGrpcServer import code.ratelimiting import code.webhook.AccountWebhook import com.openbankproject.commons.model.{AccountApplication, AmountOfMoneyJsonV121, CustomerAttribute, Product, ProductCollection, ProductCollectionItem, TaxResidence, User, UserAuthContextUpdate, _} @@ -923,7 +923,7 @@ object JSONFactory310{ def getConfigInfoJSON(): ConfigurationJsonV310 = { val configurationJson: ConfigurationJSON = JSONFactory220.getConfigInfoJSON() val defaultBankId= APIUtil.defaultBankId - val grpcPort = HelloWorldServer.port + val grpcPort = ObpGrpcServer.port ConfigurationJsonV310( defaultBankId, configurationJson.akka, 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 774e8fb19e..b85da9cac8 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 @@ -36,6 +36,7 @@ import code.api.v6_0_0.OBPAPI6_0_0 import code.abacrule.{AbacRuleEngine, MappedAbacRuleProvider} import code.mandate.{MappedMandateProvider} import code.api.v6_0_0.JSONFactory600.{createMandateJsonV600, createMandatesJsonV600, createMandateProvisionJsonV600, createMandateProvisionsJsonV600, createSignatoryPanelJsonV600, createSignatoryPanelsJsonV600, createCounterpartyAttributeJson, createCounterpartyAttributesJson} +// Chat case classes are at package level in JSONFactory6.0.0.scala, not inside JSONFactory600 object import code.metrics.{APIMetrics, ConnectorCountsRedis, ConnectorTraceProvider} import code.bankconnectors.{Connector, LocalMappedConnectorInternal} import code.bankconnectors.storedprocedure.StoredProcedureUtils @@ -12799,6 +12800,3659 @@ trait APIMethods600 { } } + // ============================================ CHAT / MESSAGING API ENDPOINTS ============================================ + + // ------ Batch A: Room CRUD ------ + + // 1a. createBankChatRoom + staticResourceDocs += ResourceDoc( + createBankChatRoom, + implementedInApiVersion, + nameOf(createBankChatRoom), + "POST", + "/banks/BANK_ID/chat-rooms", + "Create Bank Chat Room", + s"""Create a new chat room scoped to a bank. + |The creator is automatically added as a participant with all permissions. + | + |Authentication is Required + | + |""".stripMargin, + PostChatRoomJsonV600(name = "General Discussion", description = "A place to discuss general topics"), + ChatRoomJsonV600( + chat_room_id = "chat-room-id-123", + bank_id = "gh.29.uk", + name = "General Discussion", + description = "A place to discuss general topics", + joining_key = "abc123key", + created_by = "user-id-123", + created_by_username = "robert.x.0.gh", + created_by_provider = "https://github.com", + all_users_are_participants = false, + is_archived = false, + created_at = new java.util.Date(), + updated_at = new java.util.Date() + ), + List( + $AuthenticatedUserIsRequired, + InvalidJsonFormat, + ChatRoomAlreadyExists, + UnknownError + ), + List(apiTagChat), + None + ) + + lazy val createBankChatRoom: OBPEndpoint = { + case "banks" :: BankId(bankId) :: "chat-rooms" :: Nil JsonPost json -> _ => { + cc => implicit val ec = EndpointContext(Some(cc)) + for { + (Full(u), callContext) <- authenticatedAccess(cc) + postJson <- NewStyle.function.tryons(s"$InvalidJsonFormat The Json body should be the $PostChatRoomJsonV600", 400, callContext) { + json.extract[PostChatRoomJsonV600] + } + existingRoom <- Future(code.chat.ChatRoomTrait.chatRoomProvider.vend.getChatRoomByBankIdAndName(bankId.value, postJson.name)) + _ <- Helper.booleanToFuture(failMsg = ChatRoomAlreadyExists, cc = callContext) { + existingRoom.isEmpty + } + room <- Future { + code.chat.ChatRoomTrait.chatRoomProvider.vend.createChatRoom(bankId.value, postJson.name, postJson.description, u.userId) + } map { + x => unboxFullOrFail(x, callContext, s"$UnknownError Cannot create chat room", 400) + } + _ <- Future { + code.chat.ParticipantTrait.participantProvider.vend.addParticipant(room.chatRoomId, u.userId, "", code.chat.ChatPermissions.ALL_PERMISSIONS, "") + } map { + x => unboxFullOrFail(x, callContext, s"$UnknownError Cannot add creator as participant", 400) + } + } yield { + (JSONFactory600.createChatRoomJson(room), HttpCode.`201`(callContext)) + } + } + } + + // 1b. createSystemChatRoom + staticResourceDocs += ResourceDoc( + createSystemChatRoom, + implementedInApiVersion, + nameOf(createSystemChatRoom), + "POST", + "/chat-rooms", + "Create System Chat Room", + s"""Create a new system-level chat room (not scoped to a bank). + |The creator is automatically added as a participant with all permissions. + | + |Authentication is Required + | + |""".stripMargin, + PostChatRoomJsonV600(name = "General Discussion", description = "A place to discuss general topics"), + ChatRoomJsonV600( + chat_room_id = "chat-room-id-123", + bank_id = "", + name = "General Discussion", + description = "A place to discuss general topics", + joining_key = "abc123key", + created_by = "user-id-123", + created_by_username = "robert.x.0.gh", + created_by_provider = "https://github.com", + all_users_are_participants = false, + is_archived = false, + created_at = new java.util.Date(), + updated_at = new java.util.Date() + ), + List( + $AuthenticatedUserIsRequired, + InvalidJsonFormat, + ChatRoomAlreadyExists, + UnknownError + ), + List(apiTagChat), + None + ) + + lazy val createSystemChatRoom: OBPEndpoint = { + case "chat-rooms" :: Nil JsonPost json -> _ => { + cc => implicit val ec = EndpointContext(Some(cc)) + for { + (Full(u), callContext) <- authenticatedAccess(cc) + postJson <- NewStyle.function.tryons(s"$InvalidJsonFormat The Json body should be the $PostChatRoomJsonV600", 400, callContext) { + json.extract[PostChatRoomJsonV600] + } + existingRoom <- Future(code.chat.ChatRoomTrait.chatRoomProvider.vend.getChatRoomByBankIdAndName("", postJson.name)) + _ <- Helper.booleanToFuture(failMsg = ChatRoomAlreadyExists, cc = callContext) { + existingRoom.isEmpty + } + room <- Future { + code.chat.ChatRoomTrait.chatRoomProvider.vend.createChatRoom("", postJson.name, postJson.description, u.userId) + } map { + x => unboxFullOrFail(x, callContext, s"$UnknownError Cannot create chat room", 400) + } + _ <- Future { + code.chat.ParticipantTrait.participantProvider.vend.addParticipant(room.chatRoomId, u.userId, "", code.chat.ChatPermissions.ALL_PERMISSIONS, "") + } map { + x => unboxFullOrFail(x, callContext, s"$UnknownError Cannot add creator as participant", 400) + } + } yield { + (JSONFactory600.createChatRoomJson(room), HttpCode.`201`(callContext)) + } + } + } + + // 2a. getBankChatRooms + staticResourceDocs += ResourceDoc( + getBankChatRooms, + implementedInApiVersion, + nameOf(getBankChatRooms), + "GET", + "/banks/BANK_ID/chat-rooms", + "Get Bank Chat Rooms", + s"""Get all chat rooms for the specified bank that the current user is a participant of. + | + |Authentication is Required + | + |""".stripMargin, + EmptyBody, + ChatRoomsJsonV600(chat_rooms = List(ChatRoomJsonV600( + chat_room_id = "chat-room-id-123", + bank_id = "gh.29.uk", + name = "General Discussion", + description = "A place to discuss general topics", + joining_key = "abc123key", + created_by = "user-id-123", + created_by_username = "robert.x.0.gh", + created_by_provider = "https://github.com", + all_users_are_participants = false, + is_archived = false, + created_at = new java.util.Date(), + updated_at = new java.util.Date() + ))), + List( + $AuthenticatedUserIsRequired, + UnknownError + ), + List(apiTagChat), + None + ) + + lazy val getBankChatRooms: OBPEndpoint = { + case "banks" :: BankId(bankId) :: "chat-rooms" :: Nil JsonGet _ => { + cc => implicit val ec = EndpointContext(Some(cc)) + for { + (Full(u), callContext) <- authenticatedAccess(cc) + rooms <- Future { + code.chat.ChatRoomTrait.chatRoomProvider.vend.getChatRoomsByBankIdForUser(bankId.value, u.userId) + } map { + x => unboxFullOrFail(x, callContext, s"$UnknownError Cannot get chat rooms", 400) + } + } yield { + (JSONFactory600.createChatRoomsJson(rooms), HttpCode.`200`(callContext)) + } + } + } + + // 2b. getSystemChatRooms + staticResourceDocs += ResourceDoc( + getSystemChatRooms, + implementedInApiVersion, + nameOf(getSystemChatRooms), + "GET", + "/chat-rooms", + "Get System Chat Rooms", + s"""Get all system-level chat rooms that the current user is a participant of. + | + |Authentication is Required + | + |""".stripMargin, + EmptyBody, + ChatRoomsJsonV600(chat_rooms = List(ChatRoomJsonV600( + chat_room_id = "chat-room-id-123", + bank_id = "", + name = "General Discussion", + description = "A place to discuss general topics", + joining_key = "abc123key", + created_by = "user-id-123", + created_by_username = "robert.x.0.gh", + created_by_provider = "https://github.com", + all_users_are_participants = false, + is_archived = false, + created_at = new java.util.Date(), + updated_at = new java.util.Date() + ))), + List( + $AuthenticatedUserIsRequired, + UnknownError + ), + List(apiTagChat), + None + ) + + lazy val getSystemChatRooms: OBPEndpoint = { + case "chat-rooms" :: Nil JsonGet _ => { + cc => implicit val ec = EndpointContext(Some(cc)) + for { + (Full(u), callContext) <- authenticatedAccess(cc) + rooms <- Future { + code.chat.ChatRoomTrait.chatRoomProvider.vend.getChatRoomsByBankIdForUser("", u.userId) + } map { + x => unboxFullOrFail(x, callContext, s"$UnknownError Cannot get chat rooms", 400) + } + } yield { + (JSONFactory600.createChatRoomsJson(rooms), HttpCode.`200`(callContext)) + } + } + } + + // 3a. getBankChatRoom + staticResourceDocs += ResourceDoc( + getBankChatRoom, + implementedInApiVersion, + nameOf(getBankChatRoom), + "GET", + "/banks/BANK_ID/chat-rooms/CHAT_ROOM_ID", + "Get Bank Chat Room", + s"""Get a specific chat room by ID within a bank. The current user must be a participant. + | + |Authentication is Required + | + |""".stripMargin, + EmptyBody, + ChatRoomJsonV600( + chat_room_id = "chat-room-id-123", + bank_id = "gh.29.uk", + name = "General Discussion", + description = "A place to discuss general topics", + joining_key = "abc123key", + created_by = "user-id-123", + created_by_username = "robert.x.0.gh", + created_by_provider = "https://github.com", + all_users_are_participants = false, + is_archived = false, + created_at = new java.util.Date(), + updated_at = new java.util.Date() + ), + List( + $AuthenticatedUserIsRequired, + ChatRoomNotFound, + NotChatRoomParticipant, + UnknownError + ), + List(apiTagChat), + None + ) + + lazy val getBankChatRoom: OBPEndpoint = { + case "banks" :: BankId(bankId) :: "chat-rooms" :: chatRoomId :: Nil JsonGet _ => { + cc => implicit val ec = EndpointContext(Some(cc)) + for { + (Full(u), callContext) <- authenticatedAccess(cc) + room <- Future { + code.chat.ChatRoomTrait.chatRoomProvider.vend.getChatRoom(chatRoomId) + } map { + x => unboxFullOrFail(x, callContext, ChatRoomNotFound, 404) + } + _ <- Future { + code.chat.ChatPermissions.isParticipant(chatRoomId, u.userId) + } map { + x => unboxFullOrFail(x, callContext, NotChatRoomParticipant, 403) + } + } yield { + (JSONFactory600.createChatRoomJson(room), HttpCode.`200`(callContext)) + } + } + } + + // 3b. getSystemChatRoom + staticResourceDocs += ResourceDoc( + getSystemChatRoom, + implementedInApiVersion, + nameOf(getSystemChatRoom), + "GET", + "/chat-rooms/CHAT_ROOM_ID", + "Get System Chat Room", + s"""Get a specific system-level chat room by ID. The current user must be a participant. + | + |Authentication is Required + | + |""".stripMargin, + EmptyBody, + ChatRoomJsonV600( + chat_room_id = "chat-room-id-123", + bank_id = "", + name = "General Discussion", + description = "A place to discuss general topics", + joining_key = "abc123key", + created_by = "user-id-123", + created_by_username = "robert.x.0.gh", + created_by_provider = "https://github.com", + all_users_are_participants = false, + is_archived = false, + created_at = new java.util.Date(), + updated_at = new java.util.Date() + ), + List( + $AuthenticatedUserIsRequired, + ChatRoomNotFound, + NotChatRoomParticipant, + UnknownError + ), + List(apiTagChat), + None + ) + + lazy val getSystemChatRoom: OBPEndpoint = { + case "chat-rooms" :: chatRoomId :: Nil JsonGet _ => { + cc => implicit val ec = EndpointContext(Some(cc)) + for { + (Full(u), callContext) <- authenticatedAccess(cc) + room <- Future { + code.chat.ChatRoomTrait.chatRoomProvider.vend.getChatRoom(chatRoomId) + } map { + x => unboxFullOrFail(x, callContext, ChatRoomNotFound, 404) + } + _ <- Future { + code.chat.ChatPermissions.isParticipant(chatRoomId, u.userId) + } map { + x => unboxFullOrFail(x, callContext, NotChatRoomParticipant, 403) + } + } yield { + (JSONFactory600.createChatRoomJson(room), HttpCode.`200`(callContext)) + } + } + } + + // 4a. updateBankChatRoom + staticResourceDocs += ResourceDoc( + updateBankChatRoom, + implementedInApiVersion, + nameOf(updateBankChatRoom), + "PUT", + "/banks/BANK_ID/chat-rooms/CHAT_ROOM_ID", + "Update Bank Chat Room", + s"""Update the name and/or description of a chat room. Requires can_update_room permission. + | + |Authentication is Required + | + |""".stripMargin, + PutChatRoomJsonV600(name = Some("Updated Name"), description = Some("Updated description")), + ChatRoomJsonV600( + chat_room_id = "chat-room-id-123", + bank_id = "gh.29.uk", + name = "Updated Name", + description = "Updated description", + joining_key = "abc123key", + created_by = "user-id-123", + created_by_username = "robert.x.0.gh", + created_by_provider = "https://github.com", + all_users_are_participants = false, + is_archived = false, + created_at = new java.util.Date(), + updated_at = new java.util.Date() + ), + List( + $AuthenticatedUserIsRequired, + InvalidJsonFormat, + ChatRoomNotFound, + NotChatRoomParticipant, + InsufficientChatPermission, + UnknownError + ), + List(apiTagChat), + None + ) + + lazy val updateBankChatRoom: OBPEndpoint = { + case "banks" :: BankId(bankId) :: "chat-rooms" :: chatRoomId :: Nil JsonPut json -> _ => { + cc => implicit val ec = EndpointContext(Some(cc)) + for { + (Full(u), callContext) <- authenticatedAccess(cc) + putJson <- NewStyle.function.tryons(s"$InvalidJsonFormat The Json body should be the $PutChatRoomJsonV600", 400, callContext) { + json.extract[PutChatRoomJsonV600] + } + _ <- Future { + code.chat.ChatRoomTrait.chatRoomProvider.vend.getChatRoom(chatRoomId) + } map { + x => unboxFullOrFail(x, callContext, ChatRoomNotFound, 404) + } + _ <- Future { + code.chat.ChatPermissions.checkParticipantPermission(chatRoomId, u.userId, code.chat.ChatPermissions.CAN_UPDATE_ROOM) + } map { + x => unboxFullOrFail(x, callContext, InsufficientChatPermission, 403) + } + updatedRoom <- Future { + code.chat.ChatRoomTrait.chatRoomProvider.vend.updateChatRoom(chatRoomId, putJson.name, putJson.description) + } map { + x => unboxFullOrFail(x, callContext, s"$UnknownError Cannot update chat room", 400) + } + } yield { + (JSONFactory600.createChatRoomJson(updatedRoom), HttpCode.`200`(callContext)) + } + } + } + + // 4b. updateSystemChatRoom + staticResourceDocs += ResourceDoc( + updateSystemChatRoom, + implementedInApiVersion, + nameOf(updateSystemChatRoom), + "PUT", + "/chat-rooms/CHAT_ROOM_ID", + "Update System Chat Room", + s"""Update the name and/or description of a system-level chat room. Requires can_update_room permission. + | + |Authentication is Required + | + |""".stripMargin, + PutChatRoomJsonV600(name = Some("Updated Name"), description = Some("Updated description")), + ChatRoomJsonV600( + chat_room_id = "chat-room-id-123", + bank_id = "", + name = "Updated Name", + description = "Updated description", + joining_key = "abc123key", + created_by = "user-id-123", + created_by_username = "robert.x.0.gh", + created_by_provider = "https://github.com", + all_users_are_participants = false, + is_archived = false, + created_at = new java.util.Date(), + updated_at = new java.util.Date() + ), + List( + $AuthenticatedUserIsRequired, + InvalidJsonFormat, + ChatRoomNotFound, + NotChatRoomParticipant, + InsufficientChatPermission, + UnknownError + ), + List(apiTagChat), + None + ) + + lazy val updateSystemChatRoom: OBPEndpoint = { + case "chat-rooms" :: chatRoomId :: Nil JsonPut json -> _ => { + cc => implicit val ec = EndpointContext(Some(cc)) + for { + (Full(u), callContext) <- authenticatedAccess(cc) + putJson <- NewStyle.function.tryons(s"$InvalidJsonFormat The Json body should be the $PutChatRoomJsonV600", 400, callContext) { + json.extract[PutChatRoomJsonV600] + } + _ <- Future { + code.chat.ChatRoomTrait.chatRoomProvider.vend.getChatRoom(chatRoomId) + } map { + x => unboxFullOrFail(x, callContext, ChatRoomNotFound, 404) + } + _ <- Future { + code.chat.ChatPermissions.checkParticipantPermission(chatRoomId, u.userId, code.chat.ChatPermissions.CAN_UPDATE_ROOM) + } map { + x => unboxFullOrFail(x, callContext, InsufficientChatPermission, 403) + } + updatedRoom <- Future { + code.chat.ChatRoomTrait.chatRoomProvider.vend.updateChatRoom(chatRoomId, putJson.name, putJson.description) + } map { + x => unboxFullOrFail(x, callContext, s"$UnknownError Cannot update chat room", 400) + } + } yield { + (JSONFactory600.createChatRoomJson(updatedRoom), HttpCode.`200`(callContext)) + } + } + } + + // 5a. deleteBankChatRoom + staticResourceDocs += ResourceDoc( + deleteBankChatRoom, + implementedInApiVersion, + nameOf(deleteBankChatRoom), + "DELETE", + "/banks/BANK_ID/chat-rooms/CHAT_ROOM_ID", + "Delete Bank Chat Room", + s"""Delete a chat room. Requires the CanDeleteBankChatRoom role. + | + |Authentication is Required + | + |""".stripMargin, + EmptyBody, + EmptyBody, + List( + $AuthenticatedUserIsRequired, + UserHasMissingRoles, + ChatRoomNotFound, + UnknownError + ), + List(apiTagChat), + Some(List(canDeleteBankChatRoom)) + ) + + lazy val deleteBankChatRoom: OBPEndpoint = { + case "banks" :: BankId(bankId) :: "chat-rooms" :: chatRoomId :: Nil JsonDelete _ => { + cc => implicit val ec = EndpointContext(Some(cc)) + for { + (Full(u), callContext) <- authenticatedAccess(cc) + _ <- NewStyle.function.hasEntitlement(bankId.value, u.userId, canDeleteBankChatRoom, callContext) + _ <- Future { + code.chat.ChatRoomTrait.chatRoomProvider.vend.getChatRoom(chatRoomId) + } map { + x => unboxFullOrFail(x, callContext, ChatRoomNotFound, 404) + } + _ <- Future { + code.chat.ChatRoomTrait.chatRoomProvider.vend.deleteChatRoom(chatRoomId) + } map { + x => unboxFullOrFail(x, callContext, s"$UnknownError Cannot delete chat room", 400) + } + } yield { + (EmptyBody, HttpCode.`204`(callContext)) + } + } + } + + // 5b. deleteSystemChatRoom + staticResourceDocs += ResourceDoc( + deleteSystemChatRoom, + implementedInApiVersion, + nameOf(deleteSystemChatRoom), + "DELETE", + "/chat-rooms/CHAT_ROOM_ID", + "Delete System Chat Room", + s"""Delete a system-level chat room. Requires the CanDeleteSystemChatRoom role. + | + |Authentication is Required + | + |""".stripMargin, + EmptyBody, + EmptyBody, + List( + $AuthenticatedUserIsRequired, + UserHasMissingRoles, + ChatRoomNotFound, + UnknownError + ), + List(apiTagChat), + Some(List(canDeleteSystemChatRoom)) + ) + + lazy val deleteSystemChatRoom: OBPEndpoint = { + case "chat-rooms" :: chatRoomId :: Nil JsonDelete _ => { + cc => implicit val ec = EndpointContext(Some(cc)) + for { + (Full(u), callContext) <- authenticatedAccess(cc) + _ <- NewStyle.function.hasEntitlement("", u.userId, canDeleteSystemChatRoom, callContext) + _ <- Future { + code.chat.ChatRoomTrait.chatRoomProvider.vend.getChatRoom(chatRoomId) + } map { + x => unboxFullOrFail(x, callContext, ChatRoomNotFound, 404) + } + _ <- Future { + code.chat.ChatRoomTrait.chatRoomProvider.vend.deleteChatRoom(chatRoomId) + } map { + x => unboxFullOrFail(x, callContext, s"$UnknownError Cannot delete chat room", 400) + } + } yield { + (EmptyBody, HttpCode.`204`(callContext)) + } + } + } + + // 6a. archiveBankChatRoom + staticResourceDocs += ResourceDoc( + archiveBankChatRoom, + implementedInApiVersion, + nameOf(archiveBankChatRoom), + "PUT", + "/banks/BANK_ID/chat-rooms/CHAT_ROOM_ID/archive-status", + "Archive Bank Chat Room", + s"""Archive a chat room. Archived rooms cannot receive new messages or participants. + |Requires the CanArchiveBankChatRoom role. + | + |Authentication is Required + | + |""".stripMargin, + EmptyBody, + ChatRoomJsonV600( + chat_room_id = "chat-room-id-123", + bank_id = "gh.29.uk", + name = "General Discussion", + description = "A place to discuss general topics", + joining_key = "abc123key", + created_by = "user-id-123", + created_by_username = "robert.x.0.gh", + created_by_provider = "https://github.com", + all_users_are_participants = false, + is_archived = true, + created_at = new java.util.Date(), + updated_at = new java.util.Date() + ), + List( + $AuthenticatedUserIsRequired, + UserHasMissingRoles, + ChatRoomNotFound, + UnknownError + ), + List(apiTagChat), + Some(List(canArchiveBankChatRoom)) + ) + + lazy val archiveBankChatRoom: OBPEndpoint = { + case "banks" :: BankId(bankId) :: "chat-rooms" :: chatRoomId :: "archive-status" :: Nil JsonPut _ -> _ => { + cc => implicit val ec = EndpointContext(Some(cc)) + for { + (Full(u), callContext) <- authenticatedAccess(cc) + _ <- NewStyle.function.hasEntitlement(bankId.value, u.userId, canArchiveBankChatRoom, callContext) + _ <- Future { + code.chat.ChatRoomTrait.chatRoomProvider.vend.getChatRoom(chatRoomId) + } map { + x => unboxFullOrFail(x, callContext, ChatRoomNotFound, 404) + } + archivedRoom <- Future { + code.chat.ChatRoomTrait.chatRoomProvider.vend.archiveChatRoom(chatRoomId) + } map { + x => unboxFullOrFail(x, callContext, s"$UnknownError Cannot archive chat room", 400) + } + } yield { + (JSONFactory600.createChatRoomJson(archivedRoom), HttpCode.`200`(callContext)) + } + } + } + + // 6b. archiveSystemChatRoom + staticResourceDocs += ResourceDoc( + archiveSystemChatRoom, + implementedInApiVersion, + nameOf(archiveSystemChatRoom), + "PUT", + "/chat-rooms/CHAT_ROOM_ID/archive-status", + "Archive System Chat Room", + s"""Archive a system-level chat room. Archived rooms cannot receive new messages or participants. + |Requires the CanArchiveSystemChatRoom role. + | + |Authentication is Required + | + |""".stripMargin, + EmptyBody, + ChatRoomJsonV600( + chat_room_id = "chat-room-id-123", + bank_id = "", + name = "General Discussion", + description = "A place to discuss general topics", + joining_key = "abc123key", + created_by = "user-id-123", + created_by_username = "robert.x.0.gh", + created_by_provider = "https://github.com", + all_users_are_participants = false, + is_archived = true, + created_at = new java.util.Date(), + updated_at = new java.util.Date() + ), + List( + $AuthenticatedUserIsRequired, + UserHasMissingRoles, + ChatRoomNotFound, + UnknownError + ), + List(apiTagChat), + Some(List(canArchiveSystemChatRoom)) + ) + + lazy val archiveSystemChatRoom: OBPEndpoint = { + case "chat-rooms" :: chatRoomId :: "archive-status" :: Nil JsonPut _ -> _ => { + cc => implicit val ec = EndpointContext(Some(cc)) + for { + (Full(u), callContext) <- authenticatedAccess(cc) + _ <- NewStyle.function.hasEntitlement("", u.userId, canArchiveSystemChatRoom, callContext) + _ <- Future { + code.chat.ChatRoomTrait.chatRoomProvider.vend.getChatRoom(chatRoomId) + } map { + x => unboxFullOrFail(x, callContext, ChatRoomNotFound, 404) + } + archivedRoom <- Future { + code.chat.ChatRoomTrait.chatRoomProvider.vend.archiveChatRoom(chatRoomId) + } map { + x => unboxFullOrFail(x, callContext, s"$UnknownError Cannot archive chat room", 400) + } + } yield { + (JSONFactory600.createChatRoomJson(archivedRoom), HttpCode.`200`(callContext)) + } + } + } + + // 6c. setBankChatRoomAllUsersAreParticipants + staticResourceDocs += ResourceDoc( + setBankChatRoomAllUsersAreParticipants, + implementedInApiVersion, + nameOf(setBankChatRoomAllUsersAreParticipants), + "PUT", + "/banks/BANK_ID/chat-rooms/CHAT_ROOM_ID/all-users-are-participants", + "Set Chat Room All Users Are Participants", + s"""Set whether all authenticated users are implicit participants of this chat room. + | + |If true, all users can read and send messages without needing an explicit Participant record. + | + |Requires the CanSetBankChatRoomAUAP role. + | + |Authentication is Required + | + |""".stripMargin, + EmptyBody, + ChatRoomJsonV600( + chat_room_id = "chat-room-id-123", + bank_id = "gh.29.uk", + name = "General Discussion", + description = "A place to discuss general topics", + joining_key = "abc123key", + created_by = "user-id-123", + created_by_username = "username", + created_by_provider = "provider", + all_users_are_participants = true, + is_archived = false, + created_at = new java.util.Date(), + updated_at = new java.util.Date() + ), + List( + $AuthenticatedUserIsRequired, + UserHasMissingRoles, + ChatRoomNotFound, + UnknownError + ), + List(apiTagChat), + Some(List(canSetBankChatRoomAUAP)) + ) + + lazy val setBankChatRoomAllUsersAreParticipants: OBPEndpoint = { + case "banks" :: BankId(bankId) :: "chat-rooms" :: chatRoomId :: "all-users-are-participants" :: Nil JsonPut json -> _ => { + cc => implicit val ec = EndpointContext(Some(cc)) + for { + (Full(u), callContext) <- authenticatedAccess(cc) + _ <- NewStyle.function.hasEntitlement(bankId.value, u.userId, canSetBankChatRoomAUAP, callContext) + _ <- Future { + code.chat.ChatRoomTrait.chatRoomProvider.vend.getChatRoom(chatRoomId) + } map { + x => unboxFullOrFail(x, callContext, ChatRoomNotFound, 404) + } + allUsersAreParticipants = (json \ "all_users_are_participants").extractOrElse[Boolean](false) + updatedRoom <- Future { + code.chat.ChatRoomTrait.chatRoomProvider.vend.setAllUsersAreParticipants(chatRoomId, allUsersAreParticipants) + } map { + x => unboxFullOrFail(x, callContext, s"$UnknownError Cannot update chat room", 400) + } + } yield { + (JSONFactory600.createChatRoomJson(updatedRoom), HttpCode.`200`(callContext)) + } + } + } + + // 6d. setSystemChatRoomAllUsersAreParticipants + staticResourceDocs += ResourceDoc( + setSystemChatRoomAllUsersAreParticipants, + implementedInApiVersion, + nameOf(setSystemChatRoomAllUsersAreParticipants), + "PUT", + "/chat-rooms/CHAT_ROOM_ID/all-users-are-participants", + "Set System Chat Room All Users Are Participants", + s"""Set whether all authenticated users are implicit participants of this system-level chat room. + | + |If true, all users can read and send messages without needing an explicit Participant record. + | + |Requires the CanSetSystemChatRoomAUAP role. + | + |Authentication is Required + | + |""".stripMargin, + EmptyBody, + ChatRoomJsonV600( + chat_room_id = "chat-room-id-123", + bank_id = "", + name = "General Discussion", + description = "A place to discuss general topics", + joining_key = "abc123key", + created_by = "user-id-123", + created_by_username = "username", + created_by_provider = "provider", + all_users_are_participants = true, + is_archived = false, + created_at = new java.util.Date(), + updated_at = new java.util.Date() + ), + List( + $AuthenticatedUserIsRequired, + UserHasMissingRoles, + ChatRoomNotFound, + UnknownError + ), + List(apiTagChat), + Some(List(canSetSystemChatRoomAUAP)) + ) + + lazy val setSystemChatRoomAllUsersAreParticipants: OBPEndpoint = { + case "chat-rooms" :: chatRoomId :: "all-users-are-participants" :: Nil JsonPut json -> _ => { + cc => implicit val ec = EndpointContext(Some(cc)) + for { + (Full(u), callContext) <- authenticatedAccess(cc) + _ <- NewStyle.function.hasEntitlement("", u.userId, canSetSystemChatRoomAUAP, callContext) + _ <- Future { + code.chat.ChatRoomTrait.chatRoomProvider.vend.getChatRoom(chatRoomId) + } map { + x => unboxFullOrFail(x, callContext, ChatRoomNotFound, 404) + } + allUsersAreParticipants = (json \ "all_users_are_participants").extractOrElse[Boolean](false) + updatedRoom <- Future { + code.chat.ChatRoomTrait.chatRoomProvider.vend.setAllUsersAreParticipants(chatRoomId, allUsersAreParticipants) + } map { + x => unboxFullOrFail(x, callContext, s"$UnknownError Cannot update chat room", 400) + } + } yield { + (JSONFactory600.createChatRoomJson(updatedRoom), HttpCode.`200`(callContext)) + } + } + } + + // ------ Batch B: Joining ------ + + // 7a. joinBankChatRoom + staticResourceDocs += ResourceDoc( + joinBankChatRoom, + implementedInApiVersion, + nameOf(joinBankChatRoom), + "POST", + "/banks/BANK_ID/chat-room-participants", + "Join Bank Chat Room", + s"""Join a chat room using a joining key (passed as joining_key in the JSON body). + |The user is added as a participant with no special permissions. + | + |Authentication is Required + | + |""".stripMargin, + EmptyBody, + ParticipantJsonV600( + participant_id = "participant-id-123", + chat_room_id = "chat-room-id-123", + user_id = "user-id-123", + username = "robert.x.0.gh", + provider = "https://github.com", + consumer_id = "", + consumer_name = "", + permissions = List(), + webhook_url = "", + joined_at = new java.util.Date(), + last_read_at = new java.util.Date(), + is_muted = false + ), + List( + $AuthenticatedUserIsRequired, + InvalidJoiningKey, + ChatRoomIsArchived, + ChatRoomParticipantAlreadyExists, + UnknownError + ), + List(apiTagChat), + None + ) + + lazy val joinBankChatRoom: OBPEndpoint = { + case "banks" :: BankId(bankId) :: "chat-room-participants" :: Nil JsonPost json -> _ => { + cc => implicit val ec = EndpointContext(Some(cc)) + for { + (Full(u), callContext) <- authenticatedAccess(cc) + joiningKey = (json \ "joining_key").extractOpt[String].getOrElse("") + room <- Future { + code.chat.ChatRoomTrait.chatRoomProvider.vend.getChatRoomByJoiningKey(joiningKey) + } map { + x => unboxFullOrFail(x, callContext, InvalidJoiningKey, 404) + } + _ <- Helper.booleanToFuture(failMsg = ChatRoomIsArchived, cc = callContext) { + !room.isArchived + } + existingParticipant <- Future(code.chat.ChatPermissions.isParticipant(room.chatRoomId, u.userId)) + _ <- Helper.booleanToFuture(failMsg = ChatRoomParticipantAlreadyExists, cc = callContext) { + existingParticipant.isEmpty + } + participant <- Future { + code.chat.ParticipantTrait.participantProvider.vend.addParticipant(room.chatRoomId, u.userId, "", List.empty, "") + } map { + x => unboxFullOrFail(x, callContext, s"$UnknownError Cannot join chat room", 400) + } + } yield { + (JSONFactory600.createParticipantJson(participant), HttpCode.`201`(callContext)) + } + } + } + + // 7b. joinSystemChatRoom + staticResourceDocs += ResourceDoc( + joinSystemChatRoom, + implementedInApiVersion, + nameOf(joinSystemChatRoom), + "POST", + "/chat-room-participants", + "Join System Chat Room", + s"""Join a system-level chat room using a joining key (passed as joining_key in the JSON body). + |The user is added as a participant with no special permissions. + | + |Authentication is Required + | + |""".stripMargin, + EmptyBody, + ParticipantJsonV600( + participant_id = "participant-id-123", + chat_room_id = "chat-room-id-123", + user_id = "user-id-123", + username = "robert.x.0.gh", + provider = "https://github.com", + consumer_id = "", + consumer_name = "", + permissions = List(), + webhook_url = "", + joined_at = new java.util.Date(), + last_read_at = new java.util.Date(), + is_muted = false + ), + List( + $AuthenticatedUserIsRequired, + InvalidJoiningKey, + ChatRoomIsArchived, + ChatRoomParticipantAlreadyExists, + UnknownError + ), + List(apiTagChat), + None + ) + + lazy val joinSystemChatRoom: OBPEndpoint = { + case "chat-room-participants" :: Nil JsonPost json -> _ => { + cc => implicit val ec = EndpointContext(Some(cc)) + for { + (Full(u), callContext) <- authenticatedAccess(cc) + joiningKey = (json \ "joining_key").extractOpt[String].getOrElse("") + room <- Future { + code.chat.ChatRoomTrait.chatRoomProvider.vend.getChatRoomByJoiningKey(joiningKey) + } map { + x => unboxFullOrFail(x, callContext, InvalidJoiningKey, 404) + } + _ <- Helper.booleanToFuture(failMsg = ChatRoomIsArchived, cc = callContext) { + !room.isArchived + } + existingParticipant <- Future(code.chat.ChatPermissions.isParticipant(room.chatRoomId, u.userId)) + _ <- Helper.booleanToFuture(failMsg = ChatRoomParticipantAlreadyExists, cc = callContext) { + existingParticipant.isEmpty + } + participant <- Future { + code.chat.ParticipantTrait.participantProvider.vend.addParticipant(room.chatRoomId, u.userId, "", List.empty, "") + } map { + x => unboxFullOrFail(x, callContext, s"$UnknownError Cannot join chat room", 400) + } + } yield { + (JSONFactory600.createParticipantJson(participant), HttpCode.`201`(callContext)) + } + } + } + + // 8a. refreshBankJoiningKey + staticResourceDocs += ResourceDoc( + refreshBankJoiningKey, + implementedInApiVersion, + nameOf(refreshBankJoiningKey), + "PUT", + "/banks/BANK_ID/chat-rooms/CHAT_ROOM_ID/joining-key", + "Refresh Bank Chat Room Joining Key", + s"""Refresh the joining key for a chat room. The old key becomes invalid. + |Requires can_refresh_joining_key permission. + | + |Authentication is Required + | + |""".stripMargin, + EmptyBody, + JoiningKeyJsonV600(joining_key = "new-key-abc123"), + List( + $AuthenticatedUserIsRequired, + ChatRoomNotFound, + InsufficientChatPermission, + UnknownError + ), + List(apiTagChat), + None + ) + + lazy val refreshBankJoiningKey: OBPEndpoint = { + case "banks" :: BankId(bankId) :: "chat-rooms" :: chatRoomId :: "joining-key" :: Nil JsonPut _ -> _ => { + cc => implicit val ec = EndpointContext(Some(cc)) + for { + (Full(u), callContext) <- authenticatedAccess(cc) + _ <- Future { + code.chat.ChatRoomTrait.chatRoomProvider.vend.getChatRoom(chatRoomId) + } map { + x => unboxFullOrFail(x, callContext, ChatRoomNotFound, 404) + } + _ <- Future { + code.chat.ChatPermissions.checkParticipantPermission(chatRoomId, u.userId, code.chat.ChatPermissions.CAN_REFRESH_JOINING_KEY) + } map { + x => unboxFullOrFail(x, callContext, InsufficientChatPermission, 403) + } + updatedRoom <- Future { + code.chat.ChatRoomTrait.chatRoomProvider.vend.refreshJoiningKey(chatRoomId) + } map { + x => unboxFullOrFail(x, callContext, s"$UnknownError Cannot refresh joining key", 400) + } + } yield { + (JoiningKeyJsonV600(joining_key = updatedRoom.joiningKey), HttpCode.`200`(callContext)) + } + } + } + + // 8b. refreshSystemJoiningKey + staticResourceDocs += ResourceDoc( + refreshSystemJoiningKey, + implementedInApiVersion, + nameOf(refreshSystemJoiningKey), + "PUT", + "/chat-rooms/CHAT_ROOM_ID/joining-key", + "Refresh System Chat Room Joining Key", + s"""Refresh the joining key for a system-level chat room. The old key becomes invalid. + |Requires can_refresh_joining_key permission. + | + |Authentication is Required + | + |""".stripMargin, + EmptyBody, + JoiningKeyJsonV600(joining_key = "new-key-abc123"), + List( + $AuthenticatedUserIsRequired, + ChatRoomNotFound, + InsufficientChatPermission, + UnknownError + ), + List(apiTagChat), + None + ) + + lazy val refreshSystemJoiningKey: OBPEndpoint = { + case "chat-rooms" :: chatRoomId :: "joining-key" :: Nil JsonPut _ -> _ => { + cc => implicit val ec = EndpointContext(Some(cc)) + for { + (Full(u), callContext) <- authenticatedAccess(cc) + _ <- Future { + code.chat.ChatRoomTrait.chatRoomProvider.vend.getChatRoom(chatRoomId) + } map { + x => unboxFullOrFail(x, callContext, ChatRoomNotFound, 404) + } + _ <- Future { + code.chat.ChatPermissions.checkParticipantPermission(chatRoomId, u.userId, code.chat.ChatPermissions.CAN_REFRESH_JOINING_KEY) + } map { + x => unboxFullOrFail(x, callContext, InsufficientChatPermission, 403) + } + updatedRoom <- Future { + code.chat.ChatRoomTrait.chatRoomProvider.vend.refreshJoiningKey(chatRoomId) + } map { + x => unboxFullOrFail(x, callContext, s"$UnknownError Cannot refresh joining key", 400) + } + } yield { + (JoiningKeyJsonV600(joining_key = updatedRoom.joiningKey), HttpCode.`200`(callContext)) + } + } + } + + // ------ Batch C: Participants ------ + + // 9a. addBankChatRoomParticipant + staticResourceDocs += ResourceDoc( + addBankChatRoomParticipant, + implementedInApiVersion, + nameOf(addBankChatRoomParticipant), + "POST", + "/banks/BANK_ID/chat-rooms/CHAT_ROOM_ID/participants", + "Add Bank Chat Room Participant", + s"""Add a participant to a chat room. Requires can_manage_permissions permission. + |Specify either user_id or consumer_id, but not both. + | + |Authentication is Required + | + |""".stripMargin, + PostParticipantJsonV600(user_id = Some("user-id-456"), consumer_id = None, permissions = Some(List("can_delete_message")), webhook_url = None), + ParticipantJsonV600( + participant_id = "participant-id-456", + chat_room_id = "chat-room-id-123", + user_id = "user-id-456", + username = "ellie.y.1.gh", + provider = "https://github.com", + consumer_id = "", + consumer_name = "", + permissions = List("can_delete_message"), + webhook_url = "", + joined_at = new java.util.Date(), + last_read_at = new java.util.Date(), + is_muted = false + ), + List( + $AuthenticatedUserIsRequired, + InvalidJsonFormat, + ChatRoomNotFound, + InsufficientChatPermission, + MustSpecifyUserIdOrConsumerId, + ChatRoomParticipantAlreadyExists, + UnknownError + ), + List(apiTagChat), + None + ) + + lazy val addBankChatRoomParticipant: OBPEndpoint = { + case "banks" :: BankId(bankId) :: "chat-rooms" :: chatRoomId :: "participants" :: Nil JsonPost json -> _ => { + cc => implicit val ec = EndpointContext(Some(cc)) + for { + (Full(u), callContext) <- authenticatedAccess(cc) + postJson <- NewStyle.function.tryons(s"$InvalidJsonFormat The Json body should be the $PostParticipantJsonV600", 400, callContext) { + json.extract[PostParticipantJsonV600] + } + _ <- Future { + code.chat.ChatRoomTrait.chatRoomProvider.vend.getChatRoom(chatRoomId) + } map { + x => unboxFullOrFail(x, callContext, ChatRoomNotFound, 404) + } + _ <- Future { + code.chat.ChatPermissions.checkParticipantPermission(chatRoomId, u.userId, code.chat.ChatPermissions.CAN_MANAGE_PERMISSIONS) + } map { + x => unboxFullOrFail(x, callContext, InsufficientChatPermission, 403) + } + userId = postJson.user_id.getOrElse("") + consumerId = postJson.consumer_id.getOrElse("") + _ <- Helper.booleanToFuture(failMsg = MustSpecifyUserIdOrConsumerId, cc = callContext) { + (userId.nonEmpty || consumerId.nonEmpty) && !(userId.nonEmpty && consumerId.nonEmpty) + } + existingParticipant <- Future { + if (userId.nonEmpty) code.chat.ChatPermissions.isParticipant(chatRoomId, userId) + else code.chat.ChatPermissions.isParticipantByConsumerId(chatRoomId, consumerId) + } + _ <- Helper.booleanToFuture(failMsg = ChatRoomParticipantAlreadyExists, cc = callContext) { + existingParticipant.isEmpty + } + participant <- Future { + code.chat.ParticipantTrait.participantProvider.vend.addParticipant( + chatRoomId, userId, consumerId, + postJson.permissions.getOrElse(List.empty), + postJson.webhook_url.getOrElse("") + ) + } map { + x => unboxFullOrFail(x, callContext, s"$UnknownError Cannot add participant", 400) + } + } yield { + (JSONFactory600.createParticipantJson(participant), HttpCode.`201`(callContext)) + } + } + } + + // 9b. addSystemChatRoomParticipant + staticResourceDocs += ResourceDoc( + addSystemChatRoomParticipant, + implementedInApiVersion, + nameOf(addSystemChatRoomParticipant), + "POST", + "/chat-rooms/CHAT_ROOM_ID/participants", + "Add System Chat Room Participant", + s"""Add a participant to a system-level chat room. Requires can_manage_permissions permission. + |Specify either user_id or consumer_id, but not both. + | + |Authentication is Required + | + |""".stripMargin, + PostParticipantJsonV600(user_id = Some("user-id-456"), consumer_id = None, permissions = Some(List("can_delete_message")), webhook_url = None), + ParticipantJsonV600( + participant_id = "participant-id-456", + chat_room_id = "chat-room-id-123", + user_id = "user-id-456", + username = "ellie.y.1.gh", + provider = "https://github.com", + consumer_id = "", + consumer_name = "", + permissions = List("can_delete_message"), + webhook_url = "", + joined_at = new java.util.Date(), + last_read_at = new java.util.Date(), + is_muted = false + ), + List( + $AuthenticatedUserIsRequired, + InvalidJsonFormat, + ChatRoomNotFound, + InsufficientChatPermission, + MustSpecifyUserIdOrConsumerId, + ChatRoomParticipantAlreadyExists, + UnknownError + ), + List(apiTagChat), + None + ) + + lazy val addSystemChatRoomParticipant: OBPEndpoint = { + case "chat-rooms" :: chatRoomId :: "participants" :: Nil JsonPost json -> _ => { + cc => implicit val ec = EndpointContext(Some(cc)) + for { + (Full(u), callContext) <- authenticatedAccess(cc) + postJson <- NewStyle.function.tryons(s"$InvalidJsonFormat The Json body should be the $PostParticipantJsonV600", 400, callContext) { + json.extract[PostParticipantJsonV600] + } + _ <- Future { + code.chat.ChatRoomTrait.chatRoomProvider.vend.getChatRoom(chatRoomId) + } map { + x => unboxFullOrFail(x, callContext, ChatRoomNotFound, 404) + } + _ <- Future { + code.chat.ChatPermissions.checkParticipantPermission(chatRoomId, u.userId, code.chat.ChatPermissions.CAN_MANAGE_PERMISSIONS) + } map { + x => unboxFullOrFail(x, callContext, InsufficientChatPermission, 403) + } + userId = postJson.user_id.getOrElse("") + consumerId = postJson.consumer_id.getOrElse("") + _ <- Helper.booleanToFuture(failMsg = MustSpecifyUserIdOrConsumerId, cc = callContext) { + (userId.nonEmpty || consumerId.nonEmpty) && !(userId.nonEmpty && consumerId.nonEmpty) + } + existingParticipant <- Future { + if (userId.nonEmpty) code.chat.ChatPermissions.isParticipant(chatRoomId, userId) + else code.chat.ChatPermissions.isParticipantByConsumerId(chatRoomId, consumerId) + } + _ <- Helper.booleanToFuture(failMsg = ChatRoomParticipantAlreadyExists, cc = callContext) { + existingParticipant.isEmpty + } + participant <- Future { + code.chat.ParticipantTrait.participantProvider.vend.addParticipant( + chatRoomId, userId, consumerId, + postJson.permissions.getOrElse(List.empty), + postJson.webhook_url.getOrElse("") + ) + } map { + x => unboxFullOrFail(x, callContext, s"$UnknownError Cannot add participant", 400) + } + } yield { + (JSONFactory600.createParticipantJson(participant), HttpCode.`201`(callContext)) + } + } + } + + // 10a. getBankChatRoomParticipants + staticResourceDocs += ResourceDoc( + getBankChatRoomParticipants, + implementedInApiVersion, + nameOf(getBankChatRoomParticipants), + "GET", + "/banks/BANK_ID/chat-rooms/CHAT_ROOM_ID/participants", + "Get Bank Chat Room Participants", + s"""Get all participants of a chat room. The current user must be a participant. + | + |Authentication is Required + | + |""".stripMargin, + EmptyBody, + ParticipantsJsonV600(participants = List(ParticipantJsonV600( + participant_id = "participant-id-123", + chat_room_id = "chat-room-id-123", + user_id = "user-id-123", + username = "robert.x.0.gh", + provider = "https://github.com", + consumer_id = "", + consumer_name = "", + permissions = List("can_update_room", "can_delete_message"), + webhook_url = "", + joined_at = new java.util.Date(), + last_read_at = new java.util.Date(), + is_muted = false + ))), + List( + $AuthenticatedUserIsRequired, + ChatRoomNotFound, + NotChatRoomParticipant, + UnknownError + ), + List(apiTagChat), + None + ) + + lazy val getBankChatRoomParticipants: OBPEndpoint = { + case "banks" :: BankId(bankId) :: "chat-rooms" :: chatRoomId :: "participants" :: Nil JsonGet _ => { + cc => implicit val ec = EndpointContext(Some(cc)) + for { + (Full(u), callContext) <- authenticatedAccess(cc) + _ <- Future { + code.chat.ChatRoomTrait.chatRoomProvider.vend.getChatRoom(chatRoomId) + } map { + x => unboxFullOrFail(x, callContext, ChatRoomNotFound, 404) + } + _ <- Future { + code.chat.ChatPermissions.isParticipant(chatRoomId, u.userId) + } map { + x => unboxFullOrFail(x, callContext, NotChatRoomParticipant, 403) + } + participants <- Future { + code.chat.ParticipantTrait.participantProvider.vend.getParticipants(chatRoomId) + } map { + x => unboxFullOrFail(x, callContext, s"$UnknownError Cannot get participants", 400) + } + } yield { + (JSONFactory600.createParticipantsJson(participants), HttpCode.`200`(callContext)) + } + } + } + + // 10b. getSystemChatRoomParticipants + staticResourceDocs += ResourceDoc( + getSystemChatRoomParticipants, + implementedInApiVersion, + nameOf(getSystemChatRoomParticipants), + "GET", + "/chat-rooms/CHAT_ROOM_ID/participants", + "Get System Chat Room Participants", + s"""Get all participants of a system-level chat room. The current user must be a participant. + | + |Authentication is Required + | + |""".stripMargin, + EmptyBody, + ParticipantsJsonV600(participants = List(ParticipantJsonV600( + participant_id = "participant-id-123", + chat_room_id = "chat-room-id-123", + user_id = "user-id-123", + username = "robert.x.0.gh", + provider = "https://github.com", + consumer_id = "", + consumer_name = "", + permissions = List("can_update_room", "can_delete_message"), + webhook_url = "", + joined_at = new java.util.Date(), + last_read_at = new java.util.Date(), + is_muted = false + ))), + List( + $AuthenticatedUserIsRequired, + ChatRoomNotFound, + NotChatRoomParticipant, + UnknownError + ), + List(apiTagChat), + None + ) + + lazy val getSystemChatRoomParticipants: OBPEndpoint = { + case "chat-rooms" :: chatRoomId :: "participants" :: Nil JsonGet _ => { + cc => implicit val ec = EndpointContext(Some(cc)) + for { + (Full(u), callContext) <- authenticatedAccess(cc) + _ <- Future { + code.chat.ChatRoomTrait.chatRoomProvider.vend.getChatRoom(chatRoomId) + } map { + x => unboxFullOrFail(x, callContext, ChatRoomNotFound, 404) + } + _ <- Future { + code.chat.ChatPermissions.isParticipant(chatRoomId, u.userId) + } map { + x => unboxFullOrFail(x, callContext, NotChatRoomParticipant, 403) + } + participants <- Future { + code.chat.ParticipantTrait.participantProvider.vend.getParticipants(chatRoomId) + } map { + x => unboxFullOrFail(x, callContext, s"$UnknownError Cannot get participants", 400) + } + } yield { + (JSONFactory600.createParticipantsJson(participants), HttpCode.`200`(callContext)) + } + } + } + + // 11a. updateBankParticipantPermissions + staticResourceDocs += ResourceDoc( + updateBankParticipantPermissions, + implementedInApiVersion, + nameOf(updateBankParticipantPermissions), + "PUT", + "/banks/BANK_ID/chat-rooms/CHAT_ROOM_ID/participants/USER_ID", + "Update Bank Chat Room Participant Permissions", + s"""Update the permissions of a participant. Requires can_manage_permissions permission. + | + |Authentication is Required + | + |""".stripMargin, + PutParticipantPermissionsJsonV600(permissions = List("can_delete_message", "can_update_room")), + ParticipantJsonV600( + participant_id = "participant-id-456", + chat_room_id = "chat-room-id-123", + user_id = "user-id-456", + username = "ellie.y.1.gh", + provider = "https://github.com", + consumer_id = "", + consumer_name = "", + permissions = List("can_delete_message", "can_update_room"), + webhook_url = "", + joined_at = new java.util.Date(), + last_read_at = new java.util.Date(), + is_muted = false + ), + List( + $AuthenticatedUserIsRequired, + InvalidJsonFormat, + ChatRoomNotFound, + InsufficientChatPermission, + ChatRoomParticipantNotFound, + UnknownError + ), + List(apiTagChat), + None + ) + + lazy val updateBankParticipantPermissions: OBPEndpoint = { + case "banks" :: BankId(bankId) :: "chat-rooms" :: chatRoomId :: "participants" :: targetUserId :: Nil JsonPut json -> _ => { + cc => implicit val ec = EndpointContext(Some(cc)) + for { + (Full(u), callContext) <- authenticatedAccess(cc) + putJson <- NewStyle.function.tryons(s"$InvalidJsonFormat The Json body should be the $PutParticipantPermissionsJsonV600", 400, callContext) { + json.extract[PutParticipantPermissionsJsonV600] + } + _ <- Future { + code.chat.ChatRoomTrait.chatRoomProvider.vend.getChatRoom(chatRoomId) + } map { + x => unboxFullOrFail(x, callContext, ChatRoomNotFound, 404) + } + _ <- Future { + code.chat.ChatPermissions.checkParticipantPermission(chatRoomId, u.userId, code.chat.ChatPermissions.CAN_MANAGE_PERMISSIONS) + } map { + x => unboxFullOrFail(x, callContext, InsufficientChatPermission, 403) + } + _ <- Future { + code.chat.ChatPermissions.isParticipant(chatRoomId, targetUserId) + } map { + x => unboxFullOrFail(x, callContext, ChatRoomParticipantNotFound, 404) + } + updatedParticipant <- Future { + code.chat.ParticipantTrait.participantProvider.vend.updateParticipantPermissions(chatRoomId, targetUserId, putJson.permissions) + } map { + x => unboxFullOrFail(x, callContext, s"$UnknownError Cannot update participant permissions", 400) + } + } yield { + (JSONFactory600.createParticipantJson(updatedParticipant), HttpCode.`200`(callContext)) + } + } + } + + // 11b. updateSystemParticipantPermissions + staticResourceDocs += ResourceDoc( + updateSystemParticipantPermissions, + implementedInApiVersion, + nameOf(updateSystemParticipantPermissions), + "PUT", + "/chat-rooms/CHAT_ROOM_ID/participants/USER_ID", + "Update System Chat Room Participant Permissions", + s"""Update the permissions of a participant in a system-level chat room. Requires can_manage_permissions permission. + | + |Authentication is Required + | + |""".stripMargin, + PutParticipantPermissionsJsonV600(permissions = List("can_delete_message", "can_update_room")), + ParticipantJsonV600( + participant_id = "participant-id-456", + chat_room_id = "chat-room-id-123", + user_id = "user-id-456", + username = "ellie.y.1.gh", + provider = "https://github.com", + consumer_id = "", + consumer_name = "", + permissions = List("can_delete_message", "can_update_room"), + webhook_url = "", + joined_at = new java.util.Date(), + last_read_at = new java.util.Date(), + is_muted = false + ), + List( + $AuthenticatedUserIsRequired, + InvalidJsonFormat, + ChatRoomNotFound, + InsufficientChatPermission, + ChatRoomParticipantNotFound, + UnknownError + ), + List(apiTagChat), + None + ) + + lazy val updateSystemParticipantPermissions: OBPEndpoint = { + case "chat-rooms" :: chatRoomId :: "participants" :: targetUserId :: Nil JsonPut json -> _ => { + cc => implicit val ec = EndpointContext(Some(cc)) + for { + (Full(u), callContext) <- authenticatedAccess(cc) + putJson <- NewStyle.function.tryons(s"$InvalidJsonFormat The Json body should be the $PutParticipantPermissionsJsonV600", 400, callContext) { + json.extract[PutParticipantPermissionsJsonV600] + } + _ <- Future { + code.chat.ChatRoomTrait.chatRoomProvider.vend.getChatRoom(chatRoomId) + } map { + x => unboxFullOrFail(x, callContext, ChatRoomNotFound, 404) + } + _ <- Future { + code.chat.ChatPermissions.checkParticipantPermission(chatRoomId, u.userId, code.chat.ChatPermissions.CAN_MANAGE_PERMISSIONS) + } map { + x => unboxFullOrFail(x, callContext, InsufficientChatPermission, 403) + } + _ <- Future { + code.chat.ChatPermissions.isParticipant(chatRoomId, targetUserId) + } map { + x => unboxFullOrFail(x, callContext, ChatRoomParticipantNotFound, 404) + } + updatedParticipant <- Future { + code.chat.ParticipantTrait.participantProvider.vend.updateParticipantPermissions(chatRoomId, targetUserId, putJson.permissions) + } map { + x => unboxFullOrFail(x, callContext, s"$UnknownError Cannot update participant permissions", 400) + } + } yield { + (JSONFactory600.createParticipantJson(updatedParticipant), HttpCode.`200`(callContext)) + } + } + } + + // 12a. removeBankChatRoomParticipant + staticResourceDocs += ResourceDoc( + removeBankChatRoomParticipant, + implementedInApiVersion, + nameOf(removeBankChatRoomParticipant), + "DELETE", + "/banks/BANK_ID/chat-rooms/CHAT_ROOM_ID/participants/USER_ID", + "Remove Bank Chat Room Participant", + s"""Remove a participant from a chat room. Requires can_remove_participant permission, or the user can remove themselves. + | + |Authentication is Required + | + |""".stripMargin, + EmptyBody, + EmptyBody, + List( + $AuthenticatedUserIsRequired, + ChatRoomNotFound, + InsufficientChatPermission, + ChatRoomParticipantNotFound, + UnknownError + ), + List(apiTagChat), + None + ) + + lazy val removeBankChatRoomParticipant: OBPEndpoint = { + case "banks" :: BankId(bankId) :: "chat-rooms" :: chatRoomId :: "participants" :: targetUserId :: Nil JsonDelete _ => { + cc => implicit val ec = EndpointContext(Some(cc)) + for { + (Full(u), callContext) <- authenticatedAccess(cc) + _ <- Future { + code.chat.ChatRoomTrait.chatRoomProvider.vend.getChatRoom(chatRoomId) + } map { + x => unboxFullOrFail(x, callContext, ChatRoomNotFound, 404) + } + // Self-removal is allowed; otherwise need can_remove_participant + _ <- if (u.userId == targetUserId) { + Future.successful(Full(())) + } else { + Future { + code.chat.ChatPermissions.checkParticipantPermission(chatRoomId, u.userId, code.chat.ChatPermissions.CAN_REMOVE_PARTICIPANT) + } map { + x => unboxFullOrFail(x, callContext, InsufficientChatPermission, 403) + } + } + _ <- Future { + code.chat.ChatPermissions.isParticipant(chatRoomId, targetUserId) + } map { + x => unboxFullOrFail(x, callContext, ChatRoomParticipantNotFound, 404) + } + _ <- Future { + code.chat.ParticipantTrait.participantProvider.vend.removeParticipant(chatRoomId, targetUserId) + } map { + x => unboxFullOrFail(x, callContext, s"$UnknownError Cannot remove participant", 400) + } + } yield { + (EmptyBody, HttpCode.`204`(callContext)) + } + } + } + + // 12b. removeSystemChatRoomParticipant + staticResourceDocs += ResourceDoc( + removeSystemChatRoomParticipant, + implementedInApiVersion, + nameOf(removeSystemChatRoomParticipant), + "DELETE", + "/chat-rooms/CHAT_ROOM_ID/participants/USER_ID", + "Remove System Chat Room Participant", + s"""Remove a participant from a system-level chat room. Requires can_remove_participant permission, or the user can remove themselves. + | + |Authentication is Required + | + |""".stripMargin, + EmptyBody, + EmptyBody, + List( + $AuthenticatedUserIsRequired, + ChatRoomNotFound, + InsufficientChatPermission, + ChatRoomParticipantNotFound, + UnknownError + ), + List(apiTagChat), + None + ) + + lazy val removeSystemChatRoomParticipant: OBPEndpoint = { + case "chat-rooms" :: chatRoomId :: "participants" :: targetUserId :: Nil JsonDelete _ => { + cc => implicit val ec = EndpointContext(Some(cc)) + for { + (Full(u), callContext) <- authenticatedAccess(cc) + _ <- Future { + code.chat.ChatRoomTrait.chatRoomProvider.vend.getChatRoom(chatRoomId) + } map { + x => unboxFullOrFail(x, callContext, ChatRoomNotFound, 404) + } + _ <- if (u.userId == targetUserId) { + Future.successful(Full(())) + } else { + Future { + code.chat.ChatPermissions.checkParticipantPermission(chatRoomId, u.userId, code.chat.ChatPermissions.CAN_REMOVE_PARTICIPANT) + } map { + x => unboxFullOrFail(x, callContext, InsufficientChatPermission, 403) + } + } + _ <- Future { + code.chat.ChatPermissions.isParticipant(chatRoomId, targetUserId) + } map { + x => unboxFullOrFail(x, callContext, ChatRoomParticipantNotFound, 404) + } + _ <- Future { + code.chat.ParticipantTrait.participantProvider.vend.removeParticipant(chatRoomId, targetUserId) + } map { + x => unboxFullOrFail(x, callContext, s"$UnknownError Cannot remove participant", 400) + } + } yield { + (EmptyBody, HttpCode.`204`(callContext)) + } + } + } + + // ------ Batch D: Messages ------ + + // 13a. sendBankChatMessage + staticResourceDocs += ResourceDoc( + sendBankChatMessage, + implementedInApiVersion, + nameOf(sendBankChatMessage), + "POST", + "/banks/BANK_ID/chat-rooms/CHAT_ROOM_ID/messages", + "Send Bank Chat Message", + s"""Send a message in a chat room. The current user must be a participant and the room must not be archived. + | + |Authentication is Required + | + |""".stripMargin, + PostChatMessageJsonV600(content = "Hello everyone!", message_type = Some("text"), mentioned_user_ids = None, reply_to_message_id = None, thread_id = None), + ChatMessageJsonV600( + chat_message_id = "msg-id-123", + chat_room_id = "chat-room-id-123", + sender_user_id = "user-id-123", + sender_consumer_id = "", + sender_username = "robert.x.0.gh", + sender_provider = "https://github.com", + sender_consumer_name = "My Banking App", + content = "Hello everyone!", + message_type = "text", + mentioned_user_ids = List(), + reply_to_message_id = "", + thread_id = "", + is_deleted = false, + created_at = new java.util.Date(), + updated_at = new java.util.Date(), + reactions = List() + ), + List( + $AuthenticatedUserIsRequired, + InvalidJsonFormat, + ChatRoomNotFound, + NotChatRoomParticipant, + ChatRoomIsArchived, + UnknownError + ), + List(apiTagChat), + None + ) + + lazy val sendBankChatMessage: OBPEndpoint = { + case "banks" :: BankId(bankId) :: "chat-rooms" :: chatRoomId :: "messages" :: Nil JsonPost json -> _ => { + cc => implicit val ec = EndpointContext(Some(cc)) + for { + (Full(u), callContext) <- authenticatedAccess(cc) + postJson <- NewStyle.function.tryons(s"$InvalidJsonFormat The Json body should be the $PostChatMessageJsonV600", 400, callContext) { + json.extract[PostChatMessageJsonV600] + } + room <- Future { + code.chat.ChatRoomTrait.chatRoomProvider.vend.getChatRoom(chatRoomId) + } map { + x => unboxFullOrFail(x, callContext, ChatRoomNotFound, 404) + } + _ <- Future { + code.chat.ChatPermissions.isParticipant(chatRoomId, u.userId) + } map { + x => unboxFullOrFail(x, callContext, NotChatRoomParticipant, 403) + } + _ <- Helper.booleanToFuture(failMsg = ChatRoomIsArchived, cc = callContext) { + !room.isArchived + } + msg <- Future { + code.chat.ChatMessageTrait.chatMessageProvider.vend.createMessage( + chatRoomId, + u.userId, + "", + postJson.content, + postJson.message_type.getOrElse("text"), + postJson.mentioned_user_ids.getOrElse(List.empty), + postJson.reply_to_message_id.getOrElse(""), + postJson.thread_id.getOrElse("") + ) + } map { + x => unboxFullOrFail(x, callContext, s"$UnknownError Cannot send message", 400) + } + } yield { + code.chat.ChatEventPublisher.afterCreate(msg, u.name, u.provider, "") + (JSONFactory600.createChatMessageJson(msg, List.empty), HttpCode.`201`(callContext)) + } + } + } + + // 13b. sendSystemChatMessage + staticResourceDocs += ResourceDoc( + sendSystemChatMessage, + implementedInApiVersion, + nameOf(sendSystemChatMessage), + "POST", + "/chat-rooms/CHAT_ROOM_ID/messages", + "Send System Chat Message", + s"""Send a message in a system-level chat room. The current user must be a participant and the room must not be archived. + | + |Authentication is Required + | + |""".stripMargin, + PostChatMessageJsonV600(content = "Hello everyone!", message_type = Some("text"), mentioned_user_ids = None, reply_to_message_id = None, thread_id = None), + ChatMessageJsonV600( + chat_message_id = "msg-id-123", + chat_room_id = "chat-room-id-123", + sender_user_id = "user-id-123", + sender_consumer_id = "", + sender_username = "robert.x.0.gh", + sender_provider = "https://github.com", + sender_consumer_name = "My Banking App", + content = "Hello everyone!", + message_type = "text", + mentioned_user_ids = List(), + reply_to_message_id = "", + thread_id = "", + is_deleted = false, + created_at = new java.util.Date(), + updated_at = new java.util.Date(), + reactions = List() + ), + List( + $AuthenticatedUserIsRequired, + InvalidJsonFormat, + ChatRoomNotFound, + NotChatRoomParticipant, + ChatRoomIsArchived, + UnknownError + ), + List(apiTagChat), + None + ) + + lazy val sendSystemChatMessage: OBPEndpoint = { + case "chat-rooms" :: chatRoomId :: "messages" :: Nil JsonPost json -> _ => { + cc => implicit val ec = EndpointContext(Some(cc)) + for { + (Full(u), callContext) <- authenticatedAccess(cc) + postJson <- NewStyle.function.tryons(s"$InvalidJsonFormat The Json body should be the $PostChatMessageJsonV600", 400, callContext) { + json.extract[PostChatMessageJsonV600] + } + room <- Future { + code.chat.ChatRoomTrait.chatRoomProvider.vend.getChatRoom(chatRoomId) + } map { + x => unboxFullOrFail(x, callContext, ChatRoomNotFound, 404) + } + _ <- Future { + code.chat.ChatPermissions.isParticipant(chatRoomId, u.userId) + } map { + x => unboxFullOrFail(x, callContext, NotChatRoomParticipant, 403) + } + _ <- Helper.booleanToFuture(failMsg = ChatRoomIsArchived, cc = callContext) { + !room.isArchived + } + msg <- Future { + code.chat.ChatMessageTrait.chatMessageProvider.vend.createMessage( + chatRoomId, + u.userId, + "", + postJson.content, + postJson.message_type.getOrElse("text"), + postJson.mentioned_user_ids.getOrElse(List.empty), + postJson.reply_to_message_id.getOrElse(""), + postJson.thread_id.getOrElse("") + ) + } map { + x => unboxFullOrFail(x, callContext, s"$UnknownError Cannot send message", 400) + } + } yield { + code.chat.ChatEventPublisher.afterCreate(msg, u.name, u.provider, "") + (JSONFactory600.createChatMessageJson(msg, List.empty), HttpCode.`201`(callContext)) + } + } + } + + // 14a. getBankChatMessages + staticResourceDocs += ResourceDoc( + getBankChatMessages, + implementedInApiVersion, + nameOf(getBankChatMessages), + "GET", + "/banks/BANK_ID/chat-rooms/CHAT_ROOM_ID/messages", + "Get Bank Chat Messages", + s"""Get messages in a chat room. + | + |${getObpApiRoot}/banks/BANK_ID/chat-rooms/CHAT_ROOM_ID/messages?limit=50&offset=0&from_date=$DateWithMsExampleString&to_date=$DateWithMsExampleString + | + |The current user must be a participant. + | + |Authentication is Required + | + |""".stripMargin, + EmptyBody, + ChatMessagesJsonV600(messages = List(ChatMessageJsonV600( + chat_message_id = "msg-id-123", + chat_room_id = "chat-room-id-123", + sender_user_id = "user-id-123", + sender_consumer_id = "", + sender_username = "robert.x.0.gh", + sender_provider = "https://github.com", + sender_consumer_name = "My Banking App", + content = "Hello everyone!", + message_type = "text", + mentioned_user_ids = List(), + reply_to_message_id = "", + thread_id = "", + is_deleted = false, + created_at = new java.util.Date(), + updated_at = new java.util.Date(), + reactions = List(ReactionSummaryJsonV600(emoji = "thumbsup", count = 2, user_ids = List("user-1", "user-2"))) + ))), + List( + $AuthenticatedUserIsRequired, + ChatRoomNotFound, + NotChatRoomParticipant, + UnknownError + ), + List(apiTagChat), + None + ) + + lazy val getBankChatMessages: OBPEndpoint = { + case "banks" :: BankId(bankId) :: "chat-rooms" :: chatRoomId :: "messages" :: Nil JsonGet _ => { + cc => implicit val ec = EndpointContext(Some(cc)) + for { + (Full(u), callContext) <- authenticatedAccess(cc) + _ <- Future { + code.chat.ChatRoomTrait.chatRoomProvider.vend.getChatRoom(chatRoomId) + } map { + x => unboxFullOrFail(x, callContext, ChatRoomNotFound, 404) + } + _ <- Future { + code.chat.ChatPermissions.isParticipant(chatRoomId, u.userId) + } map { + x => unboxFullOrFail(x, callContext, NotChatRoomParticipant, 403) + } + limitParam = ObpS.param("limit").map(_.toInt).getOrElse(50) + offsetParam = ObpS.param("offset").map(_.toInt).getOrElse(0) + fromDate = ObpS.param("from_date").flatMap(parseObpStandardDate(_).toOption).getOrElse(theEpochTime) + toDate = ObpS.param("to_date").flatMap(parseObpStandardDate(_).toOption).getOrElse(APIUtil.DefaultToDate) + (messageRows, reactionRows) = code.chat.DoobieChatMessageQueries.getMessagesWithReactions(chatRoomId, fromDate, toDate, limitParam, offsetParam) + } yield { + (JSONFactory600.createChatMessagesJsonFromRows(messageRows, reactionRows), HttpCode.`200`(callContext)) + } + } + } + + // 14b. getSystemChatMessages + staticResourceDocs += ResourceDoc( + getSystemChatMessages, + implementedInApiVersion, + nameOf(getSystemChatMessages), + "GET", + "/chat-rooms/CHAT_ROOM_ID/messages", + "Get System Chat Messages", + s"""Get messages in a system-level chat room. + | + |${getObpApiRoot}/chat-rooms/CHAT_ROOM_ID/messages?limit=50&offset=0&from_date=$DateWithMsExampleString&to_date=$DateWithMsExampleString + | + |The current user must be a participant. + | + |Authentication is Required + | + |""".stripMargin, + EmptyBody, + ChatMessagesJsonV600(messages = List(ChatMessageJsonV600( + chat_message_id = "msg-id-123", + chat_room_id = "chat-room-id-123", + sender_user_id = "user-id-123", + sender_consumer_id = "", + sender_username = "robert.x.0.gh", + sender_provider = "https://github.com", + sender_consumer_name = "My Banking App", + content = "Hello everyone!", + message_type = "text", + mentioned_user_ids = List(), + reply_to_message_id = "", + thread_id = "", + is_deleted = false, + created_at = new java.util.Date(), + updated_at = new java.util.Date(), + reactions = List(ReactionSummaryJsonV600(emoji = "thumbsup", count = 2, user_ids = List("user-1", "user-2"))) + ))), + List( + $AuthenticatedUserIsRequired, + ChatRoomNotFound, + NotChatRoomParticipant, + UnknownError + ), + List(apiTagChat), + None + ) + + lazy val getSystemChatMessages: OBPEndpoint = { + case "chat-rooms" :: chatRoomId :: "messages" :: Nil JsonGet _ => { + cc => implicit val ec = EndpointContext(Some(cc)) + for { + (Full(u), callContext) <- authenticatedAccess(cc) + _ <- Future { + code.chat.ChatRoomTrait.chatRoomProvider.vend.getChatRoom(chatRoomId) + } map { + x => unboxFullOrFail(x, callContext, ChatRoomNotFound, 404) + } + _ <- Future { + code.chat.ChatPermissions.isParticipant(chatRoomId, u.userId) + } map { + x => unboxFullOrFail(x, callContext, NotChatRoomParticipant, 403) + } + limitParam = ObpS.param("limit").map(_.toInt).getOrElse(50) + offsetParam = ObpS.param("offset").map(_.toInt).getOrElse(0) + fromDate = ObpS.param("from_date").flatMap(parseObpStandardDate(_).toOption).getOrElse(theEpochTime) + toDate = ObpS.param("to_date").flatMap(parseObpStandardDate(_).toOption).getOrElse(APIUtil.DefaultToDate) + (messageRows, reactionRows) = code.chat.DoobieChatMessageQueries.getMessagesWithReactions(chatRoomId, fromDate, toDate, limitParam, offsetParam) + } yield { + (JSONFactory600.createChatMessagesJsonFromRows(messageRows, reactionRows), HttpCode.`200`(callContext)) + } + } + } + + // 15a. getBankChatMessage + staticResourceDocs += ResourceDoc( + getBankChatMessage, + implementedInApiVersion, + nameOf(getBankChatMessage), + "GET", + "/banks/BANK_ID/chat-rooms/CHAT_ROOM_ID/messages/CHAT_MESSAGE_ID", + "Get Bank Chat Message", + s"""Get a specific message by ID. The current user must be a participant. + | + |Authentication is Required + | + |""".stripMargin, + EmptyBody, + ChatMessageJsonV600( + chat_message_id = "msg-id-123", + chat_room_id = "chat-room-id-123", + sender_user_id = "user-id-123", + sender_consumer_id = "", + sender_username = "robert.x.0.gh", + sender_provider = "https://github.com", + sender_consumer_name = "My Banking App", + content = "Hello everyone!", + message_type = "text", + mentioned_user_ids = List(), + reply_to_message_id = "", + thread_id = "", + is_deleted = false, + created_at = new java.util.Date(), + updated_at = new java.util.Date(), + reactions = List() + ), + List( + $AuthenticatedUserIsRequired, + ChatRoomNotFound, + NotChatRoomParticipant, + ChatMessageNotFound, + UnknownError + ), + List(apiTagChat), + None + ) + + lazy val getBankChatMessage: OBPEndpoint = { + case "banks" :: BankId(bankId) :: "chat-rooms" :: chatRoomId :: "messages" :: chatMessageId :: Nil JsonGet _ => { + cc => implicit val ec = EndpointContext(Some(cc)) + for { + (Full(u), callContext) <- authenticatedAccess(cc) + _ <- Future { + code.chat.ChatRoomTrait.chatRoomProvider.vend.getChatRoom(chatRoomId) + } map { + x => unboxFullOrFail(x, callContext, ChatRoomNotFound, 404) + } + _ <- Future { + code.chat.ChatPermissions.isParticipant(chatRoomId, u.userId) + } map { + x => unboxFullOrFail(x, callContext, NotChatRoomParticipant, 403) + } + msg <- Future { + code.chat.ChatMessageTrait.chatMessageProvider.vend.getMessage(chatMessageId) + } map { + x => unboxFullOrFail(x, callContext, ChatMessageNotFound, 404) + } + reactions <- Future { + code.chat.ReactionTrait.reactionProvider.vend.getReactions(chatMessageId).openOr(List.empty) + } + } yield { + (JSONFactory600.createChatMessageJson(msg, reactions), HttpCode.`200`(callContext)) + } + } + } + + // 15b. getSystemChatMessage + staticResourceDocs += ResourceDoc( + getSystemChatMessage, + implementedInApiVersion, + nameOf(getSystemChatMessage), + "GET", + "/chat-rooms/CHAT_ROOM_ID/messages/CHAT_MESSAGE_ID", + "Get System Chat Message", + s"""Get a specific message by ID in a system-level chat room. The current user must be a participant. + | + |Authentication is Required + | + |""".stripMargin, + EmptyBody, + ChatMessageJsonV600( + chat_message_id = "msg-id-123", + chat_room_id = "chat-room-id-123", + sender_user_id = "user-id-123", + sender_consumer_id = "", + sender_username = "robert.x.0.gh", + sender_provider = "https://github.com", + sender_consumer_name = "My Banking App", + content = "Hello everyone!", + message_type = "text", + mentioned_user_ids = List(), + reply_to_message_id = "", + thread_id = "", + is_deleted = false, + created_at = new java.util.Date(), + updated_at = new java.util.Date(), + reactions = List() + ), + List( + $AuthenticatedUserIsRequired, + ChatRoomNotFound, + NotChatRoomParticipant, + ChatMessageNotFound, + UnknownError + ), + List(apiTagChat), + None + ) + + lazy val getSystemChatMessage: OBPEndpoint = { + case "chat-rooms" :: chatRoomId :: "messages" :: chatMessageId :: Nil JsonGet _ => { + cc => implicit val ec = EndpointContext(Some(cc)) + for { + (Full(u), callContext) <- authenticatedAccess(cc) + _ <- Future { + code.chat.ChatRoomTrait.chatRoomProvider.vend.getChatRoom(chatRoomId) + } map { + x => unboxFullOrFail(x, callContext, ChatRoomNotFound, 404) + } + _ <- Future { + code.chat.ChatPermissions.isParticipant(chatRoomId, u.userId) + } map { + x => unboxFullOrFail(x, callContext, NotChatRoomParticipant, 403) + } + msg <- Future { + code.chat.ChatMessageTrait.chatMessageProvider.vend.getMessage(chatMessageId) + } map { + x => unboxFullOrFail(x, callContext, ChatMessageNotFound, 404) + } + reactions <- Future { + code.chat.ReactionTrait.reactionProvider.vend.getReactions(chatMessageId).openOr(List.empty) + } + } yield { + (JSONFactory600.createChatMessageJson(msg, reactions), HttpCode.`200`(callContext)) + } + } + } + + // 16a. editBankChatMessage + staticResourceDocs += ResourceDoc( + editBankChatMessage, + implementedInApiVersion, + nameOf(editBankChatMessage), + "PUT", + "/banks/BANK_ID/chat-rooms/CHAT_ROOM_ID/messages/CHAT_MESSAGE_ID", + "Edit Bank Chat Message", + s"""Edit a message. Only the sender can edit their own messages. + | + |Authentication is Required + | + |""".stripMargin, + PutChatMessageJsonV600(content = "Updated message content"), + ChatMessageJsonV600( + chat_message_id = "msg-id-123", + chat_room_id = "chat-room-id-123", + sender_user_id = "user-id-123", + sender_consumer_id = "", + sender_username = "robert.x.0.gh", + sender_provider = "https://github.com", + sender_consumer_name = "My Banking App", + content = "Updated message content", + message_type = "text", + mentioned_user_ids = List(), + reply_to_message_id = "", + thread_id = "", + is_deleted = false, + created_at = new java.util.Date(), + updated_at = new java.util.Date(), + reactions = List() + ), + List( + $AuthenticatedUserIsRequired, + InvalidJsonFormat, + ChatRoomNotFound, + NotChatRoomParticipant, + ChatMessageNotFound, + CannotEditOthersMessage, + UnknownError + ), + List(apiTagChat), + None + ) + + lazy val editBankChatMessage: OBPEndpoint = { + case "banks" :: BankId(bankId) :: "chat-rooms" :: chatRoomId :: "messages" :: chatMessageId :: Nil JsonPut json -> _ => { + cc => implicit val ec = EndpointContext(Some(cc)) + for { + (Full(u), callContext) <- authenticatedAccess(cc) + putJson <- NewStyle.function.tryons(s"$InvalidJsonFormat The Json body should be the $PutChatMessageJsonV600", 400, callContext) { + json.extract[PutChatMessageJsonV600] + } + _ <- Future { + code.chat.ChatRoomTrait.chatRoomProvider.vend.getChatRoom(chatRoomId) + } map { + x => unboxFullOrFail(x, callContext, ChatRoomNotFound, 404) + } + _ <- Future { + code.chat.ChatPermissions.isParticipant(chatRoomId, u.userId) + } map { + x => unboxFullOrFail(x, callContext, NotChatRoomParticipant, 403) + } + msg <- Future { + code.chat.ChatMessageTrait.chatMessageProvider.vend.getMessage(chatMessageId) + } map { + x => unboxFullOrFail(x, callContext, ChatMessageNotFound, 404) + } + _ <- Helper.booleanToFuture(failMsg = CannotEditOthersMessage, cc = callContext) { + msg.senderUserId == u.userId + } + updatedMsg <- Future { + code.chat.ChatMessageTrait.chatMessageProvider.vend.updateMessage(chatMessageId, putJson.content) + } map { + x => unboxFullOrFail(x, callContext, s"$UnknownError Cannot edit message", 400) + } + reactions <- Future { + code.chat.ReactionTrait.reactionProvider.vend.getReactions(chatMessageId).openOr(List.empty) + } + } yield { + code.chat.ChatEventPublisher.afterUpdate(updatedMsg, u.name, u.provider, "") + (JSONFactory600.createChatMessageJson(updatedMsg, reactions), HttpCode.`200`(callContext)) + } + } + } + + // 16b. editSystemChatMessage + staticResourceDocs += ResourceDoc( + editSystemChatMessage, + implementedInApiVersion, + nameOf(editSystemChatMessage), + "PUT", + "/chat-rooms/CHAT_ROOM_ID/messages/CHAT_MESSAGE_ID", + "Edit System Chat Message", + s"""Edit a message in a system-level chat room. Only the sender can edit their own messages. + | + |Authentication is Required + | + |""".stripMargin, + PutChatMessageJsonV600(content = "Updated message content"), + ChatMessageJsonV600( + chat_message_id = "msg-id-123", + chat_room_id = "chat-room-id-123", + sender_user_id = "user-id-123", + sender_consumer_id = "", + sender_username = "robert.x.0.gh", + sender_provider = "https://github.com", + sender_consumer_name = "My Banking App", + content = "Updated message content", + message_type = "text", + mentioned_user_ids = List(), + reply_to_message_id = "", + thread_id = "", + is_deleted = false, + created_at = new java.util.Date(), + updated_at = new java.util.Date(), + reactions = List() + ), + List( + $AuthenticatedUserIsRequired, + InvalidJsonFormat, + ChatRoomNotFound, + NotChatRoomParticipant, + ChatMessageNotFound, + CannotEditOthersMessage, + UnknownError + ), + List(apiTagChat), + None + ) + + lazy val editSystemChatMessage: OBPEndpoint = { + case "chat-rooms" :: chatRoomId :: "messages" :: chatMessageId :: Nil JsonPut json -> _ => { + cc => implicit val ec = EndpointContext(Some(cc)) + for { + (Full(u), callContext) <- authenticatedAccess(cc) + putJson <- NewStyle.function.tryons(s"$InvalidJsonFormat The Json body should be the $PutChatMessageJsonV600", 400, callContext) { + json.extract[PutChatMessageJsonV600] + } + _ <- Future { + code.chat.ChatRoomTrait.chatRoomProvider.vend.getChatRoom(chatRoomId) + } map { + x => unboxFullOrFail(x, callContext, ChatRoomNotFound, 404) + } + _ <- Future { + code.chat.ChatPermissions.isParticipant(chatRoomId, u.userId) + } map { + x => unboxFullOrFail(x, callContext, NotChatRoomParticipant, 403) + } + msg <- Future { + code.chat.ChatMessageTrait.chatMessageProvider.vend.getMessage(chatMessageId) + } map { + x => unboxFullOrFail(x, callContext, ChatMessageNotFound, 404) + } + _ <- Helper.booleanToFuture(failMsg = CannotEditOthersMessage, cc = callContext) { + msg.senderUserId == u.userId + } + updatedMsg <- Future { + code.chat.ChatMessageTrait.chatMessageProvider.vend.updateMessage(chatMessageId, putJson.content) + } map { + x => unboxFullOrFail(x, callContext, s"$UnknownError Cannot edit message", 400) + } + reactions <- Future { + code.chat.ReactionTrait.reactionProvider.vend.getReactions(chatMessageId).openOr(List.empty) + } + } yield { + code.chat.ChatEventPublisher.afterUpdate(updatedMsg, u.name, u.provider, "") + (JSONFactory600.createChatMessageJson(updatedMsg, reactions), HttpCode.`200`(callContext)) + } + } + } + + // 17a. deleteBankChatMessage + staticResourceDocs += ResourceDoc( + deleteBankChatMessage, + implementedInApiVersion, + nameOf(deleteBankChatMessage), + "DELETE", + "/banks/BANK_ID/chat-rooms/CHAT_ROOM_ID/messages/CHAT_MESSAGE_ID", + "Delete Bank Chat Message", + s"""Soft-delete a message. The sender can delete their own messages, or a participant with can_delete_message permission can delete any message. + | + |Authentication is Required + | + |""".stripMargin, + EmptyBody, + EmptyBody, + List( + $AuthenticatedUserIsRequired, + ChatRoomNotFound, + NotChatRoomParticipant, + ChatMessageNotFound, + CannotDeleteMessage, + UnknownError + ), + List(apiTagChat), + None + ) + + lazy val deleteBankChatMessage: OBPEndpoint = { + case "banks" :: BankId(bankId) :: "chat-rooms" :: chatRoomId :: "messages" :: chatMessageId :: Nil JsonDelete _ => { + cc => implicit val ec = EndpointContext(Some(cc)) + for { + (Full(u), callContext) <- authenticatedAccess(cc) + _ <- Future { + code.chat.ChatRoomTrait.chatRoomProvider.vend.getChatRoom(chatRoomId) + } map { + x => unboxFullOrFail(x, callContext, ChatRoomNotFound, 404) + } + _ <- Future { + code.chat.ChatPermissions.isParticipant(chatRoomId, u.userId) + } map { + x => unboxFullOrFail(x, callContext, NotChatRoomParticipant, 403) + } + msg <- Future { + code.chat.ChatMessageTrait.chatMessageProvider.vend.getMessage(chatMessageId) + } map { + x => unboxFullOrFail(x, callContext, ChatMessageNotFound, 404) + } + _ <- if (msg.senderUserId == u.userId) { + Future.successful(Full(())) + } else { + Future { + code.chat.ChatPermissions.checkParticipantPermission(chatRoomId, u.userId, code.chat.ChatPermissions.CAN_DELETE_MESSAGE) + } map { + x => unboxFullOrFail(x, callContext, CannotDeleteMessage, 403) + } + } + deletedMsg <- Future { + code.chat.ChatMessageTrait.chatMessageProvider.vend.softDeleteMessage(chatMessageId) + } map { + x => unboxFullOrFail(x, callContext, s"$UnknownError Cannot delete message", 400) + } + } yield { + code.chat.ChatEventPublisher.afterDelete(deletedMsg, u.name, u.provider, "") + (EmptyBody, HttpCode.`204`(callContext)) + } + } + } + + // 17b. deleteSystemChatMessage + staticResourceDocs += ResourceDoc( + deleteSystemChatMessage, + implementedInApiVersion, + nameOf(deleteSystemChatMessage), + "DELETE", + "/chat-rooms/CHAT_ROOM_ID/messages/CHAT_MESSAGE_ID", + "Delete System Chat Message", + s"""Soft-delete a message in a system-level chat room. The sender can delete their own messages, or a participant with can_delete_message permission can delete any message. + | + |Authentication is Required + | + |""".stripMargin, + EmptyBody, + EmptyBody, + List( + $AuthenticatedUserIsRequired, + ChatRoomNotFound, + NotChatRoomParticipant, + ChatMessageNotFound, + CannotDeleteMessage, + UnknownError + ), + List(apiTagChat), + None + ) + + lazy val deleteSystemChatMessage: OBPEndpoint = { + case "chat-rooms" :: chatRoomId :: "messages" :: chatMessageId :: Nil JsonDelete _ => { + cc => implicit val ec = EndpointContext(Some(cc)) + for { + (Full(u), callContext) <- authenticatedAccess(cc) + _ <- Future { + code.chat.ChatRoomTrait.chatRoomProvider.vend.getChatRoom(chatRoomId) + } map { + x => unboxFullOrFail(x, callContext, ChatRoomNotFound, 404) + } + _ <- Future { + code.chat.ChatPermissions.isParticipant(chatRoomId, u.userId) + } map { + x => unboxFullOrFail(x, callContext, NotChatRoomParticipant, 403) + } + msg <- Future { + code.chat.ChatMessageTrait.chatMessageProvider.vend.getMessage(chatMessageId) + } map { + x => unboxFullOrFail(x, callContext, ChatMessageNotFound, 404) + } + _ <- if (msg.senderUserId == u.userId) { + Future.successful(Full(())) + } else { + Future { + code.chat.ChatPermissions.checkParticipantPermission(chatRoomId, u.userId, code.chat.ChatPermissions.CAN_DELETE_MESSAGE) + } map { + x => unboxFullOrFail(x, callContext, CannotDeleteMessage, 403) + } + } + deletedMsg <- Future { + code.chat.ChatMessageTrait.chatMessageProvider.vend.softDeleteMessage(chatMessageId) + } map { + x => unboxFullOrFail(x, callContext, s"$UnknownError Cannot delete message", 400) + } + } yield { + code.chat.ChatEventPublisher.afterDelete(deletedMsg, u.name, u.provider, "") + (EmptyBody, HttpCode.`204`(callContext)) + } + } + } + + // ------ Batch E: Threads ------ + + // 18a. getBankThreadReplies + staticResourceDocs += ResourceDoc( + getBankThreadReplies, + implementedInApiVersion, + nameOf(getBankThreadReplies), + "GET", + "/banks/BANK_ID/chat-rooms/CHAT_ROOM_ID/messages/CHAT_MESSAGE_ID/thread", + "Get Bank Thread Replies", + s"""Get all replies in a message thread. The current user must be a participant. + | + |Authentication is Required + | + |""".stripMargin, + EmptyBody, + ChatMessagesJsonV600(messages = List(ChatMessageJsonV600( + chat_message_id = "reply-id-123", + chat_room_id = "chat-room-id-123", + sender_user_id = "user-id-456", + sender_consumer_id = "", + sender_username = "robert.x.0.gh", + sender_provider = "https://github.com", + sender_consumer_name = "My Banking App", + content = "This is a reply", + message_type = "text", + mentioned_user_ids = List(), + reply_to_message_id = "", + thread_id = "msg-id-123", + is_deleted = false, + created_at = new java.util.Date(), + updated_at = new java.util.Date(), + reactions = List() + ))), + List( + $AuthenticatedUserIsRequired, + ChatRoomNotFound, + NotChatRoomParticipant, + ChatMessageNotFound, + UnknownError + ), + List(apiTagChat), + None + ) + + lazy val getBankThreadReplies: OBPEndpoint = { + case "banks" :: BankId(bankId) :: "chat-rooms" :: chatRoomId :: "messages" :: chatMessageId :: "thread" :: Nil JsonGet _ => { + cc => implicit val ec = EndpointContext(Some(cc)) + for { + (Full(u), callContext) <- authenticatedAccess(cc) + _ <- Future { + code.chat.ChatRoomTrait.chatRoomProvider.vend.getChatRoom(chatRoomId) + } map { + x => unboxFullOrFail(x, callContext, ChatRoomNotFound, 404) + } + _ <- Future { + code.chat.ChatPermissions.isParticipant(chatRoomId, u.userId) + } map { + x => unboxFullOrFail(x, callContext, NotChatRoomParticipant, 403) + } + _ <- Future { + code.chat.ChatMessageTrait.chatMessageProvider.vend.getMessage(chatMessageId) + } map { + x => unboxFullOrFail(x, callContext, ChatMessageNotFound, 404) + } + replies <- Future { + code.chat.ChatMessageTrait.chatMessageProvider.vend.getThreadReplies(chatMessageId) + } map { + x => unboxFullOrFail(x, callContext, s"$UnknownError Cannot get thread replies", 400) + } + allReactions <- Future { + replies.map { msg => + val reactions = code.chat.ReactionTrait.reactionProvider.vend.getReactions(msg.chatMessageId).openOr(List.empty) + (msg.chatMessageId, reactions) + }.toMap + } + } yield { + (JSONFactory600.createChatMessagesJson(replies, allReactions), HttpCode.`200`(callContext)) + } + } + } + + // 18b. getSystemThreadReplies + staticResourceDocs += ResourceDoc( + getSystemThreadReplies, + implementedInApiVersion, + nameOf(getSystemThreadReplies), + "GET", + "/chat-rooms/CHAT_ROOM_ID/messages/CHAT_MESSAGE_ID/thread", + "Get System Thread Replies", + s"""Get all replies in a message thread in a system-level chat room. The current user must be a participant. + | + |Authentication is Required + | + |""".stripMargin, + EmptyBody, + ChatMessagesJsonV600(messages = List(ChatMessageJsonV600( + chat_message_id = "reply-id-123", + chat_room_id = "chat-room-id-123", + sender_user_id = "user-id-456", + sender_consumer_id = "", + sender_username = "robert.x.0.gh", + sender_provider = "https://github.com", + sender_consumer_name = "My Banking App", + content = "This is a reply", + message_type = "text", + mentioned_user_ids = List(), + reply_to_message_id = "", + thread_id = "msg-id-123", + is_deleted = false, + created_at = new java.util.Date(), + updated_at = new java.util.Date(), + reactions = List() + ))), + List( + $AuthenticatedUserIsRequired, + ChatRoomNotFound, + NotChatRoomParticipant, + ChatMessageNotFound, + UnknownError + ), + List(apiTagChat), + None + ) + + lazy val getSystemThreadReplies: OBPEndpoint = { + case "chat-rooms" :: chatRoomId :: "messages" :: chatMessageId :: "thread" :: Nil JsonGet _ => { + cc => implicit val ec = EndpointContext(Some(cc)) + for { + (Full(u), callContext) <- authenticatedAccess(cc) + _ <- Future { + code.chat.ChatRoomTrait.chatRoomProvider.vend.getChatRoom(chatRoomId) + } map { + x => unboxFullOrFail(x, callContext, ChatRoomNotFound, 404) + } + _ <- Future { + code.chat.ChatPermissions.isParticipant(chatRoomId, u.userId) + } map { + x => unboxFullOrFail(x, callContext, NotChatRoomParticipant, 403) + } + _ <- Future { + code.chat.ChatMessageTrait.chatMessageProvider.vend.getMessage(chatMessageId) + } map { + x => unboxFullOrFail(x, callContext, ChatMessageNotFound, 404) + } + replies <- Future { + code.chat.ChatMessageTrait.chatMessageProvider.vend.getThreadReplies(chatMessageId) + } map { + x => unboxFullOrFail(x, callContext, s"$UnknownError Cannot get thread replies", 400) + } + allReactions <- Future { + replies.map { msg => + val reactions = code.chat.ReactionTrait.reactionProvider.vend.getReactions(msg.chatMessageId).openOr(List.empty) + (msg.chatMessageId, reactions) + }.toMap + } + } yield { + (JSONFactory600.createChatMessagesJson(replies, allReactions), HttpCode.`200`(callContext)) + } + } + } + + // 19a. replyInBankThread + staticResourceDocs += ResourceDoc( + replyInBankThread, + implementedInApiVersion, + nameOf(replyInBankThread), + "POST", + "/banks/BANK_ID/chat-rooms/CHAT_ROOM_ID/messages/CHAT_MESSAGE_ID/thread", + "Reply In Bank Thread", + s"""Reply to a message in a thread. The current user must be a participant and the room must not be archived. + | + |Authentication is Required + | + |""".stripMargin, + PostChatMessageJsonV600(content = "This is a thread reply", message_type = Some("text"), mentioned_user_ids = None, reply_to_message_id = None, thread_id = None), + ChatMessageJsonV600( + chat_message_id = "reply-id-456", + chat_room_id = "chat-room-id-123", + sender_user_id = "user-id-123", + sender_consumer_id = "", + sender_username = "robert.x.0.gh", + sender_provider = "https://github.com", + sender_consumer_name = "My Banking App", + content = "This is a thread reply", + message_type = "text", + mentioned_user_ids = List(), + reply_to_message_id = "", + thread_id = "msg-id-123", + is_deleted = false, + created_at = new java.util.Date(), + updated_at = new java.util.Date(), + reactions = List() + ), + List( + $AuthenticatedUserIsRequired, + InvalidJsonFormat, + ChatRoomNotFound, + NotChatRoomParticipant, + ChatRoomIsArchived, + ChatMessageNotFound, + UnknownError + ), + List(apiTagChat), + None + ) + + lazy val replyInBankThread: OBPEndpoint = { + case "banks" :: BankId(bankId) :: "chat-rooms" :: chatRoomId :: "messages" :: chatMessageId :: "thread" :: Nil JsonPost json -> _ => { + cc => implicit val ec = EndpointContext(Some(cc)) + for { + (Full(u), callContext) <- authenticatedAccess(cc) + postJson <- NewStyle.function.tryons(s"$InvalidJsonFormat The Json body should be the $PostChatMessageJsonV600", 400, callContext) { + json.extract[PostChatMessageJsonV600] + } + room <- Future { + code.chat.ChatRoomTrait.chatRoomProvider.vend.getChatRoom(chatRoomId) + } map { + x => unboxFullOrFail(x, callContext, ChatRoomNotFound, 404) + } + _ <- Future { + code.chat.ChatPermissions.isParticipant(chatRoomId, u.userId) + } map { + x => unboxFullOrFail(x, callContext, NotChatRoomParticipant, 403) + } + _ <- Helper.booleanToFuture(failMsg = ChatRoomIsArchived, cc = callContext) { + !room.isArchived + } + _ <- Future { + code.chat.ChatMessageTrait.chatMessageProvider.vend.getMessage(chatMessageId) + } map { + x => unboxFullOrFail(x, callContext, ChatMessageNotFound, 404) + } + msg <- Future { + code.chat.ChatMessageTrait.chatMessageProvider.vend.createMessage( + chatRoomId, + u.userId, + "", + postJson.content, + postJson.message_type.getOrElse("text"), + postJson.mentioned_user_ids.getOrElse(List.empty), + postJson.reply_to_message_id.getOrElse(""), + chatMessageId // threadId is the parent message ID + ) + } map { + x => unboxFullOrFail(x, callContext, s"$UnknownError Cannot send thread reply", 400) + } + } yield { + code.chat.ChatEventPublisher.afterCreate(msg, u.name, u.provider, "") + (JSONFactory600.createChatMessageJson(msg, List.empty), HttpCode.`201`(callContext)) + } + } + } + + // 19b. replyInSystemThread + staticResourceDocs += ResourceDoc( + replyInSystemThread, + implementedInApiVersion, + nameOf(replyInSystemThread), + "POST", + "/chat-rooms/CHAT_ROOM_ID/messages/CHAT_MESSAGE_ID/thread", + "Reply In System Thread", + s"""Reply to a message in a thread in a system-level chat room. The current user must be a participant and the room must not be archived. + | + |Authentication is Required + | + |""".stripMargin, + PostChatMessageJsonV600(content = "This is a thread reply", message_type = Some("text"), mentioned_user_ids = None, reply_to_message_id = None, thread_id = None), + ChatMessageJsonV600( + chat_message_id = "reply-id-456", + chat_room_id = "chat-room-id-123", + sender_user_id = "user-id-123", + sender_consumer_id = "", + sender_username = "robert.x.0.gh", + sender_provider = "https://github.com", + sender_consumer_name = "My Banking App", + content = "This is a thread reply", + message_type = "text", + mentioned_user_ids = List(), + reply_to_message_id = "", + thread_id = "msg-id-123", + is_deleted = false, + created_at = new java.util.Date(), + updated_at = new java.util.Date(), + reactions = List() + ), + List( + $AuthenticatedUserIsRequired, + InvalidJsonFormat, + ChatRoomNotFound, + NotChatRoomParticipant, + ChatRoomIsArchived, + ChatMessageNotFound, + UnknownError + ), + List(apiTagChat), + None + ) + + lazy val replyInSystemThread: OBPEndpoint = { + case "chat-rooms" :: chatRoomId :: "messages" :: chatMessageId :: "thread" :: Nil JsonPost json -> _ => { + cc => implicit val ec = EndpointContext(Some(cc)) + for { + (Full(u), callContext) <- authenticatedAccess(cc) + postJson <- NewStyle.function.tryons(s"$InvalidJsonFormat The Json body should be the $PostChatMessageJsonV600", 400, callContext) { + json.extract[PostChatMessageJsonV600] + } + room <- Future { + code.chat.ChatRoomTrait.chatRoomProvider.vend.getChatRoom(chatRoomId) + } map { + x => unboxFullOrFail(x, callContext, ChatRoomNotFound, 404) + } + _ <- Future { + code.chat.ChatPermissions.isParticipant(chatRoomId, u.userId) + } map { + x => unboxFullOrFail(x, callContext, NotChatRoomParticipant, 403) + } + _ <- Helper.booleanToFuture(failMsg = ChatRoomIsArchived, cc = callContext) { + !room.isArchived + } + _ <- Future { + code.chat.ChatMessageTrait.chatMessageProvider.vend.getMessage(chatMessageId) + } map { + x => unboxFullOrFail(x, callContext, ChatMessageNotFound, 404) + } + msg <- Future { + code.chat.ChatMessageTrait.chatMessageProvider.vend.createMessage( + chatRoomId, + u.userId, + "", + postJson.content, + postJson.message_type.getOrElse("text"), + postJson.mentioned_user_ids.getOrElse(List.empty), + postJson.reply_to_message_id.getOrElse(""), + chatMessageId // threadId is the parent message ID + ) + } map { + x => unboxFullOrFail(x, callContext, s"$UnknownError Cannot send thread reply", 400) + } + } yield { + code.chat.ChatEventPublisher.afterCreate(msg, u.name, u.provider, "") + (JSONFactory600.createChatMessageJson(msg, List.empty), HttpCode.`201`(callContext)) + } + } + } + + // ------ Batch F: Reactions ------ + + // 20a. addBankReaction + staticResourceDocs += ResourceDoc( + addBankReaction, + implementedInApiVersion, + nameOf(addBankReaction), + "POST", + "/banks/BANK_ID/chat-rooms/CHAT_ROOM_ID/messages/CHAT_MESSAGE_ID/reactions", + "Add Bank Reaction", + s"""Add a reaction (emoji) to a message. The current user must be a participant. + | + |Authentication is Required + | + |""".stripMargin, + PostReactionJsonV600(emoji = "thumbsup"), + ReactionJsonV600( + reaction_id = "reaction-id-123", + chat_message_id = "msg-id-123", + user_id = "user-id-123", + username = "robert.x.0.gh", + provider = "https://github.com", + emoji = "thumbsup", + created_at = new java.util.Date() + ), + List( + $AuthenticatedUserIsRequired, + InvalidJsonFormat, + ChatRoomNotFound, + NotChatRoomParticipant, + ChatMessageNotFound, + ReactionAlreadyExists, + UnknownError + ), + List(apiTagChat), + None + ) + + lazy val addBankReaction: OBPEndpoint = { + case "banks" :: BankId(bankId) :: "chat-rooms" :: chatRoomId :: "messages" :: chatMessageId :: "reactions" :: Nil JsonPost json -> _ => { + cc => implicit val ec = EndpointContext(Some(cc)) + for { + (Full(u), callContext) <- authenticatedAccess(cc) + postJson <- NewStyle.function.tryons(s"$InvalidJsonFormat The Json body should be the $PostReactionJsonV600", 400, callContext) { + json.extract[PostReactionJsonV600] + } + _ <- Future { + code.chat.ChatRoomTrait.chatRoomProvider.vend.getChatRoom(chatRoomId) + } map { + x => unboxFullOrFail(x, callContext, ChatRoomNotFound, 404) + } + _ <- Future { + code.chat.ChatPermissions.isParticipant(chatRoomId, u.userId) + } map { + x => unboxFullOrFail(x, callContext, NotChatRoomParticipant, 403) + } + _ <- Future { + code.chat.ChatMessageTrait.chatMessageProvider.vend.getMessage(chatMessageId) + } map { + x => unboxFullOrFail(x, callContext, ChatMessageNotFound, 404) + } + existingReaction <- Future(code.chat.ReactionTrait.reactionProvider.vend.getReaction(chatMessageId, u.userId, postJson.emoji)) + _ <- Helper.booleanToFuture(failMsg = ReactionAlreadyExists, cc = callContext) { + existingReaction.isEmpty + } + reaction <- Future { + code.chat.ReactionTrait.reactionProvider.vend.addReaction(chatMessageId, u.userId, postJson.emoji) + } map { + x => unboxFullOrFail(x, callContext, s"$UnknownError Cannot add reaction", 400) + } + } yield { + (JSONFactory600.createReactionJson(reaction), HttpCode.`201`(callContext)) + } + } + } + + // 20b. addSystemReaction + staticResourceDocs += ResourceDoc( + addSystemReaction, + implementedInApiVersion, + nameOf(addSystemReaction), + "POST", + "/chat-rooms/CHAT_ROOM_ID/messages/CHAT_MESSAGE_ID/reactions", + "Add System Reaction", + s"""Add a reaction (emoji) to a message in a system-level chat room. The current user must be a participant. + | + |Authentication is Required + | + |""".stripMargin, + PostReactionJsonV600(emoji = "thumbsup"), + ReactionJsonV600( + reaction_id = "reaction-id-123", + chat_message_id = "msg-id-123", + user_id = "user-id-123", + username = "robert.x.0.gh", + provider = "https://github.com", + emoji = "thumbsup", + created_at = new java.util.Date() + ), + List( + $AuthenticatedUserIsRequired, + InvalidJsonFormat, + ChatRoomNotFound, + NotChatRoomParticipant, + ChatMessageNotFound, + ReactionAlreadyExists, + UnknownError + ), + List(apiTagChat), + None + ) + + lazy val addSystemReaction: OBPEndpoint = { + case "chat-rooms" :: chatRoomId :: "messages" :: chatMessageId :: "reactions" :: Nil JsonPost json -> _ => { + cc => implicit val ec = EndpointContext(Some(cc)) + for { + (Full(u), callContext) <- authenticatedAccess(cc) + postJson <- NewStyle.function.tryons(s"$InvalidJsonFormat The Json body should be the $PostReactionJsonV600", 400, callContext) { + json.extract[PostReactionJsonV600] + } + _ <- Future { + code.chat.ChatRoomTrait.chatRoomProvider.vend.getChatRoom(chatRoomId) + } map { + x => unboxFullOrFail(x, callContext, ChatRoomNotFound, 404) + } + _ <- Future { + code.chat.ChatPermissions.isParticipant(chatRoomId, u.userId) + } map { + x => unboxFullOrFail(x, callContext, NotChatRoomParticipant, 403) + } + _ <- Future { + code.chat.ChatMessageTrait.chatMessageProvider.vend.getMessage(chatMessageId) + } map { + x => unboxFullOrFail(x, callContext, ChatMessageNotFound, 404) + } + existingReaction <- Future(code.chat.ReactionTrait.reactionProvider.vend.getReaction(chatMessageId, u.userId, postJson.emoji)) + _ <- Helper.booleanToFuture(failMsg = ReactionAlreadyExists, cc = callContext) { + existingReaction.isEmpty + } + reaction <- Future { + code.chat.ReactionTrait.reactionProvider.vend.addReaction(chatMessageId, u.userId, postJson.emoji) + } map { + x => unboxFullOrFail(x, callContext, s"$UnknownError Cannot add reaction", 400) + } + } yield { + (JSONFactory600.createReactionJson(reaction), HttpCode.`201`(callContext)) + } + } + } + + // 21a. removeBankReaction + staticResourceDocs += ResourceDoc( + removeBankReaction, + implementedInApiVersion, + nameOf(removeBankReaction), + "DELETE", + "/banks/BANK_ID/chat-rooms/CHAT_ROOM_ID/messages/CHAT_MESSAGE_ID/reactions/EMOJI", + "Remove Bank Reaction", + s"""Remove your own reaction from a message. + | + |Authentication is Required + | + |""".stripMargin, + EmptyBody, + EmptyBody, + List( + $AuthenticatedUserIsRequired, + ChatRoomNotFound, + NotChatRoomParticipant, + ChatMessageNotFound, + ReactionNotFound, + UnknownError + ), + List(apiTagChat), + None + ) + + lazy val removeBankReaction: OBPEndpoint = { + case "banks" :: BankId(bankId) :: "chat-rooms" :: chatRoomId :: "messages" :: chatMessageId :: "reactions" :: emoji :: Nil JsonDelete _ => { + cc => implicit val ec = EndpointContext(Some(cc)) + for { + (Full(u), callContext) <- authenticatedAccess(cc) + _ <- Future { + code.chat.ChatRoomTrait.chatRoomProvider.vend.getChatRoom(chatRoomId) + } map { + x => unboxFullOrFail(x, callContext, ChatRoomNotFound, 404) + } + _ <- Future { + code.chat.ChatPermissions.isParticipant(chatRoomId, u.userId) + } map { + x => unboxFullOrFail(x, callContext, NotChatRoomParticipant, 403) + } + _ <- Future { + code.chat.ChatMessageTrait.chatMessageProvider.vend.getMessage(chatMessageId) + } map { + x => unboxFullOrFail(x, callContext, ChatMessageNotFound, 404) + } + decodedEmoji = URLDecoder.decode(emoji, StandardCharsets.UTF_8.name()) + existingReaction <- Future(code.chat.ReactionTrait.reactionProvider.vend.getReaction(chatMessageId, u.userId, decodedEmoji)) + _ <- Helper.booleanToFuture(failMsg = ReactionNotFound, cc = callContext) { + existingReaction.isDefined + } + _ <- Future { + code.chat.ReactionTrait.reactionProvider.vend.removeReaction(chatMessageId, u.userId, decodedEmoji) + } map { + x => unboxFullOrFail(x, callContext, s"$UnknownError Cannot remove reaction", 400) + } + } yield { + (EmptyBody, HttpCode.`204`(callContext)) + } + } + } + + // 21b. removeSystemReaction + staticResourceDocs += ResourceDoc( + removeSystemReaction, + implementedInApiVersion, + nameOf(removeSystemReaction), + "DELETE", + "/chat-rooms/CHAT_ROOM_ID/messages/CHAT_MESSAGE_ID/reactions/EMOJI", + "Remove System Reaction", + s"""Remove your own reaction from a message in a system-level chat room. + | + |Authentication is Required + | + |""".stripMargin, + EmptyBody, + EmptyBody, + List( + $AuthenticatedUserIsRequired, + ChatRoomNotFound, + NotChatRoomParticipant, + ChatMessageNotFound, + ReactionNotFound, + UnknownError + ), + List(apiTagChat), + None + ) + + lazy val removeSystemReaction: OBPEndpoint = { + case "chat-rooms" :: chatRoomId :: "messages" :: chatMessageId :: "reactions" :: emoji :: Nil JsonDelete _ => { + cc => implicit val ec = EndpointContext(Some(cc)) + for { + (Full(u), callContext) <- authenticatedAccess(cc) + _ <- Future { + code.chat.ChatRoomTrait.chatRoomProvider.vend.getChatRoom(chatRoomId) + } map { + x => unboxFullOrFail(x, callContext, ChatRoomNotFound, 404) + } + _ <- Future { + code.chat.ChatPermissions.isParticipant(chatRoomId, u.userId) + } map { + x => unboxFullOrFail(x, callContext, NotChatRoomParticipant, 403) + } + _ <- Future { + code.chat.ChatMessageTrait.chatMessageProvider.vend.getMessage(chatMessageId) + } map { + x => unboxFullOrFail(x, callContext, ChatMessageNotFound, 404) + } + decodedEmoji = URLDecoder.decode(emoji, StandardCharsets.UTF_8.name()) + existingReaction <- Future(code.chat.ReactionTrait.reactionProvider.vend.getReaction(chatMessageId, u.userId, decodedEmoji)) + _ <- Helper.booleanToFuture(failMsg = ReactionNotFound, cc = callContext) { + existingReaction.isDefined + } + _ <- Future { + code.chat.ReactionTrait.reactionProvider.vend.removeReaction(chatMessageId, u.userId, decodedEmoji) + } map { + x => unboxFullOrFail(x, callContext, s"$UnknownError Cannot remove reaction", 400) + } + } yield { + (EmptyBody, HttpCode.`204`(callContext)) + } + } + } + + // 22a. getBankReactions + staticResourceDocs += ResourceDoc( + getBankReactions, + implementedInApiVersion, + nameOf(getBankReactions), + "GET", + "/banks/BANK_ID/chat-rooms/CHAT_ROOM_ID/messages/CHAT_MESSAGE_ID/reactions", + "Get Bank Reactions", + s"""Get all reactions for a message. The current user must be a participant. + | + |Authentication is Required + | + |""".stripMargin, + EmptyBody, + ReactionsJsonV600(reactions = List(ReactionJsonV600( + reaction_id = "reaction-id-123", + chat_message_id = "msg-id-123", + user_id = "user-id-123", + username = "robert.x.0.gh", + provider = "https://github.com", + emoji = "thumbsup", + created_at = new java.util.Date() + ))), + List( + $AuthenticatedUserIsRequired, + ChatRoomNotFound, + NotChatRoomParticipant, + ChatMessageNotFound, + UnknownError + ), + List(apiTagChat), + None + ) + + lazy val getBankReactions: OBPEndpoint = { + case "banks" :: BankId(bankId) :: "chat-rooms" :: chatRoomId :: "messages" :: chatMessageId :: "reactions" :: Nil JsonGet _ => { + cc => implicit val ec = EndpointContext(Some(cc)) + for { + (Full(u), callContext) <- authenticatedAccess(cc) + _ <- Future { + code.chat.ChatRoomTrait.chatRoomProvider.vend.getChatRoom(chatRoomId) + } map { + x => unboxFullOrFail(x, callContext, ChatRoomNotFound, 404) + } + _ <- Future { + code.chat.ChatPermissions.isParticipant(chatRoomId, u.userId) + } map { + x => unboxFullOrFail(x, callContext, NotChatRoomParticipant, 403) + } + _ <- Future { + code.chat.ChatMessageTrait.chatMessageProvider.vend.getMessage(chatMessageId) + } map { + x => unboxFullOrFail(x, callContext, ChatMessageNotFound, 404) + } + reactions <- Future { + code.chat.ReactionTrait.reactionProvider.vend.getReactions(chatMessageId) + } map { + x => unboxFullOrFail(x, callContext, s"$UnknownError Cannot get reactions", 400) + } + } yield { + (JSONFactory600.createReactionsJson(reactions), HttpCode.`200`(callContext)) + } + } + } + + // 22b. getSystemReactions + staticResourceDocs += ResourceDoc( + getSystemReactions, + implementedInApiVersion, + nameOf(getSystemReactions), + "GET", + "/chat-rooms/CHAT_ROOM_ID/messages/CHAT_MESSAGE_ID/reactions", + "Get System Reactions", + s"""Get all reactions for a message in a system-level chat room. The current user must be a participant. + | + |Authentication is Required + | + |""".stripMargin, + EmptyBody, + ReactionsJsonV600(reactions = List(ReactionJsonV600( + reaction_id = "reaction-id-123", + chat_message_id = "msg-id-123", + user_id = "user-id-123", + username = "robert.x.0.gh", + provider = "https://github.com", + emoji = "thumbsup", + created_at = new java.util.Date() + ))), + List( + $AuthenticatedUserIsRequired, + ChatRoomNotFound, + NotChatRoomParticipant, + ChatMessageNotFound, + UnknownError + ), + List(apiTagChat), + None + ) + + lazy val getSystemReactions: OBPEndpoint = { + case "chat-rooms" :: chatRoomId :: "messages" :: chatMessageId :: "reactions" :: Nil JsonGet _ => { + cc => implicit val ec = EndpointContext(Some(cc)) + for { + (Full(u), callContext) <- authenticatedAccess(cc) + _ <- Future { + code.chat.ChatRoomTrait.chatRoomProvider.vend.getChatRoom(chatRoomId) + } map { + x => unboxFullOrFail(x, callContext, ChatRoomNotFound, 404) + } + _ <- Future { + code.chat.ChatPermissions.isParticipant(chatRoomId, u.userId) + } map { + x => unboxFullOrFail(x, callContext, NotChatRoomParticipant, 403) + } + _ <- Future { + code.chat.ChatMessageTrait.chatMessageProvider.vend.getMessage(chatMessageId) + } map { + x => unboxFullOrFail(x, callContext, ChatMessageNotFound, 404) + } + reactions <- Future { + code.chat.ReactionTrait.reactionProvider.vend.getReactions(chatMessageId) + } map { + x => unboxFullOrFail(x, callContext, s"$UnknownError Cannot get reactions", 400) + } + } yield { + (JSONFactory600.createReactionsJson(reactions), HttpCode.`200`(callContext)) + } + } + } + + // ------ Batch G: Typing ------ + + // 23a. signalBankTyping + staticResourceDocs += ResourceDoc( + signalBankTyping, + implementedInApiVersion, + nameOf(signalBankTyping), + "PUT", + "/banks/BANK_ID/chat-rooms/CHAT_ROOM_ID/typing-indicators", + "Signal Bank Typing", + s"""Signal that the current user is typing in a chat room. The typing indicator expires after 5 seconds. + | + |Authentication is Required + | + |""".stripMargin, + EmptyBody, + EmptyBody, + List( + $AuthenticatedUserIsRequired, + ChatRoomNotFound, + NotChatRoomParticipant, + UnknownError + ), + List(apiTagChat), + None + ) + + lazy val signalBankTyping: OBPEndpoint = { + case "banks" :: BankId(bankId) :: "chat-rooms" :: chatRoomId :: "typing-indicators" :: Nil JsonPut _ -> _ => { + cc => implicit val ec = EndpointContext(Some(cc)) + for { + (Full(u), callContext) <- authenticatedAccess(cc) + _ <- Future { + code.chat.ChatRoomTrait.chatRoomProvider.vend.getChatRoom(chatRoomId) + } map { + x => unboxFullOrFail(x, callContext, ChatRoomNotFound, 404) + } + _ <- Future { + code.chat.ChatPermissions.isParticipant(chatRoomId, u.userId) + } map { + x => unboxFullOrFail(x, callContext, NotChatRoomParticipant, 403) + } + _ <- Future { + val key = s"chat_typing_${chatRoomId}_${u.userId}" + Redis.use(code.api.JedisMethod.SET, key, Some(5), Some("1")) + code.chat.ChatEventPublisher.afterTyping(chatRoomId, u.userId, u.name, u.provider, true) + } + } yield { + (EmptyBody, HttpCode.`200`(callContext)) + } + } + } + + // 23b. signalSystemTyping + staticResourceDocs += ResourceDoc( + signalSystemTyping, + implementedInApiVersion, + nameOf(signalSystemTyping), + "PUT", + "/chat-rooms/CHAT_ROOM_ID/typing-indicators", + "Signal System Typing", + s"""Signal that the current user is typing in a system-level chat room. The typing indicator expires after 5 seconds. + | + |Authentication is Required + | + |""".stripMargin, + EmptyBody, + EmptyBody, + List( + $AuthenticatedUserIsRequired, + ChatRoomNotFound, + NotChatRoomParticipant, + UnknownError + ), + List(apiTagChat), + None + ) + + lazy val signalSystemTyping: OBPEndpoint = { + case "chat-rooms" :: chatRoomId :: "typing-indicators" :: Nil JsonPut _ -> _ => { + cc => implicit val ec = EndpointContext(Some(cc)) + for { + (Full(u), callContext) <- authenticatedAccess(cc) + _ <- Future { + code.chat.ChatRoomTrait.chatRoomProvider.vend.getChatRoom(chatRoomId) + } map { + x => unboxFullOrFail(x, callContext, ChatRoomNotFound, 404) + } + _ <- Future { + code.chat.ChatPermissions.isParticipant(chatRoomId, u.userId) + } map { + x => unboxFullOrFail(x, callContext, NotChatRoomParticipant, 403) + } + _ <- Future { + val key = s"chat_typing_${chatRoomId}_${u.userId}" + Redis.use(code.api.JedisMethod.SET, key, Some(5), Some("1")) + code.chat.ChatEventPublisher.afterTyping(chatRoomId, u.userId, u.name, u.provider, true) + } + } yield { + (EmptyBody, HttpCode.`200`(callContext)) + } + } + } + + // 24a. getBankTypingUsers + staticResourceDocs += ResourceDoc( + getBankTypingUsers, + implementedInApiVersion, + nameOf(getBankTypingUsers), + "GET", + "/banks/BANK_ID/chat-rooms/CHAT_ROOM_ID/typing-indicators", + "Get Bank Typing Users", + s"""Get the list of users currently typing in a chat room. + | + |Authentication is Required + | + |""".stripMargin, + EmptyBody, + TypingUsersJsonV600(users = List(TypingUserJsonV600(user_id = "user-id-123", username = "robert.x.0.gh", provider = "https://github.com"))), + List( + $AuthenticatedUserIsRequired, + ChatRoomNotFound, + NotChatRoomParticipant, + UnknownError + ), + List(apiTagChat), + None + ) + + lazy val getBankTypingUsers: OBPEndpoint = { + case "banks" :: BankId(bankId) :: "chat-rooms" :: chatRoomId :: "typing-indicators" :: Nil JsonGet _ => { + cc => implicit val ec = EndpointContext(Some(cc)) + for { + (Full(u), callContext) <- authenticatedAccess(cc) + _ <- Future { + code.chat.ChatRoomTrait.chatRoomProvider.vend.getChatRoom(chatRoomId) + } map { + x => unboxFullOrFail(x, callContext, ChatRoomNotFound, 404) + } + _ <- Future { + code.chat.ChatPermissions.isParticipant(chatRoomId, u.userId) + } map { + x => unboxFullOrFail(x, callContext, NotChatRoomParticipant, 403) + } + participants <- Future { + code.chat.ParticipantTrait.participantProvider.vend.getParticipants(chatRoomId) + } map { + x => unboxFullOrFail(x, callContext, s"$UnknownError Cannot get participants", 400) + } + typingUsers <- Future { + participants.filter(_.userId.nonEmpty).flatMap { p => + val key = s"chat_typing_${chatRoomId}_${p.userId}" + try { + Redis.use(code.api.JedisMethod.GET, key) match { + case Some(_) => + val typingUser = code.users.Users.users.vend.getUserByUserId(p.userId) + Some(TypingUserJsonV600(user_id = p.userId, username = typingUser.map(_.name).getOrElse(""), provider = typingUser.map(_.provider).getOrElse(""))) + case None => None + } + } catch { + case _: Throwable => None + } + } + } + } yield { + (TypingUsersJsonV600(users = typingUsers), HttpCode.`200`(callContext)) + } + } + } + + // 24b. getSystemTypingUsers + staticResourceDocs += ResourceDoc( + getSystemTypingUsers, + implementedInApiVersion, + nameOf(getSystemTypingUsers), + "GET", + "/chat-rooms/CHAT_ROOM_ID/typing-indicators", + "Get System Typing Users", + s"""Get the list of users currently typing in a system-level chat room. + | + |Authentication is Required + | + |""".stripMargin, + EmptyBody, + TypingUsersJsonV600(users = List(TypingUserJsonV600(user_id = "user-id-123", username = "robert.x.0.gh", provider = "https://github.com"))), + List( + $AuthenticatedUserIsRequired, + ChatRoomNotFound, + NotChatRoomParticipant, + UnknownError + ), + List(apiTagChat), + None + ) + + lazy val getSystemTypingUsers: OBPEndpoint = { + case "chat-rooms" :: chatRoomId :: "typing-indicators" :: Nil JsonGet _ => { + cc => implicit val ec = EndpointContext(Some(cc)) + for { + (Full(u), callContext) <- authenticatedAccess(cc) + _ <- Future { + code.chat.ChatRoomTrait.chatRoomProvider.vend.getChatRoom(chatRoomId) + } map { + x => unboxFullOrFail(x, callContext, ChatRoomNotFound, 404) + } + _ <- Future { + code.chat.ChatPermissions.isParticipant(chatRoomId, u.userId) + } map { + x => unboxFullOrFail(x, callContext, NotChatRoomParticipant, 403) + } + participants <- Future { + code.chat.ParticipantTrait.participantProvider.vend.getParticipants(chatRoomId) + } map { + x => unboxFullOrFail(x, callContext, s"$UnknownError Cannot get participants", 400) + } + typingUsers <- Future { + participants.filter(_.userId.nonEmpty).flatMap { p => + val key = s"chat_typing_${chatRoomId}_${p.userId}" + try { + Redis.use(code.api.JedisMethod.GET, key) match { + case Some(_) => + val typingUser = code.users.Users.users.vend.getUserByUserId(p.userId) + Some(TypingUserJsonV600(user_id = p.userId, username = typingUser.map(_.name).getOrElse(""), provider = typingUser.map(_.provider).getOrElse(""))) + case None => None + } + } catch { + case _: Throwable => None + } + } + } + } yield { + (TypingUsersJsonV600(users = typingUsers), HttpCode.`200`(callContext)) + } + } + } + + // ------ Batch H: User-Level ------ + + // 25. getMyChatRooms + staticResourceDocs += ResourceDoc( + getMyChatRooms, + implementedInApiVersion, + nameOf(getMyChatRooms), + "GET", + "/users/current/chat-rooms", + "Get My Chat Rooms", + s"""Get all chat rooms the current user is a participant of, across all banks and system-level rooms. + | + |Authentication is Required + | + |""".stripMargin, + EmptyBody, + ChatRoomsJsonV600(chat_rooms = List(ChatRoomJsonV600( + chat_room_id = "chat-room-id-123", + bank_id = "gh.29.uk", + name = "General Discussion", + description = "A place to discuss general topics", + joining_key = "abc123key", + created_by = "user-id-123", + created_by_username = "robert.x.0.gh", + created_by_provider = "https://github.com", + all_users_are_participants = false, + is_archived = false, + created_at = new java.util.Date(), + updated_at = new java.util.Date() + ))), + List( + $AuthenticatedUserIsRequired, + UnknownError + ), + List(apiTagChat), + None + ) + + lazy val getMyChatRooms: OBPEndpoint = { + case "users" :: "current" :: "chat-rooms" :: Nil JsonGet _ => { + cc => implicit val ec = EndpointContext(Some(cc)) + for { + (Full(u), callContext) <- authenticatedAccess(cc) + participantRecords <- Future { + code.chat.ParticipantTrait.participantProvider.vend.getParticipantRoomsByUserId(u.userId) + } map { + x => unboxFullOrFail(x, callContext, s"$UnknownError Cannot get participant records", 400) + } + rooms <- Future { + participantRecords.flatMap { p => + code.chat.ChatRoomTrait.chatRoomProvider.vend.getChatRoom(p.chatRoomId).toList + } + } + } yield { + (JSONFactory600.createChatRoomsJson(rooms), HttpCode.`200`(callContext)) + } + } + } + + // 26. getMyUnreadCounts + staticResourceDocs += ResourceDoc( + getMyUnreadCounts, + implementedInApiVersion, + nameOf(getMyUnreadCounts), + "GET", + "/users/current/chat-rooms/unread", + "Get My Unread Counts", + s"""Get unread message counts for all chat rooms the current user is a participant of. + | + |Authentication is Required + | + |""".stripMargin, + EmptyBody, + UnreadCountsJsonV600(unread_counts = List(UnreadCountJsonV600(chat_room_id = "chat-room-id-123", unread_count = 5))), + List( + $AuthenticatedUserIsRequired, + UnknownError + ), + List(apiTagChat), + None + ) + + lazy val getMyUnreadCounts: OBPEndpoint = { + case "users" :: "current" :: "chat-rooms" :: "unread" :: Nil JsonGet _ => { + cc => implicit val ec = EndpointContext(Some(cc)) + for { + (Full(u), callContext) <- authenticatedAccess(cc) + participantRecords <- Future { + code.chat.ParticipantTrait.participantProvider.vend.getParticipantRoomsByUserId(u.userId) + } map { + x => unboxFullOrFail(x, callContext, s"$UnknownError Cannot get participant records", 400) + } + unreadCounts <- Future { + participantRecords.flatMap { p => + val count = code.chat.ChatMessageTrait.chatMessageProvider.vend.getUnreadCount(p.chatRoomId, p.lastReadAt) + count.toList.map(c => UnreadCountJsonV600(chat_room_id = p.chatRoomId, unread_count = c)) + } + } + } yield { + (UnreadCountsJsonV600(unread_counts = unreadCounts), HttpCode.`200`(callContext)) + } + } + } + + // 27. markChatRoomRead + staticResourceDocs += ResourceDoc( + markChatRoomRead, + implementedInApiVersion, + nameOf(markChatRoomRead), + "PUT", + "/users/current/chat-rooms/CHAT_ROOM_ID/read-marker", + "Mark Chat Room Read", + s"""Mark all messages in a chat room as read for the current user by updating lastReadAt to now. + | + |Authentication is Required + | + |""".stripMargin, + EmptyBody, + ParticipantJsonV600( + participant_id = "participant-id-123", + chat_room_id = "chat-room-id-123", + user_id = "user-id-123", + username = "robert.x.0.gh", + provider = "https://github.com", + consumer_id = "", + consumer_name = "", + permissions = List(), + webhook_url = "", + joined_at = new java.util.Date(), + last_read_at = new java.util.Date(), + is_muted = false + ), + List( + $AuthenticatedUserIsRequired, + ChatRoomNotFound, + NotChatRoomParticipant, + UnknownError + ), + List(apiTagChat), + None + ) + + lazy val markChatRoomRead: OBPEndpoint = { + case "users" :: "current" :: "chat-rooms" :: chatRoomId :: "read-marker" :: Nil JsonPut _ -> _ => { + cc => implicit val ec = EndpointContext(Some(cc)) + for { + (Full(u), callContext) <- authenticatedAccess(cc) + _ <- Future { + code.chat.ChatRoomTrait.chatRoomProvider.vend.getChatRoom(chatRoomId) + } map { + x => unboxFullOrFail(x, callContext, ChatRoomNotFound, 404) + } + _ <- Future { + code.chat.ChatPermissions.isParticipant(chatRoomId, u.userId) + } map { + x => unboxFullOrFail(x, callContext, NotChatRoomParticipant, 403) + } + updatedParticipant <- Future { + code.chat.ParticipantTrait.participantProvider.vend.updateLastReadAt(chatRoomId, u.userId) + } map { + x => unboxFullOrFail(x, callContext, s"$UnknownError Cannot mark as read", 400) + } + } yield { + (JSONFactory600.createParticipantJson(updatedParticipant), HttpCode.`200`(callContext)) + } + } + } + + // 28. getMyMentions + staticResourceDocs += ResourceDoc( + getMyMentions, + implementedInApiVersion, + nameOf(getMyMentions), + "GET", + "/users/current/mentions", + "Get My Mentions", + s"""Get messages where the current user is mentioned. Supports limit and offset query parameters. + | + |Authentication is Required + | + |""".stripMargin, + EmptyBody, + ChatMessagesJsonV600(messages = List(ChatMessageJsonV600( + chat_message_id = "msg-id-123", + chat_room_id = "chat-room-id-123", + sender_user_id = "user-id-456", + sender_consumer_id = "", + sender_username = "robert.x.0.gh", + sender_provider = "https://github.com", + sender_consumer_name = "My Banking App", + content = "Hey @user-id-123, check this out!", + message_type = "text", + mentioned_user_ids = List("user-id-123"), + reply_to_message_id = "", + thread_id = "", + is_deleted = false, + created_at = new java.util.Date(), + updated_at = new java.util.Date(), + reactions = List() + ))), + List( + $AuthenticatedUserIsRequired, + UnknownError + ), + List(apiTagChat), + None + ) + + lazy val getMyMentions: OBPEndpoint = { + case "users" :: "current" :: "mentions" :: Nil JsonGet _ => { + cc => implicit val ec = EndpointContext(Some(cc)) + for { + (Full(u), callContext) <- authenticatedAccess(cc) + limitParam = ObpS.param("limit").map(_.toInt).getOrElse(50) + offsetParam = ObpS.param("offset").map(_.toInt).getOrElse(0) + messages <- Future { + code.chat.ChatMessageTrait.chatMessageProvider.vend.getMentionsForUser(u.userId, limitParam, offsetParam) + } map { + x => unboxFullOrFail(x, callContext, s"$UnknownError Cannot get mentions", 400) + } + allReactions <- Future { + messages.map { msg => + val reactions = code.chat.ReactionTrait.reactionProvider.vend.getReactions(msg.chatMessageId).openOr(List.empty) + (msg.chatMessageId, reactions) + }.toMap + } + } yield { + (JSONFactory600.createChatMessagesJson(messages, allReactions), HttpCode.`200`(callContext)) + } + } + } + } } 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 cd4b3cef3d..a91c879955 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 @@ -1172,6 +1172,87 @@ case class InvestigationReportJsonV600( data_source: String ) +// Chat / Messaging API case classes +case class PostChatRoomJsonV600(name: String, description: String) +case class PutChatRoomJsonV600(name: Option[String], description: Option[String]) +case class PostParticipantJsonV600(user_id: Option[String], consumer_id: Option[String], permissions: Option[List[String]], webhook_url: Option[String]) +case class PutParticipantPermissionsJsonV600(permissions: List[String]) +case class PostChatMessageJsonV600(content: String, message_type: Option[String], mentioned_user_ids: Option[List[String]], reply_to_message_id: Option[String], thread_id: Option[String]) +case class PutChatMessageJsonV600(content: String) +case class PostReactionJsonV600(emoji: String) + +case class ChatRoomJsonV600( + chat_room_id: String, + bank_id: String, + name: String, + description: String, + joining_key: String, + created_by: String, + created_by_username: String, + created_by_provider: String, + all_users_are_participants: Boolean, + is_archived: Boolean, + created_at: java.util.Date, + updated_at: java.util.Date +) +case class ChatRoomsJsonV600(chat_rooms: List[ChatRoomJsonV600]) + +case class ParticipantJsonV600( + participant_id: String, + chat_room_id: String, + user_id: String, + username: String, + provider: String, + consumer_id: String, + consumer_name: String, + permissions: List[String], + webhook_url: String, + joined_at: java.util.Date, + last_read_at: java.util.Date, + is_muted: Boolean +) +case class ParticipantsJsonV600(participants: List[ParticipantJsonV600]) + +case class ChatMessageJsonV600( + chat_message_id: String, + chat_room_id: String, + sender_user_id: String, + sender_consumer_id: String, + sender_username: String, + sender_provider: String, + sender_consumer_name: String, + content: String, + message_type: String, + mentioned_user_ids: List[String], + reply_to_message_id: String, + thread_id: String, + is_deleted: Boolean, + created_at: java.util.Date, + updated_at: java.util.Date, + reactions: List[ReactionSummaryJsonV600] +) +case class ChatMessagesJsonV600(messages: List[ChatMessageJsonV600]) + +case class ReactionJsonV600( + reaction_id: String, + chat_message_id: String, + user_id: String, + username: String, + provider: String, + emoji: String, + created_at: java.util.Date +) +case class ReactionsJsonV600(reactions: List[ReactionJsonV600]) +case class ReactionSummaryJsonV600(emoji: String, count: Int, user_ids: List[String]) + +case class TypingUserJsonV600(user_id: String, username: String, provider: String) +case class TypingUsersJsonV600(users: List[TypingUserJsonV600]) + +case class UnreadCountJsonV600(chat_room_id: String, unread_count: Long) +case class UnreadCountsJsonV600(unread_counts: List[UnreadCountJsonV600]) + +case class JoiningKeyJsonV600(joining_key: String) + object JSONFactory600 extends CustomJsonFormats with MdcLoggable { def createRedisCallCountersJson( @@ -2800,7 +2881,7 @@ object JSONFactory600 extends CustomJsonFormats with MdcLoggable { ) } - def createCustomerLinkJson(customerLink: code.customerlinks.CustomerLink): CustomerLinkJsonV600 = { + def createCustomerLinkJson(customerLink: code.customerlinks.CustomerLinkTrait): CustomerLinkJsonV600 = { CustomerLinkJsonV600( customer_link_id = customerLink.customerLinkId, bank_id = customerLink.bankId, @@ -2813,7 +2894,7 @@ object JSONFactory600 extends CustomJsonFormats with MdcLoggable { ) } - def createCustomerLinksJson(customerLinks: List[code.customerlinks.CustomerLink]): CustomerLinksJsonV600 = { + def createCustomerLinksJson(customerLinks: List[code.customerlinks.CustomerLinkTrait]): CustomerLinksJsonV600 = { CustomerLinksJsonV600( customerLinks.map(createCustomerLinkJson) ) @@ -2879,4 +2960,131 @@ object JSONFactory600 extends CustomJsonFormats with MdcLoggable { ) } + // Chat / Messaging factory functions + def createChatRoomJson(room: code.chat.ChatRoomTrait): ChatRoomJsonV600 = { + val creator = code.users.Users.users.vend.getUserByUserId(room.createdBy) + ChatRoomJsonV600( + chat_room_id = room.chatRoomId, + bank_id = room.bankId, + name = room.name, + description = room.description, + joining_key = room.joiningKey, + created_by = room.createdBy, + created_by_username = creator.map(_.name).getOrElse(""), + created_by_provider = creator.map(_.provider).getOrElse(""), + all_users_are_participants = room.allUsersAreParticipants, + is_archived = room.isArchived, + created_at = room.createdDate, + updated_at = room.updatedDate + ) + } + def createChatRoomsJson(rooms: List[code.chat.ChatRoomTrait]): ChatRoomsJsonV600 = { + ChatRoomsJsonV600(rooms.map(createChatRoomJson)) + } + + def createParticipantJson(p: code.chat.ParticipantTrait): ParticipantJsonV600 = { + val user = code.users.Users.users.vend.getUserByUserId(p.userId) + val consumerName = if (p.consumerId.nonEmpty) + code.model.Consumer.find(By(code.model.Consumer.consumerId, p.consumerId)).map(_.name.get).getOrElse("") + else "" + ParticipantJsonV600( + participant_id = p.participantId, + chat_room_id = p.chatRoomId, + user_id = p.userId, + username = user.map(_.name).getOrElse(""), + provider = user.map(_.provider).getOrElse(""), + consumer_id = p.consumerId, + consumer_name = consumerName, + permissions = p.permissions, + webhook_url = p.webhookUrl, + joined_at = p.joinedAt, + last_read_at = p.lastReadAt, + is_muted = p.isMuted + ) + } + def createParticipantsJson(participants: List[code.chat.ParticipantTrait]): ParticipantsJsonV600 = { + ParticipantsJsonV600(participants.map(createParticipantJson)) + } + + def createChatMessageJson(msg: code.chat.ChatMessageTrait, reactions: List[code.chat.ReactionTrait]): ChatMessageJsonV600 = { + val reactionSummaries = reactions.groupBy(_.emoji).map { case (emoji, rs) => + ReactionSummaryJsonV600(emoji = emoji, count = rs.size, user_ids = rs.map(_.userId)) + }.toList + val user = code.users.Users.users.vend.getUserByUserId(msg.senderUserId) + val consumerAppName = if (msg.senderConsumerId.nonEmpty) + code.model.Consumer.find(By(code.model.Consumer.consumerId, msg.senderConsumerId)).map(_.name.get).getOrElse("") + else "" + ChatMessageJsonV600( + chat_message_id = msg.chatMessageId, + chat_room_id = msg.chatRoomId, + sender_user_id = msg.senderUserId, + sender_consumer_id = msg.senderConsumerId, + sender_username = user.map(_.name).getOrElse(""), + sender_provider = user.map(_.provider).getOrElse(""), + sender_consumer_name = consumerAppName, + content = if (msg.isDeleted) "" else msg.content, + message_type = msg.messageType, + mentioned_user_ids = msg.mentionedUserIds, + reply_to_message_id = msg.replyToMessageId, + thread_id = msg.threadId, + is_deleted = msg.isDeleted, + created_at = msg.createdDate, + updated_at = msg.updatedDate, + reactions = reactionSummaries + ) + } + def createChatMessagesJson(messages: List[code.chat.ChatMessageTrait], allReactions: Map[String, List[code.chat.ReactionTrait]]): ChatMessagesJsonV600 = { + ChatMessagesJsonV600(messages.map(msg => createChatMessageJson(msg, allReactions.getOrElse(msg.chatMessageId, List.empty)))) + } + + def createChatMessagesJsonFromRows( + messages: List[code.chat.DoobieChatMessageQueries.ChatMessageRow], + allReactions: Map[String, List[code.chat.DoobieChatMessageQueries.ReactionRow]] + ): ChatMessagesJsonV600 = { + ChatMessagesJsonV600(messages.map { msg => + val reactions = allReactions.getOrElse(msg.chatMessageId, List.empty) + val reactionSummaries = reactions.groupBy(_.emoji).map { case (emoji, rs) => + ReactionSummaryJsonV600(emoji = emoji, count = rs.size, user_ids = rs.map(_.userId)) + }.toList + val mentionedIds = msg.mentionedUserIds match { + case Some(ids) if ids.nonEmpty => ids.split(",").map(_.trim).filter(_.nonEmpty).toList + case _ => List.empty + } + ChatMessageJsonV600( + chat_message_id = msg.chatMessageId, + chat_room_id = msg.chatRoomId, + sender_user_id = msg.senderUserId, + sender_consumer_id = msg.senderConsumerId, + sender_username = msg.senderUsername, + sender_provider = msg.senderProvider, + sender_consumer_name = msg.senderConsumerName, + content = if (msg.isDeleted) "" else msg.content, + message_type = msg.messageType, + mentioned_user_ids = mentionedIds, + reply_to_message_id = msg.replyToMessageId, + thread_id = msg.threadId, + is_deleted = msg.isDeleted, + created_at = msg.createdAt, + updated_at = msg.updatedAt, + reactions = reactionSummaries + ) + }) + } + + def createReactionJson(r: code.chat.ReactionTrait): ReactionJsonV600 = { + val user = code.users.Users.users.vend.getUserByUserId(r.userId) + ReactionJsonV600( + reaction_id = r.reactionId, + chat_message_id = r.chatMessageId, + user_id = r.userId, + username = user.map(_.name).getOrElse(""), + provider = user.map(_.provider).getOrElse(""), + emoji = r.emoji, + created_at = r.createdDate + ) + } + def createReactionsJson(reactions: List[code.chat.ReactionTrait]): ReactionsJsonV600 = { + ReactionsJsonV600(reactions.map(createReactionJson)) + } + } diff --git a/obp-api/src/main/scala/code/bankconnectors/Connector.scala b/obp-api/src/main/scala/code/bankconnectors/Connector.scala index fbe5bcd286..d32b943ab1 100644 --- a/obp-api/src/main/scala/code/bankconnectors/Connector.scala +++ b/obp-api/src/main/scala/code/bankconnectors/Connector.scala @@ -1877,15 +1877,15 @@ trait Connector extends MdcLoggable { def updateCustomerAccountLinkById(customerAccountLinkId: String, relationshipType: String, callContext: Option[CallContext]): OBPReturnType[Box[CustomerAccountLinkTrait]] = Future{(Failure(setUnimplementedError(nameOf(updateCustomerAccountLinkById _))), callContext)} - def createCustomerLink(bankId: String, customerId: String, otherBankId: String, otherCustomerId: String, relationshipTo: String, callContext: Option[CallContext]): OBPReturnType[Box[code.customerlinks.CustomerLink]] = Future{(Failure(setUnimplementedError(nameOf(createCustomerLink _))), callContext)} + def createCustomerLink(bankId: String, customerId: String, otherBankId: String, otherCustomerId: String, relationshipTo: String, callContext: Option[CallContext]): OBPReturnType[Box[code.customerlinks.CustomerLinkTrait]] = Future{(Failure(setUnimplementedError(nameOf(createCustomerLink _))), callContext)} - def getCustomerLinkById(customerLinkId: String, callContext: Option[CallContext]): OBPReturnType[Box[code.customerlinks.CustomerLink]] = Future{(Failure(setUnimplementedError(nameOf(getCustomerLinkById _))), callContext)} + def getCustomerLinkById(customerLinkId: String, callContext: Option[CallContext]): OBPReturnType[Box[code.customerlinks.CustomerLinkTrait]] = Future{(Failure(setUnimplementedError(nameOf(getCustomerLinkById _))), callContext)} - def getCustomerLinksByBankId(bankId: String, callContext: Option[CallContext]): OBPReturnType[Box[List[code.customerlinks.CustomerLink]]] = Future{(Failure(setUnimplementedError(nameOf(getCustomerLinksByBankId _))), callContext)} + def getCustomerLinksByBankId(bankId: String, callContext: Option[CallContext]): OBPReturnType[Box[List[code.customerlinks.CustomerLinkTrait]]] = Future{(Failure(setUnimplementedError(nameOf(getCustomerLinksByBankId _))), callContext)} - def getCustomerLinksByCustomerId(customerId: String, callContext: Option[CallContext]): OBPReturnType[Box[List[code.customerlinks.CustomerLink]]] = Future{(Failure(setUnimplementedError(nameOf(getCustomerLinksByCustomerId _))), callContext)} + def getCustomerLinksByCustomerId(customerId: String, callContext: Option[CallContext]): OBPReturnType[Box[List[code.customerlinks.CustomerLinkTrait]]] = Future{(Failure(setUnimplementedError(nameOf(getCustomerLinksByCustomerId _))), callContext)} - def updateCustomerLinkById(customerLinkId: String, relationshipTo: String, callContext: Option[CallContext]): OBPReturnType[Box[code.customerlinks.CustomerLink]] = Future{(Failure(setUnimplementedError(nameOf(updateCustomerLinkById _))), callContext)} + def updateCustomerLinkById(customerLinkId: String, relationshipTo: String, callContext: Option[CallContext]): OBPReturnType[Box[code.customerlinks.CustomerLinkTrait]] = Future{(Failure(setUnimplementedError(nameOf(updateCustomerLinkById _))), callContext)} def deleteCustomerLinkById(customerLinkId: String, callContext: Option[CallContext]): OBPReturnType[Box[Boolean]] = Future{(Failure(setUnimplementedError(nameOf(deleteCustomerLinkById _))), callContext)} diff --git a/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala b/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala index a06c978dd2..cab71474e1 100644 --- a/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala +++ b/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala @@ -5508,23 +5508,23 @@ object LocalMappedConnector extends Connector with MdcLoggable { } } - override def createCustomerLink(bankId: String, customerId: String, otherBankId: String, otherCustomerId: String, relationshipTo: String, callContext: Option[CallContext]): OBPReturnType[Box[code.customerlinks.CustomerLink]] = Future{ + override def createCustomerLink(bankId: String, customerId: String, otherBankId: String, otherCustomerId: String, relationshipTo: String, callContext: Option[CallContext]): OBPReturnType[Box[code.customerlinks.CustomerLinkTrait]] = Future{ (code.customerlinks.CustomerLinkX.customerLink.vend.createCustomerLink(bankId, customerId, otherBankId, otherCustomerId, relationshipTo), callContext) } - override def getCustomerLinkById(customerLinkId: String, callContext: Option[CallContext]): OBPReturnType[Box[code.customerlinks.CustomerLink]] = Future{ + override def getCustomerLinkById(customerLinkId: String, callContext: Option[CallContext]): OBPReturnType[Box[code.customerlinks.CustomerLinkTrait]] = Future{ (code.customerlinks.CustomerLinkX.customerLink.vend.getCustomerLinkById(customerLinkId), callContext) } - override def getCustomerLinksByBankId(bankId: String, callContext: Option[CallContext]): OBPReturnType[Box[List[code.customerlinks.CustomerLink]]] = Future{ + override def getCustomerLinksByBankId(bankId: String, callContext: Option[CallContext]): OBPReturnType[Box[List[code.customerlinks.CustomerLinkTrait]]] = Future{ (code.customerlinks.CustomerLinkX.customerLink.vend.getCustomerLinksByBankId(bankId), callContext) } - override def getCustomerLinksByCustomerId(customerId: String, callContext: Option[CallContext]): OBPReturnType[Box[List[code.customerlinks.CustomerLink]]] = Future{ + override def getCustomerLinksByCustomerId(customerId: String, callContext: Option[CallContext]): OBPReturnType[Box[List[code.customerlinks.CustomerLinkTrait]]] = Future{ (code.customerlinks.CustomerLinkX.customerLink.vend.getCustomerLinksByCustomerId(customerId), callContext) } - override def updateCustomerLinkById(customerLinkId: String, relationshipTo: String, callContext: Option[CallContext]): OBPReturnType[Box[code.customerlinks.CustomerLink]] = Future{ + override def updateCustomerLinkById(customerLinkId: String, relationshipTo: String, callContext: Option[CallContext]): OBPReturnType[Box[code.customerlinks.CustomerLinkTrait]] = Future{ (code.customerlinks.CustomerLinkX.customerLink.vend.updateCustomerLinkById(customerLinkId, relationshipTo), callContext) } diff --git a/obp-api/src/main/scala/code/bankconnectors/grpc/GrpcUtils.scala b/obp-api/src/main/scala/code/bankconnectors/grpc/GrpcUtils.scala index 60b726be67..b6570ad126 100644 --- a/obp-api/src/main/scala/code/bankconnectors/grpc/GrpcUtils.scala +++ b/obp-api/src/main/scala/code/bankconnectors/grpc/GrpcUtils.scala @@ -6,8 +6,8 @@ import code.bankconnectors.Connector import code.bankconnectors.grpc.api.{ObpConnectorRequest, ObpConnectorServiceGrpc} import code.util.Helper.MdcLoggable import com.openbankproject.commons.model.TopicTrait -import io.grpc.netty.{GrpcSslContexts, NettyChannelBuilder} -import io.netty.handler.ssl.SslContextBuilder +import io.grpc.netty.shaded.io.grpc.netty.{GrpcSslContexts, NettyChannelBuilder} +import io.grpc.netty.shaded.io.netty.handler.ssl.SslContextBuilder import net.liftweb.common.{Box, Empty} import net.liftweb.json.Serialization.write diff --git a/obp-api/src/main/scala/code/chat/ChatEventBus.scala b/obp-api/src/main/scala/code/chat/ChatEventBus.scala new file mode 100644 index 0000000000..6af2f5453a --- /dev/null +++ b/obp-api/src/main/scala/code/chat/ChatEventBus.scala @@ -0,0 +1,156 @@ +package code.chat + +import code.api.cache.Redis +import code.api.util.APIUtil +import code.util.Helper.MdcLoggable +import io.grpc.stub.StreamObserver +import net.liftweb.json +import net.liftweb.json.{Extraction, JsonAST} +import net.liftweb.json.Serialization.write +import redis.clients.jedis.{Jedis, JedisPubSub} + +import java.util.concurrent.{ConcurrentHashMap, CopyOnWriteArrayList} +import scala.collection.JavaConverters._ + +/** + * Redis pub/sub event bus for chat real-time streaming. + * + * Bridges REST write operations (create/update/delete message, typing, etc.) + * to gRPC streaming clients via Redis pub/sub. + * + * Channel naming: + * - obp_chat:message:{chatRoomId} — new/updated/deleted messages + * - obp_chat:typing:{chatRoomId} — typing indicators + * - obp_chat:presence:{chatRoomId} — online/offline status + * - obp_chat:unread:{userId} — unread count updates + */ +object ChatEventBus extends MdcLoggable { + + implicit val formats = json.DefaultFormats + + private val CHANNEL_PREFIX = "obp_chat:" + + // Local observer registry: channel key → list of StreamObservers + private val observers = new ConcurrentHashMap[String, CopyOnWriteArrayList[StreamObserver[String]]]() + + // Dedicated subscriber connection and thread + @volatile private var subscriberThread: Thread = _ + @volatile private var subscriberJedis: Jedis = _ + @volatile private var pubSub: JedisPubSub = _ + @volatile private var running = false + + // --- Publish methods --- + + def publishMessage(chatRoomId: String, payload: String): Unit = { + publish(s"message:$chatRoomId", payload) + } + + def publishTyping(chatRoomId: String, payload: String): Unit = { + publish(s"typing:$chatRoomId", payload) + } + + def publishPresence(chatRoomId: String, payload: String): Unit = { + publish(s"presence:$chatRoomId", payload) + } + + def publishUnread(userId: String, payload: String): Unit = { + publish(s"unread:$userId", payload) + } + + private def publish(channelSuffix: String, payload: String): Unit = { + val channel = CHANNEL_PREFIX + channelSuffix + var jedis: Jedis = null + try { + jedis = Redis.jedisPool.getResource + jedis.publish(channel, payload) + } catch { + case e: Throwable => + logger.error(s"ChatEventBus says: Failed to publish to $channel: ${e.getMessage}") + } finally { + if (jedis != null) jedis.close() + } + } + + // --- Subscribe/unsubscribe for local StreamObservers --- + + def subscribe(channelSuffix: String, observer: StreamObserver[String]): Unit = { + val key = channelSuffix + observers.computeIfAbsent(key, _ => new CopyOnWriteArrayList[StreamObserver[String]]()) + observers.get(key).add(observer) + logger.info(s"ChatEventBus says: Observer subscribed to $key (total: ${observers.get(key).size})") + } + + def unsubscribe(channelSuffix: String, observer: StreamObserver[String]): Unit = { + val list = observers.get(channelSuffix) + if (list != null) { + list.remove(observer) + logger.info(s"ChatEventBus says: Observer unsubscribed from $channelSuffix (remaining: ${list.size})") + if (list.isEmpty) { + observers.remove(channelSuffix) + } + } + } + + // --- Lifecycle --- + + def start(): Unit = { + if (running) return + running = true + + pubSub = new JedisPubSub { + override def onPMessage(pattern: String, channel: String, message: String): Unit = { + // channel is e.g. "obp_chat:message:room-123" + // strip prefix to get "message:room-123" + val key = channel.stripPrefix(CHANNEL_PREFIX) + val list = observers.get(key) + if (list != null) { + list.asScala.foreach { observer => + try { + observer.synchronized { + observer.onNext(message) + } + } catch { + case e: Throwable => + logger.warn(s"ChatEventBus says: Failed to deliver to observer on $key, removing: ${e.getMessage}") + list.remove(observer) + } + } + } + } + } + + subscriberThread = new Thread(() => { + try { + // Dedicated connection for the subscriber (not from the pool) + subscriberJedis = new Jedis(Redis.url, Redis.port, Redis.timeout) + if (Redis.password != null) subscriberJedis.auth(Redis.password) + logger.info(s"ChatEventBus says: Redis subscriber started, pattern-subscribing to ${CHANNEL_PREFIX}*") + subscriberJedis.psubscribe(pubSub, s"${CHANNEL_PREFIX}*") + } catch { + case e: Throwable if running => + logger.error(s"ChatEventBus says: Redis subscriber thread died: ${e.getMessage}") + case _: Throwable => // shutting down, ignore + } + }, "chat-event-bus-subscriber") + subscriberThread.setDaemon(true) + subscriberThread.start() + + logger.info("ChatEventBus says: Started") + } + + def stop(): Unit = { + running = false + try { + if (pubSub != null) pubSub.punsubscribe() + } catch { + case _: Throwable => // ignore + } + try { + if (subscriberJedis != null) subscriberJedis.close() + } catch { + case _: Throwable => // ignore + } + observers.clear() + logger.info("ChatEventBus says: Stopped") + } +} diff --git a/obp-api/src/main/scala/code/chat/ChatEventPublisher.scala b/obp-api/src/main/scala/code/chat/ChatEventPublisher.scala new file mode 100644 index 0000000000..275194a6f8 --- /dev/null +++ b/obp-api/src/main/scala/code/chat/ChatEventPublisher.scala @@ -0,0 +1,112 @@ +package code.chat + +import code.util.Helper.MdcLoggable +import net.liftweb.json +import net.liftweb.json.Serialization.write + +/** + * Publishes chat events to ChatEventBus after REST operations. + * + * Called from APIMethods600 after createMessage, updateMessage, + * softDeleteMessage, and typing indicator operations. + */ +object ChatEventPublisher extends MdcLoggable { + + implicit val formats = json.DefaultFormats + + case class MessageEvent( + event_type: String, + chat_message_id: String, + chat_room_id: String, + sender_user_id: String, + sender_consumer_id: String, + sender_username: String, + sender_provider: String, + sender_consumer_name: String, + content: String, + message_type: String, + mentioned_user_ids: List[String], + reply_to_message_id: String, + thread_id: String, + is_deleted: Boolean, + created_at: String, + updated_at: String + ) + + case class TypingEvent( + chat_room_id: String, + user_id: String, + username: String, + provider: String, + is_typing: Boolean + ) + + case class PresenceEvent( + user_id: String, + username: String, + provider: String, + is_online: Boolean + ) + + case class UnreadEvent( + chat_room_id: String, + unread_count: Long + ) + + private val dateFormat = new java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'") + + def afterCreate(msg: ChatMessageTrait, senderUsername: String, senderProvider: String, senderConsumerName: String): Unit = { + publishMessageEvent("new", msg, senderUsername, senderProvider, senderConsumerName) + } + + def afterUpdate(msg: ChatMessageTrait, senderUsername: String, senderProvider: String, senderConsumerName: String): Unit = { + publishMessageEvent("updated", msg, senderUsername, senderProvider, senderConsumerName) + } + + def afterDelete(msg: ChatMessageTrait, senderUsername: String, senderProvider: String, senderConsumerName: String): Unit = { + publishMessageEvent("deleted", msg, senderUsername, senderProvider, senderConsumerName) + } + + def afterTyping(chatRoomId: String, userId: String, username: String, provider: String, isTyping: Boolean): Unit = { + val event = TypingEvent(chatRoomId, userId, username, provider, isTyping) + ChatEventBus.publishTyping(chatRoomId, write(event)) + } + + def afterPresenceChange(chatRoomId: String, userId: String, username: String, provider: String, isOnline: Boolean): Unit = { + val event = PresenceEvent(userId, username, provider, isOnline) + ChatEventBus.publishPresence(chatRoomId, write(event)) + } + + def afterUnreadCountChange(userId: String, chatRoomId: String, unreadCount: Long): Unit = { + val event = UnreadEvent(chatRoomId, unreadCount) + ChatEventBus.publishUnread(userId, write(event)) + } + + private def publishMessageEvent( + eventType: String, + msg: ChatMessageTrait, + senderUsername: String, + senderProvider: String, + senderConsumerName: String + ): Unit = { + val event = MessageEvent( + event_type = eventType, + chat_message_id = msg.chatMessageId, + chat_room_id = msg.chatRoomId, + sender_user_id = msg.senderUserId, + sender_consumer_id = msg.senderConsumerId, + sender_username = senderUsername, + sender_provider = senderProvider, + sender_consumer_name = senderConsumerName, + content = if (msg.isDeleted) "" else msg.content, + message_type = msg.messageType, + mentioned_user_ids = msg.mentionedUserIds, + reply_to_message_id = msg.replyToMessageId, + thread_id = msg.threadId, + is_deleted = msg.isDeleted, + created_at = dateFormat.format(msg.createdDate), + updated_at = dateFormat.format(msg.updatedDate) + ) + ChatEventBus.publishMessage(msg.chatRoomId, write(event)) + } +} diff --git a/obp-api/src/main/scala/code/chat/ChatMessageTrait.scala b/obp-api/src/main/scala/code/chat/ChatMessageTrait.scala new file mode 100644 index 0000000000..b6fe7bf05b --- /dev/null +++ b/obp-api/src/main/scala/code/chat/ChatMessageTrait.scala @@ -0,0 +1,47 @@ +package code.chat + +import java.util.Date +import net.liftweb.common.Box +import net.liftweb.util.SimpleInjector + +object ChatMessageTrait extends SimpleInjector { + val chatMessageProvider = new Inject(buildOne _) {} + def buildOne: ChatMessageProvider = MappedChatMessageProvider +} + +trait ChatMessageProvider { + def createMessage( + chatRoomId: String, + senderUserId: String, + senderConsumerId: String, + content: String, + messageType: String, + mentionedUserIds: List[String], + replyToMessageId: String, + threadId: String + ): Box[ChatMessageTrait] + + def getMessage(chatMessageId: String): Box[ChatMessageTrait] + def getMessages(chatRoomId: String, limit: Int, offset: Int, fromDate: Date, toDate: Date): Box[List[ChatMessageTrait]] + def getThreadReplies(threadId: String): Box[List[ChatMessageTrait]] + def getMentionsForUser(userId: String, limit: Int, offset: Int): Box[List[ChatMessageTrait]] + def getUnreadCount(chatRoomId: String, sinceDate: Date): Box[Long] + + def updateMessage(chatMessageId: String, content: String): Box[ChatMessageTrait] + def softDeleteMessage(chatMessageId: String): Box[ChatMessageTrait] +} + +trait ChatMessageTrait { + def chatMessageId: String + def chatRoomId: String + def senderUserId: String + def senderConsumerId: String + def content: String + def messageType: String + def mentionedUserIds: List[String] + def replyToMessageId: String + def threadId: String + def isDeleted: Boolean + def createdDate: Date + def updatedDate: Date +} diff --git a/obp-api/src/main/scala/code/chat/ChatPermissions.scala b/obp-api/src/main/scala/code/chat/ChatPermissions.scala new file mode 100644 index 0000000000..33f9cb5884 --- /dev/null +++ b/obp-api/src/main/scala/code/chat/ChatPermissions.scala @@ -0,0 +1,65 @@ +package code.chat + +import java.util.Date +import net.liftweb.common.{Box, Empty, Failure, Full} + +/** + * A synthetic participant for rooms with allUsersAreParticipants = true. + * No database row exists — the user is implicitly a member with no special permissions. + */ +case class ImplicitParticipant(chatRoomId: String, userId: String) extends ParticipantTrait { + override def participantId: String = "" + override def consumerId: String = "" + override def permissions: List[String] = List.empty + override def webhookUrl: String = "" + override def joinedAt: Date = new Date() + override def lastReadAt: Date = new Date() + override def isMuted: Boolean = false +} + +object ChatPermissions { + val CAN_DELETE_MESSAGE = "can_delete_message" + val CAN_REMOVE_PARTICIPANT = "can_remove_participant" + val CAN_REFRESH_JOINING_KEY = "can_refresh_joining_key" + val CAN_UPDATE_ROOM = "can_update_room" + val CAN_MANAGE_PERMISSIONS = "can_manage_permissions" + + val ALL_PERMISSIONS: List[String] = List( + CAN_DELETE_MESSAGE, + CAN_REMOVE_PARTICIPANT, + CAN_REFRESH_JOINING_KEY, + CAN_UPDATE_ROOM, + CAN_MANAGE_PERMISSIONS + ) + + /** + * Check if user is a participant of the room. Returns the Participant record if found, + * or a synthetic participant (via the room's allUsersAreParticipants flag) with empty permissions. + */ + def isParticipant(chatRoomId: String, userId: String): Box[ParticipantTrait] = { + ParticipantTrait.participantProvider.vend.getParticipant(chatRoomId, userId) match { + case Full(p) => Full(p) + case _ => + // Check if room has allUsersAreParticipants = true + ChatRoomTrait.chatRoomProvider.vend.getChatRoom(chatRoomId) match { + case Full(room) if room.allUsersAreParticipants => + Full(ImplicitParticipant(chatRoomId, userId)) + case _ => Empty + } + } + } + + def isParticipantByConsumerId(chatRoomId: String, consumerId: String): Box[ParticipantTrait] = { + ParticipantTrait.participantProvider.vend.getParticipantByConsumerId(chatRoomId, consumerId) + } + + def checkParticipantPermission(chatRoomId: String, userId: String, requiredPermission: String): Box[ParticipantTrait] = { + isParticipant(chatRoomId, userId) match { + case Full(p) => + if (p.permissions.contains(requiredPermission)) Full(p) + else Failure(s"Participant does not have permission: $requiredPermission") + case Empty => Empty + case f: Failure => f + } + } +} diff --git a/obp-api/src/main/scala/code/chat/ChatRoomTrait.scala b/obp-api/src/main/scala/code/chat/ChatRoomTrait.scala new file mode 100644 index 0000000000..cf9a521d80 --- /dev/null +++ b/obp-api/src/main/scala/code/chat/ChatRoomTrait.scala @@ -0,0 +1,50 @@ +package code.chat + +import java.util.Date +import net.liftweb.common.Box +import net.liftweb.util.SimpleInjector + +object ChatRoomTrait extends SimpleInjector { + val chatRoomProvider = new Inject(buildOne _) {} + def buildOne: ChatRoomProvider = MappedChatRoomProvider +} + +trait ChatRoomProvider { + def createChatRoom( + bankId: String, + name: String, + description: String, + createdBy: String + ): Box[ChatRoomTrait] + + def getChatRoom(chatRoomId: String): Box[ChatRoomTrait] + def getChatRoomByBankIdAndName(bankId: String, name: String): Box[ChatRoomTrait] + def getChatRoomsByBankId(bankId: String): Box[List[ChatRoomTrait]] + def getChatRoomsByBankIdForUser(bankId: String, userId: String): Box[List[ChatRoomTrait]] + def getChatRoomByJoiningKey(joiningKey: String): Box[ChatRoomTrait] + + def updateChatRoom( + chatRoomId: String, + name: Option[String], + description: Option[String] + ): Box[ChatRoomTrait] + + def setAllUsersAreParticipants(chatRoomId: String, allUsersAreParticipants: Boolean): Box[ChatRoomTrait] + def archiveChatRoom(chatRoomId: String): Box[ChatRoomTrait] + def deleteChatRoom(chatRoomId: String): Box[Boolean] + def refreshJoiningKey(chatRoomId: String): Box[ChatRoomTrait] + def getOrCreateDefaultRoom(): Box[ChatRoomTrait] +} + +trait ChatRoomTrait { + def chatRoomId: String + def bankId: String + def name: String + def description: String + def joiningKey: String + def createdBy: String + def allUsersAreParticipants: Boolean + def isArchived: Boolean + def createdDate: Date + def updatedDate: Date +} diff --git a/obp-api/src/main/scala/code/chat/DoobieChatMessageQueries.scala b/obp-api/src/main/scala/code/chat/DoobieChatMessageQueries.scala new file mode 100644 index 0000000000..9c4d9ae768 --- /dev/null +++ b/obp-api/src/main/scala/code/chat/DoobieChatMessageQueries.scala @@ -0,0 +1,96 @@ +package code.chat + +import java.sql.Timestamp +import java.util.Date + +import code.api.util.DoobieUtil +import code.util.Helper.MdcLoggable +import doobie._ +import doobie.implicits._ +import doobie.implicits.javasql._ + +/** + * Doobie queries for chat message retrieval. + * + * Tables used: + * - chatmessage (Lift Mapper table: ChatMessage) + * - resourceuser (Lift Mapper table: ResourceUser) — joined for sender username/provider + * - consumer (Lift Mapper table: Consumer) — joined for sender consumer app name + * - reaction (Lift Mapper table: Reaction) + */ +object DoobieChatMessageQueries extends MdcLoggable { + + case class ChatMessageRow( + chatMessageId: String, + chatRoomId: String, + senderUserId: String, + senderConsumerId: String, + senderUsername: String, + senderProvider: String, + senderConsumerName: String, + content: String, + messageType: String, + mentionedUserIds: Option[String], + replyToMessageId: String, + threadId: String, + isDeleted: Boolean, + createdAt: Timestamp, + updatedAt: Timestamp + ) + + case class ReactionRow( + reactionId: String, + chatMessageId: String, + userId: String, + emoji: String, + createdAt: Timestamp + ) + + def getMessagesWithReactions( + chatRoomId: String, + fromDate: Date, + toDate: Date, + limit: Int, + offset: Int + ): (List[ChatMessageRow], Map[String, List[ReactionRow]]) = { + val fromTs = new Timestamp(fromDate.getTime) + val toTs = new Timestamp(toDate.getTime) + + val messagesQuery: ConnectionIO[List[ChatMessageRow]] = + sql"""SELECT m.chatmessageid, m.chatroomid, m.senderuserid, m.senderconsumerid, + COALESCE(u.name_, ''), COALESCE(u.provider_, ''), + COALESCE(c.name, ''), + m.content, m.messagetype, m.mentioneduserids, m.replytomessageid, + m.threadid, m.isdeleted, m.createdat, m.updatedat + FROM chatmessage m + LEFT JOIN resourceuser u ON u.userid_ = m.senderuserid + LEFT JOIN consumer c ON c.consumerid = m.senderconsumerid + WHERE m.chatroomid = $chatRoomId + AND m.createdat >= $fromTs + AND m.createdat <= $toTs + ORDER BY m.id ASC + LIMIT $limit OFFSET $offset""" + .query[ChatMessageRow] + .to[List] + + val messages = DoobieUtil.runQuery(messagesQuery) + + val reactions: Map[String, List[ReactionRow]] = if (messages.isEmpty) { + Map.empty + } else { + val messageIds = messages.map(_.chatMessageId) + val inClause = messageIds.map(id => fr"$id").reduceLeft((a, b) => a ++ fr"," ++ b) + + val reactionsQuery: ConnectionIO[List[ReactionRow]] = + (fr"""SELECT reactionid, chatmessageid, userid, emoji, createdat + FROM reaction + WHERE chatmessageid IN (""" ++ inClause ++ fr")") + .query[ReactionRow] + .to[List] + + DoobieUtil.runQuery(reactionsQuery).groupBy(_.chatMessageId) + } + + (messages, reactions) + } +} diff --git a/obp-api/src/main/scala/code/chat/MappedChatMessage.scala b/obp-api/src/main/scala/code/chat/MappedChatMessage.scala new file mode 100644 index 0000000000..f320b6cb10 --- /dev/null +++ b/obp-api/src/main/scala/code/chat/MappedChatMessage.scala @@ -0,0 +1,135 @@ +package code.chat + +import java.util.Date +import code.util.MappedUUID +import net.liftweb.common.Box +import net.liftweb.mapper._ +import net.liftweb.util.Helpers.tryo + +object MappedChatMessageProvider extends ChatMessageProvider { + + override def createMessage( + chatRoomId: String, + senderUserId: String, + senderConsumerId: String, + content: String, + messageType: String, + mentionedUserIds: List[String], + replyToMessageId: String, + threadId: String + ): Box[ChatMessageTrait] = { + tryo { + ChatMessage.create + .ChatRoomId(chatRoomId) + .SenderUserId(senderUserId) + .SenderConsumerId(senderConsumerId) + .Content(content) + .MessageType(messageType) + .MentionedUserIds(mentionedUserIds.mkString(",")) + .ReplyToMessageId(replyToMessageId) + .ThreadId(threadId) + .IsDeleted(false) + .saveMe() + } + } + + override def getMessage(chatMessageId: String): Box[ChatMessageTrait] = { + ChatMessage.find(By(ChatMessage.ChatMessageId, chatMessageId)) + } + + override def getMessages(chatRoomId: String, limit: Int, offset: Int, fromDate: Date, toDate: Date): Box[List[ChatMessageTrait]] = { + tryo { + ChatMessage.findAll( + By(ChatMessage.ChatRoomId, chatRoomId), + By_>=(ChatMessage.createdAt, fromDate), + By_<=(ChatMessage.createdAt, toDate), + OrderBy(ChatMessage.id, Ascending), + MaxRows[ChatMessage](limit), + StartAt[ChatMessage](offset) + ) + } + } + + override def getThreadReplies(threadId: String): Box[List[ChatMessageTrait]] = { + tryo { + ChatMessage.findAll( + By(ChatMessage.ThreadId, threadId), + OrderBy(ChatMessage.id, Ascending) + ) + } + } + + override def getMentionsForUser(userId: String, limit: Int, offset: Int): Box[List[ChatMessageTrait]] = { + tryo { + ChatMessage.findAll( + Like(ChatMessage.MentionedUserIds, s"%$userId%"), + OrderBy(ChatMessage.id, Descending), + MaxRows[ChatMessage](limit), + StartAt[ChatMessage](offset) + ) + } + } + + override def getUnreadCount(chatRoomId: String, sinceDate: Date): Box[Long] = { + tryo { + ChatMessage.count( + By(ChatMessage.ChatRoomId, chatRoomId), + By_>(ChatMessage.createdAt, sinceDate) + ) + } + } + + override def updateMessage(chatMessageId: String, content: String): Box[ChatMessageTrait] = { + ChatMessage.find(By(ChatMessage.ChatMessageId, chatMessageId)).flatMap { msg => + tryo { + msg.Content(content).saveMe() + } + } + } + + override def softDeleteMessage(chatMessageId: String): Box[ChatMessageTrait] = { + ChatMessage.find(By(ChatMessage.ChatMessageId, chatMessageId)).flatMap { msg => + tryo { + msg.IsDeleted(true).saveMe() + } + } + } +} + +class ChatMessage extends ChatMessageTrait with LongKeyedMapper[ChatMessage] with IdPK with CreatedUpdated { + + def getSingleton = ChatMessage + + object ChatMessageId extends MappedUUID(this) + object ChatRoomId extends MappedString(this, 36) + object SenderUserId extends MappedString(this, 36) + object SenderConsumerId extends MappedString(this, 36) + object Content extends MappedText(this) + object MessageType extends MappedString(this, 16) + object MentionedUserIds extends MappedText(this) + object ReplyToMessageId extends MappedString(this, 36) + object ThreadId extends MappedString(this, 36) + object IsDeleted extends MappedBoolean(this) + + override def chatMessageId: String = ChatMessageId.get + override def chatRoomId: String = ChatRoomId.get + override def senderUserId: String = SenderUserId.get + override def senderConsumerId: String = SenderConsumerId.get + override def content: String = Content.get + override def messageType: String = MessageType.get + override def mentionedUserIds: List[String] = { + val ids = MentionedUserIds.get + if (ids == null || ids.isEmpty) List.empty + else ids.split(",").map(_.trim).filter(_.nonEmpty).toList + } + override def replyToMessageId: String = ReplyToMessageId.get + override def threadId: String = ThreadId.get + override def isDeleted: Boolean = IsDeleted.get + override def createdDate: Date = createdAt.get + override def updatedDate: Date = updatedAt.get +} + +object ChatMessage extends ChatMessage with LongKeyedMetaMapper[ChatMessage] { + override def dbTableName = "ChatMessage" + override def dbIndexes = UniqueIndex(ChatMessageId) :: Index(ChatRoomId) :: Index(ThreadId) :: Index(SenderUserId) :: super.dbIndexes +} diff --git a/obp-api/src/main/scala/code/chat/MappedChatRoom.scala b/obp-api/src/main/scala/code/chat/MappedChatRoom.scala new file mode 100644 index 0000000000..3a940ab3b8 --- /dev/null +++ b/obp-api/src/main/scala/code/chat/MappedChatRoom.scala @@ -0,0 +1,158 @@ +package code.chat + +import java.util.Date +import code.api.util.APIUtil.generateUUID +import code.util.MappedUUID +import net.liftweb.common.{Box, Full} +import net.liftweb.mapper._ +import net.liftweb.util.Helpers.tryo + +object MappedChatRoomProvider extends ChatRoomProvider { + + override def createChatRoom( + bankId: String, + name: String, + description: String, + createdBy: String + ): Box[ChatRoomTrait] = { + tryo { + ChatRoom.create + .BankId(bankId) + .Name(name) + .Description(description) + .CreatedBy(createdBy) + .IsArchived(false) + .saveMe() + } + } + + override def getChatRoom(chatRoomId: String): Box[ChatRoomTrait] = { + ChatRoom.find(By(ChatRoom.ChatRoomId, chatRoomId)) + } + + override def getChatRoomByBankIdAndName(bankId: String, name: String): Box[ChatRoomTrait] = { + ChatRoom.find( + By(ChatRoom.BankId, bankId), + By(ChatRoom.Name, name) + ) + } + + override def getChatRoomsByBankId(bankId: String): Box[List[ChatRoomTrait]] = { + tryo { + ChatRoom.findAll(By(ChatRoom.BankId, bankId)) + } + } + + override def getChatRoomsByBankIdForUser(bankId: String, userId: String): Box[List[ChatRoomTrait]] = { + tryo { + val participantRoomIds = Participant.findAll(By(Participant.UserId, userId)) + .map(_.chatRoomId) + val explicitRooms = ChatRoom.findAll( + By(ChatRoom.BankId, bankId), + ByList(ChatRoom.ChatRoomId, participantRoomIds) + ) + val openRooms = ChatRoom.findAll( + By(ChatRoom.BankId, bankId), + By(ChatRoom.AllUsersAreParticipants, true) + ) + (explicitRooms ++ openRooms).groupBy(_.chatRoomId).values.map(_.head).toList + } + } + + override def getChatRoomByJoiningKey(joiningKey: String): Box[ChatRoomTrait] = { + ChatRoom.find(By(ChatRoom.JoiningKey, joiningKey)) + } + + override def updateChatRoom( + chatRoomId: String, + name: Option[String], + description: Option[String] + ): Box[ChatRoomTrait] = { + ChatRoom.find(By(ChatRoom.ChatRoomId, chatRoomId)).flatMap { room => + tryo { + name.foreach(n => room.Name(n)) + description.foreach(d => room.Description(d)) + room.saveMe() + } + } + } + + override def setAllUsersAreParticipants(chatRoomId: String, allUsersAreParticipants: Boolean): Box[ChatRoomTrait] = { + ChatRoom.find(By(ChatRoom.ChatRoomId, chatRoomId)).flatMap { room => + tryo { + room.AllUsersAreParticipants(allUsersAreParticipants).saveMe() + } + } + } + + override def archiveChatRoom(chatRoomId: String): Box[ChatRoomTrait] = { + ChatRoom.find(By(ChatRoom.ChatRoomId, chatRoomId)).flatMap { room => + tryo { + room.IsArchived(true).saveMe() + } + } + } + + override def deleteChatRoom(chatRoomId: String): Box[Boolean] = { + ChatRoom.find(By(ChatRoom.ChatRoomId, chatRoomId)).flatMap { room => + tryo { + room.delete_! + } + } + } + + override def refreshJoiningKey(chatRoomId: String): Box[ChatRoomTrait] = { + ChatRoom.find(By(ChatRoom.ChatRoomId, chatRoomId)).flatMap { room => + tryo { + room.JoiningKey(generateUUID()).saveMe() + } + } + } + + override def getOrCreateDefaultRoom(): Box[ChatRoomTrait] = { + getChatRoomByBankIdAndName("", "general") match { + case Full(room) => Full(room) + case _ => + tryo { + ChatRoom.create + .BankId("") + .Name("general") + .Description("Default system-wide chat room for all users") + .CreatedBy("system") + .AllUsersAreParticipants(true) + .IsArchived(false) + .saveMe() + } + } + } +} + +class ChatRoom extends ChatRoomTrait with LongKeyedMapper[ChatRoom] with IdPK with CreatedUpdated { + + def getSingleton = ChatRoom + + object ChatRoomId extends MappedUUID(this) + object BankId extends MappedString(this, 255) + object Name extends MappedString(this, 255) + object Description extends MappedText(this) + object JoiningKey extends MappedUUID(this) + object CreatedBy extends MappedString(this, 36) + object AllUsersAreParticipants extends MappedBoolean(this) + object IsArchived extends MappedBoolean(this) + + 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 allUsersAreParticipants: Boolean = AllUsersAreParticipants.get + override def isArchived: Boolean = IsArchived.get + override def createdDate: Date = createdAt.get + override def updatedDate: Date = updatedAt.get +} + +object ChatRoom extends ChatRoom with LongKeyedMetaMapper[ChatRoom] { + override def dbTableName = "ChatRoom" + override def dbIndexes = UniqueIndex(ChatRoomId) :: UniqueIndex(BankId, Name) :: Index(BankId) :: super.dbIndexes +} diff --git a/obp-api/src/main/scala/code/chat/MappedParticipant.scala b/obp-api/src/main/scala/code/chat/MappedParticipant.scala new file mode 100644 index 0000000000..19ac0f1d3c --- /dev/null +++ b/obp-api/src/main/scala/code/chat/MappedParticipant.scala @@ -0,0 +1,154 @@ +package code.chat + +import java.util.Date +import code.util.MappedUUID +import net.liftweb.common.Box +import net.liftweb.mapper._ +import net.liftweb.util.Helpers.tryo + +object MappedParticipantProvider extends ParticipantProvider { + + override def addParticipant( + chatRoomId: String, + userId: String, + consumerId: String, + permissions: List[String], + webhookUrl: String + ): Box[ParticipantTrait] = { + tryo { + Participant.create + .ChatRoomId(chatRoomId) + .UserId(userId) + .ConsumerId(consumerId) + .Permissions(permissions.mkString(",")) + .WebhookUrl(webhookUrl) + .JoinedAt(new Date()) + .LastReadAt(new Date()) + .IsMuted(false) + .saveMe() + } + } + + override def getParticipant(chatRoomId: String, userId: String): Box[ParticipantTrait] = { + Participant.find( + By(Participant.ChatRoomId, chatRoomId), + By(Participant.UserId, userId) + ) + } + + override def getParticipantByConsumerId(chatRoomId: String, consumerId: String): Box[ParticipantTrait] = { + Participant.find( + By(Participant.ChatRoomId, chatRoomId), + By(Participant.ConsumerId, consumerId) + ) + } + + override def getParticipants(chatRoomId: String): Box[List[ParticipantTrait]] = { + tryo { + Participant.findAll(By(Participant.ChatRoomId, chatRoomId)) + } + } + + override def getParticipantRoomsByUserId(userId: String): Box[List[ParticipantTrait]] = { + tryo { + Participant.findAll(By(Participant.UserId, userId)) + } + } + + override def updateParticipantPermissions( + chatRoomId: String, + userId: String, + permissions: List[String] + ): Box[ParticipantTrait] = { + Participant.find( + By(Participant.ChatRoomId, chatRoomId), + By(Participant.UserId, userId) + ).flatMap { p => + tryo { + p.Permissions(permissions.mkString(",")).saveMe() + } + } + } + + override def updateWebhookUrl( + chatRoomId: String, + userId: String, + webhookUrl: String + ): Box[ParticipantTrait] = { + Participant.find( + By(Participant.ChatRoomId, chatRoomId), + By(Participant.UserId, userId) + ).flatMap { p => + tryo { + p.WebhookUrl(webhookUrl).saveMe() + } + } + } + + override def updateLastReadAt(chatRoomId: String, userId: String): Box[ParticipantTrait] = { + Participant.find( + By(Participant.ChatRoomId, chatRoomId), + By(Participant.UserId, userId) + ).flatMap { p => + tryo { + p.LastReadAt(new Date()).saveMe() + } + } + } + + override def updateMuted(chatRoomId: String, userId: String, isMuted: Boolean): Box[ParticipantTrait] = { + Participant.find( + By(Participant.ChatRoomId, chatRoomId), + By(Participant.UserId, userId) + ).flatMap { p => + tryo { + p.IsMuted(isMuted).saveMe() + } + } + } + + override def removeParticipant(chatRoomId: String, userId: String): Box[Boolean] = { + Participant.find( + By(Participant.ChatRoomId, chatRoomId), + By(Participant.UserId, userId) + ).flatMap { p => + tryo { + p.delete_! + } + } + } +} + +class Participant extends ParticipantTrait with LongKeyedMapper[Participant] with IdPK { + + def getSingleton = Participant + + object ParticipantId extends MappedUUID(this) + object ChatRoomId extends MappedString(this, 36) + object UserId extends MappedString(this, 36) + object ConsumerId extends MappedString(this, 36) + object Permissions extends MappedText(this) + object WebhookUrl extends MappedString(this, 1024) + object JoinedAt extends MappedDateTime(this) + object LastReadAt extends MappedDateTime(this) + object IsMuted extends MappedBoolean(this) + + override def participantId: String = ParticipantId.get + override def chatRoomId: String = ChatRoomId.get + override def userId: String = UserId.get + override def consumerId: String = ConsumerId.get + override def permissions: List[String] = { + val permsStr = Permissions.get + if (permsStr == null || permsStr.isEmpty) List.empty + else permsStr.split(",").map(_.trim).filter(_.nonEmpty).toList + } + override def webhookUrl: String = WebhookUrl.get + override def joinedAt: Date = JoinedAt.get + override def lastReadAt: Date = LastReadAt.get + override def isMuted: Boolean = IsMuted.get +} + +object Participant extends Participant with LongKeyedMetaMapper[Participant] { + override def dbTableName = "Participant" + override def dbIndexes = UniqueIndex(ParticipantId) :: Index(ChatRoomId) :: Index(UserId) :: UniqueIndex(ChatRoomId, UserId) :: super.dbIndexes +} diff --git a/obp-api/src/main/scala/code/chat/MappedReaction.scala b/obp-api/src/main/scala/code/chat/MappedReaction.scala new file mode 100644 index 0000000000..433938e08c --- /dev/null +++ b/obp-api/src/main/scala/code/chat/MappedReaction.scala @@ -0,0 +1,67 @@ +package code.chat + +import java.util.Date +import code.util.MappedUUID +import net.liftweb.common.Box +import net.liftweb.mapper._ +import net.liftweb.util.Helpers.tryo + +object MappedReactionProvider extends ReactionProvider { + + override def addReaction(chatMessageId: String, userId: String, emoji: String): Box[ReactionTrait] = { + tryo { + Reaction.create + .ChatMessageId(chatMessageId) + .UserId(userId) + .Emoji(emoji) + .saveMe() + } + } + + override def removeReaction(chatMessageId: String, userId: String, emoji: String): Box[Boolean] = { + Reaction.find( + By(Reaction.ChatMessageId, chatMessageId), + By(Reaction.UserId, userId), + By(Reaction.Emoji, emoji) + ).flatMap { r => + tryo { + r.delete_! + } + } + } + + override def getReactions(chatMessageId: String): Box[List[ReactionTrait]] = { + tryo { + Reaction.findAll(By(Reaction.ChatMessageId, chatMessageId)) + } + } + + override def getReaction(chatMessageId: String, userId: String, emoji: String): Box[ReactionTrait] = { + Reaction.find( + By(Reaction.ChatMessageId, chatMessageId), + By(Reaction.UserId, userId), + By(Reaction.Emoji, emoji) + ) + } +} + +class Reaction extends ReactionTrait with LongKeyedMapper[Reaction] with IdPK with CreatedUpdated { + + def getSingleton = Reaction + + object ReactionId extends MappedUUID(this) + object ChatMessageId extends MappedString(this, 36) + object UserId extends MappedString(this, 36) + object Emoji extends MappedString(this, 64) + + override def reactionId: String = ReactionId.get + override def chatMessageId: String = ChatMessageId.get + override def userId: String = UserId.get + override def emoji: String = Emoji.get + override def createdDate: Date = createdAt.get +} + +object Reaction extends Reaction with LongKeyedMetaMapper[Reaction] { + override def dbTableName = "Reaction" + override def dbIndexes = UniqueIndex(ReactionId) :: Index(ChatMessageId) :: UniqueIndex(ChatMessageId, UserId, Emoji) :: super.dbIndexes +} diff --git a/obp-api/src/main/scala/code/chat/ParticipantTrait.scala b/obp-api/src/main/scala/code/chat/ParticipantTrait.scala new file mode 100644 index 0000000000..f68f653f6b --- /dev/null +++ b/obp-api/src/main/scala/code/chat/ParticipantTrait.scala @@ -0,0 +1,53 @@ +package code.chat + +import java.util.Date +import net.liftweb.common.Box +import net.liftweb.util.SimpleInjector + +object ParticipantTrait extends SimpleInjector { + val participantProvider = new Inject(buildOne _) {} + def buildOne: ParticipantProvider = MappedParticipantProvider +} + +trait ParticipantProvider { + def addParticipant( + chatRoomId: String, + userId: String, + consumerId: String, + permissions: List[String], + webhookUrl: String + ): Box[ParticipantTrait] + + def getParticipant(chatRoomId: String, userId: String): Box[ParticipantTrait] + def getParticipantByConsumerId(chatRoomId: String, consumerId: String): Box[ParticipantTrait] + def getParticipants(chatRoomId: String): Box[List[ParticipantTrait]] + def getParticipantRoomsByUserId(userId: String): Box[List[ParticipantTrait]] + + def updateParticipantPermissions( + chatRoomId: String, + userId: String, + permissions: List[String] + ): Box[ParticipantTrait] + + def updateWebhookUrl( + chatRoomId: String, + userId: String, + webhookUrl: String + ): Box[ParticipantTrait] + + def updateLastReadAt(chatRoomId: String, userId: String): Box[ParticipantTrait] + def updateMuted(chatRoomId: String, userId: String, isMuted: Boolean): Box[ParticipantTrait] + def removeParticipant(chatRoomId: String, userId: String): Box[Boolean] +} + +trait ParticipantTrait { + def participantId: String + def chatRoomId: String + def userId: String + def consumerId: String + def permissions: List[String] + def webhookUrl: String + def joinedAt: Date + def lastReadAt: Date + def isMuted: Boolean +} diff --git a/obp-api/src/main/scala/code/chat/ReactionTrait.scala b/obp-api/src/main/scala/code/chat/ReactionTrait.scala new file mode 100644 index 0000000000..d924353369 --- /dev/null +++ b/obp-api/src/main/scala/code/chat/ReactionTrait.scala @@ -0,0 +1,25 @@ +package code.chat + +import java.util.Date +import net.liftweb.common.Box +import net.liftweb.util.SimpleInjector + +object ReactionTrait extends SimpleInjector { + val reactionProvider = new Inject(buildOne _) {} + def buildOne: ReactionProvider = MappedReactionProvider +} + +trait ReactionProvider { + def addReaction(chatMessageId: String, userId: String, emoji: String): Box[ReactionTrait] + def removeReaction(chatMessageId: String, userId: String, emoji: String): Box[Boolean] + def getReactions(chatMessageId: String): Box[List[ReactionTrait]] + def getReaction(chatMessageId: String, userId: String, emoji: String): Box[ReactionTrait] +} + +trait ReactionTrait { + def reactionId: String + def chatMessageId: String + def userId: String + def emoji: String + def createdDate: Date +} diff --git a/obp-api/src/main/scala/code/customerlinks/CustomerLink.scala b/obp-api/src/main/scala/code/customerlinks/CustomerLink.scala index bf8544a90d..837168d85f 100644 --- a/obp-api/src/main/scala/code/customerlinks/CustomerLink.scala +++ b/obp-api/src/main/scala/code/customerlinks/CustomerLink.scala @@ -17,16 +17,16 @@ object CustomerLinkX extends SimpleInjector { } trait CustomerLinkProvider { - def createCustomerLink(bankId: String, customerId: String, otherBankId: String, otherCustomerId: String, relationshipTo: String): Box[CustomerLink] - def getCustomerLinkById(customerLinkId: String): Box[CustomerLink] - def getCustomerLinksByBankId(bankId: String): Box[List[CustomerLink]] - def getCustomerLinksByCustomerId(customerId: String): Box[List[CustomerLink]] - def updateCustomerLinkById(customerLinkId: String, relationshipTo: String): Box[CustomerLink] + def createCustomerLink(bankId: String, customerId: String, otherBankId: String, otherCustomerId: String, relationshipTo: String): Box[CustomerLinkTrait] + def getCustomerLinkById(customerLinkId: String): Box[CustomerLinkTrait] + def getCustomerLinksByBankId(bankId: String): Box[List[CustomerLinkTrait]] + def getCustomerLinksByCustomerId(customerId: String): Box[List[CustomerLinkTrait]] + def updateCustomerLinkById(customerLinkId: String, relationshipTo: String): Box[CustomerLinkTrait] def deleteCustomerLinkById(customerLinkId: String): Future[Box[Boolean]] def bulkDeleteCustomerLinks(): Boolean } -trait CustomerLink { +trait CustomerLinkTrait { def customerLinkId: String def bankId: String def customerId: String diff --git a/obp-api/src/main/scala/code/customerlinks/MappedCustomerLink.scala b/obp-api/src/main/scala/code/customerlinks/MappedCustomerLink.scala index c587d37a69..31ab41c85e 100644 --- a/obp-api/src/main/scala/code/customerlinks/MappedCustomerLink.scala +++ b/obp-api/src/main/scala/code/customerlinks/MappedCustomerLink.scala @@ -12,41 +12,41 @@ import scala.concurrent.Future import com.openbankproject.commons.ExecutionContext.Implicits.global object MappedCustomerLinkProvider extends CustomerLinkProvider { - override def createCustomerLink(bankId: String, customerId: String, otherBankId: String, otherCustomerId: String, relationshipTo: String): Box[CustomerLink] = { + override def createCustomerLink(bankId: String, customerId: String, otherBankId: String, otherCustomerId: String, relationshipTo: String): Box[CustomerLinkTrait] = { tryo { - MappedCustomerLink.create - .mBankId(bankId) - .mCustomerId(customerId) - .mOtherBankId(otherBankId) - .mOtherCustomerId(otherCustomerId) - .mRelationshipTo(relationshipTo) + CustomerLink.create + .BankId(bankId) + .CustomerId(customerId) + .OtherBankId(otherBankId) + .OtherCustomerId(otherCustomerId) + .RelationshipTo(relationshipTo) .saveMe() } } - override def getCustomerLinkById(customerLinkId: String): Box[CustomerLink] = { - MappedCustomerLink.find( - By(MappedCustomerLink.mCustomerLinkId, customerLinkId) + override def getCustomerLinkById(customerLinkId: String): Box[CustomerLinkTrait] = { + CustomerLink.find( + By(CustomerLink.CustomerLinkId, customerLinkId) ) } - override def getCustomerLinksByBankId(bankId: String): Box[List[CustomerLink]] = { + override def getCustomerLinksByBankId(bankId: String): Box[List[CustomerLinkTrait]] = { tryo { - MappedCustomerLink.findAll( - By(MappedCustomerLink.mBankId, bankId)) + CustomerLink.findAll( + By(CustomerLink.BankId, bankId)) } } - override def getCustomerLinksByCustomerId(customerId: String): Box[List[CustomerLink]] = { + override def getCustomerLinksByCustomerId(customerId: String): Box[List[CustomerLinkTrait]] = { tryo { - MappedCustomerLink.findAll( - By(MappedCustomerLink.mCustomerId, customerId)) + CustomerLink.findAll( + By(CustomerLink.CustomerId, customerId)) } } - override def updateCustomerLinkById(customerLinkId: String, relationshipTo: String): Box[CustomerLink] = { - MappedCustomerLink.find(By(MappedCustomerLink.mCustomerLinkId, customerLinkId)) match { - case Full(t) => Full(t.mRelationshipTo(relationshipTo).saveMe()) + override def updateCustomerLinkById(customerLinkId: String, relationshipTo: String): Box[CustomerLinkTrait] = { + CustomerLink.find(By(CustomerLink.CustomerLinkId, customerLinkId)) match { + case Full(t) => Full(t.RelationshipTo(relationshipTo).saveMe()) case Empty => Empty ?~! ErrorMessages.CustomerLinkNotFound case Failure(msg, exception, chain) => Failure(msg, exception, chain) } @@ -54,7 +54,7 @@ object MappedCustomerLinkProvider extends CustomerLinkProvider { override def deleteCustomerLinkById(customerLinkId: String): Future[Box[Boolean]] = { Future { - MappedCustomerLink.find(By(MappedCustomerLink.mCustomerLinkId, customerLinkId)) match { + CustomerLink.find(By(CustomerLink.CustomerLinkId, customerLinkId)) match { case Full(t) => Full(t.delete_!) case Empty => Empty ?~! ErrorMessages.CustomerLinkNotFound case Failure(msg, exception, chain) => Failure(msg, exception, chain) @@ -63,31 +63,32 @@ object MappedCustomerLinkProvider extends CustomerLinkProvider { } override def bulkDeleteCustomerLinks(): Boolean = { - MappedCustomerLink.bulkDelete_!!() + CustomerLink.bulkDelete_!!() } } -class MappedCustomerLink extends CustomerLink with LongKeyedMapper[MappedCustomerLink] with IdPK with CreatedUpdated { +class CustomerLink extends CustomerLinkTrait with LongKeyedMapper[CustomerLink] with IdPK with CreatedUpdated { - def getSingleton = MappedCustomerLink + def getSingleton = CustomerLink - object mCustomerLinkId extends MappedUUID(this) - object mBankId extends MappedString(this, 255) - object mCustomerId extends UUIDString(this) - object mOtherBankId extends MappedString(this, 255) - object mOtherCustomerId extends UUIDString(this) - object mRelationshipTo extends MappedString(this, 255) + object CustomerLinkId extends MappedUUID(this) + object BankId extends MappedString(this, 255) + object CustomerId extends UUIDString(this) + object OtherBankId extends MappedString(this, 255) + object OtherCustomerId extends UUIDString(this) + object RelationshipTo extends MappedString(this, 255) - override def customerLinkId: String = mCustomerLinkId.get - override def bankId: String = mBankId.get - override def customerId: String = mCustomerId.get - override def otherBankId: String = mOtherBankId.get - override def otherCustomerId: String = mOtherCustomerId.get - override def relationshipTo: String = mRelationshipTo.get + override def customerLinkId: String = CustomerLinkId.get + override def bankId: String = BankId.get + override def customerId: String = CustomerId.get + override def otherBankId: String = OtherBankId.get + override def otherCustomerId: String = OtherCustomerId.get + override def relationshipTo: String = RelationshipTo.get override def dateInserted: Date = createdAt.get override def dateUpdated: Date = updatedAt.get } -object MappedCustomerLink extends MappedCustomerLink with LongKeyedMetaMapper[MappedCustomerLink] { - override def dbIndexes = UniqueIndex(mCustomerLinkId) :: Index(mCustomerId) :: Index(mOtherCustomerId) :: super.dbIndexes +object CustomerLink extends CustomerLink with LongKeyedMetaMapper[CustomerLink] { + override def dbTableName = "CustomerLink" + override def dbIndexes = UniqueIndex(CustomerLinkId) :: Index(CustomerId) :: Index(OtherCustomerId) :: super.dbIndexes } diff --git a/obp-api/src/main/scala/code/investigation/DoobieInvestigationQueries.scala b/obp-api/src/main/scala/code/investigation/DoobieInvestigationQueries.scala new file mode 100644 index 0000000000..21f5aa6ce1 --- /dev/null +++ b/obp-api/src/main/scala/code/investigation/DoobieInvestigationQueries.scala @@ -0,0 +1,176 @@ +package code.investigation + +import java.sql.Timestamp + +import code.api.util.DoobieUtil +import code.util.Helper.MdcLoggable +import doobie._ +import doobie.implicits._ +import doobie.implicits.javasql._ + +/** + * Doobie queries for the Customer Investigation Report endpoint. + * + * Each query maps to a logical data retrieval step that could potentially + * be replaced by a connector call in the future. Keeping them separate + * makes the endpoint easier to refactor for non-mapped connectors. + * + * Tables used: + * - mappedcustomer + * - customeraccountlink + * - mappedbankaccount + * - mappedtransaction + * - mappedcustomerlink + */ +object DoobieInvestigationQueries extends MdcLoggable { + + // Result case classes — these are internal to the query layer, + // not the JSON response classes. + + case class CustomerRow( + customerId: String, + legalName: String, + email: String, + mobileNumber: String, + kycStatus: Boolean + ) + + case class AccountRow( + accountId: String, + bankId: String, + currency: String, + balance: Long, + accountName: String, + accountType: String + ) + + case class TransactionRow( + transactionId: String, + bankId: String, + accountId: String, + amount: Long, + currency: String, + transactionType: String, + description: String, + startDate: Timestamp, + finishDate: Timestamp, + counterpartyName: String, + counterpartyAccount: String, + counterpartyBankName: String + ) + + case class CustomerLinkRow( + customerLinkId: String, + otherCustomerId: String, + otherBankId: String, + relationship: String, + otherLegalName: String + ) + + case class AccountLinkRow( + customerId: String, + accountId: String, + bankId: String, + relationshipType: String + ) + + /** + * Get customer details by customer ID at a specific bank. + */ + def getCustomerAtBank(customerId: String, bankId: String): Option[CustomerRow] = { + logger.info(s"getCustomerAtBank says: customerId=$customerId bankId=$bankId") + val query: ConnectionIO[Option[CustomerRow]] = + sql"""SELECT mcustomerid, mlegalname, memail, mmobilenumber, mkycstatus + FROM mappedcustomer + WHERE mcustomerid = $customerId + AND mbank = $bankId""" + .query[CustomerRow] + .option + + DoobieUtil.runQuery(query) + } + + /** + * Get all accounts linked to a customer via customeraccountlink. + */ + def getAccountsForCustomer(customerId: String): List[AccountRow] = { + logger.info(s"getAccountsForCustomer says: customerId=$customerId") + val query: ConnectionIO[List[AccountRow]] = + sql"""SELECT a.theaccountid, a.bank, a.accountcurrency, a.accountbalance, a.accountname, a.kind + FROM mappedbankaccount a + JOIN customeraccountlink cal ON cal.accountid = a.theaccountid AND cal.bankid = a.bank + WHERE cal.customerid = $customerId""" + .query[AccountRow] + .to[List] + + DoobieUtil.runQuery(query) + } + + /** + * Get transactions for a list of accounts at a bank within a date range. + */ + def getTransactionsForAccounts( + accountIds: List[String], + bankId: String, + fromDate: Timestamp, + toDate: Timestamp, + limit: Int + ): List[TransactionRow] = { + logger.info(s"getTransactionsForAccounts says: accountIds=${accountIds.size} bankId=$bankId limit=$limit") + if (accountIds.isEmpty) return Nil + + val accountIdFragments = accountIds.map(id => fr"$id") + val inClause = accountIdFragments.reduceLeft((a, b) => a ++ fr"," ++ b) + + val query: ConnectionIO[List[TransactionRow]] = + (fr"""SELECT transactionid, bank, account, amount, currency, transactiontype, + description, tstartdate, tfinishdate, + counterpartyaccountholder, + cpotheraccountroutingaddress, + counterpartybankname + FROM mappedtransaction + WHERE bank = $bankId + AND account IN (""" ++ inClause ++ fr""") + AND tstartdate >= $fromDate + AND tstartdate <= $toDate + ORDER BY tstartdate DESC + LIMIT $limit""") + .query[TransactionRow] + .to[List] + + DoobieUtil.runQuery(query) + } + + /** + * Get customer links (related customers) for a customer, + * joined with mappedcustomer to get the related customer's legal name. + */ + def getCustomerLinks(customerId: String): List[CustomerLinkRow] = { + logger.info(s"getCustomerLinks says: customerId=$customerId") + val query: ConnectionIO[List[CustomerLinkRow]] = + sql"""SELECT cl.mcustomerlinkid, cl.mothercustomerid, cl.motherbankid, cl.mrelationshipto, + COALESCE(c.mlegalname, '') + FROM mappedcustomerlink cl + LEFT JOIN mappedcustomer c ON c.mcustomerid = cl.mothercustomerid + WHERE cl.mcustomerid = $customerId""" + .query[CustomerLinkRow] + .to[List] + + DoobieUtil.runQuery(query) + } + + /** + * Get account links for a customer — which accounts they own/have access to. + */ + def getAccountLinksForCustomer(customerId: String): List[AccountLinkRow] = { + logger.info(s"getAccountLinksForCustomer says: customerId=$customerId") + val query: ConnectionIO[List[AccountLinkRow]] = + sql"""SELECT customerid, accountid, bankid, relationshiptype + FROM customeraccountlink + WHERE customerid = $customerId""" + .query[AccountLinkRow] + .to[List] + + DoobieUtil.runQuery(query) + } +} diff --git a/obp-api/src/main/scala/code/obp/grpc/Client.scala b/obp-api/src/main/scala/code/obp/grpc/Client.scala index c8209f6c16..08fdecbd31 100644 --- a/obp-api/src/main/scala/code/obp/grpc/Client.scala +++ b/obp-api/src/main/scala/code/obp/grpc/Client.scala @@ -6,7 +6,7 @@ import com.google.protobuf.empty.Empty import io.grpc.{ManagedChannel, ManagedChannelBuilder} object Client extends App { - private val channelBuilder = ManagedChannelBuilder.forAddress("demo.openbankproject.com", HelloWorldServer.port) + private val channelBuilder = ManagedChannelBuilder.forAddress("demo.openbankproject.com", ObpGrpcServer.port) .usePlaintext() .asInstanceOf[ManagedChannelBuilder[_]] val channel: ManagedChannel = channelBuilder.build() diff --git a/obp-api/src/main/scala/code/obp/grpc/HelloWorldServer.scala b/obp-api/src/main/scala/code/obp/grpc/ObpGrpcServer.scala similarity index 85% rename from obp-api/src/main/scala/code/obp/grpc/HelloWorldServer.scala rename to obp-api/src/main/scala/code/obp/grpc/ObpGrpcServer.scala index c77752fc56..04ff8875a8 100644 --- a/obp-api/src/main/scala/code/obp/grpc/HelloWorldServer.scala +++ b/obp-api/src/main/scala/code/obp/grpc/ObpGrpcServer.scala @@ -23,28 +23,36 @@ import net.liftweb.json.{Extraction, JArray} import scala.concurrent.{ExecutionContext, Future} /** - * [[https://github.com/grpc/grpc-java/blob/v0.15.0/examples/src/main/java/io/grpc/examples/helloworld/HelloWorldServer.java]] + * OBP gRPC server — serves banking RPCs (ObpService) and chat streaming RPCs (ChatStreamService). + * Enable via grpc.server.enabled=true in props. */ -object HelloWorldServer { +object ObpGrpcServer { def main(args: Array[String] = Array.empty): Unit = { - val server = new HelloWorldServer(ExecutionContext.global) + val server = new ObpGrpcServer(ExecutionContext.global) server.start() server.blockUntilShutdown() } - val port = APIUtil.getPropsAsIntValue("grpc.server.port", Helper.findAvailablePort()) + val port = APIUtil.getPropsAsIntValue("grpc.server.port", 50051) } -class HelloWorldServer(executionContext: ExecutionContext) extends MdcLoggable { self => +class ObpGrpcServer(executionContext: ExecutionContext) extends MdcLoggable { self => private[this] var server: Server = null def start(): Unit = { - val serverBuilder = ServerBuilder.forPort(HelloWorldServer.port) + // Start chat event bus for Redis pub/sub streaming + code.chat.ChatEventBus.start() + + val serverBuilder = ServerBuilder.forPort(ObpGrpcServer.port) .addService(ObpServiceGrpc.bindService(ObpServiceImpl, executionContext)) + .addService(code.obp.grpc.chat.api.ChatStreamServiceGrpc.bindService( + code.obp.grpc.chat.ChatStreamServiceImpl, executionContext)) + .addService(io.grpc.protobuf.services.ProtoReflectionService.newInstance()) + .intercept(new code.obp.grpc.chat.AuthInterceptor()) .asInstanceOf[ServerBuilder[_]] server = serverBuilder.build.start; - logger.info("Server started, listening on " + HelloWorldServer.port) + logger.info("Server started, listening on " + ObpGrpcServer.port) sys.addShutdownHook { System.err.println("*** shutting down gRPC server since JVM is shutting down") self.stop() @@ -53,6 +61,7 @@ class HelloWorldServer(executionContext: ExecutionContext) extends MdcLoggable { } def stop(): Unit = { + code.chat.ChatEventBus.stop() if (server != null) { server.shutdown() server = null diff --git a/obp-api/src/main/scala/code/obp/grpc/chat/AuthInterceptor.scala b/obp-api/src/main/scala/code/obp/grpc/chat/AuthInterceptor.scala new file mode 100644 index 0000000000..2cdcf22d77 --- /dev/null +++ b/obp-api/src/main/scala/code/obp/grpc/chat/AuthInterceptor.scala @@ -0,0 +1,97 @@ +package code.obp.grpc.chat + +import code.api.util.{APIUtil, AuthHeaderParser, CallContext} +import code.util.Helper.MdcLoggable +import com.openbankproject.commons.model.User +import io.grpc._ +import net.liftweb.common.Full +import net.liftweb.http.provider.HTTPParam + +import scala.concurrent.Await +import scala.concurrent.duration._ + +/** + * gRPC ServerInterceptor that authenticates requests using OBP's existing auth chain. + * + * Reads the "authorization" key from gRPC Metadata (same format as HTTP headers: + * "DirectLogin token=..." or "Bearer ..."), validates it using the same auth logic + * as REST endpoints, and stores the authenticated User in gRPC Context. + * + * Token is validated once at stream open, not per-message. + */ +object AuthInterceptor { + val USER_CONTEXT_KEY: Context.Key[User] = Context.key("obp-user") + val CALL_CONTEXT_KEY: Context.Key[CallContext] = Context.key("obp-call-context") + + private val AUTH_METADATA_KEY: Metadata.Key[String] = + Metadata.Key.of("authorization", Metadata.ASCII_STRING_MARSHALLER) +} + +class AuthInterceptor extends ServerInterceptor with MdcLoggable { + + import AuthInterceptor._ + + override def interceptCall[ReqT, RespT]( + call: ServerCall[ReqT, RespT], + headers: Metadata, + next: ServerCallHandler[ReqT, RespT] + ): ServerCall.Listener[ReqT] = { + + // Skip auth for gRPC reflection — allow unauthenticated service discovery + val serviceName = call.getMethodDescriptor.getServiceName + if (serviceName == "grpc.reflection.v1alpha.ServerReflection" || serviceName == "grpc.reflection.v1.ServerReflection") { + return Contexts.interceptCall(Context.current(), call, headers, next) + } + + val authHeader = Option(headers.get(AUTH_METADATA_KEY)) + + authHeader match { + case None => + logger.info("AuthInterceptor says: No authorization header in gRPC metadata") + call.close(Status.UNAUTHENTICATED.withDescription("Missing authorization header"), new Metadata()) + new ServerCall.Listener[ReqT]() {} + + case Some(authValue) => + try { + // Populate the auth-related CallContext fields via the shared parser + // so the gRPC auth path matches what the REST (http4s) path produces. + // The downstream auth chain reads authReqHeaderField / directLoginParams + // / oAuthParams — not requestHeaders — to pick a scheme. + val parsed = AuthHeaderParser.parseAuthorizationHeader(Some(authValue)) + val cc = CallContext( + requestHeaders = List(HTTPParam("Authorization", List(authValue))), + authReqHeaderField = parsed.authReqHeaderField, + directLoginParams = parsed.directLoginParams, + oAuthParams = parsed.oAuthParams, + verb = "GET", + url = "/grpc/chat", + implementedInVersion = "v6.0.0", + correlationId = APIUtil.generateUUID() + ) + + val future = APIUtil.getUserAndSessionContextFuture(cc) + val (userBox, callContextOption) = Await.result(future, 30.seconds) + + userBox match { + case Full(user) => + val updatedCallContext = callContextOption.getOrElse(cc) + val ctx = Context.current() + .withValue(USER_CONTEXT_KEY, user) + .withValue(CALL_CONTEXT_KEY, updatedCallContext) + Contexts.interceptCall(ctx, call, headers, next) + + case _ => + logger.info("AuthInterceptor says: Auth validation returned no user") + call.close(Status.UNAUTHENTICATED.withDescription("Invalid or expired token"), new Metadata()) + new ServerCall.Listener[ReqT]() {} + } + + } catch { + case e: Throwable => + logger.error(s"AuthInterceptor says: Auth validation failed: ${e.getMessage}") + call.close(Status.UNAUTHENTICATED.withDescription("Authentication failed"), new Metadata()) + new ServerCall.Listener[ReqT]() {} + } + } + } +} diff --git a/obp-api/src/main/scala/code/obp/grpc/chat/ChatStreamServiceImpl.scala b/obp-api/src/main/scala/code/obp/grpc/chat/ChatStreamServiceImpl.scala new file mode 100644 index 0000000000..74a0ace2de --- /dev/null +++ b/obp-api/src/main/scala/code/obp/grpc/chat/ChatStreamServiceImpl.scala @@ -0,0 +1,324 @@ +package code.obp.grpc.chat + +import code.chat.{ChatEventBus, ChatPermissions, ParticipantTrait} +import code.obp.grpc.chat.api._ +import code.util.Helper.MdcLoggable +import com.google.protobuf.timestamp.Timestamp +import io.grpc.Status +import io.grpc.stub.{ServerCallStreamObserver, StreamObserver} +import net.liftweb.json +import net.liftweb.json.JsonAST.JValue + +import java.time.Instant +import scala.util.Try + +/** + * gRPC service implementation for chat streaming. + * + * All methods require authentication via AuthInterceptor. + * User is retrieved from gRPC Context. + */ +object ChatStreamServiceImpl extends ChatStreamServiceGrpc.ChatStreamService with MdcLoggable { + + implicit val formats = json.DefaultFormats + + // --- StreamMessages: server-side stream --- + + override def streamMessages( + request: StreamMessagesRequest, + responseObserver: StreamObserver[ChatMessageEvent] + ): Unit = { + val user = AuthInterceptor.USER_CONTEXT_KEY.get() + if (user == null) { + responseObserver.onError(Status.UNAUTHENTICATED.withDescription("Not authenticated").asRuntimeException()) + return + } + + val chatRoomId = request.chatRoomId + val userId = user.userId + + // Verify participant + if (ChatPermissions.isParticipant(chatRoomId, userId).isEmpty) { + responseObserver.onError(Status.PERMISSION_DENIED.withDescription("Not a participant of this chat room").asRuntimeException()) + return + } + + logger.info(s"ChatStreamServiceImpl says: User $userId subscribed to messages in room $chatRoomId") + + // Create a bridge observer that parses JSON events into ChatMessageEvent protobuf messages + val jsonObserver = new StreamObserver[String] { + override def onNext(jsonPayload: String): Unit = { + try { + val jValue = json.parse(jsonPayload) + val event = jsonToChatMessageEvent(jValue) + responseObserver.onNext(event) + } catch { + case e: Throwable => + logger.warn(s"ChatStreamServiceImpl says: Failed to parse message event: ${e.getMessage}") + } + } + override def onError(t: Throwable): Unit = responseObserver.onError(t) + override def onCompleted(): Unit = responseObserver.onCompleted() + } + + ChatEventBus.subscribe(s"message:$chatRoomId", jsonObserver) + + // Unsubscribe when client disconnects + responseObserver match { + case ssco: ServerCallStreamObserver[_] => + ssco.setOnCancelHandler(() => { + ChatEventBus.unsubscribe(s"message:$chatRoomId", jsonObserver) + logger.info(s"ChatStreamServiceImpl says: User $userId unsubscribed from messages in room $chatRoomId") + }) + case _ => + } + } + + // --- StreamTyping: bidi stream --- + + override def streamTyping( + responseObserver: StreamObserver[TypingIndicator] + ): StreamObserver[TypingEvent] = { + val user = AuthInterceptor.USER_CONTEXT_KEY.get() + if (user == null) { + responseObserver.onError(Status.UNAUTHENTICATED.withDescription("Not authenticated").asRuntimeException()) + return new StreamObserver[TypingEvent] { + override def onNext(value: TypingEvent): Unit = {} + override def onError(t: Throwable): Unit = {} + override def onCompleted(): Unit = {} + } + } + + val userId = user.userId + val username = user.name + val provider = user.provider + + // Track which rooms this client has subscribed to for typing + var subscribedRooms = Set.empty[String] + var jsonObservers = Map.empty[String, StreamObserver[String]] + + // Return a request observer that handles incoming typing events from this client + new StreamObserver[TypingEvent] { + override def onNext(event: TypingEvent): Unit = { + val chatRoomId = event.chatRoomId + + // Subscribe to typing channel for this room if not already + if (!subscribedRooms.contains(chatRoomId)) { + val jsonObserver = new StreamObserver[String] { + override def onNext(jsonPayload: String): Unit = { + try { + val jValue = json.parse(jsonPayload) + val indicator = jsonToTypingIndicator(jValue) + // Filter out own typing events + if (indicator.userId != userId) { + responseObserver.synchronized { + responseObserver.onNext(indicator) + } + } + } catch { + case e: Throwable => + logger.warn(s"ChatStreamServiceImpl says: Failed to parse typing event: ${e.getMessage}") + } + } + override def onError(t: Throwable): Unit = {} + override def onCompleted(): Unit = {} + } + ChatEventBus.subscribe(s"typing:$chatRoomId", jsonObserver) + subscribedRooms += chatRoomId + jsonObservers += (chatRoomId -> jsonObserver) + } + + // Publish this client's typing event + val payload = json.Serialization.write( + Map( + "chat_room_id" -> chatRoomId, + "user_id" -> userId, + "username" -> username, + "provider" -> provider, + "is_typing" -> event.isTyping + ) + ) + ChatEventBus.publishTyping(chatRoomId, payload) + } + + override def onError(t: Throwable): Unit = cleanup() + override def onCompleted(): Unit = cleanup() + + private def cleanup(): Unit = { + jsonObservers.foreach { case (room, obs) => + ChatEventBus.unsubscribe(s"typing:$room", obs) + } + subscribedRooms = Set.empty + jsonObservers = Map.empty + } + } + } + + // --- StreamPresence: server-side stream --- + + override def streamPresence( + request: StreamPresenceRequest, + responseObserver: StreamObserver[PresenceEvent] + ): Unit = { + val user = AuthInterceptor.USER_CONTEXT_KEY.get() + if (user == null) { + responseObserver.onError(Status.UNAUTHENTICATED.withDescription("Not authenticated").asRuntimeException()) + return + } + + val chatRoomId = request.chatRoomId + val userId = user.userId + + if (ChatPermissions.isParticipant(chatRoomId, userId).isEmpty) { + responseObserver.onError(Status.PERMISSION_DENIED.withDescription("Not a participant of this chat room").asRuntimeException()) + return + } + + logger.info(s"ChatStreamServiceImpl says: User $userId subscribed to presence in room $chatRoomId") + + // Publish online event for this user + val onlinePayload = json.Serialization.write( + Map("user_id" -> userId, "username" -> user.name, "provider" -> user.provider, "is_online" -> true) + ) + ChatEventBus.publishPresence(chatRoomId, onlinePayload) + + // Subscribe to presence events + val jsonObserver = new StreamObserver[String] { + override def onNext(jsonPayload: String): Unit = { + try { + val jValue = json.parse(jsonPayload) + val event = jsonToPresenceEvent(jValue) + responseObserver.onNext(event) + } catch { + case e: Throwable => + logger.warn(s"ChatStreamServiceImpl says: Failed to parse presence event: ${e.getMessage}") + } + } + override def onError(t: Throwable): Unit = responseObserver.onError(t) + override def onCompleted(): Unit = responseObserver.onCompleted() + } + + ChatEventBus.subscribe(s"presence:$chatRoomId", jsonObserver) + + // On disconnect: publish offline and unsubscribe + responseObserver match { + case ssco: ServerCallStreamObserver[_] => + ssco.setOnCancelHandler(() => { + ChatEventBus.unsubscribe(s"presence:$chatRoomId", jsonObserver) + val offlinePayload = json.Serialization.write( + Map("user_id" -> userId, "username" -> user.name, "provider" -> user.provider, "is_online" -> false) + ) + ChatEventBus.publishPresence(chatRoomId, offlinePayload) + logger.info(s"ChatStreamServiceImpl says: User $userId went offline in room $chatRoomId") + }) + case _ => + } + } + + // --- StreamUnreadCounts: server-side stream --- + + override def streamUnreadCounts( + request: StreamUnreadCountsRequest, + responseObserver: StreamObserver[UnreadCountEvent] + ): Unit = { + val user = AuthInterceptor.USER_CONTEXT_KEY.get() + if (user == null) { + responseObserver.onError(Status.UNAUTHENTICATED.withDescription("Not authenticated").asRuntimeException()) + return + } + + val userId = user.userId + + logger.info(s"ChatStreamServiceImpl says: User $userId subscribed to unread counts") + + // Subscribe to unread count updates for this user + // Initial counts can be fetched via the REST GET unread-counts endpoint. + // This stream pushes real-time updates as new messages arrive. + val jsonObserver = new StreamObserver[String] { + override def onNext(jsonPayload: String): Unit = { + try { + val jValue = json.parse(jsonPayload) + val event = jsonToUnreadCountEvent(jValue) + responseObserver.onNext(event) + } catch { + case e: Throwable => + logger.warn(s"ChatStreamServiceImpl says: Failed to parse unread event: ${e.getMessage}") + } + } + override def onError(t: Throwable): Unit = responseObserver.onError(t) + override def onCompleted(): Unit = responseObserver.onCompleted() + } + + ChatEventBus.subscribe(s"unread:$userId", jsonObserver) + + responseObserver match { + case ssco: ServerCallStreamObserver[_] => + ssco.setOnCancelHandler(() => { + ChatEventBus.unsubscribe(s"unread:$userId", jsonObserver) + logger.info(s"ChatStreamServiceImpl says: User $userId unsubscribed from unread counts") + }) + case _ => + } + } + + // --- JSON to protobuf conversion helpers --- + + private def jsonToChatMessageEvent(jv: JValue): ChatMessageEvent = { + ChatMessageEvent( + eventType = (jv \ "event_type").extractOrElse[String](""), + chatMessageId = (jv \ "chat_message_id").extractOrElse[String](""), + chatRoomId = (jv \ "chat_room_id").extractOrElse[String](""), + senderUserId = (jv \ "sender_user_id").extractOrElse[String](""), + senderConsumerId = (jv \ "sender_consumer_id").extractOrElse[String](""), + senderUsername = (jv \ "sender_username").extractOrElse[String](""), + senderProvider = (jv \ "sender_provider").extractOrElse[String](""), + senderConsumerName = (jv \ "sender_consumer_name").extractOrElse[String](""), + content = (jv \ "content").extractOrElse[String](""), + messageType = (jv \ "message_type").extractOrElse[String](""), + mentionedUserIds = (jv \ "mentioned_user_ids").extractOrElse[List[String]](List.empty), + replyToMessageId = (jv \ "reply_to_message_id").extractOrElse[String](""), + threadId = (jv \ "thread_id").extractOrElse[String](""), + isDeleted = (jv \ "is_deleted").extractOrElse[Boolean](false), + createdAt = parseIsoToProtoTimestamp((jv \ "created_at").extractOrElse[String]("")), + updatedAt = parseIsoToProtoTimestamp((jv \ "updated_at").extractOrElse[String]("")) + ) + } + + /** + * Parse an ISO-8601 timestamp string (e.g. "2026-04-04T12:58:47Z") into a + * protobuf Timestamp. Returns None for empty or unparseable values so the + * wire format stays unchanged in the degenerate case. + */ + private def parseIsoToProtoTimestamp(iso: String): Option[Timestamp] = { + if (iso.isEmpty) None + else Try(Instant.parse(iso)).toOption.map(i => + Timestamp(seconds = i.getEpochSecond, nanos = i.getNano) + ) + } + + private def jsonToTypingIndicator(jv: JValue): TypingIndicator = { + TypingIndicator( + chatRoomId = (jv \ "chat_room_id").extractOrElse[String](""), + userId = (jv \ "user_id").extractOrElse[String](""), + username = (jv \ "username").extractOrElse[String](""), + provider = (jv \ "provider").extractOrElse[String](""), + isTyping = (jv \ "is_typing").extractOrElse[Boolean](false) + ) + } + + private def jsonToPresenceEvent(jv: JValue): PresenceEvent = { + PresenceEvent( + userId = (jv \ "user_id").extractOrElse[String](""), + username = (jv \ "username").extractOrElse[String](""), + provider = (jv \ "provider").extractOrElse[String](""), + isOnline = (jv \ "is_online").extractOrElse[Boolean](false) + ) + } + + private def jsonToUnreadCountEvent(jv: JValue): UnreadCountEvent = { + UnreadCountEvent( + chatRoomId = (jv \ "chat_room_id").extractOrElse[String](""), + unreadCount = (jv \ "unread_count").extractOrElse[Long](0L) + ) + } +} diff --git a/obp-api/src/main/scala/code/obp/grpc/chat/api/ChatMessageEvent.scala b/obp-api/src/main/scala/code/obp/grpc/chat/api/ChatMessageEvent.scala new file mode 100644 index 0000000000..98268d973c --- /dev/null +++ b/obp-api/src/main/scala/code/obp/grpc/chat/api/ChatMessageEvent.scala @@ -0,0 +1,437 @@ +// Generated by the Scala Plugin for the Protocol Buffer Compiler. +// Do not edit! +// +// Protofile syntax: PROTO3 + +package code.obp.grpc.chat.api + +@SerialVersionUID(0L) +final case class ChatMessageEvent( + eventType: _root_.scala.Predef.String = "", + chatMessageId: _root_.scala.Predef.String = "", + chatRoomId: _root_.scala.Predef.String = "", + senderUserId: _root_.scala.Predef.String = "", + senderConsumerId: _root_.scala.Predef.String = "", + senderUsername: _root_.scala.Predef.String = "", + senderProvider: _root_.scala.Predef.String = "", + senderConsumerName: _root_.scala.Predef.String = "", + content: _root_.scala.Predef.String = "", + messageType: _root_.scala.Predef.String = "", + mentionedUserIds: _root_.scala.Seq[_root_.scala.Predef.String] = _root_.scala.Seq.empty, + replyToMessageId: _root_.scala.Predef.String = "", + threadId: _root_.scala.Predef.String = "", + isDeleted: _root_.scala.Boolean = false, + createdAt: _root_.scala.Option[com.google.protobuf.timestamp.Timestamp] = _root_.scala.None, + updatedAt: _root_.scala.Option[com.google.protobuf.timestamp.Timestamp] = _root_.scala.None + ) extends scalapb.GeneratedMessage with scalapb.Message[ChatMessageEvent] with scalapb.lenses.Updatable[ChatMessageEvent] { + @transient + private[this] var __serializedSizeCachedValue: _root_.scala.Int = 0 + private[this] def __computeSerializedValue(): _root_.scala.Int = { + var __size = 0 + if (eventType != "") { __size += _root_.com.google.protobuf.CodedOutputStream.computeStringSize(1, eventType) } + if (chatMessageId != "") { __size += _root_.com.google.protobuf.CodedOutputStream.computeStringSize(2, chatMessageId) } + if (chatRoomId != "") { __size += _root_.com.google.protobuf.CodedOutputStream.computeStringSize(3, chatRoomId) } + if (senderUserId != "") { __size += _root_.com.google.protobuf.CodedOutputStream.computeStringSize(4, senderUserId) } + if (senderConsumerId != "") { __size += _root_.com.google.protobuf.CodedOutputStream.computeStringSize(5, senderConsumerId) } + if (senderUsername != "") { __size += _root_.com.google.protobuf.CodedOutputStream.computeStringSize(6, senderUsername) } + if (senderProvider != "") { __size += _root_.com.google.protobuf.CodedOutputStream.computeStringSize(7, senderProvider) } + if (senderConsumerName != "") { __size += _root_.com.google.protobuf.CodedOutputStream.computeStringSize(8, senderConsumerName) } + if (content != "") { __size += _root_.com.google.protobuf.CodedOutputStream.computeStringSize(9, content) } + if (messageType != "") { __size += _root_.com.google.protobuf.CodedOutputStream.computeStringSize(10, messageType) } + mentionedUserIds.foreach { __item => + __size += _root_.com.google.protobuf.CodedOutputStream.computeStringSize(11, __item) + } + if (replyToMessageId != "") { __size += _root_.com.google.protobuf.CodedOutputStream.computeStringSize(12, replyToMessageId) } + if (threadId != "") { __size += _root_.com.google.protobuf.CodedOutputStream.computeStringSize(13, threadId) } + if (isDeleted != false) { __size += _root_.com.google.protobuf.CodedOutputStream.computeBoolSize(14, isDeleted) } + if (createdAt.isDefined) { + val __v = createdAt.get + val __s = __v.serializedSize + __size += 1 + _root_.com.google.protobuf.CodedOutputStream.computeUInt32SizeNoTag(__s) + __s + } + if (updatedAt.isDefined) { + val __v = updatedAt.get + val __s = __v.serializedSize + __size += 2 + _root_.com.google.protobuf.CodedOutputStream.computeUInt32SizeNoTag(__s) + __s + } + __size + } + final override def serializedSize: _root_.scala.Int = { + var read = __serializedSizeCachedValue + if (read == 0) { + read = __computeSerializedValue() + __serializedSizeCachedValue = read + } + read + } + def writeTo(`_output__`: _root_.com.google.protobuf.CodedOutputStream): _root_.scala.Unit = { + { + val __v = eventType + if (__v != "") { + _output__.writeString(1, __v) + } + }; + { + val __v = chatMessageId + if (__v != "") { + _output__.writeString(2, __v) + } + }; + { + val __v = chatRoomId + if (__v != "") { + _output__.writeString(3, __v) + } + }; + { + val __v = senderUserId + if (__v != "") { + _output__.writeString(4, __v) + } + }; + { + val __v = senderConsumerId + if (__v != "") { + _output__.writeString(5, __v) + } + }; + { + val __v = senderUsername + if (__v != "") { + _output__.writeString(6, __v) + } + }; + { + val __v = senderProvider + if (__v != "") { + _output__.writeString(7, __v) + } + }; + { + val __v = senderConsumerName + if (__v != "") { + _output__.writeString(8, __v) + } + }; + { + val __v = content + if (__v != "") { + _output__.writeString(9, __v) + } + }; + { + val __v = messageType + if (__v != "") { + _output__.writeString(10, __v) + } + }; + mentionedUserIds.foreach { __v => + _output__.writeString(11, __v) + }; + { + val __v = replyToMessageId + if (__v != "") { + _output__.writeString(12, __v) + } + }; + { + val __v = threadId + if (__v != "") { + _output__.writeString(13, __v) + } + }; + { + val __v = isDeleted + if (__v != false) { + _output__.writeBool(14, __v) + } + }; + createdAt.foreach { __v => + _output__.writeTag(15, 2) + _output__.writeUInt32NoTag(__v.serializedSize) + __v.writeTo(_output__) + }; + updatedAt.foreach { __v => + _output__.writeTag(16, 2) + _output__.writeUInt32NoTag(__v.serializedSize) + __v.writeTo(_output__) + }; + } + def mergeFrom(`_input__`: _root_.com.google.protobuf.CodedInputStream): code.obp.grpc.chat.api.ChatMessageEvent = { + var __eventType = this.eventType + var __chatMessageId = this.chatMessageId + var __chatRoomId = this.chatRoomId + var __senderUserId = this.senderUserId + var __senderConsumerId = this.senderConsumerId + var __senderUsername = this.senderUsername + var __senderProvider = this.senderProvider + var __senderConsumerName = this.senderConsumerName + var __content = this.content + var __messageType = this.messageType + val __mentionedUserIds = (_root_.scala.collection.immutable.Vector.newBuilder[_root_.scala.Predef.String] ++= this.mentionedUserIds) + var __replyToMessageId = this.replyToMessageId + var __threadId = this.threadId + var __isDeleted = this.isDeleted + var __createdAt = this.createdAt + var __updatedAt = this.updatedAt + var _done__ = false + while (!_done__) { + val _tag__ = _input__.readTag() + _tag__ match { + case 0 => _done__ = true + case 10 => + __eventType = _input__.readString() + case 18 => + __chatMessageId = _input__.readString() + case 26 => + __chatRoomId = _input__.readString() + case 34 => + __senderUserId = _input__.readString() + case 42 => + __senderConsumerId = _input__.readString() + case 50 => + __senderUsername = _input__.readString() + case 58 => + __senderProvider = _input__.readString() + case 66 => + __senderConsumerName = _input__.readString() + case 74 => + __content = _input__.readString() + case 82 => + __messageType = _input__.readString() + case 90 => + __mentionedUserIds += _input__.readString() + case 98 => + __replyToMessageId = _input__.readString() + case 106 => + __threadId = _input__.readString() + case 112 => + __isDeleted = _input__.readBool() + case 122 => + __createdAt = _root_.scala.Option(_root_.scalapb.LiteParser.readMessage(_input__, __createdAt.getOrElse(com.google.protobuf.timestamp.Timestamp.defaultInstance))) + case 130 => + __updatedAt = _root_.scala.Option(_root_.scalapb.LiteParser.readMessage(_input__, __updatedAt.getOrElse(com.google.protobuf.timestamp.Timestamp.defaultInstance))) + case tag => _input__.skipField(tag) + } + } + code.obp.grpc.chat.api.ChatMessageEvent( + eventType = __eventType, + chatMessageId = __chatMessageId, + chatRoomId = __chatRoomId, + senderUserId = __senderUserId, + senderConsumerId = __senderConsumerId, + senderUsername = __senderUsername, + senderProvider = __senderProvider, + senderConsumerName = __senderConsumerName, + content = __content, + messageType = __messageType, + mentionedUserIds = __mentionedUserIds.result(), + replyToMessageId = __replyToMessageId, + threadId = __threadId, + isDeleted = __isDeleted, + createdAt = __createdAt, + updatedAt = __updatedAt + ) + } + def getCreatedAt: com.google.protobuf.timestamp.Timestamp = createdAt.getOrElse(com.google.protobuf.timestamp.Timestamp.defaultInstance) + def getUpdatedAt: com.google.protobuf.timestamp.Timestamp = updatedAt.getOrElse(com.google.protobuf.timestamp.Timestamp.defaultInstance) + def withEventType(__v: _root_.scala.Predef.String): ChatMessageEvent = copy(eventType = __v) + def withChatMessageId(__v: _root_.scala.Predef.String): ChatMessageEvent = copy(chatMessageId = __v) + def withChatRoomId(__v: _root_.scala.Predef.String): ChatMessageEvent = copy(chatRoomId = __v) + def withSenderUserId(__v: _root_.scala.Predef.String): ChatMessageEvent = copy(senderUserId = __v) + def withSenderConsumerId(__v: _root_.scala.Predef.String): ChatMessageEvent = copy(senderConsumerId = __v) + def withSenderUsername(__v: _root_.scala.Predef.String): ChatMessageEvent = copy(senderUsername = __v) + def withSenderProvider(__v: _root_.scala.Predef.String): ChatMessageEvent = copy(senderProvider = __v) + def withSenderConsumerName(__v: _root_.scala.Predef.String): ChatMessageEvent = copy(senderConsumerName = __v) + def withContent(__v: _root_.scala.Predef.String): ChatMessageEvent = copy(content = __v) + def withMessageType(__v: _root_.scala.Predef.String): ChatMessageEvent = copy(messageType = __v) + def withMentionedUserIds(__v: _root_.scala.Seq[_root_.scala.Predef.String]): ChatMessageEvent = copy(mentionedUserIds = __v) + def addMentionedUserIds(__vs: _root_.scala.Predef.String*): ChatMessageEvent = addAllMentionedUserIds(__vs) + def addAllMentionedUserIds(__vs: _root_.scala.Iterable[_root_.scala.Predef.String]): ChatMessageEvent = copy(mentionedUserIds = mentionedUserIds ++ __vs) + def withReplyToMessageId(__v: _root_.scala.Predef.String): ChatMessageEvent = copy(replyToMessageId = __v) + def withThreadId(__v: _root_.scala.Predef.String): ChatMessageEvent = copy(threadId = __v) + def withIsDeleted(__v: _root_.scala.Boolean): ChatMessageEvent = copy(isDeleted = __v) + def clearCreatedAt: ChatMessageEvent = copy(createdAt = _root_.scala.None) + def withCreatedAt(__v: com.google.protobuf.timestamp.Timestamp): ChatMessageEvent = copy(createdAt = _root_.scala.Option(__v)) + def clearUpdatedAt: ChatMessageEvent = copy(updatedAt = _root_.scala.None) + def withUpdatedAt(__v: com.google.protobuf.timestamp.Timestamp): ChatMessageEvent = copy(updatedAt = _root_.scala.Option(__v)) + def getFieldByNumber(__fieldNumber: _root_.scala.Int): scala.Any = { + (__fieldNumber: @_root_.scala.unchecked) match { + case 1 => { + val __t = eventType + if (__t != "") __t else null + } + case 2 => { + val __t = chatMessageId + if (__t != "") __t else null + } + case 3 => { + val __t = chatRoomId + if (__t != "") __t else null + } + case 4 => { + val __t = senderUserId + if (__t != "") __t else null + } + case 5 => { + val __t = senderConsumerId + if (__t != "") __t else null + } + case 6 => { + val __t = senderUsername + if (__t != "") __t else null + } + case 7 => { + val __t = senderProvider + if (__t != "") __t else null + } + case 8 => { + val __t = senderConsumerName + if (__t != "") __t else null + } + case 9 => { + val __t = content + if (__t != "") __t else null + } + case 10 => { + val __t = messageType + if (__t != "") __t else null + } + case 11 => mentionedUserIds + case 12 => { + val __t = replyToMessageId + if (__t != "") __t else null + } + case 13 => { + val __t = threadId + if (__t != "") __t else null + } + case 14 => { + val __t = isDeleted + if (__t != false) __t else null + } + case 15 => createdAt.orNull + case 16 => updatedAt.orNull + } + } + def getField(__field: _root_.scalapb.descriptors.FieldDescriptor): _root_.scalapb.descriptors.PValue = { + require(__field.containingMessage eq companion.scalaDescriptor) + (__field.number: @_root_.scala.unchecked) match { + case 1 => _root_.scalapb.descriptors.PString(eventType) + case 2 => _root_.scalapb.descriptors.PString(chatMessageId) + case 3 => _root_.scalapb.descriptors.PString(chatRoomId) + case 4 => _root_.scalapb.descriptors.PString(senderUserId) + case 5 => _root_.scalapb.descriptors.PString(senderConsumerId) + case 6 => _root_.scalapb.descriptors.PString(senderUsername) + case 7 => _root_.scalapb.descriptors.PString(senderProvider) + case 8 => _root_.scalapb.descriptors.PString(senderConsumerName) + case 9 => _root_.scalapb.descriptors.PString(content) + case 10 => _root_.scalapb.descriptors.PString(messageType) + case 11 => _root_.scalapb.descriptors.PRepeated(mentionedUserIds.iterator.map(_root_.scalapb.descriptors.PString).toVector) + case 12 => _root_.scalapb.descriptors.PString(replyToMessageId) + case 13 => _root_.scalapb.descriptors.PString(threadId) + case 14 => _root_.scalapb.descriptors.PBoolean(isDeleted) + case 15 => createdAt.map(_.toPMessage).getOrElse(_root_.scalapb.descriptors.PEmpty) + case 16 => updatedAt.map(_.toPMessage).getOrElse(_root_.scalapb.descriptors.PEmpty) + } + } + def toProtoString: _root_.scala.Predef.String = _root_.scalapb.TextFormat.printToUnicodeString(this) + def companion = code.obp.grpc.chat.api.ChatMessageEvent +} + +object ChatMessageEvent extends scalapb.GeneratedMessageCompanion[code.obp.grpc.chat.api.ChatMessageEvent] { + implicit def messageCompanion: scalapb.GeneratedMessageCompanion[code.obp.grpc.chat.api.ChatMessageEvent] = this + def fromFieldsMap(__fieldsMap: scala.collection.immutable.Map[_root_.com.google.protobuf.Descriptors.FieldDescriptor, scala.Any]): code.obp.grpc.chat.api.ChatMessageEvent = { + require(__fieldsMap.keys.forall(_.getContainingType() == javaDescriptor), "FieldDescriptor does not match message type.") + val __fields = javaDescriptor.getFields + code.obp.grpc.chat.api.ChatMessageEvent( + __fieldsMap.getOrElse(__fields.get(0), "").asInstanceOf[_root_.scala.Predef.String], + __fieldsMap.getOrElse(__fields.get(1), "").asInstanceOf[_root_.scala.Predef.String], + __fieldsMap.getOrElse(__fields.get(2), "").asInstanceOf[_root_.scala.Predef.String], + __fieldsMap.getOrElse(__fields.get(3), "").asInstanceOf[_root_.scala.Predef.String], + __fieldsMap.getOrElse(__fields.get(4), "").asInstanceOf[_root_.scala.Predef.String], + __fieldsMap.getOrElse(__fields.get(5), "").asInstanceOf[_root_.scala.Predef.String], + __fieldsMap.getOrElse(__fields.get(6), "").asInstanceOf[_root_.scala.Predef.String], + __fieldsMap.getOrElse(__fields.get(7), "").asInstanceOf[_root_.scala.Predef.String], + __fieldsMap.getOrElse(__fields.get(8), "").asInstanceOf[_root_.scala.Predef.String], + __fieldsMap.getOrElse(__fields.get(9), "").asInstanceOf[_root_.scala.Predef.String], + __fieldsMap.getOrElse(__fields.get(10), Nil).asInstanceOf[_root_.scala.Seq[_root_.scala.Predef.String]], + __fieldsMap.getOrElse(__fields.get(11), "").asInstanceOf[_root_.scala.Predef.String], + __fieldsMap.getOrElse(__fields.get(12), "").asInstanceOf[_root_.scala.Predef.String], + __fieldsMap.getOrElse(__fields.get(13), false).asInstanceOf[_root_.scala.Boolean], + __fieldsMap.get(__fields.get(14)).asInstanceOf[_root_.scala.Option[com.google.protobuf.timestamp.Timestamp]], + __fieldsMap.get(__fields.get(15)).asInstanceOf[_root_.scala.Option[com.google.protobuf.timestamp.Timestamp]] + ) + } + implicit def messageReads: _root_.scalapb.descriptors.Reads[code.obp.grpc.chat.api.ChatMessageEvent] = _root_.scalapb.descriptors.Reads{ + case _root_.scalapb.descriptors.PMessage(__fieldsMap) => + require(__fieldsMap.keys.forall(_.containingMessage == scalaDescriptor), "FieldDescriptor does not match message type.") + code.obp.grpc.chat.api.ChatMessageEvent( + __fieldsMap.get(scalaDescriptor.findFieldByNumber(1).get).map(_.as[_root_.scala.Predef.String]).getOrElse(""), + __fieldsMap.get(scalaDescriptor.findFieldByNumber(2).get).map(_.as[_root_.scala.Predef.String]).getOrElse(""), + __fieldsMap.get(scalaDescriptor.findFieldByNumber(3).get).map(_.as[_root_.scala.Predef.String]).getOrElse(""), + __fieldsMap.get(scalaDescriptor.findFieldByNumber(4).get).map(_.as[_root_.scala.Predef.String]).getOrElse(""), + __fieldsMap.get(scalaDescriptor.findFieldByNumber(5).get).map(_.as[_root_.scala.Predef.String]).getOrElse(""), + __fieldsMap.get(scalaDescriptor.findFieldByNumber(6).get).map(_.as[_root_.scala.Predef.String]).getOrElse(""), + __fieldsMap.get(scalaDescriptor.findFieldByNumber(7).get).map(_.as[_root_.scala.Predef.String]).getOrElse(""), + __fieldsMap.get(scalaDescriptor.findFieldByNumber(8).get).map(_.as[_root_.scala.Predef.String]).getOrElse(""), + __fieldsMap.get(scalaDescriptor.findFieldByNumber(9).get).map(_.as[_root_.scala.Predef.String]).getOrElse(""), + __fieldsMap.get(scalaDescriptor.findFieldByNumber(10).get).map(_.as[_root_.scala.Predef.String]).getOrElse(""), + __fieldsMap.get(scalaDescriptor.findFieldByNumber(11).get).map(_.as[_root_.scala.Seq[_root_.scala.Predef.String]]).getOrElse(_root_.scala.Seq.empty), + __fieldsMap.get(scalaDescriptor.findFieldByNumber(12).get).map(_.as[_root_.scala.Predef.String]).getOrElse(""), + __fieldsMap.get(scalaDescriptor.findFieldByNumber(13).get).map(_.as[_root_.scala.Predef.String]).getOrElse(""), + __fieldsMap.get(scalaDescriptor.findFieldByNumber(14).get).map(_.as[_root_.scala.Boolean]).getOrElse(false), + __fieldsMap.get(scalaDescriptor.findFieldByNumber(15).get).flatMap(_.as[_root_.scala.Option[com.google.protobuf.timestamp.Timestamp]]), + __fieldsMap.get(scalaDescriptor.findFieldByNumber(16).get).flatMap(_.as[_root_.scala.Option[com.google.protobuf.timestamp.Timestamp]]) + ) + case _ => throw new RuntimeException("Expected PMessage") + } + def javaDescriptor: _root_.com.google.protobuf.Descriptors.Descriptor = ChatProto.javaDescriptor.getMessageTypes.get(1) + def scalaDescriptor: _root_.scalapb.descriptors.Descriptor = throw new UnsupportedOperationException("scalaDescriptor not available") + def messageCompanionForFieldNumber(__number: _root_.scala.Int): _root_.scalapb.GeneratedMessageCompanion[_] = { + (__number: @_root_.scala.unchecked) match { + case 15 => com.google.protobuf.timestamp.Timestamp + case 16 => com.google.protobuf.timestamp.Timestamp + } + } + lazy val nestedMessagesCompanions: Seq[_root_.scalapb.GeneratedMessageCompanion[_]] = Seq.empty + def enumCompanionForFieldNumber(__fieldNumber: _root_.scala.Int): _root_.scalapb.GeneratedEnumCompanion[_] = throw new MatchError(__fieldNumber) + lazy val defaultInstance = code.obp.grpc.chat.api.ChatMessageEvent( + ) + implicit class ChatMessageEventLens[UpperPB](_l: _root_.scalapb.lenses.Lens[UpperPB, code.obp.grpc.chat.api.ChatMessageEvent]) extends _root_.scalapb.lenses.ObjectLens[UpperPB, code.obp.grpc.chat.api.ChatMessageEvent](_l) { + def eventType: _root_.scalapb.lenses.Lens[UpperPB, _root_.scala.Predef.String] = field(_.eventType)((c_, f_) => c_.copy(eventType = f_)) + def chatMessageId: _root_.scalapb.lenses.Lens[UpperPB, _root_.scala.Predef.String] = field(_.chatMessageId)((c_, f_) => c_.copy(chatMessageId = f_)) + def chatRoomId: _root_.scalapb.lenses.Lens[UpperPB, _root_.scala.Predef.String] = field(_.chatRoomId)((c_, f_) => c_.copy(chatRoomId = f_)) + def senderUserId: _root_.scalapb.lenses.Lens[UpperPB, _root_.scala.Predef.String] = field(_.senderUserId)((c_, f_) => c_.copy(senderUserId = f_)) + def senderConsumerId: _root_.scalapb.lenses.Lens[UpperPB, _root_.scala.Predef.String] = field(_.senderConsumerId)((c_, f_) => c_.copy(senderConsumerId = f_)) + def senderUsername: _root_.scalapb.lenses.Lens[UpperPB, _root_.scala.Predef.String] = field(_.senderUsername)((c_, f_) => c_.copy(senderUsername = f_)) + def senderProvider: _root_.scalapb.lenses.Lens[UpperPB, _root_.scala.Predef.String] = field(_.senderProvider)((c_, f_) => c_.copy(senderProvider = f_)) + def senderConsumerName: _root_.scalapb.lenses.Lens[UpperPB, _root_.scala.Predef.String] = field(_.senderConsumerName)((c_, f_) => c_.copy(senderConsumerName = f_)) + def content: _root_.scalapb.lenses.Lens[UpperPB, _root_.scala.Predef.String] = field(_.content)((c_, f_) => c_.copy(content = f_)) + def messageType: _root_.scalapb.lenses.Lens[UpperPB, _root_.scala.Predef.String] = field(_.messageType)((c_, f_) => c_.copy(messageType = f_)) + def mentionedUserIds: _root_.scalapb.lenses.Lens[UpperPB, _root_.scala.Seq[_root_.scala.Predef.String]] = field(_.mentionedUserIds)((c_, f_) => c_.copy(mentionedUserIds = f_)) + def replyToMessageId: _root_.scalapb.lenses.Lens[UpperPB, _root_.scala.Predef.String] = field(_.replyToMessageId)((c_, f_) => c_.copy(replyToMessageId = f_)) + def threadId: _root_.scalapb.lenses.Lens[UpperPB, _root_.scala.Predef.String] = field(_.threadId)((c_, f_) => c_.copy(threadId = f_)) + def isDeleted: _root_.scalapb.lenses.Lens[UpperPB, _root_.scala.Boolean] = field(_.isDeleted)((c_, f_) => c_.copy(isDeleted = f_)) + def createdAt: _root_.scalapb.lenses.Lens[UpperPB, com.google.protobuf.timestamp.Timestamp] = field(_.getCreatedAt)((c_, f_) => c_.copy(createdAt = _root_.scala.Option(f_))) + def optionalCreatedAt: _root_.scalapb.lenses.Lens[UpperPB, _root_.scala.Option[com.google.protobuf.timestamp.Timestamp]] = field(_.createdAt)((c_, f_) => c_.copy(createdAt = f_)) + def updatedAt: _root_.scalapb.lenses.Lens[UpperPB, com.google.protobuf.timestamp.Timestamp] = field(_.getUpdatedAt)((c_, f_) => c_.copy(updatedAt = _root_.scala.Option(f_))) + def optionalUpdatedAt: _root_.scalapb.lenses.Lens[UpperPB, _root_.scala.Option[com.google.protobuf.timestamp.Timestamp]] = field(_.updatedAt)((c_, f_) => c_.copy(updatedAt = f_)) + } + final val EVENTTYPE_FIELD_NUMBER = 1 + final val CHATMESSAGEID_FIELD_NUMBER = 2 + final val CHATROOMID_FIELD_NUMBER = 3 + final val SENDERUSERID_FIELD_NUMBER = 4 + final val SENDERCONSUMERID_FIELD_NUMBER = 5 + final val SENDERUSERNAME_FIELD_NUMBER = 6 + final val SENDERPROVIDER_FIELD_NUMBER = 7 + final val SENDERCONSUMERNAME_FIELD_NUMBER = 8 + final val CONTENT_FIELD_NUMBER = 9 + final val MESSAGETYPE_FIELD_NUMBER = 10 + final val MENTIONEDUSERIDS_FIELD_NUMBER = 11 + final val REPLYTOMESSAGEID_FIELD_NUMBER = 12 + final val THREADID_FIELD_NUMBER = 13 + final val ISDELETED_FIELD_NUMBER = 14 + final val CREATEDAT_FIELD_NUMBER = 15 + final val UPDATEDAT_FIELD_NUMBER = 16 +} diff --git a/obp-api/src/main/scala/code/obp/grpc/chat/api/ChatProto.scala b/obp-api/src/main/scala/code/obp/grpc/chat/api/ChatProto.scala new file mode 100644 index 0000000000..e24b39e814 --- /dev/null +++ b/obp-api/src/main/scala/code/obp/grpc/chat/api/ChatProto.scala @@ -0,0 +1,148 @@ +package code.obp.grpc.chat.api + +import com.google.protobuf.DescriptorProtos._ +import com.google.protobuf.DescriptorProtos.FieldDescriptorProto.{Label, Type} + +/** + * Proto file descriptor for the chat streaming service. + * Built programmatically to support gRPC reflection (service discovery). + */ +object ChatProto { + + lazy val javaDescriptor: com.google.protobuf.Descriptors.FileDescriptor = { + val fileProto = FileDescriptorProto.newBuilder() + .setName("chat.proto") + .setPackage("code.obp.grpc.chat.g1") + .setSyntax("proto3") + .addDependency("google/protobuf/timestamp.proto") + // StreamMessagesRequest + .addMessageType(DescriptorProto.newBuilder() + .setName("StreamMessagesRequest") + .addField(stringField("chat_room_id", 1)) + ) + // ChatMessageEvent + .addMessageType(DescriptorProto.newBuilder() + .setName("ChatMessageEvent") + .addField(stringField("event_type", 1)) + .addField(stringField("chat_message_id", 2)) + .addField(stringField("chat_room_id", 3)) + .addField(stringField("sender_user_id", 4)) + .addField(stringField("sender_consumer_id", 5)) + .addField(stringField("sender_username", 6)) + .addField(stringField("sender_provider", 7)) + .addField(stringField("sender_consumer_name", 8)) + .addField(stringField("content", 9)) + .addField(stringField("message_type", 10)) + .addField(repeatedStringField("mentioned_user_ids", 11)) + .addField(stringField("reply_to_message_id", 12)) + .addField(stringField("thread_id", 13)) + .addField(boolField("is_deleted", 14)) + .addField(messageField("created_at", 15, ".google.protobuf.Timestamp")) + .addField(messageField("updated_at", 16, ".google.protobuf.Timestamp")) + ) + // TypingEvent + .addMessageType(DescriptorProto.newBuilder() + .setName("TypingEvent") + .addField(stringField("chat_room_id", 1)) + .addField(boolField("is_typing", 2)) + ) + // TypingIndicator + .addMessageType(DescriptorProto.newBuilder() + .setName("TypingIndicator") + .addField(stringField("chat_room_id", 1)) + .addField(stringField("user_id", 2)) + .addField(stringField("username", 3)) + .addField(stringField("provider", 4)) + .addField(boolField("is_typing", 5)) + ) + // StreamPresenceRequest + .addMessageType(DescriptorProto.newBuilder() + .setName("StreamPresenceRequest") + .addField(stringField("chat_room_id", 1)) + ) + // PresenceEvent + .addMessageType(DescriptorProto.newBuilder() + .setName("PresenceEvent") + .addField(stringField("user_id", 1)) + .addField(stringField("username", 2)) + .addField(stringField("provider", 3)) + .addField(boolField("is_online", 4)) + ) + // StreamUnreadCountsRequest + .addMessageType(DescriptorProto.newBuilder() + .setName("StreamUnreadCountsRequest") + ) + // UnreadCountEvent + .addMessageType(DescriptorProto.newBuilder() + .setName("UnreadCountEvent") + .addField(stringField("chat_room_id", 1)) + .addField(int64Field("unread_count", 2)) + ) + // ChatStreamService + .addService(ServiceDescriptorProto.newBuilder() + .setName("ChatStreamService") + .addMethod(MethodDescriptorProto.newBuilder() + .setName("StreamMessages") + .setInputType(".code.obp.grpc.chat.g1.StreamMessagesRequest") + .setOutputType(".code.obp.grpc.chat.g1.ChatMessageEvent") + .setServerStreaming(true) + ) + .addMethod(MethodDescriptorProto.newBuilder() + .setName("StreamTyping") + .setInputType(".code.obp.grpc.chat.g1.TypingEvent") + .setOutputType(".code.obp.grpc.chat.g1.TypingIndicator") + .setClientStreaming(true) + .setServerStreaming(true) + ) + .addMethod(MethodDescriptorProto.newBuilder() + .setName("StreamPresence") + .setInputType(".code.obp.grpc.chat.g1.StreamPresenceRequest") + .setOutputType(".code.obp.grpc.chat.g1.PresenceEvent") + .setServerStreaming(true) + ) + .addMethod(MethodDescriptorProto.newBuilder() + .setName("StreamUnreadCounts") + .setInputType(".code.obp.grpc.chat.g1.StreamUnreadCountsRequest") + .setOutputType(".code.obp.grpc.chat.g1.UnreadCountEvent") + .setServerStreaming(true) + ) + ) + .build() + + com.google.protobuf.Descriptors.FileDescriptor.buildFrom( + fileProto, + Array(com.google.protobuf.TimestampProto.getDescriptor) + ) + } + + private def stringField(name: String, number: Int): FieldDescriptorProto.Builder = + FieldDescriptorProto.newBuilder() + .setName(name).setNumber(number) + .setType(Type.TYPE_STRING) + .setLabel(Label.LABEL_OPTIONAL) + + private def repeatedStringField(name: String, number: Int): FieldDescriptorProto.Builder = + FieldDescriptorProto.newBuilder() + .setName(name).setNumber(number) + .setType(Type.TYPE_STRING) + .setLabel(Label.LABEL_REPEATED) + + private def boolField(name: String, number: Int): FieldDescriptorProto.Builder = + FieldDescriptorProto.newBuilder() + .setName(name).setNumber(number) + .setType(Type.TYPE_BOOL) + .setLabel(Label.LABEL_OPTIONAL) + + private def int64Field(name: String, number: Int): FieldDescriptorProto.Builder = + FieldDescriptorProto.newBuilder() + .setName(name).setNumber(number) + .setType(Type.TYPE_INT64) + .setLabel(Label.LABEL_OPTIONAL) + + private def messageField(name: String, number: Int, typeName: String): FieldDescriptorProto.Builder = + FieldDescriptorProto.newBuilder() + .setName(name).setNumber(number) + .setType(Type.TYPE_MESSAGE) + .setTypeName(typeName) + .setLabel(Label.LABEL_OPTIONAL) +} diff --git a/obp-api/src/main/scala/code/obp/grpc/chat/api/ChatStreamServiceGrpc.scala b/obp-api/src/main/scala/code/obp/grpc/chat/api/ChatStreamServiceGrpc.scala new file mode 100644 index 0000000000..64b5ef966b --- /dev/null +++ b/obp-api/src/main/scala/code/obp/grpc/chat/api/ChatStreamServiceGrpc.scala @@ -0,0 +1,114 @@ +// Generated by the Scala Plugin for the Protocol Buffer Compiler. +// Do not edit! +// +// Protofile syntax: PROTO3 + +package code.obp.grpc.chat.api + +object ChatStreamServiceGrpc { + + val METHOD_STREAM_MESSAGES: _root_.io.grpc.MethodDescriptor[code.obp.grpc.chat.api.StreamMessagesRequest, code.obp.grpc.chat.api.ChatMessageEvent] = + _root_.io.grpc.MethodDescriptor.newBuilder() + .setType(_root_.io.grpc.MethodDescriptor.MethodType.SERVER_STREAMING) + .setFullMethodName(_root_.io.grpc.MethodDescriptor.generateFullMethodName("code.obp.grpc.chat.g1.ChatStreamService", "StreamMessages")) + .setSampledToLocalTracing(true) + .setRequestMarshaller(new scalapb.grpc.Marshaller(code.obp.grpc.chat.api.StreamMessagesRequest)) + .setResponseMarshaller(new scalapb.grpc.Marshaller(code.obp.grpc.chat.api.ChatMessageEvent)) + .build() + + val METHOD_STREAM_TYPING: _root_.io.grpc.MethodDescriptor[code.obp.grpc.chat.api.TypingEvent, code.obp.grpc.chat.api.TypingIndicator] = + _root_.io.grpc.MethodDescriptor.newBuilder() + .setType(_root_.io.grpc.MethodDescriptor.MethodType.BIDI_STREAMING) + .setFullMethodName(_root_.io.grpc.MethodDescriptor.generateFullMethodName("code.obp.grpc.chat.g1.ChatStreamService", "StreamTyping")) + .setSampledToLocalTracing(true) + .setRequestMarshaller(new scalapb.grpc.Marshaller(code.obp.grpc.chat.api.TypingEvent)) + .setResponseMarshaller(new scalapb.grpc.Marshaller(code.obp.grpc.chat.api.TypingIndicator)) + .build() + + val METHOD_STREAM_PRESENCE: _root_.io.grpc.MethodDescriptor[code.obp.grpc.chat.api.StreamPresenceRequest, code.obp.grpc.chat.api.PresenceEvent] = + _root_.io.grpc.MethodDescriptor.newBuilder() + .setType(_root_.io.grpc.MethodDescriptor.MethodType.SERVER_STREAMING) + .setFullMethodName(_root_.io.grpc.MethodDescriptor.generateFullMethodName("code.obp.grpc.chat.g1.ChatStreamService", "StreamPresence")) + .setSampledToLocalTracing(true) + .setRequestMarshaller(new scalapb.grpc.Marshaller(code.obp.grpc.chat.api.StreamPresenceRequest)) + .setResponseMarshaller(new scalapb.grpc.Marshaller(code.obp.grpc.chat.api.PresenceEvent)) + .build() + + val METHOD_STREAM_UNREAD_COUNTS: _root_.io.grpc.MethodDescriptor[code.obp.grpc.chat.api.StreamUnreadCountsRequest, code.obp.grpc.chat.api.UnreadCountEvent] = + _root_.io.grpc.MethodDescriptor.newBuilder() + .setType(_root_.io.grpc.MethodDescriptor.MethodType.SERVER_STREAMING) + .setFullMethodName(_root_.io.grpc.MethodDescriptor.generateFullMethodName("code.obp.grpc.chat.g1.ChatStreamService", "StreamUnreadCounts")) + .setSampledToLocalTracing(true) + .setRequestMarshaller(new scalapb.grpc.Marshaller(code.obp.grpc.chat.api.StreamUnreadCountsRequest)) + .setResponseMarshaller(new scalapb.grpc.Marshaller(code.obp.grpc.chat.api.UnreadCountEvent)) + .build() + + val SERVICE: _root_.io.grpc.ServiceDescriptor = + _root_.io.grpc.ServiceDescriptor.newBuilder("code.obp.grpc.chat.g1.ChatStreamService") + .setSchemaDescriptor(new _root_.scalapb.grpc.ConcreteProtoFileDescriptorSupplier(code.obp.grpc.chat.api.ChatProto.javaDescriptor)) + .addMethod(METHOD_STREAM_MESSAGES) + .addMethod(METHOD_STREAM_TYPING) + .addMethod(METHOD_STREAM_PRESENCE) + .addMethod(METHOD_STREAM_UNREAD_COUNTS) + .build() + + trait ChatStreamService extends _root_.scalapb.grpc.AbstractService { + override def serviceCompanion = ChatStreamService + + /** Server-side stream: pushes new/updated/deleted messages for a room */ + def streamMessages(request: code.obp.grpc.chat.api.StreamMessagesRequest, + responseObserver: _root_.io.grpc.stub.StreamObserver[code.obp.grpc.chat.api.ChatMessageEvent]): Unit + + /** Bidi stream: client sends typing events, server broadcasts others' typing */ + def streamTyping(responseObserver: _root_.io.grpc.stub.StreamObserver[code.obp.grpc.chat.api.TypingIndicator]): _root_.io.grpc.stub.StreamObserver[code.obp.grpc.chat.api.TypingEvent] + + /** Server-side stream: online/offline status changes for room participants */ + def streamPresence(request: code.obp.grpc.chat.api.StreamPresenceRequest, + responseObserver: _root_.io.grpc.stub.StreamObserver[code.obp.grpc.chat.api.PresenceEvent]): Unit + + /** Server-side stream: unread count updates for all user's rooms */ + def streamUnreadCounts(request: code.obp.grpc.chat.api.StreamUnreadCountsRequest, + responseObserver: _root_.io.grpc.stub.StreamObserver[code.obp.grpc.chat.api.UnreadCountEvent]): Unit + } + + object ChatStreamService extends _root_.scalapb.grpc.ServiceCompanion[ChatStreamService] { + implicit def serviceCompanion: _root_.scalapb.grpc.ServiceCompanion[ChatStreamService] = this + def javaDescriptor: _root_.com.google.protobuf.Descriptors.ServiceDescriptor = + code.obp.grpc.chat.api.ChatProto.javaDescriptor.getServices().get(0) + } + + def bindService(serviceImpl: ChatStreamService, executionContext: scala.concurrent.ExecutionContext): _root_.io.grpc.ServerServiceDefinition = + _root_.io.grpc.ServerServiceDefinition.builder(SERVICE) + .addMethod( + METHOD_STREAM_MESSAGES, + _root_.io.grpc.stub.ServerCalls.asyncServerStreamingCall( + new _root_.io.grpc.stub.ServerCalls.ServerStreamingMethod[code.obp.grpc.chat.api.StreamMessagesRequest, code.obp.grpc.chat.api.ChatMessageEvent] { + override def invoke(request: code.obp.grpc.chat.api.StreamMessagesRequest, + responseObserver: _root_.io.grpc.stub.StreamObserver[code.obp.grpc.chat.api.ChatMessageEvent]): Unit = + serviceImpl.streamMessages(request, responseObserver) + })) + .addMethod( + METHOD_STREAM_TYPING, + _root_.io.grpc.stub.ServerCalls.asyncBidiStreamingCall( + new _root_.io.grpc.stub.ServerCalls.BidiStreamingMethod[code.obp.grpc.chat.api.TypingEvent, code.obp.grpc.chat.api.TypingIndicator] { + override def invoke(responseObserver: _root_.io.grpc.stub.StreamObserver[code.obp.grpc.chat.api.TypingIndicator]): _root_.io.grpc.stub.StreamObserver[code.obp.grpc.chat.api.TypingEvent] = + serviceImpl.streamTyping(responseObserver) + })) + .addMethod( + METHOD_STREAM_PRESENCE, + _root_.io.grpc.stub.ServerCalls.asyncServerStreamingCall( + new _root_.io.grpc.stub.ServerCalls.ServerStreamingMethod[code.obp.grpc.chat.api.StreamPresenceRequest, code.obp.grpc.chat.api.PresenceEvent] { + override def invoke(request: code.obp.grpc.chat.api.StreamPresenceRequest, + responseObserver: _root_.io.grpc.stub.StreamObserver[code.obp.grpc.chat.api.PresenceEvent]): Unit = + serviceImpl.streamPresence(request, responseObserver) + })) + .addMethod( + METHOD_STREAM_UNREAD_COUNTS, + _root_.io.grpc.stub.ServerCalls.asyncServerStreamingCall( + new _root_.io.grpc.stub.ServerCalls.ServerStreamingMethod[code.obp.grpc.chat.api.StreamUnreadCountsRequest, code.obp.grpc.chat.api.UnreadCountEvent] { + override def invoke(request: code.obp.grpc.chat.api.StreamUnreadCountsRequest, + responseObserver: _root_.io.grpc.stub.StreamObserver[code.obp.grpc.chat.api.UnreadCountEvent]): Unit = + serviceImpl.streamUnreadCounts(request, responseObserver) + })) + .build() +} diff --git a/obp-api/src/main/scala/code/obp/grpc/chat/api/PresenceEvent.scala b/obp-api/src/main/scala/code/obp/grpc/chat/api/PresenceEvent.scala new file mode 100644 index 0000000000..bc2480d819 --- /dev/null +++ b/obp-api/src/main/scala/code/obp/grpc/chat/api/PresenceEvent.scala @@ -0,0 +1,164 @@ +// Generated by the Scala Plugin for the Protocol Buffer Compiler. +// Do not edit! +// +// Protofile syntax: PROTO3 + +package code.obp.grpc.chat.api + +@SerialVersionUID(0L) +final case class PresenceEvent( + userId: _root_.scala.Predef.String = "", + username: _root_.scala.Predef.String = "", + provider: _root_.scala.Predef.String = "", + isOnline: _root_.scala.Boolean = false + ) extends scalapb.GeneratedMessage with scalapb.Message[PresenceEvent] with scalapb.lenses.Updatable[PresenceEvent] { + @transient + private[this] var __serializedSizeCachedValue: _root_.scala.Int = 0 + private[this] def __computeSerializedValue(): _root_.scala.Int = { + var __size = 0 + if (userId != "") { __size += _root_.com.google.protobuf.CodedOutputStream.computeStringSize(1, userId) } + if (username != "") { __size += _root_.com.google.protobuf.CodedOutputStream.computeStringSize(2, username) } + if (provider != "") { __size += _root_.com.google.protobuf.CodedOutputStream.computeStringSize(3, provider) } + if (isOnline != false) { __size += _root_.com.google.protobuf.CodedOutputStream.computeBoolSize(4, isOnline) } + __size + } + final override def serializedSize: _root_.scala.Int = { + var read = __serializedSizeCachedValue + if (read == 0) { + read = __computeSerializedValue() + __serializedSizeCachedValue = read + } + read + } + def writeTo(`_output__`: _root_.com.google.protobuf.CodedOutputStream): _root_.scala.Unit = { + { + val __v = userId + if (__v != "") { + _output__.writeString(1, __v) + } + }; + { + val __v = username + if (__v != "") { + _output__.writeString(2, __v) + } + }; + { + val __v = provider + if (__v != "") { + _output__.writeString(3, __v) + } + }; + { + val __v = isOnline + if (__v != false) { + _output__.writeBool(4, __v) + } + }; + } + def mergeFrom(`_input__`: _root_.com.google.protobuf.CodedInputStream): code.obp.grpc.chat.api.PresenceEvent = { + var __userId = this.userId + var __username = this.username + var __provider = this.provider + var __isOnline = this.isOnline + var _done__ = false + while (!_done__) { + val _tag__ = _input__.readTag() + _tag__ match { + case 0 => _done__ = true + case 10 => + __userId = _input__.readString() + case 18 => + __username = _input__.readString() + case 26 => + __provider = _input__.readString() + case 32 => + __isOnline = _input__.readBool() + case tag => _input__.skipField(tag) + } + } + code.obp.grpc.chat.api.PresenceEvent( + userId = __userId, + username = __username, + provider = __provider, + isOnline = __isOnline + ) + } + def withUserId(__v: _root_.scala.Predef.String): PresenceEvent = copy(userId = __v) + def withUsername(__v: _root_.scala.Predef.String): PresenceEvent = copy(username = __v) + def withProvider(__v: _root_.scala.Predef.String): PresenceEvent = copy(provider = __v) + def withIsOnline(__v: _root_.scala.Boolean): PresenceEvent = copy(isOnline = __v) + def getFieldByNumber(__fieldNumber: _root_.scala.Int): scala.Any = { + (__fieldNumber: @_root_.scala.unchecked) match { + case 1 => { + val __t = userId + if (__t != "") __t else null + } + case 2 => { + val __t = username + if (__t != "") __t else null + } + case 3 => { + val __t = provider + if (__t != "") __t else null + } + case 4 => { + val __t = isOnline + if (__t != false) __t else null + } + } + } + def getField(__field: _root_.scalapb.descriptors.FieldDescriptor): _root_.scalapb.descriptors.PValue = { + require(__field.containingMessage eq companion.scalaDescriptor) + (__field.number: @_root_.scala.unchecked) match { + case 1 => _root_.scalapb.descriptors.PString(userId) + case 2 => _root_.scalapb.descriptors.PString(username) + case 3 => _root_.scalapb.descriptors.PString(provider) + case 4 => _root_.scalapb.descriptors.PBoolean(isOnline) + } + } + def toProtoString: _root_.scala.Predef.String = _root_.scalapb.TextFormat.printToUnicodeString(this) + def companion = code.obp.grpc.chat.api.PresenceEvent +} + +object PresenceEvent extends scalapb.GeneratedMessageCompanion[code.obp.grpc.chat.api.PresenceEvent] { + implicit def messageCompanion: scalapb.GeneratedMessageCompanion[code.obp.grpc.chat.api.PresenceEvent] = this + def fromFieldsMap(__fieldsMap: scala.collection.immutable.Map[_root_.com.google.protobuf.Descriptors.FieldDescriptor, scala.Any]): code.obp.grpc.chat.api.PresenceEvent = { + require(__fieldsMap.keys.forall(_.getContainingType() == javaDescriptor), "FieldDescriptor does not match message type.") + val __fields = javaDescriptor.getFields + code.obp.grpc.chat.api.PresenceEvent( + __fieldsMap.getOrElse(__fields.get(0), "").asInstanceOf[_root_.scala.Predef.String], + __fieldsMap.getOrElse(__fields.get(1), "").asInstanceOf[_root_.scala.Predef.String], + __fieldsMap.getOrElse(__fields.get(2), "").asInstanceOf[_root_.scala.Predef.String], + __fieldsMap.getOrElse(__fields.get(3), false).asInstanceOf[_root_.scala.Boolean] + ) + } + implicit def messageReads: _root_.scalapb.descriptors.Reads[code.obp.grpc.chat.api.PresenceEvent] = _root_.scalapb.descriptors.Reads{ + case _root_.scalapb.descriptors.PMessage(__fieldsMap) => + require(__fieldsMap.keys.forall(_.containingMessage == scalaDescriptor), "FieldDescriptor does not match message type.") + code.obp.grpc.chat.api.PresenceEvent( + __fieldsMap.get(scalaDescriptor.findFieldByNumber(1).get).map(_.as[_root_.scala.Predef.String]).getOrElse(""), + __fieldsMap.get(scalaDescriptor.findFieldByNumber(2).get).map(_.as[_root_.scala.Predef.String]).getOrElse(""), + __fieldsMap.get(scalaDescriptor.findFieldByNumber(3).get).map(_.as[_root_.scala.Predef.String]).getOrElse(""), + __fieldsMap.get(scalaDescriptor.findFieldByNumber(4).get).map(_.as[_root_.scala.Boolean]).getOrElse(false) + ) + case _ => throw new RuntimeException("Expected PMessage") + } + def javaDescriptor: _root_.com.google.protobuf.Descriptors.Descriptor = ChatProto.javaDescriptor.getMessageTypes.get(5) + def scalaDescriptor: _root_.scalapb.descriptors.Descriptor = throw new UnsupportedOperationException("scalaDescriptor not available") + def messageCompanionForFieldNumber(__number: _root_.scala.Int): _root_.scalapb.GeneratedMessageCompanion[_] = throw new MatchError(__number) + lazy val nestedMessagesCompanions: Seq[_root_.scalapb.GeneratedMessageCompanion[_]] = Seq.empty + def enumCompanionForFieldNumber(__fieldNumber: _root_.scala.Int): _root_.scalapb.GeneratedEnumCompanion[_] = throw new MatchError(__fieldNumber) + lazy val defaultInstance = code.obp.grpc.chat.api.PresenceEvent( + ) + implicit class PresenceEventLens[UpperPB](_l: _root_.scalapb.lenses.Lens[UpperPB, code.obp.grpc.chat.api.PresenceEvent]) extends _root_.scalapb.lenses.ObjectLens[UpperPB, code.obp.grpc.chat.api.PresenceEvent](_l) { + def userId: _root_.scalapb.lenses.Lens[UpperPB, _root_.scala.Predef.String] = field(_.userId)((c_, f_) => c_.copy(userId = f_)) + def username: _root_.scalapb.lenses.Lens[UpperPB, _root_.scala.Predef.String] = field(_.username)((c_, f_) => c_.copy(username = f_)) + def provider: _root_.scalapb.lenses.Lens[UpperPB, _root_.scala.Predef.String] = field(_.provider)((c_, f_) => c_.copy(provider = f_)) + def isOnline: _root_.scalapb.lenses.Lens[UpperPB, _root_.scala.Boolean] = field(_.isOnline)((c_, f_) => c_.copy(isOnline = f_)) + } + final val USERID_FIELD_NUMBER = 1 + final val USERNAME_FIELD_NUMBER = 2 + final val PROVIDER_FIELD_NUMBER = 3 + final val ISONLINE_FIELD_NUMBER = 4 +} diff --git a/obp-api/src/main/scala/code/obp/grpc/chat/api/StreamMessagesRequest.scala b/obp-api/src/main/scala/code/obp/grpc/chat/api/StreamMessagesRequest.scala new file mode 100644 index 0000000000..87fec2d637 --- /dev/null +++ b/obp-api/src/main/scala/code/obp/grpc/chat/api/StreamMessagesRequest.scala @@ -0,0 +1,98 @@ +// Generated by the Scala Plugin for the Protocol Buffer Compiler. +// Do not edit! +// +// Protofile syntax: PROTO3 + +package code.obp.grpc.chat.api + +@SerialVersionUID(0L) +final case class StreamMessagesRequest( + chatRoomId: _root_.scala.Predef.String = "" + ) extends scalapb.GeneratedMessage with scalapb.Message[StreamMessagesRequest] with scalapb.lenses.Updatable[StreamMessagesRequest] { + @transient + private[this] var __serializedSizeCachedValue: _root_.scala.Int = 0 + private[this] def __computeSerializedValue(): _root_.scala.Int = { + var __size = 0 + if (chatRoomId != "") { __size += _root_.com.google.protobuf.CodedOutputStream.computeStringSize(1, chatRoomId) } + __size + } + final override def serializedSize: _root_.scala.Int = { + var read = __serializedSizeCachedValue + if (read == 0) { + read = __computeSerializedValue() + __serializedSizeCachedValue = read + } + read + } + def writeTo(`_output__`: _root_.com.google.protobuf.CodedOutputStream): _root_.scala.Unit = { + { + val __v = chatRoomId + if (__v != "") { + _output__.writeString(1, __v) + } + }; + } + def mergeFrom(`_input__`: _root_.com.google.protobuf.CodedInputStream): code.obp.grpc.chat.api.StreamMessagesRequest = { + var __chatRoomId = this.chatRoomId + var _done__ = false + while (!_done__) { + val _tag__ = _input__.readTag() + _tag__ match { + case 0 => _done__ = true + case 10 => + __chatRoomId = _input__.readString() + case tag => _input__.skipField(tag) + } + } + code.obp.grpc.chat.api.StreamMessagesRequest( + chatRoomId = __chatRoomId + ) + } + def withChatRoomId(__v: _root_.scala.Predef.String): StreamMessagesRequest = copy(chatRoomId = __v) + def getFieldByNumber(__fieldNumber: _root_.scala.Int): scala.Any = { + (__fieldNumber: @_root_.scala.unchecked) match { + case 1 => { + val __t = chatRoomId + if (__t != "") __t else null + } + } + } + def getField(__field: _root_.scalapb.descriptors.FieldDescriptor): _root_.scalapb.descriptors.PValue = { + require(__field.containingMessage eq companion.scalaDescriptor) + (__field.number: @_root_.scala.unchecked) match { + case 1 => _root_.scalapb.descriptors.PString(chatRoomId) + } + } + def toProtoString: _root_.scala.Predef.String = _root_.scalapb.TextFormat.printToUnicodeString(this) + def companion = code.obp.grpc.chat.api.StreamMessagesRequest +} + +object StreamMessagesRequest extends scalapb.GeneratedMessageCompanion[code.obp.grpc.chat.api.StreamMessagesRequest] { + implicit def messageCompanion: scalapb.GeneratedMessageCompanion[code.obp.grpc.chat.api.StreamMessagesRequest] = this + def fromFieldsMap(__fieldsMap: scala.collection.immutable.Map[_root_.com.google.protobuf.Descriptors.FieldDescriptor, scala.Any]): code.obp.grpc.chat.api.StreamMessagesRequest = { + require(__fieldsMap.keys.forall(_.getContainingType() == javaDescriptor), "FieldDescriptor does not match message type.") + val __fields = javaDescriptor.getFields + code.obp.grpc.chat.api.StreamMessagesRequest( + __fieldsMap.getOrElse(__fields.get(0), "").asInstanceOf[_root_.scala.Predef.String] + ) + } + implicit def messageReads: _root_.scalapb.descriptors.Reads[code.obp.grpc.chat.api.StreamMessagesRequest] = _root_.scalapb.descriptors.Reads{ + case _root_.scalapb.descriptors.PMessage(__fieldsMap) => + require(__fieldsMap.keys.forall(_.containingMessage == scalaDescriptor), "FieldDescriptor does not match message type.") + code.obp.grpc.chat.api.StreamMessagesRequest( + __fieldsMap.get(scalaDescriptor.findFieldByNumber(1).get).map(_.as[_root_.scala.Predef.String]).getOrElse("") + ) + case _ => throw new RuntimeException("Expected PMessage") + } + def javaDescriptor: _root_.com.google.protobuf.Descriptors.Descriptor = ChatProto.javaDescriptor.getMessageTypes.get(0) + def scalaDescriptor: _root_.scalapb.descriptors.Descriptor = throw new UnsupportedOperationException("scalaDescriptor not available") + def messageCompanionForFieldNumber(__number: _root_.scala.Int): _root_.scalapb.GeneratedMessageCompanion[_] = throw new MatchError(__number) + lazy val nestedMessagesCompanions: Seq[_root_.scalapb.GeneratedMessageCompanion[_]] = Seq.empty + def enumCompanionForFieldNumber(__fieldNumber: _root_.scala.Int): _root_.scalapb.GeneratedEnumCompanion[_] = throw new MatchError(__fieldNumber) + lazy val defaultInstance = code.obp.grpc.chat.api.StreamMessagesRequest( + ) + implicit class StreamMessagesRequestLens[UpperPB](_l: _root_.scalapb.lenses.Lens[UpperPB, code.obp.grpc.chat.api.StreamMessagesRequest]) extends _root_.scalapb.lenses.ObjectLens[UpperPB, code.obp.grpc.chat.api.StreamMessagesRequest](_l) { + def chatRoomId: _root_.scalapb.lenses.Lens[UpperPB, _root_.scala.Predef.String] = field(_.chatRoomId)((c_, f_) => c_.copy(chatRoomId = f_)) + } + final val CHATROOMID_FIELD_NUMBER = 1 +} diff --git a/obp-api/src/main/scala/code/obp/grpc/chat/api/StreamPresenceRequest.scala b/obp-api/src/main/scala/code/obp/grpc/chat/api/StreamPresenceRequest.scala new file mode 100644 index 0000000000..bfec51377f --- /dev/null +++ b/obp-api/src/main/scala/code/obp/grpc/chat/api/StreamPresenceRequest.scala @@ -0,0 +1,98 @@ +// Generated by the Scala Plugin for the Protocol Buffer Compiler. +// Do not edit! +// +// Protofile syntax: PROTO3 + +package code.obp.grpc.chat.api + +@SerialVersionUID(0L) +final case class StreamPresenceRequest( + chatRoomId: _root_.scala.Predef.String = "" + ) extends scalapb.GeneratedMessage with scalapb.Message[StreamPresenceRequest] with scalapb.lenses.Updatable[StreamPresenceRequest] { + @transient + private[this] var __serializedSizeCachedValue: _root_.scala.Int = 0 + private[this] def __computeSerializedValue(): _root_.scala.Int = { + var __size = 0 + if (chatRoomId != "") { __size += _root_.com.google.protobuf.CodedOutputStream.computeStringSize(1, chatRoomId) } + __size + } + final override def serializedSize: _root_.scala.Int = { + var read = __serializedSizeCachedValue + if (read == 0) { + read = __computeSerializedValue() + __serializedSizeCachedValue = read + } + read + } + def writeTo(`_output__`: _root_.com.google.protobuf.CodedOutputStream): _root_.scala.Unit = { + { + val __v = chatRoomId + if (__v != "") { + _output__.writeString(1, __v) + } + }; + } + def mergeFrom(`_input__`: _root_.com.google.protobuf.CodedInputStream): code.obp.grpc.chat.api.StreamPresenceRequest = { + var __chatRoomId = this.chatRoomId + var _done__ = false + while (!_done__) { + val _tag__ = _input__.readTag() + _tag__ match { + case 0 => _done__ = true + case 10 => + __chatRoomId = _input__.readString() + case tag => _input__.skipField(tag) + } + } + code.obp.grpc.chat.api.StreamPresenceRequest( + chatRoomId = __chatRoomId + ) + } + def withChatRoomId(__v: _root_.scala.Predef.String): StreamPresenceRequest = copy(chatRoomId = __v) + def getFieldByNumber(__fieldNumber: _root_.scala.Int): scala.Any = { + (__fieldNumber: @_root_.scala.unchecked) match { + case 1 => { + val __t = chatRoomId + if (__t != "") __t else null + } + } + } + def getField(__field: _root_.scalapb.descriptors.FieldDescriptor): _root_.scalapb.descriptors.PValue = { + require(__field.containingMessage eq companion.scalaDescriptor) + (__field.number: @_root_.scala.unchecked) match { + case 1 => _root_.scalapb.descriptors.PString(chatRoomId) + } + } + def toProtoString: _root_.scala.Predef.String = _root_.scalapb.TextFormat.printToUnicodeString(this) + def companion = code.obp.grpc.chat.api.StreamPresenceRequest +} + +object StreamPresenceRequest extends scalapb.GeneratedMessageCompanion[code.obp.grpc.chat.api.StreamPresenceRequest] { + implicit def messageCompanion: scalapb.GeneratedMessageCompanion[code.obp.grpc.chat.api.StreamPresenceRequest] = this + def fromFieldsMap(__fieldsMap: scala.collection.immutable.Map[_root_.com.google.protobuf.Descriptors.FieldDescriptor, scala.Any]): code.obp.grpc.chat.api.StreamPresenceRequest = { + require(__fieldsMap.keys.forall(_.getContainingType() == javaDescriptor), "FieldDescriptor does not match message type.") + val __fields = javaDescriptor.getFields + code.obp.grpc.chat.api.StreamPresenceRequest( + __fieldsMap.getOrElse(__fields.get(0), "").asInstanceOf[_root_.scala.Predef.String] + ) + } + implicit def messageReads: _root_.scalapb.descriptors.Reads[code.obp.grpc.chat.api.StreamPresenceRequest] = _root_.scalapb.descriptors.Reads{ + case _root_.scalapb.descriptors.PMessage(__fieldsMap) => + require(__fieldsMap.keys.forall(_.containingMessage == scalaDescriptor), "FieldDescriptor does not match message type.") + code.obp.grpc.chat.api.StreamPresenceRequest( + __fieldsMap.get(scalaDescriptor.findFieldByNumber(1).get).map(_.as[_root_.scala.Predef.String]).getOrElse("") + ) + case _ => throw new RuntimeException("Expected PMessage") + } + def javaDescriptor: _root_.com.google.protobuf.Descriptors.Descriptor = ChatProto.javaDescriptor.getMessageTypes.get(2) + def scalaDescriptor: _root_.scalapb.descriptors.Descriptor = throw new UnsupportedOperationException("scalaDescriptor not available") + def messageCompanionForFieldNumber(__number: _root_.scala.Int): _root_.scalapb.GeneratedMessageCompanion[_] = throw new MatchError(__number) + lazy val nestedMessagesCompanions: Seq[_root_.scalapb.GeneratedMessageCompanion[_]] = Seq.empty + def enumCompanionForFieldNumber(__fieldNumber: _root_.scala.Int): _root_.scalapb.GeneratedEnumCompanion[_] = throw new MatchError(__fieldNumber) + lazy val defaultInstance = code.obp.grpc.chat.api.StreamPresenceRequest( + ) + implicit class StreamPresenceRequestLens[UpperPB](_l: _root_.scalapb.lenses.Lens[UpperPB, code.obp.grpc.chat.api.StreamPresenceRequest]) extends _root_.scalapb.lenses.ObjectLens[UpperPB, code.obp.grpc.chat.api.StreamPresenceRequest](_l) { + def chatRoomId: _root_.scalapb.lenses.Lens[UpperPB, _root_.scala.Predef.String] = field(_.chatRoomId)((c_, f_) => c_.copy(chatRoomId = f_)) + } + final val CHATROOMID_FIELD_NUMBER = 1 +} diff --git a/obp-api/src/main/scala/code/obp/grpc/chat/api/StreamUnreadCountsRequest.scala b/obp-api/src/main/scala/code/obp/grpc/chat/api/StreamUnreadCountsRequest.scala new file mode 100644 index 0000000000..5c569a0f1c --- /dev/null +++ b/obp-api/src/main/scala/code/obp/grpc/chat/api/StreamUnreadCountsRequest.scala @@ -0,0 +1,77 @@ +// Generated by the Scala Plugin for the Protocol Buffer Compiler. +// Do not edit! +// +// Protofile syntax: PROTO3 + +package code.obp.grpc.chat.api + +@SerialVersionUID(0L) +final case class StreamUnreadCountsRequest( + ) extends scalapb.GeneratedMessage with scalapb.Message[StreamUnreadCountsRequest] with scalapb.lenses.Updatable[StreamUnreadCountsRequest] { + @transient + private[this] var __serializedSizeCachedValue: _root_.scala.Int = 0 + private[this] def __computeSerializedValue(): _root_.scala.Int = { + var __size = 0 + __size + } + final override def serializedSize: _root_.scala.Int = { + var read = __serializedSizeCachedValue + if (read == 0) { + read = __computeSerializedValue() + __serializedSizeCachedValue = read + } + read + } + def writeTo(`_output__`: _root_.com.google.protobuf.CodedOutputStream): _root_.scala.Unit = { + } + def mergeFrom(`_input__`: _root_.com.google.protobuf.CodedInputStream): code.obp.grpc.chat.api.StreamUnreadCountsRequest = { + var _done__ = false + while (!_done__) { + val _tag__ = _input__.readTag() + _tag__ match { + case 0 => _done__ = true + case tag => _input__.skipField(tag) + } + } + code.obp.grpc.chat.api.StreamUnreadCountsRequest( + ) + } + def getFieldByNumber(__fieldNumber: _root_.scala.Int): scala.Any = { + (__fieldNumber: @_root_.scala.unchecked) match { + case _ => throw new MatchError(__fieldNumber) + } + } + def getField(__field: _root_.scalapb.descriptors.FieldDescriptor): _root_.scalapb.descriptors.PValue = { + require(__field.containingMessage eq companion.scalaDescriptor) + (__field.number: @_root_.scala.unchecked) match { + case _ => throw new MatchError(__field) + } + } + def toProtoString: _root_.scala.Predef.String = _root_.scalapb.TextFormat.printToUnicodeString(this) + def companion = code.obp.grpc.chat.api.StreamUnreadCountsRequest +} + +object StreamUnreadCountsRequest extends scalapb.GeneratedMessageCompanion[code.obp.grpc.chat.api.StreamUnreadCountsRequest] { + implicit def messageCompanion: scalapb.GeneratedMessageCompanion[code.obp.grpc.chat.api.StreamUnreadCountsRequest] = this + def fromFieldsMap(__fieldsMap: scala.collection.immutable.Map[_root_.com.google.protobuf.Descriptors.FieldDescriptor, scala.Any]): code.obp.grpc.chat.api.StreamUnreadCountsRequest = { + require(__fieldsMap.keys.forall(_.getContainingType() == javaDescriptor), "FieldDescriptor does not match message type.") + code.obp.grpc.chat.api.StreamUnreadCountsRequest( + ) + } + implicit def messageReads: _root_.scalapb.descriptors.Reads[code.obp.grpc.chat.api.StreamUnreadCountsRequest] = _root_.scalapb.descriptors.Reads{ + case _root_.scalapb.descriptors.PMessage(__fieldsMap) => + require(__fieldsMap.keys.forall(_.containingMessage == scalaDescriptor), "FieldDescriptor does not match message type.") + code.obp.grpc.chat.api.StreamUnreadCountsRequest( + ) + case _ => throw new RuntimeException("Expected PMessage") + } + def javaDescriptor: _root_.com.google.protobuf.Descriptors.Descriptor = ChatProto.javaDescriptor.getMessageTypes.get(3) + def scalaDescriptor: _root_.scalapb.descriptors.Descriptor = throw new UnsupportedOperationException("scalaDescriptor not available") + def messageCompanionForFieldNumber(__number: _root_.scala.Int): _root_.scalapb.GeneratedMessageCompanion[_] = throw new MatchError(__number) + lazy val nestedMessagesCompanions: Seq[_root_.scalapb.GeneratedMessageCompanion[_]] = Seq.empty + def enumCompanionForFieldNumber(__fieldNumber: _root_.scala.Int): _root_.scalapb.GeneratedEnumCompanion[_] = throw new MatchError(__fieldNumber) + lazy val defaultInstance = code.obp.grpc.chat.api.StreamUnreadCountsRequest( + ) + implicit class StreamUnreadCountsRequestLens[UpperPB](_l: _root_.scalapb.lenses.Lens[UpperPB, code.obp.grpc.chat.api.StreamUnreadCountsRequest]) extends _root_.scalapb.lenses.ObjectLens[UpperPB, code.obp.grpc.chat.api.StreamUnreadCountsRequest](_l) { + } +} diff --git a/obp-api/src/main/scala/code/obp/grpc/chat/api/TypingEvent.scala b/obp-api/src/main/scala/code/obp/grpc/chat/api/TypingEvent.scala new file mode 100644 index 0000000000..8613170b93 --- /dev/null +++ b/obp-api/src/main/scala/code/obp/grpc/chat/api/TypingEvent.scala @@ -0,0 +1,120 @@ +// Generated by the Scala Plugin for the Protocol Buffer Compiler. +// Do not edit! +// +// Protofile syntax: PROTO3 + +package code.obp.grpc.chat.api + +@SerialVersionUID(0L) +final case class TypingEvent( + chatRoomId: _root_.scala.Predef.String = "", + isTyping: _root_.scala.Boolean = false + ) extends scalapb.GeneratedMessage with scalapb.Message[TypingEvent] with scalapb.lenses.Updatable[TypingEvent] { + @transient + private[this] var __serializedSizeCachedValue: _root_.scala.Int = 0 + private[this] def __computeSerializedValue(): _root_.scala.Int = { + var __size = 0 + if (chatRoomId != "") { __size += _root_.com.google.protobuf.CodedOutputStream.computeStringSize(1, chatRoomId) } + if (isTyping != false) { __size += _root_.com.google.protobuf.CodedOutputStream.computeBoolSize(2, isTyping) } + __size + } + final override def serializedSize: _root_.scala.Int = { + var read = __serializedSizeCachedValue + if (read == 0) { + read = __computeSerializedValue() + __serializedSizeCachedValue = read + } + read + } + def writeTo(`_output__`: _root_.com.google.protobuf.CodedOutputStream): _root_.scala.Unit = { + { + val __v = chatRoomId + if (__v != "") { + _output__.writeString(1, __v) + } + }; + { + val __v = isTyping + if (__v != false) { + _output__.writeBool(2, __v) + } + }; + } + def mergeFrom(`_input__`: _root_.com.google.protobuf.CodedInputStream): code.obp.grpc.chat.api.TypingEvent = { + var __chatRoomId = this.chatRoomId + var __isTyping = this.isTyping + var _done__ = false + while (!_done__) { + val _tag__ = _input__.readTag() + _tag__ match { + case 0 => _done__ = true + case 10 => + __chatRoomId = _input__.readString() + case 16 => + __isTyping = _input__.readBool() + case tag => _input__.skipField(tag) + } + } + code.obp.grpc.chat.api.TypingEvent( + chatRoomId = __chatRoomId, + isTyping = __isTyping + ) + } + def withChatRoomId(__v: _root_.scala.Predef.String): TypingEvent = copy(chatRoomId = __v) + def withIsTyping(__v: _root_.scala.Boolean): TypingEvent = copy(isTyping = __v) + def getFieldByNumber(__fieldNumber: _root_.scala.Int): scala.Any = { + (__fieldNumber: @_root_.scala.unchecked) match { + case 1 => { + val __t = chatRoomId + if (__t != "") __t else null + } + case 2 => { + val __t = isTyping + if (__t != false) __t else null + } + } + } + def getField(__field: _root_.scalapb.descriptors.FieldDescriptor): _root_.scalapb.descriptors.PValue = { + require(__field.containingMessage eq companion.scalaDescriptor) + (__field.number: @_root_.scala.unchecked) match { + case 1 => _root_.scalapb.descriptors.PString(chatRoomId) + case 2 => _root_.scalapb.descriptors.PBoolean(isTyping) + } + } + def toProtoString: _root_.scala.Predef.String = _root_.scalapb.TextFormat.printToUnicodeString(this) + def companion = code.obp.grpc.chat.api.TypingEvent +} + +object TypingEvent extends scalapb.GeneratedMessageCompanion[code.obp.grpc.chat.api.TypingEvent] { + implicit def messageCompanion: scalapb.GeneratedMessageCompanion[code.obp.grpc.chat.api.TypingEvent] = this + def fromFieldsMap(__fieldsMap: scala.collection.immutable.Map[_root_.com.google.protobuf.Descriptors.FieldDescriptor, scala.Any]): code.obp.grpc.chat.api.TypingEvent = { + require(__fieldsMap.keys.forall(_.getContainingType() == javaDescriptor), "FieldDescriptor does not match message type.") + val __fields = javaDescriptor.getFields + code.obp.grpc.chat.api.TypingEvent( + __fieldsMap.getOrElse(__fields.get(0), "").asInstanceOf[_root_.scala.Predef.String], + __fieldsMap.getOrElse(__fields.get(1), false).asInstanceOf[_root_.scala.Boolean] + ) + } + implicit def messageReads: _root_.scalapb.descriptors.Reads[code.obp.grpc.chat.api.TypingEvent] = _root_.scalapb.descriptors.Reads{ + case _root_.scalapb.descriptors.PMessage(__fieldsMap) => + require(__fieldsMap.keys.forall(_.containingMessage == scalaDescriptor), "FieldDescriptor does not match message type.") + code.obp.grpc.chat.api.TypingEvent( + __fieldsMap.get(scalaDescriptor.findFieldByNumber(1).get).map(_.as[_root_.scala.Predef.String]).getOrElse(""), + __fieldsMap.get(scalaDescriptor.findFieldByNumber(2).get).map(_.as[_root_.scala.Boolean]).getOrElse(false) + ) + case _ => throw new RuntimeException("Expected PMessage") + } + def javaDescriptor: _root_.com.google.protobuf.Descriptors.Descriptor = ChatProto.javaDescriptor.getMessageTypes.get(1) + def scalaDescriptor: _root_.scalapb.descriptors.Descriptor = throw new UnsupportedOperationException("scalaDescriptor not available") + def messageCompanionForFieldNumber(__number: _root_.scala.Int): _root_.scalapb.GeneratedMessageCompanion[_] = throw new MatchError(__number) + lazy val nestedMessagesCompanions: Seq[_root_.scalapb.GeneratedMessageCompanion[_]] = Seq.empty + def enumCompanionForFieldNumber(__fieldNumber: _root_.scala.Int): _root_.scalapb.GeneratedEnumCompanion[_] = throw new MatchError(__fieldNumber) + lazy val defaultInstance = code.obp.grpc.chat.api.TypingEvent( + ) + implicit class TypingEventLens[UpperPB](_l: _root_.scalapb.lenses.Lens[UpperPB, code.obp.grpc.chat.api.TypingEvent]) extends _root_.scalapb.lenses.ObjectLens[UpperPB, code.obp.grpc.chat.api.TypingEvent](_l) { + def chatRoomId: _root_.scalapb.lenses.Lens[UpperPB, _root_.scala.Predef.String] = field(_.chatRoomId)((c_, f_) => c_.copy(chatRoomId = f_)) + def isTyping: _root_.scalapb.lenses.Lens[UpperPB, _root_.scala.Boolean] = field(_.isTyping)((c_, f_) => c_.copy(isTyping = f_)) + } + final val CHATROOMID_FIELD_NUMBER = 1 + final val ISTYPING_FIELD_NUMBER = 2 +} diff --git a/obp-api/src/main/scala/code/obp/grpc/chat/api/TypingIndicator.scala b/obp-api/src/main/scala/code/obp/grpc/chat/api/TypingIndicator.scala new file mode 100644 index 0000000000..a4edb6cdc6 --- /dev/null +++ b/obp-api/src/main/scala/code/obp/grpc/chat/api/TypingIndicator.scala @@ -0,0 +1,186 @@ +// Generated by the Scala Plugin for the Protocol Buffer Compiler. +// Do not edit! +// +// Protofile syntax: PROTO3 + +package code.obp.grpc.chat.api + +@SerialVersionUID(0L) +final case class TypingIndicator( + chatRoomId: _root_.scala.Predef.String = "", + userId: _root_.scala.Predef.String = "", + username: _root_.scala.Predef.String = "", + provider: _root_.scala.Predef.String = "", + isTyping: _root_.scala.Boolean = false + ) extends scalapb.GeneratedMessage with scalapb.Message[TypingIndicator] with scalapb.lenses.Updatable[TypingIndicator] { + @transient + private[this] var __serializedSizeCachedValue: _root_.scala.Int = 0 + private[this] def __computeSerializedValue(): _root_.scala.Int = { + var __size = 0 + if (chatRoomId != "") { __size += _root_.com.google.protobuf.CodedOutputStream.computeStringSize(1, chatRoomId) } + if (userId != "") { __size += _root_.com.google.protobuf.CodedOutputStream.computeStringSize(2, userId) } + if (username != "") { __size += _root_.com.google.protobuf.CodedOutputStream.computeStringSize(3, username) } + if (provider != "") { __size += _root_.com.google.protobuf.CodedOutputStream.computeStringSize(4, provider) } + if (isTyping != false) { __size += _root_.com.google.protobuf.CodedOutputStream.computeBoolSize(5, isTyping) } + __size + } + final override def serializedSize: _root_.scala.Int = { + var read = __serializedSizeCachedValue + if (read == 0) { + read = __computeSerializedValue() + __serializedSizeCachedValue = read + } + read + } + def writeTo(`_output__`: _root_.com.google.protobuf.CodedOutputStream): _root_.scala.Unit = { + { + val __v = chatRoomId + if (__v != "") { + _output__.writeString(1, __v) + } + }; + { + val __v = userId + if (__v != "") { + _output__.writeString(2, __v) + } + }; + { + val __v = username + if (__v != "") { + _output__.writeString(3, __v) + } + }; + { + val __v = provider + if (__v != "") { + _output__.writeString(4, __v) + } + }; + { + val __v = isTyping + if (__v != false) { + _output__.writeBool(5, __v) + } + }; + } + def mergeFrom(`_input__`: _root_.com.google.protobuf.CodedInputStream): code.obp.grpc.chat.api.TypingIndicator = { + var __chatRoomId = this.chatRoomId + var __userId = this.userId + var __username = this.username + var __provider = this.provider + var __isTyping = this.isTyping + var _done__ = false + while (!_done__) { + val _tag__ = _input__.readTag() + _tag__ match { + case 0 => _done__ = true + case 10 => + __chatRoomId = _input__.readString() + case 18 => + __userId = _input__.readString() + case 26 => + __username = _input__.readString() + case 34 => + __provider = _input__.readString() + case 40 => + __isTyping = _input__.readBool() + case tag => _input__.skipField(tag) + } + } + code.obp.grpc.chat.api.TypingIndicator( + chatRoomId = __chatRoomId, + userId = __userId, + username = __username, + provider = __provider, + isTyping = __isTyping + ) + } + def withChatRoomId(__v: _root_.scala.Predef.String): TypingIndicator = copy(chatRoomId = __v) + def withUserId(__v: _root_.scala.Predef.String): TypingIndicator = copy(userId = __v) + def withUsername(__v: _root_.scala.Predef.String): TypingIndicator = copy(username = __v) + def withProvider(__v: _root_.scala.Predef.String): TypingIndicator = copy(provider = __v) + def withIsTyping(__v: _root_.scala.Boolean): TypingIndicator = copy(isTyping = __v) + def getFieldByNumber(__fieldNumber: _root_.scala.Int): scala.Any = { + (__fieldNumber: @_root_.scala.unchecked) match { + case 1 => { + val __t = chatRoomId + if (__t != "") __t else null + } + case 2 => { + val __t = userId + if (__t != "") __t else null + } + case 3 => { + val __t = username + if (__t != "") __t else null + } + case 4 => { + val __t = provider + if (__t != "") __t else null + } + case 5 => { + val __t = isTyping + if (__t != false) __t else null + } + } + } + def getField(__field: _root_.scalapb.descriptors.FieldDescriptor): _root_.scalapb.descriptors.PValue = { + require(__field.containingMessage eq companion.scalaDescriptor) + (__field.number: @_root_.scala.unchecked) match { + case 1 => _root_.scalapb.descriptors.PString(chatRoomId) + case 2 => _root_.scalapb.descriptors.PString(userId) + case 3 => _root_.scalapb.descriptors.PString(username) + case 4 => _root_.scalapb.descriptors.PString(provider) + case 5 => _root_.scalapb.descriptors.PBoolean(isTyping) + } + } + def toProtoString: _root_.scala.Predef.String = _root_.scalapb.TextFormat.printToUnicodeString(this) + def companion = code.obp.grpc.chat.api.TypingIndicator +} + +object TypingIndicator extends scalapb.GeneratedMessageCompanion[code.obp.grpc.chat.api.TypingIndicator] { + implicit def messageCompanion: scalapb.GeneratedMessageCompanion[code.obp.grpc.chat.api.TypingIndicator] = this + def fromFieldsMap(__fieldsMap: scala.collection.immutable.Map[_root_.com.google.protobuf.Descriptors.FieldDescriptor, scala.Any]): code.obp.grpc.chat.api.TypingIndicator = { + require(__fieldsMap.keys.forall(_.getContainingType() == javaDescriptor), "FieldDescriptor does not match message type.") + val __fields = javaDescriptor.getFields + code.obp.grpc.chat.api.TypingIndicator( + __fieldsMap.getOrElse(__fields.get(0), "").asInstanceOf[_root_.scala.Predef.String], + __fieldsMap.getOrElse(__fields.get(1), "").asInstanceOf[_root_.scala.Predef.String], + __fieldsMap.getOrElse(__fields.get(2), "").asInstanceOf[_root_.scala.Predef.String], + __fieldsMap.getOrElse(__fields.get(3), "").asInstanceOf[_root_.scala.Predef.String], + __fieldsMap.getOrElse(__fields.get(4), false).asInstanceOf[_root_.scala.Boolean] + ) + } + implicit def messageReads: _root_.scalapb.descriptors.Reads[code.obp.grpc.chat.api.TypingIndicator] = _root_.scalapb.descriptors.Reads{ + case _root_.scalapb.descriptors.PMessage(__fieldsMap) => + require(__fieldsMap.keys.forall(_.containingMessage == scalaDescriptor), "FieldDescriptor does not match message type.") + code.obp.grpc.chat.api.TypingIndicator( + __fieldsMap.get(scalaDescriptor.findFieldByNumber(1).get).map(_.as[_root_.scala.Predef.String]).getOrElse(""), + __fieldsMap.get(scalaDescriptor.findFieldByNumber(2).get).map(_.as[_root_.scala.Predef.String]).getOrElse(""), + __fieldsMap.get(scalaDescriptor.findFieldByNumber(3).get).map(_.as[_root_.scala.Predef.String]).getOrElse(""), + __fieldsMap.get(scalaDescriptor.findFieldByNumber(4).get).map(_.as[_root_.scala.Predef.String]).getOrElse(""), + __fieldsMap.get(scalaDescriptor.findFieldByNumber(5).get).map(_.as[_root_.scala.Boolean]).getOrElse(false) + ) + case _ => throw new RuntimeException("Expected PMessage") + } + def javaDescriptor: _root_.com.google.protobuf.Descriptors.Descriptor = ChatProto.javaDescriptor.getMessageTypes.get(3) + def scalaDescriptor: _root_.scalapb.descriptors.Descriptor = throw new UnsupportedOperationException("scalaDescriptor not available") + def messageCompanionForFieldNumber(__number: _root_.scala.Int): _root_.scalapb.GeneratedMessageCompanion[_] = throw new MatchError(__number) + lazy val nestedMessagesCompanions: Seq[_root_.scalapb.GeneratedMessageCompanion[_]] = Seq.empty + def enumCompanionForFieldNumber(__fieldNumber: _root_.scala.Int): _root_.scalapb.GeneratedEnumCompanion[_] = throw new MatchError(__fieldNumber) + lazy val defaultInstance = code.obp.grpc.chat.api.TypingIndicator( + ) + implicit class TypingIndicatorLens[UpperPB](_l: _root_.scalapb.lenses.Lens[UpperPB, code.obp.grpc.chat.api.TypingIndicator]) extends _root_.scalapb.lenses.ObjectLens[UpperPB, code.obp.grpc.chat.api.TypingIndicator](_l) { + def chatRoomId: _root_.scalapb.lenses.Lens[UpperPB, _root_.scala.Predef.String] = field(_.chatRoomId)((c_, f_) => c_.copy(chatRoomId = f_)) + def userId: _root_.scalapb.lenses.Lens[UpperPB, _root_.scala.Predef.String] = field(_.userId)((c_, f_) => c_.copy(userId = f_)) + def username: _root_.scalapb.lenses.Lens[UpperPB, _root_.scala.Predef.String] = field(_.username)((c_, f_) => c_.copy(username = f_)) + def provider: _root_.scalapb.lenses.Lens[UpperPB, _root_.scala.Predef.String] = field(_.provider)((c_, f_) => c_.copy(provider = f_)) + def isTyping: _root_.scalapb.lenses.Lens[UpperPB, _root_.scala.Boolean] = field(_.isTyping)((c_, f_) => c_.copy(isTyping = f_)) + } + final val CHATROOMID_FIELD_NUMBER = 1 + final val USERID_FIELD_NUMBER = 2 + final val USERNAME_FIELD_NUMBER = 3 + final val PROVIDER_FIELD_NUMBER = 4 + final val ISTYPING_FIELD_NUMBER = 5 +} diff --git a/obp-api/src/main/scala/code/obp/grpc/chat/api/UnreadCountEvent.scala b/obp-api/src/main/scala/code/obp/grpc/chat/api/UnreadCountEvent.scala new file mode 100644 index 0000000000..47f4cf11a9 --- /dev/null +++ b/obp-api/src/main/scala/code/obp/grpc/chat/api/UnreadCountEvent.scala @@ -0,0 +1,120 @@ +// Generated by the Scala Plugin for the Protocol Buffer Compiler. +// Do not edit! +// +// Protofile syntax: PROTO3 + +package code.obp.grpc.chat.api + +@SerialVersionUID(0L) +final case class UnreadCountEvent( + chatRoomId: _root_.scala.Predef.String = "", + unreadCount: _root_.scala.Long = 0L + ) extends scalapb.GeneratedMessage with scalapb.Message[UnreadCountEvent] with scalapb.lenses.Updatable[UnreadCountEvent] { + @transient + private[this] var __serializedSizeCachedValue: _root_.scala.Int = 0 + private[this] def __computeSerializedValue(): _root_.scala.Int = { + var __size = 0 + if (chatRoomId != "") { __size += _root_.com.google.protobuf.CodedOutputStream.computeStringSize(1, chatRoomId) } + if (unreadCount != 0L) { __size += _root_.com.google.protobuf.CodedOutputStream.computeInt64Size(2, unreadCount) } + __size + } + final override def serializedSize: _root_.scala.Int = { + var read = __serializedSizeCachedValue + if (read == 0) { + read = __computeSerializedValue() + __serializedSizeCachedValue = read + } + read + } + def writeTo(`_output__`: _root_.com.google.protobuf.CodedOutputStream): _root_.scala.Unit = { + { + val __v = chatRoomId + if (__v != "") { + _output__.writeString(1, __v) + } + }; + { + val __v = unreadCount + if (__v != 0L) { + _output__.writeInt64(2, __v) + } + }; + } + def mergeFrom(`_input__`: _root_.com.google.protobuf.CodedInputStream): code.obp.grpc.chat.api.UnreadCountEvent = { + var __chatRoomId = this.chatRoomId + var __unreadCount = this.unreadCount + var _done__ = false + while (!_done__) { + val _tag__ = _input__.readTag() + _tag__ match { + case 0 => _done__ = true + case 10 => + __chatRoomId = _input__.readString() + case 16 => + __unreadCount = _input__.readInt64() + case tag => _input__.skipField(tag) + } + } + code.obp.grpc.chat.api.UnreadCountEvent( + chatRoomId = __chatRoomId, + unreadCount = __unreadCount + ) + } + def withChatRoomId(__v: _root_.scala.Predef.String): UnreadCountEvent = copy(chatRoomId = __v) + def withUnreadCount(__v: _root_.scala.Long): UnreadCountEvent = copy(unreadCount = __v) + def getFieldByNumber(__fieldNumber: _root_.scala.Int): scala.Any = { + (__fieldNumber: @_root_.scala.unchecked) match { + case 1 => { + val __t = chatRoomId + if (__t != "") __t else null + } + case 2 => { + val __t = unreadCount + if (__t != 0L) __t else null + } + } + } + def getField(__field: _root_.scalapb.descriptors.FieldDescriptor): _root_.scalapb.descriptors.PValue = { + require(__field.containingMessage eq companion.scalaDescriptor) + (__field.number: @_root_.scala.unchecked) match { + case 1 => _root_.scalapb.descriptors.PString(chatRoomId) + case 2 => _root_.scalapb.descriptors.PLong(unreadCount) + } + } + def toProtoString: _root_.scala.Predef.String = _root_.scalapb.TextFormat.printToUnicodeString(this) + def companion = code.obp.grpc.chat.api.UnreadCountEvent +} + +object UnreadCountEvent extends scalapb.GeneratedMessageCompanion[code.obp.grpc.chat.api.UnreadCountEvent] { + implicit def messageCompanion: scalapb.GeneratedMessageCompanion[code.obp.grpc.chat.api.UnreadCountEvent] = this + def fromFieldsMap(__fieldsMap: scala.collection.immutable.Map[_root_.com.google.protobuf.Descriptors.FieldDescriptor, scala.Any]): code.obp.grpc.chat.api.UnreadCountEvent = { + require(__fieldsMap.keys.forall(_.getContainingType() == javaDescriptor), "FieldDescriptor does not match message type.") + val __fields = javaDescriptor.getFields + code.obp.grpc.chat.api.UnreadCountEvent( + __fieldsMap.getOrElse(__fields.get(0), "").asInstanceOf[_root_.scala.Predef.String], + __fieldsMap.getOrElse(__fields.get(1), 0L).asInstanceOf[_root_.scala.Long] + ) + } + implicit def messageReads: _root_.scalapb.descriptors.Reads[code.obp.grpc.chat.api.UnreadCountEvent] = _root_.scalapb.descriptors.Reads{ + case _root_.scalapb.descriptors.PMessage(__fieldsMap) => + require(__fieldsMap.keys.forall(_.containingMessage == scalaDescriptor), "FieldDescriptor does not match message type.") + code.obp.grpc.chat.api.UnreadCountEvent( + __fieldsMap.get(scalaDescriptor.findFieldByNumber(1).get).map(_.as[_root_.scala.Predef.String]).getOrElse(""), + __fieldsMap.get(scalaDescriptor.findFieldByNumber(2).get).map(_.as[_root_.scala.Long]).getOrElse(0L) + ) + case _ => throw new RuntimeException("Expected PMessage") + } + def javaDescriptor: _root_.com.google.protobuf.Descriptors.Descriptor = ChatProto.javaDescriptor.getMessageTypes.get(7) + def scalaDescriptor: _root_.scalapb.descriptors.Descriptor = throw new UnsupportedOperationException("scalaDescriptor not available") + def messageCompanionForFieldNumber(__number: _root_.scala.Int): _root_.scalapb.GeneratedMessageCompanion[_] = throw new MatchError(__number) + lazy val nestedMessagesCompanions: Seq[_root_.scalapb.GeneratedMessageCompanion[_]] = Seq.empty + def enumCompanionForFieldNumber(__fieldNumber: _root_.scala.Int): _root_.scalapb.GeneratedEnumCompanion[_] = throw new MatchError(__fieldNumber) + lazy val defaultInstance = code.obp.grpc.chat.api.UnreadCountEvent( + ) + implicit class UnreadCountEventLens[UpperPB](_l: _root_.scalapb.lenses.Lens[UpperPB, code.obp.grpc.chat.api.UnreadCountEvent]) extends _root_.scalapb.lenses.ObjectLens[UpperPB, code.obp.grpc.chat.api.UnreadCountEvent](_l) { + def chatRoomId: _root_.scalapb.lenses.Lens[UpperPB, _root_.scala.Predef.String] = field(_.chatRoomId)((c_, f_) => c_.copy(chatRoomId = f_)) + def unreadCount: _root_.scalapb.lenses.Lens[UpperPB, _root_.scala.Long] = field(_.unreadCount)((c_, f_) => c_.copy(unreadCount = f_)) + } + final val CHATROOMID_FIELD_NUMBER = 1 + final val UNREADCOUNT_FIELD_NUMBER = 2 +}