Skip to content

Commit 91cc821

Browse files
authored
Merge branch 'main' into live/redcard-specs
2 parents b2e3234 + f4736a3 commit 91cc821

43 files changed

Lines changed: 2687 additions & 695 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ dynamodb-local-live-activities/
2525
**.DS_Store
2626

2727
.metals/
28+
metals.sbt
2829
.vscode/
2930
.bloop/
3031
project/.bloop/

api-models/src/main/scala/com/gu/mobile.notifications.client/models/Payloads.scala

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,22 @@ object GoalType {
6060
}
6161
}
6262

63+
sealed trait ShootoutResultType
64+
object ScoredShootoutResult extends ShootoutResultType
65+
object SavedShootoutResult extends ShootoutResultType
66+
object MissedShootoutResult extends ShootoutResultType
67+
68+
object ShootoutResultType {
69+
implicit val jf: Writes[ShootoutResultType] = new Writes[ShootoutResultType] {
70+
override def writes(o: ShootoutResultType): JsValue = o match {
71+
case ScoredShootoutResult => JsString("Scored")
72+
case SavedShootoutResult => JsString("Saved")
73+
case MissedShootoutResult => JsString("Missed")
74+
}
75+
}
76+
}
77+
78+
6379
trait Payload {
6480
def id: UUID
6581
}

api-models/src/main/scala/com/gu/mobile.notifications.client/models/Topic.scala

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,30 @@ object TopicTypes {
2020
case object TagSeries extends TopicType { override val toString = "tag-series" }
2121
case object TagBlog extends TopicType { override val toString = "tag-blog" }
2222
case object FootballTeam extends TopicType { override val toString = "football-team" }
23+
/**
24+
* When a user is subscribed to a team, we will offer them a live activity
25+
* whenever that team is playing a match, as an alternative to push
26+
* notifications throughout the match. This live activity will be provided
27+
* via a single notification when the match starts, and users with app
28+
* versions that can support live activities will receive it by subscribing
29+
* to topics of type `FootballTeamLiveActivity`. Users with app versions that
30+
* do not support live activities will instead be subscribing to topics of
31+
* type `FootballTeam`, which will deliver notifications throughout the
32+
* match.
33+
*/
34+
case object FootballTeamLiveActivity extends TopicType { override val toString = "football-team-live-activity" }
2335
case object FootballMatch extends TopicType { override val toString = "football-match" }
36+
/**
37+
* A user may visit a match info page before a live activity for that match
38+
* is available, as we only create it a couple of hours before the match
39+
* starts. We still want them to be able to subscribe and get that live
40+
* activity when it's ready, so we'll allow them to sign up to this
41+
* notification topic instead. The live activity will then be provided via a
42+
* single notification when the match starts. Users with app versions that do
43+
* not support live activities will instead be subscribing to topics of type
44+
* `FootballMatch`, which will deliver notifications throughout the match.
45+
*/
46+
case object FootballMatchLiveActivity extends TopicType { override val toString = "football-match-live-activity" }
2447
case object User extends TopicType { override val toString = "user-type" }
2548
case object Newsstand extends TopicType { override val toString = "newsstand" }
2649
}

api-models/src/main/scala/com/gu/mobile.notifications.client/models/liveActitivites/BroadcastContentState.scala

Lines changed: 66 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -2,35 +2,25 @@ package com.gu.mobile.notifications.client.models.liveActitivites
22

33
import play.api.libs.json._
44

5-
6-
75
/**
86
* These are the Live Activity Content models used for sending live activity
97
* updates to Apple APNS service.
108
**/
119

12-
13-
1410
// GENERIC CONTENT STATE //////////////////////////////////////////////////
1511
sealed trait ContentState
1612
object ContentState {
1713
import FootballContentJsonFormats._
1814

19-
implicit val format: OFormat[ContentState] = new OFormat[ContentState] {
15+
implicit val contentStateFormat: OFormat[ContentState] = new OFormat[ContentState] {
2016
def writes(cs: ContentState): JsObject = cs match {
21-
case f: FootballMatchContentState =>
22-
footballMatchContentStateFormat
23-
.writes(f)
24-
.as[JsObject] + ("type" -> JsString("football"))
25-
// Add cases for other ContentState subtypes here
17+
case f: FootballMatchContentState => footballMatchContentStateFormat.writes(f)
2618
}
27-
2819
def reads(json: JsValue): JsResult[ContentState] = {
29-
(json \ "type").validate[String].flatMap {
30-
case "football" => footballMatchContentStateFormat.reads(json)
31-
// Add cases for other ContentState subtypes here
32-
case other => JsError(s"Unknown ContentState type: $other")
33-
}
20+
// todo - if we add more content states for other live activity types
21+
// todo - check adding a _type field to the json?
22+
// Try FootballMatchContentState - could check a distinguishing field if needed
23+
footballMatchContentStateFormat.reads(json)
3424
}
3525
}
3626
}
@@ -43,44 +33,78 @@ case object PreMatch extends MatchStatus { val status = "PRE_MATCH" }
4333
case object FirstHalf extends MatchStatus { val status = "FIRST_HALF" }
4434
case object HalfTime extends MatchStatus { val status = "HALF_TIME" }
4535
case object SecondHalf extends MatchStatus { val status = "SECOND_HALF" }
36+
case object ExtraTimeToBePlayed extends MatchStatus { val status = "EXTRA_TIME_TO_BE_PLAYED" }
4637
case object ExtraTimeFirstHalf extends MatchStatus { val status = "EXTRA_TIME_FIRST_HALF" }
4738
case object ExtraTimeHalfTime extends MatchStatus { val status = "EXTRA_TIME_HALF_TIME" }
4839
case object ExtraTimeSecondHalf extends MatchStatus { val status = "EXTRA_TIME_SECOND_HALF" }
40+
case object PenaltiesToBePlayed extends MatchStatus { val status = "PENALTIES_TO_BE_PLAYED" }
4941
case object Penalties extends MatchStatus { val status = "PENALTIES" }
5042
case object FullTime extends MatchStatus { val status = "FULL_TIME" }
5143
case object Postponed extends MatchStatus { val status = "POSTPONED" }
44+
case object Suspended extends MatchStatus { val status = "SUSPENDED" }
5245
case object Abandoned extends MatchStatus { val status = "ABANDONED" }
46+
case object Resumed extends MatchStatus { val status = "RESUMED" }
47+
case object Cancelled extends MatchStatus { val status = "CANCELLED" }
5348
// @formatter:on
5449

5550
object MatchStatus {
56-
def fromString(s: String): MatchStatus = s match {
57-
case "Pre-match" | "prematch" => PreMatch
58-
case "MatchInProgress" | "1st Half" | "first_half" => FirstHalf
59-
case "HalfTime" | "Half Time" | "half_time" => HalfTime
60-
case "2nd Half" | "second_half" => SecondHalf
61-
case "Extra Time" | "ET 1st Half" => ExtraTimeFirstHalf
62-
case "ET Half Time" => ExtraTimeHalfTime
63-
case "ET 2nd Half" => ExtraTimeSecondHalf
64-
case "Penalties" => Penalties
65-
case "MatchComplete" | "MC" | "Full Time" | "Result" => FullTime
66-
case "Postponed" => Postponed
67-
case "Abandoned" => Abandoned
68-
case _ => Scheduled
69-
}
51+
def fromString(s: String): MatchStatus = statuses.getOrElse(s, Scheduled)
52+
53+
// How PA match status are handled here differs from push notification mappings
54+
private val statuses: Map[String, MatchStatus] = Map(
55+
("Fixture", PreMatch), //
56+
("-", PreMatch), // seen in the wild
57+
("New", PreMatch), //
58+
59+
("KO", FirstHalf), // The Match has started (Kicked Off).
60+
61+
("HT", HalfTime), // The Referee has blown the whistle for Half Time.
62+
63+
("SHS", SecondHalf), // The Second Half of the Match has Started.
64+
65+
("FT", FullTime), // The Referee has blown the whistle for Full Time.
66+
("Result", FullTime), // The Result is official.
67+
("MC", FullTime), // Match has been Completed.
68+
69+
("FTET", ExtraTimeToBePlayed), // Full Time, Extra Time it to be played.
70+
("ETS", ExtraTimeFirstHalf), // Extra Time has Started.
71+
("ETHT", ExtraTimeHalfTime), // Extra Time Half Time has been called.
72+
("ETSHS", ExtraTimeSecondHalf), // Extra Time, Second Half has Started.
73+
("ETFT", ExtraTimeSecondHalf), // Extra Time, Full Time has been blown.
74+
75+
("FTPT", PenaltiesToBePlayed), // Full Time, Penalties are To be played.
76+
("ETFTPT", PenaltiesToBePlayed), // Extra Time, Full Time, Penalties are To be played.
77+
("PT", Penalties), // Penalty ShooT Out has started.
78+
("PTFT", Penalties), // Penalty ShooT Full Time.
79+
80+
// edge cases
81+
("Suspended", Suspended), // Match has been Suspended.
82+
("Resumed", Resumed), // Match has been Resumed. // todo can match phase be determined by match minute?
83+
("Abandoned", Abandoned), // Match has been Abandoned.
84+
("Postponed", Postponed), // A Match has been Postponed.
85+
("Cancelled", Cancelled) // A Match has been Cancelled.
86+
)
7087
}
7188

7289
case class Competition(
90+
id: String,
7391
name: String,
74-
round: Option[String] = None
92+
round: Option[String] = None // World Cup Group name
93+
)
94+
95+
case class PenaltyShootoutState(
96+
scored: Int = 0,
97+
missed: Int = 0,
98+
saved: Int = 0,
7599
)
76100

77101
case class TeamState(
78102
name: String,
79-
score: Int = 0,
80103
logoAssetName: Option[String] = None,
81104
teamUrl: Option[String] = None,
82-
penaltyScore: Option[Int] = None,
83-
redCards: Int = 0
105+
score: Int = 0,
106+
redCards: Int = 0,
107+
penaltyScore: Option[PenaltyShootoutState] = None
84108
)
85109

86110
case class FootballMatchContentState(
@@ -108,24 +132,27 @@ object FootballContentJsonFormats {
108132
case "FIRST_HALF" => JsSuccess(FirstHalf)
109133
case "HALF_TIME" => JsSuccess(HalfTime)
110134
case "SECOND_HALF" => JsSuccess(SecondHalf)
135+
case "EXTRA_TIME_TO_BE_PLAYED" => JsSuccess(ExtraTimeToBePlayed)
111136
case "EXTRA_TIME_FIRST_HALF" => JsSuccess(ExtraTimeFirstHalf)
112137
case "EXTRA_TIME_HALF_TIME" => JsSuccess(ExtraTimeHalfTime)
113138
case "EXTRA_TIME_SECOND_HALF" => JsSuccess(ExtraTimeSecondHalf)
139+
case "PENALTIES_TO_BE_PLAYED" => JsSuccess(PenaltiesToBePlayed)
114140
case "PENALTIES" => JsSuccess(Penalties)
115141
case "FULL_TIME" => JsSuccess(FullTime)
142+
case "SUSPENDED" => JsSuccess(Suspended)
143+
case "RESUMED" => JsSuccess(Resumed)
116144
case "POSTPONED" => JsSuccess(Postponed)
117145
case "ABANDONED" => JsSuccess(Abandoned)
146+
case "CANCELLED" => JsSuccess(Cancelled)
118147
case other => JsError(s"Unknown match status: $other")
119148
}
120149
case _ => JsError("Expected a JSON string for MatchStatus")
121150
},
122151
Writes(ms => JsString(ms.status))
123152
)
124153

125-
implicit val competitionFormat: OFormat[Competition] =
126-
Json.format[Competition]
154+
implicit val competitionFormat: OFormat[Competition] = Json.format[Competition]
155+
implicit val penaltyShootoutStateFormat: OFormat[PenaltyShootoutState] = Json.format[PenaltyShootoutState]
127156
implicit val teamStateFormat: OFormat[TeamState] = Json.format[TeamState]
128-
implicit val footballMatchContentStateFormat
129-
: OFormat[FootballMatchContentState] =
130-
Json.format[FootballMatchContentState]
157+
implicit val footballMatchContentStateFormat: OFormat[FootballMatchContentState] = Json.format[FootballMatchContentState]
131158
}
Lines changed: 85 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
package com.gu.mobile.notifications.client.models.liveActitivites
22

3-
import com.gu.mobile.notifications.client.models.Topic
4-
import play.api.libs.json.{JsString, Json, Writes}
53
import java.util.UUID
4+
5+
import play.api.libs.json._
66
import com.gu.mobile.notifications.client.models.Payload
77

88
/**
@@ -11,57 +11,100 @@ import com.gu.mobile.notifications.client.models.Payload
1111
* Channel Manager and Broadcast lambdas.
1212
**/
1313

14+
sealed trait EventSource
15+
case object FootballLambda extends EventSource
16+
17+
object EventSource {
18+
implicit val format: Format[EventSource] = new Format[EventSource] {
19+
def writes(source: EventSource): JsValue = source match {
20+
case FootballLambda => JsString("football-lambda")
21+
}
22+
23+
def reads(json: JsValue): JsResult[EventSource] = json match {
24+
case JsString("football-lambda") => JsSuccess(FootballLambda)
25+
case JsString(other) => JsError(s"Invalid EventSource: $other")
26+
case _ => JsError("EventSource must be a string")
27+
}
28+
}
29+
}
30+
31+
sealed trait LiveActivityEventType {
32+
def asString: String
33+
}
34+
35+
case object CreateChannelEvent extends LiveActivityEventType { val asString = "channel-create" }
36+
case object StartLiveActivityEvent extends LiveActivityEventType { val asString = "broadcast-start" }
37+
case object UpdateLiveActivityEvent extends LiveActivityEventType { val asString = "broadcast-update" }
38+
case object EndLiveActivityEvent extends LiveActivityEventType { val asString = "broadcast-end" }
39+
case object DeleteChannelEvent extends LiveActivityEventType { val asString = "channel-delete" }
40+
41+
object LiveActivityEventType {
42+
val values: Seq[LiveActivityEventType] = Seq(
43+
CreateChannelEvent,
44+
StartLiveActivityEvent,
45+
UpdateLiveActivityEvent,
46+
EndLiveActivityEvent,
47+
DeleteChannelEvent
48+
)
49+
50+
implicit val format: Format[LiveActivityEventType] = new Format[LiveActivityEventType] {
51+
override def reads(json: JsValue): JsResult[LiveActivityEventType] = json match {
52+
case JsString("channel-create") => JsSuccess(CreateChannelEvent)
53+
case JsString("broadcast-start") => JsSuccess(StartLiveActivityEvent)
54+
case JsString("broadcast-update") => JsSuccess(UpdateLiveActivityEvent)
55+
case JsString("broadcast-end") => JsSuccess(EndLiveActivityEvent)
56+
case JsString("channel-delete") => JsSuccess(DeleteChannelEvent)
57+
case JsString(other) => JsError(s"Invalid LiveActivityEventType: $other")
58+
case _ => JsError("LiveActivityEventType must be a string")
59+
}
60+
61+
override def writes(detailType: LiveActivityEventType): JsValue =
62+
JsString(detailType.asString)
63+
}
64+
}
65+
66+
case class EventBridgeEvent(
67+
version: String,
68+
id: String,
69+
`detail-type`: LiveActivityEventType,
70+
source: EventSource,
71+
account: String,
72+
time: String,
73+
region: String,
74+
resources: List[String],
75+
detail: LiveActivityPayload
76+
)
77+
78+
object EventBridgeEvent {
79+
implicit val eventBridgeEventFormat: Format[EventBridgeEvent] = Json.format[EventBridgeEvent]
80+
}
1481

15-
// life cycle of a live activity:
16-
sealed trait LiveActivityEventType
17-
case object CreateChannelEvent extends LiveActivityEventType
18-
case object StartLiveActivityEvent extends LiveActivityEventType
19-
case object UpdateLiveActivityEvent extends LiveActivityEventType
20-
case object EndLiveActivityEvent extends LiveActivityEventType
21-
case object DeleteChannelEvent extends LiveActivityEventType
2282

2383
sealed trait LiveActivityType
2484
case object FootballLiveActivity extends LiveActivityType
2585
// tbc cricket, elections
2686

27-
case class dynamoData(
28-
liveActivityId: String,
29-
isLive: Boolean,
30-
data: Option[String], // tbc
31-
competitionId: Option[String], // should this move to data??
32-
lastEventId: Option[String],
33-
lastEventAt: Option[Long]
34-
)
35-
3687
case class LiveActivityPayload(
37-
id: UUID, // unique event id for each payload associate with a live activity, used for de-duplication
38-
eventType: LiveActivityEventType,
39-
liveActivityType: LiveActivityType,
40-
liveActivityID: String, // Match ID in the case of football, tbc for other sports/events
41-
dynamoStoreData: Option[
42-
String
43-
], // data not in contentstate but specific to match, election, etc TBC if this is needed.
44-
broadcastContentStateData: Option[ContentState],
45-
eventTimestamp: Long,
46-
topics: List[Topic] // we will need to know topics for push to start
88+
id: UUID, // unique event id for each payload associate with a live activity, used for de-duplication
89+
eventType: LiveActivityEventType,
90+
liveActivityType: LiveActivityType,
91+
liveActivityID: String, // Match ID in the case of football, tbc for other sports/events
92+
dynamoStoreData: Option[String], // data not in contentstate but specific to match, election, etc TBC if this is needed.
93+
broadcastContentStateData: Option[ContentState],
94+
eventTimestamp: Long,
4795
) extends Payload
4896

4997
object LiveActivityPayload {
50-
implicit val liveActivityEventTypeWrites: Writes[LiveActivityEventType] =
98+
99+
implicit val liveActivityTypeFormat: Format[LiveActivityType] = Format(
100+
Reads {
101+
case JsString("FootballLiveActivity") => JsSuccess(FootballLiveActivity)
102+
case _ => JsError("Unknown LiveActivityType")
103+
},
51104
Writes {
52-
case CreateChannelEvent => JsString("CreateChannel")
53-
case StartLiveActivityEvent => JsString("StartLiveActivity")
54-
case UpdateLiveActivityEvent => JsString("UpdateLiveActivity")
55-
case EndLiveActivityEvent => JsString("EndLiveActivity")
56-
case DeleteChannelEvent => JsString("DeleteChannel")
105+
case FootballLiveActivity => JsString("FootballLiveActivity")
57106
}
107+
)
58108

59-
implicit val liveActivityTypeWrites: Writes[LiveActivityType] = Writes {
60-
case FootballLiveActivity => JsString("FootballLiveActivity")
61-
}
62-
63-
implicit val dynamoDataWrites: Writes[dynamoData] = Json.writes[dynamoData]
64-
65-
implicit val liveActivityPayloadWrites: Writes[LiveActivityPayload] =
66-
Json.writes[LiveActivityPayload]
109+
implicit val liveActivityPayloadFormat: Format[LiveActivityPayload] = Json.format[LiveActivityPayload]
67110
}

0 commit comments

Comments
 (0)