diff --git a/.github/workflows/ci-main.yml b/.github/workflows/ci-main.yml new file mode 100644 index 0000000..7888a73 --- /dev/null +++ b/.github/workflows/ci-main.yml @@ -0,0 +1,50 @@ +name: ci-main +on: + workflow_dispatch: + push: + branches: + - main + +jobs: + build: + runs-on: ubuntu-22.04 + steps: + - uses: actions/setup-java@v3 + with: + java-version: 17 + distribution: temurin + - uses: actions/checkout@v3 + - uses: actions/cache@v3 + with: + path: | + ~/.m2/repository + key: ${{ runner.os }}-${{ hashFiles('**/pom.xml') }} + - name: version + run: >- + APP_SHA=$(git rev-parse --short ${GITHUB_SHA}); + APP_REV=$(git rev-list --tags --max-count=1); + APP_TAG=$(git describe --tags ${APP_REV} 2> /dev/null || echo 0.0.0); + APP_VERSION=${APP_TAG}-${APP_SHA}; + echo "APP_SHA=${APP_SHA}" >> ${GITHUB_ENV}; + echo "APP_TAG=${APP_TAG}" >> ${GITHUB_ENV}; + echo "APP_VERSION=${APP_VERSION}" >> ${GITHUB_ENV}; + - name: Build Artifact and Docker Image + run: >- + mvn versions:set + --batch-mode + --file ./pom.xml + --define newVersion="${APP_VERSION}"; + mvn clean install spring-boot:build-image + --batch-mode + --file ./pom.xml + --define app.packages.username="${APP_PACKAGES_USERNAME}" + --define app.packages.password="${APP_PACKAGES_PASSWORD}" + -Dspring-boot.build-image.imageName="ghcr.io/${{ github.repository }}:${APP_VERSION}"; + - name: Login to GitHub Container Registry + uses: docker/login-action@v2 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Push Docker Image + run: docker push ghcr.io/${{ github.repository }}:${APP_VERSION} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 549e00a..b62eda6 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,6 @@ build/ ### VS Code ### .vscode/ + +### Customized Sample Confs ### +sample-conf/** diff --git a/README.md b/README.md new file mode 100644 index 0000000..4af4a4e --- /dev/null +++ b/README.md @@ -0,0 +1,72 @@ +# Polestar Order Tracker + +Automatically track the detailed OrderInformation of you Polestar order! +Get notifications via E-Mail if anything happens regarding your future car! + +It checks every 6 hours if the OrderModel of the Polestar GRAPH API of your order has changed. If there are any changes +it will send you an email with a detailed report of what has changed. + +The service uses an in-memory database to keep track of the "old" state of the order. On startup the OrderModel will be fetched +and stored date will be used to detect the changes. It makes no sense to run this program just for a short time. I suggest +to run it as a daemon on a server. + +## Disclaimer + +Polestar has not officially released the used APIs for consumers/ 3rd party apps. That's why I cannot guarantee that the usage +of this service is allowed nor that the APIs will provide the required features in the future. + +## Contribution + +This is an OpenSource project. If you want to participate just clone the repository, do your changes and create a PullRequests. + +## Build + +To build the project on your own **Java 17+** is required. +Open a shell within the project directory and execute the following command: + +```bash +mvnw install +``` + +You can find the resulting ```polestar-order-tracker-X.X.X.jar``` in the ```target``` directory. + +## Configure & Run + +### Direct + +Create a copy of the file ```application.yml``` from ```sample-conf``` directory and place it next to the +```polestar-order-tracker-X.X.X.jar``` which you have build on your own or downloaded +from [Releases](https://github.com/f11h/polestar-order-tracker/releases) page. + +Adjust the values in this file according to your needs (Setup E-Mail Server and your Polestar order details) + +| Property | Description | Possible Values/ Example | +|----------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------| +| polestar-order-tracker.order-configs[]enabled | Enable this order to keep track of it | true, false | +| polestar-order-tracker.order-configs[]notify-email | Provide a list of E-Mail recipient, which will get notified about changes | mail@example.org | +| polestar-order-tracker.order-configs[]order-id | The UUID of your Polestar Order. Open your order via the Web-Portal. You will see an URL like ```https://www.polestar.com/de/order/424641db-6692-49a8-aa10-2fe970516404``` in your Browsers address bar. We need the ID from the end of the url. | 424641db-6692-49a8-aa10-2fe970516404 | +| polestar-order-tracker.order-configs[]username | Your E-Mail of your Polestar ID | mail@example.org | +| polestar-order-tracker.order-configs[]password | Your Password of your Polestar ID | sup3rS3cur3 | + +Just start the service with the following command + +```bash +java -jar polestar-order-tracker-X.X.X.jar +``` + +### Docker + +The service can also started as Docker container. Therefore the configuration should be done via environment variables: + +| ENV | Description | Possible Values/ Example | +|---------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------| +| SPRING_MAIL_HOST | SMTP Server Host | smtp.example.org | +| SPRING_MAIL_PORT | SMTP Server Port | 587 | +| SPRING_MAIL_USERNAME | SMTP Username | mail@example.org | +| SPRING_MAIL_PASSWORD | SMTP User Password | sup3rS3cur3 | +| POLESTARORDERTRACKER_ORDERCONFIGS_0_ENABLED | Enable this order to keep track of it | true, false | +| POLESTARORDERTRACKER_ORDERCONFIGS_0_NOTIFYEMAIL_0 | Provide a E-Mail recipient, which will get notified about changes | mail@example.org | +| POLESTARORDERTRACKER_ORDERCONFIGS_0_ORDERID | The UUID of your Polestar Order. Open your order via the Web-Portal. You will see an URL like ```https://www.polestar.com/de/order/424641db-6692-49a8-aa10-2fe970516404``` in your Browsers address bar. We need the ID from the end of the url. | 424641db-6692-49a8-aa10-2fe970516404 | +| POLESTARORDERTRACKER_ORDERCONFIGS_0_USERNAME | Your E-Mail of your Polestar ID | mail@example.org | +| POLESTARORDERTRACKER_ORDERCONFIGS_0_PASSWORD | Your Password of your Polestar ID | sup3rS3cur3 | + diff --git a/pom.xml b/pom.xml index 04a1b82..72bb76e 100644 --- a/pom.xml +++ b/pom.xml @@ -3,14 +3,14 @@ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> 4.0.0 - org.springframework.boot - spring-boot-starter-parent - 3.0.2 + org.springframework.cloud + spring-cloud-starter-parent + 2022.0.1 io.f11h polestar-order-tracker - 0.0.1-SNAPSHOT + latest polestar-order-tracker polestar-order-tracker @@ -27,33 +27,38 @@ org.springframework.boot - spring-boot-starter-web + spring-boot-starter-validation - org.liquibase - liquibase-core + org.springframework.boot + spring-boot-configuration-processor + true + + + org.springframework.cloud + spring-cloud-starter-openfeign + com.h2database h2 runtime - - org.mariadb.jdbc - mariadb-java-client - runtime - - - org.springframework.boot - spring-boot-configuration-processor - true - + + org.projectlombok lombok true + + com.flipkart.zjsonpatch + zjsonpatch + 0.4.14 + + + org.springframework.boot spring-boot-starter-test diff --git a/sample-conf/application.yml b/sample-conf/application.yml new file mode 100644 index 0000000..33cc1e9 --- /dev/null +++ b/sample-conf/application.yml @@ -0,0 +1,15 @@ +spring: + mail: + host: smtp.gmail.com + port: 587 + username: yourmail@gmail.com + password: your-gmail-passowrd + +polestar-order-tracker: + order-configs: + - enabled: true + notify-email: + - yourmail@gmail.com + order-id: 424641db-6692-49a8-aa10-2fe970516404 + username: yourmail@gmail.com + password: YourSuperSecurePolestarPassword diff --git a/src/main/java/io/f11h/polestarordertracker/PolestarOrderTrackerApplication.java b/src/main/java/io/f11h/polestarordertracker/PolestarOrderTrackerApplication.java index c0d11de..54293b0 100644 --- a/src/main/java/io/f11h/polestarordertracker/PolestarOrderTrackerApplication.java +++ b/src/main/java/io/f11h/polestarordertracker/PolestarOrderTrackerApplication.java @@ -2,8 +2,14 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.cloud.openfeign.EnableFeignClients; +import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication +@EnableFeignClients +@EnableConfigurationProperties +@EnableScheduling public class PolestarOrderTrackerApplication { public static void main(String[] args) { diff --git a/src/main/java/io/f11h/polestarordertracker/client/AccessTokenResponse.java b/src/main/java/io/f11h/polestarordertracker/client/AccessTokenResponse.java new file mode 100644 index 0000000..b6eeb1d --- /dev/null +++ b/src/main/java/io/f11h/polestarordertracker/client/AccessTokenResponse.java @@ -0,0 +1,31 @@ +package io.f11h.polestarordertracker.client; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; +import lombok.Getter; + +@Data +@Getter +public +class AccessTokenResponse { + private Data data; + + @lombok.Data + public static class Data { + + @JsonProperty("getAuthToken") + private Data.AuthToken getAuthToken; + + @lombok.Data + public static class AuthToken { + @JsonProperty("id_token") + private String idToken; + @JsonProperty("access_token") + private String accessToken; + @JsonProperty("refresh_token") + private String refreshToken; + @JsonProperty("expires_in") + private Integer expiresIn; + } + } +} diff --git a/src/main/java/io/f11h/polestarordertracker/client/GetOrderModelRequest.java b/src/main/java/io/f11h/polestarordertracker/client/GetOrderModelRequest.java new file mode 100644 index 0000000..60244b4 --- /dev/null +++ b/src/main/java/io/f11h/polestarordertracker/client/GetOrderModelRequest.java @@ -0,0 +1,31 @@ +package io.f11h.polestarordertracker.client; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +public +class GetOrderModelRequest { + private String operationName; + private String query; + private Variables variables; + + @Data + @AllArgsConstructor + public static class Variables { + + public Variables(String orderId) { + request = new Request(orderId); + } + + private Variables.Request request; + + @Data + @AllArgsConstructor + public static class Request { + private String id; + } + } +} diff --git a/src/main/java/io/f11h/polestarordertracker/client/GraphQueries.java b/src/main/java/io/f11h/polestarordertracker/client/GraphQueries.java new file mode 100644 index 0000000..4581e54 --- /dev/null +++ b/src/main/java/io/f11h/polestarordertracker/client/GraphQueries.java @@ -0,0 +1,954 @@ +package io.f11h.polestarordertracker.client; + +public class GraphQueries { + + public static final String GET_ORDER_MODEL_OPERATION = "GetOrderModel"; + public static final String GET_ORDER_MODEL_QUERY = """ + query GetOrderModel($request: QueryRequest!) { + order: getOrderModel(getOrderModelRequest: $request) { + id + orderNumber + orderExternalReferenceNumber + polestarId + countryCode + languageCode + currency + orderType + polestarModel + salesChannel + consumerType + financingOption + loanContractAppendixFilePath + version + rebuilding + updated + depositInformation { + ...DepositInformationFields + __typename + } + partnerLocations { + locationId + capabilities + __typename + } + driver { + ...DriverFields + __typename + } + consumer { + ...ConsumerFields + __typename + } + state { + ...StateFields + __typename + } + financeInformation { + financeApplication { + ...FinanceApplicationFields + fileReferences { + ...FileRefenceFields + __typename + } + identifiers { + ...IdentifierFields + __typename + } + additionalInformation { + ...AdditionalInformationFields + __typename + } + __typename + } + __typename + } + personalFinanceInformation { + financeApplication { + ...PersonalFinanceApplicationFields + personalFinanceDocuments { + ...PersonalFinanceDocumentField + __typename + } + __typename + } + __typename + } + delivery { + ...DeliveryDetailsFields + __typename + } + manufacturing { + ...ManufacuringFields + __typename + } + financialContract { + ...FinancialContractFields + __typename + } + acceptedHandover { + startTime + endTime + timeZone + __typename + } + handoverBooking { + handoverLocation { + ...HandoverLocationFields + __typename + } + suggestedHandoverTimes { + ...SuggestHandoverTimesFields + __typename + } + handoverSuggestedAt + method + __typename + } + registrationInformation { + hasMarketCitizenship + hasPreviousRegistrationCertificate + towBarInspection + transferLicensePlate + useRegistrationAgency + hasLockedRegistrationInformation + hasSelectedRegistrationType + registrationInformationHasBeenRead + documents { + ...RegistrationDocumentFields + __typename + } + registeredOwners { + ...RegisteredOwnerFields + __typename + } + possessors { + name + ssn + __typename + } + garageAddress { + ...GarageAddressFields + __typename + } + additionalInformation { + ...AdditionalRegistrationInformationFields + __typename + } + __typename + } + insuranceInformation { + ...InsuranceInformationFields + __typename + } + paymentAdvise { + ...DocumentFields + __typename + } + salesContract { + ...DocumentFields + __typename + } + salesInvoice { + ...DocumentFields + __typename + } + registrationCertificate { + ...DocumentFields + __typename + } + deliveryAcceptance { + ...DocumentFields + __typename + } + allInvoices { + ...InvoiceDocumentFields + __typename + } + orderContent { + expectedDeliveryDate + specifications { + ...SpecificationSummaryFields + __typename + } + media { + ...MediaFields + __typename + } + extras { + ...ExtrasFields + __typename + } + configuration { + modelYear + __typename + } + __typename + } + payment { + amountPaid + cashAmountPaid + creditAmountPaid + homeDeliveryAmount + homeDeliveryAmountWithoutVat + amountReserved + status + invoices { + ...DocumentFields + __typename + } + paymentOption + __typename + } + paymentInformation { + bank + accountNumber + accountType + customerName + dueDate + __typename + } + orderConfirmedAt + remarketingOrderConfirmation { + ...DocumentFields + __typename + } + salesContractSigners { + firstName + lastName + email + birthDate + ssn + phoneNumber + isConsumer + __typename + } + ordererPolestarIds + driverPolestarIds + price { + car { + ...CarPriceFields + __typename + } + extras { + ...ExtraPriceFields + __typename + } + totals { + ...TotalsFields + __typename + } + __typename + } + paymentDueDate + salesContractDeadline + remarketedCar { + ...RemarketedCarFields + __typename + } + tireDetails { + ...TireDetailsFields + __typename + } + market + isFsaFinancing + isPriceIncorrect + __typename + } + } + + fragment TireDetailsFields on TireDetails { + selectedTireHotel { + ...TireHotelFields + __typename + } + mountWinterWheels + useTireHotel + customerHasSetTireDetails + availableTireHotels { + ...RelativelyLocatedTireHotelFields + __typename + } + __typename + } + + fragment TireHotelFields on TireHotel { + locationCode + name + address + postalCode + city + phone + __typename + } + + fragment RelativelyLocatedTireHotelFields on RelativelyLocatedTireHotel { + tireHotel { + ...TireHotelFields + __typename + } + distanceMeters + index + __typename + } + + fragment RemarketedCarFields on RemarketedCar { + mileage + numberOfPreviousOwners + firstDayInTraffic + firstRegDate + factoryCompleteDate + hasWinterWheels + hasTowbar + isVatDeductible + bankDetails { + ...BankDetailsFields + __typename + } + investor { + ...InvestorFields + __typename + } + __typename + } + + fragment InvestorFields on Investor { + investorId + vehicleAdId + name + orgNumber + address + email + homeDeliveryAvailable + __typename + } + + fragment BankDetailsFields on BankDetails { + accountNumber + bank + company + bankSortCode + swift + iban + __typename + } + + fragment ExtraPriceFields on ExtraPrice { + extraPriceId: id + price + vat + priceIncVat + __typename + } + + fragment CarPriceFields on CarPrice { + code + price + vat + priceIncVat + type + __typename + } + + fragment TotalsFields on Totals { + carBreakdown { + ...CarPriceBreakdownFields + __typename + } + deliveryPriceBreakdown { + ...DeliveryPriceBreakdownFields + __typename + } + discountPriceBreakdown { + ...DiscountPriceBreakdownFields + __typename + } + priceWithDiscountBreakdown { + ...PriceWithDiscountBreakdownFields + __typename + } + grandTotalPriceWithDiscountBreakdown { + ...GrandTotalPriceWithDiscountBreakdownFields + __typename + } + grandTotal { + ...GrandTotalFields + __typename + } + __typename + } + + fragment CarPriceBreakdownFields on CarPriceBreakdown { + carTotalPrice { + ...PriceElementFields + __typename + } + carTotalBasicPrice { + ...PriceElementFields + __typename + } + carTotalTaxes { + ...PriceElementFields + __typename + } + carTotalVat { + ...PriceElementFields + __typename + } + carTotalPriceExclVat { + ...PriceElementFields + __typename + } + carTotalIncentive { + ...PriceElementFields + __typename + } + carTotalIncentiveExclVat { + ...PriceElementFields + __typename + } + carTotalPriceExclIncentive { + ...PriceElementFields + __typename + } + carTotalPriceExclIncentiveExclVat { + ...PriceElementFields + __typename + } + uraxTaxes { + ...TaxFields + __typename + } + __typename + } + + fragment TaxFields on Tax { + taxId + name + amount + __typename + } + + fragment DeliveryPriceBreakdownFields on DeliveryPriceBreakdown { + deliveryChargePrice { + ...PriceElementFields + __typename + } + deliveryChargeBasicPrice { + ...PriceElementFields + __typename + } + deliveryChargeVat { + ...PriceElementFields + __typename + } + __typename + } + + fragment DiscountPriceBreakdownFields on DiscountPriceBreakdown { + discountTotalPrice { + ...PriceElementFields + __typename + } + discountBasicPrice { + ...PriceElementFields + __typename + } + discountPriceExclVat { + ...PriceElementFields + __typename + } + __typename + } + + fragment PriceWithDiscountBreakdownFields on PriceWithDiscountBreakdown { + carTotalPriceWithDiscountBasicPrice { + ...PriceElementFields + __typename + } + carTotalPriceWithDiscount { + ...PriceElementFields + __typename + } + carTotalPriceWithDiscountExclVat { + ...PriceElementFields + __typename + } + __typename + } + + fragment GrandTotalPriceWithDiscountBreakdownFields on GrandTotalPriceWithDiscountBreakdown { + grandTotalCarExtrasDeliveryPriceWithDiscount { + ...PriceElementFields + __typename + } + grandTotalCarExtrasDeliveryPriceBeforeVatWithDiscount { + ...PriceElementFields + __typename + } + grandTotalCarExtrasDeliveryVatWithDiscount { + ...PriceElementFields + __typename + } + __typename + } + + fragment GrandTotalFields on GrandTotal { + grandTotalCarExtras { + ...GrandTotalCarExtrasFields + __typename + } + grandTotalCarExtrasDelivery { + ...GrandTotalCarExtrasDeliveryFields + __typename + } + __typename + } + + fragment GrandTotalCarExtrasFields on GrandTotalCarExtras { + grandTotalCarExtrasPrice { + ...PriceElementFields + __typename + } + grandTotalCarExtrasBeforeVat { + ...PriceElementFields + __typename + } + grandTotalCarExtrasExclIncentive { + ...PriceElementFields + __typename + } + grandTotalCarExtrasExclIncentiveBeforeVat { + ...PriceElementFields + __typename + } + __typename + } + + fragment GrandTotalCarExtrasDeliveryFields on GrandTotalCarExtrasDelivery { + grandTotalCarExtrasDeliveryPrice { + ...PriceElementFields + __typename + } + grandTotalCarExtrasDeliveryBeforeVat { + ...PriceElementFields + __typename + } + grandTotalCarExtrasDeliveryVat { + ...PriceElementFields + __typename + } + grandTotalCarExtrasDeliveryExclIncentive { + ...PriceElementFields + __typename + } + grandTotalCarExtrasDeliveryExclIncentiveBeforeVat { + ...PriceElementFields + __typename + } + __typename + } + + fragment PriceElementFields on PriceElement { + priceId: id + label + value + __typename + } + + fragment InsuranceInformationFields on InsuranceInformation { + insuranceType + hasPersonalInsurance + hasPolestarInsurance + hasInsuranceInformation + informationSource + contractReferenceNumber + insuranceOption + insuranceProvider + insurancePolicyHolder + insuranceCode + otherInformation + insuranceBroker { + ...InsuranceBrokerFields + __typename + } + insuranceCertificateDocument { + ...DocHubDocument + __typename + } + insuranceInformationHasBeenRead + __typename + } + + fragment DepositInformationFields on DepositInformation { + amount + vatAmount + currency + vatName + __typename + } + + fragment ExtrasFields on Extra { + salesExtraId + articleNumber + name + quantity + price + vat + __typename + } + + fragment MediaFields on Media { + exteriorImages { + ...ImageFields + __typename + } + interiorImages { + ...ImageFields + __typename + } + __typename + } + + fragment ImageFields on Image { + size + url + __typename + } + + fragment SpecificationSummaryFields on SpecificationSummary { + lines { + ...SpecificationLineFields + __typename + } + specifications { + ...LabelValueFields + __typename + } + dimensions { + ...LabelValueFields + __typename + } + __typename + } + + fragment SpecificationLineFields on SpecificationLine { + category + label + value + code + media + price + quantity + __typename + } + + fragment LabelValueFields on LabelValue { + label + value + __typename + } + + fragment InvoiceDocumentFields on InvoiceDocument { + isComplete + documentId + documentDisplayName + documentFullName + isCreditInvoice + created + uniqueFileReference + requestIdForCreditedInvoice + requestId + __typename + } + + fragment DriverFields on Driver { + firstName + lastName + email + phoneNumber + address + careOf + city + postalCode + marketingConsent + driverInformationHasBeenRead + __typename + } + + fragment ConsumerFields on MyO2ConsumerDetails { + consumerType + consumerSubType + firstName + lastName + email + phoneNumber + address + careOf + city + postalCode + region + birthDate + ssn + companyName + vatNumber + organizationNumber + billingPostalCode + billingCity + billingAddress + billingCareOf + billingCountry + billingRegion + hasGivenConsentToShareInformationWithDelwpForZeroEmissionProgram + agreementCodes + __typename + } + + fragment ManufacuringFields on Manufacturing { + chassisNumber + manufacturingMonth + registrationNumber + vehicleIdentificationNumber + mileage + registrationDate + vehicleTypeCertificateNumber + vehicleSerialNumber + __typename + } + + fragment HandoverLocationFields on HandoverLocation { + name + addressLine + zipCode + city + countryCode + latitude + longitude + handoverLocationId + __typename + } + + fragment StateFields on States { + isLocked + isCancelled + salesContractRequested + salesContractReceived + hasAcceptedHandover + hasInvoice + hasPendingInvoiceRequest + hasCarDelivered + additionalFinanceConfirmationRequired + remarketingOrderConfirmationReceived + remarketingOrderConfirmationRequested + hasBeenLockedAtLeastOnce + hasCarArrivedAtDealer + isSubsidyAllocated + hasSubsidyHadAnyStatusAtLeastOnce + hasChangedFinancing + hasChangedExtras + hasChangedConsumerType + __typename + } + + fragment RegistrationDocumentFields on RegistrationDocument { + type + status + uniqueFileReference + fileName + documentId + documentSource + documentFullName + __typename + } + + fragment PersonalFinanceApplicationFields on MyO2PersonalFinanceApplication { + financingStatus + leaseProvider + leaseProviderId + leaseDownPayment + loanProvider + loanProviderId + loanFinancedAmount + approvedBeforeSoftLock + numberOfFinancingReviews + loanOwnFinancingViaBankAccount + personalNumber + isCashButPresentedAsSelfFinancing + __typename + } + + fragment PersonalFinanceDocumentField on MyO2PersonalFinanceDocument { + status + uniqueFileReference + fileName + documentId + documentSource + documentFullName + __typename + } + + fragment DeliveryDetailsFields on DeliveryDetails { + bookedDate + bookedDateTimeZoneId + latestDateToLockOrder + deliveredDetails { + ...DeliveredDetailsFields + __typename + } + customerEarliestPossibleHandoverDate + insuranceDeadline + registrationDeadline + carHasArrivedAtDealer + isQuickProcess + estimatedDeliveryDate + __typename + } + + fragment DeliveredDetailsFields on DeliveredDetails { + handoverCompletedAt + mileage + signedDocumentReference + __typename + } + + fragment FinanceApplicationFields on MyO2FinanceApplication { + financialApplicationId + externalReferencingId + financingStatus + allowUpdates + allowRevisits + isCreditCheck + termMonths + downPayment + monthlyCost + interestRate + effectiveInterestRate + annualMileage + beneficialOwnershipInformationRequired + residualValue + balloonAmount + includesBalloon + isFinancialLease + priceDetails + additionalInformationFinePrint + __typename + } + + fragment InsuranceBrokerFields on InsuranceBroker { + name + email + phoneNumber + __typename + } + + fragment SuggestHandoverTimesFields on SuggestedHandoverTimes { + start + end + timezone + __typename + } + + fragment FinancialContractFields on FinancialContract { + status + signatories + branchNumber + __typename + } + + fragment DocumentFields on MyO2PDFDocument { + fileName + documentId + __typename + } + + fragment FileRefenceFields on MyO2FileReference { + bucket + path + documentType + __typename + } + + fragment AdditionalInformationFields on MyO2AdditionalInformation { + key + value + __typename + } + + fragment IdentifierFields on MyO2Identifier { + name + identifier + __typename + } + + fragment RegisteredOwnerFields on RegisteredOwner { + name + identifier + gender + dateOfBirth + emailAddress + phoneNumber + driversLicenseNumber + driversLicenseExpiryDate + businessRegistrationCustomerNumber + residentialAddress { + ...RegistrationAddressFields + __typename + } + mailingAddress { + ...RegistrationAddressFields + __typename + } + __typename + } + + fragment RegistrationAddressFields on RegistrationAddress { + addressLine1 + addressLine2 + addressLine3 + suburb + state + postalCode + __typename + } + + fragment DocHubDocument on MyO2DocHubDocument { + documentType + status + uniqueFileReference + fileName + documentId + documentSource + documentFullName + __typename + } + + fragment GarageAddressFields on GarageAddress { + addressLine1 + addressLine2 + addressLine3 + suburb + state + postalCode + typeOfUse + __typename + } + + fragment AdditionalRegistrationInformationFields on AdditionalRegistrationInformation { + dateOfBirth + placeOfBirth + choiceOfDeclaration + __typename + } + """; + + public static final String GET_ACCESS_TOKEN_OPERATION = "getAuthToken"; + public static final String GET_ACCESS_TOKEN_QUERY = """ + query getAuthToken($code: String!) { + getAuthToken(code: $code) { + id_token + access_token + refresh_token + expires_in + } + } + """; +} diff --git a/src/main/java/io/f11h/polestarordertracker/client/PolestarGraphClient.java b/src/main/java/io/f11h/polestarordertracker/client/PolestarGraphClient.java new file mode 100644 index 0000000..ec34dba --- /dev/null +++ b/src/main/java/io/f11h/polestarordertracker/client/PolestarGraphClient.java @@ -0,0 +1,23 @@ +package io.f11h.polestarordertracker.client; + +import com.fasterxml.jackson.databind.JsonNode; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@FeignClient(name = "polestarGraph", url = "${polestar-order-tracker.graph-url}") +public interface PolestarGraphClient { + + @GetMapping(value = "/auth", produces = MediaType.APPLICATION_JSON_VALUE, consumes = MediaType.APPLICATION_JSON_VALUE) + ResponseEntity getAccessToken( + @RequestParam("query") String query, + @RequestParam("operation_name") String operationName, + @RequestParam("variables") String variables); + + @PostMapping(value = "/order2delivery", produces = MediaType.APPLICATION_JSON_VALUE, consumes = MediaType.APPLICATION_JSON_VALUE) + ResponseEntity order2Delivery( + @RequestBody GetOrderModelRequest getOrderModelRequest, + @RequestHeader("Authorization") String authorizationHeader); + +} diff --git a/src/main/java/io/f11h/polestarordertracker/client/PolestarIamClient.java b/src/main/java/io/f11h/polestarordertracker/client/PolestarIamClient.java new file mode 100644 index 0000000..38ad7ac --- /dev/null +++ b/src/main/java/io/f11h/polestarordertracker/client/PolestarIamClient.java @@ -0,0 +1,39 @@ +package io.f11h.polestarordertracker.client; + +import feign.Response; +import feign.form.FormProperty; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.*; + +@FeignClient(name = "polestarIam", url = "${polestar-order-tracker.iam-url}") +public interface PolestarIamClient { + + @GetMapping("/as/authorization.oauth2") + Response startLogin( + @RequestParam("response_type") String responseType, + @RequestParam("client_id") String clientId, + @RequestParam("redirect_uri") String redirectUri, + @RequestParam("scope") String scope); + + @PostMapping(value = "/as/{resumeToken}/resume/as/authorization.ping", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE) + Response login( + @PathVariable("resumeToken") String resumeToken, + @RequestParam("client_id") String clientId, + @RequestBody LoginData loginData, + @RequestHeader("Cookie") String cookie); + + + @AllArgsConstructor + @Getter + class LoginData { + @FormProperty("pf.username") + private String username; + + @FormProperty("pf.pass") + private String password; + } + +} diff --git a/src/main/java/io/f11h/polestarordertracker/config/OrderTrackerConfigProperties.java b/src/main/java/io/f11h/polestarordertracker/config/OrderTrackerConfigProperties.java new file mode 100644 index 0000000..326b9ee --- /dev/null +++ b/src/main/java/io/f11h/polestarordertracker/config/OrderTrackerConfigProperties.java @@ -0,0 +1,119 @@ +package io.f11h.polestarordertracker.config; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; +import lombok.Data; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.hibernate.validator.constraints.Length; +import org.hibernate.validator.constraints.URL; +import org.hibernate.validator.constraints.UUID; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; +import org.springframework.validation.annotation.Validated; + +import java.util.ArrayList; +import java.util.List; + +@Configuration +@Data +@ConfigurationProperties(prefix = "polestar-order-tracker") +@Validated +@NoArgsConstructor +public class OrderTrackerConfigProperties { + + /** + * URL of Polestar IAM Service- + */ + @URL + @NotNull + private String iamUrl = "https://polestarid.eu.polestar.com"; + + /** + * URL of Polestar GRAPH API to get actual data. + */ + @URL + @NotNull + private String graphUrl = "https://pc-api.polestar.com/eu-north-1"; + + /** + * Interval in seconds how often the order data should be polled. + */ + @Min(300) // 5 Minutes + @Max(604800) // 1 Week + private Integer refreshInterval = 21_600; + + /** + * Time in seconds two wait between requests. + */ + @Min(60) // 1 Minute + @Max(600) // 10 Minutes + private Integer backOff = 60; + + /** + * List of Polestar order configs. + */ + @Size(max = 10) + private List<@Valid OrderConfig> orderConfigs = new ArrayList<>(); + + /** + * Configuration of outbound mail server to notify about order changes. + */ + @NotNull + private MailConfig mailConfig; + + @Data + @NoArgsConstructor + public static class MailConfig { + + /** + * Email Sender (displayed as FROM) + */ + @NotNull + @Length(min = 1, max = 100) + private String from; + } + + @Getter + @Setter + @NoArgsConstructor + public static class OrderConfig { + + /** + * Enable or disable tracking of this order. + */ + @NotNull + private Boolean enabled = Boolean.TRUE; + + /** + * Polestar IAM Username + */ + @Length(min = 1, max = 50) + @NotNull + private String username; + + /** + * Polestar IAM Password + */ + @Length(min = 1, max = 50) + @NotNull + private String password; + + /** + * Order ID of the order to be tracked (UUID) + */ + @UUID + @NotNull + private String orderId; + + /** + * List of Email recipient who get notified about order changes + */ + @Size(min = 1, max = 5) + @NotNull + private List<@Email @Length(max = 100) @NotNull String> notifyEmail; + } + + +} diff --git a/src/main/java/io/f11h/polestarordertracker/persistence/entity/Order.java b/src/main/java/io/f11h/polestarordertracker/persistence/entity/Order.java new file mode 100644 index 0000000..d3467cb --- /dev/null +++ b/src/main/java/io/f11h/polestarordertracker/persistence/entity/Order.java @@ -0,0 +1,49 @@ +package io.f11h.polestarordertracker.persistence.entity; + +import io.f11h.polestarordertracker.config.OrderTrackerConfigProperties; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.Setter; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "order_config") +@RequiredArgsConstructor +@Getter +@Setter +public class Order { + + @Id + private String orderId; + + @Column + private Boolean enabled; + + @Column(length = 100) + private String loginUsername; + + @Column(length = 100) + private String loginPassword; + + @Column(length = 1010) + private String notifyEmails; + + @Column + private LocalDateTime lastExecution; + + @Column(length = 50_000) + private String orderData; + + public Order(OrderTrackerConfigProperties.OrderConfig orderConfigProperties) { + orderId = orderConfigProperties.getOrderId(); + enabled = orderConfigProperties.getEnabled(); + loginUsername = orderConfigProperties.getUsername(); + loginPassword = orderConfigProperties.getPassword(); + notifyEmails = String.join(",", orderConfigProperties.getNotifyEmail()); + } +} diff --git a/src/main/java/io/f11h/polestarordertracker/persistence/repository/OrderRepository.java b/src/main/java/io/f11h/polestarordertracker/persistence/repository/OrderRepository.java new file mode 100644 index 0000000..b08a549 --- /dev/null +++ b/src/main/java/io/f11h/polestarordertracker/persistence/repository/OrderRepository.java @@ -0,0 +1,16 @@ +package io.f11h.polestarordertracker.persistence.repository; + +import io.f11h.polestarordertracker.persistence.entity.Order; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.time.LocalDateTime; +import java.util.Optional; +import java.util.UUID; + +@Repository +public interface OrderRepository extends JpaRepository { + + Optional getFirstByEnabledIsTrueAndLastExecutionIsNullOrLastExecutionIsBeforeOrderByLastExecutionAsc(LocalDateTime threshold); + +} diff --git a/src/main/java/io/f11h/polestarordertracker/service/EmailService.java b/src/main/java/io/f11h/polestarordertracker/service/EmailService.java new file mode 100644 index 0000000..ad5c643 --- /dev/null +++ b/src/main/java/io/f11h/polestarordertracker/service/EmailService.java @@ -0,0 +1,119 @@ +package io.f11h.polestarordertracker.service; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.util.DefaultPrettyPrinter; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.f11h.polestarordertracker.config.OrderTrackerConfigProperties; +import io.f11h.polestarordertracker.persistence.entity.Order; +import jakarta.mail.MessagingException; +import jakarta.mail.internet.MimeMessage; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.MimeMessageHelper; +import org.springframework.stereotype.Service; + +@Slf4j +@RequiredArgsConstructor +@Service +public class EmailService { + + private static final String EMAIL_TEMPLATE_HTML = """ + + + + + + + Your Polestar Order has been updated! + + + Good news - Your Polestar Order Details have just been updated from version <> to <>. + Check the details below to find out what has been changed. + + + <> + + + + """; + + private static final String EMAIL_TEMPLATE_TEXT = """ + Your Polestar Order has been updated! + + Good news - Your Polestar Order Details have just been updated from version <> to <>. + Check the details below to find out what has been changed. + + <> + """; + private final ObjectMapper objectMapper; + + private final JavaMailSender javaMailSender; + + private final OrderTrackerConfigProperties properties; + + public void notifyUser(Order order, JsonNode changes, int oldVersion, int newVersion) { + try { + MimeMessage mimeMailMessage = javaMailSender.createMimeMessage(); + MimeMessageHelper helper = new MimeMessageHelper(mimeMailMessage, true); + helper.setFrom(properties.getMailConfig().getFrom()); + helper.setTo(order.getNotifyEmails().split(",")); + helper.setSubject("Polestar OrderModel Update"); + helper.setText(renderEmailTemplate(EMAIL_TEMPLATE_TEXT, oldVersion, newVersion, changes, false), + renderEmailTemplate(EMAIL_TEMPLATE_HTML, oldVersion, newVersion, changes, true)); + + javaMailSender.send(mimeMailMessage); + } catch (MessagingException e) { + throw new RuntimeException(e); + } + } + + private String renderEmailTemplate(String template, int oldVersion, int newVersion, JsonNode json, boolean html) { + String jsonString; + try { + jsonString = objectMapper.writer(new DefaultPrettyPrinter()).writeValueAsString(json); + } catch (JsonProcessingException e) { + log.error("Unable to convert JSON Object to String", e); + jsonString = "Sorry - I'm not able to present you the diff-data. :-("; + } + + if (html) { + jsonString = jsonString.replaceAll(" ", " "); + jsonString = jsonString.replaceAll("\n", ""); + } + + String emailText = template; + emailText = emailText.replaceAll("<>", String.valueOf(oldVersion)); + emailText = emailText.replaceAll("<>", String.valueOf(newVersion)); + emailText = emailText.replaceAll("<>", jsonString); + + return emailText; + } + +} diff --git a/src/main/java/io/f11h/polestarordertracker/service/OrderTrackerScheduler.java b/src/main/java/io/f11h/polestarordertracker/service/OrderTrackerScheduler.java new file mode 100644 index 0000000..c89d7af --- /dev/null +++ b/src/main/java/io/f11h/polestarordertracker/service/OrderTrackerScheduler.java @@ -0,0 +1,26 @@ +package io.f11h.polestarordertracker.service; + +import io.f11h.polestarordertracker.persistence.entity.Order; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +import java.util.concurrent.TimeUnit; + +@RequiredArgsConstructor +@Service +@Slf4j +public class OrderTrackerScheduler { + + private final OrderTrackerService orderTrackerService; + + @Scheduled(initialDelay = 10, fixedDelayString = "${polestar-order-tracker.back-off}", timeUnit = TimeUnit.SECONDS) + public void scheduler() { + Order nextOrder = orderTrackerService.getNextOrderToBeProcessed(); + + if (nextOrder != null) { + orderTrackerService.processOrder(nextOrder); + } + } +} diff --git a/src/main/java/io/f11h/polestarordertracker/service/OrderTrackerService.java b/src/main/java/io/f11h/polestarordertracker/service/OrderTrackerService.java new file mode 100644 index 0000000..92a33cf --- /dev/null +++ b/src/main/java/io/f11h/polestarordertracker/service/OrderTrackerService.java @@ -0,0 +1,110 @@ +package io.f11h.polestarordertracker.service; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.flipkart.zjsonpatch.DiffFlags; +import com.flipkart.zjsonpatch.JsonDiff; +import io.f11h.polestarordertracker.config.OrderTrackerConfigProperties; +import io.f11h.polestarordertracker.persistence.entity.Order; +import io.f11h.polestarordertracker.persistence.repository.OrderRepository; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.util.EnumSet; + +@RequiredArgsConstructor +@Service +@Slf4j +public class OrderTrackerService { + + private final OrderTrackerConfigProperties properties; + + private final PolestarGraphService polestarGraphService; + + private final EmailService emailService; + + private final OrderRepository orderRepository; + + private final ObjectMapper objectMapper; + + @PostConstruct + public void importOrders() { + properties.getOrderConfigs().forEach(orderConfig -> { + log.info("Importing Order with ID {} to database", orderConfig.getOrderId()); + Order orderEntity = new Order(orderConfig); + orderRepository.save(orderEntity); + log.info("Imported Order with ID {} to database", orderConfig.getOrderId()); + }); + + log.info("Total amount of imported Orders {}", orderRepository.count()); + } + + public Order getNextOrderToBeProcessed() { + LocalDateTime threshold = LocalDateTime.now() + .minusSeconds(properties.getRefreshInterval()); + + return orderRepository.getFirstByEnabledIsTrueAndLastExecutionIsNullOrLastExecutionIsBeforeOrderByLastExecutionAsc(threshold) + .orElse(null); + } + + public void processOrder(Order order) { + // Get OrderModel from DB + JsonNode storedOrderModel = null; + if (order.getOrderData() != null) { + try { + storedOrderModel = objectMapper.readValue(order.getOrderData(), JsonNode.class); + } catch (JsonProcessingException e) { + log.error("Failed to parse JSON in database. Database entity will be truncated.", e); + updateOrder(order, null); + return; + } + } + + // Get OrderModel from Polestar API + JsonNode currentOrderModel = polestarGraphService.getOrderModel(order); + // Failed to retrieve Order Model + if (currentOrderModel == null) { + updateOrder(order, order.getOrderData()); + return; + } + + // Only compare and notify user if DB already had an OrderModel + if (storedOrderModel != null) { + int storedOrderModelVersion = storedOrderModel.get("data").get("order").get("version").asInt(); + int currentOrderModelVersion = currentOrderModel.get("data").get("order").get("version").asInt(); + log.info("Order {}: StoredOrderModelVersion {}, CurrentOrderModelVersion {}", order.getOrderId(), storedOrderModelVersion, currentOrderModelVersion); + + EnumSet flags = EnumSet.of( + DiffFlags.ADD_ORIGINAL_VALUE_ON_REPLACE, + DiffFlags.OMIT_COPY_OPERATION, + DiffFlags.OMIT_MOVE_OPERATION + ); + + JsonNode difference = JsonDiff.asJson(storedOrderModel, currentOrderModel, flags); + if (difference.isEmpty()) { + log.info("Order {}: No Changes in OrderModel detected", order.getOrderId()); + } else { + emailService.notifyUser(order, difference, storedOrderModelVersion, currentOrderModelVersion); + } + } + + // Update orderData in database + String currentOrderModelAsString = order.getOrderData(); + try { + currentOrderModelAsString = objectMapper.writeValueAsString(currentOrderModel); + } catch (JsonProcessingException e) { + log.error("Failed to convert JsonNode to String value", e); + } + updateOrder(order, currentOrderModelAsString); + } + + private void updateOrder(Order order, String orderData) { + order.setLastExecution(LocalDateTime.now()); + order.setOrderData(orderData); + orderRepository.save(order); + } +} diff --git a/src/main/java/io/f11h/polestarordertracker/service/PolestarGraphService.java b/src/main/java/io/f11h/polestarordertracker/service/PolestarGraphService.java new file mode 100644 index 0000000..75a1ac9 --- /dev/null +++ b/src/main/java/io/f11h/polestarordertracker/service/PolestarGraphService.java @@ -0,0 +1,46 @@ +package io.f11h.polestarordertracker.service; + +import com.fasterxml.jackson.databind.JsonNode; +import io.f11h.polestarordertracker.client.GetOrderModelRequest; +import io.f11h.polestarordertracker.client.GraphQueries; +import io.f11h.polestarordertracker.client.PolestarGraphClient; +import io.f11h.polestarordertracker.persistence.entity.Order; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; + +@RequiredArgsConstructor +@Service +@Slf4j +public class PolestarGraphService { + + private final PolestarGraphClient polestarGraphClient; + + private final PolestarIamService polestarIamService; + + public JsonNode getOrderModel(Order order) { + String accessToken = polestarIamService.getAccessToken(order); + + if (accessToken == null) { + log.error("Failed to get AccessToken for {}", order.getOrderId()); + return null; + } + + GetOrderModelRequest request = GetOrderModelRequest.builder() + .operationName(GraphQueries.GET_ORDER_MODEL_OPERATION) + .query(GraphQueries.GET_ORDER_MODEL_QUERY) + .variables(new GetOrderModelRequest.Variables(order.getOrderId())) + .build(); + + ResponseEntity orderModelResponse = polestarGraphClient.order2Delivery(request, "Bearer " + accessToken); + + if (orderModelResponse.getBody() == null) { + log.error("GetOrderModel for {} failed: GraphResponse does not have a body.", order.getOrderId()); + return null; + } + + log.info("GetOrderModel for {} successful", order.getOrderId()); + return orderModelResponse.getBody(); + } +} diff --git a/src/main/java/io/f11h/polestarordertracker/service/PolestarIamService.java b/src/main/java/io/f11h/polestarordertracker/service/PolestarIamService.java new file mode 100644 index 0000000..10e1183 --- /dev/null +++ b/src/main/java/io/f11h/polestarordertracker/service/PolestarIamService.java @@ -0,0 +1,98 @@ +package io.f11h.polestarordertracker.service; + +import feign.Response; +import io.f11h.polestarordertracker.client.AccessTokenResponse; +import io.f11h.polestarordertracker.client.GraphQueries; +import io.f11h.polestarordertracker.client.PolestarGraphClient; +import io.f11h.polestarordertracker.client.PolestarIamClient; +import io.f11h.polestarordertracker.persistence.entity.Order; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.web.util.UriComponents; +import org.springframework.web.util.UriComponentsBuilder; + +import java.net.URI; + +@RequiredArgsConstructor +@Service +@Slf4j +public class PolestarIamService { + + private final static String CALLBACK_URL = "https://www.polestar.com/sign-in-callback"; + private final static String OAUTH_CLIENT_ID = "polmystar"; + private final static String OAUTH_RESPONSE_TYPE = "code"; + private final static String OAUTH_SCOPES = "openid profile email customer:attributes customer:attributes:write"; + + private final PolestarIamClient polestarIamClient; + + private final PolestarGraphClient polestarGraphClient; + + public String getAccessToken(Order order) { + log.info("Start Login for {}", order.getOrderId()); + // Create Login Session and get Resume-Token for actual login + Response startLoginResponse = polestarIamClient.startLogin(OAUTH_RESPONSE_TYPE, OAUTH_CLIENT_ID, CALLBACK_URL, OAUTH_SCOPES); + startLoginResponse.close(); + + if (!startLoginResponse.headers().containsKey(HttpHeaders.LOCATION)) { + log.error("Login for {} failed: StartLoginResponse does not contain Location-Header.", order.getOrderId()); + return null; + } + if (!startLoginResponse.headers().containsKey(HttpHeaders.SET_COOKIE)) { + log.error("Login for {} failed: StartLoginResponse does not contain Set-Cookie-Header.", order.getOrderId()); + return null; + } + + String forwardUri = startLoginResponse.headers().get(HttpHeaders.LOCATION).iterator().next(); + String cookie = startLoginResponse.headers().get(HttpHeaders.SET_COOKIE).iterator().next(); + String resumeToken = getResumeTokenFromUri(forwardUri); + if (resumeToken == null) { + log.error("Login for {} failed: StartLoginResponse Forward URI does not contain Resume Token.", order.getOrderId()); + return null; + } + + // Login with credentials and get AuthCode + Response loginResponse = polestarIamClient.login(resumeToken, OAUTH_CLIENT_ID, + new PolestarIamClient.LoginData(order.getLoginUsername(), order.getLoginPassword()), cookie); + loginResponse.close(); + + if (!loginResponse.headers().containsKey(HttpHeaders.LOCATION)) { + log.error("Login for {} failed: LoginResponse does not contain Location-Header.", order.getOrderId()); + return null; + } + + String loginCallbackUri = loginResponse.headers().get(HttpHeaders.LOCATION).iterator().next(); + String authCode = getAuthCodeFromUri(loginCallbackUri); + + if (authCode == null) { + log.error("Login for {} failed: LoginResponse Forward URI does not contain AuthCode.", order.getOrderId()); + return null; + } + + // Get AccessToken for further API requests + ResponseEntity accessTokenResponseResponse = polestarGraphClient.getAccessToken( + GraphQueries.GET_ACCESS_TOKEN_QUERY, + GraphQueries.GET_ACCESS_TOKEN_OPERATION, + "{\"code\": \"" + authCode + "\"}"); + + if (accessTokenResponseResponse.getBody() == null) { + log.error("Login for {} failed: AccessTokenResponse does not have a body.", order.getOrderId()); + return null; + } + + log.info("Login for {} successful", order.getOrderId()); + return accessTokenResponseResponse.getBody().getData().getGetAuthToken().getAccessToken(); + } + + private String getResumeTokenFromUri(String uri) { + UriComponents uriComponents = UriComponentsBuilder.fromUri(URI.create(uri)).build(); + return uriComponents.getQueryParams().get("resumePath").get(0); + } + + private String getAuthCodeFromUri(String uri) { + UriComponents uriComponents = UriComponentsBuilder.fromUri(URI.create(uri)).build(); + return uriComponents.getQueryParams().get("code").get(0); + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties deleted file mode 100644 index 8b13789..0000000 --- a/src/main/resources/application.properties +++ /dev/null @@ -1 +0,0 @@ - diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..f8a5fa2 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,32 @@ +spring: + datasource: + url: jdbc:h2:mem:polestarOrderTracker + jpa: + hibernate: + ddl-auto: create + cloud: + openfeign: + client: + config: + polestarIam: + follow-redirects: false + h2: + console: + enabled: true + mail: + properties: + mail: + smtp: + auth: true + starttls: + enable: true + test-connection: true + +polestar-order-tracker: + mail-config: + from: ${spring.mail.username} + order-configs: [ ] + graph-url: https://pc-api.polestar.com/eu-north-1 + iam-url: https://polestarid.eu.polestar.com + back-off: 300 + refresh-interval: 21600 \ No newline at end of file diff --git a/src/test/java/io/f11h/polestarordertracker/PolestarOrderTrackerApplicationTests.java b/src/test/java/io/f11h/polestarordertracker/PolestarOrderTrackerApplicationTests.java index 1f8476d..5b81132 100644 --- a/src/test/java/io/f11h/polestarordertracker/PolestarOrderTrackerApplicationTests.java +++ b/src/test/java/io/f11h/polestarordertracker/PolestarOrderTrackerApplicationTests.java @@ -2,10 +2,15 @@ import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.mail.javamail.JavaMailSender; @SpringBootTest class PolestarOrderTrackerApplicationTests { + @MockBean + JavaMailSender javaMailSenderMock; + @Test void contextLoads() { }