Skip to content

Commit

Permalink
[TRELLO-2781] Implement reassign report feature (#1903)
Browse files Browse the repository at this point in the history
* [TRELLO-2781] Implement reassign report feature

* [TRELLO-2781] Change 'reassign' to 'reattribute'

* [TRELLO-2781] Improve errors

* [TRELLO-2781] Improve event
  • Loading branch information
charlescd authored Mar 4, 2025
1 parent 50d7b95 commit a01d490
Show file tree
Hide file tree
Showing 12 changed files with 234 additions and 6 deletions.
19 changes: 19 additions & 0 deletions app/controllers/ReportController.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
16 changes: 16 additions & 0 deletions app/controllers/error/AppError.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
15 changes: 15 additions & 0 deletions app/models/ReattributeCompany.scala
Original file line number Diff line number Diff line change
@@ -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]
}
17 changes: 17 additions & 0 deletions app/orchestrators/ReportFileOrchestrator.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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(())
Expand Down
147 changes: 144 additions & 3 deletions app/orchestrators/ReportOrchestrator.scala
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,15 @@ 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
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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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 =>
Expand All @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
2 changes: 2 additions & 0 deletions app/utils/Constants.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions app/utils/FrontRoute.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,13 @@
@Messages("ConsumerReportAckProEmail.signalementNonConcerne")
<br />
<strong>@ExistingReportResponse.translateResponseDetails(reportResponse).getOrElse("")</strong>
<br />
<span>@Html(Messages("ConsumerReportAckProEmail.signalementNonAccepteHelp"))</span>
</p>
<p>@Messages("ConsumerReportAckProEmail.youCanReattribute")</p>
<div style="text-align: center">
<a href="@frontRoute.website.reattribute(report)" class="btn">@Messages("ConsumerReportAckProEmail.reattribute")</a>
</div>
<p style="margin-bottom: 32px">@Messages("ConsumerReportAckProEmail.maxDelayToReattribute")</p>
<p>@Messages("ConsumerReportAckProEmail.ifYouAreSure")</p>
<p style="text-align: center; padding: 20px">
<a href="@frontRoute.website.litige(report)" class="btn">
@Messages("ReportClosedByNoReadingEmail.continuerDemarches")
Expand Down
2 changes: 1 addition & 1 deletion conf/common/app.conf
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
4 changes: 4 additions & 0 deletions conf/messages.en
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions conf/messages.fr
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions conf/routes
Original file line number Diff line number Diff line change
Expand Up @@ -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 #######
###########################################
Expand Down

0 comments on commit a01d490

Please sign in to comment.