Skip to content

Commit 6ee48fc

Browse files
committed
Implement push notifications
1 parent c0d009b commit 6ee48fc

35 files changed

+1285
-178
lines changed

README.md

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ or from the registry: **YOU MIGHT PROBABLY WANT TO START WITH THIS**
7373

7474
You must install:
7575

76-
- Node v16
76+
- Node v20
7777
- Yarn v1.22.17
7878
- optional: IDEA
7979

@@ -101,6 +101,24 @@ Once created, edit the `CMSchApplication` Run Configuration's Spring Boot Active
101101
- `local,test` if you want test data in the database also
102102
- `local` if you don't
103103

104+
## Set up push notifications
105+
106+
1. Enable the push notification component on the backend.
107+
2. Create a Firebase project and enable Firebase Cloud Messaging by navigating to `Run` > `Messaging` and clicking on enable.
108+
109+
### Backend setup
110+
1. Navigate to the Firebase Console of your project and open `Project Settings` > `Service accounts`
111+
2. Click on `Generate new private key` and download the .json file
112+
3. If you are working locally set the value of `hu.bme.sch.cmsch.google.service-account-key` property to the contents of the JSON file
113+
4. If you are setting up the application inside docker set `FIREBASE_SERVICE_ACCOUNT_KEY` to the contents of the JSON file
114+
115+
### Frontend setup
116+
1. Navigate to the Firebase Console of your project and open `Project Settings` > `General`
117+
2. Scroll down and create a __Web App__ if there is no app already by clicking `Add app`
118+
3. Find the values of `apiKey, projectId, appId, messagingSenderId` and set the `FIREBASE_*` properties in .env
119+
4. Navigate to `Project Settings` > `General` and scroll down to `Web Push certificates`
120+
5. If there is no key, click on `Generate key pair`. Copy the value from `Key pair` column and set `VITE_FIREBASE_WEB_PUSH_PUBLIC_KEY` to it.
121+
104122
## Sponsors
105123

106124
<a href="https://vercel.com?utm_source=kir-dev&utm_campaign=oss"><img src="client/public/img/powered-by-vercel.svg" height="46" /></a>

backend/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ repositories {
2424
}
2525

2626
dependencies {
27+
implementation("com.google.firebase:firebase-admin:9.3.0")
2728
implementation("jakarta.xml.bind:jakarta.xml.bind-api:4.0.2")
2829
api("org.springframework.boot:spring-boot-configuration-processor")
2930
api("org.springframework.boot:spring-boot-starter-data-jpa")
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
package hu.bme.sch.cmsch.component.pushnotification
2+
3+
import com.google.auth.oauth2.GoogleCredentials
4+
import com.google.firebase.FirebaseApp
5+
import com.google.firebase.FirebaseOptions
6+
import com.google.firebase.messaging.FirebaseMessaging
7+
import org.springframework.beans.factory.annotation.Value
8+
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean
9+
import org.springframework.context.annotation.Bean
10+
import org.springframework.context.annotation.Configuration
11+
import org.springframework.retry.backoff.BackOffPolicyBuilder
12+
import org.springframework.retry.policy.SimpleRetryPolicy
13+
import org.springframework.retry.support.RetryTemplate
14+
15+
@Configuration
16+
class FirebaseMessagingConfiguration {
17+
private val messagingScope = "https://www.googleapis.com/auth/firebase.messaging"
18+
19+
@Bean
20+
@ConditionalOnBean(PushNotificationComponent::class)
21+
fun createFirebaseApp(
22+
@Value("\${hu.bme.sch.cmsch.google.service-account-key}") accountKey: String,
23+
): FirebaseApp =
24+
runCatching { FirebaseApp.getInstance() }
25+
.getOrElse { initFirebaseApp(accountKey) } // Has to be done this way, to prevent crashing when reloading the application in development
26+
27+
28+
@Bean
29+
@ConditionalOnBean(PushNotificationComponent::class)
30+
fun createFirebaseMessaging(app: FirebaseApp): FirebaseMessaging = FirebaseMessaging.getInstance(app)
31+
32+
33+
private fun initFirebaseApp(accountKey: String): FirebaseApp {
34+
val credentials = GoogleCredentials
35+
.fromStream(accountKey.byteInputStream())
36+
.createScoped(listOf(messagingScope))
37+
38+
val options = FirebaseOptions.builder()
39+
.setCredentials(credentials)
40+
.build()
41+
42+
return FirebaseApp.initializeApp(options)
43+
}
44+
45+
46+
@Bean
47+
@ConditionalOnBean(PushNotificationComponent::class)
48+
fun retryTemplate(): RetryTemplate {
49+
val retryPolicy = SimpleRetryPolicy(5)
50+
val backOffPolicy = BackOffPolicyBuilder.newBuilder()
51+
.delay(500L)
52+
.multiplier(1.5)
53+
.build()
54+
55+
val template = RetryTemplate()
56+
template.setRetryPolicy(retryPolicy)
57+
template.setBackOffPolicy(backOffPolicy)
58+
return template
59+
}
60+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package hu.bme.sch.cmsch.component.pushnotification
2+
3+
import jakarta.persistence.*
4+
5+
@Entity
6+
@Table(
7+
name = "messaging_tokens",
8+
indexes = [Index(columnList = "userId"), Index(columnList = "userId,token", unique = true)]
9+
)
10+
class MessagingTokenEntity(
11+
@Id
12+
@GeneratedValue(strategy = GenerationType.SEQUENCE)
13+
@Column(name = "id", nullable = false)
14+
open var id: Long? = null,
15+
16+
@Column(name = "userId", nullable = false)
17+
open var userId: Int = 0,
18+
19+
@Column(name = "token", nullable = false)
20+
open var token: String = ""
21+
)
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
package hu.bme.sch.cmsch.component.pushnotification
2+
3+
import hu.bme.sch.cmsch.model.RoleType
4+
import org.springframework.data.jpa.repository.JpaRepository
5+
import org.springframework.data.jpa.repository.Query
6+
7+
interface MessagingTokenRepository : JpaRepository<MessagingTokenEntity, Long> {
8+
9+
@Query("select m.token from MessagingTokenEntity m")
10+
fun findAllTokens(): List<String>
11+
12+
@Query("select m.token from MessagingTokenEntity m where m.userId = ?1")
13+
fun findAllTokensByUserId(userId: Int): List<String>
14+
15+
@Query("select m.token from MessagingTokenEntity m inner join UserEntity u on u.id = m.userId where u.group.id = ?1")
16+
fun findAllTokensByGroupId(groupId: Int): List<String>
17+
18+
@Query("select m.token from MessagingTokenEntity m inner join UserEntity u on u.id = m.userId where u.role = ?1")
19+
fun findAllTokensByRole(role: RoleType): List<String>
20+
21+
fun deleteByUserIdAndToken(userId: Int, token: String): Long
22+
23+
fun existsByUserIdAndToken(userId: Int, token: String): Boolean
24+
25+
fun deleteByTokenIn(tokens: List<String>): Long
26+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
package hu.bme.sch.cmsch.component.pushnotification
2+
3+
import hu.bme.sch.cmsch.util.getUserOrNull
4+
import io.swagger.v3.oas.annotations.Operation
5+
import io.swagger.v3.oas.annotations.responses.ApiResponse
6+
import io.swagger.v3.oas.annotations.responses.ApiResponses
7+
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean
8+
import org.springframework.http.HttpStatus
9+
import org.springframework.http.ResponseEntity
10+
import org.springframework.security.core.Authentication
11+
import org.springframework.stereotype.Controller
12+
import org.springframework.web.bind.annotation.CrossOrigin
13+
import org.springframework.web.bind.annotation.PostMapping
14+
import org.springframework.web.bind.annotation.RequestBody
15+
import org.springframework.web.bind.annotation.RequestMapping
16+
17+
@Controller
18+
@ConditionalOnBean(PushNotificationComponent::class)
19+
@RequestMapping("/api/pushnotification/")
20+
@CrossOrigin(origins = ["\${cmsch.frontend.production-url}"], allowedHeaders = ["*"])
21+
class PushNotificationApiController(
22+
private val notificationService: PushNotificationService,
23+
private val notificationComponent: PushNotificationComponent
24+
) {
25+
26+
@PostMapping("add-token")
27+
@Operation(summary = "Subscribe to notifications by submitting the messaging token")
28+
@ApiResponses(
29+
value = [
30+
ApiResponse(responseCode = "200", description = "The messaging token has been recorded successfully"),
31+
ApiResponse(responseCode = "403", description = "The user has no permission to add a messaging token")
32+
]
33+
)
34+
fun addToken(auth: Authentication?, @RequestBody request: TokenUpdateRequest): ResponseEntity<Any> {
35+
val user = auth?.getUserOrNull()
36+
if (user == null || !notificationComponent.minRole.isAvailableForRole(user.role))
37+
return ResponseEntity.status(HttpStatus.FORBIDDEN).build()
38+
39+
notificationService.addToken(user.id, request.token)
40+
return ResponseEntity.ok().build()
41+
}
42+
43+
@PostMapping("delete-token")
44+
@Operation(summary = "Unsubscribe from receiving notifications by submitting the messaging token")
45+
@ApiResponses(
46+
value = [
47+
ApiResponse(responseCode = "200", description = "The messaging token has been deleted successfully"),
48+
ApiResponse(responseCode = "403", description = "The user has no permission to add a messaging token")
49+
]
50+
)
51+
fun deleteToken(auth: Authentication?, @RequestBody request: TokenUpdateRequest): ResponseEntity<Any> {
52+
val user = auth?.getUserOrNull()
53+
if (user == null || !notificationComponent.minRole.isAvailableForRole(user.role))
54+
return ResponseEntity.status(HttpStatus.FORBIDDEN).build()
55+
56+
notificationService.deleteToken(user.id, request.token)
57+
return ResponseEntity.ok().build()
58+
}
59+
}
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
package hu.bme.sch.cmsch.component.pushnotification
2+
3+
import hu.bme.sch.cmsch.component.*
4+
import hu.bme.sch.cmsch.service.ControlPermissions
5+
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
6+
import org.springframework.core.env.Environment
7+
import org.springframework.stereotype.Service
8+
9+
@Service
10+
@ConditionalOnProperty(
11+
prefix = "hu.bme.sch.cmsch.component.load",
12+
name = ["pushnotification"],
13+
havingValue = "true",
14+
matchIfMissing = false
15+
)
16+
class PushNotificationComponent(
17+
componentSettingService: ComponentSettingService,
18+
env: Environment
19+
) : ComponentBase(
20+
"pushnotification",
21+
"/pushnotification",
22+
"Push Értesítések",
23+
ControlPermissions.PERMISSION_CONTROL_NOTIFICATIONS,
24+
listOf(),
25+
componentSettingService, env
26+
) {
27+
28+
final override val allSettings by lazy {
29+
listOf(
30+
notificationsGroup,
31+
minRole,
32+
notificationsEnabled,
33+
permissionRequestGroup,
34+
permissionPromptText,
35+
permissionAcceptText,
36+
permissionDenyText,
37+
permissionAllowNeverShowAgain
38+
)
39+
}
40+
41+
final override val menuDisplayName = null
42+
43+
final override val minRole = MinRoleSettingProxy(
44+
componentSettingService, component,
45+
"minRole", MinRoleSettingProxy.ALL_ROLES,
46+
fieldName = "Jogosultságok", description = "Melyik roleokkal nyitható meg az oldal"
47+
)
48+
49+
50+
val notificationsGroup = SettingProxy(
51+
componentSettingService, component,
52+
"notificationsGroup", "", type = SettingType.COMPONENT_GROUP, persist = false,
53+
fieldName = "Értesítés beállítások", description = ""
54+
)
55+
56+
val notificationsEnabled = SettingProxy(
57+
componentSettingService, component,
58+
"notificationsEnabled", "true", type = SettingType.BOOLEAN,
59+
fieldName = "Értesítések engedélyezése a felhasználói felületen",
60+
description = "A felhasználók csak akkor kapnak push notificationokat, ha ez az opció engedélyezve van"
61+
)
62+
63+
val permissionRequestGroup = SettingProxy(
64+
componentSettingService, component,
65+
"permissionRequestGroup", "", type = SettingType.COMPONENT_GROUP, persist = false,
66+
fieldName = "Jogosultságkérés beállítások", description = ""
67+
)
68+
69+
val permissionPromptText = SettingProxy(
70+
componentSettingService, component,
71+
"permissionPromptText", "Szeretnél értesítéseket kapni?", fieldName = "Engedélykérés szövege",
72+
description = "Ne legyen hosszú, mert csúnyán néz ki mobilokon! Ez a szöveg jelenik meg, amikor az alkalmazás engedélyt kér a felhasználótól értesítésekhez."
73+
)
74+
75+
val permissionAcceptText = SettingProxy(
76+
componentSettingService, component,
77+
"permissionAcceptText", "Igen", fieldName = "Engedély megadás gomb szöveg",
78+
description = "Ez a szöveg jelenik meg azon a gombon, amivel engedélyt tudnak adni a felhasználók"
79+
)
80+
81+
val permissionDenyText = SettingProxy(
82+
componentSettingService, component,
83+
"permissionDenyText", "Nem", fieldName = "Események tiltása gomb szöveg",
84+
description = "Ez a szöveg jelenik meg azon a gombon, amivel letiltják az értesítéseket a felhasználók (ha üres nem jelenik meg)"
85+
)
86+
87+
val permissionAllowNeverShowAgain = SettingProxy(
88+
componentSettingService, component,
89+
"permissionAllowNeverShowAgain", "true", type = SettingType.BOOLEAN,
90+
fieldName = "Tiltás megjegyzése",
91+
description = "Ha a felhasználó letiltotta az értesítéseket, akkor többet nem nem kérdez rá az alkalmazás"
92+
)
93+
94+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package hu.bme.sch.cmsch.component.pushnotification
2+
3+
import hu.bme.sch.cmsch.component.ComponentApiBase
4+
import hu.bme.sch.cmsch.component.app.MenuService
5+
import hu.bme.sch.cmsch.service.AdminMenuService
6+
import hu.bme.sch.cmsch.service.AuditLogService
7+
import hu.bme.sch.cmsch.service.ControlPermissions
8+
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean
9+
import org.springframework.stereotype.Controller
10+
import org.springframework.web.bind.annotation.RequestMapping
11+
12+
@Controller
13+
@RequestMapping("/admin/control/component/pushnotification")
14+
@ConditionalOnBean(PushNotificationComponent::class)
15+
class PushNotificationComponentController(
16+
adminMenuService: AdminMenuService,
17+
component: PushNotificationComponent,
18+
menuService: MenuService,
19+
auditLogService: AuditLogService
20+
) : ComponentApiBase(
21+
adminMenuService,
22+
PushNotificationComponent::class.java,
23+
component,
24+
ControlPermissions.PERMISSION_SEND_NOTIFICATIONS,
25+
"Push Értesítések",
26+
"Push Értesítések beállítása",
27+
menuService = menuService,
28+
auditLogService = auditLogService
29+
)

0 commit comments

Comments
 (0)