├── src ├── main │ ├── resources │ │ ├── db │ │ │ └── migration │ │ │ │ ├── V0.2.34__sessions_add_cost_column.sql │ │ │ │ ├── V0.1.10.00__sessions_add_processed_at_column.sql │ │ │ │ ├── V0.1.17.00__sessions_add_dose1_dose2_column.sql │ │ │ │ ├── V0.2.7.00__add_notification_cache.sql │ │ │ │ ├── V0.1.0.10__states.sql │ │ │ │ └── V0.1.0.00__covid19_schema.sql │ │ └── application.yaml │ └── java │ │ └── org │ │ └── covid19 │ │ └── vaccinetracker │ │ ├── userrequests │ │ ├── model │ │ │ ├── UserRequest.java │ │ │ ├── State.java │ │ │ ├── District.java │ │ │ ├── Pincode.java │ │ │ ├── Age.java │ │ │ ├── Dose.java │ │ │ ├── Vaccine.java │ │ │ ├── UserRequestSerde.java │ │ │ └── DistrictSerde.java │ │ ├── MetadataStore.java │ │ ├── UserRequestProducerConfig.java │ │ └── reconciliation │ │ │ ├── ReconciliationStats.java │ │ │ └── PincodeReconciliation.java │ │ ├── notifications │ │ ├── bot │ │ │ ├── BotService.java │ │ │ ├── TelegramConfig.java │ │ │ ├── BotBackend.java │ │ │ └── BotUtils.java │ │ ├── absentalerts │ │ │ ├── AbsentAlertSource.java │ │ │ ├── AbsentAlertCause.java │ │ │ ├── AbsentAlertNotifications.java │ │ │ └── AbsentAlertAnalyzer.java │ │ ├── DistrictNotifications.java │ │ ├── NotificationStats.java │ │ ├── NotificationCache.java │ │ ├── TelegramLambdaWrapper.java │ │ ├── VaccineCentersProcessor.java │ │ └── KafkaNotifications.java │ │ ├── availability │ │ ├── cowin │ │ │ ├── CowinException.java │ │ │ ├── CowinConfig.java │ │ │ ├── WebClientFilter.java │ │ │ ├── CowinApiOtpClient.java │ │ │ ├── CowinApiAuth.java │ │ │ └── CowinApiClient.java │ │ ├── AvailabilityConfig.java │ │ ├── model │ │ │ ├── ConfirmOtpResponse.java │ │ │ └── GenerateOtpResponse.java │ │ ├── aws │ │ │ └── AWSConfig.java │ │ ├── UpdatedPincodesProducerConfig.java │ │ ├── PriorityDistrictsAvailability.java │ │ ├── AvailabilityStats.java │ │ └── VaccineAvailability.java │ │ ├── model │ │ ├── UsersByPincode.java │ │ ├── VaccineCenters.java │ │ ├── VaccineFee.java │ │ ├── CenterSession.java │ │ ├── UsersByPincodeSerde.java │ │ ├── VaccineCentersSerde.java │ │ ├── Center.java │ │ └── Session.java │ │ ├── Covid19VaccineTrackerApplication.java │ │ └── persistence │ │ ├── mariadb │ │ ├── repository │ │ │ ├── UserNotificationRepository.java │ │ │ ├── PincodeRepository.java │ │ │ ├── StateRepository.java │ │ │ ├── CenterRepository.java │ │ │ ├── DistrictRepository.java │ │ │ └── SessionRepository.java │ │ ├── entity │ │ │ ├── UserNotificationId.java │ │ │ ├── CoreEntity.java │ │ │ ├── UserNotification.java │ │ │ ├── CenterEntity.java │ │ │ └── SessionEntity.java │ │ └── DBController.java │ │ ├── VaccinePersistence.java │ │ └── kafka │ │ ├── KafkaConfig.java │ │ ├── DeduplicationTransformer.java │ │ ├── KafkaStateStores.java │ │ └── UsersByPincodeTransformer.java └── test │ ├── java │ └── org │ │ └── covid19 │ │ └── vaccinetracker │ │ ├── Covid19VaccineTrackerApplicationTests.java │ │ ├── notifications │ │ ├── bot │ │ │ └── TestUtils.java │ │ ├── absentalerts │ │ │ ├── AbsentAlertsNotificationsIT.java │ │ │ └── AbsentAlertsAnalyzerTest.java │ │ └── NotificationCacheTest.java │ │ ├── userrequests │ │ └── MetadataStoreTest.java │ │ ├── availability │ │ ├── VaccineAvailabilityTest.java │ │ ├── cowin │ │ │ ├── CowinApiOtpClientTest.java │ │ │ ├── CowinApiClientTest.java │ │ │ └── CowinApiAuthTest.java │ │ └── aws │ │ │ └── CowinLambdaWrapperTest.java │ │ ├── persistence │ │ └── mariadb │ │ │ └── MariaDBVaccinePersistenceTest.java │ │ └── utils │ │ └── UtilsTest.java │ └── resources │ ├── application.yaml │ └── import.sql ├── .gitignore ├── .github └── workflows │ └── maven.yml ├── LICENSE ├── HELP.md ├── README.md └── .mvn └── settings.xml /src/main/resources/db/migration/V0.2.34__sessions_add_cost_column.sql: -------------------------------------------------------------------------------- 1 | SET 2 | SQL_MODE = "NO_AUTO_VALUE_ON_ZERO"; 3 | START TRANSACTION; 4 | SET 5 | time_zone = "+00:00"; 6 | 7 | ALTER TABLE `sessions` 8 | ADD COLUMN IF NOT EXISTS `cost` VARCHAR(16) COLLATE utf8mb4_unicode_ci DEFAULT NULL AFTER `vaccine` 9 | -------------------------------------------------------------------------------- /src/main/resources/db/migration/V0.1.10.00__sessions_add_processed_at_column.sql: -------------------------------------------------------------------------------- 1 | SET 2 | SQL_MODE = "NO_AUTO_VALUE_ON_ZERO"; 3 | START TRANSACTION; 4 | SET 5 | time_zone = "+00:00"; 6 | 7 | ALTER TABLE `sessions` 8 | ADD COLUMN IF NOT EXISTS `processed_at` DATETIME 9 | COLLATE utf8mb4_unicode_ci DEFAULT NULL; 10 | -------------------------------------------------------------------------------- /src/main/java/org/covid19/vaccinetracker/userrequests/model/UserRequest.java: -------------------------------------------------------------------------------- 1 | package org.covid19.vaccinetracker.userrequests.model; 2 | 3 | import java.util.List; 4 | 5 | import lombok.Value; 6 | 7 | @Value 8 | public class UserRequest { 9 | String chatId; 10 | List pincodes; 11 | List districts; 12 | String age; 13 | String dose; 14 | String vaccine; 15 | String lastNotifiedAt; 16 | } 17 | -------------------------------------------------------------------------------- /src/main/resources/db/migration/V0.1.17.00__sessions_add_dose1_dose2_column.sql: -------------------------------------------------------------------------------- 1 | SET 2 | SQL_MODE = "NO_AUTO_VALUE_ON_ZERO"; 3 | START TRANSACTION; 4 | SET 5 | time_zone = "+00:00"; 6 | 7 | ALTER TABLE `sessions` 8 | ADD COLUMN IF NOT EXISTS `available_capacity_dose1` INT DEFAULT NULL AFTER `available_capacity`, 9 | ADD COLUMN IF NOT EXISTS `available_capacity_dose2` INT DEFAULT NULL AFTER `available_capacity_dose1`; 10 | -------------------------------------------------------------------------------- /src/test/java/org/covid19/vaccinetracker/Covid19VaccineTrackerApplicationTests.java: -------------------------------------------------------------------------------- 1 | package org.covid19.vaccinetracker; 2 | 3 | import org.junit.jupiter.api.Disabled; 4 | import org.junit.jupiter.api.Test; 5 | import org.springframework.boot.test.context.SpringBootTest; 6 | 7 | //@SpringBootTest 8 | class Covid19VaccineTrackerApplicationTests { 9 | 10 | @Disabled 11 | @Test 12 | void contextLoads() { 13 | } 14 | 15 | 16 | } 17 | -------------------------------------------------------------------------------- /src/main/java/org/covid19/vaccinetracker/notifications/bot/BotService.java: -------------------------------------------------------------------------------- 1 | package org.covid19.vaccinetracker.notifications.bot; 2 | 3 | import org.covid19.vaccinetracker.model.Center; 4 | 5 | import java.util.List; 6 | 7 | public interface BotService { 8 | boolean notifyAvailability(String userId, List
vaccineCenters); 9 | boolean notify(String userId, String text); 10 | 11 | void notifyOwner(String message); 12 | } 13 | -------------------------------------------------------------------------------- /src/main/java/org/covid19/vaccinetracker/availability/cowin/CowinException.java: -------------------------------------------------------------------------------- 1 | package org.covid19.vaccinetracker.availability.cowin; 2 | 3 | public class CowinException extends RuntimeException { 4 | private int statusCode; 5 | 6 | public CowinException(String message, int statusCode) { 7 | super(message); 8 | this.statusCode = statusCode; 9 | } 10 | 11 | public int getStatusCode() { 12 | return statusCode; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/main/java/org/covid19/vaccinetracker/model/UsersByPincode.java: -------------------------------------------------------------------------------- 1 | package org.covid19.vaccinetracker.model; 2 | 3 | import java.util.Set; 4 | 5 | import lombok.Data; 6 | import lombok.extern.slf4j.Slf4j; 7 | 8 | @Slf4j 9 | @Data 10 | public class UsersByPincode { 11 | private final String pincode; 12 | private final Set users; 13 | 14 | public UsersByPincode merge(String userId) { 15 | this.users.add(userId); 16 | return this; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/main/java/org/covid19/vaccinetracker/availability/cowin/CowinConfig.java: -------------------------------------------------------------------------------- 1 | package org.covid19.vaccinetracker.availability.cowin; 2 | 3 | import org.springframework.boot.context.properties.ConfigurationProperties; 4 | import org.springframework.context.annotation.Configuration; 5 | 6 | import lombok.Data; 7 | 8 | @Configuration 9 | @ConfigurationProperties("cowin") 10 | @Data 11 | public class CowinConfig { 12 | private String apiUrl; 13 | private String authMobile; 14 | } 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled class file 2 | *.class 3 | 4 | # Log file 5 | *.log 6 | 7 | # BlueJ files 8 | *.ctxt 9 | 10 | # Mobile Tools for Java (J2ME) 11 | .mtj.tmp/ 12 | 13 | # Package Files # 14 | *.jar 15 | *.war 16 | *.nar 17 | *.ear 18 | *.zip 19 | *.tar.gz 20 | *.rar 21 | 22 | # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml 23 | hs_err_pid* 24 | 25 | .idea 26 | target 27 | .run 28 | 29 | src/main/resources/keystores/*.jks 30 | src/main/resources/application-local.yaml 31 | -------------------------------------------------------------------------------- /src/main/java/org/covid19/vaccinetracker/availability/AvailabilityConfig.java: -------------------------------------------------------------------------------- 1 | package org.covid19.vaccinetracker.availability; 2 | 3 | import org.springframework.boot.context.properties.ConfigurationProperties; 4 | import org.springframework.context.annotation.Configuration; 5 | 6 | import java.util.List; 7 | 8 | import lombok.Data; 9 | 10 | @Data 11 | @Configuration 12 | @ConfigurationProperties(prefix = "availability") 13 | public class AvailabilityConfig { 14 | private List priorityDistricts; 15 | } 16 | -------------------------------------------------------------------------------- /src/main/java/org/covid19/vaccinetracker/Covid19VaccineTrackerApplication.java: -------------------------------------------------------------------------------- 1 | package org.covid19.vaccinetracker; 2 | 3 | import org.springframework.boot.SpringApplication; 4 | import org.springframework.boot.autoconfigure.SpringBootApplication; 5 | import org.springframework.scheduling.annotation.EnableScheduling; 6 | 7 | @EnableScheduling 8 | @SpringBootApplication 9 | public class Covid19VaccineTrackerApplication { 10 | public static void main(String[] args) { 11 | SpringApplication.run(Covid19VaccineTrackerApplication.class, args); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/main/java/org/covid19/vaccinetracker/availability/model/ConfirmOtpResponse.java: -------------------------------------------------------------------------------- 1 | package org.covid19.vaccinetracker.availability.model; 2 | 3 | import com.fasterxml.jackson.annotation.JsonInclude; 4 | 5 | import lombok.AllArgsConstructor; 6 | import lombok.Builder; 7 | import lombok.Data; 8 | import lombok.NoArgsConstructor; 9 | 10 | @JsonInclude(JsonInclude.Include.NON_NULL) 11 | @Data 12 | @Builder 13 | @AllArgsConstructor 14 | @NoArgsConstructor 15 | public class ConfirmOtpResponse { 16 | private String token; 17 | private String isNewAccount; 18 | } 19 | -------------------------------------------------------------------------------- /src/main/java/org/covid19/vaccinetracker/persistence/mariadb/repository/UserNotificationRepository.java: -------------------------------------------------------------------------------- 1 | package org.covid19.vaccinetracker.persistence.mariadb.repository; 2 | 3 | import org.covid19.vaccinetracker.persistence.mariadb.entity.UserNotification; 4 | import org.covid19.vaccinetracker.persistence.mariadb.entity.UserNotificationId; 5 | import org.springframework.data.repository.CrudRepository; 6 | import org.springframework.stereotype.Repository; 7 | 8 | @Repository 9 | public interface UserNotificationRepository extends CrudRepository { 10 | } 11 | -------------------------------------------------------------------------------- /src/main/java/org/covid19/vaccinetracker/model/VaccineCenters.java: -------------------------------------------------------------------------------- 1 | package org.covid19.vaccinetracker.model; 2 | 3 | import com.fasterxml.jackson.annotation.JsonInclude; 4 | import com.fasterxml.jackson.annotation.JsonProperty; 5 | 6 | import java.util.List; 7 | 8 | import lombok.AllArgsConstructor; 9 | import lombok.Data; 10 | import lombok.NoArgsConstructor; 11 | 12 | @JsonInclude(JsonInclude.Include.NON_NULL) 13 | @Data 14 | @AllArgsConstructor 15 | @NoArgsConstructor 16 | public class VaccineCenters { 17 | 18 | @JsonProperty("centers") 19 | public List
centers = null; 20 | 21 | } -------------------------------------------------------------------------------- /src/main/java/org/covid19/vaccinetracker/persistence/mariadb/repository/PincodeRepository.java: -------------------------------------------------------------------------------- 1 | package org.covid19.vaccinetracker.persistence.mariadb.repository; 2 | 3 | import org.covid19.vaccinetracker.userrequests.model.Pincode; 4 | import org.springframework.data.repository.CrudRepository; 5 | import org.springframework.stereotype.Repository; 6 | 7 | import java.util.List; 8 | 9 | @Repository 10 | public interface PincodeRepository extends CrudRepository { 11 | List findPincodeByDistrictId(int districtId); 12 | 13 | boolean existsByPincode(String pincode); 14 | } 15 | -------------------------------------------------------------------------------- /src/main/java/org/covid19/vaccinetracker/availability/model/GenerateOtpResponse.java: -------------------------------------------------------------------------------- 1 | package org.covid19.vaccinetracker.availability.model; 2 | 3 | import com.fasterxml.jackson.annotation.JsonInclude; 4 | import com.fasterxml.jackson.annotation.JsonProperty; 5 | 6 | import lombok.AllArgsConstructor; 7 | import lombok.Builder; 8 | import lombok.Data; 9 | import lombok.NoArgsConstructor; 10 | 11 | @JsonInclude(JsonInclude.Include.NON_NULL) 12 | @Data 13 | @Builder 14 | @AllArgsConstructor 15 | @NoArgsConstructor 16 | public class GenerateOtpResponse { 17 | @JsonProperty("txnId") 18 | private String transactionId; 19 | } 20 | -------------------------------------------------------------------------------- /src/main/resources/db/migration/V0.2.7.00__add_notification_cache.sql: -------------------------------------------------------------------------------- 1 | SET 2 | SQL_MODE = "NO_AUTO_VALUE_ON_ZERO"; 3 | START TRANSACTION; 4 | SET 5 | time_zone = "+00:00"; 6 | 7 | -- 8 | -- Table structure for table `user_notifications` 9 | -- 10 | 11 | CREATE TABLE `user_notifications` 12 | ( 13 | `user_id` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, 14 | `pincode` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, 15 | `notification_hash` varchar(255) COLLATE utf8mb4_unicode_ci, 16 | `notified_at` timestamp, 17 | PRIMARY KEY (`user_id`, `pincode`) 18 | ) ENGINE = InnoDB 19 | DEFAULT CHARSET = utf8mb4 20 | COLLATE = utf8mb4_unicode_ci; 21 | -------------------------------------------------------------------------------- /src/main/java/org/covid19/vaccinetracker/notifications/absentalerts/AbsentAlertSource.java: -------------------------------------------------------------------------------- 1 | package org.covid19.vaccinetracker.notifications.absentalerts; 2 | 3 | import org.covid19.vaccinetracker.persistence.mariadb.entity.UserNotification; 4 | 5 | import lombok.Builder; 6 | import lombok.Data; 7 | 8 | /** 9 | * This class is used as a source of information for generating the cause(s) of absent alerts for 10 | * the user. 11 | */ 12 | @Builder 13 | @Data 14 | public class AbsentAlertSource { 15 | private String userId; 16 | private String pincode; 17 | private String age; 18 | private String dose; 19 | private String vaccine; 20 | private UserNotification latestNotification; // can be null 21 | } 22 | -------------------------------------------------------------------------------- /src/main/java/org/covid19/vaccinetracker/persistence/mariadb/entity/UserNotificationId.java: -------------------------------------------------------------------------------- 1 | package org.covid19.vaccinetracker.persistence.mariadb.entity; 2 | 3 | import java.io.Serializable; 4 | 5 | import javax.persistence.Column; 6 | import javax.persistence.Embeddable; 7 | 8 | import lombok.AccessLevel; 9 | import lombok.AllArgsConstructor; 10 | import lombok.Builder; 11 | import lombok.Data; 12 | import lombok.NoArgsConstructor; 13 | 14 | @Data 15 | @Builder 16 | @Embeddable 17 | @AllArgsConstructor 18 | @NoArgsConstructor(access = AccessLevel.PROTECTED) 19 | public class UserNotificationId implements Serializable { 20 | @Column(name = "user_id") 21 | private String userId; 22 | 23 | @Column 24 | private String pincode; 25 | } 26 | -------------------------------------------------------------------------------- /src/main/java/org/covid19/vaccinetracker/userrequests/model/State.java: -------------------------------------------------------------------------------- 1 | package org.covid19.vaccinetracker.userrequests.model; 2 | 3 | import javax.persistence.Column; 4 | import javax.persistence.Entity; 5 | import javax.persistence.Id; 6 | import javax.persistence.Table; 7 | 8 | import lombok.AccessLevel; 9 | import lombok.AllArgsConstructor; 10 | import lombok.Data; 11 | import lombok.EqualsAndHashCode; 12 | import lombok.NoArgsConstructor; 13 | 14 | @EqualsAndHashCode 15 | @Data 16 | @Entity 17 | @Table(name = "states") 18 | @AllArgsConstructor 19 | @NoArgsConstructor(access = AccessLevel.PROTECTED) 20 | public class State { 21 | @Id 22 | private int id; 23 | 24 | @Column(name = "state_name") 25 | private String stateName; 26 | } 27 | -------------------------------------------------------------------------------- /src/main/java/org/covid19/vaccinetracker/persistence/mariadb/repository/StateRepository.java: -------------------------------------------------------------------------------- 1 | package org.covid19.vaccinetracker.persistence.mariadb.repository; 2 | 3 | import org.covid19.vaccinetracker.userrequests.model.State; 4 | import org.springframework.data.jpa.repository.Query; 5 | import org.springframework.data.repository.CrudRepository; 6 | import org.springframework.stereotype.Repository; 7 | 8 | @Repository 9 | public interface StateRepository extends CrudRepository { 10 | @Query("SELECT s FROM State s " + 11 | "INNER JOIN District d ON d.state = s " + 12 | "INNER JOIN Pincode p ON p.district = d " + 13 | "WHERE p.pincode = :pincode") 14 | State findByPincode(String pincode); 15 | } 16 | -------------------------------------------------------------------------------- /src/main/java/org/covid19/vaccinetracker/persistence/mariadb/entity/CoreEntity.java: -------------------------------------------------------------------------------- 1 | package org.covid19.vaccinetracker.persistence.mariadb.entity; 2 | 3 | import java.util.UUID; 4 | 5 | import javax.persistence.Column; 6 | import javax.persistence.Id; 7 | import javax.persistence.MappedSuperclass; 8 | 9 | import lombok.AccessLevel; 10 | import lombok.Data; 11 | import lombok.EqualsAndHashCode; 12 | import lombok.NoArgsConstructor; 13 | 14 | @Data 15 | @EqualsAndHashCode 16 | @NoArgsConstructor(force = true, access = AccessLevel.PROTECTED) 17 | @MappedSuperclass 18 | public abstract class CoreEntity { 19 | @Id 20 | @Column(nullable = false, unique = true, insertable = false, updatable = false) 21 | private String id = UUID.randomUUID().toString(); 22 | } 23 | -------------------------------------------------------------------------------- /src/test/resources/application.yaml: -------------------------------------------------------------------------------- 1 | telegram: 2 | enabled: false 3 | bot.username: covid19_vaccine_tracker_bot 4 | db.path: "/tmp/telegram.db" 5 | creator.id: "12345" 6 | chat.id: "12345" 7 | 8 | topic: 9 | user.requests: "user-requests" 10 | user.districts: "user-districts" 11 | user.bypincode: "users-by-pincode" 12 | updated.pincodes: "updated-pincodes" 13 | 14 | spring: 15 | flyway: 16 | enabled: false 17 | datasource: 18 | platform: h2 19 | jpa: 20 | hibernate: 21 | ddl-auto: create-drop 22 | properties: 23 | hibernate: 24 | hbm2ddl: 25 | import_files_sql_extractor: org.hibernate.tool.hbm2ddl.MultipleLinesSqlCommandExtractor 26 | 27 | logging: 28 | level: 29 | org.covid19.vaccinetracker: DEBUG 30 | -------------------------------------------------------------------------------- /.github/workflows/maven.yml: -------------------------------------------------------------------------------- 1 | # This workflow will build a Java project with Maven 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/building-and-testing-java-with-maven 3 | 4 | name: Java CI with Maven 5 | 6 | on: 7 | push: 8 | branches: [ main ] 9 | pull_request: 10 | branches: [ main ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - uses: actions/checkout@v2 19 | - name: Set up JDK 11 20 | uses: actions/setup-java@v2 21 | with: 22 | java-version: '11' 23 | distribution: 'adopt' 24 | - name: Build with Maven 25 | env: 26 | REGISTRY_USERNAME: ${{ secrets.DOCKER_USERNAME }} 27 | REGISTRY_PASSWORD: ${{ secrets.DOCKER_TOKEN }} 28 | run: mvn -s .mvn/settings.xml -B package --file pom.xml 29 | -------------------------------------------------------------------------------- /src/main/java/org/covid19/vaccinetracker/userrequests/model/District.java: -------------------------------------------------------------------------------- 1 | package org.covid19.vaccinetracker.userrequests.model; 2 | 3 | import javax.persistence.Column; 4 | import javax.persistence.Entity; 5 | import javax.persistence.Id; 6 | import javax.persistence.ManyToOne; 7 | import javax.persistence.Table; 8 | 9 | import lombok.AccessLevel; 10 | import lombok.AllArgsConstructor; 11 | import lombok.Data; 12 | import lombok.EqualsAndHashCode; 13 | import lombok.NoArgsConstructor; 14 | 15 | @EqualsAndHashCode 16 | @Data 17 | @Entity 18 | @Table(name = "districts") 19 | @AllArgsConstructor 20 | @NoArgsConstructor(access = AccessLevel.PROTECTED) 21 | public class District { 22 | @Id 23 | private int id; 24 | 25 | @Column(name = "district_name") 26 | private String districtName; 27 | 28 | @ManyToOne(optional = false) 29 | private State state; 30 | } 31 | -------------------------------------------------------------------------------- /src/main/java/org/covid19/vaccinetracker/persistence/VaccinePersistence.java: -------------------------------------------------------------------------------- 1 | package org.covid19.vaccinetracker.persistence; 2 | 3 | import org.covid19.vaccinetracker.model.CenterSession; 4 | import org.covid19.vaccinetracker.model.VaccineCenters; 5 | import org.covid19.vaccinetracker.persistence.mariadb.entity.SessionEntity; 6 | 7 | import java.util.List; 8 | import java.util.Optional; 9 | 10 | public interface VaccinePersistence { 11 | void persistVaccineCenters(VaccineCenters vaccineCenters); 12 | 13 | VaccineCenters fetchVaccineCentersByPincode(String pincode); 14 | 15 | void markProcessed(VaccineCenters vaccineCenters); 16 | 17 | void cleanupOldCenters(String date); 18 | 19 | Optional findExistingSession(Long centerId, String date, Integer age, String vaccine); 20 | 21 | List findAllSessionsByPincode(String pincode); 22 | } 23 | -------------------------------------------------------------------------------- /src/main/java/org/covid19/vaccinetracker/model/VaccineFee.java: -------------------------------------------------------------------------------- 1 | package org.covid19.vaccinetracker.model; 2 | 3 | import com.fasterxml.jackson.annotation.JsonInclude; 4 | 5 | import lombok.AllArgsConstructor; 6 | import lombok.Builder; 7 | import lombok.Data; 8 | import lombok.NoArgsConstructor; 9 | 10 | import static org.covid19.vaccinetracker.userrequests.model.Vaccine.COVAXIN; 11 | import static org.covid19.vaccinetracker.userrequests.model.Vaccine.COVISHIELD; 12 | import static org.covid19.vaccinetracker.userrequests.model.Vaccine.SPUTNIK_V; 13 | 14 | @JsonInclude(JsonInclude.Include.NON_NULL) 15 | @Data 16 | @Builder 17 | @AllArgsConstructor 18 | @NoArgsConstructor 19 | public class VaccineFee { 20 | 21 | public String vaccine; 22 | public String fee; 23 | 24 | public boolean isVaccine(String vaccine) { 25 | return this.vaccine.equalsIgnoreCase(vaccine); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/main/java/org/covid19/vaccinetracker/userrequests/model/Pincode.java: -------------------------------------------------------------------------------- 1 | package org.covid19.vaccinetracker.userrequests.model; 2 | 3 | import org.covid19.vaccinetracker.persistence.mariadb.entity.CoreEntity; 4 | 5 | import javax.persistence.Column; 6 | import javax.persistence.Entity; 7 | import javax.persistence.ManyToOne; 8 | import javax.persistence.Table; 9 | 10 | import lombok.AccessLevel; 11 | import lombok.AllArgsConstructor; 12 | import lombok.Builder; 13 | import lombok.Data; 14 | import lombok.EqualsAndHashCode; 15 | import lombok.NoArgsConstructor; 16 | 17 | @EqualsAndHashCode(callSuper = true) 18 | @Data 19 | @Builder 20 | @Entity 21 | @Table(name = "pincodes") 22 | @AllArgsConstructor 23 | @NoArgsConstructor(access = AccessLevel.PROTECTED) 24 | public class Pincode extends CoreEntity { 25 | @Column 26 | private String pincode; 27 | 28 | @ManyToOne(optional = false) 29 | private District district; 30 | } 31 | -------------------------------------------------------------------------------- /src/main/java/org/covid19/vaccinetracker/userrequests/model/Age.java: -------------------------------------------------------------------------------- 1 | package org.covid19.vaccinetracker.userrequests.model; 2 | 3 | import java.util.HashMap; 4 | import java.util.Map; 5 | 6 | import static java.util.Objects.isNull; 7 | 8 | /** 9 | * Age preference 10 | */ 11 | public enum Age { 12 | AGE_18_44("18-44"), 13 | AGE_45("45+"), 14 | AGE_BOTH("both"); 15 | 16 | private static final Map BY_LABEL = new HashMap<>(); 17 | 18 | static { 19 | for (Age a : values()) { 20 | BY_LABEL.put(a.age, a); 21 | } 22 | } 23 | 24 | public static Age find(String val) { 25 | Age age = BY_LABEL.get(val); 26 | return isNull(age) ? null : age; 27 | } 28 | 29 | private final String age; 30 | 31 | Age(String age) { 32 | this.age = age; 33 | } 34 | 35 | @Override 36 | public String toString() { 37 | return age; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/main/java/org/covid19/vaccinetracker/notifications/absentalerts/AbsentAlertCause.java: -------------------------------------------------------------------------------- 1 | package org.covid19.vaccinetracker.notifications.absentalerts; 2 | 3 | import java.util.ArrayList; 4 | import java.util.List; 5 | 6 | import lombok.Builder; 7 | import lombok.Data; 8 | 9 | import static java.util.Objects.isNull; 10 | 11 | @Builder 12 | @Data 13 | public class AbsentAlertCause { 14 | private String userId; 15 | private String pincode; 16 | private String lastNotified; 17 | private List causes; 18 | private List recents; 19 | 20 | public void addCause(String cause) { 21 | if (isNull(causes)) { 22 | causes = new ArrayList<>(); 23 | } 24 | causes.add(cause); 25 | } 26 | 27 | public void addRecent(String recent) { 28 | if (isNull(recents)) { 29 | recents = new ArrayList<>(); 30 | } 31 | recents.add(recent); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/main/java/org/covid19/vaccinetracker/persistence/mariadb/entity/UserNotification.java: -------------------------------------------------------------------------------- 1 | package org.covid19.vaccinetracker.persistence.mariadb.entity; 2 | 3 | import java.time.LocalDateTime; 4 | 5 | import javax.persistence.Column; 6 | import javax.persistence.EmbeddedId; 7 | import javax.persistence.Entity; 8 | import javax.persistence.Table; 9 | 10 | import lombok.AccessLevel; 11 | import lombok.AllArgsConstructor; 12 | import lombok.Builder; 13 | import lombok.Data; 14 | import lombok.NoArgsConstructor; 15 | 16 | @Data 17 | @Builder 18 | @Entity 19 | @Table(name = "user_notifications") 20 | @AllArgsConstructor 21 | @NoArgsConstructor(access = AccessLevel.PROTECTED) 22 | public class UserNotification { 23 | @EmbeddedId 24 | private UserNotificationId userNotificationId; 25 | 26 | @Column(name = "notification_hash") 27 | private String notificationHash; 28 | 29 | @Column(name = "notified_at") 30 | private LocalDateTime notifiedAt; 31 | } 32 | -------------------------------------------------------------------------------- /src/main/java/org/covid19/vaccinetracker/userrequests/model/Dose.java: -------------------------------------------------------------------------------- 1 | package org.covid19.vaccinetracker.userrequests.model; 2 | 3 | import java.util.HashMap; 4 | import java.util.Map; 5 | 6 | import static java.util.Objects.isNull; 7 | 8 | /** 9 | * Dose preference 10 | */ 11 | public enum Dose { 12 | DOSE_1("Dose 1"), 13 | DOSE_2("Dose 2"), 14 | DOSE_BOTH("Dose 1 and 2"); 15 | 16 | private static final Map BY_LABEL = new HashMap<>(); 17 | 18 | static { 19 | for (Dose d : values()) { 20 | BY_LABEL.put(d.dose, d); 21 | } 22 | } 23 | 24 | public static Dose find(String val) { 25 | Dose dose = BY_LABEL.get(val); 26 | return isNull(dose) ? null : dose; 27 | } 28 | 29 | private final String dose; 30 | 31 | Dose(String dose) { 32 | this.dose = dose; 33 | } 34 | 35 | @Override 36 | public String toString() { 37 | return dose; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/main/java/org/covid19/vaccinetracker/persistence/mariadb/repository/CenterRepository.java: -------------------------------------------------------------------------------- 1 | package org.covid19.vaccinetracker.persistence.mariadb.repository; 2 | 3 | import org.covid19.vaccinetracker.persistence.mariadb.entity.CenterEntity; 4 | import org.springframework.data.jpa.repository.Query; 5 | import org.springframework.data.repository.CrudRepository; 6 | import org.springframework.stereotype.Repository; 7 | 8 | import java.util.List; 9 | 10 | @Repository 11 | public interface CenterRepository extends CrudRepository { 12 | List findCenterEntityByPincode(String pincode); 13 | 14 | @Query("SELECT DISTINCT c FROM CenterEntity c " + 15 | "JOIN FETCH c.sessions s " + 16 | "WHERE c.pincode = :pincode " + 17 | "AND s.processedAt IS NULL") 18 | List findCenterEntityByPincodeAndSessionsProcessedAtIsNull(String pincode); 19 | 20 | void deleteBySessionsDate(String date); 21 | } 22 | -------------------------------------------------------------------------------- /src/main/java/org/covid19/vaccinetracker/userrequests/model/Vaccine.java: -------------------------------------------------------------------------------- 1 | package org.covid19.vaccinetracker.userrequests.model; 2 | 3 | import java.util.HashMap; 4 | import java.util.Map; 5 | 6 | import static java.util.Objects.isNull; 7 | 8 | /** 9 | * Vaccine preference 10 | */ 11 | public enum Vaccine { 12 | COVISHIELD("Covishield"), 13 | COVAXIN("Covaxin"), 14 | SPUTNIK_V("Sputnik V"), 15 | ALL("All"); 16 | 17 | private static final Map BY_LABEL = new HashMap<>(); 18 | 19 | static { 20 | for (Vaccine v : values()) { 21 | BY_LABEL.put(v.vaccine, v); 22 | } 23 | } 24 | 25 | public static Vaccine find(String val) { 26 | Vaccine vaccine = BY_LABEL.get(val); 27 | return isNull(vaccine) ? null : vaccine; 28 | } 29 | 30 | private final String vaccine; 31 | 32 | Vaccine(String vaccine) { 33 | this.vaccine = vaccine; 34 | } 35 | 36 | @Override 37 | public String toString() { 38 | return vaccine; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/main/java/org/covid19/vaccinetracker/model/CenterSession.java: -------------------------------------------------------------------------------- 1 | package org.covid19.vaccinetracker.model; 2 | 3 | import lombok.AllArgsConstructor; 4 | import lombok.Builder; 5 | import lombok.Data; 6 | import lombok.NoArgsConstructor; 7 | 8 | @Builder 9 | @Data 10 | @NoArgsConstructor 11 | @AllArgsConstructor 12 | public class CenterSession { 13 | 14 | private String centerName; 15 | private String districtName; 16 | private String pincode; 17 | private String sessionDate; 18 | private int minAge; 19 | private String sessionVaccine; 20 | private String multipleDates; // for accumulating multiple sessions 21 | 22 | public CenterSession(String centerName, String districtName, String pincode, String sessionDate, 23 | int minAge, String sessionVaccine) { 24 | this.centerName = centerName; 25 | this.districtName = districtName; 26 | this.pincode = pincode; 27 | this.sessionDate = sessionDate; 28 | this.minAge = minAge; 29 | this.sessionVaccine = sessionVaccine; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/main/java/org/covid19/vaccinetracker/persistence/mariadb/repository/DistrictRepository.java: -------------------------------------------------------------------------------- 1 | package org.covid19.vaccinetracker.persistence.mariadb.repository; 2 | 3 | import org.covid19.vaccinetracker.userrequests.model.District; 4 | import org.covid19.vaccinetracker.userrequests.model.State; 5 | import org.springframework.data.jpa.repository.Query; 6 | import org.springframework.data.repository.CrudRepository; 7 | import org.springframework.stereotype.Repository; 8 | 9 | import java.util.List; 10 | 11 | @Repository 12 | public interface DistrictRepository extends CrudRepository { 13 | List findDistrictByState(State state); 14 | 15 | @Query("SELECT d FROM District d " + 16 | "INNER JOIN State s ON d.state = s " + 17 | "WHERE d.districtName = :name " + 18 | "AND s.stateName = :stateName") 19 | District findDistrictByDistrictNameAndState(String name, String stateName); 20 | 21 | @Query("SELECT p.district FROM Pincode p WHERE p.pincode = :pincode") 22 | List findDistrictByPincode(String pincode); 23 | } 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Abhinav Sonkar 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /HELP.md: -------------------------------------------------------------------------------- 1 | # Read Me First 2 | The following was discovered as part of building this project: 3 | 4 | * The original package name 'com.abhinav.covid19.covid19-vaccine-tracker' is invalid and this project uses 'com.abhinav.covid19.covid19vaccinetracker' instead. 5 | 6 | # Getting Started 7 | 8 | ### Reference Documentation 9 | For further reference, please consider the following sections: 10 | 11 | * [Official Apache Maven documentation](https://maven.apache.org/guides/index.html) 12 | * [Spring Boot Maven Plugin Reference Guide](https://docs.spring.io/spring-boot/docs/2.4.5/maven-plugin/reference/html/) 13 | * [Create an OCI image](https://docs.spring.io/spring-boot/docs/2.4.5/maven-plugin/reference/html/#build-image) 14 | * [Spring Web](https://docs.spring.io/spring-boot/docs/2.4.5/reference/htmlsingle/#boot-features-developing-web-applications) 15 | 16 | ### Guides 17 | The following guides illustrate how to use some features concretely: 18 | 19 | * [Building a RESTful Web Service](https://spring.io/guides/gs/rest-service/) 20 | * [Serving Web Content with Spring MVC](https://spring.io/guides/gs/serving-web-content/) 21 | * [Building REST services with Spring](https://spring.io/guides/tutorials/bookmarks/) 22 | 23 | -------------------------------------------------------------------------------- /src/main/java/org/covid19/vaccinetracker/model/UsersByPincodeSerde.java: -------------------------------------------------------------------------------- 1 | package org.covid19.vaccinetracker.model; 2 | 3 | import com.google.gson.Gson; 4 | import com.google.gson.GsonBuilder; 5 | 6 | import org.apache.kafka.common.serialization.Deserializer; 7 | import org.apache.kafka.common.serialization.Serdes; 8 | import org.apache.kafka.common.serialization.Serializer; 9 | 10 | import java.nio.charset.StandardCharsets; 11 | 12 | public class UsersByPincodeSerde extends Serdes.WrapperSerde { 13 | public UsersByPincodeSerde() { 14 | super(new Serializer<>() { 15 | private Gson gson = new GsonBuilder().serializeNulls().create(); 16 | 17 | @Override 18 | public byte[] serialize(String s, UsersByPincode usersByPincode) { 19 | return gson.toJson(usersByPincode).getBytes(StandardCharsets.UTF_8); 20 | } 21 | }, new Deserializer<>() { 22 | private Gson gson = new Gson(); 23 | 24 | @Override 25 | public UsersByPincode deserialize(String s, byte[] bytes) { 26 | return gson.fromJson(new String(bytes), UsersByPincode.class); 27 | } 28 | }); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/main/java/org/covid19/vaccinetracker/userrequests/model/UserRequestSerde.java: -------------------------------------------------------------------------------- 1 | package org.covid19.vaccinetracker.userrequests.model; 2 | 3 | import com.google.gson.Gson; 4 | import com.google.gson.GsonBuilder; 5 | 6 | import org.apache.kafka.common.serialization.Deserializer; 7 | import org.apache.kafka.common.serialization.Serdes; 8 | import org.apache.kafka.common.serialization.Serializer; 9 | 10 | import java.nio.charset.StandardCharsets; 11 | 12 | public class UserRequestSerde extends Serdes.WrapperSerde { 13 | 14 | public UserRequestSerde() { 15 | super(new Serializer<>() { 16 | private Gson gson = new GsonBuilder().serializeNulls().create(); 17 | 18 | @Override 19 | public byte[] serialize(String s, UserRequest UserRequest) { 20 | return gson.toJson(UserRequest).getBytes(StandardCharsets.UTF_8); 21 | } 22 | }, new Deserializer<>() { 23 | private Gson gson = new Gson(); 24 | 25 | @Override 26 | public UserRequest deserialize(String s, byte[] bytes) { 27 | return gson.fromJson(new String(bytes), UserRequest.class); 28 | } 29 | }); 30 | } 31 | } 32 | 33 | -------------------------------------------------------------------------------- /src/main/resources/application.yaml: -------------------------------------------------------------------------------- 1 | server: 2 | port: 8087 3 | 4 | cowin: 5 | apiUrl: "http://localhost:8080" 6 | 7 | topic: 8 | user.requests: "user-requests" 9 | user.districts: "user-districts" 10 | user.bypincode: "users-by-pincode" 11 | updated.pincodes: "updated-pincodes" 12 | 13 | telegram: 14 | enabled: true 15 | bot.username: covid19_vaccine_tracker_bot 16 | db.path: "/tmp/telegram.db" 17 | 18 | jobs: 19 | cron: 20 | priority.districts.availability: "0 * 6-23 * * *" 21 | vaccine.availability: "0 0/15 6-23 * * *" 22 | district.notifications: "-" 23 | absentalerts.notifications: "-" 24 | pincode.reconciliation: "0 1 6,9,12,15,18,21 * * *" 25 | cowin.api.auth: "-" 26 | db.cleanup: "-" 27 | notification.stats: "0 4/5 6-23 * * *" 28 | user.stats: "0 3/5 6-23 * * *" 29 | 30 | logging: 31 | level: 32 | org.apache.kafka.streams: INFO 33 | 34 | availability: 35 | priorityDistricts: "" 36 | 37 | spring: 38 | kafka: 39 | streams: 40 | application-id: "org.covid19.vaccine-tracker" 41 | client-id: "org.covid19.vaccine-tracker" 42 | properties: 43 | "application.id": "org.covid19.vaccine-tracker" 44 | "client.id": "org.covid19.vaccine-tracker" 45 | -------------------------------------------------------------------------------- /src/main/java/org/covid19/vaccinetracker/userrequests/model/DistrictSerde.java: -------------------------------------------------------------------------------- 1 | package org.covid19.vaccinetracker.userrequests.model; 2 | 3 | import com.google.gson.Gson; 4 | import com.google.gson.GsonBuilder; 5 | 6 | import org.apache.kafka.common.serialization.Deserializer; 7 | import org.apache.kafka.common.serialization.Serdes; 8 | import org.apache.kafka.common.serialization.Serializer; 9 | import org.covid19.vaccinetracker.userrequests.model.District; 10 | 11 | import java.nio.charset.StandardCharsets; 12 | 13 | public class DistrictSerde extends Serdes.WrapperSerde { 14 | 15 | public DistrictSerde() { 16 | super(new Serializer<>() { 17 | private Gson gson = new GsonBuilder().serializeNulls().create(); 18 | 19 | @Override 20 | public byte[] serialize(String s, District district) { 21 | return gson.toJson(district).getBytes(StandardCharsets.UTF_8); 22 | } 23 | }, new Deserializer<>() { 24 | private Gson gson = new Gson(); 25 | 26 | @Override 27 | public District deserialize(String s, byte[] bytes) { 28 | return gson.fromJson(new String(bytes), District.class); 29 | } 30 | }); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Covid19 Telegram Bot for Vaccine Alerts 2 | 3 | This repository stores the source code of the Telegram Bot CoWIN Alerts available at https://t.me/covid19_vaccine_tracker_bot 4 | 5 | ## Features 6 | 7 | * Send up to 5 pincodes for updates. 8 | * Set your preferred age, dose and vaccine for alerts. 9 | * Bot checks against CoWIN API every 5 minutes. 10 | * Streaming pipeline to notify as soon as slots open. 11 | * Receive alerts in your local language based on input pincode. 12 | 13 | ## Architecture 14 | 15 | ![image](https://user-images.githubusercontent.com/4991449/120941710-6c05c680-c724-11eb-8884-d2156ad2664d.png) 16 | 17 | 18 | ### Design Goals 19 | * Send notifications as soon as new Vaccination slots are available. 20 | * Store critical data (like user requests) in Kafka to achieve RPO = 0 21 | * Store non-critical data (like Vaccine slots which can be recovered from CoWIN API) 22 | outside Kafka to keep costs low. 23 | 24 | ## Screenshots 25 | 26 |
27 | set pincode 28 | receive alerts 29 |
30 | -------------------------------------------------------------------------------- /src/main/java/org/covid19/vaccinetracker/persistence/mariadb/repository/SessionRepository.java: -------------------------------------------------------------------------------- 1 | package org.covid19.vaccinetracker.persistence.mariadb.repository; 2 | 3 | import org.covid19.vaccinetracker.model.CenterSession; 4 | import org.covid19.vaccinetracker.persistence.mariadb.entity.SessionEntity; 5 | import org.springframework.data.jpa.repository.JpaRepository; 6 | import org.springframework.data.jpa.repository.Query; 7 | import org.springframework.stereotype.Repository; 8 | 9 | import java.util.List; 10 | 11 | @Repository 12 | public interface SessionRepository extends JpaRepository { 13 | @Query("SELECT s FROM CenterEntity c " + 14 | "JOIN c.sessions s " + 15 | "WHERE c.id = :centerId " + 16 | "AND s.date = :date " + 17 | "AND s.minAgeLimit = :age " + 18 | "AND s.vaccine = :vaccine ") 19 | List findLatestSession(Long centerId, String date, Integer age, String vaccine); 20 | 21 | @Query("SELECT " + 22 | "new org.covid19.vaccinetracker.model.CenterSession(c.name, c.districtName, c.pincode, s.date, s.minAgeLimit, s.vaccine) " + 23 | "FROM CenterEntity c " + 24 | "JOIN c.sessions s " + 25 | "WHERE c.pincode = :pincode") 26 | List findSessionsWithPincode(String pincode); 27 | } 28 | -------------------------------------------------------------------------------- /src/main/java/org/covid19/vaccinetracker/notifications/bot/TelegramConfig.java: -------------------------------------------------------------------------------- 1 | package org.covid19.vaccinetracker.notifications.bot; 2 | 3 | import org.mapdb.DB; 4 | import org.mapdb.DBMaker; 5 | import org.springframework.boot.context.properties.ConfigurationProperties; 6 | import org.springframework.context.annotation.Bean; 7 | import org.springframework.context.annotation.Configuration; 8 | import org.telegram.abilitybots.api.db.MapDBContext; 9 | 10 | import java.io.File; 11 | import java.util.ArrayList; 12 | import java.util.List; 13 | 14 | import lombok.Data; 15 | 16 | @Configuration 17 | @ConfigurationProperties(prefix = "telegram") 18 | @Data 19 | public class TelegramConfig { 20 | private boolean enabled; 21 | private List blackListUsers = new ArrayList<>(); 22 | private String dbPath; 23 | private String botUsername; 24 | private String botToken; 25 | private String creatorId; 26 | private String chatId; 27 | 28 | @Bean 29 | public TelegramBot telegramBot() { 30 | 31 | DB db = DBMaker 32 | .fileDB(new File(dbPath)) 33 | .fileMmapEnableIfSupported() 34 | .closeOnJvmShutdown() 35 | .transactionEnable() 36 | .make(); 37 | 38 | return new TelegramBot(botToken, botUsername, new MapDBContext(db), creatorId, chatId); 39 | } 40 | 41 | } 42 | -------------------------------------------------------------------------------- /src/main/java/org/covid19/vaccinetracker/persistence/mariadb/entity/CenterEntity.java: -------------------------------------------------------------------------------- 1 | package org.covid19.vaccinetracker.persistence.mariadb.entity; 2 | 3 | import java.util.Set; 4 | 5 | import javax.persistence.CascadeType; 6 | import javax.persistence.Column; 7 | import javax.persistence.Entity; 8 | import javax.persistence.FetchType; 9 | import javax.persistence.Id; 10 | import javax.persistence.OneToMany; 11 | import javax.persistence.Table; 12 | 13 | import lombok.AccessLevel; 14 | import lombok.AllArgsConstructor; 15 | import lombok.Builder; 16 | import lombok.Data; 17 | import lombok.EqualsAndHashCode; 18 | import lombok.NoArgsConstructor; 19 | 20 | @EqualsAndHashCode 21 | @Data 22 | @Builder 23 | @Entity 24 | @Table(name = "vaccine_centers") 25 | @AllArgsConstructor 26 | @NoArgsConstructor(access = AccessLevel.PROTECTED) 27 | public class CenterEntity { 28 | @Id 29 | private long id; 30 | 31 | @Column 32 | private String name; 33 | 34 | @Column 35 | private String address; 36 | 37 | @Column 38 | private String pincode; 39 | 40 | @Column(name = "fee_type") 41 | private String feeType; 42 | 43 | @Column(name = "district_name") 44 | private String districtName; 45 | 46 | @Column(name = "state_name") 47 | private String stateName; 48 | 49 | @OneToMany(cascade = CascadeType.ALL, fetch = FetchType.EAGER, orphanRemoval = true) 50 | private Set sessions; 51 | } 52 | -------------------------------------------------------------------------------- /src/main/java/org/covid19/vaccinetracker/availability/aws/AWSConfig.java: -------------------------------------------------------------------------------- 1 | package org.covid19.vaccinetracker.availability.aws; 2 | 3 | import com.amazonaws.auth.EnvironmentVariableCredentialsProvider; 4 | import com.amazonaws.regions.Regions; 5 | import com.amazonaws.services.lambda.AWSLambda; 6 | import com.amazonaws.services.lambda.AWSLambdaAsync; 7 | import com.amazonaws.services.lambda.AWSLambdaAsyncClientBuilder; 8 | import com.amazonaws.services.lambda.AWSLambdaClientBuilder; 9 | 10 | import org.springframework.boot.context.properties.ConfigurationProperties; 11 | import org.springframework.context.annotation.Bean; 12 | import org.springframework.context.annotation.Configuration; 13 | 14 | import lombok.Data; 15 | 16 | @Configuration 17 | @ConfigurationProperties(prefix = "aws") 18 | @Data 19 | public class AWSConfig { 20 | private String calendarByDistrictLambdaArn; 21 | private String calendarByPinLambdaArn; 22 | private String sendTelegramMsgLambdaArn; 23 | 24 | @Bean 25 | public AWSLambda awsLambda() { 26 | return AWSLambdaClientBuilder.standard() 27 | .withCredentials(new EnvironmentVariableCredentialsProvider()) 28 | .withRegion(Regions.AP_SOUTH_1).build(); 29 | } 30 | 31 | @Bean 32 | public AWSLambdaAsync awsLambdaAsync() { 33 | return AWSLambdaAsyncClientBuilder.standard() 34 | .withCredentials(new EnvironmentVariableCredentialsProvider()) 35 | .withRegion(Regions.AP_SOUTH_1).build(); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/main/java/org/covid19/vaccinetracker/persistence/mariadb/entity/SessionEntity.java: -------------------------------------------------------------------------------- 1 | package org.covid19.vaccinetracker.persistence.mariadb.entity; 2 | 3 | import java.time.LocalDateTime; 4 | 5 | import javax.persistence.Column; 6 | import javax.persistence.Entity; 7 | import javax.persistence.EntityListeners; 8 | import javax.persistence.Id; 9 | import javax.persistence.Table; 10 | 11 | import lombok.AccessLevel; 12 | import lombok.AllArgsConstructor; 13 | import lombok.Builder; 14 | import lombok.Data; 15 | import lombok.EqualsAndHashCode; 16 | import lombok.NoArgsConstructor; 17 | 18 | @EqualsAndHashCode(onlyExplicitlyIncluded = true) 19 | @Builder 20 | @Data 21 | @Entity 22 | @EntityListeners(EntityListeners.class) 23 | @Table(name = "sessions") 24 | @AllArgsConstructor 25 | @NoArgsConstructor(access = AccessLevel.PROTECTED) 26 | public class SessionEntity { 27 | @EqualsAndHashCode.Include 28 | @Id 29 | private String id; 30 | 31 | @Column 32 | private String date; 33 | 34 | @Column(name = "available_capacity") 35 | private int availableCapacity; 36 | 37 | @Column(name = "available_capacity_dose1") 38 | private int availableCapacityDose1; 39 | 40 | @Column(name = "available_capacity_dose2") 41 | private int availableCapacityDose2; 42 | 43 | @Column(name = "min_age_limit") 44 | private int minAgeLimit; 45 | 46 | private String vaccine; 47 | 48 | private String cost; 49 | 50 | @Column(name = "processed_at") 51 | private LocalDateTime processedAt; 52 | } 53 | -------------------------------------------------------------------------------- /src/test/java/org/covid19/vaccinetracker/notifications/bot/TestUtils.java: -------------------------------------------------------------------------------- 1 | package org.covid19.vaccinetracker.notifications.bot; 2 | 3 | import org.jetbrains.annotations.NotNull; 4 | import org.telegram.abilitybots.api.bot.AbilityBot; 5 | import org.telegram.telegrambots.meta.api.objects.Chat; 6 | import org.telegram.telegrambots.meta.api.objects.Message; 7 | import org.telegram.telegrambots.meta.api.objects.Update; 8 | import org.telegram.telegrambots.meta.api.objects.User; 9 | 10 | import static org.mockito.Mockito.mock; 11 | import static org.mockito.Mockito.when; 12 | 13 | public final class TestUtils { 14 | public static final User USER = new User(1L, "Abhinav", false, "last", "username", null, false, false, false); 15 | public static final User CREATOR = new User(1337L, "creatorFirst", false, "creatorLast", "creatorUsername", null, false, false, false); 16 | 17 | private TestUtils() { 18 | } 19 | 20 | @NotNull 21 | static Update mockFullUpdate(AbilityBot bot, User user, String args) { 22 | bot.users().put(USER.getId(), USER); 23 | bot.users().put(CREATOR.getId(), CREATOR); 24 | bot.userIds().put(CREATOR.getUserName(), CREATOR.getId()); 25 | bot.userIds().put(USER.getUserName(), USER.getId()); 26 | 27 | bot.admins().add(CREATOR.getId()); 28 | 29 | Update update = mock(Update.class); 30 | when(update.hasMessage()).thenReturn(true); 31 | Message message = mock(Message.class); 32 | when(message.getText()).thenReturn(args); 33 | when(message.hasText()).thenReturn(true); 34 | when(update.getMessage()).thenReturn(message); 35 | return update; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/main/resources/db/migration/V0.1.0.10__states.sql: -------------------------------------------------------------------------------- 1 | SET SQL_MODE = "NO_AUTO_VALUE_ON_ZERO"; 2 | START TRANSACTION; 3 | SET time_zone = "+00:00"; 4 | 5 | 6 | /*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */; 7 | /*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */; 8 | /*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */; 9 | /*!40101 SET NAMES utf8mb4 */; 10 | 11 | -- 12 | -- Database: `covid19` 13 | -- 14 | 15 | -- 16 | -- Dumping data for table `states` 17 | -- 18 | 19 | INSERT INTO `states` (`id`, `state_name`) VALUES 20 | (1, 'Andaman and Nicobar Islands'), 21 | (2, 'Andhra Pradesh'), 22 | (3, 'Arunachal Pradesh'), 23 | (4, 'Assam'), 24 | (5, 'Bihar'), 25 | (6, 'Chandigarh'), 26 | (7, 'Chhattisgarh'), 27 | (8, 'Dadra and Nagar Haveli'), 28 | (37, 'Daman and Diu'), 29 | (9, 'Delhi'), 30 | (10, 'Goa'), 31 | (11, 'Gujarat'), 32 | (12, 'Haryana'), 33 | (13, 'Himachal Pradesh'), 34 | (14, 'Jammu and Kashmir'), 35 | (15, 'Jharkhand'), 36 | (16, 'Karnataka'), 37 | (17, 'Kerala'), 38 | (18, 'Ladakh'), 39 | (19, 'Lakshadweep'), 40 | (20, 'Madhya Pradesh'), 41 | (21, 'Maharashtra'), 42 | (22, 'Manipur'), 43 | (23, 'Meghalaya'), 44 | (24, 'Mizoram'), 45 | (25, 'Nagaland'), 46 | (26, 'Odisha'), 47 | (27, 'Puducherry'), 48 | (28, 'Punjab'), 49 | (29, 'Rajasthan'), 50 | (30, 'Sikkim'), 51 | (31, 'Tamil Nadu'), 52 | (32, 'Telangana'), 53 | (33, 'Tripura'), 54 | (34, 'Uttar Pradesh'), 55 | (35, 'Uttarakhand'), 56 | (36, 'West Bengal'); 57 | COMMIT; 58 | 59 | /*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */; 60 | /*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */; 61 | /*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */; 62 | -------------------------------------------------------------------------------- /src/main/java/org/covid19/vaccinetracker/userrequests/MetadataStore.java: -------------------------------------------------------------------------------- 1 | package org.covid19.vaccinetracker.userrequests; 2 | 3 | import org.covid19.vaccinetracker.userrequests.model.District; 4 | import org.covid19.vaccinetracker.userrequests.model.Pincode; 5 | import org.covid19.vaccinetracker.persistence.mariadb.repository.DistrictRepository; 6 | import org.covid19.vaccinetracker.persistence.mariadb.repository.PincodeRepository; 7 | import org.springframework.stereotype.Component; 8 | 9 | import java.util.List; 10 | 11 | @Component 12 | public class MetadataStore { 13 | private final DistrictRepository districtRepository; 14 | private final PincodeRepository pincodeRepository; 15 | 16 | public MetadataStore(DistrictRepository districtRepository, PincodeRepository pincodeRepository) { 17 | this.districtRepository = districtRepository; 18 | this.pincodeRepository = pincodeRepository; 19 | } 20 | 21 | public boolean pincodeExists(String pincode) { 22 | return this.pincodeRepository.existsByPincode(pincode); 23 | } 24 | 25 | public void persistPincode(Pincode pincode) { 26 | this.pincodeRepository.save(pincode); 27 | } 28 | 29 | public List fetchDistrictsByPincode(String pincode) { 30 | return districtRepository.findDistrictByPincode(pincode); 31 | } 32 | 33 | public District fetchDistrictByNameAndState(String districtName, String stateName) { 34 | return this.districtRepository.findDistrictByDistrictNameAndState(districtName, stateName); 35 | } 36 | 37 | public List fetchPincodesByDistrictId(Integer districtId) { 38 | return this.pincodeRepository.findPincodeByDistrictId(districtId); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/test/resources/import.sql: -------------------------------------------------------------------------------- 1 | -- noinspection SqlResolveForFile 2 | 3 | INSERT INTO vaccine_centers (id, address, district_name, fee_type, name, pincode, state_name) 4 | VALUES (383358, NULL, 'Madhepura', 'Free', 'Gamhariya PHC', '852108', 'Bihar'), 5 | (3, NULL, 'Central Delhi', 'Free', 'Aruna Asaf Ali Hospital DH', '110054', 'Delhi'), 6 | (168, NULL, 'Kaithal', 'Free', 'PHC Batta Covishield 18-45', '136117', 'Haryana'), 7 | (190, NULL, 'Kaithal', 'Free', 'SC Bhunsla Covishield Above 45', '136034', 'Haryana'), 8 | (196, NULL, 'Kaithal', 'Free', 'SC Rewar Jagir', '136034', 'Haryana'), 9 | (259, NULL, 'Kaithal', 'Free', 'SC Chaba', '136034', 'Haryana'), 10 | (276, NULL, 'Kaithal', 'Free', 'PHC Habri Covishield 18-45', '136026', 'Haryana'), 11 | (280, NULL, 'Kaithal', 'Free', 'SC Deeg', '136026', 'Haryana'), 12 | (328, NULL, 'Kaithal', 'Free', 'SC Kheri Gulam Ali Above 45', '136035', 'Haryana'), 13 | (436, NULL, 'Kaithal', 'Free', 'SC Kakaut', '136027', 'Haryana'); 14 | 15 | INSERT INTO sessions (id, available_capacity, available_capacity_dose1, 16 | available_capacity_dose2, date, min_age_limit, vaccine, 17 | processed_at) 18 | VALUES ('001813bc-1607-42d9-9ef6-e58ba4e42d1d', 98, 48, 50, '23-05-2021', 45, 'COVISHIELD', NULL); 19 | 20 | INSERT INTO vaccine_centers_sessions (center_entity_id, sessions_id) 21 | VALUES (383358, '001813bc-1607-42d9-9ef6-e58ba4e42d1d'); 22 | 23 | INSERT INTO states(id, state_name) 24 | VALUES (12, 'Haryana'); 25 | 26 | INSERT INTO districts(id, district_name, state_id) 27 | VALUES (201, 'Charkhi Dadri', 12); 28 | 29 | INSERT INTO pincodes (id, pincode, district_id) 30 | VALUES ('05c1ed93-ae15-11eb-9793-7a9bcab32cce', '127310', 201); 31 | 32 | COMMIT; 33 | -------------------------------------------------------------------------------- /src/main/java/org/covid19/vaccinetracker/persistence/kafka/KafkaConfig.java: -------------------------------------------------------------------------------- 1 | package org.covid19.vaccinetracker.persistence.kafka; 2 | 3 | import org.covid19.vaccinetracker.model.VaccineCenters; 4 | 5 | import org.apache.kafka.clients.producer.ProducerConfig; 6 | import org.apache.kafka.common.serialization.StringSerializer; 7 | import org.springframework.boot.autoconfigure.kafka.KafkaProperties; 8 | import org.springframework.context.annotation.Bean; 9 | import org.springframework.context.annotation.Configuration; 10 | import org.springframework.kafka.core.DefaultKafkaProducerFactory; 11 | import org.springframework.kafka.core.KafkaTemplate; 12 | import org.springframework.kafka.core.ProducerFactory; 13 | 14 | import java.util.HashMap; 15 | import java.util.Map; 16 | 17 | @Configuration 18 | public class KafkaConfig { 19 | private final KafkaProperties kafkaProperties; 20 | 21 | public KafkaConfig(KafkaProperties kafkaProperties) { 22 | this.kafkaProperties = kafkaProperties; 23 | } 24 | 25 | @Bean 26 | public Map producerConfigs() { 27 | Map props = new HashMap<>(kafkaProperties.buildProducerProperties()); 28 | props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getCanonicalName()); 29 | props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, "io.confluent.kafka.serializers.KafkaJsonSerializer"); 30 | return props; 31 | } 32 | 33 | @Bean 34 | public ProducerFactory producerFactory() { 35 | return new DefaultKafkaProducerFactory<>(producerConfigs()); 36 | } 37 | 38 | @Bean 39 | public KafkaTemplate kafkaTemplate() { 40 | return new KafkaTemplate<>(producerFactory()); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/main/java/org/covid19/vaccinetracker/notifications/bot/BotBackend.java: -------------------------------------------------------------------------------- 1 | package org.covid19.vaccinetracker.notifications.bot; 2 | 3 | import org.covid19.vaccinetracker.userrequests.UserRequestManager; 4 | import org.covid19.vaccinetracker.userrequests.model.Age; 5 | import org.covid19.vaccinetracker.userrequests.model.Dose; 6 | import org.covid19.vaccinetracker.userrequests.model.UserRequest; 7 | import org.covid19.vaccinetracker.userrequests.model.Vaccine; 8 | import org.springframework.stereotype.Service; 9 | 10 | import java.util.List; 11 | 12 | import lombok.extern.slf4j.Slf4j; 13 | 14 | import static java.util.Collections.emptyList; 15 | 16 | @Slf4j 17 | @Service 18 | public class BotBackend { 19 | private final UserRequestManager userRequestManager; 20 | 21 | public BotBackend(UserRequestManager userRequestManager) { 22 | this.userRequestManager = userRequestManager; 23 | } 24 | 25 | public void acceptUserRequest(String userId, List pincodes) { 26 | userRequestManager.acceptUserRequest(userId, pincodes); 27 | } 28 | 29 | public void cancelUserRequest(String userId) { 30 | userRequestManager.acceptUserRequest(userId, emptyList()); 31 | } 32 | 33 | public UserRequest fetchUserSubscriptions(String userId) { 34 | return userRequestManager.fetchUserRequest(userId); 35 | } 36 | 37 | public void updateAgePreference(String chatId, Age age) { 38 | userRequestManager.updateAgePreference(chatId, age); 39 | } 40 | 41 | public void updateDosePreference(String chatId, Dose dose) { 42 | userRequestManager.updateDosePreference(chatId, dose); 43 | } 44 | 45 | public void updateVaccinePreference(String chatId, Vaccine vaccine) { 46 | userRequestManager.updateVaccinePreference(chatId, vaccine); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/main/java/org/covid19/vaccinetracker/model/VaccineCentersSerde.java: -------------------------------------------------------------------------------- 1 | package org.covid19.vaccinetracker.model; 2 | 3 | import com.fasterxml.jackson.core.JsonProcessingException; 4 | import com.fasterxml.jackson.databind.ObjectMapper; 5 | 6 | import org.apache.kafka.common.serialization.Deserializer; 7 | import org.apache.kafka.common.serialization.Serdes; 8 | import org.apache.kafka.common.serialization.Serializer; 9 | 10 | import java.io.IOException; 11 | 12 | import lombok.extern.slf4j.Slf4j; 13 | 14 | @Slf4j 15 | public class VaccineCentersSerde extends Serdes.WrapperSerde { 16 | 17 | public VaccineCentersSerde() { 18 | super(new Serializer<>() { 19 | final ObjectMapper mapper = new ObjectMapper(); 20 | 21 | @Override 22 | public byte[] serialize(String s, VaccineCenters vaccineCenters) { 23 | try { 24 | mapper.writeValueAsBytes(vaccineCenters); 25 | } catch (JsonProcessingException e) { 26 | log.error("Error serializing vaccineCenters inside serde {}, {}", e.getMessage(), vaccineCenters); 27 | } 28 | return null; 29 | } 30 | }, new Deserializer<>() { 31 | final ObjectMapper mapper = new ObjectMapper(); 32 | 33 | @Override 34 | public VaccineCenters deserialize(String s, byte[] bytes) { 35 | if (bytes == null) { // handle tombstone records. 36 | return null; 37 | } 38 | try { 39 | return mapper.readValue(bytes, VaccineCenters.class); 40 | } catch (IOException e) { 41 | log.error("Error deserializing vaccineCenters inside serde {}", e.getMessage()); 42 | } 43 | return null; 44 | } 45 | }); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/main/java/org/covid19/vaccinetracker/availability/UpdatedPincodesProducerConfig.java: -------------------------------------------------------------------------------- 1 | package org.covid19.vaccinetracker.availability; 2 | 3 | import org.apache.kafka.clients.producer.ProducerConfig; 4 | import org.apache.kafka.common.serialization.Serdes; 5 | import org.springframework.boot.autoconfigure.kafka.KafkaProperties; 6 | import org.springframework.context.annotation.Bean; 7 | import org.springframework.context.annotation.Configuration; 8 | import org.springframework.kafka.core.DefaultKafkaProducerFactory; 9 | import org.springframework.kafka.core.KafkaTemplate; 10 | import org.springframework.kafka.core.ProducerFactory; 11 | 12 | import java.util.HashMap; 13 | import java.util.Map; 14 | 15 | @Configuration 16 | public class UpdatedPincodesProducerConfig { 17 | private final KafkaProperties kafkaProperties; 18 | 19 | public UpdatedPincodesProducerConfig(KafkaProperties kafkaProperties) { 20 | this.kafkaProperties = kafkaProperties; 21 | } 22 | 23 | @Bean 24 | public Map updatedPincodesProducerConfigs() { 25 | Map props = new HashMap<>(kafkaProperties.buildProducerProperties()); 26 | 27 | props.put(ProducerConfig.CLIENT_ID_CONFIG, "org.covid19.updated-pincodes-producer"); 28 | props.put(ProducerConfig.LINGER_MS_CONFIG, "5"); 29 | props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, Serdes.String().serializer().getClass().getName()); 30 | props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, "io.confluent.kafka.serializers.KafkaJsonSerializer"); 31 | 32 | return props; 33 | } 34 | 35 | @Bean 36 | public ProducerFactory updatedPincodesProducerFactory() { 37 | return new DefaultKafkaProducerFactory<>(updatedPincodesProducerConfigs()); 38 | } 39 | 40 | @Bean 41 | public KafkaTemplate updatedPincodesKafkaTemplate() { 42 | return new KafkaTemplate<>(updatedPincodesProducerFactory()); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/main/java/org/covid19/vaccinetracker/availability/PriorityDistrictsAvailability.java: -------------------------------------------------------------------------------- 1 | package org.covid19.vaccinetracker.availability; 2 | 3 | import com.google.common.util.concurrent.ThreadFactoryBuilder; 4 | 5 | import org.covid19.vaccinetracker.availability.aws.CowinLambdaWrapper; 6 | import org.springframework.scheduling.annotation.Scheduled; 7 | import org.springframework.stereotype.Component; 8 | 9 | import java.util.Objects; 10 | import java.util.concurrent.Executors; 11 | 12 | import lombok.extern.slf4j.Slf4j; 13 | 14 | /** 15 | * Schedules availability check of priority districts 16 | */ 17 | @Slf4j 18 | @Component 19 | public class PriorityDistrictsAvailability { 20 | private final AvailabilityConfig config; 21 | private final CowinLambdaWrapper cowinLambdaWrapper; 22 | 23 | public PriorityDistrictsAvailability(AvailabilityConfig config, CowinLambdaWrapper cowinLambdaWrapper) { 24 | this.config = config; 25 | this.cowinLambdaWrapper = cowinLambdaWrapper; 26 | } 27 | 28 | @Scheduled(cron = "${jobs.cron.priority.districts.availability:-}", zone = "IST") 29 | public void refreshVaccineAvailabilityOfPriorityDistricts() { 30 | Executors.newSingleThreadExecutor(new ThreadFactoryBuilder().setNameFormat("prio-dist-%d").build()) 31 | .submit(this::refreshPriorityDistrictsAvailabilityFromCowinViaLambdaAsync); 32 | } 33 | 34 | public void refreshPriorityDistrictsAvailabilityFromCowinViaLambdaAsync() { 35 | log.info("Refreshing Availability of Priority Districts via Lambda async"); 36 | 37 | config.getPriorityDistricts() 38 | .parallelStream() 39 | .filter(Objects::nonNull) 40 | .mapToInt(Integer::valueOf) 41 | .boxed() 42 | .peek(district -> log.debug("processing priority district id {}", district)) 43 | .forEach(cowinLambdaWrapper::processDistrict); 44 | 45 | log.info("Availability check of priority districts completed"); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/main/java/org/covid19/vaccinetracker/userrequests/UserRequestProducerConfig.java: -------------------------------------------------------------------------------- 1 | package org.covid19.vaccinetracker.userrequests; 2 | 3 | import org.apache.kafka.clients.producer.ProducerConfig; 4 | import org.apache.kafka.common.serialization.Serdes; 5 | import org.covid19.vaccinetracker.userrequests.model.UserRequest; 6 | import org.springframework.boot.autoconfigure.kafka.KafkaProperties; 7 | import org.springframework.context.annotation.Bean; 8 | import org.springframework.context.annotation.Configuration; 9 | import org.springframework.kafka.core.DefaultKafkaProducerFactory; 10 | import org.springframework.kafka.core.KafkaTemplate; 11 | import org.springframework.kafka.core.ProducerFactory; 12 | 13 | import java.util.HashMap; 14 | import java.util.Map; 15 | 16 | import static org.apache.kafka.clients.producer.ProducerConfig.CLIENT_ID_CONFIG; 17 | 18 | @Configuration 19 | public class UserRequestProducerConfig { 20 | private final KafkaProperties kafkaProperties; 21 | 22 | public UserRequestProducerConfig(KafkaProperties kafkaProperties) { 23 | this.kafkaProperties = kafkaProperties; 24 | } 25 | 26 | @Bean 27 | public Map userRequestProducerConfigs() { 28 | Map props = new HashMap<>(kafkaProperties.buildProducerProperties()); 29 | 30 | props.put(CLIENT_ID_CONFIG, "org.covid19.user-request-producer"); 31 | props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, Serdes.String().serializer().getClass().getName()); 32 | props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, "io.confluent.kafka.serializers.KafkaJsonSerializer"); 33 | 34 | return props; 35 | } 36 | 37 | @Bean 38 | public ProducerFactory userRequestProducerFactory() { 39 | return new DefaultKafkaProducerFactory<>(userRequestProducerConfigs()); 40 | } 41 | 42 | @Bean 43 | public KafkaTemplate userRequestKafkaTemplate() { 44 | return new KafkaTemplate<>(userRequestProducerFactory()); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/main/java/org/covid19/vaccinetracker/notifications/DistrictNotifications.java: -------------------------------------------------------------------------------- 1 | package org.covid19.vaccinetracker.notifications; 2 | 3 | import org.covid19.vaccinetracker.notifications.bot.BotService; 4 | import org.covid19.vaccinetracker.availability.cowin.CowinApiClient; 5 | import org.covid19.vaccinetracker.model.Center; 6 | import org.covid19.vaccinetracker.model.VaccineCenters; 7 | import org.springframework.scheduling.annotation.Scheduled; 8 | import org.springframework.stereotype.Service; 9 | 10 | import java.util.List; 11 | 12 | import lombok.extern.slf4j.Slf4j; 13 | 14 | @Slf4j 15 | @Service 16 | public class DistrictNotifications { 17 | private static final String MUMBAI_DISTRICT_CHANNEL_ID = "-1001318272903"; 18 | private static final int MUMBAI_DISTRICT_ID = 395; 19 | private final BotService botService; 20 | private final VaccineCentersProcessor vaccineCentersProcessor; 21 | private final CowinApiClient cowinApiClient; 22 | 23 | public DistrictNotifications(BotService botService, VaccineCentersProcessor vaccineCentersProcessor, CowinApiClient cowinApiClient) { 24 | this.botService = botService; 25 | this.vaccineCentersProcessor = vaccineCentersProcessor; 26 | this.cowinApiClient = cowinApiClient; 27 | } 28 | 29 | @Scheduled(cron = "${jobs.cron.district.notifications:-}", zone = "IST") 30 | public void sendDistrictNotifications() { 31 | final VaccineCenters vaccineCenters = cowinApiClient.fetchSessionsByDistrict(MUMBAI_DISTRICT_ID); 32 | final List
eligibleCenters = vaccineCentersProcessor.eligibleVaccineCenters(vaccineCenters, "999999"); 33 | if (eligibleCenters.isEmpty()) { 34 | log.debug("No eligible vaccine centers found for district update"); 35 | return; 36 | } 37 | if (botService.notifyAvailability(MUMBAI_DISTRICT_CHANNEL_ID, eligibleCenters)) { 38 | log.info("Sending update for Mumbai district on dedicated channel with {} eligible centers", eligibleCenters.size()); 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/test/java/org/covid19/vaccinetracker/userrequests/MetadataStoreTest.java: -------------------------------------------------------------------------------- 1 | package org.covid19.vaccinetracker.userrequests; 2 | 3 | import org.covid19.vaccinetracker.userrequests.model.District; 4 | import org.covid19.vaccinetracker.userrequests.model.State; 5 | import org.covid19.vaccinetracker.persistence.mariadb.repository.DistrictRepository; 6 | import org.covid19.vaccinetracker.persistence.mariadb.repository.PincodeRepository; 7 | import org.junit.jupiter.api.BeforeEach; 8 | import org.junit.jupiter.api.Test; 9 | import org.springframework.beans.factory.annotation.Autowired; 10 | import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; 11 | 12 | import static java.util.Collections.singletonList; 13 | import static org.junit.jupiter.api.Assertions.assertEquals; 14 | import static org.junit.jupiter.api.Assertions.assertFalse; 15 | import static org.junit.jupiter.api.Assertions.assertTrue; 16 | 17 | @DataJpaTest 18 | public class MetadataStoreTest { 19 | @Autowired 20 | private DistrictRepository districtRepository; 21 | @Autowired 22 | private PincodeRepository pincodeRepository; 23 | 24 | private MetadataStore metadataStore; 25 | 26 | @BeforeEach 27 | public void setup() { 28 | this.metadataStore = new MetadataStore(districtRepository, pincodeRepository); 29 | } 30 | 31 | @Test 32 | public void testPincodeExists() { 33 | assertTrue(metadataStore.pincodeExists("127310")); 34 | assertFalse(metadataStore.pincodeExists("440017")); 35 | } 36 | 37 | @Test 38 | public void testFetchDistrictByNameAndState() { 39 | final District expected = new District(201, "Charkhi Dadri", new State(12, "Haryana")); 40 | assertEquals(expected, metadataStore.fetchDistrictByNameAndState("Charkhi Dadri", "Haryana")); 41 | } 42 | 43 | @Test 44 | public void testFetchDistrictsByPincode() { 45 | final District expected = new District(201, "Charkhi Dadri", new State(12, "Haryana")); 46 | assertEquals(singletonList(expected), metadataStore.fetchDistrictsByPincode("127310")); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/main/java/org/covid19/vaccinetracker/model/Center.java: -------------------------------------------------------------------------------- 1 | package org.covid19.vaccinetracker.model; 2 | 3 | import com.fasterxml.jackson.annotation.JsonInclude; 4 | import com.fasterxml.jackson.annotation.JsonProperty; 5 | 6 | import java.util.Collection; 7 | import java.util.List; 8 | 9 | import lombok.AllArgsConstructor; 10 | import lombok.Builder; 11 | import lombok.Data; 12 | import lombok.NoArgsConstructor; 13 | 14 | import static java.util.Optional.ofNullable; 15 | 16 | @JsonInclude(JsonInclude.Include.NON_NULL) 17 | @Data 18 | @Builder 19 | @AllArgsConstructor 20 | @NoArgsConstructor 21 | public class Center { 22 | 23 | @JsonProperty("center_id") 24 | public Integer centerId; 25 | @JsonProperty("name") 26 | public String name; 27 | @JsonProperty("state_name") 28 | public String stateName; 29 | @JsonProperty("district_name") 30 | public String districtName; 31 | @JsonProperty("block_name") 32 | public String blockName; 33 | @JsonProperty("pincode") 34 | public Integer pincode; 35 | @JsonProperty("lat") 36 | public Integer latitude; 37 | @JsonProperty("long") 38 | public Integer longitude; 39 | @JsonProperty("from") 40 | public String from; 41 | @JsonProperty("to") 42 | public String to; 43 | @JsonProperty("fee_type") 44 | public String feeType; 45 | @JsonProperty("sessions") 46 | public List sessions = null; 47 | @JsonProperty("vaccine_fees") 48 | public List vaccineFees; 49 | 50 | public boolean areVaccineCentersAvailableFor18plus() { 51 | return this.getSessions() 52 | .stream() 53 | .anyMatch(session -> session.ageLimit18AndAbove() && session.hasCapacity()); 54 | } 55 | 56 | public boolean paid() { 57 | return ofNullable(this.feeType).map(s -> s.equalsIgnoreCase("Paid")).orElse(false); 58 | } 59 | 60 | public String costFor(String vaccine) { 61 | return ofNullable(this.vaccineFees) 62 | .stream().flatMap(Collection::stream) 63 | .filter(vaccineFee -> vaccineFee.isVaccine(vaccine)) 64 | .map(VaccineFee::getFee) 65 | .findFirst() 66 | .orElse("Unknown"); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/main/java/org/covid19/vaccinetracker/userrequests/reconciliation/ReconciliationStats.java: -------------------------------------------------------------------------------- 1 | package org.covid19.vaccinetracker.userrequests.reconciliation; 2 | 3 | import org.springframework.stereotype.Component; 4 | 5 | import java.time.Duration; 6 | import java.time.Instant; 7 | import java.util.concurrent.atomic.AtomicInteger; 8 | 9 | @Component 10 | public class ReconciliationStats { 11 | private final AtomicInteger unknownPincodes = new AtomicInteger(0); 12 | private final AtomicInteger failedReconciliations = new AtomicInteger(0); 13 | private final AtomicInteger failedWithUnknownDistrict = new AtomicInteger(0); 14 | private final AtomicInteger successfulReconciliations = new AtomicInteger(0); 15 | private Instant startTime; 16 | private Instant endTime; 17 | 18 | public void reset() { 19 | unknownPincodes.set(0); 20 | failedReconciliations.set(0); 21 | failedWithUnknownDistrict.set(0); 22 | successfulReconciliations.set(0); 23 | } 24 | 25 | public void incrementUnknownPincodes() { 26 | unknownPincodes.incrementAndGet(); 27 | } 28 | 29 | public void incrementFailedReconciliations() { 30 | failedReconciliations.incrementAndGet(); 31 | } 32 | 33 | public void incrementSuccessfulReconciliations() { 34 | successfulReconciliations.incrementAndGet(); 35 | } 36 | 37 | public void incrementFailedWithUnknownDistrict() { 38 | failedWithUnknownDistrict.incrementAndGet(); 39 | } 40 | 41 | public void noteStartTime() { 42 | startTime = Instant.now(); 43 | } 44 | 45 | public void noteEndTime() { 46 | endTime = Instant.now(); 47 | } 48 | 49 | public int unknownPincodes() { 50 | return unknownPincodes.get(); 51 | } 52 | 53 | public int failedReconciliations() { 54 | return failedReconciliations.get(); 55 | } 56 | 57 | public int failedWithUnknownDistrict() { 58 | return failedWithUnknownDistrict.get(); 59 | } 60 | 61 | public int successfulReconciliations() { 62 | return successfulReconciliations.get(); 63 | } 64 | 65 | public String timeTaken() { 66 | return Duration.between(startTime, endTime).toString(); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/main/java/org/covid19/vaccinetracker/notifications/NotificationStats.java: -------------------------------------------------------------------------------- 1 | package org.covid19.vaccinetracker.notifications; 2 | 3 | import org.springframework.stereotype.Component; 4 | 5 | import java.time.Duration; 6 | import java.time.Instant; 7 | import java.util.concurrent.atomic.AtomicInteger; 8 | 9 | @Component 10 | public class NotificationStats { 11 | private final AtomicInteger userRequests = new AtomicInteger(0); 12 | private final AtomicInteger processedPincodes = new AtomicInteger(0); 13 | private final AtomicInteger failedApiCalls = new AtomicInteger(0); 14 | private final AtomicInteger notificationsSent = new AtomicInteger(0); 15 | private final AtomicInteger notificationsErrors = new AtomicInteger(0); 16 | private Instant startTime; 17 | private Instant endTime; 18 | 19 | public void reset() { 20 | userRequests.set(0); 21 | processedPincodes.set(0); 22 | failedApiCalls.set(0); 23 | notificationsSent.set(0); 24 | notificationsErrors.set(0); 25 | } 26 | 27 | public void incrementUserRequests() { 28 | userRequests.incrementAndGet(); 29 | } 30 | 31 | public void incrementProcessedPincodes() { 32 | processedPincodes.incrementAndGet(); 33 | } 34 | 35 | public void incrementfailedApiCalls() { 36 | failedApiCalls.incrementAndGet(); 37 | } 38 | 39 | public void incrementNotificationsSent() { 40 | notificationsSent.incrementAndGet(); 41 | } 42 | 43 | public void incrementNotificationsErrors() { 44 | notificationsErrors.incrementAndGet(); 45 | } 46 | 47 | public void noteStartTime() { 48 | startTime = Instant.now(); 49 | } 50 | 51 | public void noteEndTime() { 52 | endTime = Instant.now(); 53 | } 54 | 55 | public int userRequests() { 56 | return userRequests.get(); 57 | } 58 | 59 | public int processedPincodes() { 60 | return processedPincodes.get(); 61 | } 62 | 63 | public int failedApiCalls() { 64 | return failedApiCalls.get(); 65 | } 66 | 67 | public int notificationsSent() { 68 | return notificationsSent.get(); 69 | } 70 | 71 | public int notificationsErrors() { 72 | return notificationsErrors.get(); 73 | } 74 | 75 | public String timeTaken() { 76 | return Duration.between(startTime, endTime).toString(); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/main/java/org/covid19/vaccinetracker/availability/AvailabilityStats.java: -------------------------------------------------------------------------------- 1 | package org.covid19.vaccinetracker.availability; 2 | 3 | import org.springframework.stereotype.Component; 4 | 5 | import java.time.Duration; 6 | import java.time.Instant; 7 | import java.util.concurrent.atomic.AtomicInteger; 8 | 9 | import lombok.extern.slf4j.Slf4j; 10 | 11 | @Slf4j 12 | @Component 13 | public class AvailabilityStats { 14 | private final AtomicInteger processedPincodes = new AtomicInteger(0); 15 | private final AtomicInteger processedDistricts = new AtomicInteger(0); 16 | private final AtomicInteger totalApiCalls = new AtomicInteger(0); 17 | private final AtomicInteger failedApiCalls = new AtomicInteger(0); 18 | private final AtomicInteger unknownPincodes = new AtomicInteger(0); 19 | private Instant startTime; 20 | private Instant endTime; 21 | 22 | public void reset() { 23 | processedPincodes.set(0); 24 | processedDistricts.set(0); 25 | totalApiCalls.set(0); 26 | failedApiCalls.set(0); 27 | unknownPincodes.set(0); 28 | } 29 | 30 | public void incrementProcessedPincodes() { 31 | processedPincodes.incrementAndGet(); 32 | } 33 | 34 | public void incrementProcessedDistricts() { 35 | processedDistricts.incrementAndGet(); 36 | } 37 | 38 | public void incrementFailedApiCalls() { 39 | failedApiCalls.incrementAndGet(); 40 | } 41 | 42 | public void incrementTotalApiCalls() { 43 | totalApiCalls.incrementAndGet(); 44 | } 45 | 46 | public void incrementUnknownPincodes() { 47 | unknownPincodes.incrementAndGet(); 48 | } 49 | 50 | public void noteStartTime() { 51 | startTime = Instant.now(); 52 | } 53 | 54 | public void noteEndTime() { 55 | endTime = Instant.now(); 56 | } 57 | 58 | public int processedPincodes() { 59 | return processedPincodes.get(); 60 | } 61 | 62 | public int processedDistricts() { 63 | return processedDistricts.get(); 64 | } 65 | 66 | public int failedApiCalls() { 67 | return failedApiCalls.get(); 68 | } 69 | 70 | public int totalApiCalls() { 71 | return totalApiCalls.get(); 72 | } 73 | 74 | public int unknownPincodes() { 75 | return unknownPincodes.get(); 76 | } 77 | 78 | public String timeTaken() { 79 | return Duration.between(startTime, endTime).toString(); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/main/java/org/covid19/vaccinetracker/availability/cowin/WebClientFilter.java: -------------------------------------------------------------------------------- 1 | package org.covid19.vaccinetracker.availability.cowin; 2 | 3 | import org.springframework.http.HttpStatus; 4 | import org.springframework.web.reactive.function.client.ClientRequest; 5 | import org.springframework.web.reactive.function.client.ClientResponse; 6 | import org.springframework.web.reactive.function.client.ExchangeFilterFunction; 7 | 8 | import lombok.extern.slf4j.Slf4j; 9 | import reactor.core.publisher.Mono; 10 | 11 | @Slf4j 12 | public class WebClientFilter { 13 | public static ExchangeFilterFunction logRequest() { 14 | return ExchangeFilterFunction.ofRequestProcessor(request -> { 15 | logMethodAndUrl(request); 16 | logHeaders(request); 17 | return Mono.just(request); 18 | }); 19 | } 20 | 21 | public static ExchangeFilterFunction logResponse() { 22 | return ExchangeFilterFunction.ofResponseProcessor(response -> { 23 | logStatus(response); 24 | logHeaders(response); 25 | 26 | return logBody(response); 27 | }); 28 | } 29 | 30 | private static void logStatus(ClientResponse response) { 31 | HttpStatus status = response.statusCode(); 32 | log.debug("Returned status code {} ({})", status.value(), status.getReasonPhrase()); 33 | } 34 | 35 | 36 | private static Mono logBody(ClientResponse response) { 37 | if (response.statusCode().is4xxClientError() || response.statusCode().is5xxServerError()) { 38 | return response.bodyToMono(String.class) 39 | .flatMap(body -> { 40 | log.debug("Body is {}", body); 41 | return Mono.error(new CowinException(body, response.rawStatusCode())); 42 | }); 43 | } else { 44 | return Mono.just(response); 45 | } 46 | } 47 | 48 | private static void logHeaders(ClientResponse response) { 49 | response.headers().asHttpHeaders().forEach((name, values) -> values.forEach(value -> logNameAndValuePair(name, value))); 50 | } 51 | 52 | private static void logHeaders(ClientRequest request) { 53 | request.headers().forEach((name, values) -> values.forEach(value -> logNameAndValuePair(name, value))); 54 | } 55 | 56 | private static void logNameAndValuePair(String name, String value) { 57 | log.debug("{}={}", name, value); 58 | } 59 | 60 | 61 | private static void logMethodAndUrl(ClientRequest request) { 62 | String sb = request.method().name() + 63 | " to " + 64 | request.url(); 65 | log.debug(sb); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/main/java/org/covid19/vaccinetracker/model/Session.java: -------------------------------------------------------------------------------- 1 | package org.covid19.vaccinetracker.model; 2 | 3 | import com.fasterxml.jackson.annotation.JsonIgnore; 4 | import com.fasterxml.jackson.annotation.JsonInclude; 5 | import com.fasterxml.jackson.annotation.JsonProperty; 6 | 7 | import java.util.List; 8 | import java.util.Optional; 9 | 10 | import lombok.AllArgsConstructor; 11 | import lombok.Builder; 12 | import lombok.Data; 13 | import lombok.NoArgsConstructor; 14 | 15 | import static java.util.Optional.ofNullable; 16 | import static org.covid19.vaccinetracker.userrequests.model.Vaccine.COVAXIN; 17 | import static org.covid19.vaccinetracker.userrequests.model.Vaccine.COVISHIELD; 18 | import static org.covid19.vaccinetracker.userrequests.model.Vaccine.SPUTNIK_V; 19 | 20 | @JsonInclude(JsonInclude.Include.NON_NULL) 21 | @Data 22 | @Builder 23 | @AllArgsConstructor 24 | @NoArgsConstructor 25 | public class Session { 26 | 27 | @JsonProperty("session_id") 28 | public String sessionId; 29 | @JsonProperty("date") 30 | public String date; 31 | @JsonProperty("available_capacity") 32 | public Integer availableCapacity; 33 | @JsonProperty("available_capacity_dose1") 34 | public Integer availableCapacityDose1; 35 | @JsonProperty("available_capacity_dose2") 36 | public Integer availableCapacityDose2; 37 | @JsonProperty("min_age_limit") 38 | public Integer minAgeLimit; 39 | @JsonProperty("allow_all_age") 40 | public Boolean allowAllAge; 41 | @JsonProperty("vaccine") 42 | public String vaccine; 43 | @JsonIgnore 44 | private String cost; 45 | @JsonProperty("slots") 46 | public List slots = null; 47 | @JsonIgnore 48 | public boolean shouldNotify = true; 49 | 50 | public boolean validForAllAges() { 51 | return ofNullable(allowAllAge).orElse(false); 52 | } 53 | 54 | public boolean validBetween18And44() { 55 | return minAgeLimit >= 18 && minAgeLimit <= 44; 56 | } 57 | 58 | public boolean validFor45Above() { 59 | return minAgeLimit >= 45; 60 | } 61 | 62 | public boolean ageLimit18AndAbove() { 63 | return minAgeLimit >= 18; 64 | } 65 | 66 | public boolean hasCapacity() { 67 | return (availableCapacityDose1 >= 10 || availableCapacityDose2 >= 10) 68 | && (availableCapacity == (availableCapacityDose1 + availableCapacityDose2)); 69 | } 70 | 71 | public boolean hasDose1Capacity() { 72 | return availableCapacityDose1 >= 10; 73 | } 74 | 75 | public boolean hasDose2Capacity() { 76 | return availableCapacityDose2 >= 10; 77 | } 78 | 79 | public boolean hasCovishield() { 80 | return COVISHIELD.toString().equalsIgnoreCase(vaccine); 81 | } 82 | 83 | public boolean hasCovaxin() { 84 | return COVAXIN.toString().equalsIgnoreCase(vaccine); 85 | } 86 | 87 | public boolean hasSputnikV() { 88 | return SPUTNIK_V.toString().equalsIgnoreCase(vaccine); 89 | } 90 | } -------------------------------------------------------------------------------- /src/main/java/org/covid19/vaccinetracker/availability/cowin/CowinApiOtpClient.java: -------------------------------------------------------------------------------- 1 | package org.covid19.vaccinetracker.availability.cowin; 2 | 3 | import org.covid19.vaccinetracker.availability.model.ConfirmOtpResponse; 4 | import org.covid19.vaccinetracker.availability.model.GenerateOtpResponse; 5 | import org.springframework.stereotype.Component; 6 | import org.springframework.web.reactive.function.BodyInserters; 7 | import org.springframework.web.reactive.function.client.WebClient; 8 | 9 | import java.util.Map; 10 | 11 | import lombok.extern.slf4j.Slf4j; 12 | 13 | import static java.util.Map.entry; 14 | import static org.springframework.http.HttpHeaders.CONTENT_TYPE; 15 | import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE; 16 | 17 | @Slf4j 18 | @Component 19 | public class CowinApiOtpClient { 20 | private final WebClient cowinClient; 21 | 22 | public CowinApiOtpClient(CowinConfig cowinConfig) { 23 | this.cowinClient = WebClient 24 | .builder() 25 | .baseUrl(cowinConfig.getApiUrl()) 26 | .filter(WebClientFilter.logRequest()) 27 | .filter(WebClientFilter.logResponse()) 28 | .build(); 29 | } 30 | 31 | public GenerateOtpResponse generateOtp(String mobileNumber) { 32 | Map body = Map.ofEntries(entry("mobile", mobileNumber)); 33 | try { 34 | return cowinClient.post() 35 | .uri(uriBuilder -> uriBuilder 36 | .path("/v2/auth/generateOTP") 37 | .build()) 38 | .body(BodyInserters.fromValue(body)) 39 | .header(CONTENT_TYPE, APPLICATION_JSON_VALUE) 40 | .retrieve() 41 | .bodyToMono(GenerateOtpResponse.class) 42 | .block(); 43 | } catch (CowinException we) { 44 | log.error("Error from Cowin API when generating OTP status code {}, message {}", we.getStatusCode(), we.getMessage()); 45 | return null; 46 | } 47 | } 48 | 49 | public ConfirmOtpResponse confirmOtp(String transactionId, String otp) { 50 | Map body = Map.ofEntries( 51 | entry("txnId", transactionId), 52 | entry("otp", otp) 53 | ); 54 | try { 55 | return cowinClient.post() 56 | .uri(uriBuilder -> uriBuilder 57 | .path("/v2/auth/confirmOTP") 58 | .build()) 59 | .body(BodyInserters.fromValue(body)) 60 | .header(CONTENT_TYPE, APPLICATION_JSON_VALUE) 61 | .retrieve() 62 | .bodyToMono(ConfirmOtpResponse.class) 63 | .block(); 64 | } catch (CowinException we) { 65 | log.error("Error from Cowin API when confirming OTP status code {}, message {}", we.getStatusCode(), we.getMessage()); 66 | return null; 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/test/java/org/covid19/vaccinetracker/availability/VaccineAvailabilityTest.java: -------------------------------------------------------------------------------- 1 | package org.covid19.vaccinetracker.availability; 2 | 3 | import org.covid19.vaccinetracker.availability.aws.CowinLambdaWrapper; 4 | import org.covid19.vaccinetracker.notifications.bot.BotService; 5 | import org.covid19.vaccinetracker.persistence.VaccinePersistence; 6 | import org.covid19.vaccinetracker.userrequests.UserRequestManager; 7 | import org.covid19.vaccinetracker.userrequests.model.District; 8 | import org.covid19.vaccinetracker.userrequests.model.State; 9 | import org.junit.jupiter.api.Test; 10 | import org.junit.jupiter.api.extension.ExtendWith; 11 | import org.mockito.Mock; 12 | import org.mockito.junit.jupiter.MockitoExtension; 13 | 14 | import static java.util.Collections.singleton; 15 | import static org.hamcrest.MatcherAssert.assertThat; 16 | import static org.hamcrest.Matchers.is; 17 | import static org.mockito.Mockito.times; 18 | import static org.mockito.Mockito.verify; 19 | import static org.mockito.Mockito.when; 20 | 21 | @ExtendWith(MockitoExtension.class) 22 | public class VaccineAvailabilityTest { 23 | @Mock 24 | private VaccinePersistence vaccinePersistence; 25 | 26 | @Mock 27 | private UserRequestManager userRequestManager; 28 | 29 | @Mock 30 | private BotService botService; 31 | 32 | @Mock 33 | private CowinLambdaWrapper cowinLambdaWrapper; 34 | 35 | @Mock 36 | private AvailabilityConfig config; 37 | // TODO: Add IT 38 | 39 | @Test 40 | public void testRefreshVaccineAvailabilityFromCowinViaLambda_happyScenario() { 41 | District aDistrict = new District(1, "Shahdara", new State(1, "Delhi")); 42 | when(userRequestManager.fetchAllUserDistricts()).thenReturn(singleton(aDistrict)); 43 | 44 | AvailabilityStats availabilityStats = new AvailabilityStats(); 45 | VaccineAvailability vaccineAvailability = new VaccineAvailability(vaccinePersistence, 46 | userRequestManager, availabilityStats, botService, cowinLambdaWrapper, config); 47 | vaccineAvailability.refreshVaccineAvailabilityFromCowinViaLambdaAsync(); 48 | 49 | verify(cowinLambdaWrapper, times(1)).processDistrict(1); 50 | 51 | assertThat(availabilityStats.processedDistricts(), is(1)); 52 | } 53 | 54 | // TODO: Add IT 55 | @Test 56 | public void testRefreshVaccineAvailabilityFromCowinViaLambda_nullCenters() { 57 | District aDistrict = new District(1, "Shahdara", new State(1, "Delhi")); 58 | when(userRequestManager.fetchAllUserDistricts()).thenReturn(singleton(aDistrict)); 59 | 60 | AvailabilityStats availabilityStats = new AvailabilityStats(); 61 | VaccineAvailability vaccineAvailability = new VaccineAvailability(vaccinePersistence, 62 | userRequestManager, availabilityStats, botService, cowinLambdaWrapper, config); 63 | vaccineAvailability.refreshVaccineAvailabilityFromCowinViaLambdaAsync(); 64 | 65 | verify(cowinLambdaWrapper, times(1)).processDistrict(1); 66 | 67 | assertThat(availabilityStats.processedDistricts(), is(1)); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/main/java/org/covid19/vaccinetracker/notifications/bot/BotUtils.java: -------------------------------------------------------------------------------- 1 | package org.covid19.vaccinetracker.notifications.bot; 2 | 3 | import org.telegram.telegrambots.meta.api.methods.send.SendMessage; 4 | import org.telegram.telegrambots.meta.api.objects.replykeyboard.InlineKeyboardMarkup; 5 | import org.telegram.telegrambots.meta.api.objects.replykeyboard.buttons.InlineKeyboardButton; 6 | 7 | import java.util.ArrayList; 8 | import java.util.List; 9 | 10 | public class BotUtils { 11 | public static SendMessage buildAgeSelectionKeyboard(String chatId) { 12 | InlineKeyboardMarkup markup = new InlineKeyboardMarkup(); 13 | SendMessage msg = new SendMessage(); 14 | msg.setChatId(chatId); 15 | msg.setText("Choose preferred age"); 16 | List> keyboard = new ArrayList<>(); 17 | List row = new ArrayList<>(); 18 | row.add(InlineKeyboardButton.builder().text("18-44").callbackData("18-44").build()); 19 | row.add(InlineKeyboardButton.builder().text("45+").callbackData("45+").build()); 20 | row.add(InlineKeyboardButton.builder().text("Both").callbackData("both").build()); 21 | keyboard.add(row); 22 | 23 | markup.setKeyboard(keyboard); 24 | msg.setReplyMarkup(markup); 25 | return msg; 26 | } 27 | 28 | public static SendMessage buildDoseSelectionKeyboard(String chatId) { 29 | InlineKeyboardMarkup markup = new InlineKeyboardMarkup(); 30 | SendMessage msg = new SendMessage(); 31 | msg.setChatId(chatId); 32 | msg.setText("Choose preferred dose"); 33 | List> keyboard = new ArrayList<>(); 34 | List row = new ArrayList<>(); 35 | row.add(InlineKeyboardButton.builder().text("Dose 1").callbackData("dose1").build()); 36 | row.add(InlineKeyboardButton.builder().text("Dose 2").callbackData("dose2").build()); 37 | row.add(InlineKeyboardButton.builder().text("Both").callbackData("both").build()); 38 | keyboard.add(row); 39 | 40 | markup.setKeyboard(keyboard); 41 | msg.setReplyMarkup(markup); 42 | return msg; 43 | } 44 | 45 | public static SendMessage buildVaccineSelectionKeyboard(String chatId) { 46 | InlineKeyboardMarkup markup = new InlineKeyboardMarkup(); 47 | SendMessage msg = new SendMessage(); 48 | msg.setChatId(chatId); 49 | msg.setText("Choose preferred vaccine"); 50 | List> keyboard = new ArrayList<>(); 51 | List row = new ArrayList<>(); 52 | row.add(InlineKeyboardButton.builder().text("COVISHIELD").callbackData("covishield").build()); 53 | row.add(InlineKeyboardButton.builder().text("COVAXIN").callbackData("covaxin").build()); 54 | keyboard.add(row); 55 | row = new ArrayList<>(); 56 | row.add(InlineKeyboardButton.builder().text("SPUTNIK V").callbackData("sputnikv").build()); 57 | row.add(InlineKeyboardButton.builder().text("ALL").callbackData("all").build()); 58 | keyboard.add(row); 59 | 60 | markup.setKeyboard(keyboard); 61 | msg.setReplyMarkup(markup); 62 | return msg; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/main/java/org/covid19/vaccinetracker/notifications/NotificationCache.java: -------------------------------------------------------------------------------- 1 | package org.covid19.vaccinetracker.notifications; 2 | 3 | import com.fasterxml.jackson.core.JsonProcessingException; 4 | import com.fasterxml.jackson.databind.ObjectMapper; 5 | 6 | import org.apache.commons.codec.digest.DigestUtils; 7 | import org.covid19.vaccinetracker.model.Center; 8 | import org.covid19.vaccinetracker.persistence.mariadb.entity.UserNotification; 9 | import org.covid19.vaccinetracker.persistence.mariadb.entity.UserNotificationId; 10 | import org.covid19.vaccinetracker.persistence.mariadb.repository.UserNotificationRepository; 11 | import org.jetbrains.annotations.Nullable; 12 | import org.springframework.stereotype.Component; 13 | 14 | import java.time.LocalDateTime; 15 | import java.util.List; 16 | import java.util.Optional; 17 | 18 | import lombok.extern.slf4j.Slf4j; 19 | 20 | @Slf4j 21 | @Component 22 | public class NotificationCache { 23 | private final UserNotificationRepository repository; 24 | private final ObjectMapper objectMapper; 25 | 26 | public NotificationCache(UserNotificationRepository repository, ObjectMapper objectMapper) { 27 | this.repository = repository; 28 | this.objectMapper = objectMapper; 29 | } 30 | 31 | public Optional userNotificationFor(UserNotificationId id) { 32 | return repository.findById(id); 33 | } 34 | 35 | public boolean isNewNotification(String user, String pincode, List
centers) { 36 | final Optional fromCache = this.repository.findById( 37 | UserNotificationId.builder() 38 | .userId(user) 39 | .pincode(pincode) 40 | .build()); 41 | 42 | if (fromCache.isEmpty()) { 43 | return true; 44 | } 45 | 46 | byte[] bytes = serialize(centers); 47 | if (bytes == null) { 48 | return true; 49 | } 50 | 51 | // obtain "last notified at" in IST zone 52 | // String lastNotifiedAt = ZonedDateTime.of(fromCache.get().getNotifiedAt(), ZoneId.of("UTC")).withZoneSameInstant(ZoneId.of(INDIA_TIMEZONE)).format(Utils.dtf); 53 | return !DigestUtils.sha256Hex(bytes).equals(fromCache.get().getNotificationHash()); 54 | } 55 | 56 | public void updateUser(String user, String pincode, List
centers) { 57 | byte[] bytes = serialize(centers); 58 | String notificationHash = bytes == null ? "unknown" : DigestUtils.sha256Hex(bytes); 59 | this.repository.save( 60 | UserNotification.builder() 61 | .userNotificationId(UserNotificationId.builder() 62 | .userId(user) 63 | .pincode(pincode) 64 | .build()) 65 | .notificationHash(notificationHash) 66 | .notifiedAt(LocalDateTime.now()) 67 | .build()); 68 | } 69 | 70 | @Nullable 71 | private byte[] serialize(List
centers) { 72 | byte[] bytes; 73 | try { 74 | bytes = objectMapper.writeValueAsBytes(centers); 75 | } catch (JsonProcessingException e) { 76 | log.error("Error serializing centers {}", centers); 77 | return null; 78 | } 79 | return bytes; 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/test/java/org/covid19/vaccinetracker/availability/cowin/CowinApiOtpClientTest.java: -------------------------------------------------------------------------------- 1 | package org.covid19.vaccinetracker.availability.cowin; 2 | 3 | import com.fasterxml.jackson.databind.ObjectMapper; 4 | 5 | import org.covid19.vaccinetracker.availability.model.ConfirmOtpResponse; 6 | import org.covid19.vaccinetracker.availability.model.GenerateOtpResponse; 7 | import org.junit.jupiter.api.AfterEach; 8 | import org.junit.jupiter.api.BeforeEach; 9 | import org.junit.jupiter.api.Test; 10 | 11 | import java.io.IOException; 12 | 13 | import okhttp3.mockwebserver.MockResponse; 14 | import okhttp3.mockwebserver.MockWebServer; 15 | import okhttp3.mockwebserver.RecordedRequest; 16 | 17 | import static java.util.concurrent.TimeUnit.SECONDS; 18 | import static org.hamcrest.CoreMatchers.equalTo; 19 | import static org.hamcrest.CoreMatchers.is; 20 | import static org.hamcrest.MatcherAssert.assertThat; 21 | import static org.springframework.http.HttpHeaders.CONTENT_TYPE; 22 | import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE; 23 | 24 | public class CowinApiOtpClientTest { 25 | private final MockWebServer mockWebServer = new MockWebServer(); 26 | private final ObjectMapper objectMapper = new ObjectMapper(); 27 | private final CowinConfig cowinConfig = new CowinConfig(); 28 | private CowinApiOtpClient cowinApiOtpClient; 29 | 30 | @BeforeEach 31 | public void setup() { 32 | String url = String.format("http://localhost:%s", mockWebServer.getPort()); 33 | cowinConfig.setApiUrl(url); 34 | cowinApiOtpClient = new CowinApiOtpClient(cowinConfig); 35 | } 36 | 37 | @AfterEach 38 | public void tearDown() throws IOException { 39 | mockWebServer.shutdown(); 40 | } 41 | 42 | @Test 43 | public void testGenerateOtp() throws Exception { 44 | GenerateOtpResponse expected = GenerateOtpResponse.builder().transactionId("6171c423-90db-4b16-a3b1-bbd85d3cde6a").build(); 45 | mockWebServer.enqueue(new MockResponse() 46 | .setBody(objectMapper.writeValueAsString(expected)) 47 | .addHeader(CONTENT_TYPE, APPLICATION_JSON_VALUE)); 48 | final GenerateOtpResponse actual = cowinApiOtpClient.generateOtp("9999999999"); 49 | assertThat(actual, is(equalTo(expected))); 50 | final RecordedRequest recordedRequest = mockWebServer.takeRequest(3, SECONDS); 51 | assertThat(recordedRequest.getBody().readUtf8(), is(equalTo("{\"mobile\":\"9999999999\"}"))); 52 | assertThat(recordedRequest.getHeader(CONTENT_TYPE), is(equalTo(APPLICATION_JSON_VALUE))); 53 | } 54 | 55 | @Test 56 | public void testConfirmOtp() throws Exception { 57 | ConfirmOtpResponse expected = ConfirmOtpResponse.builder().token("xxxxx").isNewAccount("N").build(); 58 | mockWebServer.enqueue(new MockResponse() 59 | .setBody(objectMapper.writeValueAsString(expected)) 60 | .addHeader(CONTENT_TYPE, APPLICATION_JSON_VALUE)); 61 | final ConfirmOtpResponse actual = cowinApiOtpClient.confirmOtp( 62 | "62b136fa-831d-4132-8457-b7bc10916d9d", 63 | "deebfa8af67efe975b1859f418e90dcaad5875301fb6881fbea47b68f94432cd"); 64 | assertThat(actual, is(equalTo(expected))); 65 | final RecordedRequest recordedRequest = mockWebServer.takeRequest(3, SECONDS); 66 | assertThat(recordedRequest.getHeader(CONTENT_TYPE), is(equalTo(APPLICATION_JSON_VALUE))); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/main/java/org/covid19/vaccinetracker/persistence/mariadb/DBController.java: -------------------------------------------------------------------------------- 1 | package org.covid19.vaccinetracker.persistence.mariadb; 2 | 3 | import org.covid19.vaccinetracker.userrequests.model.State; 4 | import org.covid19.vaccinetracker.persistence.mariadb.repository.CenterRepository; 5 | import org.covid19.vaccinetracker.persistence.mariadb.repository.DistrictRepository; 6 | import org.covid19.vaccinetracker.persistence.mariadb.repository.PincodeRepository; 7 | import org.covid19.vaccinetracker.persistence.mariadb.repository.StateRepository; 8 | import org.springframework.http.ResponseEntity; 9 | import org.springframework.web.bind.annotation.GetMapping; 10 | import org.springframework.web.bind.annotation.RequestMapping; 11 | import org.springframework.web.bind.annotation.RequestParam; 12 | import org.springframework.web.bind.annotation.RestController; 13 | 14 | @RestController 15 | @RequestMapping("/db") 16 | public class DBController { 17 | private final StateRepository stateRepository; 18 | private final DistrictRepository districtRepository; 19 | private final PincodeRepository pincodeRepository; 20 | private final CenterRepository centerRepository; 21 | 22 | public DBController(StateRepository stateRepository, DistrictRepository districtRepository, PincodeRepository pincodeRepository, CenterRepository centerRepository) { 23 | this.stateRepository = stateRepository; 24 | this.districtRepository = districtRepository; 25 | this.pincodeRepository = pincodeRepository; 26 | this.centerRepository = centerRepository; 27 | } 28 | 29 | @GetMapping("/states/all") 30 | public ResponseEntity fetchAllStates() { 31 | return ResponseEntity.ok(this.stateRepository.findAll()); 32 | } 33 | 34 | @GetMapping("/states/byPincode") 35 | public ResponseEntity fetchStateByPincode(@RequestParam String pincode) { 36 | return ResponseEntity.ok(this.stateRepository.findByPincode(pincode)); 37 | } 38 | 39 | @GetMapping("/districts/all") 40 | public ResponseEntity fetchAllDistricts() { 41 | return ResponseEntity.ok(this.districtRepository.findAll()); 42 | } 43 | 44 | @GetMapping("/districts/byState") 45 | public ResponseEntity fetchDistrictByState(@RequestParam State state) { 46 | return ResponseEntity.ok(this.districtRepository.findDistrictByState(state)); 47 | } 48 | 49 | @GetMapping("/pincodes/all") 50 | public ResponseEntity fetchAllPincodes() { 51 | return ResponseEntity.ok(this.pincodeRepository.findAll()); 52 | } 53 | 54 | @GetMapping("/pincodes/exists") 55 | public ResponseEntity pincodeExist(@RequestParam String pincode) { 56 | return ResponseEntity.ok(this.pincodeRepository.existsByPincode(pincode)); 57 | } 58 | 59 | @GetMapping("/pincodes/byDistrictId") 60 | public ResponseEntity fetchPincodesByDistrict(@RequestParam int districtId) { 61 | return ResponseEntity.ok(this.pincodeRepository.findPincodeByDistrictId(districtId)); 62 | } 63 | 64 | @GetMapping("/districts/byPincode") 65 | public ResponseEntity fetchDistrictByPincode(@RequestParam String pincode) { 66 | return ResponseEntity.ok(this.districtRepository.findDistrictByPincode(pincode)); 67 | } 68 | 69 | @GetMapping("/centers/byPincode") 70 | public ResponseEntity fetchCentersByPincode(@RequestParam String pincode) { 71 | return ResponseEntity.ok(this.centerRepository.findCenterEntityByPincodeAndSessionsProcessedAtIsNull(pincode)); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/test/java/org/covid19/vaccinetracker/notifications/absentalerts/AbsentAlertsNotificationsIT.java: -------------------------------------------------------------------------------- 1 | package org.covid19.vaccinetracker.notifications.absentalerts; 2 | 3 | import org.covid19.vaccinetracker.model.CenterSession; 4 | import org.covid19.vaccinetracker.notifications.NotificationCache; 5 | import org.covid19.vaccinetracker.persistence.VaccinePersistence; 6 | import org.covid19.vaccinetracker.persistence.mariadb.entity.UserNotification; 7 | import org.covid19.vaccinetracker.persistence.mariadb.entity.UserNotificationId; 8 | import org.covid19.vaccinetracker.userrequests.UserRequestManager; 9 | import org.covid19.vaccinetracker.userrequests.model.UserRequest; 10 | import org.junit.jupiter.api.Test; 11 | import org.springframework.beans.factory.annotation.Autowired; 12 | import org.springframework.boot.test.context.SpringBootTest; 13 | import org.springframework.boot.test.mock.mockito.MockBean; 14 | import org.springframework.test.annotation.DirtiesContext; 15 | 16 | import java.time.LocalDateTime; 17 | import java.util.List; 18 | import java.util.Optional; 19 | 20 | import lombok.extern.slf4j.Slf4j; 21 | 22 | import static org.mockito.Mockito.when; 23 | 24 | @Slf4j 25 | @SpringBootTest(classes = { 26 | AbsentAlertNotifications.class, 27 | AbsentAlertAnalyzer.class 28 | }) 29 | @DirtiesContext 30 | public class AbsentAlertsNotificationsIT { 31 | @MockBean 32 | private UserRequestManager userRequestManager; 33 | 34 | @MockBean 35 | private NotificationCache cache; 36 | 37 | @MockBean 38 | private VaccinePersistence persistence; 39 | 40 | @Autowired 41 | private AbsentAlertNotifications notifications; 42 | 43 | @Test 44 | public void testAbsentAlertsNotificationJob() { 45 | when(userRequestManager.fetchAllUserRequests()).thenReturn(userRequests()); 46 | when(cache.userNotificationFor(UserNotificationId.builder().userId("9876").pincode("412308").build())) 47 | .thenReturn(Optional.of(UserNotification.builder() 48 | .userNotificationId(UserNotificationId.builder().userId("9876").pincode("412308").build()) 49 | .notifiedAt(LocalDateTime.now().minusDays(2L)) 50 | .build())); 51 | when(persistence.findAllSessionsByPincode("412308")).thenReturn(sessions()); 52 | notifications.absentAlertsNotificationJob(); 53 | // verify notifications are sent 54 | } 55 | 56 | private List sessions() { 57 | return List.of( 58 | CenterSession.builder() 59 | .centerName("PMC G Fursungi Dispensary").districtName("Pune").pincode("412308") 60 | .minAge(18).sessionDate("12-07-2021").sessionVaccine("COVISHIELD") 61 | .build(), 62 | CenterSession.builder() 63 | .centerName("PMC G Uruli Devachi Dispensary").districtName("Pune").pincode("412308") 64 | .minAge(18).sessionDate("12-07-2021").sessionVaccine("COVISHIELD") 65 | .build(), 66 | CenterSession.builder() 67 | .centerName("PMC G NEW ENGLISH SCHOOL").districtName("Pune").pincode("412308") 68 | .minAge(18).sessionDate("10-07-2021").sessionVaccine("COVISHIELD") 69 | .build() 70 | ); 71 | } 72 | 73 | private List userRequests() { 74 | return List.of(new UserRequest("9876", List.of("412308"), List.of(), "18-44", "Dose 1", "Covishield", null)); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/main/java/org/covid19/vaccinetracker/availability/VaccineAvailability.java: -------------------------------------------------------------------------------- 1 | package org.covid19.vaccinetracker.availability; 2 | 3 | import org.covid19.vaccinetracker.availability.aws.CowinLambdaWrapper; 4 | import org.covid19.vaccinetracker.notifications.bot.BotService; 5 | import org.covid19.vaccinetracker.persistence.VaccinePersistence; 6 | import org.covid19.vaccinetracker.userrequests.UserRequestManager; 7 | import org.covid19.vaccinetracker.userrequests.model.District; 8 | import org.covid19.vaccinetracker.utils.Utils; 9 | import org.springframework.scheduling.annotation.Scheduled; 10 | import org.springframework.stereotype.Service; 11 | 12 | import java.util.Objects; 13 | import java.util.concurrent.Executors; 14 | 15 | import lombok.extern.slf4j.Slf4j; 16 | 17 | @Slf4j 18 | @Service 19 | public class VaccineAvailability { 20 | private final VaccinePersistence vaccinePersistence; 21 | private final UserRequestManager userRequestManager; 22 | private final AvailabilityStats availabilityStats; 23 | private final BotService botService; 24 | private final CowinLambdaWrapper cowinLambdaWrapper; 25 | private final AvailabilityConfig config; 26 | 27 | public VaccineAvailability(VaccinePersistence vaccinePersistence, 28 | UserRequestManager userRequestManager, 29 | AvailabilityStats availabilityStats, 30 | BotService botService, CowinLambdaWrapper cowinLambdaWrapper, AvailabilityConfig config) { 31 | this.vaccinePersistence = vaccinePersistence; 32 | this.userRequestManager = userRequestManager; 33 | this.availabilityStats = availabilityStats; 34 | this.botService = botService; 35 | this.cowinLambdaWrapper = cowinLambdaWrapper; 36 | this.config = config; 37 | } 38 | 39 | @Scheduled(cron = "${jobs.cron.vaccine.availability:-}", zone = "IST") 40 | public void refreshVaccineAvailabilityFromCowinAndTriggerNotifications() { 41 | Executors.newSingleThreadExecutor().submit(this::refreshVaccineAvailabilityFromCowinViaLambdaAsync); 42 | } 43 | 44 | public void refreshVaccineAvailabilityFromCowinViaLambdaAsync() { 45 | log.info("Refreshing Vaccine Availability from Cowin API via AWS Lambda asynchronously"); 46 | availabilityStats.reset(); 47 | availabilityStats.noteStartTime(); 48 | 49 | this.userRequestManager.fetchAllUserDistricts() 50 | .parallelStream() 51 | .filter(Objects::nonNull) 52 | .filter(this::nonPriorityDistrict) 53 | .peek(district -> availabilityStats.incrementProcessedDistricts()) 54 | .peek(district -> log.debug("processing district id {}", district.getId())) 55 | .forEach(district -> cowinLambdaWrapper.processDistrict(district.getId())); 56 | 57 | availabilityStats.noteEndTime(); 58 | final String message = String.format("[AVAILABILITY] Districts: %d, Time taken: %s", availabilityStats.processedDistricts(), availabilityStats.timeTaken()); 59 | log.info(message); 60 | botService.notifyOwner(message); 61 | } 62 | 63 | private boolean nonPriorityDistrict(District district) { 64 | return !config.getPriorityDistricts().contains(String.valueOf(district.getId())); 65 | } 66 | 67 | @Scheduled(cron = "${jobs.cron.db.cleanup:-}", zone = "IST") 68 | public void cleanupOldVaccineCenters() { 69 | String yesterday = Utils.yesterdayIST(); 70 | log.info("Deleting Vaccine centers for {}", yesterday); 71 | this.vaccinePersistence.cleanupOldCenters(yesterday); 72 | } 73 | 74 | @Scheduled(cron = "${jobs.cron.user.stats:-}", zone = "IST") 75 | public void userStats() { 76 | int size = userRequestManager.userRequestSize(); 77 | log.info("Users count: {}", size); 78 | botService.notifyOwner(String.format("User count: %d", size)); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/test/java/org/covid19/vaccinetracker/availability/cowin/CowinApiClientTest.java: -------------------------------------------------------------------------------- 1 | package org.covid19.vaccinetracker.availability.cowin; 2 | 3 | import com.fasterxml.jackson.databind.ObjectMapper; 4 | 5 | import org.covid19.vaccinetracker.model.Center; 6 | import org.covid19.vaccinetracker.model.VaccineCenters; 7 | import org.junit.jupiter.api.AfterEach; 8 | import org.junit.jupiter.api.Assertions; 9 | import org.junit.jupiter.api.BeforeEach; 10 | import org.junit.jupiter.api.Test; 11 | import org.junit.jupiter.api.extension.ExtendWith; 12 | import org.mockito.Mock; 13 | import org.mockito.junit.jupiter.MockitoExtension; 14 | 15 | import java.io.IOException; 16 | import java.util.Collections; 17 | 18 | import lombok.extern.slf4j.Slf4j; 19 | import okhttp3.mockwebserver.MockResponse; 20 | import okhttp3.mockwebserver.MockWebServer; 21 | 22 | import static org.hamcrest.CoreMatchers.equalTo; 23 | import static org.hamcrest.CoreMatchers.is; 24 | import static org.hamcrest.MatcherAssert.assertThat; 25 | import static org.mockito.Mockito.when; 26 | import static org.springframework.http.HttpHeaders.CONTENT_TYPE; 27 | import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE; 28 | 29 | @Slf4j 30 | @ExtendWith(MockitoExtension.class) 31 | public class CowinApiClientTest { 32 | private final MockWebServer mockWebServer = new MockWebServer(); 33 | private final ObjectMapper objectMapper = new ObjectMapper(); 34 | private final CowinConfig cowinConfig = new CowinConfig(); 35 | private CowinApiClient cowinApiClient; 36 | 37 | @Mock 38 | private CowinApiAuth cowinApiAuth; 39 | 40 | @BeforeEach 41 | public void setup() { 42 | String url = String.format("http://localhost:%s", mockWebServer.getPort()); 43 | cowinConfig.setApiUrl(url); 44 | cowinApiClient = new CowinApiClient(cowinConfig, cowinApiAuth); 45 | } 46 | 47 | @AfterEach 48 | public void tearDown() throws IOException { 49 | mockWebServer.shutdown(); 50 | } 51 | 52 | @Test 53 | public void testFetchVaccineCentersByPincode() throws Exception { 54 | VaccineCenters expected = new VaccineCenters(); 55 | expected.setCenters(Collections.singletonList(Center.builder().centerId(123).pincode(440022).districtName("Nagpur").build())); 56 | mockWebServer.enqueue(new MockResponse() 57 | .setBody(objectMapper.writeValueAsString(expected)) 58 | .addHeader(CONTENT_TYPE, APPLICATION_JSON_VALUE)); 59 | when(cowinApiAuth.isAvailable()).thenReturn(false); 60 | final VaccineCenters actual = cowinApiClient.fetchCentersByPincode("440022"); 61 | 62 | assertThat(actual, is(equalTo(expected))); 63 | } 64 | 65 | @Test 66 | public void testFetchVaccineCentersByPincodeException() { 67 | VaccineCenters expected = new VaccineCenters(); 68 | expected.setCenters(Collections.singletonList(Center.builder().centerId(123).pincode(440022).districtName("Nagpur").build())); 69 | mockWebServer.enqueue(new MockResponse() 70 | .setResponseCode(400) 71 | .setBody("random") 72 | .addHeader(CONTENT_TYPE, APPLICATION_JSON_VALUE)); 73 | when(cowinApiAuth.isAvailable()).thenReturn(false); 74 | final VaccineCenters actual = cowinApiClient.fetchCentersByPincode("440022"); 75 | Assertions.assertNull(actual); 76 | } 77 | 78 | @Test 79 | public void testFetchSessionsByDistrict() throws Exception { 80 | VaccineCenters expected = new VaccineCenters(); 81 | expected.setCenters(Collections.singletonList(Center.builder().centerId(123).pincode(440022).districtName("Nagpur").build())); 82 | mockWebServer.enqueue(new MockResponse() 83 | .setBody(objectMapper.writeValueAsString(expected)) 84 | .addHeader(CONTENT_TYPE, APPLICATION_JSON_VALUE)); 85 | when(cowinApiAuth.isAvailable()).thenReturn(false); 86 | final VaccineCenters actual = cowinApiClient.fetchSessionsByDistrict(315); 87 | 88 | assertThat(actual, is(equalTo(expected))); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/main/java/org/covid19/vaccinetracker/persistence/kafka/DeduplicationTransformer.java: -------------------------------------------------------------------------------- 1 | package org.covid19.vaccinetracker.persistence.kafka; 2 | 3 | import org.apache.kafka.streams.KeyValue; 4 | import org.apache.kafka.streams.kstream.KeyValueMapper; 5 | import org.apache.kafka.streams.kstream.Transformer; 6 | import org.apache.kafka.streams.processor.ProcessorContext; 7 | import org.apache.kafka.streams.state.WindowStore; 8 | import org.apache.kafka.streams.state.WindowStoreIterator; 9 | 10 | public class DeduplicationTransformer implements Transformer> { 11 | 12 | private ProcessorContext context; 13 | 14 | /** 15 | * Key: event ID Value: timestamp (event-time) of the corresponding event when the event ID was 16 | * seen for the first time 17 | */ 18 | private WindowStore eventIdStore; 19 | 20 | private final long leftDurationMs; 21 | private final long rightDurationMs; 22 | 23 | private final String storeName; 24 | 25 | private final KeyValueMapper idExtractor; 26 | 27 | /** 28 | * @param maintainDurationPerEventInMs how long to "remember" a known event (or rather, an event 29 | * ID), during the time of which any incoming duplicates of 30 | * the event will be dropped, thereby de-duplicating the 31 | * input. 32 | * @param idExtractor extracts a unique identifier from a record by which we 33 | * de-duplicate input records; if it returns null, the 34 | * record will not be considered for 35 | */ 36 | DeduplicationTransformer(final long maintainDurationPerEventInMs, final KeyValueMapper idExtractor, String storeName) { 37 | if (maintainDurationPerEventInMs < 1) { 38 | throw new IllegalArgumentException("maintain duration per event must be >= 1"); 39 | } 40 | leftDurationMs = maintainDurationPerEventInMs / 2; 41 | rightDurationMs = maintainDurationPerEventInMs - leftDurationMs; 42 | this.idExtractor = idExtractor; 43 | this.storeName = storeName; 44 | } 45 | 46 | @Override 47 | @SuppressWarnings("unchecked") 48 | public void init(final ProcessorContext context) { 49 | this.context = context; 50 | eventIdStore = (WindowStore) context.getStateStore(storeName); 51 | } 52 | 53 | public KeyValue transform(final K key, final V value) { 54 | final E eventId = idExtractor.apply(key, value); 55 | if (eventId == null) { 56 | return KeyValue.pair(key, value); 57 | } else { 58 | final KeyValue output; 59 | if (isDuplicate(eventId)) { 60 | output = null; 61 | updateTimestampOfExistingEventToPreventExpiry(eventId, context.timestamp()); 62 | } else { 63 | output = KeyValue.pair(key, value); 64 | rememberNewEvent(eventId, context.timestamp()); 65 | } 66 | return output; 67 | } 68 | } 69 | 70 | private boolean isDuplicate(final E eventId) { 71 | final long eventTime = context.timestamp(); 72 | final WindowStoreIterator timeIterator = eventIdStore.fetch( 73 | eventId, 74 | eventTime - leftDurationMs, 75 | eventTime + rightDurationMs); 76 | final boolean isDuplicate = timeIterator.hasNext(); 77 | timeIterator.close(); 78 | return isDuplicate; 79 | } 80 | 81 | private void updateTimestampOfExistingEventToPreventExpiry(final E eventId, final long newTimestamp) { 82 | eventIdStore.put(eventId, newTimestamp, newTimestamp); 83 | } 84 | 85 | private void rememberNewEvent(final E eventId, final long timestamp) { 86 | eventIdStore.put(eventId, timestamp, timestamp); 87 | } 88 | 89 | @Override 90 | public void close() { 91 | // Note: The store should NOT be closed manually here via `eventIdStore.close()`! 92 | // The Kafka Streams API will automatically close stores when necessary. 93 | } 94 | 95 | } -------------------------------------------------------------------------------- /src/main/java/org/covid19/vaccinetracker/notifications/absentalerts/AbsentAlertNotifications.java: -------------------------------------------------------------------------------- 1 | package org.covid19.vaccinetracker.notifications.absentalerts; 2 | 3 | import org.covid19.vaccinetracker.notifications.NotificationCache; 4 | import org.covid19.vaccinetracker.persistence.VaccinePersistence; 5 | import org.covid19.vaccinetracker.persistence.mariadb.entity.UserNotificationId; 6 | import org.covid19.vaccinetracker.userrequests.UserRequestManager; 7 | import org.covid19.vaccinetracker.userrequests.model.UserRequest; 8 | import org.springframework.scheduling.annotation.Scheduled; 9 | import org.springframework.stereotype.Component; 10 | 11 | import java.util.List; 12 | import java.util.Map; 13 | import java.util.function.BiConsumer; 14 | import java.util.function.Function; 15 | import java.util.function.Predicate; 16 | import java.util.stream.Collectors; 17 | import java.util.stream.Stream; 18 | 19 | import lombok.extern.slf4j.Slf4j; 20 | 21 | /** 22 | * Sends summary notifications to active users who haven't received regular alerts in last 2 days. 23 | * It explains the reason why no alerts have been sent and optionally suggestions to increase 24 | * chances of receiving alerts. 25 | */ 26 | @Slf4j 27 | @Component 28 | public class AbsentAlertNotifications { 29 | private final UserRequestManager userRequestManager; 30 | private final NotificationCache cache; 31 | private final AbsentAlertAnalyzer analyzer; 32 | private final VaccinePersistence vaccinePersistence; 33 | 34 | public AbsentAlertNotifications(UserRequestManager userRequestManager, NotificationCache cache, 35 | AbsentAlertAnalyzer analyzer, VaccinePersistence vaccinePersistence) { 36 | this.userRequestManager = userRequestManager; 37 | this.cache = cache; 38 | this.analyzer = analyzer; 39 | this.vaccinePersistence = vaccinePersistence; 40 | } 41 | 42 | public Map> onDemandAbsentAlertsNotification(String userId) { 43 | return Stream.of(userRequestManager.fetchUserRequest(userId)) 44 | .filter(userRequest -> !userRequest.getPincodes().isEmpty()) 45 | .flatMap(getLatestNotifications()) 46 | .map(identifyCause()) 47 | .collect(Collectors.groupingBy(AbsentAlertCause::getUserId)) 48 | ; 49 | } 50 | 51 | @Scheduled(cron = "${jobs.cron.absentalerts.notifications:-}", zone = "IST") 52 | public void absentAlertsNotificationJob() { 53 | userRequestManager.fetchAllUserRequests() 54 | .stream() 55 | .filter(activeUsers()) 56 | .flatMap(getLatestNotifications()) 57 | .map(identifyCause()) 58 | .collect(Collectors.groupingBy(AbsentAlertCause::getUserId)) 59 | .forEach(sendNotification()); 60 | } 61 | 62 | private Predicate activeUsers() { 63 | return userRequest -> !userRequest.getPincodes().isEmpty(); 64 | } 65 | 66 | private Function> getLatestNotifications() { 67 | return userRequest -> userRequest.getPincodes() 68 | .stream() 69 | .map(pincode -> AbsentAlertSource.builder() 70 | .userId(userRequest.getChatId()) 71 | .pincode(pincode) 72 | .age(userRequest.getAge()) 73 | .dose(userRequest.getDose()) 74 | .vaccine(userRequest.getVaccine()) 75 | .latestNotification( 76 | cache.userNotificationFor(new UserNotificationId(userRequest.getChatId(), pincode)).orElse(null)) 77 | .build()); 78 | } 79 | 80 | private Function identifyCause() { 81 | return source -> analyzer.analyze(source, vaccinePersistence.findAllSessionsByPincode(source.getPincode())); 82 | } 83 | 84 | private BiConsumer> sendNotification() { 85 | return (pincode, causes) -> { 86 | log.info("pincode is {}", pincode); 87 | log.info("cause is {}", causes); 88 | }; 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/main/java/org/covid19/vaccinetracker/persistence/kafka/KafkaStateStores.java: -------------------------------------------------------------------------------- 1 | package org.covid19.vaccinetracker.persistence.kafka; 2 | 3 | import org.apache.kafka.streams.KafkaStreams; 4 | import org.apache.kafka.streams.StoreQueryParameters; 5 | import org.apache.kafka.streams.kstream.KTable; 6 | import org.apache.kafka.streams.state.KeyValueIterator; 7 | import org.apache.kafka.streams.state.QueryableStoreTypes; 8 | import org.apache.kafka.streams.state.ReadOnlyKeyValueStore; 9 | import org.covid19.vaccinetracker.model.UsersByPincode; 10 | import org.covid19.vaccinetracker.userrequests.model.District; 11 | import org.covid19.vaccinetracker.userrequests.model.UserRequest; 12 | import org.covid19.vaccinetracker.userrequests.model.Vaccine; 13 | import org.springframework.boot.ApplicationRunner; 14 | import org.springframework.context.annotation.Bean; 15 | import org.springframework.context.annotation.Configuration; 16 | import org.springframework.kafka.config.StreamsBuilderFactoryBean; 17 | 18 | import java.util.List; 19 | import java.util.Optional; 20 | import java.util.concurrent.CountDownLatch; 21 | import java.util.concurrent.TimeUnit; 22 | 23 | import lombok.extern.slf4j.Slf4j; 24 | 25 | import static java.util.Objects.isNull; 26 | import static java.util.Optional.ofNullable; 27 | import static org.covid19.vaccinetracker.userrequests.model.Age.AGE_18_44; 28 | import static org.covid19.vaccinetracker.userrequests.model.Dose.DOSE_1; 29 | 30 | @Slf4j 31 | @Configuration 32 | public class KafkaStateStores { 33 | private ReadOnlyKeyValueStore userRequestsStore; 34 | private ReadOnlyKeyValueStore userDistrictsStore; 35 | private ReadOnlyKeyValueStore usersByPincodeStore; 36 | 37 | @Bean 38 | public CountDownLatch latch(StreamsBuilderFactoryBean fb) { 39 | CountDownLatch latch = new CountDownLatch(1); 40 | fb.setStateListener((newState, oldState) -> { 41 | if (KafkaStreams.State.RUNNING.equals(newState)) { 42 | latch.countDown(); 43 | } 44 | }); 45 | return latch; 46 | } 47 | 48 | @Bean 49 | public ApplicationRunner runner(StreamsBuilderFactoryBean fb, 50 | KTable userRequestsTable, 51 | KTable userDistrictsTable, 52 | KTable usersByPincodeTable) { 53 | return args -> { 54 | latch(fb).await(100, TimeUnit.SECONDS); 55 | userRequestsStore = fb.getKafkaStreams().store( 56 | StoreQueryParameters.fromNameAndType(userRequestsTable.queryableStoreName(), QueryableStoreTypes.keyValueStore())); 57 | userDistrictsStore = fb.getKafkaStreams().store( 58 | StoreQueryParameters.fromNameAndType(userDistrictsTable.queryableStoreName(), QueryableStoreTypes.keyValueStore())); 59 | usersByPincodeStore = fb.getKafkaStreams().store( 60 | StoreQueryParameters.fromNameAndType(usersByPincodeTable.queryableStoreName(), QueryableStoreTypes.keyValueStore())); 61 | }; 62 | } 63 | 64 | public KeyValueIterator userRequests() { 65 | return userRequestsStore.all(); 66 | } 67 | 68 | public Optional userRequestById(String userId) { 69 | if (isNull(userRequestsStore)) { 70 | return Optional.empty(); 71 | } 72 | return Optional.ofNullable(userId) 73 | .map(s -> userRequestsStore.get(userId)); 74 | } 75 | 76 | public List pincodesForUser(String userId) { 77 | return ofNullable(userRequestsStore.get(userId)) 78 | .orElseGet(() -> new UserRequest(userId, List.of(), List.of(), AGE_18_44.toString(), DOSE_1.toString(), Vaccine.ALL.toString(), null)) 79 | .getPincodes(); 80 | } 81 | 82 | public KeyValueIterator userDistricts() { 83 | return userDistrictsStore.all(); 84 | } 85 | 86 | public KeyValueIterator usersByPincode() { 87 | return usersByPincodeStore.all(); 88 | } 89 | 90 | public UsersByPincode usersByPincode(String pincode) { 91 | return usersByPincodeStore.get(pincode); 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/main/java/org/covid19/vaccinetracker/availability/cowin/CowinApiAuth.java: -------------------------------------------------------------------------------- 1 | package org.covid19.vaccinetracker.availability.cowin; 2 | 3 | import org.apache.commons.codec.digest.DigestUtils; 4 | import org.apache.commons.lang3.math.NumberUtils; 5 | import org.covid19.vaccinetracker.availability.model.ConfirmOtpResponse; 6 | import org.covid19.vaccinetracker.availability.model.GenerateOtpResponse; 7 | import org.covid19.vaccinetracker.utils.Utils; 8 | import org.springframework.scheduling.annotation.Scheduled; 9 | import org.springframework.stereotype.Component; 10 | 11 | import java.util.concurrent.atomic.AtomicBoolean; 12 | import java.util.concurrent.atomic.AtomicReference; 13 | 14 | import lombok.extern.slf4j.Slf4j; 15 | 16 | import static java.util.Objects.isNull; 17 | 18 | @Slf4j 19 | @Component 20 | public class CowinApiAuth { 21 | private final AtomicReference bearerToken = new AtomicReference<>(""); 22 | private String transactionId = ""; 23 | private final AtomicBoolean awaitingOtp = new AtomicBoolean(false); 24 | 25 | private final CowinApiOtpClient cowinApiOtpClient; 26 | private final CowinConfig cowinConfig; 27 | 28 | public CowinApiAuth(CowinApiOtpClient cowinApiOtpClient, CowinConfig cowinConfig) { 29 | this.cowinApiOtpClient = cowinApiOtpClient; 30 | this.cowinConfig = cowinConfig; 31 | } 32 | 33 | @Scheduled(cron = "${jobs.cron.cowin.api.auth:-}", zone = "IST") 34 | public void refreshCowinToken() { 35 | log.info("Refreshing Cowin Auth Token"); 36 | if (this.awaitingOtp.get()) { 37 | log.warn("Still awaiting OTP. Resetting and canceling current run."); 38 | reset(); 39 | return; 40 | } 41 | final GenerateOtpResponse generateOtpResponse = this.cowinApiOtpClient.generateOtp(cowinConfig.getAuthMobile()); 42 | if (isNull(generateOtpResponse)) { 43 | return; 44 | } 45 | this.transactionId = generateOtpResponse.getTransactionId(); 46 | this.awaitingOtp.compareAndSet(false, true); 47 | } 48 | 49 | public void handleOtpCallback(String callBackMessage) { 50 | if (!this.awaitingOtp.get()) { 51 | log.warn("Received OTP callback but not awaiting OTP at this time, skipping. Callback: {}", callBackMessage); 52 | return; 53 | } 54 | String otpHash = parseOtp(callBackMessage); 55 | if (isNull(otpHash)) { 56 | reset(); 57 | return; 58 | } 59 | final ConfirmOtpResponse confirmOtpResponse = this.cowinApiOtpClient.confirmOtp(this.transactionId, otpHash); 60 | if (isNull(confirmOtpResponse)) { 61 | reset(); 62 | return; 63 | } 64 | this.bearerToken.set(confirmOtpResponse.getToken()); 65 | this.awaitingOtp.set(false); 66 | this.transactionId = ""; 67 | log.info("CoWIN authentication completed."); 68 | } 69 | 70 | void reset() { 71 | this.awaitingOtp.set(false); 72 | this.transactionId = ""; 73 | this.bearerToken.set(""); 74 | } 75 | 76 | public String getBearerToken() { 77 | return bearerToken.get(); 78 | } 79 | 80 | public String getTransactionId() { 81 | return transactionId; 82 | } 83 | 84 | public boolean isAwaitingOtp() { 85 | return awaitingOtp.get(); 86 | } 87 | 88 | public boolean isAvailable() { 89 | return Utils.isValidJwtToken(this.bearerToken.get()); 90 | } 91 | 92 | private String parseOtp(String message) { 93 | if (isInvalidCallbackMessage(message)) { 94 | log.warn("Found invalid/unexpected callback message: {}", message); 95 | return null; 96 | } 97 | return DigestUtils.sha256Hex(extractOtp(message)); 98 | } 99 | 100 | private String extractOtp(String message) { 101 | return message.substring(37, 43); 102 | } 103 | 104 | private boolean isInvalidCallbackMessage(String message) { 105 | return isNull(message) || message.isEmpty() 106 | || !message.startsWith("Your OTP to register/access CoWIN is ") 107 | || message.length() < 43 108 | || !NumberUtils.isParsable(message.substring(37, 43)); 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/main/java/org/covid19/vaccinetracker/availability/cowin/CowinApiClient.java: -------------------------------------------------------------------------------- 1 | package org.covid19.vaccinetracker.availability.cowin; 2 | 3 | import org.covid19.vaccinetracker.model.VaccineCenters; 4 | import org.covid19.vaccinetracker.utils.Utils; 5 | import org.springframework.stereotype.Component; 6 | import org.springframework.web.reactive.function.client.WebClient; 7 | 8 | import lombok.extern.slf4j.Slf4j; 9 | 10 | import static java.util.Collections.singletonList; 11 | import static org.springframework.http.MediaType.APPLICATION_JSON; 12 | 13 | @Slf4j 14 | @Component 15 | public class CowinApiClient { 16 | private final WebClient cowinClient; 17 | private final CowinApiAuth cowinApiAuth; 18 | 19 | private static final String PATH_CALENDAR_BY_PIN = "/v2/appointment/sessions/public/calendarByPin"; 20 | private static final String PATH_CALENDAR_BY_PIN_AUTH = "/v2/appointment/sessions/calendarByPin"; 21 | private static final String PATH_CALENDAR_BY_DISTRICT = "/v2/appointment/sessions/public/calendarByDistrict"; 22 | private static final String PATH_CALENDAR_BY_DISTRICT_AUTH = "/v2/appointment/sessions/calendarByDistrict"; 23 | 24 | public CowinApiClient(CowinConfig cowinConfig, CowinApiAuth cowinApiAuth) { 25 | this.cowinClient = WebClient 26 | .builder() 27 | .baseUrl(cowinConfig.getApiUrl()) 28 | .codecs(clientCodecConfigurer -> clientCodecConfigurer.defaultCodecs().maxInMemorySize(16 * 1024 * 1024)) 29 | .filter(WebClientFilter.logRequest()) 30 | .filter(WebClientFilter.logResponse()) 31 | .build(); 32 | this.cowinApiAuth = cowinApiAuth; 33 | } 34 | 35 | public VaccineCenters fetchCentersByPincode(String pincode) { 36 | try { 37 | if (cowinApiAuth.isAvailable()) { 38 | return cowinClient.get() 39 | .uri(uriBuilder -> uriBuilder 40 | .path(PATH_CALENDAR_BY_PIN_AUTH) 41 | .queryParam("pincode", "{pincode}") 42 | .queryParam("date", "{date}") 43 | .build(pincode, Utils.tomorrowIST())) 44 | .headers(h -> h.setBearerAuth(cowinApiAuth.getBearerToken())) 45 | .headers(h -> h.setAccept(singletonList(APPLICATION_JSON))) 46 | .retrieve() 47 | .bodyToMono(VaccineCenters.class) 48 | .block(); 49 | } else { 50 | return cowinClient.get() 51 | .uri(uriBuilder -> uriBuilder 52 | .path(PATH_CALENDAR_BY_PIN) 53 | .queryParam("pincode", "{pincode}") 54 | .queryParam("date", "{date}") 55 | .build(pincode, Utils.todayIST())) 56 | .retrieve() 57 | .bodyToMono(VaccineCenters.class) 58 | .block(); 59 | } 60 | } catch (CowinException we) { 61 | log.error("Error from Cowin API for pincode {} status code {}, message {}", pincode, we.getStatusCode(), we.getMessage()); 62 | return null; 63 | } 64 | } 65 | 66 | public VaccineCenters fetchSessionsByDistrict(int districtId) { 67 | try { 68 | if (cowinApiAuth.isAvailable()) { 69 | return cowinClient.get() 70 | .uri(uriBuilder -> uriBuilder 71 | .path(PATH_CALENDAR_BY_DISTRICT_AUTH) 72 | .queryParam("district_id", "{district_id}") 73 | .queryParam("date", "{date}") 74 | .build(districtId, Utils.todayIST())) 75 | .headers(h -> h.setBearerAuth(cowinApiAuth.getBearerToken())) 76 | .headers(h -> h.setAccept(singletonList(APPLICATION_JSON))) 77 | .retrieve() 78 | .bodyToMono(VaccineCenters.class) 79 | .block(); 80 | } else { 81 | return cowinClient.get() 82 | .uri(uriBuilder -> uriBuilder 83 | .path(PATH_CALENDAR_BY_DISTRICT) 84 | .queryParam("district_id", "{district_id}") 85 | .queryParam("date", "{date}") 86 | .build(districtId, Utils.todayIST())) 87 | .retrieve() 88 | .bodyToMono(VaccineCenters.class) 89 | .block(); 90 | } 91 | } catch (CowinException we) { 92 | log.error("Error from Cowin API for district {} status code {}, message {}", districtId, we.getStatusCode(), we.getMessage()); 93 | return null; 94 | } 95 | } 96 | 97 | public boolean isProtected() { 98 | return this.cowinApiAuth.isAvailable(); 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/main/java/org/covid19/vaccinetracker/notifications/TelegramLambdaWrapper.java: -------------------------------------------------------------------------------- 1 | package org.covid19.vaccinetracker.notifications; 2 | 3 | import com.amazonaws.handlers.AsyncHandler; 4 | import com.amazonaws.services.lambda.AWSLambdaAsync; 5 | import com.amazonaws.services.lambda.model.InvokeRequest; 6 | import com.amazonaws.services.lambda.model.InvokeResult; 7 | import com.fasterxml.jackson.annotation.JsonProperty; 8 | import com.fasterxml.jackson.core.JsonProcessingException; 9 | import com.fasterxml.jackson.databind.ObjectMapper; 10 | 11 | import org.covid19.vaccinetracker.availability.aws.AWSConfig; 12 | import org.covid19.vaccinetracker.userrequests.UserRequestManager; 13 | import org.covid19.vaccinetracker.utils.Utils; 14 | import org.jetbrains.annotations.NotNull; 15 | import org.springframework.stereotype.Component; 16 | 17 | import java.nio.charset.StandardCharsets; 18 | import java.util.Optional; 19 | import java.util.stream.Stream; 20 | 21 | import lombok.AllArgsConstructor; 22 | import lombok.Builder; 23 | import lombok.Data; 24 | import lombok.NoArgsConstructor; 25 | import lombok.extern.slf4j.Slf4j; 26 | 27 | import static java.util.Collections.emptyList; 28 | 29 | @Slf4j 30 | @Component 31 | public class TelegramLambdaWrapper { 32 | private final AWSConfig awsConfig; 33 | private final AWSLambdaAsync awsLambdaAsync; 34 | private final ObjectMapper objectMapper; 35 | private final UserRequestManager userRequestManager; 36 | 37 | public TelegramLambdaWrapper(AWSConfig awsConfig, AWSLambdaAsync awsLambdaAsync, ObjectMapper objectMapper, UserRequestManager userRequestManager) { 38 | this.awsConfig = awsConfig; 39 | this.awsLambdaAsync = awsLambdaAsync; 40 | this.objectMapper = objectMapper; 41 | this.userRequestManager = userRequestManager; 42 | } 43 | 44 | /** 45 | * Invokes "SendTelegramMsg" Lambda asynchronously with given inputs 46 | * 47 | * @param chatId - Id of the TG user 48 | * @param message - TG message 49 | */ 50 | public void sendTelegramNotification(String chatId, String message) { 51 | createSendTelegramMsgLambdaEvent(chatId, message) 52 | .map(this::createSendTelegramMsgInvokeRequest) 53 | .ifPresent(invokeRequest -> awsLambdaAsync.invokeAsync(invokeRequest, sendTelegramMsgAsyncHandler())); 54 | } 55 | 56 | @NotNull 57 | private AsyncHandler sendTelegramMsgAsyncHandler() { 58 | return new AsyncHandler<>() { 59 | @Override 60 | public void onError(Exception e) { 61 | log.error("Got error {}", e.getMessage()); 62 | } 63 | 64 | @Override 65 | public void onSuccess(InvokeRequest request, InvokeResult result) { 66 | toSendTelegramMsgLambdaResponse(result) 67 | .filter(response -> !response.getStatus()) 68 | .ifPresent(response -> { 69 | log.warn("Error sending TG notification to {}, error {}", response.getChatId(), response.getErrorMsg()); 70 | if (response.getErrorMsg().contains("bot was blocked by the user") 71 | || response.getErrorMsg().contains("user is deactivated")) { 72 | // stop user preference to prevent further alerts being sent 73 | userRequestManager.acceptUserRequest(response.getChatId(), emptyList()); 74 | log.warn("User {} pincode preferences cleared", response.getChatId()); 75 | } 76 | }); 77 | } 78 | }; 79 | } 80 | 81 | @NotNull 82 | private Optional toSendTelegramMsgLambdaResponse(InvokeResult invokeResult) { 83 | return Stream.ofNullable(invokeResult.getPayload()) 84 | .map(payload -> StandardCharsets.UTF_8.decode(payload).toString()) 85 | .map(s -> Utils.parseLambdaResponseJson(objectMapper, s, SendTelegramMsgLambdaResponse.class)) 86 | .findFirst(); 87 | 88 | } 89 | 90 | private Optional createSendTelegramMsgLambdaEvent(String chatId, String message) { 91 | try { 92 | return Optional.of(objectMapper.writeValueAsString( 93 | SendTelegramMsgLambdaEvent.builder() 94 | .chatId(chatId) 95 | .message(message) 96 | .build())); 97 | } catch (JsonProcessingException e) { 98 | log.error("Error serializing lambdaEvent for chatId {}", chatId); 99 | return Optional.empty(); 100 | } 101 | } 102 | 103 | private InvokeRequest createSendTelegramMsgInvokeRequest(String event) { 104 | return new InvokeRequest() 105 | .withFunctionName(awsConfig.getSendTelegramMsgLambdaArn()) 106 | .withPayload(event); 107 | } 108 | } 109 | 110 | @Data 111 | @AllArgsConstructor 112 | @NoArgsConstructor 113 | @Builder 114 | class SendTelegramMsgLambdaEvent { 115 | @JsonProperty("chat_id") 116 | private String chatId; 117 | private String message; 118 | } 119 | 120 | @Data 121 | @AllArgsConstructor 122 | @NoArgsConstructor 123 | class SendTelegramMsgLambdaResponse { 124 | @JsonProperty("chat_id") 125 | private String chatId; 126 | private Boolean status; 127 | @JsonProperty("error_msg") 128 | private String errorMsg; 129 | } 130 | -------------------------------------------------------------------------------- /.mvn/settings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 7 | 34 | 35 | 36 | 42 | 43 | confluent 44 | confluent 45 | Confluent 46 | http://packages.confluent.io/maven/ 47 | 48 | 49 | 50 | 51 | 67 | 68 | github 69 | 70 | 71 | 72 | confluent 73 | Confluent 74 | http://packages.confluent.io/maven/ 75 | 76 | 77 | 78 | 79 | 113 | 114 | 115 | 118 | 119 | github 120 | 121 | 122 | -------------------------------------------------------------------------------- /src/test/java/org/covid19/vaccinetracker/notifications/NotificationCacheTest.java: -------------------------------------------------------------------------------- 1 | package org.covid19.vaccinetracker.notifications; 2 | 3 | import com.fasterxml.jackson.databind.ObjectMapper; 4 | 5 | import org.apache.commons.codec.digest.DigestUtils; 6 | import org.covid19.vaccinetracker.model.Center; 7 | import org.covid19.vaccinetracker.model.Session; 8 | import org.covid19.vaccinetracker.persistence.mariadb.entity.UserNotification; 9 | import org.covid19.vaccinetracker.persistence.mariadb.entity.UserNotificationId; 10 | import org.covid19.vaccinetracker.persistence.mariadb.repository.UserNotificationRepository; 11 | import org.junit.jupiter.api.BeforeEach; 12 | import org.junit.jupiter.api.Disabled; 13 | import org.junit.jupiter.api.Test; 14 | import org.springframework.beans.factory.annotation.Autowired; 15 | import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; 16 | 17 | import java.time.LocalDateTime; 18 | import java.time.ZoneId; 19 | import java.time.ZonedDateTime; 20 | import java.util.List; 21 | 22 | import static org.covid19.vaccinetracker.utils.Utils.INDIA_TIMEZONE; 23 | import static org.junit.jupiter.api.Assertions.assertFalse; 24 | import static org.junit.jupiter.api.Assertions.assertTrue; 25 | 26 | @DataJpaTest 27 | public class NotificationCacheTest { 28 | @Autowired 29 | private UserNotificationRepository repository; 30 | 31 | private NotificationCache cache; 32 | private ObjectMapper objectMapper; 33 | 34 | @BeforeEach 35 | public void setup() { 36 | objectMapper = new ObjectMapper(); 37 | this.cache = new NotificationCache(repository, objectMapper); 38 | } 39 | 40 | @Test 41 | public void testNewNotification() throws Exception { 42 | List
old = List.of(Center.builder().centerId(123).name("abc") 43 | .sessions(List.of(Session.builder() 44 | .availableCapacity(10) 45 | .availableCapacityDose1(10) 46 | .build())) 47 | .build()); 48 | this.repository.save(UserNotification.builder() 49 | .userNotificationId(UserNotificationId.builder() 50 | .userId("userA") 51 | .pincode("110022") 52 | .build()) 53 | .notificationHash(DigestUtils.sha256Hex(objectMapper.writeValueAsBytes(old))) 54 | .notifiedAt(ZonedDateTime.now().withZoneSameInstant(ZoneId.of("UTC")).minusMinutes(30).toLocalDateTime()) 55 | .build()); 56 | 57 | List
updated = List.of(Center.builder().centerId(123).pincode(110022).name("abc") 58 | .sessions(List.of(Session.builder() 59 | .availableCapacity(5) // capacity changed 60 | .availableCapacityDose1(5) 61 | .build())) 62 | .build()); 63 | 64 | assertTrue(cache.isNewNotification("userA", "110022", updated)); 65 | } 66 | 67 | @Test 68 | public void testOldNotification() throws Exception { 69 | List
old = List.of(Center.builder().centerId(123).pincode(110022).name("abc") 70 | .sessions(List.of(Session.builder() 71 | .availableCapacity(10) 72 | .availableCapacityDose1(10) 73 | .build())) 74 | .build()); 75 | this.repository.save(UserNotification.builder() 76 | .userNotificationId(UserNotificationId.builder() 77 | .userId("userA") 78 | .pincode("110022") 79 | .build()) 80 | .notificationHash(DigestUtils.sha256Hex(objectMapper.writeValueAsBytes(old))) 81 | .notifiedAt(ZonedDateTime.now().withZoneSameInstant(ZoneId.of("UTC")).minusMinutes(30).toLocalDateTime()) 82 | .build()); 83 | 84 | assertFalse(cache.isNewNotification("userA", "110022", old)); 85 | } 86 | 87 | @Test 88 | public void testNewNotificationWhenFirstTime() { 89 | assertTrue(cache.isNewNotification("userA", "110022", List.of())); 90 | } 91 | 92 | @Disabled 93 | @Test 94 | public void testNotificationLastNotifiedAtWithin15Mins() throws Exception { 95 | List
old = List.of(Center.builder().centerId(123).name("abc") 96 | .sessions(List.of(Session.builder() 97 | .availableCapacity(10) 98 | .availableCapacityDose1(10) 99 | .build())) 100 | .build()); 101 | this.repository.save(UserNotification.builder() 102 | .userNotificationId(UserNotificationId.builder() 103 | .userId("userA") 104 | .pincode("110022") 105 | .build()) 106 | .notificationHash(DigestUtils.sha256Hex(objectMapper.writeValueAsBytes(old))) 107 | .notifiedAt(ZonedDateTime.now().withZoneSameInstant(ZoneId.of("UTC")).minusMinutes(10).toLocalDateTime()) // last notified 10 mins ago 108 | .build()); 109 | 110 | List
updated = List.of(Center.builder().centerId(123).pincode(110022).name("abc") 111 | .sessions(List.of(Session.builder() 112 | .availableCapacity(5) // capacity changed 113 | .availableCapacityDose1(5) 114 | .build())) 115 | .build()); 116 | 117 | assertFalse(cache.isNewNotification("userA", "110022", updated)); 118 | } 119 | 120 | } 121 | -------------------------------------------------------------------------------- /src/test/java/org/covid19/vaccinetracker/persistence/mariadb/MariaDBVaccinePersistenceTest.java: -------------------------------------------------------------------------------- 1 | package org.covid19.vaccinetracker.persistence.mariadb; 2 | 3 | import org.covid19.vaccinetracker.model.Center; 4 | import org.covid19.vaccinetracker.model.Session; 5 | import org.covid19.vaccinetracker.model.VaccineCenters; 6 | import org.covid19.vaccinetracker.persistence.VaccinePersistence; 7 | import org.covid19.vaccinetracker.persistence.mariadb.entity.SessionEntity; 8 | import org.covid19.vaccinetracker.persistence.mariadb.repository.CenterRepository; 9 | import org.covid19.vaccinetracker.persistence.mariadb.repository.SessionRepository; 10 | import org.jetbrains.annotations.NotNull; 11 | import org.junit.jupiter.api.BeforeEach; 12 | import org.junit.jupiter.api.Test; 13 | import org.springframework.beans.factory.annotation.Autowired; 14 | import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; 15 | 16 | import java.util.Optional; 17 | 18 | import static java.util.Collections.emptyList; 19 | import static java.util.Collections.singletonList; 20 | import static org.junit.jupiter.api.Assertions.assertEquals; 21 | import static org.junit.jupiter.api.Assertions.assertFalse; 22 | import static org.junit.jupiter.api.Assertions.assertNotNull; 23 | import static org.junit.jupiter.api.Assertions.assertTrue; 24 | 25 | @DataJpaTest 26 | public class MariaDBVaccinePersistenceTest { 27 | @Autowired 28 | private CenterRepository centerRepository; 29 | @Autowired 30 | private SessionRepository sessionRepository; 31 | 32 | private VaccinePersistence vaccinePersistence; 33 | 34 | @BeforeEach 35 | public void beforeSetup() { 36 | this.vaccinePersistence = new MariaDBVaccinePersistence(centerRepository, sessionRepository 37 | ); 38 | } 39 | 40 | @Test 41 | public void testFetchVaccineCentersByPincode() { 42 | VaccineCenters expected = new VaccineCenters(); 43 | expected.setCenters(emptyList()); 44 | assertEquals(expected, vaccinePersistence.fetchVaccineCentersByPincode("123456")); 45 | 46 | expected = new VaccineCenters(); 47 | expected.setCenters( 48 | singletonList(Center.builder() 49 | .centerId(383358) 50 | .name("Gamhariya PHC") 51 | .stateName("Bihar") 52 | .districtName("Madhepura") 53 | .pincode(852108) 54 | .feeType("Free") 55 | .sessions(singletonList(Session.builder() 56 | .sessionId("001813bc-1607-42d9-9ef6-e58ba4e42d1d") 57 | .date("23-05-2021") 58 | .availableCapacity(98) 59 | .availableCapacityDose1(48) 60 | .availableCapacityDose2(50) 61 | .minAgeLimit(45) 62 | .vaccine("COVISHIELD") 63 | .build())) 64 | .build())); 65 | assertEquals(expected, vaccinePersistence.fetchVaccineCentersByPincode("852108")); 66 | } 67 | 68 | @Test 69 | public void testMarkProcessed() { 70 | final VaccineCenters vaccineCenters = buildVaccineCenters(); 71 | vaccinePersistence.markProcessed(vaccineCenters); 72 | final Optional session = sessionRepository.findById("32bbb37e-7cb4-4942-bd92-ac56d86490f9"); 73 | assertTrue(session.isPresent()); 74 | assertNotNull(session.get().getProcessedAt()); 75 | } 76 | 77 | @Test 78 | public void testPersistVaccineCenters() { 79 | final VaccineCenters vaccineCenters = buildVaccineCenters(); 80 | vaccinePersistence.persistVaccineCenters(vaccineCenters); 81 | assertTrue(sessionRepository.findById("32bbb37e-7cb4-4942-bd92-ac56d86490f9").isPresent()); 82 | assertTrue(centerRepository.findById(1205L).isPresent()); 83 | } 84 | 85 | @Test 86 | public void testFindLatestSession() { 87 | vaccinePersistence.persistVaccineCenters(buildVaccineCenters()); 88 | final Optional session = vaccinePersistence.findExistingSession(1205L, "22-05-2021", 18, "COVAXIN"); 89 | assertTrue(session.isPresent()); 90 | assertEquals("22-05-2021", session.get().getDate()); 91 | assertEquals(18, session.get().getMinAgeLimit()); 92 | assertEquals("COVAXIN", session.get().getVaccine()); 93 | 94 | final Optional shouldNotExist = vaccinePersistence.findExistingSession(1205L, "23-05-2021", 18, "COVAXIN"); 95 | assertFalse(shouldNotExist.isPresent()); 96 | } 97 | 98 | @NotNull 99 | private VaccineCenters buildVaccineCenters() { 100 | final VaccineCenters vaccineCenters = new VaccineCenters(); 101 | vaccineCenters.setCenters( 102 | singletonList(Center.builder() 103 | .centerId(1205) 104 | .name("Mohalla Clinic Peeragarhi PHC") 105 | .stateName("Delhi") 106 | .districtName("West Delhi") 107 | .pincode(110056) 108 | .feeType("Free") 109 | .sessions(singletonList(Session.builder() 110 | .sessionId("32bbb37e-7cb4-4942-bd92-ac56d86490f9") 111 | .date("22-05-2021") 112 | .availableCapacity(0) 113 | .availableCapacityDose1(0) 114 | .availableCapacityDose2(0) 115 | .minAgeLimit(18) 116 | .vaccine("COVAXIN") 117 | .build())) 118 | .build())); 119 | return vaccineCenters; 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /src/main/resources/db/migration/V0.1.0.00__covid19_schema.sql: -------------------------------------------------------------------------------- 1 | SET 2 | SQL_MODE = "NO_AUTO_VALUE_ON_ZERO"; 3 | START TRANSACTION; 4 | SET 5 | time_zone = "+00:00"; 6 | 7 | 8 | /*!40101 SET @OLD_CHARACTER_SET_CLIENT = @@CHARACTER_SET_CLIENT */; 9 | /*!40101 SET @OLD_CHARACTER_SET_RESULTS = @@CHARACTER_SET_RESULTS */; 10 | /*!40101 SET @OLD_COLLATION_CONNECTION = @@COLLATION_CONNECTION */; 11 | /*!40101 SET NAMES utf8mb4 */; 12 | 13 | -- 14 | -- Database: `covid19` 15 | -- 16 | 17 | -- -------------------------------------------------------- 18 | 19 | -- 20 | -- Table structure for table `districts` 21 | -- 22 | 23 | CREATE TABLE `districts` 24 | ( 25 | `id` int(11) NOT NULL, 26 | `district_name` varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL, 27 | `state_id` int(11) NOT NULL 28 | ) ENGINE = InnoDB 29 | DEFAULT CHARSET = utf8mb4 30 | COLLATE = utf8mb4_unicode_ci; 31 | 32 | -- -------------------------------------------------------- 33 | 34 | -- 35 | -- Table structure for table `pincodes` 36 | -- 37 | 38 | CREATE TABLE `pincodes` 39 | ( 40 | `id` varchar(36) COLLATE utf8mb4_unicode_ci NOT NULL, 41 | `pincode` varchar(6) COLLATE utf8mb4_unicode_ci NOT NULL, 42 | `district_id` int(11) NOT NULL 43 | ) ENGINE = InnoDB 44 | DEFAULT CHARSET = utf8mb4 45 | COLLATE = utf8mb4_unicode_ci; 46 | 47 | -- -------------------------------------------------------- 48 | 49 | -- 50 | -- Table structure for table `states` 51 | -- 52 | 53 | CREATE TABLE `states` 54 | ( 55 | `id` int(11) NOT NULL, 56 | `state_name` varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL 57 | ) ENGINE = InnoDB 58 | DEFAULT CHARSET = utf8mb4 59 | COLLATE = utf8mb4_unicode_ci; 60 | 61 | -- 62 | -- Indexes for dumped tables 63 | -- 64 | 65 | -- 66 | -- Indexes for table `districts` 67 | -- 68 | ALTER TABLE `districts` 69 | ADD PRIMARY KEY (`id`), 70 | ADD KEY `state_id` (`state_id`); 71 | 72 | -- 73 | -- Indexes for table `pincodes` 74 | -- 75 | ALTER TABLE `pincodes` 76 | ADD PRIMARY KEY (`id`), 77 | ADD KEY `pincode` (`pincode`), 78 | ADD KEY `district_id` (`district_id`); 79 | 80 | -- 81 | -- Indexes for table `states` 82 | -- 83 | ALTER TABLE `states` 84 | ADD PRIMARY KEY (`id`); 85 | 86 | -- 87 | -- Constraints for dumped tables 88 | -- 89 | 90 | -- 91 | -- Constraints for table `districts` 92 | -- 93 | ALTER TABLE `districts` 94 | ADD CONSTRAINT `districts_ibfk_1` FOREIGN KEY (`state_id`) REFERENCES `states` (`id`); 95 | 96 | -- 97 | -- Constraints for table `pincodes` 98 | -- 99 | ALTER TABLE `pincodes` 100 | ADD CONSTRAINT `pincodes_ibfk_1` FOREIGN KEY (`district_id`) REFERENCES `districts` (`id`); 101 | 102 | -- 103 | -- Table structure for table `sessions` 104 | -- 105 | 106 | CREATE TABLE `sessions` 107 | ( 108 | `id` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, 109 | `available_capacity` int(11) DEFAULT NULL, 110 | `date` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, 111 | `min_age_limit` int(11) DEFAULT NULL, 112 | `vaccine` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL 113 | ) ENGINE = InnoDB 114 | DEFAULT CHARSET = utf8mb4 115 | COLLATE = utf8mb4_unicode_ci; 116 | 117 | -- -------------------------------------------------------- 118 | 119 | -- 120 | -- Table structure for table `vaccine_centers` 121 | -- 122 | 123 | CREATE TABLE `vaccine_centers` 124 | ( 125 | `id` bigint(20) NOT NULL, 126 | `address` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, 127 | `district_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, 128 | `fee_type` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, 129 | `name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, 130 | `pincode` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, 131 | `state_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL 132 | ) ENGINE = InnoDB 133 | DEFAULT CHARSET = utf8mb4 134 | COLLATE = utf8mb4_unicode_ci; 135 | 136 | -- -------------------------------------------------------- 137 | 138 | -- 139 | -- Table structure for table `vaccine_centers_sessions` 140 | -- 141 | 142 | CREATE TABLE `vaccine_centers_sessions` 143 | ( 144 | `center_entity_id` bigint(20) NOT NULL, 145 | `sessions_id` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL 146 | ) ENGINE = InnoDB 147 | DEFAULT CHARSET = utf8mb4 148 | COLLATE = utf8mb4_unicode_ci; 149 | 150 | -- 151 | -- Indexes for dumped tables 152 | -- 153 | 154 | -- 155 | -- Indexes for table `sessions` 156 | -- 157 | ALTER TABLE `sessions` 158 | ADD PRIMARY KEY (`id`); 159 | 160 | -- 161 | -- Indexes for table `vaccine_centers` 162 | -- 163 | ALTER TABLE `vaccine_centers` 164 | ADD PRIMARY KEY (`id`); 165 | 166 | -- 167 | -- Indexes for table `vaccine_centers_sessions` 168 | -- 169 | ALTER TABLE `vaccine_centers_sessions` 170 | ADD PRIMARY KEY (`center_entity_id`, `sessions_id`), 171 | ADD UNIQUE KEY `UK_8cxlr15ohx4l6s01bgh1otlpe` (`sessions_id`); 172 | 173 | -- 174 | -- Constraints for dumped tables 175 | -- 176 | 177 | -- 178 | -- Constraints for table `vaccine_centers_sessions` 179 | -- 180 | ALTER TABLE `vaccine_centers_sessions` 181 | ADD CONSTRAINT `FK9xhiy5riep1lknwgrjucqj714` FOREIGN KEY (`center_entity_id`) REFERENCES `vaccine_centers` (`id`), 182 | ADD CONSTRAINT `FKk9yumo7p4xf24nji4r7i92dyr` FOREIGN KEY (`sessions_id`) REFERENCES `sessions` (`id`); 183 | COMMIT; 184 | 185 | /*!40101 SET CHARACTER_SET_CLIENT = @OLD_CHARACTER_SET_CLIENT */; 186 | /*!40101 SET CHARACTER_SET_RESULTS = @OLD_CHARACTER_SET_RESULTS */; 187 | /*!40101 SET COLLATION_CONNECTION = @OLD_COLLATION_CONNECTION */; 188 | -------------------------------------------------------------------------------- /src/test/java/org/covid19/vaccinetracker/utils/UtilsTest.java: -------------------------------------------------------------------------------- 1 | package org.covid19.vaccinetracker.utils; 2 | 3 | import org.covid19.vaccinetracker.model.Center; 4 | import org.covid19.vaccinetracker.model.Session; 5 | import org.covid19.vaccinetracker.model.VaccineFee; 6 | import org.junit.jupiter.api.Test; 7 | 8 | import java.time.ZoneId; 9 | import java.time.ZonedDateTime; 10 | import java.time.format.DateTimeFormatter; 11 | import java.util.ArrayList; 12 | import java.util.List; 13 | 14 | import static org.covid19.vaccinetracker.utils.Utils.INDIA_TIMEZONE; 15 | import static org.hamcrest.MatcherAssert.assertThat; 16 | import static org.hamcrest.Matchers.equalTo; 17 | import static org.hamcrest.Matchers.is; 18 | import static org.junit.jupiter.api.Assertions.assertEquals; 19 | import static org.junit.jupiter.api.Assertions.assertFalse; 20 | import static org.junit.jupiter.api.Assertions.assertTrue; 21 | 22 | public class UtilsTest { 23 | 24 | @Test 25 | public void pincodeValidationTest() { 26 | assertTrue(Utils.allValidPincodes("440022"), "Should be valid!"); 27 | assertTrue(Utils.allValidPincodes("400008"), "Should be valid!"); 28 | assertTrue(Utils.allValidPincodes("410038"), "Should be valid!"); 29 | assertFalse(Utils.allValidPincodes("4561091"), "Should not be valid!"); 30 | assertFalse(Utils.allValidPincodes("40010"), "Should not be valid!"); 31 | assertFalse(Utils.allValidPincodes("abcde"), "Should not be valid!"); 32 | assertFalse(Utils.allValidPincodes("395def"), "Should not be valid!"); 33 | } 34 | 35 | @Test 36 | public void testDayOldDate() { 37 | DateTimeFormatter dtf = DateTimeFormatter.ISO_OFFSET_DATE_TIME; 38 | assertTrue(Utils.dayOld("2021-05-03T15:07:35.476954+05:30")); 39 | assertTrue(Utils.dayOld(dtf.format(ZonedDateTime.now(ZoneId.of(INDIA_TIMEZONE)).minusHours(24)))); 40 | assertFalse(Utils.dayOld(dtf.format(ZonedDateTime.now(ZoneId.of(INDIA_TIMEZONE)).minusHours(2)))); 41 | } 42 | 43 | @Test 44 | public void testPast15Mins() { 45 | DateTimeFormatter dtf = DateTimeFormatter.ISO_OFFSET_DATE_TIME; 46 | assertTrue(Utils.past15mins("2021-05-03T15:07:35.476954+05:30")); 47 | assertTrue(Utils.past15mins(dtf.format(ZonedDateTime.now(ZoneId.of(INDIA_TIMEZONE)).minusMinutes(30)))); 48 | assertFalse(Utils.past15mins(dtf.format(ZonedDateTime.now(ZoneId.of(INDIA_TIMEZONE)).minusMinutes(10)))); 49 | } 50 | 51 | @Test 52 | public void testNotificationText() { 53 | String expected = "Premlok Park Disp- 2(18-44) (Pune 411033) - Paid\n
" +
54 |                 "\n8 doses (Dose 1: 3, Dose 2: 5) of COVISHIELD for 18+ age group available on 4th May for ₹780\n" +
55 |                 "(18+ आयु वर्ग के लिए COVISHIELD की 8 खुराकें (खुराक 1: 3, खुराक 2: 5) 4th May को उपलब्ध हैं)\n" +
56 |                 "\n15 doses (Dose 1: 12, Dose 2: 3) of COVAXIN for 18+ age group available on 5th May for ₹1410\n" +
57 |                 "(18+ आयु वर्ग के लिए COVAXIN की 15 खुराकें (खुराक 1: 12, खुराक 2: 3) 5th May को उपलब्ध हैं)\n
\n" + 58 | "For registration, please visit CoWIN Website\n"; 59 | List
centers = new ArrayList<>(); 60 | List sessions = new ArrayList<>(); 61 | sessions.add(Session.builder().availableCapacity(8).availableCapacityDose1(3).availableCapacityDose2(5).minAgeLimit(18).date("04-05-2021").vaccine("COVISHIELD").cost("780").build()); 62 | sessions.add(Session.builder().availableCapacity(15).availableCapacityDose1(12).availableCapacityDose2(3).minAgeLimit(18).date("05-05-2021").vaccine("COVAXIN").cost("1410").build()); 63 | centers.add(Center.builder().name("Premlok Park Disp- 2(18-44)").districtName("Pune").pincode(411033).feeType("Paid").sessions(sessions) 64 | .vaccineFees(List.of(VaccineFee.builder().vaccine("COVISHIELD").fee("780").build(), VaccineFee.builder().vaccine("COVAXIN").build())).build()); 65 | assertEquals(expected, Utils.buildNotificationMessage(centers), "Notification text does not match"); 66 | } 67 | 68 | @Test 69 | public void testIsValidJwtToken() { 70 | String invalidToken = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX25hbWUiOiJlYWYxMDc3Ny00ZjIxLTQwNjYtYTQ2Yy0yNDhjOGJkNzNiNDEiLCJ1c2VyX2lkIjoiZWFmMTA3NzctNGYyMS00MDY2LWE0NmMtMjQ4YzhiZDczYjQxIiwidXNlcl90eXBlIjoiQkVORUZJQ0lBUlkiLCJtb2JpbGVfbnVtYmVyIjo5OTk5OTk5OTk5LCJiZW5lZmljaWFyeV9yZWZlcmVuY2VfaWQiOjc1MTIxOTk5ODE4MTUwLCJ1YSI6Imluc29tbmlhLzIwMjEuMy4wIiwiZGF0ZV9tb2RpZmllZCI6IjIwMjEtMDUtMTVUMTA6MTg6MzkuMjk5WiIsImlhdCI6MTYyMTA3MzkxOSwiZXhwIjoxNjIxMDc0ODE5fQ.YDQCfcSwtKAT5epVMOFt3jmQ1dc6jOZ-XbYISOmQLGw"; 71 | assertFalse(Utils.isValidJwtToken(invalidToken)); 72 | 73 | String validToken = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX25hbWUiOiJlYWYxMDc3Ny00ZjIxLTQwNjYtYTQ2Yy0yNDhjOGJkNzNiNDEiLCJ1c2VyX2lkIjoiZWFmMTA3NzctNGYyMS00MDY2LWE0NmMtMjQ4YzhiZDczYjQxIiwidXNlcl90eXBlIjoiQkVORUZJQ0lBUlkiLCJtb2JpbGVfbnVtYmVyIjo5OTk5OTk5OTk5LCJiZW5lZmljaWFyeV9yZWZlcmVuY2VfaWQiOjc1MTIxOTk5ODE4MTUwLCJ1YSI6Imluc29tbmlhLzIwMjEuMy4wIiwiZGF0ZV9tb2RpZmllZCI6IjIwMjEtMDUtMTZUMTA6MTg6MzkuMjk5WiIsImlhdCI6MTYyMTA3MzkxOSwiZXhwIjoyNjIxMTc0ODE5fQ.CDXX9uaPoz6dk19EvYwZC_UkJYRJA_z2FSVYSXzTPyc"; 74 | assertTrue(Utils.isValidJwtToken(validToken)); 75 | 76 | String emptyToken = ""; 77 | assertFalse(Utils.isValidJwtToken(emptyToken)); 78 | assertFalse(Utils.isValidJwtToken(null)); 79 | } 80 | 81 | @Test 82 | public void testHumanReadableDate() { 83 | assertThat(Utils.humanReadable("12-01-2020"), is(equalTo("12th Jan"))); 84 | assertThat(Utils.humanReadable("22-04-2021"), is(equalTo("22nd Apr"))); 85 | assertThat(Utils.humanReadable("01-05-2021"), is(equalTo("1st May"))); 86 | assertThat(Utils.humanReadable("10-06-2021"), is(equalTo("10th Jun"))); 87 | assertThat(Utils.humanReadable("23-07-2021"), is(equalTo("23rd Jul"))); 88 | assertThat(Utils.humanReadable("18-08-2021"), is(equalTo("18th Aug"))); 89 | } 90 | } 91 | 92 | -------------------------------------------------------------------------------- /src/main/java/org/covid19/vaccinetracker/persistence/kafka/UsersByPincodeTransformer.java: -------------------------------------------------------------------------------- 1 | package org.covid19.vaccinetracker.persistence.kafka; 2 | 3 | import com.google.common.annotations.VisibleForTesting; 4 | 5 | import org.apache.kafka.streams.KeyValue; 6 | import org.apache.kafka.streams.kstream.Transformer; 7 | import org.apache.kafka.streams.processor.ProcessorContext; 8 | import org.apache.kafka.streams.state.KeyValueIterator; 9 | import org.apache.kafka.streams.state.KeyValueStore; 10 | import org.covid19.vaccinetracker.model.UsersByPincode; 11 | import org.covid19.vaccinetracker.userrequests.MetadataStore; 12 | import org.covid19.vaccinetracker.userrequests.model.Pincode; 13 | import org.covid19.vaccinetracker.userrequests.model.UserRequest; 14 | 15 | import java.util.Collection; 16 | import java.util.HashSet; 17 | import java.util.List; 18 | import java.util.Optional; 19 | import java.util.Set; 20 | import java.util.stream.Collectors; 21 | import java.util.stream.Stream; 22 | 23 | import lombok.extern.slf4j.Slf4j; 24 | 25 | @Slf4j 26 | public class UsersByPincodeTransformer implements Transformer> { 27 | private ProcessorContext ctx; 28 | private KeyValueStore aggregateStore; 29 | private final String AGGREGATE_STORE_NAME; 30 | private MetadataStore metadataStore; 31 | 32 | public UsersByPincodeTransformer(String aggregateStoreName, MetadataStore metadataStore) { 33 | this.metadataStore = metadataStore; 34 | this.AGGREGATE_STORE_NAME = aggregateStoreName; 35 | } 36 | 37 | @Override 38 | public void init(ProcessorContext context) { 39 | log.debug("UsersByPincodeTransformer init() called)"); 40 | this.ctx = context; 41 | //noinspection unchecked 42 | this.aggregateStore = (KeyValueStore) context.getStateStore(this.AGGREGATE_STORE_NAME); 43 | } 44 | 45 | @Override 46 | public KeyValue transform(String userId, UserRequest userRequest) { 47 | log.debug("Entering transform for {}", userRequest); 48 | 49 | if (userRequest.getPincodes().isEmpty()) { 50 | log.debug("Removing all references to {} in state store", userId); 51 | cleanupUserInStateStore(userId, List.of()); 52 | return null; // nothing else to forward 53 | } 54 | 55 | /* 56 | * Pincodes come from two sources: 57 | * - Set directly by the user 58 | * - From the district set by the user 59 | * We combine the two sources of pincodes before applying the transforming function 60 | */ 61 | List combinedPincodes = 62 | Stream.concat( 63 | userRequest.getPincodes().stream(), 64 | streamPincodesFromDistrict(userRequest.getDistricts()) 65 | ).collect(Collectors.toList()); 66 | 67 | combinedPincodes.stream().distinct().forEach(pincode -> { 68 | log.debug("current data in state store: {}", aggregateStore.get(pincode)); 69 | 70 | if (pincode.isBlank()) { 71 | return; // very unlikely to happen but ¯\_(ツ)_/¯ 72 | } 73 | 74 | maybeInitializeNewEventInStateStore(pincode); 75 | 76 | UsersByPincode aggregatedUsersByPincode = aggregateStore.get(pincode).merge(userId); 77 | rememberNewEvent(pincode, aggregatedUsersByPincode); 78 | 79 | log.debug("aggregate: {}", aggregatedUsersByPincode); 80 | ctx.forward(pincode, aggregatedUsersByPincode); 81 | }); 82 | 83 | cleanupUserInStateStore(userId, combinedPincodes); 84 | 85 | return null; 86 | } 87 | 88 | private Stream streamPincodesFromDistrict(List districts) { 89 | return Optional.ofNullable(districts) 90 | .stream() 91 | .flatMap(Collection::stream) 92 | .flatMap(this::fetchPincodesFromMetadataStore) 93 | ; 94 | } 95 | 96 | private Stream fetchPincodesFromMetadataStore(Integer districtId) { 97 | return metadataStore.fetchPincodesByDistrictId(districtId) 98 | .stream().map(Pincode::getPincode); 99 | } 100 | 101 | /* 102 | * Iterates through all pincodes in state store and 103 | * removes any references of given user. exceptionPincodes 104 | * are not modified. 105 | */ 106 | private void cleanupUserInStateStore(String userId, List exceptionPincodes) { 107 | final KeyValueIterator it = aggregateStore.all(); 108 | log.debug("Starting cleanup"); 109 | while (it.hasNext()) { 110 | final KeyValue entry = it.next(); 111 | String pincode = entry.key; 112 | log.debug("pincode: {}", pincode); 113 | 114 | if (exceptionPincodes.contains(pincode)) { // skip excepted pincodes 115 | log.debug("pincode in exception list: {}", pincode); 116 | continue; 117 | } 118 | 119 | final Set users = entry.value.getUsers(); 120 | 121 | if (users.remove(userId)) { 122 | log.debug("Removing subscribed user {} for pincode {}", userId, pincode); 123 | final UsersByPincode updated = new UsersByPincode(pincode, users); 124 | aggregateStore.put(pincode, updated); 125 | ctx.forward(pincode, updated); 126 | } 127 | } 128 | } 129 | 130 | private void maybeInitializeNewEventInStateStore(final String eventId) { 131 | aggregateStore.putIfAbsent(eventId, new UsersByPincode(eventId, new HashSet<>())); 132 | } 133 | 134 | private void rememberNewEvent(final String eventId, UsersByPincode aggregated) { 135 | aggregateStore.put(eventId, aggregated); 136 | } 137 | 138 | @Override 139 | public void close() { 140 | 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /src/test/java/org/covid19/vaccinetracker/availability/aws/CowinLambdaWrapperTest.java: -------------------------------------------------------------------------------- 1 | package org.covid19.vaccinetracker.availability.aws; 2 | 3 | import com.amazonaws.services.lambda.AWSLambda; 4 | import com.amazonaws.services.lambda.AWSLambdaAsync; 5 | import com.fasterxml.jackson.databind.ObjectMapper; 6 | 7 | import org.covid19.vaccinetracker.model.Center; 8 | import org.covid19.vaccinetracker.model.Session; 9 | import org.covid19.vaccinetracker.model.VaccineCenters; 10 | import org.covid19.vaccinetracker.persistence.VaccinePersistence; 11 | import org.covid19.vaccinetracker.persistence.mariadb.entity.SessionEntity; 12 | import org.jetbrains.annotations.NotNull; 13 | import org.junit.jupiter.api.Test; 14 | import org.junit.jupiter.api.extension.ExtendWith; 15 | import org.mockito.Mock; 16 | import org.mockito.Mockito; 17 | import org.mockito.junit.jupiter.MockitoExtension; 18 | import org.springframework.kafka.core.KafkaTemplate; 19 | 20 | import java.util.Optional; 21 | 22 | import static java.util.Collections.singletonList; 23 | import static org.hamcrest.CoreMatchers.is; 24 | import static org.hamcrest.MatcherAssert.assertThat; 25 | 26 | @ExtendWith(MockitoExtension.class) 27 | public class CowinLambdaWrapperTest { 28 | @Mock 29 | private AWSConfig awsConfig; 30 | @Mock 31 | private AWSLambda awsLambda; 32 | @Mock 33 | private AWSLambdaAsync awsLambdaAsync; 34 | @Mock 35 | private ObjectMapper objectMapper; 36 | @Mock 37 | private VaccinePersistence vaccinePersistence; 38 | @Mock 39 | private KafkaTemplate kafkaTemplate; 40 | 41 | @Test 42 | public void testFreshAvailabilityWithFreshSlots() { 43 | CowinLambdaWrapper lambdaWrapper = new CowinLambdaWrapper(awsConfig, awsLambda, awsLambdaAsync, 44 | objectMapper, vaccinePersistence, kafkaTemplate); 45 | Mockito.when(vaccinePersistence.findExistingSession(1205L, "22-05-2021", 18, "COVAXIN")) 46 | .thenReturn(Optional.of(SessionEntity.builder() 47 | .id("32bbb37e-7cb4-4942-bd92-ac56d86490f9") 48 | .vaccine("COVAXIN") 49 | .availableCapacity(10) 50 | .availableCapacityDose1(10) 51 | .availableCapacityDose2(0) 52 | .minAgeLimit(18) 53 | .build())); 54 | final VaccineCenters actual = lambdaWrapper.freshAvailability(buildVaccineCenters()); 55 | assertThat(actual.getCenters().size(), is(1)); 56 | assertThat(actual.getCenters().get(0).getSessions().size(), is(1)); 57 | assertThat(actual.getCenters().get(0).getSessions().get(0).isShouldNotify(), is(true)); 58 | } 59 | 60 | @Test 61 | public void testFreshAvailabilityNoFreshSlots() { 62 | CowinLambdaWrapper lambdaWrapper = new CowinLambdaWrapper(awsConfig, awsLambda, awsLambdaAsync, 63 | objectMapper, vaccinePersistence, kafkaTemplate); 64 | Mockito.when(vaccinePersistence.findExistingSession(1205L, "22-05-2021", 18, "COVAXIN")) 65 | .thenReturn(Optional.of(SessionEntity.builder() 66 | .id("32bbb37e-7cb4-4942-bd92-ac56d86490f9") 67 | .vaccine("COVAXIN") 68 | .availableCapacity(30) 69 | .availableCapacityDose1(30) 70 | .availableCapacityDose2(0) 71 | .minAgeLimit(18) 72 | .build())); 73 | final VaccineCenters actual = lambdaWrapper.freshAvailability(buildVaccineCenters()); 74 | assertThat(actual.getCenters().size(), is(1)); 75 | assertThat(actual.getCenters().get(0).getSessions().size(), is(1)); 76 | assertThat(actual.getCenters().get(0).getSessions().get(0).isShouldNotify(), is(false)); 77 | } 78 | 79 | /* 80 | * Cancellation is when available capacity increases by 1 or 2 slots only. 81 | */ 82 | @Test 83 | public void testFreshAvailabilityWithCancellations() { 84 | CowinLambdaWrapper lambdaWrapper = new CowinLambdaWrapper(awsConfig, awsLambda, awsLambdaAsync, 85 | objectMapper, vaccinePersistence, kafkaTemplate); 86 | Mockito.when(vaccinePersistence.findExistingSession(1205L, "22-05-2021", 18, "COVAXIN")) 87 | .thenReturn(Optional.of(SessionEntity.builder() 88 | .id("32bbb37e-7cb4-4942-bd92-ac56d86490f9") 89 | .vaccine("COVAXIN") 90 | .availableCapacity(13) 91 | .availableCapacityDose1(13) 92 | .availableCapacityDose2(0) 93 | .minAgeLimit(18) 94 | .build())); 95 | final VaccineCenters actual = lambdaWrapper.freshAvailability(buildVaccineCenters()); 96 | assertThat(actual.getCenters().size(), is(1)); 97 | assertThat(actual.getCenters().get(0).getSessions().size(), is(1)); 98 | assertThat(actual.getCenters().get(0).getSessions().get(0).isShouldNotify(), is(false)); 99 | } 100 | 101 | @NotNull 102 | private VaccineCenters buildVaccineCenters() { 103 | final VaccineCenters vaccineCenters = new VaccineCenters(); 104 | vaccineCenters.setCenters( 105 | singletonList(Center.builder() 106 | .centerId(1205) 107 | .name("Mohalla Clinic Peeragarhi PHC") 108 | .stateName("Delhi") 109 | .districtName("West Delhi") 110 | .pincode(110056) 111 | .feeType("Free") 112 | .sessions(singletonList(Session.builder() 113 | .sessionId("32bbb37e-7cb4-4942-bd92-ac56d86490f9") 114 | .date("22-05-2021") 115 | .availableCapacity(15) 116 | .availableCapacityDose1(15) 117 | .availableCapacityDose2(0) 118 | .minAgeLimit(18) 119 | .vaccine("COVAXIN") 120 | .build())) 121 | .build())); 122 | return vaccineCenters; 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /src/main/java/org/covid19/vaccinetracker/userrequests/reconciliation/PincodeReconciliation.java: -------------------------------------------------------------------------------- 1 | package org.covid19.vaccinetracker.userrequests.reconciliation; 2 | 3 | import org.apache.commons.lang3.tuple.ImmutablePair; 4 | import org.covid19.vaccinetracker.availability.aws.CowinLambdaWrapper; 5 | import org.covid19.vaccinetracker.model.VaccineCenters; 6 | import org.covid19.vaccinetracker.notifications.bot.BotService; 7 | import org.covid19.vaccinetracker.userrequests.MetadataStore; 8 | import org.covid19.vaccinetracker.userrequests.UserRequestManager; 9 | import org.covid19.vaccinetracker.userrequests.model.District; 10 | import org.covid19.vaccinetracker.userrequests.model.Pincode; 11 | import org.covid19.vaccinetracker.userrequests.model.UserRequest; 12 | import org.jetbrains.annotations.NotNull; 13 | import org.jetbrains.annotations.Nullable; 14 | import org.springframework.scheduling.annotation.Scheduled; 15 | import org.springframework.stereotype.Service; 16 | 17 | import java.util.List; 18 | import java.util.Objects; 19 | import java.util.Optional; 20 | import java.util.function.Consumer; 21 | import java.util.function.Predicate; 22 | 23 | import lombok.extern.slf4j.Slf4j; 24 | 25 | import static java.util.Objects.isNull; 26 | import static java.util.Objects.nonNull; 27 | 28 | @Slf4j 29 | @Service 30 | public class PincodeReconciliation { 31 | private final MetadataStore metadataStore; 32 | private final CowinLambdaWrapper cowinLambdaWrapper; 33 | private final ReconciliationStats reconciliationStats; 34 | private final UserRequestManager userRequestManager; 35 | private final BotService botService; 36 | 37 | public PincodeReconciliation(MetadataStore metadataStore, 38 | CowinLambdaWrapper cowinLambdaWrapper, ReconciliationStats reconciliationStats, UserRequestManager userRequestManager, 39 | BotService botService) { 40 | this.metadataStore = metadataStore; 41 | this.cowinLambdaWrapper = cowinLambdaWrapper; 42 | this.reconciliationStats = reconciliationStats; 43 | this.userRequestManager = userRequestManager; 44 | this.botService = botService; 45 | } 46 | 47 | @Scheduled(cron = "${jobs.cron.pincode.reconciliation:-}", zone = "IST") 48 | public void pincodesReconciliationJob() { 49 | this.reconcilePincodesFromLambda(userRequestManager.fetchAllUserRequests()); 50 | } 51 | 52 | public void reconcilePincodesFromLambda(List userRequests) { 53 | log.info("Starting reconciliation of missing pincodes from AWS Lambda"); 54 | reconciliationStats.reset(); 55 | reconciliationStats.noteStartTime(); 56 | 57 | userRequests.stream() 58 | .flatMap(userRequest -> userRequest.getPincodes().stream()) 59 | .distinct() 60 | .peek(pincode -> log.debug("reconciliating pincode {}", pincode)) 61 | .filter(missingPincodesInStore()) 62 | .peek(pincode -> reconciliationStats.incrementUnknownPincodes()) 63 | .flatMap(cowinLambdaWrapper::fetchSessionsByPincode) 64 | .flatMap(Optional::stream) 65 | .peek(logFailedReconciliations()) 66 | .filter(centersWithData()) 67 | .map(this::fetchDistrictsFromStore) 68 | .filter(Objects::nonNull) 69 | .forEach(persistPincode()); 70 | 71 | reconciliationStats.noteEndTime(); 72 | log.info("[PINCODE RECONCILIATION] Unknown: {}, Failed: {}, Missing district: {}, Successful: {}, Time taken: {}", 73 | reconciliationStats.unknownPincodes(), reconciliationStats.failedReconciliations(), 74 | reconciliationStats.failedWithUnknownDistrict(), reconciliationStats.successfulReconciliations(), reconciliationStats.timeTaken()); 75 | botService.notifyOwner(String.format("[PINCODE RECONCILIATION] Unknown: %d, Failed: %d, Missing district: %d, Successful: %d, Time taken: %s", 76 | reconciliationStats.unknownPincodes(), reconciliationStats.failedReconciliations(), 77 | reconciliationStats.failedWithUnknownDistrict(), reconciliationStats.successfulReconciliations(), reconciliationStats.timeTaken())); 78 | } 79 | 80 | @NotNull 81 | private Predicate missingPincodesInStore() { 82 | return pincode -> !metadataStore.pincodeExists(pincode); 83 | } 84 | 85 | @NotNull 86 | private Consumer> persistPincode() { 87 | return pair -> { 88 | this.metadataStore.persistPincode(Pincode.builder() 89 | .pincode(pair.getLeft()) 90 | .district(pair.getRight()) 91 | .build()); 92 | log.info("Reconciliation successful for pincode {}", pair.getLeft()); 93 | reconciliationStats.incrementSuccessfulReconciliations(); 94 | }; 95 | } 96 | 97 | @Nullable 98 | private ImmutablePair fetchDistrictsFromStore(VaccineCenters vaccineCenters) { 99 | String districtName = vaccineCenters.getCenters().get(0).getDistrictName(); 100 | String stateName = vaccineCenters.getCenters().get(0).getStateName(); 101 | String pincode = String.valueOf(vaccineCenters.getCenters().get(0).getPincode()); 102 | District district = metadataStore.fetchDistrictByNameAndState(districtName, stateName); 103 | if (isNull(district)) { 104 | log.warn("No district found in DB for name {}", districtName); 105 | reconciliationStats.incrementFailedWithUnknownDistrict(); 106 | return null; 107 | } 108 | return new ImmutablePair<>(pincode, district); 109 | } 110 | 111 | @NotNull 112 | private Predicate centersWithData() { 113 | return vaccineCenters -> nonNull(vaccineCenters) && !vaccineCenters.getCenters().isEmpty(); 114 | } 115 | 116 | @NotNull 117 | private Consumer logFailedReconciliations() { 118 | return vaccineCenters -> { 119 | if (isNull(vaccineCenters) || vaccineCenters.getCenters().isEmpty()) { 120 | reconciliationStats.incrementFailedReconciliations(); 121 | } 122 | }; 123 | } 124 | 125 | } 126 | -------------------------------------------------------------------------------- /src/test/java/org/covid19/vaccinetracker/notifications/absentalerts/AbsentAlertsAnalyzerTest.java: -------------------------------------------------------------------------------- 1 | package org.covid19.vaccinetracker.notifications.absentalerts; 2 | 3 | import org.covid19.vaccinetracker.model.CenterSession; 4 | import org.covid19.vaccinetracker.persistence.mariadb.entity.UserNotification; 5 | import org.covid19.vaccinetracker.persistence.mariadb.entity.UserNotificationId; 6 | import org.junit.jupiter.api.Test; 7 | 8 | import java.time.LocalDateTime; 9 | import java.util.List; 10 | 11 | import lombok.extern.slf4j.Slf4j; 12 | 13 | import static org.junit.jupiter.api.Assertions.assertFalse; 14 | import static org.junit.jupiter.api.Assertions.assertTrue; 15 | 16 | @Slf4j 17 | public class AbsentAlertsAnalyzerTest { 18 | @Test 19 | public void verifyAnalyzeBehaviour() { 20 | List sessions = createSessions(); 21 | AbsentAlertSource source = AbsentAlertSource.builder() 22 | .userId("1234").pincode("700014").age("45+").vaccine("Covaxin").dose("Dose 2").build(); 23 | AbsentAlertAnalyzer analyzer = new AbsentAlertAnalyzer(); 24 | final AbsentAlertCause cause = analyzer.analyze(source, sessions); 25 | log.info("Cause is {}", cause.getCauses()); 26 | log.info("Alternative is {}", cause.getRecents()); 27 | assertTrue(cause.getRecents().contains("BELLE VUE CLINIC (Kolkata 700014) had availability of COVISHIELD for 45+ on 17-07-2021, 16-07-2021, 15-07-2021, 14-07-2021")); 28 | assertTrue(cause.getRecents().contains("CNMCH PPU COVAXIN (Kolkata 700014) had availability of COVAXIN for 18+ on 12-06-2021")); 29 | assertTrue(cause.getRecents().contains("NRS PPU MCH COVAXIN (Kolkata 700014) had availability of COVAXIN for 45+ on 12-06-2021")); 30 | assertTrue(cause.getRecents().contains("UPHC-60 (Kolkata 700014) had availability of COVAXIN for 45+ on 05-06-2021")); 31 | } 32 | 33 | @Test 34 | public void verifyAnalyzeBehaviourWithUnsetPreferences() { 35 | List sessions = createSessions(); 36 | AbsentAlertSource source = AbsentAlertSource.builder() 37 | .userId("1234").pincode("700014").age(null).vaccine(null).dose(null) 38 | .latestNotification(UserNotification.builder() 39 | .userNotificationId(UserNotificationId.builder().userId("1234").pincode("700014").build()) 40 | .notifiedAt(LocalDateTime.now().minusDays(2L)) 41 | .build()) 42 | .build(); 43 | AbsentAlertAnalyzer analyzer = new AbsentAlertAnalyzer(); 44 | final AbsentAlertCause cause = analyzer.analyze(source, sessions); 45 | assertFalse(cause.getCauses().contains("No centers found for pincode 700014")); 46 | } 47 | 48 | private List createSessions() { 49 | return List.of( 50 | CenterSession.builder() 51 | .centerName("NRS PPU MCH COVAXIN").districtName("Kolkata").pincode("700014") 52 | .minAge(18).sessionDate("12-06-2021").sessionVaccine("COVAXIN") 53 | .build(), 54 | CenterSession.builder() 55 | .centerName("NRS PPU MCH COVAXIN").districtName("Kolkata").pincode("700014") 56 | .minAge(45).sessionDate("12-06-2021").sessionVaccine("COVAXIN") 57 | .build(), 58 | CenterSession.builder() 59 | .centerName("UPHC-54").districtName("Kolkata").pincode("700014") 60 | .minAge(45).sessionDate("05-06-2021").sessionVaccine("COVAXIN") 61 | .build(), 62 | CenterSession.builder() 63 | .centerName("UPHC-53").districtName("Kolkata").pincode("700014") 64 | .minAge(45).sessionDate("05-06-2021").sessionVaccine("COVAXIN") 65 | .build(), 66 | CenterSession.builder() 67 | .centerName("UPHC-60").districtName("Kolkata").pincode("700014") 68 | .minAge(45).sessionDate("05-06-2021").sessionVaccine("COVAXIN") 69 | .build(), 70 | CenterSession.builder() 71 | .centerName("BR Singh Railway Hosp COVAXIN").districtName("Kolkata").pincode("700014") 72 | .minAge(45).sessionDate("12-06-2021").sessionVaccine("COVAXIN") 73 | .build(), 74 | CenterSession.builder() 75 | .centerName("BR Singh Railway Hosp COVAXIN").districtName("Kolkata").pincode("700014") 76 | .minAge(18).sessionDate("12-06-2021").sessionVaccine("COVAXIN") 77 | .build(), 78 | CenterSession.builder() 79 | .centerName("CNMCH PPU COVAXIN").districtName("Kolkata").pincode("700014") 80 | .minAge(45).sessionDate("12-06-2021").sessionVaccine("COVAXIN") 81 | .build(), 82 | CenterSession.builder() 83 | .centerName("CNMCH PPU COVAXIN").districtName("Kolkata").pincode("700014") 84 | .minAge(18).sessionDate("12-06-2021").sessionVaccine("COVAXIN") 85 | .build(), 86 | CenterSession.builder() 87 | .centerName("BELLE VUE CLINIC").districtName("Kolkata").pincode("700014") 88 | .minAge(45).sessionDate("17-07-2021").sessionVaccine("COVISHIELD") 89 | .build(), 90 | CenterSession.builder() 91 | .centerName("BELLE VUE CLINIC").districtName("Kolkata").pincode("700014") 92 | .minAge(45).sessionDate("16-07-2021").sessionVaccine("COVISHIELD") 93 | .build(), 94 | CenterSession.builder() 95 | .centerName("BELLE VUE CLINIC").districtName("Kolkata").pincode("700014") 96 | .minAge(45).sessionDate("15-07-2021").sessionVaccine("COVISHIELD") 97 | .build(), 98 | CenterSession.builder() 99 | .centerName("BELLE VUE CLINIC").districtName("Kolkata").pincode("700014") 100 | .minAge(45).sessionDate("14-07-2021").sessionVaccine("COVISHIELD") 101 | .build() 102 | ); 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/test/java/org/covid19/vaccinetracker/availability/cowin/CowinApiAuthTest.java: -------------------------------------------------------------------------------- 1 | package org.covid19.vaccinetracker.availability.cowin; 2 | 3 | import org.covid19.vaccinetracker.availability.model.ConfirmOtpResponse; 4 | import org.covid19.vaccinetracker.availability.model.GenerateOtpResponse; 5 | import org.junit.jupiter.api.BeforeEach; 6 | import org.junit.jupiter.api.Test; 7 | import org.junit.jupiter.api.extension.ExtendWith; 8 | import org.mockito.Mock; 9 | import org.mockito.junit.jupiter.MockitoExtension; 10 | 11 | import static org.hamcrest.MatcherAssert.assertThat; 12 | import static org.hamcrest.Matchers.emptyString; 13 | import static org.hamcrest.Matchers.equalTo; 14 | import static org.hamcrest.Matchers.is; 15 | import static org.junit.jupiter.api.Assertions.assertFalse; 16 | import static org.junit.jupiter.api.Assertions.assertTrue; 17 | import static org.mockito.ArgumentMatchers.anyString; 18 | import static org.mockito.Mockito.times; 19 | import static org.mockito.Mockito.verify; 20 | import static org.mockito.Mockito.when; 21 | 22 | @ExtendWith(MockitoExtension.class) 23 | public class CowinApiAuthTest { 24 | @Mock 25 | private CowinApiOtpClient cowinApiOtpClient; 26 | private final CowinConfig cowinConfig = new CowinConfig(); 27 | private CowinApiAuth cowinApiAuth; 28 | 29 | @BeforeEach 30 | public void setup() { 31 | cowinConfig.setAuthMobile("9999999999"); 32 | cowinApiAuth = new CowinApiAuth(cowinApiOtpClient, cowinConfig); 33 | } 34 | 35 | @Test 36 | public void generateAndValidateOtp() { 37 | GenerateOtpResponse generateOtpResponse = GenerateOtpResponse.builder().transactionId("xxx").build(); 38 | when(cowinApiOtpClient.generateOtp(cowinConfig.getAuthMobile())).thenReturn(generateOtpResponse); 39 | cowinApiAuth.refreshCowinToken(); 40 | assertThat(cowinApiAuth.getTransactionId(), is(equalTo("xxx"))); 41 | assertTrue(cowinApiAuth.isAwaitingOtp()); 42 | 43 | ConfirmOtpResponse confirmOtpResponse = ConfirmOtpResponse.builder().token("xyz-token").build(); 44 | when(cowinApiOtpClient.confirmOtp(generateOtpResponse.getTransactionId(), 45 | "1544344b7ad152d165ceadbb7e76459757d5143d77b481fdede03d032acbf481")).thenReturn(confirmOtpResponse); 46 | String callback = "Your OTP to register/access CoWIN is 181895. It will be valid for 3 minutes. - CoWIN"; 47 | cowinApiAuth.handleOtpCallback(callback); 48 | assertThat(cowinApiAuth.getBearerToken(), is(equalTo("xyz-token"))); 49 | // txnId should be reset, isAwaitingOtp should be false 50 | assertThat(cowinApiAuth.getTransactionId(), is(emptyString())); 51 | assertFalse(cowinApiAuth.isAwaitingOtp()); 52 | } 53 | 54 | @Test 55 | public void testHandlingFailedGenerationOfOtp() { 56 | when(cowinApiOtpClient.generateOtp(cowinConfig.getAuthMobile())).thenReturn(null); 57 | cowinApiAuth.refreshCowinToken(); 58 | assertThat(cowinApiAuth.getTransactionId(), is(emptyString())); 59 | assertFalse(cowinApiAuth.isAwaitingOtp()); 60 | 61 | String callback = "Your OTP to register/access CoWIN is 181895. It will be valid for 3 minutes. - CoWIN"; 62 | cowinApiAuth.handleOtpCallback(callback); 63 | assertThat(cowinApiAuth.getBearerToken(), is(emptyString())); 64 | assertFalse(cowinApiAuth.isAwaitingOtp()); 65 | } 66 | 67 | @Test 68 | public void testHandlingGenerationOfOtpAndFailedConfirmation() { 69 | // generate OTP goes fine... 70 | GenerateOtpResponse generateOtpResponse = GenerateOtpResponse.builder().transactionId("xxx").build(); 71 | when(cowinApiOtpClient.generateOtp(cowinConfig.getAuthMobile())).thenReturn(generateOtpResponse); 72 | cowinApiAuth.refreshCowinToken(); 73 | assertThat(cowinApiAuth.getTransactionId(), is(equalTo("xxx"))); 74 | assertTrue(cowinApiAuth.isAwaitingOtp()); 75 | 76 | // ...confirm OTP fails for any reason... 77 | when(cowinApiOtpClient.confirmOtp(generateOtpResponse.getTransactionId(), 78 | "1544344b7ad152d165ceadbb7e76459757d5143d77b481fdede03d032acbf481")).thenReturn(null); 79 | String callback = "Your OTP to register/access CoWIN is 181895. It will be valid for 3 minutes. - CoWIN"; 80 | cowinApiAuth.handleOtpCallback(callback); 81 | 82 | // ...variables should be reset again 83 | assertThat(cowinApiAuth.getTransactionId(), is(emptyString())); 84 | assertThat(cowinApiAuth.getBearerToken(), is(emptyString())); 85 | assertFalse(cowinApiAuth.isAwaitingOtp()); 86 | } 87 | 88 | @Test 89 | public void testInvalidCallbackMessage() { 90 | // generate OTP goes fine... 91 | GenerateOtpResponse generateOtpResponse = GenerateOtpResponse.builder().transactionId("xxx").build(); 92 | when(cowinApiOtpClient.generateOtp(cowinConfig.getAuthMobile())).thenReturn(generateOtpResponse); 93 | cowinApiAuth.refreshCowinToken(); 94 | assertThat(cowinApiAuth.getTransactionId(), is(equalTo("xxx"))); 95 | assertTrue(cowinApiAuth.isAwaitingOtp()); 96 | 97 | // ...we receive invalid callback message... 98 | String invalidCallback = "This is an invalid callback message without any OTP."; 99 | cowinApiAuth.handleOtpCallback(invalidCallback); 100 | 101 | // ...variables should be reset again 102 | assertThat(cowinApiAuth.getTransactionId(), is(emptyString())); 103 | assertThat(cowinApiAuth.getBearerToken(), is(emptyString())); 104 | assertFalse(cowinApiAuth.isAwaitingOtp()); 105 | } 106 | 107 | @Test 108 | public void testConsecutiveRefreshShouldNotTriggerMultipleOtpCalls() { 109 | // generate OTP goes fine... 110 | GenerateOtpResponse generateOtpResponse = GenerateOtpResponse.builder().transactionId("xxx").build(); 111 | when(cowinApiOtpClient.generateOtp(cowinConfig.getAuthMobile())).thenReturn(generateOtpResponse); 112 | cowinApiAuth.refreshCowinToken(); 113 | assertThat(cowinApiAuth.getTransactionId(), is(equalTo("xxx"))); 114 | assertTrue(cowinApiAuth.isAwaitingOtp()); 115 | 116 | // ...try to generate otp again before it can be confirmed... 117 | cowinApiAuth.refreshCowinToken(); 118 | 119 | // ...should not trigger another call 120 | verify(cowinApiOtpClient, times(1)).generateOtp(anyString()); 121 | assertFalse(cowinApiAuth.isAwaitingOtp()); 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /src/main/java/org/covid19/vaccinetracker/notifications/absentalerts/AbsentAlertAnalyzer.java: -------------------------------------------------------------------------------- 1 | package org.covid19.vaccinetracker.notifications.absentalerts; 2 | 3 | import org.covid19.vaccinetracker.model.CenterSession; 4 | import org.covid19.vaccinetracker.userrequests.model.Age; 5 | import org.covid19.vaccinetracker.userrequests.model.Vaccine; 6 | import org.covid19.vaccinetracker.utils.Utils; 7 | import org.springframework.stereotype.Component; 8 | 9 | import java.time.Duration; 10 | import java.time.LocalDate; 11 | import java.time.LocalDateTime; 12 | import java.time.format.DateTimeFormatter; 13 | import java.util.List; 14 | import java.util.Optional; 15 | import java.util.function.Predicate; 16 | import java.util.stream.Collectors; 17 | 18 | import static java.util.Comparator.comparing; 19 | import static java.util.Objects.isNull; 20 | import static java.util.Objects.nonNull; 21 | import static java.util.Optional.ofNullable; 22 | import static java.util.stream.Collectors.groupingBy; 23 | import static java.util.stream.Collectors.maxBy; 24 | import static java.util.stream.Collectors.reducing; 25 | 26 | /** 27 | * Analyze why a user has not received any alerts. 28 | */ 29 | @Component 30 | public class AbsentAlertAnalyzer { 31 | 32 | public AbsentAlertCause analyze(AbsentAlertSource source, List sessions) { 33 | AbsentAlertCause absentAlertCause = AbsentAlertCause.builder() 34 | .userId(source.getUserId()) 35 | .pincode(source.getPincode()) 36 | .build(); 37 | 38 | List relevantSessions = sessions.stream().filter(relevantSessionPredicate(source)).collect(Collectors.toList()); 39 | 40 | // set last notified in cause 41 | ofNullable(source.getLatestNotification()) 42 | .filter(userNotification -> dayOld(userNotification.getNotifiedAt())) 43 | .ifPresentOrElse( 44 | userNotification -> absentAlertCause.setLastNotified( 45 | String.format("You were last notified for the pincode %s on %s", 46 | source.getPincode(), Utils.convertToIST(userNotification.getNotifiedAt()))), 47 | () -> absentAlertCause.setLastNotified( 48 | String.format("You have not received any notification for pincode %s", source.getPincode()))); 49 | 50 | 51 | if (!relevantSessions.isEmpty()) { 52 | // sessions have opened for user pincode before, include them in causes 53 | relevantSessions 54 | .stream() 55 | .collect(groupingBy(CenterSession::getCenterName, maxBy(comparing(o -> toLocalDate(o.getSessionDate()))))) 56 | .values() 57 | .stream() 58 | .flatMap(Optional::stream) 59 | .forEach(currentSession -> absentAlertCause.addCause( 60 | String.format("%s (%s %s) last had open slots for %s on %s", 61 | currentSession.getCenterName(), currentSession.getDistrictName(), 62 | currentSession.getPincode(), currentSession.getSessionVaccine(), currentSession.getSessionDate()))); 63 | } else { 64 | // no session has opened for user pincode before, include a helpful message 65 | absentAlertCause.addCause(String.format("No centers found for pincode %s.", source.getPincode())); 66 | } 67 | 68 | // identify recent 5 sessions 69 | sessions 70 | .stream() 71 | .collect(groupingBy(CenterSession::getCenterName, reducing((old, current) -> { 72 | if (!old.getSessionDate().equalsIgnoreCase(current.getSessionDate())) { 73 | current.setMultipleDates((nonNull(old.getMultipleDates()) ? old.getMultipleDates() : old.getSessionDate()) + ", " + current.getSessionDate()); 74 | } 75 | return current; 76 | }))) 77 | .values() 78 | .stream() 79 | .flatMap(Optional::stream) 80 | .sorted((o1, o2) -> toLocalDate(o2.getSessionDate()).compareTo(toLocalDate(o1.getSessionDate()))) 81 | .limit(5) 82 | .forEach(session -> absentAlertCause.addRecent( 83 | String.format("%s (%s %s) had availability of %s for %d+ on %s", 84 | session.getCenterName(), session.getDistrictName(), 85 | session.getPincode(), session.getSessionVaccine(), session.getMinAge(), 86 | nonNull(session.getMultipleDates()) ? session.getMultipleDates() : session.getSessionDate()))); 87 | return absentAlertCause; 88 | } 89 | 90 | private Predicate relevantSessionPredicate(AbsentAlertSource source) { 91 | return centerSession -> 92 | matchingAgePreference(source.getAge(), centerSession.getMinAge()) 93 | && matchingVaccinePreference(source.getVaccine(), centerSession.getSessionVaccine()); 94 | } 95 | 96 | private boolean matchingVaccinePreference(String userPrefVaccine, String sessionVaccine) { 97 | if (isNull(userPrefVaccine) || Vaccine.ALL.toString().equals(userPrefVaccine)) { 98 | return true; 99 | } 100 | return sessionVaccine.equalsIgnoreCase(userPrefVaccine); 101 | } 102 | 103 | private boolean dayOld(LocalDateTime notifiedAt) { 104 | return Duration.between(notifiedAt, LocalDateTime.now()) 105 | .compareTo(Duration.ofHours(24)) >= 0; 106 | } 107 | 108 | private boolean matchingAgePreference(String userPrefAge, int sessionMinAge) { 109 | if (Age.AGE_18_44.toString().equals(userPrefAge)) { 110 | return sessionMinAge < 45; 111 | } else if (Age.AGE_45.toString().equals(userPrefAge)) { 112 | return sessionMinAge >= 45; 113 | } else if (isNull(userPrefAge)) { 114 | return sessionMinAge < 45; 115 | } else { 116 | // age pref is both so just return true 117 | return true; 118 | } 119 | } 120 | 121 | private LocalDate toLocalDate(String ddMMyyyy) { 122 | DateTimeFormatter inputFormat = DateTimeFormatter.ofPattern("dd-MM-yyyy"); 123 | return LocalDate.parse(ddMMyyyy, inputFormat); 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /src/main/java/org/covid19/vaccinetracker/notifications/VaccineCentersProcessor.java: -------------------------------------------------------------------------------- 1 | package org.covid19.vaccinetracker.notifications; 2 | 3 | import org.covid19.vaccinetracker.model.Center; 4 | import org.covid19.vaccinetracker.model.Session; 5 | import org.covid19.vaccinetracker.model.VaccineCenters; 6 | import org.covid19.vaccinetracker.userrequests.UserRequestManager; 7 | import org.covid19.vaccinetracker.userrequests.model.Age; 8 | import org.covid19.vaccinetracker.userrequests.model.Dose; 9 | import org.covid19.vaccinetracker.userrequests.model.Vaccine; 10 | import org.jetbrains.annotations.NotNull; 11 | import org.springframework.stereotype.Component; 12 | 13 | import java.util.ArrayList; 14 | import java.util.List; 15 | import java.util.function.Predicate; 16 | import java.util.stream.Collectors; 17 | import java.util.stream.Stream; 18 | 19 | import static java.util.Objects.isNull; 20 | import static org.covid19.vaccinetracker.userrequests.model.Age.AGE_BOTH; 21 | import static org.covid19.vaccinetracker.userrequests.model.Dose.DOSE_BOTH; 22 | 23 | @Component 24 | public class VaccineCentersProcessor { 25 | private final UserRequestManager userRequestManager; 26 | 27 | public VaccineCentersProcessor(UserRequestManager userRequestManager) { 28 | this.userRequestManager = userRequestManager; 29 | } 30 | 31 | public boolean hasCapacity(Session session) { 32 | return (session.availableCapacityDose1 > 1) 33 | && (session.availableCapacity == (session.availableCapacityDose1 + session.availableCapacityDose2)); 34 | } 35 | 36 | public List
eligibleVaccineCenters(VaccineCenters vaccineCenters, String user) { 37 | List
eligibleCenters = new ArrayList<>(); 38 | 39 | if (isNull(vaccineCenters.centers)) { 40 | return eligibleCenters; 41 | } 42 | 43 | vaccineCenters.centers.forEach(center -> { 44 | List eligibleSessions = center.getSessions().stream() 45 | .filter(Session::hasCapacity) 46 | .filter(session -> eligibleCenterForUser(session, user)) 47 | .collect(Collectors.toList()); 48 | 49 | if (!eligibleSessions.isEmpty()) { 50 | Center eligibleCenter = buildCenter(center); 51 | eligibleCenter.setSessions(eligibleSessions); 52 | eligibleCenters.add(eligibleCenter); 53 | } 54 | }); 55 | return eligibleCenters; 56 | } 57 | 58 | private boolean eligibleCenterForUser(Session session, String user) { 59 | return Stream.of(user) 60 | .filter(checkAgePreference(session)) 61 | .filter(checkDosePreference(session)) 62 | .filter(checkVaccinePreference(session)) 63 | .map(u -> true) 64 | .findFirst() 65 | .orElse(false); 66 | } 67 | 68 | @NotNull 69 | private Predicate checkVaccinePreference(Session session) { 70 | return u -> { 71 | final Vaccine preference = userRequestManager.getUserVaccinePreference(u); 72 | return sessionAndUserValidForCovishield(session, preference) 73 | || sessionAndUserValidForCovaxin(session, preference) 74 | || sessionAndUserValidForSputnikV(session, preference) 75 | || Vaccine.ALL.equals(preference); 76 | }; 77 | } 78 | 79 | @NotNull 80 | private Predicate checkDosePreference(Session session) { 81 | return u -> { 82 | final Dose preference = userRequestManager.getUserDosePreference(u); 83 | return sessionAndUserValidForDose1(session, preference) 84 | || sessionAndUserValidForDose2(session, preference) 85 | || DOSE_BOTH.equals(preference); 86 | }; 87 | } 88 | 89 | @NotNull 90 | private Predicate checkAgePreference(Session session) { 91 | return u -> { 92 | final Age preference = userRequestManager.getUserAgePreference(u); 93 | return sessionAndUserValidFor18(session, preference) 94 | || sessionAndUserValidFor45(session, preference) 95 | || AGE_BOTH.equals(preference); 96 | }; 97 | } 98 | 99 | private boolean sessionAndUserValidFor45(Session session, Age userAgePreference) { 100 | return Age.AGE_45.equals(userAgePreference) && (session.validForAllAges() || session.validFor45Above()); 101 | } 102 | 103 | private boolean sessionAndUserValidFor18(Session session, Age userAgePreference) { 104 | return Age.AGE_18_44.equals(userAgePreference) && (session.validForAllAges() || session.validBetween18And44()); 105 | } 106 | 107 | private boolean sessionAndUserValidForDose1(Session session, Dose userDosePreference) { 108 | return Dose.DOSE_1.equals(userDosePreference) && session.hasDose1Capacity(); 109 | } 110 | 111 | private boolean sessionAndUserValidForDose2(Session session, Dose userDosePreference) { 112 | return Dose.DOSE_2.equals(userDosePreference) && session.hasDose2Capacity(); 113 | } 114 | 115 | private boolean sessionAndUserValidForCovishield(Session session, Vaccine userVaccinePreference) { 116 | return Vaccine.COVISHIELD.equals(userVaccinePreference) && session.hasCovishield(); 117 | } 118 | 119 | private boolean sessionAndUserValidForCovaxin(Session session, Vaccine userVaccinePreference) { 120 | return Vaccine.COVAXIN.equals(userVaccinePreference) && session.hasCovaxin(); 121 | } 122 | 123 | private boolean sessionAndUserValidForSputnikV(Session session, Vaccine userVaccinePreference) { 124 | return Vaccine.SPUTNIK_V.equals(userVaccinePreference) && session.hasSputnikV(); 125 | } 126 | 127 | private Center buildCenter(Center center) { 128 | return Center.builder() 129 | .centerId(center.getCenterId()) 130 | .name(center.getName()) 131 | .stateName(center.getStateName()) 132 | .districtName(center.getDistrictName()) 133 | .blockName(center.getBlockName()) 134 | .pincode(center.getPincode()) 135 | .feeType(center.getFeeType()) 136 | .from(center.getFrom()) 137 | .to(center.getTo()) 138 | .latitude(center.getLatitude()) 139 | .longitude(center.getLongitude()) 140 | .build(); 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /src/main/java/org/covid19/vaccinetracker/notifications/KafkaNotifications.java: -------------------------------------------------------------------------------- 1 | package org.covid19.vaccinetracker.notifications; 2 | 3 | import org.apache.kafka.common.serialization.Serdes; 4 | import org.apache.kafka.streams.StreamsBuilder; 5 | import org.apache.kafka.streams.kstream.Consumed; 6 | import org.apache.kafka.streams.kstream.KStream; 7 | import org.apache.kafka.streams.kstream.KTable; 8 | import org.covid19.vaccinetracker.model.Center; 9 | import org.covid19.vaccinetracker.model.UsersByPincode; 10 | import org.covid19.vaccinetracker.model.VaccineCenters; 11 | import org.covid19.vaccinetracker.notifications.bot.BotService; 12 | import org.covid19.vaccinetracker.persistence.VaccinePersistence; 13 | import org.covid19.vaccinetracker.utils.Utils; 14 | import org.jetbrains.annotations.NotNull; 15 | import org.springframework.beans.factory.annotation.Value; 16 | import org.springframework.context.annotation.Bean; 17 | import org.springframework.scheduling.annotation.Scheduled; 18 | import org.springframework.stereotype.Component; 19 | 20 | import java.util.List; 21 | import java.util.Set; 22 | import java.util.function.Consumer; 23 | import java.util.function.Function; 24 | import java.util.function.Predicate; 25 | import java.util.stream.Stream; 26 | 27 | import lombok.extern.slf4j.Slf4j; 28 | 29 | @Slf4j 30 | @Component 31 | public class KafkaNotifications { 32 | @Value("${topic.updated.pincodes}") 33 | private String updatedPincodesTopic; 34 | 35 | private final StreamsBuilder streamsBuilder; 36 | private final KTable usersByPincodeTable; 37 | private final VaccinePersistence vaccinePersistence; 38 | private final VaccineCentersProcessor vaccineCentersProcessor; 39 | private final TelegramLambdaWrapper telegramLambdaWrapper; 40 | private final BotService botService; 41 | private final NotificationStats stats; 42 | private final NotificationCache cache; 43 | 44 | public KafkaNotifications(StreamsBuilder streamsBuilder, KTable usersByPincodeTable, 45 | VaccinePersistence vaccinePersistence, VaccineCentersProcessor vaccineCentersProcessor, 46 | TelegramLambdaWrapper telegramLambdaWrapper, BotService botService, NotificationStats stats, 47 | NotificationCache cache) { 48 | this.streamsBuilder = streamsBuilder; 49 | this.usersByPincodeTable = usersByPincodeTable; 50 | this.vaccinePersistence = vaccinePersistence; 51 | this.vaccineCentersProcessor = vaccineCentersProcessor; 52 | this.telegramLambdaWrapper = telegramLambdaWrapper; 53 | this.botService = botService; 54 | this.stats = stats; 55 | this.cache = cache; 56 | } 57 | 58 | @Bean 59 | KStream> notificationsStream() { 60 | log.debug("Building notifications KStreams"); 61 | final KStream> stream = 62 | streamsBuilder.stream(updatedPincodesTopic, Consumed.with(Serdes.String(), Serdes.String())) 63 | .join(usersByPincodeTable, (pincode, usersByPincode) -> usersByPincode) 64 | .mapValues((pincode, value) -> value.getUsers()); 65 | 66 | // send notifications 67 | stream.foreach((pincode, users) -> { 68 | log.debug("Building notifications for pincode {} and users {}", pincode, users); 69 | final VaccineCenters vaccineCenters = vaccinePersistence.fetchVaccineCentersByPincode(pincode); 70 | stats.incrementProcessedPincodes(); 71 | users.forEach(user -> Stream.ofNullable(vaccineCenters) 72 | .peek(vc -> stats.incrementUserRequests()) 73 | .filter(centersWithData()) 74 | .map(eligibleCentersFor(user)) 75 | .peek(logEmptyCenters(pincode)) 76 | .filter(eligibleCentersWithData()) 77 | .forEach(eligibleCenters -> { 78 | if (cache.isNewNotification(user, pincode, eligibleCenters)) { 79 | log.debug("Slots data changed for pincode {} since {} was last notified", pincode, user); 80 | log.info("Sending notification to {} for pincode {}", user, pincode); 81 | telegramLambdaWrapper.sendTelegramNotification(user, Utils.buildNotificationMessage(eligibleCenters)); 82 | stats.incrementNotificationsSent(); 83 | cache.updateUser(user, pincode, eligibleCenters); 84 | } else { 85 | log.debug("No difference in slots data for pincode {} since {} was last notified", pincode, user); 86 | } 87 | vaccinePersistence.markProcessed(vaccineCenters); // mark processed 88 | })); 89 | }); 90 | 91 | return stream; 92 | } 93 | 94 | /* 95 | * Crude way to measure notification stats in async scenario 96 | */ 97 | @Scheduled(cron = "${jobs.cron.notification.stats:-}", zone = "IST") 98 | public void logAndResetStats() { 99 | log.info("[NOTIFICATION] Users: {}, Pincodes: {}, Sent: {}, Errors: {}", 100 | stats.userRequests(), stats.processedPincodes(), 101 | stats.notificationsSent(), stats.notificationsErrors()); 102 | botService.notifyOwner(String.format("[NOTIFICATION] Users: %d, Pincodes: %d, Sent: %d, Errors: %d", 103 | stats.userRequests(), stats.processedPincodes(), 104 | stats.notificationsSent(), stats.notificationsErrors())); 105 | stats.reset(); 106 | } 107 | 108 | @NotNull 109 | private Predicate> eligibleCentersWithData() { 110 | return centers -> !centers.isEmpty(); 111 | } 112 | 113 | @NotNull 114 | private Consumer> logEmptyCenters(String pincode) { 115 | return centers -> { 116 | if (centers.isEmpty()) { 117 | log.debug("No eligible vaccine centers found for pin code {}", pincode); 118 | } 119 | }; 120 | } 121 | 122 | @NotNull 123 | private Function> eligibleCentersFor(String user) { 124 | return vc -> vaccineCentersProcessor.eligibleVaccineCenters(vc, user); 125 | } 126 | 127 | @NotNull 128 | private Predicate centersWithData() { 129 | return vc -> !vc.getCenters().isEmpty(); 130 | } 131 | } 132 | --------------------------------------------------------------------------------