From dec925da9f9aa52cca80ed5dbccf45bed27ec679 Mon Sep 17 00:00:00 2001 From: Tom Winter Date: Mon, 23 Dec 2024 08:14:48 +0100 Subject: [PATCH 01/20] feat: add notification-api-v1 --- .../aam-backend-service/build.gradle.kts | 2 + .../controller/NotificationAdminController.kt | 64 +++++++++++++ .../controller/NotificationController.kt | 96 +++++++++++++++++++ .../notification/di/FirebaseConfiguration.kt | 56 +++++++++++ .../notification/domain/UserDevice.kt | 15 +++ .../repositiory/UserDeviceEntity.kt | 34 +++++++ .../repositiory/UserDeviceRepository.kt | 15 +++ .../src/main/resources/application.yaml | 5 +- .../api-specs/device-notification-api-v1.yaml | 77 +++++++++++++++ 9 files changed, 363 insertions(+), 1 deletion(-) create mode 100644 application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/controller/NotificationAdminController.kt create mode 100644 application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/controller/NotificationController.kt create mode 100644 application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/di/FirebaseConfiguration.kt create mode 100644 application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/domain/UserDevice.kt create mode 100644 application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/repositiory/UserDeviceEntity.kt create mode 100644 application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/repositiory/UserDeviceRepository.kt create mode 100644 docs/api-specs/device-notification-api-v1.yaml diff --git a/application/aam-backend-service/build.gradle.kts b/application/aam-backend-service/build.gradle.kts index 08f728e..b055a0c 100644 --- a/application/aam-backend-service/build.gradle.kts +++ b/application/aam-backend-service/build.gradle.kts @@ -53,6 +53,8 @@ dependencies { implementation("org.jetbrains.kotlin:kotlin-reflect") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor") // needed in some tests + implementation("com.google.firebase:firebase-admin:9.4.2") + runtimeOnly("org.postgresql:postgresql:42.7.4") annotationProcessor("org.springframework.boot:spring-boot-configuration-processor") diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/controller/NotificationAdminController.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/controller/NotificationAdminController.kt new file mode 100644 index 0000000..fad2681 --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/controller/NotificationAdminController.kt @@ -0,0 +1,64 @@ +package com.aamdigital.aambackendservice.notification.controller + +import com.aamdigital.aambackendservice.notification.repositiory.UserDeviceRepository +import com.google.firebase.messaging.FirebaseMessaging +import com.google.firebase.messaging.MulticastMessage +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty +import org.springframework.data.domain.Pageable +import org.springframework.http.ResponseEntity +import org.springframework.security.core.Authentication +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +data class TestMessageResponse( + val receiverIds: List, +) + +@RestController +@RequestMapping("/v1/notification") +@ConditionalOnProperty( + prefix = "features.notification-api", + name = ["enabled"], + havingValue = "true", + matchIfMissing = false +) +class NotificationAdminController( + private val firebaseMessaging: FirebaseMessaging, + private val userDeviceRepository: UserDeviceRepository, +) { + + @PostMapping("/message/device-test") + fun sendTestMessageToDevice( + authentication: Authentication, + ): ResponseEntity { + val userDevices = + userDeviceRepository.findByUserIdentifier(authentication.name, Pageable.unpaged()) + .map { + it.userIdentifier + }.toList() + + if (userDevices.isEmpty()) { + return ResponseEntity.ok( + TestMessageResponse( + receiverIds = emptyList() + ) + ) + } + + val message = MulticastMessage.builder() + .addAllTokens(userDevices) + .putData("body", "Hello World") + .build() + + val response = firebaseMessaging.sendEachForMulticast(message) + + val ids = response.responses.map { it.messageId }.toList() + + return ResponseEntity.ok().body( + TestMessageResponse( + receiverIds = ids + ) + ) + } +} diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/controller/NotificationController.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/controller/NotificationController.kt new file mode 100644 index 0000000..c9f1796 --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/controller/NotificationController.kt @@ -0,0 +1,96 @@ +package com.aamdigital.aambackendservice.notification.controller + +import com.aamdigital.aambackendservice.error.HttpErrorDto +import com.aamdigital.aambackendservice.notification.repositiory.UserDeviceEntity +import com.aamdigital.aambackendservice.notification.repositiory.UserDeviceRepository +import org.slf4j.LoggerFactory +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.security.core.Authentication +import org.springframework.transaction.annotation.Transactional +import org.springframework.validation.annotation.Validated +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController +import java.io.IOException +import kotlin.jvm.optionals.getOrNull + + +data class DeviceRegistrationDto( + val deviceName: String? = null, + val deviceToken: String, +) + + +@RestController +@RequestMapping("/v1/notification") +@ConditionalOnProperty( + prefix = "features.notification-api", + name = ["enabled"], + havingValue = "true", + matchIfMissing = false +) +@Transactional +class NotificationController( + private val userDeviceRepository: UserDeviceRepository, +) { + private val logger = LoggerFactory.getLogger(javaClass) + + @PostMapping("/device") + @Validated + fun registerDevice( + @RequestBody deviceRegistrationDto: DeviceRegistrationDto, + authentication: Authentication, + ): ResponseEntity { + + if (userDeviceRepository.existsByDeviceToken(deviceRegistrationDto.deviceToken)) { + return ResponseEntity.badRequest().body( + HttpErrorDto( + errorCode = "Bad Request", + errorMessage = "The device is already registered." + ) + ) + } + + userDeviceRepository.save( + UserDeviceEntity( + userIdentifier = authentication.name, + deviceToken = deviceRegistrationDto.deviceToken, + deviceName = deviceRegistrationDto.deviceName, + ) + ) + + + return ResponseEntity.noContent().build() + } + + @DeleteMapping("/device/{id}") + fun unregisterDevice( + @PathVariable id: String, + authentication: Authentication, + ): ResponseEntity { + val userDevice = + userDeviceRepository.findByDeviceToken(id).getOrNull() ?: return ResponseEntity.notFound().build() + + if (userDevice.userIdentifier != authentication.name) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).body( + HttpErrorDto( + errorCode = "Forbidden", + errorMessage = "Token does not belong to User", + ) + ) + } + + try { + userDeviceRepository.deleteByDeviceToken(id) + } catch (ex: IOException) { + logger.warn("[NotificationController.unregisterDevice()] error: {}", ex.message) + } + + return ResponseEntity.noContent().build() + } +} diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/di/FirebaseConfiguration.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/di/FirebaseConfiguration.kt new file mode 100644 index 0000000..2977a42 --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/di/FirebaseConfiguration.kt @@ -0,0 +1,56 @@ +package com.aamdigital.aambackendservice.notification.di + +import com.google.auth.oauth2.GoogleCredentials +import com.google.firebase.FirebaseApp +import com.google.firebase.FirebaseOptions +import com.google.firebase.messaging.FirebaseMessaging +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty +import org.springframework.boot.context.properties.ConfigurationProperties +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import java.util.* + + +@ConfigurationProperties("notification-firebase-configuration") +class NotificationFirebaseClientConfiguration( + val credentialFileBase64: String, +) + +@Configuration +@ConditionalOnProperty( + prefix = "features.notification-api", + name = ["enabled"], + havingValue = "true", + matchIfMissing = false +) +class FirebaseConfiguration { + + @Bean + @ConditionalOnProperty( + prefix = "features.notification-api", + name = ["mode"], + havingValue = "firebase", + matchIfMissing = false + ) + fun firebaseApp(notificationFirebaseClientConfiguration: NotificationFirebaseClientConfiguration): FirebaseApp { + val credentialFile = + Base64.getDecoder().decode(notificationFirebaseClientConfiguration.credentialFileBase64) + + val options = FirebaseOptions.builder() + .setCredentials(GoogleCredentials.fromStream(credentialFile.inputStream())) + .build() + + return FirebaseApp.initializeApp(options) + } + + @Bean + @ConditionalOnProperty( + prefix = "features.notification-api", + name = ["mode"], + havingValue = "firebase", + matchIfMissing = false + ) + fun firebaseMessaging(firebaseApp: FirebaseApp): FirebaseMessaging { + return FirebaseMessaging.getInstance(firebaseApp) + } +} diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/domain/UserDevice.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/domain/UserDevice.kt new file mode 100644 index 0000000..ff3bf4b --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/domain/UserDevice.kt @@ -0,0 +1,15 @@ +package com.aamdigital.aambackendservice.notification.domain + +import com.aamdigital.aambackendservice.domain.DomainReference +import java.time.Instant + +/** + * Representation of a user device + */ +data class UserDevice( + var id: String, + var deviceName: String?, + var deviceToken: String, + var user: DomainReference, + var createdAt: Instant, +) diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/repositiory/UserDeviceEntity.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/repositiory/UserDeviceEntity.kt new file mode 100644 index 0000000..9b512a1 --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/repositiory/UserDeviceEntity.kt @@ -0,0 +1,34 @@ +package com.aamdigital.aambackendservice.notification.repositiory + +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import org.hibernate.annotations.CreationTimestamp +import org.hibernate.annotations.SourceType +import java.time.OffsetDateTime + +@Entity +data class UserDeviceEntity( + @Id + @GeneratedValue(strategy = GenerationType.SEQUENCE) + val id: Long = 0, + + @Column + var deviceName: String?, + + @Column( + unique = true, + nullable = false, + updatable = false, + ) + var deviceToken: String, + + @Column + var userIdentifier: String, + + @CreationTimestamp(source = SourceType.DB) + @Column + var createdAt: OffsetDateTime? = null, +) diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/repositiory/UserDeviceRepository.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/repositiory/UserDeviceRepository.kt new file mode 100644 index 0000000..1ad7ae5 --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/repositiory/UserDeviceRepository.kt @@ -0,0 +1,15 @@ +package com.aamdigital.aambackendservice.notification.repositiory + +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable +import org.springframework.data.repository.CrudRepository +import org.springframework.data.repository.PagingAndSortingRepository +import java.util.* + +interface UserDeviceRepository : PagingAndSortingRepository, + CrudRepository { + fun findByUserIdentifier(userIdentifier: String, pageable: Pageable): Page + fun findByDeviceToken(deviceToken: String): Optional + fun existsByDeviceToken(deviceToken: String): Boolean + fun deleteByDeviceToken(deviceToken: String) +} diff --git a/application/aam-backend-service/src/main/resources/application.yaml b/application/aam-backend-service/src/main/resources/application.yaml index 35b60ad..9670541 100644 --- a/application/aam-backend-service/src/main/resources/application.yaml +++ b/application/aam-backend-service/src/main/resources/application.yaml @@ -135,7 +135,10 @@ features: export-api: enabled: false skill-api: - mode: skilllab + mode: disabled + notification-api: + enabled: false + mode: firebase crypto-configuration: secret: super-duper-secret diff --git a/docs/api-specs/device-notification-api-v1.yaml b/docs/api-specs/device-notification-api-v1.yaml new file mode 100644 index 0000000..511b7e4 --- /dev/null +++ b/docs/api-specs/device-notification-api-v1.yaml @@ -0,0 +1,77 @@ +openapi: 3.0.3 +info: + title: Notification API + description: Provide (push) notification functionality to users and devices. + version: 1.0.0 +servers: + - url: /v1/notification + +paths: + /device: + post: + summary: Register a new device to receive notifications. + tags: + - device + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/DeviceRegistration' + responses: + 204: + description: Device registration successful + 400: + description: The device is already registered + + /device/{deviceId}: + delete: + summary: Delete an existing device registration. + parameters: + - in: path + name: deviceId + schema: + type: string + required: true + tags: + - device + responses: + 403: + description: Device does not belong to user + 404: + description: Device registration does not exist + 204: + description: Device registration was removed + + /message/device-test: + delete: + summary: Send a hello-world notification to all registered devices of the current user + tags: + - message + responses: + '200': + description: Successful response + content: + application/json: + schema: + $ref: '#/components/schemas/TestMessageResponse' + +components: + schemas: + DeviceRegistration: + type: object + properties: + deviceName: + type: string + required: false + deviceToken: + type: string + required: true + + TestMessageResponse: + type: object + properties: + receiverIds: + type: array + items: + type: string From 48d9f7c45ee7566760a2e33a89403eb27d216017 Mon Sep 17 00:00:00 2001 From: Tom Winter Date: Mon, 23 Dec 2024 08:36:44 +0100 Subject: [PATCH 02/20] test: add missing e2e test config --- .../src/test/resources/application-e2e.yaml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/application/aam-backend-service/src/test/resources/application-e2e.yaml b/application/aam-backend-service/src/test/resources/application-e2e.yaml index ba81635..264a136 100644 --- a/application/aam-backend-service/src/test/resources/application-e2e.yaml +++ b/application/aam-backend-service/src/test/resources/application-e2e.yaml @@ -71,11 +71,17 @@ skilllab-api-client-configuration: base-path: http://localhost:9005/skilllab # todo test container response-timeout-in-seconds: 15 +notification-firebase-configuration: + credential-file-base64: "" + features: export-api: enabled: true skill-api: mode: disabled + notification-api: + enabled: false + mode: firebase events: listener: From 506e1f4ea518fdfbf7838cc8f8cd6647e40e030d Mon Sep 17 00:00:00 2001 From: Tom Winter Date: Thu, 2 Jan 2025 10:12:09 +0100 Subject: [PATCH 03/20] docs: add docker setup for locally start notification-api --- README.md | 6 +-- .../src/main/resources/application.yaml | 2 - .../aam-backend-service-notification-api/.env | 1 + .../.gitignore | 2 + .../README.md | 17 +++++++++ .../application.env | 21 ++++++++++ .../docker-compose.yml | 38 +++++++++++++++++++ .../secrets.env.example | 1 + 8 files changed, 82 insertions(+), 6 deletions(-) create mode 100644 docs/developer/aam-backend-service-notification-api/.env create mode 100644 docs/developer/aam-backend-service-notification-api/.gitignore create mode 100644 docs/developer/aam-backend-service-notification-api/README.md create mode 100644 docs/developer/aam-backend-service-notification-api/application.env create mode 100644 docs/developer/aam-backend-service-notification-api/docker-compose.yml create mode 100644 docs/developer/aam-backend-service-notification-api/secrets.env.example diff --git a/README.md b/README.md index b936423..5e51e6a 100644 --- a/README.md +++ b/README.md @@ -10,10 +10,8 @@ A modularize Spring Boot application that contains API modules for [Aam Digital' 1. Create additional databases in CouchDB: `report-calculation` and `notification-webhook` (used by the Reporting Module to store details) 2. Set up necessary environment variables (e.g. using an `application.env` file for docker compose): - -- see [example .env](./docs/examples/application.env) -- CRYPTO_CONFIGURATION_SECRET: _a random secret used to encrypt data_ - + - see [example .env](./docs/examples/application.env) + - CRYPTO_CONFIGURATION_SECRET: _a random secret used to encrypt data_ 3. See ndb-setup for instructions to enable the backend in an overall system: [ndb-setup README](https://github.com/Aam-Digital/ndb-setup?tab=readme-ov-file#api-integrations-and-sql-reports) ## API Modules diff --git a/application/aam-backend-service/src/main/resources/application.yaml b/application/aam-backend-service/src/main/resources/application.yaml index 9670541..03f6b30 100644 --- a/application/aam-backend-service/src/main/resources/application.yaml +++ b/application/aam-backend-service/src/main/resources/application.yaml @@ -13,8 +13,6 @@ spring: prefetch: 1 datasource: driver-class-name: org.postgresql.Driver - username: admin - password: docker jpa: generate-ddl: true hibernate: diff --git a/docs/developer/aam-backend-service-notification-api/.env b/docs/developer/aam-backend-service-notification-api/.env new file mode 100644 index 0000000..3705e18 --- /dev/null +++ b/docs/developer/aam-backend-service-notification-api/.env @@ -0,0 +1 @@ +REPLICATION_BACKEND_PUBLIC_KEY=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAyi1JqD5O920nFtmiYZjrVs9hn1fq5p8XI0UItNrQupDJ2J0Gw/EOB98o9rQaHfqMtfdPiyVtQuurIxlKWCBTZvk8wE4hwlsdYwS7ZKyGRUqKWadOwVrGGE14EK9Rpo/lNkoBrd2s6ZlIY7B7BOdulLYFBjTY3KHaHLF4DXHUT+RAkOOpxFmd37QM9NugRPIgxuRxZEC3WYTOwzbzqF7948DOLWKZvZO8+hz6tUI0esp5wMQxa+5kQZCopdKGZvqyDYsgK3eyDfnjXg094q5Ucr3bOJu/Rbo24y/msXYfty+8dnU9Pqmk0xHFXPRE9cOy7XJtTWRAAY4nDJFKaNCDgwIDAQAB diff --git a/docs/developer/aam-backend-service-notification-api/.gitignore b/docs/developer/aam-backend-service-notification-api/.gitignore new file mode 100644 index 0000000..bb8a3c2 --- /dev/null +++ b/docs/developer/aam-backend-service-notification-api/.gitignore @@ -0,0 +1,2 @@ + +secrets.env diff --git a/docs/developer/aam-backend-service-notification-api/README.md b/docs/developer/aam-backend-service-notification-api/README.md new file mode 100644 index 0000000..07395dc --- /dev/null +++ b/docs/developer/aam-backend-service-notification-api/README.md @@ -0,0 +1,17 @@ +## aam-services with notification backend + +Run this docker-compose file to start the notification backend service locally + +### Setup + +1. Download the `firebase-credentials.json` from the firebase interface. +2. Encode it as base64 + - `base64 -i firebase-credentials.json` will print the encoded file to the console +3. Copy the output +4. Create a new file, based on the `secrets.env.example` + - `cp secrets.env.example secrets.env` +5. Edit `secrets.env` with an editor of your choice and replace the placeholder with the base-64 output you just copied + - ``` + NOTIFICATIONFIREBASECONFIGURATION_CREDENTIALFILEBASE64= + ``` +6. Run `docker compose up -d` diff --git a/docs/developer/aam-backend-service-notification-api/application.env b/docs/developer/aam-backend-service-notification-api/application.env new file mode 100644 index 0000000..3ab1c89 --- /dev/null +++ b/docs/developer/aam-backend-service-notification-api/application.env @@ -0,0 +1,21 @@ +CRYPTO_CONFIGURATION_SECRET=super-duper-secret +SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_ISSUERURI=https://keycloak.aam-digital.net/realms/dev +SPRING_RABBITMQ_VIRTUALHOST=/ +SPRING_RABBITMQ_HOST=rabbitmq +SPRING_RABBITMQ_LISTENER_DIRECT_RETRY_ENABLED=true +SPRING_RABBITMQ_LISTENER_DIRECT_RETRY_MAXATTEMPTS=5 +SPRING_DATASOURCE_URL=jdbc:postgresql://aam-backend-service-db:5432/aam-backend-service +SPRING_DATASOURCE_USERNAME=admin +SPRING_DATASOURCE_PASSWORD=docker +FEATURES_EXPORTAPI_ENABLED=fakse +FEATURES_SKILLAPI_MODE=disabled +FEATURES_NOTIFICATIONAPI_ENABLED=true +FEATURES_NOTIFICATIONAPI_MODE=firebase +COUCHDBCLIENTCONFIGURATION_BASEPATH=http://couchdb:5984 +COUCHDBCLIENTCONFIGURATION_BASICAUTHUSERNAME=admin +COUCHDBCLIENTCONFIGURATION_BASICAUTHPASSWORD=docker +SQSCLIENTCONFIGURATION_BASEPATH=http://sqs:4984 +SQSCLIENTCONFIGURATION_BASICAUTHUSERNAME=admin +SQSCLIENTCONFIGURATION_BASICAUTHPASSWORD=docker +DATABASECHANGEDETECTION_ENABLED=false +EVENTS_LISTENER_REPORTCALCULATION_ENABLED=false diff --git a/docs/developer/aam-backend-service-notification-api/docker-compose.yml b/docs/developer/aam-backend-service-notification-api/docker-compose.yml new file mode 100644 index 0000000..2568376 --- /dev/null +++ b/docs/developer/aam-backend-service-notification-api/docker-compose.yml @@ -0,0 +1,38 @@ +name: aam-backend-service-notification-api +services: + couchdb: + image: couchdb:3 + container_name: database + ports: + - "5984:5984" + environment: + COUCHDB_USER: admin + COUCHDB_PASSWORD: docker + restart: unless-stopped + + aam-backend-service: + image: ghcr.io/aam-digital/aam-backend-service:pr-58 + container_name: aam-backend-service + ports: + - "8080:8080" + depends_on: + - aam-backend-service-db + - rabbitmq + env_file: + - ./application.env + - ./secrets.env + restart: unless-stopped + + aam-backend-service-db: + image: postgres:16.6-bookworm + container_name: aam-backend-service-db + environment: + POSTGRES_DB: aam-backend-service + POSTGRES_USER: admin + POSTGRES_PASSWORD: docker + restart: unless-stopped + + rabbitmq: + image: rabbitmq:3-management-alpine + container_name: rabbitmq + restart: unless-stopped diff --git a/docs/developer/aam-backend-service-notification-api/secrets.env.example b/docs/developer/aam-backend-service-notification-api/secrets.env.example new file mode 100644 index 0000000..3c03935 --- /dev/null +++ b/docs/developer/aam-backend-service-notification-api/secrets.env.example @@ -0,0 +1 @@ +NOTIFICATIONFIREBASECONFIGURATION_CREDENTIALFILEBASE64= From cdb49d921a5323b048142aba7831addc8814f071 Mon Sep 17 00:00:00 2001 From: Tom Winter Date: Fri, 3 Jan 2025 09:29:34 +0100 Subject: [PATCH 04/20] docs: fix api doc --- docs/api-specs/device-notification-api-v1.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/api-specs/device-notification-api-v1.yaml b/docs/api-specs/device-notification-api-v1.yaml index 511b7e4..4f29474 100644 --- a/docs/api-specs/device-notification-api-v1.yaml +++ b/docs/api-specs/device-notification-api-v1.yaml @@ -44,7 +44,7 @@ paths: description: Device registration was removed /message/device-test: - delete: + post: summary: Send a hello-world notification to all registered devices of the current user tags: - message From 2574f61d1720b7b5c4841ae4dc8719f425026f6a Mon Sep 17 00:00:00 2001 From: Tom Winter Date: Fri, 3 Jan 2025 09:43:33 +0100 Subject: [PATCH 05/20] docs: fix api doc --- .../aam-backend-service-notification-api/application.env | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/developer/aam-backend-service-notification-api/application.env b/docs/developer/aam-backend-service-notification-api/application.env index 3ab1c89..ee145fb 100644 --- a/docs/developer/aam-backend-service-notification-api/application.env +++ b/docs/developer/aam-backend-service-notification-api/application.env @@ -7,7 +7,7 @@ SPRING_RABBITMQ_LISTENER_DIRECT_RETRY_MAXATTEMPTS=5 SPRING_DATASOURCE_URL=jdbc:postgresql://aam-backend-service-db:5432/aam-backend-service SPRING_DATASOURCE_USERNAME=admin SPRING_DATASOURCE_PASSWORD=docker -FEATURES_EXPORTAPI_ENABLED=fakse +FEATURES_EXPORTAPI_ENABLED=false FEATURES_SKILLAPI_MODE=disabled FEATURES_NOTIFICATIONAPI_ENABLED=true FEATURES_NOTIFICATIONAPI_MODE=firebase From 12636830227a8082a24cd1d52f47cceb484989ed Mon Sep 17 00:00:00 2001 From: Tom Winter Date: Fri, 3 Jan 2025 13:33:25 +0100 Subject: [PATCH 06/20] feat: reverse proxy --- .../aam-backend-service-notification-api/.env | 2 +- .../Caddyfile | 12 ++ .../application.env | 4 +- .../docker-compose.yml | 117 ++++++++++++++++-- 4 files changed, 121 insertions(+), 14 deletions(-) create mode 100644 docs/developer/aam-backend-service-notification-api/Caddyfile diff --git a/docs/developer/aam-backend-service-notification-api/.env b/docs/developer/aam-backend-service-notification-api/.env index 3705e18..79566d5 100644 --- a/docs/developer/aam-backend-service-notification-api/.env +++ b/docs/developer/aam-backend-service-notification-api/.env @@ -1 +1 @@ -REPLICATION_BACKEND_PUBLIC_KEY=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAyi1JqD5O920nFtmiYZjrVs9hn1fq5p8XI0UItNrQupDJ2J0Gw/EOB98o9rQaHfqMtfdPiyVtQuurIxlKWCBTZvk8wE4hwlsdYwS7ZKyGRUqKWadOwVrGGE14EK9Rpo/lNkoBrd2s6ZlIY7B7BOdulLYFBjTY3KHaHLF4DXHUT+RAkOOpxFmd37QM9NugRPIgxuRxZEC3WYTOwzbzqF7948DOLWKZvZO8+hz6tUI0esp5wMQxa+5kQZCopdKGZvqyDYsgK3eyDfnjXg094q5Ucr3bOJu/Rbo24y/msXYfty+8dnU9Pqmk0xHFXPRE9cOy7XJtTWRAAY4nDJFKaNCDgwIDAQAB +REPLICATION_BACKEND_PUBLIC_KEY=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAo4pJNuTGMJAKWUMzzD9QD7lLwsVzxE1QlimQ/wnyjuBgUdzTrSB/svdj+Q/kTGKzExVcTaZKYlS4U7CG4DYChpIwVKscOdMV7+RMSglh63kxYXFqx1+nj2qtb33Jd0Xtt5DyS4cznhup+fmMazSxk00ZMLPIGpetlcIF5H7viEPGnHeF0QLswKL6RSVlGbeEDVr7XrsWLydmHty3fweg5vAneHuT8lhGnMvc+buFxP5VaZerclLGlKGiYfYPokjDyK//qdmm4Hf2Nx9orEyzDvtCxl6VZg040SgciZIyg5SOWo/KH+P1cWAftAEwO5Fpwa75VxRGunBUvmn2terZywIDAQAB diff --git a/docs/developer/aam-backend-service-notification-api/Caddyfile b/docs/developer/aam-backend-service-notification-api/Caddyfile new file mode 100644 index 0000000..9ebecad --- /dev/null +++ b/docs/developer/aam-backend-service-notification-api/Caddyfile @@ -0,0 +1,12 @@ +:80 { + handle_path /auth* { + reverse_proxy keycloak:8080 { + header_up Host {host} + header_up X-Forwarded-Proto {scheme} + } + } + + handle { + respond "Hello. This is Caddy." 200 + } +} diff --git a/docs/developer/aam-backend-service-notification-api/application.env b/docs/developer/aam-backend-service-notification-api/application.env index ee145fb..6ea588e 100644 --- a/docs/developer/aam-backend-service-notification-api/application.env +++ b/docs/developer/aam-backend-service-notification-api/application.env @@ -1,5 +1,5 @@ CRYPTO_CONFIGURATION_SECRET=super-duper-secret -SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_ISSUERURI=https://keycloak.aam-digital.net/realms/dev +SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_ISSUERURI=http://localhost/auth/realms/dummy-realm SPRING_RABBITMQ_VIRTUALHOST=/ SPRING_RABBITMQ_HOST=rabbitmq SPRING_RABBITMQ_LISTENER_DIRECT_RETRY_ENABLED=true @@ -11,7 +11,7 @@ FEATURES_EXPORTAPI_ENABLED=false FEATURES_SKILLAPI_MODE=disabled FEATURES_NOTIFICATIONAPI_ENABLED=true FEATURES_NOTIFICATIONAPI_MODE=firebase -COUCHDBCLIENTCONFIGURATION_BASEPATH=http://couchdb:5984 +COUCHDBCLIENTCONFIGURATION_BASEPATH=http://db-couch:5984 COUCHDBCLIENTCONFIGURATION_BASICAUTHUSERNAME=admin COUCHDBCLIENTCONFIGURATION_BASICAUTHPASSWORD=docker SQSCLIENTCONFIGURATION_BASEPATH=http://sqs:4984 diff --git a/docs/developer/aam-backend-service-notification-api/docker-compose.yml b/docs/developer/aam-backend-service-notification-api/docker-compose.yml index 2568376..fa0ddf7 100644 --- a/docs/developer/aam-backend-service-notification-api/docker-compose.yml +++ b/docs/developer/aam-backend-service-notification-api/docker-compose.yml @@ -1,20 +1,120 @@ name: aam-backend-service-notification-api services: - couchdb: - image: couchdb:3 - container_name: database + reverse-proxy: + image: caddy:2.9-alpine + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile ports: - - "5984:5984" + - "80:80" + - "443:443" + - "2019:2019" + + # maildev: + # image: maildev/maildev + # ports: + # - "1025:1025" + # - "1080:1080" + + db-couch: + image: couchdb:3.3 + volumes: + - ~/docker-volumes/aam-digital/aam-services/db-couch/document-data:/opt/couchdb/data + - ~/docker-volumes/aam-digital/aam-services/db-couch/document-etc-locald:/opt/couchdb/etc/local.d + - ~/docker-volumes/aam-digital/aam-services/db-couch/document-log:/opt/couchdb/log environment: COUCHDB_USER: admin COUCHDB_PASSWORD: docker - restart: unless-stopped + COUCHDB_SECRET: docker + ports: + - "5984:5984" + + db-keycloak: + image: postgres:16 + volumes: + - ~/docker-volumes/aam-digital/aam-services/db-keycloak/postgresql-data:/var/lib/postgresql/data + environment: + POSTGRES_DB: postgres + POSTGRES_USER: postgres + POSTGRES_PASSWORD: keycloak + ports: + - "5401:5432" + + db-backend: + image: postgres:16.5-bookworm + volumes: + - ~/docker-volumes/aam-digital/aam-services/db-backend/postgresql-data:/var/lib/postgresql/data + environment: + POSTGRES_DB: aam-backend-service + POSTGRES_USER: admin + POSTGRES_PASSWORD: docker + ports: + - "5402:5432" + + rabbitmq: + image: rabbitmq:3-management-alpine + volumes: + - ~/docker-volumes/aam-digital/aam-services/rabbitmq/data:/var/lib/rabbitmq/ + - ~/docker-volumes/aam-digital/aam-services/rabbitmq/log:/var/log/rabbitmq + ports: + - "5672:5672" + - "15672:15672" + + # sqs: + # image: ghcr.io/aam-digital/aam-sqs-mac:latest + # platform: linux/amd64 + # depends_on: + # - db-couch + # ports: + # - "4984:4984" + # volumes: + # - ~/docker-volumes/aam-digital/aam-services/sqs/data:/data + # environment: + # SQS_COUCHDB_URL: http://db-couch:5984 + + keycloak: + image: quay.io/keycloak/keycloak:26.0.7-0 + command: "start --proxy-headers forwarded" + volumes: + - ~/docker-volumes/aam-digital/aam-services/keycloak/data:/opt/keycloak/data + environment: + KC_HTTP_ENABLED: true + KC_HOSTNAME: http://localhost/auth + KC_FRONTEND_URL: http://localhost/auth + KC_DB: postgres + KC_DB_URL: jdbc:postgresql://db-keycloak:5432/postgres + KC_DB_SCHEMA: public + KC_DB_USERNAME: postgres + KC_DB_PASSWORD: keycloak + KEYCLOAK_ADMIN: admin + KEYCLOAK_ADMIN_PASSWORD: docker + ports: + - "8080:8080" + depends_on: + - db-keycloak + + # carbone-io: + # image: carbone/carbone-ee + # platform: linux/amd64 + # volumes: + # - ~/docker-volumes/aam-digital/aam-services/carbone-io/template:/app/template + # - ~/docker-volumes/aam-digital/aam-services/carbone-io/render:/app/render + # ports: + # - "4000:4000" + + # jaeger: + # image: jaegertracing/all-in-one:latest + # ports: + # - "16686:16686" # the jaeger UI + # - "4317:4317" # the OpenTelemetry collector grpc + # - "4318:4318" # the OpenTelemetry collector http + # environment: + # - COLLECTOR_OTLP_ENABLED=true aam-backend-service: image: ghcr.io/aam-digital/aam-backend-service:pr-58 container_name: aam-backend-service ports: - - "8080:8080" + - "8081:8080" depends_on: - aam-backend-service-db - rabbitmq @@ -31,8 +131,3 @@ services: POSTGRES_USER: admin POSTGRES_PASSWORD: docker restart: unless-stopped - - rabbitmq: - image: rabbitmq:3-management-alpine - container_name: rabbitmq - restart: unless-stopped From 5d546476a5a2ee376fbd602daf9909b21bca40cd Mon Sep 17 00:00:00 2001 From: Tom Winter Date: Fri, 3 Jan 2025 15:26:04 +0100 Subject: [PATCH 07/20] feat: reverse proxy --- .../aam-backend-service-notification-api/docker-compose.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/developer/aam-backend-service-notification-api/docker-compose.yml b/docs/developer/aam-backend-service-notification-api/docker-compose.yml index fa0ddf7..fc39f77 100644 --- a/docs/developer/aam-backend-service-notification-api/docker-compose.yml +++ b/docs/developer/aam-backend-service-notification-api/docker-compose.yml @@ -76,6 +76,8 @@ services: command: "start --proxy-headers forwarded" volumes: - ~/docker-volumes/aam-digital/aam-services/keycloak/data:/opt/keycloak/data + - ~/docker-volumes/aam-digital/aam-services/keycloak/themes:/opt/keycloak/themes + - environment: KC_HTTP_ENABLED: true KC_HOSTNAME: http://localhost/auth From 3273a8ad193882c79154074a9c235de50f7d7c78 Mon Sep 17 00:00:00 2001 From: Tom Winter Date: Sat, 4 Jan 2025 13:31:23 +0100 Subject: [PATCH 08/20] feat: reverse proxy --- .../aam-backend-service-notification-api/docker-compose.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/developer/aam-backend-service-notification-api/docker-compose.yml b/docs/developer/aam-backend-service-notification-api/docker-compose.yml index fc39f77..c3cd25e 100644 --- a/docs/developer/aam-backend-service-notification-api/docker-compose.yml +++ b/docs/developer/aam-backend-service-notification-api/docker-compose.yml @@ -77,7 +77,6 @@ services: volumes: - ~/docker-volumes/aam-digital/aam-services/keycloak/data:/opt/keycloak/data - ~/docker-volumes/aam-digital/aam-services/keycloak/themes:/opt/keycloak/themes - - environment: KC_HTTP_ENABLED: true KC_HOSTNAME: http://localhost/auth From 09239c79ecf1c490e46b829459f65e4118ace6b1 Mon Sep 17 00:00:00 2001 From: Tom Winter Date: Sun, 5 Jan 2025 22:08:25 +0100 Subject: [PATCH 09/20] docs: add development guide for local setup --- docs/assets/keychain-access-1.png | Bin 0 -> 91394 bytes docs/assets/keychain-access-2.png | Bin 0 -> 75133 bytes docs/developer/.env.example | 3 + docs/developer/.gitignore | 3 + docs/developer/Caddyfile | 47 + docs/developer/README.md | 293 +++- .../aam-backend-service-notification-api/.env | 1 - .../.gitignore | 2 - .../Caddyfile | 12 - .../README.md | 17 - .../docker-compose.yml | 134 -- .../application.env | 3 +- docs/developer/docker-compose.yml | 203 +-- docs/developer/example-data/client_app.json | 80 ++ .../realm_config.dummy-realm.json | 1199 +++++++++++++++++ .../secrets.env.example | 0 16 files changed, 1698 insertions(+), 299 deletions(-) create mode 100644 docs/assets/keychain-access-1.png create mode 100644 docs/assets/keychain-access-2.png create mode 100644 docs/developer/.env.example create mode 100644 docs/developer/.gitignore create mode 100644 docs/developer/Caddyfile delete mode 100644 docs/developer/aam-backend-service-notification-api/.env delete mode 100644 docs/developer/aam-backend-service-notification-api/.gitignore delete mode 100644 docs/developer/aam-backend-service-notification-api/Caddyfile delete mode 100644 docs/developer/aam-backend-service-notification-api/README.md delete mode 100644 docs/developer/aam-backend-service-notification-api/docker-compose.yml rename docs/developer/{aam-backend-service-notification-api => }/application.env (87%) create mode 100644 docs/developer/example-data/client_app.json create mode 100644 docs/developer/example-data/realm_config.dummy-realm.json rename docs/developer/{aam-backend-service-notification-api => }/secrets.env.example (100%) diff --git a/docs/assets/keychain-access-1.png b/docs/assets/keychain-access-1.png new file mode 100644 index 0000000000000000000000000000000000000000..36b1f770e1498f86ba83f6ba2e92fd95f4a3dbea GIT binary patch literal 91394 zcmZsj1yogA_qS0}x~0332I=lD>F(}4G}1_ebV&-*-AH$*ba#g|d>ik5t+A;VtLZJ zesd24Np}y!B={roIDl~v4x*MiCfk^6O5JiI=;0)~3v`N4KmP4N2wep%zlt2K64J)o zNcoai$Uj^cg0UF4AhdgPsAkQxPVG3#cJ?^$LA7kLjmJyQ0?F{ENGU_&>AGG;p27+z zo$$y(SOQL=xBr}1x}im$AO>EP&z1A^JY~otnxT#7M;O&zFdieUN3b?I^c0;(Taa`Q zer{}s?CDtITwJ>Mk)Q}lFCJ>>x$$S;mi1sa+_%s%^FjoW>f^}YNEAd8TSA%PWr!sZ z4N`dzPi}8cQWqv*8GSp;6H_%Eu`0;N39T(jA$z^wl-Ycfelp9Il4WyZA&!1(nY!NL zC>QoSh5atw2jX{0zvmnWjWvIj*Y`CT6lqE22AWybL|xKURu+r~_zVN~Cd2{^0{HX> zc;Nsqphx0@!JvV6RNy6&{q|p1!L72v|MmF|_3Mp-%EFS8z`L@Mqlt;FlewKUe=Kzs zkkzckCv|6aSs5-PI~#ffV>?3=dUqT9*Cb#(?p(k}8xv;(B6k~WTPH4eUXnj|Z~>oR ze`X*d`g4o36)%aptOAj+oudiSM|wtjMiM?)A|fIlM`KeiB@wZ|lLOy)Nz9#{?YS5j z+}zyg-B{@D9L*S*I5{~P7?~NEndyK#=$t^d&IayuwoasfW%93lL`TJ*SDg#eYh&b^5znKm{3IuP`vtGcx=;Zy+hp>#tl27Vak2 z8X^`pK=S})@G*0;^ZdF0f3Eze#Q#dE{-2Z_>}>y?^1rV9Go^}?iKDQc4N#;r-+y}M z@5KLo@$ZB@46il+ua@|$ng9F>v@;(p55vFvj1PAG>5U8+m;jihh~OvpH%A$e=_)F7 zy>r7cljKAQZ(%?C1w}%J!-ngq6J>=izqx4pNaL#Cq7OYE2}y&gPb3wJNbQ0a=c9FUtL-w^`dkahsYtKAtwg&~Nwq|sI3*>; z&=3X!C7H{m*mM zvWtajmTJ}-55$pISL6aPPo zaSQ+pjI^-WpZTGdbamyd(dGrRoz&9)KkkJ07ZMT}Scu|2p<-lrqEM2c(yIGMtzX+< z3-!&dh6hk#2xSSh|1+D700EC*RVI+pF%J~vj2Yz{rN^!29xm3uqUhpgF+BaH*hJ@Xp25H^Z)1&15$+#{hGF6$_4?bv@iDf zku5z!1Ebjg>ahV7D!sOr1Uju=Q0^%5Q>LK=8k@VM({2@ch(jc|+>o5xuC~Yq_I}y{ybPub@6U?IaEuL$kycVVUyqtajUCZrmgYOdVVcEtyFm^ zbM2-kuYT;n?sa4dV7*!K{1C5NAcI|`lpkJZlaY3_@{!c4vBsz#VkKU8Hcr@s?B?T*e&%yimHE;qU4i+;h>G!25qFdj)|tFm7g zx_i1=HQ65g8l>YD^~f?g^c`CvD&FJ%U+dwLV%Vld^80l+oBraTT zgLL8tMF!*RgPHg`o5exm5ImB2O8LY}qrSl=mqXZUnu(UF@TEjb&6?vsJ*Z%foUNCY zww?JUumn`4Xstl=4da|6QDjmq1WKM`{s3z3y2~`_w|yBEMf%O&M3;+hYJdHz4=Eaj zO#f8Ic%ze9<3aI{?ZPadI4!$Ya(NI0nm+=683R7&>pY@*hM6wWP#$=X)*EnIV?P2a zr_*cG4%Mr*RPjcl*J)zKD;0>=sfkDetD9GBuz;IHgOf{JES6z-!s9391dtH7;|4oL zuCF>nd88I4Zna~4nzSy3Y%#uqM)8S?;@~OzK9yZ^7By{edE&NSY2wlF(hP%J{w{;7 zB$*9E%h~6{`AF}TS&?1N^U1DhHIQr6sOqp{UHPB;kZ6GKz}$s;DZuhLCmVxWn^3f znjD9q9M8E3==p9pgZ8O*AT_Sse)lu2fkzvpY$|IkzFW!RhA5tEei0YF*3MW~`Kefc z<=^w}oxc#1)OY2w2%cYMMcT6^Wi>_GiZoo2gNRWrwWUgm^fsFEnbxZ^q4C8>>U26{ z+KM`5M=Fx8R!U+tsuJA)}*J)7KxS{yF*SNADU|HwiEAV$j#5W;dG;CQ}(6AHuQ~aTm&q zYb<|$ajlLlwA@~L>y)-AIq&^cEJ^c-6sZ~TT$}Gj%0BjUd(Y9~#fx`owp2;4BM2p= zfm~1u)dF0e&u~u9x0SK*?d7*c+h!I1VZjm&R%Eo~M+sp5&vY{@5)2Fq;!1c2Fnj*C z#%49oZn;1B(J?jQ%>`BREjAp_b>4t82j9bdbW@)Q6z9fs{)Mg=ZkOj+l5EXi(v(LM zlYk*3gB?PO^sQhH)c$v!X4ke=t1$Z|mpPrUL|zvIl$Ly05nl=--R7!HhCUanu-pO* z-H>hbaY1T5oBYR7HVcr z#g=4*-mEPbDN|a^m1U%QPl|ffXJ&q-2(0)zIbg}CHeivqQ<9V#sfl|v%e@r3%o5*Kt^TOVDa3-O_ zMEXa@u6~DSA0v?4B_hY=V)gQLEt*bY(n$Sgbe4SkN*u`(;mNR`JS$nEt^=Z=h39n#iH=2 zPg^eQC^&&-JLr+f`{`aMd-wiopZ4LTxtt~cm*Os${t0mJCg)DTOu3%kN&AZrd)%Vx znD705;l;dB^Z^V#;U&u$k8+dq{sxzti}M9aLj z%TaDUOa|Q&-C0DAH864&BW_*~pS8-AljuYCsCSG6x|1Kr_Acz&pD-gm zwqnI4Z%&q7K4HfXt@=EuWO`jlvKf$|X0CYL@M&?m9(~6LAExREiFFC|8sk3Y;au|r zug$I2@9?^NykxoBLE|Z&rQnRJv;{rULao+}-VFnY=vRxEUAa%cOHKsV2PTOp>VXZN^gW z~Y7eRw!J_bSl4TG>juDhi!nFAT*1p{F%jM5S?7pZOI`7SMWF+ zB;dzb&vVgFYOv@c^EN%oTsNLco+wyoZ6}6&DvMd3wC<;nY{)`Sj(U^CFd4ICzQ9yrtp9*>W#N+j#=aAdauwbv!#Q z_qd0rShw}walxYS9epb>Xc0MqDQfc5?@Mh&3d>7^X04@k2qM1m-uO4igQCJgpNHcl z#-;R}t%ef7dTB5-AHri}wG<_9`GTeY4vs8pH`#6Wu)Mu2lRCg5xvN(n?KDsb;~1`+ z{+rK}2e4dZiLL%vSD$_=Z{OK#rpCiOOuiv}`|K0;cBdIMg0mHQ=PDV>zNBW_OG6Y{43ti%`)#Z|y~# zSGyP)&OiOPPNm`2h4zpX#=#ly+Nccx$d`U_(py*Q)tO8X>W` zj11WH(lRM|MJQt7aaa|G@VQ+h#Eb|e<|8Hx`Hw1uUEHn@R4%UP^qN;Wc*SyecRAC& zE~q^a&8YhD=cZx{l@mTqPWbh^p86!9vT>0j4`lLs_U}#PvLWO}%cW8lurE937Tg?j zUX2US7#Hg_|HjEexw9&36uZRcyigGjT~#dAuCkZ4egV!c0x0vUB9WcMXq8a%?uFjx zJ@2J@i6|=roiqEpVHrZ5uHZH#yGh6{RHEL8*lD!fSa2XfI8PN=lPkg32>x`J_YevW zBMAWEp(Sy^4-*5X@touHvu7Ep6)r2%O5=2;c4HOdF8(LD{%n#&gyf{$W20xbz?7`_ zW$#QXc-~^-XNuh0zxJ_!g(y)692113qbr(J%#}izN>g5AlJQ4PhMSY^Yus-l!<@N} z=`!)dw>yWNUmVE7t-WE4I2oL2fFN4G@q~t;`(PB)Xlg1`ic|Ld>hPwAZF3fXoq}I#zu4X!+clgenI~}N9^vHYFOAUZAwVPZcGlM9 zQg%;IXR|7Z8joMKDvz8*(UsWY+)dBLf5&R$J|zN}{_pWe@VTyZLBLr2G!KOkgdQvG&xAnct5or)SjMFlt+w}7Hderr-t+zqx>k{te|+t})vV;=;OMl%-PBHa z1GsLR0tvZJnQP#%cA6LQD76zyzRuLhOvD-0NC1>jMZb8h>xp^qNli5^-v6w8s9$NkV5fG6ux(iPsOS4-b6?plavDxcKgP6AAp4$B7{V}k zf2BmD+U3e=IJt7NREvG4MsOB66c&QcyV{iX@f%VctL)lyDZ-Mcmv(FRga@z>s%$2F z{kl)l`0|auB0!UqkiA8x-gd}qV9kevpwGPdu`i3$gnh~0D9Sw91Cj8#eXmh}m3$th zzdw|!kKJZrV-!ajZ`D*Rp!M-?SEl_3>Cy1ER^5?a$f_C+p;P}yn*}MrGjY|&(q3~mGSilDpJSTqwF&dP?rP?Mg%WBm*mT4w92NvF1WcgOYAO(lb$ zA>r`9rDk;D#oaKcGOlzbj}_9GPcp`ahzNI&TYqCq1DHb zpPu$6#9S)VBiwJkb#ssL0x|E`COqpL)o3AE_(a661KF`63Dp83r zFGzrB^hIVP7xEfjTGX^5t6omEqK!A9%%v=gca5Zld$M1-d?W$oaVL?cvFf?NZj44} zHkh!|>h{w1mB)t5F7t4h)MyGuhQBlJZVctlGdp6nrR}rZ_v#xO8{I|p2abs%?{j@l z8qHL!dIDW{)V?7Yio_!wG&txDp~=HfMNGh-Gd-R65nf@HzBexQehA%(j87IIQ7({~ zq07xqCN%sV;Km9=46$=D#4vRx^1_&Owa}tv+bBAM=6QXvy%S=Sa+*lxU2LHHL(D5Z z>$W!F{-#}`dfu+xJ7d=Rd(?`dVF33Dp-=L6)39MhqrBT+W?2&-Hw7ab{n)Jdesp7o z&@MLEUCSPSTdQT1zo@tBu4pIEqmjwFKOMSE=2m-ZoNRgB(keSq;{W;*$4A0>w`|d& z`@qH7sJVmLQdVbYvfI>tWV-QQvRq_DMGlnrV=cH5*W{NPc^{f_9tQdD%NK?=Dx1ZV z0@9*nUOA`Cwq#P5Sq-!Ox1}GnBWHn=fA0bnUqeI;aKtU=WYzKVJD(0^)RZy{;Dlgb zagR^;JmDcHj=+KDAH?At<(IL))xTVC18l182$s=LNEQw`yZfw8SX^!=B^C||om}F& z<37tyW4wyW^23}(;FnWGa?aY1>GAthg@+OxTNs}?4ZB&S+=(4wK>oD!6~y1%R=w*_ zj4(wAk}WIEZTq4!rs3m8$K?5+&_}X(7(1z5 zY{v!{@=j?-&)aSSv@0*V7r^5BnwQXO7e<>IAdscuYYgCp9ESCoqf&rL9_xgRx}UVx z62`@9S!1HpWY0Mbx;|h#$Y*9@8A4fMwsZ}<8R*n~qMSgVSV-7f>A^wG4|}MI|xL z^5zrG4EErGcckAVem(;e)mEqe(3$mXw6nVX**fIgfW&B1-bDha&taO z4#MLK1xmg>L4%dI_+(`B^ih=Z&!W5R717lRW|<%{UH5fRPaq;Z*_YV0jsWN%bL=oj zz~hvVMeqHr8P9T z#0Nd4`OX`**t0g{0klNaWM0q2)C6X?q&J+QOM&k;XPldr3!^NnWS=(QrHVqWx<}`7 z8Y4#^Zr6su8`{iQDBjMVZDvHziLV?EGj+kEM&dxA7%&d$akp>t9Cns!)vs(%h_rKh zRTH&#aC8fEyg&3Jn|}V|-bcpuuK}Du#JD!1g3`ZgSUE>6=9^}UC?aVSolGMvn4LyE zuv^U=TPoM|e(mXMHlkqm@tymHJLWlUueR6*PlcNd4F{!db-g<@nO3th*-WJ7rUs7s zb2CUKWGXjBWZMUoQE8+Wwxf&Tr*r*P3~IQFUjj%DuiSSD9dgk7Eo5|v2!}ghU?Jcm z-5NPR7)Upz`SA%jDj>x)A0KgNDD`zU19b%}Kdf~(KC!3HN! zo(+Qb=ybUBT_<#t%`l>)#9=J(Wk}hs0Hu|Cm=!;H{Yx?-#kf( z!bX+^!O3qMVagfx^&t7@kXTh}Mqy{)_(0u(cr4ET=A|=Ig8)>%4 z|AImOabaKu*~6EwD=F}_tfW8j=GN{T1}f%FjYrgmCDOz@{|#<_{{A9Ra0#2MTEr=aZ81!ziU~q~~$^V;HQMp6d+kZ~d zX}hlEVZrnYOU(U~K@Z%UbfvF!TBDrlnkkMw;RqVJy_xD*7Seb3J&}8r5x5<1fhYUJ zy!=VTz@RC=(yC3I@(z_l%rVez6stS@;X>%|-d+tbd5h#iDZ-VVrb$7DV`yAr6ex<# z^WYTOM5gBl$M^i{ke|r&t}tgddX0`j9t9=p4$#Lx^YXd*V}G4ApQ`f68X>68LF>1Q zW7MbZbaL#-7|Y9B;zTVw&hQmyP5~$0`8a{DyLwKwFo@>q1hZ{!Jb2&cm!mt{k{cwu za#@YE=_@979JZGYnndHsL2QG|B=f^kl3ZM=1C|BDWPk}Bj3q}l~i@{$MD+}+>bFV4Qsk^d^|b;dBhU4+!QjJzQG-Vpau zk6%ykFIT~TRShT+8Oi@RyyQ;IL+O*YL|S=td6v!p!8j#?f4WM}dgVrvfyk_1&no|T zWCZ`KI8}om<^M-@0Fp&&mA_7-h5JAGW_J@;=%fiIHvFi}_|yQnT^1#!OR)d{tb+yC zNh`eWE|P;140P418g+Vl6vc-z=LZZ!1X|Kc=T)X@jQ{aG=tdAq%mFe%;~1MCHD~_O zDz9xs&`C5t~id%uP(1zQ??VWrXk^yh0EFA>L> zL6gR`0G{B+$*S6J02)i#);I%`B=KP8hqS3_!3XsUBDY%CO4?7~iE6Fp67!{#22KNh}=g+UTK3#4CSjG-Iet(?&mMuJLY3?BLZD3u$8p`FEsojwM$fy=UbqtV${be9X$gR8W-}}S znZdc-1HSEgsmWp$TJm7PZCZ*tm`JB3^-9H0mKrYrN4yONe>o*l7WZ*Y{0Ge%DwC=m z{ygi*l5Gr-wAtUQqgb}-Nb3DDTo%Jf-xdEJ{~6#JPeHs*H03Ed26|iJ5v6}7y;gn1 zz}ON%?NV~%G_|(Nn&ZZhK}kjmxXuGKI*F9KE4tRz?qs`fgr_$*SQ~HVZHjVCIfo|1%@ra8Ws(9>lgnQ?CHVw z=dl7tHKVWyH)NRkvd4QfTSXPD5#i>*x-Y;IwGAxZ7{xBaaC3DwTC#1MQQTb0gH^9o z!D6MQG1U?o@$LceTC%|-;F$)!f)gAzOLLwYH^GjGao>dB^F~N1A8j;c=r97K8UT`_ zjDTk%7!LG(0YsGdg77bZR!lXGrE@Be&6H}X0U8D>yj)STEUB+FMVyd%3=(y&DS)xg1b|K7$Ee4JL%yOR>7*g|NBh`2%YP(O?95^HJ58IF^3AYXKr1 zWF{Dr>m2J*gE2V`^L6AJS7hl>^abHgBBdH!)S$X?xN9*hiF69t)IE!fEglHCs@}>h zVF8{_uLmO2yxruh7zr7^j+m>OqrFnrqJ znze`c-C)kzX!_ldPKDau>$>lrG#!@RKf{OIan#91fvqNe3Ot1cFN7qA_i;U#rcTB0 z19(@(tG46!lGE58I}?rp?rC25)W@;{j8{~jj|KQm!|otRXlaG~_N?y|c|)OasJIM$ zzNo_ZmplPYJ<(_sLmUb#6hfpTXtg_lGb+_W8f))y@L6EA<<|Q^<_<8%F;W(3Q0r=Oc6;|IlM!=Rv{FpR>AQP9y7JERqw{o#D2kpV`dN}E?9 z>btz#f;6Z>@0(=?z>ymQkMEIH?GZLiE}uY;*Q;+*Z!0b)Pv{_`#(6#6|A3w2+J(R_ zHCHCS=XQEEH8$?1$LdJ$O5{nI>8=#zKcP()jmw5D=YXd9*KR8sD6mlvqNiE^0Gq2x zg=gY!2oWYU74#8CSxg40t1~1uij%NTU1TV_X7?r^wlas${9Q}$@LUMjg>bGIFAA$o zg6y(6lJQXeP+`|66iUiWnb^_O2fFKmuE5^VD?U00qjw+@KHdlOX(GRAl+Ix4BK9Y; zmb`Zv@3~q+Da(uM-oQV(_?1jd+}ubMgV0@gpwRj5_eik;36d8;vKFurqWIpsc!w*4 zoUMtMPTL;JUWv&?FRooba7sq=7OQvBX`FLszuuc5^;m^G?GU-%n9sZW4sF}~x^ zq<*Wo$RQbiZ84nAD4tIWt}!M}863@&d)5!g4}KWKM3UkAUhHN9?qWh#JFf3udmUL; z;lI%?5GKIoVbFIQ%A9%ju=K-WJ8~|Q^}f+B>6u$igexJA+9HRt<3OBnv(^!Mo%#1J z17lldsQ(FEk&xG;CUtr8uD6RC^m!2gehdH{X^OyR5rf8oDI~oHXvUtmBT(KD979WX za&~QZAi^=--MO|R;u!c1(^$7vd20cd0+fqb*Ox{HC9K;so*4cyx=wDxt^QcekA2Au z!Yd7G?mqkvM=X)iy^U6LWtK`ZyzLJs+;;!~F)45Ttz>hhrspl{=x=Oq)3`bg<)DLo z9)j+8-0@{M`GWuPOt?n?4eI&)WbF=|fAAgLjTV;H3hvg3(?Y$S9yXIyeIi+U2>};KwM$kkc zuw1TK<46w2})T z&Nd(Z&>O&2clglebqD)-2P^X9ET5h?o189a+eFst6(a$rSQyNfM)gjuFks)gFf0aJ zZuCPZjH2Qb{hm+|9KY=!pSVb6%bK;@&n9V@z7Yvy;qSxyFfT4;B3wwIFUo4b z5mleZa+->qH-@N;ZZ2&UkK%_pRD=Au^Yf|i%aq4>hxjXaKZfAB-^)!UB<+Hvb0*uj z7Xl+vSrT{V*@qE^!ID|aJe|z{Qn$Wen5F92=~5Uk`VcC;+s%Ep4kCpoURpJq&U%1| zgv8h6`dpLcNSqIIgMqIrG0uJ3lAD2-$zCDawZ4mtTF-d4zOobYj?87tTaXgZaEL88 z)7hCgeJvP`IPg;c4$vrAHgg1rP7>Jp$ndOCuZ1CxIw3&u?DBg4{zL+wsg1-{SQ*$P zutgN99g^MMm3V`19O2dnBR);_{;j_~kHsVzCt#`7BAl05Riw8Z34g|}ID;5z7bd#Z zB@#w9gqVcgX_@*glm>FUzach&$7DxMJr16^#DGM>+cctkjWBeu|Xgt!$W zZDzdLCkCeWsl5b8rvSJrnmo^WU|i1A??w4`wnZGz1F?)ovyK62o|1~pH5~~p5Fbd~ zv5fH~vNtkq?|Re2eSIMZCE)w|2Y6+`w0zM9207D>OkLuurs=Sk-McIJ`r;?}4ekC$ zs$fkBVyjwvzSr*F`Fr6-#+|qeBXi987Qzs46F=Go-1Uk;Fsq}$Kd$v7r9RIjm|lZQO-0I zR{}Jxvwp6E1X%usnaN=jDI|oU(A|Mico_mv$n%9O){@Ye$czi4Pmy8~RjqY_X4}a| zQDJp3@Ltdv4bH)j*K2&gofP1}2{Va@mw);Z+T?aYY2@X?c7WmfXlrrROcRp8HsdWFg|+5WDB-Z0QOvA|K)lIcD0xVB%;FUZWyumA#pZrMsc*^nN#+ z5TZZ<5oU#X*kqaK^_)9umf@0!ste`mzUqk2yT5gx3ED}~KNW?d9M7|F&&Id`*;pNcY=1@`j$B4{ zcZGYVHq`ig#_Xp054(@G^wBXaUThCpXq>l^`ed4vYO*aiHx}@@g%EfoBO)sjPz*gk z)H(0Bd$bZw3L3=3`ICg|aFi6`!f&jE_&Jqyjue(L(ZU=EYDDd*bff4@z z0Ud0HV>d%Z=4z84Q}?pl+Wxr1Q_LCf)+t6iIU)`{tBf-lk(&7qtk9nOghno3@-0Xf zQ4^YL(m5R)OfW#0w+PLNd(S-gm(U>u=ePJs)zA*8VahSiT|9gRKM8@+y%GWDyGo=E z;)sq=eeT8(j$QIlgx!D}b^CV4GeOU{h|cqjKRf`*NR6hsj2HgXHAI)gX0L^N@~5E1 zwZ)ywvpzlo2#9mg)VHgGcs^VA!!D-nOQGkmitF>ok6ymafO4eip7rjR5k@Xz@XK1diW;kKY*K^WL~!i|9kM%>7Hc%){U z{tRAyuNR?0_cb~V#)yz|;v*g1#mV_xydCwiF_`n(Pr;~?Vor( z`_~Hq+wc1Vjgosg7#&sa>+_xcSgbi%EIonK z%&ip~Ey6ZSVa&sfG>P>DvZ5Z8H*y&qzAOw%stl``_Nn{aOj7JB-DTV>jGId^BmFQ9 z9ZBp0XC)Z=MfLsRTN$K|WY0v;y}jIM)knw6r68tvbL6v3bFfF)d*7nWi<6NcJJ#9+ zn|oUf%J8>v-z8PkRqQ@@=6v48DEUc`qN)uIRfZLMp4Z5Dg0O;ujL-8~$QdN|Dru7? z1QAf-eo!md-Q1~>b-b}_#Jd~_m<#=wg8_%yuJ2y*q)}~paLswQrF^Bq`*7s61(-qy zo=6en-4{hDopMD=+?$zBp$C z;fI(@KKMAH-H_2|Kti3rKs!9R*DzI-t1Lu0h4v;ZTK+Wp2~#OhEHaAt+6&Olc`JOg zCKhx4{cwF)*zF(ddmt7PkH|EN{T=Swushkw{NeuQv^>KYhFk>dTWJgemjTT6E?y^R zjfZ=_kx0g7HnkoyQjMCkmwmWk$lR_tUywhNOP*6GHGxf#m*6nR7)!sOWVvo@%}R{C zOa*d!r~cr6B@uy0!;gm6ZV2m)bL{bxJ?Y-!cRa7?+{dtU2a{%BvqGI6e5@# zg9@75PRkIzHVk^6Mx2J6el~+qM|cJ>Cm*_WKt64S)0R!&z>dLQb~I$&dEc%>e+EZ} z;svyld70L{b^6EKNqQ8GO$4N`6j=*)jiQy^i(|2*B-DvyLp-Ybs?lpB%;2QeJ9y=W z2Yih8rUa4R`+6oxs;;3R;qY>Uca%Hk2EscAur-vDgt1u7V{|V8jeWymbl8@dTb8ra z<*DsVlT0!rUvd7GOWj}IZSr>jjFabKN<-A6*=S3WlY&;1hK!!X3W+ueL5#io*i-Zr zk$E4@b-jUQC|pQcFT@3dkTH>iI+$DL{5b%%9N*m1+>XPC(Wb?Fa?(MbB8K7uDM?ZN zc3hP;Jh>pKTq}p!0_ogae6k)+S(Ph){gRKTf!tOQn0oWUr(^DO3@dl~I&emWrF4ko zjFqBY$r24}cMchysHZ*%eVZH+k|i9KqlzEr*r>t=5t7i5Hr;mvw$ZnIO1PAv`(eOo zTdV@&7Vp_yE<3R*q$qUbkDcwmHW zSawOjwe#Wp0JSX&@ZoY3(iaj%GX)4&hng~DE9PpS%q*>xS03Q@lBuSv?;3v1penal z=ZOHYs!GUbj`wBnH664^jc6j@xv4v9@t^FU;dR+(;`Na%^AU(p$mDsc+Yeo0+sZTY zO!$;MqSdl#%e9!X&w8W&AUL1w3%A<+bFi7mgdroh5}T~+F{xz!<4dQ)fv`7T&(J`Z z4O5l+06{eP;UnTXHH@HgBCG~&XqA*B!OBt%S$)UBtCDq*{q4RN7V6XRk;6mVn-Z75 z{r^4S{+~%KYl1=g=d3e5xwVUJm}}s2e}y5Tn?b}`&W1Z}m)ib^79iuknSI|ijD#k= zlZQb9|L zeHZovBq<-RaA`{_Mz7{eXVm#yH zCUJceCGY;`HCCs*?YuWN7t(Rsj9ur^NPu;39K-cbGIuEF__1{bgp&K4NZRQ)PeRk`AWVxs{j$!ewX^+4KH3?wCfiG|RI2%%H{Y#0yX^-SpnqQ*RtO|$?DFJq!Rid@qpA`@ISf$4@dGmoF ziHplI6enI8!k35DmqCD*yvDG^eiIHEe09O?!~l-i3_P#1j<*K)YXL~pOOr_|pBD9T zBO5R<4pgK5*#a#nR0`0fLrv}UxHx!JDQt^dx9d+R2nz?iWuyLLk#12gTXcoL;Wkjo z7&;(^^_p*1JoABVoY}A&=58xqe(QT~RGeS{ltm2zug9x1`46SJ-HiURZ`>D!pKrU| zgrf`C^+ka4;$#kpQ7Tp~rJ>WTi2rqp+Y}(`&bb`+c{~@N$%(|{RFWkQLBK7*VcRLLgmJZq zBib=#)<`XDsr@(e`?C<~dji8aMMksEsV7TOk<95!Umuk|HpY^NC2np=(TTCCX>k`y zT{0`P-f`6sef%46sP8|X-l0)^FBHE3cuw)v;ha1WUy&mx>T|dKp7XpLEf-O`rB6cd zW1pbo=9nGB;&G$R;t*Sv>pvOqIkE)zdLUXA(6Mvw>(m)HdHUNONQ3t~l^LO-T8O2C z^R)SoB{xcgEE;5Z;R?3)bhXNIoTA#E#@9EtK{|bBj&qnY!Lnd=sMiRViN1fyPQSsN22iYIsz^`yr zN`Itu@INb%LJW*?Lv244OZiYYvIL|*xpblg5Y4C@X6XQn{07sq~6ciT1!3`;|mXwr~ zUeFJtJw4ncbs=(VV}4Noj@3gmG*@oegO4seYd#!()O_0JxRYWwAji!X^T=fhJG%*Z zNU70aZ@~C{{DxMwgeo~s4T$Xo7%%3fE~)j+GPn6{Z={yz9po-1o$IU% zgIbAtY!?dpP?3PxFDPHu4~f@MH_D8et4MmypR!^Bb}0#{1p~R1>fMF#1Fns!6YNvY zCl^5fnitvG$o*fjjRi3*?-J-CY$ih%c7sq9jQP4E@p)lA8$Rmr9Tv5@bUpHCv&Pwa++Qh@0fiTCR zy$M3jbR|zQ%egW-Kv`$|6 zqXx#X?Uk30YxqIaPGMBy-%1hvMG}2uo5c-18mV%1HeuIaKjN&_^_u!M8!s%X&a!0O zA}`Af4(3i@wt#JA;vq1KNm}JKAh&p=Na5S(R~*%IK#(KXqtWET&8*oPfXF2QM0D{P zWLnWo0<;`Z={o^?PkW9r-B;(v$bf=_U|ic6kL!cM2F{j=fBi3j28T`7JBPmOggP5H zC!B%^AyO-3_nJT$<9fLa{IB-QKoFxNpfraeaDM>w$UhMPQ>Bt+riAV_Zgx0}Up~|K z8HBHZ#k^%Z6G*4qqOMx5O9upaPI%q|t4utTQSWO+AlnnL=mvT&NW0_9jOL2MNhxT- zfrOMmV&eq}*v#D)_nPK75>zrRG9Q(If6psIvvpuk<%tQ6t@f-gSbA_M3+o;z|?&;Z_;(TPal%w7t z(=qqd4H{i9D$Fb%Xn*!dx8&Gc0-~X6J43y0PQ-t>mij#1j~bU{9RVx0P@(hhBaMqr zIEK(unH)&sEMUhO8ct!!#X;6%s8M{c=M|RPHqrQeTu*mMZ+LP46CBBM8rZz0xEEiy z@s5C(++Ew2bEp&+v#2qe{lpr8l(eBD4Ug~r{a@B<*#z~+@Tdjbjyw=~Q2{>_Pi%X4cw zlbsy|1YhZ1Je*E#hY1OCEc$tn(2KLTdfHXC{ z0#Y2rl>L>~<{HZxO-#CbaamjoH1#Y%{5)A}#m=i&q-0_-{XLcxF-dB*&HHP@3&6NK z?_--!7Q`+OW)2~K0l3VCGwu~}*S4H>zD%{qaUZj`Qeu8cKCMYp6F*Em80C9#p8bwY z!|0csz26BR>-ojX{_$K+_Qbe56lI|o5T{R~G!i)8@0;!MhNvQZuJyR4al;=|CE)=? z&9!~d|#pZK)mmO!^au3Jgs_L#hhTwGayJ?neM}lYpcg~ zjm~*LPHGL{CRK1B4My-38u^LDb0*;37PmYW);R$mTyE9w^M1=b3;*96j@axG0hJd- zx{!^sX`QjnaH7<;ru6pxaylwYLRynd1~UVNpg= z+o%E((jeU}jdXXXf^-YQ(47*}-AGA;fOMC1cXu}^-3@2+_II6M@M~rmp1t;3cP$~> zWKz-4XyBf6ggJ1bl7YlE(GppSvNVf<1~czar{YUPCo^kr=sDUBP)Kc!^#LKjJcrVn zyx=iZO3PS?Iip zx^dM=S3RTdfBTZC?=c)v{#J-YK9*djC88X=^_hmNqRbZB*wGCprNwvO%v*}q5Z==^lk>aV5P6pP4|S0$*+?(glqv-xvdp*4tHjQXlIac58BRN)?Z&FF#9aa27h=y zhe1as&5HmZwROpP@lcj^Y|PE)MpVNnM!#AI4R46FLC{MNr_0Y_iwCfgE<``goQKE| z2f?ScqHlE=%&qrtr< zBdPF)7kF8}5iRyTl2(B-PSttQii>0S!lVVQ+xVKzm}C5r7+~}E|KZd6kG}4Eo3rAK zuXzOfpN&Pd*Fz-Z@sk?(a8e&4p_@QJStNH)N&n$FuA>M1XpY!nckFmfZu+??Cn?k!_I zXz-cir@uce-&6D!He-qf|IqswFhJ}jcngMmmdY?>OKR}nf=M>M*svwI;l1w_U4pu7 zDc9EcAW$*Yky{8B+pHp{_M7_S`XEMH0tTl!lgO_?wM?%w!+!ld=RVp%n)Z4Kc%rw! z92l8WC^|OygT5#)_*5RLk22tlGIA+ z2l7tO6W@^HdNVUGjoP3Tn67mheD-F4t7yZ)!uO&NBz-W-&dcjR+I3y87^rmk?~JUUKaba_pFhOzpjA^G5jM>#B1>I?@V; zc}X$b_ZXCT+(hFRwYMvlwVGnn)n?)Q1@MF>88H^K4ZV{_ z*wMw7|DM6i(;zcv3uGwya>eTvi&RnY+)1icuyzQ-iZnk_=+E`W8Z%GZ!lM)k(y|&H zcK=E9rishv?Zr>vUD=&!{DiQs_AAZEgi}j9Ni8CZI3YkCDsVJJ^%CZTswn4vvG*H= zjzviPzUzS<1`a!E)GZ5|h}~(EzjS3(s5J`MG)}vNui!g&0#G>diFST}82y(W#$<02 zqRrg%&<=OZXG#bs*?bNF_L)3qL8aQ_bx!-Of<4-5#pl~m1E*9UHIthYboVr%`Ybvx zC-sAT21y7-CNW9^m(}T}DA?!z#O`}!IO)G{4LO72Fv>XOogeq&H>F_!wToVQu-+K; zmnzMvkIXpqKH`kjx0lGp!%;Cw()oD|ifpYjvja3AMZF| z?A~Jy_dlgUWu7=bn3vt?@=f>2>>(Yr^fOKo>TrDj5t{_0z`W@1+fz8)+n>}w;*9S0&g7D! z&-vH(x8omZOd{mRMb`1lkMD7dQ10ne{h|oQh7s*8xC3Uj{4Hb*zi4$mZ%1mp*h!}ZFbbe5ACgsIs3EH_5KVVty`IVi0QZVu*(X{Q6fyC&{c;eNh0eY( zB|VSJ1r0(|SEBE&L&zZWC8R`zxpN1zn+?UcUqm?ep%f(o=&W#^u^< z8=_}Gg#GV)BaE5Xp>x;i6vFPjJ*XZH1abXt9!)wG*Jd3~s3ff*>?9QN)GuW;O6aM} zH0u^t0W0SB{!iP)H$Zf9 z;dVS_hPq2hv)l~V!-L@jCu1*dV&b3afPtLcnlX2GYt)~PK2ceJ4alU@NcsA*-+ade z2{^diEE`CHt|FRsOuMltNso7?yTw)RG|d@t%g6Cp$BnT2hWGzfjeimNuSPdu_8$@23C_25(!E{iy24wv1fmz~9`b7XseHt;7Y zxG8^^M1~Tl*#UQhot7VkeXnM&A-dH6%GqWgO(vryczM%1E12{Hjw>$Q& z=95GVc~ zOM;r0Wcw*ikWe7MbmU;Oe$W-SBYvuaAa@adbI~LhT<3n7LKykY!&R>qTx`bc?fWT- zXN(d5St?(ALB{pf!3&A1B>|RmX5KQZ^;!eNw;B_i#nM?@O-_9(ecaveTRbSNXrXFV z+0z=gZw9FAHuG)A$A5M9MvYq?r$xuy0e_$c@vN>(nt2>4PFGe=SObn(tW*47M~YnJ zmACpV8EdyEUb)SsCDrq*g6q<5+=^S@p`fCJVZP(!1&2@) z+wq?DW}ek81C zZhffi3&ywl&hU*tDgGO{z<7B_`*=+h>Xw~<#?r$RI4C5=b&HPp8$JcpLNhxi{qCmFh^FI}LPL}v z9qSvXHE#bhQQ3Dm!gpM(B|r$*HApk3ct**%^SPMlN4bLp!6nXzN2DDUjB5a1)4Ze- z#COT3-iYZO>dIJZ%aoqK5_5<{A z?1c!~UJvpZq-%2v)Vm1waoVN|A&WZ&(Ud#&!l^7lUycwRidpJ9ACMz(DAv%JQe|0U zmH3D9(QJD?`t+e&slqSQUJ@OUdw&u*tf0+cPza^Ue3WEq=8`zOg{g~XgQq-?@HGp@ z#9jbdZ4QyJK@_i)^paG~k#866ds8{7a~H*yiEK-9&4*+74nH#r|5rE* zhl_Qyx6){ZFL7!JM8*i`?7bpkg9P%48$sM6Px;Ci)W zhc1EOA%YMnSKU4L4&1rUbiSo(t-<&;iV!RDY`@cu3w5*h8hdr@4!M;M>KU=vb*yhU zo31V)vp47R`|rQ?zevGo0+@+rPBN|#djhUMI;t}Ii4NAHT02@YM$09vJUI2kRx5&n zX*;e^c)?OFkk;#WILw~e=Me9k0IE;PUB8A%ucnw25(4X2_VMniKE<~Q&&`F9t-YcZ zeL?WG32}r|$C^Y^f}zR8#IC>~K+p3f9fdkFx)ESLdIHBjSok$BI7y0ZlIj;InLBV# zOECc@_bs4+b!r18XQGCLl?qICgs<1lF*fib+ z#wsjqpH~7>9tiw`S4v_m|ZqS zyhgrI*8!h%_ElL(Hejkw8Kqr^J%dWA_?zUw>~;win@SfUlc+zyAS5qA1_jgWAs!k zBs^B}VUqDRyJ1`Rj-?uqV5PL%9fOTb5UB)FNhz{0er-EDGQ7|g>)c%2^8bF%fC(?5 zS=!3c8;tT2<2-VVHlDGJ+f1yr`(d28EHZOo=M=eA)anpUVZ*cRzZ)FA^?l^-hq~Uc zv~%!3bUoMNRo^48p!^!6zmpL`#3zZ<*{yrte2p?4`3S`6#fC%ouK#}1s2Z_C#)f5N zhw%9Ax1^~)Ij(@9GCQiUVzQ~`Z!Xha#EG*}f_DY6fdGtC(+v>g z9@{X0?Q_?1rEA)Xm5a|6rXwW<0JYeVn8dT&q9_T>&M zdWjoPK8Pb{zu1|I+{kyrld@irr-@7vXv0H#c--?P(shQBZ}VRhe>ZHt;PDppA{QFM z_-Ba8&_?X`Xn$X#!aOt~6K#ZX@_j^xNgASr_do*V_C?Zdm}9mNV3|eYM6r7zDN=$P z$gC4P4}~+NKc9BbnQ66c4eiWy)+vyZgsne_AqV}4=lsC!=a>8dPfYkozI@x?JMkZ( zO8;?;+peO%tN3K?t|fZ+7bFc2q-|)4^XclRz1wj~O42NH& zq};RcWqjM`3`g2aS2=bYYLk3S2AKUFbr4jt8MOC1zulzBA(NzqzyTHgrNga>U-aJ` z3DEnG;25Le??nGOto_O4I;c}4aJ{6G79#9SHq7837C6L=X$#nu!mGGjGcr`u!>lkygLgy`D4!^pG-4Kyag#n64X z{e9nFu;_qem0GmrIal6$t}_AYCsMHMuLo<)qa z9qOSd88xhC;Qexk?TJYmB$Iw;+xbO*@D9KAfK-u|CJ~83$e1A=W6lN(@aufN$^4?m zw#p!bQ7oC=EH&Y&|+TV4u>IRaY+;QN^IO~Z!CRHeua!=xc{S7)V_(LB)4ej zA5E0dZGVgwYpj1ApbDkw2b1Bv@VhXLl6dwMK!MKCbY+lAgQQWK=}N!tn`+C=U!zup zBtm*TAp@c6aL?9wyp9Ev43edcen`C}tAl+ha7+Kk?Hmp51C>^lK(R~CEgUCm3a2cY zpKRp^i>$gY5`-TiHD>%9=v_yatZ_hO-soonvHZJ@FgQqWjZ#L-c%+AQ(GlPDOUPL+ zcYT`#e+U2jp;q|wbQliOevlTHq_!~xRYkj_vHs%(bV&)I{Ctr4w}FKuv=|>k|LxjA z`$9nv6RVhZHriq;S0)a^P3d2+BgaA(?(i^XX^4XG`^@-_ifk(?nV8?e1FO6enH!pU z2Q8vXQ=Z~ELjM}>Fy6KU5*`KdSFt;8DIN$vnS=kQ{PM9{LaS|Y!8fCEOQWtOz^cVm zcybdfhz{&?e(8H#pe zgBR*ffo8s9T51Ty8)v2iy`6DI*EfLEaxf86zqyiU@vghwF12vF|+zLSw?ohJuhTNorbk_};!b{_oJz!KZ z)67qIFLd_{s8&io`&ruQa!?^)A3s^P`bKt+8R%86o9f0~xg&iTVBh$9103h~6;?K? znX%V2`d zc)=Fm_llZJ^ghWUAek~3Y8c-aGd7l5cIMZx1hF+!pe3IElpNAfy%3(8j2k9y7twHeKv!`mOcPR>)e9=(JP z#Vb;i`Ol^22e#3j0#Gr&A8&aQxmGitdt?7wsqwXb#{cDmx>#?9&m2wvB<8$RGWHe%4;cecZXlD5f-0#9agL+Wn z1SjlKN{AOM;0YJ>PIh=V2z!O->D5a=0=8nMo$3QOHOlDxMt7LKzsL-W4KSwS=-vE8 zWG*skch!;jHjpaOM}$xN4}&4-0>VGJoD!kiNVpOF(TR!&qUYzpe~R(PWLS$x+-$z` z2PetIJEe~88`q&AiO%81DAQECJoBNk+KIrE%M)A{p#&atk@e&r9u#N#DmH?8=WP_}~p> zXtvYbVc8ryzWY-{VtreyP!E8*Dq%MAhgJ;|6YMl z1yHEUB`KrOP|`FJktG>Hl6ph4>$?FwW)DrkR;2RpO=XA!5 z5VuH;C56U1kPzcks*)ZeIli3xHT72S&1}WW)=^N+Gei8_(#=wFYc|7kV0Q}w4gXlZ zYlPMWD8rzZy2q+wjrJF@IXHxn1QN~uY zONFQ1I4%t$2?`HIyPZEdi_?qZOGZ6O%X~1#Eq?SYLq9)>s_gF_1Q82M9wU8Aqd_E* z#b&#SQ*-5H*<_b?^9i(=nr%(!k6HU8rPOsB^qb!Q3P}4V+-j0EQvRpH1`7X`AYFE` zM}+bP5~-MlPVL?&fk~}_(NI@uhTuwHw=r0D_0t8>d^$DLkelet2WL(rCyl>dZ$h-n zTN>u`ts!t5HH=(tRz>yy@M3PwAI8c>R+d^dS{-Lk_4Wx39t2Fni|vfbER6Duc`7Sr z%#3y-?qw6?ZxvW)OA>#L$QkABk9P)m2v(yyu*qNTc{m_@XpCEZC|g=mo7I~x-^O%3 z(ygW+Dg(adbY9eRpgsS+8J8{)r4q#X5+VAt2=Da$K!Q>ASAQ-C6lH3q$YK&{y~BkX z_496n8kW{gy|AHs^CSLZz0Cv>SKZO|Ta4jte+0~FGQ>zveguS_cEMY=-H2P?-vhuZ zSL}eEeBQ0o2$Dv1U{Rl<%q9Cb!IS1}aiB0`>U4XCm10$%YBsslymo{xA2IIw)x^Z? zX9(uIoviT$P{;>sXhgH&^&1^SP=X0PFUCXh(hZwo22XdtSZ(S?*%Eb_%{F{X_IbnJ z27gVf#ogpA8lOx6`M-w*=k=@+t?;zg-|lg(^jALcS=ki$x80$#G#t&AJeu53e7C)f z6(7;1$P_m<$9Z6XFH|QR$I~B6FYp2_m;<%x*R+OJ-ra^z3RKW|qN%qrtF;)bipx7W z7C zjfB^Pp79P+0}s}*F-c1C^{_+oEziV^Lhz0ZOm_zDVTVxtNN*c&n>4D^+%i!GE^Oo9 zi={^wGwhH`UJ1BSAApf74xA7*A`kw{DHZd`ke-J{n^u)wiljFyUf+yc(v(OTf_(7* z!S}lZW!bXmS^nNb-HE`f`LD!t07O2nHb3N?rIiy1WNp0!t^`LEPhwStG<|0>4aHn! z%>@*NgQu^29CWypjl}+bKX7bM>SIvOElaUdWVbp|Dda-S*6Itk&a$2Ag|H>I6T_7wJ)JxYJ6j0X{;Udss_xM3eZem2=kQR=^@nT)s&m7)m zssEcB6fX)xj;Snb*jdsq5%db5`glZe!**UC*mYv$jOd&fvk+<@64VPnuhf5Pb z8YF(AGZcNiPX4(&Osbie8I9!;o$s1UkL{Clc)IaT=L z-IugYk7E0c>9iYo|M&8DDNHMxbH^sjr9%YT%h^PrwChelh=PP^@8(k{K z-Nl-UVQMOc?ACPjX%4|0=Q#C`J+bT6OoOSCrKt|@E5?U~QZGleC!S*IF-p)r?j7bw z42@25TKFLq~yXy>219 zJqq){aN^eyH}OYkRA<9!)%ygBbABS>O2WUQ7Kxt})08b2-Wh3z8myFBRgc$k;Tqou z3bVp&B->ESnXdmY8dUB{j+VdJX~NulbXDJN5pGb%b46#(+30r1<^^W9t+DXXk<`9ennGOy19zm6jT&6RUHOybEEMU6$P9^M8rkGcub}8WEgUl!I=!o%EFF^Qq=? ztU66vX}paGOWE^Gip$N{b~{1E+dEU*ZtvJl@f71~4_0}Yf_Ri$9<-9r_Gc@#gC2Dn z{`4KI=*4P!o5)Gtk&JA+W0}qVwN!Ucp`YbR=v!(sU^{fO`ZfGv@n>(NChPyg9{>gM znF#ovERs;V->jg8ST_-nt@>x@2GO|Zaq!duCT!DD=Yr1)A4N)sz2BJ$6r=+Xcsp96i$y6eWZ%5z! zKbOpWES!cgDQJIA#4fQIu>MB4scby(&@gFyHT0pJBq!9SVBsdx5+P-|Oh>RDFn z`g&&|UOQ6zalks1l9&P&4 zfi7~xbLM6H!oT$1a{sb0A(LeE7DVuQ-qQR>+}C?a_z-uIR?Kbcc4k=fB^>sDSn1Fba4{s}*JyKN z%yyJa*Z5UiPYUlma)@J(eEV!7?>TI?emJpiAXEZ8tGc1wrOo{HUHgU^}C+bjg>TGgl z<>~5G_Q8m{$d@8x3MQk`@BQ|+G}VL z(&`1(VlhXNKzUArGiHm!=+)+nY&r}wJza^nv4HYXFz#`P)* z*#*=N8%3V!sCEH}l@rMk?gOywET!&P&TRb7Psn(W(Bnl_{P;c&q!l3XR1td3?hSe% z$b%TT9dYsbt_Ws_Eoe0>DZOnUt?t%?5YDav<5!Qw$^ycR|4__Ml5~#VzNTJYcMMO3HoRK@UznAyOomW1d?j0>Vx=vEl`R?E6 zHGVNOsd8A!CWf|wc6Y;Ek za~9R+A=)feE}Rhit{uO6A-+ub!%RAd;rao5h8$nlY8!8#(p*$QSac_6n6aVi{OJvTK%76sY<=501__@?^t8pP?!#OCaRuM5 z2_OWkm47)nQTMSC4t$e79{GqIa`jj@Y^w>C0cJsQ<@$K_csE_GG3!CJfnvS-;ei$9 z3`%l0Mv55SWH}Fdcp1pG6~OL~U%*Keurrb-2KG-#(V0yDOzp1LZ+-Ie6{F1PhEdt! z;3O&2*1D?h`1aUz)pj9z8(?2obh6_3!r%NG&w+dK$xc=64p)D>W`go!G1vq4acAtA zo^fbYeql7ayQ@PAFN3#>H5O8xL9OKLsHfW^@cSG@ypFcX8wKyZo0`^rf9wYmG=2g? zjpp_)l*2xth?WKxr^M^FMifF<8WGGr1xzRmm~rJ_9{9pjB^I+5Y3xJ2+dnz2WNn%+ zIW6AGu|D43Edv7gUR3x^CnK8o9B_nZ4f+8Ts z^cQH+qli5((EIlRk#mNKKeyYQ?sHi(yf^@K1{=ua&Nh$hXt>O(Fum;)9wt3>l!lv& z7n1{A@%oa>G3o`}Cw?p_cYdUvyJ^sF#L{amLTGvnki>U}Tp#69)ZG8$ThI9Dh1YHY z-t6e1!sC_|WZbkaT~jr^4|RLJ`E%Cw99cp=YpEG5$wB`JP$Zu*Hp8pj3^|okg}#Pr z-+3*@KKeOWg!TZEr@(+vXp5PTpXIA{-eI)bWOzLA7PbkIyRI)<$Vj1_y9{dN^wbG_ z2{Ya}HS$0-HVpl`1HZdy`}H|ym_XvIi*IX9plz?_9(|kq7o=LkU-T>z;|UQekl8E! z_nfQfbXrZEM1s|RIU*d`3nzIbOzM=qA-}AC=2Z#!$f{faGe=QK6YE8tHT^r$ihxjlnjf7X~yNfh6h_^mI{ywz~ zWMrTI?L=4{g4DuY7BSo~wHX=zN+9)(SD$Xp&XJ0m09L4Op@h19>Jzo)iy@;Z0Vdx8 zgw*DXB!f%1%?t2a4g_8!_(dVHFKII5hd|g(j!j8oB0qwo1v1P~Ay)_b8Xp@aYL$|$ z0;n|Uxh1OeBk(ye+8@C4m&>u#YLKaGwcLn>wb8=n{p4XL=fO>cU*Q7{cgERY2y>w4 zaVn7)p#Wo;W89Asy8&!VLTyM3T>bG~3aUjy=q<^$usz0VWh9hM|>lf$h0je818(Ef@qnK zJi}EkjaH1Z_bsajH-0wdEf$^ZpDaa|&Ol@%vOl5WF*Yr?f%Tpz?LqRhom4uK=L37L zfr0_2o~Yg%uv)FA>3DQTG}uX`9!5*ds)de)73iKP0BVUpBexdqK)5EtSFT3wk*r3x z)f^4W1l4bwMb0&RRo-e*Ponphfrq^lBF4b|CWotE*!$gUiM3o(G!MMTT;pFM={4}e z8V*mSTzKolIz_0jnYwhX(!+iW|B`jwXnl-tb;QH&ll`P;5*S)_Vi*3V z+|NQ?<#o#=dSXmhB`MBe(MrEZx7S1n44u6m<9I32eB%$BLobgu==$r$ha)RwaGqz zyUbaTsJFo(dy5&xsA~`n3>elc16Kk9ms4@%ls?IRoKRY*Eb!n+x3VOnVH+&e9Seh4 z#^gC@Z&HoJRB&@9s7F8VFp8-6sZm*WT8wYlV?RBeh3$K~ zfu}@#@1)U(2X^xt_}#%Ru#W_HR;+kjVf59Yto$I5Kb#}t7e1i{KR>#>VUx0=@&NQq z1JiauKOEbeb(fPhxPWzUAPN=#qg?P&q~B<(4^_gTekbLMft?U|B5| z3yZ7;2JVA&7#z#*kCIPFu+^Lz2Cl&WL-n@S9g9&b9%R#ZeCrQMS&gg8Db*983NpEp3D1vbpXryN1&Whh28((>uk z035wc;PX)MH#N8fM9O*XHYUTKXTNVZj@LM8xhNmjoOLD_q!B2`hqm?ia7?k8#f4RZ zBoLBD()d(B_0sI~6v0lnG=QK;GU@ETk(;6|20*9$@=VIm`A zVOrBA!5c=@FmdFsnltp-1}TRaSqI}%L4Ce$eyAu3PdEjC&p*=?U?HL<;e6x@LxeF5 zXVzdS(r@))2xr*TrLS9>P?><2un}SVC?z3`-iHkX{MC#s=H!3LUkSo`GjzEkk7P>V zyzg86SVXld6=v*15leGs#OHG6GR1k)Tj2K1*qAaycad@SzV~>QCWS+uRm$~}1Se*V zz|zj66dtxtik$x`_IBXeR{qPzr@~o-SC0*@%vLMq;j)RpI}(5R$; zc+MaAwlj+cbHS3~y`PGYYB5liJ30$8iaqkXHyh(DEGq4DdNxt>VouQCYn4kefpa!476<9+I{c_4pj<8Ph;H3Bu|AbIrX+{*Q+>)lHA(9 z?p!u2qU_ch%dku=V;91>uT+rM@h?Cmwmtg4rlCb}Dkz7jH$Z^Ho1x%oOdT>x-$1e< z>`jSCV7+e$=fQph@}nc(iy%*UgA*?y?6I3xM}@S9rJ)&@6m0Qn*o|W!HW2eBD}u}; zOP8tnLiv_}`vgUOtZKbk37%lgc>1@>&o{yp!Ui!=3j&s^ixoye83vmX1d!gDVTfL; z$b9n!4lYWE$nlEj9vJ>Gti&?CxsS)DNOpB!_ui;!L)go8G4Y2kkoE*Wvh7DeBU2_F zKHs+nZXhx0Z1&)=4nNWz`*n z>g7Y>u$*HVwlf4l%cW?jR@ci9$chG-g*LhBLBcvvgSUV4bAH*>82BK2x4GS2m}S?j z){j>&!V>>sc`{}%8wxkA^%$!)GJVZH#7)m*ImdS7x?1?dM*TZ-MhwjYG$NG-WWGmx z=aW8zEr`QU*u}33;C|OEGFc`GT3baFD7xY_LLClOR49h1{!mYhvV~1^PY3H^nwoV! z^BKwtB5${w;*WMS8YiboB?ZdO72s&Xjk0=sRBx z?^BN0Sm^?<4s=Jfs;yF$-gc7+b#-XDo>DaE$TU6LEzT@^IBa2g3^9f7Ck>qrTSXUq zQyUIOKfbr}6L;6Rgv~)TN;Y&`C%Mst4&=^x>ISh`~sHf;QdU;w4cYEi8&;v+H3 z9k<_`JOWs&w?>N`wghE3Y;!(lj@9T-b=J$|t|FQA%DDyb%F=@qxCcJ4DV3lqN+d}_ z&dP~UVHYjh*|SG|Z;O>VpguBhxnh}QvY!}3gZl`U@lL!ZHY)EPdrL7khfKs9Pp8hgdzNVzAW0y4 z2(Fg*i-@BFb##bRUo~8xqc|Fk{7eudh-&jh;^0MKhJak6h2F4&1LyMl6}3Q>{WhH@ ze>+srFMXELIY~H$s6))!XJ1a>4zFc6fZ3PJIyhz%%G*K?CN zyZEoCnp0>|@jd1p?!PZU(Yv1zlV2wgzC1L%Qqi@8E^UL?FV#s06$%`AEIPtH8xV*D z=_b4|r^Azm4rU%wxK4o(`ho@~MmLf&5?jDHL;UFwgD>GywwpHcl3k5A23t|qgv@9f z<}ZpM=ZTG5zuWBS2#=>?(z5p?20n+j8s5E;r;ITku$js_pYB}djjA2)Sy%e|k^D<+ zdSYa;a=_Mfd)RzHdD@#my|y)!0!y2d`iUgKw&=L&uLS6epGn)kX{nJ&-_^$|3S(F;nd9 z_eGnK+G8m%kas=g`Um~9Qzwj;+9}*Q77q<T*z+H_+ zuM-l$c&83lAQ=i{9MQuh0_}nnt~&%$p^I?=RRtOU(jX5_e`0WRs#9<4bF-d05B5S) zb|6BKv^e0mUIJTQ6tV%yF5-KV0)|hIE>!w@mD&ea`CzFyIb~LJ*5fIDJi#spnRK4% z$7Ar~VI0*NGU-4|5dS_5n-SwRZU+GbQFNlLbtJKTOwI5QU25yLL;N#LIY{*&t(n}1cEUFkQG_TFPGk)xV(kn*RlCyHwBRC& zB+4{}f$Z-aMCkAsV(Sb4JSH7X@DNMlF0!ug3Sia6=ZEGKUzrJ@i5q9vY!s!uP5@5^*i-Ej7Txs};Hv&Ji zS3VPUA75glHejwUAB6mEW|&|IVFYE3a0)bFq&OeGy8$u3l!k-TAAdjCU7#q(kwi%$ zDn{}XsVmAcP_|kKenkzwDI{FmAPA~0we{Xu{x%^plR{enCfd8Uck9K7NVV!;?ulx- z+>s}0Qv@xM&F3qBS)z;LT1Is>yn`5IHsG@FS@TrEjorV&j0 z%bgGpZ#q#ib3LPjhInqkVbF?!OmD^MFU+BHGrdHsj64L#$~wnNy@@fzk|QU0s>dOS%NB^Oq(meJIHSrT0d6p?HaCu#be^!I<1vzUhbD|1J?FY_iTi zyS%jJyin+%nN@7Rh2>Z$x4nY5FtCJ1#?^rkuzn$~4_Dupll=5h@s2lN!nAN@9a)yU zk&8kmb*r4VSb~8$J3|-S#B;o4k)@BdMQd$nk1==>_=XkZqKXIHR z_)>1?5@UA)G8Y2}1#voFar{gRZFSkUiWZM|PWB_+)**=bujZ6tL`JOzs_in(I{l9X zxQxu8k2F7wjS0VcJNvy|3eT_G=j!$4C2JbDexQ{cXpiF&rERbF_RD$)oX*!%aVob( zTk7Av!uhO`N6W|32YLMD!cvcCrmhXgUxAG5V2^x6;+et}@ z-h7jc$p88ZXFrGr#-v)sWbmH?B0HQrJfU`zVUzP>?ac6!ntQ1Zte+RW!k0{N^;YJf z%)LeSlSmZyyAUyjOb%`R7(ZoT@xDiC6yB|Z8U@76nu#` zlGlU&cYjPyk@*EmrH9BJGP;%hgHd)aUm=>8ow+rNIqn%Stit0_nC^>;+on~Bz31VH z*@&_>pbk~1%~B8{`c?WnK$mMnru@6vcNW7@i?8g6U8p_sZ;TGA!f5;6Q^1RmeXi&g3Y&O7NmpIIRW;eqSYbRY&dg85HD2BIm$lVM6GX9In%Cg(_3L-4ij z8>T&sITs1Z{Opl{u)u}+tf3-ni_T3eimx1W0%bTHGu=jM441Qy%^!xH+Ru&Nnjsrs zDi!oZFMWSyxpQs-_dZ3vaPl3K+t<52!J3^^^X4;9)rANu{Rg{j5hw||}! z{_IM`Jo72BvEm=x-hN7hbvfs(5WTo2dCr+0T+I`eIIpT@NFj$tnX4-37bW!0{i$AQ zWVHY3d?u>GRz0}$Od)zi^LMJwYSnK`J&{Ap!nzq%FdYDBOBnpHhXX^xcBJ!=BcozK z<_=wQeZ0Ms5F#&+OqEI7CL$I_RE0s{A{p12CjOI$u%(@>{M_j5DakGkNv%nQ!AjFj z;JX15xgb=c+yT`u1-9edx51pBgSai3oVLWRn%S1Q@jw0Vo4vqk#ge3VpIP=B;MP^X z!O~u@IqytSlC8sct6GeT&Z&Mb915s5xazDnzcr>)Ipv>e+|D`tnfS54vSu$fq3rxH zM&*5ItLl%W?-r?KDZjzj_Md;ofBmRL_1R0!U+-%fFq7qhe$7)EnC3T!=F|{w1a5E!?L4wm(#>Zm^{2ubLQH zHJcQgG#k#?6g9>SS5w*gcL3546NLgFqH;GQ!fk5S7lm$m7XGr-ci^u!bmWtr95O>5 ziqy-PW~(-}&GcGTJ_&l1H`r!%TpSOqt$qPbM;q7BN&D{dAD6Fm^h@p``%PSybJ4$V zcm$NSE<67}w!Shf%4lnw7+@&rZjg{}kZw?E6zP!e5D5i_?(ULMLKGw=1nHJ8=^9c& z8p)Y&d){;2IOqGtr5Bg8-Orw9uXV3Gu)e}KHnK8&j(q04t(~3~n_gyoUvBXp^x1ZQ zV!5W;QYWIQq5fYFaIHc$DEvzW&mCNn`!W#cv*o>48ZR~Fs8&-^7454;F@>TI!`5FWK$noGqS>{lcAPGQei6jk}!A{%rBmCKHN(8T{7JX_T9n)&pBy1ml$ z9Q+zA#8V*p03jU zcwK}%|2;zOh6uxy;mu#}GCKXhdUM`qz*1Ctyjg+01?M&#?50uWy5|M`|O68^)bI%O0#b{eQ{Kw&w(>m<1Wbbe9ERXQ;cFuF9{A#&>`z}x7 z9OBybC_n!*|KYz$JfIN@1?vjzqlqC?NCnHR3^W z-%`DXKc~FJ)7PI{_RH_7zg5~zj2}@2MH=eC-1-z?sq3mfO1-DsiyI{TaxpJit-1k! zE1$aY=@yPI-z(k!dW{UwL9MTcsr3z+Oyv1P$+5ZM;kXp!Vo-3h=yqJuFLiM@Xd<^ii=eYubYZ}5Y389i+yNt#?N2seH zKgW>LBdq;m!HLy59EZ#SSn{S2OahL*FrYICEQ4{v+4&Eva?Q8Es1oDhSvTVDdJ1dU zcl9;bZJ(MUU+a;=*C-LG@P~ZUZW}$=lbJ>54hv^5Ce9S$0)e-GY6EItpHruhH;n%E ziyWH$_{@pwleSOKYMmWH%m%e{3-|NSv;n{=PIq)R4vYN7_;c8$Wow+Ct3`v?;fg7_ zG58AdHt`wNM*$c?ips8#j#l!*332pVDzmEGz6?13j~Qk2yc_Ic{?QTab8_%5@=`Z< zQWzvN<`oys#1}e!ZjSh(mX5(PEk886-r%y#)+N3mDIsCxUZwM%4Y#ykNxWBdNG{al zYrxILI}s^hpI86q_Ug1Gk8Z)I@Pv%>$iOQ}L{;{*zbEtSgn-PSeLo>S+9dnWY+Bh0 ztT7Z6JiOig)h1tvRk38Ye9|D4JZAMW_MppUp`OS@u3xXIZCr{5PFCOQ4SxgNkS4+Nl%H$Ebsld3 z%o7YCC)OGY3GY@~nK-6!iY^0gsj+CleU=QBz&^2N4y1Oqi`%p&+zitz6v$K2h&U=0 zBBlIJA20X4MB-lv*q0Sv|FcvWzK%#Yg+9A%p}Pd^g~5}#jSVE zwM(%VB(DtPkCi?ae)Us$u#pkhTtHyLq`}-~SdKy7fO1@l()^4e&Fvy@esZTn`#t?! zn_+ukfNdWvHE^cJO4V_xNnj*bOskLlOb43Qu)lj_u?^&T^57$0Vc-qM!GbWZMb&9W z39OdHLi$;smU+K@+GP}shS6-klge*V3dF{`^YoD(PzBU5vQ5Lp#zT2VmDR)X&NL6e z`q7nz&X{``Td1Ry8l-hL%>IRsY#FRN0g>RnNPaEL0T>S!@V4v=68W>;9-%8Mt43?P zpV|LZ7OH3M`wWJ!ag4(C&VJ!gEQ-25ZiY+->IY#CBz<1S4^6q=?{H7PLOfZN}O zw`unsm>-=2nU38biCPXc>X5aFE7AjqM126}sm19wk3G2Q)Dw+M)OQKS)&G|DfA9E# zZ%}^V$6t$G_BD%=X;%&!N{D8OLxR_E96Z=5@2tK_=>Ir_`#~y8m&J<^aDw9bt+OhC zX{kKcJzByd43Y=+>Vy{jL`?VCxWYmiBWE6~8)fzEhecFpId^KyYb(uZcTwxN2y}in zY1V~EreP>hKpyAPLwb68?q^STa$wA&hmcY#a;X&s628;ccjAABHe2~ZjkzaPRg0w}zJ0fz_3R7@=f2R%lTE?R*?^MD3nEI>+Ou)KYS?IW* z1~FkA^a)hbZwR2oIXoqWY3Gf_=v%mGJQi$|&tyg;xyFy1L6KMM%+@s~85g>ytiPa? z?btPHZ))PKw_7!vkYbDn3U0NHko5vU&BNuMQF{L@s&PMkt1^v)VY2HosD5K(qY1W^ zUK`@kXYj1m7n@28jmo>2Ahc$1%_D>gPA2G0$#Ccv6Q{AeXb@L!^o4q)0Lg`=mV8P0 zR7V9WWiWs$6OKcX(#w&HGa6FlW;~T963&144VixiOQY+ug(Q*?dY|m@byx*GknAIl zi~o)^^yDSu)iu~(VX(gq;_A~~L$?S<{TU^IAM!WabXW3ui=9d8>2W*r0-BmISYXOvC@Zc}c|C^!Tj} zL@-&fFtHJY?-+gMsynRX21#I9S%nzgkb4nCBn@O<9okGX5IIWzAPY9efi>(te9TmG zeTk9cCnr5U;Z34(5WGwI*ZhPb6*b=caU3*kPoWA$KT-Q|w>ojev&g1V(b#|5;tyeN zqc~Wkgm(@}WI=lxa~^SJ&HG+pyDR{cbLCy7fTRcwhOO{fOK>ebG}Ep-pRnsA>ZnZQ zuar?MoMrK^pUGKWkKl1g_vXEq?S9numOB6|c|5XpsnkZWC>*6ON6d}>=UffRmmQaQ zGnRm;7NRUGN^a*u-DhZR6~opkQ_1Tx#ZH3)oZ_14NYkO zRc&7<^)#v#ejlCl`yK^c@;BNE#ejz-+G!5%`te)AT-cs9*GVK^RH>3;FOZh^THv{*%-v6nnnohkVs>61B8`gOVIb}#p z#v*vJZ!#K^@<8%uddfyOtKt4Y=c?Qkb|u45)WIuFqOHj!5N|Q$8y+ikeRja*vca$@ zD_u_od0nvnGL5i8$5(WsV!f{K1vKa9^q{I9AU9nO3~(5}L0P6fe2F7ZqQcCJE2*~b6MPFoK)5alloL5r4`l*AXf8_#iXGVIspAToREzVK=FIkw znAVD&Lizc3)Yn@);Fv2vuZm1^LUDD$*MR-B@POpz4?|MmDa9WuZk$3`(daBgJwgWe zH|!`|eteQg+KtH`<$oPBW}fc!2}gysphDDO$c`3cMTgu#aA$)wKZS>fhoGOLT5zxh zIw}^4M7<|IwJ?;^WFDL0({fgEVRndx?RO`3{u@_z&l}!sj4OGB8VNO@{jh~IZN>El zL0l;rTIfu7#z>!{VTZk;C60qpOF)Qx$B;gt69qEq! z^rj|6w32@jUHK%)x=$srQZ)W%yp+r~*8*_aS!ma~JzD!F-@U?o`y!SlOT$@XvEG4& zZ0DtcS!f&TSLd0JWJ0+mSL!n}UchNKhNj^UlLohbl>RrRM}Ca~y}9?uY32IGe^C$5 z33iSx)G+GRT0fZIFaT68kjG5%>R=(AB0{^5v{?=c$0ZM{eno%@jHq2sNvlWC%LunN5`oxaYY(T6khH;Cd;>e;{FB& z9s0qZJ(jB>h50w_idoEYE1}aDG>Q~rVqY$yAC)*xUxIMs;t6inyCcOl@ecEw+0% zE?s1QcXN(#KJ`&l4|Z@`-bs${WWy^t`7ZJfyH9Ms$~OYU3{qHPyW z^2PyM|0k0+C|w?GMn>~|T9irR6*g%SrQPcPC^f`s9Flipg%-w7{gK{6qQigaf;TW^ zUh!)3t3GX9VkK^vJU{!mnr%O@eYy#yYVozaA?xZGaaHY+w5NQY*WEFOu+%~Mu^k$W1&_vMZpDnH zTMf9OE2wnfS~!C*6|igN+}{J){$S}F!I>~WSMW;Wwt3iHIHzHx#G;XarkSY@P4OXd1!7HyK zV}noUvNE!Nq%(;!O+o&)Hn}UvV(2WorTSw{9+M<{a<)F9B0L}L%Sdn-xsuA7&nF$~ zgi~qQ647w->UKqtnP3`!o+VGjJ<0OXGT@bEQfA#ZTaVf$_sOd_{keqjD}yln;%{Dif-*vL;jUW^o%4N%O)l>D zYCteY9@1lH_+3>Xxg<1VrPbx(ge&HgP}wlkykEbTKZ*4UX$-%Wd_H<>$&DXDFxe?; z0@WUudtuD6&6`V6|8+K@Y|~E2C%>s0!ww&ikJ&xe<3v?%*7FvE5O-U2xOoQtp+_;w zqx90Rqk zCM-0D$&Rq_mu^w9Ley#uS8^z!4D}j>_8s?M<9E${x>>)6|J=kRPkq=xx^gt~$9m#d zxizKNgv6F1Gw7*YLs(3BHZ{h~>k*G;&D>nZdeyPcBwPLx*6NVpl!WYeVcTDyC@bL}|Zd=QMlVHlQvI&ZT`hl}a3o=`)v9`eQb zgV#43ek-vsCKf`!I}6nX{uNVHUxIK;l?ArEYo;o>P~VRGc?}I70*8g4gN1GHre43( z+@8A#l zcFUJx=@wmDw!erzntU*~pi>OT-}+6)rWmgCcii^h0RKN45b-*qf!b>FkJpz+_WbMY zPcQR+<{SS^);pf@eDHrh8mvIh9WtD5)_GUEnL`5+K}jm?&B72`sc6z+=>5L zuG}o4xI>rGY)naw7Ax9(jmYWKjOVBS#MAzJ4WpP4!Fqh+Or5pTUMatmAq|g<+PF9~ z-DG%BZwbT0&)W0mj9h8!(KMs(dQ_t{Mh7_o`Ns#@cjFE*|9fA+$96nWWmNn}-@1HQ z{`Mp#e;ChWuiucd+Hjp(dqd?Bbfqc%bi7D0aQEZ*HdbWY|E%=C_O}cJbp?|uUlV)v za;QKS>F{8)d`S56d8p>I-(~C!_2qXcz82Vw^%w|@h>8EtHa>(_SCi*Qg&dY_{XFpC z&guw@>>G55n@Z+fAa(ilmvr@ihXtPGXqM*u#ANA3bg2(NWvpjlqS4epT8|iGU9Tq7 zjsKrV{?By+p-k43e$j!mMg^7NB0C=Si1_z4L+-6pRH$gVdOy^sFTFCP^!(trU~2d8 z&v?KP8dj-`tyTQQ^dZ-ooYgB8ssBtmf6WQVI=s9Wfe>dkJefhkx&}(UFiS{xEroKLZ3vG7Qy$cdv5< zZEz0eYFL(=iqPb8!|IHpqoa#LE?WvPs6~-DzjFU$Yx6WAn$flgzU2|5Eq3_Ib%nN# zB;-r{`3aevV>kDw+LvAoBr$mbGaDViul-<9^MbkQ+u7ukr@*#O-RcMMt)>>T#UtZ0 zle!gt+D$H_Pp$hOPrEj(;{nlZiWQEI^{q@}p{L;^Pbua5M6CU!?#rQuF{BR^0Neci z*7r=k_Q1Q{&n>^-0U~4?4k?Emg{hZLHHZANLckMlt$eBSF2z)e$?6Vr5<>Q;3rthm z1po20wIP%8jK+=fY~&x*`k$Eh{8+hh`d(=GWXL>0_uXap!lPr|`%q`Ui^GlYDhXwV zJ%TnvQL z9YIILlFp$M;{AK+k$J#*xvG!T?4JiUMPV~Az;RQJ!n|`06fU+VaPxa zB!CsKn7@-!gLwI30u2*@D%%4TP}uLp`YWK9e=uqGtan)yeGT%HdZkBj%FF)aVR4{Y zG7^F?lFBmg!N?NOVM$J_?djKf=t563jh*1pz| zCU^b(B0LOotad+s`uq-37Oj7BU+?k)jMAyEuYTDB$&44skI(^0 zPrzg<@Er-eCJ3^ZRsw-Cg}@Cvf%Exipb2OwIfnO>Yye;TXvR1Yi0qb@2HZWR|Kv5n z0BFse^;Zr9QR+EdY8C?PjVI?dQG1fXT?F90tQ=tn1u&zK1h8Bph+I+!oULL2?v&`{ z=KXpF@`b8#8hsAUIyovjsMY@CED6TTT{zAf+gu6<^YmN=gCl8#IaVR2%#Sawjg^zr$+V$kn2Ho ziozLaO9$V8(_0S6aW8%_{C|#PZH(Mm`CT7*j}VN7mxyTF@s?15ra`g|BmmV05Q!Da5>>ot1Sr0YDWUiDrQV1>`liEZ$JqLkxxoNr->HR$@8~L**J=Lx(vXJ0YTZ$szi9nZ9wL z@J^tY=3HcAiURsb^;(lJj${C~kLGROBv$ZswhZgc7y!xPsim54IgRgEUi=zJW&z2y zsMu3L-+K+*gjjj(zk`HOtyTZic>7}zk)mSM>{$YJC!RyNul%cn`QI;9@ocDdRdvR& z-T1px9Y}!?uJ8k$8vhKQI|6Mi9Oqmyw^BgbehY+RtUoSTO!FS)$^=LNiB9K;?Zy85 z36E&rvu-MpEFdf*5q=2_otRC){dEA&GNb=fn#|-iJR`tk-h$7GBQJoV^J5v1F+x~L z3;6=lEVL#=7}@)XrZ?d5W&gNH1mJJ}uZrd$`;`;`RXQ4pexR?It`C}z42|!`x8CMw z#B>kK1fDpThpJQ?4`Phz1&`TaOu8VaMP_>SGNNs1G4ZWv9MbjuBcu9m1W~NAgVAIp4&phBfBZ688rTM;f8f#J+#0FpviCD>FJ<^1P|T=lkLgDOMt%b$`_^vffi;d0zgn z3h{r=pPZ&Lqo9dP_8;}zfp zB&u;99kTRHx$@t?k?tzYgbmdZ3+vZ6AW(`>hA{r6=A}%w&y;z-8xsqgB#vkyx)fm5|v^j=Ese~q?eFo12Gu2?sc6nq`&{V6giD3 zyBnL-UL%>dTE2bo=%%2TMOd42#&(ABAQAl#TIRuv%$arpg+}ZRP?5Wu(3pS(KE%T~v87(Mienh*O zYv|^*Z&aPJe;b}ktVdp{VAH{*tUe^P3-(^#`Ci92Nfy69O+*hnc#IOc*WX61+f|j& zE`gx#syQp~y6 z(xs)za8=VrKX&QWjk~Aw-~ZVjeg0?b%E^%>iU$4(dt{w-WF#3KEH8OZ>@%gOK>J1v zLu7>4?fLrVoY(El@$-K3IOb8x;D>!iHtrtgb6tTF$hl;e)+^O;ks{{1K@K!tHx?rN zi6Xw3krH{I$6{}1<2RwNR@~`3M>2YSPpb|#T`_leMM?A7ihmtw{J>`GTvp3z@9cgt za8P_{%)eAN$xt|sD1#?Xu`Z$N_nZ5064`qpQ1N*Ec5i=qQ{sl7FyKWmI;0_o@Q@{4 zIa8HqL^5c>VQh!HvHADoa>sh-qz*+>de%pO`5M~Yc(vYzNguyVJ>gspz~Db#meZV< zIz*+iM_@|g#s+XA;!slK*VffV`&=C~_Qob-s(j|p$qHg?{8n7({+m>0r+09!!>L|NUW%A8#WQ+7+PpmJO~2Tp!vXo?DJM5CnuJ2DuF= z()ygfH%BAmdiJ904-L`Xn;;mo>k{eCYi3`ThDYc*3=o0j+xM7bt35w%gL9l(-7iCT zF?DY7)gl7Z5cSAl`r%8x8c*dtc7&;FKZD)dB|1fmhg1FLLNEv955(aw1UHc zwyhs_#j6}hmaN^|*qVOW?I1Ysda6a67NRDc0 zch$dgKVl@IZ8ppeM_E-hyYf}T5BUX8<}AX}*Mg!Rlwxffq~(l-aP+SN>cQRAmnrC6 zTd{oiU3F6{1F9cKv_Vf|is$VScp(;C?$yf{kz&2(+JqK=*WIg>vquca>LXoz=5;$5 z0C#&zy3Kch+&x$LEvTB6t+yxZbSFDRr3!hDUY-c63B`HCd56kWB3ncfv0e;yitcrq zN@i0GfJ7}xu^sk3NOQYVON=!dyALV88Blv6K^(|*mgUj+wX?H4UFsl~Dh%&5R(#Zc z3(`v?&XkP^(21)$7QXV`U^1{k&|{U+w)GG`I~A+uKs<(NVkOIz-VyLafNjHoWsr5= z)N!PTpp~O5EleK$_bscm;(&tl0>b|FYEw4NB69PD!F!&{Dw;r*kYYt4UN)gDL6%XR zz|{`-PFaONlV-GFT$@t%fnTi7i$TmZpJm?|{9}q_uMpxV)3_`m_$e=m)=>N7xOJ7r zLXuh)DNm>&>1&skvIUJ}5l6mtnWjza^E}LgDMRTzPp)2^GEvf`!VJB+vzc26P)aq?EF3kX?=fyzSs@~Tk16EP$MZPLXCb|^!!)~epI=#`a9`^^n)uafdT3Q z4U?>f)&qw1aRcv}cHig?ukLd(_brI2v5+z4gG@0Pp$lj;%Jp_eo1w7C{Nh=_W9F$w zKr>7)zcY43UwNL-JIfTE5ZnwK70N}1g=Uj%@s<`VkUc1un-@lEK5Y{;I4g7&5D=Jt zuS)P5RC9a4QlPNq_t#j?rymG~otqMphi_Sy^(-a4cIAMnt0xjTSc+b^1GSd(cj|Mw z0L}E8Whs8yAImjOa8x}GHQj;95)HBX`&Jiyk`E*-_cyQ=M88%O@t8D4{S8cQ?P5d) zN(6P#E|l8k`}IBQj^~BC|y8fWV90D0q8TbrzX#`#NiJto7~K z`In6SGTF!gpU3OpTQ%QG0HL_;$Kf3>%N(<%v{kVQV0)ki8iW$yZk@K!;FMW(>^4_7 zHhC`Ql`60I^ViLz^md-;*jR(wc0?HsQZHNflJ4-<>ZZWu*GO)|cwc;O$@0VnKUFU> z=YlRw^sfm-!mmE>hs_pOH(M;t*MUo<^>Zvn1KVpG)hWkd~&+ZIC9XS-e%0VM$5QdvR<;`pr#wfPWTVl4QjQ@l0?M!PC?b|!pesH-m7M1**C#2VsVQbfX4BIa zafM*9*PQI5*F4&%tfqyf>FJZ?`G}>RCOrCnk8tMGk3z8N=9qbD)_RHzJj_pVdbi_C z;QfhMBeJ4y^b+(8W7sD{_DaR|stO7r=76-l5+0?sel{65)11&;9&2 zw1QDlFLKnntanxsYJA}!_TX`nHzG-f=dO7Na{6!@72SE?anYP zyW$yx{BW@}gG3uhFxW##(=Yfnn1QpPr~c4tEjw^?`So z)FMAghWbMa#9(+W4ZCIU5&x+1c7CO_>7cp-=Zti&w~D4MUPVJYPcwS>eHJFTr!Uc$ zrhdXoo@5r6>jf4|zwrja*K~UjTt~l?%@Xs#Y(->0ZXA!XnP#r9f6hZIOqZqV=)mhe z_K&8KjS{uxW`Ni5mkCrXn{lg|&RJxVoPRtsEnImkqzm&Qe2IcrhTzzl&u?dOoB&<8 z!|cA{+4l-VVPJj{6B`R}SXKvhI;mjS#$Fc&E(Z*O#{I#K=xa~|*#Q!TPZSMVs*y9v zSGR+r|8?UeL(pdgM4E0F5VJn1u7-)ZZ2Cd#1-Cy5q%kbsU9DPaN%2~WdA=rLzm)?q zftkYfcio4`vP^_P(p-XF&ghwJ_jnqoUP|e$czA!Z^mMtRYRpcr4L9W5m~gl#7Cx{P znNqMTfL%NYI2mMjy|L;n1(u67|jf1-u* zN(i@mL##%q*C=^t@1v_KztYI(jHYDK_8fPElFpU`#sqc~A~`fmN5Q^rkIex~kIUOr zzRTMjqT;pZ5o4d1ksTIYkEdy>+T}sKVOisfpD{7#pgJj@z12!<^V23ZoxcE;A>~aL z#vtj}tL^+S{;`wU_n$t0%_&vSS6>Z0B|E!~<;!Y1aSGJbx(mP1)kFNR=dv@1*l5+9u*cxztrHjsP||f->xcoQts9+#pDSVV5+3Rv<_ufXP zDZam?T(2~mhGS;@BcGdZ*wAXkvumFP75casKas4HPn{3GArjTfj~gEWyNszWg4XMW zUtaw|lJ@9)NAcIzq|&!XEM7l4TKwo8R1*bRXnKtVvN(70zrP%Hky1@8m2a)w->}v2 z>zp4)zdfZGPQ*(bT~zBV77E7MeyfBg>YQG^>@mKv8y4rV>yb6vKQiCwvIxv zWYq|yrLv5^xGDmE?3W%br`gX%AfP4M8|hGLV}TLqGrDv&{r-*5LcOIBz{_JNpU05% zXxa>=C3~iA*@d5^-`~%wYJVpaaC7RnayC}DM;{73!z!vLc-h6q9RKx9JJW?~(7)9#CoAXH+}Zv|aTHSQUxTl4L( z_Trc4Gta+2{sm-uN^{*Z{+I5YI`6{N*Q%edN7LE7?fg_bhjtCJTE+qQMhQhZ5G2lL zw%`*7M#Uby2Tnnx>t~)a)%oB0Mqg)*FS-7-4h~jRxqo`kE%248g@?JbNq=Wtn5M)i zy5U0!WFlo~Ht;@uBZXW#B}Orp_x?S{6gmvv1S($~X^I}QE!b0R;U$p0GS9p|5UmDn7;1*Dhe5=;2MFG!7;r1CsIjdSWtM#;2`06?h!1 zojQWwVZn&@%UhxAWD(dHUVzH&ubC>R)K~cpn)%O5WX)62wHupx^b-`RPqh|Obmd5_ z&&XDdVx<~lnThY|4PP{;J-wA0s&h(SJX$ZtBIBoYR=WcTb_tY|k=DKx)ZJ`o_aK7y z!_^|FmhQ7qBM$o^iq{Z|o4CREH}oRA}7OGM05R zf*%WJKL)g%FCXo!!jL7vu)uJiaEZ~&5|>WhrLFnYFeeZGO^bQlW0=vpw7%No5hgIs*~L`6ztp=q94P*W^|Nz(=&!%5 z_qLJ&=q_|IudyevFLV+h|HTKa)x}+UEP^(n>0Whz3U=ovtWw6JAu{cKefLzVtCH34PyXtSKe&?5M0-3f-oPcEbyqhz>U)IfLPuEFa6w z*1A;LmDDC_7eqAnpeOGLPT*1F=~y(}wnlQ&uxDjpXJbvwZ&Jw!G3F^T5Zf}6V>*l* zyKIvSYjhPGA1~6Hij*2-36O9eYA69DB`$T$aPdIxYLj zAv7$4>g|GM0y-(iM{wvs-ouxB?E!Bbqz+d?LW;roNFCF5NkJ@0mUjo$t`lVl(<-lR zqqUh}v|~GQIH{l&Bpwp#?j<^m2qCPiY)4#JtsWXx$O}F6fYF_`d)NhbW{5?iFG;>v(QLRk_5ur3ijfy;dKB4*W6WOjh476h5bc|Ssau{2 z#|w5CfQZ#(&Uzu!AG|s;Cm4C-H3VP>q=Vs5R-{%u>#Nx_i;P*G1=1-#A*$LnW z;z!-BH>{8*6Zk21Mf-NuWs7wed88w4h|cxt8FS)H2VDFJE;(xp4)Y6!B-%BCm7?Q2 zA9xSX0b=KoI0{T@rP?FZSu0l(#_Kc~F_iw}B8~);V08{IniHqd?<=)f_omT)np)Jc zXR(?IB!+N6j$z-ePO3_`JtHuhIHk$sYrHH%F26jK_7_mnxOU&fs(tbvh~<8t zGbGl!{{U5(7vCx0guuiZC}$T2v_+p9kS-FhRvB9Qk z?Is_vLQ9V*ABvq^KuQNpS&8^c+`HvJB)173yRh~O(A*2G-czw@Rx=#|2e=ahCMoxg{)aI~m;Nk*Di$(%DBUdt;%I z{f{X>8>d0%Z;(=+l(MB6v+b_rP_eoYYN)v`uZZ-_^r!)t+M`R_4x#~Q)8feWbU^w#NKI6 ziw!Qn4TX&f!op`AfLw!lSbi$%HT*@!bMpYQ6+=;b0ma8F#Zr&~{fhfEg2KOv`!NNu zo02tDRqsEVlj&JidF1J8+e6anPIy7I!g~fP`6C=%X56jS)A?Z3T05GXhHrfdys>#awJ^VHTXrbLS&eOF9B@ny_aw2T=t zN9w`cNUa*99T1qP1oWS0B6I%n_s4dSQMT(;53baMvCy3H*l5@2K_4e>VJrRs*F&Bb zp|&j{=bf34OsB8lWi$r44Jkf< z_IH#wKzX6P{iFK< zm+JF?#js~Qd-($nm!4}r$2NbB8M zq8t5V3jlGa5&rInqyZ`1E#N-3TWPZ>GL^H!kaFscQSbRT{=u+S2#cVe`+x3);?lQF9a>C6_kUn)Eg$+&mp z4NyCt9cW>5cec0*S;1S(%=EQ9ng$!ds(7nQV2`i!{6j(24tgI4?hs@hZL1ICzLjq; zI@*0+wVEvq5)>YD+!YX#;yn(P&y5gz({#4kf%<-1#$k~$6b1civ4VNg)m~Y$jHXMk zVrhM!WKaBVOp0;frBpvY7kGU?+gxiqQ2M}tsOW5*bB{vyM8ngPEW!lt%Ua065Q-dw zaZ}~i{ZwYCu$bpn`5|RlpA2{Vw|E2u{IWECKLwd*6KbTLiu#V1O`C7nnBTp9VULKB z^&xqJFF&Z&@QL#4vuh#g6=vHNYjC1Q)~RQeQ2YT4&p8^azubYu&fnO@;;Zp@5kfAmGvjb z*`9eG`JLnd7MB?6^?Ne9duJV5}N<~k?LpZ4YfIXV5#cizG~ zG|cf6;By;W|8ht3(Y+gZrA4Gvktu|Y|FRF)wI2v-X*S=li(5syBz2%M5UKj+y$UVN zJcz);cuC%UZ;K5}Qu}z$qMwP~i6)`o^D`AL_+ias6l}Pxlu^SIX2{^`azae~Tvjb* z3%g$8OlrB6@|*Rs`^h!8%Tjeo?GGw@)lUD!^zVIX-#8p^ohz!9C##e$VJ8UlF^Sl69%Xnn7ICe}g&KObne?)*KwNFXIrW*w4u*^E zGTm+J)s3TBPBon2lAuTyQRAAzn<7UIU;x#N3z%@?qFnZGWG&PRH$(G=g~ScFzpna_ z+7(_JpDXD0=gU$EJ_br$;~jk24J9V=5;u9?5wZyjIgH{jV@oCsx!Eb8JJ;fZF|_72 zgZ>BxfssmJaD~oWUMw}G;UGQ5diM>ITs@ooH`^~{wb$qWc>1oi(^-T#%)7&xA)f%Y zU$cF`Gd@_zyb1M~c(!AGt@4jE6{f_HCK@tg?+`CQ*U z+xU3&=f!Nxp;@fkRlT#ZiDVSE+Q~i;G-0zEK^yAXAPn)Q)dKu0> zpV_tC&QBpf_VgZSHR$w7x*(U95v7%45GwRnw^PS_PHC$&>nF*kCv#%MLj z&ha`nE@qAE^3JUoKWg*O$J?>&L8SF3+sd^rORu9^v8(rUmiNEoYWj)Yb*&oV^3GvQ z)nedS7Rv2o^ovcnD{lC-Hi{WgLb-|njX6ZCkZm||QfuQ$b**n5E^7}{oOI?EzFVW* z>*dw?G6Fd2PFH(oKfoUyoyvXXRgpUrh#?D2fD&UZzi z;;Q6hr~`HcR;Qo~w%>!hY%|-yFGvB0rqIaFo&=fb!SR&cODo3eIZ7!A%h=_i!T>$- zB<4<|kmaoawLs%|2BiKj9r2c_V^XDWZ}rHL);wk4`ZhH$>?%%!_a_LKL>-tOxs!g= zzv?_w(d?TKiKSZ8W5=tcay1RH2ssEY>MBVn=gpRv6LuowQhO?7VmV%ep5cM5{)2P! zTbtRNpGT#ijC{l@oCIXH(_tbotZI}XS`;!^U2;!s!VlNIVHxzhC7&Pf&afbOw6~w` zv~CKHVo~XO_OvAsyO*H{FU@`)Ezk*iFa!twZgCQjAK^m&ynGKIGvr4o9slXfuWiKp z`i&LKsL-Jfwz9GAPt8lwITGclDTDUr&+o0nreix4e%*~R>bUHLWf@Z}8n(5)h`QfP zeKP2h$mmoLv3BTs(+#Z&dp`vM`{x?Wn{yQk*vKk6$+(BRX#CnOa}@kH#yF-(8?^ZH z&9MNOo0p#GU!Sg_dvWVf75n=>{4IlJu1)g27=Hez*txR2l?;Z9FB^OhiLHkD;53#o zp=GS|m8bu)`v$dBwYi(u@$*}0epEUvyD8`>Ux@f`GZYtXc&uDZI|V~%)T$e&TZt@z zj9fo)y8KXNCj9ryBMgxmbqO<=*3fLuknwTj_z}tOQ9+P(l0^Bc;pt{xr9M~vd}Ydf zP7@nBCYo(Jo+NXe<%DG>WZ}*JOx`7;LGe0_ z=&Q+>Z+kZe!z#B&41{4pyF%W$pFB`~K|{>&q4SKR(L9*ff7BT+c2R#fh|f+w1t+BK z9L}woJKDfQBT}k4 z94)joXXRmk0FOYq-3jYfJoQ0I!Rml;n29-o0bK3zTDKgkU~DEyN1KrZdPpBW>g5=A zWaK2ncnzjS?J~yrS9tl^PEtG;113(AVQU`B#mr>3JV86O1Ewi1m4*n46+uZo;0>Ck z(<+gTbe6j0*A5!q*n(77WsKszzEnm*yzE*pI>R!lK+SUYL9PGo%X#MI`o0$Di^g0&OB5ph9AXr+XRfDoF1U|Pc z9`$0$|B2N0@FWQH^_D?#v{APgwsfd*6FsYc2deDRp!>VSi&nFvC0rbbGGh zu$WyYrWRs}bgG3s>%mi;3-Z?scm@_kL69{Qtn#t1#XLL}qBF2?RX*@n;P>w8+`XnRa;TcXR0;SG4^PG&l$7 zb2Z@dHDNg+NH$?QuRrGC^-PQZ|EP3Cd0GbyD5kla9UbI};t5sa%!o zeLjs<#Jc_Aiq%DcZYv6vn3S^JIPB+-A7$FbDpwZgz(}Xf9>qB6q92Hx8dUmaP)83~ z#4K^Y7a|UB{L~sN8Wm-gC=-PtHAjL?+D7?zj8L(YXSC3Ss>^`m zA+c>1X687FMGat_{O@ugzW`{AY6Ez*{mP#zPry82I*CpWBLx8s4zR$9fKZ>o8L+){ zOAa+4mjNu4N`SdWi!u=NoJkYpi2=~ssotmS?=>VZ8DF3i?h7DNwReC8{ZAV*p%i~L zVE&s2hWT~z_+ozeqZ>N=9|k&ZfC72;KK4cqdOWlf^}l8X(j;$mYd)G1&hvQxmjT0c zd4KarD-6oKXQv{&Kd{7#BfI_ZHEdmCB0_G5e zIKau~KO*`+8}I%^xTF;uVA}LAMMI$tMohMEJf*gIX`{}@WmOA z%MUaZUWe0>0F3+|6`Q1J=W^f>u+;bgVBhy?bQRoXmVij&5XkNzTUJq5|3pct;o_Q) z&r}dKTo33B{u|Lh9#z!HqH5jolY}~o)O$zGn|=w3q7CWhd)&o;mEz(eNb%BA@{=0>4(|ZabrvFDn5k~+As+rJ9D&QIA<6k< zDjZNVs%Jje9s%Azm$yO z{!Is*C59OJPL^!_q=R#p8(QLw{x}}u(sM*ICgbHYxrbb56 zJi!W|y+ScY^0fl#ZY@DnIJ*}T1eJL1+bOxNS4)ODs+E#NK{x+Zqyr4V8D$ctz@RiEu zd+CD)0pqt~AX}TyeOJlvY{^z8AJ1}JV5Txr{ECMpqDk3h_5~QzDFZ>O$&6+p$a-GO#S$^m)(WHnGbSaVwKe-F_x&GtZP2=tBg$5tXSufW?W3CA@<0lN zYKp_oj#>?cMCK~vAmCs zE{RT|k*Y%Qhn;Z_D-|QhUA>4wyN@b zW56=qinAe!IhE5;#Z&_b6Ghpp{-zO)oAP68OS{&bF6CW~&$?gf=kbHF>JDgaBJPE)tIs>z1W82j!LBa0;H@2!h&HG0nm&NKVr-jdc+N$x*1?pOvB+NHyH1ZbybY99ztfXHn0|JLN zAr3EU^S(RQ1T3(vAFn}sfN^yOV21U%<#s>_VngqqdTZ|h7K;yqwJaMiE4Yp|KBu(pqgnh^*OE$U z#XddBK_`n^7#fzk(o_QfEGze%i6|)SzvnYAgrP3&3-7&c_6obviVZlq=QWVuKvdg^ ztBIa?E#|G=^q+ugIP3x2YWr{Oe{&QW#K3HFm6hs?YrUdlplNDv08jWc6HA=UYS@TLoL$N zOh1jAs@>04MIa+nXjh#;?@=XiXi^-iX1UbnQ-k#xt!%ck6Hx3CGzX`=&lbwEWBT>& z8`)cK1|g?qjBPomQaQ)>Jw1`wLx5k%7{i%Rc{-0Il92JzpK8;&xm|5jy4AhK0Bl6; z&lWbUT8(w%+i8Oy)(}YrQs%J!(cjjt!#vcA4m>xsDo=xO2#k*rB)$}rBV<(JgfVxQIwNw}ZTp&RcgZ8pd zDHupPC4te(Q0(`46!&F;`a{f zIIB;GJqlY}9e0mqZ6B!&-2O#vbOhkPUvA9i-_LR2w|S1?h35kc$27^$1+U|I<(iKd zk}{HfNBEK#wEJ_~LHfn%V!k9vHf{Ud-?^-{7K_B=P6n%T-tlHk~ z*M0dR^5Xj(gvqp+uhO?+7ceaRu%*(ZM^(*@fnsJhR##O=poE3!y!17ymK2>Xfzk%; zu@NNVsl;48%mN+PQIeI{vyMcFiT>6AFz`td+VEwVVs`pm z()%Scob^^s*V)c4<}>PhLlP1;JIV#J6k$e2fhkGRGvz|T39}3Nzt6>um!wFfNML*v z)=Dj6Ntj@5gksR`<;|hueKpSauB%E&d7Vk#G@dt2^iIQsE^a|3c9|LUFUU zx!FMRFg$=S*@`_@^*)&tal81Ac9YBawiGs&BZ54-q{W(k`)|DoP^GB?J@)d`0_%hu zSE=*yTy=>FacBddqtLp+m`t`E_uUE%P#jB^44Vu}eiQKg^Ck|brAAtz9Sn!L zqdg5r;ZRg6%N8zrf|3Y{iL0P#-2de#Vq)RVHvoz;c^sB(b>UnWttm`&hcYA*^MW6< zukFgI&_n+qEh~SafI@kQTcZ^i;N8gRdJwMz-n%H9@3tRb7ylb+{ZQ6^O;)z~Zv&3} z>R!!^%|yrTa!#LE*LUB-A_qP-)j1HkGgYUj3efS|^d?N_aJ zg7(wzs&YyGwb))w7WTH@xdFBiqRx7*!c%Sr|5&f1!p=7{fpVMX<%E1oojA&=BjZP zkCp)nrOd%#g7MWXZ4cc#zL375-*Dm^X;ml$e|u`jhM5YZ|$db zgJFlZc2rH-b{%KyjZ{?i{8%rC6lGbKhOu;-n`2+-q6SAWjdp!;qTsL{rI5h5RWMy zn}oyr77DmThW#0#I{*94_$H#P%x)Fc?g@`Z6l0-PT2=AG2MYgh_A*ysmGwy(+-@bAZK zP}@ZoUJy}!SHH`2b9vkQ#-&v!)zh|#??ywwS2j{@9E8P`Lv=zNDT(zMm0p+QI$2R~ zJO0ac*p}E=8jVY6RIWHQn`QP1A%*X7Zq(_Hs`d6#cW-Jj*&OLE|f`&4K)yKYVRFOEputyY9lYQzDH zIwzh&0Q!dpVpoe`%=7`RIG@S1dvkLjgH%#t?z?l{39_kzNatVBUkXYdX*~51G97`- zxC&a9$d`_O0wsQnQ_8#E4{+`%X=)zL-AKC=XeK)-1mh;WKZ#O+=xrYxgaflIGMOrq z`kiJ7hgP|IT+-$IQDX%5e_?Kwl8@q-an;N=6PS&L@2Y=p!9Mi>BG9ryPiXoWtvSoJ zDPZO^$- zI8mx`QU3k->owks)7&W)PDN~520%wySgKakWL!o?0ggFAY|i3)4OwRvR#t~4)|?jv zecNIvv!%ZAglPCY6U@!(Qa~!hm84`6q^XnT)wE4(+?%l+?ChVEAc_67?Z z;nJ5^9M$N?T9B=rEtr*2;y+Ok^0CJLsG+9@Rb>6e#V2# z(X~2Rn2%LuxhnR)$#92VS!kuz_5JR;z;rI4wn6r^jm4tRr#%I5s?e4;UBQ6Vl!kS; z^$V5n13F~(4*^HzrE}H`rrhU;HbWwP;e6vVZrv>oNF!UyB_!3)=;+mf6OGapQ(>r3 zYjhwL-SsQc83kX&;X35LGnfw}XbewCZGfwRa9I>U5e>fM{kmzh=vAaebQqwLPqXzgeYh z|C3I=v4d%ovxEfDcX@vBN0C}%@e}9QsQFRB*!ZJZCGA5PkJl0YQJYzztl!%GNNWpS zAn>0V*#d;9=DdHF5I9e={yn4rR~x;bCy9|Ekq|EgM82+_{;yNLB6eD(s*L!|KUDwo z%q>DyPsT>}Xa%ec*2sF96_LNSR56U!f9i8zTh&-lY_)gOcoSaob6a_g=-vd=BtvZ- zXdEfyQ!`A+fnumSANC!r>ARk8x&qP&ahxgPTGf~s!oa{FX+2u#GP`^cU?opzjAr7XL6;TE z|6Dj1haQa6YoQycSXjwohnD3QOu9+EMjnkfB~oCy{<9S4AhVb65%+e$4;v5ow4`5L zxSpb*Dx-~q&g_2>$P0BLBBTA{3uSq&L$LWYl)_?f1iNSpzLUB@z$3~kax!Z3=Gq^z z^CRq;siQp3u&C9yV1n0*wQ<*csP;+^!?w)e&M{dv$eL;2SkP<_1$Sog zjnBx?`>vrDc8MF3A~UcdtKP45*8H4u+;=A!wl=rrd=pSjuy)bk98r2I|JmkWr*FTr z)}!64kx_g$cu!3wjhjJ;^Pt8-wb9n4zhg=|vt<6iKBKZB$KRh4%I3V$`)BAi?gmql zE~&FvggTYj@Hgr|?tAcm#qS<@FL*LX za2wFv;mZ~n1?xert9(IwJI%j4B5DFouQo}s7J;0L$rR9dOrE=*!h`uBGrin)1pt{g7yb(7&J z^-4IjI~()s|L^xY1bDaq_W_YYMOr0t19q?<^}ei2p_0OHyp1eQyCjieh%e_nfsL(4 znv5P1JOgo5v_4ATe&OypI^BO)Em%#I_)IA-Yh02ql`gMZ@{H_nmu>g|x@2>@ByuGE znHxU0_qT`sQIJ>I4)ds2{xs|0;FadX<@_#@jnftQw^XrK9gU#XbhyH)YwXL2)yh%J zZ@cnWv#lWYVZ|ughE00WlZ%a$?p(qw*uiZ?Zv7hktScXn~l2AuO!(Bipi8w2aN}Gf4n&d|j zl5Cc`UWt|nKAY#Z*oOR8IKyv|mP-PVIIpiw`JZ5HFvh(%Z0W}KGO@2qTzPRC6#XID z#^|vkFk(-Nh@5oDX2=v&GE9`?7nQRiA)04)gvgk#QUc*22=7%9!l`MH~5R+nG-M~t)t6NNQ?snX$ldDb{v95?oFm+KE6 zpF&05elpckA4HXBp^|-M8x;Xl_FYqIEa5>H<~3>&R7hdoFgJSh&(<<~*$CJGHbUM-Vpfz4#~IQy8{{Y@nZ=#v zX=G#i0~AVcP>~8-j_lmM-ZFmD&!MT1O_LNBrWv?Q(c4AV>@*VJ7OzggtKfN_BK%s5 zCM(mVh*siF%{HV|pq5VX#fKBYKFoE+DcE!g+=^j`Y4W07CKY5CLNc8F(fhQGme;`Q zt3$C)2J{P-AF-L#M7~vd%wPDU&wjGhhn(aTTUYwx+vr1N#(;T(mc6J-K4{ZV^`A>0 z_=YY|Ll3@{_}oYl-2B8nAr6Ngw_IvT{Y;JeMG)_9a>L){1FyHn{I*hIlSa|CDOWg{ zT@*}G(o3;GqHT`D@pji%B zFynDS69Ex&4QR--WP#pV~e$wP;w zY)ASPS+*yvukGWHQQ`D*)cQhJoC)($Q&$qpWn>x^3@YU{7pqnyQeEOw1CPazLiGeeqrFTi|9F@ zHz;X$B1PT%Bq3>$y!dP0<0mGeWlCaV4ig!z*dKpV$Fi_7==pNX5kmQBU zJW5!mX}{pn-!`o3bP2uOlt@LvO0aQL=^v)UOH7hH$QCV2YsR@-yR%6i!Av*A+Rl%b z$MC03P@I!2TZ`N>vVyRjQ!_gO#vp=BEC#8^`X&XIF$qcx26^fA-0WA3?RPMu!f&y- zc=5@QO$24oV}{iz1$*7pU?{$&*l3*cxydU!hjMzt0d{*48N?|fE?Vipo(FO7N%W26aE;T7k4iJN# zTr`uBSNJLTqN&MQU7)#6zlHNPIFWph)zPMk`-CdTE`%^}H9LYK@=^J~kj3ZR&w2Y; zzeIY2>AK6)H}l#>-)NuvBRv`YYd*W>ksnwGsx0+U`pCV$ChLJL~YIwZc6 zYzg-_^wMY1oBjg{OIB`AZxP5;u763r-7#YXXSUC@lSdR8WQpI^w$GOpJhxvLIj#ni z?unp^nr}5{I+s?%OJJW+`3EPf_llHYp35(WU7!>^kfW9Yv)Q;OU%Razo1rlBBMArc zz*%aIE%o$e*TH`jPSyz1&*Nm|mi#FF z+Zrn>O;p|H#apcvZ~YA+86=RxeCO;C9Xcd7){`szd?wXOVMYM_TJWA9y6?G4;wj4p zV5(Jfy%(%Sq4n|UdmLerXi@V%t{Fqb3?}PGH{aaVne+4(_GIo$RJA>Y^O^-)T<*KK;QAd;V*WCE{0XV9ANd{ZK zZgOp{dHJ+C?**fcIr{?KLE}|2s;c_n{wajYp!Vz`1KT4-;68oWu-Li&6~u$^@}(x> zPwp2wmfu6I)Tk9K6I1WZ@*;N#Mu!C0Uw3I|jk;StaEdl9mW&j4>GQ!2YS4_qzqUY0 zon`2DFzycqP<*PVeHA(arN0_a8FEAtam*}u7}=vyd`5Zx@wLTe)P{p)h-+nOoaF5F zDP$WdnPqlE9|}UYRi}XsTV@^!Dw+VU42#)(g=HOrA#L|NO0%0L7j506)pQ-S_%$X~ z{ruFZn=551+VQG3daJG9#DBd<4^Qgl*o?@kmpK}~k&-5O{DMe0T%kz*OJT`4i0t{R z0S_-Mwx$^@y_ZMKdrX_To9&aVXRSY1Ic5a%L@H)MV{x97?5&}r+iafxZ^m;a`+E<+ zE&DC9wV)$$+R--j<^3(G@fCPsQS%6&e2d!qa5Eb2E{KGZ5J`{UO%vhv#Y;^xb`yj! zL%h+JZPU+2K;kfvhfxk4c5nP#*UX$qni@Xv!>aeIhp&o>RJLLQEmg5KA7YqC4OgGe z^dQ$s>x^{_57qufWFnaFWp^_~khD zM8XsCZ&&kk-E(Rn0v$k<&~N+=ETr|JV^J|sGFza z(a<5&5~0%8!%?9{8L-ol!P~Wi;uV$^#-LV&!%Kf~^>1x8D>!-|wE=bnnHy*jcX!;=AStA4Z3^^tx=Bn7)IJ#;xPQ zM)MQas=b|5u=YD4xX5J-4s;jeTL!|6ORcxv+Udig;48EkKI~UY(QequEJiioa5FW& ze8}q;n+K_k7TbqVjjpUS%jnLZFl8^J!ynYPgkpXuEAVAygv*C>R=z zt6}wKod8Ynyi);x>oQCS>e552jZM+1RU-UlSipJstkN7P_Ef8Oe;FbWP9J`~+a?mN z3-R6$Lk8=Bc?iTT)=3Y8&AhY)ounp8w#9zvs`0&w?u9?9+QZ1WQ)NB||0rNj+Lsf4 zthJ=@gSN;{H}|snVTJ9jz5T5^fN0EQ*)M%~YvlH@S3}HqykI#^;!$fSx#`AA9X^$m zD3+GaayG({AJi6ICG!)hsCQ&*@PJh^*7yf+H-=8`+v=Orbh&9uvgd4R0uc=S$T7%< z^P@h(hh5AU4&`}HEBPI9=*pp#+lE*TspTn=tW}X0>L8rT|Cw5iT1iM#DF!VolK>ihdNr&Kb{0kFfEIE%(g}`3JL!1nr%u(do;* zPggJPy)kXH#FAxW)d zcK8mO_$x#GODXn{qwmgh3#sg%5AmaAvjNW?3t(#uhY4wAMfs4n;I+~--Tb0pMIO1K zK}bO3wf)AVa6gj{*yws=rV=g)!yLAPY~Fp2eA`t}$6ZZ=gov2)@$V;v7*|qBOd9x% zp4uK7J#QbjQglH0EWzHcJRw=PIZ1OzW#G%rf;P`~gn<9ALO>(-;T9xJiwdwHqlWcm z3UMOyaDrZJEgU4(OC70oal9}A=c8Z>;&epJu(5et5qR%FK;z-qM=6`-_ zeq#n!SU+i_a2ERJ?ojMFU2U($hKtmQ8qo$47*#E}sY2ABN`%llMz5&v{mv?$#~!f? z+=#nEYiK+1D2bP0S4-ng*ZTd5^AoLA$IX`DsiG8w7t1y9X~zi*e41ElJT%rN=W3CI zTQN&&(Dmp1OHz|XBs;=FF9b5p}TWk$06vJm5zt@zgW)5U^FZ{kq!wq9%8i(i=4-8P)`nebg4>~@`d z$J3F!8wud|zNE;_pvoGn^3af=3`til1(LcMdj&2yieN`c8XN#6dX|Qb>}KRLtZP1Z z>XFSZSgP!IDhDBRbfQ_R^VpY>j;4dj1}IDzO~xV`D`D0(<#3l=i}iSd*T_qyzf)g^ zPp)De^>D+z@{D;PUvlq7@Ta>z}yI-?zWIF2f)8A{DPx zX?Su+7V^{IfL!#2n5)4?zki2gzF03Q`vD($4-4NW_hf8mJ5={l;-2>L*CI>)SrLsh z$jzm+`(~({d3TB@2=%_m>nMX|>#vH=2Xo2c=H;67#FdrV-JVfO=RzvVH&|JFj1kdp zGkAlnzQ~haw`)QwN@?y{d$U<~6k^t0fkNjo@!g9S5jp-m;~wVnXJ|QEWH&Q?KHHUY zeD@;~$`|K1cS)^4bXYgq z{MKi%qZ+cp>i8P*%ngP08tM3Cp51V-bLIDl!KAWLSnkcib#EnH)|KUa?H5XA*Z3+G zf24YX45>Wt_cUm7{u)D>jpx=g%0$^5-p zHeIN(RVXVo%isz=UnvndB07BDBK^Rja=d9O@aam@?|dxT9eK4RkvcIMf8ad1{=kwp zlPolhTQiu#Cq=-q9!-Fr^iZXoFPU^iX6J5@+F)PVMe5AJNKlS#@bZxuh#wNTmBnmw_ z_t2zh9!Na4;;4Cah;NC&YMfpzb+ZkXX9K=-F;sh zAHv~dLRMM|?t=?YR&)LQ1n+476~YJx*IJ~XWw-P-$RmrfwJRWOJWM366r%X6^j%z1 z<>zeDlUFmr5YloZ$$D`-`Xmv{n7}J_ilv`H1}trx^Frir-emEksG_AR_H+;;t$Mtk z54-q#)G0)iHx6=%Y_o}v|K4sE6|Tf(>|=D+#UMl;BHbvSv}J7)H>(IB{dlYB#_}xLS8deOqRoG(*TcO*(pYD?z4P_W9Nd z5u=9eCQ%K!SO0}$csG%KXuHNGojH+AjSs#k+XDwDe34F}dhgRM} zxRrW2KMZr1GKpX{x$7|t;>ptGv+56|M8ZaTy5`$o%0nhRiu(I-Czy)knO&^OLu6@j zbcY~j|Dvvz3^Q8>+Cfq)RXAj!rM<_^vSO?#-^P=ER^Owi(>{An=D4eIMgMIYZ;mp!sIznOshz99fl_ zMO87t_M0#y=ES5|6HG7FdA~H3Aw~JJD0+|bUV9-_)}Zoen1OwMszve4<5l~5w(J`f zFl`lAA6tuNde5u&$A!@%P7$hYxYU*iM9VkRkPw(kF1?ZKT*j`@q{BE9d8HeBSp$dd zBp^HQx1wFZ#g5_r{fG}FJE)1e-mfI^bR!3nFau_ZrqSa)Hs+a9KjcIm7a|jxLGiN) zmqP?e`toMxYm>$9LH3s$8;=at#vO;1;yCh@RIV)laMp@4su4TAy1}oeJ;`egtEyTG zJOiIQ+pkuzd}C$x`L;LnYRJO- zE@GayyU(N2+ZN0uSc9d%hYHP?OE02%BnszVAAGW+u9QG00k@gI8xgTnsupU%@2^TW%;MdJjhu~`ZbAj#ERQe1m5 zbrgYA_^q^sUd@vG$%15x_Zw2}`iO&3U4p3Csd2gn%J>1Qa+MkMHs3Y6oI zybMBGPyUX}(ZX;Jdn1ho(w}kruOD+Bvj*0{%msX>JnagdG@6*lt*HV?rzJ#gEF>8t z^SRS+$1YiUXyuw`;y&KgKVSXg*JgQrd54f)5Zq3#1FQ72=cV^8Q+CvNN zPiW0id7%pmY?G&&7~P@CsFv+WakKNTDIImPq?_fH!xNQZ(}#MKNToM+bx4yf9}{3= zP*8|;WF@pK7EEpDA$gBI|9c;wPY@PIw`svrcOSE-NnMx9m&N&gG2t>W)VN+Y6gP$- z$JVq}iGoFo@M0}0y)3xn@q3;WIzCP6P=y;tpI<2sEZKKdVj_@$^-3?TnP2f`enL)u z@hkRZ9I*TT4kWl(o%g^hL~GUefE*5*QBS}7zLXFxp6xCCr@AIb(uTah0z$Gg9YN=3 z9ci)u#|r@Ufdq#jpY8f4?3m(7g!qivv*sr%C&5cXM3kf8?e99=v&SXx$OMn@PuoTG z0(KSzoB?ti!Oyy>j%yz3!4zh%jqcUlu`~P2rCz5WcF7W>nN9Y&9_%U{dJu3%$v}-z ztKG8-+^h%FxN>vE$U?^iRikV?Gnd_6f> zm&4_1$~by&^K^#Mj7Bs2)Q`WL_H53%gpOt1(SZMLapDzS*Vi^JF3c0|xA{Sen=1?< zs%2jjW`=_Z&VI<$*iVVOi)-6F`|FB!n9SBOeu6O6)g(}AuVF(T!vDB~)}m`?d6O#7 z7Jal@lX-7>;gk@gx$P`j_U1{-4>(V=i}|b#;aujiQ#?tL(i~rmok#|1d@7Q)%RNb( zRjOxNC=faz`?-@JgIiRC0dEBAYEG*krLxeVYep;{sF@56!dQD*HUmrhjM?Yla9V<_ z2We@s8!`P*cY1`gbjOTRIwMjrf)wL|1jXpig`{khQ+L^C-?0qytAUgG2Y-GHC_Xlx zT!lJUK!|QlD#h|V=jXSh5Wm|c!O>H@C9JXInjMeBMJ?#~i;fL-1H|g+AZOda~WT9W{ zSAy`m8Ihl^U6Na1uKj#<>6!;caT&x1a?TuGHB`h*UIUiS{d|9Jglz{}UEIe{8CWx( zO}0kWDRG+p_r~CjZV4C0+o}EVv~yUS`!Idwz{>@mtL*m8M7XTNH7mLWgTkXsvqf8Z zNZc+7!y5?amneAUw!o@`u|-b{0zPT$5D_RX9P-z1hVnxNL9@awRfo=D!kc%X8QN{@IC?9e+LJF2yZfInevf)UJT6+guo z^t=8nXT_<-bRl@n0i9CnY4WF^%0lD~jH~^<`Mlq$neHKQ>Mo_`+I;cXsQQyQn$E>3 zxldbpzg*pq=Dh11ON}(RST#6yG(O!E!}}K|)YLv*NIAdlnE_8j(W>-+_1xUDFKrqb zs~L3}y!&|4{?RW=z4mqu9TvWnHs4KeM(a{0t}8}%>rOWBH(3p5rWSaGtUsev3^Jy5 zB+^*L!CtN-{x+D8+`yn|TFH?%jMHaT$?g^v*g<8<;CjxDV*pC;DiD8@bkOXH2HHWn z%}=<)1+>>v$8?nqoZC{@Gk*OE^G~4i{17E(1o;&JtGc0u>?uSgjB;gNLZm#VS*% zlRhsiw3-B3TF_G~S&@6Q^RFhZ@6&jsbst&>LMqgI8XU zBNWt+_C|s;+*vGY^Rvwaew+1?t;hi_!FZJXNq3ujsQnOX=uUIb)OCLOAxzPy07OQU zQhvX)j$;Nr-)e)JYZzcsAn13wlq)Md1ejkktofL@)Y=lwm(!#2m1ScbAAq*xknX!a zQWaEro0v}g%(sG&a*9-+MWobaL>3x+iULMV@ygxmA674! z6|0C)u4qons7QY&U(&Z7u{_EFv2lL1gA87y_>Z}D%9-Gj;P>Gm@UaH=Z7MnVTz8X4 z{&74DUSiX;jxqyx(383g752zVz=GuQ=hY6#gjR6b+R2S`?61ZI>>$K*UVAL{-z$r*ifZZD?JE8voK&``ZN3Xj^NR&vLQX@*hFXq^bA$ zp*LdCd=F8)IAUKsPd4Q5`S0=3+V0YGE|NrQNZSXDsy@&)%H)byKTN#Ze75)FoFq1o z3P0@1hTXpggPt`kau5uuNypu!sFU;q9FtmWsu4hhw7RKzs1rO?Jrsx3sGjUh$}4tz zLG=^NdaHllZLok%Py;dT`3Fc5Pp=CcmoT|wkr%m}EzkX44SH)5^gVw;jb(=SN}Gi3 zAPD65*7M9$NLX`-zt&#u*T=A*19OcEEF*8gnPFN=_b->VxxpJNWTZ&?2#A%^&j&~> zvOA)?`+Bx?M@O^2AJn$DWccG4VlapzLJ?wH377!6P)hL2qbfWYG?-Kx^$kLP0{!5UVc{BAv^;Cb_(3=y#GtD0z7wNP~8m13tfP zF_Rr}XGTkL`0)H2<)sDV?5gC-@HIA_xd4UH{>m?#-j8vd=0?=mA7#CBMxz9S#EuIB zzl2jUk4qM%oqWFKZ0?iGj3>LBr?xTeJm&NCBo&qJ@M zigW=3C)p+mq!!0Kp4KX3VEy+M!7AD(mu~FDT^)zR>-uwTtV6ZU}ON>a}BtnF6BAq6YEanP{(t|C&BL+Ky#rVk|3e z6ChnsE_C7WaB2y6h{H8Mz8=6_^yo!??Mr|`(W{e5gLn@y@(3;0}DSyvxmJ z4lD!ka$~%>9(SUVrVF~{r-Ds26Ip$O*6P-SuHHolzD;^tZf)n*KR-;Wi+ATiis4WA z#^R>rDpkT;i1h2ni2?1MJ>t8aitHHfkmB^~up1a2S%0+jfpa9S$2r$h@$Hz%roSpv zy7`+|-Q_V%bfbQDcy=3F2A>V_*YFh8mw!I!@kU;YN5s+~oIQTo7xFl-W#3;t&d;YU z|0*ZL7bTN5{FSy8^AU$u#2OXCzbAfcca}V~n0-f8*)NWIhf_Je$_Hsct$kf+hd6EX ztxnS&m%LGK?cDDlaj$4|4M;Sq15$|$7dbbis?iO*Y*?o@44)hRs}pp1s*Uh;s-*hw zLFPQ+-7`9rJwMdlK$CI@#QsGJ+(e(+47k10=lb_VNN4q*BH@VRKHUB?B+-E8-OQ|g zzfOEw@oj$pX0-5{U>wu7RYZG{$+?h2>Xp-TCBc?KWs>FPHBMOiIRx~au}+}=_i%hp z8M1MV4|kVdgHFr@zJv20CiaMCf$+W6nInU&>0Gl2T$2z;I}V~y#!qP=#U+R_%b6W- z(J*6q20ywkxlN!_3_31BayHzt)r+unWEX7lKZadqB7QNFjY$DfMY_Hoc`;KQE^!pL zW&s`!6L(fMYYAf#{r8M(IKGZ>-TP46kYn0A%s@OYraLt^(5ot&fkKJ%xDJ}vbUc?K zL}$q61*jLLOar?khl>?+0@QDuLS6OaC&g!5`;d^|31fg3E(COb@) zG27Qsx%OVhGE`S!*>#E~nF7t+LNQvY4FkcMDXtzAS7RSBqtjED)s1e0(Ld#3IdR_! ztm1}^Y9LZW?wWo5$*K&kHZgTzGk<)KN^Ad+gl&ZW9PcKLfD5F``N+~7@nXqxyv@>> zAF6u7nB|LV7jxUhz{lL8W8q8*cIndpV9!{S}EuO>gp4=yQf+s4A^ciBK?4&4)G2ckA&} z4vY1FXZj?OFC90EVmza=nYlu8C6|#xzaFVJ4DiF>=c8G|^DqwzxvJJ(%N@hgo1D71 zrVopM_&bVic;Iy)a(yO2kYyHg88>ezM7ZUGgoW`PD)ikFqMgA8q{S?JP+cTe}lq@OV!-r=h=~v1Rit8sSG%P>X#A z3GZ4?kc*_>^*A|n*g!$wq0Lj%e}W0)QJ?NEdc16rB9db48V)zkh?{KJqLs16czc3G z*VX#2aRN&{&|l6G7QrZV!P|;yuY|6oF^w=J^V{{;oj=Oj1RBlNIoGPIAsKynSd%+b zrq@wec%MftOkM3Ro*Gj+{t^`)(ZDP)OQHCYxaS59?!XI+kTM-K5V{+h9?L1MyNWRI z=MNNj&M@ktGEY1s)^rTQu3*2DI2@j>qb11|fgD?4poybcDrNj8H{Mj*>;aZ4eB!NO zo$Wg%R*Uun@J(FS7NN9|^EV4qb9>ob>ho->m-74UQq->}hU#v|*O7_^;waEWfsSaW z6xia1fVHbN>ai%R(>gq`)TpWxvh_9xKc`U_Eg2Rc{YvSw%i$#m_9A(B^;vlT<12}X zIAY1~N(kVTP3sFH_3|P`h0@UMM$TI^ySux)I}9`1&-?w@-LJO# z*Azt;P0cxdpIff$a+@>jXt+)7MlB}=lMa7}SRiHv{sH7A8%fsV`Eq~wk z-7Q$zkBSa%y@-}gb$OkcLDR{fyi+^xx(Jy~^k_y|pf-n?5Ay%NO_$1jz6 zu;`h~el?1cJ8>H5>?vWCRrzri92}C>OerTk8Z53%<^p}KK5fhjc;dxk@V=}|oThjg z1oa-!(;H*+w!NVr7cOOBy9uBf_==L~wW%cBBic=)e(;C;gH9P#uM7%PyXt@+;f1}W zMRC0TDTLjr+6VIv*+R5pXhZ+cl(z~nDC63zJ`%!iFR5 zx-`b#(M0Bz3ziT z6{qIw>>>-?nU;@Iz%MLn41Bz>&WB*QjXedWJ%ZNH?gmUPnoQ@Kk4E|g`16caGCxZENt z{gNjJhcfFKT=zVW#J}+y>v;4h6?i-XGxD?K;?+8NSAeo?JmAXx)^B3g`Q%F^5I>pi zXCHxex;I3i#i6hbeGr@0tj$U6WBJp-T{c#lH^q?Sd`HLCfQA&RrIp}(bo38P?}Axy zbF-jChN9H$IT^ELp+L|b@JI`cYqPW6k$t^aMM2?7Ru@;6x@!M679g;eEpXWZr5T#Y z?_e}cuH{0Qp3ZN)3`@`nhalv@@wFfXEdLr&UjFR$h(|Is<5YE1p1l!G8%^iXo^i#1 zxg8M+;VbZX^@fP-;~yB>TwQGN!aOS{ye4hXI+LrR(=Jdv&C5&b^WYdUAq+@K!Wbis z_c!G%W8nm@^Q$6TVFEugr9w+8zx_`!^okFq;3NgUpba(!w%3n$K09EK*V0>^iVm>U ztFwxW_a687o9Cyx?lj8IMXL>*Mtozo8%!EnszqnauAj(r@b#BDXl=}VohAc`sGqB% z);%4rJ}@$Bgb54FVO1FZwPp;$L3&4h)@qA@g?Ckpw>SG|%{-Cy2|s<-J))M9~L zvQkcsoSY+PtEhRF=gMpNG)mH7~pYP&Gn$aAa85j^4+v|b4>7`yqxcVq)1@?6S zw6Ftd*twAR5`vKn99HPVU~&+{COg7Qj6Ly_@t}G8Nd%+QXV^m-I6{=ka=vw2XHwR& zjMPjuWif>(NNhD!U%++!lq5JBMXNuTX|_ov$(fdtu9>-t_Ctq!&e37e?;;5Wf=S}8 zR>+!^JArv`^4{u^u8mM}!Fm&^G$~E$@ilvme`3?%fy&-Kjnz^imb*A5@T*eEGY1iLLH8AH06Bo>07JrN3+8y#!7YgEZx0L9 zRjq=aJ{nR$+t%m7-J>4or;vsl*hVu{)8P}%TqpOWRFbfQ31(&{ETpr;_;tWwaSy|S zt3yNUE3&}-T%tJD^`f3^b}^S`e6VS!aQ8W5Ui}r3Z>2c7DF?ph3)bGucxs>#c`#3mqmjH5%3ZdQ`|5wXb7m77XSCCS7&7u_qN z@)KPHiF8#U2)jZoLq?)WnzxCSG7j4vENdqJDdNX4D(IXu;>SLv;-~Jr(Y{ib%}$ab zl6OpfmG)<-{4t$qn%9d=f;IQQOATFR(AD6MxlW2^t(NnCAcL;_E>W#_MKU*AuRaTP zGtAVS)ca$(z@G|Sdn#H(*dS++nv;nxb}jaZh2OwfEpa3`^U1ifqyU5^tgd2nRIvvt zcZIU-FUmJLIW=qd`d7G5q!~^w617t~G5O5PLnlvpY4yurXsP@J+}pzfWe1%5u@25T zPjlFvCvTZt?#w>GY3w6vu89rvn4RN|>gFpzD6_}od51!+$;jV-~*1xd*W{_O>^RU;hKUEU+d#>GZyAKWRyh96g*&CKE*K67XIFeKJ z`xDuQ|BTKZm#VcTBqT1`iPm4e+xTFHYwX)Z7e=C#+vF3L@uE#U?bii-?2L>uXavl( z;aKF|4-M?J!-PLL7H-ynseLh`CR;{pqZi*c7GMzlaZ_serGo)!kD~}UK znwQIhjJqo8xqeuG(q$u&aIS5p{BWm)yeIN7`=8Pax)ZVf3K(r$64=!Gg+9$qD5UWu ze)@i#|LJv3=Fudc{y&Zbovxd*a;4kJ{5QD-##p;gFVhm6-hppY7xpXoG?A0+y|0JD zrjMhNmRmPBjYvM4gz)&(kq}>WkGE))v6mdZy$jLTfWo{Bfe}w>f2bEV)PMd&sWF;N zaGFEQ`~Hw5+Z|vJa{<7jrn`CF*TRljB9B$)BKd4+9-u-s0GJ0%fLi${$M$~);fszJ zgZ>yw4#2vLoAF2v8(*!px@xYku%1blPhf`ck0xg@RCtv0f80Xx0&EUD8wLA?zE5aY z18So2s*}eX6&>=a*TM3S$F?LQ-oKdXQc_%9H#-B-G|_&xOVn6Y499@R#lQnYv&Azx zoqzufUGI$)y+W{FGljjXZ_idZ{Mo#cU0v_eLy!7nUf`WlQ&8DsK+wj~48gDMukIhp zxeJVY!q%56mbpWF_s7z0Urqae%{_1U-i&z~!&I5~wLo_X7atoFT2I6gW-39epZ)b2}h%c390I+H~~FVuZt$+U8nQlAb8`5n=*x4W*y21RzD>0 zmHY|7i+T7KEqlR{+2I*KVVFuU+60l8%uR$rkibvHp|@Dcar3+1lY1b^YRDtk$uSpN z;oQGu2e_JiM{R>cI};^3MF!T)pVEDP9Z<5tD}iom&BoHc)nJkdh#B}FnYR@siKrEh zPFG2ya8`o?bIGYHTH~}-;`N@+-DzjzA8S~k*DW{8KC%B{CP*D?<21_FJMSdn>TrQQ)^#MZ99stWv03^yf`{S83 z_!iSeN*uN0wYCce^$sia08mMpMFm)21faB^#iPKtjNyuys&+he^7<|*?xSf+YoI8* z)@3~80?ZcD{kvmR;)r(k{n8G7wgqa2{VBGldw>QbhDav9WZ5TL{jak4jrYNo;(`Cg|K|V9V_q-E z1lo(E;cObe#J-zSJ&bml99H-Z|8C%$kK6OCwV>x=4lcg`i$cd5GXGHBhV+fAEmIf( z1(8F?$={|ja3gx!jTd*y)h!%s17KiOREfKbRXhJe(}lg_)JrrJyv0nLO$XeY0ER69 zs7Xw#0J6GHx<3HSSs47(gq*sgl@;R;OQEKl@fIHF!-hYQ^?i{ACLHTZF-hlTzgt08 z?*RS=&_biZ=4-+#_LGq5gYA52EC3qQj7Vs4YX1EFd{r9|VShl`)Zl_oTL;t-hl+n5 z0-5^|8Ly6Jiei8^&M%+bPf@#<-#FQYTnvS-E3<~;m;_$0{|ezI5jnumW0U1${Gx*JLT}InD7x@BW7}oONl4hTCBBuYM!+J z6_WUV$sEisx%3ucBS##;>pCJVPDqJ8bPLeIaR8Nnd3Z`vV``@+oJm1sTjGW7WnNoA zv-B_*6=54WkA-FoK-;^Qy$$u#pYKjk;MkJ6irp+rwQtz*qwc{g$DB7m1?W`Dk& zQThh=(Wq05U`VpTjEk{c2fu!kFa zJ*fbwBz!~cGGTNgD683HgeAKH@F!cO{Q^v3E?t)u6Ab{WkUbB$NZU`s=`@nlc>@8Bl zB$aG#i&0wO&V7)qob!l|z%bFrO@!y@Za^;b3&r2C40ws1BC#G@>uG8I=aJ{g?*hiW zRf(h#k^#g^U)tkmGy+!ISd~~g&HwCYOHHmt${()>X<``l^Iu?dSp8`->w&(Vjn1ax zFvY+J0s4#9o9N!&-k|mNqA|~dOm5{#HxNC1xs^TCpH_bn6stTD68f9u~wp`Rh zW+kaO?_}-e&;xA&h|p#g(dX*`)I`Q_s((!XY@&VqRv6T^*x^_qpU$Vy4!QmciAru< z6o_a~>8yOQ{rHvw2@B2VY`HG6O)L)BuhjC#SRSI>j%T$k##7+zZN8$~@c-NA;tuKN zrK;C&b_@Njt{uU46aPj{>fq|0r{>gG0=JvgYNvWwcdft(Xc-Bo#7gjSQp!#E zg{1E+<2xvf+a6*&9WarHg2J>qq<(uirH>?%G}h)8l8a`Om0M(_!1j15SmQ=EM5X3q z6C5h0Bi@;6TBa^88OCT?4#<>SqwCZOTq_crs zFfQWgzRt!a0sWHZvy6*ZlE2%$AJo5O*)r?apQdHO>#qBH72N1%QvVuL4wV8FY1ELp z>FQgQwsv+5gHm%ESz( zQ}4EFqR_+|`4|&~#AaIi>1?ePJmBb2gL1%Q$*H zvq3U@!+`u`H`Vg}SHer|3LYQrMR&9l77bQSZ-0q_oG2Y^rd9KaEJ-iKb_PdD;y74N zkPjxl{4VLg&T84_6%DUcUb*xN_YV7~WMnv7?G_%-rP+ly@Kr`yCNw6_^)jV4{xa}S zfnA9u8Ztw;1^NAKD0E*ej*%hgu^q#Gu2r~kqEBJKR|E@{MP8rn$1058LOuAehMX!BZwbI_Rr~rr zloOX=UUGy0p>Cf+DNC7c7l4A$0f;Y&iO&GbvOpex#y7AH#ZAH305hj@mhgHK(`I8Z zCp08X#z_YCxcK+&8+bDs?jhv{2lBbg4d(%J9g5Hb;K;$ea|sCjO2ob($A%=n@(1$>U#}5+^6jwbtZ>cj3MJH0VXDS1*t7SbDfkSCUXq)`#?~R(%IGrZM z>ukR50c<~wFZ)11BMHsm^6hofMj9*k$eEj6N`9A9&P?0PzwQ~&MT`#>2n*k&;NB{! z;9*J4TBEAZla86U#Pz3`H2R;V7|{KmMCU^A%YG`g(z@`!u>_z$8iw9W?WkI39t#=F;?F2eCzIJS&4=-l zyCOI1P27ZJObNI$W(+-kv)*j)W9!m2jAOP=7wf9R&arl zKAJ4$D`bE$ykp!EQNj=P?B+{Tb(95_gw+&g-tlu`&!l(0;M9(g3rM4gU3Tmx>CS!q zARVp8%qsQy^WYXu_&4R%cE1*(uYX>qaZ0^=zM6FfA<44xmx8kT>4x`&m9?ijU?@)F zB6&#`{Vv#~4|s9zg#*48*{#^R#?8u;1GG-!e)$jh1`f?f^mRWj;PhNf+NMo|-L5XT zBuD+fAy^X;ym?snsZj4|3S;*EYnHom2B2ZK>Ytj&@w&q_c&AOK&zkR!g9zIhN1PY6 zuu(6_5GbN0Vu+#xuTgVa24W}|Ainzo$pCH2l@56a)4-y(XXG?E)O|c-*DJLI4N=$g zCyq=juA009o))Se0;-r|ixYBr6xEl;L@_GuxId)K>u(T!wXn#26C@Aiv}dpBbdS#r|Ey6*;u-@`jTW+80x zuYB1vu!C<|Y?c+9zAB1LRmy#I>L4fK!JBBbnx%YWF^t1ipTW+G;Wj4dc^R-i5y)wt z!WHH8ierNB`8PFha&I?A(jc7Jd4^B_1CJ-3aHmkeR4)@S3NJ>JYr2SjLLc!M^mIdK zTVIOywf+u11rP5p9ABjcS(%d=PAYJ~q_?pyQQ5KhUO_EhsGB4tP>RA+@x%B1FAK^> zyR=tA3k(58G+_vGuAPUUnX-K)j?7n7C(Nfb(~N}-SorqBJa%=D>5 zJ`g3>@wdCghTBdjO6U1zAt)FaLg(bW8tA%h!MRPEd%3f^2||-chT%ec*YI(D$Cx~{ zTP;a$KR2Q~?BOr|DzI88U$Y6_5@nkP=tNEmUsSwB8=2VFrqo=bJ0c$s_--4 z_UF?XeZD^{0UAE@UL35N-LC0Fh+UU2E;?XXL@&uaY-O(Mn^hEHqz->+qq4n_%ZL2T zi4s24Dv~rhQR=-3`{58?XEU#QGE+0LUIDM<-S|!s;kD`ITt}JSKhIo$EE^sP?T>zw zTMH7l=vG96q3>0}?7#2)Sj6wyL9pAJY3XP|Bwn;U^}=ap?Yu9T*3-32_idtiwQ4Iv z(bU8gU8$DQFC9BJO}Y)3IAG%Cqf0K8w23hvtB}_wcB)M$-vuc(=t$J&wj87gl{}KY ztbBb>Xaa_j5>FS=N?eBF3#PgNjzPgj81K$WQa-kk>QUFoIc@eFPD|pFep7Ktz=&6? z?WMyzqyTuC1?hhQG8Fw;RYW#})K#MiE=x1XZ>`^*S0a7@M~UaKWHW@{qi6VY!w2MM zvkYM`)%HLkD!<_&ZZA)c);%WWieiu-HyIgD=sgbHjURxeu2OmYNVhJ$W<66Fb@e%e zCss!X*NO{n)lwRK`#~-M-q@bCcv(>ATK@2j2Wi%2->ZR}xG@D+AV8)~l8Ph@L=9tx z=Razzs1s{!bd2h~Z2$D|R$mQO@`H~<77TcwZ)UyEtK{lk8KW!@yo)UoR1F>ior7tr z7<$CU4gp2+EN^%vmnZ~?%|HB@6}eK#EitRe63I7s^|m2d#NempBCcrL@BF$Q^=g+TW-L&9&$&k(14!fBsaJfGfEFi@fju<$i9}twvSjC5bgWYfi z{Vd-V6skU$i&NvUrjy{zXW;b5C3^i``=&Vm=#&6l+}&k@-1IEG0RsrR@h^+Wx5FJueM%BXzfKy{C2Q9GzZ# zhx;!a`q%U3)-`h!ym8j3_&#}>SnO}Wv{tpn5PgG$(I6@&2~s9tR2_^S6Ed<9Vv~+x zg|DJfbCLfOWR&XS=Q^f&C2SU4jv)M@f#UZK2~H*)f~YcmC z`Ap%e<1nc|vi-R#d0~qT_lamFshw`8!Ijd+VTdsj86F_7Nwietb9AjHm(Z+DsS>RU z1Cau{`vYY>Z$kMVWz?xrrLM6xrRJXmU)Elq?=&yjx&rC*zErJM9nW{uB+GtgWQ4u> z5})g^=8c24FygCqD&OHnNns%Wus!)&OIcgU)|n!F*%>_C$wkQ)3k0buM5*)&RSoQ? z-{*3ogI@k&62e;}!BHe}x}OqT)f=j2)LLFpeE*pDbqA3QJU(;V?bN&;__`XiELp;9 zJ5?K~<9M#Uy(OfFp3!1)mReF(n#$YR`ZI6yr!-k#-4_oNt~GV5%QtU)3e?Hhu|2OKw53J7PMu?dyJA;6DO6hm>OSKC|&8GBIw(dbmB6@gE30df~K%f#KcNNAEm#G+DQ=2ib3hZ+{9T+{EO95_0mn z^S(rB#DoJ-;T#d6yUeF8GVKok;N}Z80U>Op{9K>>*wt{zQBjYRA3IcxaNln;5JlZX zI<)h0tEa9Juplr}93UJp9x$&Abs~t#|3yol0^WFjw3$w&V;}BvYj@V`JvL7AZbZ<+ zQzp^+f@O&PU+;)sXHX!QZ_2R9$r>*|%TBY;OQ-k+3s-bXCDi`v=D|LF0x-D>^CjMU zTimtq?&pvsW<#b!7$W}^pf9v-8{cBWs;GA_GacLY2Nf1^rK03d!b@Mgi|ztAycNSk zzxQ46vzHy82^QDXTtq%TyVO0 zFRDj2EpAE%Ke9;jHjLvww{EXs55;xot+vZqflmkrzaSdDTMRAUccv~Jr5>}ZrCH-` z#!i49N}80n+&q=so#!<`lN{MoJzwr8v<5TR6NS_To|%y@Y2Y2gi)Y^^z_Yo|4n7@s zco>}fVrtBah0J<^{B`<_ zu7mN3>hg~5*^9{^h17Lvc=LyT>P*PE%2*)ikr;oU`B>Rr7rBhCpk4!&$wfqM2l%$qA^Ke6DzkwarqNja;5B z2GM#CpcuL>ED%4ER?s3X|2QE8s#ES#k~bT8)8(P&XORxyL&H@Cn~gA1hOIn<=zqoT zzcI11jgPn7p=BlKF}qCgJ{7z~fr_V@UwPEiTs zpo4D2&5iT|V)Xz2^;jg)iDsM3N(<4dR%ng3A7#ka*Ku1?{)ixTtv%+?w25 zsRMo!4|?dmk@RdcDW2;^__)_g&zP*O6%Hva7nm9U)wj7_!%oIVBNOJcWKZ&eVt41{ z%dvs$xetP63-t7v+qJ)x2|Ffb8&`hNav?h~^kJ@TfAvA$@(H_Kqy;*fab*AoN={xT z*ZsvJx?O0tOn=xdw+lX`Dr_Uc>C8U1WrzHJ{7l0iXZoJINukw??@2+pVn6etO$`f4 zAb!M8fy{cC8%X1P+M`{+v6%K*Nae{c+vwgHWqz0&@Ay1b%psTH%?&ZA!zFpzU#kvuKpSKo1sL zlAJO*YAghU%((E6#_57Xib$i+ecDZ(lFhTc?h=!(FwTXC<}MVakt>Y4f}TZqA3SPP z!Y>y;4n{JwF-JQYGn(h)J_iMRUyJ?8i1J^?l48_SU-g|fFmnS^KP?s$Bs zd|=o74=}SMlO^mmS=4q#S;ptLIgsxKK3%N(Txlk%hP>g*g8b58IYH5WO#+#_h~_6D zIUry4+Y<74T?drTGnaXb{|R3t!?BJ$Bo+MVqrBV~j@tQ;*c<%Sl3FNc+>_3t`M;+M z@26_&f`hn*I)gP0D@}wE%Ip9vnU3*TU}Co63G@(!v-l@p{EXh%2YM(&H22jILBDWr zN)+QCaSx#o)dbqeG$X?-5f;^#^7qSBD7mQs{myFnvgA|W{8w>t4a0*>XYo4W&bLhO5MG`0T1S? zhyksO(aS%|k<<=Q_~64de&VRSbylF`BI991i>3acR%k2m)74Bk>;<&1 z%9T$e8H~Y%nRR^&S@7}8p15cLZ?tzv2r}HQRV>`-`e^x-wJlK0Q_Mp}`wx`fW&@>> zrAB?o;@FGf2e_yXhqdPL*N2UXU56{w31Og;kD=d^MXi$9nMHJm;d5S>ycGsWs=c;e zuP(v|VAr0W0RJVke|i|)u;z{qV0qDP%bSuouFo4y|Bj8pPRtjri!voVSR6%8bRoef z08}>=TlBH>1>-Ikt0-$(knCvy6t)GB*Hc^pf%e*Dkwr99;C6X-i888=#=x}2EqQ7Nfm;)^H!$O8rUnp@Rl5N(ckuE-vUo7Jen!QpRwGe zmSRWBcTIomuk)H~tcPpV0j}<|ji*m1>fWYH6YC@R{ZGW%FQKqG-#8;{QbXSBsl-7L zX5vFopm7SW@|||k)Ow?bX8XcE|0m8`aL^BB#DU}<} zm)92@8jY0&didUKx|C=XIjlJi(Gi6Mxp%?eg=2w_1xEwEJGICse5lftezjM{wzc2> zsQzt+#yv+m65WP{iF1R03d zJ2L$4m$u?%vqJ#8_X5A9Wwu}R#KE71eAykR-gnZ^>VudWtHnj`Rbv}zfuEqS1<4Xc zisY~KUynY9B)Nrb7tp9Ph)?UDZ7|!dxCpJ}=!@EABYX@s8IiFsYly zotOp%P0D1BdD>sGEn%4G~dpQXHU^<;FvL1|+>sbScGut>kt&FDOpKp8qvs(X-x}gT&h_t(hbmJUhE{Pc6yLX?6s?u9Dqzs z&UKz}m5(zpf{j>YWBg5v(@}iUYJza9eXFOZ-_;_-bh!?*#$p@|m~q1-E$tB3YB3Pe z6tz3;7tye8u<4UB5QGA57mQ3H4=mtp={)mO*P*G;(L*3)9TUZ>DQl>4n@KKIX`Zl!GiGu;$qw5eM%e+<{xJV4Q+ zyQX=ic6CyI^bw2o%^RvC+0WwYE%Qo)9Mv-mfH1sM5#r9iwNbw2Y|J5)DCKNlJ=70K zS^C3ED$XzV*_}J=CXMukn1ng(Z1*y3Ea-a9TWfkh%UuH+H}|c}9&8UO$i2`-NFYM_ zTVMzD^L*S2?=gfuC)|*5?ecZ-k_x$(P6=;}Vm4rG_`j58mSf#v;tt0)ufjTmw@SHN znti)m*VT%`TF&1S8hGs`iY+}XSyFNz{Wag$I|&7XqkepUNg>{~sb!nAY;TwNyOs_6 zL*F$1jbZ#lhQeAHO{+b>t-xseClArN<$|ga%gs(($-FP=OV&=w$|LepVH^Cy85?K5 zx}y#_%^CA_c6JtQYGoKe?I^EqLuf0_7Fnwe`aa9=o`gS>2tbM2F1V#2P!)|I%P)bBaf)DlWE%C z^;_xRt&Oa{PSx8pk0D)A)=47_Wa8J`)t6mEKb3aykogO>*|VJxhayM1LEVI5tiFP& z;ET40)BOh>Zqu}V6jJc>j7skM@?E!WMF$}=7TKojXJE%iAT7Fsnfg(3_?*=h&5#Rw zRyy{ljHKBEd7?QKebC@Asl-OR=F@8R_@_HbpuXvg(RH!v8eau+-5ms*&vXx`AuirT*LCe zh8)dy`PZ(YICQUV&+g5Bo}F6(1ONZmBMF%gCv?NTYtZ?AJpcA#xeQ-m$?f0i{XWY^ zrEzt%L8I{`rhm)QibK$1ezwQ@6JXU{?Sl;2Ge?Vl&wrkMd?>x3^^@~mRF3j`(C;9S zgngU<3^~IGE8c}Dq-n*Qbjc}M)hi{rl8z7cMTUm~C%?Fx!+@vR@Jdy0g&d~ogF9@A zy;shRPZvGLPTeIIEgee*+_98I?hg2MG~?MH{3s!ldK46W>3c}b<^pUyjAa5G+( z8Yd+oP~RFe&%;2JZe7Q=y;^#xs|oO~y?eb3aK1Uri}4nb{apCn9-)3-$F`{h&`N9p z6n#|el`-RQa}xZ4u!~?Umrrc<4(})$f%#RR1jp-LJPF&G0qpk`ybF3Xr4FAW4~WTb z%N)I5RpUpBvI+NsWOsobUj2|rUw**%Va>zfpz+nX@EILfv;Fn!RVe8UKKXJ#sDUaSaL9U|l*A^ye(4RF!1C_8ibzwhMG-l}?C$ ztwLAaBcL_bo&^;$qvgTC={T-O)PHjFwox#%$NNcNKaIR zCyje_l|8Vcw1J?9Wn;;2W-QK?w|&uC zQ7jxthd=wimuRdj^t648?i&5DK}oqx?var!vXw4R%bLd55MKjm1Qso}!zzJ|rU^bo zqs`lPMZ#LV@vb^DH!QK&2XK#i;%62B`B(A-DI!F+)bG> z{WUI?>;HCLFDG~6-9x3Wlo*yGd*SJu`F$TZ?tu4<-$ib+}a zwQCS;lsnJ&dOyT-caWjSqn@#K(CqeZVWSbZ*m0j_c1`JbY-;M1@_iKFx&Q{LdvYcY z7B)(%Y9_Z&bmkY2hm|Qna56ixu-5(EzH0`^HDAuBJEm?o;DNtx>35qazY1Qumr2J^ zI1De>97vV#exyk=CLN@p=O7fA^mknKac5o@k#A5t2U@4R{EinYE=bjmCq9IPzKcZl zn0t-6H;Nq&yQ7ZBEVqM|jOU56t|Ahi&CF#dmUAk5&A~b&W)d0BU%CnKF}LVB4>+1+ zvf^U8#kORUHhYhTZ(Mgf*4$UZ+4U7~q{gygzNrr|z@Rxtv`JvzSzmn%=2`J1rIADl4 zTF}N%H=)NPA!-I#Q%^x|6>azKH3ItyLQ|H*L#E;JiOfg8sX2_ z9~M^q5AqNDyQ^l|#E)#t!rvSn&vJ_{k!&InQXav`qidttd47tfStjokvc3BW&y%%R zT&Se8yCWumZbAt|ZcVdjGk@Ea`xMT(TPo6qDEo7%BH!nuqUeb)hz&r%|H%w$KVMpW zlxG|FcETigL_xD`&L$KaGy9Wq*JWzk$+pHF4bnpb)>8TU;Y)Sno|Nz zfRGHpr7f~+tp-M&8cgzM0W)Nfn%OkYzOo9~z1tP>u+eC0J# z`~tm!;`Y35h4Eug7fLvaT^<9s-1g2hUcWdfaOq6RTMrEB_K109^vgJ^bhPjJ-bOyo zS&m)L)oR9{p6v%swa*v`gHs9MyT{ zZ@KPH)$Zfo<5{^oPtxBv2TSQZ!Qe`yW=wMi&i%|OXjO#1hyKQ7HWu$0qWwBVvm4Ts zXk54aHq-^uu<8=2yuydfYNE_3uWaYWiEiz1QS*FvLHQ-de*kvBWNBy^;P<#C-R{*0 zv^c!NOyx8A`s9_f(0BTyT+Ii@l$s&(IX!10!x|P))d5BHVuAP~P)Pj;ljYu_$J-;r z<)cMT{)kEgD+ar46vag+;M6E)bVW2$u2D88F>z7t74S0k%8`Oag{eP0HIoHfdXfIxmX$)weq4MHA_Gp{H z(XJ%*tKHTk(Ecfu?f0;X_*r(#>dY9^qR7c2Jm)S)|HQn5cwhGPpsI9u3ShFW(1s^p>ZZ~7hv zP<*nUrll-GL{&3Xs(}u7`$V_R$dWbO)j=$>qKME9`X?EM)J+t(kge8Rn~Z@P<|XOQ z`}&jvV5K+pR@ytn^Ki^b--9Wfpkuac9hlW(DW= z#N*qjXgbK=`ix=e8$@|4{L3hK_3zlD!o;)x2!wx0G$(M0PSH<}2T?Yd zui@|L6OrEJ8vjvAD#hPgFHA4axvMGo>q}^Ca0?c>;yu63EgpD1ik(;bQ+f5ZbWvn> zL!9p{GDG_zuG>C)3#2GH6QLP64Qgkf!t+$tb0PL zur&(!`6K_#uh10-(rw07D&zLSmrs1QZZ5m54A7Fa(Wh(;f#ER99nv|{=K=DCamU&$ z@8cn<;hbmmpY2pUQP&@ydJVtJb<3_WUW}%0gw7~|#;{#HYCn25A9-VWG<)#7s=p8u zo}l$}D~WsBz7Df+w5%)M-d>f1UT%-#53N^dCn{1Ly}jd3_C70t)n;evsilD)`jt=f zYJl>p;o;pgf-Q-I;8H-*0v8Y^4zoA%+=U~%ImgAL{Gq%e_!YO>^5;@%fd%Uzy)KC7 zI!Z?ze`FC)N$b5`kO=|yw3|+%bt0ujx;4$uaFY3hYi546Qi!v6ifCC$DxtmgR9$x~ zWfqpJ?K~>4y(>d<-^LROT0CL2>?P0_rbzFoM)=HyZ4PId_ziaj8k&gi_eW{F1Pv|V zaZz@|h$|@Q^G9QgO(4|hH}s8j(Q2|+lNe$tIqE_oQ&eVhmC(FE;+#l5CS~QKv%t?5 z`hGRQv(3XzusXnJ)^b`M2_7|?%sE+;zx=xLm&~a%aOa;kRQjYPo@P2d*RR1 zrHo1=+qHsgeph`&XN9>2;^T8s7oPl!14IH{do>?3F zN;XDW3wlkwr=}>X3M5%7nqzo&bHF0ocCVu|_%0fpRI$hs>DP7iY0e~bs|*@2#O1n3 z=N>e!fM8*2==*WW0j5jnG?;a%&`A55w08e9l`u7)z^_3dE5>qV5P;k{{r0f&{PfB0)R82}p zan_&HbteZaUfSWOli}iiwr)xo=E6-=EGe%i82UuyZyLM@pYGxinx66mmTXZ-GY(Ir zk^aT`+C1sFnbRlSIu%A5mJEfOir?4D&%ObB_^HMy+#4UB4`js33~)XqvI9WEjonKn*3;X z5MX5u>&t9XjUrz_MubEAagspC>}G#1ql0A030xUA2LGCHqYjv0MB-_0*D&dol3OMV zL)c=1?Xf4$;&4(3LWp_uh0pTkyrp6!kd)uyb#eyAqc)xC!6#zR-)6r;W{Q%>fKQM8 z^VEC3U%(czol|zT=AJds|BD|HG^NyhR&;sP=1e@Zxae433u>+nOJRqmSf0jQX_!H0 zxku=9=vd4PewJ*Jdlme-YG z-@@i$cW}fGPkn(;Fg(p)Sb$Zp^1Icf-5BS{DE;0x97lUfc#at`)M8hN7u79zkYQSs z+T+*7?Yuo;p))iuT_uG@!uw>RQ_c7rs@FJi%N zzOIvJYfg0eQ3k$^*5ZyfrY-y7fT%b^>Uk4IbZ>ujn^wK^!J&}e7g0hX`<41DQoePN zosoqAt(sE87k%ypGra!fDo%TGak_C}1dv=H;pL(}=WhxuxPt>RJoJR3{ztC<)3iZE ziAHNeQMUuy$G7Ix0VBuYO)rtwSksK{43$iLrfNi`+kj&aYvN>uM`yip@KYiTNTd6W zUWp@!;s(glShW3;UXJUdF@)**-RVHT%&gRTM=OJj$ZqI#`^nPbC=m7IO8t?5pc^}u1I@%&bAA6JhRa$B`->J%F!0fv9zN|NR|7{IJ^pd zQo(7m;gcq|c?FSql6*14^@1lnGaKekGa4w`l|*C4X71nj$F;DIng+jB@43sTL`*dx z^&}E$)kCS@sep49>~&gRlLiANMN^`m!`1)Bf;0GcfsX zkY+p>9IwDvm%gW)fo%~6jO5)vBxY4z#*dyMdd2*=;=Nj&P5Z+lF_+jH*6m(vvl5+M zzoI*EO`v?^#0&wv6cn4J0+19uHG|!5xLuEpF}K$+UnRkUyrI33ZU#t919qCPxg!Sb zzM+FaE||dq+&!tVTAdDSSgSWbwA5g?8zc@NOK(8+%|TWJKdRC1qd>F#KQU2yr+I81 z9x-HjcHg@BRall5O{L^+4Dg)-Ta2H*?#x)>&WW9r2e(Cf4QozoT{}U z+A_x59{op_izpI0NTMjhhtDT|k|BP+NEZYJ-`E|eZ%%Iv&IDHP^6fI0HGx>``)~no%d#cy#Me1 zyz|XD=bo8+@7#0lIUgT6KD~3Lk~?iEdIlPeb#qS{{FJqoVl2KPCa>5wSn=S#cdSf# zdt1a+qy_{s{PWj!X zCe0k6F1Llbctn+^0rzu;Y!700P$q?iD5VK2hL$RCsTXe?a5{&AADjx)2iLU=j`hi4 z)+}0)op4h8Kt&$G;EtI1(!NQ9kwyVid!sN*rd8h7jPh2d1Wz+?Je&H)VH>5#=X@WZ zo_qcz0DG@2ug#5e2&ljkIy&WJj>(KR)3|_AQEZyYHC>a+)_u4Iz-*bt#Y_;L^oc== z*-^9<YI*?mxfk8Mw^3qN zCA9|6viRZ=CQs!He&s46zc#M4en)N$y$BNZOJi4tb-z+q zusIP<%HZQTd_f>#YD~{#;*doPAvHpWe@C%fXR8(kt!3}*{EwO zhJCQAKf;63;a-}gORL&eH*qak*oI)Y4LO!3m{`XY2ktZ9*Wn3tT%0-5<~zV#u>qk8 z6JCqPV3_~8)t3qQpZjZN4*0;T%!^1o1^WL`utX3&pWWww=!zWoTt zE!%CddxVIVC6OM9ctuk=s#~-cB~jQliKeQMnX^77+!-iW~ z2rrp}UddqbjzWsv_2O`0S_6425hO!T4IYqk3s#XNb?ot_f|&Z zK;|~6*q+6f_sfl8fBa77);;F};^$ff?wvzfJSw6&kLdCe0T+k`= ztWdF?ehUzv7qv{)o5f+423BOYZCjK+D#w0EX}8;SA0F*~#U>*dTnsu~SQ0O+t4d$F zU+q`Vb{<&oqo;c`-0XqsWO5G&JUxb+8@O5%_G(@`9nossw7D9$<0-k?@@sB^-8)V> z)bCpP$PXON*HxSbibNqejrkDgvL!6pp`sDnDFaP?3u=0`W+Tp-BolT6LJ-3}BRLlj zuzVjJ6~aQrK3DHQ>|L0ODtS?+rCmk(Rt6IH`7%D)q*oX{fM$MN>q(}P?!qb!ubr=U zzCZes@`oX*+wT^7eaSpH7rh5^;J`{evb4X(J-A$_S!1Kp+p`Gms= zmUW8f5z3c)_12Ree1(Z!t0I5Pi;z0?TtUobrfZjf+lNV=6PJyKsrVgQi2$Ip>bXIg zXtu)`(lazi>|^Sc0o#13@U$DYS28BTFlLQ!dm1~3nJCCeqmy$00<&M(VC8^GTK zVlZR)s9AGpo87}ez65WEW~ndo0_UZPDtw5ynlk(u6m8(Ra zZ;DU{ndLYF$2l&Fqzwx_$c4rNnih65*9JmS?x)Cl@SU{+L!2$LHD^_ZL4wW)q>LED zqr!oG3r1?}8(?QOcYqLhcajoP>m;gIvp-h_yW8V$*%0^Tz1qXWtylAPTeUt_dMQ`8 zS-UL^HbvE6rf;rsyBWvDYMt&DF-2|ey2KpGz5*sQsY@Wr;3b72)yII;+uG-oK4{Ca zkK!savTYEh$IvW6g+^)i8Ysk~M@r*_rj9o3nhxI|QQUqen(Xun0M{+-g|1q->$w#A zOQC5*?2BQ)=R}fb(w(hty*_LnRYh>C&u0f`D_Afj{|U~yp3^6xo=2Vq*ZNm+WVC9- zAI+-vLdQn)mrBIM3aOS8$}UhVOxo_Zta#kgGKy*JL|40q{-1(M1^c$6XuGO?(Uh_72#2X2>JiD!87-Y9 z?$y(!dPAd6@P8SPl>K8jo`hK{ZjSW+mXi(oSTOE0Z0~z535)lAT=dS%K;R1d(2jMU zO%c`fxg&k0_O*{v#$8iE*cVV!oS3DgChe? z?2vD+wlmn>+&ZX3lPAx>8>T$(v)2 zNR)07kjs(_C8SMgZt%+;H%AtJ;eyv5(Hx~v z+VHp>e15nft7j0VOhuOS4u0y&Ncv>H7);>`2{0C1;G=f7#%W^YRaSg?F9_)F1P;{L{!sRkIeeWgiq+}3?SEmmyktQ;jYP6X zOM+}$c1tsa6El-aYa#y^^uPG%hji%ag3rZuMq{nNGNtE{IVV(LU9kvD6ejSpVbw%f z9laNox0nTG(0#XR?Yv2z2M7lvMCixgtEStZ#Qm)EyXOJEsmN}5!Rux)<|kzXYX$~J^UW<$K?XtzvwvbX6cZbU0g;M4vIeeJ{A?@?6S@$3PGaE7AphQ%^-b>% z{230K;TeuuxHRo71aOE5RZp9kZ^|>HX+0J8a-PuZbwR{9S#T_ZsRs6=h7zM1+Ro2J z<%VA*C|Vy1@SC7GvTtX!Zquhh<1F5O{_OjUdc{fypRc?Xit$5SQwLvZ6wOmOAppTUT$d7dhQYfdOs1cx z=v%kk#YEadQnufzuo&6^A$sJMX>dT>R=5WN1Z?7>2r;zgBx(V~)|vu(zz@8_u0ZC^qUfs}y9+B>!du(S-9A^P zXy66HFPT9vLAUH@?n$qS)>H+<0HYDHwoG2oG^?9wN}J2cfqem`;lRKntiYf^DR9t* z54ym>Ad|wuU_rm=pi3+t;=gyn-|`{6f~swpSSW8z@PXk_YOY{uwm=lE9@Fg{NnP}0uK)ri#7&eqgveD#N^@O z!RW!t=-_O@#LUgj%>-a!Vqswbm0)o3vUfG|WUzOk_>YkP$`LbjF>$tXbhUD@C;dyV zk+FlDD?d5;Uk&~H`AB^~|H=kc<@+m_N72gD%vMXx z$__L=pf&{9Sh@KAF8}|#^M5V=PfgALtH}=F{@(1 zD*x}ze=71Z{WbFcnTh{Q^WV9kc@{w6WBT`*2_T3Jd)k7534uwA39EX7pXR_Cs)^4J zQp0~vne;)?6Ba_lS`e=;uo={^Di02>&^wLRt3LT@BdSzg7R_dcMR2GMp1PiO`-KvG zddS27z+B&+u`*nd6;^-U??L`;VwKx$MA~|_$w^pQIf<5r1`wqTke8l`N@Ma8li}2a z`Ec-&d$n6>z>=|8YjG>~c_97 z&eBKEk8%D{1#*5Sf0BK(7f3#;t}z5iYIdt>G0~-TY9^+M%i;0yIR$_J4g@H`czf8d zn9iOC`}b>=yl5;sYohj-u1$Es_oi>T&^M8oe_9P5RY&(ad+Y6R(?4?K1uw*&uJ`J} z^pm64fq@E6_l^diOeb9Zl8sxgZx(Qj|Dnr^BtQ7cU=(XRh3Aul6?$gCXKyP`9s zE80m(6pi+L<|7a3w84>*PREVYUp>);^Oa)kW|Gz5*`3Yl>80vsYvQCPR=+=>!rV*PZP>^^ERb!YI>K>55kig&r40U;;>u(o*A$dI6OD~ zuY?yW2;g0*YKgJ+e3_Mcg|=BzpRd7WofU>kxi$OTd2LP2{di`pHaDm-WblYU(fn9) zMQ;AJ?-~`hBWe}8!?PuND^;0m%&#oV1iJM^|0!gcCqYWbn%s{-)$De{L8VgXGkE!M zbnl+(@^`P1gIMChdFAs$7qsY+rg>2Q%>62y7NA7~(#& z$ElZVXW+A21ErIxjyTqoWw_!)aUmDUV|&oxB^!v*VER8#1G}Ec2!WUkvV{H8G*WyQ zaCatcrgUr(JlxK3r?hY>CM5oB#*P&ll@+vknO|bSPL(2{i-X&2v6Sh6BWly*zf()M z*S96T-6-4Zu1mZPNtq%Dvy7*erI^N5r7Ou-5Y(yC^!20_Mh1g00KV_a4G8tqvMor! zQuTqnTnVO-0DKNxOG8VfLew|otL^@g)vpkeEoGQ?llei&^IJfH7b&kl`9PYP2&J>e z4gV3SpYl1{N!zJh@%&ZMVU^#2SP?pBogeR+gWG~1Y%*z#alC@KUm3JCWtMH`%ceX5 zC{^2fv~tB0Q7LT+dwabNdZBDU`ZH*ZH7(Ujeg5RBvWzi?VoU7XAgx++tkGJpoBd+@ z93K4ej>tsufI2bml?J;~2;2uQqZ@^8Z_u{J7n8?VE0_*%+_*WI;G{F-gPS&rAS!;z z3VHY`mu1djzxu0j$4l~XfkCHX7V=M1_#(l$(}piBe0OR5>+NdEksxmMpFaQ|CI(kPc!VnG!@IiJa?K+y-{W?LU z-b@p(b80#zDy!!b(Fo+s^VMdTy|zf%IUzin0$P>g&voaaHHFgZ-)g2S57RA! z+`E1Xnwokd1s?#%ap*Pkml-<=qUIpY<+$n;VN6CfU(Jv`D7y(M9+>H(! z`gqf~M>C?!?64+ewo38)<5@N}o4%Vtu$dXG7U4Byc0h1k<@7K_obb!RXJMW0TN+l` z7@iyZ?kZ0SV!oeo{vU51b5c|HQ#D@l&156J>~0b^KiVxJ;RX;PVN{-F3Zbx5a_a01 zL`Pw4aM_D5CF1a{YHp#g!Ev6+Gdbo_Mvzp(q9(F96?)P0YJwWi1o=qg#Kul6cQyHg zF(-I~!y-`ZQ1t5{XItEC7zjpS?a<>7P3FH&Rp{~BnM@a+S3kSd9l&XJyWc&OIGQ;< z+}lBoTwRNib6op;X}VT4dzB8$UShG|HqNyW;K!b+%#Md4Th&~u#!-+kQ}7H_=xy5L zK4RV=iIH|$1QYX2*uv0}$`FK4s$2hdi~6+?5lxK21`0AT1E9DhjEjV;$ehy)MP^eI z>eK~=Ysg%kKq9D*$2JxzOdv7H37M7`nP8Rei+g)UYTax2Y^rD)Xgobd;|>xqg(CH3^nhn}2a zzpxuc0tG|FrcpM}_eoQimdQ)SBIZ*F*TU+}LXN8;1Yd}kF}{R~q!3uZarC~do>3z3 z+$b7&F&u&tJCvD03;RIBbVsQ-BKo4JfBlmI-UJ%wa`U0aY}8m7?VdY!oP`sDv@B6D zcAkfge8O&e@YGX;B=E3wQ$h<(n_fu)5JVL{Hs|`rC^6!R z^|CkCunXV}(ojHwCZYSP(%4v}euUCU1I2lvAVB~(kk$-ZrcPDxDw`+hgDiruum^Gh zzz!JDjfauT;Pp5!dwQ;ST26l48Ud#WuTIA9iN^x#CK=EYT@GUjDwwNzGuz2*S$Xes z=;;NcP+~8!AeaiKO_*Cel2MFzxFQHf@mjvWWK}&@Fev(?nP8h&FPqmO+EnyGHbyC( z^}GNBH5yL3_cJfxom=&%=0#+=4}j2b7y#Rinl9kNhn zpud|@W)_qr6#sJH_Mrk~u|pW|dG93!vtN%<&Cl0biwR^S6TFr)pYNi%wQn{4#r zv6@?UU@HU!#LeZsO;Bu+R3M$}FA0%;nVI-AoW||f;Mgr?G#!?$mO;m-?vJxRlFZ;+ zO+(E)_+1@!pWG>tETwX^ks8LcQ#yHL$!LEr_~>BQ{!=+OsS*PM{0V#K?$=Rm!I`k+m%c`;3s+5y0H@%?;~!JE=awD8LB7 zu#c|VX}*ku#Tj-lgN=x4^)TZQ2&^^|d6tuk;Eq1lRfNSZeh{9ey#l&9MlbiDwcI+j zuN&zadiSu}U`4BU74m!xLAyqFVcU?o?&_3t44_}A4@b+P03}9_{)b z<1M|4!?*1%^7SMC9~n~RyPUg)i>ySP^sI~YUzK}0J8E#7k=!i|2Gpo|vL zOSHkR`ToH|r&4MH-eGv7;y(9<(Fabl==A23st_{+AF8^N+Ie z>c+?Xy&uJDmtz+rg_NI~Y7nrJT6B%UN%h*^`U_=g4Xn!%G6kL5(TCFqFZQrG_aa^s zsQHy?Z2Iyae*(i;Sy)`xiCt(Qqlh*&uo;_7x`YUVnlHyD%5bwIds$(O+3-sX0;#FBteQG)(=1We8b-s$T zSj+X@xGJ$$MUUl}+Y*O1(;IUo61z_r!In6(J9UUi+)4dCEWa$Dv_9nlq{*`ri|rh6 zDd&-vgJ^Mftyf@!!1gU)NH^fnP~VWsNeZIhVcxU3tUZ+aG7AgzUV%nf`Lb7S z6uaXQ9bt;H`VEA)th{E0&~cZrTVX9z)Mu{ZW$%P%+)HBC3vl9&jSo&xNkwxBAs&Z{ zRbE`wNXsU}&J2y4<#15Vh2n#?Qt`qXKMbb7d1k`ktOqcWNT)yX;Y!n!%T{Qj(B!tM zWX5YF>~ikW(-)-Y>VWjPJd6hn!7@HWm@qIX!H3@0IKPpu8!y&K+dUB0eHl|?aI+^c zqM7~Ir{&Z|q{&@0-FAhohM^V+HW?axmnpF8h;EXtZhzd}s0ell7aG~D7J%i--1R;8 zgXh5JS3S*cWac3ilI@6?f=(@%*e;0m&EvcgGW*0s(FVaQyQ_q|_(bHU$plo@ zs)or%sf2qA2538i7~GjdK{&fMn}*JTx}v?`Q%DQrVc6H`*L03 zvS-?n3QtHZMmxe%kO9~XWkqz!>!#OUne3R##mMVs9txBqFz<~A8|fk?u{&qa-L-2nbTv;e7)(2reXgiJKO=qKuvz9yRf4(k15l;i(dFR9vR<#A5aNX% z!io9Kui?JJ*ary$XpnKy-xH|SP*+#OUmWaXC2WaNh7M=!er(269!=LE+C@eR$5njM z5(G;92QC20s#UAgz$vBnBQ2YCo3}lh6w(ub{Svp!u1zI@lQ_G1chRS z(~iW8FQp#zzpaD%Rr>J1k}`z5#*fl7irul?{y_?A;sZ^d&Dvp`o_YTxO$by5)Y{B! zMd3fP73a}FV<>w7(+K#d(=@E0p&OWIF00M{ckDb6@ki-*#GA+eMHM?R`5XnRPSK*){u1X=W^!-=@_y69nTt-=A5$zdzXT50V71K|@2Yghjbh zaC0wxFD}L($;!(kp+`L=_08{BS(rDt6Bz|jF7alEE7nP`&XKHo&`z>}1 z)XL`|&mJ^4H?NpM{{gw(EPuJK1f2G4Ae6WOus4#*3dipIA_xVyuyCmG?s(6$=zi5f z0TRdoOEkNrC^3Q^MN`z7EiEl=R2_-AvE~7HDg&1y^PL6dj*Y5FEpsW2MDRxH^BX_A zt@GC;EiTTLRsOPFO4IzKmBz}aK|y&Iwcn~!p$+zH)&lR36p$uxoKgcLaox{-7z%7h zN2%k#lqebz^=r-L{QiVc=lPNs1f7$7JVrF%&A<1!E@ZM!@VT_sdF;oXuO)D}oy@Z* zQ!B8z9Q=XL!ZK^QUk*5`W$JkB;d?%$FzO9!jhb{jEQrWZD^<^&Dw4EG3_<3Rs>T<5 z*SOh=_<+Y@*zy5nf*ic8@83;J6o}#iUa$Cf^(k1Lw#8V@Ms`DIVmW(KHT10*n>IzT zmLrohGOUO_H^FaCDmyLFQ_gaI&ar|nFGkpIuCI}}rWI@rPFI_2n=d-Rzp$Z3y^#3b z7I;1FrPnj9ISmqh$+ima5EKn>GtmzKL0m*dRw5 z%{PBq#WasFe{USmm5!Sv@c(YY9@X^cd%I;V207IQE1(rK*5-9HcDdC%{xi=XzonA` z2ZWAze2-Rhk5f zoBUQSNwQAI`l1T>cnJ9TIdPvNm|~?+j3Lj(tl9?Z)->PCDXaNt8bg846ytKEg9Bsh zDNC*C@HXm90(QsuiOKAXwi|hsPJjNJUKBw~7K?1B7ORbSuqjU!cl;Si)IMu)GPb!A z6^)E@Q51etdHy@h^JN- z^T4t)`k68<*0WyFs^&BqjAx%K)mV7U;&kA+wUU9W6&5LdNZ#;_rM6xt98rT;$K@}_h<7}nhs6+q{)GPq z+O~>uF~ZjkEB36GaMU@jHP$bm@YzsRF4fFC-|ktF>|?MQ*e*I=Dyl=!wegM&t@Wx5 zg{3HJ_e6N#7dk6ck{Ls8Z;!;ytuqFgC-@)QV2Icb-H7UR8f>j#keUoG*4!3y_9p~C z>ZJQ$sm`%9bPJG>Zob5BmN&wjaDIE(2naALOF!}W`n4v5-KN3o=m$<1{PSHFB&^`QT&EFv&3y7={%k0*;qiXu;;qQfK6nspLYz^&Gq(&dYn&w)xRa$%+stkZr0SX|CGwG9@C3_ z@>|$w%n&R@^k$$Rxk!J9YnS zqe)=C+Ic}!2{v}AR&30F=j5{D9jINgQT+YEn3E2SjgUgt-p%YJ2-;_IIutab z;)8HsT<@`L3w`|x0m0`(6W2-e3X=WX1%rSLv(dD$o`Dv_$xvoxDxgS*lvo6jJ1|qR zvbi|V+E0Q1u8yTuE{j9jD=sLC@8cD5zpVC;D#I=4Tis@`P#m#d_Eo>xSxvXt4MyolmxTcN4>=q2H4F9cOUVH~)p+Ytf#)Vx-y6HGMK`u!?2>SgEAHsUH>b9`f zLcG0blAt!8=m|_ZnJ@pD$-MP)cS0BYy&$~Nt4Pjg|4oABEs61j(OSySWsY9HwZP5z z4775K2PoD+dO;Nr^^PKEBj6)$1%3uY0f}vb@5c349+9&YjvQp1_tCUjfk4mrguu<% z3P1rUFi*Xl&Q7Wc4RnzFB|IteE3_(6DDB9=&&?pGMrbDwVASSy)sGWn^gGBeKvPhd zm>3HP+KTcE&5NYpHojqE4Zv~QEzc%e<<_IO@xo=onN&yCvl%+jst4@*#m#T@)|bky z`0+yBvq7PoJcs>YO5}b+m@^Gh+*$Y0_WN4T=arQX9Q(oQ!6i2uiN`lzrbYA>MTNrU zdm1VPNT2?O#6jA*8iYMA0~IB&=VTb0H8Yl096HO*Rm#w2 zG4_MbQ@wVnKh40~En`|;e7lyHmPR`n za(ebWU3B{0-upQtd9W|03NtPog@nqD=_gmdw24!o;W4TW?qnO4K1MLbsz-|HpCNLdxGrbwX3B|NoQfm<&s^TmvS`#J&40RJ?IaxAVhagmGXz9-f5 zJ~A_ouEcBk)Z2@{OZ^=A!{raR2rH%gv_-GmQ!3cqX9=){md$&@Hd=f*Kxi=Rz*#g2$&fxOW1E< z5Y#y_!g;%dJmbQD>Xkrjq~-c!*t%vb_SbN~{GrUN^V;#s( z?M^gu2HAgek|5x#`4jpAXDoqslcR8?RlZZv8S6remrI&Jo?w9guL{mJ*P`_ul;EK_ z&UMngUxY1#Aiq`=7=m(Mcr&5PI@e9eCZ-E>2h#Wk0WTkYhiUKmJVb$j9Sc%89gyud z=_rdLgh3@c>F~a2p*ushg)1;YJ{9osVqo?R+{=Ionk?=;BhPO~6QLi^<@ez&cF4Os zUz03m;e$bBq{j^dHdKW+Aoo`4w}-b=PNYXvpcSLqAF|e(kF&hJYHGmCydrFSvu8dM$QyZ3+blFf2_4|?u`M4A1C zwCFFg*ky}Y9SPqf7N3>|$Dn=t6Gjr%2p{|tfPzBwg4vTb;8V33s(Fy3%P2!5koUYv z<=`@-HGGS8lcf_eB!r&vnW85TL;euWyQha=u-4cquR+Ub!&d(nvxTW=rVEYn7PMK6 zSht<2dTm{B64rd=g#qH0K|VIE-FfZYB)Sa;5Lqaz?f7@;?LCm$V0o~=gL!SkWrDu| z1rawSG|_p*t}y~v;ARnY3^~&PJKM`AEg6U`m(_oX!I?P`a$O-){L%`-+N!r%=yhEl zkY_d84-YmGrP=9;`FOhss8QLxnT%)|yom?M^(>=B3dDyaPr;IS9~$|kFOp$Ip;j=q zU5cPN&s(B~VvG=%3J!XoPfTo7yuX|;lU4mBO#~J*`6{sc;pQj6vS_kiGBZVDN5X`A zWv1t)TMs8uepzB&G(<3UG*PTKa>qm(r8oY>0e?_*CqDTN*zru2iR{uvAsG+&nd9EV z2-RB_&oTDO-zUO6){}-C`9`ICUfX{XO;<77FL}{^U{wq7mqD7f1di>nqWF!oFGM!` zy@(pUzidpkc)|~W`Mp2gQ<-K_ZDfOAPDzqP*cU*94ht~7{P48L{j8As9IZsyUeRAd zy{*?H>(=K1Z+F7pdED0fN(P==&|bMekBpGbne*$7`>X|FJg`rN9opG7ul-djGn5S{ zC5HlOXEugVg0^E>y~Fp%*sETJ>B72Tw{A;*=1y-+y&LieO_}`YTD5E3B&(v=Z31Y| z8cz%nsJvVK%qh-1%JEn)aM|DRNv`y1u99_9CE@K6r;vOK4d- zua*Ijl%^0kNbFO#m@ViR{5#BDMQ3FHIf_edhMZ_mxS1qwuh0%rFapMU{mcE0wyw z5H5NM+XfPir5PNI)Kll{bqoSHKs2)M>$8!fh)UH;{nt!+fWA_58;^~!$0o`$?@MQf zsF`EX7Ap{m=(!p>A1IG^YnXj17|y(0E(@|^*;&oU6cNE({>WkkHk)yHc|ApwAcb;k z0J`xnUoJXrqTp@VL8i-_8Uu^|TSJr?RhxuW=~wNs)D@HvhNl|Itoyz)gx-iJ=EmV{ zHWk!|b)STFQ8+SWs@2x>7MXc)+i9#>&9F!i+nklSZ-$4|6iPvlqxmV{X=flTSdp4H zAx;Py!zhSM9b>FZla+Ku(*OO*0`D+DP4;-ff%J`yKLuM8HtMsHZ*K}E-#V4HI5yX97;25_UBB^gxhM2~K z5V+G!mLwxllYjmo!=0@+z;;?mW`$#U9zuc$@0o{omxB;9X6u?*NI^JO{}h^j8m0-1 z+oq@i`xlH2cYpqQUJ+enyq2p00=I6`x6B&W^{VpAT%R`a^w67``SSioXOAH_<&m`B7$M8{#Y5st9y;gUupeM}wF4T9>@vpqarMx=bmY^6v8UBMN z)18<>p&tLUQT!wd(43nODmqnOxrP<6O^nr^- zN}pkTx1&Db%GE6b2gaIU!&=UK3N)G37(p{h7 z2VHc2_@^`b9tTdE)vqVb!xv;JSSi?V*ag`9TukP(`81<6t&?#_uZlG7jn%GLRmbRQNn@MWjmiK4!$2{KMy^l+V;L|P!R$vBsbt~lk{&Q2!Uof5zBLqCN-&SHxoZMsn>5K+= zew98zXCKU{%km1zcucsq5vP_BpKHa^a22>UMXyO?s-e&uY1A>#wjUA02F&X?Z#h9K zb@+B}^n5CAF~JbifciPoW+xe&q;%?KW=9}c$|ED%aH=3q6qr9rDqLSN+pHP(27fNT zpegalwOot(csMzCvCdYxPtz{TOk%o*ZGv}8a zumzM$XC`Cq)t<5a<|*K0p(cvlL!oY9w%ROjV`pngc8ObzWNj}A0oZ_CLrWxy*>b9+uMO36ZaZu0TN5-N=}9OtUwT^MMJ3{cv}_ zd959XVJDQ36o5tG$Wj=dOU6j<97L)h^BB<)#sn|!hinFl2l=fv;hBwMA^rlz>VQnA z$uADaqHAp-k+yy%ITZgQ(x>fzt>~>#U~3q4xZXH&?XDvxZqr(x?`1da2{=8#m(4of zCd|mBSn#B*_V!TyHKx`h^Tn1rM9D=8&Yp#N$HkmamLMo7#P*NCi|v}q7$yWZM7-G9 zTxmMt)bU=J`2;>#F+M7~=fEsDgjA>h8=Ktp*+yq)?s0#Q2tDE{O+Ip3+uVzOj)&@; zq-8Zp*8BSPCq_c&UTm&NJ5M-i*-EIqGX}T9ajl_diqI}g6vX9kuDZH^_rIzXRVwyK zrev`q0;UWi@YOti-JdHk1X4qudk@pB&lcABgAws{@;t*}FoN^tEQLp8YTTRd%)o+r-Ke`tY|(;O!a1)ARW z*5fC+53);pD6Oi9Lz8L#4&Fl!;>Qbu!ny9=G`c0OMbTi!rHQ%MEJ!dP_Z1k!+~1Jc zU(h-o%vb_$X$is3bj2o^$KQty6T`&PlFSIkfOAiwNK*A})iLFoPzE*)q;pgG7Gr8c zd4f>L;|4zUgQ6uBDyUCDCJ~4gC^XqBbjK<p4&KZT_bir{Nvg^Uo* zmgX!d@a%XQk^t8BH7(ro0fdq9RqK~Ud)xbyA1$@f&^3|xxq4)<4A1T;p%sHrTi^OL z)OhzkFGOl2k#`Gk(f7WrMnMvS^b4sgj87)Lk|WgG`rk%`NP=_ypRKB0J?%z=r*ruy z*tn9NcK_x9?p2}CO`i*o4yx@G1g_)W5lxWLky2bHxydF38nxNx!C>Taof1%19xo?h zTpDB(Xd!$)nGBjH5U9$dh-w;s4LsNli{b{k6d72ZQ@g_?`_L-XgnL4so5W9mgapMd z)(^CT2FBUnntF|u#h>sWIC-H_*)&}oXO-6GAi9G^SM#299Y(YLF<+*ZY>$!J-N`vKmS&4m) zsW8=qX5aAa=#oWVkvcB6Ix^v7(|#DXFX-8oTymNWxat`MeYgh*Xz0yJLJPja)LBj` zPiIvivZ3+3hUQ0#;*|-*#ln$zelsbDv3Oz?hjKau!GdvS%a(WUPnWMl6s^NgH~g3~ z4dYHo^lxO!K1hU*p|hoF_riqRq^|W=GwRqW@!x8fUJlW^d&57a-<(z^)#a*z2NtCs zP8p%lSuT2^7HjqMU*;)r@L&)r!oY9F2U+xumkw5IJB@bLg(kB= z)GRtxTnOseGtzNrh;62>&Vb#{OTdaF$AlrS4$SmY{P&JowG&+g*-m+)i8pG2!RoId z;zn|TOxI<+zQrCq9Lc2PVg03!=3&jlAX2HeBFM2!$MdvWQqA#cGvpy`sa>ONaja1g zgqpC})rhQSua_OPZTMWYM^nb)k^Ktz&{4bG6j1pLC*9XehAwZ-W#WH4Yt>S_)eTFc zNLH@SFhNN>kAQ4w9MPY(Z3_$jM?loG2}Em@Jh1uxN#G_&y8ALrcoe5OtM$M2j;UC`f&A4F9s2VB9$dvX=gr-8t{LJe!fwX?lN`VBa-(ar6rBae-x-NQWG&ap{g1NQ{j;|-X;V-XLNXSMYq)zV?Pd2Yb^TNaLMLzvt z?@m>mZ^>xfNDej@?^8z2;|-YrHgIBpFce%tv6OXNft+J)sqf9Z_Q)*S!1C z0yJde%{TvJA_c8Q1`fh+(}QVO{yAX%nJ~ZopU6ohVCvT5c;}GS4adJ&~;TeyN&;+}|A=O`8b{QdtsMaodn3z?{=yf9G1L}%_s~0w0>b2kE8GTE%WV41K@K{_%`mBO93XA!y}$dU5-q| z^IK~@kHp*z2;^j17tvTkA$pElH~9&XmI9AKb=)%u0>wm-rt@GHzE5O=&8zgufcTB+ zdi=4d+IcX+6G)iV$~64x$B6)eL_I>4Iitz&PXz2*0Rpw=(gukFJ+rVCnIM+1{!i!? z#?9@l+2bXea$X^$?HcmVHr`k~*LI{LTYx}8mJuU)y<`F`+hy*MpZJ5dDiOjAN^%XGv{AwNEOC%vCP2r=VJG% z#8(IQhe_kp4CG`>_WEPn)d)8aF$0-|*(7Y-@7LCUq&MU~w?lI4l&|>rz5=*w?P5*B zGei$a3(^hz#mBdRw`3j62`+gDp%i?5!Bxck-iPWu?MjcZtdDOGD1aPZ68Z@I_LBWu zi#$IG_JHZ$;2ww$a7dU#4vGB+;pOV0&$xSb^s ziDbO)bzr9_HMk`u%i8v|Pv8vG%hWQ2;_^U|;CX^(>l+{d$CH9){x+}UFpAxDf5zaJpC>B2f5xdTH3bEyuY=|C6EP^H@V0f<4sbL9auP3W^N0@t-aIy8BIT zO)YYP2h#28_LK%=9)#!Dn~!5o3z<`7nN;)hf9!RVzn$qX0)@}SFiaQxC0(wImBGV* zmd){9&#W?uZt8|Njx(g}7BQJiDlp>g>c@l^61k5~K~zDxe2$#`zC^9txOOr*m5=Ou zX+W0cvaV}2@U_2>3F&+A*v)dY|1^1y9ZUZ6#S));zsJ|GujW&noEdw9D6Ceg$>S** zI^&QHO?vEsFc;{NI(VllII$wlBgtqPK6oZw*gr4CI0R)`4BV#sXF+K*Q6H;W zt@qdhc~|PVZftAiV4mGri!)LM-)K4~ShQ+>c5Eq04*Pm_l+e&EHx_V@xYZ!-0=(#< zWd{6BzKr@mTbFCgN!*n6Nw zuTmdJLgz=o_$b##+Dk3l3Z9PZie6WML;f z!$Mrw_lmMbw43>rwB@`J6wFc8iYgU()H})TA$X|0VBm@CE1m%>k6r8Prqu-9xY}m? zbw0h@hYjw{oUDmVnm-RkLVwO+w$M)};4DpT~Z`wU`EIJG2Xr*0* zDnFv=>1Fin=SRuQVb-Kg70%IX|T5ZU=rgu9>+ zLKvY&vN51h{c7F&+h%pggFst!e|%NDQLAkPyiYbxjOLD6*`wh!5a2KX)N~q?#J$zV z2B2?bi!E=7_C7b-F12Xr-|K2@bmnML`OMnXU05oYYOrRRGHY3n%emYKVFMdi`m~yjxAV#&++EMt7MG>-bpSOH`S*!koDN@USh3X}5-dweMoF zhN>jHW+-g`+CPy%;1ON=!9vEF9hOgHE)fE+>a~p;I{QK@;=K;jJ7Q+<(ltNZeCMo- zcjOv}e!r=zkjl3GDbO4DUaHVI$^X;hS0?dtUpya_v$u|GD%@xduCYf9jCPi zy|wt;z|RKiX0_3IPyR~Ha?zosb5EqwY{MzZfIDP<*@H=s(5<53pONOB@0Eg-Yl8iV z8`!KS%L)5|kRMb}=N9{8o*VSp28PN>2gB;6Uy4N!WGFqjKPk%Z3{TMUaY;l=>T%~& z+xqP_{EyGf0GHC$#&om#`wWpJly)S~n4~)DhBu?!t8lHhN+CD{!f4;tYMV!k&9hy@ z^DChtR+sZ$^S7D9Yw03DjV+4uA zZ*?2n-*c8mEZXXv3@h_&u!O$RV#BPX>0>N0*s<(38WV{d%JRp>RPQ~{%9})_$Jnna zpSl0+4>q>1`Ku?fZO0Xzp;0-0e=!OkXcO1|3%t(#M#AbC4BnH9vFf;t$>hH^4d=ns zU6{(nl}n>jXQ{OP_>J7zUsOUuLQ~f>9J0)@?_TQJlxlGWSjuzcLUwjO9^2knR&>gUjct)itIn$s=QEPi8 zRO`GXV}3qXU`+N)RkN}1`FD!4$ScTlwC{Lxx2>KyoLcW@h@y&$G4x&OZE~mD!b)KA zIG!!hsA}=m28C-DIL7qES zqG1i>)&?7Qb7^$?Je>ERpZz8wJah%cEq!Hg74WM_>dh5k4T0^X93)OqRfyn#Tvd0u z-U7vh8S4Apv2%mhL<2N_ zjo(0)1dM{hizH@#2tG$)e74$g=y+0}(7F^v)ZfvK?(pr$(g-DXO4@-Xd6mo-^&@OK z=>$Ip%G;(;J}V*+?b?$Dw2!L=Ux*i zg#@5$h8Teg-#4Ut77?$L#fls(zNlA_E&FO1U2Cy8bV}bVI;1#M)TUe=M3W|F)eg@( z%nlIxKEhL#i>M_}9lMI_pO`b?&o(P2(n{BAmS2M)m2bV;_LCFkjqCUt%EHpVw}%tA zukU6xbUg|jC_WRpSPlUz7g7&rE-!Wuzjkux6`IDw<|_>@I*G2h3?RfuSf}4CoubX~ zw+2B$X0(i<7n8z^`%0kqKb;VLbFwcV!kSNOWX3<sy z0iW+9;4eNC7pRga(0-@2P@%VXS&-aUGMOBH$t6!-mM70Uexp0Y?~5;1=(xAdHUN6V z)d2_ED1C;Q*ZuU@M6g@GD0v!uKqf#N3SU=Er_jEsHr!GHj&tHMGO&8*qX(Ta)ou+H zjDW-T^G|_w8cE#^>&#KF&(wLb^^?GZTpvbPyO}zx%Q*Ld4ZnvGt8AM&(cQmDG_N&} z4X}W!s^!K{sAUek8H{I3H?l8Ww`Y}toFP{t;E*^C6X~X941Ne$$04^o_Jk7>If$`GmwWei)8h?z_vJSblyMa0-FwvMyI^+AcAM97p0&5Q z0Rc17l0D+yA0WD$)zh`s{{$3rW^pC?0VIwN*$2I}BF1;m4+?HPj_J^-)Ia8SD0`HG zliUq5@2~F84vdJ=9ja*DQG~~` zOLS*7LF*492ulSHTzfRBF_MNrkF5xa$#cY@>G^|fzMM>SJ(a^(BSD7hqH*0@X60$Y zU@%6=xz*4){~kJCY+u)Z8P=uLg0Zq(^08;L#r3GrA00&W2)OylYE|9e9y+yk*E0zq zSjr$fg1FEpT}#?QVg(#uVhx8>Kso3|=n^-}F(6YtkrZrC2-$hm2Pon#5|GW*Gx;H+ z{pHEA4qygF!RC$2O-zXPtnazybw?$|10o|aBfi=-sTNMW-uyrE-tsF7t_}O9K^jQ~ zNojQR+V=cyTrPXik@S1ifH*S-^PSJ}iDw48~*SJsFX&g1+ zQYoG>K7A{a@p|Ig8JhUNEC77lc-t`B zFS5Z`?H_@m7RS*F?jW<3LP0B2?IERZuGmQr|2YZ;--+nemv{OVy+rK4{J6kJHsk-Zf=NecM2q{WgQvKy4TuwIL!KU_EhbC_D$GP zGRl4wAKha8NF5(6VL-m+!hvtDG1KNf)k$W2cCmp#vSJ9qDw%B z{-V+FInhjjbBD8~UVfG!1D_#*sl@+iqTzhsEoZ4iddVLi#i+HVUcH8>DNJD%P5FU+ z2&kvlRK2moz%xWY&K@_}_-%f%H-;KFZ13r$6B{%^Ksp~EY*o7L!CNn=QOlNU z*kU#t+3Rx!EeK_O0gbKOJzIr$ul5<(t>;8vF%J-j-<|e9<3oT*n|ylgmZ@S%OYr5GSdVP2)_WAXGSO}GO7I)ll=>8>%sNX0hO z_O2(&hFOd!7t4emeUeWKSs_GDB{*KB;V z6Zy++R#EO|+F_B<_Bl0PWv32}kU6wKiZ@^4?^XhS(^LZ6Lga)#>Hx4Hn?*f#hoTOY zDdk3Fat}|MUShOQ0e@@;ENeSlV0U{6^vVG1FpdeQ#8BSwOIAI3ECP!@zO_OGYn+DT zb`)XF-%H02tNSopuA_$|n!{Y{sArREx;y>yWi{6GyC{(&By7TgaBS#@pM<41W^}9S z9L)bu>M3xXjB~9%Mm+*c_)q_CX(qSVY!A3a3>qQQkNIc}s4E``J37tdZ5A7Lwv#Mx zgWwR7gP#azs`)U>hhUS$c|94FBRJCJWEIr}`J1pneMX{@N_ z@z<;{vOl)hYAGkO!KJ6{kuvPkf8E}Y7x4q?LBNdmG^B^zUm>!R0jaR{X9)32hYDQJ z_%ik4@9c_I(CvwoKA#NVFEqM^)qL2SvSveC7iK`_R22(bCke3&h8KxoV`R{2RPW}O zC+NI&PEA>b;fP)eS9d~Mds&=vm^e^F4dZ|8G)a0Y_Ap7|wmC02Jt*KQT{pTJL+6;! zE><{ztHl4BN2d8Zs<;z6_r%6mZj>;7Cah0)m@iMWU_Y%{d1EOZ3@0r2(rykWRga4n z>Qdd{By@40mX0K` zddc9)n>QEeY7*+lzR*aMpeZXeNdHnlh~Zn{bATQXnPMn5|K(ekgkk*K7T1(VK!_Y!FUZ?9vC0bxaU+EZdU>@-tLoG zcpK>jXHJs5w+85s-Ot;$KD@$JwLL0&WJ?vs(jwNPZh8a*ima|8RkTL)>|FdHdu$2m z`bApxdFN#pN?S$}k-4>fjh758{9{JF;7Ml;U6g@F>i{aVHMn>6?D6({EH%0XUsA$R z9S5w=w%w&T4&jDPPM+Sq({Oa~i$FQ&mIfbG+<6EeuHHU>j{WWV&aXWNy#A#0?=Ml; zWlnbd;>BgQf8^`GcLf*&nem*Z26zS`^%=|vbnXpI_T*YKzq`Ng}!hm@1&OrI1hPM zFx#7?qfgIj*;UyCGr(1D&bM3}1&g+%iNvO8?UlMnRa5%?xt191C}rY=70!xe@fjWXxFOaNZo^%rZmxIeSelX$RyX{k z+4I#oc{cW5^IMAwj$$@!?|mkeQ@q}L&jyJ)!oU;L?u*$9G1ZIhtpuu9lY;~KF3EBI zxqYZXpG7|{fBee;isT9IKEPt>hnmw;0s$09(Q$L8fK5F|AQtCWSyr|$AW|49=-ZW< zINkAGhjYA;gSZjAVxl?IM;&olbgGqs9Z(vl4R8kQi=C&iO#=}#bgHieS;l^#EBXoW zD1_fLU!B6R^1n~L-nwEq`H2~7(%&ZCm=`E^Cgf2wp`PPTI>i`;e*pp`hC*gT5bY{b znL5lI_v6rz*PJ$4Th<6(IlK@G5zCD~W@ByKa%=_)hQ6gLh0ZkURk!2lW?J#u&WS8X z;B3_i;vM*n)C>%|%3GsF8sZWJNR&kDF!a!82Lo5@IwfI^?fPO4b&JC0eGjKy6^wwqzCw7_i+Oe&DJY3+r>lg%HC?7pk!rCf@L<&Hd*?L&){EE@SxN6? z1jFDU6SXL*X96Q9E2uTFwTE0A4*&KKNLLuG>Muq?VbeYof2wr4g4zk9fKc%}>*;ZS ziEj^AQQu0iSxNL66om|$9l`7G#H+tiB$o&|R)0s{L`YdIQl@oS8zLl}Byf*GDJr-Y z*v+!$+WOWis-^`S73=B9-(O4r+)3s)I%kp~zBlO`&vFpo{RNp7m#k_n(y7-(D0w>y z=|U#6S_YBKKIE7HV)wtR3QM0)rJ0&Uw?j3!!>M|u?d}d-zEC~LqlJiv`Zy!NrB(WV z@msR+Ms?<}CWp*aO61xudlw9rnSd|g;k27L9(8#cN^nPimlkPf6|{-*z@<02wWV8Y zqw@fB&tC(PcD+l}CG>Di+1=Z{((!sd1VL{{L3NHXa&#EP*J>@^(`H1xLTC5e=|0i$ z`m7TSLm3$28}Q|rBV!Z!N>#)Rhi&^qYXx?JCg#;92%|1bZlB6_3d6D`2p6ynhWM|D zuxil9wjVz;)Hp}Dgv-d53;5kg?cx_dW}V$^6OWUyn#f34`uzQgXzaKCz(Xz18$rY^ zH6OxfB~UXUc7yMBzfK&$G~l04zIy=-+j+jCb229>&T~8d+COaGtv6AiZ=!v$>NSEF z>`ZCNj*+_!0Towt)iO@orb8-9POno%;Rg z;~|@(P~V+8kSM267YvVqTofH zc|zakN*Yu~aPni-%|C<149MO#Z#;Ql} z>Jb!VOv<>!#`AkS>btgOT1!MbCvt!j)0ExHwzo81C2+tU^V`jOCeUSg5N6cIG7-0* zDk0xz!61pPFjt0`HliJgDR%sBs_(UbhioomZDJMO`rX0lBuZ@=ZE`n4rZSQD}NB>4L1V39!~Q^(G_o_PLZ zZkJf6B!D+Y#YeRMa(nv<_v}bs;3liYQST>-VrT~1KTM}g6g`YV_aY5m|ojQt?%p`bHm4fmprf}LG9FhaKNgZ%@7 zN0@dQhGtA@`p?d|zl=z5-LafnPvO)a1jJ4sd{JInOD`H~m|O9Uku^Sfl@DKwA7`dY zX{!2Yo^#)d*jb%~oaoNiR9`hNQoaK&k|VMo;iN$xoKltOZTEe1N5CflkBIett1i3e z$WNUa$p$m{scQdV*@(kBRteg;B!>0e5F9Jk^QSVGTS2ERUvWj)n2LW*u@je*siL0< znnnfC&fe@_8@iK)k{o8E_K^C+T4%b=9*3gSK(Zw4_dNkjY9|u(AXVAfrf7wnjA{|j zd`=iLDt3-#o7?=)Vn?CrA_Xuo(~CPQId!Za`c{@wT0N z`>D30F@)m@oR#KNZCgsJE!c*)5cg|p-P;qHdk<8n6bA{ zK*|Vpm>iAW+n17VjyVhj{R6l=?myhu3a8JJn56W*esxF2+PpX#-vRNh*q7Z)SDDPF zgUz22kfj&a6<$3zDq5EUpp*zAoSA^J?_u%NR6;%e7t@r}d=(iGbqc+y{F-<_k76=Z zg)bYon=+yz7jePV93>`-hlQ|FMjBm2ul!dpV%NSF>4nPMqN0UJ$4Q?7;d-cSf8X@g zcqxBItbdu@6cDgLFD)ci2U@Cj|3wD_+PK-y8Y4>lX$?|;vTkfuNqQo(eX!!SNZ0Sg6mYOxlHqGhv<+5}1q_A}o zxz1cT5XhTiA=OAF>Yeb0s7r)|u;I1u%aCKJLXMEcqyof5Rs}XDn><_=gR3ZR8tVS@ znq_C05;EgGuMh8B=N^X)zw@ddz$g_ue;6)G*BY9zoKV4|C}#EHy_KB(VmhFfuC1?m zQ@YErt2rwa`${$)D4^dwT&o# zxt-W(&3*dGd4oFl>)zso!e~CB69d{B1O=UAb>HLChQx88e2ay<$~Yy3rAIL<1*O^j zqQF3gfr&p1+H5-L!ip=Wd8`_{L=+zsJqy`^AgIM2(-;<{^LVd+K60MOkX<_|FC^-P3$V z&R$dJiAi?hOML(&C42BL<<^d)I>}&9P2hU*j~VK$86rbH?)DpUg7WcA3N> zDMDPY*E-*8Et9k9F@LGzAJ|%N{x3Jc2r2g4PQR3I%D_7+50U$j^4KER-<+x+rM^R+ z-Y~)D_SzfFwmdylq?BER@%(;8pZlX+4L{N#MJ?h3a{^^gq5~@3cCe#F7ZAA3kx*ER zH7b7~p@m)Cy9{ym*dxiLV~=zFh^w-TwFhCJpoB--+?34R*;N_%J>lhMwf<0_^$S<| zJ-2YBc@J9gaSH7=5A41OfOwPmuy5l*iwe6Lua$THW9(kx^jpY0k zLEp)g6Rq>U+4R}NYUugH+|btLgu9tq{dBOHA;QOh8N@h`$F8)zC9*Or(J(}>*%hZlIkK0KGu@TAw?D1BZg`wzM|@heGxdu3kj1vv+mfY!jO@CVXj)K7kdgs0 z_UqyJZ{xYl@!7g?ltR9Lm%nC=7Qzhb!Vm^3nW?S-kj=LKgIK%#i@-)QF&)F5xU%q~ z7@wue4Ug>94b)FYPd?I45*@oYAUU7Mir=ik<}zy>|JpNo-Kmr&buDe0JR2+DX=J8Y ztSz{FsrT#Yah1o=U=20Vob935DYDtP>ySZHrZf6OK3*&3*1vWK zfGPpFj_6?L$3>)q&uo%1PC(~)TpE!o`S#DLKm-3w#5ml0 z<)}_CZDssG>{V@2uC(bIK$=|dY*^_bNSUg#o>%2NE1qRsdb~R-{cvVtVq$Y;rMA$@ zJOXT)Rsd6J=HTG)`Hl?TKZ(uU09;O0_Ge3IFhU*xfkVQJWdG~EzZ;w7lnfDhHej|^ z`bVzaYUj$m;r>`(LNrq)P;%_?DTBvpSL$Imu$Ux7dNiGTl$pNp^IhdMWBwRpa`=Fi`^-#~EUJa3VlSZ1dULQRLr-}KkWR7Qlj10Y`<5JeD`$&Xr zv*|Fa>>cbJ;fupm_x)6LODg#!BdXqzn0>CEG!dKo&!CB1kUS5f#&@=>~+ zY)DK7KNE33?9E~CbN}h_@g24-ZE8p*#YDyK>vX}QmC3pjtsb7Mp4W|dIi%X%$K1jM zvX6iINg|xL1`;WHke@y{uD?=+5}f^fe^rORRC@#WN*a{T)bh#6RU1_zu@A&Cx{V$~$-zbityNxr zIi97O@{{bl;Oy7z1$%z7b&=iSRg{FokC#5wrvv@9G>%0tLa|Re#J+v@xf6I(JP;b+ z(`AR$DGmrWGXw{AUvds2IQxz5R^XSrN9$Rr-u^u3chyHEX}?YsSh~E{^EOg{FI}^} z<(MYW(RZ`0n}PnBtI!8{3*CP_^-`xB;Jwq#WAp^hRcpDw_Wt=)uzvx8w$th8j{z(V zZvUS@3P(N{yOT=TTptjI(*TH1VTaW-dJn%K`6XS(KU1`y0^_Qwk4a6~!6s23g8 z5R?59Z45QpSfv-E-$O7+a>ijEJ;9w!yWbhu7AEdx-8*}N%F`1pgpLV#KIi?Y3|Ud+ zM9WyE$H1i3!K5C*9GtD7(a$c&d$zm9d|UbuuKZS`tXh5CpEGTFlVnx z>&56Ps`aOeikS0Kzgi&TG#^urP$w=zPjFYb1jt=)#o3cyyE*gRS2?0w)zOj5_1}vL zH+e56lw{Yt!v}#MwyKGHDgm3soHK`=!@7@c`RjHUty1ocnPx4>Qi$H+lq5cZQ0i+$ zdSq`xyYHf()=`n;dlYJIK1e-SN_ho`Kmut?V+Gh9HjpkTM;n|7Uua}_>5XK(pX17Yx@6$5w?K+@w0s7@O{odp9G;;S#2f9 z0!r&Uu#N46CRI^dcPdWAP(6?t-+v_1r7WaGfu>O_B5n!e8~{;C+#ltCQ-6NbZupF4 zn-G#zRi#Wj4AT|u{CNX&W)`U~$di{)U(&~Zk&)*(az}f}bGGO8Rro84>GUf`CNkuK zHFrd6)tCBjeIBai-kfD*6FE&pza|;C*3^V=>Yiwk8JhPwm{c3&!6GI2cHbHO+x23; zOuxzPYLV=vNvgi4Ut8FbR2&_l){TL?F%KC(zxGzHk_uq!`nRn&*@B3%2@qAcV&sGA zei@CJMBVS?k_@(s`6SglZYM?x-2d^Qf6U~gt5YHZi&&7Gtx>r6T9Pu>?6b2mKR#<>?y+Z@END9t7 z^|F!AcOIN?&H>nz!q}+TI0G>N*n$&4dqE?i^^T5|w3T$i4@>C{iRW>%f$(kbT2j5~ za9SUv?aTIds!N*2yJoClR9c-&K+gHtUd{YVJmTXrs=9mZ9Xzts>`BozsH&6lm2yMx zHVmt^f#nxT`|JeBN7D7wi4mzhxMB5fXLx6zS!m%SnvlfnQqo8a)h|sGdg0Lx>Fsl)-q+X@g>?q z^J>@B@w7Ry!KMa7by3 zIr>cG$q%}$H^(e5D(hvnsKt7lEx)=QR{Y?Ezl<$tb_b|Wih+qH(I{(aTyRoY(78MD zh@fhN;gqLPjCAo$x4le{CQ2>V0*9XHdhle$Gf@R$~EiNN09jWnNt9eM~BgaYZ7P zHF9;-Wg!(RrGz>uWU#|`ce=Fn2MHtttYv5A1JBiV_`c4to_7ih^@vIcwr0KGvxC`w zx_?hgZ*1OnYDL>?D~sD0oI@^^AH@*%63TIDyv;7a6nz*Q51Y#gowEJv?(`Oe?W91Z z@TPKHpvHaBqCP&$wOkd1rNzmiP?faWepEwk1O$b|!){sL{Sqt(t|2Mr9HK$iq`f<} zbFJ6(qHXvL=cJ!kSEJtI;n=z(L&N-hLH|-oRozGL?_jd2*&9q4x2I#*`oXIq(lUP! zV3)`fIty1fEF*uWq9enluU~L+F zQ<)CTZiWOSE#Wt_xhiv$waypw@zU)x_x5T;Xc$ZnZ)4&=&4|C8J|pJ&U2>r*J09!QbV7vDsngzb3YX7vU1asMZJZG2fMFKgJI#W;}yk&xJ)DX^KJveT^ zlC{U6`L~_`>j3=^vzR82s~6R#G-xOie}l31L`MAu=JK@aV-@&H-uaQ`SP@LW%1!^N zgnF6yE>NxIt$A`w)zkGn6{Uipk9OGDNr_c)TVNIceUquHTNN>VJ^C~W?XzJl9|p#s z^at+zlfPTg9h!{UP84#bW(G`v&~-OqK%nANzPYZ~qzX^5-)7L|cIr=E(#F9AW)b`I z7S{6%H61sx>3vYWwn7#iM&6&)kD{x|xeSvCI+@K@vgnm1r zB+tybU>Cqrwn_hEFQe~%n`d+-ar}~?`bV=mPeP#vwr?mla`5p}4&z9=5965M%StZ$ z((7$pKqwl%+#1{D&T(YGK%Fv+Rw#iOCP%7WdNLcUu~cq*m5 zp=u#okHvQ>-(DzH^oyZUO2S~UG8oBS?!|naFJ(k-Y=3KpQ>ma;!Nto{vnw{FG~w}7qbsjey}W=Ba@J$$C&OyFBV9C zEAU$dQ!7z8(}(qq-$hqVxNN+DXZNez{-iH)4MOk04H~8u z^&M&bPMp23-7_|$g;0M9W^(`fjqmSqxAOH4H>*<3eh|fEppma|a(QR9?w;Sxr|{l! zj+z}?wwE$d5Eujqp7za)V4lx^skcNEa}P#-{$iN{6?4q{f>u;~B|66qhD%|JC*Yx& zAR1UQ_tB8B1$I{+)4}#!;0#qf#EA=Wu*OD1`bd^tR#XK2d^kGU#Lxy;SET8s$f)U= ztCORwGdASKLqdtlO*W7M&ewi%|6HvgIKIJ8n+|d)LitNBqi1=kIx5sM*2rGjE(jr6 zQ$j!HI-FTYKKU~NJx%h%vSliP$v)JUHoXDq@~ktff2|4FQ`H9-WYVJqF3xI$;)(4~ z<8~&nw%&V`c}3rR?n8&|zl=-WK~HEG!`IG|`7Y-7$TCv_AsLwZ2W?aiBdiMha#X!rYJtt_>zVCX+eZ*($%>KtzCE9%YZS6uZHDF5*MVu$ z1tV?-Fbs1X=YIHE){ribi@lFU-V9b}yVKIAgX5RzYJzNr?tD)EAUMQ`B9SeqW1roN z*XVP->SLj4dfOE|>f0w^=xd z^I;XFVVqfDhN=(c5N4<%sU|bWU*%~0Cay3mt+cq8Rg;7G$lY1|=D+2TGxv;Pn)4pn zAd(@phG-G2xx*S^ouPDZ)=uKQ!oen6)8`ROKEuArL_}a4=dJZG3@WvS=m`2@ig8!f%4pJSag}p@d&^9}SVovR=zi9~^#? z<+~NDa=+7Geu z4d8b9U(pwlVrA?6YNgv=>_4+hfD&O&4zQr+nLA_uPBemFZ6*E&Fr^-L>vfs`o0P)# zj3s3o{cQfvLwXp`7c~Ald#%xbUg7`pq6v@03tS~;|Hv3Lvd_wfESt9GKM&0WfEVz= zupv@o`$xuzrGokWu8SWRmyU5Q@)Ne$+{PzD9ZdwH*ghGi9*=!a>s~tVlnA9PH za9I9OHk|Pc8byyFskv6yntP}1vS=+HNol15jF+Rst z9jEB}`_uk48c;v}M|TFm2Nb`T=C=^11TS#emOa~ghl5q+;VB~m_*G*^ZyuCtZTJIz zEEQ=eojqJFhaDY+uZ8_?&ev`6qmoU7JZe^36w>#9vOTZ`ZGEff`{*?6N=#$PH3Tr^ z{DXCCZBlQRpPH9P4eGD|_qU?QqB%(s&=iot?@k9`tVADo_l6r##4GnR%CeQTJi1r9 zo~-3buIUoCtX-E2Y$X^Dfb8}F3KO5L$xw><<5@b!0H_Lz@dDllAc)EK@c$!>wKjhh z3T5*`LHI_(>b^aMn4H>m+-nIOD;&}fCf+T=LCCi!f^`5DGcfW~L>3HN;y$Pv9|V`e zcgA}}$lp^MMJCa**BSp=kY$|YWg=Qp)=p-Z0L(Q@``VjJ2z*Y3bN60XXb|AewWN`7 zcgHn81s6c(bkEaJiwnLY00iyr>l*{@^4gaA6@L_A`-Px?)==L|7~alvXyk4|^Iy{BN2 zA_^{)+ouH+%k$9tKNw+O5j|Zoy;7swI}5`)_y2uEP07nTwjNS7xZj=1|MRMAVy(oCCN11{s1?g6IqU6l z|JoFi4-PG!`#<|ZNGmpt03y7$0>}oOK_{lBU_454``GnzS@sML;md+mi!yYgDR);drV0^K4QiNju&O62U0Ji^It-us0R7z zX!6P$0C{oWgvi4z(&gRzRv$2V#p$oYms?OW!tCm{`3EUS5Y58&p(rWjDUii+So6Fw zKerPywgUbVX!;%M;+vXZ;jpMe$I|hPxIbg@5Vb31yRi_W{saVE-BR8|kl>MVCZa_g zMf2lx<0k>{PhO9 z%K(E{ij2qbSCPl2d8$A~8uy(hoA~SDc&2oKS~jR_P|A|{saa!{&0;!4#Q3g;7>u4$ zxz*bTl*BD%0O3>#LP5+2DAd9bk`k;>+9$Kt9$S_EP%rbCK12}F zf$K8#M#MZr;NVaqt6J4XwN|~uE;#xl=ilF$r8>3U9>W6!=S9-QPZ7kX!%xV}%fDSp zO^8qPO0<7lP-V!}-8b(d#g7U|un?4qE;CPp13?iO;Xezy?dC`9f^XnhvVxr359;BE z)70=R$8E*}?+1j#MtisM(q+eIGjP}s##jc8k6@b~(pCdNUFBMv#l_`8*%GaK<)_Do z$p8!iGvcnkY5=21%ESLr0~RNh*Pk^0-~Q^2XG07-mT7$TDxe}(0d+{`0<|fxV#$W; zdxE#NzFAkmlTrYX2-B&kerbF+XeybaFRm(i>%rFC?0!%^J@IT=?T&bp3nX(KWB(+8 zO6+p~@j>z&dY2Df7fceRpDyqgWT7q>l1)OmJN27SB!#Aos@>e zB_fyaQw&@0Y;d+0zb2!@AtDx1$tIamCsgeMJ9vS^X5UzJUmS@-p=z-*F!!r)+AJsl z_%PCV8`$RyLEtOcts7r=SkjR7tYwH{Et~kdprAu-nPT32XJE_Ow)vp%<&;=N{nu& zM*0KL&BwcY_4&ErvnT*90L_cL)VT(yLSf4Q=dG({rhL#9O|TLuO}h|L7)|p8{8aru z*DXgXp7E5cA-;tU(Wz0?E?~Kw1SHB*I_10!Fefd``Dm$FWhG=`mVb{%t)#Ig?1}c~ z&&KQrB=QywYS$_KF;9N#McpLYqecN>bGPZ`l}%#X1aNMfJcPx4(D7tVtlIm* z{Q|g*8^&J=(s?RBEJ1CLg7$*wA0an-%XCTsRQ1fGgmIvS{3Q{0P^mDYtp+2Yyx!5~ zWAG{5;Egin9+h37e??Bj1^B5~YBNR}wZ9ECad;;;M!WwH5{1=CSpQ-|6 z-Z+-^9(l}mBQ``p=Z*$TEA1DeluD_Nwz3mA2cx&aN}t;8<+C{dJBr+UIcs|Nb!+u_ zRUtaK>_z5Jp!JYUJ~PP|o_b?)m7~eUCcw<8iQh*7H8GdH#-GvT+SF;sAJ9fW(@SHVfeG zsKZO`k#k@?jeEaqkoEdp&LW&hAxuBqVPN74jG;dq%~r;{gF`(VH){hNsTOhW;^6_A z78`%C8X}6w`GrXtwQse7^-?C`HPYbOFoqHsTKF-ZNmmtYy^4Tl{SiPD#kMA@02TdH z-*k$=%|^^_XDGHi_G6!8oy+|jC(qx`2iZPc-dCc*jN8 z&a9&95h5XWknDkR{;LIw-NVm2W4$xW?t4#x_8olZ*Xsup^rhdvPj`@Nf{kt5wf^lM zvSfuu<(B5J@4}^B%obx=q3u=9e~$yx;{E+wK+{1}IzzX2&hTMPqTAAhj4Wj1%_-P6 z|GM-&snV+!VVVri>TV(VuADW@HaP7~fPg@jPTF*yqR3>4_J@}AG}R{#pRuzl1$MKM zl_}eEL1x_|-4;eI5)DTP+RESTc%7GPA>HU}^(KQBUG64LtV=H`r7S{kqW$p_CUYl>mbxx#W901%6=(Xe7nU{aMk)+gZ~+T*6;fOHt_~L`PS&ZbcuIe4^d~UKETN9SVEz+JTj?*f~w{np}pGu7a%(l@PMXGE-_$~N?jwWlEj|Te^-iF>{?qJX!+-!#+ zx4|K5R5ozfD+zulL`5My1Gj6M!1O`P;=Tmt(iiVR<(EMv_mmHA9g@t)L9zatuO-d* zXQn3wpYDH!&XCWZYZvr0xWhiFOtj-Xr%wZW^=)V{0DDGwoj)Y6PVnJe2~gi{^3j#6m)+nP1} zZEo+>Y51&xM#LqNOv-}-TW>iX*$xE<5C3Ak+G!6@dzpy&2(*Z`ys}@t+nsOmX@Lwc z-TvVb8WT_cP9}oT)>Wi&^!M>8=Rq(@^6uIXhh9~yv>e|3p>*PP^u0|cN~O#FwLLrF zIX!srP~Z9g9v83~DQX=_<8u>!tl!o!BE@_{>F%;cx)VSxqh{5cL&;){nbyG;L@3HS zV(yW6op%yVqwvmVW`!KVX?Frw`10Xuevwk4ob0{TaxUUI5@J(M@AG}UXHDO#U<%?QGWW?{~~YR57di)wgeqE{y;2z#z=3i(yZo$ zj+O*_Lk%PnFu!c{9*xP!ScjM1eQr5)v28FzPnF8NC8cV z);K61vD&|eH=B(b@W@;8AL40hdP`gKXtmI81wVn|g`qzVIxylbizi7k#aK$76)2u=WhVh3zBURv)C;wHx&4Wg?%LW-<8qH=3&3SQA{b z8Ko-Dbco@O+mbHX?f@6)3Pz;sD3!mT+WDp~5aI*Dy zmC1tEvO%J!BjicGuqY%k3!?UgQ?Fzz4Q(6>{k@-mW$BQI5;4LrwbC?qrG;OqgnO3l z0-OuUXbG}2e^Bipz1h<73*V{W2t4(f#OGL?)hGH~V9eg0r%RmY5P!jRygcovQ0|F% zBX;3-f6YWS+dp}gk_mjAuW=!k-Q4&l5fZ#vvYcSh$JFkL0z7jkj-TmWIN_6&BR2Uo3H*f%& z6BKW+{U&e~KXLG%=(WD|imYF#G_AE-vufrNgw~pkbv|6cT1BuuCABsf{!kr!z@;Fh z5LI60P~ZnW?8u=BYa+ zDu&YjZ2hCm{xZ)zvWZ77%v@^_w&m6f!OZc_X&#Y%>EmF zGhO2xJh2`U*$jq&x(eHKedL7yfmNl-46D3w9ix4r^-KE~J9*vuo_N3Vb|Vy3**D!0b-q z8oV3qO5u1$`QOVazB{da2!?^GWXhIw6(&vA?q@231)gldjhZ1Q-|qh8UFqkg%+cu{ ztcXAd;9-?@YsqMea$CE9Y;Co^6rlaa;mcf+_yBmoV5k2gx!OJBWV&nNYrC0)GFAGZq|B6a^>ef6+F(8A4C#A z6wFY4s&W-&BLnWY7y*y9uv^tqop)n+gv^A*572*5eCeTx5=`?hPq14r2P(7Y^dr$y z+}bs4+x+2$g|s)dF1Y5~LdT|4IILzAkg#;Nqv7o&()EZCFo+qtW;4pUJBmg=DA!#J zmEn2c+s8v_`99Dc0_3K|N@MRaxY-9;rqu_M30VdqAhfET!S2@RI4wA==>Z66V>>>UCKdrDKIxdUO8C|l z0yi#;%R0HID^rNOK2?|ry;1vQr$tw(ye`NAe#7tsilDX73GD>N5Ptmb(5MsI(hsHx zWqX;DB--A>lt7O&-F8rVOV`n=I|BujQ8L`nzLu2S!Ho^6H1>|p5^j_z945$LK{uQ z$Q4LS4xdfjYRpA%#dI=FYVo-J@M^nmBx8gmm$q|abryY@e|y3zCpa&mVyjPG;e=;$ zn`+Rl%P*>dlp)PGG~_n*feORP0O3@NB5gAtUoxQ@iFFmu#|ff(%G)EZUA5E6|FG^M zdbVXXJc0eQApBY ze`hX@eSfbwJtxam>j2aHUlxG3oVV3I2kB`MypDa~@txA|7?T11YZFlf*8`jn(!Zz3 z1FEy4r=@Ns4bu*2)(K!SaSHWA*CoGD4~h(sbw46CR~ACT8%KSAQJ898!+I(~cW z5B7gQUhgM9ut)#~rr3?|ecYMfwFYvvu z?0~C7k2F#0hs=)8gMOOGs8y=j@$NMvk zD@PAQX3S3n5I59OzPYak z>F@rDrZ_aU_X;R>8AxDGVy4|Z>;t5S3?26!A4RH1P_HPx)8xxM2nm@0O5Iw?^s+37 z+fA?zpDQ9_5G$bIF&xLU%goW2Hf928EHBOLQlr|%)-IuH=8?s0L{Fv0N&{#NLS!|M zTP;8&_P=_*s{gJx>NU8yoVP6mvO)oHg%yx^&*USZ6aAIjrgGepNh){MdH`*!XTc09 zX6uGh4Eag0l0l>5uba2z!EEJ+CNR9r2N4-Io53X>uBQmgp`iI?*2J?KKY9e9ZDwOf+ijTo$;Ge`ZMypzxf zUI8-w)$UrCE^grS$*ivs4`f)#a;olqHuss^lE<9g!{m6zdl9m0i6l^s#GzqHmYNJE z=@(q6g|t*b;<%b!fB4`464pI0{q?-XL z1G(+#Vj9I_dnlFM_%8VgSf0#b(!@YZLG=r-6KU7>Kw`}NC(NaB_lMhy?^6YFmWG_% zzZ|?kNHa?v+>|ILkVKVRnC|cBw&?{$-voy|fuSP>QBew4E<~fKt;&cnX$P_NYC6nI zoyhWhCxPxOesrh9P4HoU*Z$B?wBrdpoaQVmsx>Vt?fTQ(a2m+AGK1%R|D~r-IhX8yZQF z|9{>JJP=e2d^KRSsMY#{-DwG9x0i<%Kra7soT=fk^w0*p)68Zcu&d`TG=$<^gK9_M z{(}2=-n?sm54pi=tI@Zlb`em<#}?-pxw*ONfvrzZ;{<8dlq0ROAU!}vhp==YBx&H8 z+5*wcth(!}E98FZ-+j(cV5#gZwuNP9{E8Gi+deG=8cxm>$n{L+$JOfq#5l>*u--nc z8RVGCTEIWXG6p*!>6eOBvgo4m6Q6G2IJG;iVC32I;Vm^fOYuni4DhVoEmoRIeJB|D zZ8`!p@EC-mn4Mpx(F)|L>Twy^s7p=dLc{z}bSPTtD7y8W|T=*M| z>*U4Cn$lSl>aQmo!$klxE(Mka+cRaYc6%kD0#A%;lmL$^ z5t3IiUIGU7dnfuj?LMB<;B9-}pW1Cju2M@S=A+jx`FhFAF4F!1c>OvE@Ak|E?Dx9) zR6%1VpUA9Q2$+1P5c1I1L#eaU-A2I4o_f(iS`i37313r8sB%GGjY?T>fEAOz7lq(& zoY;-pE#xt=5v&&82Tc!!JD3&h8(*53A_t~` z7r^7pQ4CxS=4kZ$z_h^Q&d10AZ3eX-8OC1r!)tYg;1MYfqbe=md;@|@5lgJP>|xtd z4|k8&T4*yLHgclT55ZDL?TWUGW=IwA{jq5AG^gV0vB5gJR*}8Sf5<)7aU2Z0eDL{nx@%Y` z!iQ+gw)X8GeVD*Z4^Y{=%Sfyi77woRjux)}MvYDbA?VDJ;ftmHp{v!p!y}jotcXdO zY}XS=jQaDnm*2f^cD`0jfu*5S5BIkpx?D+=82BNV;9C^|38d+2W4wxZ{f?*$4s9#w za6dE#pt}68waRfib@R=|$`$T&Fp3w0+vF$D#?McR;rJ$V3^87yGTf>J=$T&;LgqG; zzqCsnA94w7qwC|beX#OqCol%mI8s7(4v+|}m`|KO~#xAF-Mqi(6z zEBl{w=(L~$zMZ`n*&#sl(L(dN#RRU0#50}?oBr^E59t&^K~K=U-S+3}WHbYPNm-s) z)dJm9-WwQJ8oRSCsW_AF{<=r#G+lYeNFFAX23Mf!mn9kp4JVJ_GhYs{;TJ$w%1zpv>@oSbReCpS4A)wzvF^gt*%E<^FA1zRqC-z@U@RCJQeO zA9C`Bk!TXFx#G9XLIDPNA^aQ8Ei z%*=-!tYIPd#J5mu_23jG@Q*MO74V4rh;{VubSs%e2&ZDr9hqG7x~!TE=WMdyP6+L_wzAA@((Xd zW7o=*-vD^#_{AW^!N9dmGe)1Nv)>aKz`s(j@sYb__XkyCYCf|rIkf+*!TaH~8;o}i zbwv`ad`u(O&#WY$`nJkMU>RKUrsutWQ`JLH;C$ zB!-jqg!CZLV=X zaE$t_lSUn17K}lV{0^eg6CHm9L{D2~NhY(kqggm~6WlRVk;JU$H_G{NO;=#L)cn9a zbnbRRA!FkfFH;O?|NNp$ZvEl*)K<2%!kQs-9l9#Ia}N|S>rZ~f*Zh|e&Sh>&SOlmI zEE?Ur-V&nh#;qasgalB@EASeR^hyXScD+2no6#6mPAFwJywc`lVkki<&K2hSgu-ut zgtNPOX{X#}A-QExXUm9-G%6bn+fFw2b?paPY%Vy%T-))otPl?NI|aKDzwTmF6J9i% z%LAiy`F)}vczDRqJPV%P@V(zsb3aeTGG&OjT&S}v0*?fvMR0_~bQDAQAD9V>lx`F; zD|>qA*jSJ4_jua8>>Pf_@);;DbbG;m??CqL9|?icoDP~L8m*=}O44OK6sgA^Z=gv0 z#f9UYNc|}&SWt3KX>8eWRz8iM00m2G>i(}3e=)Bz{BMF;&}vY~&|x(BE1b6eJDq|~ zTVtC|#HpgUq6uX#>+jM$obY3?k3x|vY~m;cyT7Gv&wOivU=NY0bQ&V}fJVy!83$Z$ z^qxwOW2jl@7{1T_`K-R}61_#}HEJhDQAh5ITra^A8&2%cN@%BeuC&1!;KYI+SnSL<6%n&#^eg?TPE@08>vFC!Y-LzQ@xG6IjL7$Ge0H3e) zU{ZPiXjW4(Mm%aQ+OZqJ!P9TvDF{O_oq|aNl{1|W=g;#6l4*U#{@uZ8TPKS;C=c5F z>G%JBMDN<@0B5)fIT-N}1<;@q$H@$j-+)CD4REJhzxmpdYK6QDu;ww$kKjT4QSKv= z<6U;2UGzdMxFa>=|?m- za48i{+|y8nN92O5e2v||pmPnQ(@$`TkR~zw61JSv$pNmwNgyzqE0Ki!c^(7Czi+-_ zZnQR(CS{jABcI+tu=8$ape@bu#KL&4<6nT^?uzq1!-7dF_6@#NL>fx}4oI@h^xnHS zpgwJ<7a+XYz07Q2BD&)?UJbzt4%}P>a=%ZPM516Q30~nlX7*A1v;8>9bYT>GT(rcKn3OJ10n_Jc`>sZ#%P;D+rxy&?n(zghnM83R9<*w6!>`QO1>~v{eM41X9tgRPsJ1Odwk4bHFd; z(R1**vq9fh=XZgQ->s%5bFKgBM)K(SW_3IF7Z5FsL&!S@CP;xHX=h(ZkG0vKjoP3l zeQ?1GzLcY~VbKpvpqGo3DlExpU4GQ!{T`_Ui>7sF>DFZ)BNO+7h&&y28FwuTI)TFL zIk!~j=s*S+I%;XWpmn=oi~KX$D;g&_YIdjW`fo$g$u{51=}>MC&#fYkZmH%9$^`XVs@h7s;)v zRfC|M#!bQYSoZObY7!Gd$JHl$y`GIYmU8QWn%SKIbBHGN-`%O3&!af|i?4kW(Po`OCw(wmhrTDM z?11tTjDcjdKZ-(zcY0!Xb$PR`Gz`+tfT=WYCB`*+9-R%H0wz(titD9#L@8g@P)(&r zGE+6l9y-?#Zv6y~dcxfb(Zy-@SJed+cI0WA%XibHSvC~@iZqWGnccIy2U<3y?N@hv z4!$|W7CX$Ax1m+j%K88zWZl}#v1qo={?|*CM}+%vm#HNkYPsFdKt=1jwaU}SGRCfS zp^}TO*R_#npIa|SjytpEo)Afg_4q%H5~9r1OeT5|pyZH3*G)Cq?2@2Z8BDue+idzF zC%Ta%B+#6^8vLs^OC7mwB~UqQne}6oov9C6&oOZ>y!>|amzYb}ydV%LAaWaHXe;gbL$G>P=&&%L4MdoA0aMA5bj_amdIwfyr!8a9DSUrX4aO`)ZWI?%xkx)21dmvUmKSE3?i$>#m}|mC?9W=W1S&J zBhSTe2K-gV2Y5?#sI`F>O%S{Xa80jN^SE)(Y)^_rf6>5+jUja1#ytP$}weJZ+ zQwOLT{4VAvP25}Ly0wkS(2eBJ^5Bi|sa^QgV{ss*#{)oBj6*M-oScFL9ucD4gC0qR zMvGLLSTUyO+zH5oA6dRy@BvTyWA@d>xhNO~xcoNYd<=UC9iv&&^tmI%WadLd`!A~q z5>#gnKL+F9q2#2{9NH`CF?t3&pw%#heB{@?_%K$-)}OBG-dbU7%oEeF?t zO+VyheQ-Cv?GI1F;BpO~V=>4`rP0+3Lj`cLO$!*hub@0(&A+q|-6lZX^Z3>;UIET! zxfVrMlPjLW9$$5`bFV)?mnhc$Y&}l*j*{ClI{B}gVxs}3=mvWC^vh($p(1O(=Yt7l(xt=NuhNfSptW}#b^>WdOwJzD8$ z<`|T|S0NiejvrCY68fS8Job@~0sX;aEllt2z$jSvYn=n@8*YBcGisl)O90n}ZCe6z zskgNjl#F_gG)tj&8e%+p^h1B}6Kj#NT}LqxUqUtWiK?LHp=s+U>xw%`s$vO(AuDtv z64kGNFlL^w^vF$tDk?=&(y8+hJ9e$&c*qH_osOae18f2$&ck=Id0)gcY^(t+b2ib_ z3g{-%udLpmrCHq+6SImXAN3d1JSbm-Uu+b*1yIUt_vcmL7@ie@KE%9zLEoE?B2n7} zdC!vBjbxS6xMb3I78)vIYhiSR=jP201|5A8(y+=zKWSKG{~3_A`JmZZ9U_7X~;<#8zg z6D+c(RlpPr>B?af)+`*V_RK$qyIEpV!`=4u&Vid5rR+CRpz!tw)wJlnkkfL&+G>w< za?e8)OV_nh`_+}{b3C%fT9oKfgAjp8x2>@kLnuH{(gHJX%6u%fIJ;RoY49<@(RlRe zz!f@8Tegs^e%tNAb5X~%-iAQWeQ++ml2FPKQ!e+}Y$B4BlH!@vdLb!dQ8BQnY=4XL z2z!e+QIQP*SRth4K<1R@exLm1Ppyrk+=F6{SnyOFdU*XvwkWI8s{g+~Q)C(JBYE!# zlHQMs+DrrHhoKc7g8w-V{;b9Qa6;o+$!w;RdaTMqPIPHoT7Q5V^&5UPQb-f<_$9X@ z&@oR3MW9rqOrMIAz+IInv@x2)@yvHIoCcQ{B)n0(P(6X5#=6w?h^Y|pqTlCpxUK5T zcg22CPf~{rQi}U!!Vj2Lv*W$9Usb}w2@qHI`AGL-oA)$uDqS-#XXvV`1?fM%ojXdK zOQ1zT%3$gxH$|1sL#M(0s#~Fxv*y zG$DaWSHmG&0ls-W?m&r=#w>O{2Xl=koAZ+&|1l880^=7 zzS05>j&RU?>zU+u}&&quHYLmsTU0_?xvhGp-4AFA|vn@zcEq&UxB5=1OwAvlWvgU-lVP zmHazDK6O=3cU~NL-%{@v6&Q~2U=WYqdp=RV7I5ss>XOhW<9c0OYSaSuh1a(jNM_-T zo1bE1VU24TP$_V0@bRgb_mN8mdl6v`r(`Y4kAp5sZ(wjb@gFpH;Re250Ia5GWbz7V z$9=acLB^q7T3c+vsZMh)iyj^FwielN{P}0090BJ%NRwHQalFUT)sxQD&*h5^jwY#= z0&A~NH_Xxj^)tfR8F&-CXZwO z9}T{@_~TD2N3!0SZe=bp*5t&I^FK_l&N9rXbEKoEVp)m0bE*2J6QqR9pzimBpU*h_ z+rUPgqI54x3?K6QV0|@-_yT zaNcj9)LvvCmNV{=&W{KxOx3`VAi;$6vbN~v;cKVoD;0Q-Z?*_RAWjOS8QrgYOppQu z8{cnV!fgV5au1R8G5R~jEu+=*jmI5%TA%g~S_^h2RBd_-9jjyDMj(g#(Kwx~DKu<~ zK42Etw!IjLRkfjx-dyhh%6j4>x(NSAPRDtQc}X1OM_uIj5U#xgxR2p=Y<)kGM-*~5 zj#R`dc>ohS%OwbZx^qLb*T-mF*keCcm6tDW#S3J?F$qOz zfkPC9-@?nm28C}_hrGif6e;`ya8lE90k5;-17TlJy&H{&a>tIC@cD2hp?wt-v2A$j%LK&4-$%S-o@FMJ47Wcg+^L#5sQP zl@rX``X^KeI>X$dU*|zvUGMLZwivU*KNE!|7H^mKtThUU#RcZOQD~!%eFfint z5wSgX^GJ<7_~ZTEa3wsl9zzrL8tG=kllQcj;%~>qS6vS9(8MC>Z8INJc|HDAU+cjs z=1+ag7$cuH_ApH<_WDdu>usZub6}MNM>o>SLyR5I?tXqoYN+0Zbz+0>4vWk0Vgi!m7AtY4GmD8y|=y1RiebV%iCeIPOgx%#UfC5fp( z?XP0v4ve~>;0cG@Pm_tSqP6bmJAGWZX1X)i>5Nz}wrhoYj`!f|A?ZkldKa5Mk>*U3 zj>v-fpUM$jb=Sfq*#*K+?i)7068Z7pMRj1zqc}!m@8(<#wWjlR5#GB|rM{j(Y0{wL z4r*A!8_m~uq;ZgABv_S0HC3WkFX>6Hf`vavPn4?<81~7G_oJF@uK;|nihA>W|V;s`#uR=D?R0b}hmI7&UTZ`}r7uvj48;>)Z z&3CbTv;fGTaN=1vL@m)9P1<83W@4k>$7Y9?(Xpw2Y#$~95R_{awnO{ihrbqG+~7ax z^=iS=`1);tmAhmr%i*rO3-fvUgUsc0>PB9Ru|ZB>fHY)1-i~H!aynh))JeD#JowNDOj;F{T)wEzc8m?Q>@P=W~m} z$hCy>S63zySL0}i1AsC>H>fk_>Kzzg;YwVo=L>7EC_Eum@L7ggW+hWVuGPGjY&yIBA? ze^BtHezzlubeTmb)du)0a6DfZylVgbBMn8P&ng}E^&tOlxD5J$nNV4=0j zA!1`|4lI6?NjpM%4M7cxDo}2rGpv2iHvvrHmz2|lZQ%n{q%=BrTjLf5mv>|JQ;mH+8Zt`e6(%0uxdz=|xolUc zPk0Q)o$M@U38D)Y4U?`B=Lop@EfJyHE8qh&)#~Z8&Kj}~BwDCK=9UlGQOf=6%%|LG z$wi7HpvuA|WmycwRx}(I#MtE^M23CT(!H=NR`M(xY3{wLk|Z5g3_0@l{2KpkS5Or5 zQWRuPB8wv_#6>-hOno1pD~p;8Y6EYM@#S*1XgTJ`>^E(`qe;wh(K*HORWIz#`jJhL z-Tw|YABD2sK0gLmoY9xr7O)6GV&8tAk2Wnmd^T)4DFs82e&kOW6FC`*|3H@4OV7f} z)8Fpd2;(}v@nS~(pq=y8Z(VxNMrx0y7k{I{nrrExd=C&DZk>WY5NfcjZs_ScF5CG$ z-1K;eIP26{vH{`Q!JVl6XoZC`1Lds8M5y(3bZ!b=%ZcS|=sT!vJ|IO}L zN1%rzfoRVZA8VN#Y{|u1S6b@>>q0}evunz@12@|zGOzPaAHd>{__1FhqZ|J%)ka=T z5K89bQwVhap$zATk~&un#E=&Se=E=efgLp zat;p0{egTwXomjSDLtjz;zk)UXbb{k2{!;o`Lg>kB@bu#d)p4tVjx+?1# zxW7UE4H3=;1r&8a7ZeH@HH$fXGvPmhi52x;WjPm2&hi-}wpgA@9 zg@AY?42bMFRXF&ItN4y~KO3yaB~d64j3w3i!5;%2q-fEnPvtlG03+k|)*q3v`dHal z_*{2}xc9l^qL0wdDr1|Qm^u)l;^Uj)1)vaX){Z`dYLZX})6&ntT6-*&QUlgA&)982DBr z1k~QoO8qb&U(Wp^*z#CldiM)RXXl~bhUPY4<%g2-2eVbLt=t`GO4bW0_&Db4;dB{A z2RW@O^S)Z!`fs(IufMA~j1K1`0^_ty_OIuTeyDoeO#H4tJ)sDCcfO;o9AX=#JWEsp z2!T%m>wp(-67oC=v7If?S{$4AvYbTgNH4B)Klq*7ezU{+YcKkB78rC59EuX&L1g(} z@$vBKAO+O`ilK?0IwYSl*#qo;?&}|QF}d%0-gR~K?B8~gFWrjd(@F;*(mCw+gY66~ zLs3N3)=#}I_86rp3em$DQt4r7Afk^&8{paeZoQO=itWSriB1tINuqGo7=o|fAl^Vm z$B_+mHJuMlm}}wJa~cPWY!tXHHuXPCytw}9k%@%NWf#SIiW$IxIZW+;M;lE$YI@d+ zY;k>Qq8n~P&kL=g{GQVh&_G8hCY2v}<^_D{^MjU}+7kLC#PR{~m`;Oon~>b~pYGYh zv^+f0t0h5Y^czW4oeWQ&s!5D0u-_Y8OPHMm=04XcYpf}*#X+?|+DN|{|2;PItH$aP zGh7K!v7Ent>+)z`{!Y?S&J=jT?Y-1;Z&Zvb`v%yh`p4+&$|=TC{+3~$u0lXhN#`+;qDsylbEA)fntjB4S>P^ zTmJZZh0hMBM(AJ;i>@4^uMU+fAPi#O4dQGe4;-l z-S69UUc1*)fe^Dn*`O{ej0EXtD2Ub6A)n29WKXj2NWUQkOcI>K5Y>Q^WavN=eC8%9 z>8yb+8H#rMH;N;C&Gx52g)BLz<&d>!G{ZVtIBy7+dv(#hX$!3VitJh7pQtB6R?T#(IKc~-qZE8^ zah&CF%#dP`mHPUkIl-p+IGyYnY}=N-yEcOeHg54S!Nz{&lSl zK&4!ngGo}mfc7877wTu4+{F=cvtE0x{ro5?><`0w1dx$Q_o>Hj0B92pdF?9P1C1anYL|Bq()y7>u?QUo%n4X~_u zg6jMs@UioBrB#dgL!;u<_v}#)qnkhX-&<-@W;%qH0pRdjQvzG^nWZK_lJj|VgQ%&5 zzxo?+56Cho7YqlYI%be6y()*W&7M;?F{%1xmZJl>O*aCDQ?AplrC%cRi-Nl<7y&UosSpwU2V%qu1H#6-y}T^|x061mc~i8=Z-c z6$1583p;*m#saCO%h0Ixid>=DUsyW7(?@WyGBoS|DlKxit}VtqcyOW-FOh-eqMyPq zCEMcMP-RKrmpbzu^tJQd;~{|_QfF03_>?!T-g?wNl@bgYH|OMA!|RR38&v+S-);by z8)DZ1+FaN6r%Lj5NZRv($jwkHLh%PA&e@oA%yhX!et-uWy~JXo;72zD&5Qg{Io=(X z=~Oy|>2JHITi5$HaU+2$)9K;Pv#Nv6p)##VK0(R8Yc4n|K$Oz7<`7id{a$S&p#2J9 zLxXCN8Yx;-Bagz?;@T#Vno^%N*X6fWO*&}3*&S8i&)gvBQx1fTaX;}vbsAb|aIAo!VU{BMyFv{pT60|k%p|o5h=OJ5)dJLzQ|&SXY7Yzz zKq7<9?JTIFmJ9ADAD8OWDgN&&TmrsKx}C%?fr36JBF(%^jYQjX&~7tn>5$~7*PefW zgqF%$6AC;AmLI7>@E5+veVbn2&^@0d91amOJ>J{h<|TSb+F3j7h_u}Tja#@o{?v$K|k5X=Wf$)M5+_JhJB|&NCJ+WWXnc9-Drx-W>`!8a`9ehtLLi{o0~sf-{^Un~Q;$4C(G>j^h-$DBr*AbeM6gcK;W` zqq3y>p;owm@sBF;)v^Hr6K{uTNZ^7)%MPb6kFJfhKN`m2rv(jti=f_@LIN*-zAtA> zm9*gNT#WjG*@=$VHK6dAWo}s$Dk>&ll(Xz`Xsp{p^@{H9<^2fXkULB`Q^BkduAaNH z++uc>NgNg$y5;H#E02X@=wcUyr=MIU$-$0Q(Jh4ZZPxabeYiw_MO`ij#>G!UDpyyf7o>& z?a4ulro2I+*ruNOU#iWQ7NJMyKaG`Y?RINn;h3mc4=`iDHA{NaO8In;9mz4&M?0On z8F-^DSSxPI#V>vwvRwZjS!wQJ$6Ya7jlian8E5|K4dvIQ_7vf_NZRm>vGu>J>$(y+ zLW}tQivW{u-Dl;yN!Aec`QHu*F=BIzOi&*`D#t-;5a?54!6)gCaM&8770WPl$UETO zamgX|We6a!&^bxy+qH1A|0%TD&&^#(zwzUmoXb-_e?R=E@ab~nkLxFn9im1&mWwcA zA93qZO&pBaEqCdT7n92T8$2mT@sS7$L|UK_}94^C@o zOTQd9I7)#8GzNp~m{e>DE;J{3*$}&v5K^&~a@)Yrx zDr}P&6khn;As~A)MBY{Ob|#d$Yg2F(mi+3*p<3mzz}V42W`KL<(QXeQbL`D& zx)g7!BAzcK_ep5M1JELwMaUpHPf$&fn9#(ZI1a`2#rH`lnRi0(Q+vz5EtpGKAeE2= z#l--sWJq1-+~75n53a;o!qIU@z>$H1_{yk_BzYj?4VFW{y+08HI?Y}2cvk5VnjmCh zTeu%7gi5%b@yb2=rNN8GaLDH3t@>6Cn>lCvG6MFHN&|oH?M_}>Px73c@Joje37tSK zN{~n%w&ipxf>n|~WSNg8H^3@kjYL3Q@@Jl_dBcWI7l>Tob6-TeCw!Pt+-<3R1d}hE zzG+u2R66RZK@GnI1vBD{stNn1=AB5J6&R0*lckEwYI9(gJhm1A$E)YdQel#7)BVNM z=8CUyr72No5;%4mYZ%$kQ9wRE$2xTGj13Ve8^K>jQyAg-ujGmRQP^oOQ?j}m*A>a@p5Cfao;SR2Jmj6OcGJ zNNF`qKrE$7@{X2IdYB%LBcVpyUFCQ2%d76q;FHNO0p-NcwA8?{ZuZ67plLvHGK{H$ zOwDx7s2J-yFv)kyF@n5-Vi1uiCqKItGL*)>UF;J#`0ET!_ZTDdI|+5b(@=nYLuDBY zQ**Z{n9d-3SZfZ zFCo20#L4GH9)MQ+8A|_5s=BN9gf>YsZ;s}eghhSJZIpcye}YVRFW9B2Pbb1L8u^6n zS#Nh*%CgjAK>Ll*vssrrVC1Dd)31Byu(!Aq1y)=>WK<)1cKmgJF4}ndpC@fp_?g%| zWD8G*qhVSMV!6p*huku9F-m)HPcYhchhUjCZ$95uT5hV~v1;FH_5T~eT$`NtzsLoR zUpUS2BXX=-YQX$!hY^JBC_ClFnwdxz&=6Ip(1H#qX<1pAv1IK`vAQO&bO z-p-$vhLAzfF%SZdeC_fpk3qztEocm^G&KTsHcA+^X@&HIzD0-*dNhD!!G?g5tKA63;`(qq*KzLmmbW zf10)X11|#Zq^*Cq-+KeBLm+}mKBN^?HCRtdn<@LS%cs6Rf{~Fu%MB(K9@tHJPK?S^ z!|+$YO5w?7SGan!5fb0rODdoIyvY~{nyInj7ux_*N`jAvtTHO`h4>7oPuqgplWP2h zS{wRO?~PQeko^AleW}2Ye@a`oy@g0@vFiAg^;{IDyq}>RB3lP22nR0PIPoh*r2D|G z*T$+jVvM8aH^ID`&F@Jm=@y8iBbKZ{lM7Uxqi~@fXd%)eAowa5l8ct8^1cQn@HM@5 zS*9hF0zkA4p>O3yl5muR3(Q9Dn*99L^lnAmr(y43)n9}t>}(@?jLY;mDGl0VcR(3W zi1i1(RgaLh4+WW`-uMpUqb%oilu~z~H;is6!XZ|H)}*}^I-1#@b_Qx;Y=cPYu4ytM z>EKKt?*&ps>w;vMCx;6pd@8<$N@;IuIy{bbX`>T=jbc=-8O1AM;-R6j4IqLW_lV#| zq7uag5Z8X#X+hc$i414Z*qOy?VLWNO7W8%)`{Jz=P&<%Cb(^NTKTsC~5~T!` zy4&ol9>f3S0}x}fDd&r7&Wjzn{1*lg&s6?9fL)5@0@$bd@c%_Ha2J%!DnX$vjSYA{ zrcP5-U`-&BRSHsJSoBC{;E_s%+yDx9`!*7sTy39$1>HiT!Eh6}e&^lwsmzyAa>DJ2 zFDX`F4=zL`-U_IFk%!Acu8Rc$}vgm;QMB%jX1UZWI>yZuKgw*`fR-aMqQC7mvW|52xgM}YdF%U=OZ4q z7@u`yxXx9#pW9zmp(!LF94$*zMn2|xv^8yMJrzFw88)8XUhj`$>(;n<@v+>!*yxqEiDy`&Fg0- zfWNR*vL-F2?x!8ZM5CYRY$xfn9W;natdN5A1z+)NbDV$dx?w@aS7 zX`A4nVT&8vkh`O=yO@RFvO(5)o=rfS(~LLs>6$%9o_8&kHUATK!>6x?+df^;cV{Zc z;UT<3a=q9UJ_PpydLUjd3bRUQ2_knff8nQe`+R7c@864g`veVGG~h~q2yiNN zQaJZ9Mezmj(O84(o5$0_7F0k2yy7)IMThhMKO?(0mrLyr)_9N;{j>qXcr43S>8obfuFZDg~1EE~Hh|i_6u6N*k6V6R7jqiHF zzhqCO(Bia0!}f!BM#Vri63Yxg8tI=63B5T(l4cL1r5$eqEd#u8)%osu?iXvf?Gmaz zKVXNV<;CG4PN#V{;7wmpzj=tm&!F|welL?BsY;UEY=_T5^Ly=Tndxjf^_LMK&xYTp z@{=N}usn%D9;@UprE#4tud)x-Oz%ePg{Oz=f`zJW-Gko#?3gB>Q~!q~ie3l{DbXXB zYIjGyB~+`HhXYzC6@@7uxrx3NC=(8M4|t2O3y+E-e?#jfoE8hg>ul95)lxVfmbl?P ze>XizcN(;fChP^8BhP${i9`}r$PYWjO|(v?zlt+RzX#6KK@-n@zKof&$lXy--u7E}ZFiD>njfKVzMO$A?|y27*)Qria4r*}UciiViWPjY z=B&6aTU9A~hCOTJON3*FfD9S_7 z5(?scRbWAA1hTPU@OPMN+h{Zv92x7esz=ePAqqw4{yQFXB&OHk<87RlV{PBB^M(_G z50VUFQW3tO%AgG~vP(Z`nzisyV;_RbNC0VxeUaX<8Mdl8 zf|~gEw*Uf}$nq9G0bJXkqgABsdIdz`1wZ(Tx#xE;f-J{#^!StW9Q4_iP0oe%Z=!zY z0pb1+`9%GrehbEoPrz#+N6&I1mfqMR;B+=P2z$`RUuQmUR+BhCEBJU-qU^thX_&*1 z&RfpqM_`y9#nITKj|$xC{U16bZp6#M2#W-@HJ$7~eGehRAF%2-FPyT`gk9@bv5-;a z-9i}pBSwSz0=kfP16|E1h4EzfcNS2)B2~?4et5bdA|BG166z06&5c)~~CZEBUGGQDS z!grSCZkb+zY=poAU+4>b4w?Lpf=ss9%?}3-W~3Y*_ql)KOWBB9M6B4H#1eo|aUY)%&EqkU{8GvSkU2B9Q!ubF^J5HT&wgk-oCK@mw`}3Ln?UcBii-BUW6RkhRj5Ws; z>rEf42}xm;P?7&mOWA&obJ<9nPVr?IOFsF5fy`vRxadW2sN_)u$r0%H46h|1(f~v6 zIru7;7Udk|g|}~_F-Vzg{z!_OJgzKB7xBFn``Xi8h)p8b$(>_EpLe6&{@u`b<@@ZZ z#7^P!WrE9x9jpQee@~w;*WL$v90^F5hf5sHb8o;6Olfh z9ewE~y&V2m_a$Fl#Edxg9ROzuW396lEim*=HGsiAUGc#o!Jja*SKSi0JY`&tf?RFzBX0zJt7f z66=y^J~n7AECzF@yoZGnB^WfdL7~L_n!3b7n@h|j|8F8d zT3l>MQse-&7pW1La4jkFjq-6IG*4WTf`)nA=xia=EqM|I1Aj&RpAuuCc;3bgYMKgZ zTwl%$vo#)Qp%B#oyd^B~o=k2=_)PnaZ?tYN_Fc;^ND=KIBleQkG!G=~hH9gtmzkC? zZ#=c_h_Ky3bOBNI@ZF5_!KB-;kH!7up;3$7?j(biXoH4pxYG zaZag{%m4EkNSMI7;``hzmlw8d((w>?^-|3e9KAtk4n@C|{izJsYx%?HJEz%vh>>y# z0u>G+j-Y1h!b;Uj<(8xX{@APGEaBNKpNl=gHvAdv{4k~uex09**$wq}@+(+0et-LS z2n}of{F%;nAa);74|C%m1}dkuV01rVX0-vpg$~2PLu}aGb^%m>H9qLPU{b=zrSb*8 z@~oTd%^Y5pnWkIVBtM)HJ@axStxX@dml#1kvJNrFyAe>#afp7?DybG0qTo9R{*DtcL8`V{lUYx`s@R0JP7_m(j(Ooy3Q5;ekKAq* zFU*)BoPfr*(?uKQdY`;rr0kL6YW0kc>y3V!^O zS^NItE0Cd~O|(YTuM0@_#qPkqb$!6;h`eu;j)3Zufss5c19XOzfGVNLmzN;+J$Yf7 z60=l%uWT9q1MuVpIlz%tBU;}wnTIpY{bW{3eF{`l9CH-R7VLw{7RUM8SJTDEl|JHr zcOH!!U?VU2G?+3y+uyCfY%(wH#8?H){p^IRS|gW8(v8wEgwmoogaQ(Zl2RfK0!oL3h_rNfbN9TjufKKIy6gUN|Gwv+ zv(6lyXJDRZfA`*>^1gq5cI>U+Xp}rP(^hc-PPr5CIz2bOT3-GLC(A@A&R=rwQ ztAz?0HA?KhdJ)x6*y6q?tr(P>a2%H5pPzq?VtFaccyPSlGwii{k3)lo-#{Bd-R<|@ zUnhpMq;&CZdrQIx;IDC%%!8SbW}w8M05B*Y6<%g=ol_30hMf=?j{It3!>Ja+AsDQC zEXc@swTPHw5c~YhSGiE~M>z!C*0I3SgQHfltvBq;W|F$uJw_J$ zDl$ylRIs%LJE!8=l|9nW5+OLuU3F!nQ@E2o1+PpKz{al1 z$usIHqHpOW9l05-)67oE9bcj4e@6&Ueq(4phN@wh&sTV)reM^v;S%18aE+Xf6_t!| zkpHU~ronvn6mGJwFPm={uRp>1VAUUU;g<-r29;`){C~t&RN4K=f!O-+z_K|vL^ePeuzxi=$eculk9;vD4R)aCm8)IloS zeU93n*LxZZAc%Um(uYE^Vv^#(sdj2dBHA^Wm6zKmNKYCFH|@Z56M zJl5@BH>ww(y)|-HSydH#y_tn66m6%wT=It>k0Ug=y3F70!2`mP8Vx#Za{M2y(6#(FRQCX)Rbp$d{N(DSJI>ZFT6R4h&230m+(+) z97X)*vF}>`#6)e8XY0XQwrQZ_xW*`)at|Gw7SLlEFwiyRD})?>JryH9cz!YKBX&Xo z>))BoW4C1}QAij=`D@;bU?DvMcVxxzCJkC!KAc=~dP9_6p&vQ9eT`|pP3_)bx-I#0 zBtBNcygA2-Alr#v`|YME_SkSIu)$*=7f&q{GS}^_(_PdcV$|?0`^%7D^(5ENLNnyU z9{+Lzt~< z*{MqG)s*$*3?!T)+jLiq@_V$MsjZaXo%ZK+EDWsP2#ca&wTgL?hkE}QHrxp=Ri@9^ z7bsG%wc)qnV@#;1Yw^tC4zb%Dr&>Ldnh7$%fkkved<{3Pa>nOBx+<-#W<4aNb7ywSZVs498~9Rzm>|un3G29n@nzY88~tr`Y6!rO7gJsr zak^z)twZr*iyVN?J1;%pNCThwOE$R6=EvFBT`r*k89bl8JM@c~66u$2voOf3;k4nE za8a^P@=f0IGo-`h?6uaSMt_MqT2?>sHo^Fb%fV^1Z1EzV?0n*Ow8$Dk@{^MTf~P)n zhSE0QQ%BaBG&(L@y|GC>CUOgz9ReL5`$1YFPnxqZ3;lH7cvT2Ej!Q&eMPq+7@7@5`xD_XfW|JYk`BkkR%q zvi)c|y+|~$XYrL?qIwW`q`jhAfgwNu(~tXB+WX-@u8+GfxGSmbUfXWnIR)1gb|d~# zi!RFl`%Lzp3|!{)PKM!RQ<_&=Xp3XLR14nuxkwW(%inN%dj2S5Fd98FVZD@kCpMAG zk1yB(nj(HhAA}5cWBA72`h*#n6>4rd{nwZ6K5D^N#iDxtO_@^7Q(Ur5ATdo{+ zu=P}*OgG;^=i~4B575pp3zZgdCCRcIJS~#|-&TTEqHS#;Q%eoKFqc-D1aLeLoYL}s zT&Eugqcq;^$E_}Z+8_F#ERsC+uJhe@>J#WaJJ_T`HZsWheFo?JFJI&3eKs;k$G3w5 z0u->8UAXk5FFec0U<1z>=|SMd(5Pm?(APiz&5!!>9hJC(D6~goti725MX$}_)>9Iw+MK%)SlM0+{X@Vv9Uch??`oi20p00cUu7Ra-!x^_#G3Dr<&!bqhPOkyn6X{lrOdvFbo& zb)-aDX^GN?&G6GKukW726D>8-nZ3Gv8W4TYT=}2qn{N(d`Zs7zgfBNnZ#n>#{zB!x z{r8$ubYnl67H&bTpMPMW=Bp*Rh(V2=4-iaKNa zz+uf-_D})j`JUM=o*(C6Fp&L0 zuYemF*Hxny_a@}BWy*g@)*2x}GCSIy>dZL3;2+5RMj{HWE+&7{v;cEbG(#33@*nPXAI~!R zU+jeqn|jr^fZlh2JgQOc4lIG`+2YaFr&YM$i;XMSnjm}e|ERfl=y@ZP@N)6wqaiNf zsmCj6Zvlwr!B4?7;?}_gccwLd-qp8L!trwcczLX=2uHe^>j3bALs>3KS zNa&Unx9dl)A08aI*v`SskT*X=e0WecS%qT-VgVcxG%{II3@|$bWP>c#@exG>rGNSw zz))&Ji!-bgm+yXY0n~Dk9)I>Hz|&I~3}!oC-6jYuw}tWMhEG~c&sDm}SwUn+*oNQk z63y8plbuqCf|h~;DnJVrX3!r21bjZc@mCcNKig%)lW@aHZZdE`jvqh3$6H)ULk<)l zw%a}n6CbMem9SpuJphvcC9oH?4( zeAFFL1OngwhG6W2qw(76AFvV1VJ@1o7jn5te({>u$unr$zgf?M*#4@>@P14iptpS) zilLI1Z@(c!B`XRA~#S zuX#lNiclrv-rxE|6gw7v6+&}z;q>5DyrTZi)sw_BzIDrb-WEI4RXDJBq|qRAQ(01& zGGB)Lh52Hzv@I-elYT*kx|6b}D&FY)bBkyn+dT<<1q;diZ@%CuIl4GY@)!;{i_CS{ z33+TxVm?e}uxz<#u@;em0NnJ6(EcrmwfWrhcx!e}%FXRY1ADxz%((s^r;?9L5Z*+X zcr!^(b&mi^d9&61HMg1~+ZS^3Tvn5BZB1Ug9V!j?6*iU`{TEuZ%(cu38xV<}9y|Ab&dgNi^m5g(6BBqX$gk$0<-0k3Y7asP+k zrLvKwCeg{=QJnV`(Ob2`*KGijUg3JMOE}~_i#wY956(ryED{ z5DN>7-#v1fn?3{kK}MBbJcMa~AG z$RW!QU&O}tY=kX>^CY!*Qmm1hWI_rx1R$88>-1J0Nt-`%W7+d*t`;KrHu*<;AP= zcSpQ2y<6aieShO+`^weE&*nPM2tzKj>XCdFgDTy?#v1Nh zCvWyKA&C<;(2!jRGu{wWrmT~GDpn(+coRb7bDv=jK~LoF-Sbxb<7vm7by?nVNWhZG z#FuoV(t%SIHQpZH;Y?$)wn z)ulfim34tKj!1fDN^QMAC#p#%NVvhjOc-?7EkERS4qlZbtS=bQ z9}s$78b2#Eoh6AJHB2W+>NcEVxhQCNRluor^P{eeBLd*CQ|0nR6Sg7T&g1R^i*p? z_P%j5JY}Ay%Aiz%kDDTV*b=xxgPlK6`1@?;VS3}JcLDwB4-oLLSdu9#P< znOnkM`(B_~hq<6;g(1v<0=0z1oSQg6ahm1Y#ggU^eHsPbfy2laSvDvw-7h*_8Nh8)L)thJr;*PieK%bpxGZOrVulbdAGr3ZMh3$C{E z;S@JmDJKh^`iQOOhSFF&6GmNBrNC<1$wJuLsgh%0eVXiWhk#40zNbM#rwb}(qp|u3 zg$}Y$Yf(?-O>fo=np5#K-bTvvsn2rUpn!s(AL*h3lnmepqdJYuxA9GIC&mi1WF5l(0x|(jKqg0#(vzD7ek~xDe)|nUb z)4{pasAAf&cTQtyL@yApns+!Dk=@rPQN=r$2jGL70%2SYCasKnkcM@saNeZNoImb5 z!2cP=&8<5Nl5M4T^9@Q2N|VWZsK!@|%sU2?9 zWk5YQ&S5t_jilhzeNZA_(9c}^?Uf*FeFNUQzxR&GB6!+Xd|+AMWEHlQj0h|LV(}RR z+hHhQO3e}~G>zKzX6YFUqDip&4NqGScqXycg43QRB~otN7n1c;>Y1a*9wXNLyYzq$ z(_AMv5$-3u!)70c7xCQ2uy6M?@h<4xf;RVkvx$ed_xvVA(n#JwUE!bzS-~B9dbua8 z8RJGP_@{Lm#7yJ7NRURT@Pfjkg3$W8VM|7lGKdqc!vpu%n2n0f1BzQGdmG0C za*fh;EB0=H;G>n8m14bNflbD5r%x?^6{3K`800u+f7kg(B};E{S|-a{gTs$plTsS^ z5p1eoDI-LWmrz- zyrLNd+e-FH_Xwz32U5g7CO0lzV|LV^4}8VBAv|!jk|W)pG%jSTSCsWfnUC?#`O0si z%gm?!jvSPefSB=@5RtFZm5^<8a{L~8NNH3X8!h#A3qpK#Pjf@~DSTQfJa4O$M-reP zYl6~A1y*uOFPIo`w!LBUy3*(XgYUP|Do2{p7w2G+op*hU(*B0|lMMJWwgud5znTRc zhCLH8HMBeQ&(qC27aoWR$AyGVBK)tw3cixMrul+LF>=C56Rb+7UGjupbLiEkrZU&pI#~tQbRKNgndn%iZ2DTEr738i z!9sD2A3@%-)OfXFHT|%}1o5Q)d(c`Fc6QJgCe%Pg}Eo}aGjN9y<5q>EWAl740d z#v|ppp1yXGsVtU=(WeIlmpm7GgRfw~oiaV21>vk8R<%4fr@0<#9BG7oBcY`Ce2jQz z4$-F%-$Ae44-tn8(7`j^p>xHfyy$XAF}(ky@N1h)>TL{n*{01Vno#{dr;*|}vEVn` zSq&rN%A=5sure@{|49DL@TK+movvZ}fY{zWeKXH&SY(%U5bT3@nB!%@Rdr~I_g8)@ zgQZWW$@NGxXJ==w1@27XTraMvEm7#uG;UnFneuhjV{Odvy3x*jy9|1eP{VzYm8XgY^Kh{TSd`R3FC$6KK11`H#ZK3Zs_O^=a?)vqtBCRS#cOYntU z>q2d2x!Gr1u!^Hxmxml*xOhaIoaAm$4694@1GAGb(eeh{jdowzVtMRB0t6=zyY;Zf zlpq+GLD2w$FVahRDv27^6QiQ|hU32U(xPh8)6@IEV82w@3G23&(gU~u!GsV_;jzfMmFF0+diD=X_Rf6fDaYN72-yT%Xd7p>QHHVzh> z8ZX=L5zxeqgoWLJ0ko}&^^79n>cd`lL-2uY#dInJaFntz82A2l7)ULN23LNT>YC`3 zR$@)L0n0ECIly<)DAkX|2}T9M2`_9x+tiCML~wJ%d6biXJOdozE~`!!iM9(8jE)tW zNDnqK>9%dpD`q--`sn^3Q6;zuOdYQqQRSozL{wF>Zr(i~I=3_qyg=n(2YXSbyQfWI z+ckYX1Eu5>*mvY0`-e}8s_(Z_*%_{vz0}-rA7OhBLfLtBk zako9s+~gQr>C0;4QHx!PepTvDIdnvUMei#Nw&TXPo}=b6S+O-<}ofv)7u~IIPqU>7|kX~@%{dM@rAbD zT|>581F-$rxxythhMrOD#$lsybTD&!BnLt$1X>zcMU-#BoSO0uS;H;}UL)-{iqpa5 z``be{@<|58b44FLr*UG!MldDy9sPC-Dd8{k`WkugRzxh|KUJxh7D1V z!Az@Sh@egM*P@Jx5m~23AC{*#Zw!V6DMK~(M&zOr2s(Ley4f6=WWffU+Fqnr!O;$G z)O-rya=e`Rpu7w<_o{s`NG#@843LUP2)r0vFMz!7DD>9;^>0C9bCmI zr)LX`bxU9v%<4`I>(-3ta6mlIhIoZHGu$s$GK7gRjXyUM1dD zk_$6qIsFpAOmt@4nzg++16D0BrH(jKzgv(FDikXfXgn1@HB0{GZ#6F1Z z+4c;r6duofqwpU%?QeQTYgs0(%E}(kKyYY5C$cQbHJBAU;b@&AHzFZEsnrfXYF@& z&*9#>d;=}6keyX>P!PsP`);PDVKCYoJox^>)ZhY}7X5O0WjZJFL$fE*oWFs@hBJA* z9G#>5eU}@V2vyk0;9y@c91O;^AArLkYXpas>hby6>0yzLxr_YCMpnPmx;Cu4e>9ot zTxk`1DPJmMZ)s{eJ|?z;41h;f@h)~L5HMI91BpZG8Gqzx>eKQRyOL?KT06|b#z1nS z@)RpEB9C?rvuh+X!9qd?QJn+lVYiWFVTpGJl;sf zGqZrl5?3bjS;?O@{2i}Tlm+Wr@x+arScq!V&AU?ADq%qBbqhT8WISK(uOKe8P~`!2W~CBsB8JKCs{x(Y zT9M0nyrw;7vi-ahBEPRzYY7Rg{EQ^Ta*tpmu|9{`jPg9sbku$pmXBRCI}UPD8t3kg zO%oo^!Db1$l_E~)S3EA*PH2}u*?lxFMHWLCwFxAZmr{~^BF+XlZ{$cO>wgD74K=@t zpNN#FN!h7zH2!J!%ok|pca45&oASGoOw57>>Ceh%_BS{7A02IV2)-0&`9`z~R(2y~ zF@ZE53mY%D2rA2L(>5w0(#E1(VhZP_;Cffx{grWaZh=oJdzrB9?twX{?iUZc zp9^gVn=_t?Nm)9nr5De<`(f+PVh>s^gXK^ROAljnP9}sFEl!tonOCL5&nCvsc-HwT zuAN^a-u@&vy`dd!-`c!0Cy0a*k+)Z9%sqSx zC+j2fSkGlJGhql1YM`S(2tOtm#y!~}zcyNC*D>@K*4ZU5u?q2Ku```3)MUMj>#{-U zVVE>}eo5tE;Co_V=_Ga|4I>8-Y`!{Cny~8AJ~bODC^~;>xi09NFjAv?1u=3RhH!hj zlo4<2Q8`*0l#iH2=!KKx5mXM{Eq^jK{tAPIE_y=oibfn_N2wZ|DI9Jq+b0#sGs_t| z@kc+e(pZ_=*x1mzbspJv()Gji!ijE7BVO?IX!=7*Jt^J4gcPX^EWTJ1sYSXbdX)WvR?iy`r zl`_yjJ3Gd?nQVD#$x(XL@U=Ko2qeMQmC84X);}}TxF}JED1CV+?Uagt+EPbU=G?lW z(>aJ-Bbk*cI7$Dadci^Y$fGSjuAcnq~@zU$;l% zJ$nsd749W02!;k}_MmyS^MUEtyw96L6zI{DuWG}^YBq8`oQ&NNxUFLL+aiX4unu`~ z1$HW93?Xa}MglQg1yce#Q?ovgb6Om^`iDRC6UM?63OFcnrG7Sn27_Ov=TZK#qeGyr9Ci zaGKNi6Wh+19R3?qQ33w-76&~QW0i_>!2BK(#KwkDkw;JVfuz~^|M^dNRpA92yx9%& zB-Ouv3EoQ$D)dfI@DBPt{_|=iB@_iUg3kU(c#r?Q75OQ8a53&z4JOPjRzk3q`1;j_w0;2pIt0~+9(WMmeA)HHIzWYl8Z-jLFI<=oxFdSqoPo! z{{6Z=y&mJ{8=0T|F%1n0*L?T*Pj?2Cjv$Qe%A)lp1`+G*qPit@uvN{)C89+=EXf9J z2>ig>RajW0AbFYkN4}x^7ndol0;BTw=W4h9{lK526+Vldf`3}-^m9p9%ce^*z+vGG znLBU9XYFkW4n;(dszVTvm}x)o6xGJZ$8~|y`O)KkgJnFMB9`V6NZ8)reRoULs+CwX zWmN+_njI2njsE?HMG(lS#p-IC9Kb~Hbf>ZCDJYO1EMy(&0v-6HhnfH>keq<@mji7~ z9~{1yEt-(JAnFi!cGQrM#?v5?A{$7FcLC9}-B}P$ItBxlscndl=!2s|Y8|4E@&Lbj z!K1^mwY8NCVD|uQ4%!c+%}h;AMS=J>`q?$H|3p7A?l#ekqZHl$ZlP%$tSQSQIKy(c z=g%|263UofzU=t?v`9464Md(Uq3#f?l_Kj)`>N-aVExu^b>GGmCl4e)l{x&plhb%9 z2OP5VmK{7M8!)?JZzl@_(4}-3huln2tE}rqzLoYHz{R&BlYoiyEDs@^Da>0aNG; z@H3^SAbG}0ARXGztsVZajO+0j?(*WN!GzR23IKNG0_Qm9{_*pgR#Bku2hMg=@ud{TwUm7Av9PCth%y+pg3y%(*EE5tj9?%aMLV~ zKAM%d&Y)!aMq(^|I9XTqqrpdGIAv!@f{Au6VD)DKYqKBvwr>*?X5OLun3E8ZM%_*# zlnBz=TQDgmL2dR}ACG})K(WZIUQZxA&KA~hkF;8p__+6R|65IPf(^nNq=ipk37UPf z4m>$1@gCZcm5~{7kOwW$c-lTVVU2$AB+6WqaK^gKHc9Zi%KcRVQ5fOlPs#FZY(b6P zsS>RIFx$U?@SgDo7>Pvyxt9E0>wD?PclG4{uw*|Nacsf1%|T3o-GBl4f9`xFT-F~0 zvo&yKq{Jn4r?FRY?7p%nl3BOqeoohMQ1&;I?TOXwL4c;qJ%oCS96dD1?xdAoSL5Z$c@__;EucamMS*~_X zDp-PqX(83QPPj`7!id(~VZgnz57sPN9j}VmaMUO%|NHqP{n2H9Rtn_Ebb|%PamGI@ zomqcZd%psm_BS2O=6+EmKBwo>j|A8te8&v+cmk63)kkNMYC*@Uyk>P+_ugywp{Q4C z+~);m*VeihtqJAI&ayQU@%Y|RMei#ur3mo#Sr zw!9V?D`pFreCdU(GhOracT9a`Dee<>r;>56UP;*ZBdGGfcU@tIGr=&7VO$kRL78AJ zuvGxWO^^-FpuUG@!qaf~I?;ViM)4CZ@Fix(CsS&LWWPMW8M~R*o3MX=0&;jVpl3wG zUhu0$8wHjloRKyMArW4788@j-n1D@nBomdb+)xdIRR;lCeape?KOh$GqFu3cB@2X!qZ^ zDjYt8Nt^D$q-=YAdX)bx)W#_p1OajSV|4L!xskxZ)%XJ_6S%zE8Ehhx zkQ((o)%eMcbUE4MVG32CW?$PGHVeG0*(3|5`;@!OuOgw0--NxlwIXI_2u0mzxEDN@ zhi;Bm?~`rq?96&qIG@L45gA=5m-?P6AqdOoCf>nc)is~Yf8+q)rytV)04w~!-wUNz zb(ar2Lfy;r=5_Re0nm7STpSq?R6=WNYx7)ZT5t3k+yd@N4rmQ1nl2UHuL@Zjc2>ZIQz4N$_I(^`~-Wpov6MawZ3HK^%)GnJQ#Zpd!horH-ctzT-l$u9Jai142QLL$pGL5}NYmy_1aq2tDOS694x@z<@fi=K^;qM$dqekiYcFd+M9A zQ&E045xR1bPmq&u2VCrt^gDt&n+vYryjQBjuRe~OrUg0513Rh5Thx9|bHk*>#Mw|0 zOXi~7-{SCN7!Wevf&%*0XdgCnWstfWMRpE{Ci0V@X-x(QBY0MipXKPM6bL#)EHirYw;CNtLk`cN)9~Zh{g%jZ0_Uk=RBK%@Y;Q|)3(hIX z$v2R=*tT1Rg^HtJ?zNno@EccX*)9DlaC-QCx_cP*1m!1T>kwI}_gJYqNE&NrX;4#1 zK9NiF20Dy%#`l7ePL<%_DoYAgE&{u(nEGeUW2)1W?VcCx%1_X9ZgQStY1q#bIC~#i z{d#iG1PdJ$nqyRu%!zioy!TL~Se~_Rn}RdkU2ewd4B#m$zdhCfgK(G5e~|LO7c?dG_D&9ek+`*b_>+d`GsyK9OS4u_1Ap@Y&{1d_+M+vAwcT_q z;c=gM_6vzwcZ2$xAN}viC9gt6=CW2DsNPte8tmlE5&2(#u8^;&;CEDF{bAfov?k^7 z*M(WFzsIN?`b3oL{t|8Y_XSh*3#PZKiT}38Z2w>XM94d4_D4X($~6_Ap==aWbM*Ad zDO&ybI3Wr4itks|>5Y`np%JGHr*tlT%Re4yuFAr~BJ<9AeXZ0zeZfB26h#x)> z9v6ATxQ!Z&d-X6ArnzXP>Y@(%Uww9N)HqF1L5BnM3XHWRk6_ zPdQi;)apk5sbgyW$sZ?Npb(5D(+bfu?rt4PX5?6G;F!^cx}r4?UaQmB01|A>Orr3$ z1a~}&Eu~ApDFg)tX%s)P)-zBRMZ5v#SjDPo}V&27{n3t8LX=;(#>H(CQJwKiIwdT$iiFpYiujkEQK))+6Z80*!t ziwK`v&vn0UE<&jW_pUMWF<*VaQ~!n0&UqXMkc#=wf~eGu_*I#Rccho8s?i$*tU0ox zkwIeC^_1cD_if8=j^%2l>QBB9^-312+Y(+b+GH7(7+BJNRY04y+}bCu0xxvy1T+L; z>iK`_3dQXlvH5k}8Za7rd%pZt;d+FJ%CY%@Ps=7bP19$qwj$k(%;nCVm?T0GKkcHS zx1FQK`mOv$!X*BL1p6Ch3`2FGZ%eGsZWdrXw()ucy;RFLIq~X;M-J>+wQ`F<`LD1W zNLfY-5p&z8Q4O>SI1eqmW6mUO2dfRl*S*PfoQ%7*#h688hp!|8-U03F7u99{L zMHHQZ#>THS;Tg59O}oOi-*1NsLXV!t_hf)E-d!*RJ`9yG8>O71$ISiWy>5NTc7Y3# zSxh1Ms5P45gS?|;v^bI&S?)C}XLGd9r}Gxc@8GrA9|n@S9Aqjrf7*rZkyQNIl3Kcc z8OG%o+n=KVHK7oygz@+R3F`%AkYOEoR=<`WBasfmz+iXLP*K$N&IRx87hS1v1bn=9 zj+8qs%OvDYeA=CU9zlnM*6%Ldev%`da8UguPUg5yI3&6;vjg3Pxm2&x#)WaHdwIiy za@tzr1oOX3Z6toz2c{P{WUWH6@AIM|>FMga+45;UXL^Y49{Xc6@=*q9Fa9ad`hb7{ zrV&uMiQKr$fBoI<^f@=l6djnAYvOMuUu{_uZw+4X6T-Gi0CEpG@JFA)q3vkbJaL{+ zSZn-D;QhCt1^p~l5~)t1kX`d&WN|Nqfwf{-g;>kE&8k1y`L@~n1z|!=GV#6-yshKw zj@Ql+^c*AFz(zy3BcFiuy{SmB)!fg|s2Ae4JDbSnRH%?VwsE~bSmXn;i%BMjanSOX zFjbZU)KfwG`$rdu}KOpYF-(5C(*+&N1s5F>4`1Rf3UW; z#(T1HFZQ#~WWyPGDjMRqA9>Geg^|@V0xh)~EFw6N*YTk9dj84U(`H!}XhZ}vXb*$N zFW6gqdv}6UJ_u+1gwAqs&xf?)2c$#?R6wq`CIG8CiW#axAR2vCc0tXElpL}-jZw*S z-?uU2T&KUTS9P-nrZo=31?DytqwoZPgFm&ryG#+(F0538r}8N6W7z_r3X~zn?8Ve? z&UN#~trW>oO?PmkT41s*J#K4h2}A2?_^~ucQs8M82ziL};+p3p>p4JX-T@oE7mCT1 zsZ2-TFn#0ZYvvbEXdeyb=gc|hwM++1uqONfHp;) zb?x?oFSN}EZ)ycmc8;fQV5gz)_`*DR8fbiDEU0Vhl~YS8H_8K(PgCUN)tCqe5#() z(q1uo|LJA&V(`xyCOftr%sxDLkUMl3epRk3kn_TU(%f?$RulIQri^byIj}yu3n9?v z?6?U`7F&gYuD#L#qpFw3-v8OJ!-Wb{WEg)P6T8+~?Rf2DQ-hg3+e7gX3C+ipugG`RAICPjZNuDuy7 z{1_sV?5Ehv{5D^j?nMJ`V9)c10%ZB4S1S9iR*|d)~eXjwc6p`F|e_8^- zl`8?_2^5_SJ#DbSv775OY%i-HS_>eJrm1&5ZFDAWV%Q@DG`Owbsz#B@2`?Wv^LHin z)im>%AU14vTHlKh3zLb{*OoL-yB(zd21RFX=N!AzRhoHODh40j@&;+NP9v23p4{y1 z1WWgJBH%@GNj(Nzqi5`K88M83)*@DYZ(BUPz=WOom_`hn;pzhf*it8~TTT5yUMDtM z1;W0R9VmBp3F(_?E^|FlfF&EQJ>0Ct(O(AQvSUQ6gtr9cX!r z+jW27M_xdFpE6bVb1sof)m1sem4_ohWUjECv^?IMum#Jz&IT6}kq_ds%F=FLw?ZE9 z!fG@!*R}h>!Htw3$qd4@E+SZWBU*_XHRGs!o;;zolpv;^_CG$G#Wj2-cz-0ZT-Crf zL-y=wS-cK6TG-p=XM@8tPNbxoSrqX{L=!=lsh>-%D8gf-ygOMWwSbwNzJzrWmR}AO z!QpWg#m+cNX6iWn7L5&Kf4X0%7DyWv7wePu@QTXe*oXAc&)`^eH`D+s(?f5yt^(d_ z^p%gd=RO%S9|SX45VK8UvdWd+z1c87-JQbiHTEW?PX-?XE>#>cN_e)Po2Bhm(epJI*~{0A?INxq&D3qDl^3Ymv<7H{7$gstF-97Ac69xL#fD7{Vc{1HDl9 z#QmTu%olqbvyx-3ZJY@cyY#WR{oVd)0a)j7nmYkgp`|>iWa&CCk*L;U?+7Iqp=FX# zd8lcP&v&(hWzj{RC)avlyZX4Ve}8xLXKBX9muswAd*(h*GKb{AVE|i-If`C=Uvn5c z7wj3Z{q6P^X^Zwq#%i%LU z+&cWZvHhU@_Q@PfJ&)(1aU zV&S5U;IRLB4QthYAUEdQH2Ke=C~+Y(dckmqmC&II3p$XT-R-Bj$I2s&A zBjU0770hXm7=#I3{RZYVd-#CY8QbaN?smgGPoyj|;R}JoGi0j~I6hQtDBLaQLP&26 zEXNHE#T*7Pw-S)hH*MtIRjhA;`TA39j1<=5=JKhy3rRGKZ1`pn4e<~nzP`SmvMg6* zsQ6-r<7b7=J==zIJfiTkhEF(|cs+hoO<`4Xr}s)MuXFjlLhROcJIg37V@W0l#JP~s#N7xp5%N07XjblR zP%PF~z-TPdX1~p6&16(d{&`ZVAzT?@Bt&(56wu|4k^2nyIC`|c_{r{t7J~`7wM(~{ z-T_nM&gl_}!OY3YHg{4k8vKIt^s<0+#P1#uDLZ5lm*_>1QlIZRjn~*!*~kQ|;&0cS zHN3{`xIOqmaaT#<>mkIl88pjzE{PIda?#JYT|8@k8(*vISLJzkfVYtDi3t0}kX;-f zP;9^H`VAXu>u@}Ik$m|PgQxRkPRs|#VzPl}g2qoL_eq`#;Z5%KsqI$9!ehki*YoK* zd$!y88RNwri!t(cHl$)u$UUrKX|EHOd`Tsp=J_IdYw4k>i9(o!ueS7<$1wZM6#2rq z{m>pCX8^V~Jl?mZ-|!)B3Dw_Do^Goj22^9yJ))MKZN)u!FV2P_@I2qRQXsB}Cw?(@ zv|AXdC}e7&UTlNuB(_r25by?eliQ{dy=%)~V$@zVj`e_@Li$Tm=DKP7NzxPHS17Vh zqI_!hu7s>@2M4q68Coo7M-x~3`#RSy*mgnds4sm`uVXzL@@T+-{mgZGT?6)=53ewt zKQ7cSVsV}e4HtU6b#aJy$C8SkKKte)5~{$SJ|dDeyPKvH!=vv6BEl5sgIhzp9L)99 zu@jq=@Ra2EKOf-W)s9k*vdw=Btkqlj)rCQpN1K=e>7`tnYm0&FF)}A-3FlD-Ckd!SP9V;AwIT0HhXLv4GHTXL`3N;+pl5X*j zX7J@N_B9>|Cs}@({Ny>M(x*)UhiqjLq%R)wtSM(3;%{7xM_eN7XEI4pDJ6%bSlO+t zl8>~iuy%0^hC4o{eM;gV)EJ}tOj&%HESe$}qhyA8*W7ehB#7ieail`W?xtP0fA99w z_MPoWa5@n%=O&zce0Cy#lFv>i)W6N_aoQfjDC!^+aqv1rz+oixCC&__?J}O5YIF1B zm`Tv{B05c|#Md!@!EWTEO;)94lKbJ$5H`|}xS~!4hgRpoxgZDn^eYoUk0oc<87YRL zNZ7tE@+W2eWNM}fnPBny+5BY~A|(PU;^|Vag7BsDk8-uMoyzM;@4~=kpTc=wZ$cdA zle4;0FZyZ`A)De47N$F@3cl_SqoV=zy#+;Oscak?0rNX3sVX5NbXE+5A-l^gyxC=!SecTB#tg*_jc zqE?3K=WtA#iMdM8P`UPMmEthS#9*G~p^zMt{osc#B?aiiUswmURy;*iwK|ig?wVtY z$Wdk9Bh4iFY%if{d)T!$libY0SmqdRXS?6EB&yl$_cq|yZtFNx*uytnqlslBF|ha_ zkqzuW=2102*q~S`0^>RA)UmA3hmr$KUZWDuYgwXap6yFwQr*#-cpQkY9r-RzJpuq3!@FWPj%_#Zek7iqbe*|O&gqe#*M<3E-#F5f{W2R zmHbGJfPX+l68e>-TWKEu5NiYGP_zHwKg-MTiq+y*jmm!@)c^lR|DS(HDVMsx ze}4)9m>LkhqUYWiNSs54Ov1;HAMbJW0}A@^B{$t)EU5$^4c*J=qiN)SI`n&i#G6%h zX{Za9=Fb2n)POxz5+g8`--Qtce!AcB^uN%N!io|AP|?XnIC`?szb6lt*Qr0hNWi6q z(l)#1r2ZM}sdPX|ibB0S+`FoOVUDe&i8La3jAeJ<$gdh5^6G{HdQEsXDXp?O<`DKk%I z+jezS>_?qHK6)fECPf3Nut(BOSDl@kQw96dY&5MqS;XdBPfr~9#wwvlMlfHx1Lgkv z#JNMU#h=c_%%g!MaEP_^6a>%)y`2|G1uXsA{~7PQJb|^9yv%3#i5e_kgDadTZh>`E zHn?^S0GZhi$khB8fM&$->b}qDPW%r(#LT$}sv$kc?_qy`)URj)hNVk%yitP;nbZfE2#?a})(2o;koIEoKXJtd`~|yFEgevy(JJ>#vH=e1eM+_?)!s#e z^l-A=0$@qo|Cb_+K=J_DR$W4G#}#T6Cn4K6Gxi=T;63?u zlW-`-y?_ZII$4RyQ;xu1c5%8TYUkw8eeGKWYry-mrREzQtZE>f)*(Kjs%GXl{B&_5 zR1Ynk^&(Qp_y$>NIa)9{E`l3ulKsqg0O;*Np$i^ZzAe$mCDx>T*tSc$@;Qy^?||N& z@Xm()R;Z9=HpnhNahY#Svfmguk0~Id;`X}@7i*Ml5&1Ph7<0E=^>b?^f)?RPXG7g1?yDeV_jDEMZ09R}gIOS<)D~9pCTU&pk%nt#PQ2~vAmizq4 zBJkGvcW^o`68wF1*P}sJ<75VrsQqYT{z(`Z`c<)pL z&5hpy5jjukfr(s^VJY|SLSHDvnRN^wg#2Ve!yVuO$=x^`CyN2s`s1Ni-!KVEl}9g` zk1-XBugUlwG%UTzf{c{VNoya_i?R~YY?UDhV~05Z?e&!vP<2+4*TIv~8LMgyr1^dD z^*t70>D&a!)Oz4B=}LVs27@v^P z2FWSl7QpQne)ugu0aUeGBlq_QZp-&U)0SM)^g1Yx0T&OC@(2eYe1i4qh-J*h3|yi) zG@T+3w%9ApYAM}htER&R0E9HKxt}f%KdC7ioNM?lQ$Z%ruMBFcKXN@OVpR7C{$kfE zSpR7OP(?=xy(ry9dMqCcGSg@(a`J}gK?6d;P0X3F#W8hte5xTi?-|(>F4FzR8s^=X zj2*wE+a`9kD-Bck+EF=HA{<`p$_o_#1FL1wfH+IVozj13dlnj)i%ub3jQ;o1WAvmU zErxN>i>Hua-3;7tXtNbT{CCJoX$vuLg@a?%*!t&$s3RE*RXw4SBH4lm%^7-UuOjx ze!{6`k<4dSHpG#-%1K5>_U3M>?_NL0uaG2Q$ncuhe6F37IyJfPji`)vttSGcEG|p2 zPLnzIMT_QJxA(mRu+bn&V!lBVkZ8RfJZzkmRxlk}(|Wu3#<`}@obX3h!PiPa;$jH% zGJ7Rlku@;OgFw;+s~~zqBaFHlKFBmL?-%l8g*E zUad4Q;#Q5cLW;?Ob(iw47+?os3}?~G_|m#;Ov!8+FL-z5uYoOt-69Z(+);DDD(VN4 zkk&Rm6%jR0gVp^SAK^=t4u`n-w&3BAB5aK6rg+Zqd3CHRRQXEao}pEWe4r_^Py)gF zyV6-c_I9wuJxB$5Z6+HNi^ZxEfmHB@s3_3fu)UUa4QT^`xisk}aU;)y*uaDf(^HSd zF+eO_Ge9Sv2{1N8BEo&F++N~inf%#-@@@{9$f0u`LxZbOhVE3%OTc2L^v$y{kU{Z( z0gJW{g$t%X{ny5%)`&uE!Xg~zrDQzSlcD>g3aPlLWh?NsbuII{$vUu@EG-UvhHFUg z{Q~-f`Dm6f;TuP?Ojr6Vg6x09#+8q)jhL4{4O&J!Dh(i>YX1+mi2HwrEkNb}j$Ik= zZd0`EpD|oe(tBiyRNTi)?~$oPY|z;Dwb>Lwlq2Ld3Z1cy$6ruTJMJb4qC z9Ns!`ee&eV2bV9esAo>YC(7kvYLB&z*#FV|pb98Zz&Mk$2v+-rCS1ENS0<*WrV@?} zXtopxei&~%5BY_LFJM|vJ2^R#+<*m}%?obbXK?>@{c#b^N|TcP5i+QU+Xih8;|!eU z_>IKT*UvVlltsanG)L!mMcLGPU=lD4u7m&GWB{ybwe6&yN`qWKgB3Eil(@DPn@z^Ax^;bEvpHGZWaw>C_SOr-?URiJA{fL zI-hbQbb47RN;WoU)ChlJgdHnG?<5h!B5Nc}Wj6GAM~v1#T38e-3&R>%jcGJGc)~X1 z)R`80ivy|i#5zP2S_DQC8_gl>z}-n81%*-uY7B4;rEd>y(J)5P2Da8;7~`#A-9bnP zL`nW%ja}(iQwJ6&5JDi>gq)1YX3os}2i`gFeYn4Sf7>5K z21?VCItm`h&Ezx07cZiM+tQrk9Vrp;6(?JIOpru$F{n1-JmsLTmWvyFpS>3krn#)zHaF&ya>CG7eeS7~CF1po*5JS7|g_ zH3&ZNc|yYn@f(hPYP+clK%IkwZ`O~0(in0Q!^btjr_|9f1A|U)26bazC*sMjL2cW` znXz@R7-YjAiY$DRI?umZ8LfIOv3S!}#MKJ_cybv4 z*@K?0(-yu$8F+F(60@u>1S)YqfWXFV^xQmh+K1G^Z0HU#Pi)-J15b8Yi72Zd;2qQsxj@IFi6a%?bXR-$H=4h=C!K?M7Z zq-(m>zrwvsey*Nn~rKy0Albjja83K zWhZ{#Jx3{1FSa?o=_tmCIohV)CM^AB)uNOmfI#W2JyvxuA*w!zzz=yk?Uqx@OV+59 zf2f>^caGp6%?QpX*t}#7tZ;+G9ofk$w$0~ zo2C=jC7Y3@&brUxnO({s=s3aqVwmTK(!$ za!D&trWibYvvXDkrU_7e*;njesROlcXYb}RjTG^FXcpbKu&$!^dK9nuQKeQa=j3;u z${sJ*jwnRUer>hO^b`#ys*h9x60#f z8bw!l9l=7ykouCF1I+SF9aH=ceyeICVf|e9mvom;Pn1NxoQMT?sO0*&JoyN-26RCL ztq!N|o5S<^@*gBSbuZSRk_~Jq6YneeS>EZZ=&6=Ow7I3~HPO`X+4 z4nt<*)W%R>N&#&O>mBXp=9Xf4wr~ra(VhvJX1MI8N^$QAeD5|S>3*pv6OoaOg}$6C z*j5vQ=i&s~{7TQgu&WnMmvDXVFLbfJ3sWxiLSYR03m0qVkx}-mG2c_F`E5L8euy2O zTt(f-6{Cur(RE*cSPqxb%xjxJ!5^ATg^W|X!zzJ?$5~V3phJ`FE=&CYyTM^Dw{>Cf z_Zv|I{~lP#+~!VK5Tl28iHrqF&;4!I7?e^6IPr*Edx*rq%HuZeSLS2#-esP8QjlsF zEx2Jm;?#L^MPhxvbMV0D43)m!{WK%1r_|f_BOPZj+l@m}wyO!gMvF~v1si&{I=b%s zH2Aq!K6VKf$O0{+ztJ43(3`#^Y5k}P%vz`pmwW&K literal 0 HcmV?d00001 diff --git a/docs/developer/.env.example b/docs/developer/.env.example new file mode 100644 index 0000000..6f61388 --- /dev/null +++ b/docs/developer/.env.example @@ -0,0 +1,3 @@ +APP_VERSION=latest +AAM_BACKEND_SERVICE_VERSION=latest +REPLICATION_BACKEND_PUBLIC_KEY= diff --git a/docs/developer/.gitignore b/docs/developer/.gitignore new file mode 100644 index 0000000..e6ad638 --- /dev/null +++ b/docs/developer/.gitignore @@ -0,0 +1,3 @@ +container-data/ +.env +secrets.env diff --git a/docs/developer/Caddyfile b/docs/developer/Caddyfile new file mode 100644 index 0000000..24e4975 --- /dev/null +++ b/docs/developer/Caddyfile @@ -0,0 +1,47 @@ +{ + local_certs +} + +localhost:80, localhost:443 { + handle_path /auth* { + reverse_proxy keycloak:8080 { + header_up Host {host} + } + } + + handle_path /db/couchdb* { + reverse_proxy db-couch:5984 + } + + handle_path /db* { + reverse_proxy replication-backend:5984 + } + + handle_path /replication-backend* { + reverse_proxy replication-backend:5984 + } + + handle_path /api* { + reverse_proxy aam-backend-service:8080 + } + + handle_path /sqs* { + reverse_proxy sqs:4984 + } + + handle_path /rabbitmq/* { + reverse_proxy rabbitmq:15672 + } + + handle_path /maildev/* { + reverse_proxy maildev:1080 + } + + handle_path /hello { + respond "Hello. This is aam-digital-reverse-proxy." 200 + } + + handle_path /* { + reverse_proxy http://host.docker.internal:4200 + } +} diff --git a/docs/developer/README.md b/docs/developer/README.md index 9a07984..3f7d993 100644 --- a/docs/developer/README.md +++ b/docs/developer/README.md @@ -1,94 +1,273 @@ +## Overview + +The aam-digital stack contains multiple services from different repositories wich are developed and maintained by aam-digital. +For some features, we also use third party solutions that are maintained from the respective development team. + +### managed by aam-digital (public) + +- *ndb-core*: main angular frontend application [GitHub](https://github.com/Aam-Digital/ndb-core) +- *replication-backend*: (optional) layer between frontend and the couchdb for handling document permissions [GitHub](https://github.com/Aam-Digital/replication-backend) +- *aam-backend-services*: main backend spring boot application, modulith architecture [GitHub](https://github.com/Aam-Digital/aam-services/tree/main/application/aam-backend-service) + +### managed by aam-digital (private) + +Accessible for aam-digital internals and contributors only. + +- *aam-external-mock-service*: mock of external systems are subject to a duty of non-disclosure in some cases. + +--- + +### used by aam-digital stack, managed by third party (public) + +- *couchdb*: Seamless multi-master syncing database with an intuitive HTTP/JSON API, designed for reliability [GitHub](https://github.com/apache/couchdb) +- *postgresql*: PostgreSQL is an advanced object-relational database management system [GitHub](https://github.com/postgres/postgres) +- *keycloak*: Open Source Identity and Access Management For Modern Applications and Services [GitHub](https://github.com/keycloak/keycloak) +- *rabbitmq-server*: Multi-protocol messaging and streaming broker. [GitHub](https://github.com/rabbitmq/rabbitmq-server) +- *carbone*: Fast, Simple and Powerful report generator in any format [GitHub](https://github.com/carboneio/carbone) + +### used by aam-digital stack, managed by third party (private) + +Accessible for aam-digital internals and contributors only. + +- *structured-query-server (sqs)*: An SQL query engine for CouchDB, letting you use SQL SELECT statements to extract information from a CouchDB database. [Homepage](https://neighbourhood.ie/products-and-services/structured-query-server) + +--- + +### third party tools used for development (public) + +- [maildev](https://github.com/maildev/maildev) +- [caddy](https://github.com/caddyserver/caddy) + ## Getting started -### start local environment +To make development as simple as possible, we provide all services as docker containers. You can start them with the docker-compose file provided. +All container will communicate directly over the docker network. -You can start all services needed for the local development with docker-compose: +--- -```shell -docker compose -f docker-compose.yml -p aam-services up -d -``` +### reverse-proxy + +The stack includes a caddy reverse-proxy that runs on https://localhost - SSL is enabled by default. However, this certificate is self-signed +and must be added manually as trustworthy. -#### Caddy (reverse-proxy) +#### add self-signed certificate -Part of the deployed services is a reverse-proxy. If you need to change the behavior, adapt the `./reverse-proxy/Caddyfile` -and restart the reverse-proxy container. +You can add import the auto generated caddy certificate after the aam-stack is started. -If you need local TLS support, you will need to import the Caddy Root CA. +##### macos -Install `caddy` on your local machine: +1. Open Keychain Access (`Cmd` + `Space` and search for it) +2. Switch to System `Keychains` -> `System` -> `Certificates` + ![Keychain Access](../assets/keychain-access-1.png) +3. Drag and Drop the `./caddy-authorities/root.crt` into Keychain Access +4. Open certificate details by double-click the certificate +5. Trust the certificate for SSL by setting `Trust` -> `Secure Sockets Layer (SSL)` to `Always Trust` + ![Keychain Access](../assets/keychain-access-2.png) + +## Full local setup with Docker and docker-compose + +### Step 1: start the local development stack + +You can start all services needed for the local development with docker-compose: -MacOS: ```shell -brew install caddy +docker compose -f docker-compose.yml up -d ``` -Trust the Caddy Root CA for local testing: +or in the same directory just + ```shell -# make sure, that the docker container is running -caddy trust +docker compose up -d ``` -### useful tips and tricks +- If needed, switch the sqs image in `docker-compose.yml` from `aam-sqs-mac` to `aam-sqs-linux` for compatibility. +- Attention: sqs is a private repository for internal use only. If you don't have permissions, + reach out to us or disable this block in the `docker-compose.yml` file -#### Reset http/https redirect cache in chrome +You can test the running proxy by open [https://localhost/hello](https://localhost/hello) - You should see a welcome message. +When you see a SSL warning, follow the steps in `add self-signed certificate` -Sometimes, when you're playing around with `http(s)://` redirects in your browser, -Chrome will cache the redirect for some time. When you explicit want to open -the `http://` version of an url, but Chrome will not let you: +### Step 2: Configure Keycloak -- go to `chrome://net-internals/#hsts` -- insert your domain in the `Delete domain security policies` section -- press `delete` +- Open the Keycloak Admin UI at [https://localhost/auth](https://localhost/auth) with the credentials defined in the docker-compose file. + + - username: `admin` + - password: `docker` + +- Create a new realm called **dummy-realm** by importing the [realm configuration file](example-data/realm_config.dummy-realm.json). +- Under **Keycloak Realm > Clients** ([https://localhost/auth/admin/master/console/#/dummy-realm/clients](https://localhost/auth/admin/master/console/#/dummy-realm/clients)), + import the client configuration using [client_app_configuration](example-data/client_app.json). +- In the new realm, create a user and assign relevant roles. (Usually you will want at least "user_app" and/or "admin_app" role to be able to load the basic app config.) + +### Step 3: Set Up CouchDB (todo: improve this by automatic script) -You can open the `http://` version directly again. +- Access CouchDB at [https://localhost/db/couchdb/_utils/#database/app/_all_docs](https://localhost/db/couchdb/_utils/#database/app/_all_docs). +- Create some new databases: + - `_users` + - `app` + - `app-attachmets` + - `notification-webhook` + - `report-calculation` +- Add a document of type **Config:CONFIG_ENTITY** to the `app` database (e.g., from [dev.aam-digital.net CouchDB instance](https://dev.aam-digital.net/db/couchdb/_utils/#database/app/Config%3ACONFIG_ENTITY)). + **Note: If you get an error while adding a document (e.g. document update conflict warning) remove the "_rev": "value".** + +- Add a document of type **Config:Permissions** to the `app` database: + +``` +{ + "_id": "Config:Permissions", + "data": { + "public": [ + { + "subject": [ + "Config", + "SiteSettings", + "PublicFormConfig", + "ConfigurableEnum" + ], + "action": "read" + } + ], + "default": [ + { + "subject": "all", + "action": "read" + } + ], + "admin_app": [ + { + "subject": "all", + "action": "manage" + } + ] + } +} +``` -## Full Local Setup (with Docker) +### Step 4: Configure the replication-backend + +- If not already done, copy the `.env.example` to `.env` -### Step 1: Start Docker Services -1. Run the `docker-compose` file to start all related services: ```shell -   docker-compose up +# /aam-services/developer +cp .env.example .env ``` -2. If needed, switch the image in `docker-compose.yml` from `aam-sqs-mac` to `aam-sqs-linux` for compatibility. -### Step 2: Configure Keycloak -3. Open the Keycloak Admin UI at [http://localhost:8080](http://localhost:8080) with the credentials defined in the docker-compose file. Defaults are: +- Retrieve the `public_key` for **dummy-realm** from [https://localhost/auth/realms/dummy-realm](https://localhost/auth/realms/dummy-realm) and add it to the `.env` file as `REPLICATION_BACKEND_PUBLIC_KEY`. + ``` -username - admin -password - docker +# from +REPLICATION_BACKEND_PUBLIC_KEY= + +# to +REPLICATION_BACKEND_PUBLIC_KEY=MIIBI.... ``` -4. Create a new realm called **dummy-realm** by importing the [realm base configuration file](https://github.com/Aam-Digital/ndb-setup/blob/master/baseConfigs/realm_config.example.json). -5. Under **Keycloak Realm > Clients** ([http://localhost:8080/admin/master/console/#/dummy-realm/clients](http://localhost:8080/admin/master/console/#/dummy-realm/clients)), import the client configuration using the [client config file](https://github.com/Aam-Digital/ndb-setup/blob/master/keycloak/client_config.json). -6. In the new realm, create a user and assign relevant roles. (Usually you will want at least "user_app" and/or "admin_app" role to be able to load the basic app config.) - -### Step 3: Set Up CouchDB -7. Access CouchDB at [http://localhost:5984/_utils/#database/app/_all_docs](http://localhost:5984/_utils/#database/app/_all_docs). -8. Create a new database name as `app`. -9. Add a document of type **Config:CONFIG_ENTITY** to the `app` database (e.g., from [dev.aam-digital.net CouchDB instance](https://dev.aam-digital.net/db/couchdb/_utils/#database/app/Config%3ACONFIG_ENTITY)). -**Note: If you get an error while adding a document (eg. document update conflict warning) remove the "_rev": "value".** - -### Step 4: Start the Backend -9. Clone the [replication-backend](https://github.com/Aam-Digital/replication-backend) repository (if you do not already have it available locally) and follow the setup instructions of its README to install dependencies. -10. Retrieve the `public_key` for **dummy-realm** from [http://localhost:8080/realms/dummy-realm](http://localhost:8080/realms/dummy-realm) and add it to the `.env` file for the replication backend as `JWT_PUBLIC_KEY`. -10. Start the replication backend: + +- Restart the deployment + ```shell -    npm run start:dev +docker compose down && docker compose up -d ``` ### Step 5: Start the Frontend -11. Update `environment.ts` or `assets/config.json` with the following settings, in order to run the app in "synced" mode using the backend services: + +- Update `environment.ts` or `assets/config.json` with the following settings, in order to run the app in "synced" mode using the backend services: + ``` -    "session_type": "synced", -    "demo_mode": false +"session_type": "synced", +"demo_mode": false ``` -12. Start the frontend: + +- Update `keycloak.json` with the following settings + +``` +{ + "realm": "dummy-realm", + "auth-server-url": "https://localhost/auth", + "ssl-required": "external", + "resource": "app", + "public-client": true, + "confidential-port": 0 +} + +``` + +- Start the frontend: + ```shell -    npm start +ng serve --host 0.0.0.0 +``` + +**Attention** + +If you use the default `npm start` command, make sure to update the start command in the `package.json` to: + +```json +{ + "scripts": { + "start": "ng serve --host 0.0.0.0" + } +} ``` +### Step 6: (optional) Configure the notification module of aam-backend-services + +1. Download the `firebase-credentials.json` from the firebase interface. +2. Encode it as base64 + run this to print the encoded file to the console: + ```bash + base64 -i firebase-credentials.json + ``` +3. Copy the output +4. Create a new file, based on the `secrets.env.example` + ```bash + cp secrets.env.example secrets.env + ``` +5. Edit `secrets.env` with an editor of your choice and replace the placeholder with the base-64 output you just copied + ``` + NOTIFICATIONFIREBASECONFIGURATION_CREDENTIALFILEBASE64= + ``` +6. Apply config by restart the containers + ```bash + docker compose up -d + ``` + +## tips and tricks + ### Accessing the Local Environment -- Frontend App: [http://localhost:4200](http://localhost:4200) -- Replication Backend: [http://localhost:3000](http://localhost:3000) -- Keycloak: [http://localhost:8080](http://localhost:8080) -- CouchDB: [http://localhost:5984](http://localhost:5984) +- ndb-core (frontend): [https://localhost/](https://localhost/) +- replication-backend: [https://localhost/replication-backend](https://localhost/replication-backend) + - additional proxy for ndb-core: [https://localhost/db](https://localhost/db) +- aam-backend-service: [https://localhost/api](https://localhost/api) +- maildev (smtp-trap): [https://localhost/maildev/](https://localhost/maildev/) +- Keycloak: [https://localhost/auth](https://localhost/auth) +- CouchDB: [https://localhost/db/couchdb](https://localhost/db/couchdb) +- CouchDB Admin: [https://localhost/db/couchdb/_utils/](https://localhost/db/couchdb/_utils/) (the last "/" is important!) +- RabbitMQ: [https://localhost/rabbitmq/](https://localhost/rabbitmq/) (the last "/" is important!) + +### developer credentials + +For easy start in local development, we create some default accounts. + +Unless otherwise specified, the default credentials are: + +- username: `admin` +- password: `docker` + +The default credentials for rabbitmq are: + +- username: `guest` +- password: `guest` + +### Reset http/https redirect cache in chrome + +Sometimes, when you're playing around with `http(s)://` redirects in your browser, +Chrome will cache the redirect for some time. When you explicit want to open +the `http://` version of an url, but Chrome will not let you: + +- go to `chrome://net-internals/#hsts` +- insert your domain in the `Delete domain security policies` section +- press `delete` + +You can open the `http://` version directly again. diff --git a/docs/developer/aam-backend-service-notification-api/.env b/docs/developer/aam-backend-service-notification-api/.env deleted file mode 100644 index 79566d5..0000000 --- a/docs/developer/aam-backend-service-notification-api/.env +++ /dev/null @@ -1 +0,0 @@ -REPLICATION_BACKEND_PUBLIC_KEY=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAo4pJNuTGMJAKWUMzzD9QD7lLwsVzxE1QlimQ/wnyjuBgUdzTrSB/svdj+Q/kTGKzExVcTaZKYlS4U7CG4DYChpIwVKscOdMV7+RMSglh63kxYXFqx1+nj2qtb33Jd0Xtt5DyS4cznhup+fmMazSxk00ZMLPIGpetlcIF5H7viEPGnHeF0QLswKL6RSVlGbeEDVr7XrsWLydmHty3fweg5vAneHuT8lhGnMvc+buFxP5VaZerclLGlKGiYfYPokjDyK//qdmm4Hf2Nx9orEyzDvtCxl6VZg040SgciZIyg5SOWo/KH+P1cWAftAEwO5Fpwa75VxRGunBUvmn2terZywIDAQAB diff --git a/docs/developer/aam-backend-service-notification-api/.gitignore b/docs/developer/aam-backend-service-notification-api/.gitignore deleted file mode 100644 index bb8a3c2..0000000 --- a/docs/developer/aam-backend-service-notification-api/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ - -secrets.env diff --git a/docs/developer/aam-backend-service-notification-api/Caddyfile b/docs/developer/aam-backend-service-notification-api/Caddyfile deleted file mode 100644 index 9ebecad..0000000 --- a/docs/developer/aam-backend-service-notification-api/Caddyfile +++ /dev/null @@ -1,12 +0,0 @@ -:80 { - handle_path /auth* { - reverse_proxy keycloak:8080 { - header_up Host {host} - header_up X-Forwarded-Proto {scheme} - } - } - - handle { - respond "Hello. This is Caddy." 200 - } -} diff --git a/docs/developer/aam-backend-service-notification-api/README.md b/docs/developer/aam-backend-service-notification-api/README.md deleted file mode 100644 index 07395dc..0000000 --- a/docs/developer/aam-backend-service-notification-api/README.md +++ /dev/null @@ -1,17 +0,0 @@ -## aam-services with notification backend - -Run this docker-compose file to start the notification backend service locally - -### Setup - -1. Download the `firebase-credentials.json` from the firebase interface. -2. Encode it as base64 - - `base64 -i firebase-credentials.json` will print the encoded file to the console -3. Copy the output -4. Create a new file, based on the `secrets.env.example` - - `cp secrets.env.example secrets.env` -5. Edit `secrets.env` with an editor of your choice and replace the placeholder with the base-64 output you just copied - - ``` - NOTIFICATIONFIREBASECONFIGURATION_CREDENTIALFILEBASE64= - ``` -6. Run `docker compose up -d` diff --git a/docs/developer/aam-backend-service-notification-api/docker-compose.yml b/docs/developer/aam-backend-service-notification-api/docker-compose.yml deleted file mode 100644 index c3cd25e..0000000 --- a/docs/developer/aam-backend-service-notification-api/docker-compose.yml +++ /dev/null @@ -1,134 +0,0 @@ -name: aam-backend-service-notification-api -services: - reverse-proxy: - image: caddy:2.9-alpine - volumes: - - ./Caddyfile:/etc/caddy/Caddyfile - ports: - - "80:80" - - "443:443" - - "2019:2019" - - # maildev: - # image: maildev/maildev - # ports: - # - "1025:1025" - # - "1080:1080" - - db-couch: - image: couchdb:3.3 - volumes: - - ~/docker-volumes/aam-digital/aam-services/db-couch/document-data:/opt/couchdb/data - - ~/docker-volumes/aam-digital/aam-services/db-couch/document-etc-locald:/opt/couchdb/etc/local.d - - ~/docker-volumes/aam-digital/aam-services/db-couch/document-log:/opt/couchdb/log - environment: - COUCHDB_USER: admin - COUCHDB_PASSWORD: docker - COUCHDB_SECRET: docker - ports: - - "5984:5984" - - db-keycloak: - image: postgres:16 - volumes: - - ~/docker-volumes/aam-digital/aam-services/db-keycloak/postgresql-data:/var/lib/postgresql/data - environment: - POSTGRES_DB: postgres - POSTGRES_USER: postgres - POSTGRES_PASSWORD: keycloak - ports: - - "5401:5432" - - db-backend: - image: postgres:16.5-bookworm - volumes: - - ~/docker-volumes/aam-digital/aam-services/db-backend/postgresql-data:/var/lib/postgresql/data - environment: - POSTGRES_DB: aam-backend-service - POSTGRES_USER: admin - POSTGRES_PASSWORD: docker - ports: - - "5402:5432" - - rabbitmq: - image: rabbitmq:3-management-alpine - volumes: - - ~/docker-volumes/aam-digital/aam-services/rabbitmq/data:/var/lib/rabbitmq/ - - ~/docker-volumes/aam-digital/aam-services/rabbitmq/log:/var/log/rabbitmq - ports: - - "5672:5672" - - "15672:15672" - - # sqs: - # image: ghcr.io/aam-digital/aam-sqs-mac:latest - # platform: linux/amd64 - # depends_on: - # - db-couch - # ports: - # - "4984:4984" - # volumes: - # - ~/docker-volumes/aam-digital/aam-services/sqs/data:/data - # environment: - # SQS_COUCHDB_URL: http://db-couch:5984 - - keycloak: - image: quay.io/keycloak/keycloak:26.0.7-0 - command: "start --proxy-headers forwarded" - volumes: - - ~/docker-volumes/aam-digital/aam-services/keycloak/data:/opt/keycloak/data - - ~/docker-volumes/aam-digital/aam-services/keycloak/themes:/opt/keycloak/themes - environment: - KC_HTTP_ENABLED: true - KC_HOSTNAME: http://localhost/auth - KC_FRONTEND_URL: http://localhost/auth - KC_DB: postgres - KC_DB_URL: jdbc:postgresql://db-keycloak:5432/postgres - KC_DB_SCHEMA: public - KC_DB_USERNAME: postgres - KC_DB_PASSWORD: keycloak - KEYCLOAK_ADMIN: admin - KEYCLOAK_ADMIN_PASSWORD: docker - ports: - - "8080:8080" - depends_on: - - db-keycloak - - # carbone-io: - # image: carbone/carbone-ee - # platform: linux/amd64 - # volumes: - # - ~/docker-volumes/aam-digital/aam-services/carbone-io/template:/app/template - # - ~/docker-volumes/aam-digital/aam-services/carbone-io/render:/app/render - # ports: - # - "4000:4000" - - # jaeger: - # image: jaegertracing/all-in-one:latest - # ports: - # - "16686:16686" # the jaeger UI - # - "4317:4317" # the OpenTelemetry collector grpc - # - "4318:4318" # the OpenTelemetry collector http - # environment: - # - COLLECTOR_OTLP_ENABLED=true - - aam-backend-service: - image: ghcr.io/aam-digital/aam-backend-service:pr-58 - container_name: aam-backend-service - ports: - - "8081:8080" - depends_on: - - aam-backend-service-db - - rabbitmq - env_file: - - ./application.env - - ./secrets.env - restart: unless-stopped - - aam-backend-service-db: - image: postgres:16.6-bookworm - container_name: aam-backend-service-db - environment: - POSTGRES_DB: aam-backend-service - POSTGRES_USER: admin - POSTGRES_PASSWORD: docker - restart: unless-stopped diff --git a/docs/developer/aam-backend-service-notification-api/application.env b/docs/developer/application.env similarity index 87% rename from docs/developer/aam-backend-service-notification-api/application.env rename to docs/developer/application.env index 6ea588e..b8dbb68 100644 --- a/docs/developer/aam-backend-service-notification-api/application.env +++ b/docs/developer/application.env @@ -1,5 +1,6 @@ CRYPTO_CONFIGURATION_SECRET=super-duper-secret -SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_ISSUERURI=http://localhost/auth/realms/dummy-realm +SERVER_SERVLET_CONTEXT_PATH=/ +SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_ISSUERURI=https://localhost/auth/realms/dummy-realm SPRING_RABBITMQ_VIRTUALHOST=/ SPRING_RABBITMQ_HOST=rabbitmq SPRING_RABBITMQ_LISTENER_DIRECT_RETRY_ENABLED=true diff --git a/docs/developer/docker-compose.yml b/docs/developer/docker-compose.yml index 646c325..9cccd4c 100644 --- a/docs/developer/docker-compose.yml +++ b/docs/developer/docker-compose.yml @@ -1,104 +1,157 @@ -# *************************************************************** -# start local development environment (without application) -# *************************************************************** -name: aam-services +name: aam-digital-developer-stack services: - maildev: - image: maildev/maildev + # *************************************************************** + # dev tools + # *************************************************************** + reverse-proxy: + image: caddy:2.9-alpine + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile + - ./container-data/caddy-authorities:/data/caddy/pki/authorities/local ports: - - "1025:1025" - - "1080:1080" + - "80:80" + - "443:443" + - "2019:2019" + + maildev: + image: maildev/maildev:2.2.1 + + # *************************************************************** + # couchdb + # *************************************************************** db-couch: - image: couchdb:3.3 + image: couchdb:3.4.2 volumes: - - ~/docker-volumes/aam-digital/aam-services/db-couch/document-data:/opt/couchdb/data - - ~/docker-volumes/aam-digital/aam-services/db-couch/document-etc-locald:/opt/couchdb/etc/local.d - - ~/docker-volumes/aam-digital/aam-services/db-couch/document-log:/opt/couchdb/log + - ./container-data/db-couch/data:/opt/couchdb/data + - ./container-data/db-couch/local.d:/opt/couchdb/etc/local.d environment: COUCHDB_USER: admin COUCHDB_PASSWORD: docker - COUCHDB_SECRET: docker - ports: - - "5984:5984" - db-keycloak: - image: postgres:16 - volumes: - - ~/docker-volumes/aam-digital/aam-services/db-keycloak/postgresql-data:/var/lib/postgresql/data + # *************************************************************** + # keycloak + # *************************************************************** + + keycloak: + image: quay.io/keycloak/keycloak:26.0.7-0 + command: "start --proxy-headers forwarded" environment: - POSTGRES_DB: postgres - POSTGRES_USER: postgres - POSTGRES_PASSWORD: keycloak - ports: - - "5401:5432" + KC_HTTP_ENABLED: true + KC_HOSTNAME: https://localhost/auth + KC_FRONTEND_URL: https://localhost/auth + KC_DB: postgres + KC_DB_URL: jdbc:postgresql://db-keycloak:5432/postgres + KC_DB_SCHEMA: public + KC_DB_USERNAME: admin + KC_DB_PASSWORD: docker + KEYCLOAK_ADMIN: admin + KEYCLOAK_ADMIN_PASSWORD: docker + depends_on: + - db-keycloak - db-backend: - image: postgres:16.5-bookworm + db-keycloak: + image: postgres:16 volumes: - - ~/docker-volumes/aam-digital/aam-services/db-backend/postgresql-data:/var/lib/postgresql/data + - ./container-data/db-keycloak/data:/var/lib/postgresql/data environment: - POSTGRES_DB: aam-backend-service + POSTGRES_DB: keycloak POSTGRES_USER: admin POSTGRES_PASSWORD: docker ports: - - "5402:5432" + - "5401:5432" + + # *************************************************************** + # rabbitmq + # *************************************************************** rabbitmq: image: rabbitmq:3-management-alpine volumes: - - ~/docker-volumes/aam-digital/aam-services/rabbitmq/data:/var/lib/rabbitmq/ - - ~/docker-volumes/aam-digital/aam-services/rabbitmq/log:/var/log/rabbitmq - ports: - - "5672:5672" - - "15672:15672" + - ./container-data/rabbitmq/data:/var/lib/rabbitmq/ - sqs: - image: ghcr.io/aam-digital/aam-sqs-mac:latest - platform: linux/amd64 + # *************************************************************** + # replication-backend + # *************************************************************** + + replication-backend: + image: ghcr.io/aam-digital/replication-backend:latest depends_on: - db-couch - ports: - - "4984:4984" - volumes: - - ~/docker-volumes/aam-digital/aam-services/sqs/data:/data environment: - SQS_COUCHDB_URL: http://db-couch:5984 + DATABASE_URL: http://db-couch:5984 + DATABASE_NAME: app + DATABASE_USER: admin + DATABASE_PASSWORD: docker + JWT_SECRET: someJwtSecret + JWT_PUBLIC_KEY: "-----BEGIN PUBLIC KEY-----\n${REPLICATION_BACKEND_PUBLIC_KEY:?REPLICATION_BACKEND_PUBLIC_KEY is not set}\n-----END PUBLIC KEY-----" + SENTRY_DSN: "" + SENTRY_ENABLED: false + SENTRY_INSTANCE_NAME: "" + SENTRY_ENVIRONMENT: "" + PORT: 5984 + restart: unless-stopped - keycloak: - image: quay.io/keycloak/keycloak:23.0 - volumes: - - ~/docker-volumes/aam-digital/aam-services/keycloak/data:/opt/keycloak/data - - ~/docker-volumes/aam-digital/aam-services/keycloak/themes:/opt/keycloak/themes - ports: - - "8080:8080" - environment: - KC_DB: postgres - KC_DB_URL: jdbc:postgresql://db-keycloak:5432/postgres - KC_DB_SCHEMA: public - KC_DB_USERNAME: postgres - KC_DB_PASSWORD: keycloak - KEYCLOAK_ADMIN: admin - KEYCLOAK_ADMIN_PASSWORD: docker + # *************************************************************** + # aam-backend-service + # *************************************************************** + + aam-backend-service: + image: ghcr.io/aam-digital/aam-backend-service:${AAM_BACKEND_SERVICE_VERSION:-latest} + container_name: aam-backend-service depends_on: - - db-keycloak - command: - - start-dev + - aam-backend-service-db + - rabbitmq + env_file: + - application.env + - secrets.env + restart: unless-stopped - carbone-io: - image: carbone/carbone-ee - platform: linux/amd64 - volumes: - - ~/docker-volumes/aam-digital/aam-services/carbone-io/template:/app/template - - ~/docker-volumes/aam-digital/aam-services/carbone-io/render:/app/render + aam-backend-service-db: + image: postgres:16.6-bookworm + container_name: aam-backend-service-db + environment: + POSTGRES_DB: aam-backend-service + POSTGRES_USER: admin + POSTGRES_PASSWORD: docker ports: - - "4000:4000" + - "5402:5432" + restart: unless-stopped - jaeger: - image: jaegertracing/all-in-one:latest - ports: - - "16686:16686" # the jaeger UI - - "4317:4317" # the OpenTelemetry collector grpc - - "4318:4318" # the OpenTelemetry collector http - environment: - - COLLECTOR_OTLP_ENABLED=true + # *************************************************************** + # aam-backend-service feature dependencies (todo) + # *************************************************************** + + # sqs: + # image: ghcr.io/aam-digital/aam-sqs-mac:latest + # platform: linux/amd64 + # depends_on: + # - db-couch + # ports: + # - "4984:4984" + # volumes: + # - ~/docker-volumes/aam-digital/aam-services/sqs/data:/data + # environment: + # SQS_COUCHDB_URL: http://db-couch:5984 + + # carbone-io: + # image: carbone/carbone-ee + # platform: linux/amd64 + # volumes: + # - ~/docker-volumes/aam-digital/aam-services/carbone-io/template:/app/template + # - ~/docker-volumes/aam-digital/aam-services/carbone-io/render:/app/render + # ports: + # - "4000:4000" + + # *************************************************************** + # tracing and monitoring (todo) + # *************************************************************** + + # jaeger: + # image: jaegertracing/all-in-one:latest + # ports: + # - "16686:16686" # the jaeger UI + # - "4317:4317" # the OpenTelemetry collector grpc + # - "4318:4318" # the OpenTelemetry collector http + # environment: + # - COLLECTOR_OTLP_ENABLED=true diff --git a/docs/developer/example-data/client_app.json b/docs/developer/example-data/client_app.json new file mode 100644 index 0000000..d15ecdf --- /dev/null +++ b/docs/developer/example-data/client_app.json @@ -0,0 +1,80 @@ +{ + "clientId": "app", + "name": "local ndb-core Angular app", + "description": "", + "rootUrl": "https://localhost", + "adminUrl": "", + "baseUrl": "https://localhost", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [ + "*" + ], + "webOrigins": [ + "*" + ], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": true, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": true, + "protocol": "openid-connect", + "attributes": { + "realm_client": "false", + "oidc.ciba.grant.enabled": "false", + "backchannel.logout.session.required": "true", + "display.on.consent.screen": "false", + "oauth2.device.authorization.grant.enabled": "false", + "backchannel.logout.revoke.offline.tokens": "false" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": true, + "nodeReRegistrationTimeout": -1, + "protocolMappers": [ + { + "name": "exact_username", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "username", + "id.token.claim": "true", + "lightweight.claim": "true", + "access.token.claim": "true", + "claim.name": "username", + "jsonType.label": "String" + } + }, + { + "name": "realm roles", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-realm-role-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "multivalued": "true", + "userinfo.token.claim": "true", + "id.token.claim": "true", + "lightweight.claim": "true", + "access.token.claim": "true", + "claim.name": "_couchdb\\.roles", + "jsonType.label": "String" + } + } + ], + "defaultClientScopes": [], + "optionalClientScopes": [], + "access": { + "view": true, + "configure": true, + "manage": true + } +} diff --git a/docs/developer/example-data/realm_config.dummy-realm.json b/docs/developer/example-data/realm_config.dummy-realm.json new file mode 100644 index 0000000..763806a --- /dev/null +++ b/docs/developer/example-data/realm_config.dummy-realm.json @@ -0,0 +1,1199 @@ +{ + "enabled": true, + "loginTheme": "default", + "emailTheme": "default", + "rememberMe": true, + "resetPasswordAllowed": true, + "accessTokenLifespan": 300, + "ssoSessionIdleTimeout": 7200, + "ssoSessionMaxLifespan": 36000, + "actionTokenGeneratedByAdminLifespan": 864000, + "passwordPolicy": "hashIterations(27500) and length(8) and digits(1) and upperCase(1)", + "internationalizationEnabled": true, + "supportedLocales": [ + "de", + "en", + "fr", + "it" + ], + "smtpServer": { + "starttls": "false", + "auth": "false", + "envelopeFrom": "", + "ssl": "false", + "replyTo": "", + "replyToDisplayName": "", + "from": "keycloak@localhost", + "fromDisplayName": "keycloak@localhost", + "host": "maildev", + "port": "1025", + "user": "", + "password": "" + }, + "eventsListeners": [], + "roles": { + "realm": [ + { + "name": "account_manager", + "description": "[FE] User with this role can create and update accounts", + "composite": false, + "clientRole": false, + "attributes": {} + }, + { + "name": "admin_app", + "description": "[FE] Users with this role can access everything", + "composite": false, + "clientRole": false, + "attributes": {} + }, + { + "name": "skill_admin", + "description": "[BE] User with this role can use the skill-api 'admin' endpoints", + "composite": false, + "clientRole": false, + "attributes": {} + }, + { + "name": "skill_reader", + "description": "[BE] User with this role can use the skill-api 'skills' endpoints", + "composite": false, + "clientRole": false, + "attributes": {} + }, + { + "name": "user_app", + "description": "[FE] Default rule which every user should have", + "composite": false, + "clientRole": false, + "attributes": {} + }, + { + "name": "default-roles", + "description": "${role_default-roles}", + "composite": true, + "composites": { + "client": { + "account": [ + "view-profile", + "manage-account" + ] + } + }, + "clientRole": false, + "attributes": {} + } + ] + }, + "defaultRole": { + "name": "default-roles", + "description": "${role_default-roles}", + "composite": true, + "clientRole": false + }, + "authenticationFlows": [ + { + "alias": "Account verification options", + "description": "Method with which to verity the existing account", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "idp-email-verification", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "ALTERNATIVE", + "priority": 20, + "autheticatorFlow": true, + "flowAlias": "Verify Existing Account by Re-authentication", + "userSetupAllowed": false + } + ] + }, + { + "alias": "Browser - Conditional OTP", + "description": "Flow to determine if the OTP is required for the authentication", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "auth-otp-form", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "alias": "Email 2FA", + "description": "browser based authentication", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": false, + "authenticationExecutions": [ + { + "authenticator": "auth-cookie", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "auth-spnego", + "authenticatorFlow": false, + "requirement": "DISABLED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "identity-provider-redirector", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 25, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "ALTERNATIVE", + "priority": 30, + "autheticatorFlow": true, + "flowAlias": "forms and OTP", + "userSetupAllowed": false + } + ] + }, + { + "alias": "forms and OTP", + "description": "Username, password, otp and other auth forms.", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": false, + "authenticationExecutions": [ + { + "authenticator": "auth-username-password-form", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "CONDITIONAL", + "priority": 11, + "autheticatorFlow": true, + "flowAlias": "email OTP", + "userSetupAllowed": false + } + ] + }, + { + "alias": "Direct Grant - Conditional OTP", + "description": "Flow to determine if the OTP is required for the authentication", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "direct-grant-validate-otp", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "alias": "First broker login - Conditional OTP", + "description": "Flow to determine if the OTP is required for the authentication", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "auth-otp-form", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "alias": "Handle Existing Account", + "description": "Handle what to do if there is existing account with same email/username like authenticated identity provider", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "idp-confirm-link", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": true, + "flowAlias": "Account verification options", + "userSetupAllowed": false + } + ] + }, + { + "alias": "Reset - Conditional OTP", + "description": "Flow to determine if the OTP should be reset or not. Set to REQUIRED to force.", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "reset-otp", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "alias": "User creation or linking", + "description": "Flow for the existing/non-existing user alternatives", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticatorConfig": "create unique user config", + "authenticator": "idp-create-user-if-unique", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "ALTERNATIVE", + "priority": 20, + "autheticatorFlow": true, + "flowAlias": "Handle Existing Account", + "userSetupAllowed": false + } + ] + }, + { + "alias": "Verify Existing Account by Re-authentication", + "description": "Reauthentication of existing account", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "idp-username-password-form", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "CONDITIONAL", + "priority": 20, + "autheticatorFlow": true, + "flowAlias": "First broker login - Conditional OTP", + "userSetupAllowed": false + } + ] + }, + { + "alias": "browser", + "description": "browser based authentication", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "auth-cookie", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "auth-spnego", + "authenticatorFlow": false, + "requirement": "DISABLED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "identity-provider-redirector", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 25, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "ALTERNATIVE", + "priority": 30, + "autheticatorFlow": true, + "flowAlias": "forms", + "userSetupAllowed": false + } + ] + }, + { + "alias": "clients", + "description": "Base authentication for clients", + "providerId": "client-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "client-secret", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "client-jwt", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "client-secret-jwt", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 30, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "client-x509", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 40, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "alias": "direct grant", + "description": "OpenID Connect Resource Owner Grant", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "direct-grant-validate-username", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "direct-grant-validate-password", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "CONDITIONAL", + "priority": 30, + "autheticatorFlow": true, + "flowAlias": "Direct Grant - Conditional OTP", + "userSetupAllowed": false + } + ] + }, + { + "alias": "docker auth", + "description": "Used by Docker clients to authenticate against the IDP", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "docker-http-basic-authenticator", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "alias": "email OTP", + "description": "", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": false, + "authenticationExecutions": [ + { + "authenticatorConfig": "trusted-config", + "authenticator": "trusted-device-condition", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 0, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "email-authenticator", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 1, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "trusted-device-authenticator", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 2, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "alias": "first broker login", + "description": "Actions taken after first broker login with identity provider account, which is not yet linked to any Keycloak account", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticatorConfig": "review profile config", + "authenticator": "idp-review-profile", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": true, + "flowAlias": "User creation or linking", + "userSetupAllowed": false + } + ] + }, + { + "alias": "forms", + "description": "Username, password, otp and other auth forms.", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "auth-username-password-form", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "CONDITIONAL", + "priority": 20, + "autheticatorFlow": true, + "flowAlias": "Browser - Conditional OTP", + "userSetupAllowed": false + } + ] + }, + { + "alias": "registration", + "description": "registration flow", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "registration-page-form", + "authenticatorFlow": true, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": true, + "flowAlias": "registration form", + "userSetupAllowed": false + } + ] + }, + { + "alias": "registration form", + "description": "registration form", + "providerId": "form-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "registration-user-creation", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "registration-profile-action", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 40, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "registration-password-action", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 50, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "registration-recaptcha-action", + "authenticatorFlow": false, + "requirement": "DISABLED", + "priority": 60, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "alias": "reset credentials", + "description": "Reset credentials for a user if they forgot their password or something", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "reset-credentials-choose-user", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "reset-credential-email", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "reset-password", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 30, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "CONDITIONAL", + "priority": 40, + "autheticatorFlow": true, + "flowAlias": "Reset - Conditional OTP", + "userSetupAllowed": false + } + ] + }, + { + "alias": "saml ecp", + "description": "SAML ECP Profile Authentication Flow", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "http-basic-authenticator", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + } + ], + "authenticatorConfig": [ + { + "alias": "create unique user config", + "config": { + "require.password.update.after.registration": "false" + } + }, + { + "alias": "review profile config", + "config": { + "update.profile.on.first.login": "missing" + } + }, + { + "alias": "trusted-config", + "config": { + "negate": "true" + } + } + ], + "clientScopes": [ + { + "name": "email", + "description": "OpenID Connect built-in scope: email", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "consent.screen.text": "${emailScopeConsentText}" + }, + "protocolMappers": [ + { + "name": "email", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "email", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "email", + "jsonType.label": "String" + } + }, + { + "name": "email verified", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "emailVerified", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "email_verified", + "jsonType.label": "boolean" + } + } + ] + }, + { + "name": "address", + "description": "OpenID Connect built-in scope: address", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "consent.screen.text": "${addressScopeConsentText}" + }, + "protocolMappers": [ + { + "name": "address", + "protocol": "openid-connect", + "protocolMapper": "oidc-address-mapper", + "consentRequired": false, + "config": { + "user.attribute.formatted": "formatted", + "user.attribute.country": "country", + "user.attribute.postal_code": "postal_code", + "userinfo.token.claim": "true", + "user.attribute.street": "street", + "id.token.claim": "true", + "user.attribute.region": "region", + "access.token.claim": "true", + "user.attribute.locality": "locality" + } + } + ] + }, + { + "name": "roles", + "description": "OpenID Connect scope for add user roles to the access token", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "display.on.consent.screen": "false", + "consent.screen.text": "${rolesScopeConsentText}" + }, + "protocolMappers": [ + { + "name": "audience resolve", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-resolve-mapper", + "consentRequired": false, + "config": {} + }, + { + "name": "realm roles", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-realm-role-mapper", + "consentRequired": false, + "config": { + "user.attribute": "foo", + "access.token.claim": "true", + "claim.name": "realm_access.roles", + "jsonType.label": "String", + "multivalued": "true" + } + }, + { + "name": "client roles", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-client-role-mapper", + "consentRequired": false, + "config": { + "user.attribute": "foo", + "access.token.claim": "true", + "claim.name": "resource_access.${client_id}.roles", + "jsonType.label": "String", + "multivalued": "true" + } + } + ] + }, + { + "name": "profile", + "description": "OpenID Connect built-in scope: profile", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "consent.screen.text": "${profileScopeConsentText}" + }, + "protocolMappers": [ + { + "name": "full name", + "protocol": "openid-connect", + "protocolMapper": "oidc-full-name-mapper", + "consentRequired": false, + "config": { + "id.token.claim": "true", + "access.token.claim": "true", + "userinfo.token.claim": "true" + } + }, + { + "name": "locale", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "locale", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "locale", + "jsonType.label": "String" + } + }, + { + "name": "given name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "firstName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "given_name", + "jsonType.label": "String" + } + }, + { + "name": "birthdate", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "birthdate", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "birthdate", + "jsonType.label": "String" + } + }, + { + "name": "username", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "username", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "preferred_username", + "jsonType.label": "String" + } + }, + { + "name": "zoneinfo", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "zoneinfo", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "zoneinfo", + "jsonType.label": "String" + } + }, + { + "name": "website", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "website", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "website", + "jsonType.label": "String" + } + }, + { + "name": "middle name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "middleName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "middle_name", + "jsonType.label": "String" + } + }, + { + "name": "picture", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "picture", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "picture", + "jsonType.label": "String" + } + }, + { + "name": "updated at", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "updatedAt", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "updated_at", + "jsonType.label": "long" + } + }, + { + "name": "family name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "lastName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "family_name", + "jsonType.label": "String" + } + }, + { + "name": "gender", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "gender", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "gender", + "jsonType.label": "String" + } + }, + { + "name": "profile", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "profile", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "profile", + "jsonType.label": "String" + } + }, + { + "name": "nickname", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "nickname", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "nickname", + "jsonType.label": "String" + } + } + ] + }, + { + "name": "microprofile-jwt", + "description": "Microprofile - JWT built-in scope", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "false" + }, + "protocolMappers": [ + { + "name": "groups", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-realm-role-mapper", + "consentRequired": false, + "config": { + "multivalued": "true", + "user.attribute": "foo", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "groups", + "jsonType.label": "String" + } + }, + { + "name": "upn", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "username", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "upn", + "jsonType.label": "String" + } + } + ] + }, + { + "name": "acr", + "description": "OpenID Connect scope for add acr (authentication context class reference) to the token", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "display.on.consent.screen": "false" + }, + "protocolMappers": [ + { + "name": "acr loa level", + "protocol": "openid-connect", + "protocolMapper": "oidc-acr-mapper", + "consentRequired": false, + "config": { + "id.token.claim": "true", + "access.token.claim": "true" + } + } + ] + }, + { + "name": "offline_access", + "description": "OpenID Connect built-in scope: offline_access", + "protocol": "openid-connect", + "attributes": { + "consent.screen.text": "${offlineAccessScopeConsentText}", + "display.on.consent.screen": "true" + } + }, + { + "name": "phone", + "description": "OpenID Connect built-in scope: phone", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "consent.screen.text": "${phoneScopeConsentText}" + }, + "protocolMappers": [ + { + "name": "phone number", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "phoneNumber", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "phone_number", + "jsonType.label": "String" + } + }, + { + "name": "phone number verified", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "phoneNumberVerified", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "phone_number_verified", + "jsonType.label": "boolean" + } + } + ] + }, + { + "name": "role_list", + "description": "SAML role list", + "protocol": "saml", + "attributes": { + "consent.screen.text": "${samlRoleListScopeConsentText}", + "display.on.consent.screen": "true" + }, + "protocolMappers": [ + { + "name": "role list", + "protocol": "saml", + "protocolMapper": "saml-role-list-mapper", + "consentRequired": false, + "config": { + "single": "false", + "attribute.nameformat": "Basic", + "attribute.name": "Role" + } + } + ] + }, + { + "name": "web-origins", + "description": "OpenID Connect scope for add allowed web origins to the access token", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "display.on.consent.screen": "false", + "consent.screen.text": "" + }, + "protocolMappers": [ + { + "name": "allowed web origins", + "protocol": "openid-connect", + "protocolMapper": "oidc-allowed-origins-mapper", + "consentRequired": false, + "config": {} + } + ] + }, + { + "name": "openid", + "description": "", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "gui.order": "", + "consent.screen.text": "" + } + } + ] +} diff --git a/docs/developer/aam-backend-service-notification-api/secrets.env.example b/docs/developer/secrets.env.example similarity index 100% rename from docs/developer/aam-backend-service-notification-api/secrets.env.example rename to docs/developer/secrets.env.example From 5d91881579bcb56fe0094c23e1e5a2ec8b9d37f0 Mon Sep 17 00:00:00 2001 From: Tom Winter Date: Tue, 7 Jan 2025 11:20:54 +0100 Subject: [PATCH 10/20] feat: support for containerised backend --- .../security/LocalDevelopmentJwtDecoder.kt | 42 +++++++++++++++++++ .../security/SecurityConfiguration.kt | 2 + .../src/main/resources/application.yaml | 26 ++++++------ docs/developer/application.env | 2 +- 4 files changed, 59 insertions(+), 13 deletions(-) create mode 100644 application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/security/LocalDevelopmentJwtDecoder.kt diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/security/LocalDevelopmentJwtDecoder.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/security/LocalDevelopmentJwtDecoder.kt new file mode 100644 index 0000000..fc61c0d --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/security/LocalDevelopmentJwtDecoder.kt @@ -0,0 +1,42 @@ +package com.aamdigital.aambackendservice.security + +import com.nimbusds.jwt.JWTParser +import org.springframework.security.oauth2.jwt.BadJwtException +import org.springframework.security.oauth2.jwt.Jwt +import org.springframework.security.oauth2.jwt.JwtDecoder +import org.springframework.security.oauth2.jwt.JwtDecoders +import org.springframework.security.oauth2.jwt.JwtException +import org.springframework.stereotype.Component +import java.text.ParseException + +@Component +class LocalDevelopmentJwtDecoder : JwtDecoder { + private val issuerDecoderMapping: Map = mapOf( + "https://localhost/auth/realms/dummy-realm" to + JwtDecoders.fromIssuerLocation("http://keycloak:8080/realms/dummy-realm"), + ) + + override fun decode(token: String): Jwt { + val parsedJwt = parse(token) + val decoder = issuerDecoderMapping[parsedJwt.jwtClaimsSet.issuer] + ?: throw JwtException("Issuer not recognized: ${parsedJwt.jwtClaimsSet.issuer}") + + return decoder.decode(token) + } + + private fun parse(token: String): com.nimbusds.jwt.JWT { + try { + return JWTParser.parse(token) + } catch (ex: ParseException) { + throw BadJwtException( + String.format( + format = "An error occurred while attempting to decode the Jwt: %s", "Malformed token" + ), ex + ) + } catch (ex: Exception) { + throw BadJwtException( + String.format(format = "An error occurred while attempting to decode the Jwt: %s", ex.message), ex + ) + } + } +} diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/security/SecurityConfiguration.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/security/SecurityConfiguration.kt index 8ff6077..57d5164 100644 --- a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/security/SecurityConfiguration.kt +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/security/SecurityConfiguration.kt @@ -20,6 +20,7 @@ class SecurityConfiguration { http: HttpSecurity, aamAuthenticationConverter: AamAuthenticationConverter, aamAccessDeniedHandler: AamAccessDeniedHandler, + localDevelopmentJwtDecoder: LocalDevelopmentJwtDecoder, objectMapper: ObjectMapper, ): SecurityFilterChain { http { @@ -52,6 +53,7 @@ class SecurityConfiguration { } oauth2ResourceServer { jwt { + jwtDecoder = localDevelopmentJwtDecoder jwtAuthenticationConverter = aamAuthenticationConverter authenticationEntryPoint = AamAuthenticationEntryPoint( diff --git a/application/aam-backend-service/src/main/resources/application.yaml b/application/aam-backend-service/src/main/resources/application.yaml index 03f6b30..d75f7f6 100644 --- a/application/aam-backend-service/src/main/resources/application.yaml +++ b/application/aam-backend-service/src/main/resources/application.yaml @@ -73,9 +73,9 @@ spring: oauth2: resourceserver: jwt: - issuer-uri: http://localhost:8080/realms/dummy-realm + issuer-uri: https://localhost/auth/realms/dummy-realm rabbitmq: - virtual-host: local + # virtual-host: local listener: direct: retry: @@ -83,14 +83,16 @@ spring: max-attempts: 5 simple: observation-enabled: true - username: local-spring - password: docker + # username: local-spring + # password: docker datasource: url: jdbc:postgresql://localhost:5402/aam-backend-service username: admin password: docker server: + servlet: + context-path: / error: include-message: always include-binding-errors: always @@ -114,19 +116,19 @@ aam-render-api-client-configuration: # scope: couch-db-client-configuration: - base-path: http://localhost:5984 + base-path: https://localhost/db/couchdb basic-auth-username: admin basic-auth-password: docker sqs-client-configuration: - base-path: http://localhost:4984 + base-path: https://localhost/sqs basic-auth-username: admin basic-auth-password: docker skilllab-api-client-configuration: api-key: skilllab-api-key project-id: dummy-project - base-path: http://localhost:9005/skilllab + base-path: https://localhost/skilllab response-timeout-in-seconds: 15 features: @@ -135,7 +137,7 @@ features: skill-api: mode: disabled notification-api: - enabled: false + enabled: true mode: firebase crypto-configuration: @@ -157,7 +159,7 @@ sentry: application: version: local-dev-build -management: - otlp: - tracing: - endpoint: http://localhost:4318/v1/traces +#management: +# otlp: +# tracing: +# endpoint: http://localhost:4318/v1/traces diff --git a/docs/developer/application.env b/docs/developer/application.env index b8dbb68..d64425e 100644 --- a/docs/developer/application.env +++ b/docs/developer/application.env @@ -1,6 +1,6 @@ CRYPTO_CONFIGURATION_SECRET=super-duper-secret SERVER_SERVLET_CONTEXT_PATH=/ -SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_ISSUERURI=https://localhost/auth/realms/dummy-realm +SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_ISSUERURI=http://keycloak:8080/realms/dummy-realm SPRING_RABBITMQ_VIRTUALHOST=/ SPRING_RABBITMQ_HOST=rabbitmq SPRING_RABBITMQ_LISTENER_DIRECT_RETRY_ENABLED=true From 0c5513a11823cd848bb9a32facabb846a046fcd0 Mon Sep 17 00:00:00 2001 From: Tom Winter Date: Wed, 8 Jan 2025 17:58:27 +0100 Subject: [PATCH 11/20] feat: rollback --- .../security/LocalDevelopmentJwtDecoder.kt | 42 ------------------- .../security/SecurityConfiguration.kt | 2 - 2 files changed, 44 deletions(-) delete mode 100644 application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/security/LocalDevelopmentJwtDecoder.kt diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/security/LocalDevelopmentJwtDecoder.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/security/LocalDevelopmentJwtDecoder.kt deleted file mode 100644 index fc61c0d..0000000 --- a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/security/LocalDevelopmentJwtDecoder.kt +++ /dev/null @@ -1,42 +0,0 @@ -package com.aamdigital.aambackendservice.security - -import com.nimbusds.jwt.JWTParser -import org.springframework.security.oauth2.jwt.BadJwtException -import org.springframework.security.oauth2.jwt.Jwt -import org.springframework.security.oauth2.jwt.JwtDecoder -import org.springframework.security.oauth2.jwt.JwtDecoders -import org.springframework.security.oauth2.jwt.JwtException -import org.springframework.stereotype.Component -import java.text.ParseException - -@Component -class LocalDevelopmentJwtDecoder : JwtDecoder { - private val issuerDecoderMapping: Map = mapOf( - "https://localhost/auth/realms/dummy-realm" to - JwtDecoders.fromIssuerLocation("http://keycloak:8080/realms/dummy-realm"), - ) - - override fun decode(token: String): Jwt { - val parsedJwt = parse(token) - val decoder = issuerDecoderMapping[parsedJwt.jwtClaimsSet.issuer] - ?: throw JwtException("Issuer not recognized: ${parsedJwt.jwtClaimsSet.issuer}") - - return decoder.decode(token) - } - - private fun parse(token: String): com.nimbusds.jwt.JWT { - try { - return JWTParser.parse(token) - } catch (ex: ParseException) { - throw BadJwtException( - String.format( - format = "An error occurred while attempting to decode the Jwt: %s", "Malformed token" - ), ex - ) - } catch (ex: Exception) { - throw BadJwtException( - String.format(format = "An error occurred while attempting to decode the Jwt: %s", ex.message), ex - ) - } - } -} diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/security/SecurityConfiguration.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/security/SecurityConfiguration.kt index 57d5164..8ff6077 100644 --- a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/security/SecurityConfiguration.kt +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/security/SecurityConfiguration.kt @@ -20,7 +20,6 @@ class SecurityConfiguration { http: HttpSecurity, aamAuthenticationConverter: AamAuthenticationConverter, aamAccessDeniedHandler: AamAccessDeniedHandler, - localDevelopmentJwtDecoder: LocalDevelopmentJwtDecoder, objectMapper: ObjectMapper, ): SecurityFilterChain { http { @@ -53,7 +52,6 @@ class SecurityConfiguration { } oauth2ResourceServer { jwt { - jwtDecoder = localDevelopmentJwtDecoder jwtAuthenticationConverter = aamAuthenticationConverter authenticationEntryPoint = AamAuthenticationEntryPoint( From 8b8486b2b7e79dff62d309656f4276c80e1f800e Mon Sep 17 00:00:00 2001 From: Tom Winter Date: Wed, 8 Jan 2025 18:09:17 +0100 Subject: [PATCH 12/20] feat: aam.local support --- .../src/main/resources/application.yaml | 8 ++++---- docs/developer/Caddyfile | 5 +++-- docs/developer/application.env | 2 +- docs/developer/docker-compose.yml | 9 +++++++-- 4 files changed, 15 insertions(+), 9 deletions(-) diff --git a/application/aam-backend-service/src/main/resources/application.yaml b/application/aam-backend-service/src/main/resources/application.yaml index d75f7f6..d33ad0b 100644 --- a/application/aam-backend-service/src/main/resources/application.yaml +++ b/application/aam-backend-service/src/main/resources/application.yaml @@ -73,7 +73,7 @@ spring: oauth2: resourceserver: jwt: - issuer-uri: https://localhost/auth/realms/dummy-realm + issuer-uri: https://aam.local/auth/realms/dummy-realm rabbitmq: # virtual-host: local listener: @@ -116,19 +116,19 @@ aam-render-api-client-configuration: # scope: couch-db-client-configuration: - base-path: https://localhost/db/couchdb + base-path: https://aam.local/db/couchdb basic-auth-username: admin basic-auth-password: docker sqs-client-configuration: - base-path: https://localhost/sqs + base-path: https://aam.local/sqs basic-auth-username: admin basic-auth-password: docker skilllab-api-client-configuration: api-key: skilllab-api-key project-id: dummy-project - base-path: https://localhost/skilllab + base-path: https://aam.local/skilllab response-timeout-in-seconds: 15 features: diff --git a/docs/developer/Caddyfile b/docs/developer/Caddyfile index 24e4975..0bb8605 100644 --- a/docs/developer/Caddyfile +++ b/docs/developer/Caddyfile @@ -2,7 +2,7 @@ local_certs } -localhost:80, localhost:443 { +aam.local:80, aam.local:443 { handle_path /auth* { reverse_proxy keycloak:8080 { header_up Host {host} @@ -22,7 +22,8 @@ localhost:80, localhost:443 { } handle_path /api* { - reverse_proxy aam-backend-service:8080 + # reverse_proxy http://host.docker.internal:9000 # local running app + reverse_proxy aam-backend-service:8080 # docker container } handle_path /sqs* { diff --git a/docs/developer/application.env b/docs/developer/application.env index d64425e..83216bd 100644 --- a/docs/developer/application.env +++ b/docs/developer/application.env @@ -1,6 +1,6 @@ CRYPTO_CONFIGURATION_SECRET=super-duper-secret SERVER_SERVLET_CONTEXT_PATH=/ -SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_ISSUERURI=http://keycloak:8080/realms/dummy-realm +SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_ISSUERURI=https://aam.local/auth/realms/dummy-realm SPRING_RABBITMQ_VIRTUALHOST=/ SPRING_RABBITMQ_HOST=rabbitmq SPRING_RABBITMQ_LISTENER_DIRECT_RETRY_ENABLED=true diff --git a/docs/developer/docker-compose.yml b/docs/developer/docker-compose.yml index 9cccd4c..5a80e32 100644 --- a/docs/developer/docker-compose.yml +++ b/docs/developer/docker-compose.yml @@ -38,8 +38,8 @@ services: command: "start --proxy-headers forwarded" environment: KC_HTTP_ENABLED: true - KC_HOSTNAME: https://localhost/auth - KC_FRONTEND_URL: https://localhost/auth + KC_HOSTNAME: https://aam.local/auth + KC_FRONTEND_URL: https://aam.local/auth KC_DB: postgres KC_DB_URL: jdbc:postgresql://db-keycloak:5432/postgres KC_DB_SCHEMA: public @@ -69,6 +69,9 @@ services: image: rabbitmq:3-management-alpine volumes: - ./container-data/rabbitmq/data:/var/lib/rabbitmq/ + ports: + - "5672:5672" + - "15672:15672" # *************************************************************** # replication-backend @@ -99,6 +102,8 @@ services: aam-backend-service: image: ghcr.io/aam-digital/aam-backend-service:${AAM_BACKEND_SERVICE_VERSION:-latest} container_name: aam-backend-service + external_links: + - reverse-proxy:aam.local depends_on: - aam-backend-service-db - rabbitmq From bdc2e8ef31f881b5b2dc45d660192b5e05027aa2 Mon Sep 17 00:00:00 2001 From: Tom Winter Date: Thu, 9 Jan 2025 08:43:34 +0100 Subject: [PATCH 13/20] feat: aam.localhost support --- .../security/DevelopmentNimbusJwtDecoder.kt | 48 +++++++++++++++++++ .../src/main/resources/application.yaml | 8 ++-- docs/developer/Caddyfile | 5 +- docs/developer/application.env | 3 +- docs/developer/docker-compose.yml | 6 +-- 5 files changed, 60 insertions(+), 10 deletions(-) create mode 100644 application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/security/DevelopmentNimbusJwtDecoder.kt diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/security/DevelopmentNimbusJwtDecoder.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/security/DevelopmentNimbusJwtDecoder.kt new file mode 100644 index 0000000..4a6e9dd --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/security/DevelopmentNimbusJwtDecoder.kt @@ -0,0 +1,48 @@ +package com.aamdigital.aambackendservice.security + + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.context.annotation.Profile +import org.springframework.security.oauth2.jwt.JwtDecoder +import org.springframework.security.oauth2.jwt.NimbusJwtDecoder +import org.springframework.web.client.RestTemplate +import java.security.cert.X509Certificate +import javax.net.ssl.HttpsURLConnection +import javax.net.ssl.SSLContext +import javax.net.ssl.TrustManager +import javax.net.ssl.X509TrustManager + + +/** + * DO NOT USE IN PRODUCTION + */ +@Configuration +class DevelopmentNimbusJwtDecoder { + + private val insecureTrustManager = arrayOf( + object : X509TrustManager { + override fun getAcceptedIssuers(): Array { + return emptyArray() + } + + override fun checkClientTrusted(certs: Array, authType: String) = Unit + override fun checkServerTrusted(certs: Array, authType: String) = Unit + } + ) + + @Bean + @Profile("local-docker-development") + fun sslCheckDisabledJwtDecoder(): JwtDecoder { + val sc = SSLContext.getInstance("SSL") + sc.init(null, insecureTrustManager, null) + HttpsURLConnection.setDefaultSSLSocketFactory(sc.socketFactory) + + return NimbusJwtDecoder + .withJwkSetUri( + "https://aam.localhost/auth/realms/dummy-realm" + ) + .restOperations(RestTemplate()) + .build() + } +} diff --git a/application/aam-backend-service/src/main/resources/application.yaml b/application/aam-backend-service/src/main/resources/application.yaml index d33ad0b..1bfef64 100644 --- a/application/aam-backend-service/src/main/resources/application.yaml +++ b/application/aam-backend-service/src/main/resources/application.yaml @@ -73,7 +73,7 @@ spring: oauth2: resourceserver: jwt: - issuer-uri: https://aam.local/auth/realms/dummy-realm + issuer-uri: https://aam.localhost/auth/realms/dummy-realm rabbitmq: # virtual-host: local listener: @@ -116,19 +116,19 @@ aam-render-api-client-configuration: # scope: couch-db-client-configuration: - base-path: https://aam.local/db/couchdb + base-path: https://aam.localhost/db/couchdb basic-auth-username: admin basic-auth-password: docker sqs-client-configuration: - base-path: https://aam.local/sqs + base-path: https://aam.localhost/sqs basic-auth-username: admin basic-auth-password: docker skilllab-api-client-configuration: api-key: skilllab-api-key project-id: dummy-project - base-path: https://aam.local/skilllab + base-path: htts://aam.localhost/skilllab response-timeout-in-seconds: 15 features: diff --git a/docs/developer/Caddyfile b/docs/developer/Caddyfile index 0bb8605..814f98d 100644 --- a/docs/developer/Caddyfile +++ b/docs/developer/Caddyfile @@ -1,8 +1,9 @@ { - local_certs + local_certs + auto_https disable_redirects } -aam.local:80, aam.local:443 { +aam.localhost:80, aam.localhost:443 { handle_path /auth* { reverse_proxy keycloak:8080 { header_up Host {host} diff --git a/docs/developer/application.env b/docs/developer/application.env index 83216bd..2c56fcb 100644 --- a/docs/developer/application.env +++ b/docs/developer/application.env @@ -1,6 +1,7 @@ CRYPTO_CONFIGURATION_SECRET=super-duper-secret +SPRING_PROFILES_ACTIVE=local-docker-development SERVER_SERVLET_CONTEXT_PATH=/ -SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_ISSUERURI=https://aam.local/auth/realms/dummy-realm +SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_ISSUERURI=https://aam.localhost/auth/realms/dummy-realm SPRING_RABBITMQ_VIRTUALHOST=/ SPRING_RABBITMQ_HOST=rabbitmq SPRING_RABBITMQ_LISTENER_DIRECT_RETRY_ENABLED=true diff --git a/docs/developer/docker-compose.yml b/docs/developer/docker-compose.yml index 5a80e32..4324b1a 100644 --- a/docs/developer/docker-compose.yml +++ b/docs/developer/docker-compose.yml @@ -38,8 +38,8 @@ services: command: "start --proxy-headers forwarded" environment: KC_HTTP_ENABLED: true - KC_HOSTNAME: https://aam.local/auth - KC_FRONTEND_URL: https://aam.local/auth + KC_HOSTNAME: https://aam.localhost/auth + KC_FRONTEND_URL: https://aam.localhost/auth KC_DB: postgres KC_DB_URL: jdbc:postgresql://db-keycloak:5432/postgres KC_DB_SCHEMA: public @@ -103,7 +103,7 @@ services: image: ghcr.io/aam-digital/aam-backend-service:${AAM_BACKEND_SERVICE_VERSION:-latest} container_name: aam-backend-service external_links: - - reverse-proxy:aam.local + - reverse-proxy:aam.localhost depends_on: - aam-backend-service-db - rabbitmq From b38520af6a73eca8adef268d955cf23f0d2c436e Mon Sep 17 00:00:00 2001 From: Tom Winter Date: Thu, 9 Jan 2025 09:04:28 +0100 Subject: [PATCH 14/20] feat: aam.localhost support --- .../aambackendservice/security/DevelopmentNimbusJwtDecoder.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/security/DevelopmentNimbusJwtDecoder.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/security/DevelopmentNimbusJwtDecoder.kt index 4a6e9dd..c99fdaf 100644 --- a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/security/DevelopmentNimbusJwtDecoder.kt +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/security/DevelopmentNimbusJwtDecoder.kt @@ -39,7 +39,7 @@ class DevelopmentNimbusJwtDecoder { HttpsURLConnection.setDefaultSSLSocketFactory(sc.socketFactory) return NimbusJwtDecoder - .withJwkSetUri( + .withIssuerLocation( "https://aam.localhost/auth/realms/dummy-realm" ) .restOperations(RestTemplate()) From 402b018f4e0bbcbee6741a5a90a951046fb91f00 Mon Sep 17 00:00:00 2001 From: Tom Winter Date: Thu, 9 Jan 2025 09:28:38 +0100 Subject: [PATCH 15/20] feat: aam.localhost support --- .../notification/controller/NotificationController.kt | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/controller/NotificationController.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/controller/NotificationController.kt index c9f1796..0e76319 100644 --- a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/controller/NotificationController.kt +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/controller/NotificationController.kt @@ -8,6 +8,7 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty import org.springframework.http.HttpStatus import org.springframework.http.ResponseEntity import org.springframework.security.core.Authentication +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken import org.springframework.transaction.annotation.Transactional import org.springframework.validation.annotation.Validated import org.springframework.web.bind.annotation.DeleteMapping @@ -44,7 +45,7 @@ class NotificationController( @Validated fun registerDevice( @RequestBody deviceRegistrationDto: DeviceRegistrationDto, - authentication: Authentication, + authentication: JwtAuthenticationToken, ): ResponseEntity { if (userDeviceRepository.existsByDeviceToken(deviceRegistrationDto.deviceToken)) { @@ -58,7 +59,7 @@ class NotificationController( userDeviceRepository.save( UserDeviceEntity( - userIdentifier = authentication.name, + userIdentifier = authentication.name ?: authentication.tokenAttributes["username"].toString(), deviceToken = deviceRegistrationDto.deviceToken, deviceName = deviceRegistrationDto.deviceName, ) @@ -84,7 +85,7 @@ class NotificationController( ) ) } - + try { userDeviceRepository.deleteByDeviceToken(id) } catch (ex: IOException) { From a51235108cdb5673c280acd6ba97c7b1674872e7 Mon Sep 17 00:00:00 2001 From: Tom Winter Date: Thu, 9 Jan 2025 09:41:11 +0100 Subject: [PATCH 16/20] docs: update host information --- docs/developer/README.md | 47 +++++++++++++++++++++++++--------------- 1 file changed, 30 insertions(+), 17 deletions(-) diff --git a/docs/developer/README.md b/docs/developer/README.md index 3f7d993..362ced0 100644 --- a/docs/developer/README.md +++ b/docs/developer/README.md @@ -47,9 +47,22 @@ All container will communicate directly over the docker network. ### reverse-proxy -The stack includes a caddy reverse-proxy that runs on https://localhost - SSL is enabled by default. However, this certificate is self-signed +The stack includes a caddy reverse-proxy that runs on https://aam.localhost/ - SSL is enabled by default. However, this certificate is self-signed and must be added manually as trustworthy. +You also need to adapt your `/etc/hosts` file and add an entry for `aam.localhost` to `127.0.0.1`: + +```bash +sudo nano /etc/hosts +``` + +Add another line for `aam.localhsot`: + +``` +127.0.0.1 localhost +127.0.0.1 aam.localhost +``` + #### add self-signed certificate You can add import the auto generated caddy certificate after the aam-stack is started. @@ -84,24 +97,24 @@ docker compose up -d - Attention: sqs is a private repository for internal use only. If you don't have permissions, reach out to us or disable this block in the `docker-compose.yml` file -You can test the running proxy by open [https://localhost/hello](https://localhost/hello) - You should see a welcome message. +You can test the running proxy by open [https://aam.localhost/hello](https://aam.localhost/hello) - You should see a welcome message. When you see a SSL warning, follow the steps in `add self-signed certificate` ### Step 2: Configure Keycloak -- Open the Keycloak Admin UI at [https://localhost/auth](https://localhost/auth) with the credentials defined in the docker-compose file. +- Open the Keycloak Admin UI at [https://aam.localhost/auth](https://aam.localhost/auth) with the credentials defined in the docker-compose file. - username: `admin` - password: `docker` - Create a new realm called **dummy-realm** by importing the [realm configuration file](example-data/realm_config.dummy-realm.json). -- Under **Keycloak Realm > Clients** ([https://localhost/auth/admin/master/console/#/dummy-realm/clients](https://localhost/auth/admin/master/console/#/dummy-realm/clients)), +- Under **Keycloak Realm > Clients** ([https://aam.localhost/auth/admin/master/console/#/dummy-realm/clients](https://aam.localhost/auth/admin/master/console/#/dummy-realm/clients)), import the client configuration using [client_app_configuration](example-data/client_app.json). - In the new realm, create a user and assign relevant roles. (Usually you will want at least "user_app" and/or "admin_app" role to be able to load the basic app config.) ### Step 3: Set Up CouchDB (todo: improve this by automatic script) -- Access CouchDB at [https://localhost/db/couchdb/_utils/#database/app/_all_docs](https://localhost/db/couchdb/_utils/#database/app/_all_docs). +- Access CouchDB at [https://aam.localhost/db/couchdb/_utils/#database/app/_all_docs](https://aam.localhost/db/couchdb/_utils/#database/app/_all_docs). - Create some new databases: - `_users` - `app` @@ -153,11 +166,11 @@ When you see a SSL warning, follow the steps in `add self-signed certificate` cp .env.example .env ``` -- Retrieve the `public_key` for **dummy-realm** from [https://localhost/auth/realms/dummy-realm](https://localhost/auth/realms/dummy-realm) and add it to the `.env` file as `REPLICATION_BACKEND_PUBLIC_KEY`. +- Retrieve the `public_key` for **dummy-realm** from [https://aam.localhost/auth/realms/dummy-realm](https://aam.localhost/auth/realms/dummy-realm) and add it to the `.env` file as `REPLICATION_BACKEND_PUBLIC_KEY`. ``` # from -REPLICATION_BACKEND_PUBLIC_KEY= +REPLICATION_BACKEND_PUBLIC_KEY= # to REPLICATION_BACKEND_PUBLIC_KEY=MIIBI.... @@ -183,7 +196,7 @@ docker compose down && docker compose up -d ``` { "realm": "dummy-realm", - "auth-server-url": "https://localhost/auth", + "auth-server-url": "https://aam.localhost/auth", "ssl-required": "external", "resource": "app", "public-client": true, @@ -236,15 +249,15 @@ If you use the default `npm start` command, make sure to update the start comman ### Accessing the Local Environment -- ndb-core (frontend): [https://localhost/](https://localhost/) -- replication-backend: [https://localhost/replication-backend](https://localhost/replication-backend) - - additional proxy for ndb-core: [https://localhost/db](https://localhost/db) -- aam-backend-service: [https://localhost/api](https://localhost/api) -- maildev (smtp-trap): [https://localhost/maildev/](https://localhost/maildev/) -- Keycloak: [https://localhost/auth](https://localhost/auth) -- CouchDB: [https://localhost/db/couchdb](https://localhost/db/couchdb) -- CouchDB Admin: [https://localhost/db/couchdb/_utils/](https://localhost/db/couchdb/_utils/) (the last "/" is important!) -- RabbitMQ: [https://localhost/rabbitmq/](https://localhost/rabbitmq/) (the last "/" is important!) +- ndb-core (frontend): [https://aam.localhost/](https://aam.localhost/) +- replication-backend: [https://aam.localhost/replication-backend](https://aam.localhost/replication-backend) + - additional proxy for ndb-core: [https://aam.localhost/db](https://aam.localhost/db) +- aam-backend-service: [https://aam.localhost/api](https://aam.localhost/api) +- maildev (smtp-trap): [https://aam.localhost/maildev/](https://aam.localhost/maildev/) +- Keycloak: [https://aam.localhost/auth](https://aam.localhost/auth) +- CouchDB: [https://aam.localhost/db/couchdb](https://aam.localhost/db/couchdb) +- CouchDB Admin: [https://aam.localhost/db/couchdb/_utils/](https://aam.localhost/db/couchdb/_utils/) (the last "/" is important!) +- RabbitMQ: [https://aam.localhost/rabbitmq/](https://aam.localhost/rabbitmq/) (the last "/" is important!) ### developer credentials From f845966c1313467c871af4df5b4793a942f104ac Mon Sep 17 00:00:00 2001 From: Tom Winter Date: Thu, 9 Jan 2025 11:08:51 +0100 Subject: [PATCH 17/20] feat: add accounts backend to local dev stack --- docs/developer/Caddyfile | 5 +++++ docs/developer/README.md | 5 +++-- docs/developer/docker-compose.yml | 23 +++++++++++++++++++++++ 3 files changed, 31 insertions(+), 2 deletions(-) diff --git a/docs/developer/Caddyfile b/docs/developer/Caddyfile index 814f98d..04d56df 100644 --- a/docs/developer/Caddyfile +++ b/docs/developer/Caddyfile @@ -18,6 +18,11 @@ aam.localhost:80, aam.localhost:443 { reverse_proxy replication-backend:5984 } + handle_path /accounts-backend* { + # reverse_proxy http://host.docker.internal:3000 # local running app + reverse_proxy accounts-backend:3000 # docker container + } + handle_path /replication-backend* { reverse_proxy replication-backend:5984 } diff --git a/docs/developer/README.md b/docs/developer/README.md index 362ced0..5c6cfe6 100644 --- a/docs/developer/README.md +++ b/docs/developer/README.md @@ -187,8 +187,9 @@ docker compose down && docker compose up -d - Update `environment.ts` or `assets/config.json` with the following settings, in order to run the app in "synced" mode using the backend services: ``` -"session_type": "synced", -"demo_mode": false +session_type: "synced", +demo_mode: false, +account_url: "https://aam.localhost/accounts-backend" ``` - Update `keycloak.json` with the following settings diff --git a/docs/developer/docker-compose.yml b/docs/developer/docker-compose.yml index 4324b1a..d670b06 100644 --- a/docs/developer/docker-compose.yml +++ b/docs/developer/docker-compose.yml @@ -73,6 +73,29 @@ services: - "5672:5672" - "15672:15672" + # *************************************************************** + # accounts-backend + # *************************************************************** + + accounts-backend: + image: aamdigital/account-ms:latest + depends_on: + - keycloak + external_links: + - reverse-proxy:aam.localhost + platform: linux/amd64 + environment: + CORS: "*" + SENTRY_DSN: "" + SENTRY_ENABLED: false + SENTRY_INSTANCE_NAME: "" + SENTRY_ENVIRONMENT: "" + KEYCLOAK_URL: https://aam.localhost/auth + KEYCLOAK_ADMIN: admin + KEYCLOAK_PASSWORD: docker + NODE_TLS_REJECT_UNAUTHORIZED: 0 # never use this in production + restart: unless-stopped + # *************************************************************** # replication-backend # *************************************************************** From 265e21aa90d31b05141c2cf5adfc0eca6fa108c7 Mon Sep 17 00:00:00 2001 From: Tom Winter Date: Wed, 15 Jan 2025 09:54:48 +0100 Subject: [PATCH 18/20] fix: test notification test --- .../controller/NotificationAdminController.kt | 10 ++++++---- .../notification/controller/NotificationController.kt | 7 ++++--- docs/developer/docker-compose.yml | 3 +++ 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/controller/NotificationAdminController.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/controller/NotificationAdminController.kt index fad2681..a3a525d 100644 --- a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/controller/NotificationAdminController.kt +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/controller/NotificationAdminController.kt @@ -6,7 +6,7 @@ import com.google.firebase.messaging.MulticastMessage import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty import org.springframework.data.domain.Pageable import org.springframework.http.ResponseEntity -import org.springframework.security.core.Authentication +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RestController @@ -30,12 +30,14 @@ class NotificationAdminController( @PostMapping("/message/device-test") fun sendTestMessageToDevice( - authentication: Authentication, + authentication: JwtAuthenticationToken, ): ResponseEntity { val userDevices = - userDeviceRepository.findByUserIdentifier(authentication.name, Pageable.unpaged()) + userDeviceRepository.findByUserIdentifier( + authentication.name ?: authentication.tokenAttributes["username"].toString(), Pageable.unpaged() + ) .map { - it.userIdentifier + it.deviceToken }.toList() if (userDevices.isEmpty()) { diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/controller/NotificationController.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/controller/NotificationController.kt index 0e76319..d82b7d4 100644 --- a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/controller/NotificationController.kt +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/controller/NotificationController.kt @@ -7,7 +7,6 @@ import org.slf4j.LoggerFactory import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty import org.springframework.http.HttpStatus import org.springframework.http.ResponseEntity -import org.springframework.security.core.Authentication import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken import org.springframework.transaction.annotation.Transactional import org.springframework.validation.annotation.Validated @@ -72,12 +71,14 @@ class NotificationController( @DeleteMapping("/device/{id}") fun unregisterDevice( @PathVariable id: String, - authentication: Authentication, + authentication: JwtAuthenticationToken, ): ResponseEntity { val userDevice = userDeviceRepository.findByDeviceToken(id).getOrNull() ?: return ResponseEntity.notFound().build() - if (userDevice.userIdentifier != authentication.name) { + if (userDevice.userIdentifier != (authentication.name + ?: authentication.tokenAttributes["username"].toString()) + ) { return ResponseEntity.status(HttpStatus.FORBIDDEN).body( HttpErrorDto( errorCode = "Forbidden", diff --git a/docs/developer/docker-compose.yml b/docs/developer/docker-compose.yml index d670b06..7d9d29e 100644 --- a/docs/developer/docker-compose.yml +++ b/docs/developer/docker-compose.yml @@ -47,6 +47,7 @@ services: KC_DB_PASSWORD: docker KEYCLOAK_ADMIN: admin KEYCLOAK_ADMIN_PASSWORD: docker + JAVA_TOOL_OPTIONS: -XX:UseSVE=0 # bug on M4 chips with Sequoia 15.2: https://github.com/corretto/corretto-21/issues/85 depends_on: - db-keycloak @@ -133,6 +134,8 @@ services: env_file: - application.env - secrets.env + environment: + JAVA_TOOL_OPTIONS: -XX:UseSVE=0 # bug on M4 chips with Sequoia 15.2: https://github.com/corretto/corretto-21/issues/85 restart: unless-stopped aam-backend-service-db: From 4cbfabacddf808cbcdabd7339becbac5c10bd458 Mon Sep 17 00:00:00 2001 From: Tom Winter Date: Wed, 15 Jan 2025 11:43:36 +0100 Subject: [PATCH 19/20] fix: use NotificationPayload instead of Body --- .../notification/controller/NotificationAdminController.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/controller/NotificationAdminController.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/controller/NotificationAdminController.kt index a3a525d..7b3ded9 100644 --- a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/controller/NotificationAdminController.kt +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/controller/NotificationAdminController.kt @@ -3,6 +3,7 @@ package com.aamdigital.aambackendservice.notification.controller import com.aamdigital.aambackendservice.notification.repositiory.UserDeviceRepository import com.google.firebase.messaging.FirebaseMessaging import com.google.firebase.messaging.MulticastMessage +import com.google.firebase.messaging.Notification import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty import org.springframework.data.domain.Pageable import org.springframework.http.ResponseEntity @@ -50,7 +51,7 @@ class NotificationAdminController( val message = MulticastMessage.builder() .addAllTokens(userDevices) - .putData("body", "Hello World") + .setNotification(Notification.builder().setTitle("Aam Digital Test").setBody("Hello World").build()) .build() val response = firebaseMessaging.sendEachForMulticast(message) From d7fae01cbd297f16a9cc5629cce2643722d1e654 Mon Sep 17 00:00:00 2001 From: Tom Winter Date: Thu, 30 Jan 2025 05:59:53 +0100 Subject: [PATCH 20/20] feat(aam-backed-service): add initial notification system --- .../changes/core/ChangeEventPublisher.kt | 6 +- .../core/CouchDbDatabaseChangeDetection.kt | 10 +- .../core/CreateDocumentChangeUseCase.kt | 7 + .../changes/core/DatabaseChangeDetection.kt | 2 +- .../core/DatabaseChangeEventConsumer.kt | 2 +- .../DefaultCreateDocumentChangeUseCase.kt | 8 +- .../core/NoopDatabaseChangeDetection.kt | 2 +- .../changes/di/ChangesConfiguration.kt | 16 +- .../changes/di/ChangesQueueConfiguration.kt | 12 +- .../domain}/DatabaseChangeEvent.kt | 2 +- .../domain}/DocumentChangeEvent.kt | 2 +- .../changes/jobs/CouchDbChangeDetectionJob.kt | 4 +- .../queue/DefaultChangeEventPublisher.kt | 8 +- .../DefaultDatabaseChangeEventConsumer.kt | 10 +- .../changes/repository/SyncRepository.kt | 2 +- .../controller/NotificationController.kt | 13 +- .../core/AppCreateNotificationHandler.kt | 94 ++++++++++ .../core/ApplyNotificationRulesUseCase.kt | 17 ++ .../CouchDbSyncNotificationConfigUseCase.kt | 176 ++++++++++++++++++ .../core/CreateNotificationHandler.kt | 9 + .../core/CreateNotificationUseCase.kt | 18 ++ .../DefaultApplyNotificationRulesUseCase.kt | 152 +++++++++++++++ .../core/DefaultCreateNotificationUseCase.kt | 31 +++ ...faultNotificationDocumentChangeConsumer.kt | 73 ++++++++ .../core/DefaultUserNotificationConsumer.kt | 58 ++++++ .../core/DefaultUserNotificationPublisher.kt | 58 ++++++ .../NotificationDocumentChangeConsumer.kt | 8 + .../core/PushCreateNotificationHandler.kt | 82 ++++++++ .../core/SyncNotificationConfigUseCase.kt | 22 +++ .../core/UserNotificationConsumer.kt | 8 + .../core/UserNotificationPublisher.kt | 8 + .../core/event/CreateUserNotificationEvent.kt | 10 + .../notification/di/FirebaseConfiguration.kt | 1 - .../di/NotificationConfiguration.kt | 67 +++++++ .../di/NotificationQueueConfiguration.kt | 72 +++++++ .../domain/NotificationChannelType.kt | 12 ++ .../notification/domain/NotificationType.kt | 12 ++ .../NotificationConditionEntity.kt | 16 ++ .../repositiory/NotificationConfigEntity.kt | 43 +++++ .../NotificationConfigRepository.kt | 8 + .../repositiory/NotificationRuleEntity.kt | 41 ++++ .../core/CreateDocumentChangeUseCase.kt | 7 - .../core/DefaultNotificationEventConsumer.kt | 2 +- .../notification/core/NotificationService.kt | 6 +- ... => ReportingNotificationConfiguration.kt} | 2 +- ...eportingNotificationQueueConfiguration.kt} | 2 +- ...efaultReportDocumentChangeEventConsumer.kt | 2 +- .../core/IdentifyAffectedReportsUseCase.kt | 2 +- .../report/di/ReportQueueConfiguration.kt | 6 +- .../DefaultIdentifyAffectedReportsUseCase.kt | 2 +- .../core/ReportCalculationChangeUseCase.kt | 2 +- .../DefaultReportCalculationChangeUseCase.kt | 2 +- .../src/main/resources/application.yaml | 8 +- 53 files changed, 1176 insertions(+), 69 deletions(-) rename application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/{reporting => }/changes/core/ChangeEventPublisher.kt (52%) rename application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/{reporting => }/changes/core/CouchDbDatabaseChangeDetection.kt (89%) create mode 100644 application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/changes/core/CreateDocumentChangeUseCase.kt rename application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/{reporting => }/changes/core/DatabaseChangeDetection.kt (50%) rename application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/{reporting => }/changes/core/DatabaseChangeEventConsumer.kt (75%) rename application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/{reporting => }/changes/core/DefaultCreateDocumentChangeUseCase.kt (88%) rename application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/{reporting => }/changes/core/NoopDatabaseChangeDetection.kt (61%) rename application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/{reporting => }/changes/di/ChangesConfiguration.kt (68%) rename application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/{reporting => }/changes/di/ChangesQueueConfiguration.kt (82%) rename application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/{reporting/domain/event => changes/domain}/DatabaseChangeEvent.kt (76%) rename application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/{reporting/domain/event => changes/domain}/DocumentChangeEvent.kt (81%) rename application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/{reporting => }/changes/jobs/CouchDbChangeDetectionJob.kt (87%) rename application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/{reporting => }/changes/queue/DefaultChangeEventPublisher.kt (90%) rename application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/{reporting => }/changes/queue/DefaultDatabaseChangeEventConsumer.kt (80%) rename application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/{reporting => }/changes/repository/SyncRepository.kt (91%) create mode 100644 application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/core/AppCreateNotificationHandler.kt create mode 100644 application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/core/ApplyNotificationRulesUseCase.kt create mode 100644 application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/core/CouchDbSyncNotificationConfigUseCase.kt create mode 100644 application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/core/CreateNotificationHandler.kt create mode 100644 application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/core/CreateNotificationUseCase.kt create mode 100644 application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/core/DefaultApplyNotificationRulesUseCase.kt create mode 100644 application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/core/DefaultCreateNotificationUseCase.kt create mode 100644 application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/core/DefaultNotificationDocumentChangeConsumer.kt create mode 100644 application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/core/DefaultUserNotificationConsumer.kt create mode 100644 application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/core/DefaultUserNotificationPublisher.kt create mode 100644 application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/core/NotificationDocumentChangeConsumer.kt create mode 100644 application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/core/PushCreateNotificationHandler.kt create mode 100644 application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/core/SyncNotificationConfigUseCase.kt create mode 100644 application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/core/UserNotificationConsumer.kt create mode 100644 application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/core/UserNotificationPublisher.kt create mode 100644 application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/core/event/CreateUserNotificationEvent.kt create mode 100644 application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/di/NotificationConfiguration.kt create mode 100644 application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/di/NotificationQueueConfiguration.kt create mode 100644 application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/domain/NotificationChannelType.kt create mode 100644 application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/domain/NotificationType.kt create mode 100644 application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/repositiory/NotificationConditionEntity.kt create mode 100644 application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/repositiory/NotificationConfigEntity.kt create mode 100644 application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/repositiory/NotificationConfigRepository.kt create mode 100644 application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/repositiory/NotificationRuleEntity.kt delete mode 100644 application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/core/CreateDocumentChangeUseCase.kt rename application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/di/{NotificationConfiguration.kt => ReportingNotificationConfiguration.kt} (98%) rename application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/di/{NotificationQueueConfiguration.kt => ReportingNotificationQueueConfiguration.kt} (98%) diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/core/ChangeEventPublisher.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/changes/core/ChangeEventPublisher.kt similarity index 52% rename from application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/core/ChangeEventPublisher.kt rename to application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/changes/core/ChangeEventPublisher.kt index 66e6fd6..f2b63cb 100644 --- a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/core/ChangeEventPublisher.kt +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/changes/core/ChangeEventPublisher.kt @@ -1,8 +1,8 @@ -package com.aamdigital.aambackendservice.reporting.changes.core +package com.aamdigital.aambackendservice.changes.core +import com.aamdigital.aambackendservice.changes.domain.DatabaseChangeEvent +import com.aamdigital.aambackendservice.changes.domain.DocumentChangeEvent import com.aamdigital.aambackendservice.queue.core.QueueMessage -import com.aamdigital.aambackendservice.reporting.domain.event.DatabaseChangeEvent -import com.aamdigital.aambackendservice.reporting.domain.event.DocumentChangeEvent interface ChangeEventPublisher { fun publish(channel: String, event: DatabaseChangeEvent): QueueMessage diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/core/CouchDbDatabaseChangeDetection.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/changes/core/CouchDbDatabaseChangeDetection.kt similarity index 89% rename from application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/core/CouchDbDatabaseChangeDetection.kt rename to application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/changes/core/CouchDbDatabaseChangeDetection.kt index 6b28e3e..c9f90cb 100644 --- a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/core/CouchDbDatabaseChangeDetection.kt +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/changes/core/CouchDbDatabaseChangeDetection.kt @@ -1,13 +1,13 @@ -package com.aamdigital.aambackendservice.reporting.changes.core +package com.aamdigital.aambackendservice.changes.core +import com.aamdigital.aambackendservice.changes.di.ChangesQueueConfiguration.Companion.DB_CHANGES_QUEUE +import com.aamdigital.aambackendservice.changes.domain.DatabaseChangeEvent +import com.aamdigital.aambackendservice.changes.repository.SyncEntry +import com.aamdigital.aambackendservice.changes.repository.SyncRepository import com.aamdigital.aambackendservice.couchdb.core.CouchDbClient import com.aamdigital.aambackendservice.couchdb.core.getEmptyQueryParams import com.aamdigital.aambackendservice.error.AamErrorCode import com.aamdigital.aambackendservice.error.InvalidArgumentException -import com.aamdigital.aambackendservice.reporting.changes.di.ChangesQueueConfiguration.Companion.DB_CHANGES_QUEUE -import com.aamdigital.aambackendservice.reporting.changes.repository.SyncEntry -import com.aamdigital.aambackendservice.reporting.changes.repository.SyncRepository -import com.aamdigital.aambackendservice.reporting.domain.event.DatabaseChangeEvent import com.fasterxml.jackson.databind.node.ObjectNode import java.util.* import kotlin.jvm.optionals.getOrDefault diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/changes/core/CreateDocumentChangeUseCase.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/changes/core/CreateDocumentChangeUseCase.kt new file mode 100644 index 0000000..cb0e5d4 --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/changes/core/CreateDocumentChangeUseCase.kt @@ -0,0 +1,7 @@ +package com.aamdigital.aambackendservice.changes.core + +import com.aamdigital.aambackendservice.changes.domain.DatabaseChangeEvent + +interface CreateDocumentChangeUseCase { + fun createEvent(event: DatabaseChangeEvent) +} diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/core/DatabaseChangeDetection.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/changes/core/DatabaseChangeDetection.kt similarity index 50% rename from application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/core/DatabaseChangeDetection.kt rename to application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/changes/core/DatabaseChangeDetection.kt index 566470a..6d73584 100644 --- a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/core/DatabaseChangeDetection.kt +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/changes/core/DatabaseChangeDetection.kt @@ -1,4 +1,4 @@ -package com.aamdigital.aambackendservice.reporting.changes.core +package com.aamdigital.aambackendservice.changes.core interface DatabaseChangeDetection { fun checkForChanges() diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/core/DatabaseChangeEventConsumer.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/changes/core/DatabaseChangeEventConsumer.kt similarity index 75% rename from application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/core/DatabaseChangeEventConsumer.kt rename to application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/changes/core/DatabaseChangeEventConsumer.kt index cdc734f..aa2e1e3 100644 --- a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/core/DatabaseChangeEventConsumer.kt +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/changes/core/DatabaseChangeEventConsumer.kt @@ -1,4 +1,4 @@ -package com.aamdigital.aambackendservice.reporting.changes.core +package com.aamdigital.aambackendservice.changes.core import com.rabbitmq.client.Channel import org.springframework.amqp.core.Message diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/core/DefaultCreateDocumentChangeUseCase.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/changes/core/DefaultCreateDocumentChangeUseCase.kt similarity index 88% rename from application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/core/DefaultCreateDocumentChangeUseCase.kt rename to application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/changes/core/DefaultCreateDocumentChangeUseCase.kt index acb5b99..fd11488 100644 --- a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/core/DefaultCreateDocumentChangeUseCase.kt +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/changes/core/DefaultCreateDocumentChangeUseCase.kt @@ -1,11 +1,11 @@ -package com.aamdigital.aambackendservice.reporting.changes.core +package com.aamdigital.aambackendservice.changes.core +import com.aamdigital.aambackendservice.changes.di.ChangesQueueConfiguration.Companion.DOCUMENT_CHANGES_EXCHANGE +import com.aamdigital.aambackendservice.changes.domain.DatabaseChangeEvent +import com.aamdigital.aambackendservice.changes.domain.DocumentChangeEvent import com.aamdigital.aambackendservice.couchdb.core.CouchDbClient import com.aamdigital.aambackendservice.couchdb.core.getEmptyQueryParams import com.aamdigital.aambackendservice.error.AamException -import com.aamdigital.aambackendservice.reporting.changes.di.ChangesQueueConfiguration.Companion.DOCUMENT_CHANGES_EXCHANGE -import com.aamdigital.aambackendservice.reporting.domain.event.DatabaseChangeEvent -import com.aamdigital.aambackendservice.reporting.domain.event.DocumentChangeEvent import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.databind.node.ObjectNode import org.slf4j.LoggerFactory diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/core/NoopDatabaseChangeDetection.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/changes/core/NoopDatabaseChangeDetection.kt similarity index 61% rename from application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/core/NoopDatabaseChangeDetection.kt rename to application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/changes/core/NoopDatabaseChangeDetection.kt index 7bc10ae..528b080 100644 --- a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/core/NoopDatabaseChangeDetection.kt +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/changes/core/NoopDatabaseChangeDetection.kt @@ -1,4 +1,4 @@ -package com.aamdigital.aambackendservice.reporting.changes.core +package com.aamdigital.aambackendservice.changes.core class NoopDatabaseChangeDetection : DatabaseChangeDetection { override fun checkForChanges() {} diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/di/ChangesConfiguration.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/changes/di/ChangesConfiguration.kt similarity index 68% rename from application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/di/ChangesConfiguration.kt rename to application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/changes/di/ChangesConfiguration.kt index 68c1033..7deb58d 100644 --- a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/di/ChangesConfiguration.kt +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/changes/di/ChangesConfiguration.kt @@ -1,13 +1,13 @@ -package com.aamdigital.aambackendservice.reporting.changes.di +package com.aamdigital.aambackendservice.changes.di +import com.aamdigital.aambackendservice.changes.core.ChangeEventPublisher +import com.aamdigital.aambackendservice.changes.core.CouchDbDatabaseChangeDetection +import com.aamdigital.aambackendservice.changes.core.CreateDocumentChangeUseCase +import com.aamdigital.aambackendservice.changes.core.DatabaseChangeDetection +import com.aamdigital.aambackendservice.changes.core.DefaultCreateDocumentChangeUseCase +import com.aamdigital.aambackendservice.changes.core.NoopDatabaseChangeDetection +import com.aamdigital.aambackendservice.changes.repository.SyncRepository import com.aamdigital.aambackendservice.couchdb.core.CouchDbClient -import com.aamdigital.aambackendservice.reporting.changes.core.ChangeEventPublisher -import com.aamdigital.aambackendservice.reporting.changes.core.CouchDbDatabaseChangeDetection -import com.aamdigital.aambackendservice.reporting.changes.core.CreateDocumentChangeUseCase -import com.aamdigital.aambackendservice.reporting.changes.core.DatabaseChangeDetection -import com.aamdigital.aambackendservice.reporting.changes.core.DefaultCreateDocumentChangeUseCase -import com.aamdigital.aambackendservice.reporting.changes.core.NoopDatabaseChangeDetection -import com.aamdigital.aambackendservice.reporting.changes.repository.SyncRepository import com.fasterxml.jackson.databind.ObjectMapper import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty import org.springframework.context.annotation.Bean diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/di/ChangesQueueConfiguration.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/changes/di/ChangesQueueConfiguration.kt similarity index 82% rename from application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/di/ChangesQueueConfiguration.kt rename to application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/changes/di/ChangesQueueConfiguration.kt index 9fe2e8b..92a50be 100644 --- a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/di/ChangesQueueConfiguration.kt +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/changes/di/ChangesQueueConfiguration.kt @@ -1,11 +1,11 @@ -package com.aamdigital.aambackendservice.reporting.changes.di +package com.aamdigital.aambackendservice.changes.di +import com.aamdigital.aambackendservice.changes.core.ChangeEventPublisher +import com.aamdigital.aambackendservice.changes.core.CreateDocumentChangeUseCase +import com.aamdigital.aambackendservice.changes.core.DatabaseChangeEventConsumer +import com.aamdigital.aambackendservice.changes.queue.DefaultChangeEventPublisher +import com.aamdigital.aambackendservice.changes.queue.DefaultDatabaseChangeEventConsumer import com.aamdigital.aambackendservice.queue.core.QueueMessageParser -import com.aamdigital.aambackendservice.reporting.changes.core.ChangeEventPublisher -import com.aamdigital.aambackendservice.reporting.changes.core.CreateDocumentChangeUseCase -import com.aamdigital.aambackendservice.reporting.changes.core.DatabaseChangeEventConsumer -import com.aamdigital.aambackendservice.reporting.changes.queue.DefaultChangeEventPublisher -import com.aamdigital.aambackendservice.reporting.changes.queue.DefaultDatabaseChangeEventConsumer import com.fasterxml.jackson.databind.ObjectMapper import org.springframework.amqp.core.Binding import org.springframework.amqp.core.BindingBuilder diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/domain/event/DatabaseChangeEvent.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/changes/domain/DatabaseChangeEvent.kt similarity index 76% rename from application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/domain/event/DatabaseChangeEvent.kt rename to application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/changes/domain/DatabaseChangeEvent.kt index 3c8100c..8f93524 100644 --- a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/domain/event/DatabaseChangeEvent.kt +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/changes/domain/DatabaseChangeEvent.kt @@ -1,4 +1,4 @@ -package com.aamdigital.aambackendservice.reporting.domain.event +package com.aamdigital.aambackendservice.changes.domain import com.aamdigital.aambackendservice.events.DomainEvent diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/domain/event/DocumentChangeEvent.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/changes/domain/DocumentChangeEvent.kt similarity index 81% rename from application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/domain/event/DocumentChangeEvent.kt rename to application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/changes/domain/DocumentChangeEvent.kt index 4418f9e..0a9ea35 100644 --- a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/domain/event/DocumentChangeEvent.kt +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/changes/domain/DocumentChangeEvent.kt @@ -1,4 +1,4 @@ -package com.aamdigital.aambackendservice.reporting.domain.event +package com.aamdigital.aambackendservice.changes.domain import com.aamdigital.aambackendservice.events.DomainEvent diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/jobs/CouchDbChangeDetectionJob.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/changes/jobs/CouchDbChangeDetectionJob.kt similarity index 87% rename from application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/jobs/CouchDbChangeDetectionJob.kt rename to application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/changes/jobs/CouchDbChangeDetectionJob.kt index e1b3a84..a9cc1c2 100644 --- a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/jobs/CouchDbChangeDetectionJob.kt +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/changes/jobs/CouchDbChangeDetectionJob.kt @@ -1,6 +1,6 @@ -package com.aamdigital.aambackendservice.reporting.changes.jobs +package com.aamdigital.aambackendservice.changes.jobs -import com.aamdigital.aambackendservice.reporting.changes.core.DatabaseChangeDetection +import com.aamdigital.aambackendservice.changes.core.DatabaseChangeDetection import org.slf4j.LoggerFactory import org.springframework.context.annotation.Configuration import org.springframework.scheduling.annotation.Scheduled diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/queue/DefaultChangeEventPublisher.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/changes/queue/DefaultChangeEventPublisher.kt similarity index 90% rename from application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/queue/DefaultChangeEventPublisher.kt rename to application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/changes/queue/DefaultChangeEventPublisher.kt index 2194a25..76c3125 100644 --- a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/queue/DefaultChangeEventPublisher.kt +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/changes/queue/DefaultChangeEventPublisher.kt @@ -1,12 +1,12 @@ -package com.aamdigital.aambackendservice.reporting.changes.queue +package com.aamdigital.aambackendservice.changes.queue +import com.aamdigital.aambackendservice.changes.core.ChangeEventPublisher +import com.aamdigital.aambackendservice.changes.domain.DatabaseChangeEvent +import com.aamdigital.aambackendservice.changes.domain.DocumentChangeEvent import com.aamdigital.aambackendservice.error.AamErrorCode import com.aamdigital.aambackendservice.error.AamException import com.aamdigital.aambackendservice.error.InternalServerException import com.aamdigital.aambackendservice.queue.core.QueueMessage -import com.aamdigital.aambackendservice.reporting.changes.core.ChangeEventPublisher -import com.aamdigital.aambackendservice.reporting.domain.event.DatabaseChangeEvent -import com.aamdigital.aambackendservice.reporting.domain.event.DocumentChangeEvent import com.fasterxml.jackson.databind.ObjectMapper import org.slf4j.LoggerFactory import org.springframework.amqp.AmqpException diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/queue/DefaultDatabaseChangeEventConsumer.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/changes/queue/DefaultDatabaseChangeEventConsumer.kt similarity index 80% rename from application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/queue/DefaultDatabaseChangeEventConsumer.kt rename to application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/changes/queue/DefaultDatabaseChangeEventConsumer.kt index e1173de..746a1d5 100644 --- a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/queue/DefaultDatabaseChangeEventConsumer.kt +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/changes/queue/DefaultDatabaseChangeEventConsumer.kt @@ -1,11 +1,11 @@ -package com.aamdigital.aambackendservice.reporting.changes.queue +package com.aamdigital.aambackendservice.changes.queue +import com.aamdigital.aambackendservice.changes.core.CreateDocumentChangeUseCase +import com.aamdigital.aambackendservice.changes.core.DatabaseChangeEventConsumer +import com.aamdigital.aambackendservice.changes.di.ChangesQueueConfiguration.Companion.DB_CHANGES_QUEUE +import com.aamdigital.aambackendservice.changes.domain.DatabaseChangeEvent import com.aamdigital.aambackendservice.error.AamException import com.aamdigital.aambackendservice.queue.core.QueueMessageParser -import com.aamdigital.aambackendservice.reporting.changes.core.CreateDocumentChangeUseCase -import com.aamdigital.aambackendservice.reporting.changes.core.DatabaseChangeEventConsumer -import com.aamdigital.aambackendservice.reporting.changes.di.ChangesQueueConfiguration.Companion.DB_CHANGES_QUEUE -import com.aamdigital.aambackendservice.reporting.domain.event.DatabaseChangeEvent import com.rabbitmq.client.Channel import org.slf4j.LoggerFactory import org.springframework.amqp.AmqpRejectAndDontRequeueException diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/repository/SyncRepository.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/changes/repository/SyncRepository.kt similarity index 91% rename from application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/repository/SyncRepository.kt rename to application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/changes/repository/SyncRepository.kt index 3141671..2d621db 100644 --- a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/repository/SyncRepository.kt +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/changes/repository/SyncRepository.kt @@ -1,4 +1,4 @@ -package com.aamdigital.aambackendservice.reporting.changes.repository +package com.aamdigital.aambackendservice.changes.repository import jakarta.persistence.Column import jakarta.persistence.Entity diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/controller/NotificationController.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/controller/NotificationController.kt index d82b7d4..2b6cc16 100644 --- a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/controller/NotificationController.kt +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/controller/NotificationController.kt @@ -19,13 +19,11 @@ import org.springframework.web.bind.annotation.RestController import java.io.IOException import kotlin.jvm.optionals.getOrNull - data class DeviceRegistrationDto( val deviceName: String? = null, val deviceToken: String, ) - @RestController @RequestMapping("/v1/notification") @ConditionalOnProperty( @@ -56,9 +54,18 @@ class NotificationController( ) } + if (authentication.name == null) { + return ResponseEntity.badRequest().body( + HttpErrorDto( + errorCode = "Bad Request", + errorMessage = "No subject found in the token." + ) + ) + } + userDeviceRepository.save( UserDeviceEntity( - userIdentifier = authentication.name ?: authentication.tokenAttributes["username"].toString(), + userIdentifier = authentication.name, deviceToken = deviceRegistrationDto.deviceToken, deviceName = deviceRegistrationDto.deviceName, ) diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/core/AppCreateNotificationHandler.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/core/AppCreateNotificationHandler.kt new file mode 100644 index 0000000..9a10d87 --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/core/AppCreateNotificationHandler.kt @@ -0,0 +1,94 @@ +package com.aamdigital.aambackendservice.notification.core + +import com.aamdigital.aambackendservice.couchdb.core.CouchDbClient +import com.aamdigital.aambackendservice.notification.core.event.CreateUserNotificationEvent +import com.aamdigital.aambackendservice.notification.domain.NotificationChannelType +import com.aamdigital.aambackendservice.notification.domain.NotificationType +import com.aamdigital.aambackendservice.notification.repositiory.NotificationConfigRepository +import com.fasterxml.jackson.annotation.JsonProperty +import java.util.* +import kotlin.jvm.optionals.getOrNull + +data class NotificationEventDto( + @JsonProperty("_id") + val id: String, + val title: String, + val body: String, + val actionUrl: String, + val notificationFor: String, + val notificationType: String, + val created: TimeStampDto, + val updated: TimeStampDto, +) + +data class TimeStampDto( + val at: String, + val by: String, +) + +class AppCreateNotificationHandler( + private val couchDbClient: CouchDbClient, + private val notificationConfigRepository: NotificationConfigRepository, +) : CreateNotificationHandler { + + override fun canHandle(notificationChannelType: NotificationChannelType): Boolean = + NotificationChannelType.APP == notificationChannelType + + override fun createMessage(createUserNotificationEvent: CreateUserNotificationEvent): CreateNotificationData { + + val notificationConfig = notificationConfigRepository.findByUserIdentifier( + userIdentifier = createUserNotificationEvent.userIdentifier + ).getOrNull() + + if (notificationConfig == null) { + return CreateNotificationData( + success = true, + messageCreated = false, + messageReference = null + ) + } + + val notificationRule = notificationConfig.notificationRules.find { + it.externalIdentifier == createUserNotificationEvent.notificationRule + } + + if (notificationRule == null) { + return CreateNotificationData( + success = true, + messageCreated = false, + messageReference = null + ) + } + + + val event = NotificationEventDto( + id = "NotificationEvent:${UUID.randomUUID()}", + title = "Update from Aam Digital", + body = notificationRule.label, + actionUrl = "", + notificationFor = createUserNotificationEvent.userIdentifier, + notificationType = NotificationType.ENTITY_CHANGE.toString(), + created = TimeStampDto( + at = "", + by = "system" + ), + updated = TimeStampDto( + at = "", + by = "system" + ) + ) + + couchDbClient + .putDatabaseDocument( + database = "app", // todo get user database here + documentId = event.id, + body = event, + ) + + return CreateNotificationData( + success = true, + messageCreated = false, + messageReference = null + ) + } +} diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/core/ApplyNotificationRulesUseCase.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/core/ApplyNotificationRulesUseCase.kt new file mode 100644 index 0000000..55be8c9 --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/core/ApplyNotificationRulesUseCase.kt @@ -0,0 +1,17 @@ +package com.aamdigital.aambackendservice.notification.core + +import com.aamdigital.aambackendservice.changes.domain.DocumentChangeEvent +import com.aamdigital.aambackendservice.domain.DomainUseCase +import com.aamdigital.aambackendservice.domain.UseCaseData +import com.aamdigital.aambackendservice.domain.UseCaseRequest + +data class ApplyNotificationRulesRequest( + val documentChangeEvent: DocumentChangeEvent, +) : UseCaseRequest + +data class ApplyNotificationRulesData( + val notificationsSendCount: Int +) : UseCaseData + +abstract class ApplyNotificationRulesUseCase : + DomainUseCase() diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/core/CouchDbSyncNotificationConfigUseCase.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/core/CouchDbSyncNotificationConfigUseCase.kt new file mode 100644 index 0000000..0c3e84a --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/core/CouchDbSyncNotificationConfigUseCase.kt @@ -0,0 +1,176 @@ +package com.aamdigital.aambackendservice.notification.core + +import com.aamdigital.aambackendservice.couchdb.core.CouchDbClient +import com.aamdigital.aambackendservice.domain.UseCaseOutcome +import com.aamdigital.aambackendservice.error.AamErrorCode +import com.aamdigital.aambackendservice.error.AamException +import com.aamdigital.aambackendservice.error.NotFoundException +import com.aamdigital.aambackendservice.notification.domain.NotificationType +import com.aamdigital.aambackendservice.notification.repositiory.NotificationConditionEntity +import com.aamdigital.aambackendservice.notification.repositiory.NotificationConfigEntity +import com.aamdigital.aambackendservice.notification.repositiory.NotificationConfigRepository +import com.aamdigital.aambackendservice.notification.repositiory.NotificationRuleEntity +import com.fasterxml.jackson.annotation.JsonProperty +import org.springframework.util.LinkedMultiValueMap +import java.util.* + +data class NotificationConfigDto( + @JsonProperty("_id") val id: String, + @JsonProperty("_rev") val rev: String, + val notificationRules: List, + val channels: NotificationChannelConfig?, +) + +data class NotificationRuleDto( + val label: String, + val notificationType: NotificationType, + val entityType: String, + val changeType: List, + val conditions: Map>, + val enabled: Boolean, +) + +data class NotificationChannelConfig( + val push: Boolean?, + val email: Boolean?, +) + +enum class CouchDbSyncNotificationConfigErrorCode : AamErrorCode { + INVALID_USER_IDENTIFIER, + IO_EXCEPTION, +} + +class CouchDbSyncNotificationConfigUseCase( + private val couchDbClient: CouchDbClient, + private val notificationConfigRepository: NotificationConfigRepository, +) : SyncNotificationConfigUseCase() { + + override fun apply(request: SyncNotificationConfigRequest): UseCaseOutcome { + val userIdentifier = try { + request.notificationConfigId.split(":")[1] + } catch (ex: Exception) { + return UseCaseOutcome.Failure( + errorCode = CouchDbSyncNotificationConfigErrorCode.INVALID_USER_IDENTIFIER, + errorMessage = ex.localizedMessage, + cause = ex + ) + } + + val notificationConfig = try { + couchDbClient.getDatabaseDocument( + database = request.notificationConfigDatabase, + documentId = request.notificationConfigId, + queryParams = LinkedMultiValueMap(), + kClass = NotificationConfigDto::class + ) + } catch (@Suppress("SwallowedException") ex: NotFoundException) { + val currentNotificationConfigOptional = notificationConfigRepository.findByUserIdentifier(userIdentifier) + + currentNotificationConfigOptional.ifPresent { + notificationConfigRepository.delete(it) + } + + return UseCaseOutcome.Success( + data = SyncNotificationConfigData( + imported = false, + updated = false, + skipped = false, + deleted = true, + message = "NotificationConfig deleted successfully." + ) + ) + } catch (ex: AamException) { + return UseCaseOutcome.Failure( + errorCode = CouchDbSyncNotificationConfigErrorCode.IO_EXCEPTION, + errorMessage = ex.localizedMessage, + cause = ex + ) + } + + val currentNotificationConfigOptional = notificationConfigRepository.findByUserIdentifier(userIdentifier) + + return if (currentNotificationConfigOptional.isEmpty) { + notificationConfigRepository.save( + NotificationConfigEntity( + revision = notificationConfig.rev, + userIdentifier = userIdentifier, + channelPush = notificationConfig.channels?.push ?: false, + channelEmail = false, // todo email support + notificationRules = mapToNotificationRules(notificationConfig) + ) + ) + UseCaseOutcome.Success( + data = SyncNotificationConfigData( + imported = true, + updated = false, + skipped = false, + deleted = false, + message = "NotificationConfig updated successfully." + ) + ) + } else { + updateNotificationConfigEntity(currentNotificationConfigOptional.get(), notificationConfig) + } + } + + private fun updateNotificationConfigEntity( + notificationConfigEntity: NotificationConfigEntity, + notificationConfig: NotificationConfigDto, + ): UseCaseOutcome { + try { + notificationConfigEntity.revision = notificationConfig.rev + notificationConfigEntity.channelPush = notificationConfig.channels?.push ?: false + notificationConfigEntity.channelEmail = notificationConfig.channels?.email ?: false + + // delete all existing entities from database + notificationConfigEntity.notificationRules = emptyList() + notificationConfigRepository.save(notificationConfigEntity) + + // create new rule entities + notificationConfigEntity.notificationRules = mapToNotificationRules(notificationConfig) + notificationConfigRepository.save(notificationConfigEntity) + } catch (ex: Exception) { + return UseCaseOutcome.Failure( + errorCode = CouchDbSyncNotificationConfigErrorCode.IO_EXCEPTION, + errorMessage = ex.localizedMessage, + cause = ex + ) + } + + return UseCaseOutcome.Success( + data = SyncNotificationConfigData( + imported = true, + updated = true, + skipped = false, + deleted = false, + message = "NotificationConfig updated successfully." + ) + ) + } + + private fun mapToNotificationRules(notificationConfig: NotificationConfigDto): List = + notificationConfig.notificationRules.flatMap { rule -> + rule.changeType.map { + NotificationRuleEntity( + notificationType = rule.notificationType, + label = rule.label, + externalIdentifier = UUID.randomUUID().toString(), + entityType = rule.entityType, + changeType = it, + enabled = rule.enabled, + conditions = rule.conditions.mapNotNull { condition -> + if (condition.value.isEmpty()) return@mapNotNull null + + val firstKey = condition.value.keys.first() + val firstValue = condition.value[firstKey] ?: return@mapNotNull null + + NotificationConditionEntity( + field = condition.key, + operator = firstKey, + value = firstValue, + ) + }, + ) + } + } +} diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/core/CreateNotificationHandler.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/core/CreateNotificationHandler.kt new file mode 100644 index 0000000..82cc3e7 --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/core/CreateNotificationHandler.kt @@ -0,0 +1,9 @@ +package com.aamdigital.aambackendservice.notification.core + +import com.aamdigital.aambackendservice.notification.core.event.CreateUserNotificationEvent +import com.aamdigital.aambackendservice.notification.domain.NotificationChannelType + +interface CreateNotificationHandler { + fun canHandle(notificationChannelType: NotificationChannelType): Boolean + fun createMessage(createUserNotificationEvent: CreateUserNotificationEvent): CreateNotificationData +} diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/core/CreateNotificationUseCase.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/core/CreateNotificationUseCase.kt new file mode 100644 index 0000000..45971e1 --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/core/CreateNotificationUseCase.kt @@ -0,0 +1,18 @@ +package com.aamdigital.aambackendservice.notification.core + +import com.aamdigital.aambackendservice.domain.DomainUseCase +import com.aamdigital.aambackendservice.domain.UseCaseData +import com.aamdigital.aambackendservice.domain.UseCaseRequest +import com.aamdigital.aambackendservice.notification.core.event.CreateUserNotificationEvent + +data class CreateNotificationRequest( + val createUserNotificationEvent: CreateUserNotificationEvent, +) : UseCaseRequest + +data class CreateNotificationData( + val success: Boolean, + val messageCreated: Boolean, + val messageReference: String?, +) : UseCaseData + +abstract class CreateNotificationUseCase : DomainUseCase() diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/core/DefaultApplyNotificationRulesUseCase.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/core/DefaultApplyNotificationRulesUseCase.kt new file mode 100644 index 0000000..706965a --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/core/DefaultApplyNotificationRulesUseCase.kt @@ -0,0 +1,152 @@ +package com.aamdigital.aambackendservice.notification.core + +import com.aamdigital.aambackendservice.changes.domain.DocumentChangeEvent +import com.aamdigital.aambackendservice.domain.UseCaseOutcome +import com.aamdigital.aambackendservice.notification.core.event.CreateUserNotificationEvent +import com.aamdigital.aambackendservice.notification.di.NotificationQueueConfiguration.Companion.USER_NOTIFICATION_QUEUE +import com.aamdigital.aambackendservice.notification.domain.NotificationChannelType +import com.aamdigital.aambackendservice.notification.repositiory.NotificationConditionEntity +import com.aamdigital.aambackendservice.notification.repositiory.NotificationConfigRepository +import com.aamdigital.aambackendservice.notification.repositiory.NotificationRuleEntity +import kotlin.jvm.optionals.getOrNull + +class DefaultApplyNotificationRulesUseCase( + val notificationConfigRepository: NotificationConfigRepository, + val userNotificationPublisher: UserNotificationPublisher, +) : ApplyNotificationRulesUseCase() { + + override fun apply(request: ApplyNotificationRulesRequest): UseCaseOutcome { + val changedEntity = request.documentChangeEvent.documentId.split(":").first() + val changeType = extractChangeType(request.documentChangeEvent) + + val notificationConfigurations = notificationConfigRepository.findAll() // todo database access optimization + + val userRuleMappings = notificationConfigurations.groupBy { it.userIdentifier } + .map { userRuleMapping -> + userRuleMapping.key to userRuleMapping.value.flatMap { notificationConfig -> + notificationConfig.notificationRules + .filter { it.enabled } + .filter { it.entityType == changedEntity } + .filter { it.changeType == changeType } + } + }.toMap() + + return applyRules(userRuleMappings, request.documentChangeEvent) + } + + private fun extractChangeType(documentChangeEvent: DocumentChangeEvent): String { + if (documentChangeEvent.deleted) { +// return "deleted" // todo frontend support for this? + return "updated" + } + + if (documentChangeEvent.previousVersion.isEmpty()) { + return "created" + } + + return "updated" + } + + private fun applyRules( + userRoleMapping: Map>, + documentChangeEvent: DocumentChangeEvent, + ): UseCaseOutcome { + userRoleMapping.forEach { rule -> + appleRulesForUser( + userIdentifier = rule.key, + rules = rule.value, + documentChangeEvent = documentChangeEvent + ) + } + + return UseCaseOutcome.Success( + ApplyNotificationRulesData( + 0 // todo useful summery here + ) + ) + } + + private fun appleRulesForUser( + userIdentifier: String, + rules: List, + documentChangeEvent: DocumentChangeEvent, + ) { + rules.forEach { rule -> + logger.trace("{} -> {}", userIdentifier, rule) + + val userConfig = notificationConfigRepository.findByUserIdentifier(userIdentifier).getOrNull() ?: return + + rule.conditions.forEach { condition -> + val conditionOutcome: Boolean = checkConditionForDocument(condition, documentChangeEvent) + logger.trace( + "rule {} outcome {} for user {}", + rule.externalIdentifier, + conditionOutcome, + userIdentifier + ) + + if (!conditionOutcome) { + return + } + } + + userNotificationPublisher.publish( + channel = USER_NOTIFICATION_QUEUE, + event = CreateUserNotificationEvent( + userIdentifier = userIdentifier, + notificationChannelType = NotificationChannelType.APP, + notificationRule = rule.externalIdentifier + ) + ) + + if (userConfig.channelPush) { + userNotificationPublisher.publish( + channel = USER_NOTIFICATION_QUEUE, + event = CreateUserNotificationEvent( + userIdentifier = userIdentifier, + notificationChannelType = NotificationChannelType.PUSH, + notificationRule = rule.externalIdentifier + ) + ) + } + + if (userConfig.channelEmail) { + userNotificationPublisher.publish( + channel = USER_NOTIFICATION_QUEUE, + event = CreateUserNotificationEvent( + userIdentifier = userIdentifier, + notificationChannelType = NotificationChannelType.EMAIL, + notificationRule = rule.externalIdentifier + ) + ) + } + } + } + + /** + * todo: move this to an separate class with extended testing + */ + private fun checkConditionForDocument( + condition: NotificationConditionEntity, + documentChangeEvent: DocumentChangeEvent + ): Boolean { + return when (condition.operator) { + "\$eq" -> { + val currentValue = documentChangeEvent.currentVersion[condition.field] + return when (currentValue) { + is String -> documentChangeEvent.currentVersion[condition.field] == condition.value + is ArrayList<*> -> + (documentChangeEvent.currentVersion[condition.field] as ArrayList<*>) + .first() == condition.value + + else -> false + } + } + + else -> { + logger.warn("Unknown condition operator: ${condition.operator}") + true + } + } + } +} diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/core/DefaultCreateNotificationUseCase.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/core/DefaultCreateNotificationUseCase.kt new file mode 100644 index 0000000..c367ea3 --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/core/DefaultCreateNotificationUseCase.kt @@ -0,0 +1,31 @@ +package com.aamdigital.aambackendservice.notification.core + +import com.aamdigital.aambackendservice.domain.UseCaseOutcome +import com.aamdigital.aambackendservice.error.AamErrorCode + +class DefaultCreateNotificationUseCase( + private val createNotificationHandler: List +) : CreateNotificationUseCase() { + + enum class DefaultCreateNotificationUseCaseError : AamErrorCode { + INVALID_NOTIFICATION_CHANNEL_TYPE, + } + + override fun apply(request: CreateNotificationRequest): UseCaseOutcome { + + for (handler in createNotificationHandler) { + if (handler.canHandle(request.createUserNotificationEvent.notificationChannelType)) { + val outcome = handler.createMessage(createUserNotificationEvent = request.createUserNotificationEvent) + + return UseCaseOutcome.Success( + data = outcome + ) + } + } + + return UseCaseOutcome.Failure( + errorMessage = "No Handler for this NotificationChannelType", + errorCode = DefaultCreateNotificationUseCaseError.INVALID_NOTIFICATION_CHANNEL_TYPE, + ) + } +} diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/core/DefaultNotificationDocumentChangeConsumer.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/core/DefaultNotificationDocumentChangeConsumer.kt new file mode 100644 index 0000000..1699568 --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/core/DefaultNotificationDocumentChangeConsumer.kt @@ -0,0 +1,73 @@ +package com.aamdigital.aambackendservice.notification.core + +import com.aamdigital.aambackendservice.changes.domain.DocumentChangeEvent +import com.aamdigital.aambackendservice.error.AamException +import com.aamdigital.aambackendservice.notification.di.NotificationQueueConfiguration.Companion.DOCUMENT_CHANGES_NOTIFICATION_QUEUE +import com.aamdigital.aambackendservice.queue.core.QueueMessageParser +import com.rabbitmq.client.Channel +import org.slf4j.LoggerFactory +import org.springframework.amqp.AmqpRejectAndDontRequeueException +import org.springframework.amqp.core.Message +import org.springframework.amqp.rabbit.annotation.RabbitListener + +class DefaultNotificationDocumentChangeConsumer( + private val messageParser: QueueMessageParser, + private val syncNotificationConfigUseCase: SyncNotificationConfigUseCase, + private val applyNotificationRulesUseCase: ApplyNotificationRulesUseCase, +) : NotificationDocumentChangeConsumer { + private val logger = LoggerFactory.getLogger(javaClass) + + @RabbitListener( + queues = [DOCUMENT_CHANGES_NOTIFICATION_QUEUE], + // avoid concurrent processing so that we do not trigger multiple calculations for same data unnecessarily + concurrency = "1-1", + ) + override fun consume(rawMessage: String, message: Message, channel: Channel) { + val type = try { + messageParser.getTypeKClass(rawMessage.toByteArray()) + } catch (ex: AamException) { + throw AmqpRejectAndDontRequeueException("[${ex.code}] ${ex.localizedMessage}", ex) + } + + when (type.qualifiedName) { + DocumentChangeEvent::class.qualifiedName -> { + val payload: DocumentChangeEvent = messageParser.getPayload( + body = rawMessage.toByteArray(), + kClass = DocumentChangeEvent::class + ) + + if (payload.documentId.startsWith("NotificationConfig:")) { + logger.trace(payload.toString()) + + syncNotificationConfigUseCase.run( + request = SyncNotificationConfigRequest( + notificationConfigDatabase = "app", // todo: configurable + notificationConfigId = payload.documentId, + notificationConfigRev = payload.rev, + ) + ) + + return + } + + applyNotificationRulesUseCase.run( + request = ApplyNotificationRulesRequest( + documentChangeEvent = payload + ) + ) + + return + } + + else -> { + logger.warn( + "[DefaultNotificationDocumentChangeConsumer] Could not find any use case for this EventType: {}", + type.qualifiedName, + ) + throw AmqpRejectAndDontRequeueException( + "[NO_USECASE_CONFIGURED] Could not find matching use case for: ${type.qualifiedName}", + ) + } + } + } +} diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/core/DefaultUserNotificationConsumer.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/core/DefaultUserNotificationConsumer.kt new file mode 100644 index 0000000..e644831 --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/core/DefaultUserNotificationConsumer.kt @@ -0,0 +1,58 @@ +package com.aamdigital.aambackendservice.notification.core + +import com.aamdigital.aambackendservice.error.AamException +import com.aamdigital.aambackendservice.notification.core.event.CreateUserNotificationEvent +import com.aamdigital.aambackendservice.notification.di.NotificationQueueConfiguration.Companion.USER_NOTIFICATION_QUEUE +import com.aamdigital.aambackendservice.queue.core.QueueMessageParser +import com.rabbitmq.client.Channel +import org.slf4j.LoggerFactory +import org.springframework.amqp.AmqpRejectAndDontRequeueException +import org.springframework.amqp.core.Message +import org.springframework.amqp.rabbit.annotation.RabbitListener + +class DefaultUserNotificationConsumer( + private val messageParser: QueueMessageParser, + private val createNotificationUseCase: CreateNotificationUseCase, +) : UserNotificationConsumer { + private val logger = LoggerFactory.getLogger(javaClass) + + @RabbitListener( + queues = [USER_NOTIFICATION_QUEUE], + // avoid concurrent processing so that we do not trigger multiple calculations for same data unnecessarily + concurrency = "1-1", + ) + override fun consume(rawMessage: String, message: Message, channel: Channel) { + val type = try { + messageParser.getTypeKClass(rawMessage.toByteArray()) + } catch (ex: AamException) { + throw AmqpRejectAndDontRequeueException("[${ex.code}] ${ex.localizedMessage}", ex) + } + + when (type.qualifiedName) { + CreateUserNotificationEvent::class.qualifiedName -> { + val payload: CreateUserNotificationEvent = messageParser.getPayload( + body = rawMessage.toByteArray(), + kClass = CreateUserNotificationEvent::class + ) + + createNotificationUseCase.run( + request = CreateNotificationRequest( + createUserNotificationEvent = payload + ) + ) + + return + } + + else -> { + logger.warn( + "[DefaultUserNotificationConsumer] Could not find any use case for this EventType: {}", + type.qualifiedName, + ) + throw AmqpRejectAndDontRequeueException( + "[NO_USECASE_CONFIGURED] Could not find matching use case for: ${type.qualifiedName}", + ) + } + } + } +} diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/core/DefaultUserNotificationPublisher.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/core/DefaultUserNotificationPublisher.kt new file mode 100644 index 0000000..a77b35f --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/core/DefaultUserNotificationPublisher.kt @@ -0,0 +1,58 @@ +package com.aamdigital.aambackendservice.notification.core + +import com.aamdigital.aambackendservice.error.AamErrorCode +import com.aamdigital.aambackendservice.error.InternalServerException +import com.aamdigital.aambackendservice.notification.core.event.CreateUserNotificationEvent +import com.aamdigital.aambackendservice.queue.core.QueueMessage +import com.fasterxml.jackson.databind.ObjectMapper +import org.slf4j.LoggerFactory +import org.springframework.amqp.AmqpException +import org.springframework.amqp.rabbit.core.RabbitTemplate +import java.time.Instant +import java.time.ZoneOffset +import java.time.format.DateTimeFormatter +import java.util.* + +class DefaultUserNotificationPublisher( + private val objectMapper: ObjectMapper, + private val rabbitTemplate: RabbitTemplate, +) : UserNotificationPublisher { + + enum class DefaultNotificationPublisherErrorCode : AamErrorCode { + EVENT_PUBLISH_ERROR + } + + private val logger = LoggerFactory.getLogger(javaClass) + + override fun publish(channel: String, event: CreateUserNotificationEvent): QueueMessage { + val message = QueueMessage( + id = UUID.randomUUID(), + eventType = CreateUserNotificationEvent::class.java.canonicalName, + event = event, + createdAt = Instant.now() + .atOffset(ZoneOffset.UTC) + .format(DateTimeFormatter.ISO_LOCAL_DATE_TIME) + ) + + try { + rabbitTemplate.convertAndSend( + channel, + objectMapper.writeValueAsString(message) + ) + } catch (ex: AmqpException) { + throw InternalServerException( + message = "Could not publish SendNotificationEvent: $event", + code = DefaultNotificationPublisherErrorCode.EVENT_PUBLISH_ERROR, + cause = ex + ) + } + + logger.trace( + "[DefaultNotificationPublisher]: publish message to channel '{}' Payload: {}", + channel, + objectMapper.writeValueAsString(message) + ) + + return message + } +} diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/core/NotificationDocumentChangeConsumer.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/core/NotificationDocumentChangeConsumer.kt new file mode 100644 index 0000000..615638e --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/core/NotificationDocumentChangeConsumer.kt @@ -0,0 +1,8 @@ +package com.aamdigital.aambackendservice.notification.core + +import com.rabbitmq.client.Channel +import org.springframework.amqp.core.Message + +interface NotificationDocumentChangeConsumer { + fun consume(rawMessage: String, message: Message, channel: Channel) +} diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/core/PushCreateNotificationHandler.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/core/PushCreateNotificationHandler.kt new file mode 100644 index 0000000..97a1503 --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/core/PushCreateNotificationHandler.kt @@ -0,0 +1,82 @@ +package com.aamdigital.aambackendservice.notification.core + +import com.aamdigital.aambackendservice.notification.core.event.CreateUserNotificationEvent +import com.aamdigital.aambackendservice.notification.domain.NotificationChannelType +import com.aamdigital.aambackendservice.notification.repositiory.NotificationConfigRepository +import com.aamdigital.aambackendservice.notification.repositiory.UserDeviceRepository +import com.google.firebase.messaging.FirebaseMessaging +import com.google.firebase.messaging.MulticastMessage +import com.google.firebase.messaging.Notification +import org.slf4j.LoggerFactory +import org.springframework.data.domain.Pageable +import kotlin.jvm.optionals.getOrNull + +class PushCreateNotificationHandler( + private val firebaseMessaging: FirebaseMessaging, + private val userDeviceRepository: UserDeviceRepository, + private val notificationConfigRepository: NotificationConfigRepository, +) : CreateNotificationHandler { + + private val logger = LoggerFactory.getLogger(javaClass) + + override fun canHandle(notificationChannelType: NotificationChannelType): Boolean = + NotificationChannelType.PUSH == notificationChannelType + + override fun createMessage(createUserNotificationEvent: CreateUserNotificationEvent): CreateNotificationData { + val userDevices = userDeviceRepository.findByUserIdentifier( + createUserNotificationEvent.userIdentifier, Pageable.unpaged() + ).map { + it.deviceToken + }.toList() + + if (userDevices.isEmpty()) { + return CreateNotificationData( + success = true, + messageCreated = false, + messageReference = null + ) + } + + val notificationConfig = notificationConfigRepository.findByUserIdentifier( + userIdentifier = createUserNotificationEvent.userIdentifier + ).getOrNull() + + if (notificationConfig == null) { + return CreateNotificationData( + success = true, + messageCreated = false, + messageReference = null + ) + } + + val notificationRule = notificationConfig.notificationRules.find { + it.externalIdentifier == createUserNotificationEvent.notificationRule + } + + if (notificationRule == null) { + return CreateNotificationData( + success = true, + messageCreated = false, + messageReference = null + ) + } + + val message = MulticastMessage.builder().addAllTokens(userDevices) + .setNotification( + Notification.builder() + .setTitle("Update from Aam Digital") + .setBody(notificationRule.label) + .build() + ).build() + + val response = firebaseMessaging.sendEachForMulticast(message) + + val ids = response.responses.map { it.messageId }.toList() + + logger.trace("push notification send {}", ids.toString()) + + return CreateNotificationData( + success = true, messageCreated = true, messageReference = ids.toString() + ) + } +} diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/core/SyncNotificationConfigUseCase.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/core/SyncNotificationConfigUseCase.kt new file mode 100644 index 0000000..8f41872 --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/core/SyncNotificationConfigUseCase.kt @@ -0,0 +1,22 @@ +package com.aamdigital.aambackendservice.notification.core + +import com.aamdigital.aambackendservice.domain.DomainUseCase +import com.aamdigital.aambackendservice.domain.UseCaseData +import com.aamdigital.aambackendservice.domain.UseCaseRequest + +data class SyncNotificationConfigRequest( + val notificationConfigDatabase: String, + val notificationConfigId: String, + val notificationConfigRev: String, +) : UseCaseRequest + +data class SyncNotificationConfigData( + val imported: Boolean, + val updated: Boolean, + val skipped: Boolean, + val deleted: Boolean, + val message: String, +) : UseCaseData + +abstract class SyncNotificationConfigUseCase : + DomainUseCase() diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/core/UserNotificationConsumer.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/core/UserNotificationConsumer.kt new file mode 100644 index 0000000..218bc0b --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/core/UserNotificationConsumer.kt @@ -0,0 +1,8 @@ +package com.aamdigital.aambackendservice.notification.core + +import com.rabbitmq.client.Channel +import org.springframework.amqp.core.Message + +interface UserNotificationConsumer { + fun consume(rawMessage: String, message: Message, channel: Channel) +} diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/core/UserNotificationPublisher.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/core/UserNotificationPublisher.kt new file mode 100644 index 0000000..84b174d --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/core/UserNotificationPublisher.kt @@ -0,0 +1,8 @@ +package com.aamdigital.aambackendservice.notification.core + +import com.aamdigital.aambackendservice.notification.core.event.CreateUserNotificationEvent +import com.aamdigital.aambackendservice.queue.core.QueueMessage + +interface UserNotificationPublisher { + fun publish(channel: String, event: CreateUserNotificationEvent): QueueMessage +} diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/core/event/CreateUserNotificationEvent.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/core/event/CreateUserNotificationEvent.kt new file mode 100644 index 0000000..db72712 --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/core/event/CreateUserNotificationEvent.kt @@ -0,0 +1,10 @@ +package com.aamdigital.aambackendservice.notification.core.event + +import com.aamdigital.aambackendservice.events.DomainEvent +import com.aamdigital.aambackendservice.notification.domain.NotificationChannelType + +data class CreateUserNotificationEvent( + val userIdentifier: String, + val notificationChannelType: NotificationChannelType, + val notificationRule: String, +) : DomainEvent() diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/di/FirebaseConfiguration.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/di/FirebaseConfiguration.kt index 2977a42..13c4593 100644 --- a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/di/FirebaseConfiguration.kt +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/di/FirebaseConfiguration.kt @@ -10,7 +10,6 @@ import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration import java.util.* - @ConfigurationProperties("notification-firebase-configuration") class NotificationFirebaseClientConfiguration( val credentialFileBase64: String, diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/di/NotificationConfiguration.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/di/NotificationConfiguration.kt new file mode 100644 index 0000000..09b6995 --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/di/NotificationConfiguration.kt @@ -0,0 +1,67 @@ +package com.aamdigital.aambackendservice.notification.di + +import com.aamdigital.aambackendservice.couchdb.core.CouchDbClient +import com.aamdigital.aambackendservice.notification.core.AppCreateNotificationHandler +import com.aamdigital.aambackendservice.notification.core.ApplyNotificationRulesUseCase +import com.aamdigital.aambackendservice.notification.core.CouchDbSyncNotificationConfigUseCase +import com.aamdigital.aambackendservice.notification.core.CreateNotificationHandler +import com.aamdigital.aambackendservice.notification.core.CreateNotificationUseCase +import com.aamdigital.aambackendservice.notification.core.DefaultApplyNotificationRulesUseCase +import com.aamdigital.aambackendservice.notification.core.DefaultCreateNotificationUseCase +import com.aamdigital.aambackendservice.notification.core.PushCreateNotificationHandler +import com.aamdigital.aambackendservice.notification.core.SyncNotificationConfigUseCase +import com.aamdigital.aambackendservice.notification.core.UserNotificationPublisher +import com.aamdigital.aambackendservice.notification.repositiory.NotificationConfigRepository +import com.aamdigital.aambackendservice.notification.repositiory.UserDeviceRepository +import com.google.firebase.messaging.FirebaseMessaging +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +@Configuration +class NotificationConfiguration { + + @Bean + fun defaultSyncNotificationConfigUseCase( + couchDbClient: CouchDbClient, + notificationConfigRepository: NotificationConfigRepository, + ): SyncNotificationConfigUseCase = CouchDbSyncNotificationConfigUseCase( + couchDbClient = couchDbClient, + notificationConfigRepository = notificationConfigRepository, + ) + + @Bean + fun defaultApplyNotificationRulesUseCase( + notificationConfigRepository: NotificationConfigRepository, + userNotificationPublisher: UserNotificationPublisher, + ): ApplyNotificationRulesUseCase = DefaultApplyNotificationRulesUseCase( + notificationConfigRepository = notificationConfigRepository, + userNotificationPublisher = userNotificationPublisher, + ) + + @Bean + fun defaultCreateNotificationUseCase( + createNotificationHandler: List + ): CreateNotificationUseCase = DefaultCreateNotificationUseCase( + createNotificationHandler = createNotificationHandler, + ) + + @Bean("push-create-notification-handler") + fun pushCreateNotificationHandler( + firebaseMessaging: FirebaseMessaging, + userDeviceRepository: UserDeviceRepository, + notificationConfigRepository: NotificationConfigRepository, + ): CreateNotificationHandler = PushCreateNotificationHandler( + firebaseMessaging = firebaseMessaging, + userDeviceRepository = userDeviceRepository, + notificationConfigRepository = notificationConfigRepository, + ) + + @Bean("app-create-notification-handler") + fun appCreateNotificationHandler( + couchDbClient: CouchDbClient, + notificationConfigRepository: NotificationConfigRepository, + ): CreateNotificationHandler = AppCreateNotificationHandler( + couchDbClient = couchDbClient, + notificationConfigRepository = notificationConfigRepository + ) +} diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/di/NotificationQueueConfiguration.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/di/NotificationQueueConfiguration.kt new file mode 100644 index 0000000..8ed5a61 --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/di/NotificationQueueConfiguration.kt @@ -0,0 +1,72 @@ +package com.aamdigital.aambackendservice.notification.di + +import com.aamdigital.aambackendservice.notification.core.ApplyNotificationRulesUseCase +import com.aamdigital.aambackendservice.notification.core.CreateNotificationUseCase +import com.aamdigital.aambackendservice.notification.core.DefaultNotificationDocumentChangeConsumer +import com.aamdigital.aambackendservice.notification.core.DefaultUserNotificationConsumer +import com.aamdigital.aambackendservice.notification.core.DefaultUserNotificationPublisher +import com.aamdigital.aambackendservice.notification.core.NotificationDocumentChangeConsumer +import com.aamdigital.aambackendservice.notification.core.SyncNotificationConfigUseCase +import com.aamdigital.aambackendservice.notification.core.UserNotificationConsumer +import com.aamdigital.aambackendservice.notification.core.UserNotificationPublisher +import com.aamdigital.aambackendservice.queue.core.QueueMessageParser +import com.fasterxml.jackson.databind.ObjectMapper +import org.springframework.amqp.core.Binding +import org.springframework.amqp.core.BindingBuilder +import org.springframework.amqp.core.FanoutExchange +import org.springframework.amqp.core.Queue +import org.springframework.amqp.core.QueueBuilder +import org.springframework.amqp.rabbit.core.RabbitTemplate +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +@Configuration +class NotificationQueueConfiguration { + + companion object { + const val DOCUMENT_CHANGES_NOTIFICATION_QUEUE = "document.changes.notification" + const val USER_NOTIFICATION_QUEUE = "notification.user" + } + + @Bean("notification-document-changes-queue") + fun notificationDocumentChangesQueue(): Queue = QueueBuilder.durable(DOCUMENT_CHANGES_NOTIFICATION_QUEUE).build() + + @Bean("notification-user-notification-queue") + fun notificationUserNotificationQueue(): Queue = QueueBuilder.durable(USER_NOTIFICATION_QUEUE).build() + + @Bean("notification-document-changes-exchange") + fun notificationDocumentChangesBinding( + @Qualifier("notification-document-changes-queue") queue: Queue, + @Qualifier("document-changes-exchange") exchange: FanoutExchange, + ): Binding = BindingBuilder.bind(queue).to(exchange) + + @Bean("notification-document-changes-consumer") + fun notificationDocumentChangeEventConsumer( + messageParser: QueueMessageParser, + syncNotificationConfigUseCase: SyncNotificationConfigUseCase, + applyNotificationRulesUseCase: ApplyNotificationRulesUseCase, + ): NotificationDocumentChangeConsumer = DefaultNotificationDocumentChangeConsumer( + messageParser, + syncNotificationConfigUseCase, + applyNotificationRulesUseCase, + ) + + @Bean("notification-user-notification-consumer") + fun notificationUserNotificationEventConsumer( + messageParser: QueueMessageParser, + createUserUseCase: CreateNotificationUseCase, + ): UserNotificationConsumer = DefaultUserNotificationConsumer( + messageParser, + createUserUseCase, + ) + + @Bean("notification-user-device-publisher") + fun notificationUserDevicePublisher( + objectMapper: ObjectMapper, + rabbitTemplate: RabbitTemplate, + ): UserNotificationPublisher = DefaultUserNotificationPublisher( + objectMapper = objectMapper, + rabbitTemplate = rabbitTemplate, + ) +} diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/domain/NotificationChannelType.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/domain/NotificationChannelType.kt new file mode 100644 index 0000000..f76ffdb --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/domain/NotificationChannelType.kt @@ -0,0 +1,12 @@ +package com.aamdigital.aambackendservice.notification.domain + +import com.fasterxml.jackson.annotation.JsonEnumDefaultValue + +enum class NotificationChannelType { + APP, + PUSH, + EMAIL, + + @JsonEnumDefaultValue + UNKNOWN, +} diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/domain/NotificationType.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/domain/NotificationType.kt new file mode 100644 index 0000000..0d11e9c --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/domain/NotificationType.kt @@ -0,0 +1,12 @@ +package com.aamdigital.aambackendservice.notification.domain + +import com.fasterxml.jackson.annotation.JsonEnumDefaultValue +import com.fasterxml.jackson.annotation.JsonProperty + +enum class NotificationType { + @JsonProperty("entity_change") + ENTITY_CHANGE, + + @JsonEnumDefaultValue + UNKNOWN, +} diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/repositiory/NotificationConditionEntity.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/repositiory/NotificationConditionEntity.kt new file mode 100644 index 0000000..2d5ba9b --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/repositiory/NotificationConditionEntity.kt @@ -0,0 +1,16 @@ +package com.aamdigital.aambackendservice.notification.repositiory + +import jakarta.persistence.Column +import jakarta.persistence.Embeddable + +@Embeddable +data class NotificationConditionEntity( + @Column + var field: String, + + @Column + var operator: String, + + @Column + var value: String, +) diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/repositiory/NotificationConfigEntity.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/repositiory/NotificationConfigEntity.kt new file mode 100644 index 0000000..a5a5e73 --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/repositiory/NotificationConfigEntity.kt @@ -0,0 +1,43 @@ +package com.aamdigital.aambackendservice.notification.repositiory + +import jakarta.persistence.CascadeType +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.FetchType +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import jakarta.persistence.OneToMany +import org.hibernate.annotations.CreationTimestamp +import org.hibernate.annotations.SourceType +import java.time.OffsetDateTime + +@Entity +data class NotificationConfigEntity( + @Id + @GeneratedValue(strategy = GenerationType.SEQUENCE) + val id: Long = 0, + + @Column + var channelPush: Boolean, + + @Column + var channelEmail: Boolean, + + @Column + var revision: String, + + @Column(unique = true) + var userIdentifier: String, + + @OneToMany(fetch = FetchType.EAGER, cascade = [CascadeType.ALL], orphanRemoval = true) + var notificationRules: List, + + @CreationTimestamp(source = SourceType.DB) + @Column + var createdAt: OffsetDateTime? = null, + + @CreationTimestamp(source = SourceType.DB) + @Column + var updatedAt: OffsetDateTime? = null, +) diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/repositiory/NotificationConfigRepository.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/repositiory/NotificationConfigRepository.kt new file mode 100644 index 0000000..d71e90f --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/repositiory/NotificationConfigRepository.kt @@ -0,0 +1,8 @@ +package com.aamdigital.aambackendservice.notification.repositiory + +import org.springframework.data.repository.CrudRepository +import java.util.* + +interface NotificationConfigRepository : CrudRepository { + fun findByUserIdentifier(userIdentifier: String): Optional +} diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/repositiory/NotificationRuleEntity.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/repositiory/NotificationRuleEntity.kt new file mode 100644 index 0000000..d108e58 --- /dev/null +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/notification/repositiory/NotificationRuleEntity.kt @@ -0,0 +1,41 @@ +package com.aamdigital.aambackendservice.notification.repositiory + +import com.aamdigital.aambackendservice.notification.domain.NotificationType +import jakarta.persistence.Column +import jakarta.persistence.ElementCollection +import jakarta.persistence.Entity +import jakarta.persistence.EnumType +import jakarta.persistence.Enumerated +import jakarta.persistence.FetchType +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id + +@Entity +data class NotificationRuleEntity( + @Id + @GeneratedValue(strategy = GenerationType.SEQUENCE) + val id: Long = 0, + + @Column + var label: String, + + @Column(unique = true) + var externalIdentifier: String, + + @Column + @Enumerated(EnumType.STRING) + var notificationType: NotificationType, + + @Column + var entityType: String, + + @Column + var changeType: String, + + @ElementCollection(fetch = FetchType.EAGER) + var conditions: List, + + @Column + var enabled: Boolean, +) diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/core/CreateDocumentChangeUseCase.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/core/CreateDocumentChangeUseCase.kt deleted file mode 100644 index fc26cdc..0000000 --- a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/changes/core/CreateDocumentChangeUseCase.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.aamdigital.aambackendservice.reporting.changes.core - -import com.aamdigital.aambackendservice.reporting.domain.event.DatabaseChangeEvent - -interface CreateDocumentChangeUseCase { - fun createEvent(event: DatabaseChangeEvent) -} diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/core/DefaultNotificationEventConsumer.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/core/DefaultNotificationEventConsumer.kt index 97331e3..0f2bf86 100644 --- a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/core/DefaultNotificationEventConsumer.kt +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/core/DefaultNotificationEventConsumer.kt @@ -3,7 +3,7 @@ package com.aamdigital.aambackendservice.reporting.notification.core import com.aamdigital.aambackendservice.error.AamException import com.aamdigital.aambackendservice.queue.core.QueueMessageParser import com.aamdigital.aambackendservice.reporting.domain.event.NotificationEvent -import com.aamdigital.aambackendservice.reporting.notification.di.NotificationQueueConfiguration.Companion.NOTIFICATION_QUEUE +import com.aamdigital.aambackendservice.reporting.notification.di.ReportingNotificationQueueConfiguration.Companion.NOTIFICATION_QUEUE import com.rabbitmq.client.Channel import org.slf4j.LoggerFactory import org.springframework.amqp.AmqpRejectAndDontRequeueException diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/core/NotificationService.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/core/NotificationService.kt index 4f75b8e..895c752 100644 --- a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/core/NotificationService.kt +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/core/NotificationService.kt @@ -2,7 +2,7 @@ package com.aamdigital.aambackendservice.reporting.notification.core import com.aamdigital.aambackendservice.domain.DomainReference import com.aamdigital.aambackendservice.reporting.domain.event.NotificationEvent -import com.aamdigital.aambackendservice.reporting.notification.di.NotificationQueueConfiguration +import com.aamdigital.aambackendservice.reporting.notification.di.ReportingNotificationQueueConfiguration import org.slf4j.LoggerFactory import org.springframework.stereotype.Service @@ -29,7 +29,7 @@ class NotificationService( fun sendNotifications(report: DomainReference, reportCalculation: DomainReference) { logger.debug("[NotificationService]: Trigger all affected webhooks for ${report.id}") val affectedWebhooks = getAffectedWebhooks(report) - + affectedWebhooks.map { webhook -> triggerWebhook( report = report, @@ -42,7 +42,7 @@ class NotificationService( fun triggerWebhook(report: DomainReference, reportCalculation: DomainReference, webhook: DomainReference) { logger.debug("[NotificationService]: Trigger NotificationEvent for ${webhook.id} and ${report.id}") notificationEventPublisher.publish( - NotificationQueueConfiguration.NOTIFICATION_QUEUE, + ReportingNotificationQueueConfiguration.NOTIFICATION_QUEUE, NotificationEvent( webhookId = webhook.id, reportId = report.id, diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/di/NotificationConfiguration.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/di/ReportingNotificationConfiguration.kt similarity index 98% rename from application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/di/NotificationConfiguration.kt rename to application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/di/ReportingNotificationConfiguration.kt index 76ef9b8..a5ccd46 100644 --- a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/di/NotificationConfiguration.kt +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/di/ReportingNotificationConfiguration.kt @@ -20,7 +20,7 @@ import org.springframework.context.annotation.Configuration import org.springframework.web.client.RestClient @Configuration -class NotificationConfiguration { +class ReportingNotificationConfiguration { @Bean fun defaultAddWebhookSubscription( diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/di/NotificationQueueConfiguration.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/di/ReportingNotificationQueueConfiguration.kt similarity index 98% rename from application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/di/NotificationQueueConfiguration.kt rename to application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/di/ReportingNotificationQueueConfiguration.kt index 306f9e7..e10fe54 100644 --- a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/di/NotificationQueueConfiguration.kt +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/notification/di/ReportingNotificationQueueConfiguration.kt @@ -18,7 +18,7 @@ import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration @Configuration -class NotificationQueueConfiguration { +class ReportingNotificationQueueConfiguration { companion object { const val NOTIFICATION_QUEUE = "notification.webhook" diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/core/DefaultReportDocumentChangeEventConsumer.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/core/DefaultReportDocumentChangeEventConsumer.kt index e6110a0..39b41ed 100644 --- a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/core/DefaultReportDocumentChangeEventConsumer.kt +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/core/DefaultReportDocumentChangeEventConsumer.kt @@ -1,9 +1,9 @@ package com.aamdigital.aambackendservice.reporting.report.core +import com.aamdigital.aambackendservice.changes.domain.DocumentChangeEvent import com.aamdigital.aambackendservice.domain.DomainReference import com.aamdigital.aambackendservice.error.AamException import com.aamdigital.aambackendservice.queue.core.QueueMessageParser -import com.aamdigital.aambackendservice.reporting.domain.event.DocumentChangeEvent import com.aamdigital.aambackendservice.reporting.report.di.ReportQueueConfiguration import com.aamdigital.aambackendservice.reporting.reportcalculation.core.CreateReportCalculationRequest import com.aamdigital.aambackendservice.reporting.reportcalculation.core.CreateReportCalculationUseCase diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/core/IdentifyAffectedReportsUseCase.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/core/IdentifyAffectedReportsUseCase.kt index 133d48c..e41b0c0 100644 --- a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/core/IdentifyAffectedReportsUseCase.kt +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/core/IdentifyAffectedReportsUseCase.kt @@ -1,7 +1,7 @@ package com.aamdigital.aambackendservice.reporting.report.core +import com.aamdigital.aambackendservice.changes.domain.DocumentChangeEvent import com.aamdigital.aambackendservice.domain.DomainReference -import com.aamdigital.aambackendservice.reporting.domain.event.DocumentChangeEvent interface IdentifyAffectedReportsUseCase { fun analyse(documentChangeEvent: DocumentChangeEvent): List diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/di/ReportQueueConfiguration.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/di/ReportQueueConfiguration.kt index 4e65ea7..016536f 100644 --- a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/di/ReportQueueConfiguration.kt +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/di/ReportQueueConfiguration.kt @@ -27,13 +27,13 @@ class ReportQueueConfiguration { .durable(DOCUMENT_CHANGES_REPORT_QUEUE) .build() - @Bean - fun documentChangesBinding( + @Bean("report-document-changes-exchange") + fun reportDocumentChangesBinding( @Qualifier("report-config-changes-queue") queue: Queue, @Qualifier("document-changes-exchange") exchange: FanoutExchange, ): Binding = BindingBuilder.bind(queue).to(exchange) - @Bean + @Bean("report-document-changes-consumer") fun reportDocumentChangeEventConsumer( messageParser: QueueMessageParser, createReportCalculationUseCase: CreateReportCalculationUseCase, diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/usecase/DefaultIdentifyAffectedReportsUseCase.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/usecase/DefaultIdentifyAffectedReportsUseCase.kt index 57fc97c..7b46d17 100644 --- a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/usecase/DefaultIdentifyAffectedReportsUseCase.kt +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/report/usecase/DefaultIdentifyAffectedReportsUseCase.kt @@ -1,7 +1,7 @@ package com.aamdigital.aambackendservice.reporting.report.usecase +import com.aamdigital.aambackendservice.changes.domain.DocumentChangeEvent import com.aamdigital.aambackendservice.domain.DomainReference -import com.aamdigital.aambackendservice.reporting.domain.event.DocumentChangeEvent import com.aamdigital.aambackendservice.reporting.report.core.IdentifyAffectedReportsUseCase import com.aamdigital.aambackendservice.reporting.report.core.ReportQueryAnalyser import com.aamdigital.aambackendservice.reporting.report.core.ReportStorage diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/reportcalculation/core/ReportCalculationChangeUseCase.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/reportcalculation/core/ReportCalculationChangeUseCase.kt index 47f6e22..2f83472 100644 --- a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/reportcalculation/core/ReportCalculationChangeUseCase.kt +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/reportcalculation/core/ReportCalculationChangeUseCase.kt @@ -1,6 +1,6 @@ package com.aamdigital.aambackendservice.reporting.reportcalculation.core -import com.aamdigital.aambackendservice.reporting.domain.event.DocumentChangeEvent +import com.aamdigital.aambackendservice.changes.domain.DocumentChangeEvent interface ReportCalculationChangeUseCase { fun handle(documentChangeEvent: DocumentChangeEvent) diff --git a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/reportcalculation/usecase/DefaultReportCalculationChangeUseCase.kt b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/reportcalculation/usecase/DefaultReportCalculationChangeUseCase.kt index 21b8b7f..e88d32a 100644 --- a/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/reportcalculation/usecase/DefaultReportCalculationChangeUseCase.kt +++ b/application/aam-backend-service/src/main/kotlin/com/aamdigital/aambackendservice/reporting/reportcalculation/usecase/DefaultReportCalculationChangeUseCase.kt @@ -1,8 +1,8 @@ package com.aamdigital.aambackendservice.reporting.reportcalculation.usecase +import com.aamdigital.aambackendservice.changes.domain.DocumentChangeEvent import com.aamdigital.aambackendservice.domain.DomainReference import com.aamdigital.aambackendservice.reporting.domain.ReportCalculationStatus -import com.aamdigital.aambackendservice.reporting.domain.event.DocumentChangeEvent import com.aamdigital.aambackendservice.reporting.notification.core.NotificationService import com.aamdigital.aambackendservice.reporting.reportcalculation.core.ReportCalculationChangeUseCase import com.aamdigital.aambackendservice.reporting.reportcalculation.core.ReportCalculationStorage diff --git a/application/aam-backend-service/src/main/resources/application.yaml b/application/aam-backend-service/src/main/resources/application.yaml index 1bfef64..b15e60a 100644 --- a/application/aam-backend-service/src/main/resources/application.yaml +++ b/application/aam-backend-service/src/main/resources/application.yaml @@ -74,6 +74,12 @@ spring: resourceserver: jwt: issuer-uri: https://aam.localhost/auth/realms/dummy-realm + ssl: + bundle: + pem: + local-development: + truststore: + certificate: "classpath:reverse-proxy.crt" rabbitmq: # virtual-host: local listener: @@ -128,7 +134,7 @@ sqs-client-configuration: skilllab-api-client-configuration: api-key: skilllab-api-key project-id: dummy-project - base-path: htts://aam.localhost/skilllab + base-path: https://aam.localhost/skilllab response-timeout-in-seconds: 15 features: