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.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 #######
###########################################