From a01d490802e358ef868615a6ac8029d7007686f6 Mon Sep 17 00:00:00 2001 From: Charles Dufour Date: Tue, 4 Mar 2025 13:21:41 +0100 Subject: [PATCH] [TRELLO-2781] Implement reassign report feature (#1903) * [TRELLO-2781] Implement reassign report feature * [TRELLO-2781] Change 'reassign' to 'reattribute' * [TRELLO-2781] Improve errors * [TRELLO-2781] Improve event --- app/controllers/ReportController.scala | 19 +++ app/controllers/error/AppError.scala | 16 ++ app/models/ReattributeCompany.scala | 15 ++ .../ReportFileOrchestrator.scala | 17 ++ app/orchestrators/ReportOrchestrator.scala | 147 +++++++++++++++++- app/utils/Constants.scala | 2 + app/utils/FrontRoute.scala | 1 + ...portToConsumerAcknowledgmentPro.scala.html | 8 +- conf/common/app.conf | 2 +- conf/messages.en | 4 + conf/messages.fr | 4 + conf/routes | 5 + 12 files changed, 234 insertions(+), 6 deletions(-) create mode 100644 app/models/ReattributeCompany.scala diff --git a/app/controllers/ReportController.scala b/app/controllers/ReportController.scala index 8b836c4a..d2239790 100644 --- a/app/controllers/ReportController.scala +++ b/app/controllers/ReportController.scala @@ -17,6 +17,7 @@ import play.api.i18n.MessagesProvider import play.api.libs.json.JsValue import play.api.libs.json.Json import play.api.mvc.Action +import play.api.mvc.AnyContent import play.api.mvc.ControllerComponents import repositories.company.CompanyRepositoryInterface import repositories.report.ReportRepositoryInterface @@ -56,6 +57,24 @@ class ReportController( val logger: Logger = Logger(this.getClass) + def isReattributable(reportId: UUID): Action[AnyContent] = Act.public.standardLimit.async { _ => + reportOrchestrator.isReattributable(reportId).map(Ok(_)) + } + + def reattribute(reportId: UUID): Action[JsValue] = Act.public.standardLimit.async(parse.json) { implicit request => + implicit val userRole: Option[UserRole] = None + val consumerIp = ConsumerIp(request.remoteAddress) + for { + reattributeCompany <- request.parseBody[ReattributeCompany]() + createdReport <- reportOrchestrator.reattribute( + reportId, + reattributeCompany.company, + reattributeCompany.metadata, + consumerIp + ) + } yield Ok(Json.toJson(createdReport)) + } + def createReport: Action[JsValue] = Act.public.standardLimit.async(parse.json) { implicit request => implicit val userRole: Option[UserRole] = None for { diff --git a/app/controllers/error/AppError.scala b/app/controllers/error/AppError.scala index 6fcef11d..7e1abd2c 100644 --- a/app/controllers/error/AppError.scala +++ b/app/controllers/error/AppError.scala @@ -656,4 +656,20 @@ object AppError { override val titleForLogs: String = "website_not_identified" } + + final case class ReportNotReattributable(id: UUID) extends BadRequestError { + override val scErrorCode: String = "SC-0072" + override val title: String = s"Report is not reattributable" + override val details: String = + s"Le signalement $id n'est pas réattribuable" + override val titleForLogs: String = "report_not_reattributable" + } + + final case object CantReattributeToTheSameCompany extends BadRequestError { + override val scErrorCode: String = "SC-0073" + override val title: String = s"Report is not reattributable" + override val details: String = + "Vous ne pouvez pas réattribuer ce signalement à la même entreprise" + override val titleForLogs: String = "report_not_reattributable" + } } diff --git a/app/models/ReattributeCompany.scala b/app/models/ReattributeCompany.scala new file mode 100644 index 00000000..6a8c30be --- /dev/null +++ b/app/models/ReattributeCompany.scala @@ -0,0 +1,15 @@ +package models + +import models.report.reportmetadata.ReportMetadataDraft +import play.api.libs.json.Json +import play.api.libs.json.Reads +import tasks.company.CompanySearchResult + +case class ReattributeCompany( + company: CompanySearchResult, + metadata: ReportMetadataDraft +) + +object ReattributeCompany { + implicit val reads: Reads[ReattributeCompany] = Json.reads[ReattributeCompany] +} diff --git a/app/orchestrators/ReportFileOrchestrator.scala b/app/orchestrators/ReportFileOrchestrator.scala index b47533e8..2a257439 100644 --- a/app/orchestrators/ReportFileOrchestrator.scala +++ b/app/orchestrators/ReportFileOrchestrator.scala @@ -234,6 +234,23 @@ class ReportFileOrchestrator( _ <- s3Service.delete(filename) } yield res + def duplicate(fileId: ReportFileId, filename: String, maybeReportId: Option[UUID]): Future[ReportFile] = for { + file <- getFileByIdAndName(fileId, filename) + data <- s3Service.download(file.storageFilename) + newName = s"${UUID.randomUUID()}_$filename" + newFile = ReportFile( + id = ReportFileId.generateId(), + reportId = maybeReportId, + creationDate = OffsetDateTime.now(), + filename = filename, + storageFilename = newName, + origin = file.origin, + avOutput = file.avOutput + ) + newReportFile <- reportFileRepository.create(newFile) + _ <- Source.single(data).runWith(s3Service.upload(newReportFile.storageFilename)) + } yield newReportFile + private def checkIsNotYetUsedInReport(file: ReportFile): Try[Unit] = file.reportId match { case None => Success(()) diff --git a/app/orchestrators/ReportOrchestrator.scala b/app/orchestrators/ReportOrchestrator.scala index 181bbea1..db283c08 100644 --- a/app/orchestrators/ReportOrchestrator.scala +++ b/app/orchestrators/ReportOrchestrator.scala @@ -25,6 +25,7 @@ import models.report.ReportStatus.hasResponse import models.report.ReportWordOccurrence.StopWords import models.report._ import models.report.reportmetadata.ReportExtra +import models.report.reportmetadata.ReportMetadataDraft import models.report.reportmetadata.ReportWithMetadataAndBookmark import models.token.TokenKind.CompanyInit import models.website.Website @@ -32,6 +33,7 @@ import orchestrators.ReportOrchestrator.ReportCompanyChangeThresholdInDays import orchestrators.ReportOrchestrator.validateNotGouvWebsite import play.api.Logger import play.api.i18n.MessagesApi +import play.api.libs.json.JsObject import play.api.libs.json.Json import repositories.accesstoken.AccessTokenRepositoryInterface import repositories.blacklistedemails.BlacklistedEmailsRepositoryInterface @@ -52,6 +54,7 @@ import services.emails.EmailDefinitionsConsumer.ConsumerReportReadByProNotificat import services.emails.EmailDefinitionsPro.ProNewReportNotification import services.emails.EmailDefinitionsPro.ProResponseAcknowledgment import services.emails.MailServiceInterface +import tasks.company.CompanySearchResult import tasks.company.CompanySyncServiceInterface import utils.Constants.ActionEvent._ import utils.Constants.ActionEvent @@ -64,6 +67,7 @@ import java.time.LocalDate import java.time.OffsetDateTime import java.time.Period import java.time.ZoneOffset +import java.time.temporal.ChronoUnit import java.time.temporal.TemporalAmount import java.util.UUID import java.util.concurrent.TimeUnit @@ -291,8 +295,10 @@ class ReportOrchestrator( throw SpammerEmailBlocked(emailAddress) } else () - private[orchestrators] def validateCompany(reportDraft: ReportDraft): Future[Done.type] = { + private[orchestrators] def validateCompany(reportDraft: ReportDraft): Future[Done.type] = + validateCompany(reportDraft.companyActivityCode, reportDraft.companySiret) + private def validateCompany(maybeActivityCode: Option[String], maybeSiret: Option[SIRET]): Future[Done.type] = { def validateSiretExistsOnEntrepriseApi(siret: SIRET) = companySyncService .companyBySiret(siret) .recoverWith { case error => @@ -307,14 +313,14 @@ class ReportOrchestrator( } for { - _ <- reportDraft.companyActivityCode match { + _ <- maybeActivityCode match { // 84 correspond à l'administration publique, sauf quelques cas particuliers : // - 84.30B : Complémentaires retraites donc pas publique case Some(activityCode) if activityCode.startsWith("84.") && activityCode != "84.30B" => Future.failed(AppError.CannotReportPublicAdministration) case _ => Future.unit } - _ <- reportDraft.companySiret match { + _ <- maybeSiret match { case Some(siret) => // Try to check if siret exist in signal conso database companyRepository.findBySiret(siret).flatMap { @@ -1105,6 +1111,141 @@ class ReportOrchestrator( .sortWith(_.count > _.count) .slice(0, 10) + private def ensureReportReattributable(report: Report) = + report.websiteURL.websiteURL.isEmpty && + report.websiteURL.host.isEmpty && + report.influencer.isEmpty && + report.barcodeProductId.isEmpty && + report.train.isEmpty && + report.station.isEmpty && + report.companyId.isDefined + + private def isReattributable(report: Report) = for { + proEvents <- eventRepository.getEvents( + report.id, + EventFilter(eventType = Some(EventType.PRO), action = Some(ActionEvent.REPORT_PRO_RESPONSE)) + ) + filteredProEvents = proEvents + .filter(event => event.details.as[IncomingReportResponse].responseType == ReportResponseType.NOT_CONCERNED) + .filter(_.creationDate.isAfter(OffsetDateTime.now().minusDays(15))) + consoEvents <- eventRepository.getEvents( + report.id, + EventFilter(eventType = Some(EventType.CONSO), action = Some(ActionEvent.REATTRIBUTE)) + ) + } yield + if (ensureReportReattributable(report) && consoEvents.isEmpty) filteredProEvents.headOption.map(_.creationDate) + else None + + // Signalement "réattribuable" si : + // - Le signalement existe et il ne concerne pas un site web, un train etc. (voir méthode ensureReportReattributable) + // - Le pro a répondu 'MalAttribue' (voir isReattributable) + // - Le conso ne l'a pas déjà réattribué + // - La réponse du pro n'est pas trop vieille (moins de 15 jours) + def isReattributable(reportId: UUID): Future[JsObject] = for { + maybeReport <- reportRepository.get(reportId) + report <- maybeReport.liftTo[Future](ReportNotFound(reportId)) + maybeProResponseDate <- isReattributable(report) + proResponseDate <- maybeProResponseDate match { + case Some(date) => Future.successful(date) + case None => Future.failed(ReportNotReattributable(reportId)) + } + } yield Json.obj( + "creationDate" -> report.creationDate, + "tags" -> report.tags, + "companyName" -> report.companyName, + "daysToAnswer" -> (15 - ChronoUnit.DAYS.between(proResponseDate, OffsetDateTime.now())) + ) + + private def toCompany(companySearchResult: CompanySearchResult) = + Company( + siret = companySearchResult.siret, + name = companySearchResult.name.getOrElse(""), + address = companySearchResult.address, + activityCode = companySearchResult.activityCode, + isHeadOffice = companySearchResult.isHeadOffice, + isOpen = companySearchResult.isOpen, + isPublic = companySearchResult.isPublic, + brand = companySearchResult.brand, + commercialName = companySearchResult.commercialName, + establishmentCommercialName = companySearchResult.establishmentCommercialName + ) + + // On vérifie si le signalement est réattribuable + // On vérifie en plus que la réattribution n'est pas à la meme entreprise + def reattribute( + reportId: UUID, + companyCreation: CompanySearchResult, + metadata: ReportMetadataDraft, + consumerIp: ConsumerIp + ): Future[Report] = for { + maybeReport <- reportRepository.get(reportId) + report <- maybeReport.liftTo[Future](ReportNotFound(reportId)) + maybeProResponseDate <- isReattributable(report) + _ <- if (maybeProResponseDate.nonEmpty) Future.unit else Future.failed(ReportNotReattributable(reportId)) + _ <- validateCompany(companyCreation.activityCode, Some(companyCreation.siret)) + _ <- + if (report.companySiret.contains(companyCreation.siret)) Future.failed(CantReattributeToTheSameCompany) + else Future.unit + company <- companyRepository.getOrCreate(companyCreation.siret, toCompany(companyCreation)) + reportFilesMap <- reportFileOrchestrator.prefetchReportsFiles(List(reportId)) + files = reportFilesMap.getOrElse(reportId, List.empty).filter(_.origin == ReportFileOrigin.Consumer) + newFiles <- files.traverse(f => reportFileOrchestrator.duplicate(f.id, f.filename, f.reportId)) + + reportDraft = ReportDraft( + gender = report.gender, + category = report.category, + subcategories = report.subcategories, + details = report.details, + influencer = report.influencer, + companyName = Some(company.name), + companyCommercialName = company.commercialName, + companyEstablishmentCommercialName = company.establishmentCommercialName, + companyBrand = company.brand, + companyAddress = Some(company.address), + companySiret = Some(company.siret), + companyActivityCode = company.activityCode, + websiteURL = report.websiteURL.websiteURL, + phone = report.phone, + firstName = report.firstName, + lastName = report.lastName, + email = report.email, + consumerPhone = report.consumerPhone, + consumerReferenceNumber = report.consumerReferenceNumber, + contactAgreement = report.contactAgreement, + employeeConsumer = report.employeeConsumer, + forwardToReponseConso = Some(report.forwardToReponseConso), + fileIds = newFiles.map(_.id), + vendor = report.vendor, + tags = report.tags, + reponseconsoCode = Some(report.reponseconsoCode), + ccrfCode = Some(report.ccrfCode), + lang = report.lang, + barcodeProductId = report.barcodeProductId, + metadata = Some(metadata), + train = report.train, + station = report.station, + rappelConsoId = report.rappelConsoId, + companyIsHeadOffice = None, + companyIsOpen = None, + companyIsPublic = None + ) + createdReport <- createReport(reportDraft, consumerIp) + _ <- eventRepository.create( + Event( + UUID.randomUUID(), + Some(report.id), + report.companyId, + None, + OffsetDateTime.now(), + Constants.EventType.CONSO, + Constants.ActionEvent.REATTRIBUTE, + Json.obj( + "newReportId" -> createdReport.id, + "newCompanyId" -> createdReport.companyId + ) + ) + ) + } yield createdReport } object ReportOrchestrator { diff --git a/app/utils/Constants.scala b/app/utils/Constants.scala index 53d1a74f..136c4990 100644 --- a/app/utils/Constants.scala +++ b/app/utils/Constants.scala @@ -119,6 +119,8 @@ object Constants { object USER_ACCESS_CREATED extends ActionEventValue("UserAccessCreated") object USER_ACCESS_REMOVED extends ActionEventValue("UserAccessRemoved") + object REATTRIBUTE extends ActionEventValue("Réattribution du signalement") + val actionEvents = Seq( A_CONTACTER, HORS_PERIMETRE, diff --git a/app/utils/FrontRoute.scala b/app/utils/FrontRoute.scala index 09098628..3d1b6833 100644 --- a/app/utils/FrontRoute.scala +++ b/app/utils/FrontRoute.scala @@ -17,6 +17,7 @@ class FrontRoute(signalConsoConfiguration: SignalConsoConfiguration) { if (report.tags.contains(Telecom)) "/litige/telecom" else "/litige" ) + def reattribute(report: Report) = url.resolve(s"reattribuer/${report.id}") def reportReview(id: String)(evaluation: ResponseEvaluation) = url.resolve( s"/avis/$id?evaluation=${evaluation.entryName}" diff --git a/app/views/mails/consumer/reportToConsumerAcknowledgmentPro.scala.html b/app/views/mails/consumer/reportToConsumerAcknowledgmentPro.scala.html index 292e2498..df22b03c 100644 --- a/app/views/mails/consumer/reportToConsumerAcknowledgmentPro.scala.html +++ b/app/views/mails/consumer/reportToConsumerAcknowledgmentPro.scala.html @@ -57,9 +57,13 @@ @Messages("ConsumerReportAckProEmail.signalementNonConcerne")
@ExistingReportResponse.translateResponseDetails(reportResponse).getOrElse("") -
- @Html(Messages("ConsumerReportAckProEmail.signalementNonAccepteHelp"))

+

@Messages("ConsumerReportAckProEmail.youCanReattribute")

+
+ @Messages("ConsumerReportAckProEmail.reattribute") +
+

@Messages("ConsumerReportAckProEmail.maxDelayToReattribute")

+

@Messages("ConsumerReportAckProEmail.ifYouAreSure")

@Messages("ReportClosedByNoReadingEmail.continuerDemarches") diff --git a/conf/common/app.conf b/conf/common/app.conf index 074bf20d..7e2cd26c 100644 --- a/conf/common/app.conf +++ b/conf/common/app.conf @@ -8,7 +8,7 @@ app { website-url = "http://localhost:3001" website-url = ${?WEBSITE_URL} - dashboard-url = "http://localhost:3000/#" + dashboard-url = "http://localhost:3000" dashboard-url = ${?DASHBOARD_URL} tmp-directory = ${?TMP_DIR} diff --git a/conf/messages.en b/conf/messages.en index aa298ef2..57ce7b8a 100644 --- a/conf/messages.en +++ b/conf/messages.en @@ -45,6 +45,10 @@ ConsumerReportAckProEmail.signalementNonConcerne=The company stated that this re ConsumerReportAckProEmail.signalementNonAccepteHelp=You can still continue the process to find a solution or obtain compensation: ConsumerReportAckProEmail.signalementAccepte=With its response, the company committed to considering your report. It made the following commitment: ConsumerReportAckProEmail.engagementReminderEmailPeriodLater=You will receive a new email in {0} days to ask if the company has fulfilled its commitment. Thank you for your patience and please refrain from submitting another report or contacting support before the end of this period. +ConsumerReportAckProEmail.youCanReattribute=You can reattribute your report to the correct company by following this link: +ConsumerReportAckProEmail.reattribute=Reattribute my report +ConsumerReportAckProEmail.maxDelayToReattribute=You have 15 days to reattribute it. +ConsumerReportAckProEmail.ifYouAreSure=However, if you are certain that it is the correct company, you can proceed with the steps to find a solution or obtain redress: ConsumerReportProEngagementEmail.subject=Your feedback on the company''s commitment regarding your report ConsumerReportProEngagementEmail.title=Your feedback on the company''s commitment regarding your report diff --git a/conf/messages.fr b/conf/messages.fr index 86e893d0..8a5a88ab 100644 --- a/conf/messages.fr +++ b/conf/messages.fr @@ -45,6 +45,10 @@ ConsumerReportAckProEmail.signalementNonConcerne=L''entreprise a déclaré que c ConsumerReportAckProEmail.signalementNonAccepteHelp=Vous pouvez néanmoins continuer les démarches pour trouver une solution ou obtenir réparation : ConsumerReportAckProEmail.signalementAccepte=Avec sa réponse, l''entreprise s''est engagée à prendre en compte votre signalement. Elle a pris l''engagement suivant : ConsumerReportAckProEmail.engagementReminderEmailPeriodLater=Vous recevrez un nouvel email dans {0} jours pour vous demander si l''entreprise a bien tenu son engagement. Merci de patienter et de ne pas refaire de signalement ou contacter le support avant la fin de ce délai. +ConsumerReportAckProEmail.youCanReattribute=Vous pouvez réattribuer votre signalement à la bonne entreprise en suivante ce lien : +ConsumerReportAckProEmail.reattribute=Réattribuer mon signalement +ConsumerReportAckProEmail.maxDelayToReattribute=Vous disposez de 15 jours pour le réattribuer. +ConsumerReportAckProEmail.ifYouAreSure=Néanmoins, si vous êtes certain qu''il s''agit bien de la bonne entreprise, vous pouvez poursuivre les démarches pour trouver une solution ou obtenir réparation : ConsumerReportProEngagementEmail.subject=Votre avis sur l''engagement de l''entreprise concernant votre signalement ConsumerReportProEngagementEmail.title=Votre avis sur l''engagement de l''entreprise concernant votre signalement diff --git a/conf/routes b/conf/routes index ca64658b..d466b728 100644 --- a/conf/routes +++ b/conf/routes @@ -38,6 +38,11 @@ DELETE /api/reports/files/temporary/:fileId/:filename controller # this next one is LEGACY, to be removed very soon, once the frontend is updated (was used both in website and dashboard) GET /api/reports/files/:uuid/:filename controllers.ReportFileController.legacyDownloadReportFile(uuid: ReportFileId, filename) +# For the conso, to reattribute a report if the pro said he was not concerned +GET /api/reports/:uuid/reattribute controllers.ReportController.isReattributable(uuid: java.util.UUID) +POST /api/reports/:uuid/reattribute controllers.ReportController.reattribute(uuid: java.util.UUID) + + ########################################### ########## REPORTS SEARCH/LIST PAGE ####### ###########################################