diff --git a/.github/workflows/developer-guide-docs.yml b/.github/workflows/developer-guide-docs.yml index f57befe575c..ca08aa4fe2e 100644 --- a/.github/workflows/developer-guide-docs.yml +++ b/.github/workflows/developer-guide-docs.yml @@ -14,6 +14,8 @@ on: # sample itself is compiled and tested by wayline.yml, so the demo build # and the figure render have nothing to add for it. - 'scripts/wayline/**' + # The Savor chapters, the same way; savor.yml compiles and tests the sample. + - 'scripts/savor/**' # The API-name check derives the published pages from the javadoc's own # sources, and the guide links into the java.* compatibility classes -- # java/util/List.html among them. A change that renames or deletes one @@ -125,6 +127,10 @@ jobs: - 'Ports/JavaSE/**' - 'maven/core/**' - 'maven/javase/**' + # The Wayline and Savor chapters include these trees by tag, so a + # renamed or deleted tag there has to reach the snippet check. + - 'scripts/wayline/**' + - 'scripts/savor/**' workflow: - '.github/workflows/developer-guide-docs.yml' diff --git a/.github/workflows/savor.yml b/.github/workflows/savor.yml new file mode 100644 index 00000000000..38331244e66 --- /dev/null +++ b/.github/workflows/savor.yml @@ -0,0 +1,475 @@ +name: Savor (full-stack sample) + +# scripts/savor is one Maven project holding an application and its server: +# a `shared` module of transfer objects and @RestClient contracts, the +# Codename One client that calls them, and a Codename One Backend that +# implements them. It is also what the initializr offers as a template. +# +# Six things are checked here, because each fails without the others noticing: +# +# backend the server's own tests, on the JVM and compiled by ParparVM +# client-e2e the application in the simulator, driven through its real +# screens against a running server +# web the browser build of the application, hosted by the server +# (cn1:backend-webapp) and driven in a headless browser: it +# loads, signs in and reaches the home screen +# template the Initializr's template is this project (template-sync), and +# a project generated from it under another name builds: its +# server's tests pass and its app compiles (a step of client-e2e, +# which has the artifacts installed) +# android the Android Gradle project builds +# ios the Xcode project generates and builds for the simulator +# +# The project has the minimal layout, with no platform modules: common builds +# the simulator, the desktop, Android, iOS and the browser, and +# build-android-app.sh and build-ios-app.sh find the device projects there. +# +# The two device builds stop at a build: the screens are covered by the +# simulator run, and neither port holds screenshot baselines for this sample. + +on: + pull_request: + paths: + - 'scripts/savor/**' + - 'vm/backend/src/**' + - 'vm/backend/impl/**' + - 'maven/backend/**' + - 'maven/backend-test/**' + - 'maven/build-engine/src/main/java/com/codename1/maven/**' + - 'maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/**' + - 'CodenameOne/src/com/codename1/maps/**' + - 'CodenameOne/src/com/codename1/io/oidc/**' + - 'CodenameOne/src/com/codename1/io/rest/**' + # The REST client maps its responses through the mapper registry. + - 'CodenameOne/src/com/codename1/mapping/**' + # What the hosted web app runs on: the JavaScript port, both the + # browser side and the Java the app is compiled with (its network + # connection, its implementation class). The `web` job is the only + # test here that signs in through either. + - 'Ports/JavaScriptPort/src/main/**' + - 'scripts/build-android-app.sh' + - 'scripts/build-ios-app.sh' + - 'scripts/sync-initializr-template.py' + - 'scripts/tests/generate-initializr-fixtures.py' + - 'scripts/tests/GenerateInitializr.java' + # The generator and the template. + - 'scripts/initializr/common/src/**' + - '.github/workflows/savor.yml' + push: + branches: [ master ] + paths: + - 'scripts/savor/**' + - 'vm/backend/src/**' + - 'maven/backend/**' + - 'maven/backend-test/**' + - 'scripts/sync-initializr-template.py' + - 'scripts/initializr/common/src/**' + - '.github/workflows/savor.yml' + workflow_dispatch: + +concurrency: + # Only the latest revision needs validation. PR numbers keep forks isolated; + # event names keep a manual run from cancelling a push or PR run. + group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + # Required to pull the pr-ci-container image from ghcr.io. + packages: read + +jobs: + template-sync: + name: Initializr template matches the sample + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - uses: actions/checkout@v6 + # The template's resources are derived from scripts/savor and committed. + # A change to one without the other is a template that is not the project + # this workflow tests. + - name: Compare the template with scripts/savor + run: python3 scripts/sync-initializr-template.py savor + + backend: + name: Server tests (JVM and compiled) + runs-on: ubuntu-24.04 + timeout-minutes: 60 + steps: + - uses: actions/checkout@v6 + + - name: Install native build tools + run: | + bash scripts/ci/apt-get-update.sh + # A server binary links libcurl, OpenSSL and nghttp2; clang alone + # compiles the tests and then fails at the link. + sudo apt-get install -y clang zlib1g-dev libcurl4-openssl-dev libssl-dev libnghttp2-dev + + - name: Set up JDK 8 + uses: actions/setup-java@v5 + with: + distribution: 'temurin' + java-version: '8' + cache: 'maven' + - name: Save JDK 8 Path + run: echo "JDK_8_HOME=$JAVA_HOME" >> "$GITHUB_ENV" + + - name: Set up JDK 17 + uses: actions/setup-java@v5 + with: + distribution: 'temurin' + java-version: '17' + - name: Save JDK 17 Path + run: echo "JDK_17_HOME=$JAVA_HOME" >> "$GITHUB_ENV" + + # The quality gates run in pr.yml; here they would only slow an install + # whose purpose is to put this checkout's artifacts in the repository. + - name: Install the framework, the backend runtime and the plugin + working-directory: maven + run: | + bash "$GITHUB_WORKSPACE/scripts/ci/retry.sh" mvn -B -pl core,backend,backend-test,codenameone-maven-plugin \ + -am install -DskipTests -Plocal-dev-javase \ + -Dspotbugs.skip=true -Dpmd.skip=true -Dcheckstyle.skip=true \ + -Dmaven.javadoc.skip=true \ + -Dmaven.repo.local="$GITHUB_WORKSPACE/.m2-repo" + env: + JAVA_HOME: ${{ env.JDK_8_HOME }} + RETRY_ONLY_MATCHING: transient + + - name: Run the server tests on the JVM and compiled + working-directory: scripts/savor + run: | + mvn -B -Dcodename1.platform=backend -pl shared,backend test \ + -Dcn1.backend.compiledTests=true \ + -Dmaven.repo.local="$GITHUB_WORKSPACE/.m2-repo" + env: + JAVA_HOME: ${{ env.JDK_17_HOME }} + JDK_8_HOME: ${{ env.JDK_8_HOME }} + + # A goal that skipped leaves the job green, so the reports are what prove + # that both runners ran. + - name: Check that both runners reported + working-directory: scripts/savor/backend/target/surefire-reports + run: | + jvm=$(find . -maxdepth 1 -name 'TEST-*.xml' ! -name '*-compiled.xml' | wc -l) + compiled=$(find . -maxdepth 1 -name 'TEST-*-compiled.xml' | wc -l) + echo "JVM reports: $jvm, compiled reports: $compiled" + if [ "$jvm" -eq 0 ] || [ "$compiled" -eq 0 ]; then + echo "::error::a runner produced no test reports" + exit 1 + fi + + - name: Upload the test reports + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v7 + with: + name: savor-backend-tests + path: scripts/savor/backend/target/surefire-reports/** + if-no-files-found: warn + retention-days: 14 + + client-e2e: + name: Application against a running server (simulator) + runs-on: ubuntu-24.04 + container: ghcr.io/codenameone/codenameone/pr-ci-container:latest + timeout-minutes: 45 + steps: + - uses: actions/checkout@v6 + + - name: Install Codename One artifacts (core / javase / css-compiler / plugin / backend) + # Retried only for a failure that is the network's: a compilation + # error has to fail on the first attempt. + env: + RETRY_ONLY_MATCHING: transient + RETRY_ATTEMPTS: '5' + RETRY_DELAY_SECONDS: '60' + run: | + export JAVA_HOME="${JAVA_HOME_8}" + export PATH="${JAVA_HOME_8}/bin:$PATH" + cd maven + bash ../scripts/ci/retry.sh mvn -B \ + -pl core,javase,css-compiler,codenameone-maven-plugin,backend,backend-test -am install \ + -Plocal-dev-javase -DskipTests -Dmaven.javadoc.skip=true \ + -Dspotbugs.skip=true -Dpmd.skip=true -Dcheckstyle.skip=true + + - name: Ensure a virtual display (xvfb) for the simulator + run: | + if ! command -v xvfb-run >/dev/null 2>&1; then + (timeout 300 apt-get update && timeout 300 apt-get install -y xvfb) || true + fi + + # Ahead of the simulator run: it is quick, and a template that does not + # build should not wait behind the screens to say so. + - name: Build a project generated from the Initializr template + run: | + export JDK_8_HOME="${JAVA_HOME_8}" + export JAVA_HOME="${JAVA_HOME_17}" + export PATH="${JAVA_HOME_17}/bin:$PATH" + scripts/savor/check-template.sh "$RUNNER_TEMP/savor-template" + + # The Initializr's own tests, which cover this template too, run in + # wayline.yml: a change here reaches the generator's resources, which + # trigger it. + + - name: Run the application against the server + run: | + export JDK_8_HOME="${JAVA_HOME_8}" + export JAVA_HOME="${JAVA_HOME_17}" + export PATH="${JAVA_HOME_17}/bin:$PATH" + scripts/savor/run-e2e.sh + + - name: Upload the captures and the server log + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v7 + with: + name: savor-e2e + path: | + scripts/savor/target/e2e-shots/** + scripts/savor/target/e2e-server.log + if-no-files-found: warn + retention-days: 14 + + web: + name: Web app hosted by the server (headless browser) + runs-on: ubuntu-24.04 + timeout-minutes: 45 + steps: + - uses: actions/checkout@v6 + + - name: Set up JDK 8 + uses: actions/setup-java@v5 + with: + distribution: 'temurin' + java-version: '8' + cache: 'maven' + - name: Save JDK 8 Path + run: echo "JDK_8_HOME=$JAVA_HOME" >> "$GITHUB_ENV" + + - name: Set up JDK 17 + uses: actions/setup-java@v5 + with: + distribution: 'temurin' + java-version: '17' + - name: Save JDK 17 Path + run: echo "JDK_17_HOME=$JAVA_HOME" >> "$GITHUB_ENV" + + - name: Set up Node 20 + uses: actions/setup-node@v6 + with: + node-version: '20' + + - name: Cache Playwright browsers + uses: actions/cache@v5 + with: + path: ~/.cache/ms-playwright + key: ${{ runner.os }}-playwright-chromium-${{ hashFiles('scripts/package-lock.json') }} + restore-keys: | + ${{ runner.os }}-playwright-chromium- + + - name: Install Playwright Chromium + run: | + cd scripts + npm ci + # Playwright shells out to apt for the OS-level packages, so the + # runner image's third-party sources are pruned first. + bash "$GITHUB_WORKSPACE/scripts/ci/apt-get-update.sh" + npx playwright install-deps chromium + npx playwright install chromium + + # The JavaScript build is the plugin's own: the port and the translator + # travel inside it, so installing the plugin with what it is built from + # is all the browser build needs. + - name: Install the framework, the backend runtime and the plugin + working-directory: maven + run: | + bash "$GITHUB_WORKSPACE/scripts/ci/retry.sh" mvn -B \ + -pl core,javase,css-compiler,codenameone-maven-plugin,backend,backend-test \ + -am install -DskipTests -Plocal-dev-javase \ + -Dspotbugs.skip=true -Dpmd.skip=true -Dcheckstyle.skip=true \ + -Dmaven.javadoc.skip=true + env: + JAVA_HOME: ${{ env.JDK_8_HOME }} + RETRY_ONLY_MATCHING: transient + + # Builds the server and the browser build of the app, starts the server + # with the app staged where it serves it from, and signs in through the + # real screens. + - name: Run the hosted web app in a headless browser + run: scripts/savor/run-web-e2e.sh + env: + JAVA_HOME: ${{ env.JDK_17_HOME }} + JDK_8_HOME: ${{ env.JDK_8_HOME }} + + - name: Upload the screenshots and the logs + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v7 + with: + name: savor-web-e2e + path: scripts/savor/target/web-e2e/** + if-no-files-found: warn + retention-days: 14 + + android: + name: Android build + runs-on: ubuntu-24.04 + timeout-minutes: 60 + env: + CN1_APP_DIR: scripts/savor + steps: + - uses: actions/checkout@v6 + - name: Set TMPDIR + run: echo "TMPDIR=${{ runner.temp }}" >> "$GITHUB_ENV" + - name: Cache codenameone-tools + uses: actions/cache@v5 + with: + path: ${{ runner.temp }}/codenameone-tools + key: ${{ runner.os }}-cn1-tools-${{ hashFiles('scripts/setup-workspace.sh') }} + restore-keys: | + ${{ runner.os }}-cn1-tools- + - name: Cache Maven repository + uses: actions/cache@v5 + with: + path: ~/.m2/repository + key: ${{ runner.os }}-m2-savor-${{ hashFiles('**/pom.xml') }} + restore-keys: | + ${{ runner.os }}-m2-savor- + ${{ runner.os }}-m2- + - name: Cache Gradle + uses: actions/cache@v5 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-savor-${{ hashFiles('Ports/Android/build.gradle*') }} + restore-keys: | + ${{ runner.os }}-gradle-savor- + ${{ runner.os }}-gradle- + - name: Setup workspace + run: ./scripts/setup-workspace.sh -q -DskipTests + - name: Build Android port + run: ./scripts/build-android-port.sh -q -DskipTests + - name: Build the Android application + run: | + mkdir -p ~/.codenameone + cp maven/UpdateCodenameOne.jar ~/.codenameone/ + ./scripts/build-android-app.sh -q -DskipTests + + build-ios-port: + # The reusable workflow scripts-ios.yml depends on; sharing it shares its cache. + uses: ./.github/workflows/_build-ios-port.yml + + ios: + name: iOS build (simulator) + needs: build-ios-port + runs-on: macos-15 + timeout-minutes: 50 + env: + CN1_APP_DIR: scripts/savor + steps: + - uses: actions/checkout@v6 + + - name: Cache CocoaPods and user gems + uses: actions/cache@v5 + with: + path: | + ~/.gem + ~/Library/Caches/CocoaPods + ~/.cocoapods/repos + key: ${{ runner.os }}-pods-v1-${{ hashFiles('scripts/setup-workspace.sh') }} + restore-keys: | + ${{ runner.os }}-pods-v1- + + - name: Ensure CocoaPods tooling + run: | + mkdir -p ~/.codenameone + cp maven/UpdateCodenameOne.jar ~/.codenameone/ + set -euo pipefail + GEM_USER_DIR="$(ruby -e 'print Gem.user_dir')" + export PATH="$GEM_USER_DIR/bin:$PATH" + if ! command -v pod >/dev/null 2>&1; then + gem install cocoapods xcodeproj --no-document --user-install + fi + pod --version + + - name: Compute setup-workspace hash + id: setup_hash + run: echo "hash=$(shasum -a 256 scripts/setup-workspace.sh | awk '{print $1}')" >> "$GITHUB_OUTPUT" + + - name: Set TMPDIR + run: echo "TMPDIR=${{ runner.temp }}" >> "$GITHUB_ENV" + + - name: Cache codenameone-tools + uses: actions/cache@v5 + with: + path: ${{ runner.temp }}/codenameone-tools + key: ${{ runner.os }}-cn1-tools-${{ steps.setup_hash.outputs.hash }} + restore-keys: | + ${{ runner.os }}-cn1-tools- + + - name: Cache Maven repository + uses: actions/cache@v5 + with: + path: ~/.m2/repository + key: ${{ runner.os }}-m2-${{ hashFiles('**/pom.xml') }} + restore-keys: | + ${{ runner.os }}-m2- + + - name: Restore cn1-binaries cache + uses: actions/cache@v5 + with: + path: ../cn1-binaries + key: cn1-binaries-${{ runner.os }}-${{ steps.setup_hash.outputs.hash }} + restore-keys: | + cn1-binaries-${{ runner.os }}- + + - name: Download built CN1 + iOS port bundle + uses: actions/download-artifact@v4 + with: + name: cn1-built-bundle + path: ${{ runner.temp }} + - name: Restore built CN1 + iOS port tree + run: | + set -euo pipefail + tar -C / -xf "$RUNNER_TEMP/cn1-built-bundle.tar" + + - name: Install XcodeGen + run: brew install xcodegen + + - name: Generate the Xcode project + id: build-ios-app + run: ./scripts/build-ios-app.sh -q -DskipTests + timeout-minutes: 25 + + - name: Build the application for the simulator + run: | + set -euo pipefail + WORKSPACE='${{ steps.build-ios-app.outputs.workspace }}' + SCHEME='${{ steps.build-ios-app.outputs.scheme }}' + DERIVED_DATA="${{ runner.temp }}/savor-dd" + rm -rf "$DERIVED_DATA" + # One architecture, the host's: the port's SIMD natives are NEON + # under __aarch64__, and a universal simulator build compiles an + # x86_64 slice that has no use here. + HOST_ARCH="$(uname -m)" + case "$HOST_ARCH" in + arm64|x86_64) BUILD_ARCH="$HOST_ARCH" ;; + *) BUILD_ARCH=arm64 ;; + esac + XCB_CONTAINER_FLAG="-workspace" + if [[ "$WORKSPACE" != *.xcworkspace ]]; then + XCB_CONTAINER_FLAG="-project" + fi + xcodebuild "$XCB_CONTAINER_FLAG" "$WORKSPACE" -scheme "$SCHEME" \ + -sdk iphonesimulator \ + -configuration Debug \ + -destination 'generic/platform=iOS Simulator' \ + -derivedDataPath "$DERIVED_DATA" \ + ARCHS="$BUILD_ARCH" ONLY_ACTIVE_ARCH=YES \ + EXCLUDED_ARCHS="armv7 armv7s" \ + CODE_SIGNING_ALLOWED=NO build + APP_BUNDLE="$(find "$DERIVED_DATA/Build/Products" -maxdepth 3 -type d -name "${SCHEME}.app" | head -n 1)" + if [ -z "$APP_BUNDLE" ]; then + echo "::error::no .app bundle under $DERIVED_DATA" + exit 1 + fi + timeout-minutes: 20 diff --git a/.github/workflows/wayline.yml b/.github/workflows/wayline.yml index 2eef7f25286..b5b7be04c5c 100644 --- a/.github/workflows/wayline.yml +++ b/.github/workflows/wayline.yml @@ -40,6 +40,8 @@ on: - 'CodenameOne/src/com/codename1/maps/**' - 'CodenameOne/src/com/codename1/io/oidc/**' - 'CodenameOne/src/com/codename1/io/rest/**' + # The REST client maps its responses through the mapper registry. + - 'CodenameOne/src/com/codename1/mapping/**' # What the hosted web app runs on: the JavaScript port, both the # browser side and the Java the app is compiled with (its network # connection, its implementation class). The `web` job is the only @@ -47,7 +49,7 @@ on: - 'Ports/JavaScriptPort/src/main/**' - 'scripts/build-android-app.sh' - 'scripts/build-ios-app.sh' - - 'scripts/sync-initializr-wayline.py' + - 'scripts/sync-initializr-template.py' - 'scripts/tests/generate-initializr-fixtures.py' - 'scripts/tests/GenerateInitializr.java' # The generator, the template, and the generator's own tests, which this @@ -61,7 +63,7 @@ on: - 'vm/backend/src/**' - 'maven/backend/**' - 'maven/backend-test/**' - - 'scripts/sync-initializr-wayline.py' + - 'scripts/sync-initializr-template.py' - 'scripts/initializr/common/src/**' - '.github/workflows/wayline.yml' workflow_dispatch: @@ -88,7 +90,7 @@ jobs: # A change to one without the other is a template that is not the project # this workflow tests. - name: Compare the template with scripts/wayline - run: python3 scripts/sync-initializr-wayline.py + run: python3 scripts/sync-initializr-template.py wayline backend: name: Server tests (JVM and compiled) diff --git a/.github/workflows/website-docs.yml b/.github/workflows/website-docs.yml index 9978768ed55..337128c8ad3 100644 --- a/.github/workflows/website-docs.yml +++ b/.github/workflows/website-docs.yml @@ -63,6 +63,7 @@ on: # tag, so a merged change there changes the published guide. Not on pull # requests: developer-guide-docs.yml already renders the guide for those. - 'scripts/wayline/**' + - 'scripts/savor/**' - 'scripts/website/**' - 'scripts/skindesigner/**' - 'Ports/JavaScriptPort/**' diff --git a/CodenameOne/src/com/codename1/io/rest/RequestBuilder.java b/CodenameOne/src/com/codename1/io/rest/RequestBuilder.java index 275eacc27d7..2d75f4d1053 100644 --- a/CodenameOne/src/com/codename1/io/rest/RequestBuilder.java +++ b/CodenameOne/src/com/codename1/io/rest/RequestBuilder.java @@ -799,10 +799,10 @@ public ConnectionRequest fetchAsJsonList(final OnComplete> callba /// `process-annotations` mojo from the `@Mapped` annotation and lives /// in `.generated.Mapper`. /// - /// If the type has no registered mapper at runtime, the listener - /// completes with `null` data and a non-200 response code is *not* - /// synthesised -- inspect `response.getResponseCode()` to differentiate - /// "server returned an error" from "no mapper registered". + /// If the type has no registered mapper at runtime the request fails: the + /// `#onError(com.codename1.ui.events.ActionListener)` listeners receive the + /// `IllegalStateException`, or, when none is registered, the callback + /// completes with response code -1, `null` data and the exception's message. /// /// #### Parameters /// @@ -814,7 +814,7 @@ public ConnectionRequest fetchAsJsonList(final OnComplete> callba /// the Connection Request object so it can be killed if necessary public ConnectionRequest fetchAsMapped(final Class type, final OnComplete> callback) { final Connection request = createRequest(true); - request.addResponseListener(new FetchAsMappedActionListener(request, type, callback)); + request.addResponseListener(new FetchAsMappedActionListener(request, type, callback, errorCallbacks)); fetched = true; CN.addToQueue(request); return request; @@ -847,7 +847,7 @@ public ConnectionRequest fetchAsMapped(final Class type, final OnComplete /// the Connection Request object so it can be killed if necessary public ConnectionRequest fetchAsMappedList(final Class type, final OnComplete>> callback) { final Connection request = createRequest(true); - request.addResponseListener(new FetchAsMappedListActionListener(request, type, callback)); + request.addResponseListener(new FetchAsMappedListActionListener(request, type, callback, errorCallbacks)); fetched = true; CN.addToQueue(request); return request; @@ -1350,18 +1350,21 @@ public void actionPerformed(NetworkEvent evt) { } /// Routes a JSON-object response through the build-time POJO mapping - /// framework. Returns a `null` typed result when no mapper is registered - /// for `type` (typical cause: forgot `@Mapped` on the class, or the - /// process-annotations Mojo didn't run). + /// framework. A type with no registered mapper (typical cause: forgot + /// `@Mapped` on the class, or the process-annotations Mojo didn't run) + /// fails the request, whatever the body. private static class FetchAsMappedActionListener implements ActionListener { private final Connection request; private final Class type; private final OnComplete> callback; + private final List> errorListeners; - public FetchAsMappedActionListener(Connection request, Class type, OnComplete> callback) { + public FetchAsMappedActionListener(Connection request, Class type, OnComplete> callback, + List> errorListeners) { this.request = request; this.type = type; this.callback = callback; + this.errorListeners = new ArrayList>(errorListeners); } @Override @@ -1370,20 +1373,43 @@ public void actionPerformed(NetworkEvent evt) { if (request.errorCode) { return; } - Map parsed = (Map) evt.getMetaData(); - T result = null; - if (parsed != null) { - com.codename1.mapping.Mapper m = - com.codename1.mapping.Mappers.get(type); - if (m != null) { - result = m.fromMap(parsed); - } + // Looked up before the body is: a missing mapper fails the request + // even when the answer is null or empty, which would otherwise hide it. + com.codename1.mapping.Mapper m = requireMapper(request, type, errorListeners, callback); + if (m == null) { + return; } + Map parsed = (Map) evt.getMetaData(); + T result = parsed == null ? null : m.fromMap(parsed); Response res = new Response(evt.getResponseCode(), result, evt.getMessage()); callback.completed(res); } } + /// Looks up the mapper for `type`. A missing one is a programming error, + /// but thrown from a response listener it would only reach the network + /// thread's log while the caller waited forever, so it is delivered as a + /// failure instead: to the error listeners when there are any, otherwise + /// as a completed response with code -1 and `null` data. Answers `null` + /// once the failure has been delivered. + @SuppressWarnings({"unchecked", "rawtypes"}) + private static com.codename1.mapping.Mapper requireMapper(Connection request, Class type, + List> errorListeners, OnComplete callback) { + try { + return com.codename1.mapping.Mappers.require(type); + } catch (IllegalStateException err) { + if (errorListeners.isEmpty()) { + callback.completed(new Response(-1, null, err.getMessage())); + } else { + NetworkEvent evt = new NetworkEvent(request, err); + for (ActionListener l : errorListeners) { + l.actionPerformed(evt); + } + } + return null; + } + } + /// List-typed mapped variant. Pulls the array out of the top-level /// JSON envelope (same as `FetchAsJsonListActionListener`), then maps /// each element through the registered mapper for `type`. @@ -1391,11 +1417,14 @@ private static class FetchAsMappedListActionListener implements ActionListene private final Connection request; private final Class type; private final OnComplete>> callback; + private final List> errorListeners; - public FetchAsMappedListActionListener(Connection request, Class type, OnComplete>> callback) { + public FetchAsMappedListActionListener(Connection request, Class type, OnComplete>> callback, + List> errorListeners) { this.request = request; this.type = type; this.callback = callback; + this.errorListeners = new ArrayList>(errorListeners); } @Override @@ -1404,6 +1433,11 @@ public void actionPerformed(NetworkEvent evt) { if (request.errorCode) { return; } + // As in FetchAsMappedActionListener: the mapper first, whatever the body. + com.codename1.mapping.Mapper m = requireMapper(request, type, errorListeners, callback); + if (m == null) { + return; + } Map parsed = (Map) evt.getMetaData(); List raw = null; if (parsed != null && parsed.get("root") instanceof List) { @@ -1413,17 +1447,11 @@ public void actionPerformed(NetworkEvent evt) { if (raw == null) { out = java.util.Collections.emptyList(); } else { - com.codename1.mapping.Mapper m = - com.codename1.mapping.Mappers.get(type); - if (m == null) { - out = java.util.Collections.emptyList(); - } else { - out = new ArrayList(raw.size()); - for (Object e : raw) { - if (e instanceof Map) { - Map mm = (Map) e; - out.add(m.fromMap(mm)); - } + out = new ArrayList(raw.size()); + for (Object e : raw) { + if (e instanceof Map) { + Map mm = (Map) e; + out.add(m.fromMap(mm)); } } } diff --git a/CodenameOne/src/com/codename1/mapping/Mappers.java b/CodenameOne/src/com/codename1/mapping/Mappers.java index 618da8bb786..ba2b35707d4 100644 --- a/CodenameOne/src/com/codename1/mapping/Mappers.java +++ b/CodenameOne/src/com/codename1/mapping/Mappers.java @@ -96,6 +96,21 @@ public static Mapper get(Class type) { return (Mapper) BY_NAME.get(type.getName()); } + /// The mapper for `type`, failing loudly when there is none. A response + /// mapped with no mapper would otherwise read as empty: a list with nothing + /// in it, an object that is null, and no hint that `@Mapped` is missing. + /// + /// #### Throws + /// + /// - `IllegalStateException`: when no mapper is registered for `type` + public static Mapper require(Class type) { + Mapper m = get(type); + if (m == null) { + throw missing(type); + } + return m; + } + /// Serializes `instance` to JSON. Throws `IllegalStateException` when /// no mapper is registered for its concrete class; that always points /// at a missing `@Mapped` annotation or a build that ran without the diff --git a/CodenameOne/src/com/codename1/ui/util/Resources.java b/CodenameOne/src/com/codename1/ui/util/Resources.java index 33c28d2c531..91075fa8a88 100644 --- a/CodenameOne/src/com/codename1/ui/util/Resources.java +++ b/CodenameOne/src/com/codename1/ui/util/Resources.java @@ -1443,6 +1443,15 @@ private Font systemFont(int face, int style, int size) { return f; } + /// Whether a theme font that names a TrueType font is loaded as one. A + /// resource editor overrides it: it keeps such a font as written even where + /// the platform it runs on cannot draw TrueType, or a theme it loads and + /// saves again would come out with every such font turned into its system + /// fallback. + boolean isTrueTypeFileSupported() { + return Font.isTrueTypeFileSupported(); + } + Font createTrueTypeFont(Font f, String fontName, String fileName, float fontSize, int sizeSetting) { switch (sizeSetting) { case 0: // small @@ -1773,7 +1782,7 @@ Hashtable loadTheme(String id, boolean newerVersion) throws IOException { String fontName = input.readUTF(); int sizeSetting = input.readInt(); float fontSize = input.readFloat(); - if (Font.isTrueTypeFileSupported()) { + if (isTrueTypeFileSupported()) { fontKeys.add(key); f = createTrueTypeFont(f, fontName, fileName, fontSize, sizeSetting); } diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java index d8cbd46d321..2f62aca21ef 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java @@ -14662,10 +14662,16 @@ private static boolean endsWithIgnoreCase(String value, String suffix) { } private java.awt.Font desktopNativeFont(String alias) { - String family = fontFaceSystem; // FACE_SYSTEM and native aliases share the installed family resolved at // theme selection. Windows Java may expose "Segoe UI Variable" without // the preferred "Text" suffix; retaining that missing name gives Dialog. + return weightedNativeFont(fontFaceSystem, alias); + } + + /// The installed `family` at the weight and posture `alias` names + /// (`native:MainBold`, `native:ItalicLight`...). The font is remembered as + /// an alias font, so deriving it at another size keeps those attributes. + private java.awt.Font weightedNativeFont(String family, String alias) { String variant = alias.substring("native:".length()); boolean italic = variant.startsWith("Italic"); if (!italic && !variant.startsWith("Main")) { @@ -14702,8 +14708,10 @@ public Object loadTrueTypeFont(String fontName, String fileName) { if(isIOS) { String nn = nativeFontName(fontName); if (nn != null) { - java.awt.Font nf = new java.awt.Font(nn, java.awt.Font.PLAIN, medianFontSize); - return nf; + // The weight is part of the alias. Created PLAIN, every + // native:MainBold under an iOS skin drew at the regular + // weight wherever SF or Helvetica Neue is installed. + return weightedNativeFont(nn, fontName); } } String res; diff --git a/docs/demos/common/src/main/snippets/developer-guide/savor.sh b/docs/demos/common/src/main/snippets/developer-guide/savor.sh new file mode 100644 index 00000000000..4d073da7aba --- /dev/null +++ b/docs/demos/common/src/main/snippets/developer-guide/savor.sh @@ -0,0 +1,16 @@ +// Snippets of the Savor chapters of the developer guide. Edit them here, not inline. + +// tag::savor-sh-server[] +CN1_PROFILE=dev backend/server.sh run +// end::savor-sh-server[] + +// tag::savor-sh-simulator[] +mvn verify -Psimulator -DskipTests -Dcodename1.platform=javase +// end::savor-sh-simulator[] + +// tag::savor-sh-tests[] +mvn -pl shared,backend -Dcodename1.platform=backend test +mvn -pl shared,backend -Dcodename1.platform=backend test -Dcn1.backend.compiledTests=true +./run-e2e.sh +./run-web-e2e.sh +// end::savor-sh-tests[] diff --git a/docs/developer-guide/Savor-Implementors-Guide.asciidoc b/docs/developer-guide/Savor-Implementors-Guide.asciidoc new file mode 100644 index 00000000000..63a1434c629 --- /dev/null +++ b/docs/developer-guide/Savor-Implementors-Guide.asciidoc @@ -0,0 +1,363 @@ +[[savor-implementors-guide]] +== Savor: Building on the sample + +Savor began as a copy of <> and kept most of +what isn't about rides: the module layout, the accounts and sign-in, the cards and +the payment provider, text messages, push notifications, the live channel, the map, +the operations console and hosting the app in a browser. This chapter covers what a +food-delivery service changes. For the parts the two share, it points at the Wayline +chapter, which is true of Savor with the names swapped. + +[[savor-launch]] +=== Running it + +The project is in `scripts/savor`, and the Initializr generates it under your own +package and name. Start the server on the development profile, which seeds the demo +accounts and restaurants and simulates the cards, the kitchens and a courier: + +[source,bash] +---- +include::../demos/common/src/main/snippets/developer-guide/savor.sh[tag=savor-sh-server,indent=0] +---- + +Then run the app in the simulator, from the project's directory: + +[source,bash] +---- +include::../demos/common/src/main/snippets/developer-guide/savor.sh[tag=savor-sh-simulator,indent=0] +---- + +`backend/server.sh run --web` builds the app for a browser as well and serves it at +`http://localhost:8080/`. Running on a device, and the compiled server, work as +<> describes. + +=== The modules + +The three modules are Wayline's: `shared` holds the contract, the data transfer +objects and the `@RestClient` interfaces both sides compile against; `common` is +the app; `backend` is the server. The backend's packages follow the domain: + +[cols="1,3"] +|=== +| Package | What it holds + +| `catalog` +| Restaurants, their menus and photographs, the search that lists the restaurants + that deliver to a point, and applications to list a restaurant. + +| `order` +| Carts priced into quotes, orders and their state machine, promo codes, saved + addresses, the order history and the simulated kitchen. + +| `delivery` +| Deliveries and their state machine, offers to couriers and the simulated + courier. + +| `courier`, `onboarding` +| Couriers' shifts and earnings, and applications to deliver with their documents. + Wayline's driver code under new names. + +| `pay`, `sms`, `push`, `live`, `account`, `geo`, `admin` +| Taken from Wayline and adapted where an order differs from a ride. +|=== + +=== An order and its delivery + +A ride is one thing that moves from requested to finished. An order is two. The +order is what the customer bought and the kitchen cooks, the delivery is what a +courier carries, and each has its own states: + +[cols="1,3"] +|=== +| Order | Delivery + +| `PLACED`, then `ACCEPTED` or `REJECTED` by the kitchen, `READY`, `PICKED_UP`, + `DELIVERED`; or cancelled by the customer or an administrator, or `NO_COURIERS` +| `REQUESTED`, `OFFERED` to a courier, `ACCEPTED`, `ARRIVED` at the restaurant, + `IN_PROGRESS` to the door, `COMPLETED`; or cancelled, or `NO_COURIERS` +|=== + +The delivery is Wayline's ride, almost unchanged: it picks up at the restaurant and +drops off at the door. Keeping it that way let the whole courier side -- offers, +shifts, the moving marker, earnings, the guards on two answers at once -- come +across without being rewritten. + +What couples the two is when a delivery starts. A customer places an order, and +the card is held: + +[source,java] +---- +include::../../scripts/savor/backend/src/main/java/com/codenameone/examples/savor/order/Orders.java[tag=savor-place,indent=0] +---- + +No courier is looked for yet. A courier sent to a restaurant that then turns the +order down has wasted a trip, so the delivery is created when the kitchen accepts, +in the same transaction: + +[source,java] +---- +include::../../scripts/savor/backend/src/main/java/com/codenameone/examples/savor/order/Orders.java[tag=savor-accept,indent=0] +---- + +[source,java] +---- +include::../../scripts/savor/backend/src/main/java/com/codenameone/examples/savor/delivery/Deliveries.java[tag=savor-dispatch,indent=0] +---- + +The delivery shares the order's id, so either side can find the other without a +join table. From then on the couriers are asked while the kitchen cooks, and the +order follows its delivery: the courier collecting the food moves the order to +`PICKED_UP`, and only from `READY`, so nobody leaves with an order the kitchen has +not finished; the delivery completing moves the order to `DELIVERED`. A delivery +that finds no courier ends the order with `NO_COURIERS` and lets the hold go. + +Every move of either state is a conditional update that names the states it may +come from, so two answers at the same moment can't both win -- a customer +cancelling while the kitchen accepts, or two couriers taking one offer. It's the +pattern <> explains: + +[source,java] +---- +include::../../scripts/savor/backend/src/main/java/com/codenameone/examples/savor/delivery/Deliveries.java[tag=savor-delivery-trymove,indent=0] +---- + +=== Hold now, charge on arrival + +A rider pays at the end of a ride, for a fare known by then. A customer pays for +food before anyone cooks it, but shouldn't be charged for an order that never +arrives. The card is therefore authorized for the total when the order is placed and +captured when it's delivered: + +[source,java] +---- +include::../../scripts/savor/backend/src/main/java/com/codenameone/examples/savor/pay/Payments.java[tag=savor-authorize,indent=0] +---- + +The provider interface Wayline's billing chapter describes gained the two calls +that make it possible, `authorize` and `capture`, and a `release` for a hold that +is let go: + +[source,java] +---- +include::../../scripts/savor/backend/src/main/java/com/codenameone/examples/savor/pay/PaymentProvider.java[tag=savor-payment-provider,indent=0] +---- + +* **Placed:** `Payments.authorize` holds the total. A refused card answers 402 and + nothing is written. An order that couldn't be written lets its hold go at once. +* **Rejected or cancelled:** `Payments.release` lets the hold go. Nothing was taken, + so there is nothing to refund. +* **Delivered:** `Payments.settle` captures the hold. A capture can still fail -- a + hold expires, or a bank declines then -- and the receipt then says so, for an + administrator to follow up. +* **After delivery:** a tip is a charge of its own; a refund, full or partial, is + made against the captured payment. + +The receipt is kept with the delivery. Before a delivery exists, a customer's +receipt is built from the order itself and shows the hold. + +With Stripe, a hold is a PaymentIntent created with manual capture. Card holds +expire after about seven days, far longer than any meal takes. Turning Stripe on, +and the hosted page that keeps a card number away from the server, are exactly as +<> describes. + +=== Restaurants near the door + +A rider is matched to the nearest driver. A customer chooses a restaurant, from the +ones that deliver to their door. The search takes a box around the point from the +database and then measures each restaurant in it, as Wayline does to find the +drivers near a pickup: + +[source,java] +---- +include::../../scripts/savor/backend/src/main/java/com/codenameone/examples/savor/catalog/Catalog.java[tag=savor-nearby,indent=0] +---- + +Couriers are found the same way, near the restaurant rather than the customer, +because the restaurant is where they go first. + +=== Prices + +`order/Quotes` prices a cart, and does so on the server alone: the app shows the +quote it's given and sends only the dishes, their choices, the promo code and the +tip. The total is: + +* The food, from the menu as it's when the order is placed, with each dish's + choices checked against its groups' minimum and maximum. +* The delivery fee, a base plus a price per kilometer by road from the restaurant, + times a surge an administrator can raise. +* A service fee, a percentage of the food. +* A flat small-order fee for food under a threshold. +* Tax, a percentage of the food after the discount. +* A promo code's discount, once per account. +* The tip. + +An administrator sets the fees, the commission and the tax under **Pricing**. The +commission is what the service keeps of the food; the merchant's sales show the rest. + +=== Merchants + +Applying to list a restaurant creates it waiting for approval, owned by the account +that asked: + +[source,java] +---- +include::../../scripts/savor/backend/src/main/java/com/codenameone/examples/savor/catalog/Restaurants.java[tag=savor-apply,indent=0] +---- + +Approving it grants the account the `merchant` role. Everything a merchant does is +under `/api/merchant/restaurants/{id}/...`, and each request checks that the account +is a merchant of that restaurant, so one restaurant's staff can't read another's +orders. The rules are in the same table as the rest of the server's: + +[source,java] +---- +include::../../scripts/savor/backend/src/main/java/com/codenameone/examples/savor/SecurityConfig.java[tag=savor-api-rules,indent=0] +---- + +The merchant's screen is built on the operations console's `Console`, so it's a +list beside a detail on a wide screen and a list that opens a screen per order on a +phone. A new order reaches it as a frame on the live channel, which makes it read +the queue again: the frames say what changed, never the data, as in Wayline. + +Menu photographs are uploaded the way couriers' documents are, stored by the server +and served from it, so the seed data carries its own photographs rather than +pointing at someone else's site. + +=== Simulated kitchens and couriers + +A development server simulates the two people a customer would otherwise wait for. +`order/DemoKitchen` accepts, cooks and readies the orders of each restaurant that has +no merchant, and `delivery/DemoFleet` takes deliveries while no real courier +is online. Both are on only for the development profile, the way the simulated cards +are: the test profile turns them off, because the tests play the kitchen and the +courier themselves. A restaurant with a merchant is left to its staff, and the +simulated courier stands aside while a real one is online. + +=== Tests + +[source,bash] +---- +include::../demos/common/src/main/snippets/developer-guide/savor.sh[tag=savor-sh-tests,indent=0] +---- + +The backend tests run the server on the JVM and then compiled, against the order +and delivery state machines, the holds and captures, the quotes and promo codes and +who may call what. `run-e2e.sh` starts a server on the test profile and drives the +app in the simulator through every role -- an order placed, accepted, collected and +delivered, a card held and charged, a tip and a refund -- taking the screenshots in +the user's guide. `run-web-e2e.sh` does the same for the app in a browser. + +=== Making it yours + +The ways to make Wayline your own apply here too: the name, icon and colors, the +languages, the text-message and push providers, the database, hosting. A few apply +to food in particular: + +* **One restaurant or a small chain.** Ordering and delivery under the restaurant's + own name, which <> describes. +* **Pickup.** An order with no delivery is one the kitchen hands over itself. Mark + the order as collected, skip `dispatch` when the kitchen accepts it, and let the + merchant move it from `READY` to `DELIVERED`, which captures the hold. +* **Scheduled orders.** Store the time the customer wants the food, and accept the + order for the kitchen only when its preparation time before that's reached. +* **Before it's a product.** A real service needs restaurant payouts, tax rules for + the places it sells in, menus that change by the hour, allergens and age checks. + None of them are in the sample. + +[[savor-own-restaurant]] +=== Making it yours: A restaurant's own delivery service + +A marketplace takes a share of every order and keeps the customer. A restaurant, +or a chain of a few, that runs its own ordering and delivery keeps both: the app +carries its name, the customers' accounts and order history are its own, and the +couriers are its staff or a courier company it contracts. Savor is most of that +service already. What changes is that there is one brand instead of many, and +nobody applies to sell. This section says what to change, and the class each +change belongs in. + +==== One brand, one or a few kitchens + +A chain is a restaurant row per branch, each with its own address, hours, +preparation time and menu. Seed them in place of `catalog/DemoRestaurants`, or +create them through the admin console. Nothing else in the catalog changes: +`Catalog.nearby` already answers the branches that deliver to the customer's +door, nearest first, and the order goes to the branch the customer picked. + +The customer's first screen changes. With one kitchen, open its menu instead of +the list: where `Nav.build` makes the customer's home, call +`RestaurantForm.show` with the restaurant's id. With a few, keep the list, which +is now a list of branches, and drop the row of cuisines and the sort above it +in `CustomerForm`, which only make sense for many restaurants. + +The name, the icon and the colors are the ones <> and Wayline's +chapter describe. Use the restaurant's own photographs: replace the files in +`backend/photos`, which the development server reads into the database the first +time it starts, and name them in the seed. In production, upload them from the +merchant's menu editor. + +==== Nobody applies to sell + +Restaurants no longer join, so close that door: + +* Remove the restaurant applications from the server's rules and from + `Restaurants`: `apply`, `application` and `applicationPhoto`. The admin's + **Applications** pane then lists couriers only. +* Set the commission to zero under **Pricing**, or with + `savor.fee.commissionPercent=0`. The commission is what a marketplace keeps of + the food; a restaurant selling its own food keeps it all, and the merchant's + sales should say so. +* Make each branch's staff merchants of their branch from the admin console. A + merchant account works on one restaurant, the one `Restaurants.mine` finds. + An owner who watches several branches needs a branch picker on + `MerchantForm` and the restaurant's id in the merchant's calls in place of + `mine`. + +==== Couriers of its own + +A restaurant's couriers are known people, so the courier side works unchanged, +with the application step done by an admin. Approve couriers from +**Applications**, or give the role straight from **People**, and remove +**Deliver with Savor** from the customer's menu in `CustomerForm`, and the +courier choice from `RegisterForm`, if the public shouldn't apply. +Offers go to the nearest courier online, as before. With two or three couriers, +lengthen `savor.offer.seconds` and `savor.search.seconds`, because the one +courier nearby may be at a door. + +Many restaurants deliver only part of the time, or hand deliveries to a courier +company at busy hours. `Deliveries.dispatch` is the one place a delivery +starts: send it to the company's API there instead of offering it to couriers, +and move the delivery with the updates the company sends back. + +==== Pickup + +A customer who collects the order needs no courier. Mark the order as collected +at checkout, skip `dispatch` when the kitchen accepts it in `Orders.accept`, +and let the merchant move it from `READY` to `DELIVERED`, which captures the +hold the way a delivery does. + +==== Booking a table + +A restaurant that delivers usually takes bookings too, and the parts are all in +the sample. A booking isn't in it, so this is the outline: + +* **The record.** A `Reservation` entity -- restaurant, account, time, party + size, a note and a state of requested, confirmed, declined, seated or + cancelled -- in a new migration, with a state machine of conditional moves + like the order's, so a guest cancelling while the restaurant confirms can't + both win. +* **The contract.** A `@RestClient` interface in `shared` for the guest's calls + (the free times, book, cancel, their own bookings) and calls under + `/api/merchant/restaurants/{id}/...` for the staff, behind the same rules as + the merchant's other calls. +* **Free times.** Slots inside the restaurant's opening hours, each with the + number of covers the room takes, less the bookings already confirmed. +* **The staff's screen.** A tab beside the order queue in `MerchantForm`, + refreshed by a frame on the live channel when a booking arrives, as a new + order is. +* **Telling the guest.** A text message through `SmsSender` and a push through + the `push` package when a booking is confirmed, and a reminder before it. + +A deposit for a large party is a card hold, as an order is: authorize when the +booking is made, capture it for a no-show, and release it when the guest +arrives. + diff --git a/docs/developer-guide/Savor-Users-Guide.asciidoc b/docs/developer-guide/Savor-Users-Guide.asciidoc new file mode 100644 index 00000000000..6ccf73d443b --- /dev/null +++ b/docs/developer-guide/Savor-Users-Guide.asciidoc @@ -0,0 +1,359 @@ +[[savor-users-guide]] +== Savor: A food-delivery app from four sides + +Savor is a sample app with a server behind it. A customer browses the +restaurants that deliver to them, fills a cart and pays. The restaurant accepts +the order and cooks it, the nearest courier is offered the delivery, collects it +and brings it to the door while the customer follows it on a map, and an +administrator runs the service. The app and the server are one Maven project, +written in Java on both sides, and the Initializr's food-delivery template +generates it under your own name. + +This chapter is the user's guide: what each of the four kinds of user sees and +can do. <> is the other half. It explains how the +project is built and what it does differently from <>, +the ride-hailing sample it grew from. + +One app serves all four. The server gives every account a set of roles, and the +app opens on the screen that fits them: a customer on the restaurants, a merchant +on the restaurant's order queue, a courier on the delivery screen, an +administrator on the operations console. Someone who holds more than one role +switches between them from the menu. + +[cols="1,3"] +|=== +| Role | What it adds + +| Customer +| Every account is a customer. Choose where to deliver to, browse and search the + restaurants, build an order, pay, follow it to the door, rate it, tip and keep + the receipt. + +| Merchant +| Granted when an administrator approves a restaurant. Accept or reject the + orders that arrive, say how long the kitchen needs, mark them ready, pause the + restaurant, edit the menu and read the sales. + +| Courier +| Granted when an administrator approves an application to deliver. Go online, + accept or decline the deliveries offered, collect and deliver them, and see and + cash out what they earned. + +| Admin +| Granted by another administrator. See the service in numbers and charts, + approve restaurants and couriers, flag or block accounts, cancel and refund + orders, read the payments, and set the prices and the promo codes. +|=== + +=== Trying it + +Start the server on its development profile and it creates a set of accounts, all +with the password `savor-demo`, twelve restaurants with their menus, and two weeks +of orders so that the charts have something to draw. <> has the +commands. + +[cols="2,3"] +|=== +| Account | Role + +| `customer@savor.example`, `customer2@savor.example` +| Customers. + +| `customer3@savor.example` +| A customer an administrator has flagged. + +| `merchant@savor.example` +| Runs the kitchen of Ember & Crust, one of the twelve restaurants. + +| `courier@savor.example`, `courier2@savor.example` +| Approved couriers. + +| `courier3@savor.example`, `courier4@savor.example` +| Approved couriers who carry the two weeks of past orders. + +| `applicant@savor.example` +| Has applied to deliver and is waiting for a decision. + +| `admin@savor.example` +| Runs the service. +|=== + +A customer alone can watch an order come all the way. On the development profile +a simulated kitchen accepts and cooks the orders of every restaurant that has no +merchant, and a simulated courier takes the delivery while no real courier is +online. Sign in as the merchant and as a courier in other simulators to play those +parts yourself. + +No money moves on a development server, which simulates cards unless it's told to +use a payment provider. An order's total is held on the card when it's placed and +taken when the food arrives. Five numbers behave in five ways: + +[cols="2,3"] +|=== +| Card number | What happens + +| `4242 4242 4242 4242` +| Accepted, and every hold and charge succeeds. + +| `4000 0000 0000 0002` +| Declined when you try to add it. + +| `4000 0000 0000 0341` +| Accepted and held, then declined when the order arrives. + +| `4000 0000 0000 0119` +| Accepted and held, then taking the money fails without an answer, so the + order's payment stays pending. + +| `4000 0000 0000 7726` +| Accepted, and every charge succeeds. A refund is pending at first, and shows as + refunded about a minute later. +|=== + +Any expiry date in the future and any security code work with them. The codes +`WELCOME10` (10% off an order of $15 or more) and `SAVOR5` ($5 off an +order of $25 or more) work once per account. + +=== Getting in + +The welcome screen offers to sign in or to create an account. **Server address** +is there for a phone on the same network as a development server: `localhost` on a +phone is the phone, so the app has to be told where the server is. The app in a +browser has no such entry: it always talks to the server that hosts it. + +.The welcome screen +image::img/savor-welcome.png["The Savor welcome screen with buttons to sign in, to create an account and to set the server address",scaledwidth=40%] + +A new account asks for a name, an e-mail address, a mobile number and a password, +and whether the person also wants to deliver. The mobile number is verified with a +code sent as a text message before the first order, because the courier calls it +from the door. A development server prints the code on its console and the app +shows it. + +.Creating an account +image::img/savor-register.png["The form for a new account with fields for name, e-mail, mobile number and password",scaledwidth=40%] + +.Verifying the phone number +image::img/savor-verify-phone.png[The verification screen showing the code on screen because a development server sends no text message,scaledwidth=40%] + +The first sign-in on a device asks whether the app may share usage statistics. +The answer can be changed in the settings at any time. + +.The usage statistics question +image::img/savor-analytics-consent.png["The screen that asks whether to share usage statistics, with a button to share them and a button to decline",scaledwidth=40%] + +=== The customer + +The home screen starts with where the food goes. The address under **Deliver to** +is the device's position until another is chosen, and tapping it searches for a +street, a place or a saved address. Under a greeting that follows the time of day +are the cuisines, the order to list the restaurants in (nearest, top-rated, lowest +fee or fastest) and the restaurants themselves: the ones that deliver to the +address, each with its rating, its time to the door and its delivery fee. A +restaurant that's closed or paused says so and still shows its menu. + +.The home screen +image::img/savor-customer-home.png["The customer home screen: the address to deliver to, a greeting, a row of cuisines, the sort choices and the list of restaurants",scaledwidth=40%] + +.The home screen in the dark theme +image::img/savor-customer-home-dark.png[The customer home screen in the dark theme,scaledwidth=40%] + +**Search** finds restaurants by their name, their cuisine or a dish on their menu. +**Orders** lists what's on its way and what came before, with a button to order a +past one again, and **Favorites** keeps the restaurants marked with a heart. + +A restaurant's page shows its menu by section. A dish with choices -- a size, a +sauce, extra toppings -- opens on a page of its own, which says which choices are +required and how many of each may be picked, and adds the dish with a quantity. +The cart button at the bottom follows the order's total. A cart holds the dishes of +one restaurant: adding a dish from another asks first and starts again. + +The checkout lists the dishes, takes a note for the kitchen, the card or cash that +pays, a promo code and a tip for the courier, and shows the total as the server +prices it: the food, the delivery fee, the service fee, a small-order fee for a +small order, the tax, the discount and the tip. The tip chosen is +remembered for the next order. + +.The checkout +image::img/savor-checkout.png["The checkout with the dish, a note, the card that pays, a promo code field, the tip choices and the total broken into fees",scaledwidth=40%] + +**Place order** holds the total on the card and opens the order on a map. The +steps across the bottom follow it: waiting for the restaurant, being prepared, +ready, on its way and delivered. Until the restaurant accepts it the order can be +cancelled, and the hold on the card is let go. Once a courier has it the map shows +the courier's position and the time to the door, and the courier can be called or +texted. + +.Waiting for the restaurant +image::img/savor-order-placed.png["The order screen just after placing it: the restaurant and the door on a map, the step reading Waiting for the restaurant, and a button to cancel",scaledwidth=40%] + +.On its way +image::img/savor-order-on-its-way.png["The order screen while the courier rides to the door, with the courier on the map and the time left",scaledwidth=40%] + +When the food arrives the order asks for a rating and offers the receipt. The card +is charged then, for the total that was held. + +.Delivered +image::img/savor-order-delivered.png["The order screen once it's delivered, with five stars to rate it and a button for the receipt",scaledwidth=40%] + +==== Wallet and receipts + +The wallet holds the account's cards and cash. Tapping one makes it the default, +which the checkout starts from. A card is added on a form of its own, or on the +payment provider's page when the server uses one, so its number never reaches the +app or the server. + +.The wallet +image::img/savor-wallet.png["The wallet with one saved card marked as the default, cash, and a button to add a card",scaledwidth=40%] + +A receipt shows what was paid, part by part, and takes a tip for the courier +afterward, once. + +=== The merchant + +A merchant opens on their restaurant's queue. A new order arrives with a sound and +a vibration, without the screen being reloaded, and the queue keeps the orders +that still need the kitchen at the top. On a tablet or a desktop the order opens +beside the queue; on a phone it opens on a screen of its own. + +.The order queue +image::img/savor-merchant-queue.png["The restaurant's order queue with a new order at the top, its dishes summarized and a badge reading New",scaledwidth=40%] + +An order shows its dishes with their choices, the customer's note and the total of +the food. **Accept** takes it with the time the kitchen needs, which the customer +sees, and sends for a courier at once, so that one is on the way while the food +cooks. **Reject** asks for a reason, which the customer reads, and lets the hold on +the card go. **Mark ready** tells the courier to collect it: a courier can't take +an order the kitchen hasn't finished. + +.An order as the kitchen sees it +image::img/savor-merchant-order.png["An order on the merchant's screen with its dishes, the note No onions, a choice of preparation time and buttons to accept or reject it",scaledwidth=40%] + +The switch at the top pauses the restaurant: customers still see it, and can't +order. The menu editor adds and renames sections, adds dishes with a photograph, +a price and choices, and marks a dish sold out for the day. **Sales** totals what +the restaurant earned, after the service's commission. + +Anyone can ask for a restaurant to be listed, from the menu of the customer's home +screen. It waits, unseen by customers, until an administrator approves it, and the +account that asked then becomes its merchant. + +=== The courier + +Anyone can apply to deliver, from the menu of the customer's home screen. + +==== Applying to deliver + +The application asks for the courier's details, the vehicle, and five photographs: +an identity document, the driving license, a selfie, the vehicle registration and the +insurance. Each can be taken with the camera or picked from the gallery, and the +application can be left and finished later. + +.The documents of an application +image::img/savor-onboarding-documents.png["A step of the application to deliver, with a card for each document and buttons to retake a photo or pick one from the gallery",scaledwidth=40%] + +Once sent, it waits for an administrator. A rejection carries a reason, and the +applicant can correct the application and send it again. + +.Waiting for a decision +image::img/savor-onboarding-under-review.png["The screen an applicant sees while the application is under review, with the steps that follow",scaledwidth=40%] + +==== Delivering + +An approved courier opens on the delivery screen. **Go online** makes them one of +the couriers the server offers deliveries to: those near the restaurant, as soon as +its kitchen accepts an order. + +.Offline +image::img/savor-courier-offline.png["The delivery screen while offline: the day's earnings, a badge reading Offline and the button to go online",scaledwidth=40%] + +An offer shows what the courier is paid -- the delivery fee and the tip -- the +restaurant, the distance and a countdown. Declined or left to run out, it goes to +the next courier. + +.An offer +image::img/savor-courier-offer.png["The courier screen with a delivery request: the pay, a countdown, the restaurant and the door, and buttons to accept or decline",scaledwidth=40%] + +An accepted delivery is two legs. The first leads to the restaurant, where a button +tells the kitchen the courier is waiting. Collecting the food works once the kitchen +has marked the order ready, and the second leg leads to the door, with the customer's name, the address, the note and, for an order paid in +cash, the amount to collect. **Delivered** ends it. + +.On the way to the door +image::img/savor-courier-on-the-way.png["The courier screen on the second leg, with the route to the door and a button reading Delivered",scaledwidth=40%] + +==== Earnings + +The earnings screen shows the balance that can be cashed out, the totals for +today, this week and this month, and a chart of the last two weeks. + +.Earnings +image::img/savor-courier-earnings.png["The earnings screen with the balance ready to cash out, the totals for today, this week and this month, and a chart of the last two weeks",scaledwidth=40%] + +=== The administrator + +The operations console starts on a dashboard: what needs an answer -- restaurants +and couriers waiting for approval, flagged accounts -- above the service as it's +now, and charts of orders and their value over the last two weeks. + +.The dashboard +image::img/savor-admin-dashboard.png["The admin dashboard on a phone: alerts for applications to review above numbers describing the service right now",scaledwidth=40%] + +.The trends +image::img/savor-admin-trends.png["Two bar charts on the admin dashboard, one of orders per day and one of their value per day",scaledwidth=40%] + +**Restaurants** lists them by status: waiting, approved, suspended, or rejected. A +waiting one is approved or rejected with a reason; an approved one can be +suspended, which takes it off the customers' lists, and let back later. +**Couriers** holds the applications to deliver, each with its documents. + +.An application to deliver +image::img/savor-admin-application.png["An application to deliver as an administrator reads it, with the applicant's details and the vehicle",scaledwidth=40%] + +**People** finds any account. An account can be flagged or blocked with a reason +that's kept in its history, and a blocked account can neither sign in nor be +offered a delivery. + +.An account +image::img/savor-admin-user.png["An account as an administrator sees it, with its numbers, its roles, the moderation buttons and the history",scaledwidth=40%] + +**Orders** lists every order with its state. One that hasn't arrived can be +cancelled, which lets the hold go; one that has can be refunded in full or in part. +**Payments** lists each charge, **Pricing** sets the delivery fee and its price by +distance, the small-order fee, the service fee, the commission and the tax, and +**Promos** creates and ends promo codes. The live map shows the couriers who are +online. + +On a tablet or a desktop the console lays its sections down the left side and opens +what's picked beside its list. + +=== Settings + +The settings hold the account -- its name, phone number and password -- the cards, +the language, the theme, notifications and the usage statistics. The app is in +English, Spanish, French, German and Hebrew, and in Hebrew it's laid out from right +to left. + +NOTE: The translations are machine-generated samples, and no native speaker has +reviewed them. Have them checked before you ship the app in those languages. + +.The settings +image::img/savor-settings.png["The settings screen with the account's profile, verified phone number and password",scaledwidth=40%] + +.The settings in the dark theme +image::img/savor-settings-dark.png[The settings screen in the dark theme,scaledwidth=40%] + +.The settings in Hebrew +image::img/savor-settings-hebrew.png["The settings screen in Hebrew, laid out from right to left",scaledwidth=40%] + +.Usage statistics +image::img/savor-settings-analytics.png["The privacy section of the settings, with the switch for usage statistics",scaledwidth=40%] + +=== In a browser + +The server can host the app itself: open the server's address in a browser and the +same app loads there, signs in against that server and opens its live channel to +it. A browser gives a page less than a phone gives an app, and the app says so +where it matters. Calling and texting a courier are offered only where the device +can dial, and the restaurants listed are those near a default address when the +browser won't say where it is. diff --git a/docs/developer-guide/developer-guide.asciidoc b/docs/developer-guide/developer-guide.asciidoc index 20b762e1ae8..f4f4136baea 100644 --- a/docs/developer-guide/developer-guide.asciidoc +++ b/docs/developer-guide/developer-guide.asciidoc @@ -151,6 +151,10 @@ include::Wayline-Users-Guide.asciidoc[] include::Wayline-Implementors-Guide.asciidoc[] +include::Savor-Users-Guide.asciidoc[] + +include::Savor-Implementors-Guide.asciidoc[] + = Device and platform services include::Push-Notifications.asciidoc[] diff --git a/docs/developer-guide/img/savor-admin-application.png b/docs/developer-guide/img/savor-admin-application.png new file mode 100644 index 00000000000..1589eb95ff4 Binary files /dev/null and b/docs/developer-guide/img/savor-admin-application.png differ diff --git a/docs/developer-guide/img/savor-admin-dashboard.png b/docs/developer-guide/img/savor-admin-dashboard.png new file mode 100644 index 00000000000..1af2f517fc5 Binary files /dev/null and b/docs/developer-guide/img/savor-admin-dashboard.png differ diff --git a/docs/developer-guide/img/savor-admin-trends.png b/docs/developer-guide/img/savor-admin-trends.png new file mode 100644 index 00000000000..d9ff263e4ae Binary files /dev/null and b/docs/developer-guide/img/savor-admin-trends.png differ diff --git a/docs/developer-guide/img/savor-admin-user.png b/docs/developer-guide/img/savor-admin-user.png new file mode 100644 index 00000000000..e048c55867b Binary files /dev/null and b/docs/developer-guide/img/savor-admin-user.png differ diff --git a/docs/developer-guide/img/savor-analytics-consent.png b/docs/developer-guide/img/savor-analytics-consent.png new file mode 100644 index 00000000000..a485de6accc Binary files /dev/null and b/docs/developer-guide/img/savor-analytics-consent.png differ diff --git a/docs/developer-guide/img/savor-checkout.png b/docs/developer-guide/img/savor-checkout.png new file mode 100644 index 00000000000..f32e55e37b6 Binary files /dev/null and b/docs/developer-guide/img/savor-checkout.png differ diff --git a/docs/developer-guide/img/savor-courier-earnings.png b/docs/developer-guide/img/savor-courier-earnings.png new file mode 100644 index 00000000000..7d2634c69d3 Binary files /dev/null and b/docs/developer-guide/img/savor-courier-earnings.png differ diff --git a/docs/developer-guide/img/savor-courier-offer.png b/docs/developer-guide/img/savor-courier-offer.png new file mode 100644 index 00000000000..1c4078034f4 Binary files /dev/null and b/docs/developer-guide/img/savor-courier-offer.png differ diff --git a/docs/developer-guide/img/savor-courier-offline.png b/docs/developer-guide/img/savor-courier-offline.png new file mode 100644 index 00000000000..789cb97e3cd Binary files /dev/null and b/docs/developer-guide/img/savor-courier-offline.png differ diff --git a/docs/developer-guide/img/savor-courier-on-the-way.png b/docs/developer-guide/img/savor-courier-on-the-way.png new file mode 100644 index 00000000000..9bd94a1c1da Binary files /dev/null and b/docs/developer-guide/img/savor-courier-on-the-way.png differ diff --git a/docs/developer-guide/img/savor-customer-home-dark.png b/docs/developer-guide/img/savor-customer-home-dark.png new file mode 100644 index 00000000000..262acfd29dd Binary files /dev/null and b/docs/developer-guide/img/savor-customer-home-dark.png differ diff --git a/docs/developer-guide/img/savor-customer-home.png b/docs/developer-guide/img/savor-customer-home.png new file mode 100644 index 00000000000..75c05b269f0 Binary files /dev/null and b/docs/developer-guide/img/savor-customer-home.png differ diff --git a/docs/developer-guide/img/savor-merchant-order.png b/docs/developer-guide/img/savor-merchant-order.png new file mode 100644 index 00000000000..555b9f2b649 Binary files /dev/null and b/docs/developer-guide/img/savor-merchant-order.png differ diff --git a/docs/developer-guide/img/savor-merchant-queue.png b/docs/developer-guide/img/savor-merchant-queue.png new file mode 100644 index 00000000000..c0b1c752614 Binary files /dev/null and b/docs/developer-guide/img/savor-merchant-queue.png differ diff --git a/docs/developer-guide/img/savor-onboarding-documents.png b/docs/developer-guide/img/savor-onboarding-documents.png new file mode 100644 index 00000000000..3c768619127 Binary files /dev/null and b/docs/developer-guide/img/savor-onboarding-documents.png differ diff --git a/docs/developer-guide/img/savor-onboarding-under-review.png b/docs/developer-guide/img/savor-onboarding-under-review.png new file mode 100644 index 00000000000..4240118cc1d Binary files /dev/null and b/docs/developer-guide/img/savor-onboarding-under-review.png differ diff --git a/docs/developer-guide/img/savor-order-delivered.png b/docs/developer-guide/img/savor-order-delivered.png new file mode 100644 index 00000000000..386c63fb6e5 Binary files /dev/null and b/docs/developer-guide/img/savor-order-delivered.png differ diff --git a/docs/developer-guide/img/savor-order-on-its-way.png b/docs/developer-guide/img/savor-order-on-its-way.png new file mode 100644 index 00000000000..cc879278699 Binary files /dev/null and b/docs/developer-guide/img/savor-order-on-its-way.png differ diff --git a/docs/developer-guide/img/savor-order-placed.png b/docs/developer-guide/img/savor-order-placed.png new file mode 100644 index 00000000000..d8905f54868 Binary files /dev/null and b/docs/developer-guide/img/savor-order-placed.png differ diff --git a/docs/developer-guide/img/savor-register.png b/docs/developer-guide/img/savor-register.png new file mode 100644 index 00000000000..e1a67475e26 Binary files /dev/null and b/docs/developer-guide/img/savor-register.png differ diff --git a/docs/developer-guide/img/savor-settings-analytics.png b/docs/developer-guide/img/savor-settings-analytics.png new file mode 100644 index 00000000000..cba1c48fec9 Binary files /dev/null and b/docs/developer-guide/img/savor-settings-analytics.png differ diff --git a/docs/developer-guide/img/savor-settings-dark.png b/docs/developer-guide/img/savor-settings-dark.png new file mode 100644 index 00000000000..f1e7f240dad Binary files /dev/null and b/docs/developer-guide/img/savor-settings-dark.png differ diff --git a/docs/developer-guide/img/savor-settings-hebrew.png b/docs/developer-guide/img/savor-settings-hebrew.png new file mode 100644 index 00000000000..648ff08ec63 Binary files /dev/null and b/docs/developer-guide/img/savor-settings-hebrew.png differ diff --git a/docs/developer-guide/img/savor-settings.png b/docs/developer-guide/img/savor-settings.png new file mode 100644 index 00000000000..d7fa0294b85 Binary files /dev/null and b/docs/developer-guide/img/savor-settings.png differ diff --git a/docs/developer-guide/img/savor-verify-phone.png b/docs/developer-guide/img/savor-verify-phone.png new file mode 100644 index 00000000000..0d8c0335483 Binary files /dev/null and b/docs/developer-guide/img/savor-verify-phone.png differ diff --git a/docs/developer-guide/img/savor-wallet.png b/docs/developer-guide/img/savor-wallet.png new file mode 100644 index 00000000000..1c85586617c Binary files /dev/null and b/docs/developer-guide/img/savor-wallet.png differ diff --git a/docs/developer-guide/img/savor-welcome.png b/docs/developer-guide/img/savor-welcome.png new file mode 100644 index 00000000000..8b56b2b85a0 Binary files /dev/null and b/docs/developer-guide/img/savor-welcome.png differ diff --git a/docs/developer-guide/languagetool-accept.txt b/docs/developer-guide/languagetool-accept.txt index 367a628ba57..0607d079bff 100644 --- a/docs/developer-guide/languagetool-accept.txt +++ b/docs/developer-guide/languagetool-accept.txt @@ -806,4 +806,5 @@ inlining inlined # ----------------------------------------------------------------------------- # The sample application the Wayline chapters walk through. +Savor Wayline diff --git a/maven/core-unittests/src/test/java/com/codename1/io/rest/FetchAsMappedActionListenerTest.java b/maven/core-unittests/src/test/java/com/codename1/io/rest/FetchAsMappedActionListenerTest.java index 26b65ad4cb5..213675f2442 100644 --- a/maven/core-unittests/src/test/java/com/codename1/io/rest/FetchAsMappedActionListenerTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/io/rest/FetchAsMappedActionListenerTest.java @@ -67,13 +67,26 @@ void mapsJsonObjectThroughRegisteredMapper() { } @Test - void unregisteredTypeYieldsNullData() { + void unregisteredTypeNeverAnswersWithNull() { + // A missing @Mapped fails rather than answering null data. prepareJson(BASE_URL + "/unmapped", "{\"title\":\"Widget\"}"); Response response = fetch(BASE_URL + "/unmapped", Unmapped.class); - assertNotNull(response); - assertEquals(200, response.getResponseCode()); + assertNotNull(response, "the callback must still run so the caller does not hang"); + assertEquals(-1, response.getResponseCode()); assertNull(response.getResponseData()); + assertTrue(response.getResponseErrorMessage().contains("@Mapped"), response.getResponseErrorMessage()); + } + + @Test + void unregisteredTypeFailsEvenForANullBody() { + // The mapper is asked for before the body, so a JSON null cannot hide it. + prepareJson(BASE_URL + "/unmapped-null", "null"); + + Response response = fetch(BASE_URL + "/unmapped-null", Unmapped.class); + assertNotNull(response, "the callback must still run so the caller does not hang"); + assertEquals(-1, response.getResponseCode()); + assertTrue(response.getResponseErrorMessage().contains("@Mapped"), response.getResponseErrorMessage()); } private Response fetch(String url, Class type) { diff --git a/maven/core-unittests/src/test/java/com/codename1/io/rest/FetchAsMappedListTest.java b/maven/core-unittests/src/test/java/com/codename1/io/rest/FetchAsMappedListTest.java index 1da64ff974a..81c3d686041 100644 --- a/maven/core-unittests/src/test/java/com/codename1/io/rest/FetchAsMappedListTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/io/rest/FetchAsMappedListTest.java @@ -71,12 +71,16 @@ void mapsJsonArrayThroughRegisteredMapper() { } @Test - void unregisteredTypeYieldsEmptyList() { + void unregisteredTypeNeverAnswersWithAnEmptyList() { + // A missing @Mapped used to answer an empty list, which a screen shows + // as "nothing here" with no hint of the cause. It fails instead. prepareJson(BASE_URL + "/unmapped", "[{\"title\":\"A\"}]"); Response> response = fetch(BASE_URL + "/unmapped", Unmapped.class); - assertNotNull(response); - assertTrue(response.getResponseData().isEmpty()); + assertNotNull(response, "the callback must still run so the caller does not hang"); + assertEquals(-1, response.getResponseCode()); + assertNull(response.getResponseData()); + assertTrue(response.getResponseErrorMessage().contains("@Mapped"), response.getResponseErrorMessage()); } @Test diff --git a/maven/css-cli/src/test/java/com/codename1/designer/css/CN1CSSCLIEndToEndTest.java b/maven/css-cli/src/test/java/com/codename1/designer/css/CN1CSSCLIEndToEndTest.java index 84c29354208..2021db8a7a6 100644 --- a/maven/css-cli/src/test/java/com/codename1/designer/css/CN1CSSCLIEndToEndTest.java +++ b/maven/css-cli/src/test/java/com/codename1/designer/css/CN1CSSCLIEndToEndTest.java @@ -418,6 +418,48 @@ void anEditedImageIsRepaintedIntoARuleWhoseCssDidNotChange(@TempDir Path tmp) th assertEquals(0xff0000ff, after[after.length / 2], "the rule was painted again from the new image"); } + /// Whether the theme font stored under `key` still names its TrueType + /// font. Read from the bytes, not through [Resources]: this headless JVM + /// cannot draw TrueType, so the runtime loader hands back the system + /// fallback either way. After the key come the new-font flag, face, style + /// and size bytes, then the flag this asks about. + private static boolean storesTrueTypeFont(File res, String key) throws Exception { + byte[] data = java.nio.file.Files.readAllBytes(res.toPath()); + byte[] name = key.getBytes("US-ASCII"); + for (int i = 0; i + name.length + 5 <= data.length; i++) { + boolean match = true; + for (int j = 0; j < name.length && match; j++) { + match = data[i + j] == name[j]; + } + if (match) { + return data[i + name.length + 4] == 1; + } + } + throw new AssertionError(key + " is not in " + res); + } + + @Test + void aRuleThatDidNotChangeKeepsItsNativeFontThroughAnIncrementalCompile(@TempDir Path tmp) throws Exception { + File common = mavenProject(tmp.toFile()); + String kept = "Kept { font-family: \"native:MainBold\"; font-size: 3mm; }\n"; + File css = write(new File(common, "src/main/css/theme.css"), kept + "Edited { color: #000001; }\n"); + File res = new File(common, "target/classes/theme.res"); + String[] args = {"-input", css.getPath(), "-output", res.getPath()}; + + assertEquals(0, CN1CSSCLI.run(args), stderr()); + assertTrue(storesTrueTypeFont(res, "Kept.font"), "a clean compile writes the native font"); + + long built = res.lastModified(); + write(css, kept + "Edited { color: #000002; }\n"); + assertTrue(css.setLastModified(built + 5000)); + assertEquals(0, CN1CSSCLI.run(args), stderr()); + assertEquals("2", theme(res).get("Edited.fgColor"), "the second compile was a real one"); + // The rule that did not change is carried over from the theme the + // first compile wrote. Loading that theme used to turn the font into + // its system fallback, and every unedited rule lost its font. + assertTrue(storesTrueTypeFont(res, "Kept.font"), "the unchanged rule keeps its native font"); + } + @Test void aBuildDirectoryOutsideTheProjectIsStillAProjectBuild(@TempDir Path tmp) throws Exception { File common = mavenProject(new File(tmp.toFile(), "project")); diff --git a/maven/css-compiler/src/main/java/com/codename1/ui/util/EditableResources.java b/maven/css-compiler/src/main/java/com/codename1/ui/util/EditableResources.java index 0a4a2fa308c..f2dd663e152 100644 --- a/maven/css-compiler/src/main/java/com/codename1/ui/util/EditableResources.java +++ b/maven/css-compiler/src/main/java/com/codename1/ui/util/EditableResources.java @@ -2031,6 +2031,14 @@ public String[] getDataResourceNames() { return super.getDataResourceNames(); } + // A theme loaded to be compiled again -- the CSS compiler's incremental + // build keeps the rules that did not change -- must keep its TrueType + // fonts whatever the headless platform says about drawing them. + @Override + boolean isTrueTypeFileSupported() { + return true; + } + com.codename1.ui.Font createTrueTypeFont(com.codename1.ui.Font f, String fontName, String fileName, float fontSize, int sizeSetting) { // workaround for NPE in case of people doing stupid things like moving the res file. File loadedFile = getLoadedFile(); diff --git a/scripts/check-copyright-headers.sh b/scripts/check-copyright-headers.sh index 61a2c2def93..29e5f1db808 100755 --- a/scripts/check-copyright-headers.sh +++ b/scripts/check-copyright-headers.sh @@ -38,10 +38,10 @@ is_excluded() { local candidate="$1" # What Initializr puts into a generated project is the developer's, not # Codename One's GPLv2 + Classpath Exception source: these trees are released - # under MIT No Attribution (scripts/wayline/LICENSE), and carry that header + # under MIT No Attribution (scripts/wayline/LICENSE, scripts/savor/LICENSE), and carry that header # or none. case "$candidate" in - scripts/wayline/*|maven/cn1app-archetype/src/main/resources/archetype-resources/*|scripts/initializr/common/src/main/resources/skill/*) return 0 ;; + scripts/wayline/*|scripts/savor/*|maven/cn1app-archetype/src/main/resources/archetype-resources/*|scripts/initializr/common/src/main/resources/skill/*) return 0 ;; esac awk -F '\\|' -v candidate="$candidate" ' /^[[:space:]]*(#|$)/ { next } diff --git a/scripts/developer-guide/validate-guide-snippets.py b/scripts/developer-guide/validate-guide-snippets.py index 6615ea3a9de..b516a39d80c 100755 --- a/scripts/developer-guide/validate-guide-snippets.py +++ b/scripts/developer-guide/validate-guide-snippets.py @@ -15,7 +15,7 @@ LISTING_DELIMITER_RE = re.compile(r"^-{4,}\s*$") TAG_BLOCK_RE = re.compile(r"// tag::([^\[]+)\[\]\n(.*?)\n// end::\1\[\]", re.S) JAVA_IDENTIFIER_RE = re.compile(r"^[A-Za-z_$][A-Za-z0-9_$]*\.java$") -INCLUDE_ROOTS = ("../demos/", "../../scripts/wayline/") +INCLUDE_ROOTS = ("../demos/", "../../scripts/wayline/", "../../scripts/savor/") def parse_args(): @@ -135,8 +135,8 @@ def validate_includes(includes): cache = {} java_snippets = [] for item in includes: - # The Wayline chapters show the sample application itself, which is - # built and tested by wayline.yml, so they include its sources directly + # The Wayline and Savor chapters show the sample applications themselves, + # which are built and tested by wayline.yml and savor.yml, so they include its sources directly # rather than through copies under docs/demos that could drift from it. if not item["target"].startswith(INCLUDE_ROOTS): errors.append( diff --git a/scripts/initializr/common/src/main/java/com/codename1/initializr/Initializr.java b/scripts/initializr/common/src/main/java/com/codename1/initializr/Initializr.java index 55471a7520c..6828203edb3 100644 --- a/scripts/initializr/common/src/main/java/com/codename1/initializr/Initializr.java +++ b/scripts/initializr/common/src/main/java/com/codename1/initializr/Initializr.java @@ -185,7 +185,11 @@ public void run() { } lookSubtitle.setText(schemeLabel(brandColor[0]) + (roundedCorners[0] ? "" : " . Square corners")); - lookIcon.setIcon(AppIcon.create(appNameField.getText(), schemeColor(brandColor[0]), + if (ownScheme[0] != null) { + ownScheme[0].setIcon(swatch(GeneratorModel.brandOf(selectedTemplate[0]))); + } + lookIcon.setIcon(AppIcon.create(appNameField.getText(), + schemeColor(brandColor[0], selectedTemplate[0]), convertToPixels(12))); previewPanel.setTemplate(selectedTemplate[0]); previewPanel.setOptions(options); @@ -270,7 +274,7 @@ public void run() { .withPlatformModules(allPlatformModules[0]); if (selectedTemplate[0].isFullStack()) { options = options.forFullStack().withScheme(brandColor[0], roundedCorners[0]) - .withIcon(AppIcon.png(appName, schemeColor(brandColor[0]))); + .withIcon(AppIcon.png(appName, schemeColor(brandColor[0], selectedTemplate[0]))); } GeneratorModel model = GeneratorModel.create(selectedIde[0], selectedTemplate[0], appName, packageName, options); @@ -429,18 +433,19 @@ private Container createEssentialsPanel(TextField appNameField, TextField packag } /// What the project starts as: an empty app in Java or in Kotlin, or -- once the - /// plugin the Initializr generates against can build it -- the ride-hailing app - /// with its server. + /// plugin the Initializr generates against can build it -- the ride-hailing or + /// the food-delivery app with its server. private Container createLanguageSelector(Template[] selectedTemplate, Runnable onSelectionChanged) { boolean fullStack = GeneratorModel.isTemplateOffered(Template.WAYLINE); - Container selector = new Container(new GridLayout(fullStack ? 3 : 1, fullStack ? 1 : 2)); + Container selector = new Container(new GridLayout(fullStack ? 4 : 1, fullStack ? 1 : 2)); selector.setUIID("InitializrChoicesGrid"); ButtonGroup group = new ButtonGroup(); Template[] languages = fullStack - ? new Template[] {Template.BAREBONES, Template.KOTLIN, Template.WAYLINE} + ? new Template[] {Template.BAREBONES, Template.KOTLIN, Template.WAYLINE, Template.SAVOR} : new Template[] {Template.BAREBONES, Template.KOTLIN}; String[] labels = fullStack - ? new String[] {"Empty app, Java", "Empty app, Kotlin", "Ride-hailing app with its server, Java"} + ? new String[] {"Empty app, Java", "Empty app, Kotlin", "Ride-hailing app with its server, Java", + "Food delivery app with its server, Java"} : new String[] {"Java", "Kotlin"}; for (int i = 0; i < languages.length; i++) { final Template template = languages[i]; @@ -464,9 +469,12 @@ private Container createLanguageSelector(Template[] selectedTemplate, Runnable o /// The colour schemes a full-stack template is offered in. The first is the /// template's own, which leaves its stylesheet as it is. - private static final String[] SCHEME_LABELS = {"Blue", "Teal", "Violet", "Orange", "Rose", "Graphite"}; + private static final String[] SCHEME_LABELS = {"As designed", "Teal", "Violet", "Orange", "Rose", "Graphite"}; private static final int[] SCHEME_COLORS = {-1, 0x0f766e, 0x6d3fd1, 0xd9540b, 0xc8235d, 0x2f3a4a}; + /// The choice of the template's own scheme, whose swatch follows the template. + private final RadioButton[] ownScheme = new RadioButton[1]; + private static String schemeLabel(int brandColor) { for (int i = 0; i < SCHEME_COLORS.length; i++) { if (SCHEME_COLORS[i] == brandColor) { @@ -477,8 +485,18 @@ private static String schemeLabel(int brandColor) { } /// The colour a scheme paints with, which for the template's own is not -1. - private static int schemeColor(int brandColor) { - return brandColor < 0 ? GeneratorModel.FULL_STACK_BRAND : brandColor; + private static int schemeColor(int brandColor, Template template) { + return brandColor < 0 ? GeneratorModel.brandOf(template) : brandColor; + } + + /// A round swatch of `color`, the icon of a scheme's choice. + private static Image swatch(int color) { + int dot = convertToPixels(3); + Image swatch = Image.createImage(dot, dot, 0); + swatch.getGraphics().setAntiAliased(true); + swatch.getGraphics().setColor(color); + swatch.getGraphics().fillArc(0, 0, dot, dot, 0, 360); + return swatch; } /// The look of a full-stack template: its brand colour, the corners, and the @@ -489,17 +507,16 @@ private Container createLookPanel(int[] brandColor, boolean[] roundedCorners, La Container colors = new Container(new GridLayout(2, 3)); colors.setUIID("InitializrChoicesGrid"); ButtonGroup group = new ButtonGroup(); - int dot = convertToPixels(3); for (int i = 0; i < SCHEME_COLORS.length; i++) { final int color = SCHEME_COLORS[i]; RadioButton button = new RadioButton(SCHEME_LABELS[i]); button.setToggle(true); button.setUIID("InitializrChoice"); - Image swatch = Image.createImage(dot, dot, 0); - swatch.getGraphics().setAntiAliased(true); - swatch.getGraphics().setColor(schemeColor(color)); - swatch.getGraphics().fillArc(0, 0, dot, dot, 0, 360); - button.setIcon(swatch); + // The template's own colour is painted when the template is known. + button.setIcon(swatch(color < 0 ? 0 : color)); + if (color < 0) { + ownScheme[0] = button; + } group.add(button); colors.add(button); if (color == brandColor[0]) { @@ -1130,6 +1147,9 @@ static String browserIdeUrl(String appName, String packageName, Template templat return "https://cloud.codenameone.com/ide/?new=" + kind + "&name=" + com.codename1.io.Util.encodeUrl(appName) + "&package=" + com.codename1.io.Util.encodeUrl(packageName) + // There is more than one full-stack template, and new=fullstack alone + // would give the browser IDE no way to tell which one was chosen. + + (template.isFullStack() ? "&template=" + template.name() : "") // Full-stack templates (Wayline) are Maven-only (ProjectOptions.forFullStack), // whatever the hidden build-tool choice still holds; an ordinary // App + backend project keeps the Gradle the user picked. @@ -1155,7 +1175,8 @@ private String createSummary(String appName, String packageName, Template templa String safePackage = packageName == null ? "" : packageName.trim(); return "App " + safeApp + "\n" + "Package " + safePackage + "\n" - + (template.isFullStack() ? "Template RIDE-HAILING APP + SERVER\n" : "") + + (template == Template.SAVOR ? "Template FOOD-DELIVERY APP + SERVER\n" + : template.isFullStack() ? "Template RIDE-HAILING APP + SERVER\n" : "") + "Language " + (template.IS_KOTLIN ? "KOTLIN" : "JAVA") + "\n" + "IDE " + ide.name() + "\n" + "Build " + options.buildTool.label diff --git a/scripts/initializr/common/src/main/java/com/codename1/initializr/model/GeneratorModel.java b/scripts/initializr/common/src/main/java/com/codename1/initializr/model/GeneratorModel.java index db3e0621a12..b9ab4b0817d 100644 --- a/scripts/initializr/common/src/main/java/com/codename1/initializr/model/GeneratorModel.java +++ b/scripts/initializr/common/src/main/java/com/codename1/initializr/model/GeneratorModel.java @@ -83,6 +83,15 @@ public static boolean isMavenLayoutChoiceOffered() { /// colour is chosen. GeneratorModelMatrixTest holds the two together. public static final int FULL_STACK_BRAND = 0x1f5eff; + /// The food-delivery template's `--brand`, held to its theme.css the same way. + public static final int SAVOR_BRAND = 0x7ac943; + + /// The brand colour `template` ships with: the one its icon is painted with + /// when the developer keeps the template's own scheme. + public static int brandOf(Template template) { + return template == Template.SAVOR ? SAVOR_BRAND : FULL_STACK_BRAND; + } + /// Whether `template` can be generated at [CN1_PLUGIN_VERSION]: the UI offers /// only the templates this answers true for. public static boolean isTemplateOffered(Template template) { @@ -1884,13 +1893,22 @@ private String buildReadmeMarkdown() { .append("binary with `./mvnw -pl backend -Dcodename1.platform=backend cn1:backend-package`.\n\n"); } if (template.isFullStack()) { + boolean food = template == Template.SAVOR; + String lower = toLowerCaseInvariant(appName); out.append("## The app and its server\n\n") - .append("This project is a working ride-hailing app: a rider, a driver and an admin mode in one ") + .append(food + ? "This project is a working food-delivery app: a customer, a restaurant, a courier " + + "and an admin mode in one " + : "This project is a working ride-hailing app: a rider, a driver and an admin mode in one ") .append("app, chosen by the role of whoever signs in, and the server they talk to.\n\n") .append("- `shared/` is the contract: the REST interfaces and the objects they carry. The app's ") .append("client and the server's dispatcher are both generated from it, so the two cannot drift.\n") - .append("- `backend/` is the server: accounts and roles, phone verification, driver applications, ") - .append("rides, matching, payments, the live channel and the database migrations.\n") + .append(food + ? "- `backend/` is the server: accounts and roles, phone verification, restaurants and " + + "their menus, orders, courier applications, deliveries, payments, the live channel " + + "and the database migrations.\n" + : "- `backend/` is the server: accounts and roles, phone verification, driver applications, " + + "rides, matching, payments, the live channel and the database migrations.\n") .append("- `common/` is the app. `src/main/css/theme.css` opens with the colours and corner ") .append("radius every screen takes its look from.\n\n") .append("Start the server, then, in a second terminal, the app:\n\n```\n") @@ -1899,11 +1917,13 @@ private String buildReadmeMarkdown() { .append("`server.sh` is a few lines of shell around Maven; on Windows run what it runs, ") .append("with `mvnw.cmd`, or use a Unix shell such as Git Bash or WSL.\n\n") .append("On the `dev` profile the server creates demonstration accounts, all with the ") - .append("password `").append(toLowerCaseInvariant(appName)).append("-demo`: `rider@") - .append(toLowerCaseInvariant(appName)).append(".example`, `driver@") - .append(toLowerCaseInvariant(appName)).append(".example`, `admin@") - .append(toLowerCaseInvariant(appName)).append(".example` and `applicant@") - .append(toLowerCaseInvariant(appName)).append(".example`, who has applied to drive ") + .append("password `").append(lower).append("-demo`: ") + .append(food + ? "`customer@" + lower + ".example`, `merchant@" + lower + ".example`, who runs the " + + "first restaurant's kitchen, `courier@" + lower + ".example`, `admin@" + lower + + ".example` and `applicant@" + lower + ".example`, who has applied to deliver " + : "`rider@" + lower + ".example`, `driver@" + lower + ".example`, `admin@" + lower + + ".example` and `applicant@" + lower + ".example`, who has applied to drive ") .append("and waits for the admin's decision. ") .append("Verification codes are written to the server's log until an SMS provider is ") .append("configured. Cards are simulated (`4242 4242 4242 4242` is accepted) until the ") @@ -1914,11 +1934,15 @@ private String buildReadmeMarkdown() { .append("serves it at `http://localhost:8080/`. In a desktop window or a desktop browser the ") .append("admin mode becomes a console with the operating system's own look.\n\n") .append("Two chapters of the developer guide, at https://www.codenameone.com/developer-guide/, ") - .append("cover the sample this project started as: \"A ride-hailing app from three sides\" ") - .append("shows it as a rider, a driver and an admin see it, and \"Building on the sample\" ") - .append("explains how it is built and what to change: billing, maps and demo data, native ") - .append("maps and the ") - .append("database.\n\n"); + .append(food + ? "cover the sample this project started as: \"A food-delivery app from four sides\" " + + "shows it as a customer, a restaurant, a courier and an admin see it, and " + + "\"Building on the sample\" explains how it is built and what to change, including " + + "running it as a restaurant's own delivery service.\n\n" + : "cover the sample this project started as: \"A ride-hailing app from three sides\" " + + "shows it as a rider, a driver and an admin see it, and \"Building on the sample\" " + + "explains how it is built and what to change: billing, maps and demo data, native " + + "maps and the database.\n\n"); } out.append("## Signing\n\n") diff --git a/scripts/initializr/common/src/main/java/com/codename1/initializr/model/Template.java b/scripts/initializr/common/src/main/java/com/codename1/initializr/model/Template.java index 2f821007a9d..b432e9a8c64 100644 --- a/scripts/initializr/common/src/main/java/com/codename1/initializr/model/Template.java +++ b/scripts/initializr/common/src/main/java/com/codename1/initializr/model/Template.java @@ -36,8 +36,8 @@ public enum Template { // A whole application rather than a starting point: a ride-hailing app with a // rider, a driver and an admin mode, and the server it talks to. It is the // project in scripts/wayline, which CI builds and tests; the resources named - // here are derived from it by scripts/sync-initializr-wayline.py and never - // edited by hand. + // here are derived from it by `scripts/sync-initializr-template.py wayline` + // and never edited by hand. // // Its preview is a resource of its own, read when the template is chosen, // and not an image of the theme: the theme is loaded at start-up, so a @@ -51,7 +51,16 @@ public enum Template { "com.codenameone.examples.wayline", "Wayline", "/wayline-preview.jpg", "The ride-hailing template is an app, a server and the contract module they share; " + "an Initializr Gradle project is a single project.", - new String[0], "/wayline-modules.zip", "/wayline-settings.properties"); + new String[0], "/wayline-modules.zip", "/wayline-settings.properties"), + // The same kind of template as WAYLINE: a food-delivery app with a customer, + // a courier, a restaurant and an admin mode, and its server. It is the project + // in scripts/savor, and its resources come from + // `scripts/sync-initializr-template.py savor`. + SAVOR(false, "/savor-src.zip", "/savor-css.zip", "/savor-pom.xml", + "com.codenameone.examples.savor", "Savor", "/savor-preview.jpg", + "The food-delivery template is an app, a server and the contract module they share; " + + "an Initializr Gradle project is a single project.", + new String[0], "/savor-modules.zip", "/savor-settings.properties"); /// The resource holding the picture shown for a template that has no live /// preview, or null for one that has. diff --git a/scripts/initializr/common/src/main/resources/savor-css.zip b/scripts/initializr/common/src/main/resources/savor-css.zip new file mode 100644 index 00000000000..4dd57130809 Binary files /dev/null and b/scripts/initializr/common/src/main/resources/savor-css.zip differ diff --git a/scripts/initializr/common/src/main/resources/savor-modules.zip b/scripts/initializr/common/src/main/resources/savor-modules.zip new file mode 100644 index 00000000000..300d13895e4 Binary files /dev/null and b/scripts/initializr/common/src/main/resources/savor-modules.zip differ diff --git a/scripts/initializr/common/src/main/resources/savor-pom.xml b/scripts/initializr/common/src/main/resources/savor-pom.xml new file mode 100644 index 00000000000..781c692b810 --- /dev/null +++ b/scripts/initializr/common/src/main/resources/savor-pom.xml @@ -0,0 +1,398 @@ + + + 4.0.0 + + com.example.myapp + myappname + 1.0-SNAPSHOT + + com.example.myapp + myappname-common + 1.0-SNAPSHOT + jar + + + + + + ${project.groupId} + myappname-shared + ${project.version} + + + com.codenameone + codenameone-core + + + com.codenameone + codenameone-javase + test + + + + + + + + + + + install-codenameone + ${user.home}/.codenameone/guibuilder.jar + + + + org.apache.maven.plugins + maven-antrun-plugin + + + + validate + + run + + + + + + + + + + + + + + + + + + + + + + + + + + + + kotlin + + + + ${basedir}/src/main/kotlin + + + + 1.6.0 + true + + + + org.jetbrains.kotlin + kotlin-stdlib + ${kotlin.version} + + + + + + org.jetbrains + annotations + 13.0 + + + com.codenameone + java-runtime + provided + + + + + + org.codehaus.mojo + properties-maven-plugin + 1.0.0 + + + initialize + + read-project-properties + + + + ${basedir}/codenameone_settings.properties + + + + + + + org.jetbrains.kotlin + kotlin-maven-plugin + ${kotlin.version} + + + compile + + compile + + + + ${project.basedir}/src/main/kotlin + ${project.basedir}/src/main/java + + + -no-reflect + -no-jdk + + + + + test-compile + + test-compile + + + + ${project.basedir}/src/test/kotlin + ${project.basedir}/src/test/java + + + -no-reflect + -no-jdk + + + + + + + + + + + + + javase + + + codename1.platform + javase + + + + javase + + + + + org.codehaus.mojo + exec-maven-plugin + + java + true + + -Xmx1024M + + -classpath + + ${exec.mainClass} + ${cn1.mainClass} + + + + + + + + + + simulator + + javase + + + + + + ios-debug + + + iphone + + + ios + + + + + ios-release + + + iphone + true + + + ios + true + + + + + javascript + + javascript + javascript + + + + + android + + android + android + + + + + windows + + desktop_windows + javase + + + + + mac + + desktop_macosx + javase + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + 1.8 + 1.8 + + + + org.codehaus.mojo + properties-maven-plugin + 1.0.0 + + + initialize + + read-project-properties + + + + ${basedir}/codenameone_settings.properties + + + + + + + + com.codenameone + codenameone-maven-plugin + + + xalan + serializer + 2.7.3 + + + + + + generate-gui-sources + process-sources + + generate-gui-sources + + + + cn1-process-classes + process-classes + + bytecode-compliance + css + + process-annotations + + + + + attach-test-artifact + test + + attach-test-artifact + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + true + + + + + + + + + + diff --git a/scripts/initializr/common/src/main/resources/savor-preview.jpg b/scripts/initializr/common/src/main/resources/savor-preview.jpg new file mode 100644 index 00000000000..6c5bc9645de Binary files /dev/null and b/scripts/initializr/common/src/main/resources/savor-preview.jpg differ diff --git a/scripts/initializr/common/src/main/resources/savor-settings.properties b/scripts/initializr/common/src/main/resources/savor-settings.properties new file mode 100644 index 00000000000..80966f2de3b --- /dev/null +++ b/scripts/initializr/common/src/main/resources/savor-settings.properties @@ -0,0 +1,28 @@ +# A server started on this machine is reached over plain HTTP, which Android +# refuses by default. Remove this once the app talks to a server with a +# certificate. +codename1.arg.android.xapplication_attr=android\:usesCleartextTraffic\="true" +# What iOS shows when someone applying to drive photographs a document. +codename1.arg.ios.NSCameraUsageDescription=The camera photographs the documents of your application to drive. +# What iOS shows when they pick a picture of a document they already have. +codename1.arg.ios.NSPhotoLibraryUsageDescription=Your photo library is where you pick a picture of a document from. +# What iOS shows before Face ID unlocks the sign-in kept in secure storage. +codename1.arg.ios.NSFaceIDUsageDescription=Authenticate to securely access your account +# What iOS shows when the app first asks where the device is. +codename1.arg.ios.NSLocationWhenInUseUsageDescription=Your location is where your order is delivered to, and, when you deliver, where the nearby orders are offered to you. +# While a courier is online the app keeps reporting where they are from the +# background: iOS needs the location background mode and the Always prompt's +# text, Android a foreground service of the location type. +codename1.arg.ios.background_modes=location +codename1.arg.ios.locationUsageDescription=Your location is where your order is delivered to, and, when you deliver, where the nearby orders are offered to you. +codename1.arg.ios.NSLocationAlwaysAndWhenInUseUsageDescription=While you are online as a courier, Savor keeps sharing your location with customers and dispatch, even with the app in the background. +codename1.arg.android.foregroundServiceType=location +# Push notifications. With push included the build adds the +# remote-notification background mode and the push entitlement itself. +codename1.arg.ios.includePush=true +# Android push is off until the app has its Firebase file: a build that +# uses PushClient without google-services.json stops with an error. Once +# native/android/google-services.json is in place, delete this line. +codename1.arg.android.messagingService=none +# The delivery on the lock screen updates as the courier moves. +codename1.arg.ios.surfaces.frequentUpdates=true diff --git a/scripts/initializr/common/src/main/resources/savor-src.zip b/scripts/initializr/common/src/main/resources/savor-src.zip new file mode 100644 index 00000000000..af2dfbbec5b Binary files /dev/null and b/scripts/initializr/common/src/main/resources/savor-src.zip differ diff --git a/scripts/initializr/common/src/main/resources/wayline-src.zip b/scripts/initializr/common/src/main/resources/wayline-src.zip index 89edf2e9545..fea44c8f8bf 100644 Binary files a/scripts/initializr/common/src/main/resources/wayline-src.zip and b/scripts/initializr/common/src/main/resources/wayline-src.zip differ diff --git a/scripts/initializr/common/src/test/java/com/codename1/initializr/BrowserIdeUrlTest.java b/scripts/initializr/common/src/test/java/com/codename1/initializr/BrowserIdeUrlTest.java index c1af3065f67..90fe5381907 100644 --- a/scripts/initializr/common/src/test/java/com/codename1/initializr/BrowserIdeUrlTest.java +++ b/scripts/initializr/common/src/test/java/com/codename1/initializr/BrowserIdeUrlTest.java @@ -48,6 +48,14 @@ public boolean runTest() throws Exception { ProjectOptions.ProjectType.APP, ProjectOptions.BuildTool.GRADLE).endsWith("&build=maven")); } } + // Each full-stack template names itself, so the browser IDE builds the one chosen. + for (Template t : Template.values()) { + if (t.isFullStack()) { + assertTrue(Initializr.browserIdeUrl("MyApp", "com.example.myapp", t, + ProjectOptions.ProjectType.APP, ProjectOptions.BuildTool.MAVEN) + .contains("&template=" + t.name() + "&"), t.name()); + } + } // Names are encoded, so nothing can add its own parameters. assertTrue(Initializr.browserIdeUrl("A&b=c", "com.example.myapp", Template.BAREBONES, ProjectOptions.ProjectType.APP, ProjectOptions.BuildTool.MAVEN).contains("name=A%26b%3Dc")); diff --git a/scripts/initializr/common/src/test/java/com/codename1/initializr/model/GeneratorModelMatrixTest.java b/scripts/initializr/common/src/test/java/com/codename1/initializr/model/GeneratorModelMatrixTest.java index f04c9e1edae..ab1344015c9 100644 --- a/scripts/initializr/common/src/test/java/com/codename1/initializr/model/GeneratorModelMatrixTest.java +++ b/scripts/initializr/common/src/test/java/com/codename1/initializr/model/GeneratorModelMatrixTest.java @@ -370,8 +370,12 @@ private void validateMavenRefusals() { /// What a full-stack template adds to an app with a backend: the contract /// module both halves compile against, the template's own server in place of /// the generic one, and nothing left that still carries the template's name. - private void assertFullStackProject(Map entries, byte[] zipData, String packageName, - String mainClassName, String label) throws IOException { + private void assertFullStackProject(Template template, Map entries, byte[] zipData, + String packageName, String mainClassName, String label) throws IOException { + boolean rides = template == Template.WAYLINE; + String name = template.SOURCE_MAIN_CLASS; + String upper = template.name(); + String lower = GeneratorModel.toLowerCaseInvariant(name); String packagePath = StringUtil.replaceAll(packageName, ".", "/"); String artifact = GeneratorModel.toLowerCaseInvariant(mainClassName); String rootPom = removeWhitespace(getText(entries, "pom.xml")); @@ -379,6 +383,14 @@ private void assertFullStackProject(Map entries, byte[] zipData, label + "the shared module is built ahead of the app"); assertTrue(rootPom.indexOf("shared") == rootPom.lastIndexOf("shared"), label + "the shared module is in the reactor once"); + // The README describes the app this template really is: a food-delivery + // project once got the ride-hailing accounts and chapters. + String readme = getText(entries, "README.md"); + assertContains(readme, rides ? "ride-hailing app" : "food-delivery app", label + "README names the app"); + assertContains(readme, (rides ? "`rider@" : "`customer@") + artifact + ".example`", + label + "README lists the template's own demo accounts"); + assertFalse(readme.indexOf(rides ? "courier@" : "rider@") >= 0, + label + "README carries no other template's accounts"); String sharedPom = getText(entries, "shared/pom.xml"); assertContains(sharedPom, "" + artifact + "-shared", label + "shared module id"); assertContains(getText(entries, "common/pom.xml"), "" + artifact + "-shared", @@ -388,15 +400,15 @@ private void assertFullStackProject(Map entries, byte[] zipData, assertContains(getText(entries, "backend/pom.xml"), "generate-annotation-stubs", label + "the server generates its half of the contract"); - assertContains(getText(entries, "shared/src/main/java/" + packagePath + "/api/RiderApi.java"), + assertContains(getText(entries, "shared/src/main/java/" + packagePath + (rides ? "/api/RiderApi.java" : "/api/OrderApi.java")), "package " + packageName + ".api;", label + "the contract moves to the project's package"); assertContains(getText(entries, "backend/src/main/java/" + packagePath + "/SecurityConfig.java"), "package " + packageName + ";", label + "the server moves to the project's package"); assertNotNull(entries.get("backend/src/main/resources/db/migration/V1__accounts.sql"), label + "the server keeps its migrations"); - assertNotNull(entries.get("backend/src/test/java/" + packagePath + "/RideFlowTest.java"), + assertNotNull(entries.get("backend/src/test/java/" + packagePath + (rides ? "/RideFlowTest.java" : "/OrderFlowTest.java")), label + "the server keeps its tests"); - assertNotNull(entries.get("common/src/main/java/" + packagePath + "/rider/RiderForm.java"), + assertNotNull(entries.get("common/src/main/java/" + packagePath + (rides ? "/rider/RiderForm.java" : "/customer/CustomerForm.java")), label + "the app's screens move to the project's package"); assertNotNull(entries.get("common/src/main/l10n/Bundle.properties"), label + "the app keeps its bundle"); // The generic server every other project gets, which this one replaces. @@ -416,13 +428,13 @@ private void assertFullStackProject(Map entries, byte[] zipData, for (Map.Entry entry : entries.entrySet()) { String path = entry.getKey(); - assertFalse(path.indexOf("wayline") >= 0 || path.indexOf("Wayline") >= 0, + assertFalse(path.indexOf(lower) >= 0 || path.indexOf(name) >= 0, label + "the template's name is still in the path " + path); if (path.endsWith(".png") || path.endsWith(".jar") || path.endsWith(".res") || path.endsWith(".ttf")) { continue; } String text = StringUtil.newString(entry.getValue()); - assertFalse(text.indexOf("wayline") >= 0 || text.indexOf("Wayline") >= 0 || text.indexOf("WAYLINE") >= 0, + assertFalse(text.indexOf(lower) >= 0 || text.indexOf(name) >= 0 || text.indexOf(upper) >= 0, label + "the template's name is still in " + path); assertFalse(text.indexOf("myappname") >= 0 || text.indexOf("MyAppName") >= 0 || text.indexOf("MYAPPNAME") >= 0, @@ -678,7 +690,7 @@ private void validateMavenLayoutCombination(Template template, ProjectOptions.Pr assertContains(getText(entries, "README.md"), "## Backend", label + "README should explain the backend"); } if (template.isFullStack()) { - assertFullStackProject(entries, zipData, packageName, mainClassName, label); + assertFullStackProject(template, entries, zipData, packageName, mainClassName, label); } else { assertNull(entries.get("shared/pom.xml"), label + "only a full-stack template has a shared module"); assertFalse(rootPom.indexOf("shared") >= 0, diff --git a/scripts/savor/LICENSE b/scripts/savor/LICENSE new file mode 100644 index 00000000000..6a09ed8e735 --- /dev/null +++ b/scripts/savor/LICENSE @@ -0,0 +1,22 @@ +MIT No Attribution + +Copyright (c) 2026, Codename One and/or its affiliates. + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +This applies to the Savor sample and to everything Codename One Initializr +writes into a generated project: the application templates under +maven/cn1app-archetype/src/main/resources/archetype-resources, the agent skill +under scripts/initializr/common/src/main/resources/skill, and the Initializr +template archives built from them. diff --git a/scripts/savor/README.adoc b/scripts/savor/README.adoc new file mode 100644 index 00000000000..f045c49355c --- /dev/null +++ b/scripts/savor/README.adoc @@ -0,0 +1,241 @@ += Savor + +A food-delivery application and its server, in one Maven project and one language. +A customer browses the restaurants that deliver to them, fills a cart and pays; the +restaurant accepts the order and cooks it; the nearest courier is offered the +delivery, collects it and brings it to the door while the customer follows it on a +map; and an administrator runs the service. Which of these a person sees is decided +by the roles the server gives their account: a customer opens on the restaurants, a +merchant on the restaurant's order queue, a courier on the delivery screen, an +administrator on the operations console. + +It is a sample of how a Codename One application and a Codename One Backend fit +together, and the project the Initializr generates when you start from the +food-delivery template. + +== Start here + +Two chapters of the developer guide cover this project: + +* https://www.codenameone.com/developer-guide/#savor-users-guide[The user's guide] + (link:../../docs/developer-guide/Savor-Users-Guide.asciidoc[source]) shows the + application as a customer, a merchant, a courier and an administrator see it, + screen by screen, + with the demo accounts and test cards. +* https://www.codenameone.com/developer-guide/#savor-implementors-guide[The implementor's guide] + (link:../../docs/developer-guide/Savor-Implementors-Guide.asciidoc[source]) + explains how the project is built and what to change to make it yours: one + restaurant's own ordering app or a whole marketplace, real billing, your own map data or native maps, sign-in through another identity + provider, real text messages, analytics, and the database: the engines it + runs on, changing the schema, and hosting it. + +To see it running first, the two commands under <> are all it takes. + +== The modules + +[cols="1,4"] +|=== +| `shared` +| The contract: the data classes both sides exchange and the `@RestClient` + interfaces that name every call. The app's build generates a client from them + and the server's build generates the interfaces it implements, so a change the + other side did not follow is a compile error. + +| `common` +| The application: sign-in, the customer, merchant, courier and administrator + screens, the map, + the wallet, the settings and the live channel. Styled in + `src/main/css/theme.css`, whose first block of variables holds the colors and + the corner radius everything else is drawn with. The text is in + `src/main/l10n`, in English, Spanish, French, German and Hebrew. The + translations are machine-generated samples that no native speaker has + reviewed; have them checked before you ship. + What it reports to Codename One analytics about how it is used, once the user + has agreed, is all in `Telemetry`. + +| `backend` +| The server: accounts and roles, phone verification, the restaurants and their + menus, courier applications and their documents, moderation, quotes and + pricing, the order and delivery state machines, dispatch, payments held at + checkout and taken on delivery, payouts, statistics, and a WebSocket that + pushes changes as they happen. + The data is entity classes in `domain`, read and written by the repository + beside each service. The schema is the migration scripts in + `src/main/resources/db/migration`, and the same server runs on SQLite, + PostgreSQL, MySQL and MariaDB. + +|=== + +There are no platform modules: `common` builds the application for the +simulator, the desktop, Android, iOS and the browser itself. + +== Running it + +Start the server on the development profile. It needs nothing installed: the +database is in memory, and text messages are printed on the console. + +[source,bash] +---- +CN1_PROFILE=dev backend/server.sh run +---- + +Then run the application in the simulator, from another terminal: + +[source,bash] +---- +mvn verify -Psimulator -DskipTests -Dcodename1.platform=javase +---- + +The development profile creates these accounts, all with the password +`savor-demo`, twelve restaurants with their menus, and a fortnight of orders for +the charts to draw: + +[cols="2,3"] +|=== +| `customer@savor.example`, `customer2@savor.example` | order food +| `customer3@savor.example` | a customer an administrator has flagged +| `courier@savor.example`, `courier2@savor.example` | approved couriers +| `courier3@savor.example`, `courier4@savor.example` | approved couriers who + carry the past orders +| `merchant@savor.example` | runs Ember & Crust's kitchen +| `applicant@savor.example` | has applied to deliver and is waiting for a decision +| `admin@savor.example` | runs the service +|=== + +On the development profile a customer alone can watch an order all the way: a +simulated kitchen accepts and cooks for every restaurant whose merchant is not +signed in, and a simulated courier takes the delivery while no real one is +online. Sign in as the merchant and a courier in other simulators to play those +parts yourself. On a phone, set the server's address on the welcome screen: +`localhost` on a phone is the phone. + +=== In a browser, with nothing installed + +The server can host the application itself. Build the browser version once, and +again when the application changes; it takes about a minute: + +[source,bash] +---- +backend/server.sh web +CN1_PROFILE=dev backend/server.sh run +---- + +Then open `http://localhost:8080/`. `server.sh run --web` does both in one step. + +`server.sh web` runs `cn1:backend-webapp`, which builds the application for the +browser and stages it in `backend/target/webapp`; a server that finds it there +serves it at `/`, after its own routes. In the browser the application takes the +page's address as the server's, signs in through the same authorization-code +flow with the code sent back to `/signin/code` on that server, and opens its live +channel to the same origin. To deploy it, put `backend/target/webapp` beside the +server binary as `webapp`. + +A browser gives a page less than a phone gives an app, and the application says +so where it matters: calling and texting a courier are offered only where the +device can dial, and the restaurants listed are the ones near the default +address when the browser will not say where it is. + +== What each role does + +A customer chooses where to deliver to, browses the restaurants by cuisine or +searches their dishes, and builds an order with each dish's options. The checkout +shows the fees, takes a promo code (`WELCOME10`, `SAVOR5` on the development +profile) and a tip, and the card is held, not charged. The order is then followed +step by step and on a map, and can be cancelled until the kitchen accepts it. Past +orders can be ordered again, restaurants kept as favorites, and a tip added after +the food arrives. + +A merchant sees their restaurant's orders arrive, accepts one with a preparation +time or rejects it with a reason, and marks it ready for the courier. They pause +the restaurant when the kitchen is full, edit the menu, its photos and what is sold +out, and see the sales. + +Anyone can apply to deliver. The application asks for the courier's details, the +vehicle, and five photographs: an identity document, the licence, a selfie, the +vehicle registration and the insurance. Nobody delivers until an administrator +approves the application; a rejection carries a reason, and the applicant can +correct it and send it again. An approved courier goes online, is offered +deliveries from restaurants near them, collects the food once it is ready, and +sees what they earned and cashes it out. + +An administrator sees the service in charts, approves restaurants and couriers, +flags or blocks an account with a reason that is kept in its history, cancels and +refunds orders, and sets the prices and the promos. + +== What is real and what is simulated + +Maps, search and routes are real and need no key: the map tiles come from +OpenFreeMap, routes from the public OSRM server, and place search from Photon, +which the server calls so the app never talks to a third party directly. + +Text messages go through the provider `savor.sms.provider` names: `twilio`, +`vonage` or `messagebird`, each with its own credentials. On a development +profile they are printed on the console instead, and the app is handed the code +so a sign-up can finish. Any other profile needs a provider; one whose +credentials are missing is logged as a configuration error, naming the missing +settings, and the app is told the code could not be sent. + +Payments are simulated on a development profile. Simulated, the card +`4242 4242 4242 4242` is accepted, `4000 0000 0000 0002` is declined, and no +money moves. With `savor.payments.provider=stripe` and +`SAVOR_PAYMENTS_STRIPE_SECRET` set in the server's environment, a card is +added on Stripe's own page, so its number never reaches the app or this server, +and an order's total is held on the card at checkout and taken when the food is +delivered; a rejected or cancelled order releases the hold. The Stripe provider is an untested +stub: it is covered by tests that answer for Stripe, has never been run against +a live account, and has to be tried with a Stripe test key before it is trusted +with money. Paying a +courier out is a ledger entry in both cases; moving that money to a bank is left +to the deployment. + +`backend/application.properties` lists every setting a deployment supplies. The +database is the one named by `DATABASE_URL`: a `postgres://` or `mysql://` +address, or the path of a SQLite file. The SMS and payment settings are listed +there too, each also read from the environment in upper case with `_` for every +dot (`SAVOR_SMS_TWILIO_TOKEN`). + +== Tests + +[source,bash] +---- +mvn -pl shared,backend -Dcodename1.platform=backend test # <1> +mvn -pl shared,backend -Dcodename1.platform=backend test -Dcn1.backend.compiledTests=true # <2> +./run-e2e.sh # <3> +./check-template.sh # <4> +./run-web-e2e.sh # <5> +---- +<1> The server's tests, on the JVM. +<2> The same tests again, translated to a native binary, which is how the server + ships. +<3> Starts the server on the `test` profile and runs the application's tests + against it in the simulator: they register, apply to deliver and are approved, + order food, accept and cook it as the restaurant, deliver it, pay for it and + administer it, and compare the screens in `screenshots/`. Pass `--native` to run them against the compiled + server. On Linux they then run a second time with the simulator as a desktop + window, where the admin console is laid out side by side under the + platform's own theme. +<4> Generates a project from the Initializr template, under another name and + package, and builds it. +<5> Builds the browser version of the application, starts the server hosting + it, and drives it in a headless browser: the application loads from the + server, signs in as the demo customer, reaches the home screen and opens its + live channel, then signs out and signs in as the demo admin. Needs Node with Playwright and its Chromium (`npm ci` and + `npx playwright install chromium` in `scripts/`). Screenshots and logs go + to `target/web-e2e`. + +`.github/workflows/savor.yml` runs all five, and builds the application for +Android and iOS through `scripts/build-android-app.sh` and +`scripts/build-ios-app.sh` with `CN1_APP_DIR=scripts/savor`. + +== The Initializr template + +`scripts/sync-initializr-template.py savor --write` copies this project into the +Initializr's resources; without `--write` it fails when they differ, which is what +CI runs. Change the project here, never the copies. + +== Licence + +Savor is released under MIT No Attribution (see `LICENSE`), not under the +GPLv2 with Classpath Exception that covers Codename One itself. Take any of it +into your own application, under any licence, with no notice to keep. The same +holds for everything Initializr writes into a generated project. diff --git a/scripts/savor/backend/application-dev.properties b/scripts/savor/backend/application-dev.properties new file mode 100644 index 00000000000..cc6975aabef --- /dev/null +++ b/scripts/savor/backend/application-dev.properties @@ -0,0 +1,30 @@ +# The development profile, chosen with CN1_PROFILE=dev. +# +# An in-memory SQLite database, so the server starts with nothing installed: the +# migrations create the tables at start-up, and the demo accounts are added to +# them. Point this at a file to keep the data between runs: +# +# cn1.datasource.url=./target/app.db +# +# Production points cn1.datasource.url at the real database instead -- a +# postgres:// or mysql:// URL -- and the same code runs against it. +cn1.datasource.url=:memory: + +# On this profile the server also serves, without a token: +# +# /manage/health, /manage/metrics, /manage/prometheus, /manage/jobs +# /mcp -- the development MCP tools, for an agent working on this backend +# +# Neither is served outside a development profile unless it is turned on and +# given a token (cn1.management.token, cn1.mcp.token). + +# Text messages and push notifications are printed on the console and cards are +# simulated. All three are the defaults of a development profile anyway, and are +# spelled out to show where the choice is made. The console senders are refused +# outside a development profile. See application.properties for the real +# providers. +# tag::savor-properties-dev-providers[] +savor.sms.provider=console +savor.payments.provider=simulated +savor.push.provider=console +# end::savor-properties-dev-providers[] diff --git a/scripts/savor/backend/application-test.properties b/scripts/savor/backend/application-test.properties new file mode 100644 index 00000000000..31d340f0e12 --- /dev/null +++ b/scripts/savor/backend/application-test.properties @@ -0,0 +1,20 @@ +# The test profile, chosen with CN1_PROFILE=test. It is what run-e2e.sh starts the +# server on for the app's end-to-end tests, and what @BackendTest runs the +# server's own tests on. +# +# Like `dev` -- an in-memory database, the demo accounts, text messages that are +# printed and not sent -- and one thing more: places are looked up in a list of +# ten the server carries, so a test run asks no public geocoder for anything. +cn1.datasource.url=:memory: + +# The tests sign several accounts in from one address, one after another, which +# is exactly what the sign-in rate limit is there to stop. +savor.signin.per-minute=1000 + +# Codes printed on the console (and handed back to the test as demoCode), cards +# simulated: no test reaches an SMS provider or a payment processor. +savor.sms.provider=console +savor.payments.provider=simulated + +# Push notifications are kept for the tests to read, not sent. +savor.push.provider=recording diff --git a/scripts/savor/backend/application.properties b/scripts/savor/backend/application.properties new file mode 100644 index 00000000000..16cb2f2d4cb --- /dev/null +++ b/scripts/savor/backend/application.properties @@ -0,0 +1,98 @@ +# The settings this server reads at start-up, and the layer with the lowest +# precedence. A system property of the same name wins over anything here, then +# the environment variable the key maps to (cn1.datasource.url is read from +# CN1_DATASOURCE_URL, and also from DATABASE_URL, which is what a platform sets +# for you), then application-.properties, then this file. +# +# It sits in the module root on purpose. This runtime has no classpath to load a +# resource from, so Config opens application*.properties as FILES relative to the +# working directory: the module directory under `cn1:backend`, and wherever the +# binary is started once it is packaged. +# +# Choose a profile with CN1_PROFILE. The one beside this file is `dev`: +# +# CN1_PROFILE=dev mvn -pl backend -Dcodename1.platform=backend cn1:backend +# +# A development profile (dev, test, local) needs nothing else: an in-memory +# database, a signing key made at start-up, text messages printed on the +# console, simulated cards, and a set of demo accounts with a fortnight of deliveries behind them. A real +# deployment sets, here or in its environment: +# +# cn1.datasource.url the database +# cn1.security.authorizationserver.issuer the public address of this server +# cn1.security.authorizationserver.jwk.keys the key tokens are signed with +# savor.admin.email, savor.admin.password the first admin, who grants the rest +# savor.sms.provider and its settings below +# savor.payments.provider and its settings below +# savor.push.app, savor.push.key push notifications, below +# +# and may change the fees (savor.fee.*), the currency (savor.currency) and +# the geocoder (savor.geocoder.url). +# +# Text messages. savor.sms.provider names the provider verification codes are +# sent through, and each needs its own settings: +# +# twilio savor.sms.twilio.sid, savor.sms.twilio.token, +# savor.sms.twilio.from +# vonage savor.sms.vonage.key, savor.sms.vonage.secret, +# savor.sms.vonage.from +# messagebird savor.sms.messagebird.key, savor.sms.messagebird.originator +# console prints the code; development profiles only +# +# Unset, it is console on a development profile and twilio on any other. A +# provider whose settings are missing is a configuration error: it is logged at +# start-up and on every send, naming the missing settings, and the app is told +# the code could not be sent. Nothing falls back to printing codes in +# production. +# +# Payments. savor.payments.provider is simulated or stripe; unset, it is +# simulated on a development profile and stripe on any other. Simulated cards: +# 4242 4242 4242 4242 is accepted, 4000 0000 0000 0002 is declined, and nothing +# is charged. Stripe is an untested stub over Stripe's REST API, a starting +# point to try against a test-mode key: cards are added on Stripe's own page, so +# a card number never reaches the app or this server, and deliveries are charged +# when they complete. It needs the secret key, and without it every payment +# call fails with a configuration error in the log. Keep the key and the SMS +# credentials out of this file and out of the repository; set them in the +# environment of the deployment instead, where each setting is read in upper +# case with _ for every dot: +# +# SAVOR_PAYMENTS_STRIPE_SECRET a restricted or secret key (sk_...) +# SAVOR_SMS_TWILIO_TOKEN and so on +# +# Stripe's page sends the browser back to /pay/return on this server when it is +# done, so the server has to know the address it is reached at from a phone. It +# uses cn1.security.authorizationserver.issuer, which a deployment sets anyway; +# savor.public.url overrides that when the two differ. +# +# Push notifications. savor.push.provider is cloud, console or recording; +# unset, it is recording on the test profile, console on the other development +# profiles and cloud on any other. cloud sends through the Codename One push +# service, which delivers to Firebase, Apple, Huawei and the browsers for you, +# and needs two settings from the Push section of the Codename One console: +# +# savor.push.app the app's push id +# savor.push.key a server key with the push scope; put it in the +# environment as SAVOR_PUSH_KEY, like the other secrets +# +# Without them the server still starts and serves deliveries; the missing settings +# are logged at start-up and again for every notification that is not sent. +# savor.push.activity.seconds (45) is how often, at most, a moving car +# updates the customer's Live Activity. +# +# The prices are settings only until an admin changes them in the app. From +# then on they are in the database (savor.fee.* is the starting point, +# savor.fee.serviceFeePercent and savor.fee.commissionPercent among them). + +# ${DATABASE_URL} is resolved when the value is READ, so this line is fine in a +# checkout where that variable is unset: the dev profile overrides the key, and +# nothing reads this one. SQLite (a file path), postgres:// and mysql:// all work. +cn1.datasource.url=${DATABASE_URL} + +# The port. A platform that sets PORT wins over this, which is what lets the same +# build deploy anywhere. +cn1.server.port=8080 + +# Creates the tables the accounts, the registered app and the tokens are kept in. +# Read by the build, so it has to be here and not in a profile's file. +cn1.security.schema.enabled=true diff --git a/scripts/savor/backend/photos/burrata.jpg b/scripts/savor/backend/photos/burrata.jpg new file mode 100644 index 00000000000..f697e80eb40 Binary files /dev/null and b/scripts/savor/backend/photos/burrata.jpg differ diff --git a/scripts/savor/backend/photos/carbonara.jpg b/scripts/savor/backend/photos/carbonara.jpg new file mode 100644 index 00000000000..e53a7e7ceb1 Binary files /dev/null and b/scripts/savor/backend/photos/carbonara.jpg differ diff --git a/scripts/savor/backend/photos/chicken-sandwich.jpg b/scripts/savor/backend/photos/chicken-sandwich.jpg new file mode 100644 index 00000000000..623d1b7e259 Binary files /dev/null and b/scripts/savor/backend/photos/chicken-sandwich.jpg differ diff --git a/scripts/savor/backend/photos/chirashi.jpg b/scripts/savor/backend/photos/chirashi.jpg new file mode 100644 index 00000000000..5e0c0513c0f Binary files /dev/null and b/scripts/savor/backend/photos/chirashi.jpg differ diff --git a/scripts/savor/backend/photos/cold-brew.jpg b/scripts/savor/backend/photos/cold-brew.jpg new file mode 100644 index 00000000000..01715df4f0f Binary files /dev/null and b/scripts/savor/backend/photos/cold-brew.jpg differ diff --git a/scripts/savor/backend/photos/diavola.jpg b/scripts/savor/backend/photos/diavola.jpg new file mode 100644 index 00000000000..e9616f5c8b2 Binary files /dev/null and b/scripts/savor/backend/photos/diavola.jpg differ diff --git a/scripts/savor/backend/photos/elote.jpg b/scripts/savor/backend/photos/elote.jpg new file mode 100644 index 00000000000..87652b9664d Binary files /dev/null and b/scripts/savor/backend/photos/elote.jpg differ diff --git a/scripts/savor/backend/photos/ember.jpg b/scripts/savor/backend/photos/ember.jpg new file mode 100644 index 00000000000..2fb555e67de Binary files /dev/null and b/scripts/savor/backend/photos/ember.jpg differ diff --git a/scripts/savor/backend/photos/fries.jpg b/scripts/savor/backend/photos/fries.jpg new file mode 100644 index 00000000000..9fcc7bc82cb Binary files /dev/null and b/scripts/savor/backend/photos/fries.jpg differ diff --git a/scripts/savor/backend/photos/gilded.jpg b/scripts/savor/backend/photos/gilded.jpg new file mode 100644 index 00000000000..93b2ab5c48e Binary files /dev/null and b/scripts/savor/backend/photos/gilded.jpg differ diff --git a/scripts/savor/backend/photos/glaze.jpg b/scripts/savor/backend/photos/glaze.jpg new file mode 100644 index 00000000000..48863ba8054 Binary files /dev/null and b/scripts/savor/backend/photos/glaze.jpg differ diff --git a/scripts/savor/backend/photos/harbor.jpg b/scripts/savor/backend/photos/harbor.jpg new file mode 100644 index 00000000000..fd7dee0633c Binary files /dev/null and b/scripts/savor/backend/photos/harbor.jpg differ diff --git a/scripts/savor/backend/photos/jade.jpg b/scripts/savor/backend/photos/jade.jpg new file mode 100644 index 00000000000..1341ecf8256 Binary files /dev/null and b/scripts/savor/backend/photos/jade.jpg differ diff --git a/scripts/savor/backend/photos/juice.jpg b/scripts/savor/backend/photos/juice.jpg new file mode 100644 index 00000000000..ee6bd5db6f9 Binary files /dev/null and b/scripts/savor/backend/photos/juice.jpg differ diff --git a/scripts/savor/backend/photos/kumo.jpg b/scripts/savor/backend/photos/kumo.jpg new file mode 100644 index 00000000000..7cdc73e8778 Binary files /dev/null and b/scripts/savor/backend/photos/kumo.jpg differ diff --git a/scripts/savor/backend/photos/luz.jpg b/scripts/savor/backend/photos/luz.jpg new file mode 100644 index 00000000000..46b010aca8b Binary files /dev/null and b/scripts/savor/backend/photos/luz.jpg differ diff --git a/scripts/savor/backend/photos/mezze.jpg b/scripts/savor/backend/photos/mezze.jpg new file mode 100644 index 00000000000..e9d5feac9d6 Binary files /dev/null and b/scripts/savor/backend/photos/mezze.jpg differ diff --git a/scripts/savor/backend/photos/mushrooms.jpg b/scripts/savor/backend/photos/mushrooms.jpg new file mode 100644 index 00000000000..58f03697f6e Binary files /dev/null and b/scripts/savor/backend/photos/mushrooms.jpg differ diff --git a/scripts/savor/backend/photos/onion.jpg b/scripts/savor/backend/photos/onion.jpg new file mode 100644 index 00000000000..97a7980b114 Binary files /dev/null and b/scripts/savor/backend/photos/onion.jpg differ diff --git a/scripts/savor/backend/photos/pancakes.jpg b/scripts/savor/backend/photos/pancakes.jpg new file mode 100644 index 00000000000..ab8413ac415 Binary files /dev/null and b/scripts/savor/backend/photos/pancakes.jpg differ diff --git a/scripts/savor/backend/photos/prime.jpg b/scripts/savor/backend/photos/prime.jpg new file mode 100644 index 00000000000..cd843ed7de8 Binary files /dev/null and b/scripts/savor/backend/photos/prime.jpg differ diff --git a/scripts/savor/backend/photos/ritual.jpg b/scripts/savor/backend/photos/ritual.jpg new file mode 100644 index 00000000000..8ebf60afc53 Binary files /dev/null and b/scripts/savor/backend/photos/ritual.jpg differ diff --git a/scripts/savor/backend/photos/saffron.jpg b/scripts/savor/backend/photos/saffron.jpg new file mode 100644 index 00000000000..beeb35579d7 Binary files /dev/null and b/scripts/savor/backend/photos/saffron.jpg differ diff --git a/scripts/savor/backend/photos/steak-frites.jpg b/scripts/savor/backend/photos/steak-frites.jpg new file mode 100644 index 00000000000..1671c0f0aac Binary files /dev/null and b/scripts/savor/backend/photos/steak-frites.jpg differ diff --git a/scripts/savor/backend/photos/toro-roll.jpg b/scripts/savor/backend/photos/toro-roll.jpg new file mode 100644 index 00000000000..b22be16617f Binary files /dev/null and b/scripts/savor/backend/photos/toro-roll.jpg differ diff --git a/scripts/savor/backend/photos/velvet.jpg b/scripts/savor/backend/photos/velvet.jpg new file mode 100644 index 00000000000..12b5cace2f7 Binary files /dev/null and b/scripts/savor/backend/photos/velvet.jpg differ diff --git a/scripts/savor/backend/photos/verde.jpg b/scripts/savor/backend/photos/verde.jpg new file mode 100644 index 00000000000..27eb7232880 Binary files /dev/null and b/scripts/savor/backend/photos/verde.jpg differ diff --git a/scripts/savor/backend/pom.xml b/scripts/savor/backend/pom.xml new file mode 100644 index 00000000000..a336210b612 --- /dev/null +++ b/scripts/savor/backend/pom.xml @@ -0,0 +1,160 @@ + + + 4.0.0 + + com.codenameone.examples.savor + savor + 1.0-SNAPSHOT + + com.codenameone.examples.savor + savor-backend + 1.0-SNAPSHOT + jar + + savor-backend + + + + + com.codenameone + codenameone-backend + ${cn1.version} + + + + ${project.groupId} + savor-shared + ${project.version} + + + + org.xerial + sqlite-jdbc + 3.46.1.0 + runtime + + + + com.codenameone + codenameone-backend-test + ${cn1.version} + test + + + org.junit.jupiter + junit-jupiter + 5.9.3 + test + + + + + + + com.codenameone + codenameone-maven-plugin + ${cn1.plugin.version} + + + + cn1-generate-annotation-stubs + generate-sources + + + generate-annotation-stubs + + + + cn1-process-annotations + process-classes + + + process-annotations + + + + cn1-process-test-annotations + process-test-classes + + + process-test-annotations + + + + cn1-compiled-tests + test + + + backend-test + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.0.0-M5 + + + false + + + + + diff --git a/scripts/savor/backend/server.sh b/scripts/savor/backend/server.sh new file mode 100755 index 00000000000..a4fde6d1cb1 --- /dev/null +++ b/scripts/savor/backend/server.sh @@ -0,0 +1,112 @@ +#!/usr/bin/env bash +# +# Builds and runs the Savor server. +# +# server.sh build [--jvm|--native] [--web] +# server.sh run [--jvm|--native] [--web] [--prebuilt] [--port N] +# server.sh web +# +# --jvm (the default) runs the server on this JDK, which is how you work on it. +# --native compiles it to a native binary, which is how it ships. +# +# The profile comes from CN1_PROFILE. `test` and `dev` need nothing else: an +# in-memory database, four demo accounts, a geocoder that knows ten places +# (`test`) and text messages that are printed, not sent. Anything else is a +# deployment and reads its settings from the environment -- see +# application.properties. +# +# `web` builds the app for the browser and stages it in target/webapp, which +# takes about a minute; `--web` does that as part of a build or a run. A server +# that finds the app there serves it at /, so http://localhost:8080/ is Savor +# with nothing installed. It stays staged until the next `mvn clean`: build it +# once, and again when the app changed. +# +# The server prints "listening on http port N" when it accepts connections. +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +APP="$(cd "$HERE/.." && pwd)" +# The project's own Maven wrapper where it has one. +if [ -z "${MVN:-}" ]; then + if [ -x "$APP/mvnw" ]; then MVN="$APP/mvnw"; else MVN="mvn"; fi +fi + +log() { echo "[savor-server] $*" >&2; } + +build_jvm() { + log "building the server for the JVM" + "$MVN" -B -q -f "$APP/pom.xml" -Dcodename1.platform=backend -pl shared,backend \ + -Dmaven.test.skip=true package dependency:copy-dependencies \ + -DincludeScope=runtime -DoutputDirectory="$APP/backend/target/server-lib" >&2 +} + +build_native() { + log "translating the server to a native binary" + "$MVN" -B -q -f "$APP/pom.xml" -Dcodename1.platform=backend -pl shared,backend \ + -Dmaven.test.skip=true package cn1:backend-package \ + -Dcn1.backend.output="$APP/backend/target/savor-server" >&2 +} + +build_web() { + log "building the app for the browser" + "$MVN" -B -q -f "$APP/pom.xml" -Dcodename1.platform=backend -pl backend \ + cn1:backend-webapp >&2 +} + +# The server looks for the app in `webapp` beside its settings; here it is build +# output, so it is under target and the server is told. An address set by the +# caller is left alone. +serve_web() { + if [ -z "${CN1_WEBAPP_ROOT:-}" ] && [ -f "$APP/backend/target/webapp/index.html" ]; then + export CN1_WEBAPP_ROOT="target/webapp" + log "serving the web app in backend/target/webapp" + fi +} + +command="${1:-run}" +shift || true +mode="jvm" +port="${PORT:-8080}" +prebuilt=0 +web=0 +while [ $# -gt 0 ]; do + case "$1" in + --jvm) mode="jvm" ;; + --native) mode="native" ;; + --prebuilt) prebuilt=1 ;; + --web) web=1 ;; + --port) port="$2"; shift ;; + *) log "unknown argument $1"; exit 2 ;; + esac + shift +done + +case "$command" in + build) + if [ "$mode" = "native" ]; then build_native; else build_jvm; fi + [ "$web" = 0 ] || build_web + ;; + web) + build_web + ;; + run) + # application.properties is read from the working directory. + cd "$APP/backend" + if [ "$mode" = "native" ]; then + [ "$prebuilt" = 1 ] || build_native + [ "$web" = 0 ] || [ "$prebuilt" = 1 ] || build_web + serve_web + PORT="$port" exec "$APP/backend/target/savor-server" + fi + [ "$prebuilt" = 1 ] || build_jvm + [ "$web" = 0 ] || [ "$prebuilt" = 1 ] || build_web + serve_web + main="$(tr -d '\r\n' < "$APP/backend/target/classes/META-INF/cn1-backend-main")" + log "running $main on port $port" + PORT="$port" exec java -cp "$APP/backend/target/classes:$APP/backend/target/server-lib/*" "$main" + ;; + *) + log "unknown command $command (build, run, web)" + exit 2 + ;; +esac diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/Caller.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/Caller.java new file mode 100644 index 00000000000..82a3d473ae2 --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/Caller.java @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor; + +import com.codename1.backend.ResponseStatusException; +import com.codename1.backend.security.Authentication; +import com.codename1.backend.security.GrantedAuthority; +import com.codename1.backend.security.SecurityContextHolder; + +import java.util.Iterator; + +/// Who is making the request being served. +/// +/// The endpoints implement interfaces generated from the shared contract, whose +/// methods take what the app sends and nothing else, so the caller is read from +/// the security context of the request and not from a parameter. +public final class Caller { + private Caller() { + } + + /// The signed-in user's name, which is the e-mail address of the account. + /// Answers 401 when nobody is signed in; the filter chain has normally done + /// so already, and this is what stands behind it. + public static String name() { + Authentication who = SecurityContextHolder.getContext().getAuthentication(); + if (who == null || !who.isAuthenticated() || who.getName() == null) { + throw new ResponseStatusException(401, "Sign in first"); + } + return who.getName(); + } + + /// Whether the caller holds `role`, named without its `ROLE_` prefix. + public static boolean hasRole(String role) { + Authentication who = SecurityContextHolder.getContext().getAuthentication(); + if (who == null || who.getAuthorities() == null) { + return false; + } + String wanted = "ROLE_" + role; + Iterator it = who.getAuthorities().iterator(); + while (it.hasNext()) { + if (wanted.equals(it.next().getAuthority())) { + return true; + } + } + return false; + } +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/Days.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/Days.java new file mode 100644 index 00000000000..4fa9a5c36a9 --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/Days.java @@ -0,0 +1,123 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor; + +/// Days, counted in UTC. +/// +/// The charts and the earnings are by day, and a day has to mean the same on +/// SQLite, PostgreSQL and MySQL, whose date functions agree on nothing. So the +/// tables hold milliseconds since the epoch, and the arithmetic is done here. +/// UTC throughout: a service in one city would shift by that city's offset. +public final class Days { + public static final long MILLIS = 86400000L; + + private Days() { + } + + /// The number of the day `millis` falls in: whole days since the epoch. + public static long number(long millis) { + return millis / MILLIS; + } + + /// The first millisecond of the day `millis` falls in. + public static long start(long millis) { + return millis - millis % MILLIS; + } + + /// The stretch of time from `from` to `to` cut at each midnight: the + /// number of each day it touches followed by how many of its milliseconds + /// fall in that day, day after day. Empty when `to` is not after `from`. + /// + /// Time on line is credited to the days it was spent in. Credited whole + /// to the day it ended in, a courier on line across midnight had the + /// minutes before it counted as the next day's. + public static long[] split(long from, long to) { + if (to <= from) { + return new long[0]; + } + long[] out = new long[(int) (number(to - 1) - number(from) + 1) * 2]; + long at = from; + for (int iter = 0; iter < out.length; iter += 2) { + long end = Math.min(to, start(at) + MILLIS); + out[iter] = number(at); + out[iter + 1] = end - at; + at = end; + } + return out; + } + + /// The day `millis` falls in, as `yyyy-MM-dd`. + public static String iso(long millis) { + // Days since the epoch to a civil date, by the usual era arithmetic: + // shift the year to start in March so the leap day is its last day. + long z = number(millis) + 719468L; + long era = z / 146097L; + long dayOfEra = z - era * 146097L; + long yearOfEra = (dayOfEra - dayOfEra / 1460L + dayOfEra / 36524L - dayOfEra / 146096L) + / 365L; + long dayOfYear = dayOfEra - (365L * yearOfEra + yearOfEra / 4L - yearOfEra / 100L); + long shifted = (5L * dayOfYear + 2L) / 153L; + long day = dayOfYear - (153L * shifted + 2L) / 5L + 1L; + long month = shifted < 10L ? shifted + 3L : shifted - 9L; + long year = yearOfEra + era * 400L + (month <= 2L ? 1L : 0L); + StringBuilder out = new StringBuilder(10); + out.append(year).append('-'); + if (month < 10L) { + out.append('0'); + } + out.append(month).append('-'); + if (day < 10L) { + out.append('0'); + } + return out.append(day).toString(); + } + + /// Whether `text` is a date written `yyyy-MM-dd`, with a month and a day + /// that could exist. + public static boolean isIso(String text) { + if (text == null || text.length() != 10 || text.charAt(4) != '-' || text.charAt(7) != '-') { + return false; + } + for (int iter = 0; iter < 10; iter++) { + char c = text.charAt(iter); + if (iter != 4 && iter != 7 && (c < '0' || c > '9')) { + return false; + } + } + int year = 0; + for (int iter = 0; iter < 4; iter++) { + year = year * 10 + (text.charAt(iter) - '0'); + } + int month = (text.charAt(5) - '0') * 10 + (text.charAt(6) - '0'); + int day = (text.charAt(8) - '0') * 10 + (text.charAt(9) - '0'); + // The day is held to its own month: these strings are compared as + // text and never parsed, so a 31st of February accepted here would be + // stored, and approved, as somebody's date of birth. + return month >= 1 && month <= 12 && day >= 1 && day <= length(year, month); + } + + // How many days a month has, by the Gregorian rule for leap years. + private static int length(int year, int month) { + if (month == 2) { + boolean leap = year % 4 == 0 && (year % 100 != 0 || year % 400 == 0); + return leap ? 29 : 28; + } + return month == 4 || month == 6 || month == 9 || month == 11 ? 30 : 31; + } +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/Ids.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/Ids.java new file mode 100644 index 00000000000..a0b3035ab5b --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/Ids.java @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor; + +import com.codename1.backend.Crypto; + +import java.io.IOException; + +/// Identifiers and secrets made of random bytes. +public final class Ids { + private static final char[] HEX = "0123456789abcdef".toCharArray(); + + private Ids() { + } + + /// A new identifier: 32 hex digits from 16 random bytes. Random and not a + /// counter, so the id of one delivery says nothing about the id of another, and + /// so no table needs a generated key, which every database spells its own way. + public static String next() throws IOException { + return hex(Crypto.randomBytes(16)); + } + + public static String hex(byte[] bytes) { + char[] out = new char[bytes.length * 2]; + for (int iter = 0; iter < bytes.length; iter++) { + out[iter * 2] = HEX[(bytes[iter] >> 4) & 0xf]; + out[iter * 2 + 1] = HEX[bytes[iter] & 0xf]; + } + return new String(out); + } + + /// The SHA-256 of `text`, in hex. What is stored in place of a secret that + /// only ever needs to be compared. + public static String sha256(String text) throws IOException { + return hex(Crypto.sha256(text.getBytes("UTF-8"))); + } +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/Roles.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/Roles.java new file mode 100644 index 00000000000..9705fcaeaf2 --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/Roles.java @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor; + +/// The roles an account can hold. Every account is a customer; delivering, +/// running a restaurant and administering are granted on top of that, and the +/// app offers a mode for each role the account holds. +public final class Roles { + public static final String CUSTOMER = "CUSTOMER"; + public static final String COURIER = "COURIER"; + public static final String ADMIN = "ADMIN"; + /// Works for a restaurant on the service: takes its orders and keeps its + /// menu. Which restaurant is the restaurant's staff list, not the role. + public static final String MERCHANT = "MERCHANT"; + + private Roles() { + } +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/Rows.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/Rows.java new file mode 100644 index 00000000000..6c84eae362d --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/Rows.java @@ -0,0 +1,94 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor; + +import com.codenameone.examples.savor.api.DeliveryState; +import com.codenameone.examples.savor.api.OrderState; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/// Reading the rows a repository answers for a select of several values, +/// `select r.courier, count(r) ...`, which come back as `Object[]`. +/// +/// What a column holds is checked and never cast blindly: a count is a `Long` +/// on one database and an `Integer` on another, and a state is the enum or +/// its name depending on how the column was read. +public final class Rows { + private Rows() { + } + + /// A column as a whole number; 0 for null or anything that is not a + /// number. + public static long number(Object value) { + return value instanceof Number ? ((Number) value).longValue() : 0L; + } + + /// A column as text; empty for null. + public static String text(Object value) { + return value == null ? "" : String.valueOf(value); + } + + /// A column holding a delivery's state, or null for one that names none. + /// An order's state as a query answers it: the enum, or its name. + public static OrderState orderState(Object value) { + if (value instanceof OrderState) { + return (OrderState) value; + } + String name = text(value); + OrderState[] all = OrderState.values(); + for (int iter = 0; iter < all.length; iter++) { + if (all[iter].name().equals(name)) { + return all[iter]; + } + } + return null; + } + + public static DeliveryState state(Object value) { + if (value instanceof DeliveryState) { + return (DeliveryState) value; + } + String name = text(value); + DeliveryState[] all = DeliveryState.values(); + for (int iter = 0; iter < all.length; iter++) { + if (all[iter].name().equals(name)) { + return all[iter]; + } + } + return null; + } + + /// Rows of `{name, count}` as a map from the name to the count. + public static Map counts(List rows) { + Map out = new HashMap(); + for (int iter = 0; iter < rows.size(); iter++) { + Object[] row = rows.get(iter); + out.put(text(row[0]), Long.valueOf(number(row[1]))); + } + return out; + } + + /// A single number a repository answers, which is null for a sum or a + /// maximum of no rows. + public static long number(Long value) { + return value == null ? 0L : value.longValue(); + } +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/SecurityConfig.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/SecurityConfig.java new file mode 100644 index 00000000000..8ff21d94ed8 --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/SecurityConfig.java @@ -0,0 +1,423 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor; + +import com.codename1.backend.Config; +import com.codename1.backend.DataSource; +import com.codename1.backend.HttpServer; +import com.codename1.backend.annotations.Bean; +import com.codename1.backend.annotations.Configuration; +import com.codename1.backend.annotations.Order; +import com.codename1.backend.annotations.Value; +import com.codename1.backend.security.AntPathRequestMatcher; +import com.codename1.backend.security.DisabledException; +import com.codename1.backend.security.GrantedAuthority; +import com.codename1.backend.security.HttpSecurity; +import com.codename1.backend.security.HttpStatusReturningLogoutSuccessHandler; +import com.codename1.backend.security.LogoutFilter; +import com.codename1.backend.security.SecurityExchange; +import com.codename1.backend.security.SecurityFilter; +import com.codename1.backend.security.SecurityFilterChain; +import com.codename1.backend.security.SessionCreationPolicy; +import com.codename1.backend.security.core.userdetails.JdbcUserDetailsManager; +import com.codename1.backend.security.core.userdetails.UserDetails; +import com.codename1.backend.security.crypto.JwkSource; +import com.codename1.backend.security.crypto.PasswordEncoder; +import com.codename1.backend.security.crypto.PasswordEncoderFactories; +import com.codename1.backend.security.oauth2.core.AuthorizationGrantType; +import com.codename1.backend.security.oauth2.core.ClientAuthenticationMethod; +import com.codename1.backend.security.oauth2.jwt.DefaultJwtDecoder; +import com.codename1.backend.security.oauth2.jwt.JwtAudienceValidator; +import com.codename1.backend.security.oauth2.jwt.JwtClaimValidator; +import com.codename1.backend.security.oauth2.jwt.JwtIssuerValidator; +import com.codename1.backend.security.oauth2.jwt.JwtValidators; +import com.codename1.backend.security.oauth2.server.authorization.AuthorizationServerKeys; +import com.codename1.backend.security.oauth2.server.authorization.AuthorizationServerSettings; +import com.codename1.backend.security.oauth2.server.authorization.JdbcOAuth2AuthorizationService; +import com.codename1.backend.security.oauth2.server.authorization.JdbcRegisteredClientRepository; +import com.codename1.backend.security.oauth2.server.authorization.OAuth2AuthorizationService; +import com.codename1.backend.security.oauth2.server.authorization.RegisteredClient; +import com.codename1.backend.security.oauth2.server.authorization.RegisteredClientRepository; +import com.codename1.backend.security.oauth2.server.resource.JwtAuthenticationConverter; +import com.codename1.backend.security.ratelimit.InMemoryRateLimiter; +import com.codename1.backend.security.ratelimit.RateLimitKeys; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collection; + +/// Who may call what. +/// +/// This server is two things at once, and has a filter chain for each: +/// +/// - **An authorization server.** The app signs a user in here -- e-mail and +/// password, then the authorization-code flow with PKCE -- and gets a +/// short-lived access token and a refresh token. Nothing but this server ever +/// sees the password. +/// - **An API.** Everything under `/api` takes that access token as a bearer +/// token, keeps no session, and decides from the account's roles what the +/// caller may do. +/// +/// The roles are CUSTOMER, COURIER and ADMIN ([Roles]). `/api/courier` is for +/// couriers and `/api/admin` for admins; the rest is for anyone signed in, and +/// acts on the caller's own account, cards, receipts and onboarding application. +/// `/api/onboarding` is that last one, and is deliberately not under +/// `/api/courier`: applying to deliver is what someone does before they may. +@Configuration +public class SecurityConfig { + /// The id the app signs in as. A public client: it holds no secret, since + /// anything shipped inside an app can be read out of it, and proves itself + /// with PKCE instead. + public static final String CLIENT_ID = "savor-app"; + /// Where the authorization code is sent back to. The app makes the requests + /// of the flow itself and reads the code out of the redirect, so this is + /// never opened; it is the loopback address because that is the one address + /// nobody else can register a listener on. An app that signs in through the + /// system browser adds its own address scheme here with `redirectUri`. + public static final String LOOPBACK_REDIRECT = "http://127.0.0.1/callback"; + /// Where the code is sent back to when the app runs in a browser: a path on + /// this server, which answers with the code it was given. See + /// [#withWebRedirect]. + public static final String WEB_REDIRECT_PATH = "/signin/code"; + /// Whom the access tokens are for. The API accepts no token issued for + /// anything else. + public static final String AUDIENCE = "urn:savor:api"; + public static final String ISSUER_KEY = "cn1.security.authorizationserver.issuer"; + + @Bean + public PasswordEncoder passwordEncoder() { + return PasswordEncoderFactories.createDelegatingPasswordEncoder(); + } + + /// The accounts, in the tables `cn1.security.schema.enabled` creates. + @Bean + public JdbcUserDetailsManager users(DataSource db) { + return new JdbcUserDetailsManager(db); + } + + @Bean + public RegisteredClientRepository clients(DataSource db, + @Value("${" + ISSUER_KEY + ":}") final String issuer) throws IOException { + final JdbcRegisteredClientRepository clients = new JdbcRegisteredClientRepository(db); + if (clients.findByClientId(CLIENT_ID) == null) { + clients.save(RegisteredClient.withId("savor").clientId(CLIENT_ID) + .clientName("Savor") + .clientAuthenticationMethod(ClientAuthenticationMethod.NONE) + .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) + .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN) + .redirectUri(LOOPBACK_REDIRECT) + .scope("openid").scope("profile") + .build()); + } + // The app in a browser is the same client with one more address: this + // server's own. It is worked out and not stored, because on a + // development profile the server has no one address to store -- and a + // row written by an earlier version would not have it either. + return new RegisteredClientRepository() { + @Override + public void save(RegisteredClient client) { + clients.save(client); + } + + @Override + public RegisteredClient findByClientId(String clientId) { + return withWebRedirect(clients.findByClientId(clientId), issuer); + } + + @Override + public RegisteredClient findById(String id) { + return withWebRedirect(clients.findById(id), issuer); + } + }; + } + + /// The app's registration with [#WEB_REDIRECT_PATH] on this server added + /// to the addresses a code may be sent back to. + /// + /// A browser will not show a page the answer to a request that was + /// redirected to another origin, so the app running in one cannot read its + /// code off a redirect to the loopback address the way the installed app + /// does. It asks for the code to be sent to this server instead, which + /// hands it straight back ([SignInCodePage]). + /// + /// "This server" is the configured issuer where there is one, and is then + /// the only web address accepted. With none -- a development profile -- it + /// is the address the request arrived at, the same rule + /// [#issuedAtThisAddress] holds a token to: a code is only ever sent back + /// to the server the browser was already talking to. + static RegisteredClient withWebRedirect(RegisteredClient client, String issuer) { + if (client == null || !CLIENT_ID.equals(client.getClientId())) { + return client; + } + String here = here(issuer); + if (here == null) { + return client; + } + RegisteredClient.Builder copy = RegisteredClient.withId(client.getId()) + .clientId(client.getClientId()) + .clientName(client.getClientName()) + .clientSettings(client.getClientSettings()) + .tokenSettings(client.getTokenSettings()) + .redirectUri(here + WEB_REDIRECT_PATH); + for (ClientAuthenticationMethod method : client.getClientAuthenticationMethods()) { + copy.clientAuthenticationMethod(method); + } + for (AuthorizationGrantType grant : client.getAuthorizationGrantTypes()) { + copy.authorizationGrantType(grant); + } + for (String uri : client.getRedirectUris()) { + copy.redirectUri(uri); + } + for (String scope : client.getScopes()) { + copy.scope(scope); + } + for (String resource : client.getResources()) { + copy.resource(resource); + } + return copy.build(); + } + + /// Refuses a request that changes something and was sent by a page of + /// another site. + /// + /// A browser names the site a request came from in `Origin` on every + /// request that is not a plain read, and a page cannot change or drop it. + /// The installed app is not a browser and sends none, so a request without + /// one is let through: there is no cookie jar of somebody else's for it to + /// be riding on. + static SecurityFilter sameOrigin(final String issuer) { + return (request, chain) -> { + String method = request.getMethod(); + if ("GET".equals(method) || "HEAD".equals(method) || "OPTIONS".equals(method)) { + return chain.doFilter(request); + } + String origin = request.getHeader("Origin"); + if (origin == null || origin.length() == 0) { + return chain.doFilter(request); + } + String here = here(issuer); + if (here != null && sameOrigin(origin, here)) { + return chain.doFilter(request); + } + return new HttpServer.Response(403, "application/json", + "{\"error\":\"cross_origin\"}".getBytes("UTF-8")) + .header("Cache-Control", "no-store"); + }; + } + + /// This server's address: the configured issuer, and with none -- a + /// development profile -- the address the request in hand arrived at. Null + /// outside a request that says. + static String here(String issuer) { + if (issuer != null && issuer.length() > 0) { + return issuer; + } + SecurityExchange exchange = SecurityExchange.current(); + String host = exchange == null ? null : exchange.getRequest().getHeader("Host"); + if (host == null || host.length() == 0) { + return null; + } + return (exchange.isSecure() ? "https://" : "http://") + host; + } + + /// Whether `origin` is the origin of `address`: the same scheme, host and + /// port, whatever path the address goes on to name. Scheme and host are + /// compared without regard to case, and without folding it -- that would + /// depend on the language the server runs in. + static boolean sameOrigin(String origin, String address) { + int path = address.indexOf('/', address.indexOf("://") + 3); + String root = path < 0 ? address : address.substring(0, path); + return origin.equalsIgnoreCase(root); + } + + /// Kept in the database, so a refresh token outlives a restart and is good + /// on every instance of the server. + @Bean + public OAuth2AuthorizationService authorizations(DataSource db) { + return new JdbcOAuth2AuthorizationService(db); + } + + /// The keys the tokens are signed with: the files named by + /// `cn1.security.authorizationserver.jwk.keys`. On a development profile + /// with none configured, a key made at start-up -- which is why signing in + /// again is needed after restarting a development server. + @Bean + public JwkSource signingKeys(Config config) throws IOException { + return AuthorizationServerKeys.load(config); + } + + /// Signing in. A session exists here and only here, for the few requests + /// between the password being accepted and the code being handed over; + /// the app ends it with `POST /logout` once it has its tokens, and one it + /// never ends runs out after `cn1.session.timeout` seconds without a + /// request, half an hour unless set. + @Bean + @Order(1) + public SecurityFilterChain signIn(HttpSecurity http, + @Value("${" + ISSUER_KEY + ":}") final String issuer, + @Value("${savor.signin.per-minute:10}") int signInsPerMinute) throws IOException { + http.securityMatcher("/oauth2/**", "/.well-known/**", "/userinfo", "/login", "/login/**", + "/logout") + .authorizeHttpRequests(auth -> auth.anyRequest().authenticated()) + // No CSRF token: the app posts the sign-in form itself, from its own + // screen, and would have to fetch a token first to send one. What a + // token is for still has to be done, because the app also runs in a + // browser, where the session is a cookie the browser keeps and would + // send along with a form another site submitted -- signing its + // visitor in as somebody else. Two things stand in for it. The + // session cookie is `SameSite=Lax`, the server's default, so it does + // not travel with another site's request; and [#sameOrigin] below + // refuses any request that changes something and says it came from + // another site, which is the one a forged sign-in is. A server that + // signs people in through a page it serves keeps CSRF on. + .csrf(csrf -> csrf.disable()) + .addFilterBefore(sameOrigin(issuer), LogoutFilter.class) + // Signing out is a POST and nothing else -- with CSRF off the + // default takes a GET too, which an image tag on any page can send + // -- and is answered with a status, for the reason signing in is. + .logout(logout -> logout + .logoutRequestMatcher(AntPathRequestMatcher.antMatcher("POST", "/logout")) + .logoutSuccessHandler(new HttpStatusReturningLogoutSuccessHandler(204))) + // And it is answered with a status, not a redirect to a page: + // the app in a browser cannot see a redirect or the cookie, only + // how the request ended, so that has to be the answer. The session + // cookie is set either way; the installed app reads it, a browser + // keeps it. + .formLogin(form -> form + .successHandler((request, authentication) -> new HttpServer.Response(200, + "application/json", "{\"signedIn\":true}".getBytes("UTF-8")) + .header("Cache-Control", "no-store")) + .failureHandler((request, refused) -> new HttpServer.Response(401, + "application/json", "{\"signedIn\":false}".getBytes("UTF-8")) + .header("Cache-Control", "no-store"))) + // Guessing passwords is the attack on this chain. Ten tries a minute + // from one address, unless `savor.signin.per-minute` says otherwise: + // the end-to-end tests are many people signing in from one address, + // and their profile raises it. + .rateLimit("/login", RateLimitKeys.clientAddress(), + new InMemoryRateLimiter(signInsPerMinute, 60)) + .authorizationServer(server -> server + .settings(AuthorizationServerSettings.builder() + .defaultAudience(AUDIENCE).build())); + return http.build(); + } + + /// The API. Stateless: every request stands on its own bearer token. + @Bean + @Order(2) + public SecurityFilterChain api(HttpSecurity http, JwkSource signingKeys, + @Value("${" + ISSUER_KEY + ":}") final String issuer, + JdbcUserDetailsManager users) throws IOException { + DefaultJwtDecoder decoder = DefaultJwtDecoder.withJwkSource(signingKeys).build(); + decoder.setJwtValidator(JwtValidators.createDefaultWithValidators( + new JwtAudienceValidator(AUDIENCE), + issuer.length() > 0 ? new JwtIssuerValidator(issuer) + : new JwtClaimValidator("iss", SecurityConfig::issuedAtThisAddress))); + + // The token says who the caller is and nothing about what they may do. + // The roles are read from the account on every request instead of being + // copied into the token, for one reason: an admin who withdraws a role + // or suspends an account means now, not when the token runs out. It + // costs a lookup by primary key per request. + JwtAuthenticationConverter roles = new JwtAuthenticationConverter(); + roles.setJwtGrantedAuthoritiesConverter(jwt -> { + String name = jwt.getSubject(); + if (name == null || !users.userExists(name)) { + throw new DisabledException("No such account"); + } + UserDetails account = users.loadUserByUsername(name); + if (!account.isEnabled() || !account.isAccountNonLocked()) { + throw new DisabledException("This account is suspended"); + } + Collection held = new ArrayList(); + held.addAll(account.getAuthorities()); + return held; + }); + + http.securityMatcher("/api/**") + .sessionManagement(session -> + session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + // Opening accounts and sending text messages are the two things + // here that cost something each time and that a stranger can ask + // for. One limiter per rule: a shared one would share its count. + .rateLimit("/api/account/register", RateLimitKeys.clientAddress(), + new InMemoryRateLimiter(20, 3600)) + .rateLimit("/api/account/phone/**", + RateLimitKeys.firstOf(RateLimitKeys.principal(), RateLimitKeys.clientAddress()), + new InMemoryRateLimiter(10, 600)) + // Guessing the current password with a token in hand, and filling + // the database with pictures: an hour's allowance of each is more + // than anyone honest needs. + .rateLimit("/api/account/password", + RateLimitKeys.firstOf(RateLimitKeys.principal(), RateLimitKeys.clientAddress()), + new InMemoryRateLimiter(10, 3600)) + // Every search and every dropped pin is a request this server + // makes of somebody else's geocoder, in the service's name. A + // person typing an address makes a few a second at most; a client + // in a loop would get the whole service refused by the provider. + .rateLimit("/api/geo/**", + RateLimitKeys.firstOf(RateLimitKeys.principal(), RateLimitKeys.clientAddress()), + new InMemoryRateLimiter(120, 60)) + .rateLimit("/api/onboarding/application/documents", + RateLimitKeys.firstOf(RateLimitKeys.principal(), RateLimitKeys.clientAddress()), + new InMemoryRateLimiter(60, 3600)) + // Restaurant photos are the other way to fill the database with + // pictures: an application's own and a listed menu's. Each route + // gets its own limiter, for the reason given above. + .rateLimit("/api/restaurants/application/photo", + RateLimitKeys.firstOf(RateLimitKeys.principal(), RateLimitKeys.clientAddress()), + new InMemoryRateLimiter(60, 3600)) + .rateLimit("/api/merchant/photos", + RateLimitKeys.firstOf(RateLimitKeys.principal(), RateLimitKeys.clientAddress()), + new InMemoryRateLimiter(60, 3600)) + // Every card setup is a session made at the payment processor. + // `Payments` caps how many an account has open; this caps how + // often it may start one, so abandoning setups in a loop is slow. + .rateLimit("/api/payments/methods/setup", + RateLimitKeys.firstOf(RateLimitKeys.principal(), RateLimitKeys.clientAddress()), + new InMemoryRateLimiter(30, 3600)) + // tag::savor-api-rules[] + .authorizeHttpRequests(auth -> auth + .requestMatchers(AntPathRequestMatcher.antMatcher("POST", + "/api/account/register")).permitAll() + .requestMatchers("/api/courier/**").hasRole(Roles.COURIER) + .requestMatchers("/api/admin/**").hasRole(Roles.ADMIN) + .requestMatchers("/api/merchant/**").hasRole(Roles.MERCHANT) + .anyRequest().authenticated()) + // end::savor-api-rules[] + .oauth2ResourceServer(oauth2 -> oauth2.jwt(jwt -> jwt.decoder(decoder) + .jwtAuthenticationConverter(roles))); + return http.build(); + } + + /// Whether a token's issuer is the address this request came to. + /// + /// Used only when no issuer is configured, which the server allows only on + /// a development profile: it then issues tokens in the name of whatever + /// address each request arrived at, so that is the name to hold a token to. + static boolean issuedAtThisAddress(Object issuer) { + SecurityExchange exchange = SecurityExchange.current(); + if (exchange == null || !(issuer instanceof String)) { + return false; + } + String host = exchange.getRequest().getHeader("Host"); + return host != null && ((exchange.isSecure() ? "https://" : "http://") + host) + .equals(issuer); + } +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/Services.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/Services.java new file mode 100644 index 00000000000..c48e80058b2 --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/Services.java @@ -0,0 +1,242 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor; + +import com.codename1.backend.Config; +import com.codename1.backend.annotations.Bean; +import com.codename1.backend.annotations.Configuration; +import com.codename1.backend.annotations.Value; +import com.codename1.backend.push.CloudPush; +import com.codenameone.examples.savor.geo.Geocoder; +import com.codenameone.examples.savor.geo.PhotonGeocoder; +import com.codenameone.examples.savor.geo.StubGeocoder; +import com.codenameone.examples.savor.pay.MisconfiguredPayments; +import com.codenameone.examples.savor.pay.PaymentProvider; +import com.codenameone.examples.savor.pay.SimulatedPayments; +import com.codenameone.examples.savor.pay.StripePayments; +import com.codenameone.examples.savor.push.CloudPushSender; +import com.codenameone.examples.savor.push.ConsolePushSender; +import com.codenameone.examples.savor.push.MisconfiguredPushSender; +import com.codenameone.examples.savor.push.PushSender; +import com.codenameone.examples.savor.push.RecordingPushSender; +import com.codenameone.examples.savor.sms.ConsoleSmsSender; +import com.codenameone.examples.savor.sms.MessageBirdSmsSender; +import com.codenameone.examples.savor.sms.MisconfiguredSmsSender; +import com.codenameone.examples.savor.sms.SmsSender; +import com.codenameone.examples.savor.sms.TwilioSmsSender; +import com.codenameone.examples.savor.sms.VonageSmsSender; + +import java.io.IOException; + +/// The services this server reaches outside itself for, each behind an +/// interface and each chosen here from the settings. +/// +/// Each is named by one setting -- `savor.sms.provider`, +/// `savor.payments.provider` and `savor.push.provider` -- and, when that is +/// not set, a development profile (dev, test, local) gets the one that needs no +/// account: codes and notifications printed on the console, cards simulated. Any other profile gets the real provider, +/// and when its settings are missing the server still starts, logs a +/// configuration error naming each missing setting, and fails every call to +/// that provider with the same message. It never quietly falls back to a +/// development stand-in. +/// +/// Every setting is also read from the environment, in upper case with `_` +/// for each `.` (`SAVOR_SMS_TWILIO_TOKEN`), which is where secrets belong. +@Configuration +public class Services { + /// `savor.sms.provider` is one of: + /// + /// - `twilio`: `savor.sms.twilio.sid`, `.token` and `.from` + /// - `vonage`: `savor.sms.vonage.key`, `.secret` and `.from` + /// - `messagebird`: `savor.sms.messagebird.key` and `.originator` + /// - `console`: prints the message, on a development profile only + /// + /// Unset, it is `console` on a development profile and `twilio` otherwise. + @Bean + public SmsSender smsSender(Config config, + @Value("${savor.sms.provider:}") String chosen, + @Value("${savor.sms.twilio.sid:}") String twilioSid, + @Value("${savor.sms.twilio.token:}") String twilioToken, + @Value("${savor.sms.twilio.from:}") String twilioFrom, + @Value("${savor.sms.vonage.key:}") String vonageKey, + @Value("${savor.sms.vonage.secret:}") String vonageSecret, + @Value("${savor.sms.vonage.from:}") String vonageFrom, + @Value("${savor.sms.messagebird.key:}") String birdKey, + @Value("${savor.sms.messagebird.originator:}") String birdOriginator) { + String kind = chosen.trim().length() > 0 ? chosen.trim() + : config.isDevelopmentProfile() ? "console" : "twilio"; + if ("console".equalsIgnoreCase(kind)) { + if (!config.isDevelopmentProfile()) { + return new MisconfiguredSmsSender("savor.sms.provider=console only prints codes " + + "and is refused on the '" + config.getProfile() + "' profile; set it to " + + "twilio, vonage or messagebird"); + } + return new ConsoleSmsSender(); + } + if ("twilio".equalsIgnoreCase(kind)) { + String missing = missing("savor.sms.twilio.", new String[] {"sid", twilioSid, + "token", twilioToken, "from", twilioFrom}); + return missing.length() > 0 ? new MisconfiguredSmsSender(unset("SMS", "twilio", + missing)) : new TwilioSmsSender(twilioSid, twilioToken, twilioFrom); + } + // tag::savor-sms-choice[] + if ("vonage".equalsIgnoreCase(kind)) { + String missing = missing("savor.sms.vonage.", new String[] {"key", vonageKey, + "secret", vonageSecret, "from", vonageFrom}); + return missing.length() > 0 ? new MisconfiguredSmsSender(unset("SMS", "vonage", + missing)) : new VonageSmsSender(vonageKey, vonageSecret, vonageFrom); + } + // end::savor-sms-choice[] + if ("messagebird".equalsIgnoreCase(kind)) { + String missing = missing("savor.sms.messagebird.", new String[] {"key", birdKey, + "originator", birdOriginator}); + return missing.length() > 0 ? new MisconfiguredSmsSender(unset("SMS", "messagebird", + missing)) : new MessageBirdSmsSender(birdKey, birdOriginator); + } + return new MisconfiguredSmsSender("savor.sms.provider is '" + kind + "', which is " + + "none of twilio, vonage, messagebird or console"); + } + + /// Photon, at `savor.geocoder.url`. `savor.geocoder=stub` selects the + /// one that knows ten places and asks nobody, and it is what the `test` + /// profile gets unless told otherwise. + @Bean + public Geocoder geocoder(Config config, @Value("${savor.geocoder:}") String chosen, + @Value("${savor.geocoder.url:https://photon.komoot.io}") String address) + throws IOException { + String kind = chosen.length() > 0 ? chosen + : "test".equals(config.getProfile()) ? "stub" : "photon"; + if ("stub".equals(kind)) { + return new StubGeocoder(); + } + return new PhotonGeocoder(address); + } + + // tag::savor-payment-choice[] + /// `savor.payments.provider` is one of: + /// + /// - `stripe`: [StripePayments], an untested stub over Stripe's REST API. + /// It needs `savor.payments.stripe.secret`. + /// - `simulated`: test card numbers, and no money moves. + /// + /// Unset, it is `simulated` on a development profile and `stripe` otherwise. + /// + /// With Stripe a card is typed into Stripe's own page, which then sends the + /// browser back to `/pay/return` on this server. That address has to be the + /// one a phone can reach, so it is `savor.public.url` when that is set + /// and the issuer -- the public address of this server, which a deployment + /// sets anyway -- when it is not. + @Bean + public PaymentProvider paymentProvider(Config config, + @Value("${savor.payments.provider:}") String chosen, + @Value("${savor.payments.stripe.secret:}") String secret, + @Value("${savor.public.url:}") String publicUrl, + @Value("${" + SecurityConfig.ISSUER_KEY + ":}") String issuer) { + String kind = chosen.trim().length() > 0 ? chosen.trim() + : config.isDevelopmentProfile() ? "simulated" : "stripe"; + if ("simulated".equalsIgnoreCase(kind)) { + return new SimulatedPayments(); + } + if (!"stripe".equalsIgnoreCase(kind)) { + return new MisconfiguredPayments(kind, "savor.payments.provider is '" + kind + + "', which is neither stripe nor simulated"); + } + String address = publicUrl.length() > 0 ? publicUrl : issuer; + String missing = missing("", new String[] {"savor.payments.stripe.secret", secret, + "savor.public.url (or " + SecurityConfig.ISSUER_KEY + ")", address}); + if (missing.length() > 0) { + return new MisconfiguredPayments("stripe", unset("payments", "stripe", missing)); + } + while (address.endsWith("/")) { + address = address.substring(0, address.length() - 1); + } + return new StripePayments(secret, address + "/pay/return"); + } + // end::savor-payment-choice[] + + // tag::savor-push-choice[] + /// `savor.push.provider` is one of: + /// + /// - `cloud`: the Codename One push service. It needs `savor.push.app`, + /// the app's push id, and `savor.push.key`, a server key with the push + /// scope; both are in the Push section of the Codename One console. + /// - `console`: prints each notification, on a development profile only. + /// - `recording`: keeps each notification for the tests to read, on a + /// development profile only. + /// + /// Unset, it is `recording` on the `test` profile, `console` on the other + /// development profiles and `cloud` otherwise. A server whose push settings + /// are missing still starts and still serves deliveries: it logs a configuration + /// error at start-up and again for every notification it could not send. + @Bean + public PushSender pushSender(Config config, + @Value("${savor.push.provider:}") String chosen, + @Value("${savor.push.app:}") String app, + @Value("${savor.push.key:}") String key, + @Value("${savor.push.url:}") String url) { + String kind = chosen.trim().length() > 0 ? chosen.trim() + : "test".equals(config.getProfile()) ? "recording" + : config.isDevelopmentProfile() ? "console" : "cloud"; + if ("console".equalsIgnoreCase(kind) || "recording".equalsIgnoreCase(kind)) { + if (!config.isDevelopmentProfile()) { + return new MisconfiguredPushSender("savor.push.provider=" + kind + + " sends nothing and is refused on the '" + config.getProfile() + + "' profile; set it to cloud"); + } + return "console".equalsIgnoreCase(kind) ? new ConsolePushSender() + : new RecordingPushSender(); + } + if (!"cloud".equalsIgnoreCase(kind)) { + return new MisconfiguredPushSender("savor.push.provider is '" + kind + + "', which is none of cloud, console or recording"); + } + String missing = missing("savor.push.", new String[] {"app", app, "key", key}); + if (missing.length() > 0) { + return new MisconfiguredPushSender(unset("push", "cloud", missing)); + } + try { + CloudPush push = new CloudPush(app.trim(), key.trim()); + return new CloudPushSender(url.trim().length() > 0 ? push.withEndpoint(url.trim()) + : push); + } catch (IllegalArgumentException err) { + return new MisconfiguredPushSender("push provider 'cloud' is misconfigured: " + + err.getMessage()); + } + } + // end::savor-push-choice[] + + /// The names, after `prefix`, of the settings in `namesAndValues` whose + /// value is empty, separated by commas. + private static String missing(String prefix, String[] namesAndValues) { + StringBuilder out = new StringBuilder(); + for (int iter = 0; iter + 1 < namesAndValues.length; iter += 2) { + if (namesAndValues[iter + 1] == null || namesAndValues[iter + 1].trim().length() == 0) { + if (out.length() > 0) { + out.append(", "); + } + out.append(prefix).append(namesAndValues[iter]); + } + } + return out.toString(); + } + + private static String unset(String service, String provider, String missing) { + return service + " provider '" + provider + "' is not configured; set " + missing; + } +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/SignInCodePage.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/SignInCodePage.java new file mode 100644 index 00000000000..a5e53e5c598 --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/SignInCodePage.java @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor; + +import com.codename1.backend.HttpServer; +import com.codename1.backend.Json; +import com.codename1.backend.annotations.GetMapping; +import com.codename1.backend.annotations.RequestParam; +import com.codename1.backend.annotations.RestController; + +import java.util.LinkedHashMap; +import java.util.Map; + +/// Where a sign-in from a browser ends: the authorization code, handed back. +/// +/// The installed app asks for its code to be sent to a loopback address and +/// reads it off the redirect without following it. A page in a browser cannot +/// do that -- the browser follows the redirect itself and shows the page only +/// what it found at the end -- so the app running in one asks for the code to +/// be sent here, and what is at the end is the code. +/// +/// Nothing is decided here and nothing is trusted: the code arrived in the +/// address, so repeating it tells the caller nothing it did not have, and it +/// is worth nothing without the PKCE verifier only the app that started the +/// sign-in holds. That is why this needs no sign-in of its own. +@RestController +public class SignInCodePage { + @GetMapping(SecurityConfig.WEB_REDIRECT_PATH) + public HttpServer.Response code( + @RequestParam(value = "code", required = false) String code, + @RequestParam(value = "state", required = false) String state, + @RequestParam(value = "error", required = false) String error) throws Exception { + Map answer = new LinkedHashMap(); + if (code != null) { + answer.put("code", code); + } + if (state != null) { + answer.put("state", state); + } + if (error != null) { + answer.put("error", error); + } + return new HttpServer.Response(code == null ? 400 : 200, "application/json", + Json.write(answer).getBytes("UTF-8")) + // A code is good once, for a few minutes; nothing should keep it. + .header("Cache-Control", "no-store") + // And nothing should read it as anything but data: the values + // come from the address, which anyone can write a link to. + .header("X-Content-Type-Options", "nosniff"); + } +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/Text.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/Text.java new file mode 100644 index 00000000000..7ec106d3ace --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/Text.java @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor; + +import com.codename1.backend.ResponseStatusException; + +/// What the app sends, made safe to store: trimmed, bounded, and one of the +/// words a field allows. +public final class Text { + private Text() { + } + + /// `value` trimmed, or empty for null; answers 400 when longer than `max`. + public static String optional(String value, int max, String what) { + String text = value == null ? "" : value.trim(); + if (text.length() > max) { + throw new ResponseStatusException(400, "That is too long for " + what); + } + return text; + } + + /// `value` trimmed; answers 400 when it is empty or longer than `max`. + public static String required(String value, int max, String what) { + String text = optional(value, max, what); + if (text.length() == 0) { + throw new ResponseStatusException(400, "Tell us " + what); + } + return text; + } + + /// `value` when it is one of `allowed`, and `fallback` when it is empty; + /// answers 400 for anything else. The words are compared as written: they + /// are the app's own constants, not something a person types. + public static String oneOf(String value, String[] allowed, String fallback, String what) { + String text = value == null ? "" : value.trim(); + if (text.length() == 0) { + return fallback; + } + for (int iter = 0; iter < allowed.length; iter++) { + if (allowed[iter].equals(text)) { + return text; + } + } + throw new ResponseStatusException(400, "That is not a value for " + what); + } + + /// The first letters of the first and last words of a name, in upper case + /// when they are ASCII letters: "Dana Courier" is "DD". + public static String initials(String name) { + String text = name == null ? "" : name.trim(); + if (text.length() == 0) { + return ""; + } + int last = text.lastIndexOf(' '); + StringBuilder out = new StringBuilder(2); + out.append(upper(text.charAt(0))); + if (last > 0 && last + 1 < text.length()) { + out.append(upper(text.charAt(last + 1))); + } + return out.toString(); + } + + private static char upper(char c) { + // By hand: toUpperCase() follows the server's locale. + return c >= 'a' && c <= 'z' ? (char) (c - ('a' - 'A')) : c; + } +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/account/AccountCleanup.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/account/AccountCleanup.java new file mode 100644 index 00000000000..ecac220794f --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/account/AccountCleanup.java @@ -0,0 +1,213 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.account; + +import com.codename1.backend.annotations.Component; +import com.codenameone.examples.savor.api.DeliveryState; +import com.codenameone.examples.savor.api.OrderState; +import com.codenameone.examples.savor.catalog.Catalog; +import com.codenameone.examples.savor.domain.Restaurant; +import com.codenameone.examples.savor.domain.RestaurantStaff; +import com.codenameone.examples.savor.repository.RestaurantRepository; +import com.codenameone.examples.savor.repository.RestaurantStaffRepository; +import com.codenameone.examples.savor.repository.SavedAddressRepository; +import com.codenameone.examples.savor.repository.FavoriteRepository; +import com.codenameone.examples.savor.repository.CustomerOrderRepository; +import com.codenameone.examples.savor.repository.CourierHoursRepository; +import com.codenameone.examples.savor.repository.CourierStateRepository; +import com.codenameone.examples.savor.repository.LiveTicketRepository; +import com.codenameone.examples.savor.repository.PayoutAccountRepository; +import com.codenameone.examples.savor.repository.PhoneCodeRepository; +import com.codenameone.examples.savor.repository.PreferenceRepository; +import com.codenameone.examples.savor.repository.PushDeviceRepository; +import com.codenameone.examples.savor.repository.DeliveryActivityRepository; +import com.codenameone.examples.savor.repository.DeliveryRepository; + +import java.util.List; + +/// What closing or restricting an account does to the rows other parts of the +/// server keep under its name. +/// +/// These are questions and statements over whole sets of rows -- everything +/// of one account in a table -- and not the work of the services that own +/// those tables, which is why they are gathered here. Each deletion is a bulk +/// statement, so whatever the session had loaded before it is stale +/// afterwards and must be read again before it is used. It has no +/// transaction of its own: [Accounts] calls it inside one. +@Component +public class AccountCleanup { + /// The states of a delivery that somebody is still waiting on. + private static final DeliveryState[] UNDER_WAY = {DeliveryState.REQUESTED, DeliveryState.OFFERED, + DeliveryState.ACCEPTED, DeliveryState.ARRIVED, DeliveryState.IN_PROGRESS}; + /// The states of an order that has not reached the door or ended. + private static final OrderState[] ORDERED = {OrderState.PLACED, OrderState.ACCEPTED, + OrderState.READY, OrderState.PICKED_UP}; + /// The states of a delivery a courier has taken and has yet to finish. + private static final DeliveryState[] TAKEN = {DeliveryState.ACCEPTED, DeliveryState.ARRIVED, + DeliveryState.IN_PROGRESS}; + + private final DeliveryRepository deliveries; + private final CourierStateRepository couriers; + private final CourierHoursRepository hours; + private final PreferenceRepository preferences; + private final PayoutAccountRepository payoutAccounts; + private final PhoneCodeRepository codes; + private final LiveTicketRepository tickets; + private final PushDeviceRepository pushDevices; + private final DeliveryActivityRepository activities; + private final CustomerOrderRepository orders; + private final FavoriteRepository favorites; + private final SavedAddressRepository addresses; + private final RestaurantStaffRepository staff; + private final RestaurantRepository restaurants; + + public AccountCleanup(DeliveryRepository deliveries, CourierStateRepository couriers, + CourierHoursRepository hours, PreferenceRepository preferences, + PayoutAccountRepository payoutAccounts, PhoneCodeRepository codes, + LiveTicketRepository tickets, PushDeviceRepository pushDevices, + DeliveryActivityRepository activities, CustomerOrderRepository orders, + FavoriteRepository favorites, SavedAddressRepository addresses, + RestaurantStaffRepository staff, RestaurantRepository restaurants) { + this.restaurants = restaurants; + this.deliveries = deliveries; + this.couriers = couriers; + this.hours = hours; + this.preferences = preferences; + this.payoutAccounts = payoutAccounts; + this.codes = codes; + this.tickets = tickets; + this.pushDevices = pushDevices; + this.activities = activities; + this.orders = orders; + this.favorites = favorites; + this.addresses = addresses; + this.staff = staff; + } + + /// Whether the account is the customer or the courier of a delivery that has not + /// ended. + /// An order not yet delivered counts too: one the kitchen has not + /// answered has no delivery yet, and the card is held for it. + public boolean hasDeliveryUnderWay(String username) { + return orders.countByCustomerAndStateIn(username, ORDERED) > 0 + || deliveries.existsByCustomerAndStateInOrCourierAndStateIn(username, UNDER_WAY, + username, UNDER_WAY); + } + + /// Whether the account is onboarding a delivery it has taken and not finished. An + /// offer it has not answered is not one. + public boolean drivesDeliveryUnderWay(String username) { + return deliveries.existsByCourierAndStateIn(username, TAKEN); + } + + /// Whether the account is in a delivery that has not ended: as its customer, from + /// the moment it is asked for, or as its courier, once taken. An offer the + /// courier has not answered is not one; it passes to the next courier. + public boolean inDeliveryUnderWay(String username) { + return orders.countByCustomerAndStateIn(username, ORDERED) > 0 + || deliveries.existsByCustomerAndStateInOrCourierAndStateIn(username, UNDER_WAY, + username, TAKEN); + } + + /// Takes a courier off line, when the account stops being allowed to deliver. + /// Does nothing for an account that never drove. + public void takeOffLine(String username) { + couriers.takeOffLine(username); + } + + /// True when the account is the only staff of an approved restaurant: + /// closing it would leave the restaurant listed and taking orders with + /// nobody to cook them, so the closure is refused until an admin adds + /// somebody else or the restaurant is no longer approved. + /// Whether a restaurant `username` is the only merchant of still has orders + /// the kitchen has to see through. Closing that account would take it off + /// the app (see [#removeEverythingOf]) with nobody left to cook them. + public boolean soleMerchantWithOpenOrders(String username) { + List mine = staff.findByUsername(username); + for (int iter = 0; iter < mine.size(); iter++) { + String restaurantId = mine.get(iter).getRestaurantId(); + if (staff.findByRestaurantId(restaurantId).size() < 2 + && !orders.findByRestaurantIdAndStateInOrderByPlacedAtAsc(restaurantId, OPEN).isEmpty()) { + return true; + } + } + return false; + } + + private static final OrderState[] OPEN = open(); + + private static OrderState[] open() { + java.util.List out = new java.util.ArrayList(); + OrderState[] all = OrderState.values(); + for (int iter = 0; iter < all.length; iter++) { + if (all[iter].isActive()) { + out.add(all[iter]); + } + } + return out.toArray(new OrderState[out.size()]); + } + + public boolean soleMerchantOfListed(String username) { + List mine = staff.findByUsername(username); + for (int iter = 0; iter < mine.size(); iter++) { + String restaurantId = mine.get(iter).getRestaurantId(); + Restaurant restaurant = restaurants.findById(restaurantId); + if (restaurant != null && Catalog.APPROVED.equals(restaurant.getStatus()) + && staff.findByRestaurantId(restaurantId).size() < 2) { + return true; + } + } + return false; + } + + /// Deletes what an account leaves in the tables that are keyed by it and + /// belong to nobody's `forget`: its preferences, where it is paid, the + /// hours it drove, its place on the map, a code it was texted, the + /// tickets it held for the live connection, and the phones it was sent + /// notifications on. + public void removeEverythingOf(String username) { + preferences.deleteByUsername(username); + payoutAccounts.deleteByUsername(username); + hours.deleteByUsername(username); + couriers.deleteByUsername(username); + codes.deleteByUsername(username); + tickets.deleteByUsername(username); + pushDevices.deleteByUsername(username); + activities.deleteByUsername(username); + favorites.deleteByUsername(username); + addresses.deleteByUsername(username); + // A restaurant somebody else still works at stays listed, as its orders + // and its menu do. One left with nobody is taken off the app: there is + // no screen that hands a restaurant to another account, and + // Restaurants.suspend will not list it again until it has staff who can + // sign in. Its orders and menu stay for the records. + List mine = staff.findByUsername(username); + for (int iter = 0; iter < mine.size(); iter++) { + String restaurantId = mine.get(iter).getRestaurantId(); + Restaurant restaurant = restaurants.findById(restaurantId); + if (restaurant != null && Catalog.APPROVED.equals(restaurant.getStatus()) + && staff.findByRestaurantId(restaurantId).size() < 2) { + restaurant.setStatus(Catalog.SUSPENDED); + restaurant.setReason("Its only merchant closed their account"); + restaurant.setUpdatedAt(System.currentTimeMillis()); + } + } + staff.deleteByUsername(username); + } +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/account/AccountEndpoint.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/account/AccountEndpoint.java new file mode 100644 index 00000000000..7586696decc --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/account/AccountEndpoint.java @@ -0,0 +1,124 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.account; + +import com.codename1.backend.annotations.Component; +import com.codenameone.examples.savor.Caller; +import com.codenameone.examples.savor.api.AccountApiServer; +import com.codenameone.examples.savor.api.AccountClosureDto; +import com.codenameone.examples.savor.api.PasswordChangeDto; +import com.codenameone.examples.savor.api.PhoneChallengeDto; +import com.codenameone.examples.savor.api.PhoneCodeDto; +import com.codenameone.examples.savor.api.PreferencesDto; +import com.codenameone.examples.savor.api.ProfileDto; +import com.codenameone.examples.savor.api.PushDeviceDto; +import com.codenameone.examples.savor.api.RegisterDto; +import com.codenameone.examples.savor.api.TicketDto; +import com.codenameone.examples.savor.api.UserDto; +import com.codenameone.examples.savor.live.LiveTickets; +import com.codenameone.examples.savor.push.PushDevices; + +/// The server's half of `AccountApi`. +/// +/// `AccountApiServer` is generated from the contract the app calls through, and +/// the build routes each of the contract's paths to this class. Change a path or +/// a type in the contract and both sides stop compiling until they agree. +@Component +public class AccountEndpoint implements AccountApiServer { + private final Accounts accounts; + private final PhoneVerification phones; + private final LiveTickets tickets; + private final Preferences preferences; + private final PushDevices devices; + + public AccountEndpoint(Accounts accounts, PhoneVerification phones, LiveTickets tickets, + Preferences preferences, PushDevices devices) { + this.accounts = accounts; + this.phones = phones; + this.tickets = tickets; + this.preferences = preferences; + this.devices = devices; + } + + @Override + public UserDto register(RegisterDto request) throws Exception { + return accounts.register(request); + } + + @Override + public UserDto me() throws Exception { + return accounts.describe(Caller.name()); + } + + @Override + public PhoneChallengeDto startPhoneVerification() throws Exception { + return phones.start(Caller.name()); + } + + @Override + public UserDto verifyPhone(PhoneCodeDto code) throws Exception { + return phones.verify(Caller.name(), code == null ? null : code.code()); + } + + @Override + public TicketDto liveTicket() throws Exception { + return tickets.issue(Caller.name()); + } + + @Override + public PreferencesDto preferences() throws Exception { + return preferences.get(Caller.name()); + } + + @Override + public PreferencesDto savePreferences(PreferencesDto wanted) throws Exception { + return preferences.save(Caller.name(), wanted); + } + + @Override + public ProfileDto profile() throws Exception { + return accounts.profile(Caller.name()); + } + + @Override + public UserDto saveProfile(ProfileDto profile) throws Exception { + return accounts.saveProfile(Caller.name(), profile); + } + + @Override + public UserDto changePassword(PasswordChangeDto change) throws Exception { + return accounts.changePassword(Caller.name(), change == null ? null : change.current(), + change == null ? null : change.replacement()); + } + + @Override + public UserDto deleteAccount(AccountClosureDto closure) throws Exception { + return accounts.close(Caller.name(), closure == null ? null : closure.password()); + } + + @Override + public PushDeviceDto registerPush(PushDeviceDto device) throws Exception { + return devices.register(Caller.name(), device); + } + + @Override + public PushDeviceDto unregisterPush(PushDeviceDto device) throws Exception { + return devices.unregister(Caller.name(), device); + } +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/account/Accounts.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/account/Accounts.java new file mode 100644 index 00000000000..d761c97ad69 --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/account/Accounts.java @@ -0,0 +1,684 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.account; + +import com.codename1.backend.ResponseStatusException; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Transactional; +import com.codename1.backend.security.GrantedAuthority; +import com.codename1.backend.security.core.userdetails.JdbcUserDetailsManager; +import com.codename1.backend.security.core.userdetails.User; +import com.codename1.backend.security.core.userdetails.UserDetails; +import com.codename1.backend.security.core.userdetails.UsernameNotFoundException; +import com.codename1.backend.security.crypto.PasswordEncoder; +import com.codenameone.examples.savor.Ids; +import com.codenameone.examples.savor.Roles; +import com.codenameone.examples.savor.Text; +import com.codenameone.examples.savor.api.ProfileDto; +import com.codenameone.examples.savor.api.RegisterDto; +import com.codenameone.examples.savor.api.UserDto; +import com.codenameone.examples.savor.domain.ClosedAccount; +import com.codenameone.examples.savor.domain.Profile; +import com.codenameone.examples.savor.courier.Earnings; +import com.codenameone.examples.savor.onboarding.Applications; +import com.codenameone.examples.savor.live.LiveHub; +import com.codenameone.examples.savor.pay.Payments; +import com.codenameone.examples.savor.repository.ClosedAccountRepository; +import com.codenameone.examples.savor.repository.ProfileRepository; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.Map; + +/// Accounts: opening one, describing one, and what an admin may change. +/// +/// An account is two things kept under one name. Its sign-in -- password hash, +/// roles, whether it is enabled -- is the backend's own user store. Its profile +/// -- display name, phone, car -- is this application's table. The name is the +/// e-mail address, in lower case. +/// +/// Onboarding is not something an account is opened with. Asking to deliver opens +/// an application ([Applications]), and the COURIER role arrives when an admin +/// approves it. +@Component +public class Accounts { + + private final ProfileRepository profiles; + private final AccountCleanup cleanup; + private final JdbcUserDetailsManager users; + private final PasswordEncoder encoder; + private final Applications applications; + private final Moderation moderation; + private final Payments payments; + private final LiveHub live; + private final ClosedAccountRepository closed; + private final Grants grants; + private final Earnings earnings; + + public Accounts(ProfileRepository profiles, AccountCleanup cleanup, + JdbcUserDetailsManager users, PasswordEncoder encoder, Applications applications, + Moderation moderation, Payments payments, LiveHub live, + ClosedAccountRepository closed, Grants grants, Earnings earnings) { + this.earnings = earnings; + this.closed = closed; + this.grants = grants; + this.profiles = profiles; + this.cleanup = cleanup; + this.users = users; + this.encoder = encoder; + this.applications = applications; + this.moderation = moderation; + this.payments = payments; + this.live = live; + } + + /// Opens an account. Answers 400 for a request that cannot be an account + /// and 409 when the e-mail already has one. + /// + /// Not a transaction itself, so that it can look again after one failed. + /// Two requests for the same e-mail can both find it free; the name's + /// unique key then refuses the second, and what the database says about + /// that differs with the database. So nothing here reads the error: if the + /// account exists once the transaction is over, that is why it failed, and + /// the answer is the same 409 the check gives. + public UserDto register(RegisterDto request) throws IOException { + try { + return open(request); + } catch (ResponseStatusException answered) { + throw answered; + } catch (IOException failed) { + throw taken(request, failed); + } catch (RuntimeException failed) { + throw taken(request, failed); + } + } + + /// The 409 for a registration that failed because the account was opened + /// meanwhile, or the failure itself when it was something else. + private T taken(RegisterDto request, T failed) { + if (request != null && request.email() != null + && users.userExists(email(request.email()))) { + throw new ResponseStatusException(409, "That e-mail already has an account"); + } + return failed; + } + + @Transactional(rollbackFor = IOException.class) + private UserDto open(RegisterDto request) throws IOException { + if (request == null) { + throw new ResponseStatusException(400, "Nothing to register"); + } + String username = email(request.email()); + password(request.password()); + String displayName = required(request.displayName(), 120, "your name"); + String phone = phone(request.phone()); + String vehicle = ""; + String plate = ""; + if (request.wantsToDeliver()) { + vehicle = Text.optional(request.vehicle(), 120, "what you deliver with"); + plate = Text.optional(request.plate(), 32, "its plate"); + } + if (users.userExists(username)) { + throw new ResponseStatusException(409, "That e-mail already has an account"); + } + // An address whose account was closed is not given to anyone again: + // the deliveries that account took are still filed under it. See + // [#retire]. + if (closed.existsById(Ids.sha256(username))) { + throw new ResponseStatusException(409, + "That e-mail belonged to an account that was closed. Use another"); + } + // Every account opens as a customer. Asking to deliver begins an + // application, and the role waits for an admin to approve it. + create(username, request.password(), displayName, phone, false, vehicle, plate, false, + false, System.currentTimeMillis()); + if (request.wantsToDeliver()) { + applications.open(username, vehicle, plate); + } + return describe(username); + } + + /// Creates the sign-in and the profile. The caller has checked the name is + /// free. An account created as a courier is an approved one: this is how the + /// demo accounts are made, not how anyone signs up. + @Transactional(rollbackFor = IOException.class) + void create(String username, String password, String displayName, String phone, + boolean phoneVerified, String vehicle, String plate, boolean courier, boolean admin) + throws IOException { + create(username, password, displayName, phone, phoneVerified, vehicle, plate, courier, + admin, System.currentTimeMillis()); + } + + /// As the method above, for a caller that also knows when the account was + /// opened; the demo data is the one caller with a past to + /// write. The profile is stored whole, in one go, and not corrected + /// afterwards. + @Transactional(rollbackFor = IOException.class) + void create(String username, String password, String displayName, String phone, + boolean phoneVerified, String vehicle, String plate, boolean courier, boolean admin, + long openedAt) throws IOException { + users.createUser(User.withUsername(username).password(encoder.encode(password)) + .roles(roles(courier, admin)).build()); + Profile profile = new Profile(); + profile.setUsername(username); + profile.setDisplayName(displayName); + profile.setPhone(phone); + profile.setPhoneVerified(phoneVerified); + profile.setVehicle(vehicle); + profile.setPlate(plate); + profile.setCreatedAt(openedAt); + profiles.insert(profile); + if (courier) { + applications.grant(username, username); + } + } + + public boolean exists(String username) { + return users.userExists(username); + } + + /// Whether the account exists and has not been blocked, read once. + public boolean enabled(String username) { + try { + return users.loadUserByUsername(username).isEnabled(); + } catch (UsernameNotFoundException gone) { + return false; + } + } + + /// The account as the app sees it. Answers 404 for a name with no account. + @Transactional(readOnly = true) + public UserDto describe(String username) throws IOException { + Profile profile = profiles.findById(username); + if (profile == null || !users.userExists(username)) { + throw new ResponseStatusException(404, "No such account"); + } + UserDetails signIn = users.loadUserByUsername(username); + return toDto(profile, authorities(signIn), signIn.isEnabled(), + applications.status(username)); + } + + /// Every account, newest first, for the admin. + @Transactional(readOnly = true) + public List all() throws IOException { + Map onboarding = applications.statuses(); + List out = new ArrayList(); + List everyone = profiles.newestFirst(); + for (int iter = 0; iter < everyone.size(); iter++) { + Profile profile = everyone.get(iter); + // One sign-in read per account. The user store answers for one name + // at a time and has no way to list its users or to load several at + // once, and its tables are its own, not this application's to + // query. That is two statements an account, which the admin's list + // can afford; a store that could load them together would be asked + // once. + List held = new ArrayList(); + boolean enabled = true; + try { + UserDetails signIn = users.loadUserByUsername(profile.getUsername()); + held = authorities(signIn); + enabled = signIn.isEnabled(); + } catch (UsernameNotFoundException gone) { + // A profile whose sign-in was deleted under it is listed with + // no roles rather than failing the whole list. + enabled = true; + } + String status = onboarding.get(profile.getUsername()); + out.add(toDto(profile, held, enabled, status == null ? Applications.NONE : status)); + } + return out; + } + + /// Grants or withdraws onboarding and administering. `by` is the admin doing + /// it, who cannot withdraw their own admin role: that is how a service ends + /// up with nobody able to administer it. + @Transactional(rollbackFor = IOException.class) + public UserDto setRoles(String by, String username, boolean courier, boolean admin) + throws IOException { + actingAdmin(by, username); + UserDetails signIn = load(username); + if (!admin && username.equals(by)) { + throw new ResponseStatusException(400, "You cannot withdraw your own admin role"); + } + // Not from under a customer. The role is read on every request, so a + // courier who lost it mid-delivery could no longer arrive, start, complete + // or cancel, and the trip would sit there until somebody noticed. The + // admin ends the delivery first, or waits for it to end. + if (!courier && cleanup.drivesDeliveryUnderWay(username)) { + throw new ResponseStatusException(409, + "That courier is on a delivery. Withdraw the role when it has ended"); + } + // A courier can lose the role with earnings not yet cashed out, and + // the payouts are courier routes. The money is still theirs in the + // ledger, and settling it is the admin's to arrange -- the role given + // back for the cash-out, or paid outside the app. A removal for cause + // must not wait on a payout, so it is not refused here. + // Merchant is not one of the switches: it comes with a place on a + // restaurant's staff, and stays as long as that does. + boolean merchant = authorities(signIn).contains("ROLE_" + Roles.MERCHANT); + users.updateUser(User.withUserDetails(signIn).roles(roles(courier, admin, merchant)) + .build()); + // The role and the approval go together, whichever way it was given: + // being approved is what lets a courier go on line. + if (courier) { + applications.grant(by, username); + } else { + applications.withdraw(by, username); + cleanup.takeOffLine(username); + } + moderation.record(username, by, Moderation.ROLES, + (courier ? "courier" : "customer") + (admin ? ", admin" : "")); + return describe(username); + } + + /// Gives or takes the merchant role, which goes with a place on a + /// restaurant's staff: `Restaurants` calls this in the transaction that + /// adds or removes the place. Every other role is left as it is. + public void setMerchant(String username, boolean merchant) throws IOException { + UserDetails signIn = load(username); + List held = authorities(signIn); + boolean courier = held.contains("ROLE_" + Roles.COURIER); + boolean admin = held.contains("ROLE_" + Roles.ADMIN); + users.updateUser(User.withUserDetails(signIn).roles(roles(courier, admin, merchant)) + .build()); + } + + /// Blocks an account or lifts the block. A blocked account cannot sign in, + /// and a token it already holds stops working at once, because the API + /// reads the account on every request. + @Transactional(rollbackFor = IOException.class) + public UserDto suspend(String by, String username, boolean suspended, String reason) + throws IOException { + actingAdmin(by, username); + UserDetails signIn = load(username); + if (suspended && username.equals(by)) { + throw new ResponseStatusException(400, "You cannot suspend your own account"); + } + String why = Text.optional(reason, 255, "the reason"); + // Not in the middle of a delivery, for the reason a courier's role is not + // withdrawn then: a blocked account's requests are all refused, so a + // courier could no longer arrive, start, finish or cancel, and a customer + // could not cancel, and the other person would be left with a delivery + // nobody can end. The admin cancels the delivery first, or waits. + if (suspended && signIn.isEnabled() && cleanup.inDeliveryUnderWay(username)) { + throw new ResponseStatusException(409, + "That account is on a delivery. Block it when the delivery has ended"); + } + // Nor the only merchant of a listed restaurant: it would go on taking + // orders nobody can accept. The admin suspends the restaurant first. + if (suspended && signIn.isEnabled() && cleanup.soleMerchantOfListed(username)) { + throw new ResponseStatusException(409, + "That account is its restaurant's only merchant. Suspend the restaurant first"); + } + users.updateUser(User.withUserDetails(signIn).disabled(suspended).build()); + if (suspended) { + cleanup.takeOffLine(username); + } + if (suspended == signIn.isEnabled()) { + // Only a change is an event; saying the same thing twice is not. + moderation.record(username, by, suspended ? Moderation.BLOCK : Moderation.UNBLOCK, why); + } + return describe(username); + } + + /// Marks an account for the admins' attention, or clears the mark. A flag + /// changes nothing the account can do; blocking is what does that. + @Transactional(rollbackFor = IOException.class) + public UserDto flag(String by, String username, boolean flagged, String reason) + throws IOException { + // The profile row first, as the other admin changes hold it: a flag + // racing the account's closure either lands before erase() (which + // then forgets it with the rest) or finds no account. Otherwise the + // moderation row recorded below would outlive the closure's cleanup. + if (profiles.hold(username) == 0) { + throw new ResponseStatusException(404, "No such account"); + } + load(username); + String why = Text.optional(reason, 255, "the reason"); + if (flagged && why.length() == 0) { + throw new ResponseStatusException(400, "Say why the account is flagged"); + } + Profile profile = profiles.findById(username); + if (profile != null) { + profile.setFlagged(flagged); + profile.setFlagReason(flagged ? why : ""); + } + moderation.record(username, by, flagged ? Moderation.FLAG : Moderation.UNFLAG, why); + return describe(username); + } + + // ------------------------------------------------------------ one's own + + @Transactional(readOnly = true) + public ProfileDto profile(String username) throws IOException { + Profile row = profiles.findById(username); + if (row == null) { + throw new ResponseStatusException(404, "No such account"); + } + return new ProfileDto(row.getDisplayName()); + } + + @Transactional(rollbackFor = IOException.class) + public UserDto saveProfile(String username, ProfileDto profile) throws IOException { + if (profile == null) { + throw new ResponseStatusException(400, "Nothing to save"); + } + String name = required(profile.name(), 120, "your name"); + Profile row = profiles.findById(username); + if (row == null) { + throw new ResponseStatusException(404, "No such account"); + } + row.setDisplayName(name); + return describe(username); + } + + /// Changes a password, for someone who knows the one it replaces. A token + /// in hand is not enough: it is what a borrowed phone has. The account's + /// other sign-ins end with the old password. + @Transactional(rollbackFor = IOException.class) + public UserDto changePassword(String username, String current, String replacement) + throws IOException { + // Before the password is read. Two changes at once would otherwise + // both check the same old password and both succeed, the last one + // winning: whoever else knows the old password could race the owner's + // change and keep the account. Behind the hold the second change + // checks against the password the first one set. + profiles.hold(username); + UserDetails signIn = load(username); + if (current == null || !encoder.matches(current, signIn.getPassword())) { + throw new ResponseStatusException(403, "That is not your current password"); + } + password(replacement); + users.updatePassword(signIn, encoder.encode(replacement)); + // A password is changed, as often as not, because someone else may + // have the old one or a device that was signed in with it. A refresh + // token asks for no password, so every sign-in but this one ends here. + grants.endOthers(username); + return describe(username); + } + + /// Closes an account, for good: the sign-in, the profile, the saved cards + /// and the onboarding application with its documents are deleted, not hidden. + /// The deliveries it took stay, as the other party's history and the service's + /// accounts, under a name that no longer has anyone behind it. + /// + /// Refused while a delivery is under way, which has a courier or a customer waiting + /// on it, and for an admin, so that a service is not left with none. + /// + /// `password` is the account's own, asked for again as a change of + /// password asks for it. A token alone is not enough for something that + /// cannot be undone: a phone left unlocked, or a token that leaked, would + /// otherwise be all it takes to destroy an account. + public UserDto close(String username, String password) throws IOException { + UserDto was = erase(username, password); + // After the commit and not inside it: what is said over a live + // connection is acted on at once, and must not be said about a change + // that could still be rolled back. + live.drop(username); + // The account as it was, answered as what it now is: unable to sign in. + return new UserDto(was.username(), was.displayName(), was.phone(), was.phoneVerified(), + was.customer(), was.courier(), was.admin(), was.merchant(), true, was.vehicle(), was.plate(), + was.courierStatus(), was.flagged(), was.flagReason(), + was.language()); + } + + /// Everything closing an account deletes, as one transaction: either the + /// account is gone from every table or it is in all of them still. The + /// user store works on the same database and so takes part in it. + @Transactional(rollbackFor = IOException.class) + UserDto erase(String username, String password) throws IOException { + // Before anything is read. Asking for a delivery holds the same row, so + // of a delivery and a closure sent together one waits for the other: the + // closure then sees the delivery and is refused, or the delivery finds no + // profile. Without it both could pass, leaving a delivery under way that + // belongs to nobody who can cancel it. + profiles.hold(username); + UserDetails signIn = load(username); + if (password == null || !encoder.matches(password, signIn.getPassword())) { + throw new ResponseStatusException(403, "That is not your current password"); + } + UserDto was = describe(username); + if (was.admin()) { + throw new ResponseStatusException(409, "Hand the admin role on before closing"); + } + if (cleanup.hasDeliveryUnderWay(username)) { + throw new ResponseStatusException(409, "Finish your delivery before closing the account"); + } + // The only merchant of a restaurant may close, and the restaurant is then + // taken off the app (AccountCleanup.removeEverythingOf), but not while + // its kitchen still has orders to finish. + if (cleanup.soleMerchantWithOpenOrders(username)) { + throw new ResponseStatusException(409, + "Finish your restaurant's open orders before closing the account"); + } + // Money this courier is owed. The deliveries and the payout ledger outlive + // the account, but the sign-in and the payout account do not, and + // the name is retired: once closed, nobody could cash it out. So the + // courier cashes out first. No delivery is under way, so nothing can add + // to the balance between this check and the commit. Below zero is a + // debt: a delivery refunded after it was cashed out. Closing deletes the + // payout ledger it is computed from, so that is refused too and settled + // by support first, outside the app, as cash is (see Earnings). + long balance = earnings.balance(username); + if (balance > 0L) { + throw new ResponseStatusException(409, "Cash out your earnings before closing the account"); + } + if (balance < 0L) { + throw new ResponseStatusException(409, + "A refund left your earnings below zero; contact support to settle it before closing"); + } + payments.forget(username); + applications.forget(username); + moderation.forget(username); + cleanup.removeEverythingOf(username); + // Read again: the statements above each cleared what the session had + // loaded, the profile that was described among it. + Profile profile = profiles.findById(username); + if (profile != null) { + profiles.deleteById(username); + } + // The address is retired with the account, in the same transaction: + // nobody can register it and inherit the deliveries kept under it. + retire(username); + // Last: the tokens in hand stop working when the sign-in is gone. + users.deleteUser(username); + grants.endAll(username); + return was; + } + + /// Retires an e-mail for good, as only a hash of it. + /// + /// A delivery outlives the account that took it, under the e-mail that account + /// signed in with, and so does a token until it expires. Were the address + /// free to register again, whoever took it would be handed the deliveries, the + /// receipts and the ratings of the person who left, and that person's old + /// token would sign in to the new account. Only a hash is kept: enough to + /// refuse it, and not a list of who has left. + private void retire(String username) throws IOException { + String hash = Ids.sha256(username); + if (!closed.existsById(hash)) { + ClosedAccount row = new ClosedAccount(); + row.setUsernameHash(hash); + row.setClosedAt(System.currentTimeMillis()); + closed.insert(row); + } + } + + /// Makes the rest of the caller's transaction take its turn with any other + /// that holds the same account. See [ProfileRepository#hold]. Answers 404 + /// when the account is gone: it was closed while the request was under + /// way, its clean-up has run, and a row the caller went on to insert would + /// never be deleted. + public void hold(String username) { + if (profiles.hold(username) == 0) { + throw new ResponseStatusException(404, "No such account"); + } + } + + @Transactional(rollbackFor = IOException.class) + public void markPhoneVerified(String username) throws IOException { + Profile profile = profiles.findById(username); + if (profile != null) { + profile.setPhoneVerified(true); + } + } + + /// Makes a change one admin is making to an account take its turn with any + /// other touching either of the two, and then checks `by` still is an + /// admin. Call it before anything is read. + /// + /// Without it two admins could each take the other's role, or block each + /// other, at the same moment. Each request was authorized before the other + /// committed and neither touches its own account, so both would go through + /// and the service would be left with nobody to administer it. Both + /// profiles are held, in the order of their names so that the two requests + /// queue and cannot deadlock; whichever comes second then reads what the + /// first left, finds it is no longer an admin, and is refused. + private void actingAdmin(String by, String username) { + if (by == null || username == null) { + return; + } + String first = by.compareTo(username) <= 0 ? by : username; + String second = first.equals(by) ? username : by; + profiles.hold(first); + if (!second.equals(first)) { + profiles.hold(second); + } + if (!users.userExists(by)) { + throw new ResponseStatusException(403, "You are no longer an admin"); + } + UserDetails acting = users.loadUserByUsername(by); + if (!acting.isEnabled() || !authorities(acting).contains("ROLE_ADMIN")) { + throw new ResponseStatusException(403, "You are no longer an admin"); + } + } + + private UserDetails load(String username) { + if (username == null || !users.userExists(username)) { + throw new ResponseStatusException(404, "No such account"); + } + return users.loadUserByUsername(username); + } + + private static String[] roles(boolean courier, boolean admin) { + return roles(courier, admin, false); + } + + private static String[] roles(boolean courier, boolean admin, boolean merchant) { + List roles = new ArrayList(); + roles.add(Roles.CUSTOMER); + if (merchant) { + roles.add(Roles.MERCHANT); + } + if (courier) { + roles.add(Roles.COURIER); + } + if (admin) { + roles.add(Roles.ADMIN); + } + return roles.toArray(new String[roles.size()]); + } + + private static void password(String value) { + if (value == null || value.length() < 8) { + throw new ResponseStatusException(400, "Choose a password of at least 8 characters"); + } + if (value.length() > 200) { + throw new ResponseStatusException(400, "That password is too long"); + } + } + + private static List authorities(UserDetails signIn) { + List held = new ArrayList(); + Iterator it = signIn.getAuthorities().iterator(); + while (it.hasNext()) { + held.add(it.next().getAuthority()); + } + return held; + } + + private static UserDto toDto(Profile profile, List authorities, boolean enabled, + String courierStatus) { + boolean customer = authorities.contains("ROLE_" + Roles.CUSTOMER); + boolean courier = authorities.contains("ROLE_" + Roles.COURIER); + boolean admin = authorities.contains("ROLE_" + Roles.ADMIN); + boolean merchant = authorities.contains("ROLE_" + Roles.MERCHANT); + return new UserDto(profile.getUsername(), profile.getDisplayName(), profile.getPhone(), + profile.isPhoneVerified(), customer, courier, admin, merchant, !enabled, profile.getVehicle(), + profile.getPlate(), courierStatus, profile.isFlagged(), + profile.getFlagReason(), profile.getLanguage()); + } + + /// An e-mail address as an account name: trimmed, and its ASCII letters in + /// lower case. Folded by hand, because `toLowerCase()` follows the server's + /// locale and a key must not: under a Turkish locale the `I` of `INFO@` would + /// fold to a dotless i and name a different account. + public static String email(String value) { + String text = value == null ? "" : value.trim(); + int at = text.indexOf('@'); + if (at < 1 || at != text.lastIndexOf('@') || at > text.length() - 4 + || text.indexOf('.', at) < 0 || text.length() > 190) { + throw new ResponseStatusException(400, "That is not an e-mail address"); + } + char[] chars = text.toCharArray(); + for (int iter = 0; iter < chars.length; iter++) { + char c = chars[iter]; + if (c <= ' ' || c > '~') { + throw new ResponseStatusException(400, "That is not an e-mail address"); + } + if (c >= 'A' && c <= 'Z') { + chars[iter] = (char) (c + ('a' - 'A')); + } + } + return new String(chars); + } + + /// A phone number in the form it is sent to and stored in: a `+` and its + /// digits, with the spaces, dashes and brackets people type taken out. + static String phone(String value) { + String text = value == null ? "" : value.trim(); + StringBuilder digits = new StringBuilder(); + for (int iter = 0; iter < text.length(); iter++) { + char c = text.charAt(iter); + if (c >= '0' && c <= '9') { + digits.append(c); + } else if (c != ' ' && c != '-' && c != '(' && c != ')' && c != '.' + && !(c == '+' && iter == 0)) { + throw new ResponseStatusException(400, "That is not a phone number"); + } + } + if (digits.length() < 7 || digits.length() > 15) { + throw new ResponseStatusException(400, "That is not a phone number"); + } + return "+" + digits; + } + + private static String required(String value, int max, String what) { + String text = value == null ? "" : value.trim(); + if (text.length() == 0) { + throw new ResponseStatusException(400, "Tell us " + what); + } + if (text.length() > max) { + throw new ResponseStatusException(400, "That is too long for " + what); + } + return text; + } +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/account/DemoAccounts.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/account/DemoAccounts.java new file mode 100644 index 00000000000..24fc43cac8e --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/account/DemoAccounts.java @@ -0,0 +1,299 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.account; + +import com.codename1.backend.Config; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.PostConstruct; +import com.codename1.backend.annotations.Transactional; +import com.codename1.backend.annotations.Value; +import com.codenameone.examples.savor.Days; +import com.codenameone.examples.savor.api.CardDto; +import com.codenameone.examples.savor.api.DocumentUploadDto; +import com.codenameone.examples.savor.api.CourierApplicationDto; +import com.codenameone.examples.savor.api.PaymentSetupDto; +import com.codenameone.examples.savor.domain.CourierApplication; +import com.codenameone.examples.savor.domain.CourierDocument; +import com.codenameone.examples.savor.domain.Profile; +import com.codenameone.examples.savor.onboarding.Applications; +import com.codenameone.examples.savor.pay.Payments; +import com.codenameone.examples.savor.repository.CourierApplicationRepository; +import com.codenameone.examples.savor.repository.CourierDocumentRepository; +import com.codenameone.examples.savor.repository.ProfileRepository; +import com.codenameone.examples.savor.catalog.DemoRestaurants; +import com.codenameone.examples.savor.order.DemoOrders; + +import java.io.IOException; + +/// The accounts a server starts with. +/// +/// On a development profile, enough of each kind that every screen of the app +/// has something to show straight after a checkout: customers, approved couriers, +/// a merchant who runs one of the restaurants ([DemoRestaurants]), an admin, +/// someone whose application to deliver is waiting for an answer, an account the +/// admins have flagged, and a fortnight of orders between them ([DemoOrders]). +/// +/// Anywhere else, at most one: the admin named by `savor.admin.email` and +/// `savor.admin.password` (or the same two names in the environment, in upper +/// case with `_` for each `.`), because a new deployment needs somebody able to +/// grant the first roles. It is created once; changing the setting later does +/// not change the password of an account that exists. +@Component +public class DemoAccounts { + public static final String CUSTOMER = "customer@savor.example"; + public static final String COURIER = "courier@savor.example"; + public static final String SECOND_COURIER = "courier2@savor.example"; + public static final String ADMIN = "admin@savor.example"; + /// Two more couriers, who carry the fortnight of past orders. + public static final String THIRD_COURIER = "courier3@savor.example"; + public static final String FOURTH_COURIER = "courier4@savor.example"; + /// Runs the first demo restaurant's kitchen. + public static final String MERCHANT = "merchant@savor.example"; + public static final String SECOND_CUSTOMER = "customer2@savor.example"; + /// A customer the admins have flagged. + public static final String FLAGGED_CUSTOMER = "customer3@savor.example"; + /// Someone whose application to deliver is complete and waiting for an admin. + public static final String APPLICANT = "applicant@savor.example"; + /// The password of every demo account. Not a secret: these accounts exist + /// only where the profile says the server is not a real one. + public static final String PASSWORD = "savor-demo"; + + /// A small grey chequered PNG, standing in for every photographed document + /// of the demo accounts. + static final String PLACEHOLDER_PICTURE = "iVBORw0KGgoAAAANSUhEUgAAADAAAAAeCAAAAABPz8IdAAAAJUlE" + + "QVR42mN4AgUnoIAQn2FEaiBWIYw/MjWMpqXRtDSaluioAQAjjbP9bRBUyQAAAABJRU5ErkJggg=="; + + private final Accounts accounts; + private final Applications applications; + private final Moderation moderation; + private final Payments payments; + private final DemoRestaurants restaurants; + private final DemoOrders orders; + private final ProfileRepository profiles; + private final CourierApplicationRepository forms; + private final CourierDocumentRepository documents; + private final Config config; + private final String adminEmail; + private final String adminPassword; + + public DemoAccounts(Accounts accounts, Applications applications, Moderation moderation, + Payments payments, DemoRestaurants restaurants, DemoOrders orders, + ProfileRepository profiles, + CourierApplicationRepository forms, CourierDocumentRepository documents, Config config, + @Value("${savor.admin.email:}") String adminEmail, + @Value("${savor.admin.password:}") String adminPassword) { + this.accounts = accounts; + this.applications = applications; + this.moderation = moderation; + this.payments = payments; + this.restaurants = restaurants; + this.orders = orders; + this.profiles = profiles; + this.forms = forms; + this.documents = documents; + this.config = config; + this.adminEmail = adminEmail; + this.adminPassword = adminPassword; + } + + /// Runs once, when the server starts. Not a transaction itself: each + /// account is made by a transactional method -- [Accounts]' or one of this + /// class's own -- so a server stopped half way through start-up is left + /// with whole accounts, and the next start adds the ones that are missing. + @PostConstruct + public void seed() throws IOException { + if (!config.isDevelopmentProfile()) { + if (adminEmail.length() > 0 && adminPassword.length() >= 8) { + add(Accounts.email(adminEmail), adminPassword, "Administrator", "+10000000", "", + ""); + } + return; + } + // The fortnight is written once, with the accounts it is shared among, + // and not again when a server with a database that persists restarts. + // The marker is an account made before the orders, so a start-up that + // failed between the two leaves a development database with the + // accounts and no fortnight; the answer there is to drop the database. + boolean first = !accounts.exists(THIRD_COURIER); + // The tests sign in as the first accounts and expect to find them as + // new accounts are: no orders behind them, no card saved, cash by + // default. Under the `test` profile the fortnight is the others' alone. + boolean everyone = !"test".equals(config.getProfile()); + + customer(CUSTOMER, "Riley Customer", "+15550100", 0, everyone); + courier(COURIER, "Dana Courier", "+15550101", "Blue", "Vespa", "Primavera", "SAV 101", 0); + courier(SECOND_COURIER, "Devon Courier", "+15550102", "Black", "Trek", "Allant+", + "SAV 102", 0); + add(ADMIN, PASSWORD, "Alex Admin", "+15550103", "", ""); + courier(THIRD_COURIER, "Carmen Courier", "+15550104", "Red", "Honda", "PCX", "SAV 103", + 12); + courier(FOURTH_COURIER, "Dmitri Courier", "+15550105", "Silver", "Toyota", "Yaris", + "SAV 104", 9); + customer(SECOND_CUSTOMER, "Robin Customer", "+15550106", 11, true); + boolean flag = !accounts.exists(FLAGGED_CUSTOMER); + customer(FLAGGED_CUSTOMER, "Rae Customer", "+15550107", 6, false); + if (flag) { + flag(FLAGGED_CUSTOMER, "Three orders refused at the door in one week", 3); + } + customer(MERCHANT, "Morgan Merchant", "+15550109", 20, false); + applicant(); + restaurants.seed(MERCHANT); + if (first) { + orders.seed(everyone ? new String[] {CUSTOMER, SECOND_CUSTOMER, FLAGGED_CUSTOMER} + : new String[] {SECOND_CUSTOMER, FLAGGED_CUSTOMER}, + everyone ? new String[] {COURIER, SECOND_COURIER, THIRD_COURIER, + FOURTH_COURIER} : new String[] {THIRD_COURIER, FOURTH_COURIER}); + } + } + + /// Marks an account as one the admins flagged some days ago, with the + /// event that says who did and why. + @Transactional(rollbackFor = IOException.class) + void flag(String username, String why, int daysAgo) throws IOException { + Profile profile = profiles.findById(username); + profile.setFlagged(true); + profile.setFlagReason(why); + moderation.record(username, ADMIN, Moderation.FLAG, why, + System.currentTimeMillis() - daysAgo * Days.MILLIS); + } + + /// An admin, or the plainest kind of account. + private void add(String username, String password, String name, String phone, String vehicle, + String plate) throws IOException { + if (accounts.exists(username)) { + return; + } + try { + accounts.create(username, password, name, phone, true, vehicle, plate, false, + ADMIN.equals(username) || !config.isDevelopmentProfile()); + } catch (IOException | RuntimeException failed) { + // Two servers started together against a new database both find + // no admin and both make one; the second insert fails on the name. + // create() is one transaction, so the loser left nothing behind, + // and the account the other server made is the one wanted. Any + // other failure leaves no account, and is the start-up's to report. + if (!accounts.exists(username)) { + throw failed; + } + } + } + + private void customer(String username, String name, String phone, int daysAgo, + boolean card) throws IOException { + if (accounts.exists(username)) { + return; + } + accounts.create(username, PASSWORD, name, phone, true, "", "", false, false, + opened(daysAgo)); + if (card) { + PaymentSetupDto setup = payments.startSetup(username, name); + // Only a simulated card can be made up. With a real processor + // configured the demo customers pay cash until someone adds a card. + if (!setup.hosted()) { + CardDto visa = new CardDto("4242424242424242", 12, year() + 3, "123", name); + payments.completeSetup(username, setup.id(), visa); + } + } + } + + /// An approved courier, with the application an approval implies: filled + /// in, its documents accepted. + private void courier(String username, String name, String phone, String colour, String make, + String model, String plate, int daysAgo) throws IOException { + if (accounts.exists(username)) { + return; + } + accounts.create(username, PASSWORD, name, phone, true, colour + " " + make + " " + model, + plate, true, false, opened(daysAgo)); + approved(username, name, colour, make, model, plate, daysAgo); + } + + /// Fills in the application an account created as a courier was given, and + /// adds its documents, already accepted. + @Transactional(rollbackFor = IOException.class) + void approved(String username, String name, String colour, String make, String model, + String plate, int daysAgo) throws IOException { + long handedIn = System.currentTimeMillis() - (daysAgo + 1) * Days.MILLIS; + CourierApplication form = forms.findById(username); + form.setLegalName(name); + form.setDateOfBirth("1988-04-12"); + form.setLicenceNumber("D" + (4400000 + plate.charAt(plate.length() - 1) * 7919)); + form.setLicenceExpiry((year() + 2) + "-06-30"); + form.setVehicleMake(make); + form.setVehicleModel(model); + form.setVehicleYear(year() - 3); + form.setVehicleColor(colour); + form.setVehiclePlate(plate); + form.setSubmittedAt(handedIn); + form.setReviewedBy(ADMIN); + for (int iter = 0; iter < Applications.KINDS.length; iter++) { + CourierDocument picture = new CourierDocument(); + picture.setUsername(username); + picture.setKind(Applications.KINDS[iter]); + picture.setContentType("image/png"); + picture.setStatus("accepted"); + picture.setUploadedAt(handedIn); + picture.setData(PLACEHOLDER_PICTURE); + documents.insert(picture); + } + } + + /// The applicant, taken through the same steps the app takes: a form, five + /// pictures, and handing it in. Which also means the demo application is + /// one the server itself accepts as complete. + private void applicant() throws IOException { + if (accounts.exists(APPLICANT)) { + return; + } + accounts.create(APPLICANT, PASSWORD, "Avery Applicant", "+15550108", true, "", "", false, + false, opened(4)); + applications.open(APPLICANT, "", ""); + // What an applicant fills in. The rest (who, the status, the documents, + // the dates) is the server's to say and is not read from a form. + CourierApplicationDto form = new CourierApplicationDto(null, null, null, + "Avery Jordan Applicant", "1992-09-03", "D4471902", (year() + 3) + "-03-31", + "Honda", "Civic", year() - 2, "Grey", "SAV 207", null, null, 0L, 0L, null); + applications.save(APPLICANT, form); + for (int iter = 0; iter < Applications.KINDS.length; iter++) { + DocumentUploadDto picture = new DocumentUploadDto(Applications.KINDS[iter], + "image/png", PLACEHOLDER_PICTURE); + applications.upload(APPLICANT, picture); + } + applications.submit(APPLICANT); + handedIn(APPLICANT, 2); + } + + /// Dates an application that was just handed in to some days ago, so that + /// the admins' queue shows one that has been waiting. + @Transactional(rollbackFor = IOException.class) + void handedIn(String username, int daysAgo) throws IOException { + CourierApplication form = forms.findById(username); + form.setSubmittedAt(System.currentTimeMillis() - daysAgo * Days.MILLIS); + } + + /// When an account opened that many days ago was opened, so that the chart + /// of new accounts is not one bar. + private static long opened(int daysAgo) { + return System.currentTimeMillis() - daysAgo * Days.MILLIS; + } + + private static int year() { + return Integer.parseInt(Days.iso(System.currentTimeMillis()).substring(0, 4)); + } +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/account/Grants.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/account/Grants.java new file mode 100644 index 00000000000..73ac3a6933b --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/account/Grants.java @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.account; + +import com.codename1.backend.DataSource; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.security.Authentication; +import com.codename1.backend.security.SecurityContextHolder; +import com.codename1.backend.security.oauth2.server.authorization.OAuth2AuthorizationService; +import com.codename1.backend.security.oauth2.server.resource.JwtAuthenticationToken; + +import java.io.IOException; +import java.util.List; +import java.util.Map; + +/// The sign-ins an account has open, and ending them. +/// +/// Signing in leaves a grant in the authorization server's own table, and a +/// refresh token is good for as long as its grant is there: refreshing asks +/// for no password. So a password changed because a phone was lost changes +/// nothing for whoever holds that phone, unless the grants go too. +/// +/// An access token already issued is not recalled by this. It is a signed +/// token that is checked without the table, and it runs out on its own within +/// its few minutes; what ends here is the means of getting another. +@Component +public class Grants { + private final DataSource db; + private final OAuth2AuthorizationService authorizations; + + public Grants(DataSource db, OAuth2AuthorizationService authorizations) { + this.db = db; + this.authorizations = authorizations; + } + + /// Ends every sign-in of an account but the one this request was made + /// with, so the device a password was changed on stays signed in and every + /// other has to give the new password. + public void endOthers(String username) throws IOException { + end(username, current()); + } + + /// Ends every sign-in of an account. + public void endAll(String username) throws IOException { + end(username, ""); + } + + private void end(String username, String kept) throws IOException { + // The table is the authorization server's, which offers a grant by its + // id and no list of an account's: this reads the ids and leaves the + // removing, which takes the grant's tokens with it, to the service. + List rows = db.query("SELECT id FROM cn1_oauth2_authorization WHERE principal_name = ?", + new Object[] {username}); + for (int iter = 0; rows != null && iter < rows.size(); iter++) { + Object row = rows.get(iter); + Object id = row instanceof Map ? ((Map) row).get("id") : null; + if (id != null && !kept.equals(String.valueOf(id))) { + authorizations.remove(String.valueOf(id)); + } + } + } + + /// The id of the grant the request's token was issued under, or empty for + /// a request that carried no such token. The server writes the grant's id + /// before the first dot of the token's own. + private static String current() { + Authentication who = SecurityContextHolder.getContext().getAuthentication(); + if (!(who instanceof JwtAuthenticationToken)) { + return ""; + } + String id = ((JwtAuthenticationToken) who).getToken().getId(); + int dot = id == null ? -1 : id.indexOf('.'); + return dot > 0 ? id.substring(0, dot) : ""; + } +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/account/Moderation.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/account/Moderation.java new file mode 100644 index 00000000000..24ab4f8f2dc --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/account/Moderation.java @@ -0,0 +1,101 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.account; + +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Transactional; +import com.codenameone.examples.savor.Ids; +import com.codenameone.examples.savor.api.ModerationEventDto; +import com.codenameone.examples.savor.domain.ModerationEvent; +import com.codenameone.examples.savor.repository.ModerationEventRepository; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +/// The record of what the admins did to an account, and why. +/// +/// Flagging, blocking, approving and rejecting all change an account's state, +/// and the state alone does not say who decided it or on what grounds. This +/// does. A row is written with the change and never altered afterwards. +@Component +public class Moderation { + public static final String FLAG = "flag"; + public static final String UNFLAG = "unflag"; + public static final String BLOCK = "block"; + public static final String UNBLOCK = "unblock"; + public static final String APPROVE = "approve"; + public static final String REJECT = "reject"; + public static final String ROLES = "roles"; + /// A restaurant application approved; the column holds 16 characters. + public static final String LISTED = "listed"; + public static final String CANCEL_ORDER = "cancel_order"; + + private final ModerationEventRepository events; + + public Moderation(ModerationEventRepository events) { + this.events = events; + } + + @Transactional + public void record(String username, String by, String action, String reason) + throws IOException { + // Two changes made in the same millisecond -- a block and the flag that + // came with it -- would otherwise be listed in either order. The later + // one is stamped after the one before it, so the list is the order in + // which things were done. + Long newest = events.newest(username); + long after = newest == null ? 0L : newest.longValue() + 1L; + record(username, by, action, reason, Math.max(System.currentTimeMillis(), after)); + } + + /// As [#record(String, String, String, String)], at a time of the caller's + /// choosing; the demo data is the one caller that has a past to write. + @Transactional + public void record(String username, String by, String action, String reason, long at) + throws IOException { + String why = reason == null ? "" : reason.trim(); + ModerationEvent event = new ModerationEvent(); + event.setId(Ids.next()); + event.setUsername(username); + event.setCreatedAt(at); + event.setActor(by); + event.setAction(action); + event.setReason(why.length() > 255 ? why.substring(0, 255) : why); + events.insert(event); + } + + /// An account's events, newest first. + @Transactional(readOnly = true) + public List events(String username) throws IOException { + List rows = events.findTop100ByUsernameOrderByCreatedAtDescIdAsc(username); + List out = new ArrayList(); + for (int iter = 0; iter < rows.size(); iter++) { + ModerationEvent row = rows.get(iter); + out.add(new ModerationEventDto(row.getCreatedAt(), row.getActor(), row.getAction(), + row.getReason())); + } + return out; + } + + @Transactional + public void forget(String username) throws IOException { + events.deleteByUsername(username); + } +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/account/PhoneVerification.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/account/PhoneVerification.java new file mode 100644 index 00000000000..cb85edf4f8e --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/account/PhoneVerification.java @@ -0,0 +1,262 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.account; + +import com.codename1.backend.Config; +import com.codename1.backend.Crypto; +import com.codename1.backend.ResponseStatusException; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Transactional; +import com.codenameone.examples.savor.Ids; +import com.codenameone.examples.savor.api.PhoneChallengeDto; +import com.codenameone.examples.savor.api.UserDto; +import com.codenameone.examples.savor.domain.PhoneCode; +import com.codenameone.examples.savor.repository.PhoneCodeRepository; +import com.codenameone.examples.savor.sms.SmsSender; + +import java.io.IOException; + +/// Proves an account's phone number by texting it a code. +/// +/// The server makes the code, keeps only its hash, and is the one that decides +/// whether what the user typed matches. The app never sees the code it is asking +/// the user for, so it cannot be talked into accepting one. +@Component +public class PhoneVerification { + /// How long a code stays good. + static final int CODE_SECONDS = 300; + /// Wrong guesses allowed before the code is void. Six digits and five + /// guesses, each against at most two live codes (see [#store]), is one + /// chance in 100,000 per code sent. + static final int MAX_ATTEMPTS = 5; + /// The least time between two codes for one account. Each one is a text + /// message somebody pays for and somebody receives. + static final int RESEND_SECONDS = 30; + + private final PhoneCodeRepository codes; + private final Accounts accounts; + private final SmsSender sms; + private final Config config; + + public PhoneVerification(PhoneCodeRepository codes, Accounts accounts, SmsSender sms, + Config config) { + this.codes = codes; + this.accounts = accounts; + this.sms = sms; + this.config = config; + } + + /// Sends a new code, replacing the one before it. + /// + /// Not one transaction, because a text message is sent in the middle and a + /// transaction must not be held open across a call that leaves the server. + /// The code is stored and committed, then sent, and taken back in a second + /// transaction when it could not be. + public PhoneChallengeDto start(String username) throws IOException { + UserDto user = accounts.describe(username); + String code = newCode(); + String codeHash = hash(username, code); + store(username, codeHash, System.currentTimeMillis()); + try { + sms.send(user.phone(), "Your Savor code is " + code); + } catch (IOException failed) { + System.err.println("could not send a verification code: " + failed.getMessage()); + discard(username, codeHash); + throw new ResponseStatusException(503, "We could not send the code. Try again shortly"); + } + // Only a server that cannot send a text message, and only on a + // development profile, tells the app what the code was. Both halves + // matter: a production server whose SMS settings were forgotten must fail + // to verify anyone, not verify everyone. + String demoCode = !sms.delivers() && config.isDevelopmentProfile() ? code : null; + return new PhoneChallengeDto(CODE_SECONDS, demoCode); + } + + /// Keeps the hash of a new code in place of the one before it. Answers 429 + /// when the one before it was sent a moment ago. + /// + /// The code before it stays good until it would have expired anyway. A + /// second code is asked for when the first is slow to arrive, and text + /// messages are not delivered in order: the first may arrive last, and the + /// newest message on the phone must not be a code the server refuses. Only + /// one is kept, so at most two codes are live, and only one whose guesses + /// were not used up. + @Transactional + void store(String username, String codeHash, long now) throws IOException { + // Before the code is read: two requests for one account take turns, + // so the second finds the code the first just stored and is told to + // wait. Otherwise both pass the check below and both send a text. + accounts.hold(username); + PhoneCode pending = codes.findById(username); + if (pending != null && now - pending.getSentAt() < RESEND_SECONDS * 1000L) { + throw new ResponseStatusException(429, "Wait a moment before asking for another code"); + } + boolean first = pending == null; + if (first) { + pending = new PhoneCode(); + pending.setUsername(username); + } else if (pending.getExpiresAt() >= now && pending.getAttempts() < MAX_ATTEMPTS) { + // With the guesses it has used: asking for another code must not + // buy the old one a fresh set. + pending.setPreviousHash(pending.getCodeHash()); + pending.setPreviousExpiresAt(pending.getExpiresAt()); + pending.setPreviousAttempts(pending.getAttempts()); + } else { + pending.setPreviousHash(""); + pending.setPreviousExpiresAt(0L); + pending.setPreviousAttempts(0); + } + pending.setCodeHash(codeHash); + pending.setExpiresAt(now + CODE_SECONDS * 1000L); + pending.setAttempts(0); + pending.setSentAt(now); + if (first) { + codes.insert(pending); + } + } + + /// Takes back a code that could not be sent, if it is still the one the + /// account is waiting on. A send can take longer to fail than the wait + /// between two codes, and by then a later request may have stored a code + /// of its own and had it delivered: that one is the user's, and stays. + /// The code before it, when it is still good, is the one waited on again, + /// and another may be asked for at once. + @Transactional + void discard(String username, String codeHash) throws IOException { + // The hold store() takes, before the row is read: a resend storing its + // code meanwhile commits first, and this then finds that code and + // leaves it, where read before the wait it would overwrite or delete it. + accounts.hold(username); + PhoneCode pending = codes.findById(username); + if (pending == null || !codeHash.equals(pending.getCodeHash())) { + return; + } + if (pending.getPreviousHash().length() == 0 + || pending.getPreviousExpiresAt() < System.currentTimeMillis()) { + codes.deleteById(username); + return; + } + pending.setCodeHash(pending.getPreviousHash()); + pending.setExpiresAt(pending.getPreviousExpiresAt()); + pending.setAttempts(pending.getPreviousAttempts()); + pending.setPreviousHash(""); + pending.setPreviousExpiresAt(0L); + pending.setPreviousAttempts(0); + pending.setSentAt(0L); + } + + /// Checks the code the user typed. Answers 400 for a wrong or expired one + /// and 429 once the guesses are used up. + /// + /// The refusal of a wrong code is thrown here, outside the transaction that + /// counted the guess. Thrown inside it, the error would roll the count back + /// and every code could be guessed at for ever. + public UserDto verify(String username, String code) throws IOException { + if (!guess(username, code)) { + throw new ResponseStatusException(400, "That is not the code we sent"); + } + return accounts.describe(username); + } + + /// Takes one guess at an account's code and answers whether it was right. + /// A right one uses the code up and marks the phone verified; a wrong one + /// leaves the guess counted, which is why this returns false where it + /// could have thrown. + @Transactional + boolean guess(String username, String code) throws IOException { + // The hold store() and discard() take, and in the same order as a + // closure, which holds the profile and then deletes the code: taken + // the other way round, a guess and a closure could each wait on the + // row the other has. + accounts.hold(username); + PhoneCode pending = codes.findById(username); + if (pending == null || pending.getExpiresAt() < System.currentTimeMillis()) { + throw new ResponseStatusException(400, "That code has expired. Ask for a new one"); + } + if (pending.getAttempts() >= MAX_ATTEMPTS) { + throw new ResponseStatusException(429, "Too many wrong codes. Ask for a new one"); + } + String expected = pending.getCodeHash(); + // Taken before the comparison, and by the database, which also holds + // the limit: the count read above is what it was when this request + // arrived, and any number of requests arriving together all read the + // same one. Only a request that is given a guess has its code looked at. + // And only of the code read above: one sent meanwhile is not this one. + if (codes.takeGuess(username, expected, MAX_ATTEMPTS) != 1) { + throw new ResponseStatusException(429, "Too many wrong codes. Ask for a new one"); + } + // Read again after the guess was taken, which the update did under + // the row's lock: the count of the code before is then this guess's + // own, and within its limit only when this guess was one it had left. + PhoneCode counted = codes.findById(username); + String typed = code == null ? "" : code.trim(); + byte[] given = hash(username, typed).getBytes("UTF-8"); + // Both compared every time, so the answer takes as long either way. The + // code before is good only until it would have expired; it was sent + // first, so it never outlives the current one checked above. + // + // `counted.getPreviousAttempts()` already includes every guess made + // since the resend: takeGuess adds one to `previousAttempts` in the + // same statement that adds one to `attempts`, so it is the old code's + // whole count, not the snapshot store() copied. A code that used four + // guesses before the resend reads 5 on its first guess after it (still + // allowed: its fifth) and 6 on the next (refused), so its budget stays + // five. AccountTest.theCodeBeforeKeepsTheGuessesItHadLeft holds that. + boolean current = Crypto.equalsConstantTime(given, expected.getBytes("UTF-8")); + boolean previous = Crypto.equalsConstantTime(given, + pending.getPreviousHash().getBytes("UTF-8")) + && pending.getPreviousHash().length() > 0 + && pending.getPreviousExpiresAt() >= System.currentTimeMillis() + && counted != null && counted.getPreviousAttempts() <= MAX_ATTEMPTS; + if (!current && !previous) { + return false; + } + pending = codes.findById(username); + if (pending != null) { + codes.deleteById(username); + } + accounts.markPhoneVerified(username); + return true; + } + + /// Six digits, each value as likely as the next: a draw that would favour + /// the low numbers is thrown away and drawn again. + private static String newCode() throws IOException { + while (true) { + byte[] random = Crypto.randomBytes(4); + int value = ((random[0] & 0x7f) << 24) | ((random[1] & 0xff) << 16) + | ((random[2] & 0xff) << 8) | (random[3] & 0xff); + if (value < 2147000000) { + String digits = String.valueOf(1000000 + value % 1000000); + return digits.substring(1); + } + } + } + + /// An unkeyed digest, kept so on purpose. Somebody who can read the + /// `phone_code` table could try every code within the five minutes one + /// lasts, but somebody who can read the database already owns what a + /// phone check guards: it proves a number, and grants nothing on its own. + /// A keyed digest under a server secret is a hardening step for a + /// production deployment, listed in the implementor's guide among what the + /// sample leaves out. + private static String hash(String username, String code) throws IOException { + return Ids.sha256(username + ":" + code); + } +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/account/Preferences.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/account/Preferences.java new file mode 100644 index 00000000000..27fde134aa5 --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/account/Preferences.java @@ -0,0 +1,131 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.account; + +import com.codename1.backend.ResponseStatusException; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Transactional; +import com.codenameone.examples.savor.Text; +import com.codenameone.examples.savor.api.PreferencesDto; +import com.codenameone.examples.savor.domain.Preference; +import com.codenameone.examples.savor.domain.Profile; +import com.codenameone.examples.savor.pay.Payments; +import com.codenameone.examples.savor.repository.PreferenceRepository; +import com.codenameone.examples.savor.repository.ProfileRepository; + +import java.io.IOException; + +/// How one person wants the app and their deliveries: language, theme, units, what +/// they are told about, and what a delivery is asked for with unless they say +/// otherwise. +/// +/// Kept on the server so that they follow the account from one phone to the +/// next. Two of them live elsewhere, because something else reads them: the +/// language is in the profile, where the admin sees it, and the default way to +/// pay is the mark on the saved card itself, so that removing a card cannot +/// leave a preference pointing at it. +@Component +public class Preferences { + private static final String[] LANGUAGES = {"en", "es", "fr", "de", "he"}; + private static final String[] THEMES = {"system", "light", "dark"}; + private static final String[] UNITS = {"km", "mi"}; + + private final ProfileRepository profiles; + private final PreferenceRepository preferences; + private final Payments payments; + + public Preferences(ProfileRepository profiles, PreferenceRepository preferences, + Payments payments) { + this.profiles = profiles; + this.preferences = preferences; + this.payments = payments; + } + + @Transactional(readOnly = true) + public PreferencesDto get(String username) throws IOException { + Profile profile = profiles.findById(username); + if (profile == null) { + throw new ResponseStatusException(404, "No such account"); + } + Preference row = preferences.findById(username); + String language = profile.getLanguage(); + String method = payments.defaultMethod(username); + if (row == null) { + // What someone who never opened the settings gets. + return new PreferencesDto(language, THEMES[0], UNITS[0], true, true, false, method, + 0); + } + return new PreferencesDto(language, row.getTheme(), row.getUnits(), row.isNotifyDeliveries(), + row.isNotifyReceipts(), row.isNotifyPromotions(), method, row.getDefaultTip()); + } + + /// Saves all of them. Each is checked against the values it can take: these + /// are words the server later acts on, not text to show back. One + /// transaction, so a value that is refused leaves none of the others saved. + @Transactional + public PreferencesDto save(String username, PreferencesDto wanted) throws IOException { + if (wanted == null) { + throw new ResponseStatusException(400, "Nothing to save"); + } + // First, before anything is read: two saves sent together for an + // account with no row of settings yet would each find none and each + // insert one, and the second insert fails on the key. The profile is + // there from registration, so held, one save waits and finds the + // other's row. + profiles.hold(username); + int tip = wanted.defaultTipPercent(); + if (tip != 0 && tip != 10 && tip != 15 && tip != 20) { + throw new ResponseStatusException(400, "A default tip is 0, 10, 15 or 20 percent"); + } + String theme = Text.oneOf(wanted.theme(), THEMES, THEMES[0], "the theme"); + String units = Text.oneOf(wanted.units(), UNITS, UNITS[0], "the units"); + String language = Text.oneOf(wanted.language(), LANGUAGES, LANGUAGES[0], "the language"); + String method = wanted.defaultPaymentMethodId() == null ? "" + : wanted.defaultPaymentMethodId().trim(); + if (method.length() > 0 && !method.equals(payments.defaultMethod(username))) { + // Answers 404 for a card that is not this user's, before anything + // else is saved. + payments.makeDefault(username, method); + } + // Read after the card was marked, not before: marking one may be a + // statement over every card of the account, and what was read before + // such a statement is no longer the session's to write. + Profile profile = profiles.findById(username); + if (profile == null) { + throw new ResponseStatusException(404, "No such account"); + } + profile.setLanguage(language); + Preference row = preferences.findById(username); + boolean unsaved = row == null; + if (unsaved) { + row = new Preference(); + row.setUsername(username); + } + row.setTheme(theme); + row.setUnits(units); + row.setNotifyDeliveries(wanted.notifyDeliveryUpdates()); + row.setNotifyReceipts(wanted.notifyReceipts()); + row.setNotifyPromotions(wanted.notifyPromotions()); + row.setDefaultTip(tip); + if (unsaved) { + preferences.insert(row); + } + return get(username); + } +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/admin/AdminEndpoint.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/admin/AdminEndpoint.java new file mode 100644 index 00000000000..cb97161262c --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/admin/AdminEndpoint.java @@ -0,0 +1,250 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.admin; + +import com.codename1.backend.ResponseStatusException; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.PreAuthorize; +import com.codenameone.examples.savor.Caller; +import com.codenameone.examples.savor.Text; +import com.codenameone.examples.savor.account.Accounts; +import com.codenameone.examples.savor.api.AdminApiServer; +import com.codenameone.examples.savor.api.AdminStatsDto; +import com.codenameone.examples.savor.api.DocumentContentDto; +import com.codenameone.examples.savor.api.CourierApplicationDto; +import com.codenameone.examples.savor.api.CourierDto; +import com.codenameone.examples.savor.api.FlagDto; +import com.codenameone.examples.savor.api.PricingDto; +import com.codenameone.examples.savor.api.ReasonDto; +import com.codenameone.examples.savor.api.ReceiptDto; +import com.codenameone.examples.savor.api.DeliveryDto; +import com.codenameone.examples.savor.api.RoleChangeDto; +import com.codenameone.examples.savor.api.StatsSeriesDto; +import com.codenameone.examples.savor.api.SuspendDto; +import com.codenameone.examples.savor.api.UserDetailDto; +import com.codenameone.examples.savor.api.UserDto; +import com.codenameone.examples.savor.courier.Couriers; +import com.codenameone.examples.savor.onboarding.Applications; +import com.codenameone.examples.savor.live.LiveHub; +import com.codenameone.examples.savor.pay.Payments; +import com.codenameone.examples.savor.order.Quotes; +import com.codenameone.examples.savor.api.OrderDto; +import com.codenameone.examples.savor.api.PromoDto; +import com.codenameone.examples.savor.api.RestaurantDto; +import com.codenameone.examples.savor.api.SuspendDto; +import com.codenameone.examples.savor.catalog.Catalog; +import com.codenameone.examples.savor.catalog.Restaurants; +import com.codenameone.examples.savor.delivery.Deliveries; +import com.codenameone.examples.savor.order.Orders; +import com.codenameone.examples.savor.order.Promos; + +import java.util.List; + +/// The server's half of `AdminApi`. Guarded by path in the filter chain and by +/// role here, as `CourierEndpoint` is. +/// +/// This is the one place a name in the path chooses whose data is read: an +/// applicant's documents, a customer's receipts. Everywhere else the caller is +/// the subject, and only an admin gets to name somebody else. +@Component +@PreAuthorize("hasRole('ADMIN')") +public class AdminEndpoint implements AdminApiServer { + private final Accounts accounts; + private final Deliveries deliveries; + private final Orders orders; + private final Restaurants restaurants; + private final Promos promos; + private final Couriers couriers; + private final Quotes fares; + private final LiveHub live; + private final Applications applications; + private final Payments payments; + private final Statistics statistics; + + public AdminEndpoint(Accounts accounts, Deliveries deliveries, Orders orders, + Restaurants restaurants, Promos promos, Couriers couriers, + Quotes fares, LiveHub live, Applications applications, Payments payments, + Statistics statistics) { + this.applications = applications; + this.payments = payments; + this.statistics = statistics; + this.accounts = accounts; + this.deliveries = deliveries; + this.orders = orders; + this.restaurants = restaurants; + this.promos = promos; + this.couriers = couriers; + this.fares = fares; + this.live = live; + } + + @Override + public AdminStatsDto stats() throws Exception { + return statistics.dashboard(); + } + + @Override + public List deliveries() throws Exception { + return deliveries.recent(); + } + + @Override + public List users() throws Exception { + return accounts.all(); + } + + @Override + public List couriers() throws Exception { + return couriers.all(); + } + + @Override + public UserDto setRoles(String username, RoleChangeDto roles) throws Exception { + UserDto user = accounts.setRoles(Caller.name(), username, roles != null && roles.courier(), + roles != null && roles.admin()); + // A live channel keeps the roles it was opened with: one opened by an + // admin goes on receiving where every courier is after the role is + // taken away, since it asks for nothing again. Closed here, the app + // opens another with a fresh ticket and gets the frames of the roles + // the account has now. + live.drop(username); + return user; + } + + @Override + public UserDto suspend(String username, SuspendDto suspend) throws Exception { + UserDto user = accounts.suspend(Caller.name(), username, + suspend != null && suspend.suspended(), suspend == null ? "" : suspend.reason()); + if (user.suspended()) { + // The API turns a suspended account away on its next request. A + // live channel already open makes no further request, so it is + // closed here. + live.drop(username); + } + return user; + } + + @Override + public StatsSeriesDto statsSeries(int days) throws Exception { + return statistics.series(days); + } + + @Override + public UserDetailDto user(String username) throws Exception { + return statistics.user(username); + } + + @Override + public UserDto flag(String username, FlagDto flag) throws Exception { + return accounts.flag(Caller.name(), username, flag != null && flag.flagged(), + flag == null ? "" : flag.reason()); + } + + @Override + public List applications(String status) throws Exception { + return applications.list(status); + } + + @Override + public CourierApplicationDto application(String username) throws Exception { + return applications.review(username); + } + + @Override + public DocumentContentDto document(String username, String kind) throws Exception { + return applications.document(username, kind); + } + + @Override + public CourierApplicationDto approve(String username) throws Exception { + return applications.approve(Caller.name(), username); + } + + @Override + public CourierApplicationDto reject(String username, ReasonDto reason) throws Exception { + return applications.reject(Caller.name(), username, reason == null ? "" : reason.reason()); + } + + @Override + public List orders() throws Exception { + return orders.recent(); + } + + @Override + public OrderDto cancelOrder(String id, ReasonDto reason) throws Exception { + return orders.cancelByAdmin(Caller.name(), id, reason == null ? "" : reason.reason()); + } + + @Override + public ReceiptDto refundOrder(String id, ReasonDto reason) throws Exception { + return payments.refund(id, Text.required(reason == null ? "" : reason.reason(), 255, + "why it is refunded")); + } + + @Override + public List restaurants(String status) throws Exception { + return restaurants.byStatus(status == null || status.length() == 0 ? Catalog.PENDING + : status); + } + + @Override + public RestaurantDto approveRestaurant(String id) throws Exception { + return restaurants.approve(Caller.name(), id); + } + + @Override + public RestaurantDto rejectRestaurant(String id, ReasonDto reason) throws Exception { + return restaurants.reject(Caller.name(), id, reason == null ? "" : reason.reason()); + } + + @Override + public RestaurantDto suspendRestaurant(String id, SuspendDto suspend) throws Exception { + if (suspend == null) { + throw new ResponseStatusException(400, "Say whether to suspend it"); + } + return restaurants.suspend(Caller.name(), id, suspend.suspended(), suspend.reason()); + } + + @Override + public List promos() throws Exception { + Caller.name(); + return promos.all(); + } + + @Override + public PromoDto savePromo(PromoDto promo) throws Exception { + Caller.name(); + return promos.save(promo); + } + + @Override + public List payments() throws Exception { + return payments.allReceipts(); + } + + @Override + public PricingDto pricing() throws Exception { + return fares.pricing(); + } + + @Override + public PricingDto savePricing(PricingDto pricing) throws Exception { + return fares.save(Caller.name(), pricing); + } +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/admin/Statistics.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/admin/Statistics.java new file mode 100644 index 00000000000..5e476f363bf --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/admin/Statistics.java @@ -0,0 +1,295 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.admin; + +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Transactional; +import com.codenameone.examples.savor.Days; +import com.codenameone.examples.savor.Rows; +import com.codenameone.examples.savor.account.Accounts; +import com.codenameone.examples.savor.account.Moderation; +import com.codenameone.examples.savor.api.AdminStatsDto; +import com.codenameone.examples.savor.api.DayStatsDto; +import com.codenameone.examples.savor.api.CourierDto; +import com.codenameone.examples.savor.api.NameCountDto; +import com.codenameone.examples.savor.api.DeliveryState; +import com.codenameone.examples.savor.api.OrderState; +import com.codenameone.examples.savor.api.StatsSeriesDto; +import com.codenameone.examples.savor.api.UserDetailDto; +import com.codenameone.examples.savor.api.UserDto; +import com.codenameone.examples.savor.domain.CourierState; +import com.codenameone.examples.savor.domain.Restaurant; +import com.codenameone.examples.savor.courier.Couriers; +import com.codenameone.examples.savor.courier.Earnings; +import com.codenameone.examples.savor.onboarding.Applications; +import com.codenameone.examples.savor.repository.CourierStateRepository; +import com.codenameone.examples.savor.repository.ProfileRepository; +import com.codenameone.examples.savor.repository.CustomerOrderRepository; +import com.codenameone.examples.savor.repository.DeliveryEventRepository; +import com.codenameone.examples.savor.repository.DeliveryRepository; +import com.codenameone.examples.savor.repository.RestaurantRepository; +import com.codenameone.examples.savor.order.Quotes; +import com.codenameone.examples.savor.delivery.Matcher; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/// The numbers behind the admin's dashboard and charts, and the account of one +/// user. +/// +/// Whatever can be counted by a column is counted by the database: deliveries by +/// state, by product and by courier, and every sum of money. The charts by day +/// and by hour of the day are counted here instead, from one row per delivery of +/// the period. Times are stored as milliseconds and a day is a division +/// ([Days]); [CustomerOrderRepository#timeline] says why that division is not left +/// to a query. A sample's ninety days fit in memory. A service whose do not +/// keeps a table of daily totals, and this class is where it would be read. +@Component +public class Statistics { + static final int DEFAULT_DAYS = 14; + static final int MAX_DAYS = 90; + private static final int TOP_COURIERS = 5; + private static final OrderState[] STATES = OrderState.values(); + + private static final String PAID = "PAID"; + private static final OrderState[] ACTIVE = {OrderState.PLACED, OrderState.ACCEPTED, + OrderState.READY, OrderState.PICKED_UP}; + + private final ProfileRepository profiles; + private final CourierStateRepository cars; + private final DeliveryRepository deliveries; + private final CustomerOrderRepository orders; + private final DeliveryEventRepository events; + private final Accounts accounts; + private final Couriers couriers; + private final Moderation moderation; + private final Quotes fares; + private final Applications applications; + private final Earnings earnings; + private final RestaurantRepository restaurants; + + public Statistics(ProfileRepository profiles, CourierStateRepository cars, + DeliveryRepository deliveries, CustomerOrderRepository orders, + DeliveryEventRepository events, Accounts accounts, + Couriers couriers, Moderation moderation, Quotes fares, Applications applications, + Earnings earnings, RestaurantRepository restaurants) { + this.earnings = earnings; + this.restaurants = restaurants; + this.profiles = profiles; + this.cars = cars; + this.deliveries = deliveries; + this.orders = orders; + this.events = events; + this.applications = applications; + this.accounts = accounts; + this.couriers = couriers; + this.moderation = moderation; + this.fares = fares; + } + + /// The handful of totals at the top of the admin's dashboard. + @Transactional(readOnly = true) + public AdminStatsDto dashboard() throws IOException { + long now = System.currentTimeMillis(); + int users = (int) profiles.count(); + // Who holds a role is the sign-in store's to say and not a table of + // this application's, so the couriers are counted from the accounts as + // the accounts service describes them. + int onboarding = 0; + List everyone = accounts.all(); + for (int iter = 0; iter < everyone.size(); iter++) { + if (everyone.get(iter).courier()) { + onboarding++; + } + } + int online = (int) cars.countByOnlineTrueAndLastSeenGreaterThanEqual( + now - Matcher.FRESH_MILLIS); + int today = (int) orders.countByPlacedAtGreaterThanEqual(Days.start(now)); + int active = (int) orders.countByStateIn(ACTIVE); + int done = (int) orders.countByStateIn(new OrderState[] {OrderState.DELIVERED}); + long revenue = Rows.number(orders.totals(OrderState.DELIVERED, PAID)); + return new AdminStatsDto(users, onboarding, online, today, active, done, revenue, + fares.currency()); + } + + /// The last `days` days, today included and last. Fewer than one is taken + /// as the default and more than [#MAX_DAYS] as that many. + @Transactional(readOnly = true) + public StatsSeriesDto series(int days) throws IOException { + int span = days < 1 ? DEFAULT_DAYS : Math.min(days, MAX_DAYS); + long today = Days.start(System.currentTimeMillis()); + long since = today - (span - 1) * Days.MILLIS; + // Each day's figures, added up here and made into a DayStatsDto at the end. + int[] dayOrders = new int[span]; + int[] dayDelivered = new int[span]; + int[] dayCancelled = new int[span]; + long[] dayRevenue = new long[span]; + int[] dayNewUsers = new int[span]; + int[] byHour = new int[24]; + // By day, by hour and by state, counted here from one row per order; + // and the time from placing to the door, of each one that got there. + int[] byState = new int[STATES.length]; + long delivered = 0L; + long finished = 0L; + long totalSum = 0L; + long tookMillis = 0L; + List timeline = orders.timeline(since); + for (int iter = 0; iter < timeline.size(); iter++) { + Object[] order = timeline.get(iter); + long at = whole(order[1]); + OrderState state = Rows.orderState(order[2]); + byHour[(int) (at % Days.MILLIS / 3600000L)]++; + if (state != null) { + byState[state.ordinal()]++; + } + int index = (int) ((at - since) / Days.MILLIS); + boolean arrived = OrderState.DELIVERED == state; + if (arrived) { + delivered++; + finished++; + totalSum += whole(order[3]); + tookMillis += Math.max(0L, whole(order[5]) - at); + } else if (ended(state)) { + finished++; + } + if (index < 0 || index >= span) { + continue; + } + dayOrders[index]++; + if (arrived) { + dayDelivered[index]++; + if (PAID.equals(order[4])) { + dayRevenue[index] += whole(order[3]); + } + } else if (ended(state)) { + dayCancelled[index]++; + } + } + Map completedBy = new HashMap(); + List perCourier = deliveries.countByCourier(since, DeliveryState.COMPLETED); + for (int iter = 0; iter < perCourier.size(); iter++) { + completedBy.put(Rows.text(perCourier.get(iter)[0]), + new int[] {(int) whole(perCourier.get(iter)[1])}); + } + List hours = new ArrayList(); + for (int iter = 0; iter < byHour.length; iter++) { + hours.add(Integer.valueOf(byHour[iter])); + } + List statesSeen = new ArrayList(); + for (int iter = 0; iter < STATES.length; iter++) { + // A state no order is in is left out; a chart has no slice for it. + if (byState[iter] > 0) { + statesSeen.add(new NameCountDto(STATES[iter].name(), byState[iter])); + } + } + List byRestaurant = new ArrayList(); + List kitchens = orders.countByRestaurant(since); + for (int iter = 0; iter < kitchens.size(); iter++) { + // Counted by id, and named as the restaurant is called now: two + // kitchens may share a name, and one may have been renamed. + Restaurant kitchen = restaurants.findById(Rows.text(kitchens.get(iter)[0])); + byRestaurant.add(new NameCountDto(kitchen == null ? Rows.text(kitchens.get(iter)[0]) + : kitchen.getName(), (int) whole(kitchens.get(iter)[1]))); + } + Collections.sort(byRestaurant, (a, b) -> Integer.compare(b.count(), a.count())); + // Tested before dividing, and not left to an exception: dividing a + // whole number by zero does not raise one on every runtime this runs on. + long averageOrder = delivered == 0L ? 0L : totalSum / delivered; + double averageDeliveryMinutes = delivered == 0L ? 0d + : Math.round(tookMillis / 6000d / delivered) / 10d; + double completionRate = finished == 0L ? 0d + : Math.round(delivered * 100d / finished) / 100d; + + List signUps = profiles.signUps(since); + for (int iter = 0; iter < signUps.size(); iter++) { + int index = (int) ((signUps.get(iter).longValue() - since) / Days.MILLIS); + if (index >= 0 && index < span) { + dayNewUsers[index]++; + } + } + List daily = new ArrayList(); + for (int iter = 0; iter < span; iter++) { + daily.add(new DayStatsDto(Days.iso(since + iter * Days.MILLIS), dayOrders[iter], + dayDelivered[iter], dayCancelled[iter], dayRevenue[iter], + dayNewUsers[iter])); + } + + List topCouriers = new ArrayList(); + for (int place = 0; place < TOP_COURIERS && !completedBy.isEmpty(); place++) { + String best = null; + for (Map.Entry entry : completedBy.entrySet()) { + int[] top = best == null ? null : completedBy.get(best); + // Ties go to the name that sorts first, so the order is the + // same every time the chart is drawn. + if (top == null || entry.getValue()[0] > top[0] || (entry.getValue()[0] == top[0] + && entry.getKey().compareTo(best) < 0)) { + best = entry.getKey(); + } + } + int rode = completedBy.remove(best)[0]; + if (accounts.exists(best)) { + // Within the period, which is what the chart is of. + topCouriers.add(couriers.describe(best, rode)); + } + } + return new StatsSeriesDto(daily, hours, statesSeen, byRestaurant, topCouriers, + averageOrder, averageDeliveryMinutes, completionRate, applications.waiting(), + (int) profiles.countByFlaggedTrue(), fares.currency()); + } + + /// One account, with what it has done and what was done to it. + @Transactional(readOnly = true) + public UserDetailDto user(String username) throws IOException { + UserDto user = accounts.describe(username); + int ordered = (int) orders.countByCustomer(username); + int carried = (int) deliveries.countByCourier(username); + // Called off by this account: as a customer, and as a courier who + // gave a delivery back. + int cancellations = (int) (orders.countByCustomerAndState(username, + OrderState.CANCELLED_BY_CUSTOMER) + events.countByActorAndState(username, + DeliveryState.CANCELLED_BY_COURIER)); + CourierState car = username == null || username.length() == 0 + || username.length() > 190 ? null : cars.findById(username); + // A rating is a courier's; an account that has never delivered has none. + double rating = car == null ? 0d : car.rating(); + long spent = Rows.number(orders.spent(username, OrderState.DELIVERED, PAID)); + // What the courier's own screens call earned, from the same sum: a + // card delivery whose charge failed, is pending or was refunded, and a + // tip whose charge has not come back, are not money the courier has. + long earned = earnings.earned(username); + return new UserDetailDto(user, ordered, carried, cancellations, rating, spent, earned, + moderation.events(username)); + } + + /// Whether an order in `state` ended without reaching the door. + private static boolean ended(OrderState state) { + return OrderState.REJECTED == state || OrderState.CANCELLED_BY_CUSTOMER == state + || OrderState.CANCELLED_BY_ADMIN == state || OrderState.NO_COURIERS == state; + } + + /// A count or a sum as a query answers it. + private static long whole(Object value) { + return value instanceof Number ? ((Number) value).longValue() : 0L; + } + +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/catalog/Catalog.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/catalog/Catalog.java new file mode 100644 index 00000000000..0e61b522f0d --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/catalog/Catalog.java @@ -0,0 +1,354 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.catalog; + +import com.codename1.backend.Base64; +import com.codename1.backend.ResponseStatusException; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Transactional; +import com.codename1.orm.session.Identifier; +import com.codenameone.examples.savor.api.DishDto; +import com.codenameone.examples.savor.api.DishOptionDto; +import com.codenameone.examples.savor.api.MenuSectionDto; +import com.codenameone.examples.savor.api.PhotoDto; +import com.codenameone.examples.savor.api.PricingDto; +import com.codenameone.examples.savor.api.RestaurantDto; +import com.codenameone.examples.savor.api.RestaurantSummaryDto; +import com.codenameone.examples.savor.domain.Dish; +import com.codenameone.examples.savor.domain.DishOption; +import com.codenameone.examples.savor.domain.Favorite; +import com.codenameone.examples.savor.domain.MenuSection; +import com.codenameone.examples.savor.domain.Photo; +import com.codenameone.examples.savor.domain.Restaurant; +import com.codenameone.examples.savor.geo.Geo; +import com.codenameone.examples.savor.order.Quotes; +import com.codenameone.examples.savor.repository.DishOptionRepository; +import com.codenameone.examples.savor.repository.DishRepository; +import com.codenameone.examples.savor.repository.FavoriteRepository; +import com.codenameone.examples.savor.repository.MenuSectionRepository; +import com.codenameone.examples.savor.repository.PhotoRepository; +import com.codenameone.examples.savor.repository.RestaurantRepository; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/// What a customer browses: the restaurants near a point, one restaurant and +/// its menu, the photos, and the restaurants they keep as favourites. +/// +/// Nearby is a bounding box the database answers from its index, measured and +/// sorted here, the way couriers are found in `Matcher`. +@Component +public class Catalog { + public static final String APPROVED = "APPROVED"; + public static final String PENDING = "PENDING"; + public static final String REJECTED = "REJECTED"; + public static final String SUSPENDED = "SUSPENDED"; + static final String[] SORTS = {"distance", "rating", "fee", "time"}; + + private final RestaurantRepository restaurants; + private final MenuSectionRepository sections; + private final DishRepository dishes; + private final DishOptionRepository options; + private final PhotoRepository photos; + private final FavoriteRepository favorites; + private final Quotes quotes; + + public Catalog(RestaurantRepository restaurants, MenuSectionRepository sections, + DishRepository dishes, DishOptionRepository options, PhotoRepository photos, + FavoriteRepository favorites, Quotes quotes) { + this.restaurants = restaurants; + this.sections = sections; + this.dishes = dishes; + this.options = options; + this.photos = photos; + this.favorites = favorites; + this.quotes = quotes; + } + + // tag::savor-nearby[] + /// The approved restaurants that deliver to a point, filtered by cuisine and + /// by a word in the name, the cuisine or a dish, and sorted. + @Transactional(readOnly = true) + public List nearby(String viewer, double lat, double lng, + String cuisine, String text, String sort) throws IOException { + if (!Geo.isPoint(lat, lng)) { + throw new ResponseStatusException(400, "Choose where to deliver to"); + } + double straight = Quotes.MAX_METERS / Quotes.ROAD_FACTOR; + double dLat = straight / Geo.METERS_PER_DEGREE; + double dLng = dLat / Math.max(0.01d, Math.cos(Math.toRadians(lat))); + List box = restaurants.inBox(APPROVED, lat - dLat, lat + dLat, lng - dLng, + lng + dLng); + String word = text == null ? "" : text.trim(); + Set dishMatches = word.length() == 0 ? Collections.emptySet() + : dishMatches(box, word); + Set kept = favoriteIds(viewer); + PricingDto pricing = quotes.pricing(); + int minute = quotes.minuteOfDay(System.currentTimeMillis()); + List out = new ArrayList(); + for (int iter = 0; iter < box.size(); iter++) { + Restaurant restaurant = box.get(iter); + if (cuisine != null && cuisine.length() > 0 + && !cuisine.equalsIgnoreCase(restaurant.getCuisine())) { + continue; + } + if (word.length() > 0 && !contains(restaurant.getName(), word) + && !contains(restaurant.getCuisine(), word) + && !dishMatches.contains(restaurant.getId())) { + continue; + } + double meters = Quotes.roadMeters(restaurant.getLat(), restaurant.getLng(), lat, lng); + if (meters > Quotes.MAX_METERS) { + continue; + } + out.add(summary(restaurant, meters, pricing, minute, + kept.contains(restaurant.getId()))); + } + sort(out, sort); + return out; + } + // end::savor-nearby[] + + private static void sort(List list, String sort) { + String by = sort == null ? SORTS[0] : sort; + Comparator order; + if ("rating".equals(by)) { + order = (a, b) -> Double.compare(b.rating(), a.rating()); + } else if ("fee".equals(by)) { + order = (a, b) -> Long.compare(a.deliveryFeeCents(), b.deliveryFeeCents()); + } else if ("time".equals(by)) { + order = (a, b) -> Integer.compare(a.etaMinutes(), b.etaMinutes()); + } else { + order = (a, b) -> Double.compare(a.distanceMeters(), b.distanceMeters()); + } + // Open restaurants first, whatever the order asked for. + Collections.sort(list, (a, b) -> a.open() == b.open() ? order.compare(a, b) + : (a.open() ? -1 : 1)); + } + + /// Whether `text` holds `word`, ignoring ASCII case, without folding + /// either: `toLowerCase()` follows the server's locale. + static boolean contains(String text, String word) { + if (text == null) { + return false; + } + int last = text.length() - word.length(); + for (int iter = 0; iter <= last; iter++) { + if (text.regionMatches(true, iter, word, 0, word.length())) { + return true; + } + } + return false; + } + + private Set dishMatches(List box, String word) { + Set out = new HashSet(); + if (box.isEmpty()) { + return out; + } + List ids = new ArrayList(); + for (int iter = 0; iter < box.size(); iter++) { + ids.add(box.get(iter).getId()); + } + List rows = dishes.findByRestaurantIdIn(ids); + for (int iter = 0; iter < rows.size(); iter++) { + Dish dish = rows.get(iter); + if (dish.isAvailable() && (contains(dish.getName(), word) + || contains(dish.getDescription(), word))) { + out.add(dish.getRestaurantId()); + } + } + return out; + } + + private Set favoriteIds(String viewer) { + Set out = new HashSet(); + if (viewer == null) { + return out; + } + List rows = favorites.findByUsernameOrderByCreatedAtDesc(viewer); + for (int iter = 0; iter < rows.size(); iter++) { + out.add(rows.get(iter).getRestaurantId()); + } + return out; + } + + static double rating(Restaurant restaurant) { + return restaurant.getRatingCount() == 0L ? 0d + : Math.round(restaurant.getRatingTotal() * 10d / restaurant.getRatingCount()) + / 10d; + } + + private static RestaurantSummaryDto summary(Restaurant restaurant, double meters, + PricingDto pricing, int minute, boolean favorite) { + return new RestaurantSummaryDto(restaurant.getId(), restaurant.getName(), + restaurant.getCuisine(), restaurant.getPhotoId(), rating(restaurant), + restaurant.getRatingCount(), restaurant.getPriceLevel(), + restaurant.getPrepMinutes() + Quotes.travelMinutes(meters), + Quotes.deliveryFee(pricing, meters), meters, Quotes.open(restaurant, minute), + favorite); + } + + /// One restaurant and its menu, as a customer sees it. A restaurant that is + /// not approved is not found, except by its own staff and the admins, who + /// see it through [#detail]. + @Transactional(readOnly = true) + public RestaurantDto restaurant(String viewer, String id, double lat, double lng) + throws IOException { + Restaurant restaurant = id == null ? null : restaurants.findById(id); + if (restaurant == null || !APPROVED.equals(restaurant.getStatus())) { + throw new ResponseStatusException(404, "No such restaurant"); + } + return detail(restaurant, viewer, lat, lng, false); + } + + /// A restaurant and its whole menu. + /// + /// @param everything whether dishes that are sold out are listed: the + /// kitchen sees them to put them back, a customer sees them greyed + public RestaurantDto detail(Restaurant restaurant, String viewer, double lat, double lng, + boolean everything) throws IOException { + PricingDto pricing = quotes.pricing(); + boolean located = Geo.isPoint(lat, lng); + double meters = located ? Quotes.roadMeters(restaurant.getLat(), restaurant.getLng(), + lat, lng) : 0d; + List rows = sections.findByRestaurantIdOrderByPositionAsc(restaurant.getId()); + List food = dishes.findByRestaurantIdOrderByPositionAsc(restaurant.getId()); + List ids = new ArrayList(); + for (int iter = 0; iter < food.size(); iter++) { + ids.add(food.get(iter).getId()); + } + Map> extras = new HashMap>(); + if (!ids.isEmpty()) { + List all = options.findByDishIdIn(ids); + Collections.sort(all, (a, b) -> Integer.compare(a.getPosition(), b.getPosition())); + for (int iter = 0; iter < all.size(); iter++) { + DishOption option = all.get(iter); + List list = extras.get(option.getDishId()); + if (list == null) { + list = new ArrayList(); + extras.put(option.getDishId(), list); + } + list.add(new DishOptionDto(option.getId(), option.getName(), + option.getPriceCents())); + } + } + List menu = new ArrayList(); + for (int iter = 0; iter < rows.size(); iter++) { + MenuSection section = rows.get(iter); + List list = new ArrayList(); + for (int dish = 0; dish < food.size(); dish++) { + Dish row = food.get(dish); + if (row.getSectionId().equals(section.getId())) { + list.add(dish(row, extras.get(row.getId()))); + } + } + if (everything || !list.isEmpty()) { + menu.add(new MenuSectionDto(section.getId(), section.getName(), list)); + } + } + boolean favorite = viewer != null && favorites.existsById(Identifier.of(viewer, + restaurant.getId())); + return new RestaurantDto(restaurant.getId(), restaurant.getName(), + restaurant.getCuisine(), restaurant.getDescription(), restaurant.getAddress(), + restaurant.getLat(), restaurant.getLng(), restaurant.getPhotoId(), + rating(restaurant), restaurant.getRatingCount(), restaurant.getPriceLevel(), + restaurant.getPrepMinutes() + (located ? Quotes.travelMinutes(meters) : 0), + located ? Quotes.deliveryFee(pricing, meters) : pricing.baseCents(), + restaurant.getMinimumCents(), + Quotes.open(restaurant, quotes.minuteOfDay(System.currentTimeMillis())), + restaurant.isPaused(), restaurant.getOpensAt(), restaurant.getClosesAt(), + restaurant.getPrepMinutes(), favorite, restaurant.getStatus(), + restaurant.getReason(), menu); + } + + static DishDto dish(Dish row, List extras) { + return new DishDto(row.getId(), row.getSectionId(), row.getName(), row.getDescription(), + row.getPriceCents(), row.getPhotoId(), row.isAvailable(), row.isPopular(), + extras == null ? new ArrayList() : extras); + } + + /// A photo, to anyone: menus are public. + @Transactional(readOnly = true) + public PhotoDto photo(String id) { + Photo photo = id == null || id.length() > 32 ? null : photos.findById(id); + if (photo == null) { + throw new ResponseStatusException(404, "No such photo"); + } + return new PhotoDto(photo.getId(), photo.getContentType(), Base64.encode(photo.getData())); + } + + // ------------------------------------------------------------- favorites + + @Transactional(readOnly = true) + public List favorites(String viewer, double lat, double lng) + throws IOException { + List rows = favorites.findByUsernameOrderByCreatedAtDesc(viewer); + PricingDto pricing = quotes.pricing(); + boolean located = Geo.isPoint(lat, lng); + int minute = quotes.minuteOfDay(System.currentTimeMillis()); + List out = new ArrayList(); + for (int iter = 0; iter < rows.size(); iter++) { + Restaurant restaurant = restaurants.findById(rows.get(iter).getRestaurantId()); + if (restaurant != null && APPROVED.equals(restaurant.getStatus())) { + double meters = located ? Quotes.roadMeters(restaurant.getLat(), + restaurant.getLng(), lat, lng) : 0d; + // Out of range of where the order goes, as nearby() leaves it + // out: every quote for it would be refused. It is listed again + // once the customer is back within reach. + if (meters > Quotes.MAX_METERS) { + continue; + } + out.add(summary(restaurant, meters, pricing, minute, true)); + } + } + return out; + } + + @Transactional + public RestaurantSummaryDto favorite(String viewer, String id, boolean keep) + throws IOException { + Restaurant restaurant = id == null ? null : restaurants.findById(id); + if (restaurant == null || !APPROVED.equals(restaurant.getStatus())) { + throw new ResponseStatusException(404, "No such restaurant"); + } + Identifier key = Identifier.of(viewer, id); + boolean kept = favorites.existsById(key); + if (keep && !kept) { + if (favorites.countByUsername(viewer) >= 200) { + throw new ResponseStatusException(409, "That is enough favourites"); + } + Favorite row = new Favorite(); + row.setUsername(viewer); + row.setRestaurantId(id); + row.setCreatedAt(System.currentTimeMillis()); + favorites.insert(row); + } else if (!keep && kept) { + favorites.deleteById(key); + } + return summary(restaurant, 0d, quotes.pricing(), + quotes.minuteOfDay(System.currentTimeMillis()), keep); + } +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/catalog/CatalogEndpoint.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/catalog/CatalogEndpoint.java new file mode 100644 index 00000000000..c244447ca91 --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/catalog/CatalogEndpoint.java @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.catalog; + +import com.codename1.backend.annotations.Component; +import com.codenameone.examples.savor.Caller; +import com.codenameone.examples.savor.api.CatalogApiServer; +import com.codenameone.examples.savor.api.PhotoDto; +import com.codenameone.examples.savor.api.RestaurantDto; +import com.codenameone.examples.savor.api.RestaurantEditDto; +import com.codenameone.examples.savor.api.RestaurantSummaryDto; +import java.util.List; + +/// Browsing restaurants, and applying to list one. +@Component +public class CatalogEndpoint implements CatalogApiServer { + private final Catalog catalog; + private final Restaurants restaurants; + + public CatalogEndpoint(Catalog catalog, Restaurants restaurants) { + this.catalog = catalog; + this.restaurants = restaurants; + } + + @Override + public List restaurants(double lat, double lng, String cuisine, + String text, String sort) throws Exception { + return catalog.nearby(Caller.name(), lat, lng, cuisine, text, sort); + } + + @Override + public RestaurantDto restaurant(String id, double lat, double lng) throws Exception { + return catalog.restaurant(Caller.name(), id, lat, lng); + } + + @Override + public PhotoDto photo(String id) throws Exception { + Caller.name(); + return catalog.photo(id); + } + + @Override + public RestaurantDto apply(RestaurantEditDto application) throws Exception { + return restaurants.apply(Caller.name(), application); + } + + @Override + public RestaurantDto application() throws Exception { + return restaurants.application(Caller.name()); + } + + @Override + public PhotoDto applicationPhoto(PhotoDto photo) throws Exception { + return restaurants.applicationPhoto(Caller.name(), photo); + } +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/catalog/DemoRestaurants.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/catalog/DemoRestaurants.java new file mode 100644 index 00000000000..0fadb79eb42 --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/catalog/DemoRestaurants.java @@ -0,0 +1,413 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.catalog; + +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Transactional; +import com.codename1.backend.annotations.Value; +import com.codenameone.examples.savor.Ids; +import com.codenameone.examples.savor.account.Accounts; +import com.codenameone.examples.savor.domain.Dish; +import com.codenameone.examples.savor.domain.DishOption; +import com.codenameone.examples.savor.domain.MenuSection; +import com.codenameone.examples.savor.domain.Photo; +import com.codenameone.examples.savor.domain.Promo; +import com.codenameone.examples.savor.domain.Restaurant; +import com.codenameone.examples.savor.domain.RestaurantStaff; +import com.codenameone.examples.savor.repository.DishOptionRepository; +import com.codenameone.examples.savor.repository.DishRepository; +import com.codenameone.examples.savor.repository.MenuSectionRepository; +import com.codenameone.examples.savor.repository.PhotoRepository; +import com.codenameone.examples.savor.repository.PromoRepository; +import com.codenameone.examples.savor.repository.RestaurantRepository; +import com.codenameone.examples.savor.repository.RestaurantStaffRepository; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; + +/// The restaurants a development server starts with: a dozen around San +/// Francisco's waterfront, one per cuisine on the home screen's row, each with +/// a menu, plus one application waiting for an admin and two promotion codes. +/// +/// The first restaurant's kitchen is run by the demo merchant; the rest have +/// nobody, and `DemoKitchen` accepts and cooks their orders so a customer can +/// order anywhere and watch it arrive. +@Component +public class DemoRestaurants { + /// The cuisines of the home screen's row, in its order. The client keeps + /// the same list for its icons. + public static final String[] CUISINES = {"Pizza", "Burgers", "Sushi", "Chicken", "Kebab", + "Rice", "Steak", "Tacos", "Salads", "Donuts", "Dessert", "Coffee"}; + + /// Each restaurant: id, name, cuisine, description, address, lat, lng, + /// prep minutes, price level, rating total, rating count. Its menu is + /// [#MENUS] at the same index. + static final Object[][] RESTAURANTS = { + {"r-ember", "Ember & Crust", "Pizza", + "Wood-fired Neapolitan pies, fresh pasta and natural wines.", + "Pier 39", 37.8085, -122.4100, 18, 2, 470L, 100L}, + {"r-harbor", "Harbor Burger Co.", "Burgers", "Dry-aged smash burgers and hand-cut fries.", + "1 Ferry Building", 37.7952, -122.3934, 12, 2, 452L, 100L}, + {"r-kumo", "Kumo Omakase", "Sushi", "Seasonal nigiri, hand rolls and donburi.", + "550 Washington St", 37.7959, -122.4011, 20, 4, 489L, 100L}, + {"r-gilded", "The Gilded Hen", "Chicken", "Buttermilk fried chicken and biscuits.", + "900 North Point St", 37.8059, -122.4229, 15, 2, 461L, 100L}, + {"r-saffron", "Saffron Grill", "Kebab", "Charcoal kebabs, mezze and warm pita.", + "2 Embarcadero Center", 37.7949, -122.3991, 14, 2, 455L, 100L}, + {"r-jade", "Jade Bowl", "Rice", "Claypot rice, congee and wok-fired greens.", + "750 Kearny St", 37.7952, -122.4048, 16, 1, 444L, 100L}, + {"r-prime", "Prime & Pepper", "Steak", "Steakhouse cuts, sides and sauces to share.", + "Pier 15", 37.8017, -122.3973, 25, 4, 482L, 100L}, + {"r-luz", "Luz Taqueria", "Tacos", "Handmade tortillas, al pastor and fresh salsas.", + "Fort Mason Center", 37.8066, -122.4310, 10, 1, 466L, 100L}, + {"r-verde", "Verde Table", "Salads", "Grain bowls and big salads, all made to order.", + "Union Square", 37.7880, -122.4075, 8, 2, 438L, 100L}, + {"r-gloss", "Glaze Donut Atelier", "Donuts", "Brioche donuts, glazed every hour.", + "Lombard St", 37.8021, -122.4187, 6, 1, 471L, 100L}, + {"r-velvet", "Velvet Spoon", "Dessert", "Gelato, tarts and chocolate made in-house.", + "Ghirardelli Square", 37.8057, -122.4225, 6, 2, 478L, 100L}, + {"r-ritual", "Morning Ritual", "Coffee", "Single-origin coffee, pancakes and fresh juice.", + "Oracle Park", 37.7786, -122.3893, 5, 1, 459L, 100L}}; + + /// Each menu: rows in order, where a row of one element names a section and + /// the dishes after it, each {name, description, cents, popular, then its + /// options as name, cents, name, cents...}, are in that section. Three dimensions, not + /// four: the compiled server has no array classes deeper than that. + static final Object[][][] MENUS = { + {{"Pizza"}, + {"Margherita", "San Marzano tomato, fior di latte, basil.", 1600, true, + "Extra mozzarella", 250, "Fresh chili", 100, "Gluten-free crust", 300}, + {"Diavola", "Spicy salami, chili honey, oregano.", 1900, true, + "Extra salami", 350, "Burrata", 450}, + {"Funghi Bianca", "Wild mushrooms, ricotta, thyme, no tomato.", 1850, false}, + {"Starters"}, + {"Burrata", "Heirloom tomatoes, basil oil, sourdough.", 1400, false}, + {"Garlic Knots", "With whipped ricotta.", 800, false}, + {"Pasta"}, + {"Spaghetti Carbonara", "Guanciale, pecorino, egg yolk, black pepper.", 1750, true}}, + {{"Burgers"}, + {"Double Smash", "Two dry-aged patties, American cheese, house sauce.", 1500, true, + "Bacon", 250, "Extra patty", 400, "Lettuce wrap", 0}, + {"Mushroom Melt", "Swiss, caramelised onion, truffle mayo.", 1600, false}, + {"Crispy Chicken", "Pickles, slaw, hot honey.", 1450, false}, + {"Sides"}, + {"Hand-cut Fries", "Sea salt and rosemary.", 600, true, "Truffle", 200, "Cheese", 150}, + {"Onion Rings", "Beer battered.", 700, false}, + {"Shake", "Vanilla, chocolate or strawberry.", 750, false}}, + {{"Nigiri sets"}, + {"Omakase Twelve", "The chef's twelve pieces of the day.", 5800, true}, + {"Salmon Lover", "Eight salmon nigiri, three ways.", 3200, false}, + {"Rolls and bowls"}, + {"Toro Hand Roll", "Fatty tuna, scallion, pickled radish.", 1400, true}, + {"Chirashi Bowl", "Chef's selection over seasoned rice.", 3600, false, + "Extra uni", 1200, "Ikura", 600}, + {"Miso Soup", "White miso, tofu, wakame.", 450, false}}, + {{"Fried chicken"}, + {"Three-piece Meal", "Thigh, drumstick and wing, with a biscuit.", 1550, true, + "Make it spicy", 0, "Extra biscuit", 300}, + {"Chicken Sandwich", "Buttermilk thigh, slaw, pickles.", 1350, true}, + {"Tenders", "Five tenders and two sauces.", 1250, false}, + {"Sides"}, + {"Mac and Cheese", "Three cheeses, breadcrumb crust.", 650, false}, + {"Collard Greens", "Slow cooked.", 550, false}}, + {{"Kebabs"}, + {"Lamb Adana", "Hand-minced lamb, sumac onions, grilled pepper.", 1900, true, + "Extra pita", 150, "Garlic sauce", 100}, + {"Chicken Shish", "Yoghurt-marinated thigh, saffron rice.", 1700, true}, + {"Falafel Plate", "Tahini, pickles, salad.", 1450, false}, + {"Mezze"}, + {"Hummus", "With warm pita.", 750, false}, + {"Baba Ghanoush", "Smoked aubergine, pomegranate.", 800, false}, + {"Mezze Platter", "Hummus, baba ghanoush, falafel and warm pita to share.", 1800, + true}}, + {{"Rice"}, + {"Claypot Chicken Rice", "Lap cheong, mushroom, soy.", 1600, true, "Fried egg", 200}, + {"Pork Belly Bowl", "Five-spice belly, pickled greens.", 1700, false}, + {"Seafood Congee", "Prawn, scallop, ginger.", 1500, false}, + {"Wok"}, + {"Garlic Greens", "Gai lan, garlic, oyster sauce.", 900, false}, + {"Salt and Pepper Tofu", "Chili, scallion.", 1100, true}}, + {{"Steaks"}, + {"Ribeye 14oz", "Dry-aged, with peppercorn sauce.", 5400, true, + "Bearnaise", 300, "Truffle butter", 500}, + {"Filet 8oz", "With red wine jus.", 4800, false}, + {"Steak Frites", "Hanger steak, fries, chimichurri.", 3200, true}, + {"Sides"}, + {"Creamed Spinach", "Nutmeg and parmesan.", 900, false}, + {"Potato Gratin", "Gruyere and thyme.", 1000, false}}, + {{"Tacos"}, + {"Al Pastor", "Spit-roasted pork, pineapple, salsa verde. Three tacos.", 1200, true, + "Extra pineapple", 100, "Guacamole", 250}, + {"Baja Fish", "Beer-battered cod, cabbage, chipotle crema.", 1350, true}, + {"Mushroom Asada", "Grilled oyster mushrooms, salsa macha.", 1150, false}, + {"Extras"}, + {"Chips and Guacamole", "Made to order.", 800, false}, + {"Elote", "Grilled corn, cotija, lime.", 600, false}}, + {{"Bowls"}, + {"Harvest Bowl", "Wild rice, roast squash, kale, goat cheese.", 1500, true, + "Chicken", 400, "Salmon", 600, "Avocado", 250}, + {"Green Goddess", "Greens, cucumber, herbs, chickpeas.", 1350, false}, + {"Spicy Tuna Poke", "Seasoned rice, edamame, nori.", 1700, true}, + {"Drinks"}, + {"Cold-pressed Juice", "Green or citrus.", 750, false}}, + {{"Donuts"}, + {"Classic Glazed", "Brioche, vanilla glaze.", 400, true, "Make it a dozen", 3600}, + {"Creme Brulee", "Custard filled, torched sugar.", 550, true}, + {"Maple Bacon", "Maple glaze, candied bacon.", 550, false}, + {"Coffee"}, + {"Drip Coffee", "House blend.", 350, false}}, + {{"Gelato"}, + {"Pint of Gelato", "Pistachio, chocolate, or seasonal.", 1200, true}, + {"Affogato", "Vanilla gelato, espresso.", 700, false}, + {"Pastry"}, + {"Lemon Tart", "Italian meringue.", 800, true}, + {"Chocolate Torte", "Flourless, 70% cacao.", 850, false}}, + {{"Coffee"}, + {"Flat White", "Double ristretto.", 500, true, "Oat milk", 75, "Extra shot", 100}, + {"Cold Brew", "Steeped eighteen hours.", 550, false}, + {"Fresh Orange Juice", "Squeezed to order.", 600, false}, + {"Bakery"}, + {"Almond Croissant", "Baked this morning.", 550, true}, + {"Buttermilk Pancakes", "Raspberries and maple syrup.", 1150, true}, + {"Breakfast Sandwich", "Egg, cheddar, chive, brioche.", 950, false, "Bacon", 200}}}; + + /// The restaurants that have a photograph, as {restaurant id, photo key}. A + /// key is a file in the photos directory, `savor.photos.dir` (`photos` + /// beside the server), as `.jpg` or `.png`. Replace the files to + /// change the pictures; a missing file leaves the cover the app draws. + private static final String[][] RESTAURANT_PHOTOS = { + {"r-ember", "ember"}, {"r-harbor", "harbor"}, {"r-kumo", "kumo"}, + {"r-gilded", "gilded"}, {"r-saffron", "saffron"}, {"r-jade", "jade"}, + {"r-prime", "prime"}, {"r-luz", "luz"}, {"r-verde", "verde"}, {"r-gloss", "glaze"}, + {"r-velvet", "velvet"}, {"r-ritual", "ritual"}}; + + /// The dishes that have one, as {dish name, photo key}. + private static final String[][] DISH_PHOTOS = { + {"Margherita", "ember"}, {"Diavola", "diavola"}, {"Funghi Bianca", "mushrooms"}, + {"Burrata", "burrata"}, {"Spaghetti Carbonara", "carbonara"}, + {"Double Smash", "harbor"}, {"Mushroom Melt", "mushrooms"}, {"Onion Rings", "onion"}, + {"Hand-cut Fries", "fries"}, {"Omakase Twelve", "kumo"}, {"Toro Hand Roll", "toro-roll"}, + {"Chirashi Bowl", "chirashi"}, {"Three-piece Meal", "gilded"}, + {"Chicken Sandwich", "chicken-sandwich"}, {"Lamb Adana", "saffron"}, + {"Mezze Platter", "mezze"}, {"Ribeye 14oz", "prime"}, {"Steak Frites", "steak-frites"}, + {"Al Pastor", "luz"}, {"Elote", "elote"}, {"Buttermilk Pancakes", "pancakes"}, + {"Flat White", "ritual"}, {"Cold Brew", "cold-brew"}, {"Fresh Orange Juice", "juice"}}; + + private final RestaurantRepository restaurants; + private final RestaurantStaffRepository staff; + private final MenuSectionRepository sections; + private final DishRepository dishes; + private final DishOptionRepository options; + private final PromoRepository promos; + private final Accounts accounts; + private final PhotoRepository photos; + private final String photosDir; + + public DemoRestaurants(RestaurantRepository restaurants, RestaurantStaffRepository staff, + MenuSectionRepository sections, DishRepository dishes, DishOptionRepository options, + PromoRepository promos, Accounts accounts, PhotoRepository photos, + @Value("${savor.photos.dir:photos}") String photosDir) { + this.restaurants = restaurants; + this.staff = staff; + this.sections = sections; + this.dishes = dishes; + this.options = options; + this.promos = promos; + this.accounts = accounts; + this.photos = photos; + this.photosDir = photosDir; + } + + /// Writes them all, once: a database that already has the first one is + /// left as it is. + @Transactional(rollbackFor = IOException.class) + public void seed(String merchant) throws IOException { + if (restaurants.findById(String.valueOf(RESTAURANTS[0][0])) != null) { + return; + } + long now = System.currentTimeMillis(); + for (int iter = 0; iter < RESTAURANTS.length; iter++) { + Object[] row = RESTAURANTS[iter]; + Restaurant restaurant = new Restaurant(); + restaurant.setId(String.valueOf(row[0])); + restaurant.setName(String.valueOf(row[1])); + restaurant.setCuisine(String.valueOf(row[2])); + restaurant.setDescription(String.valueOf(row[3])); + restaurant.setAddress(String.valueOf(row[4]) + ", San Francisco"); + restaurant.setLat(number(row[5])); + restaurant.setLng(number(row[6])); + restaurant.setStatus(Catalog.APPROVED); + // Open around the clock, so the demo works whenever it is run. + restaurant.setOpensAt(0); + restaurant.setClosesAt(0); + restaurant.setPrepMinutes(whole(row[7])); + restaurant.setPriceLevel(whole(row[8])); + restaurant.setMinimumCents(0L); + restaurant.setRatingTotal(count(row[9])); + restaurant.setRatingCount(count(row[10])); + restaurant.setPhotoId(photoFor(RESTAURANT_PHOTOS, restaurant.getId(), restaurant.getId())); + restaurant.setApplicant(iter == 0 ? merchant : ""); + restaurant.setReason(""); + restaurant.setCreatedAt(now - (60 - iter) * 86400000L); + restaurant.setUpdatedAt(now); + restaurants.insert(restaurant); + menu(restaurant.getId(), MENUS[iter]); + } + RestaurantStaff kitchen = new RestaurantStaff(); + kitchen.setRestaurantId(String.valueOf(RESTAURANTS[0][0])); + kitchen.setUsername(merchant); + staff.insert(kitchen); + accounts.setMerchant(merchant, true); + + Restaurant waiting = new Restaurant(); + waiting.setId("r-noodle"); + waiting.setName("Noodle Lab"); + waiting.setCuisine("Rice"); + waiting.setDescription("Hand-pulled noodles and dumplings."); + waiting.setAddress("Transamerica Pyramid, San Francisco"); + waiting.setLat(37.7952); + waiting.setLng(-122.4028); + waiting.setStatus(Catalog.PENDING); + waiting.setPrepMinutes(15); + waiting.setPriceLevel(1); + waiting.setPhotoId(""); + waiting.setApplicant(""); + waiting.setReason(""); + waiting.setCreatedAt(now - 2L * 86400000L); + waiting.setUpdatedAt(now - 2L * 86400000L); + restaurants.insert(waiting); + + promo("WELCOME10", 10, 0L, 1500L, now); + promo("SAVOR5", 0, 500L, 2500L, now); + } + + private void menu(String restaurantId, Object[][] menu) throws IOException { + int dishAt = 0; + int section = -1; + String sectionId = null; + for (int iter = 0; iter < menu.length; iter++) { + if (menu[iter].length == 1) { + MenuSection row = new MenuSection(); + row.setId(Ids.next()); + row.setRestaurantId(restaurantId); + row.setName(String.valueOf(menu[iter][0])); + row.setPosition(++section); + sections.insert(row); + sectionId = row.getId(); + continue; + } + Object[] food = menu[iter]; + Dish dish = new Dish(); + dish.setId(Ids.next()); + dish.setRestaurantId(restaurantId); + dish.setSectionId(sectionId); + dish.setName(String.valueOf(food[0])); + dish.setDescription(String.valueOf(food[1])); + dish.setPriceCents(whole(food[2])); + dish.setPopular(Boolean.TRUE.equals(food[3])); + dish.setAvailable(true); + dish.setPhotoId(photoFor(DISH_PHOTOS, dish.getName(), restaurantId)); + dish.setPosition(dishAt++); + dishes.insert(dish); + for (int at = 4; at + 1 < food.length; at += 2) { + DishOption extra = new DishOption(); + extra.setId(Ids.next()); + extra.setDishId(dish.getId()); + extra.setName(String.valueOf(food[at])); + extra.setPriceCents(count(food[at + 1])); + extra.setPosition((at - 4) / 2); + options.insert(extra); + } + } + } + + /// The photo `pairs` gives `name`, stored for `restaurantId` -- a photo is + /// the restaurant's, so its merchant may keep it on a dish -- or "" when + /// there is none or its file is missing. + private String photoFor(String[][] pairs, String name, String restaurantId) + throws IOException { + for (int iter = 0; iter < pairs.length; iter++) { + if (pairs[iter][0].equals(name)) { + return photo(pairs[iter][1], restaurantId); + } + } + return ""; + } + + private String photo(String key, String restaurantId) throws IOException { + String id = restaurantId + "-" + key; + if (photos.findById(id) != null) { + return id; + } + String type = "image/jpeg"; + File file = new File(photosDir, key + ".jpg"); + if (!file.exists()) { + type = "image/png"; + file = new File(photosDir, key + ".png"); + } + if (!file.exists()) { + return ""; + } + Photo row = new Photo(); + row.setId(id); + row.setRestaurantId(restaurantId); + row.setContentType(type); + row.setData(read(file)); + row.setCreatedAt(System.currentTimeMillis()); + photos.insert(row); + return id; + } + + private static byte[] read(File file) throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + try (InputStream in = new FileInputStream(file)) { + byte[] buffer = new byte[16384]; + int count; + while ((count = in.read(buffer)) > 0) { + out.write(buffer, 0, count); + } + } + return out.toByteArray(); + } + + private void promo(String code, int percent, long amount, long minimum, long now) { + Promo promo = new Promo(); + promo.setCode(code); + promo.setPercentOff(percent); + promo.setAmountOffCents(amount); + promo.setMinimumCents(minimum); + promo.setActive(true); + promo.setCreatedAt(now); + promos.insert(promo); + } + + private static double number(Object value) { + return value instanceof Number ? ((Number) value).doubleValue() : 0d; + } + + private static int whole(Object value) { + return value instanceof Number ? ((Number) value).intValue() : 0; + } + + private static long count(Object value) { + return value instanceof Number ? ((Number) value).longValue() : 0L; + } +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/catalog/MerchantEndpoint.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/catalog/MerchantEndpoint.java new file mode 100644 index 00000000000..463ac338899 --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/catalog/MerchantEndpoint.java @@ -0,0 +1,141 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.catalog; + +import com.codename1.backend.annotations.Component; +import com.codenameone.examples.savor.Caller; +import com.codenameone.examples.savor.api.AcceptDto; +import com.codenameone.examples.savor.api.DishDto; +import com.codenameone.examples.savor.api.MenuSectionDto; +import com.codenameone.examples.savor.api.MerchantApiServer; +import com.codenameone.examples.savor.api.MerchantSummaryDto; +import com.codenameone.examples.savor.api.OrderDto; +import com.codenameone.examples.savor.api.PhotoDto; +import com.codenameone.examples.savor.api.ReasonDto; +import com.codenameone.examples.savor.api.RestaurantDto; +import com.codenameone.examples.savor.api.RestaurantEditDto; +import com.codenameone.examples.savor.order.Orders; +import java.util.List; + +/// The restaurant's own side: its details, its orders and its menu. Every +/// call acts for the restaurant the signed-in merchant works for. +@Component +public class MerchantEndpoint implements MerchantApiServer { + private final Restaurants restaurants; + private final Orders orders; + + public MerchantEndpoint(Restaurants restaurants, Orders orders) { + this.restaurants = restaurants; + this.orders = orders; + } + + private String kitchen(String who) { + return restaurants.mine(who).getId(); + } + + @Override + public RestaurantDto restaurant() throws Exception { + return restaurants.view(Caller.name()); + } + + @Override + public RestaurantDto save(RestaurantEditDto details) throws Exception { + return restaurants.save(Caller.name(), details); + } + + @Override + public RestaurantDto pause(boolean paused) throws Exception { + return restaurants.pause(Caller.name(), paused); + } + + @Override + public MerchantSummaryDto summary() throws Exception { + return restaurants.summary(Caller.name()); + } + + @Override + public List orders() throws Exception { + String who = Caller.name(); + return orders.queue(kitchen(who), who); + } + + @Override + public List history() throws Exception { + String who = Caller.name(); + return orders.kitchenHistory(kitchen(who), who); + } + + @Override + public OrderDto accept(String id, AcceptDto accept) throws Exception { + String who = Caller.name(); + return orders.accept(who, kitchen(who), id, accept == null ? 0 : accept.prepMinutes()); + } + + @Override + public OrderDto reject(String id, ReasonDto reason) throws Exception { + String who = Caller.name(); + return orders.reject(who, kitchen(who), id, reason == null ? null : reason.reason()); + } + + @Override + public OrderDto ready(String id) throws Exception { + String who = Caller.name(); + return orders.ready(who, kitchen(who), id); + } + + @Override + public MenuSectionDto addSection(MenuSectionDto section) throws Exception { + return restaurants.addSection(Caller.name(), section); + } + + @Override + public MenuSectionDto saveSection(String id, MenuSectionDto section) throws Exception { + return restaurants.saveSection(Caller.name(), id, section); + } + + @Override + public MenuSectionDto removeSection(String id) throws Exception { + return restaurants.removeSection(Caller.name(), id); + } + + @Override + public DishDto addDish(DishDto dish) throws Exception { + return restaurants.addDish(Caller.name(), dish); + } + + @Override + public DishDto saveDish(String id, DishDto dish) throws Exception { + return restaurants.saveDish(Caller.name(), id, dish); + } + + @Override + public DishDto removeDish(String id) throws Exception { + return restaurants.removeDish(Caller.name(), id); + } + + @Override + public DishDto available(String id, boolean available) throws Exception { + return restaurants.available(Caller.name(), id, available); + } + + @Override + public PhotoDto upload(PhotoDto photo) throws Exception { + return restaurants.upload(Caller.name(), photo); + } +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/catalog/Restaurants.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/catalog/Restaurants.java new file mode 100644 index 00000000000..869bc359a6d --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/catalog/Restaurants.java @@ -0,0 +1,642 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.catalog; + +import com.codename1.backend.Base64; +import com.codename1.backend.ResponseStatusException; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Transactional; +import com.codename1.orm.session.Identifier; +import com.codenameone.examples.savor.Ids; +import com.codenameone.examples.savor.Text; +import com.codenameone.examples.savor.account.Accounts; +import com.codenameone.examples.savor.account.Moderation; +import com.codenameone.examples.savor.api.DishDto; +import com.codenameone.examples.savor.api.DishOptionDto; +import com.codenameone.examples.savor.api.MenuSectionDto; +import com.codenameone.examples.savor.api.MerchantSummaryDto; +import com.codenameone.examples.savor.api.OrderState; +import com.codenameone.examples.savor.api.PhotoDto; +import com.codenameone.examples.savor.api.RestaurantDto; +import com.codenameone.examples.savor.api.RestaurantEditDto; +import com.codenameone.examples.savor.domain.CustomerOrder; +import com.codenameone.examples.savor.domain.Dish; +import com.codenameone.examples.savor.domain.DishOption; +import com.codenameone.examples.savor.domain.MenuSection; +import com.codenameone.examples.savor.domain.Photo; +import com.codenameone.examples.savor.domain.Restaurant; +import com.codenameone.examples.savor.domain.RestaurantStaff; +import com.codenameone.examples.savor.geo.Geo; +import com.codenameone.examples.savor.onboarding.Applications; +import com.codenameone.examples.savor.order.Quotes; +import com.codenameone.examples.savor.pay.Payments; +import com.codenameone.examples.savor.repository.CustomerOrderRepository; +import com.codenameone.examples.savor.repository.DishOptionRepository; +import com.codenameone.examples.savor.repository.DishRepository; +import com.codenameone.examples.savor.repository.MenuSectionRepository; +import com.codenameone.examples.savor.repository.PhotoRepository; +import com.codenameone.examples.savor.repository.ProfileRepository; +import com.codenameone.examples.savor.repository.RestaurantRepository; +import com.codenameone.examples.savor.repository.RestaurantStaffRepository; +import com.codenameone.examples.savor.Days; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +/// Restaurants as their owners and the admins see them: applying to join, +/// approval, the kitchen's own details and menu, and photo uploads. +/// +/// A merchant acts for the restaurant whose staff they are on, found from +/// the signed-in account on every request; the client never names it. +@Component +public class Restaurants { + /// A photo is kept in the database as base64, so it is kept small: the + /// client scales it down before it uploads. + static final int MAX_PHOTO_CHARS = 700000; + + /// The most photo rows one restaurant may hold. Every upload is a new + /// row, so without a cap one account could grow the database without + /// bound; rows nothing uses are pruned first (see [#prune]). + static final int MAX_PHOTOS = 300; + + /// How long an uploaded photo nothing uses yet is kept: the app uploads + /// a picture and then saves the form naming it. + static final long UNUSED_PHOTO_GRACE = 3600000L; + static final int MAX_DISHES = 300; + static final int MAX_SECTIONS = 40; + static final int MAX_OPTIONS = 20; + + private final RestaurantRepository restaurants; + private final RestaurantStaffRepository staff; + private final MenuSectionRepository sections; + private final DishRepository dishes; + private final DishOptionRepository options; + private final PhotoRepository photos; + private final CustomerOrderRepository orders; + private final Catalog catalog; + private final Accounts accounts; + private final Moderation moderation; + private final Quotes quotes; + private final ProfileRepository profiles; + + public Restaurants(RestaurantRepository restaurants, RestaurantStaffRepository staff, + MenuSectionRepository sections, DishRepository dishes, DishOptionRepository options, + PhotoRepository photos, CustomerOrderRepository orders, Catalog catalog, + Accounts accounts, Moderation moderation, Quotes quotes, + ProfileRepository profiles) { + this.restaurants = restaurants; + this.staff = staff; + this.sections = sections; + this.dishes = dishes; + this.options = options; + this.photos = photos; + this.orders = orders; + this.catalog = catalog; + this.accounts = accounts; + this.moderation = moderation; + this.quotes = quotes; + this.profiles = profiles; + } + + /// The restaurant `username` works for; 403 when none. + @Transactional(readOnly = true) + public Restaurant mine(String username) { + List rows = staff.findByUsername(username); + Restaurant restaurant = rows.isEmpty() ? null + : restaurants.findById(rows.get(0).getRestaurantId()); + if (restaurant == null) { + throw new ResponseStatusException(403, "This account works for no restaurant"); + } + return restaurant; + } + + // ----------------------------------------------------------- application + + // tag::savor-apply[] + /// Asks for a restaurant to be listed. It waits, unseen by customers, for + /// an admin to approve it; the applicant then joins its staff and gets the + /// merchant role. One application at a time per account. + @Transactional + public RestaurantDto apply(String username, RestaurantEditDto form) throws IOException { + // Two submissions at once would both find no application below and + // both insert one; holding the profile first makes the second wait + // and then see the first. + profiles.hold(username); + Restaurant last = restaurants.findFirstByApplicantOrderByCreatedAtDesc(username); + if (last != null && !Catalog.REJECTED.equals(last.getStatus())) { + throw new ResponseStatusException(409, Catalog.PENDING.equals(last.getStatus()) + ? "Your application is waiting for review" : "You already have a restaurant"); + } + Restaurant restaurant = new Restaurant(); + restaurant.setId(Ids.next()); + restaurant.setStatus(Catalog.PENDING); + restaurant.setApplicant(username); + restaurant.setReason(""); + restaurant.setPhotoId(""); + long now = System.currentTimeMillis(); + restaurant.setCreatedAt(now); + restaurant.setUpdatedAt(now); + fill(restaurant, form); + restaurants.insert(restaurant); + return catalog.detail(restaurant, username, 0d, 0d, true); + } + // end::savor-apply[] + + /// The account's latest application, or 404. + @Transactional(readOnly = true) + public RestaurantDto application(String username) throws IOException { + Restaurant last = restaurants.findFirstByApplicantOrderByCreatedAtDesc(username); + if (last == null) { + throw new ResponseStatusException(404, "No application"); + } + return catalog.detail(last, username, 0d, 0d, true); + } + + /// A photo for the application waiting for review, which becomes its cover. + @Transactional + public PhotoDto applicationPhoto(String username, PhotoDto photo) throws IOException { + Restaurant found = restaurants.findFirstByApplicantOrderByCreatedAtDesc(username); + // Held and read again: an approval or a rejection that lands meanwhile + // is seen here, and one that comes after waits for this photo, so a + // listed restaurant never shows a cover nobody reviewed. + Restaurant last = found == null || restaurants.hold(found.getId()) == 0 ? null + : restaurants.findById(found.getId()); + if (last == null || !Catalog.PENDING.equals(last.getStatus())) { + throw new ResponseStatusException(409, "Apply first"); + } + PhotoDto saved = store(last.getId(), photo); + last.setPhotoId(saved.id()); + last.setUpdatedAt(System.currentTimeMillis()); + return saved; + } + + private static void fill(Restaurant restaurant, RestaurantEditDto form) { + if (form == null) { + throw new ResponseStatusException(400, "Fill in the restaurant's details"); + } + restaurant.setName(Text.required(form.name(), 80, "the restaurant's name")); + restaurant.setCuisine(Text.required(form.cuisine(), 32, "the cuisine")); + restaurant.setDescription(Text.optional(form.description(), 500, "the description")); + restaurant.setAddress(Text.required(form.address(), 255, "the address")); + if (!Geo.isPoint(form.lat(), form.lng())) { + throw new ResponseStatusException(400, "Choose where the restaurant is"); + } + restaurant.setLat(form.lat()); + restaurant.setLng(form.lng()); + if (form.opensAt() < 0 || form.opensAt() >= 1440 || form.closesAt() < 0 + || form.closesAt() >= 1440) { + throw new ResponseStatusException(400, "Check the opening hours"); + } + restaurant.setOpensAt(form.opensAt()); + restaurant.setClosesAt(form.closesAt()); + if (form.prepMinutes() < 1 || form.prepMinutes() > 180) { + throw new ResponseStatusException(400, "Check how long an order takes"); + } + restaurant.setPrepMinutes(form.prepMinutes()); + if (form.minimumCents() < 0 || form.minimumCents() > 100000) { + throw new ResponseStatusException(400, "Check the minimum order"); + } + restaurant.setMinimumCents(form.minimumCents()); + restaurant.setPriceLevel(Math.max(1, Math.min(4, form.priceLevel()))); + } + + // ----------------------------------------------------------------- admin + + @Transactional(readOnly = true) + public List byStatus(String status) throws IOException { + List rows = restaurants.findByStatusOrderByCreatedAtDesc(status); + List out = new ArrayList(); + for (int iter = 0; iter < rows.size(); iter++) { + out.add(catalog.detail(rows.get(iter), null, 0d, 0d, true)); + } + return out; + } + + /// Lists an application: the restaurant is shown to customers, and its + /// applicant joins its staff with the merchant role. + @Transactional(rollbackFor = IOException.class) + public RestaurantDto approve(String by, String id) throws IOException { + // Held before it is read, as an application photo holds it: the two + // take turns, so what is approved is what the admin last saw. + moderated(id); + Restaurant restaurant = existing(id); + // Only an application: a suspended restaurant comes back through + // suspend(), which checks it still has staff to take its orders. + if (!Catalog.PENDING.equals(restaurant.getStatus())) { + throw new ResponseStatusException(409, Catalog.APPROVED.equals(restaurant.getStatus()) + ? "That restaurant is already listed" : "Only an application can be approved"); + } + // Listed without its applicant, it would take orders nobody can see. + // The profile is held so a closure cannot land between this and the + // staff row below. + String applicant = restaurant.getApplicant(); + if (applicant == null || applicant.length() == 0 || profiles.hold(applicant) == 0 + || !accounts.exists(applicant)) { + throw new ResponseStatusException(409, + "The applicant closed their account; turn the application down instead"); + } + if (!accounts.enabled(applicant)) { + // Blocked: listed, it would take orders its merchant cannot see. + throw new ResponseStatusException(409, + "The applicant is blocked; unblock them or turn the application down"); + } + restaurant.setStatus(Catalog.APPROVED); + restaurant.setReason(""); + restaurant.setUpdatedAt(System.currentTimeMillis()); + if (!staff.existsById(Identifier.of(restaurant.getId(), applicant))) { + RestaurantStaff row = new RestaurantStaff(); + row.setRestaurantId(restaurant.getId()); + row.setUsername(applicant); + staff.insert(row); + } + accounts.setMerchant(applicant, true); + moderation.record(applicant, by, Moderation.LISTED, restaurant.getName()); + return catalog.detail(restaurant, null, 0d, 0d, true); + } + + @Transactional + public RestaurantDto reject(String by, String id, String reason) throws IOException { + // Held before it is read, as an application photo holds it: the two + // take turns, so what is approved is what the admin last saw. + moderated(id); + Restaurant restaurant = existing(id); + if (!Catalog.PENDING.equals(restaurant.getStatus())) { + throw new ResponseStatusException(409, "Only an application can be turned down"); + } + restaurant.setStatus(Catalog.REJECTED); + restaurant.setReason(Text.required(reason, 255, "why it is turned down")); + restaurant.setUpdatedAt(System.currentTimeMillis()); + return catalog.detail(restaurant, null, 0d, 0d, true); + } + + /// Takes a listed restaurant off the app, or puts it back. Its staff keep + /// their places and can still see their orders. + @Transactional + public RestaurantDto suspend(String by, String id, boolean suspended, String reason) + throws IOException { + Restaurant restaurant = existing(id); + String from = suspended ? Catalog.APPROVED : Catalog.SUSPENDED; + if (!from.equals(restaurant.getStatus())) { + throw new ResponseStatusException(409, suspended + ? "Only a listed restaurant can be suspended" : "That restaurant is not suspended"); + } + if (!suspended && !staffed(restaurant.getId())) { + // Its only merchant may have closed their account while it was + // suspended, or been blocked: the sole-merchant guard protects + // listed restaurants only. Listed again, it would take orders + // nobody can see. + throw new ResponseStatusException(409, + "That restaurant has no staff left to take its orders"); + } + restaurant.setStatus(suspended ? Catalog.SUSPENDED : Catalog.APPROVED); + restaurant.setReason(suspended ? Text.required(reason, 255, "why it is suspended") : ""); + restaurant.setUpdatedAt(System.currentTimeMillis()); + return catalog.detail(restaurant, null, 0d, 0d, true); + } + + /// Whether anybody on the restaurant's staff can still sign in. + private boolean staffed(String restaurantId) { + List rows = staff.findByRestaurantId(restaurantId); + for (int iter = 0; iter < rows.size(); iter++) { + if (accounts.enabled(rows.get(iter).getUsername())) { + return true; + } + } + return false; + } + + private void moderated(String id) { + if (id != null && id.length() <= 32) { + restaurants.hold(id); + } + } + + private Restaurant existing(String id) { + Restaurant restaurant = id == null || id.length() > 32 ? null : restaurants.findById(id); + if (restaurant == null) { + throw new ResponseStatusException(404, "No such restaurant"); + } + return restaurant; + } + + // -------------------------------------------------------------- merchant + + @Transactional(readOnly = true) + public RestaurantDto view(String username) throws IOException { + return catalog.detail(mine(username), username, 0d, 0d, true); + } + + @Transactional + public RestaurantDto save(String username, RestaurantEditDto form) throws IOException { + Restaurant restaurant = mine(username); + fill(restaurant, form); + if (form.photoId() != null && form.photoId().length() > 0) { + ownPhoto(restaurant, form.photoId()); + restaurant.setPhotoId(form.photoId()); + } + restaurant.setUpdatedAt(System.currentTimeMillis()); + return catalog.detail(restaurant, username, 0d, 0d, true); + } + + /// Stops new orders for a while, or takes them again. + @Transactional + public RestaurantDto pause(String username, boolean paused) throws IOException { + Restaurant restaurant = mine(username); + restaurant.setPaused(paused); + restaurant.setUpdatedAt(System.currentTimeMillis()); + return catalog.detail(restaurant, username, 0d, 0d, true); + } + + /// Today's and this week's takings: delivered orders' subtotals after + /// discount, less the platform's commission. + @Transactional(readOnly = true) + public MerchantSummaryDto summary(String username) throws IOException { + Restaurant restaurant = mine(username); + long now = System.currentTimeMillis(); + long today = Days.start(now); + long week = today - 6L * Days.MILLIS; + List delivered = orders.findByRestaurantIdAndStateAndDeliveredAtGreaterThanEqual( + restaurant.getId(), OrderState.DELIVERED, week); + long count = 0L; + long todayCents = 0L; + long weekCents = 0L; + for (int iter = 0; iter < delivered.size(); iter++) { + CustomerOrder order = delivered.get(iter); + // A delivered order whose capture failed, or that was refunded, + // took no money: counted only once paid, as the admin's revenue is. + if (!Payments.PAID.equals(order.getPaymentStatus())) { + continue; + } + long earned = order.getSubtotalCents() - order.getDiscountCents() + - order.getCommissionCents(); + weekCents += earned; + if (order.getDeliveredAt() >= today) { + todayCents += earned; + count++; + } + } + int waiting = orders.findByRestaurantIdAndStateInOrderByPlacedAtAsc(restaurant.getId(), + new OrderState[] {OrderState.PLACED}).size(); + return new MerchantSummaryDto(count, todayCents, weekCents, waiting, quotes.currency()); + } + + // ------------------------------------------------------------------ menu + + @Transactional + public MenuSectionDto addSection(String username, MenuSectionDto form) { + Restaurant restaurant = mine(username); + if (sections.countByRestaurantId(restaurant.getId()) >= MAX_SECTIONS) { + throw new ResponseStatusException(409, "That is enough sections"); + } + MenuSection section = new MenuSection(); + section.setId(sectionId()); + section.setRestaurantId(restaurant.getId()); + section.setName(Text.required(form == null ? null : form.name(), 60, "the section's name")); + // After the last one, not at the count: removing a section leaves a gap, + // and the count would then tie with a later section's position. + List before = sections.findByRestaurantIdOrderByPositionAsc(restaurant.getId()); + section.setPosition(before.isEmpty() ? 0 : before.get(before.size() - 1).getPosition() + 1); + sections.insert(section); + return new MenuSectionDto(section.getId(), section.getName(), new ArrayList()); + } + + private static String sectionId() { + try { + return Ids.next(); + } catch (IOException err) { + throw new IllegalStateException(err); + } + } + + @Transactional + public MenuSectionDto saveSection(String username, String id, MenuSectionDto form) { + MenuSection section = ownSection(mine(username), id); + section.setName(Text.required(form == null ? null : form.name(), 60, "the section's name")); + return new MenuSectionDto(section.getId(), section.getName(), new ArrayList()); + } + + @Transactional + public MenuSectionDto removeSection(String username, String id) { + MenuSection section = ownSection(mine(username), id); + if (dishes.countBySectionId(id) > 0) { + throw new ResponseStatusException(409, "Move or remove its dishes first"); + } + sections.deleteById(id); + return new MenuSectionDto(section.getId(), section.getName(), new ArrayList()); + } + + /// The merchant's restaurant, its row locked: a change to what an order can + /// contain or cost takes the lock Orders.store takes before it prices the cart + /// again, so an order is stored against the menu before the change or after + /// it, never against one the customer was not charged for. + private Restaurant editing(String username) { + Restaurant restaurant = mine(username); + restaurants.hold(restaurant.getId()); + return restaurant; + } + + private MenuSection ownSection(Restaurant restaurant, String id) { + MenuSection section = id == null || id.length() > 32 ? null : sections.findById(id); + if (section == null || !section.getRestaurantId().equals(restaurant.getId())) { + throw new ResponseStatusException(404, "No such section"); + } + return section; + } + + @Transactional + public DishDto addDish(String username, DishDto form) throws IOException { + Restaurant restaurant = mine(username); + if (dishes.countByRestaurantId(restaurant.getId()) >= MAX_DISHES) { + throw new ResponseStatusException(409, "That is enough dishes"); + } + Dish dish = new Dish(); + dish.setId(Ids.next()); + dish.setRestaurantId(restaurant.getId()); + dish.setAvailable(true); + // After the last one; see addSection. + List before = dishes.findByRestaurantIdOrderByPositionAsc(restaurant.getId()); + dish.setPosition(before.isEmpty() ? 0 : before.get(before.size() - 1).getPosition() + 1); + fill(restaurant, dish, form); + dishes.insert(dish); + return writeOptions(dish, form); + } + + @Transactional + public DishDto saveDish(String username, String id, DishDto form) throws IOException { + Restaurant restaurant = editing(username); + Dish dish = ownDish(restaurant, id); + fill(restaurant, dish, form); + options.deleteByDishId(id); + return writeOptions(dish, form); + } + + @Transactional + public DishDto removeDish(String username, String id) { + Dish dish = ownDish(editing(username), id); + options.deleteByDishId(id); + dishes.deleteById(id); + return Catalog.dish(dish, null); + } + + /// Marks a dish sold out, or back on. + @Transactional + public DishDto available(String username, String id, boolean available) { + Dish dish = ownDish(editing(username), id); + dish.setAvailable(available); + return Catalog.dish(dish, extras(id)); + } + + private void fill(Restaurant restaurant, Dish dish, DishDto form) { + if (form == null) { + throw new ResponseStatusException(400, "Fill in the dish"); + } + dish.setName(Text.required(form.name(), 80, "the dish's name")); + dish.setDescription(Text.optional(form.description(), 500, "the description")); + if (form.priceCents() < 0 || form.priceCents() > 100000) { + throw new ResponseStatusException(400, "Check the price"); + } + dish.setPriceCents(form.priceCents()); + dish.setSectionId(ownSection(restaurant, form.sectionId()).getId()); + dish.setPopular(form.popular()); + String photo = form.photoId() == null ? "" : form.photoId(); + if (photo.length() > 0) { + ownPhoto(restaurant, photo); + } + dish.setPhotoId(photo); + } + + private DishDto writeOptions(Dish dish, DishDto form) throws IOException { + List wanted = form.options() == null ? new ArrayList() + : form.options(); + if (wanted.size() > MAX_OPTIONS) { + throw new ResponseStatusException(400, "That is too many extras"); + } + for (int iter = 0; iter < wanted.size(); iter++) { + DishOptionDto dto = wanted.get(iter); + if (dto.priceCents() < 0 || dto.priceCents() > 100000) { + throw new ResponseStatusException(400, "Check the extra's price"); + } + DishOption option = new DishOption(); + option.setId(Ids.next()); + option.setDishId(dish.getId()); + option.setName(Text.required(dto.name(), 60, "the extra's name")); + option.setPriceCents(dto.priceCents()); + option.setPosition(iter); + options.insert(option); + } + return Catalog.dish(dish, extras(dish.getId())); + } + + private List extras(String dishId) { + List rows = options.findByDishIdOrderByPositionAsc(dishId); + List out = new ArrayList(); + for (int iter = 0; iter < rows.size(); iter++) { + DishOption row = rows.get(iter); + out.add(new DishOptionDto(row.getId(), row.getName(), row.getPriceCents())); + } + return out; + } + + private Dish ownDish(Restaurant restaurant, String id) { + Dish dish = id == null || id.length() > 32 ? null : dishes.findById(id); + if (dish == null || !dish.getRestaurantId().equals(restaurant.getId())) { + throw new ResponseStatusException(404, "No such dish"); + } + return dish; + } + + private void ownPhoto(Restaurant restaurant, String id) { + Photo photo = id.length() > 32 ? null : photos.findById(id); + if (photo == null || !photo.getRestaurantId().equals(restaurant.getId())) { + throw new ResponseStatusException(400, "Upload the photo first"); + } + } + + // ---------------------------------------------------------------- photos + + @Transactional + public PhotoDto upload(String username, PhotoDto photo) throws IOException { + return store(mine(username).getId(), photo); + } + + private PhotoDto store(String restaurantId, PhotoDto photo) throws IOException { + if (photo == null || photo.data() == null || photo.data().length() == 0) { + throw new ResponseStatusException(400, "No photo"); + } + String type = photo.contentType(); + if (!"image/jpeg".equals(type) && !"image/png".equals(type)) { + throw new ResponseStatusException(415, "A photo is a JPEG or a PNG"); + } + if (photo.data().length() > MAX_PHOTO_CHARS) { + throw new ResponseStatusException(413, "That photo is too large"); + } + for (int iter = 0; iter < photo.data().length(); iter++) { + char c = photo.data().charAt(iter); + if (!(c >= 'A' && c <= 'Z' || c >= 'a' && c <= 'z' || c >= '0' && c <= '9' + || c == '+' || c == '/' || c == '=')) { + throw new ResponseStatusException(400, "The photo is not base64"); + } + } + byte[] bytes = Base64.decode(photo.data()); + if (bytes == null || bytes.length == 0) { + throw new ResponseStatusException(400, "The photo is not base64"); + } + Applications.checkSize(bytes, type, "The photo is not a JPEG or a PNG"); + if (prune(restaurantId) >= MAX_PHOTOS) { + throw new ResponseStatusException(429, "Too many photos; try again later"); + } + Photo row = new Photo(); + row.setId(Ids.next()); + row.setRestaurantId(restaurantId); + row.setContentType(type); + row.setData(bytes); + row.setCreatedAt(System.currentTimeMillis()); + photos.insert(row); + return new PhotoDto(row.getId(), type, ""); + } + + /// Deletes the restaurant's photos that neither it nor any of its dishes + /// names and that are past [#UNUSED_PHOTO_GRACE], answering how many + /// rows remain. + private int prune(String restaurantId) { + List used = new ArrayList(); + Restaurant restaurant = restaurants.findById(restaurantId); + if (restaurant != null) { + used.add(restaurant.getPhotoId()); + } + List menu = dishes.findByRestaurantIdOrderByPositionAsc(restaurantId); + for (int iter = 0; iter < menu.size(); iter++) { + used.add(menu.get(iter).getPhotoId()); + } + long before = System.currentTimeMillis() - UNUSED_PHOTO_GRACE; + List stored = photos.stored(restaurantId); + int remaining = 0; + for (int iter = 0; iter < stored.size(); iter++) { + Object id = stored.get(iter)[0]; + Object at = stored.get(iter)[1]; + long createdAt = at instanceof Number ? ((Number) at).longValue() : Long.MAX_VALUE; + if (id instanceof String && !used.contains(id) && createdAt < before) { + photos.deleteById((String) id); + } else { + remaining++; + } + } + return remaining; + } +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/courier/CourierEndpoint.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/courier/CourierEndpoint.java new file mode 100644 index 00000000000..0bafefb8032 --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/courier/CourierEndpoint.java @@ -0,0 +1,134 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.courier; + +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.PreAuthorize; +import com.codenameone.examples.savor.Caller; +import com.codenameone.examples.savor.api.CourierApiServer; +import com.codenameone.examples.savor.api.CourierDto; +import com.codenameone.examples.savor.api.CourierStatusDto; +import com.codenameone.examples.savor.api.EarningsDto; +import com.codenameone.examples.savor.api.EarningsSummaryDto; +import com.codenameone.examples.savor.api.PayoutAccountDto; +import com.codenameone.examples.savor.api.PayoutDto; +import com.codenameone.examples.savor.api.DeliveryDto; +import com.codenameone.examples.savor.delivery.Deliveries; + +import java.util.List; + +/// The server's half of `CourierApi`. +/// +/// Guarded twice, on purpose. The filter chain refuses `/api/courier/**` to +/// anyone who is not a courier, by path; `@PreAuthorize` refuses these methods to +/// anyone who is not a courier, whatever path led to them. Either alone would do +/// today. The second is what still holds after someone moves a path. +@Component +@PreAuthorize("hasRole('COURIER')") +public class CourierEndpoint implements CourierApiServer { + private final Couriers couriers; + private final Deliveries deliveries; + private final Earnings earnings; + + public CourierEndpoint(Couriers couriers, Deliveries deliveries, Earnings earnings) { + this.earnings = earnings; + this.couriers = couriers; + this.deliveries = deliveries; + } + + @Override + public CourierDto status(CourierStatusDto status) throws Exception { + return couriers.report(Caller.name(), status); + } + + @Override + public CourierDto me() throws Exception { + return couriers.describe(Caller.name()); + } + + @Override + public DeliveryDto active() throws Exception { + return deliveries.activeForCourier(Caller.name()); + } + + @Override + public DeliveryDto accept(String id) throws Exception { + return deliveries.accept(Caller.name(), id); + } + + @Override + public DeliveryDto decline(String id) throws Exception { + return deliveries.decline(Caller.name(), id); + } + + @Override + public DeliveryDto arrived(String id) throws Exception { + return deliveries.arrived(Caller.name(), id); + } + + @Override + public DeliveryDto start(String id) throws Exception { + return deliveries.start(Caller.name(), id); + } + + @Override + public DeliveryDto complete(String id) throws Exception { + return deliveries.complete(Caller.name(), id); + } + + @Override + public DeliveryDto cancel(String id) throws Exception { + return deliveries.cancelByCourier(Caller.name(), id); + } + + @Override + public EarningsDto earnings() throws Exception { + return couriers.earnings(Caller.name()); + } + + @Override + public EarningsSummaryDto earningsSummary() throws Exception { + return earnings.summary(Caller.name()); + } + + @Override + public List payouts() throws Exception { + return earnings.payouts(Caller.name()); + } + + @Override + public PayoutDto cashOut() throws Exception { + return earnings.cashOut(Caller.name()); + } + + @Override + public PayoutAccountDto payoutAccount() throws Exception { + return earnings.account(Caller.name()); + } + + @Override + public PayoutAccountDto savePayoutAccount(PayoutAccountDto account) throws Exception { + return earnings.saveAccount(Caller.name(), account); + } + + @Override + public List history() throws Exception { + return deliveries.historyForCourier(Caller.name()); + } +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/courier/Couriers.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/courier/Couriers.java new file mode 100644 index 00000000000..f1c1253ab0a --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/courier/Couriers.java @@ -0,0 +1,321 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.courier; + +import com.codename1.backend.ResponseStatusException; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Transactional; +import com.codenameone.examples.savor.Days; +import com.codenameone.examples.savor.Rows; +import com.codenameone.examples.savor.api.CourierDto; +import com.codenameone.examples.savor.api.CourierStatusDto; +import com.codenameone.examples.savor.api.EarningsDto; +import com.codenameone.examples.savor.api.DeliveryState; +import com.codenameone.examples.savor.domain.CourierApplication; +import com.codenameone.examples.savor.domain.CourierHours; +import com.codenameone.examples.savor.domain.CourierState; +import com.codenameone.examples.savor.domain.Profile; +import com.codenameone.examples.savor.domain.Delivery; +import com.codenameone.examples.savor.onboarding.Applications; +import com.codenameone.examples.savor.geo.Geo; +import com.codenameone.examples.savor.live.LiveHub; +import com.codenameone.examples.savor.push.OrderPushes; +import com.codenameone.examples.savor.order.Quotes; +import com.codenameone.examples.savor.repository.CourierApplicationRepository; +import com.codenameone.examples.savor.repository.CourierHoursRepository; +import com.codenameone.examples.savor.repository.CourierStateRepository; +import com.codenameone.examples.savor.repository.ProfileRepository; +import com.codenameone.examples.savor.repository.DeliveryEventRepository; +import com.codenameone.examples.savor.repository.DeliveryPassedRepository; +import com.codenameone.examples.savor.repository.DeliveryRepository; +import com.codenameone.examples.savor.delivery.Matcher; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/// Couriers' live state and what they have earned. +@Component +public class Couriers { + private static final long DAY_MILLIS = 86400000L; + + /// The states of a delivery a courier has taken and has yet to finish. + private static final DeliveryState[] TAKEN = {DeliveryState.ACCEPTED, DeliveryState.ARRIVED, + DeliveryState.IN_PROGRESS}; + + private final CourierStateRepository couriers; + private final CourierHoursRepository hours; + private final CourierApplicationRepository made; + private final DeliveryRepository deliveries; + private final DeliveryEventRepository events; + private final DeliveryPassedRepository passes; + private final ProfileRepository profiles; + private final LiveHub live; + private final OrderPushes pushes; + private final Quotes fares; + private final Applications applications; + + public Couriers(CourierStateRepository couriers, CourierHoursRepository hours, + CourierApplicationRepository made, DeliveryRepository deliveries, DeliveryEventRepository events, + DeliveryPassedRepository passes, ProfileRepository profiles, LiveHub live, OrderPushes pushes, + Quotes fares, Applications applications) { + this.couriers = couriers; + this.hours = hours; + this.made = made; + this.deliveries = deliveries; + this.events = events; + this.passes = passes; + this.profiles = profiles; + this.applications = applications; + this.live = live; + this.pushes = pushes; + this.fares = fares; + } + + /// Records whether a courier is on line and where the car is. The app sends + /// this every few seconds while on line; it is both the position customers + /// follow and the sign the courier is still there. + /// + /// The customer being driven and the admin's fleet view are told once the + /// report is stored, and not before: what they are told sends them here to + /// read it. + public CourierDto report(String courier, CourierStatusDto status) throws IOException { + if (status == null || (status.online() && !Geo.isPoint(status.lat(), status.lng()))) { + throw new ResponseStatusException(400, "Going online needs your position"); + } + Report made = store(courier, status); + CourierDto dto = made.courier; + if (made.deliveryId != null) { + live.courierMoved(made.customer, made.deliveryId, dto.lat(), dto.lng(), dto.heading()); + pushes.courierMoved(made.deliveryId, dto.lat(), dto.lng()); + } + live.fleetChanged(dto.username(), dto.online(), dto.lat(), dto.lng(), dto.heading()); + return dto; + } + + /// A report as it was stored, and the delivery the courier is on, if any. Not + /// private: the class the build writes to begin and commit the transaction + /// of [#store] has to be able to name what it answers. + static final class Report { + CourierDto courier; + String deliveryId; + String customer; + } + + @Transactional + private Report store(String courier, CourierStatusDto status) throws IOException { + // Two reports from one courier take turns, before anything is read: read together, + // both would count the same stretch since the last one as time on + // line. The clock is read after the wait, so reports are also stored + // in the order they were let through and none moves the time back. + // + // A courier's first report has no state row to hold, so two first + // reports would both find none and both insert one under the same + // name. They take turns on the profile instead, the order blocking an + // account takes them in, and the second then finds the first's row. + // No profile either means the account was closed meanwhile, after its + // clean-up deleted the state row; inserting one now would leave it + // behind for good. + if (couriers.hold(courier) == 0 && profiles.hold(courier) == 0) { + throw new ResponseStatusException(404, "No such account"); + } + // The role lets a courier into this part of the API; the approval is + // what lets them take deliveries. They are granted together, but they are + // kept apart, and it is the approval that is asked for here. + if (status.online() && !applications.approved(courier)) { + throw new ResponseStatusException(403, "Your application to deliver is not approved"); + } + Delivery delivery = deliveries.findFirstByCourierAndStateIn(courier, TAKEN); + if (!status.online() && delivery != null) { + throw new ResponseStatusException(409, "Finish the delivery before going off line"); + } + long now = System.currentTimeMillis(); + CourierState state = couriers.findById(courier); + // The time since the last report was time on line, unless it was so + // long ago that the app had plainly stopped reporting. It is counted + // here, from the row as it was, and added last, once the row has been + // changed. Adding it is a bulk update that leaves the session as it + // is, so the row read here stays managed and its change is written + // either way; a statement marked clearAutomatically would detach it, + // and a change made after that would be lost. + long since = state != null && state.isOnline() ? now - state.getLastSeen() : 0L; + boolean first = state == null; + if (first) { + state = new CourierState(); + state.setUsername(courier); + } + state.setOnline(status.online()); + state.setLastSeen(now); + // Off line keeps the last position: there is none to report. A first + // report stores whatever came with it. + if (status.online() || first) { + state.setLat(status.lat()); + state.setLng(status.lng()); + state.setHeading(status.heading()); + } + if (first) { + couriers.insert(state); + } + if (since > 0L && since < Matcher.FRESH_MILLIS) { + // Cut at midnight: a stretch on line across it belongs half to + // each day, not all to the new one. + long[] days = Days.split(now - since, now); + for (int iter = 0; iter < days.length; iter += 2) { + addHours(courier, days[iter], days[iter + 1]); + } + } + Report made = new Report(); + made.courier = view(courier, state); + if (delivery != null) { + made.deliveryId = delivery.getId(); + made.customer = delivery.getCustomer(); + } + return made; + } + + @Transactional(readOnly = true) + public CourierDto describe(String courier) throws IOException { + return view(courier, couriers.findById(courier)); + } + + /// A courier with `deliveries` given rather than counted: the deliveries of a period + /// the caller is describing, not of all time. + @Transactional(readOnly = true) + public CourierDto describe(String courier, int deliveries) throws IOException { + return view(courier, couriers.findById(courier), deliveries); + } + + private CourierDto view(String courier, CourierState state) { + return view(courier, state, (int) deliveries.countByCourierAndState(courier, + DeliveryState.COMPLETED)); + } + + private CourierDto view(String courier, CourierState state, int completed) { + Profile profile = profiles.findById(courier); + if (profile == null) { + throw new ResponseStatusException(404, "No such courier"); + } + return toDto(profile, made.findById(courier), state, completed, + acceptanceRate(events.countByActorAndState(courier, DeliveryState.ACCEPTED), + passes.countByCourier(courier))); + } + + /// Adds time on line to a courier's day. The addition is the database's, + /// so reports that overlap are both counted. + private void addHours(String courier, long dayNumber, long millis) { + if (hours.addMillis(courier, dayNumber, millis) == 0) { + CourierHours day = new CourierHours(); + day.setUsername(courier); + day.setDayNumber(dayNumber); + day.setMillis(millis); + hours.insert(day); + } + } + + /// The share of the offers a courier answered that they took: 1 before they + /// have answered any. An offer left to lapse counts as passed on. + static double acceptanceRate(long accepted, long passed) { + long offers = accepted + passed; + return offers == 0L ? 1d : Math.round(accepted * 100d / offers) / 100d; + } + + /// Every account that has driven or gone on line, for the admin's fleet + /// view; those on line first. + @Transactional(readOnly = true) + public List all() throws IOException { + Map done = Rows.counts(deliveries.countByCourier(DeliveryState.COMPLETED)); + Map accepted = Rows.counts(events.countByActor(DeliveryState.ACCEPTED)); + Map passed = Rows.counts(passes.countByCourier()); + Map people = new HashMap(); + List profilesOfAll = profiles.couriers(); + for (int iter = 0; iter < profilesOfAll.size(); iter++) { + people.put(profilesOfAll.get(iter).getUsername(), profilesOfAll.get(iter)); + } + Map forms = new HashMap(); + List applicationsOfAll = made.ofCouriers(); + for (int iter = 0; iter < applicationsOfAll.size(); iter++) { + forms.put(applicationsOfAll.get(iter).getUsername(), applicationsOfAll.get(iter)); + } + List states = couriers.onLineFirst(); + List out = new ArrayList(); + for (int iter = 0; iter < states.size(); iter++) { + CourierState state = states.get(iter); + Profile profile = people.get(state.getUsername()); + if (profile == null) { + // A state left behind by an account that is gone. + continue; + } + Long rode = done.get(state.getUsername()); + Long took = accepted.get(state.getUsername()); + Long left = passed.get(state.getUsername()); + out.add(toDto(profile, forms.get(state.getUsername()), state, + rode == null ? 0 : (int) rode.longValue(), + acceptanceRate(took == null ? 0L : took.longValue(), + left == null ? 0L : left.longValue()))); + } + return out; + } + + /// What a courier's completed, paid deliveries earned them: their share of + /// each fare, without tips. "Today" is the day in UTC; a service in one + /// city would use that city's midnight. + @Transactional(readOnly = true) + public EarningsDto earnings(String courier) throws IOException { + long now = System.currentTimeMillis(); + // The day's trips and the day's money from one query, so the header + // that shows them together counts the deliveries that made the sum. + Object[] day = deliveries.paidTotalsSince(courier, now - now % DAY_MILLIS); + int todayCount = day == null ? 0 : (int) Rows.number(day[0]); + long today = day == null ? 0L : Rows.number(day[1]); + long week = Rows.number(deliveries.paidSince(courier, now - 7 * DAY_MILLIS)); + Object[] total = deliveries.paidTotals(courier); + int count = total == null ? 0 : (int) Rows.number(total[0]); + long all = total == null ? 0L : Rows.number(total[1]); + return new EarningsDto(today, week, all, count, todayCount, fares.currency()); + } + + /// @param application the account's application to deliver, or null + /// @param car the account's state, or null when it has never gone on line + private static CourierDto toDto(Profile profile, CourierApplication application, + CourierState car, int deliveries, double acceptanceRate) { + boolean approved = application != null + && Applications.APPROVED.equals(application.getStatus()); + boolean online = false; + double lat = 0d; + double lng = 0d; + double heading = 0d; + double rating = 5d; + long lastSeen = 0L; + if (car != null) { + lastSeen = car.getLastSeen(); + // On line means said so and heard from lately; see Matcher. + online = car.isOnline() + && System.currentTimeMillis() - lastSeen < Matcher.FRESH_MILLIS; + lat = car.getLat(); + lng = car.getLng(); + heading = car.getHeading(); + rating = car.rating(); + } + return new CourierDto(profile.getUsername(), profile.getDisplayName(), + profile.getVehicle(), profile.getPlate(), online, lat, lng, heading, rating, + lastSeen, deliveries, approved, acceptanceRate, profile.isFlagged()); + } +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/courier/Earnings.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/courier/Earnings.java new file mode 100644 index 00000000000..d082b078f0c --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/courier/Earnings.java @@ -0,0 +1,302 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.courier; + +import com.codename1.backend.ResponseStatusException; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Transactional; +import com.codename1.orm.session.Identifier; +import com.codenameone.examples.savor.Days; +import com.codenameone.examples.savor.Ids; +import com.codenameone.examples.savor.Rows; +import com.codenameone.examples.savor.Text; +import com.codenameone.examples.savor.api.DayAmountDto; +import com.codenameone.examples.savor.api.EarningsSummaryDto; +import com.codenameone.examples.savor.api.PayoutAccountDto; +import com.codenameone.examples.savor.api.PayoutDto; +import com.codenameone.examples.savor.api.DeliveryState; +import com.codenameone.examples.savor.domain.CourierHours; +import com.codenameone.examples.savor.domain.CourierState; +import com.codenameone.examples.savor.domain.Payout; +import com.codenameone.examples.savor.domain.PayoutAccount; +import com.codenameone.examples.savor.domain.Delivery; +import com.codenameone.examples.savor.pay.Payments; +import com.codenameone.examples.savor.repository.CourierHoursRepository; +import com.codenameone.examples.savor.repository.CourierStateRepository; +import com.codenameone.examples.savor.repository.PayoutAccountRepository; +import com.codenameone.examples.savor.repository.PayoutRepository; +import com.codenameone.examples.savor.repository.ProfileRepository; +import com.codenameone.examples.savor.repository.DeliveryEventRepository; +import com.codenameone.examples.savor.repository.DeliveryPassedRepository; +import com.codenameone.examples.savor.repository.DeliveryRepository; +import com.codenameone.examples.savor.order.Quotes; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/// What a courier has earned, and what has been paid out to them. +/// +/// A completed delivery earns its courier the fare less the service's commission, +/// fixed when the delivery was asked for, plus the whole of any tip. The balance +/// is everything earned less everything paid out, and cashing out moves all +/// of it. +/// +/// Payouts are a ledger and no more: a row says the money was sent, and no +/// bank is asked to send it. +/// +/// A cash delivery counts toward what a courier earned, but never toward what +/// they can cash out: the customer handed them the whole order in cash, their +/// share and any tip included, so paying that share again would pay it twice, +/// and a cash tip, which no processor ever charged, would become a payout. What +/// the courier owes the service from that cash (the order less their share) +/// is settled outside the app; a service that moves real money would net it +/// against the transfer this sample does not make. +@Component +public class Earnings { + private static final int DAYS = 14; + + private final PayoutRepository payouts; + private final PayoutAccountRepository accounts; + private final DeliveryRepository deliveries; + private final DeliveryEventRepository events; + private final DeliveryPassedRepository passes; + private final CourierStateRepository couriers; + private final CourierHoursRepository hours; + private final ProfileRepository profiles; + private final Quotes fares; + + public Earnings(PayoutRepository payouts, PayoutAccountRepository accounts, + DeliveryRepository deliveries, DeliveryEventRepository events, DeliveryPassedRepository passes, + CourierStateRepository couriers, CourierHoursRepository hours, + ProfileRepository profiles, Quotes fares) { + this.payouts = payouts; + this.accounts = accounts; + this.deliveries = deliveries; + this.events = events; + this.passes = passes; + this.couriers = couriers; + this.hours = hours; + this.profiles = profiles; + this.fares = fares; + } + + @Transactional(readOnly = true) + public EarningsSummaryDto summary(String courier) throws IOException { + long now = System.currentTimeMillis(); + long today = Days.start(now); + long month = 0L; + long week = 0L; + long earnedToday = 0L; + // Each day's money and trips, oldest first. + long[] dayAmount = new long[DAYS]; + int[] dayTrips = new int[DAYS]; + // Thirty days of deliveries, added up here: "group by day" is spelt + // differently by every database, and a courier's month is a few hundred + // rows. + long monthStart = today - 29 * Days.MILLIS; + long weekStart = today - 6 * Days.MILLIS; + List done = deliveries.findByCourierAndStateAndUpdatedAtGreaterThanEqual(courier, + DeliveryState.COMPLETED, monthStart); + // A card tip is on its delivery from the moment it is asked for, and is + // still there when the processor never answered. Only one whose charge + // is on record is earned, here as in the balance: otherwise a day's + // total would show money that cannot be cashed out. + Set charged = new HashSet( + deliveries.tipChargedSince(courier, monthStart)); + for (int iter = 0; iter < done.size(); iter++) { + Delivery delivery = done.get(iter); + long at = delivery.getUpdatedAt(); + boolean tipped = "cash".equals(delivery.getMethodId()) || charged.contains(delivery.getId()); + // The fare likewise counts only once it is paid, as in the balance: + // a card charge that failed, was refunded or has not come back is + // no money of the courier's. The trip still counts as driven. + boolean paid = Payments.PAID.equals(delivery.getPaymentStatus()); + long earned = (paid ? delivery.getCourierCents() : 0L) + (tipped ? delivery.getTipCents() : 0L); + month += earned; + if (at >= weekStart) { + week += earned; + } + if (at >= today) { + earnedToday += earned; + } + int index = DAYS - 1 - (int) (Days.number(today) - Days.number(at)); + if (index >= 0 && index < DAYS) { + dayAmount[index] += earned; + dayTrips[index]++; + } + } + List daily = new ArrayList(); + for (int iter = 0; iter < DAYS; iter++) { + daily.add(new DayAmountDto(Days.iso(today - (DAYS - 1 - iter) * Days.MILLIS), + dayAmount[iter], dayTrips[iter])); + } + int trips = (int) deliveries.countByCourierAndState(courier, DeliveryState.COMPLETED); + CourierHours day = hours.findById(Identifier.of(courier, Long.valueOf(Days.number(now)))); + int onlineMinutes = day == null ? 0 : (int) (day.getMillis() / 60000L); + CourierState car = couriers.findById(courier); + double rating = car == null ? 5d : car.rating(); + double acceptanceRate = Couriers.acceptanceRate(events.countByActorAndState(courier, + DeliveryState.ACCEPTED), passes.countByCourier(courier)); + return new EarningsSummaryDto(fares.currency(), earnedToday, week, month, + balance(courier), trips, tips(courier), onlineMinutes, rating, acceptanceRate, + daily); + } + + /// The payouts made to a courier, newest first. + @Transactional(readOnly = true) + public List payouts(String courier) throws IOException { + List rows = payouts.findTop100ByCourierOrderByCreatedAtDescIdAsc(courier); + List out = new ArrayList(); + for (int iter = 0; iter < rows.size(); iter++) { + out.add(toDto(rows.get(iter))); + } + return out; + } + + /// Pays out the whole balance to the courier's account. Answers 409 when + /// there is no account to pay it to, or nothing to pay. + /// + /// Two requests at once would both read the same balance and both pay + /// it. So the first thing the transaction does is hold the courier's + /// payout account, the one row every cash-out of theirs needs: the second + /// request waits there until the first has committed, then reads a + /// balance that already has the first payout taken off it, and is told + /// there is nothing left. The hold has to come before any read, or the + /// second request would have looked at the ledger before it waited. + @Transactional + public PayoutDto cashOut(String courier) throws IOException { + if (accounts.hold(courier) == 0) { + throw new ResponseStatusException(409, "Add the account to be paid into first"); + } + PayoutAccount account = accounts.findById(courier); + long amount = balance(courier); + if (amount <= 0L) { + throw new ResponseStatusException(409, "There is nothing to cash out"); + } + Payout row = new Payout(); + row.setId(Ids.next()); + row.setCourier(courier); + row.setAmountCents(amount); + row.setStatus("paid"); + row.setCreatedAt(System.currentTimeMillis()); + row.setDestination(account.getBankName() + " ****" + account.getLast4()); + payouts.insert(row); + return toDto(row); + } + + /// Where a courier is paid; every field empty until they have said. + @Transactional(readOnly = true) + public PayoutAccountDto account(String courier) throws IOException { + return toDto(accounts.findById(courier)); + } + + /// Saves where a courier is paid. Only the last four digits of the account + /// are taken, which is all a ledger with no bank behind it has a use for. + @Transactional + public PayoutAccountDto saveAccount(String courier, PayoutAccountDto wanted) + throws IOException { + if (wanted == null) { + throw new ResponseStatusException(400, "Nothing to save"); + } + String last4 = wanted.accountLast4() == null ? "" : wanted.accountLast4().trim(); + boolean digits = last4.length() == 4; + for (int iter = 0; digits && iter < 4; iter++) { + digits = last4.charAt(iter) >= '0' && last4.charAt(iter) <= '9'; + } + if (!digits) { + throw new ResponseStatusException(400, "Give the last four digits of the account"); + } + String holder = Text.required(wanted.holder(), 120, "the account holder's name"); + String bankName = Text.required(wanted.bankName(), 80, "the bank's name"); + // Before the row is looked for. Two first saves at once would both + // find none and both insert one under the same name, and one would + // fail; behind the profile's hold the second finds the first's row + // and updates it. The payout account itself cannot be held: until + // this inserts it there is no row. With no profile to hold the account + // was closed meanwhile and its clean-up has run: a row inserted now + // would keep the closed account's bank details with nobody able to + // remove them. + if (profiles.hold(courier) == 0) { + throw new ResponseStatusException(404, "No such account"); + } + PayoutAccount row = accounts.findById(courier); + boolean added = row == null; + if (added) { + row = new PayoutAccount(); + row.setUsername(courier); + } + row.setHolder(holder); + row.setBankName(bankName); + row.setLast4(last4); + if (added) { + accounts.insert(row); + } + return toDto(row); + } + + /// What the service holds for the courier: the card-paid part of [#earned] + /// less everything paid out. Cash deliveries are left out; see the class + /// comment. A refund after a cash-out takes it below zero. Closing an + /// account asks it too, and is refused unless it is zero. + public long balance(String courier) { + return Rows.number(deliveries.cardShare(courier)) + Rows.number(deliveries.chargedTips(courier)) + - Rows.number(payouts.paidOut(courier)); + } + + /// Everything a courier has earned, before anything was paid out: their + /// share of the fares of every delivery they completed and that was paid for, + /// plus their tips as [#tips] counts them. + /// + /// The administrator's view of an account shows this figure, and takes it + /// from here rather than from a sum of its own, so the two can never + /// disagree about a charge that failed, a refund or a tip whose charge has + /// not come back. + /// + /// @param courier the courier's user name + /// @return the cents earned + public long earned(String courier) { + return Rows.number(deliveries.courierShare(courier)) + tips(courier); + } + + /// The tips a courier has been given: those handed over in cash, and those + /// put on a card whose charge has come back. + /// + /// A card tip sits on its delivery from the moment it is asked for, and stays + /// there with no charge recorded when the processor did not answer. It is + /// not money yet -- the card may never have been charged -- so it is not + /// counted, and so cannot be cashed out, until the charge is on record. + private long tips(String courier) { + return Rows.number(deliveries.cashTips(courier)) + Rows.number(deliveries.chargedTips(courier)); + } + + private static PayoutAccountDto toDto(PayoutAccount row) { + if (row == null) { + return new PayoutAccountDto("", "", ""); + } + return new PayoutAccountDto(row.getHolder(), row.getBankName(), row.getLast4()); + } + + private static PayoutDto toDto(Payout row) { + return new PayoutDto(row.getId(), row.getAmountCents(), row.getStatus(), + row.getCreatedAt(), row.getDestination()); + } +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/delivery/Deliveries.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/delivery/Deliveries.java new file mode 100644 index 00000000000..0e556c6e391 --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/delivery/Deliveries.java @@ -0,0 +1,978 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.delivery; + +import com.codename1.backend.ConcurrencyFailureException; +import com.codename1.backend.ResponseStatusException; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Scheduled; +import com.codename1.backend.annotations.Transactional; +import com.codename1.backend.annotations.Value; +import com.codename1.orm.repository.PageRequest; +import com.codename1.orm.session.Identifier; +import com.codename1.orm.session.PersistenceException; +import com.codenameone.examples.savor.Ids; +import com.codenameone.examples.savor.Text; +import com.codenameone.examples.savor.account.Moderation; +import com.codenameone.examples.savor.api.DeliveryDto; +import com.codenameone.examples.savor.api.DeliveryState; +import com.codenameone.examples.savor.api.OrderState; +import com.codenameone.examples.savor.domain.CustomerOrder; +import com.codenameone.examples.savor.domain.Restaurant; +import com.codenameone.examples.savor.order.OrderEvents; +import com.codenameone.examples.savor.order.OrderStateMachine; +import com.codenameone.examples.savor.order.Quotes; +import com.codenameone.examples.savor.repository.CustomerOrderRepository; +import com.codenameone.examples.savor.domain.CourierState; +import com.codenameone.examples.savor.domain.Profile; +import com.codenameone.examples.savor.domain.Delivery; +import com.codenameone.examples.savor.domain.DeliveryEvent; +import com.codenameone.examples.savor.domain.DeliveryPassed; +import com.codenameone.examples.savor.geo.Geo; +import com.codenameone.examples.savor.live.LiveHub; +import com.codenameone.examples.savor.pay.Payments; +import com.codenameone.examples.savor.order.OrderNews; +import com.codenameone.examples.savor.push.OrderPushes; +import com.codenameone.examples.savor.repository.CourierStateRepository; +import com.codenameone.examples.savor.repository.ProfileRepository; +import com.codenameone.examples.savor.repository.DeliveryEventRepository; +import com.codenameone.examples.savor.repository.DeliveryPassedRepository; +import com.codenameone.examples.savor.repository.DeliveryRepository; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/// Deliveries: asking for one, finding it a courier, and taking it through to the end. +/// +/// Every change of state goes through [#move], which makes it with a single +/// conditional update ([DeliveryStateMachine]) and records it. The people concerned +/// are then told over the live channel. What they are told is only that the +/// delivery changed; they read the delivery itself from here, so there is one account +/// of a delivery and the live channel cannot disagree with it. +/// +/// That is also why the telling waits for the change to be committed. An app +/// told of a change asks for the delivery at once, over another connection, and +/// must find it changed. So the methods that change a delivery come in pairs: a +/// private one that is the transaction, and the public one that calls it and, +/// once it has returned, tells whoever is concerned. +@Component +public class Deliveries { + /// Deliveries shorter than this are not deliveries, and longer than this are not this + /// service's. + static final double MIN_METERS = 100d; + static final double MAX_METERS = 150000d; + /// How many couriers one search may choose before it leaves the delivery to the + /// next tick; see [#search]. + private static final int SEARCH_TRIES = 3; + /// The two people a move can be held to; see [#tryMove]. + private static final String CUSTOMER = "customer"; + private static final String COURIER = "courier"; + private static final DeliveryState[] ACTIVE = {DeliveryState.REQUESTED, DeliveryState.OFFERED, + DeliveryState.ACCEPTED, DeliveryState.ARRIVED, DeliveryState.IN_PROGRESS}; + private static final DeliveryState[] WITH_COURIER = {DeliveryState.OFFERED, DeliveryState.ACCEPTED, + DeliveryState.ARRIVED, DeliveryState.IN_PROGRESS}; + private static final DeliveryState[] TAKEN_NOT_STARTED = {DeliveryState.ACCEPTED, + DeliveryState.ARRIVED}; + private static final DeliveryState[] ENDED_WITH_COURIER = {DeliveryState.COMPLETED, + DeliveryState.CANCELLED_BY_CUSTOMER, DeliveryState.CANCELLED_BY_COURIER, + DeliveryState.CANCELLED_BY_ADMIN}; + + private final DeliveryRepository deliveries; + private final DeliveryEventRepository events; + private final DeliveryPassedRepository passes; + private final CourierStateRepository couriers; + private final ProfileRepository profiles; + private final Matcher matcher; + private final CustomerOrderRepository orders; + private final OrderEvents orderEvents; + private final Payments payments; + private final LiveHub live; + private final OrderPushes pushes; + private final OrderNews news; + private final Moderation moderation; + private final long offerMillis; + private final long searchMillis; + + /// @param offerSeconds how long a courier has to answer an offer + /// @param searchSeconds how long a delivery looks for a courier before giving up + public Deliveries(DeliveryRepository deliveries, DeliveryEventRepository events, DeliveryPassedRepository passes, + CourierStateRepository couriers, ProfileRepository profiles, Matcher matcher, + CustomerOrderRepository orders, OrderEvents orderEvents, Payments payments, LiveHub live, + OrderPushes pushes, OrderNews news, Moderation moderation, + @Value("${savor.offer.seconds:15}") int offerSeconds, + @Value("${savor.search.seconds:900}") int searchSeconds) { + this.deliveries = deliveries; + this.events = events; + this.passes = passes; + this.couriers = couriers; + this.profiles = profiles; + this.moderation = moderation; + this.matcher = matcher; + this.orders = orders; + this.orderEvents = orderEvents; + this.payments = payments; + this.live = live; + this.pushes = pushes; + this.news = news; + offerMillis = offerSeconds * 1000L; + searchMillis = searchSeconds * 1000L; + } + + // ---------------------------------------------------------------- orders + + // tag::savor-dispatch[] + /// Creates the delivery for an order the kitchen has just accepted, with the + /// order's id, from the restaurant to the order's door. Runs inside the + /// transaction that accepts the order, so the two commit together; [#tick] + /// finds the delivery a courier from then on, while the kitchen cooks. + /// + /// The courier is paid the delivery fee and the tip, so that is the fare a + /// courier is offered. + public void dispatch(CustomerOrder order, Restaurant restaurant) throws IOException { + long now = System.currentTimeMillis(); + Delivery delivery = new Delivery(); + delivery.setId(order.getId()); + delivery.setState(DeliveryState.REQUESTED); + delivery.setCustomer(order.getCustomer()); + delivery.setCourier(""); + delivery.setPickupLat(restaurant.getLat()); + delivery.setPickupLng(restaurant.getLng()); + delivery.setPickupAddress(address(restaurant.getName() + ", " + restaurant.getAddress())); + delivery.setDropoffLat(order.getDropoffLat()); + delivery.setDropoffLng(order.getDropoffLng()); + delivery.setDropoffAddress(address(order.getDropoffAddress())); + double meters = Quotes.roadMeters(restaurant.getLat(), restaurant.getLng(), + order.getDropoffLat(), order.getDropoffLng()); + delivery.setDistanceMeters(meters); + delivery.setDurationSeconds(meters / Quotes.METERS_PER_SECOND); + delivery.setFareCents(order.getDeliveryFeeCents()); + delivery.setFeeCents(order.getDeliveryFeeCents()); + // A tip given at checkout is part of the order's total, taken with it, + // so it is in the courier's share. The delivery's own tip is the one + // given after it arrives, charged on its own: counting the checkout + // tip there too would pay it to the courier twice. + delivery.setTipCents(0L); + delivery.setCourierCents(order.getDeliveryFeeCents() + order.getTipCents()); + delivery.setCurrency(order.getCurrency()); + delivery.setMethodId(order.getMethodId()); + delivery.setMethodLabel(order.getMethodLabel()); + delivery.setPaymentStatus(order.getPaymentStatus()); + delivery.setNote(order.getNote()); + delivery.setRequestedAt(now); + delivery.setUpdatedAt(now); + delivery.setSearchUntil(now + searchMillis); + deliveries.insert(delivery); + event(order.getId(), DeliveryState.REQUESTED, ""); + } + // end::savor-dispatch[] + + /// The customer's stars for the order, given to its courier too: inside + /// the transaction that rates the order. An order that had no courier -- + /// or whose delivery is already rated -- changes nothing here. + public void rate(String id, String customer, int stars) { + if (deliveries.rate(id, customer, stars) == 1) { + Delivery delivery = find(id); + if (delivery != null && delivery.getCourier() != null + && delivery.getCourier().length() > 0) { + couriers.addRating(delivery.getCourier(), stars); + } + } + } + + /// Ends the delivery of an order an admin cancelled, inside that + /// transaction, and answers what changed for [#told], or null when the + /// order had no delivery under way. + public Change endForOrder(String id, String by) throws IOException { + Delivery before = find(id); + if (before == null || !tryMove(id, DeliveryState.CANCELLED_BY_ADMIN, by, null, null, + null)) { + return null; + } + return new Change(id, DeliveryState.CANCELLED_BY_ADMIN, before.getCustomer(), + before.getCourier(), "", null); + } + + /// Tells the people concerned about a change made by another class's + /// transaction, once it has committed. + public void told(Change change) { + tell(change); + } + + // --------------------------------------------------------------- couriers + + /// The offer waiting for this courier's answer, or the delivery they are on. + @Transactional(readOnly = true) + public DeliveryDto activeForCourier(String courier) throws IOException { + Delivery delivery = deliveries.findFirstByCourierAndStateIn(courier, WITH_COURIER); + return delivery == null ? none() : toDto(delivery, courier, new HashMap()); + } + + @Transactional(readOnly = true) + public List historyForCourier(String courier) throws IOException { + return list(deliveries.findTop50ByCourierAndStateInOrderByRequestedAtDesc(courier, + ENDED_WITH_COURIER), courier); + } + + public DeliveryDto accept(String courier, String id) throws IOException { + return tell(attempt(() -> step(courier, id, DeliveryState.ACCEPTED))); + } + + /// Passes on an offer. The delivery goes back to looking, and not at this + /// courier again. + public DeliveryDto decline(String courier, String id) throws IOException { + tell(attempt(() -> pass(courier, id))); + search(id); + return none(); + } + + @Transactional + private Change pass(String courier, String id) throws IOException { + Delivery delivery = owned(id, COURIER, courier); + move(id, DeliveryState.REQUESTED, courier, COURIER, "", null); + passed(id, courier); + return new Change(id, DeliveryState.REQUESTED, delivery.getCustomer(), "", courier, null); + } + + public DeliveryDto arrived(String courier, String id) throws IOException { + return tell(attempt(() -> step(courier, id, DeliveryState.ARRIVED))); + } + + public DeliveryDto start(String courier, String id) throws IOException { + return tell(attempt(() -> step(courier, id, DeliveryState.IN_PROGRESS))); + } + + /// Ends the trip and charges the customer. + /// + /// In three steps, and only the first and the last are transactions. The + /// charge between them may be a call to a payment provider, which can take + /// seconds, and a transaction held open across it would hold a connection + /// for as long. + public DeliveryDto complete(String courier, String id) throws IOException { + Delivery delivery = attempt(() -> finish(courier, id)); + // After the move, and only by the request whose update made it: a delivery + // completes once, so it is charged once. + String status = payments.settle(delivery); + return tell(settled(courier, id, status)); + } + + @Transactional + private Delivery finish(String courier, String id) throws IOException { + Delivery delivery = owned(id, COURIER, courier); + move(id, DeliveryState.COMPLETED, courier, COURIER, null, null); + if (orders.deliver(id, OrderStateMachine.sources(OrderState.DELIVERED), + OrderState.DELIVERED, System.currentTimeMillis()) == 1) { + orderEvents.record(id, OrderState.DELIVERED, courier); + } + return delivery; + } + + /// How long a completed delivery may wait for its payment before it is taken + /// to have been left behind. Longer than any charge takes. + private static final long UNSETTLED_MILLIS = 2L * 60L * 1000L; + + /// How long a completed delivery's charge goes on being asked for. Short of + /// the day a processor remembers the key a charge was first sent under. + private static final long RETRY_MILLIS = Payments.RETRY_MILLIS; + + /// Finishes the payment of deliveries that completed and were never settled. + /// + /// Completing a delivery commits before the card is charged, and what came of + /// the charge is written after it. A server stopped between the two leaves + /// a delivery that is over and says its payment is pending, and nothing would + /// come back for it: the courier's request cannot be made again, the delivery + /// having completed. This does. Settling is safe to repeat -- see + /// [Payments#settle] -- so a delivery whose card was charged just before the + /// server stopped is recorded as paid and not charged again. + /// + /// A delivery whose charge failed without the processor saying whether it took + /// the money is left pending too, and is asked about again here, every + /// minute for [#RETRY_MILLIS]. After that it stays pending and is not asked + /// about: the key that makes asking twice safe is kept by a processor for a + /// day, and a charge sent under it later than that is a second charge. What + /// became of such a delivery is for a person to find in the processor's records. + @Scheduled(fixedDelay = 60000, initialDelay = 30000, lock = "savor-settlement") + public void settleLeftBehind() throws IOException { + long now = System.currentTimeMillis(); + List unsettled = unsettled(now - RETRY_MILLIS, now - UNSETTLED_MILLIS); + Exception failed = null; + for (int iter = 0; iter < unsettled.size(); iter++) { + Delivery delivery = unsettled.get(iter); + try { + tell(settled(delivery.getCourier(), delivery.getId(), payments.settle(delivery))); + } catch (IOException err) { + failed = failed == null ? err : failed; + } catch (RuntimeException err) { + failed = failed == null ? err : failed; + } + } + if (failed instanceof IOException) { + throw (IOException) failed; + } + if (failed instanceof RuntimeException) { + throw (RuntimeException) failed; + } + } + + @Transactional(readOnly = true) + private List unsettled(long after, long before) { + return deliveries.unsettled(after, before, new PageRequest(0, 50)); + } + + @Transactional + private Change settled(String courier, String id, String status) throws IOException { + deliveries.paymentStatus(id, status); + orders.paymentStatus(id, status, System.currentTimeMillis()); + Delivery delivery = find(id); + return new Change(id, delivery.getState(), delivery.getCustomer(), delivery.getCourier(), "", + toDto(delivery, courier, new HashMap())); + } + + /// Gives back a delivery a courier took and cannot finish, before they have + /// the food. The order still has to reach its door, so the delivery goes + /// back to looking, and not at this courier again. + public DeliveryDto cancelByCourier(String courier, String id) throws IOException { + tell(attempt(() -> giveUp(courier, id))); + search(id); + return none(); + } + + @Transactional + private Change giveUp(String courier, String id) throws IOException { + Delivery delivery = owned(id, COURIER, courier); + if (deliveries.giveBack(id, TAKEN_NOT_STARTED, DeliveryState.REQUESTED, + System.currentTimeMillis(), courier, "") != 1) { + throw new ResponseStatusException(409, "That delivery is " + + describe(find(id).getState())); + } + // Recorded as what the courier did, though the delivery itself is + // looking again: the admin's count of a courier's cancellations is + // read from these. + event(id, DeliveryState.CANCELLED_BY_COURIER, courier); + passed(id, courier); + return new Change(id, DeliveryState.REQUESTED, delivery.getCustomer(), "", courier, null); + } + + /// One of a courier's own moves of a delivery that is theirs, and the delivery as + /// they see it afterwards. + @Transactional + private Change step(String courier, String id, DeliveryState to) throws IOException { + if (DeliveryState.ACCEPTED == to) { + // Blocking a courier is refused while they have a delivery taken, and + // takes the courier's profile to look. Taking an offer holds the + // same row first and then asks whether the account is still + // allowed: a block that committed meanwhile is seen here, and a + // block that comes after this commits sees the accepted delivery. + // Either way no delivery is left with a courier who can no longer + // carry it. Withdrawing the courier's role holds the same row and + // is asked the same way. + profiles.hold(courier); + if (!matcher.mayDeliver(courier)) { + throw new ResponseStatusException(403, "This account can no longer deliver"); + } + } + owned(id, COURIER, courier); + if (DeliveryState.IN_PROGRESS == to) { + // The courier has the food: the order is picked up in the same + // transaction, and only once the kitchen said it is ready. + if (orders.move(id, OrderStateMachine.sources(OrderState.PICKED_UP), + OrderState.PICKED_UP, System.currentTimeMillis()) != 1) { + throw new ResponseStatusException(409, "The kitchen has not marked it ready yet"); + } + orderEvents.record(id, OrderState.PICKED_UP, courier); + } + move(id, to, courier, COURIER, null, null); + Delivery delivery = find(id); + return new Change(id, delivery.getState(), delivery.getCustomer(), delivery.getCourier(), "", + toDto(delivery, courier, new HashMap())); + } + + // ----------------------------------------------------------------- admin + + // ---------------------------------------------------------------- anyone + + /// A delivery, for someone entitled to see it: its customer, its courier, or an + /// admin. Anyone else is told it does not exist, which is all they should + /// learn about it. + @Transactional(readOnly = true) + public DeliveryDto get(String viewer, boolean admin, String id) throws IOException { + Delivery delivery = find(id); + if (delivery == null || !(admin || viewer.equals(delivery.getCustomer()) + || viewer.equals(delivery.getCourier()))) { + throw new ResponseStatusException(404, "No such delivery"); + } + return toDto(delivery, admin ? delivery.getCourier() : viewer, new HashMap(), + !admin); + } + + /// The delivery of an order, for showing inside the order, or null when the + /// kitchen has not accepted it yet. The caller has already decided that + /// `viewer` may see the order; `party` is false only for an admin, and true + /// for its customer, its courier and its restaurant's staff alike: a + /// merchant is not shown the customer's sign-in name. + @Transactional(readOnly = true) + public DeliveryDto forOrder(String id, String viewer, boolean party) throws IOException { + Delivery delivery = find(id); + return delivery == null ? null : toDto(delivery, viewer, new HashMap(), + party); + } + + /// The most recent deliveries, for the admin. + @Transactional(readOnly = true) + public List recent() throws IOException { + List rows = deliveries.newest(new PageRequest(0, 100)); + Map cache = new HashMap(); + List out = new ArrayList(); + for (int iter = 0; iter < rows.size(); iter++) { + Delivery delivery = rows.get(iter); + out.add(toDto(delivery, delivery.getCourier(), cache, false)); + } + return out; + } + + // -------------------------------------------------------------- matching + + /// Offers a waiting delivery to the nearest courier who has not passed on it. + /// Does nothing when nobody is in reach; [#tick] tries again. + /// + /// In two transactions: one that chooses a courier and one that makes the + /// offer. Choosing reads who is free, and two deliveries asked for together + /// each read the same courier as free; the second transaction is where + /// they take turns over that courier, and it has to open with that -- see + /// [#offer]. + /// + /// A courier the offer finds no longer fit for the delivery is passed over for + /// the next, as a decline or a lapse would be, without waiting for the + /// next tick: choosing again reads them as they now are, and does not pick + /// them. Bounded, so couriers changing faster than this can choose cost the + /// delivery a tick and no more. + public void search(String id) throws IOException { + String[] chosen = new String[1]; + Change change = null; + for (int tries = 0; tries < SEARCH_TRIES; tries++) { + change = attempt(() -> choose(id, chosen)); + if (chosen[0] == null) { + break; + } + change = attempt(() -> offer(id, chosen[0])); + if (change != null) { + break; + } + } + tell(change); + if (change != null && DeliveryState.NO_COURIERS == change.state) { + // The order will not come, so the money held for it is let go. + payments.release(id); + } + } + + /// Gives up on a delivery that has waited too long, or names the courier to + /// offer it to in `chosen`. Returns what it changed, or null for nothing. + @Transactional + private Change choose(String id, String[] chosen) throws IOException { + chosen[0] = null; + Delivery delivery = find(id); + if (delivery == null || DeliveryState.REQUESTED != delivery.getState()) { + return null; + } + long now = System.currentTimeMillis(); + if (now > delivery.getSearchUntil()) { + if (!tryMove(id, DeliveryState.NO_COURIERS, "", null, null, null)) { + return null; + } + if (orders.end(id, OrderStateMachine.sources(OrderState.NO_COURIERS), + OrderState.NO_COURIERS, "No courier was free to bring it", now) == 1) { + orderEvents.record(id, OrderState.NO_COURIERS, ""); + } + return new Change(id, DeliveryState.NO_COURIERS, delivery.getCustomer(), "", "", null); + } + chosen[0] = matcher.pick(delivery, now - delivery.getRequestedAt()); + return null; + } + + // tag::savor-delivery-offer[] + /// Offers a delivery to the courier chosen for it, unless they were given + /// another in the meantime. Returns what it changed, or null for nothing, + /// in which case [#tick] looks again. + /// + /// The courier is held before anything is read, so two deliveries that chose + /// the same courier come through here one after the other, and the second + /// reads the offer the first one committed. The update of the delivery cannot + /// do this on its own: it is conditional on its own delivery's row, and each + /// of the two has a row of its own. + /// + /// Everything that made the courier the choice is asked again once they + /// are held: a courier who went off line, drove out of reach, lost their + /// approval or was blocked since [#choose] read them would otherwise get + /// the offer and keep the customer waiting for it to lapse. Not private, so + /// that a test can make the offer to a courier who changed in between. + @Transactional + Change offer(String id, String courier) throws IOException { + if (couriers.hold(courier) == 0 + || deliveries.findFirstByCourierAndStateIn(courier, WITH_COURIER) != null) { + return null; + } + Delivery delivery = find(id); + if (delivery == null || DeliveryState.REQUESTED != delivery.getState()) { + return null; + } + long now = System.currentTimeMillis(); + if (!matcher.fits(delivery, courier, now - delivery.getRequestedAt())) { + return null; + } + return tryMove(id, DeliveryState.OFFERED, "", null, courier, Long.valueOf(now + offerMillis)) + ? new Change(id, DeliveryState.OFFERED, delivery.getCustomer(), courier, "", null) : null; + } + // end::savor-delivery-offer[] + + /// Takes back the offers nobody answered and looks again for every delivery + /// still waiting. Runs every two seconds, on one instance at a time: `lock` + /// keeps a second server from offering the same delivery to two couriers. + /// + /// Not a transaction itself. Each delivery is dealt with in one of its own, so + /// a delivery that cannot be dealt with costs nobody else their turn; what went + /// wrong with the first such delivery is reported once the rest are done. + @Scheduled(fixedDelay = 2000, initialDelay = 2000, lock = "savor-matching") + public void tick() throws IOException { + Exception failed = null; + List lapsed = lapsedOffers(System.currentTimeMillis()); + for (int iter = 0; iter < lapsed.size(); iter++) { + Delivery delivery = lapsed.get(iter); + try { + tell(attempt(() -> lapse(delivery.getId(), delivery.getCustomer(), delivery.getCourier()))); + } catch (IOException err) { + failed = failed == null ? err : failed; + } catch (RuntimeException err) { + failed = failed == null ? err : failed; + } + } + List waiting = waitingDeliveries(); + for (int iter = 0; iter < waiting.size(); iter++) { + try { + search(waiting.get(iter).getId()); + } catch (IOException err) { + failed = failed == null ? err : failed; + } catch (RuntimeException err) { + failed = failed == null ? err : failed; + } + } + if (failed instanceof IOException) { + throw (IOException) failed; + } + if (failed instanceof RuntimeException) { + throw (RuntimeException) failed; + } + } + + @Transactional(readOnly = true) + private List lapsedOffers(long now) throws IOException { + return deliveries.findByStateAndOfferExpiresAtLessThan(DeliveryState.OFFERED, now); + } + + @Transactional(readOnly = true) + private List waitingDeliveries() throws IOException { + return deliveries.findByStateOrderByRequestedAtAsc(DeliveryState.REQUESTED); + } + + /// Takes a lapsed offer back from the courier it was made to, or changes + /// nothing and answers null. + @Transactional + private Change lapse(String id, String customer, String courier) throws IOException { + // Held to the courier it lapsed for: if they accepted in the instant + // since the query, this changes nothing and the delivery is theirs. + if (!tryMove(id, DeliveryState.REQUESTED, courier, COURIER, "", null)) { + return null; + } + passed(id, courier); + return new Change(id, DeliveryState.REQUESTED, customer, "", courier, null); + } + + // ------------------------------------------------------------- internals + + /// What a transaction did to a delivery, for telling the people concerned once + /// it has committed, and the delivery as the caller is to see it. Not private: + /// the transactional methods answer it, and the class the build writes to + /// begin and commit their transactions has to be able to name it. + public static final class Change { + final String id; + final DeliveryState state; + final String customer; + final String courier; + /// Someone else to tell: the courier an offer was taken back from. + final String also; + final DeliveryDto view; + + Change(String id, DeliveryState state, String customer, String courier, String also, DeliveryDto view) { + this.id = id; + this.state = state; + this.customer = customer; + this.courier = courier; + this.also = also; + this.view = view; + } + } + + /// Tells the people concerned about a change that has been committed, and + /// answers the view that came with it. Nothing changed, nothing said. + private DeliveryDto tell(Change change) { + if (change == null) { + return null; + } + live.deliveryChanged(change.id, change.state, change.customer, change.courier, change.also); + // The same moment, and for the same reason: a phone told of a change + // must find it made. + pushes.deliveryChanged(change.id, change.state, change.courier); + // The steps that move the order too: the customer and the kitchen + // hear of those as the order's. + if (DeliveryState.IN_PROGRESS == change.state || DeliveryState.COMPLETED == change.state + || DeliveryState.NO_COURIERS == change.state) { + news.told(change.id); + } + return change.view; + } + + /// One of the transactions of this class, for [#attempt] to run. + private interface Work { + T run() throws IOException; + } + + /// Runs a transaction that changes a delivery, and runs it again if the database + /// ended it because another transaction changed the same delivery first. + /// + /// Every change here is a delivery read and then moved by a conditional update, + /// and two of them racing are meant to be settled by that update: the loser + /// changes no rows. Most databases do exactly that. One that holds a + /// transaction to the snapshot it first read from -- MariaDB, as it is + /// configured out of the box -- refuses the loser's update instead, because + /// the delivery it read is no longer the delivery that is there, and ends its + /// transaction with a [ConcurrencyFailureException]. + /// + /// That is the race lost and not a fault, so it gets the answer the other + /// databases give. The transaction was rolled back as the exception left + /// it, so nothing of it remains; run again, it reads the delivery as the winner + /// left it and its update changes no rows. This is the one place that + /// happens, and it has to be outside the transaction: the database has + /// already ended that one, and a statement sent after the failure would run + /// on its own or be refused, depending on the database. + /// + /// Three attempts, because each one lost means someone else moved the delivery, + /// and a delivery has only so many moves in it. + private static T attempt(Work work) throws IOException { + for (int tries = 1; ; tries++) { + try { + return work.run(); + } catch (PersistenceException err) { + // The session reports what the database said as its own + // unchecked exception, with the database's as the cause. + if (tries == 3 || !ConcurrencyFailureException.isCauseOf(err)) { + throw err; + } + } catch (ConcurrencyFailureException err) { + if (tries == 3) { + throw err; + } + } + } + } + + /// Moves a delivery to `to`, or answers 409 when its state does not allow it. + private void move(String id, DeliveryState to, String actor, String ownerField, String newCourier, + Long newOfferExpiresAt) throws IOException { + if (!tryMove(id, to, actor, ownerField, newCourier, newOfferExpiresAt)) { + Delivery now = find(id); + throw new ResponseStatusException(409, "That delivery is " + + (now == null ? "gone" : describe(now.getState()))); + } + } + + /// The conditional update behind every change of state. True when this call + /// is the one that made the change. + /// + /// The session is empty afterwards, every move in `DeliveryRepository` being + /// marked `clearAutomatically`: a delivery read before this is no longer the + /// session's, and is read again by whoever needs it as it is now. + /// + /// @param ownerField [#CUSTOMER] or [#COURIER] to hold the change to deliveries + /// whose customer or courier is `actor`, or null for a change the server + /// makes itself + /// @param newCourier the courier to write with the change, or null to leave + /// it as it is + /// @param newOfferExpiresAt when the offer this change makes lapses, or null + // tag::savor-delivery-trymove[] + private boolean tryMove(String id, DeliveryState to, String actor, String ownerField, + String newCourier, Long newOfferExpiresAt) throws IOException { + DeliveryState[] sources = DeliveryStateMachine.sources(to); + if (sources.length == 0) { + return false; + } + long now = System.currentTimeMillis(); + int moved; + if (newOfferExpiresAt != null) { + moved = deliveries.offer(id, sources, to, now, newCourier, newOfferExpiresAt.longValue()); + } else if (CUSTOMER.equals(ownerField)) { + moved = deliveries.moveAsCustomer(id, sources, to, now, actor); + } else if (!COURIER.equals(ownerField)) { + moved = deliveries.moveByServer(id, sources, to, now); + } else if (newCourier != null) { + moved = deliveries.giveBack(id, sources, to, now, actor, newCourier); + } else if (DeliveryState.ACCEPTED == to) { + // An offer that has lapsed cannot be taken, even in the moment + // before the sweep takes it back. + moved = deliveries.takeOffer(id, sources, to, now, actor); + } else { + moved = deliveries.moveAsCourier(id, sources, to, now, actor); + } + if (moved != 1) { + return false; + } + event(id, to, actor); + return true; + } + // end::savor-delivery-trymove[] + + private void event(String id, DeliveryState state, String actor) throws IOException { + DeliveryEvent event = new DeliveryEvent(); + event.setId(Ids.next()); + event.setDeliveryId(id); + event.setState(state); + event.setActor(actor); + event.setCreatedAt(System.currentTimeMillis()); + events.insert(event); + } + + private void passed(String id, String courier) { + if (!passes.existsById(Identifier.of(id, courier))) { + DeliveryPassed passed = new DeliveryPassed(); + passed.setDeliveryId(id); + passed.setCourier(courier); + passes.insert(passed); + } + } + + /// The delivery, when `actor` is its customer or its courier, whichever `field` + /// names; 404 otherwise. + private Delivery owned(String id, String field, String actor) { + Delivery delivery = find(id); + if (delivery == null || !actor.equals(CUSTOMER.equals(field) ? delivery.getCustomer() + : delivery.getCourier())) { + throw new ResponseStatusException(404, "No such delivery"); + } + return delivery; + } + + /// The delivery of this id, or null when there is none. An id that cannot be + /// one is not looked for. + private Delivery find(String id) { + if (id == null || id.length() == 0 || id.length() > 32) { + return null; + } + return deliveries.findById(id); + } + + private DeliveryDto view(String id, String viewer) throws IOException { + return toDto(find(id), viewer, new HashMap()); + } + + private List list(List rows, String viewer) throws IOException { + Map cache = new HashMap(); + List out = new ArrayList(); + for (int iter = 0; iter < rows.size(); iter++) { + out.add(toDto(rows.get(iter), viewer, cache)); + } + return out; + } + + static DeliveryDto none() { + return DeliveryDto.none(); + } + + private DeliveryDto toDto(Delivery delivery, String viewer, Map cache) + throws IOException { + return toDto(delivery, viewer, cache, true); + } + + /// @param party whether `viewer` is asking as the delivery's customer or courier. + /// An admin is shown a delivery as its courier sees it, and is not a party: + /// the two phone numbers are for the two people who have to find each + /// other, and only while they do. + private DeliveryDto toDto(Delivery delivery, String viewer, Map cache, boolean party) + throws IOException { + DeliveryState state = delivery.getState(); + // An account's name is the e-mail it signs in with. The other party + // to a delivery is shown a display name, and a phone number while the two + // are meeting; the address is for its owner and for an admin. + boolean asCustomer = !party || (viewer != null && viewer.equals(delivery.getCustomer())); + boolean asCourier = !party || (viewer != null && viewer.equals(delivery.getCourier())); + Profile customer = profile(delivery.getCustomer(), cache); + // What is known of the courier, filled in below for a delivery that has one + // this viewer may see. + String courierUsername = null; + String courierName = null; + String courierPhotoInitials = ""; + String vehicle = null; + String plate = null; + String courierPhone = null; + String customerPhone = null; + double courierRating = 0d; + double courierLat = 0d; + double courierLng = 0d; + double courierHeading = 0d; + int etaMinutes = 0; + int offerExpiresInSeconds = 0; + String courier = delivery.getCourier(); + boolean offered = DeliveryState.OFFERED == state; + // An offer is between the server and one courier. Until it is taken the + // customer has no courier, and is not shown who was asked. + if (courier.length() > 0 && (!offered || courier.equals(viewer))) { + if (asCourier) { + courierUsername = courier; + } + Profile who = profile(courier, cache); + if (who != null) { + courierName = who.getDisplayName(); + courierPhotoInitials = Text.initials(courierName); + vehicle = who.getVehicle(); + plate = who.getPlate(); + } + boolean meeting = DeliveryState.ACCEPTED == state + || DeliveryState.ARRIVED == state + || DeliveryState.IN_PROGRESS == state; + if (party && meeting && viewer != null) { + if (viewer.equals(delivery.getCustomer()) && who != null) { + courierPhone = who.getPhone(); + } else if (viewer.equals(courier) && customer != null) { + customerPhone = customer.getPhone(); + } + } + CourierState car = couriers.findById(courier); + if (car != null) { + courierRating = car.rating(); + } + // Where the car is belongs to the delivery while it brings the two + // together and carries one of them. A delivery that has ended stays in + // its customer's history for good, and the courier goes on reporting + // where they are for other people's deliveries: read from here, a past + // customer could follow them indefinitely. + if (car != null && meeting) { + courierLat = car.getLat(); + courierLng = car.getLng(); + courierHeading = car.getHeading(); + // How long until the car is where it is going next: the pickup + // until the customer is in it, the destination after. + if (DeliveryState.ACCEPTED == state || DeliveryState.ARRIVED == state) { + etaMinutes = etaMinutes(courierLat, courierLng, delivery.getPickupLat(), + delivery.getPickupLng()); + } else if (DeliveryState.IN_PROGRESS == state) { + etaMinutes = etaMinutes(courierLat, courierLng, delivery.getDropoffLat(), + delivery.getDropoffLng()); + } + } + } + if (offered) { + offerExpiresInSeconds = (int) Math.max(0L, + (delivery.getOfferExpiresAt() - System.currentTimeMillis()) / 1000L); + } + long fareCents = delivery.getFareCents(); + long tip = delivery.getTipCents(); + // What the customer pays is the order's whole total (food, fees, tax, + // discount and the checkout tip) plus a tip given after arrival. The + // courier collects exactly this for a cash order, so the fare alone is + // far too little. A delivery shares its order's id. + CustomerOrder order = orders.findById(delivery.getId()); + long total = (order == null ? fareCents : order.getTotalCents()) + tip; + return new DeliveryDto( + delivery.getId(), + state, + asCustomer ? delivery.getCustomer() : null, + customer == null ? "" : customer.getDisplayName(), + courierUsername, + courierName, + vehicle, + plate, + courierRating, + delivery.getPickupLat(), + delivery.getPickupLng(), + delivery.getPickupAddress(), + delivery.getDropoffLat(), + delivery.getDropoffLng(), + delivery.getDropoffAddress(), + courierLat, + courierLng, + courierHeading, + fareCents, + delivery.getCurrency(), + delivery.getDistanceMeters(), + delivery.getDurationSeconds(), + delivery.getRequestedAt(), + delivery.getUpdatedAt(), + delivery.getStars(), + delivery.getPaymentStatus(), + offerExpiresInSeconds, + delivery.getMethodLabel(), + Payments.CASH.equals(delivery.getMethodId()), + 0L, + tip, + total, + courierPhotoInitials, + etaMinutes, + delivery.getNote(), + courierPhone, + customerPhone); + } + + /// Minutes for a courier to travel from one point to another, by the same + /// estimate the fares are made with. Never less than one. + public static int etaMinutes(double fromLat, double fromLng, double toLat, double toLng) { + double seconds = Geo.meters(fromLat, fromLng, toLat, toLng) * Quotes.ROAD_FACTOR + / Quotes.METERS_PER_SECOND; + return Math.max(1, (int) Math.ceil(seconds / 60d)); + } + + private Profile profile(String username, Map cache) { + if (cache.containsKey(username)) { + return cache.get(username); + } + Profile row = profiles.findById(username); + cache.put(username, row); + return row; + } + + private static String address(String value) { + String text = value == null ? "" : value.trim(); + return text.length() > 255 ? text.substring(0, 255) : text; + } + + private static String describe(DeliveryState state) { + if (DeliveryState.COMPLETED == state) { + return "already completed"; + } + if (DeliveryState.IN_PROGRESS == state) { + return "already under way"; + } + if (DeliveryState.ACCEPTED == state || DeliveryState.ARRIVED == state) { + return "already taken"; + } + if (DeliveryState.REQUESTED == state || DeliveryState.OFFERED == state) { + return "no longer on offer to you"; + } + return "no longer available"; + } +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/delivery/DeliveryStateMachine.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/delivery/DeliveryStateMachine.java new file mode 100644 index 00000000000..9724e3456ec --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/delivery/DeliveryStateMachine.java @@ -0,0 +1,99 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.delivery; + +import com.codenameone.examples.savor.api.DeliveryState; + +/// Which state a delivery may move to from which. +/// +/// ``` +/// REQUESTED -> OFFERED -> ACCEPTED -> ARRIVED -> IN_PROGRESS -> COMPLETED +/// ^ | +/// +-----------+ a courier passes, or lets the offer lapse +/// ``` +/// +/// The customer can cancel until the trip starts, the courier once they have taken +/// it and until it starts, and a search that finds nobody ends in NO_COURIERS. +/// An admin can cancel a delivery at any point before it ends. +/// Nothing leaves a finished state. +/// +/// This table is the only place the rules are written. A move is made by one +/// `UPDATE ... WHERE state IN (the states it may come from)`, so the database +/// applies the rule and settles a race in the same statement: of two couriers +/// accepting at once, or a customer cancelling as a courier accepts, one statement +/// changes a row and the other changes none. +public final class DeliveryStateMachine { + private static final DeliveryState[] NONE = {}; + private static final DeliveryState[] FROM_REQUESTED = {DeliveryState.REQUESTED}; + private static final DeliveryState[] FROM_OFFERED = {DeliveryState.OFFERED}; + private static final DeliveryState[] FROM_ACCEPTED = {DeliveryState.ACCEPTED}; + private static final DeliveryState[] FROM_ARRIVED = {DeliveryState.ARRIVED}; + private static final DeliveryState[] FROM_IN_PROGRESS = {DeliveryState.IN_PROGRESS}; + private static final DeliveryState[] BEFORE_THE_TRIP = {DeliveryState.REQUESTED, DeliveryState.OFFERED, + DeliveryState.ACCEPTED, DeliveryState.ARRIVED}; + private static final DeliveryState[] UNDER_WAY = {DeliveryState.REQUESTED, DeliveryState.OFFERED, + DeliveryState.ACCEPTED, DeliveryState.ARRIVED, DeliveryState.IN_PROGRESS}; + private static final DeliveryState[] TAKEN_NOT_STARTED = {DeliveryState.ACCEPTED, DeliveryState.ARRIVED}; + + private DeliveryStateMachine() { + } + + /// The states a delivery may be in to move to `to`. Empty for a state nothing + /// moves to. + public static DeliveryState[] sources(DeliveryState to) { + if (DeliveryState.OFFERED == to) { + return FROM_REQUESTED; + } + if (DeliveryState.REQUESTED == to || DeliveryState.ACCEPTED == to) { + return FROM_OFFERED; + } + if (DeliveryState.ARRIVED == to) { + return FROM_ACCEPTED; + } + if (DeliveryState.IN_PROGRESS == to) { + return FROM_ARRIVED; + } + if (DeliveryState.COMPLETED == to) { + return FROM_IN_PROGRESS; + } + if (DeliveryState.CANCELLED_BY_CUSTOMER == to) { + return BEFORE_THE_TRIP; + } + if (DeliveryState.CANCELLED_BY_COURIER == to) { + return TAKEN_NOT_STARTED; + } + if (DeliveryState.CANCELLED_BY_ADMIN == to) { + return UNDER_WAY; + } + if (DeliveryState.NO_COURIERS == to) { + return FROM_REQUESTED; + } + return NONE; + } + + public static boolean allowed(DeliveryState from, DeliveryState to) { + DeliveryState[] sources = sources(to); + for (int iter = 0; iter < sources.length; iter++) { + if (sources[iter] == from) { + return true; + } + } + return false; + } +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/delivery/DemoFleet.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/delivery/DemoFleet.java new file mode 100644 index 00000000000..2952996bbe9 --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/delivery/DemoFleet.java @@ -0,0 +1,133 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.delivery; + +import com.codename1.backend.Config; +import com.codename1.backend.ResponseStatusException; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Scheduled; +import com.codenameone.examples.savor.account.DemoAccounts; +import com.codenameone.examples.savor.api.CourierStatusDto; +import com.codenameone.examples.savor.api.DeliveryDto; +import com.codenameone.examples.savor.api.DeliveryState; +import com.codenameone.examples.savor.courier.Couriers; +import com.codenameone.examples.savor.geo.Geo; +import java.io.IOException; +import java.util.List; + +/// One courier the server rides itself on a development server, so that a +/// customer alone can watch an order come all the way: it takes offers, rides +/// to the restaurant, waits for the food, and rides to the door, reporting +/// where it is as a phone would. +/// +/// It steps aside -- goes off line -- whenever a real courier is on line and +/// free, so a demo with a courier's phone in hand gets the offers. Off on +/// every other profile, the `test` one included. +@Component +public class DemoFleet { + /// Where it waits between orders: the waterfront, among the restaurants. + static final double HOME_LAT = 37.7990; + static final double HOME_LNG = -122.4020; + /// Metres per tick, about 45 km/h at one tick every two seconds. + static final double STEP_METERS = 25d; + static final double THERE_METERS = 30d; + + private final Couriers couriers; + private final Deliveries deliveries; + private final Matcher matcher; + private final boolean on; + private final String courier = DemoAccounts.FOURTH_COURIER; + private double lat = HOME_LAT; + private double lng = HOME_LNG; + + public DemoFleet(Couriers couriers, Deliveries deliveries, Matcher matcher, Config config) { + this.couriers = couriers; + this.deliveries = deliveries; + this.matcher = matcher; + on = config.isDevelopmentProfile() && !"test".equals(config.getProfile()); + } + + @Scheduled(fixedDelay = 2000, initialDelay = 10000, lock = "savor-demo-fleet") + public void ride() throws IOException { + if (!on) { + return; + } + DeliveryDto job = deliveries.activeForCourier(courier); + DeliveryState state = job == null ? DeliveryState.NONE : job.state(); + if (DeliveryState.NONE == state && someoneElse()) { + couriers.report(courier, new CourierStatusDto(false, lat, lng, 0d)); + return; + } + double heading = 0d; + try { + if (DeliveryState.OFFERED == state) { + deliveries.accept(courier, job.id()); + } else if (DeliveryState.ACCEPTED == state) { + heading = toward(job.pickupLat(), job.pickupLng()); + if (near(job.pickupLat(), job.pickupLng())) { + deliveries.arrived(courier, job.id()); + } + } else if (DeliveryState.ARRIVED == state) { + // Refused until the kitchen says the food is ready. + deliveries.start(courier, job.id()); + } else if (DeliveryState.IN_PROGRESS == state) { + heading = toward(job.dropoffLat(), job.dropoffLng()); + if (near(job.dropoffLat(), job.dropoffLng())) { + deliveries.complete(courier, job.id()); + } + } + } catch (ResponseStatusException notYet) { + // The kitchen is not done, or the order was cancelled; next tick. + } + couriers.report(courier, new CourierStatusDto(true, lat, lng, heading)); + } + + /// Whether a real courier is on line and free near the restaurants. + private boolean someoneElse() throws IOException { + List free = matcher.nearby(HOME_LAT, HOME_LNG, 50000d); + for (int iter = 0; iter < free.size(); iter++) { + if (!courier.equals(free.get(iter).username)) { + return true; + } + } + return false; + } + + private boolean near(double toLat, double toLng) { + return Geo.meters(lat, lng, toLat, toLng) <= THERE_METERS; + } + + /// One step toward a point, and the heading of it in degrees. + private double toward(double toLat, double toLng) { + double meters = Geo.meters(lat, lng, toLat, toLng); + double dLat = toLat - lat; + double dLng = toLng - lng; + double heading = (Math.toDegrees(Math.atan2(dLng * Math.cos(Math.toRadians(lat)), dLat)) + + 360d) % 360d; + if (meters <= STEP_METERS) { + lat = toLat; + lng = toLng; + } else { + double share = STEP_METERS / meters; + lat += dLat * share; + lng += dLng * share; + } + return heading; + } +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/delivery/Matcher.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/delivery/Matcher.java new file mode 100644 index 00000000000..3d632f9b9a7 --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/delivery/Matcher.java @@ -0,0 +1,249 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.delivery; + +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Transactional; +import com.codename1.backend.security.GrantedAuthority; +import com.codename1.backend.security.core.userdetails.JdbcUserDetailsManager; +import com.codename1.backend.security.core.userdetails.UserDetails; +import com.codename1.backend.security.core.userdetails.UsernameNotFoundException; +import com.codenameone.examples.savor.Roles; +import com.codenameone.examples.savor.api.DeliveryState; +import com.codenameone.examples.savor.domain.CourierApplication; +import com.codenameone.examples.savor.domain.CourierState; +import com.codenameone.examples.savor.domain.Delivery; +import com.codenameone.examples.savor.domain.DeliveryPassed; +import com.codenameone.examples.savor.onboarding.Applications; +import com.codenameone.examples.savor.geo.Geo; +import com.codenameone.examples.savor.repository.CourierApplicationRepository; +import com.codenameone.examples.savor.repository.CourierStateRepository; +import com.codenameone.examples.savor.repository.DeliveryPassedRepository; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/// Finds the couriers near a point. +/// +/// No spatial index and no spatial database: the query asks for the couriers on +/// line inside a box of latitude and longitude around the point, which an +/// ordinary index answers, and the handful it returns are measured and sorted +/// here. That runs unchanged on SQLite, PostgreSQL and MySQL, and it is enough +/// until one city has more couriers on line than fit in a query result -- at +/// which point the box becomes a geohash column and nothing else changes. +/// +/// The box does not wrap around the 180th meridian. A service operating across +/// it would need a second box. +@Component +public class Matcher { + /// A courier not heard from for this long is not there, whatever their + /// status says: the app reports its position every few seconds while on + /// line, and a phone that lost its signal cannot go off line. + public static final long FRESH_MILLIS = 60000L; + /// How far to look at first, and how far once the first look found nobody. + static final double NEAR_METERS = 3000d; + static final double FAR_METERS = 8000d; + /// How long a delivery waits before the search widens. + static final long WIDEN_AFTER_MILLIS = 20000L; + + /// The states of a delivery that keep its courier from being offered another. + private static final DeliveryState[] WITH_DELIVERY = {DeliveryState.OFFERED, DeliveryState.ACCEPTED, + DeliveryState.ARRIVED, DeliveryState.IN_PROGRESS}; + + private final CourierStateRepository couriers; + private final CourierApplicationRepository applications; + private final DeliveryPassedRepository passes; + private final JdbcUserDetailsManager users; + + public Matcher(CourierStateRepository couriers, CourierApplicationRepository applications, + DeliveryPassedRepository passes, + JdbcUserDetailsManager users) { + this.couriers = couriers; + this.applications = applications; + this.passes = passes; + this.users = users; + } + + /// A courier who could be offered a delivery: where the car is, what kind it + /// is, and how far it is from the point that was asked about. + public static final class Candidate { + public final String username; + public final double lat; + public final double lng; + public final double heading; + public final double meters; + /// The courier's state as it was read, for whoever wants more of it. + public final CourierState state; + + Candidate(CourierState state, double meters) { + this.state = state; + this.username = state.getUsername(); + this.lat = state.getLat(); + this.lng = state.getLng(); + this.heading = state.getHeading(); + this.meters = meters; + } + } + + /// The radius to search for a delivery that has been waiting `waitedMillis`. + static double radius(long waitedMillis) { + return waitedMillis < WIDEN_AFTER_MILLIS ? NEAR_METERS : FAR_METERS; + } + + /// The couriers on line and free within `radiusMeters` of a point, nearest + /// first, of any kind. + @Transactional(readOnly = true) + public List nearby(double lat, double lng, double radiusMeters) + throws IOException { + return free(lat, lng, radiusMeters); + } + + /// The couriers a delivery could be offered to: on line, free, and in reach + /// of the restaurant. + /// + /// A courier is offered deliveries only while their application stands approved + /// and their account is not blocked. Both are read here, each time, and + /// not trusted to have been put right elsewhere: an approval withdrawn or + /// an account blocked takes a courier out of matching at once, whatever + /// their state still says. The approval is part of the query. Whether the + /// account is blocked is the sign-in's to say, and its tables are its own, + /// so each courier in reach is asked about there. + @Transactional(readOnly = true) + private List free(double lat, double lng, double radiusMeters) throws IOException { + double dLat = radiusMeters / Geo.METERS_PER_DEGREE; + double shrink = Math.max(0.01d, Math.cos(Math.toRadians(lat))); + double dLng = radiusMeters / (Geo.METERS_PER_DEGREE * shrink); + List boxed = couriers.freeInBox(System.currentTimeMillis() - FRESH_MILLIS, + lat - dLat, lat + dLat, lng - dLng, lng + dLng, Applications.APPROVED, WITH_DELIVERY); + List inReach = new ArrayList(); + List names = new ArrayList(); + for (int iter = 0; iter < boxed.size(); iter++) { + CourierState state = boxed.get(iter); + if (Geo.meters(lat, lng, state.getLat(), state.getLng()) > radiusMeters) { + // Inside the box but outside the circle: a corner of it. + continue; + } + inReach.add(state); + names.add(state.getUsername()); + } + if (inReach.isEmpty()) { + return new ArrayList(); + } + Map cars = new HashMap(); + List rows = applications.findByUsernameIn(names); + for (int iter = 0; iter < rows.size(); iter++) { + cars.put(rows.get(iter).getUsername(), rows.get(iter)); + } + List near = new ArrayList(); + for (int iter = 0; iter < inReach.size(); iter++) { + CourierState state = inReach.get(iter); + CourierApplication car = cars.get(state.getUsername()); + if (car == null || !Applications.APPROVED.equals(car.getStatus())) { + // The application went between the two queries, or its + // approval was withdrawn: the row stays, rejected. + continue; + } + if (!enabled(state.getUsername())) { + continue; + } + double meters = Geo.meters(lat, lng, state.getLat(), state.getLng()); + int at = 0; + while (at < near.size() && near.get(at).meters <= meters) { + at++; + } + near.add(at, new Candidate(state, meters)); + } + return near; + } + + /// Whether `courier` is still one a delivery could be offered to: on line and + /// heard from lately, free, in reach, approved, not blocked, and fit for + /// still approved. [#pick] read all of that in a transaction of its + /// own, and any of it can have changed before the offer is made; the + /// offer asks again once it holds the courier. + @Transactional(readOnly = true) + public boolean fits(Delivery delivery, String courier, long waitedMillis) throws IOException { + List near = free(delivery.getPickupLat(), delivery.getPickupLng(), + radius(waitedMillis)); + for (int iter = 0; iter < near.size(); iter++) { + if (courier.equals(near.get(iter).username)) { + return true; + } + } + return false; + } + + /// Whether the account of this name exists and has not been blocked. + /// + /// Read once: an account closed between asking whether it exists and + /// loading it would otherwise throw, and turn a customer's request into a + /// server error over a courier who is simply no longer there. + boolean enabled(String username) { + try { + return users.loadUserByUsername(username).isEnabled(); + } catch (UsernameNotFoundException gone) { + return false; + } + } + + /// Whether the account exists, is not blocked and still has the courier's + /// role, read once as [#enabled] is. + boolean mayDeliver(String username) { + try { + UserDetails user = users.loadUserByUsername(username); + if (!user.isEnabled()) { + return false; + } + for (GrantedAuthority authority : user.getAuthorities()) { + if (("ROLE_" + Roles.COURIER).equals(authority.getAuthority())) { + return true; + } + } + return false; + } catch (UsernameNotFoundException gone) { + return false; + } + } + + /// The nearest free courier fit for a delivery who has not already passed on it, + /// or null when there is none in reach. + @Transactional(readOnly = true) + public String pick(Delivery delivery, long waitedMillis) throws IOException { + List near = free(delivery.getPickupLat(), delivery.getPickupLng(), + radius(waitedMillis)); + if (near.isEmpty()) { + return null; + } + List rows = passes.findByDeliveryId(delivery.getId()); + List passed = new ArrayList(); + for (int iter = 0; iter < rows.size(); iter++) { + passed.add(rows.get(iter).getCourier()); + } + for (int iter = 0; iter < near.size(); iter++) { + String courier = near.get(iter).username; + if (!passed.contains(courier)) { + return courier; + } + } + return null; + } +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/domain/ClosedAccount.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/domain/ClosedAccount.java new file mode 100644 index 00000000000..9b9a38567d3 --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/domain/ClosedAccount.java @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; + +/// What is left of an account that was closed: the hash of its e-mail, kept so +/// that the address cannot be registered again. +@Entity +public class ClosedAccount { + @Id(autoIncrement = false) + @Column(nullable = false) + private String usernameHash = ""; + + @Column(nullable = false) + private long closedAt; + + public ClosedAccount() { + } + + public String getUsernameHash() { + return usernameHash; + } + + public void setUsernameHash(String usernameHash) { + this.usernameHash = usernameHash; + } + + public long getClosedAt() { + return closedAt; + } + + public void setClosedAt(long closedAt) { + this.closedAt = closedAt; + } +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/domain/CourierApplication.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/domain/CourierApplication.java new file mode 100644 index 00000000000..cfb2f2c7742 --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/domain/CourierApplication.java @@ -0,0 +1,207 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; + +/// An account's application to deliver, and what became of it. +@Entity +public class CourierApplication { + @Id(autoIncrement = false) + @Column(nullable = false) + private String username = ""; + + @Column(nullable = false) + private String status = ""; + + @Column(nullable = false) + private String legalName = ""; + + @Column(nullable = false) + private String dateOfBirth = ""; + + @Column(nullable = false) + private String licenceNumber = ""; + + @Column(nullable = false) + private String licenceExpiry = ""; + + @Column(nullable = false) + private String vehicleMake = ""; + + @Column(nullable = false) + private String vehicleModel = ""; + + @Column(nullable = false) + private int vehicleYear; + + @Column(nullable = false) + private String vehicleColor = ""; + + @Column(nullable = false) + private String vehiclePlate = ""; + + @Column(nullable = false) + private String rejectionReason = ""; + + @Column(nullable = false) + private long createdAt; + + @Column(nullable = false) + private long submittedAt; + + @Column(nullable = false) + private long reviewedAt; + + @Column(nullable = false) + private String reviewedBy = ""; + + public CourierApplication() { + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public String getLegalName() { + return legalName; + } + + public void setLegalName(String legalName) { + this.legalName = legalName; + } + + public String getDateOfBirth() { + return dateOfBirth; + } + + public void setDateOfBirth(String dateOfBirth) { + this.dateOfBirth = dateOfBirth; + } + + public String getLicenceNumber() { + return licenceNumber; + } + + public void setLicenceNumber(String licenceNumber) { + this.licenceNumber = licenceNumber; + } + + public String getLicenceExpiry() { + return licenceExpiry; + } + + public void setLicenceExpiry(String licenceExpiry) { + this.licenceExpiry = licenceExpiry; + } + + public String getVehicleMake() { + return vehicleMake; + } + + public void setVehicleMake(String vehicleMake) { + this.vehicleMake = vehicleMake; + } + + public String getVehicleModel() { + return vehicleModel; + } + + public void setVehicleModel(String vehicleModel) { + this.vehicleModel = vehicleModel; + } + + public int getVehicleYear() { + return vehicleYear; + } + + public void setVehicleYear(int vehicleYear) { + this.vehicleYear = vehicleYear; + } + + public String getVehicleColor() { + return vehicleColor; + } + + public void setVehicleColor(String vehicleColor) { + this.vehicleColor = vehicleColor; + } + + public String getVehiclePlate() { + return vehiclePlate; + } + + public void setVehiclePlate(String vehiclePlate) { + this.vehiclePlate = vehiclePlate; + } + + public String getRejectionReason() { + return rejectionReason; + } + + public void setRejectionReason(String rejectionReason) { + this.rejectionReason = rejectionReason; + } + + public long getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(long createdAt) { + this.createdAt = createdAt; + } + + public long getSubmittedAt() { + return submittedAt; + } + + public void setSubmittedAt(long submittedAt) { + this.submittedAt = submittedAt; + } + + public long getReviewedAt() { + return reviewedAt; + } + + public void setReviewedAt(long reviewedAt) { + this.reviewedAt = reviewedAt; + } + + public String getReviewedBy() { + return reviewedBy; + } + + public void setReviewedBy(String reviewedBy) { + this.reviewedBy = reviewedBy; + } +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/domain/CourierDocument.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/domain/CourierDocument.java new file mode 100644 index 00000000000..2c705779c44 --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/domain/CourierDocument.java @@ -0,0 +1,99 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; + +/// A document an applicant uploaded, one of each kind. `data` is the file in +/// Base64, which is text on every database. +@Entity +public class CourierDocument { + @Id(autoIncrement = false) + @Column(nullable = false) + private String username = ""; + + @Id(autoIncrement = false) + @Column(nullable = false) + private String kind = ""; + + @Column(nullable = false) + private String contentType = ""; + + @Column(nullable = false) + private String status = ""; + + @Column(nullable = false) + private long uploadedAt; + + @Column(nullable = false) + private String data = ""; + + public CourierDocument() { + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getKind() { + return kind; + } + + public void setKind(String kind) { + this.kind = kind; + } + + public String getContentType() { + return contentType; + } + + public void setContentType(String contentType) { + this.contentType = contentType; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public long getUploadedAt() { + return uploadedAt; + } + + public void setUploadedAt(long uploadedAt) { + this.uploadedAt = uploadedAt; + } + + public String getData() { + return data; + } + + public void setData(String data) { + this.data = data; + } +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/domain/CourierHours.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/domain/CourierHours.java new file mode 100644 index 00000000000..e97a9f0bc4a --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/domain/CourierHours.java @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; + +/// How long a courier was on line on one day, counted in UTC days. +@Entity +public class CourierHours { + @Id(autoIncrement = false) + @Column(nullable = false) + private String username = ""; + + @Id(autoIncrement = false) + @Column(nullable = false) + private long dayNumber; + + @Column(nullable = false) + private long millis; + + public CourierHours() { + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public long getDayNumber() { + return dayNumber; + } + + public void setDayNumber(long dayNumber) { + this.dayNumber = dayNumber; + } + + public long getMillis() { + return millis; + } + + public void setMillis(long millis) { + this.millis = millis; + } +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/domain/CourierState.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/domain/CourierState.java new file mode 100644 index 00000000000..f15a020b0f3 --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/domain/CourierState.java @@ -0,0 +1,124 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; + +/// Where a courier's car is, whether they are on line, and what customers made of them. +@Entity +public class CourierState { + @Id(autoIncrement = false) + @Column(nullable = false) + private String username = ""; + + @Column(nullable = false) + private boolean online; + + @Column(nullable = false) + private double lat; + + @Column(nullable = false) + private double lng; + + @Column(nullable = false) + private double heading; + + @Column(nullable = false) + private long lastSeen; + + @Column(nullable = false) + private long ratingSum; + + @Column(nullable = false) + private long ratingCount; + + public CourierState() { + } + + /// The mean of the stars given, or 5 before any were. + public double rating() { + return ratingCount == 0 ? 5d : Math.round(ratingSum * 10d / ratingCount) / 10d; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public boolean isOnline() { + return online; + } + + public void setOnline(boolean online) { + this.online = online; + } + + public double getLat() { + return lat; + } + + public void setLat(double lat) { + this.lat = lat; + } + + public double getLng() { + return lng; + } + + public void setLng(double lng) { + this.lng = lng; + } + + public double getHeading() { + return heading; + } + + public void setHeading(double heading) { + this.heading = heading; + } + + public long getLastSeen() { + return lastSeen; + } + + public void setLastSeen(long lastSeen) { + this.lastSeen = lastSeen; + } + + public long getRatingSum() { + return ratingSum; + } + + public void setRatingSum(long ratingSum) { + this.ratingSum = ratingSum; + } + + public long getRatingCount() { + return ratingCount; + } + + public void setRatingCount(long ratingCount) { + this.ratingCount = ratingCount; + } +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/domain/CustomerOrder.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/domain/CustomerOrder.java new file mode 100644 index 00000000000..9d90ecbd936 --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/domain/CustomerOrder.java @@ -0,0 +1,388 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.domain; + +import com.codenameone.examples.savor.api.OrderState; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; + +/// An order: what a customer bought from one restaurant, what it cost, and where +/// it stands. Its lines, prices and address are copied in when it is placed, so a +/// later change of menu or of prices does not rewrite it. Its delivery, once the +/// restaurant has accepted it, has the same id. +@Entity +public class CustomerOrder { + @Id(autoIncrement = false) + @Column(nullable = false) + private String id = ""; + + @Column(nullable = false) + private String customer = ""; + + @Column(nullable = false) + private String restaurantId = ""; + + @Column(nullable = false) + private String restaurantName = ""; + + @Column(nullable = false) + private OrderState state; + + @Column(nullable = false) + private long subtotalCents; + + @Column(nullable = false) + private long deliveryFeeCents; + + @Column(nullable = false) + private long serviceFeeCents; + + @Column(nullable = false) + private long smallOrderCents; + + @Column(nullable = false) + private long taxCents; + + @Column(nullable = false) + private long discountCents; + + @Column(nullable = false) + private long tipCents; + + @Column(nullable = false) + private long totalCents; + + @Column(nullable = false) + private long commissionCents; + + @Column(nullable = false) + private String currency = ""; + + @Column(nullable = false) + private String promoCode = ""; + + @Column(nullable = false) + private double dropoffLat; + + @Column(nullable = false) + private double dropoffLng; + + @Column(nullable = false) + private String dropoffAddress = ""; + + @Column(nullable = false) + private String note = ""; + + @Column(nullable = false) + private String methodId = ""; + + @Column(nullable = false) + private String methodLabel = ""; + + @Column(nullable = false) + private String authorizationRef = ""; + + @Column(nullable = false) + private String paymentStatus = ""; + + @Column(nullable = false) + private int prepMinutes; + + @Column(nullable = false) + private long placedAt; + + @Column(nullable = false) + private long acceptedAt; + + @Column(nullable = false) + private long readyAt; + + @Column(nullable = false) + private long deliveredAt; + + @Column(nullable = false) + private long updatedAt; + + @Column(nullable = false) + private String reason = ""; + + @Column(nullable = false) + private int stars; + + public CustomerOrder() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getCustomer() { + return customer; + } + + public void setCustomer(String customer) { + this.customer = customer; + } + + public String getRestaurantId() { + return restaurantId; + } + + public void setRestaurantId(String restaurantId) { + this.restaurantId = restaurantId; + } + + public String getRestaurantName() { + return restaurantName; + } + + public void setRestaurantName(String restaurantName) { + this.restaurantName = restaurantName; + } + + public OrderState getState() { + return state; + } + + public void setState(OrderState state) { + this.state = state; + } + + public long getSubtotalCents() { + return subtotalCents; + } + + public void setSubtotalCents(long subtotalCents) { + this.subtotalCents = subtotalCents; + } + + public long getDeliveryFeeCents() { + return deliveryFeeCents; + } + + public void setDeliveryFeeCents(long deliveryFeeCents) { + this.deliveryFeeCents = deliveryFeeCents; + } + + public long getServiceFeeCents() { + return serviceFeeCents; + } + + public void setServiceFeeCents(long serviceFeeCents) { + this.serviceFeeCents = serviceFeeCents; + } + + public long getSmallOrderCents() { + return smallOrderCents; + } + + public void setSmallOrderCents(long smallOrderCents) { + this.smallOrderCents = smallOrderCents; + } + + public long getTaxCents() { + return taxCents; + } + + public void setTaxCents(long taxCents) { + this.taxCents = taxCents; + } + + public long getDiscountCents() { + return discountCents; + } + + public void setDiscountCents(long discountCents) { + this.discountCents = discountCents; + } + + public long getTipCents() { + return tipCents; + } + + public void setTipCents(long tipCents) { + this.tipCents = tipCents; + } + + public long getTotalCents() { + return totalCents; + } + + public void setTotalCents(long totalCents) { + this.totalCents = totalCents; + } + + public long getCommissionCents() { + return commissionCents; + } + + public void setCommissionCents(long commissionCents) { + this.commissionCents = commissionCents; + } + + public String getCurrency() { + return currency; + } + + public void setCurrency(String currency) { + this.currency = currency; + } + + public String getPromoCode() { + return promoCode; + } + + public void setPromoCode(String promoCode) { + this.promoCode = promoCode; + } + + public double getDropoffLat() { + return dropoffLat; + } + + public void setDropoffLat(double dropoffLat) { + this.dropoffLat = dropoffLat; + } + + public double getDropoffLng() { + return dropoffLng; + } + + public void setDropoffLng(double dropoffLng) { + this.dropoffLng = dropoffLng; + } + + public String getDropoffAddress() { + return dropoffAddress; + } + + public void setDropoffAddress(String dropoffAddress) { + this.dropoffAddress = dropoffAddress; + } + + public String getNote() { + return note; + } + + public void setNote(String note) { + this.note = note; + } + + public String getMethodId() { + return methodId; + } + + public void setMethodId(String methodId) { + this.methodId = methodId; + } + + public String getMethodLabel() { + return methodLabel; + } + + public void setMethodLabel(String methodLabel) { + this.methodLabel = methodLabel; + } + + public String getAuthorizationRef() { + return authorizationRef; + } + + public void setAuthorizationRef(String authorizationRef) { + this.authorizationRef = authorizationRef; + } + + public String getPaymentStatus() { + return paymentStatus; + } + + public void setPaymentStatus(String paymentStatus) { + this.paymentStatus = paymentStatus; + } + + public int getPrepMinutes() { + return prepMinutes; + } + + public void setPrepMinutes(int prepMinutes) { + this.prepMinutes = prepMinutes; + } + + public long getPlacedAt() { + return placedAt; + } + + public void setPlacedAt(long placedAt) { + this.placedAt = placedAt; + } + + public long getAcceptedAt() { + return acceptedAt; + } + + public void setAcceptedAt(long acceptedAt) { + this.acceptedAt = acceptedAt; + } + + public long getReadyAt() { + return readyAt; + } + + public void setReadyAt(long readyAt) { + this.readyAt = readyAt; + } + + public long getDeliveredAt() { + return deliveredAt; + } + + public void setDeliveredAt(long deliveredAt) { + this.deliveredAt = deliveredAt; + } + + public long getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(long updatedAt) { + this.updatedAt = updatedAt; + } + + public String getReason() { + return reason; + } + + public void setReason(String reason) { + this.reason = reason; + } + + public int getStars() { + return stars; + } + + public void setStars(int stars) { + this.stars = stars; + } +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/domain/Delivery.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/domain/Delivery.java new file mode 100644 index 00000000000..d52a3e4d730 --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/domain/Delivery.java @@ -0,0 +1,323 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; +import com.codenameone.examples.savor.api.DeliveryState; + +// tag::savor-entity[] +/// A delivery, from the request to its end. +/// +/// `customer` and `courier` are account names and not references: a delivery outlives the +/// accounts that took it, and `courier` is empty until one is offered it. +@Entity +public class Delivery { + @Id(autoIncrement = false) + @Column(nullable = false) + private String id = ""; + + @Column(nullable = false) + private DeliveryState state = DeliveryState.REQUESTED; + + @Column(nullable = false) + private String customer = ""; + + @Column(nullable = false) + private String courier = ""; + // end::savor-entity[] + + @Column(nullable = false) + private double pickupLat; + + @Column(nullable = false) + private double pickupLng; + + @Column(nullable = false) + private String pickupAddress = ""; + + @Column(nullable = false) + private double dropoffLat; + + @Column(nullable = false) + private double dropoffLng; + + @Column(nullable = false) + private String dropoffAddress = ""; + + @Column(nullable = false) + private long fareCents; + + @Column(nullable = false) + private String currency = ""; + + @Column(nullable = false) + private double distanceMeters; + + @Column(nullable = false) + private double durationSeconds; + + @Column(nullable = false) + private long requestedAt; + + @Column(nullable = false) + private long updatedAt; + + @Column(nullable = false) + private long offerExpiresAt; + + @Column(nullable = false) + private long searchUntil; + + @Column(nullable = false) + private int stars; + + @Column(nullable = false) + private String paymentStatus = ""; + + @Column(nullable = false) + private String methodId = "cash"; + + @Column(nullable = false) + private String methodLabel = "Cash"; + + @Column(nullable = false) + private long feeCents; + + @Column(nullable = false) + private long tipCents; + + @Column(nullable = false) + private long courierCents; + + @Column(nullable = false) + private String note = ""; + + public Delivery() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public DeliveryState getState() { + return state; + } + + public void setState(DeliveryState state) { + this.state = state; + } + + public String getCustomer() { + return customer; + } + + public void setCustomer(String customer) { + this.customer = customer; + } + + public String getCourier() { + return courier; + } + + public void setCourier(String courier) { + this.courier = courier; + } + + public double getPickupLat() { + return pickupLat; + } + + public void setPickupLat(double pickupLat) { + this.pickupLat = pickupLat; + } + + public double getPickupLng() { + return pickupLng; + } + + public void setPickupLng(double pickupLng) { + this.pickupLng = pickupLng; + } + + public String getPickupAddress() { + return pickupAddress; + } + + public void setPickupAddress(String pickupAddress) { + this.pickupAddress = pickupAddress; + } + + public double getDropoffLat() { + return dropoffLat; + } + + public void setDropoffLat(double dropoffLat) { + this.dropoffLat = dropoffLat; + } + + public double getDropoffLng() { + return dropoffLng; + } + + public void setDropoffLng(double dropoffLng) { + this.dropoffLng = dropoffLng; + } + + public String getDropoffAddress() { + return dropoffAddress; + } + + public void setDropoffAddress(String dropoffAddress) { + this.dropoffAddress = dropoffAddress; + } + + public long getFareCents() { + return fareCents; + } + + public void setFareCents(long fareCents) { + this.fareCents = fareCents; + } + + public String getCurrency() { + return currency; + } + + public void setCurrency(String currency) { + this.currency = currency; + } + + public double getDistanceMeters() { + return distanceMeters; + } + + public void setDistanceMeters(double distanceMeters) { + this.distanceMeters = distanceMeters; + } + + public double getDurationSeconds() { + return durationSeconds; + } + + public void setDurationSeconds(double durationSeconds) { + this.durationSeconds = durationSeconds; + } + + public long getRequestedAt() { + return requestedAt; + } + + public void setRequestedAt(long requestedAt) { + this.requestedAt = requestedAt; + } + + public long getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(long updatedAt) { + this.updatedAt = updatedAt; + } + + public long getOfferExpiresAt() { + return offerExpiresAt; + } + + public void setOfferExpiresAt(long offerExpiresAt) { + this.offerExpiresAt = offerExpiresAt; + } + + public long getSearchUntil() { + return searchUntil; + } + + public void setSearchUntil(long searchUntil) { + this.searchUntil = searchUntil; + } + + public int getStars() { + return stars; + } + + public void setStars(int stars) { + this.stars = stars; + } + + public String getPaymentStatus() { + return paymentStatus; + } + + public void setPaymentStatus(String paymentStatus) { + this.paymentStatus = paymentStatus; + } + + public String getMethodId() { + return methodId; + } + + public void setMethodId(String methodId) { + this.methodId = methodId; + } + + public String getMethodLabel() { + return methodLabel; + } + + public void setMethodLabel(String methodLabel) { + this.methodLabel = methodLabel; + } + + public long getFeeCents() { + return feeCents; + } + + public void setFeeCents(long feeCents) { + this.feeCents = feeCents; + } + + public long getTipCents() { + return tipCents; + } + + public void setTipCents(long tipCents) { + this.tipCents = tipCents; + } + + public long getCourierCents() { + return courierCents; + } + + public void setCourierCents(long courierCents) { + this.courierCents = courierCents; + } + + public String getNote() { + return note; + } + + public void setNote(String note) { + this.note = note; + } +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/domain/DeliveryActivity.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/domain/DeliveryActivity.java new file mode 100644 index 00000000000..48ad9497554 --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/domain/DeliveryActivity.java @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; + +/// The Live Activity a customer's phone shows a delivery in, while the delivery lasts. +@Entity +public class DeliveryActivity { + @Id(autoIncrement = false) + @Column(nullable = false) + private String deliveryId = ""; + + @Column(nullable = false) + private String username = ""; + + /// The id the phone's platform gave the activity. + @Column(nullable = false) + private String activityId = ""; + + /// When the server last sent it a state, for spacing out the updates. + @Column(nullable = false) + private long sentAt; + + /// When its delivery ended, 0 while the delivery lasts. An ended activity is kept + /// until the phone has been told to end it. + @Column(nullable = false) + private long endedAt; + + public DeliveryActivity() { + } + + public String getDeliveryId() { + return deliveryId; + } + + public void setDeliveryId(String deliveryId) { + this.deliveryId = deliveryId; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getActivityId() { + return activityId; + } + + public void setActivityId(String activityId) { + this.activityId = activityId; + } + + public long getSentAt() { + return sentAt; + } + + public void setSentAt(long sentAt) { + this.sentAt = sentAt; + } + + public long getEndedAt() { + return endedAt; + } + + public void setEndedAt(long endedAt) { + this.endedAt = endedAt; + } +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/domain/DeliveryEvent.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/domain/DeliveryEvent.java new file mode 100644 index 00000000000..fe0ea5f6032 --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/domain/DeliveryEvent.java @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; +import com.codenameone.examples.savor.api.DeliveryState; + +/// One change of a delivery's state: what it became, who made it so, and when. +@Entity +public class DeliveryEvent { + @Id(autoIncrement = false) + @Column(nullable = false) + private String id = ""; + + @Column(nullable = false) + private String deliveryId = ""; + + @Column(nullable = false) + private DeliveryState state; + + @Column(nullable = false) + private String actor = ""; + + @Column(nullable = false) + private long createdAt; + + public DeliveryEvent() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getDeliveryId() { + return deliveryId; + } + + public void setDeliveryId(String deliveryId) { + this.deliveryId = deliveryId; + } + + public DeliveryState getState() { + return state; + } + + public void setState(DeliveryState state) { + this.state = state; + } + + public String getActor() { + return actor; + } + + public void setActor(String actor) { + this.actor = actor; + } + + public long getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(long createdAt) { + this.createdAt = createdAt; + } +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/domain/DeliveryPassed.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/domain/DeliveryPassed.java new file mode 100644 index 00000000000..9947f81c826 --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/domain/DeliveryPassed.java @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; + +/// A courier who passed on a delivery, or let its offer lapse, and is not asked again. +@Entity +public class DeliveryPassed { + @Id(autoIncrement = false) + @Column(nullable = false) + private String deliveryId = ""; + + @Id(autoIncrement = false) + @Column(nullable = false) + private String courier = ""; + + public DeliveryPassed() { + } + + public String getDeliveryId() { + return deliveryId; + } + + public void setDeliveryId(String deliveryId) { + this.deliveryId = deliveryId; + } + + public String getCourier() { + return courier; + } + + public void setCourier(String courier) { + this.courier = courier; + } +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/domain/Dish.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/domain/Dish.java new file mode 100644 index 00000000000..5c5433d5e75 --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/domain/Dish.java @@ -0,0 +1,142 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; + +/// Something on a restaurant's menu. A dish that is not `available` is listed +/// as sold out and cannot be ordered. +@Entity +public class Dish { + @Id(autoIncrement = false) + @Column(nullable = false) + private String id = ""; + + @Column(nullable = false) + private String restaurantId = ""; + + @Column(nullable = false) + private String sectionId = ""; + + @Column(nullable = false) + private String name = ""; + + @Column(nullable = false) + private String description = ""; + + @Column(nullable = false) + private long priceCents; + + @Column(nullable = false) + private String photoId = ""; + + @Column(nullable = false) + private boolean available; + + @Column(nullable = false) + private boolean popular; + + @Column(nullable = false) + private int position; + + public Dish() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getRestaurantId() { + return restaurantId; + } + + public void setRestaurantId(String restaurantId) { + this.restaurantId = restaurantId; + } + + public String getSectionId() { + return sectionId; + } + + public void setSectionId(String sectionId) { + this.sectionId = sectionId; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public long getPriceCents() { + return priceCents; + } + + public void setPriceCents(long priceCents) { + this.priceCents = priceCents; + } + + public String getPhotoId() { + return photoId; + } + + public void setPhotoId(String photoId) { + this.photoId = photoId; + } + + public boolean isAvailable() { + return available; + } + + public void setAvailable(boolean available) { + this.available = available; + } + + public boolean isPopular() { + return popular; + } + + public void setPopular(boolean popular) { + this.popular = popular; + } + + public int getPosition() { + return position; + } + + public void setPosition(int position) { + this.position = position; + } +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/domain/DishOption.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/domain/DishOption.java new file mode 100644 index 00000000000..c4b8098f924 --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/domain/DishOption.java @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; + +/// An extra a dish can be ordered with, at a price of its own. +@Entity +public class DishOption { + @Id(autoIncrement = false) + @Column(nullable = false) + private String id = ""; + + @Column(nullable = false) + private String dishId = ""; + + @Column(nullable = false) + private String name = ""; + + @Column(nullable = false) + private long priceCents; + + @Column(nullable = false) + private int position; + + public DishOption() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getDishId() { + return dishId; + } + + public void setDishId(String dishId) { + this.dishId = dishId; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public long getPriceCents() { + return priceCents; + } + + public void setPriceCents(long priceCents) { + this.priceCents = priceCents; + } + + public int getPosition() { + return position; + } + + public void setPosition(int position) { + this.position = position; + } +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/domain/Favorite.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/domain/Favorite.java new file mode 100644 index 00000000000..958b4ee1145 --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/domain/Favorite.java @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; + +/// A restaurant a customer keeps, keyed by `Identifier.of(username, restaurantId)`. +@Entity +public class Favorite { + @Id(autoIncrement = false) + @Column(nullable = false) + private String username = ""; + + @Id(autoIncrement = false) + @Column(nullable = false) + private String restaurantId = ""; + + @Column(nullable = false) + private long createdAt; + + public Favorite() { + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getRestaurantId() { + return restaurantId; + } + + public void setRestaurantId(String restaurantId) { + this.restaurantId = restaurantId; + } + + public long getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(long createdAt) { + this.createdAt = createdAt; + } +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/domain/LiveTicket.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/domain/LiveTicket.java new file mode 100644 index 00000000000..f18d3c1aafb --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/domain/LiveTicket.java @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; + +/// A ticket for one connection to the live channel, as its hash. +@Entity +public class LiveTicket { + @Id(autoIncrement = false) + @Column(nullable = false) + private String ticketHash = ""; + + @Column(nullable = false) + private String username = ""; + + @Column(nullable = false) + private long expiresAt; + + public LiveTicket() { + } + + public String getTicketHash() { + return ticketHash; + } + + public void setTicketHash(String ticketHash) { + this.ticketHash = ticketHash; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public long getExpiresAt() { + return expiresAt; + } + + public void setExpiresAt(long expiresAt) { + this.expiresAt = expiresAt; + } +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/domain/MenuSection.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/domain/MenuSection.java new file mode 100644 index 00000000000..7a249c592b0 --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/domain/MenuSection.java @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; + +/// A heading on a restaurant's menu, in the order the restaurant lists them. +@Entity +public class MenuSection { + @Id(autoIncrement = false) + @Column(nullable = false) + private String id = ""; + + @Column(nullable = false) + private String restaurantId = ""; + + @Column(nullable = false) + private String name = ""; + + @Column(nullable = false) + private int position; + + public MenuSection() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getRestaurantId() { + return restaurantId; + } + + public void setRestaurantId(String restaurantId) { + this.restaurantId = restaurantId; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public int getPosition() { + return position; + } + + public void setPosition(int position) { + this.position = position; + } +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/domain/ModerationEvent.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/domain/ModerationEvent.java new file mode 100644 index 00000000000..9d3f43c63c6 --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/domain/ModerationEvent.java @@ -0,0 +1,97 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; + +/// One thing an admin did to an account, and why. Written once, never changed. +@Entity +public class ModerationEvent { + @Id(autoIncrement = false) + @Column(nullable = false) + private String id = ""; + + @Column(nullable = false) + private String username = ""; + + @Column(nullable = false) + private long createdAt; + + @Column(nullable = false) + private String actor = ""; + + @Column(nullable = false) + private String action = ""; + + @Column(nullable = false) + private String reason = ""; + + public ModerationEvent() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public long getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(long createdAt) { + this.createdAt = createdAt; + } + + public String getActor() { + return actor; + } + + public void setActor(String actor) { + this.actor = actor; + } + + public String getAction() { + return action; + } + + public void setAction(String action) { + this.action = action; + } + + public String getReason() { + return reason; + } + + public void setReason(String reason) { + this.reason = reason; + } +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/domain/OrderEvent.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/domain/OrderEvent.java new file mode 100644 index 00000000000..7ff07c594ca --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/domain/OrderEvent.java @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.domain; + +import com.codenameone.examples.savor.api.OrderState; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; + +/// A change of an order's state: what it became, who made it, and when. +@Entity +public class OrderEvent { + @Id(autoIncrement = false) + @Column(nullable = false) + private String id = ""; + + @Column(nullable = false) + private String orderId = ""; + + @Column(nullable = false) + private OrderState state; + + @Column(nullable = false) + private String actor = ""; + + @Column(nullable = false) + private long createdAt; + + public OrderEvent() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getOrderId() { + return orderId; + } + + public void setOrderId(String orderId) { + this.orderId = orderId; + } + + public OrderState getState() { + return state; + } + + public void setState(OrderState state) { + this.state = state; + } + + public String getActor() { + return actor; + } + + public void setActor(String actor) { + this.actor = actor; + } + + public long getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(long createdAt) { + this.createdAt = createdAt; + } +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/domain/OrderLine.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/domain/OrderLine.java new file mode 100644 index 00000000000..93cd7d37c79 --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/domain/OrderLine.java @@ -0,0 +1,130 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; + +/// One line of an order: a dish, the extras chosen with it, and how many. +@Entity +public class OrderLine { + @Id(autoIncrement = false) + @Column(nullable = false) + private String id = ""; + + @Column(nullable = false) + private String orderId = ""; + + @Column(nullable = false) + private String dishId = ""; + + @Column(nullable = false) + private String name = ""; + + @Column(nullable = false) + private String options = ""; + + @Column(nullable = false) + private int quantity; + + @Column(nullable = false) + private long unitCents; + + @Column(nullable = false) + private long lineCents; + + @Column(nullable = false) + private int position; + + public OrderLine() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getOrderId() { + return orderId; + } + + public void setOrderId(String orderId) { + this.orderId = orderId; + } + + public String getDishId() { + return dishId; + } + + public void setDishId(String dishId) { + this.dishId = dishId; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getOptions() { + return options; + } + + public void setOptions(String options) { + this.options = options; + } + + public int getQuantity() { + return quantity; + } + + public void setQuantity(int quantity) { + this.quantity = quantity; + } + + public long getUnitCents() { + return unitCents; + } + + public void setUnitCents(long unitCents) { + this.unitCents = unitCents; + } + + public long getLineCents() { + return lineCents; + } + + public void setLineCents(long lineCents) { + this.lineCents = lineCents; + } + + public int getPosition() { + return position; + } + + public void setPosition(int position) { + this.position = position; + } +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/domain/Payment.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/domain/Payment.java new file mode 100644 index 00000000000..4aa654d2f51 --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/domain/Payment.java @@ -0,0 +1,152 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; + +/// What a completed delivery was charged, and what was given back. +@Entity +public class Payment { + @Id(autoIncrement = false) + @Column(nullable = false) + private String deliveryId = ""; + + @Column(nullable = false) + private long amountCents; + + @Column(nullable = false) + private String currency = ""; + + @Column(nullable = false) + private String status = ""; + + @Column(nullable = false) + private String providerRef = ""; + + @Column(nullable = false) + private long createdAt; + + @Column(nullable = false) + private String tipRef = ""; + + @Column(nullable = false) + private String refundRef = ""; + + @Column(nullable = false) + private String tipRefundRef = ""; + + @Column(nullable = false) + private String refundReason = ""; + + @Column(nullable = false) + private long refundedAt; + + public Payment() { + } + + public String getDeliveryId() { + return deliveryId; + } + + public void setDeliveryId(String deliveryId) { + this.deliveryId = deliveryId; + } + + public long getAmountCents() { + return amountCents; + } + + public void setAmountCents(long amountCents) { + this.amountCents = amountCents; + } + + public String getCurrency() { + return currency; + } + + public void setCurrency(String currency) { + this.currency = currency; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public String getProviderRef() { + return providerRef; + } + + public void setProviderRef(String providerRef) { + this.providerRef = providerRef; + } + + public long getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(long createdAt) { + this.createdAt = createdAt; + } + + public String getTipRef() { + return tipRef; + } + + public void setTipRef(String tipRef) { + this.tipRef = tipRef; + } + + public String getRefundRef() { + return refundRef; + } + + public void setRefundRef(String refundRef) { + this.refundRef = refundRef; + } + + public String getTipRefundRef() { + return tipRefundRef; + } + + public void setTipRefundRef(String tipRefundRef) { + this.tipRefundRef = tipRefundRef; + } + + public String getRefundReason() { + return refundReason; + } + + public void setRefundReason(String refundReason) { + this.refundReason = refundReason; + } + + public long getRefundedAt() { + return refundedAt; + } + + public void setRefundedAt(long refundedAt) { + this.refundedAt = refundedAt; + } +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/domain/PaymentCustomer.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/domain/PaymentCustomer.java new file mode 100644 index 00000000000..f544ad0026c --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/domain/PaymentCustomer.java @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; + +/// The payment provider's name for an account. +@Entity +public class PaymentCustomer { + @Id(autoIncrement = false) + @Column(nullable = false) + private String username = ""; + + @Column(nullable = false) + private String reference = ""; + + public PaymentCustomer() { + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getReference() { + return reference; + } + + public void setReference(String reference) { + this.reference = reference; + } +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/domain/PaymentMethod.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/domain/PaymentMethod.java new file mode 100644 index 00000000000..72b2f106e66 --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/domain/PaymentMethod.java @@ -0,0 +1,131 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; + +/// A saved card: what the provider calls it and what to show for it. The +/// number itself is never here. +@Entity +public class PaymentMethod { + @Id(autoIncrement = false) + @Column(nullable = false) + private String id = ""; + + @Column(nullable = false) + private String username = ""; + + @Column(nullable = false) + private String brand = ""; + + @Column(nullable = false) + private String last4 = ""; + + @Column(nullable = false) + private int expMonth; + + @Column(nullable = false) + private int expYear; + + @Column(nullable = false) + private String token = ""; + + @Column(nullable = false) + private boolean preferred; + + @Column(nullable = false) + private long createdAt; + + public PaymentMethod() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getBrand() { + return brand; + } + + public void setBrand(String brand) { + this.brand = brand; + } + + public String getLast4() { + return last4; + } + + public void setLast4(String last4) { + this.last4 = last4; + } + + public int getExpMonth() { + return expMonth; + } + + public void setExpMonth(int expMonth) { + this.expMonth = expMonth; + } + + public int getExpYear() { + return expYear; + } + + public void setExpYear(int expYear) { + this.expYear = expYear; + } + + public String getToken() { + return token; + } + + public void setToken(String token) { + this.token = token; + } + + public boolean isPreferred() { + return preferred; + } + + public void setPreferred(boolean preferred) { + this.preferred = preferred; + } + + public long getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(long createdAt) { + this.createdAt = createdAt; + } +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/domain/PaymentSetup.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/domain/PaymentSetup.java new file mode 100644 index 00000000000..2e1744a9b4b --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/domain/PaymentSetup.java @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; + +/// A card being added on the provider's page, until the browser comes back. +@Entity +public class PaymentSetup { + @Id(autoIncrement = false) + @Column(nullable = false) + private String id = ""; + + @Column(nullable = false) + private String username = ""; + + @Column(nullable = false) + private String reference = ""; + + @Column(nullable = false) + private long createdAt; + + @Column(nullable = false) + private boolean completed; + + public PaymentSetup() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getReference() { + return reference; + } + + public void setReference(String reference) { + this.reference = reference; + } + + public long getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(long createdAt) { + this.createdAt = createdAt; + } + + public boolean isCompleted() { + return completed; + } + + public void setCompleted(boolean completed) { + this.completed = completed; + } +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/domain/Payout.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/domain/Payout.java new file mode 100644 index 00000000000..8257615ec17 --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/domain/Payout.java @@ -0,0 +1,97 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; + +/// One payment of earnings to a courier. +@Entity +public class Payout { + @Id(autoIncrement = false) + @Column(nullable = false) + private String id = ""; + + @Column(nullable = false) + private String courier = ""; + + @Column(nullable = false) + private long amountCents; + + @Column(nullable = false) + private String status = ""; + + @Column(nullable = false) + private long createdAt; + + @Column(nullable = false) + private String destination = ""; + + public Payout() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getCourier() { + return courier; + } + + public void setCourier(String courier) { + this.courier = courier; + } + + public long getAmountCents() { + return amountCents; + } + + public void setAmountCents(long amountCents) { + this.amountCents = amountCents; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public long getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(long createdAt) { + this.createdAt = createdAt; + } + + public String getDestination() { + return destination; + } + + public void setDestination(String destination) { + this.destination = destination; + } +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/domain/PayoutAccount.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/domain/PayoutAccount.java new file mode 100644 index 00000000000..ab30501a75c --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/domain/PayoutAccount.java @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; + +/// Where a courier's earnings are paid out to. +@Entity +public class PayoutAccount { + @Id(autoIncrement = false) + @Column(nullable = false) + private String username = ""; + + @Column(nullable = false) + private String holder = ""; + + @Column(nullable = false) + private String bankName = ""; + + @Column(nullable = false) + private String last4 = ""; + + public PayoutAccount() { + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getHolder() { + return holder; + } + + public void setHolder(String holder) { + this.holder = holder; + } + + public String getBankName() { + return bankName; + } + + public void setBankName(String bankName) { + this.bankName = bankName; + } + + public String getLast4() { + return last4; + } + + public void setLast4(String last4) { + this.last4 = last4; + } +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/domain/PhoneCode.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/domain/PhoneCode.java new file mode 100644 index 00000000000..42d69fbecd2 --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/domain/PhoneCode.java @@ -0,0 +1,122 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; + +/// The verification code an account was last sent, as its hash. +@Entity +public class PhoneCode { + @Id(autoIncrement = false) + @Column(nullable = false) + private String username = ""; + + @Column(nullable = false) + private String codeHash = ""; + + @Column(nullable = false) + private long expiresAt; + + @Column(nullable = false) + private int attempts; + + @Column(nullable = false) + private long sentAt; + + /// The code sent before this one, while it is still good, or empty. + @Column(nullable = false) + private String previousHash = ""; + + @Column(nullable = false) + private long previousExpiresAt; + + /// The guesses already taken at the code before, which keeps the budget + /// it had rather than starting a new one when another code is sent. + @Column(nullable = false) + private int previousAttempts; + + public PhoneCode() { + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getCodeHash() { + return codeHash; + } + + public void setCodeHash(String codeHash) { + this.codeHash = codeHash; + } + + public long getExpiresAt() { + return expiresAt; + } + + public void setExpiresAt(long expiresAt) { + this.expiresAt = expiresAt; + } + + public int getAttempts() { + return attempts; + } + + public void setAttempts(int attempts) { + this.attempts = attempts; + } + + public long getSentAt() { + return sentAt; + } + + public void setSentAt(long sentAt) { + this.sentAt = sentAt; + } + + public String getPreviousHash() { + return previousHash; + } + + public void setPreviousHash(String previousHash) { + this.previousHash = previousHash; + } + + public long getPreviousExpiresAt() { + return previousExpiresAt; + } + + public void setPreviousExpiresAt(long previousExpiresAt) { + this.previousExpiresAt = previousExpiresAt; + } + + public int getPreviousAttempts() { + return previousAttempts; + } + + public void setPreviousAttempts(int previousAttempts) { + this.previousAttempts = previousAttempts; + } +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/domain/Photo.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/domain/Photo.java new file mode 100644 index 00000000000..170a087197c --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/domain/Photo.java @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; + +/// A restaurant's or a dish's picture: the bytes of a JPEG or a PNG. +@Entity +public class Photo { + @Id(autoIncrement = false) + @Column(nullable = false) + private String id = ""; + + @Column(nullable = false) + private String restaurantId = ""; + + @Column(nullable = false) + private String contentType = ""; + + @Column(nullable = false) + private long createdAt; + + @Column(nullable = false) + private byte[] data = new byte[0]; + + public Photo() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getRestaurantId() { + return restaurantId; + } + + public void setRestaurantId(String restaurantId) { + this.restaurantId = restaurantId; + } + + public String getContentType() { + return contentType; + } + + public void setContentType(String contentType) { + this.contentType = contentType; + } + + public long getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(long createdAt) { + this.createdAt = createdAt; + } + + public byte[] getData() { + return data; + } + + public void setData(byte[] data) { + this.data = data; + } +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/domain/Preference.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/domain/Preference.java new file mode 100644 index 00000000000..a479975fe81 --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/domain/Preference.java @@ -0,0 +1,109 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; + +/// How one person wants the app and their deliveries, when they have said. +@Entity +public class Preference { + @Id(autoIncrement = false) + @Column(nullable = false) + private String username = ""; + + @Column(nullable = false) + private String theme = ""; + + @Column(nullable = false) + private String units = ""; + + @Column(nullable = false) + private boolean notifyDeliveries; + + @Column(nullable = false) + private boolean notifyReceipts; + + @Column(nullable = false) + private boolean notifyPromotions; + + @Column(nullable = false) + private int defaultTip; + + public Preference() { + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getTheme() { + return theme; + } + + public void setTheme(String theme) { + this.theme = theme; + } + + public String getUnits() { + return units; + } + + public void setUnits(String units) { + this.units = units; + } + + public boolean isNotifyDeliveries() { + return notifyDeliveries; + } + + public void setNotifyDeliveries(boolean notifyDeliveries) { + this.notifyDeliveries = notifyDeliveries; + } + + public boolean isNotifyReceipts() { + return notifyReceipts; + } + + public void setNotifyReceipts(boolean notifyReceipts) { + this.notifyReceipts = notifyReceipts; + } + + public boolean isNotifyPromotions() { + return notifyPromotions; + } + + public void setNotifyPromotions(boolean notifyPromotions) { + this.notifyPromotions = notifyPromotions; + } + + public int getDefaultTip() { + return defaultTip; + } + + public void setDefaultTip(int defaultTip) { + this.defaultTip = defaultTip; + } + +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/domain/Pricing.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/domain/Pricing.java new file mode 100644 index 00000000000..9fd7bec5c4b --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/domain/Pricing.java @@ -0,0 +1,153 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; + +/// What an order costs on top of its food, and what the service keeps of it. +/// There is one row; see V6. +@Entity +public class Pricing { + @Id(autoIncrement = false) + @Column(nullable = false) + private int id; + + @Column(nullable = false) + private long baseCents; + + @Column(nullable = false) + private long perKmCents; + + @Column(nullable = false) + private double surge; + + @Column(nullable = false) + private long smallOrderCents; + + @Column(nullable = false) + private long smallOrderFeeCents; + + @Column(nullable = false) + private double feePercent; + + @Column(nullable = false) + private double taxPercent; + + @Column(nullable = false) + private double commissionPercent; + + @Column(nullable = false) + private long updatedAt; + + @Column(nullable = false) + private String updatedBy = ""; + + public Pricing() { + } + + public int getId() { + return id; + } + + public void setId(int id) { + this.id = id; + } + + public long getBaseCents() { + return baseCents; + } + + public void setBaseCents(long baseCents) { + this.baseCents = baseCents; + } + + public long getPerKmCents() { + return perKmCents; + } + + public void setPerKmCents(long perKmCents) { + this.perKmCents = perKmCents; + } + + public double getSurge() { + return surge; + } + + public void setSurge(double surge) { + this.surge = surge; + } + + public long getSmallOrderCents() { + return smallOrderCents; + } + + public void setSmallOrderCents(long smallOrderCents) { + this.smallOrderCents = smallOrderCents; + } + + public long getSmallOrderFeeCents() { + return smallOrderFeeCents; + } + + public void setSmallOrderFeeCents(long smallOrderFeeCents) { + this.smallOrderFeeCents = smallOrderFeeCents; + } + + public double getFeePercent() { + return feePercent; + } + + public void setFeePercent(double feePercent) { + this.feePercent = feePercent; + } + + public double getTaxPercent() { + return taxPercent; + } + + public void setTaxPercent(double taxPercent) { + this.taxPercent = taxPercent; + } + + public double getCommissionPercent() { + return commissionPercent; + } + + public void setCommissionPercent(double commissionPercent) { + this.commissionPercent = commissionPercent; + } + + public long getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(long updatedAt) { + this.updatedAt = updatedAt; + } + + public String getUpdatedBy() { + return updatedBy; + } + + public void setUpdatedBy(String updatedBy) { + this.updatedBy = updatedBy; + } +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/domain/Profile.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/domain/Profile.java new file mode 100644 index 00000000000..58fc3f43e79 --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/domain/Profile.java @@ -0,0 +1,145 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; + +/// An account's profile: who it is to the people it deliveries with. +/// +/// The sign-in -- password hash, roles, whether it is enabled -- is the backend's +/// own user store, under the same name. +@Entity +public class Profile { + @Id(autoIncrement = false) + @Column(nullable = false) + private String username = ""; + + @Column(nullable = false) + private String displayName = ""; + + @Column(nullable = false) + private String phone = ""; + + @Column(nullable = false) + private boolean phoneVerified; + + @Column(nullable = false) + private String vehicle = ""; + + @Column(nullable = false) + private String plate = ""; + + @Column(nullable = false) + private long createdAt; + + @Column(nullable = false) + private String language = "en"; + + @Column(nullable = false) + private boolean flagged; + + @Column(nullable = false) + private String flagReason = ""; + + public Profile() { + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getDisplayName() { + return displayName; + } + + public void setDisplayName(String displayName) { + this.displayName = displayName; + } + + public String getPhone() { + return phone; + } + + public void setPhone(String phone) { + this.phone = phone; + } + + public boolean isPhoneVerified() { + return phoneVerified; + } + + public void setPhoneVerified(boolean phoneVerified) { + this.phoneVerified = phoneVerified; + } + + public String getVehicle() { + return vehicle; + } + + public void setVehicle(String vehicle) { + this.vehicle = vehicle; + } + + public String getPlate() { + return plate; + } + + public void setPlate(String plate) { + this.plate = plate; + } + + public long getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(long createdAt) { + this.createdAt = createdAt; + } + + public String getLanguage() { + return language; + } + + public void setLanguage(String language) { + this.language = language; + } + + public boolean isFlagged() { + return flagged; + } + + public void setFlagged(boolean flagged) { + this.flagged = flagged; + } + + public String getFlagReason() { + return flagReason; + } + + public void setFlagReason(String flagReason) { + this.flagReason = flagReason; + } + +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/domain/Promo.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/domain/Promo.java new file mode 100644 index 00000000000..5a83551e02e --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/domain/Promo.java @@ -0,0 +1,98 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; + +/// A code a customer types at checkout: a percentage or an amount off the food, +/// for an order of at least `minimumCents`, once per account. +@Entity +public class Promo { + @Id(autoIncrement = false) + @Column(nullable = false) + private String code = ""; + + @Column(nullable = false) + private int percentOff; + + @Column(nullable = false) + private long amountOffCents; + + @Column(nullable = false) + private long minimumCents; + + @Column(nullable = false) + private boolean active; + + @Column(nullable = false) + private long createdAt; + + public Promo() { + } + + public String getCode() { + return code; + } + + public void setCode(String code) { + this.code = code; + } + + public int getPercentOff() { + return percentOff; + } + + public void setPercentOff(int percentOff) { + this.percentOff = percentOff; + } + + public long getAmountOffCents() { + return amountOffCents; + } + + public void setAmountOffCents(long amountOffCents) { + this.amountOffCents = amountOffCents; + } + + public long getMinimumCents() { + return minimumCents; + } + + public void setMinimumCents(long minimumCents) { + this.minimumCents = minimumCents; + } + + public boolean isActive() { + return active; + } + + public void setActive(boolean active) { + this.active = active; + } + + public long getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(long createdAt) { + this.createdAt = createdAt; + } +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/domain/PromoUse.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/domain/PromoUse.java new file mode 100644 index 00000000000..6ce49117686 --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/domain/PromoUse.java @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; + +/// A promotion an account has used, and on which order, keyed by +/// `Identifier.of(code, username)`. +@Entity +public class PromoUse { + @Id(autoIncrement = false) + @Column(nullable = false) + private String code = ""; + + @Id(autoIncrement = false) + @Column(nullable = false) + private String username = ""; + + @Column(nullable = false) + private String orderId = ""; + + public PromoUse() { + } + + public String getCode() { + return code; + } + + public void setCode(String code) { + this.code = code; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getOrderId() { + return orderId; + } + + public void setOrderId(String orderId) { + this.orderId = orderId; + } +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/domain/PushDevice.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/domain/PushDevice.java new file mode 100644 index 00000000000..5bbc9ec41d5 --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/domain/PushDevice.java @@ -0,0 +1,101 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; + +/// A device an account is sent push notifications on. +/// +/// Keyed by the hash of its provider and token, so a token is on one account at +/// a time: the phone belongs to whoever signed in on it last. +@Entity +public class PushDevice { + @Id(autoIncrement = false) + @Column(nullable = false) + private String id = ""; + + @Column(nullable = false) + private String username = ""; + + /// `fcm`, `apns`, `huawei`, `wns` or `web`. + @Column(nullable = false) + private String provider = ""; + + @Column(nullable = false) + private String token = ""; + + @Column(nullable = false) + private String platform = ""; + + @Column(nullable = false) + private long updatedAt; + + public PushDevice() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getProvider() { + return provider; + } + + public void setProvider(String provider) { + this.provider = provider; + } + + public String getToken() { + return token; + } + + public void setToken(String token) { + this.token = token; + } + + public String getPlatform() { + return platform; + } + + public void setPlatform(String platform) { + this.platform = platform; + } + + public long getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(long updatedAt) { + this.updatedAt = updatedAt; + } +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/domain/Restaurant.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/domain/Restaurant.java new file mode 100644 index 00000000000..a0191e72eff --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/domain/Restaurant.java @@ -0,0 +1,263 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; + +/// A restaurant on the service: listed once an admin approves it, and taking +/// orders while it is open and its staff have not paused it. +@Entity +public class Restaurant { + @Id(autoIncrement = false) + @Column(nullable = false) + private String id = ""; + + @Column(nullable = false) + private String name = ""; + + @Column(nullable = false) + private String cuisine = ""; + + @Column(nullable = false) + private String description = ""; + + @Column(nullable = false) + private String address = ""; + + @Column(nullable = false) + private double lat; + + @Column(nullable = false) + private double lng; + + @Column(nullable = false) + private String status = ""; + + @Column(nullable = false) + private boolean paused; + + @Column(nullable = false) + private int opensAt; + + @Column(nullable = false) + private int closesAt; + + @Column(nullable = false) + private int prepMinutes; + + @Column(nullable = false) + private long minimumCents; + + @Column(nullable = false) + private int priceLevel; + + @Column(nullable = false) + private long ratingTotal; + + @Column(nullable = false) + private long ratingCount; + + @Column(nullable = false) + private String photoId = ""; + + @Column(nullable = false) + private String applicant = ""; + + @Column(nullable = false) + private String reason = ""; + + @Column(nullable = false) + private long createdAt; + + @Column(nullable = false) + private long updatedAt; + + public Restaurant() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getCuisine() { + return cuisine; + } + + public void setCuisine(String cuisine) { + this.cuisine = cuisine; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public String getAddress() { + return address; + } + + public void setAddress(String address) { + this.address = address; + } + + public double getLat() { + return lat; + } + + public void setLat(double lat) { + this.lat = lat; + } + + public double getLng() { + return lng; + } + + public void setLng(double lng) { + this.lng = lng; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public boolean isPaused() { + return paused; + } + + public void setPaused(boolean paused) { + this.paused = paused; + } + + public int getOpensAt() { + return opensAt; + } + + public void setOpensAt(int opensAt) { + this.opensAt = opensAt; + } + + public int getClosesAt() { + return closesAt; + } + + public void setClosesAt(int closesAt) { + this.closesAt = closesAt; + } + + public int getPrepMinutes() { + return prepMinutes; + } + + public void setPrepMinutes(int prepMinutes) { + this.prepMinutes = prepMinutes; + } + + public long getMinimumCents() { + return minimumCents; + } + + public void setMinimumCents(long minimumCents) { + this.minimumCents = minimumCents; + } + + public int getPriceLevel() { + return priceLevel; + } + + public void setPriceLevel(int priceLevel) { + this.priceLevel = priceLevel; + } + + public long getRatingTotal() { + return ratingTotal; + } + + public void setRatingTotal(long ratingTotal) { + this.ratingTotal = ratingTotal; + } + + public long getRatingCount() { + return ratingCount; + } + + public void setRatingCount(long ratingCount) { + this.ratingCount = ratingCount; + } + + public String getPhotoId() { + return photoId; + } + + public void setPhotoId(String photoId) { + this.photoId = photoId; + } + + public String getApplicant() { + return applicant; + } + + public void setApplicant(String applicant) { + this.applicant = applicant; + } + + public String getReason() { + return reason; + } + + public void setReason(String reason) { + this.reason = reason; + } + + public long getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(long createdAt) { + this.createdAt = createdAt; + } + + public long getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(long updatedAt) { + this.updatedAt = updatedAt; + } +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/domain/RestaurantStaff.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/domain/RestaurantStaff.java new file mode 100644 index 00000000000..db4c279f072 --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/domain/RestaurantStaff.java @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; + +/// An account that works for a restaurant, keyed by +/// `Identifier.of(restaurantId, username)`. +@Entity +public class RestaurantStaff { + @Id(autoIncrement = false) + @Column(nullable = false) + private String restaurantId = ""; + + @Id(autoIncrement = false) + @Column(nullable = false) + private String username = ""; + + public RestaurantStaff() { + } + + public String getRestaurantId() { + return restaurantId; + } + + public void setRestaurantId(String restaurantId) { + this.restaurantId = restaurantId; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/domain/SavedAddress.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/domain/SavedAddress.java new file mode 100644 index 00000000000..a635503311a --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/domain/SavedAddress.java @@ -0,0 +1,119 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; + +/// A place a customer has kept to be delivered to. +@Entity +public class SavedAddress { + @Id(autoIncrement = false) + @Column(nullable = false) + private String id = ""; + + @Column(nullable = false) + private String username = ""; + + @Column(nullable = false) + private String label = ""; + + @Column(nullable = false) + private String address = ""; + + @Column(nullable = false) + private double lat; + + @Column(nullable = false) + private double lng; + + @Column(nullable = false) + private String note = ""; + + @Column(nullable = false) + private long createdAt; + + public SavedAddress() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getLabel() { + return label; + } + + public void setLabel(String label) { + this.label = label; + } + + public String getAddress() { + return address; + } + + public void setAddress(String address) { + this.address = address; + } + + public double getLat() { + return lat; + } + + public void setLat(double lat) { + this.lat = lat; + } + + public double getLng() { + return lng; + } + + public void setLng(double lng) { + this.lng = lng; + } + + public String getNote() { + return note; + } + + public void setNote(String note) { + this.note = note; + } + + public long getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(long createdAt) { + this.createdAt = createdAt; + } +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/geo/Geo.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/geo/Geo.java new file mode 100644 index 00000000000..33ea7ce799a --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/geo/Geo.java @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.geo; + +/// Distances on the ground. +public final class Geo { + private static final double EARTH_RADIUS_METERS = 6371000d; + /// Metres in a degree of latitude, anywhere. A degree of longitude is this + /// times the cosine of the latitude. + public static final double METERS_PER_DEGREE = 111320d; + + private Geo() { + } + + /// The great-circle distance between two points, in metres. + public static double meters(double lat1, double lng1, double lat2, double lng2) { + double dLat = Math.toRadians(lat2 - lat1); + double dLng = Math.toRadians(lng2 - lng1); + double sinLat = Math.sin(dLat / 2); + double sinLng = Math.sin(dLng / 2); + double a = sinLat * sinLat + Math.cos(Math.toRadians(lat1)) + * Math.cos(Math.toRadians(lat2)) * sinLng * sinLng; + return 2 * EARTH_RADIUS_METERS * Math.atan2(Math.sqrt(a), Math.sqrt(Math.max(0d, 1 - a))); + } + + public static boolean isPoint(double lat, double lng) { + return lat >= -90d && lat <= 90d && lng >= -180d && lng <= 180d + && !(lat == 0d && lng == 0d); + } +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/geo/GeoEndpoint.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/geo/GeoEndpoint.java new file mode 100644 index 00000000000..648cff9f525 --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/geo/GeoEndpoint.java @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.geo; + +import com.codename1.backend.ResponseStatusException; +import com.codename1.backend.annotations.Component; +import com.codenameone.examples.savor.api.GeoApiServer; +import com.codenameone.examples.savor.api.PlaceDto; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +/// The server's half of `GeoApi`. +@Component +public class GeoEndpoint implements GeoApiServer { + private final Geocoder geocoder; + + public GeoEndpoint(Geocoder geocoder) { + this.geocoder = geocoder; + } + + @Override + public List search(String q, double lat, double lng) throws Exception { + String text = q == null ? "" : q.trim(); + // Two letters match half the map; nothing useful comes back, and each + // keystroke would be a request to the service behind this. + if (text.length() < 3 || text.length() > 120) { + return new ArrayList(); + } + try { + return geocoder.search(text, lat, lng); + } catch (IOException failed) { + System.err.println("geocoder search failed: " + failed.getMessage()); + throw new ResponseStatusException(502, "Search is unavailable right now"); + } + } + + @Override + public PlaceDto reverse(double lat, double lng) throws Exception { + if (!Geo.isPoint(lat, lng)) { + throw new ResponseStatusException(400, "That is not a point on the map"); + } + PlaceDto found; + try { + found = geocoder.reverse(lat, lng); + } catch (IOException failed) { + System.err.println("geocoder reverse failed: " + failed.getMessage()); + found = null; + } + // The point asked about, not the centre of whatever was found near it. + if (found == null) { + // A pin can be dropped anywhere, and a delivery can start there: an + // address the service does not know is a point without a name. + return new PlaceDto("Dropped pin", "", lat, lng); + } + return new PlaceDto(found.name(), found.address(), lat, lng); + } +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/geo/Geocoder.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/geo/Geocoder.java new file mode 100644 index 00000000000..9add0472c8c --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/geo/Geocoder.java @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.geo; + +import com.codenameone.examples.savor.api.PlaceDto; + +import java.io.IOException; +import java.util.List; + +/// Turns text into places and a point into an address. +/// +/// The app never talks to a geocoding service itself. It asks this server, +/// which asks whichever service is configured: no key ships inside the app, the +/// service can be replaced without a new release, and the server is where its +/// use can be limited. +public interface Geocoder { + /// Places matching `text`, best first, favouring those near the point. + List search(String text, double lat, double lng) throws IOException; + + /// The address at a point, or null when the service knows of none. + PlaceDto reverse(double lat, double lng) throws IOException; +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/geo/PhotonGeocoder.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/geo/PhotonGeocoder.java new file mode 100644 index 00000000000..919c4e9e110 --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/geo/PhotonGeocoder.java @@ -0,0 +1,130 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.geo; + +import com.codename1.backend.Json; +import com.codename1.backend.Web; +import com.codenameone.examples.savor.api.PlaceDto; + +import java.io.IOException; +import java.net.URLEncoder; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/// Geocoding by Photon, the open-source geocoder over OpenStreetMap data. +/// +/// The address defaults to the public instance, which needs no key and asks in +/// return for moderate use. Run your own, or point `savor.geocoder.url` at +/// another Photon, before sending it a real service's traffic. +public class PhotonGeocoder implements Geocoder { + private final String base; + + public PhotonGeocoder(String base) { + this.base = base.endsWith("/") ? base.substring(0, base.length() - 1) : base; + } + + @Override + public List search(String text, double lat, double lng) throws IOException { + String url = base + "/api?limit=8&q=" + URLEncoder.encode(text, "UTF-8"); + if (Geo.isPoint(lat, lng)) { + url += "&lat=" + lat + "&lon=" + lng; + } + return places(url); + } + + @Override + public PlaceDto reverse(double lat, double lng) throws IOException { + List found = places(base + "/reverse?lat=" + lat + "&lon=" + lng); + return found.isEmpty() ? null : found.get(0); + } + + private static List places(String url) throws IOException { + Web.Result answer = Web.get(url); + if (!answer.isSuccess()) { + throw new IOException("The geocoder answered " + answer.getStatus()); + } + List out = new ArrayList(); + Object features = Json.parseObject(answer.getBodyAsString()).get("features"); + if (!(features instanceof List)) { + return out; + } + List list = (List) features; + for (int iter = 0; iter < list.size(); iter++) { + PlaceDto place = place(list.get(iter)); + if (place != null) { + out.add(place); + } + } + return out; + } + + /// One GeoJSON feature as a place, or null when it has no usable point. + private static PlaceDto place(Object feature) { + if (!(feature instanceof Map)) { + return null; + } + Object geometry = ((Map) feature).get("geometry"); + Object properties = ((Map) feature).get("properties"); + if (!(geometry instanceof Map) || !(properties instanceof Map)) { + return null; + } + Object coordinates = ((Map) geometry).get("coordinates"); + if (!(coordinates instanceof List) || ((List) coordinates).size() < 2) { + return null; + } + Object lng = ((List) coordinates).get(0); + Object lat = ((List) coordinates).get(1); + if (!(lng instanceof Number) || !(lat instanceof Number)) { + return null; + } + Map p = (Map) properties; + String street = text(p, "street"); + if (street.length() > 0 && text(p, "housenumber").length() > 0) { + street = text(p, "housenumber") + " " + street; + } + String name = text(p, "name"); + if (name.length() == 0) { + name = street.length() > 0 ? street : text(p, "city"); + } + if (name.length() == 0) { + return null; + } + StringBuilder address = new StringBuilder(); + append(address, name.equals(street) ? "" : street); + append(address, text(p, "city")); + append(address, text(p, "country")); + return new PlaceDto(name, address.toString(), ((Number) lat).doubleValue(), + ((Number) lng).doubleValue()); + } + + private static String text(Map properties, String key) { + Object value = properties.get(key); + return value instanceof String ? (String) value : ""; + } + + private static void append(StringBuilder to, String part) { + if (part.length() > 0) { + if (to.length() > 0) { + to.append(", "); + } + to.append(part); + } + } +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/geo/StubGeocoder.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/geo/StubGeocoder.java new file mode 100644 index 00000000000..c0edf35a6ca --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/geo/StubGeocoder.java @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.geo; + +import com.codenameone.examples.savor.api.PlaceDto; + +import java.util.ArrayList; +import java.util.List; + +/// A geocoder that knows ten places and asks nobody. What the tests run +/// against, so they neither depend on a public service nor lean on one. +public class StubGeocoder implements Geocoder { + private static final Object[][] PLACES = { + {"Ferry Building", "1 Ferry Building, San Francisco", 37.7955, -122.3937}, + {"Pier 39", "The Embarcadero, San Francisco", 37.8087, -122.4098}, + {"Coit Tower", "1 Telegraph Hill Blvd, San Francisco", 37.8024, -122.4058}, + {"Ghirardelli Square", "900 North Point St, San Francisco", 37.8059, -122.4229}, + {"Lombard Street", "Lombard St, San Francisco", 37.8021, -122.4187}, + {"Palace of Fine Arts", "3601 Lyon St, San Francisco", 37.8029, -122.4484}, + {"Union Square", "333 Post St, San Francisco", 37.7880, -122.4075}, + {"Oracle Park", "24 Willie Mays Plaza, San Francisco", 37.7786, -122.3893}, + {"Fort Mason", "2 Marina Blvd, San Francisco", 37.8066, -122.4310}, + {"Transamerica Pyramid", "600 Montgomery St, San Francisco", 37.7952, -122.4028}, + }; + + @Override + public List search(String text, double lat, double lng) { + List out = new ArrayList(); + String wanted = text == null ? "" : text.trim(); + for (int iter = 0; iter < PLACES.length; iter++) { + if (contains((String) PLACES[iter][0], wanted)) { + out.add(place(iter)); + } + } + return out; + } + + @Override + public PlaceDto reverse(double lat, double lng) { + int nearest = 0; + double best = Double.MAX_VALUE; + for (int iter = 0; iter < PLACES.length; iter++) { + double meters = Geo.meters(lat, lng, ((Double) PLACES[iter][2]).doubleValue(), + ((Double) PLACES[iter][3]).doubleValue()); + if (meters < best) { + best = meters; + nearest = iter; + } + } + return place(nearest); + } + + private static PlaceDto place(int index) { + return new PlaceDto((String) PLACES[index][0], (String) PLACES[index][1], + ((Double) PLACES[index][2]).doubleValue(), + ((Double) PLACES[index][3]).doubleValue()); + } + + /// Whether `text` contains `part`, whatever the case of either. Compared a + /// character at a time and not by folding both, which would follow the + /// server's locale. + private static boolean contains(String text, String part) { + for (int at = 0; at + part.length() <= text.length(); at++) { + if (text.regionMatches(true, at, part, 0, part.length())) { + return true; + } + } + return false; + } +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/live/LiveEndpoint.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/live/LiveEndpoint.java new file mode 100644 index 00000000000..196a992e105 --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/live/LiveEndpoint.java @@ -0,0 +1,117 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.live; + +import com.codename1.backend.WebSocket; +import com.codename1.backend.WebSocketSession; +import com.codename1.backend.annotations.WebSocketMapping; +import com.codenameone.examples.savor.account.Accounts; +import com.codenameone.examples.savor.api.UserDto; + +/// The live channel: one WebSocket per signed-in app, down which the server +/// says what changed ([LiveHub]). +/// +/// No filter chain covers this path, deliberately: the handshake cannot carry +/// the bearer token on every platform. The connection is let in by the ticket +/// in its address instead ([LiveTickets]), and one that presents none, or one +/// already used, is closed before it is told anything. +@WebSocketMapping("/ws/live") +public class LiveEndpoint implements WebSocket { + private final LiveTickets tickets; + private final Accounts accounts; + private final LiveHub hub; + + public LiveEndpoint(LiveTickets tickets, Accounts accounts, LiveHub hub) { + this.tickets = tickets; + this.accounts = accounts; + this.hub = hub; + } + + @Override + public void onOpen(WebSocketSession session) throws Exception { + String username = tickets.redeem(ticket(session.getQuery())); + UserDto user = username == null ? null : accounts.describe(username); + if (user == null || user.suspended()) { + session.close(4401, "ticket"); + return; + } + // Who this connection is, kept on the connection: the one piece of + // state a WebSocket has that a request does not. + session.setAttachment(username); + hub.add(username, user.admin(), session); + // Read again now that the connection is in the hub. A block or a + // withdrawn admin role commits and then drops the account's + // connections; one that committed after the read above and dropped + // before the add found nothing to drop, and this connection would + // carry what was read before -- for an admin, every courier's position. + // Either the change is seen here, or its drop comes after the add and + // closes this connection. + UserDto now = accounts.describe(username); + if (now == null || now.suspended() || now.admin() != user.admin()) { + hub.remove(username, session); + session.close(4403, "changed"); + return; + } + session.sendText("{\"type\":\"ready\"}"); + } + + @Override + public void onText(WebSocketSession session, String message) throws Exception { + // The app says one thing, to learn the connection is still alive on a + // network that drops idle ones without telling either end. + if (session.getAttachment() != null && message != null + && message.indexOf("\"ping\"") >= 0) { + session.sendText("{\"type\":\"pong\"}"); + } + } + + @Override + public void onBinary(WebSocketSession session, byte[] message, int offset, int length) + throws Exception { + session.close(1003, "text only"); + } + + @Override + public void onClose(WebSocketSession session, int code, String reason) { + Object username = session.getAttachment(); + if (username instanceof String) { + hub.remove((String) username, session); + } + } + + /// The `ticket` parameter of a query string, or null. A ticket is hex, so + /// there is nothing in it to decode. + static String ticket(String query) { + if (query == null) { + return null; + } + int at = 0; + while (at < query.length()) { + int end = query.indexOf('&', at); + if (end < 0) { + end = query.length(); + } + if (query.startsWith("ticket=", at)) { + return query.substring(at + 7, end); + } + at = end + 1; + } + return null; + } +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/live/LiveHub.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/live/LiveHub.java new file mode 100644 index 00000000000..a9e6f43657c --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/live/LiveHub.java @@ -0,0 +1,211 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.live; + +import com.codename1.backend.Json; +import com.codename1.backend.WebSocketSession; +import com.codename1.backend.annotations.Component; +import com.codenameone.examples.savor.api.DeliveryState; +import com.codenameone.examples.savor.api.OrderState; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/// The open live connections, and what is pushed down them. +/// +/// Four frames, each a small JSON object: +/// +/// - `{"type":"order","id":...,"state":...}` -- an order changed. Sent to its +/// customer, its restaurant's staff, its courier and the admins, who then +/// read the order from the API. +/// - `{"type":"delivery","id":...,"state":...}` -- a delivery changed. Sent to its +/// customer, its courier and the admins, who then read the delivery from the API. +/// The frame carries no more than that on purpose: the API is the one account +/// of a delivery, and a client that missed a frame is put right by its next read. +/// - `{"type":"courier","deliveryId":...,"lat":...,"lng":...,"heading":...}` -- the +/// courier of a delivery moved. Sent to its customer. +/// - `{"type":"fleet","username":...,"online":...,"lat":...}` -- a courier's +/// status or position changed. Sent to the admins. +/// +/// The connections are those of this process. Two instances of the server +/// would each know only their own, and a change made on one would not reach a +/// client connected to the other: running more than one needs a channel +/// between them -- a database LISTEN/NOTIFY, a message broker -- feeding the +/// same three methods. +@Component +public class LiveHub { + private final Map> byUser = + new HashMap>(); + private final List admins = new ArrayList(); + + synchronized void add(String username, boolean admin, WebSocketSession session) { + List open = byUser.get(username); + if (open == null) { + open = new ArrayList(); + byUser.put(username, open); + } + open.add(session); + if (admin) { + admins.add(session); + } + } + + synchronized void remove(String username, WebSocketSession session) { + List open = byUser.get(username); + if (open != null) { + open.remove(session); + if (open.isEmpty()) { + byUser.remove(username); + } + } + admins.remove(session); + } + + /// How many connections are open, for the tests. + public synchronized int connections() { + int total = 0; + for (List open : byUser.values()) { + total += open.size(); + } + return total; + } + + /// Closes every connection an account has open. + public void drop(String username) { + List open; + synchronized (this) { + open = byUser.remove(username); + if (open != null) { + admins.removeAll(open); + } + } + for (int iter = 0; open != null && iter < open.size(); iter++) { + open.get(iter).close(4403, "suspended"); + } + } + + /// Tells a delivery's customer and courier, and the admins, that it changed. + /// `also` is one more account to tell, or empty: the courier an offer was + /// taken back from is no longer the delivery's courier, and still has to hear. + public void deliveryChanged(String id, DeliveryState state, String customer, String courier, String also) { + Map frame = new LinkedHashMap(); + frame.put("type", "delivery"); + frame.put("id", id); + frame.put("state", state.name()); + String text = Json.write(frame); + List to = new ArrayList(); + synchronized (this) { + collect(to, customer); + collect(to, courier); + collect(to, also); + for (int iter = 0; iter < admins.size(); iter++) { + if (!to.contains(admins.get(iter))) { + to.add(admins.get(iter)); + } + } + } + send(to, text); + } + + /// Tells an order's customer, its restaurant's staff, its courier and the + /// admins that it changed. They read the order itself over REST. + public void orderChanged(String id, OrderState state, String customer, List staff, + String courier) { + Map frame = new LinkedHashMap(); + frame.put("type", "order"); + frame.put("id", id); + frame.put("state", state.name()); + String text = Json.write(frame); + List to = new ArrayList(); + synchronized (this) { + collect(to, customer); + collect(to, courier); + for (int iter = 0; staff != null && iter < staff.size(); iter++) { + collect(to, staff.get(iter)); + } + for (int iter = 0; iter < admins.size(); iter++) { + if (!to.contains(admins.get(iter))) { + to.add(admins.get(iter)); + } + } + } + send(to, text); + } + + public void courierMoved(String customer, String deliveryId, double lat, double lng, double heading) { + Map frame = new LinkedHashMap(); + frame.put("type", "courier"); + frame.put("deliveryId", deliveryId); + frame.put("lat", Double.valueOf(lat)); + frame.put("lng", Double.valueOf(lng)); + frame.put("heading", Double.valueOf(heading)); + List to = new ArrayList(); + synchronized (this) { + collect(to, customer); + } + send(to, Json.write(frame)); + } + + public void fleetChanged(String username, boolean online, double lat, double lng, + double heading) { + List to; + synchronized (this) { + if (admins.isEmpty()) { + return; + } + to = new ArrayList(admins); + } + Map frame = new LinkedHashMap(); + frame.put("type", "fleet"); + frame.put("username", username); + frame.put("online", Boolean.valueOf(online)); + frame.put("lat", Double.valueOf(lat)); + frame.put("lng", Double.valueOf(lng)); + frame.put("heading", Double.valueOf(heading)); + send(to, Json.write(frame)); + } + + /// Must be called holding the lock. + private void collect(List to, String username) { + List open = username == null ? null : byUser.get(username); + for (int iter = 0; open != null && iter < open.size(); iter++) { + if (!to.contains(open.get(iter))) { + to.add(open.get(iter)); + } + } + } + + /// Sends outside the lock: a slow client holds up its own frame, not + /// everyone's. One that cannot be written to is dropped; its close callback + /// takes it out of the maps. + private static void send(List to, String text) { + for (int iter = 0; iter < to.size(); iter++) { + WebSocketSession session = to.get(iter); + try { + session.sendText(text); + } catch (IOException gone) { + session.abort(); + } + } + } +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/live/LiveTickets.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/live/LiveTickets.java new file mode 100644 index 00000000000..8cf7d0215b7 --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/live/LiveTickets.java @@ -0,0 +1,103 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.live; + +import com.codename1.backend.Crypto; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Scheduled; +import com.codename1.backend.annotations.Transactional; +import com.codenameone.examples.savor.Ids; +import com.codenameone.examples.savor.api.TicketDto; +import com.codenameone.examples.savor.domain.LiveTicket; +import com.codenameone.examples.savor.repository.LiveTicketRepository; + +import java.io.IOException; + +/// Tickets for the live channel. +/// +/// A WebSocket handshake is one request the app cannot attach its bearer token +/// to on every platform -- a browser's WebSocket takes no headers at all -- and a +/// token in the address would end up in every access log on the way. So the app +/// asks for a ticket over the authenticated API and presents that instead: it +/// is random, it names nothing, it is good for one connection, and it is stale +/// in half a minute. +@Component +public class LiveTickets { + static final int TICKET_SECONDS = 30; + + private final LiveTicketRepository tickets; + + public LiveTickets(LiveTicketRepository tickets) { + this.tickets = tickets; + } + + /// Not held against the account being closed, as the rows that last are. + /// A ticket issued in the instant an account is erased outlives it by at + /// most [#TICKET_SECONDS] plus one [#sweep], and opens nothing meanwhile: + /// `LiveEndpoint` checks the account again when the ticket is redeemed. + /// A row lock on every socket connect is not worth that minute. + @Transactional + public TicketDto issue(String username) throws IOException { + String ticket = Ids.hex(Crypto.randomBytes(24)); + LiveTicket row = new LiveTicket(); + row.setTicketHash(Ids.sha256(ticket)); + row.setUsername(username); + row.setExpiresAt(System.currentTimeMillis() + TICKET_SECONDS * 1000L); + tickets.insert(row); + return new TicketDto(ticket, TICKET_SECONDS); + } + + /// The account a ticket was issued to, or null when it is unknown, used or + /// stale. Redeeming it uses it up: the row is deleted, and only the + /// connection whose delete removed it is let in, so a ticket presented twice + /// at the same moment still opens one connection. + @Transactional + public String redeem(String ticket) throws IOException { + if (ticket == null || ticket.length() == 0 || ticket.length() > 128) { + return null; + } + String hash = Ids.sha256(ticket); + LiveTicket row = tickets.findById(hash); + if (row == null) { + return null; + } + String username = row.getUsername(); + long expiresAt = row.getExpiresAt(); + // One delete statement: the count it answers is what says this + // connection, and not another presenting the same ticket, used it. + int removed = tickets.deleteByTicketHash(hash); + if (removed != 1 || expiresAt < System.currentTimeMillis()) { + return null; + } + return username; + } + + /// Clears the tickets nobody came back for. + @Scheduled(fixedDelay = 60000, initialDelay = 60000) + @Transactional + public void sweep() throws IOException { + tickets.deleteByExpiresAtLessThan(System.currentTimeMillis()); + } + + /// Forgets the tickets of an account that is being closed. + @Transactional + public void forget(String username) { + tickets.deleteByUsername(username); + } +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/onboarding/Applications.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/onboarding/Applications.java new file mode 100644 index 00000000000..64e1c6652ac --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/onboarding/Applications.java @@ -0,0 +1,656 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.onboarding; + +import com.codename1.backend.Base64; +import com.codename1.backend.ResponseStatusException; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Transactional; +import com.codename1.backend.security.GrantedAuthority; +import com.codename1.backend.security.core.userdetails.JdbcUserDetailsManager; +import com.codename1.backend.security.core.userdetails.User; +import com.codename1.backend.security.core.userdetails.UserDetails; +import com.codename1.orm.repository.PageRequest; +import com.codename1.orm.session.Identifier; +import com.codenameone.examples.savor.Days; +import com.codenameone.examples.savor.Roles; +import com.codenameone.examples.savor.Text; +import com.codenameone.examples.savor.account.Moderation; +import com.codenameone.examples.savor.api.DocumentContentDto; +import com.codenameone.examples.savor.api.DocumentDto; +import com.codenameone.examples.savor.api.DocumentUploadDto; +import com.codenameone.examples.savor.api.CourierApplicationDto; +import com.codenameone.examples.savor.domain.CourierApplication; +import com.codenameone.examples.savor.domain.CourierDocument; +import com.codenameone.examples.savor.domain.Profile; +import com.codenameone.examples.savor.repository.CourierApplicationRepository; +import com.codenameone.examples.savor.repository.CourierDocumentRepository; +import com.codenameone.examples.savor.repository.ProfileRepository; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; + +/// Applications to deliver, from the first field filled in to an admin's answer. +/// +/// ``` +/// (none) -> draft -> pending -> approved +/// ^ | +/// +---------+-> rejected -> pending corrected and handed in again +/// ``` +/// +/// The applicant fills in a form and uploads five documents, in any order and +/// over as many visits as it takes; nothing is checked for completeness until +/// they hand it in. An admin then approves it, which is the one thing that +/// grants the COURIER role, or rejects it with a reason the applicant reads +/// before correcting it and handing it in again. +/// +/// `approved` is what the rest of the server reads: going on line and being +/// offered deliveries both ask for it here, and not for the role alone. +@Component +public class Applications { + public static final String NONE = "none"; + public static final String DRAFT = "draft"; + public static final String PENDING = "pending"; + public static final String APPROVED = "approved"; + public static final String REJECTED = "rejected"; + /// The documents an application needs, in the order the app asks for them. + public static final String[] KINDS = {"photo_id", "licence", "selfie", "vehicle_registration", + "insurance"}; + /// The largest picture taken, decoded: 1.5 MB. The app shrinks a photograph + /// well below this before sending it; the cap is for whoever does not. + static final int MAX_DOCUMENT_BYTES = 1572864; + + /// The most pixels a document may have on a side, read from its header. + public static final int MAX_DOCUMENT_SIDE = 4096; + + private static final String[] KIND_NAMES = {"a photo of your ID", "a photo of your licence", + "a selfie", "the vehicle's registration", "proof of insurance"}; + + private static final int LIST_LIMIT = 200; + + private final CourierApplicationRepository applications; + private final CourierDocumentRepository documents; + private final ProfileRepository profiles; + private final JdbcUserDetailsManager users; + private final Moderation moderation; + + public Applications(CourierApplicationRepository applications, + CourierDocumentRepository documents, ProfileRepository profiles, + JdbcUserDetailsManager users, Moderation moderation) { + this.applications = applications; + this.documents = documents; + this.profiles = profiles; + this.users = users; + this.moderation = moderation; + } + + // ------------------------------------------------------------- applicant + + /// Where an account stands: one of the five statuses. + @Transactional(readOnly = true) + public String status(String username) throws IOException { + CourierApplication application = find(username); + return application == null ? NONE : application.getStatus(); + } + + @Transactional(readOnly = true) + public boolean approved(String username) throws IOException { + return APPROVED.equals(status(username)); + } + + /// The status of every account that has an application, by name. + @Transactional(readOnly = true) + public Map statuses() throws IOException { + Map out = new HashMap(); + List rows = applications.statuses(); + for (int iter = 0; iter < rows.size(); iter++) { + Object[] row = rows.get(iter); + out.put((String) row[0], (String) row[1]); + } + return out; + } + + /// An account's application, or one whose status is `none`. + @Transactional(readOnly = true) + public CourierApplicationDto get(String username) throws IOException { + Profile profile = profiles.findById(username); + return toDto(username, profile == null ? "" : profile.getDisplayName(), + find(username), documents.summaries(username), 0); + } + + /// Begins an application as a draft, when the account has none. `vehicle` + /// and `plate` are what an older app collected at registration. + @Transactional + public void open(String username, String vehicle, String plate) throws IOException { + if (find(username) == null) { + applications.insert(blank(username, DRAFT, vehicle, plate)); + } + } + + /// Saves the form. Nothing has to be complete, but what is there has to be + /// well formed. An application being reviewed or already approved is not + /// the applicant's to change. + /// + /// A refusal does not undo the transaction: the draft a first visit begins + /// is kept whether or not what came with it was well formed. Nothing else + /// is written before the last check has passed. + @Transactional(noRollbackFor = ResponseStatusException.class) + public CourierApplicationDto save(String username, CourierApplicationDto form) + throws IOException { + if (form == null) { + throw new ResponseStatusException(400, "Nothing to save"); + } + // Held first, as an upload holds it: what is saved here must not land + // on an application that was handed in after it was read. + hold(username); + CourierApplication application = editable(username); + String birth = date(form.dateOfBirth(), "your date of birth"); + String expiry = date(form.licenceExpiry(), "the licence's expiry date"); + if (form.vehicleYear() < 0 || form.vehicleYear() > 2200) { + throw new ResponseStatusException(400, "Check the vehicle's year"); + } + // Every field is checked before the first is assigned: the application + // is a managed entity, and a field assigned ahead of a refusal would be + // written with the draft. + String legalName = Text.optional(form.legalName(), 120, "your name"); + String licenceNumber = Text.optional(form.licenceNumber(), 40, "the licence number"); + String make = Text.optional(form.vehicleMake(), 40, "the vehicle's make"); + String model = Text.optional(form.vehicleModel(), 40, "the vehicle's model"); + String color = Text.optional(form.vehicleColor(), 24, "the vehicle's colour"); + String plate = Text.optional(form.vehiclePlate(), 32, "the vehicle's plate"); + application.setLegalName(legalName); + application.setDateOfBirth(birth); + application.setLicenceNumber(licenceNumber); + application.setLicenceExpiry(expiry); + application.setVehicleMake(make); + application.setVehicleModel(model); + application.setVehicleYear(form.vehicleYear()); + application.setVehicleColor(color); + application.setVehiclePlate(plate); + return get(username); + } + + /// Stores a document, in place of an earlier one of its kind. + /// + /// What arrives is checked to be what it says: base64 that decodes, to no + /// more than the cap, to bytes that begin as a JPEG or a PNG does. The + /// declared type alone is the caller's word, and this is later sent to an + /// admin's screen to be drawn. + @Transactional + public CourierApplicationDto upload(String username, DocumentUploadDto upload) + throws IOException { + if (upload == null) { + throw new ResponseStatusException(400, "No document to store"); + } + String kind = Text.oneOf(upload.kind(), KINDS, "", "the kind of document"); + if (kind.length() == 0) { + throw new ResponseStatusException(400, "Say which document this is"); + } + String data = upload.dataBase64() == null ? "" : upload.dataBase64().trim(); + // Refused by length first, so an oversized upload is not decoded at all. + if (data.length() == 0 || data.length() > (MAX_DOCUMENT_BYTES / 3 + 1) * 4) { + throw new ResponseStatusException(data.length() == 0 ? 400 : 413, + data.length() == 0 ? "The document is empty" : "That picture is too large"); + } + byte[] bytes = Base64.decode(data); + if (bytes == null) { + throw new ResponseStatusException(400, "The document is not base64"); + } + if (bytes.length > MAX_DOCUMENT_BYTES) { + throw new ResponseStatusException(413, "That picture is too large"); + } + String type; + if (bytes.length > 3 && (bytes[0] & 0xff) == 0xff && (bytes[1] & 0xff) == 0xd8 + && (bytes[2] & 0xff) == 0xff) { + type = "image/jpeg"; + } else if (bytes.length > 8 && (bytes[0] & 0xff) == 0x89 && bytes[1] == 'P' + && bytes[2] == 'N' && bytes[3] == 'G') { + type = "image/png"; + } else { + throw new ResponseStatusException(400, "Send a JPEG or a PNG picture"); + } + // The size the picture says it has, before anyone decodes it. A few + // kilobytes can declare a picture of millions of pixels, and an admin's + // app decoding that runs out of memory: the app sends at most 1280 on + // a side, so anything far past that is refused. + checkSize(bytes, type, "Send a JPEG or a PNG picture"); + // Held before it is read. The check below says the application may + // still be changed, and the picture is stored after it: without the + // hold, an application handed in between the two had a document + // replaced while it was under review, perhaps after an admin had + // looked at the one it replaced. + hold(username); + editable(username); + long now = System.currentTimeMillis(); + // Replaced with a statement where there is one already, so the picture + // it held is not read only to be overwritten. + if (documents.replace(username, kind, type, "uploaded", now, data) == 0) { + CourierDocument document = new CourierDocument(); + document.setUsername(username); + document.setKind(kind); + document.setContentType(type); + document.setStatus("uploaded"); + document.setUploadedAt(now); + document.setData(data); + documents.insert(document); + } + return get(username); + } + + /// A stored document, picture included. The caller decides who may ask: + /// the applicant for their own, an admin for anyone's. + @Transactional(readOnly = true) + public DocumentContentDto document(String username, String kind) throws IOException { + CourierDocument stored = kind == null || kind.length() == 0 || kind.length() > 24 + || username == null || username.length() == 0 ? null + : documents.findById(Identifier.of(username, kind)); + if (stored == null) { + throw new ResponseStatusException(404, "No such document"); + } + return new DocumentContentDto(stored.getKind(), stored.getContentType(), + stored.getData()); + } + + /// Hands the application in. Answers 400, naming what is missing, until it + /// is complete. As in [#save], that refusal keeps the draft a first visit + /// began. + @Transactional(noRollbackFor = ResponseStatusException.class) + public CourierApplicationDto submit(String username) throws IOException { + // Held before the form is read and checked: a save that lands between + // the check and the move below would otherwise hand in an application + // emptied after it was found complete, where it can no longer be + // corrected. Behind the hold a save either went first, and is what is + // checked, or comes after and finds it handed in. + hold(username); + String was = editable(username).getStatus(); + CourierApplicationDto form = get(username); + List missing = new ArrayList(); + String today = Days.iso(System.currentTimeMillis()); + need(missing, form.legalName().length() > 0, "your legal name"); + // Dates in this form compare as text. Eighteen years ago is today with + // the year lowered. + String adult = (Integer.parseInt(today.substring(0, 4)) - 18) + today.substring(4); + need(missing, form.dateOfBirth().length() > 0 && form.dateOfBirth().compareTo(adult) <= 0, + "a date of birth that makes you 18"); + need(missing, form.licenceNumber().length() > 0, "your licence number"); + need(missing, form.licenceExpiry().compareTo(today) >= 0, "a licence that has not expired"); + need(missing, form.vehicleMake().length() > 0 && form.vehicleModel().length() > 0, + "the vehicle's make and model"); + need(missing, form.vehicleYear() >= 1990, "the vehicle's year"); + need(missing, form.vehicleColor().length() > 0, "the vehicle's colour"); + need(missing, form.vehiclePlate().length() > 0, "the vehicle's plate"); + for (int iter = 0; iter < form.documents().size(); iter++) { + need(missing, !"missing".equals(form.documents().get(iter).status()), KIND_NAMES[iter]); + } + if (!missing.isEmpty()) { + StringBuilder what = new StringBuilder("Still needed: "); + for (int iter = 0; iter < missing.size(); iter++) { + what.append(iter == 0 ? "" : ", ").append(missing.get(iter)); + } + throw new ResponseStatusException(400, what.toString()); + } + // Held to the status it was read in: handed in twice, it is handed in once. + applications.submit(username, was, PENDING, System.currentTimeMillis()); + return get(username); + } + + // ----------------------------------------------------------------- admin + + /// Applications, the longest waiting first among those waiting. `status` + /// narrows them to one status; empty lists all of them. + @Transactional(readOnly = true) + public List list(String status) throws IOException { + String wanted = Text.oneOf(status, new String[] {DRAFT, PENDING, APPROVED, REJECTED}, "", + "an application's status"); + PageRequest page = new PageRequest(0, LIST_LIMIT); + List rows = wanted.length() == 0 ? applications.newest(page) + : applications.inStatus(wanted, page); + List out = new ArrayList(); + if (rows.isEmpty()) { + return out; + } + List owners = new ArrayList(); + for (int iter = 0; iter < rows.size(); iter++) { + owners.add(rows.get(iter).getUsername()); + } + // The applicants' names in one query, and the documents of all of them + // in another, without the pictures. + Map names = new HashMap(); + List named = profiles.displayNames(owners); + for (int iter = 0; iter < named.size(); iter++) { + Object[] row = named.get(iter); + names.put((String) row[0], (String) row[1]); + } + Map> byOwner = new HashMap>(); + List all = documents.summaries(owners); + for (int iter = 0; iter < all.size(); iter++) { + Object[] row = all.get(iter); + String owner = (String) row[0]; + List held = byOwner.get(owner); + if (held == null) { + held = new ArrayList(); + byOwner.put(owner, held); + } + held.add(row); + } + for (int iter = 0; iter < rows.size(); iter++) { + CourierApplication row = rows.get(iter); + String name = names.get(row.getUsername()); + List held = byOwner.get(row.getUsername()); + out.add(toDto(row.getUsername(), name == null ? "" : name, row, + held == null ? new ArrayList() : held, 1)); + } + return out; + } + + /// How many applications are waiting for an admin's answer. + @Transactional(readOnly = true) + public int waiting() throws IOException { + return (int) applications.countByStatus(PENDING); + } + + /// An application for an admin: 404 for an account that never began one. + @Transactional(readOnly = true) + public CourierApplicationDto review(String username) throws IOException { + if (find(username) == null) { + throw new ResponseStatusException(404, "No such application"); + } + return get(username); + } + + /// Approves an application that was handed in: the account becomes a + /// courier, with the car on the application as the car customers look for. + @Transactional + public CourierApplicationDto approve(String by, String username) throws IOException { + // The account first, the lock blocking an account takes too: role() + // rewrites the whole user row, its enabled flag with it, so an approval + // that read the account before a block and wrote it after would undo + // the block. Behind the hold it reads what the block left. + profiles.hold(username); + decide(by, username, APPROVED, ""); + documents.markAll(username, "accepted"); + // Read after markAll, which empties the session. + CourierApplication application = find(username); + Profile profile = profiles.findById(username); + if (application.getVehicleMake().length() > 0 && profile != null) { + String car = (application.getVehicleColor() + " " + application.getVehicleMake() + " " + + application.getVehicleModel()).trim(); + profile.setVehicle(car.length() > 120 ? car.substring(0, 120) : car); + profile.setPlate(application.getVehiclePlate()); + } + role(username, true); + moderation.record(username, by, Moderation.APPROVE, ""); + return get(username); + } + + /// Rejects an application that was handed in, with the reason the + /// applicant is shown. + @Transactional + public CourierApplicationDto reject(String by, String username, String reason) + throws IOException { + String why = Text.required(reason, 255, "why it is rejected"); + decide(by, username, REJECTED, why); + moderation.record(username, by, Moderation.REJECT, why); + return get(username); + } + + /// Makes an account an approved courier without an application having been + /// handed in: the admin's own switch, and the demo accounts. Whatever + /// application the account has is marked approved, and one is made if it + /// has none, so that "approved" has one meaning everywhere. + @Transactional + public void grant(String by, String username) throws IOException { + if (find(username) == null) { + applications.insert(blank(username, APPROVED, "", "")); + return; + } + applications.approveUnlessApproved(username, APPROVED, System.currentTimeMillis(), by); + } + + /// Takes the approval back, when an admin withdraws the COURIER role. The + /// application is left rejected, so it can be corrected and handed in again. + @Transactional + public void withdraw(String by, String username) throws IOException { + applications.move(username, APPROVED, REJECTED, "Onboarding was withdrawn by an admin", + System.currentTimeMillis(), by); + } + + /// Everything kept about an account's application, for an account being + /// closed. + @Transactional + public void forget(String username) throws IOException { + documents.deleteByUsername(username); + applications.deleteByUsername(username); + } + + // ------------------------------------------------------------- internals + + /// The one conditional update behind both answers: it changes an + /// application only while it is waiting, so of two admins answering at + /// once one decides and the other is told it has been decided. + private void decide(String by, String username, String to, String reason) + throws IOException { + if (find(username) == null) { + throw new ResponseStatusException(404, "No such application"); + } + if (applications.move(username, PENDING, to, reason, System.currentTimeMillis(), by) + != 1) { + throw new ResponseStatusException(409, "That application is not waiting for an answer"); + } + } + + /// Grants or withdraws the COURIER role, leaving the account's other roles + /// as they are. + private void role(String username, boolean courier) { + if (!users.userExists(username)) { + throw new ResponseStatusException(404, "No such account"); + } + UserDetails signIn = users.loadUserByUsername(username); + String wanted = "ROLE_" + Roles.COURIER; + List held = new ArrayList(); + Iterator it = signIn.getAuthorities().iterator(); + while (it.hasNext()) { + String authority = it.next().getAuthority(); + if (!wanted.equals(authority)) { + held.add(authority); + } + } + if (courier) { + held.add(wanted); + } + users.updateUser(User.withUserDetails(signIn) + .authorities(held.toArray(new String[held.size()])).build()); + } + + /// The application, made as a draft if there is none, when the applicant + /// may still change it; 409 otherwise. + /// Takes turns with every other change to an account's application. One + /// that has none yet has no row to hold: the first save, upload or + /// hand-in makes it, and two at once would both insert one under the same + /// name, so they take turns on the profile instead. + /// + /// No profile to hold means Accounts.erase committed first, after + /// forget() had already deleted the application and its documents: going + /// on would make a new draft and store identity documents for an account + /// that no longer exists, which nothing would ever delete. 404 instead, as + /// PushDevices.register does. + private void hold(String username) { + if (applications.hold(username) == 0 && profiles.hold(username) == 0) { + throw new ResponseStatusException(404, "No such account"); + } + } + + /// An account's application, or null when it has none. + private CourierApplication find(String username) { + if (username == null || username.length() == 0 || username.length() > 190) { + return null; + } + return applications.findById(username); + } + + private CourierApplication editable(String username) { + CourierApplication application = find(username); + if (application == null) { + application = blank(username, DRAFT, "", ""); + applications.insert(application); + } + if (PENDING.equals(application.getStatus())) { + throw new ResponseStatusException(409, "Your application is being reviewed"); + } + if (APPROVED.equals(application.getStatus())) { + throw new ResponseStatusException(409, "Your application was approved"); + } + return application; + } + + /// A new application with nothing filled in but what an older app + /// collected at registration. One made already approved was reviewed the + /// moment it was made. + private static CourierApplication blank(String username, String status, String vehicle, + String plate) { + long now = System.currentTimeMillis(); + CourierApplication application = new CourierApplication(); + application.setUsername(username); + application.setStatus(status); + application.setVehicleModel(vehicle.length() > 40 ? vehicle.substring(0, 40) : vehicle); + application.setVehiclePlate(plate); + application.setCreatedAt(now); + application.setReviewedAt(APPROVED.equals(status) ? now : 0L); + return application; + } + + private static void need(List missing, boolean present, String what) { + if (!present) { + missing.add(what); + } + } + + /// A date field: empty, or `yyyy-MM-dd`. + private static String date(String value, String what) { + String text = value == null ? "" : value.trim(); + if (text.length() > 0 && !Days.isIso(text)) { + throw new ResponseStatusException(400, "Write " + what + " as yyyy-MM-dd"); + } + return text; + } + + /// `documents` holds `{kind, status, uploadedAt}` from the index `from` on: + /// a list read for several accounts has the owner's name in front. + private static CourierApplicationDto toDto(String username, String name, + CourierApplication row, List documents, int from) { + List listed = new ArrayList(); + for (int kind = 0; kind < KINDS.length; kind++) { + String status = "missing"; + long uploadedAt = 0L; + for (int iter = 0; iter < documents.size(); iter++) { + Object[] stored = documents.get(iter); + if (KINDS[kind].equals(stored[from])) { + status = (String) stored[from + 1]; + Object at = stored[from + 2]; + uploadedAt = at instanceof Number ? ((Number) at).longValue() : 0L; + } + } + listed.add(new DocumentDto(KINDS[kind], status, uploadedAt)); + } + if (row == null) { + // Nobody has applied: an empty form, on the defaults it opens with. + return new CourierApplicationDto(username, name, NONE, "", "", "", "", "", "", 0, "", + "", listed, "", 0L, 0L, ""); + } + return new CourierApplicationDto(username, name, row.getStatus(), row.getLegalName(), + row.getDateOfBirth(), row.getLicenceNumber(), row.getLicenceExpiry(), + row.getVehicleMake(), row.getVehicleModel(), row.getVehicleYear(), + row.getVehicleColor(), row.getVehiclePlate(), listed, + row.getRejectionReason(), row.getSubmittedAt(), row.getReviewedAt(), + row.getReviewedBy()); + } + + /// Refuses a picture whose header declares no size, or more than + /// [#MAX_DOCUMENT_SIDE] pixels on a side, before anyone decodes it. + /// `type` is `image/png` or `image/jpeg`; `unreadable` is the message for + /// a header that cannot be read. + public static void checkSize(byte[] bytes, String type, String unreadable) { + int[] size = "image/png".equals(type) ? pngSize(bytes) : jpegSize(bytes); + if (size == null || size[0] <= 0 || size[1] <= 0) { + throw new ResponseStatusException(400, unreadable); + } + if (size[0] > MAX_DOCUMENT_SIDE || size[1] > MAX_DOCUMENT_SIDE) { + throw new ResponseStatusException(413, "That picture is too large"); + } + } + + /// Width and height from a PNG's header chunk, or null without one. + static int[] pngSize(byte[] bytes) { + if (bytes.length < 24 || bytes[12] != 'I' || bytes[13] != 'H' || bytes[14] != 'D' + || bytes[15] != 'R') { + return null; + } + return new int[] {int32(bytes, 16), int32(bytes, 20)}; + } + + /// Width and height from a JPEG's frame header, or null when the scan + /// starts, or the bytes run out, before one. + static int[] jpegSize(byte[] bytes) { + int at = 2; + while (at + 3 < bytes.length) { + if ((bytes[at] & 0xff) != 0xff) { + return null; + } + int marker = bytes[at + 1] & 0xff; + if (marker == 0xff) { + // Fill before a marker. + at++; + continue; + } + if (marker == 0x01 || (marker >= 0xd0 && marker <= 0xd8)) { + // Markers that carry no length. + at += 2; + continue; + } + if (marker == 0xda || marker == 0xd9) { + return null; + } + int length = ((bytes[at + 2] & 0xff) << 8) | (bytes[at + 3] & 0xff); + boolean frame = marker >= 0xc0 && marker <= 0xcf && marker != 0xc4 + && marker != 0xc8 && marker != 0xcc; + if (frame) { + if (at + 8 >= bytes.length) { + return null; + } + int height = ((bytes[at + 5] & 0xff) << 8) | (bytes[at + 6] & 0xff); + int width = ((bytes[at + 7] & 0xff) << 8) | (bytes[at + 8] & 0xff); + return new int[] {width, height}; + } + if (length < 2) { + return null; + } + at += 2 + length; + } + return null; + } + + private static int int32(byte[] bytes, int at) { + return ((bytes[at] & 0xff) << 24) | ((bytes[at + 1] & 0xff) << 16) + | ((bytes[at + 2] & 0xff) << 8) | (bytes[at + 3] & 0xff); + } +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/onboarding/OnboardingEndpoint.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/onboarding/OnboardingEndpoint.java new file mode 100644 index 00000000000..9cc52815f84 --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/onboarding/OnboardingEndpoint.java @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.onboarding; + +import com.codename1.backend.annotations.Component; +import com.codenameone.examples.savor.Caller; +import com.codenameone.examples.savor.api.DocumentContentDto; +import com.codenameone.examples.savor.api.DocumentUploadDto; +import com.codenameone.examples.savor.api.CourierApplicationDto; +import com.codenameone.examples.savor.api.OnboardingApiServer; + +/// The server's half of `OnboardingApi`. +/// +/// Open to anyone signed in, which is why its paths are not under +/// `/api/courier`: applying is what someone does before they hold the COURIER +/// role. What keeps one applicant out of another's application -- and out of +/// the identity documents in it -- is that no method here takes a name. Each +/// acts on the application of whoever is signed in, and there is no parameter +/// to change. +@Component +public class OnboardingEndpoint implements OnboardingApiServer { + private final Applications applications; + + public OnboardingEndpoint(Applications applications) { + this.applications = applications; + } + + @Override + public CourierApplicationDto application() throws Exception { + return applications.get(Caller.name()); + } + + @Override + public CourierApplicationDto save(CourierApplicationDto application) throws Exception { + return applications.save(Caller.name(), application); + } + + @Override + public CourierApplicationDto upload(DocumentUploadDto document) throws Exception { + return applications.upload(Caller.name(), document); + } + + @Override + public DocumentContentDto document(String kind) throws Exception { + return applications.document(Caller.name(), kind); + } + + @Override + public CourierApplicationDto submit() throws Exception { + return applications.submit(Caller.name()); + } +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/order/Addresses.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/order/Addresses.java new file mode 100644 index 00000000000..48261e9e938 --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/order/Addresses.java @@ -0,0 +1,112 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.order; + +import com.codename1.backend.ResponseStatusException; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Transactional; +import com.codenameone.examples.savor.Ids; +import com.codenameone.examples.savor.Text; +import com.codenameone.examples.savor.api.SavedAddressDto; +import com.codenameone.examples.savor.domain.SavedAddress; +import com.codenameone.examples.savor.geo.Geo; +import com.codenameone.examples.savor.repository.ProfileRepository; +import com.codenameone.examples.savor.repository.SavedAddressRepository; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +/// The places a customer has food brought to: home, work, a friend's. +@Component +public class Addresses { + static final int MAX = 20; + + private final SavedAddressRepository addresses; + private final ProfileRepository profiles; + + public Addresses(SavedAddressRepository addresses, ProfileRepository profiles) { + this.addresses = addresses; + this.profiles = profiles; + } + + @Transactional(readOnly = true) + public List list(String username) { + List rows = addresses.findByUsernameOrderByCreatedAtAsc(username); + List out = new ArrayList(); + for (int iter = 0; iter < rows.size(); iter++) { + out.add(toDto(rows.get(iter))); + } + return out; + } + + /// Saves a new address, or changes one of the account's own when it names one. + @Transactional + public SavedAddressDto save(String username, SavedAddressDto form) throws IOException { + if (form == null || !Geo.isPoint(form.lat(), form.lng())) { + throw new ResponseStatusException(400, "Choose the place on the map"); + } + SavedAddress row; + if (form.id() != null && form.id().length() > 0) { + row = own(username, form.id()); + } else { + // Under the account's lock, which closing it takes too: an address + // written as the account closes would outlive it, under a retired + // name, with nobody left to remove it. + if (profiles.hold(username) == 0) { + throw new ResponseStatusException(404, "No such account"); + } + if (addresses.countByUsername(username) >= MAX) { + throw new ResponseStatusException(409, "That is enough saved addresses"); + } + row = new SavedAddress(); + row.setId(Ids.next()); + row.setUsername(username); + row.setCreatedAt(System.currentTimeMillis()); + } + row.setLabel(Text.required(form.label(), 40, "a name for the place")); + row.setAddress(Text.required(form.address(), 255, "the address")); + row.setNote(Text.optional(form.note(), 255, "the note for the courier")); + row.setLat(form.lat()); + row.setLng(form.lng()); + if (form.id() == null || form.id().length() == 0) { + addresses.insert(row); + } + return toDto(row); + } + + @Transactional + public SavedAddressDto remove(String username, String id) { + SavedAddress row = own(username, id); + addresses.deleteById(id); + return toDto(row); + } + + private SavedAddress own(String username, String id) { + SavedAddress row = id == null || id.length() > 32 ? null : addresses.findById(id); + if (row == null || !username.equals(row.getUsername())) { + throw new ResponseStatusException(404, "No such address"); + } + return row; + } + + private static SavedAddressDto toDto(SavedAddress row) { + return new SavedAddressDto(row.getId(), row.getLabel(), row.getAddress(), row.getLat(), + row.getLng(), row.getNote()); + } +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/order/DemoKitchen.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/order/DemoKitchen.java new file mode 100644 index 00000000000..de13aa5c456 --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/order/DemoKitchen.java @@ -0,0 +1,99 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.order; + +import com.codename1.backend.Config; +import com.codename1.backend.ResponseStatusException; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Scheduled; +import com.codename1.backend.annotations.Transactional; +import com.codenameone.examples.savor.api.OrderState; +import com.codenameone.examples.savor.domain.CustomerOrder; +import com.codenameone.examples.savor.repository.CustomerOrderRepository; +import com.codenameone.examples.savor.repository.RestaurantStaffRepository; +import java.io.IOException; +import java.util.List; + +/// The kitchens of the demo restaurants nobody runs, on a development server: +/// it accepts their orders a few seconds after they come in and has them ready +/// half a minute later, so a customer can order anywhere and watch it arrive. +/// A restaurant with staff is left to its staff. +/// +/// Off on every other profile, the `test` one included: the tests move orders +/// themselves and must not race a kitchen. +@Component +public class DemoKitchen { + static final long ACCEPT_AFTER_MILLIS = 8000L; + static final long READY_AFTER_MILLIS = 30000L; + static final String ACTOR = "kitchen"; + + private final CustomerOrderRepository orders; + private final RestaurantStaffRepository staff; + private final Orders kitchen; + private final boolean on; + + public DemoKitchen(CustomerOrderRepository orders, RestaurantStaffRepository staff, + Orders kitchen, Config config) { + this.orders = orders; + this.staff = staff; + this.kitchen = kitchen; + on = config.isDevelopmentProfile() && !"test".equals(config.getProfile()); + } + + @Scheduled(fixedDelay = 3000, initialDelay = 5000, lock = "savor-demo-kitchen") + public void cook() throws IOException { + if (!on) { + return; + } + long now = System.currentTimeMillis(); + List waiting = unattended(OrderState.PLACED, now - ACCEPT_AFTER_MILLIS); + for (int iter = 0; iter < waiting.size(); iter++) { + CustomerOrder order = waiting.get(iter); + try { + kitchen.accept(ACTOR, order.getRestaurantId(), order.getId(), + order.getPrepMinutes() < 1 ? 10 : order.getPrepMinutes()); + } catch (ResponseStatusException moved) { + // Cancelled or turned down in the meantime. + } + } + List cooking = unattended(OrderState.ACCEPTED, now - READY_AFTER_MILLIS); + for (int iter = 0; iter < cooking.size(); iter++) { + CustomerOrder order = cooking.get(iter); + try { + kitchen.ready(ACTOR, order.getRestaurantId(), order.getId()); + } catch (ResponseStatusException moved) { + // Cancelled by an admin in the meantime. + } + } + } + + /// Orders in `state` since before `before` at restaurants with no staff. + @Transactional(readOnly = true) + private List unattended(OrderState state, long before) { + List rows = OrderState.PLACED == state + ? orders.findByStateAndPlacedAtLessThan(state, before) + : orders.findByStateAndAcceptedAtLessThan(state, before); + for (int iter = rows.size() - 1; iter >= 0; iter--) { + if (!staff.findByRestaurantId(rows.get(iter).getRestaurantId()).isEmpty()) { + rows.remove(iter); + } + } + return rows; + } +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/order/DemoOrders.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/order/DemoOrders.java new file mode 100644 index 00000000000..82a3b972b2f --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/order/DemoOrders.java @@ -0,0 +1,387 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.order; + +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Transactional; +import com.codenameone.examples.savor.Days; +import com.codenameone.examples.savor.Ids; +import com.codenameone.examples.savor.api.DeliveryState; +import com.codenameone.examples.savor.api.OrderState; +import com.codenameone.examples.savor.api.PricingDto; +import com.codenameone.examples.savor.domain.CourierState; +import com.codenameone.examples.savor.domain.CustomerOrder; +import com.codenameone.examples.savor.domain.Delivery; +import com.codenameone.examples.savor.domain.DeliveryEvent; +import com.codenameone.examples.savor.domain.Dish; +import com.codenameone.examples.savor.domain.OrderEvent; +import com.codenameone.examples.savor.domain.OrderLine; +import com.codenameone.examples.savor.domain.Payment; +import com.codenameone.examples.savor.domain.Payout; +import com.codenameone.examples.savor.domain.PayoutAccount; +import com.codenameone.examples.savor.domain.Restaurant; +import com.codenameone.examples.savor.pay.Payments; +import com.codenameone.examples.savor.repository.CourierStateRepository; +import com.codenameone.examples.savor.repository.CustomerOrderRepository; +import com.codenameone.examples.savor.repository.DeliveryEventRepository; +import com.codenameone.examples.savor.repository.DeliveryRepository; +import com.codenameone.examples.savor.repository.DishRepository; +import com.codenameone.examples.savor.repository.OrderEventRepository; +import com.codenameone.examples.savor.repository.OrderLineRepository; +import com.codenameone.examples.savor.repository.PaymentRepository; +import com.codenameone.examples.savor.repository.PayoutAccountRepository; +import com.codenameone.examples.savor.repository.PayoutRepository; +import com.codenameone.examples.savor.repository.RestaurantRepository; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +/// A fortnight of finished orders for a development server, so the dashboards, +/// the histories and the couriers' earnings have something in them from the +/// first start. Written straight to the tables, as the services would have left +/// them, with a fixed sequence so every runtime writes the same fortnight. +@Component +public class DemoOrders { + private static final int DAYS = 14; + /// Where the demo customers have food brought to. + private static final Object[][] DOORS = { + {"Coit Tower", 37.8024, -122.4058}, + {"Palace of Fine Arts", 37.8029, -122.4484}, + {"Union Square", 37.7880, -122.4075}, + {"Fort Mason", 37.8066, -122.4310}, + {"Transamerica Pyramid", 37.7952, -122.4028}, + {"Russian Hill", 37.8011, -122.4194}, + {"North Beach", 37.8061, -122.4103}}; + /// Lunch and dinner, the busy hours more than once. + private static final int[] HOURS = {8, 11, 12, 12, 13, 13, 15, 18, 18, 19, 19, 20, 21, 22}; + + private final RestaurantRepository restaurants; + private final DishRepository dishes; + private final CustomerOrderRepository orders; + private final OrderLineRepository lines; + private final OrderEventRepository orderEvents; + private final DeliveryRepository deliveries; + private final DeliveryEventRepository deliveryEvents; + private final PaymentRepository paid; + private final CourierStateRepository couriersState; + private final PayoutAccountRepository payoutAccounts; + private final PayoutRepository payouts; + private final Quotes quotes; + private final Payments payments; + private long sequence = 20240229L; + + public DemoOrders(RestaurantRepository restaurants, DishRepository dishes, + CustomerOrderRepository orders, OrderLineRepository lines, + OrderEventRepository orderEvents, DeliveryRepository deliveries, + DeliveryEventRepository deliveryEvents, PaymentRepository paid, + CourierStateRepository couriersState, PayoutAccountRepository payoutAccounts, + PayoutRepository payouts, Quotes quotes, Payments payments) { + this.restaurants = restaurants; + this.dishes = dishes; + this.orders = orders; + this.lines = lines; + this.orderEvents = orderEvents; + this.deliveries = deliveries; + this.deliveryEvents = deliveryEvents; + this.paid = paid; + this.couriersState = couriersState; + this.payoutAccounts = payoutAccounts; + this.payouts = payouts; + this.quotes = quotes; + this.payments = payments; + } + + /// Writes the fortnight, shared out among these customers, couriers and + /// every approved restaurant. It ends yesterday, so today's numbers are + /// only what is ordered today. + @Transactional + public void seed(String[] customers, String[] couriers) throws IOException { + List kitchens = restaurants.findByStatusOrderByCreatedAtDesc("APPROVED"); + if (kitchens.isEmpty()) { + return; + } + List> menus = new ArrayList>(); + for (int iter = 0; iter < kitchens.size(); iter++) { + menus.add(dishes.findByRestaurantIdOrderByPositionAsc(kitchens.get(iter).getId())); + } + long now = System.currentTimeMillis(); + long today = Days.start(now) - Days.MILLIS; + PricingDto pricing = quotes.pricing(); + long[] stars = new long[couriers.length]; + long[] rated = new long[couriers.length]; + long[] earnedEarlier = new long[couriers.length]; + long[] lastSeen = new long[couriers.length]; + long[] kitchenStars = new long[kitchens.size()]; + long[] kitchenRated = new long[kitchens.size()]; + for (int day = DAYS - 1; day >= 0; day--) { + int placed = 4 + next(5); + for (int number = 0; number < placed; number++) { + String customer = customers[next(customers.length)]; + int courierIndex = next(couriers.length); + String courier = couriers[courierIndex]; + int kitchenIndex = next(kitchens.size()); + Restaurant kitchen = kitchens.get(kitchenIndex); + List menu = menus.get(kitchenIndex); + if (menu.isEmpty()) { + continue; + } + int door = next(DOORS.length); + double meters = Quotes.roadMeters(kitchen.getLat(), kitchen.getLng(), + real(door, 1), real(door, 2)); + long at = today - day * Days.MILLIS + HOURS[next(HOURS.length)] * 3600000L + + next(3600) * 1000L; + long prep = kitchen.getPrepMinutes() * 60000L; + long ride = Quotes.travelMinutes(meters) * 60000L; + + int roll = next(20); + OrderState state = roll < 16 ? OrderState.DELIVERED + : roll == 16 ? OrderState.REJECTED + : roll == 17 ? OrderState.NO_COURIERS : OrderState.CANCELLED_BY_CUSTOMER; + boolean delivered = OrderState.DELIVERED == state; + String[] method = next(4) == 0 ? new String[] {Payments.CASH, Payments.CASH_LABEL} + : payments.choose(customer, null); + boolean cash = Payments.CASH.equals(method[0]); + + String id = Ids.next(); + long subtotal = 0L; + int count = 1 + next(3); + for (int line = 0; line < count; line++) { + Dish dish = menu.get(next(menu.size())); + int quantity = 1 + next(2); + OrderLine row = new OrderLine(); + row.setId(Ids.next()); + row.setOrderId(id); + row.setDishId(dish.getId()); + row.setName(dish.getName()); + row.setOptions(""); + row.setQuantity(quantity); + row.setUnitCents(dish.getPriceCents()); + row.setLineCents(dish.getPriceCents() * quantity); + row.setPosition(line); + subtotal += row.getLineCents(); + lines.insert(row); + } + long fee = Quotes.deliveryFee(pricing, meters); + long small = subtotal < pricing.smallOrderCents() ? pricing.smallOrderFeeCents() + : 0L; + long service = Math.round(subtotal * pricing.serviceFeePercent() / 100d); + long tax = Math.round(subtotal * pricing.taxPercent() / 100d); + long tip = delivered && !cash && next(3) != 0 ? subtotal * 15L / 100L : 0L; + long total = subtotal + fee + small + service + tax + tip; + long accepted = at + (60 + next(120)) * 1000L; + long ready = accepted + prep; + long picked = ready + (60 + next(240)) * 1000L; + long ended = delivered ? picked + ride + : OrderState.REJECTED == state ? at + 120000L + : OrderState.NO_COURIERS == state ? accepted + 900000L : at + 45000L; + int given = 0; + if (delivered && next(4) != 0) { + int liked = next(10); + given = liked == 0 ? 3 : liked < 4 ? 4 : 5; + stars[courierIndex] += given; + rated[courierIndex]++; + kitchenStars[kitchenIndex] += given; + kitchenRated[kitchenIndex]++; + } + + CustomerOrder order = new CustomerOrder(); + order.setId(id); + order.setCustomer(customer); + order.setRestaurantId(kitchen.getId()); + order.setRestaurantName(kitchen.getName()); + order.setState(state); + order.setSubtotalCents(subtotal); + order.setDeliveryFeeCents(fee); + order.setServiceFeeCents(service); + order.setSmallOrderCents(small); + order.setTaxCents(tax); + order.setTipCents(tip); + order.setTotalCents(total); + order.setCommissionCents(Quotes.commission(pricing, subtotal)); + order.setCurrency(pricing.currency()); + order.setPromoCode(""); + order.setDropoffLat(real(door, 1)); + order.setDropoffLng(real(door, 2)); + order.setDropoffAddress(String.valueOf(DOORS[door][0])); + order.setNote(""); + order.setMethodId(method[0]); + order.setMethodLabel(method[1]); + order.setAuthorizationRef(cash ? Payments.CASH : "sim_ch_order-" + id); + order.setPaymentStatus(delivered ? Payments.PAID + : cash ? Payments.PENDING : Payments.RELEASED); + order.setPrepMinutes(kitchen.getPrepMinutes()); + order.setPlacedAt(at); + order.setUpdatedAt(ended); + order.setAcceptedAt(OrderState.REJECTED == state + || OrderState.CANCELLED_BY_CUSTOMER == state ? 0L : accepted); + order.setReadyAt(delivered ? ready : 0L); + order.setDeliveredAt(delivered ? ended : 0L); + order.setReason(OrderState.REJECTED == state ? "Out of ingredients" + : OrderState.NO_COURIERS == state ? "No courier was free to bring it" + : OrderState.CANCELLED_BY_CUSTOMER == state ? "Cancelled by the customer" + : ""); + order.setStars(given); + orders.insert(order); + orderEvent(id, OrderState.PLACED, customer, at); + if (OrderState.REJECTED == state) { + orderEvent(id, state, "", ended); + continue; + } + if (OrderState.CANCELLED_BY_CUSTOMER == state) { + orderEvent(id, state, customer, ended); + continue; + } + orderEvent(id, OrderState.ACCEPTED, "", accepted); + + Delivery delivery = new Delivery(); + delivery.setId(id); + delivery.setState(delivered ? DeliveryState.COMPLETED : DeliveryState.NO_COURIERS); + delivery.setCustomer(customer); + delivery.setCourier(delivered ? courier : ""); + delivery.setPickupLat(kitchen.getLat()); + delivery.setPickupLng(kitchen.getLng()); + delivery.setPickupAddress(kitchen.getName() + ", " + kitchen.getAddress()); + delivery.setDropoffLat(real(door, 1)); + delivery.setDropoffLng(real(door, 2)); + delivery.setDropoffAddress(String.valueOf(DOORS[door][0])); + delivery.setFareCents(fee); + delivery.setFeeCents(fee); + // As Deliveries.dispatch writes it: the checkout tip is the + // order's, and the delivery's own tip is one added after it + // arrived -- a receipt adds that one on top. + delivery.setTipCents(0L); + delivery.setCourierCents(fee + tip); + delivery.setCurrency(pricing.currency()); + delivery.setDistanceMeters(meters); + delivery.setDurationSeconds(meters / Quotes.METERS_PER_SECOND); + delivery.setRequestedAt(accepted); + delivery.setUpdatedAt(ended); + delivery.setSearchUntil(accepted + 900000L); + delivery.setStars(given); + delivery.setPaymentStatus(delivered ? Payments.PAID : Payments.PENDING); + delivery.setMethodId(method[0]); + delivery.setMethodLabel(method[1]); + delivery.setNote(""); + deliveries.insert(delivery); + deliveryEvent(id, DeliveryState.REQUESTED, "", accepted); + if (!delivered) { + deliveryEvent(id, DeliveryState.NO_COURIERS, "", ended); + orderEvent(id, OrderState.NO_COURIERS, "", ended); + continue; + } + deliveryEvent(id, DeliveryState.OFFERED, "", accepted + 2000L); + deliveryEvent(id, DeliveryState.ACCEPTED, courier, accepted + 20000L); + orderEvent(id, OrderState.READY, "", ready); + deliveryEvent(id, DeliveryState.ARRIVED, courier, ready - 60000L); + deliveryEvent(id, DeliveryState.IN_PROGRESS, courier, picked); + orderEvent(id, OrderState.PICKED_UP, courier, picked); + deliveryEvent(id, DeliveryState.COMPLETED, courier, ended); + orderEvent(id, OrderState.DELIVERED, courier, ended); + Payment payment = new Payment(); + payment.setDeliveryId(id); + payment.setAmountCents(total); + payment.setCurrency(pricing.currency()); + payment.setStatus(Payments.PAID); + payment.setProviderRef(cash ? Payments.CASH : "sim_ch_order-" + id); + payment.setTipRef(""); + payment.setCreatedAt(ended); + paid.insert(payment); + lastSeen[courierIndex] = Math.max(lastSeen[courierIndex], ended); + if (ended < now - 7L * Days.MILLIS) { + earnedEarlier[courierIndex] += fee + tip; + } + } + } + for (int iter = 0; iter < kitchens.size(); iter++) { + if (kitchenRated[iter] > 0L) { + Restaurant kitchen = restaurants.findById(kitchens.get(iter).getId()); + kitchen.setRatingTotal(kitchen.getRatingTotal() + kitchenStars[iter]); + kitchen.setRatingCount(kitchen.getRatingCount() + kitchenRated[iter]); + } + } + for (int iter = 0; iter < couriers.length; iter++) { + CourierState state = couriersState.findById(couriers[iter]); + boolean first = state == null; + if (first) { + state = new CourierState(); + state.setUsername(couriers[iter]); + } + state.setLat(real(iter % DOORS.length, 1)); + state.setLng(real(iter % DOORS.length, 2)); + state.setLastSeen(lastSeen[iter]); + state.setRatingSum(stars[iter]); + state.setRatingCount(rated[iter]); + if (first) { + couriersState.insert(state); + } + if (earnedEarlier[iter] > 0L) { + // What the first week earned was cashed out at the end of it. + PayoutAccount account = new PayoutAccount(); + account.setUsername(couriers[iter]); + account.setHolder(couriers[iter]); + account.setBankName("Bay Credit Union"); + account.setLast4("67" + (10 + iter)); + payoutAccounts.insert(account); + Payout payout = new Payout(); + payout.setId(Ids.next()); + payout.setCourier(couriers[iter]); + payout.setAmountCents(earnedEarlier[iter]); + payout.setStatus("paid"); + payout.setCreatedAt(now - 7L * Days.MILLIS); + payout.setDestination("Bay Credit Union ****67" + (10 + iter)); + payouts.insert(payout); + } + } + } + + private void orderEvent(String order, OrderState state, String actor, long at) + throws IOException { + OrderEvent event = new OrderEvent(); + event.setId(Ids.next()); + event.setOrderId(order); + event.setState(state); + event.setActor(actor); + event.setCreatedAt(at); + orderEvents.insert(event); + } + + private void deliveryEvent(String delivery, DeliveryState state, String actor, long at) + throws IOException { + DeliveryEvent event = new DeliveryEvent(); + event.setId(Ids.next()); + event.setDeliveryId(delivery); + event.setState(state); + event.setActor(actor); + event.setCreatedAt(at); + deliveryEvents.insert(event); + } + + private static double real(int place, int column) { + Object value = DOORS[place][column]; + return value instanceof Number ? ((Number) value).doubleValue() : 0d; + } + + /// The next number of the sequence, from 0 to `below` less one: a linear + /// congruential generator, which is all a made-up fortnight needs and is + /// the same on every runtime. + private int next(int below) { + sequence = (sequence * 1103515245L + 12345L) & 0x7fffffffL; + return (int) ((sequence >> 8) % below); + } +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/order/OrderEndpoint.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/order/OrderEndpoint.java new file mode 100644 index 00000000000..79d4c5a9e07 --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/order/OrderEndpoint.java @@ -0,0 +1,124 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.order; + +import com.codename1.backend.ResponseStatusException; +import com.codename1.backend.annotations.Component; +import com.codenameone.examples.savor.Caller; +import com.codenameone.examples.savor.Roles; +import com.codenameone.examples.savor.api.CartDto; +import com.codenameone.examples.savor.api.DeliveryActivityDto; +import com.codenameone.examples.savor.api.OrderApiServer; +import com.codenameone.examples.savor.api.OrderDto; +import com.codenameone.examples.savor.api.QuoteDto; +import com.codenameone.examples.savor.api.RatingDto; +import com.codenameone.examples.savor.api.RestaurantSummaryDto; +import com.codenameone.examples.savor.api.SavedAddressDto; +import com.codenameone.examples.savor.catalog.Catalog; +import com.codenameone.examples.savor.push.OrderPushes; +import java.util.List; + +/// A customer's orders, favourites and saved addresses. +@Component +public class OrderEndpoint implements OrderApiServer { + private final OrderPushes pushes; + private final Orders orders; + private final Catalog catalog; + private final Addresses addresses; + + public OrderEndpoint(Orders orders, Catalog catalog, Addresses addresses, OrderPushes pushes) { + this.pushes = pushes; + this.orders = orders; + this.catalog = catalog; + this.addresses = addresses; + } + + @Override + public DeliveryActivityDto activity(String id, DeliveryActivityDto activity) throws Exception { + return pushes.attach(Caller.name(), id, activity); + } + + @Override + public QuoteDto quote(CartDto cart) throws Exception { + return orders.quote(Caller.name(), cart); + } + + @Override + public OrderDto place(CartDto cart) throws Exception { + return orders.place(Caller.name(), cart); + } + + @Override + public List active() throws Exception { + return orders.active(Caller.name()); + } + + @Override + public List history() throws Exception { + return orders.history(Caller.name()); + } + + @Override + public OrderDto order(String id) throws Exception { + return orders.get(Caller.name(), Caller.hasRole(Roles.ADMIN), id); + } + + @Override + public OrderDto cancel(String id) throws Exception { + return orders.cancel(Caller.name(), id); + } + + @Override + public OrderDto rate(String id, RatingDto rating) throws Exception { + if (rating == null) { + throw new ResponseStatusException(400, "Give between one and five stars"); + } + return orders.rate(Caller.name(), id, rating.stars()); + } + + @Override + public List favorites(double lat, double lng) throws Exception { + return catalog.favorites(Caller.name(), lat, lng); + } + + @Override + public RestaurantSummaryDto favorite(String restaurantId) throws Exception { + return catalog.favorite(Caller.name(), restaurantId, true); + } + + @Override + public RestaurantSummaryDto unfavorite(String restaurantId) throws Exception { + return catalog.favorite(Caller.name(), restaurantId, false); + } + + @Override + public List addresses() throws Exception { + return addresses.list(Caller.name()); + } + + @Override + public SavedAddressDto saveAddress(SavedAddressDto address) throws Exception { + return addresses.save(Caller.name(), address); + } + + @Override + public SavedAddressDto removeAddress(String id) throws Exception { + return addresses.remove(Caller.name(), id); + } +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/order/OrderEvents.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/order/OrderEvents.java new file mode 100644 index 00000000000..b9388a118e3 --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/order/OrderEvents.java @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.order; + +import com.codename1.backend.annotations.Component; +import com.codenameone.examples.savor.Ids; +import com.codenameone.examples.savor.api.OrderState; +import com.codenameone.examples.savor.domain.OrderEvent; +import com.codenameone.examples.savor.repository.OrderEventRepository; +import java.io.IOException; + +/// The history of an order: one row per move, with who made it. Written in +/// the transaction that made the move. +@Component +public class OrderEvents { + private final OrderEventRepository events; + + public OrderEvents(OrderEventRepository events) { + this.events = events; + } + + public void record(String orderId, OrderState state, String actor) throws IOException { + OrderEvent event = new OrderEvent(); + event.setId(Ids.next()); + event.setOrderId(orderId); + event.setState(state); + event.setActor(actor == null ? "" : actor); + event.setCreatedAt(System.currentTimeMillis()); + events.insert(event); + } +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/order/OrderNews.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/order/OrderNews.java new file mode 100644 index 00000000000..67160a2a3d7 --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/order/OrderNews.java @@ -0,0 +1,99 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.order; + +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Transactional; +import com.codenameone.examples.savor.domain.CustomerOrder; +import com.codenameone.examples.savor.domain.Delivery; +import com.codenameone.examples.savor.domain.RestaurantStaff; +import com.codenameone.examples.savor.live.LiveHub; +import com.codenameone.examples.savor.push.OrderPushes; +import com.codenameone.examples.savor.repository.CustomerOrderRepository; +import com.codenameone.examples.savor.repository.DeliveryRepository; +import com.codenameone.examples.savor.repository.RestaurantStaffRepository; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +/// Tells an order's people that it changed: its customer, its kitchen's staff +/// and its courier over the live channel, and by push whoever is not looking. +/// +/// Both machines move an order -- [Orders] for what the customer, the kitchen +/// and the admin do, [com.codenameone.examples.savor.delivery.Deliveries] for +/// the pickup, the drop-off and a search that found nobody -- so both tell it +/// through here, once the change has committed. +@Component +public class OrderNews { + private final CustomerOrderRepository orders; + private final DeliveryRepository deliveries; + private final RestaurantStaffRepository staff; + private final LiveHub live; + private final OrderPushes pushes; + + public OrderNews(CustomerOrderRepository orders, DeliveryRepository deliveries, + RestaurantStaffRepository staff, LiveHub live, OrderPushes pushes) { + this.orders = orders; + this.deliveries = deliveries; + this.staff = staff; + this.live = live; + this.pushes = pushes; + } + + /// Tells everyone concerned with order `id` how it now stands. Never + /// fails the change it is about: a read that fails is logged. + public void told(String id) { + Object[] found; + try { + found = read(id); + } catch (IOException | RuntimeException err) { + System.err.println("[order] nobody was told of order " + id + ": " + err); + return; + } + if (found == null) { + return; + } + CustomerOrder order = (CustomerOrder) found[0]; + @SuppressWarnings("unchecked") + List kitchen = (List) found[1]; + live.orderChanged(id, order.getState(), order.getCustomer(), kitchen, (String) found[2]); + // The same moment, and for the same reason: a phone told of a change + // must find it made. + pushes.orderChanged(id, order.getState()); + } + + /// `{order, staff, courier}`, or null for an order that is not there. + @Transactional(readOnly = true) + private Object[] read(String id) throws IOException { + CustomerOrder order = orders.findById(id); + if (order == null) { + return null; + } + List kitchen = new ArrayList(); + List rows = staff.findByRestaurantId(order.getRestaurantId()); + for (int iter = 0; iter < rows.size(); iter++) { + kitchen.add(rows.get(iter).getUsername()); + } + Delivery delivery = deliveries.findById(id); + String courier = delivery == null || delivery.getCourier() == null ? "" + : delivery.getCourier(); + return new Object[] {order, kitchen, courier}; + } +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/order/OrderStateMachine.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/order/OrderStateMachine.java new file mode 100644 index 00000000000..49d5eacbc03 --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/order/OrderStateMachine.java @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.order; + +import com.codenameone.examples.savor.api.OrderState; + +/// The moves an order may make, as the states each one may start from. Every +/// move is a conditional update of the order's row on these sources, so the +/// database enforces the rule and settles a race in the same statement. +/// +/// An order is coupled to its delivery, which has the same id: the delivery is +/// created when the kitchen accepts, the order is picked up in the same +/// transaction as the courier starts the delivery, and it is delivered in the +/// same transaction as the delivery completes. +public final class OrderStateMachine { + private static final OrderState[] NONE = {}; + private static final OrderState[] FROM_PLACED = {OrderState.PLACED}; + private static final OrderState[] FROM_ACCEPTED = {OrderState.ACCEPTED}; + private static final OrderState[] FROM_READY = {OrderState.READY}; + private static final OrderState[] FROM_PICKED_UP = {OrderState.PICKED_UP}; + private static final OrderState[] IN_THE_KITCHEN = {OrderState.ACCEPTED, OrderState.READY}; + private static final OrderState[] NOT_DELIVERED = {OrderState.PLACED, OrderState.ACCEPTED, + OrderState.READY, OrderState.PICKED_UP}; + + private OrderStateMachine() { + } + + /// The states an order may move to `to` from; empty when nothing does. + public static OrderState[] sources(OrderState to) { + if (OrderState.ACCEPTED == to || OrderState.REJECTED == to + || OrderState.CANCELLED_BY_CUSTOMER == to) { + return FROM_PLACED; + } + if (OrderState.READY == to) { + return FROM_ACCEPTED; + } + if (OrderState.PICKED_UP == to) { + return FROM_READY; + } + if (OrderState.DELIVERED == to) { + return FROM_PICKED_UP; + } + if (OrderState.NO_COURIERS == to) { + return IN_THE_KITCHEN; + } + if (OrderState.CANCELLED_BY_ADMIN == to) { + return NOT_DELIVERED; + } + return NONE; + } + + public static boolean allowed(OrderState from, OrderState to) { + OrderState[] sources = sources(to); + for (int iter = 0; iter < sources.length; iter++) { + if (sources[iter] == from) { + return true; + } + } + return false; + } +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/order/Orders.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/order/Orders.java new file mode 100644 index 00000000000..6d21f69e3dd --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/order/Orders.java @@ -0,0 +1,758 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.order; + +import com.codename1.backend.ConcurrencyFailureException; +import com.codename1.backend.ResponseStatusException; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Scheduled; +import com.codename1.backend.annotations.Transactional; +import com.codename1.backend.annotations.Value; +import com.codename1.backend.security.core.userdetails.JdbcUserDetailsManager; +import com.codename1.backend.security.core.userdetails.UsernameNotFoundException; +import com.codename1.orm.repository.PageRequest; +import com.codename1.orm.session.Identifier; +import com.codename1.orm.session.PersistenceException; +import com.codenameone.examples.savor.Ids; +import com.codenameone.examples.savor.Text; +import com.codenameone.examples.savor.account.Moderation; +import com.codenameone.examples.savor.api.CartDto; +import com.codenameone.examples.savor.api.DeliveryDto; +import com.codenameone.examples.savor.api.OrderDto; +import com.codenameone.examples.savor.api.OrderLineDto; +import com.codenameone.examples.savor.api.OrderState; +import com.codenameone.examples.savor.api.QuoteDto; +import com.codenameone.examples.savor.delivery.Deliveries; +import com.codenameone.examples.savor.domain.CustomerOrder; +import com.codenameone.examples.savor.domain.OrderLine; +import com.codenameone.examples.savor.domain.Profile; +import com.codenameone.examples.savor.domain.PromoUse; +import com.codenameone.examples.savor.domain.Restaurant; +import com.codenameone.examples.savor.domain.RestaurantStaff; +import com.codenameone.examples.savor.pay.Payments; +import com.codenameone.examples.savor.repository.CustomerOrderRepository; +import com.codenameone.examples.savor.repository.OrderLineRepository; +import com.codenameone.examples.savor.repository.ProfileRepository; +import com.codenameone.examples.savor.repository.PromoUseRepository; +import com.codenameone.examples.savor.repository.RestaurantRepository; +import com.codenameone.examples.savor.repository.RestaurantStaffRepository; +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/// Orders: placing one, the kitchen's moves, and how each party sees it. +/// +/// As in [Deliveries], every change is a conditional update +/// ([OrderStateMachine]) inside a private transaction, and the public method +/// tells whoever is concerned once it has committed. The card is held before +/// the order is written and let go if the order ends without arriving; both +/// calls to the processor happen with no transaction open. +@Component +public class Orders { + /// How many orders one customer may have on the way at once. + static final int MAX_ACTIVE = 3; + private static final OrderState[] ACTIVE = {OrderState.PLACED, OrderState.ACCEPTED, + OrderState.READY, OrderState.PICKED_UP}; + private static final OrderState[] KITCHEN = {OrderState.PLACED, OrderState.ACCEPTED, + OrderState.READY}; + + private final CustomerOrderRepository orders; + private final OrderLineRepository lines; + private final RestaurantRepository restaurants; + private final RestaurantStaffRepository staff; + private final PromoUseRepository promoUses; + private final ProfileRepository profiles; + private final Quotes quotes; + private final OrderEvents events; + private final Deliveries deliveries; + private final Payments payments; + private final Moderation moderation; + private final OrderNews news; + private final JdbcUserDetailsManager users; + private final long answerMillis; + + /// @param answerSeconds how long a kitchen has to accept an order before + /// it is turned down for them + public Orders(CustomerOrderRepository orders, OrderLineRepository lines, + RestaurantRepository restaurants, RestaurantStaffRepository staff, + PromoUseRepository promoUses, ProfileRepository profiles, Quotes quotes, + OrderEvents events, Deliveries deliveries, Payments payments, OrderNews news, + Moderation moderation, JdbcUserDetailsManager users, + @Value("${savor.kitchen.answerSeconds:300}") int answerSeconds) { + this.orders = orders; + this.lines = lines; + this.restaurants = restaurants; + this.staff = staff; + this.promoUses = promoUses; + this.profiles = profiles; + this.quotes = quotes; + this.events = events; + this.deliveries = deliveries; + this.payments = payments; + this.moderation = moderation; + this.news = news; + this.users = users; + answerMillis = answerSeconds * 1000L; + } + + private boolean enabled(String username) { + try { + return users.loadUserByUsername(username).isEnabled(); + } catch (UsernameNotFoundException gone) { + return false; + } + } + + // -------------------------------------------------------------- customer + + public QuoteDto quote(String customer, CartDto cart) throws IOException { + return quotes.quote(customer, cart).quote; + } + + // tag::savor-place[] + /// Places an order: prices it from the menu, holds the total on the card, + /// and writes it. A hold whose order could not be written is let go. + public OrderDto place(String customer, CartDto cart) throws IOException { + String id = attemptOf(cart); + CustomerOrder already = find(id); + if (already != null) { + return placedBefore(customer, already); + } + Quotes.Priced priced = quotes.quote(customer, cart); + Restaurant restaurant = priced.restaurant; + if (!Quotes.open(restaurant, quotes.minuteOfDay(System.currentTimeMillis()))) { + throw new ResponseStatusException(409, restaurant.getName() + + " is not taking orders right now"); + } + if (priced.quote.subtotalCents() < restaurant.getMinimumCents()) { + throw new ResponseStatusException(400, restaurant.getName() + + " needs a bigger order"); + } + if (cart.promoCode() != null && cart.promoCode().trim().length() > 0 + && priced.promo == null) { + throw new ResponseStatusException(400, priced.quote.promoMessage()); + } + // The same refusals store() makes under its locks, asked first without + // them so an order that could never be accepted puts no hold on the + // card and costs no call to the payment processor. store() still asks + // again: two orders placed at once both pass here. + eligible(customer); + String[] method = payments.choose(customer, cart.paymentMethodId()); + String hold = payments.authorize(id, customer, method[0], priced.quote.totalCents(), + priced.quote.currency()); + try { + attempt(() -> store(id, customer, cart, priced, method, hold)); + } catch (IOException | RuntimeException failed) { + CustomerOrder raced = find(id); + if (raced != null && customer.equals(raced.getCustomer())) { + // The same checkout sent twice at once: the other one wrote the + // order. Its hold was asked for under the same key and may be + // this very one, so it is not let go. + return view(customer, false, id); + } + // Nothing was written, so the hold is let go by its reference. + payments.releaseHold(id, hold); + throw failed; + } + CustomerOrder order = find(id); + tell(order); + return view(customer, false, id); + } + + /// The order's id: the checkout's own attempt id when the app sent one, so + /// a placement sent again after its answer was lost finds the order it + /// wrote instead of writing and charging another. Anything not shaped like + /// a server id gets a fresh one. + private static String attemptOf(CartDto cart) throws IOException { + String attempt = cart.attemptId(); + if (attempt == null || attempt.length() != 32) { + return Ids.next(); + } + for (int iter = 0; iter < attempt.length(); iter++) { + char c = attempt.charAt(iter); + if (!(c >= '0' && c <= '9' || c >= 'a' && c <= 'f')) { + return Ids.next(); + } + } + return attempt; + } + + /// The answer to a checkout placed again: the order it already wrote. An + /// id another customer's order holds is refused without saying whose. + private OrderDto placedBefore(String customer, CustomerOrder order) throws IOException { + if (!customer.equals(order.getCustomer())) { + throw new ResponseStatusException(409, "Start the checkout again"); + } + return view(customer, false, order.getId()); + } + + /// The refusals [#store] makes, without its locks. See [#place]. + @Transactional(readOnly = true) + private Void eligible(String customer) { + if (!enabled(customer)) { + throw new ResponseStatusException(403, "This account is blocked"); + } + if (orders.countByCustomerAndStateIn(customer, ACTIVE) >= MAX_ACTIVE) { + throw new ResponseStatusException(409, "You already have " + MAX_ACTIVE + + " orders on the way"); + } + Profile profile = profiles.findById(customer); + if (profile == null || !profile.isPhoneVerified()) { + throw new ResponseStatusException(403, "Verify your phone number before you order"); + } + return null; + } + + @Transactional + private Void store(String id, String customer, CartDto cart, Quotes.Priced charged, + String[] method, String hold) throws IOException { + Quotes.Priced priced = charged; + // Held first, so two orders placed at once count each other and not + // just what had committed before either began. + profiles.hold(customer); + // The card was held with no transaction open, and that can take a + // while: the account may have been blocked, or the restaurant paused + // or suspended, since the checks before it. Asked again under the + // locks those changes take, a refusal here lets the hold go. + if (!enabled(customer)) { + throw new ResponseStatusException(403, "This account is blocked"); + } + Restaurant restaurant = restaurants.hold(priced.restaurant.getId()) == 0 ? null + : restaurants.findById(priced.restaurant.getId()); + if (restaurant == null + || !Quotes.open(restaurant, quotes.minuteOfDay(System.currentTimeMillis()))) { + throw new ResponseStatusException(409, priced.restaurant.getName() + + " is not taking orders right now"); + } + if (orders.countByCustomerAndStateIn(customer, ACTIVE) >= MAX_ACTIVE) { + throw new ResponseStatusException(409, "You already have " + MAX_ACTIVE + + " orders on the way"); + } + Profile profile = profiles.findById(customer); + if (profile == null || !profile.isPhoneVerified()) { + // The courier calls this number from the door. + throw new ResponseStatusException(403, "Verify your phone number before you order"); + } + // The menu was read before the card was held. Priced again under the + // restaurant's lock, which every dish edit takes too (Restaurants.editing): + // a dish now sold out or gone throws, a price that moved refuses, and either + // way the hold is let go rather than charging for a menu that has changed. + Quotes.Priced again = quotes.quote(customer, cart); + if (again.quote.totalCents() != charged.quote.totalCents()) { + throw new ResponseStatusException(409, "The menu changed while you were paying." + + " Check your cart and try again"); + } + // The minimum may have been raised while the card was held, and it + // leaves the total alone, so the comparison above does not see it. + if (again.quote.subtotalCents() < restaurant.getMinimumCents()) { + throw new ResponseStatusException(400, restaurant.getName() + + " needs a bigger order"); + } + // The card may have been removed while it was held: Payments.remove + // takes the profile lock held here and found no active order then. + // Asked again, a card that is gone refuses with 400. + payments.choose(customer, method[0]); + priced = again; + QuoteDto quote = priced.quote; + long now = System.currentTimeMillis(); + CustomerOrder order = new CustomerOrder(); + order.setId(id); + order.setCustomer(customer); + order.setRestaurantId(priced.restaurant.getId()); + order.setRestaurantName(priced.restaurant.getName()); + order.setState(OrderState.PLACED); + order.setSubtotalCents(quote.subtotalCents()); + order.setDeliveryFeeCents(quote.deliveryFeeCents()); + order.setServiceFeeCents(quote.serviceFeeCents()); + order.setSmallOrderCents(quote.smallOrderCents()); + order.setTaxCents(quote.taxCents()); + order.setDiscountCents(quote.discountCents()); + order.setTipCents(quote.tipCents()); + order.setTotalCents(quote.totalCents()); + order.setCommissionCents(priced.commissionCents); + order.setCurrency(quote.currency()); + order.setPromoCode(priced.promo == null ? "" : priced.promo.getCode()); + order.setDropoffLat(cart.dropoffLat()); + order.setDropoffLng(cart.dropoffLng()); + order.setDropoffAddress(Text.required(cart.dropoffAddress(), 255, "the address")); + order.setNote(Text.optional(cart.note(), 255, "the note")); + order.setMethodId(method[0]); + order.setMethodLabel(method[1]); + order.setAuthorizationRef(hold); + order.setPaymentStatus(Payments.CASH.equals(hold) ? Payments.PENDING : Payments.AUTHORIZED); + order.setPrepMinutes(priced.restaurant.getPrepMinutes()); + order.setPlacedAt(now); + order.setUpdatedAt(now); + order.setReason(""); + orders.insert(order); + for (int iter = 0; iter < priced.lines.size(); iter++) { + OrderLineDto dto = priced.lines.get(iter); + OrderLine line = new OrderLine(); + line.setId(Ids.next()); + line.setOrderId(id); + line.setDishId(dto.dishId()); + line.setName(dto.name()); + line.setOptions(dto.options()); + line.setQuantity(dto.quantity()); + line.setUnitCents(dto.unitCents()); + line.setLineCents(dto.lineCents()); + line.setPosition(iter); + lines.insert(line); + } + if (priced.promo != null) { + // Keyed by code and account, so a second order racing this one + // with the same code fails its insert and is rolled back. + if (promoUses.existsById(Identifier.of(priced.promo.getCode(), customer))) { + throw new ResponseStatusException(409, "You have already used that code"); + } + PromoUse use = new PromoUse(); + use.setCode(priced.promo.getCode()); + use.setUsername(customer); + use.setOrderId(id); + promoUses.insert(use); + } + events.record(id, OrderState.PLACED, customer); + return null; + } + // end::savor-place[] + + @Transactional(readOnly = true) + public List active(String customer) throws IOException { + return list(orders.findByCustomerAndStateInOrderByPlacedAtDesc(customer, ACTIVE), + customer, false); + } + + @Transactional(readOnly = true) + public List history(String customer) throws IOException { + // Filtered by the query, before its limit, as kitchenHistory is: active + // orders taking slots would leave fewer than fifty ended ones. + return list(orders.findTop50ByCustomerAndStateInOrderByPlacedAtDesc(customer, ended()), + customer, false); + } + + /// An order, for its customer, its restaurant's staff, its courier, or an + /// admin; 404 for anyone else. + @Transactional(readOnly = true) + public OrderDto get(String viewer, boolean admin, String id) throws IOException { + CustomerOrder order = find(id); + if (order == null || !(admin || mayView(viewer, order))) { + throw new ResponseStatusException(404, "No such order"); + } + return toDto(order, viewer, !admin && viewer.equals(order.getCustomer()), admin, + new HashMap()); + } + + private boolean mayView(String viewer, CustomerOrder order) throws IOException { + if (viewer.equals(order.getCustomer())) { + return true; + } + if (staff.existsById(Identifier.of(order.getRestaurantId(), viewer))) { + return true; + } + DeliveryDto delivery = deliveries.forOrder(order.getId(), viewer, false); + return delivery != null && viewer.equals(delivery.courierUsername()); + } + + /// Cancels an order the kitchen has not accepted yet, and lets the hold go. + public OrderDto cancel(String customer, String id) throws IOException { + CustomerOrder order = owned(customer, id); + attempt(() -> end(id, OrderState.CANCELLED_BY_CUSTOMER, customer, + "Cancelled by the customer")); + payments.release(id); + tell(find(id)); + return view(customer, false, id); + } + + /// The order, its delivery and the customer's record, as one step: an + /// order that ends with its courier still riding to it would be a delivery + /// nobody pays for. + @Transactional + private Void adminEnd(String by, String id, String why, Deliveries.Change[] change) + throws IOException { + CustomerOrder order = find(id); + if (order == null) { + throw new ResponseStatusException(404, "No such order"); + } + end(id, OrderState.CANCELLED_BY_ADMIN, by, why); + change[0] = deliveries.endForOrder(id, by); + // On the customer's record, where the admin looking them up next sees it. + moderation.record(order.getCustomer(), by, Moderation.CANCEL_ORDER, why); + return null; + } + + @Transactional + private Void end(String id, OrderState to, String actor, String reason) throws IOException { + if (orders.end(id, OrderStateMachine.sources(to), to, reason, + System.currentTimeMillis()) != 1) { + CustomerOrder now = find(id); + throw new ResponseStatusException(409, "That order is " + + (now == null ? "gone" : describe(now.getState()))); + } + events.record(id, to, actor); + return null; + } + + /// The customer's stars for a delivered order, once; they count toward + /// the restaurant's rating and its courier's. + public OrderDto rate(String customer, String id, int stars) throws IOException { + if (stars < 1 || stars > 5) { + throw new ResponseStatusException(400, "Give between one and five stars"); + } + attempt(() -> rated(customer, id, stars)); + return view(customer, false, id); + } + + @Transactional + private Void rated(String customer, String id, int stars) throws IOException { + CustomerOrder order = owned(customer, id); + if (OrderState.DELIVERED != order.getState()) { + throw new ResponseStatusException(409, "Only a delivered order can be rated"); + } + if (orders.rate(id, customer, stars, System.currentTimeMillis()) != 1) { + throw new ResponseStatusException(409, "That order is already rated"); + } + restaurants.addRating(order.getRestaurantId(), stars); + deliveries.rate(id, customer, stars); + return null; + } + + // --------------------------------------------------------------- kitchen + + /// The orders a restaurant has to deal with, oldest first. + @Transactional(readOnly = true) + public List queue(String restaurantId, String viewer) throws IOException { + return list(orders.findByRestaurantIdAndStateInOrderByPlacedAtAsc(restaurantId, KITCHEN), + viewer, false); + } + + @Transactional(readOnly = true) + public List kitchenHistory(String restaurantId, String viewer) throws IOException { + // Filtered by the query, before its limit: dropping the kitchen's own + // orders afterwards let fifty current ones push all of the history out. + return list(orders.findTop50ByRestaurantIdAndStateInOrderByPlacedAtDesc(restaurantId, + history()), viewer, false); + } + + /// Every state an order has ended in. + private static OrderState[] ended() { + List out = new ArrayList(); + OrderState[] all = OrderState.values(); + for (int iter = 0; iter < all.length; iter++) { + if (!all[iter].isActive()) { + out.add(all[iter]); + } + } + return out.toArray(new OrderState[out.size()]); + } + + /// Every state but the kitchen's, the orders it is done with. + private static OrderState[] history() { + List out = new ArrayList(); + OrderState[] all = OrderState.values(); + for (int iter = 0; iter < all.length; iter++) { + if (!inKitchen(all[iter])) { + out.add(all[iter]); + } + } + return out.toArray(new OrderState[out.size()]); + } + + private static boolean inKitchen(OrderState state) { + return OrderState.PLACED == state || OrderState.ACCEPTED == state + || OrderState.READY == state; + } + + // tag::savor-accept[] + /// The kitchen takes an order: it says how long it needs, and the order's + /// delivery starts looking for a courier in the same transaction. + public OrderDto accept(String actor, String restaurantId, String id, int prepMinutes) + throws IOException { + if (prepMinutes < 1 || prepMinutes > 180) { + throw new ResponseStatusException(400, "Say how many minutes it needs"); + } + attempt(() -> accepted(actor, restaurantId, id, prepMinutes)); + tell(find(id)); + // Once committed, as an offer is always made: a courier asked from + // inside the transaction could be shown an order that then rolled back. + deliveries.search(id); + return view(actor, false, id); + } + + @Transactional + private Void accepted(String actor, String restaurantId, String id, int prepMinutes) + throws IOException { + CustomerOrder order = kitchens(restaurantId, id); + if (orders.accept(id, OrderStateMachine.sources(OrderState.ACCEPTED), + OrderState.ACCEPTED, prepMinutes, System.currentTimeMillis()) != 1) { + throw new ResponseStatusException(409, "That order is " + + describe(order.getState())); + } + events.record(id, OrderState.ACCEPTED, actor); + deliveries.dispatch(find(id), restaurants.findById(restaurantId)); + return null; + } + // end::savor-accept[] + + public OrderDto reject(String actor, String restaurantId, String id, String reason) + throws IOException { + String why = Text.required(reason, 255, "why the order is turned down"); + kitchensReadOnly(restaurantId, id); + attempt(() -> end(id, OrderState.REJECTED, actor, why)); + payments.release(id); + tell(find(id)); + return view(actor, false, id); + } + + public OrderDto ready(String actor, String restaurantId, String id) throws IOException { + attempt(() -> readied(actor, restaurantId, id)); + tell(find(id)); + return view(actor, false, id); + } + + @Transactional + private Void readied(String actor, String restaurantId, String id) throws IOException { + CustomerOrder order = kitchens(restaurantId, id); + if (orders.ready(id, OrderStateMachine.sources(OrderState.READY), OrderState.READY, + System.currentTimeMillis()) != 1) { + throw new ResponseStatusException(409, "That order is " + + describe(order.getState())); + } + events.record(id, OrderState.READY, actor); + return null; + } + + /// Turns down, for the kitchen, the orders nobody accepted in time, and + /// lets their holds go. A customer is not left waiting on a closed + /// kitchen. + @Scheduled(fixedDelay = 15000, initialDelay = 15000, lock = "savor-kitchen") + public void sweep() throws IOException { + List late = late(System.currentTimeMillis() - answerMillis); + for (int iter = 0; iter < late.size(); iter++) { + String id = late.get(iter).getId(); + try { + attempt(() -> end(id, OrderState.REJECTED, "", + "The restaurant did not answer in time")); + } catch (ResponseStatusException moved) { + // Accepted in the meantime. + continue; + } + payments.release(id); + tell(find(id)); + } + // Ending an order commits before its hold is let go, so a server + // stopped between the two leaves an order that is over with the money + // still held, and asking to end it again is refused: it has ended. + // Releasing is safe to repeat -- see Payments.release -- so this + // finishes what was left, for every way an order ends short of the door. + List held = unreleased(System.currentTimeMillis() - UNRELEASED_MILLIS); + for (int iter = 0; iter < held.size(); iter++) { + payments.release(held.get(iter).getId()); + } + } + + /// How long an ended order may hold its money before the sweep lets it go: + /// long enough that the request ending it has had its turn. + private static final long UNRELEASED_MILLIS = 2L * 60L * 1000L; + + private static final OrderState[] ENDED_SHORT = {OrderState.REJECTED, + OrderState.CANCELLED_BY_CUSTOMER, OrderState.CANCELLED_BY_ADMIN, OrderState.NO_COURIERS}; + + @Transactional(readOnly = true) + private List late(long before) { + return orders.findByStateAndPlacedAtLessThan(OrderState.PLACED, before); + } + + @Transactional(readOnly = true) + private List unreleased(long before) { + return orders.unreleased(ENDED_SHORT, + new String[] {Payments.AUTHORIZED, Payments.PENDING}, before, + new PageRequest(0, 50)); + } + + // ----------------------------------------------------------------- admin + + /// Ends an order that has not arrived, whatever it was doing, lets the + /// hold go and takes its delivery off the road. + public OrderDto cancelByAdmin(String by, String id, String reason) throws IOException { + String why = Text.required(reason, 255, "why the order is cancelled"); + Deliveries.Change[] change = new Deliveries.Change[1]; + attempt(() -> adminEnd(by, id, why, change)); + payments.release(id); + deliveries.told(change[0]); + tell(find(id)); + return get(by, true, id); + } + + /// The most recent orders, for the admin. + @Transactional(readOnly = true) + public List recent() throws IOException { + return list(orders.newest(new PageRequest(0, 100)), "", true); + } + + // ------------------------------------------------------------- internals + + /// Tells the order's people it changed, once the change has committed. + private void tell(CustomerOrder order) { + if (order != null) { + news.told(order.getId()); + } + } + + private interface Work { + Void run() throws IOException; + } + + /// Runs a transaction again when the database ended it because another + /// changed the same order first; see `Deliveries.attempt`. + private static void attempt(Work work) throws IOException { + for (int tries = 1; ; tries++) { + try { + work.run(); + return; + } catch (PersistenceException err) { + if (tries == 3 || !ConcurrencyFailureException.isCauseOf(err)) { + throw err; + } + } catch (ConcurrencyFailureException err) { + if (tries == 3) { + throw err; + } + } + } + } + + @Transactional(readOnly = true) + private CustomerOrder find(String id) { + if (id == null || id.length() == 0 || id.length() > 32) { + return null; + } + return orders.findById(id); + } + + @Transactional(readOnly = true) + private CustomerOrder owned(String customer, String id) { + CustomerOrder order = find(id); + if (order == null || !customer.equals(order.getCustomer())) { + throw new ResponseStatusException(404, "No such order"); + } + return order; + } + + private CustomerOrder kitchens(String restaurantId, String id) { + CustomerOrder order = find(id); + if (order == null || !order.getRestaurantId().equals(restaurantId)) { + throw new ResponseStatusException(404, "No such order"); + } + return order; + } + + @Transactional(readOnly = true) + private CustomerOrder kitchensReadOnly(String restaurantId, String id) { + return kitchens(restaurantId, id); + } + + @Transactional(readOnly = true) + private OrderDto view(String viewer, boolean admin, String id) throws IOException { + CustomerOrder order = find(id); + return toDto(order, viewer, viewer.equals(order.getCustomer()), admin, + new HashMap()); + } + + private List list(List rows, String viewer, boolean admin) + throws IOException { + Map cache = new HashMap(); + List out = new ArrayList(); + for (int iter = 0; iter < rows.size(); iter++) { + CustomerOrder order = rows.get(iter); + out.add(toDto(order, viewer, viewer.equals(order.getCustomer()), admin, cache)); + } + return out; + } + + /// `admin` is whether the order is read for an admin, the only reader its + /// delivery shows the customer's sign-in name to; its restaurant's staff + /// and its courier are parties, and see what a party sees. + private OrderDto toDto(CustomerOrder order, String viewer, boolean customer, boolean admin, + Map cache) throws IOException { + Restaurant restaurant = cache.get(order.getRestaurantId()); + if (restaurant == null) { + restaurant = restaurants.findById(order.getRestaurantId()); + if (restaurant != null) { + cache.put(restaurant.getId(), restaurant); + } + } + List rows = lines.findByOrderIdOrderByPositionAsc(order.getId()); + List items = new ArrayList(); + for (int iter = 0; iter < rows.size(); iter++) { + OrderLine line = rows.get(iter); + items.add(new OrderLineDto(line.getDishId(), line.getName(), line.getOptions(), + line.getQuantity(), line.getUnitCents(), line.getLineCents())); + } + Profile who = profiles.findById(order.getCustomer()); + DeliveryDto delivery = deliveries.forOrder(order.getId(), viewer, !admin); + int eta = 0; + OrderState state = order.getState(); + if (state.isActive() && restaurant != null) { + int ride = Quotes.travelMinutes(Quotes.roadMeters(restaurant.getLat(), + restaurant.getLng(), order.getDropoffLat(), order.getDropoffLng())); + if (OrderState.PICKED_UP == state && delivery != null && delivery.etaMinutes() > 0) { + eta = delivery.etaMinutes(); + } else { + long cooked = (order.getAcceptedAt() == 0L ? order.getPlacedAt() + : order.getAcceptedAt()) + order.getPrepMinutes() * 60000L; + int left = (int) Math.max(0L, (cooked - System.currentTimeMillis()) / 60000L); + eta = left + ride; + } + } + return new OrderDto(order.getId(), state, order.getRestaurantId(), + order.getRestaurantName(), + restaurant == null ? "" : restaurant.getAddress(), + restaurant == null ? 0d : restaurant.getLat(), + restaurant == null ? 0d : restaurant.getLng(), + customer ? order.getCustomer() : null, + who == null ? "" : who.getDisplayName(), + items, order.getSubtotalCents(), order.getDeliveryFeeCents(), + order.getServiceFeeCents(), order.getSmallOrderCents(), order.getTaxCents(), + order.getDiscountCents(), order.getTipCents(), order.getTotalCents(), + order.getCurrency(), order.getDropoffLat(), order.getDropoffLng(), + order.getDropoffAddress(), order.getNote(), order.getMethodLabel(), + order.getPaymentStatus(), order.getPrepMinutes(), order.getPlacedAt(), + order.getAcceptedAt(), order.getReadyAt(), order.getDeliveredAt(), eta, + order.getReason(), order.getStars(), delivery); + } + + private static String describe(OrderState state) { + if (OrderState.DELIVERED == state) { + return "already delivered"; + } + if (OrderState.PICKED_UP == state) { + return "already on its way"; + } + if (OrderState.ACCEPTED == state || OrderState.READY == state) { + return "already being prepared"; + } + if (state.isCancelled() || OrderState.REJECTED == state + || OrderState.NO_COURIERS == state) { + return "no longer going ahead"; + } + return "not ready for that"; + } +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/order/Promos.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/order/Promos.java new file mode 100644 index 00000000000..5726384ae6d --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/order/Promos.java @@ -0,0 +1,96 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.order; + +import com.codename1.backend.ResponseStatusException; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Transactional; +import com.codename1.orm.repository.PageRequest; +import com.codenameone.examples.savor.api.PromoDto; +import com.codenameone.examples.savor.domain.Promo; +import com.codenameone.examples.savor.repository.PromoRepository; +import java.util.ArrayList; +import java.util.List; + +/// Promotion codes, which the admins create and switch off. A code takes a +/// percentage off, an amount off, or both, from orders over a minimum, once +/// per account. +@Component +public class Promos { + private final PromoRepository promos; + + public Promos(PromoRepository promos) { + this.promos = promos; + } + + @Transactional(readOnly = true) + public List all() { + List rows = promos.newest(new PageRequest(0, 200)); + List out = new ArrayList(); + for (int iter = 0; iter < rows.size(); iter++) { + out.add(toDto(rows.get(iter))); + } + return out; + } + + @Transactional + public PromoDto save(PromoDto form) { + if (form == null || form.code() == null) { + throw new ResponseStatusException(400, "Name the code"); + } + String code = Quotes.promoCode(form.code()); + if (code.length() < 3 || code.length() > 20) { + throw new ResponseStatusException(400, "A code is 3 to 20 letters and digits"); + } + for (int iter = 0; iter < code.length(); iter++) { + char c = code.charAt(iter); + if (!(c >= 'A' && c <= 'Z' || c >= '0' && c <= '9')) { + throw new ResponseStatusException(400, "A code is 3 to 20 letters and digits"); + } + } + if (form.percentOff() < 0 || form.percentOff() > 100 || form.amountOffCents() < 0 + || form.amountOffCents() > 100000 || form.minimumCents() < 0 + || form.minimumCents() > 100000) { + throw new ResponseStatusException(400, "Check the discount and the minimum"); + } + if (form.percentOff() == 0 && form.amountOffCents() == 0L) { + throw new ResponseStatusException(400, "A code has to take something off"); + } + Promo row = promos.findById(code); + boolean unsaved = row == null; + if (unsaved) { + row = new Promo(); + row.setCode(code); + row.setCreatedAt(System.currentTimeMillis()); + } + row.setPercentOff(form.percentOff()); + row.setAmountOffCents(form.amountOffCents()); + row.setMinimumCents(form.minimumCents()); + row.setActive(form.active()); + if (unsaved) { + promos.insert(row); + } + return toDto(row); + } + + private static PromoDto toDto(Promo row) { + return new PromoDto(row.getCode(), row.getPercentOff(), row.getAmountOffCents(), + row.getMinimumCents(), row.isActive()); + } +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/order/Quotes.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/order/Quotes.java new file mode 100644 index 00000000000..fd64514b7eb --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/order/Quotes.java @@ -0,0 +1,361 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.order; + +import com.codename1.backend.ResponseStatusException; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Transactional; +import com.codename1.backend.annotations.Value; +import com.codename1.orm.session.Identifier; +import com.codenameone.examples.savor.api.CartDto; +import com.codenameone.examples.savor.api.CartLineDto; +import com.codenameone.examples.savor.api.OrderLineDto; +import com.codenameone.examples.savor.api.PricingDto; +import com.codenameone.examples.savor.api.QuoteDto; +import com.codenameone.examples.savor.domain.Dish; +import com.codenameone.examples.savor.domain.DishOption; +import com.codenameone.examples.savor.domain.Pricing; +import com.codenameone.examples.savor.domain.Promo; +import com.codenameone.examples.savor.domain.Restaurant; +import com.codenameone.examples.savor.geo.Geo; +import com.codenameone.examples.savor.repository.DishOptionRepository; +import com.codenameone.examples.savor.repository.DishRepository; +import com.codenameone.examples.savor.repository.PricingRepository; +import com.codenameone.examples.savor.repository.PromoRepository; +import com.codenameone.examples.savor.repository.PromoUseRepository; +import com.codenameone.examples.savor.repository.RestaurantRepository; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Calendar; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.TimeZone; + +/// What an order costs, worked out on the server from the menu as it stands: +/// the client sends dish and option ids and quantities, never prices. +/// +/// The delivery fee is a base plus a price per kilometre from the restaurant to +/// the door, times the surge; an order under the small-order threshold pays a +/// flat surcharge; the service fee and tax are percentages of the subtotal; a +/// promotion takes its discount off the subtotal. The platform keeps its +/// commission from the restaurant's share, and the courier is paid the delivery +/// fee and the tip. +@Component +public class Quotes { + /// How long the names of a line's extras may be, together: the width of + /// order_line.options. + static final int OPTIONS_COLUMN = 500; + /// Roads are longer than the straight line between two points. + public static final double ROAD_FACTOR = 1.3d; + /// About 30 km/h, a courier in town. + public static final double METERS_PER_SECOND = 8.33d; + /// The furthest a restaurant delivers. + public static final double MAX_METERS = 15000d; + /// The most of one line anyone orders. + static final int MAX_QUANTITY = 50; + + private final PricingRepository prices; + private final RestaurantRepository restaurants; + private final DishRepository dishes; + private final DishOptionRepository options; + private final PromoRepository promos; + private final PromoUseRepository promoUses; + private final PricingDto defaults; + private final TimeZone zone; + + public Quotes(PricingRepository prices, RestaurantRepository restaurants, + DishRepository dishes, DishOptionRepository options, PromoRepository promos, + PromoUseRepository promoUses, + @Value("${savor.currency:USD}") String currency, + @Value("${savor.fee.baseCents:199}") int baseCents, + @Value("${savor.fee.perKmCents:60}") int perKmCents, + @Value("${savor.fee.smallOrderCents:1200}") int smallOrderCents, + @Value("${savor.fee.smallOrderFeeCents:200}") int smallOrderFeeCents, + @Value("${savor.fee.serviceFeePercent:10}") int serviceFeePercent, + @Value("${savor.fee.taxPercent:8}") int taxPercent, + @Value("${savor.fee.commissionPercent:20}") int commissionPercent, + @Value("${savor.timezone:}") String timezone) { + this.prices = prices; + this.restaurants = restaurants; + this.dishes = dishes; + this.options = options; + this.promos = promos; + this.promoUses = promoUses; + // The zone the restaurants' opening hours are written in: `savor.timezone` + // as a zone ID ("America/New_York", "GMT+2"), or the server's own zone + // when it is not set. A sample with one city keeps one zone; a real + // platform keeps one per restaurant. + zone = timezone == null || timezone.trim().length() == 0 ? TimeZone.getDefault() + : TimeZone.getTimeZone(timezone.trim()); + defaults = new PricingDto(currency, baseCents, perKmCents, 1d, smallOrderCents, + smallOrderFeeCents, serviceFeePercent, taxPercent, commissionPercent); + } + + public String currency() { + return defaults.currency(); + } + + @Transactional(readOnly = true) + public PricingDto pricing() throws IOException { + return toDto(prices.findById(PricingRepository.ONLY)); + } + + private PricingDto toDto(Pricing row) { + if (row == null || row.getUpdatedAt() == 0L) { + return defaults; + } + return new PricingDto(defaults.currency(), row.getBaseCents(), row.getPerKmCents(), + row.getSurge(), row.getSmallOrderCents(), row.getSmallOrderFeeCents(), + row.getFeePercent(), row.getTaxPercent(), row.getCommissionPercent()); + } + + @Transactional + public PricingDto save(String by, PricingDto wanted) throws IOException { + if (wanted == null) { + throw new ResponseStatusException(400, "No prices to save"); + } + amount(wanted.baseCents(), "the base delivery fee"); + amount(wanted.perKmCents(), "the price per kilometre"); + amount(wanted.smallOrderCents(), "the small-order threshold"); + amount(wanted.smallOrderFeeCents(), "the small-order fee"); + between(wanted.surge(), 1d, 5d, "the surge multiplier"); + between(wanted.serviceFeePercent(), 0d, 50d, "the service fee"); + between(wanted.taxPercent(), 0d, 50d, "the tax"); + between(wanted.commissionPercent(), 0d, 90d, "the commission"); + if (prices.change(PricingRepository.ONLY.intValue(), wanted.baseCents(), + wanted.perKmCents(), wanted.surge(), wanted.smallOrderCents(), + wanted.smallOrderFeeCents(), wanted.serviceFeePercent(), wanted.taxPercent(), + wanted.commissionPercent(), System.currentTimeMillis(), by) == 0) { + throw new IllegalStateException("The pricing row is missing; the V6 migration " + + "creates it"); + } + return new PricingDto(defaults.currency(), wanted.baseCents(), wanted.perKmCents(), + wanted.surge(), wanted.smallOrderCents(), wanted.smallOrderFeeCents(), + wanted.serviceFeePercent(), wanted.taxPercent(), wanted.commissionPercent()); + } + + private static void amount(long cents, String what) { + if (cents < 0 || cents > 100000) { + throw new ResponseStatusException(400, "Check " + what); + } + } + + private static void between(double value, double low, double high, String what) { + if (!(value >= low && value <= high)) { + throw new ResponseStatusException(400, "Check " + what); + } + } + + /// The road distance from a restaurant to a door, estimated. + public static double roadMeters(double fromLat, double fromLng, double toLat, double toLng) { + return Geo.meters(fromLat, fromLng, toLat, toLng) * ROAD_FACTOR; + } + + /// Minutes of travel for a road distance, at least one. + public static int travelMinutes(double roadMeters) { + return (int) Math.max(1L, Math.round(roadMeters / METERS_PER_SECOND / 60d)); + } + + /// The delivery fee for a road distance. + public static long deliveryFee(PricingDto pricing, double roadMeters) { + return Math.round((pricing.baseCents() + roadMeters / 1000d * pricing.perKmCents()) + * pricing.surge()); + } + + /// The platform's share of a subtotal. + public static long commission(PricingDto pricing, long subtotalCents) { + return Math.round(subtotalCents * pricing.commissionPercent() / 100d); + } + + /// Whether a restaurant takes orders at this minute of the day. + public static boolean open(Restaurant restaurant, int minuteOfDay) { + if (restaurant.isPaused() || !"APPROVED".equals(restaurant.getStatus())) { + return false; + } + int from = restaurant.getOpensAt(); + int to = restaurant.getClosesAt(); + if (from == to) { + return true; + } + if (from < to) { + return minuteOfDay >= from && minuteOfDay < to; + } + return minuteOfDay >= from || minuteOfDay < to; + } + + /// The minute of the day at `now`, on the clocks of the configured + /// `savor.timezone`: what a restaurant's opening hours are compared with. + public int minuteOfDay(long now) { + return minuteOfDay(now, zone); + } + + /// The minute of the day at `now` on the clocks of `zone`, daylight saving + /// included. + public static int minuteOfDay(long now, TimeZone zone) { + Calendar clock = Calendar.getInstance(zone); + clock.setTime(new Date(now)); + return clock.get(Calendar.HOUR_OF_DAY) * 60 + clock.get(Calendar.MINUTE); + } + + /// A worked-out order, kept together for [Orders] to store. + public static final class Priced { + public Restaurant restaurant; + public QuoteDto quote; + public List lines; + public long commissionCents; + public Promo promo; + public double roadMeters; + } + + /// Prices a cart against the menu as it stands. + /// + /// @param username who is ordering, for one-use promotions; null skips that check + @Transactional(readOnly = true) + public Priced quote(String username, CartDto cart) throws IOException { + if (cart == null || cart.restaurantId() == null || cart.lines() == null + || cart.lines().isEmpty()) { + throw new ResponseStatusException(400, "The cart is empty"); + } + if (cart.lines().size() > 100) { + throw new ResponseStatusException(400, "That is too many lines for one order"); + } + if (!Geo.isPoint(cart.dropoffLat(), cart.dropoffLng())) { + throw new ResponseStatusException(400, "Choose where the order goes"); + } + Restaurant restaurant = restaurants.findById(cart.restaurantId()); + if (restaurant == null || !"APPROVED".equals(restaurant.getStatus())) { + throw new ResponseStatusException(404, "That restaurant is not taking orders"); + } + double meters = roadMeters(restaurant.getLat(), restaurant.getLng(), cart.dropoffLat(), + cart.dropoffLng()); + if (meters > MAX_METERS) { + throw new ResponseStatusException(400, restaurant.getName() + + " does not deliver that far"); + } + Map menu = new HashMap(); + List ids = new ArrayList(); + for (Dish dish : dishes.findByRestaurantIdOrderByPositionAsc(restaurant.getId())) { + menu.put(dish.getId(), dish); + ids.add(dish.getId()); + } + Map extras = new HashMap(); + if (!ids.isEmpty()) { + for (DishOption option : options.findByDishIdIn(ids)) { + extras.put(option.getId(), option); + } + } + List lines = new ArrayList(); + long subtotal = 0L; + for (CartLineDto line : cart.lines()) { + Dish dish = line == null ? null : menu.get(line.dishId()); + if (dish == null) { + throw new ResponseStatusException(400, "A dish in the cart is no longer on the " + + "menu"); + } + if (!dish.isAvailable()) { + throw new ResponseStatusException(409, dish.getName() + " is sold out"); + } + if (line.quantity() < 1 || line.quantity() > MAX_QUANTITY) { + throw new ResponseStatusException(400, "Check how many " + dish.getName()); + } + long unit = dish.getPriceCents(); + StringBuilder names = new StringBuilder(); + if (line.optionIds() != null) { + for (String optionId : line.optionIds()) { + DishOption option = extras.get(optionId); + if (option == null || !option.getDishId().equals(dish.getId())) { + throw new ResponseStatusException(400, "An extra for " + dish.getName() + + " is no longer offered"); + } + unit += option.getPriceCents(); + if (names.length() > 0) { + names.append(", "); + } + names.append(option.getName()); + } + } + if (names.length() > OPTIONS_COLUMN) { + // Every extra is priced above; only their names are cut, to + // the order_line column that keeps them. + names.setLength(OPTIONS_COLUMN - 3); + names.append("..."); + } + long cents = unit * line.quantity(); + subtotal += cents; + lines.add(new OrderLineDto(dish.getId(), dish.getName(), names.toString(), + line.quantity(), unit, cents)); + } + PricingDto pricing = pricing(); + long delivery = deliveryFee(pricing, meters); + long small = subtotal < pricing.smallOrderCents() ? pricing.smallOrderFeeCents() : 0L; + long service = Math.round(subtotal * pricing.serviceFeePercent() / 100d); + long discount = 0L; + String message = ""; + Promo promo = null; + String code = cart.promoCode() == null ? "" : cart.promoCode().trim(); + if (code.length() > 0) { + promo = promos.findById(promoCode(code)); + if (promo == null || !promo.isActive()) { + message = "That code is not valid"; + promo = null; + } else if (subtotal < promo.getMinimumCents()) { + message = "That code needs a bigger order"; + promo = null; + } else if (username != null + && promoUses.findById(Identifier.of(promo.getCode(), username)) != null) { + message = "You have already used that code"; + promo = null; + } else { + discount = Math.min(subtotal, promo.getAmountOffCents() + + Math.round(subtotal * promo.getPercentOff() / 100d)); + message = "Code applied"; + } + } + long tax = Math.round((subtotal - discount) * pricing.taxPercent() / 100d); + long tip = cart.tipCents(); + if (tip < 0 || tip > 100000) { + throw new ResponseStatusException(400, "Check the tip"); + } + long total = subtotal - discount + delivery + small + service + tax + tip; + int eta = restaurant.getPrepMinutes() + travelMinutes(meters); + Priced priced = new Priced(); + priced.restaurant = restaurant; + priced.lines = lines; + priced.promo = promo; + priced.roadMeters = meters; + priced.commissionCents = commission(pricing, subtotal - discount); + priced.quote = new QuoteDto(pricing.currency(), lines, subtotal, delivery, service, small, + tax, discount, tip, total, eta, message); + return priced; + } + + /// A promotion code in its stored form: ASCII upper case, folded by hand + /// because `toUpperCase()` follows the server's locale. + public static String promoCode(String code) { + char[] chars = code.trim().toCharArray(); + for (int iter = 0; iter < chars.length; iter++) { + if (chars[iter] >= 'a' && chars[iter] <= 'z') { + chars[iter] = (char) (chars[iter] - 32); + } + } + return new String(chars); + } +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/pay/MisconfiguredPayments.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/pay/MisconfiguredPayments.java new file mode 100644 index 00000000000..04865df2681 --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/pay/MisconfiguredPayments.java @@ -0,0 +1,112 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.pay; + +import com.codenameone.examples.savor.api.CardDto; + +import java.io.IOException; + +/// The payment provider a server gets when the one it was told to use cannot +/// be set up: every call fails, and says why. +/// +/// The problem is logged when the server starts and again on every call, +/// naming the settings to fix. Each call throws an `IOException`, as an outage +/// of the processor would, so a customer is told payments are unavailable and no +/// delivery is charged to a card nobody can reach. +public class MisconfiguredPayments implements PaymentProvider { + private final String provider; + private final String problem; + + /// @param provider the provider that was asked for, which is what [#name] + /// answers so that stored references keep naming it + /// @param problem what is wrong, naming the settings to fix + public MisconfiguredPayments(String provider, String problem) { + this.provider = provider; + this.problem = problem; + System.err.println("[config] " + problem); + } + + @Override + public String name() { + return provider; + } + + @Override + public String createCustomer(String username, String displayName) throws IOException { + throw fail(); + } + + @Override + public void forgetCustomer(String customer) throws IOException { + throw fail(); + } + + @Override + public Setup startCardSetup(String customer, String setupId) throws IOException { + throw fail(); + } + + @Override + public Card finishCardSetup(String customer, String reference, CardDto typed) + throws IOException { + throw fail(); + } + + @Override + public void detachCard(String customer, String token) throws IOException { + throw fail(); + } + + @Override + public String charge(String key, String customer, String token, long amountCents, + String currency, String description) throws IOException { + throw fail(); + } + + @Override + public String authorize(String key, String customer, String token, long amountCents, + String currency, String description) throws IOException { + throw fail(); + } + + @Override + public void capture(String reference, long amountCents) throws IOException { + throw fail(); + } + + @Override + public void release(String reference) throws IOException { + throw fail(); + } + + @Override + public Refund refund(String chargeReference, long amountCents) throws IOException { + throw fail(); + } + + @Override + public String refundStatus(String refundReference) throws IOException { + throw fail(); + } + + private IOException fail() { + System.err.println("[config] a payment call was refused: " + problem); + return new IOException(problem); + } +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/pay/PayReturnPage.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/pay/PayReturnPage.java new file mode 100644 index 00000000000..b82949e4e02 --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/pay/PayReturnPage.java @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.pay; + +import com.codename1.backend.HttpServer; +import com.codename1.backend.annotations.GetMapping; +import com.codename1.backend.annotations.RequestParam; +import com.codename1.backend.annotations.RestController; + +/// The page a hosted card setup ends on. +/// +/// The payment processor's page sends the browser here when the user has added +/// a card or given up. There is nothing to do here but say so: the app is what +/// finishes the setup, by asking the server to read it back, once the user +/// returns to it. That is also why the page needs no sign-in and takes nothing +/// from its address but which of two sentences to show -- being sent here +/// proves nothing and changes nothing. +@RestController +public class PayReturnPage { + @GetMapping("/pay/return") + public HttpServer.Response page( + @RequestParam(value = "result", required = false) String result) throws Exception { + boolean done = "done".equals(result); + String html = "" + + "" + + "Savor" + + "

" + (done ? "Card added" : "No card was added") + "

" + + (done ? "You can close this page and go back to Savor to finish." + : "You can close this page and go back to Savor to try again.") + + "

"; + return new HttpServer.Response(200, "text/html; charset=utf-8", html.getBytes("UTF-8")); + } +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/pay/PaymentDeclinedException.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/pay/PaymentDeclinedException.java new file mode 100644 index 00000000000..8aa8db34d11 --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/pay/PaymentDeclinedException.java @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.pay; + +import java.io.IOException; + +/// A card was refused, or a setup was not finished: the processor was asked +/// and said no. The message is written for the person paying and is safe to +/// show them -- unlike that of a plain `IOException` from a provider, which +/// describes a fault and goes to the log. +public class PaymentDeclinedException extends IOException { + public PaymentDeclinedException(String message) { + super(message); + } +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/pay/PaymentEndpoint.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/pay/PaymentEndpoint.java new file mode 100644 index 00000000000..73f7d21425f --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/pay/PaymentEndpoint.java @@ -0,0 +1,96 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.pay; + +import com.codename1.backend.annotations.Component; +import com.codenameone.examples.savor.Caller; +import com.codenameone.examples.savor.account.Accounts; +import com.codenameone.examples.savor.api.CardDto; +import com.codenameone.examples.savor.api.PaymentApiServer; +import com.codenameone.examples.savor.api.PaymentConfigDto; +import com.codenameone.examples.savor.api.PaymentMethodDto; +import com.codenameone.examples.savor.api.PaymentSetupDto; +import com.codenameone.examples.savor.api.ReceiptDto; +import com.codenameone.examples.savor.api.TipDto; +import com.codenameone.examples.savor.order.Quotes; + +import java.util.List; + +/// The server's half of `PaymentApi`. Every method acts as the signed-in user +/// and on that user's own cards and receipts: whose they are is never a +/// parameter, so there is no id to change to reach someone else's. +@Component +public class PaymentEndpoint implements PaymentApiServer { + private final Payments payments; + private final Accounts accounts; + private final Quotes fares; + + public PaymentEndpoint(Payments payments, Accounts accounts, Quotes fares) { + this.payments = payments; + this.accounts = accounts; + this.fares = fares; + } + + @Override + public PaymentConfigDto config() throws Exception { + Caller.name(); + return payments.config(fares.currency()); + } + + @Override + public List methods() throws Exception { + return payments.methods(Caller.name()); + } + + @Override + public PaymentSetupDto startSetup() throws Exception { + String who = Caller.name(); + return payments.startSetup(who, accounts.describe(who).displayName()); + } + + @Override + public PaymentMethodDto completeSetup(String id, CardDto card) throws Exception { + return payments.completeSetup(Caller.name(), id, card); + } + + @Override + public PaymentMethodDto makeDefault(String id) throws Exception { + return payments.makeDefault(Caller.name(), id); + } + + @Override + public PaymentMethodDto remove(String id) throws Exception { + return payments.remove(Caller.name(), id); + } + + @Override + public List receipts() throws Exception { + return payments.receipts(Caller.name()); + } + + @Override + public ReceiptDto receipt(String deliveryId) throws Exception { + return payments.receipt(Caller.name(), false, deliveryId); + } + + @Override + public ReceiptDto tip(String id, TipDto tip) throws Exception { + return payments.tip(Caller.name(), id, tip == null ? 0L : tip.amount()); + } +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/pay/PaymentProvider.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/pay/PaymentProvider.java new file mode 100644 index 00000000000..19626fd741d --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/pay/PaymentProvider.java @@ -0,0 +1,137 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.pay; + +import com.codenameone.examples.savor.api.CardDto; + +import java.io.IOException; + +/// A payment processor. The one place the server touches one. +/// +/// The shape is the one real processors share. A customer is a *customer* of the +/// processor; a card is saved to that customer once, in a *setup*, and comes +/// back as a token; from then on the server charges the token with nobody +/// present, which is what lets a delivery be paid for as it ends. The server keeps +/// the token, a brand and four digits. It never keeps a card number, and with +/// a hosted setup it never sees one. +// tag::savor-payment-provider[] +public interface PaymentProvider { + /// `simulated` or `stripe`: what the app is told, so it knows how to add a + /// card. + String name(); + + /// Opens the processor's record of a customer and returns its reference. + String createCustomer(String username, String displayName) throws IOException; + + /// Deletes the processor's record of a customer, and the cards saved to it + /// with it. A record that is already gone is deleted: this is asked again + /// when the first answer was lost. + void forgetCustomer(String customer) throws IOException; + + /// Begins saving a card to `customer`. `setupId` is the server's own id + /// for the attempt, for the processor to hand back. + Setup startCardSetup(String customer, String setupId) throws IOException; + + /// Finishes a setup and returns the card it saved. + /// + /// @param reference what [#startCardSetup] returned as the setup's reference + /// @param typed the card as typed into the app, for a processor whose setup + /// is not hosted; ignored by one whose setup is + /// - `PaymentDeclinedException`: when the card was refused or the setup was + /// not finished + Card finishCardSetup(String customer, String reference, CardDto typed) throws IOException; + + /// Takes a saved card off `customer`, so that it can't be charged again. + /// A card that is already off, or gone, is detached: this is asked again + /// when the first answer was lost. + /// + /// @param token what [#finishCardSetup] returned as the card's token + void detachCard(String customer, String token) throws IOException; + + /// Charges a saved card with its owner absent. + /// + /// @param key names this charge and no other. A processor asked twice with + /// the same key charges once, so a request repeated after a timeout + /// cannot take the money twice. + /// @return the processor's reference for the charge + /// - `PaymentDeclinedException`: when the card was refused + /// - `IOException`: when the processor could not be asked + String charge(String key, String customer, String token, long amountCents, String currency, + String description) throws IOException; + + /// Returns `amountCents` of a charge to the card it came from. + /// + /// A processor may take the refund and only later say whether the bank + /// returned the money: such a refund comes back [Refund#pending], and is + /// asked about with [#refundStatus] until it has an answer. + /// + /// @return the refund as the processor took it + /// - `IOException`: when the processor could not be asked, or refused the + /// refund outright + Refund refund(String chargeReference, long amountCents) throws IOException; + + /// What became of a refund that was [Refund#pending]: [#REFUND_SUCCEEDED], + /// [#REFUND_PENDING] while the processor still does not know, or + /// [#REFUND_FAILED] when the money did not go back. + String refundStatus(String refundReference) throws IOException; + /// Holds `amountCents` on a saved card without taking it, for an order + /// that is paid for only once it arrives. Keyed as [#charge] is. + /// + /// @return the processor's reference for the hold, for [#capture] or [#release] + /// - `PaymentDeclinedException`: when the card was refused + String authorize(String key, String customer, String token, long amountCents, + String currency, String description) throws IOException; + /// Takes `amountCents`, at most what was held, from a hold. A hold that was + /// already captured is captured: this is asked again when the first answer + /// was lost. + void capture(String reference, long amountCents) throws IOException; + /// Lets a hold go, so the money is the cardholder's again. A hold that is + /// already let go is released. + void release(String reference) throws IOException; + // end::savor-payment-provider[] + + String REFUND_SUCCEEDED = "succeeded"; + String REFUND_PENDING = "pending"; + String REFUND_FAILED = "failed"; + + /// A refund the processor took. + final class Refund { + public String reference = ""; + /// Whether the money is not yet known to be back on the card. + public boolean pending; + } + + /// A setup under way. + final class Setup { + /// Whether the card is typed into the processor's own page, at `url`. + public boolean hosted; + public String url = ""; + /// What to hand to [PaymentProvider#finishCardSetup]. + public String reference = ""; + } + + /// A saved card: what may be shown of it, and the token it is charged by. + final class Card { + public String brand = ""; + public String last4 = ""; + public int expMonth; + public int expYear; + public String token = ""; + } +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/pay/Payments.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/pay/Payments.java new file mode 100644 index 00000000000..35c5fb933c2 --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/pay/Payments.java @@ -0,0 +1,1271 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.pay; + +import com.codename1.backend.ResponseStatusException; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Scheduled; +import com.codename1.backend.annotations.Transactional; +import com.codename1.orm.repository.PageRequest; +import com.codenameone.examples.savor.Ids; +import com.codenameone.examples.savor.api.CardDto; +import com.codenameone.examples.savor.api.PaymentConfigDto; +import com.codenameone.examples.savor.api.PaymentMethodDto; +import com.codenameone.examples.savor.api.PaymentSetupDto; +import com.codenameone.examples.savor.api.ReceiptDto; +import com.codenameone.examples.savor.api.DeliveryState; +import com.codenameone.examples.savor.api.OrderState; +import com.codenameone.examples.savor.domain.Payment; +import com.codenameone.examples.savor.domain.PaymentCustomer; +import com.codenameone.examples.savor.domain.PaymentMethod; +import com.codenameone.examples.savor.domain.PaymentSetup; +import com.codenameone.examples.savor.domain.Profile; +import com.codenameone.examples.savor.domain.CustomerOrder; +import com.codenameone.examples.savor.domain.Delivery; +import com.codenameone.examples.savor.live.LiveHub; +import com.codenameone.examples.savor.repository.PaymentCustomerRepository; +import com.codenameone.examples.savor.repository.PaymentMethodRepository; +import com.codenameone.examples.savor.repository.PaymentRepository; +import com.codenameone.examples.savor.repository.PaymentSetupRepository; +import com.codenameone.examples.savor.repository.ProfileRepository; +import com.codenameone.examples.savor.repository.CustomerOrderRepository; +import com.codenameone.examples.savor.repository.DeliveryRepository; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/// The ways a customer can pay, and what each delivery came to. +/// +/// A delivery is paid for when it completes: the fare and the service fee, charged +/// to the method the delivery was asked for with. A tip is a second charge, made +/// when the customer leaves one. A delivery paid in cash is recorded and nothing is +/// charged. What the courier earns from all this is `courier/Earnings`' business. +/// +/// Everything here that takes a `username` acts on that user's own methods and +/// deliveries and no one else's; the endpoints pass the signed-in user. +/// +/// The processor is somewhere else, and a call to it can take seconds, so none +/// is made with a transaction open. Whatever talks to it is three steps: a +/// transaction that reads, or claims, what the call needs and commits; the +/// call; and a transaction that records what came of it, or gives the claim +/// back. +@Component +public class Payments { + public static final String PENDING = "PENDING"; + /// An order whose money is held on the card and not yet taken. + public static final String AUTHORIZED = "AUTHORIZED"; + /// An order that never arrived, whose hold was let go. + public static final String RELEASED = "RELEASED"; + /// How long a completed delivery's charge goes on being asked for. Short of + /// the day a processor remembers the key a charge was first sent under. + public static final long RETRY_MILLIS = 23L * 60L * 60L * 1000L; + public static final String PAID = "PAID"; + public static final String FAILED = "FAILED"; + public static final String REFUNDED = "REFUNDED"; + /// Refunded at the processor, which has not yet said that the money is + /// back on the card. See [#settleRefunds]. + public static final String REFUND_PENDING = "REFUND_PENDING"; + /// A refund the processor took and then failed or canceled: the customer has + /// not had the money back. + public static final String REFUND_FAILED = "REFUND_FAILED"; + /// The id and the label of the method every account has. + public static final String CASH = "cash"; + public static final String CASH_LABEL = "Cash"; + private static final int MAX_CARDS = 8; + /// A setup not finished within this long is abandoned. + private static final long SETUP_MILLIS = 3600000L; + /// How many setups an account may have open at once. Each is a session + /// made at the processor and a row here; someone adding a card has one, + /// or two after giving up on the first. + private static final int MAX_OPEN_SETUPS = 5; + private static final long MAX_TIP_CENTS = 20000L; + /// The states of a delivery that has not ended. + private static final DeliveryState[] UNDER_WAY = {DeliveryState.REQUESTED, DeliveryState.OFFERED, + DeliveryState.ACCEPTED, DeliveryState.ARRIVED, DeliveryState.IN_PROGRESS}; + /// An order in one of these holds money on its card, to be taken when it + /// arrives -- including while the kitchen has not answered, before any + /// delivery exists to see. + private static final OrderState[] ORDER_UNDER_WAY = {OrderState.PLACED, OrderState.ACCEPTED, + OrderState.READY, OrderState.PICKED_UP}; + + private final PaymentMethodRepository cards; + private final PaymentSetupRepository setups; + private final PaymentCustomerRepository customers; + private final PaymentRepository payments; + private final DeliveryRepository deliveries; + private final CustomerOrderRepository orders; + private final ProfileRepository profiles; + private final PaymentProvider provider; + private final LiveHub live; + + public Payments(PaymentMethodRepository cards, PaymentSetupRepository setups, + PaymentCustomerRepository customers, PaymentRepository payments, + DeliveryRepository deliveries, CustomerOrderRepository orders, + ProfileRepository profiles, PaymentProvider provider, LiveHub live) { + this.cards = cards; + this.setups = setups; + this.customers = customers; + this.payments = payments; + this.deliveries = deliveries; + this.orders = orders; + this.profiles = profiles; + this.provider = provider; + this.live = live; + } + + public PaymentConfigDto config(String currency) { + return new PaymentConfigDto(provider.name(), currency); + } + + // --------------------------------------------------------------- methods + + /// The saved cards, oldest first, then cash. Exactly one of them is the + /// default: a card when one is marked, cash otherwise. + @Transactional(readOnly = true) + public List methods(String username) throws IOException { + List rows = cards.findByUsernameOrderByCreatedAtAscIdAsc(username); + List out = new ArrayList(); + boolean marked = false; + for (int iter = 0; iter < rows.size(); iter++) { + PaymentMethodDto dto = card(rows.get(iter)); + marked |= dto.isDefault(); + out.add(dto); + } + out.add(cash(!marked)); + return out; + } + + /// The id of the user's default method. + @Transactional(readOnly = true) + public String defaultMethod(String username) throws IOException { + PaymentMethod row = cards.findFirstByUsernameAndPreferredTrue(username); + return row == null ? CASH : row.getId(); + } + + /// Begins adding a card. + public PaymentSetupDto startSetup(String username, String displayName) throws IOException { + String customer = customerForSetup(username); + String id = Ids.next(); + PaymentProvider.Setup setup; + try { + if (customer == null) { + String created = provider.createCustomer(username, displayName); + customer = keepCustomer(username, created); + if (!created.equals(customer)) { + // Not kept: the account was closed while the processor was + // asked (its clean-up found no customer to delete, so this + // one is ours to delete), or another request kept its own + // first. Either way the record made here would otherwise + // stay at the processor with the customer's name on it. + forgetQuietly(created); + } + if (customer == null) { + throw new ResponseStatusException(404, "No such account"); + } + } + setup = provider.startCardSetup(customer, id); + } catch (IOException failed) { + throw unavailable("card setup", failed); + } + keepSetup(username, id, setup.reference); + return new PaymentSetupDto(id, setup.hosted, setup.url); + } + + /// The customer's reference at the processor, or null when they have none + /// yet. Answers 409 for an account that has all the cards it may, and + /// 429 for one with as many setups open as it may have. + @Transactional(readOnly = true) + private String customerForSetup(String username) { + if (cards.countByUsername(username) >= MAX_CARDS) { + throw new ResponseStatusException(409, "Remove a card before adding another"); + } + // Refused before the processor is asked: every setup is a session + // made there, so a loop of these would otherwise make sessions + // without end in the service's name. + refuseTooManySetups(username); + PaymentCustomer row = customers.findById(username); + return row == null ? null : row.getReference(); + } + + /// Answers 429 when the account already has [#MAX_OPEN_SETUPS] setups + /// that are neither finished nor abandoned. + private void refuseTooManySetups(String username) { + if (setups.countByUsernameAndCompletedFalseAndCreatedAtGreaterThan(username, + System.currentTimeMillis() - SETUP_MILLIS) >= MAX_OPEN_SETUPS) { + throw new ResponseStatusException(429, + "Too many cards are being added. Finish one, or try again later"); + } + } + + /// Deletes the setups nobody can use any more: the finished ones, whose + /// card is saved, and the abandoned ones. Without it every setup ever + /// started would stay, one row for each tap of "Add card". + /// + /// An abandoned setup is kept for twice the time it can be finished in. + /// One finished just inside the hour is still at the processor when the + /// hour passes, and its row is what saves the card when the answer comes. + @Scheduled(fixedDelay = 600000, initialDelay = 60000, lock = "savor-card-setups") + @Transactional + public void sweepSetups() throws IOException { + setups.deleteByCompletedTrue(); + setups.deleteByCreatedAtLessThan(System.currentTimeMillis() - 2L * SETUP_MILLIS); + } + + /// Keeps the reference the processor made for a customer on first use, and + /// returns the one the account has: another request may have kept its own + /// while this one was asking for it. Null when the account is gone. + @Transactional + private String keepCustomer(String username, String reference) { + // Two first cards started together both find no row. Taking turns on + // the account lets the second see the first's row and use it, where + // it would otherwise insert a second and fail on the key. With no + // profile to hold the account was closed meanwhile, and its clean-up + // has already run: a row kept now would never be deleted. + if (profiles.hold(username) == 0) { + return null; + } + PaymentCustomer row = customers.findById(username); + if (row == null) { + row = new PaymentCustomer(); + row.setUsername(username); + row.setReference(reference); + customers.insert(row); + } + return row.getReference(); + } + + @Transactional + private void keepSetup(String username, String id, String reference) { + // As in keepCustomer: an account closed while the processor was asked + // gets no row its clean-up has already missed. + if (profiles.hold(username) == 0) { + throw new ResponseStatusException(404, "No such account"); + } + // Counted again with the account held: several requests sent together + // all pass the count made before the processor was asked. + // + // The session a refusal here leaves at the processor is not cleaned + // up, on purpose. Its address is never answered, so nobody can open + // it; a card entered there anyway could not be saved, because saving + // claims this row and there is none; and the processor expires an + // unfinished session by itself. Only the requests already past the + // first count when the limit was reached can leave one, so a loop + // of them still makes no more than a handful. + refuseTooManySetups(username); + PaymentSetup row = new PaymentSetup(); + row.setId(id); + row.setUsername(username); + row.setReference(reference); + row.setCreatedAt(System.currentTimeMillis()); + row.setCompleted(false); + setups.insert(row); + } + + /// Finishes adding a card and returns it. The first card an account saves + /// becomes its default: someone who adds a card means to pay with it. + /// Answers 402 when the card was refused, with the reason. + public PaymentMethodDto completeSetup(String username, String setupId, CardDto typed) + throws IOException { + String[] references = openSetup(username, setupId); + PaymentProvider.Card card; + try { + card = provider.finishCardSetup(references[0], references[1], typed); + } catch (PaymentDeclinedException declined) { + throw new ResponseStatusException(402, declined.getMessage()); + } catch (IOException failed) { + throw unavailable("card setup", failed); + } + try { + return keepCard(username, setupId, Ids.next(), card); + } catch (ResponseStatusException refused) { + // The processor attached the card when the setup finished. Refused + // here by the card limit -- two setups finishing together for the + // last slot, or a slot filled meanwhile -- it has no row, so the + // customer has nothing to remove it through: it is detached here + // instead. keepCard has rolled back by now, so this runs outside + // its transaction and nothing undoes it. + // + // Undone after the fact rather than a slot reserved before the + // processor is asked: a reservation is one more row with a life of + // its own, given back when the processor declines, times out or + // the server stops between the two, and swept when nothing gives + // it back. A refusal is the rare case, and this costs only it. + // + // Only the limit's 409. The 404 is a setup claimed by a request + // sent twice, whose other copy may have saved this very card. + if (refused.getStatus() == 409) { + discard(username, references[0], card); + } + throw refused; + } + } + + /// Detaches a card the processor attached that this server did not keep. + /// A failure is logged and not answered: the customer's answer is the refusal + /// that brought us here, and the card is no worse off than before. + private void discard(String username, String customer, PaymentProvider.Card card) { + try { + provider.detachCard(customer, card.token); + } catch (IOException failed) { + System.err.println("a card refused for " + username + " is still attached at the " + + "processor, as " + card.token + ": " + failed.getMessage()); + } + } + + /// What the processor calls the customer and a setup of theirs that is still + /// open, in that order. Answers 404 for a setup that is not. + @Transactional(readOnly = true) + private String[] openSetup(String username, String setupId) { + PaymentSetup setup = setupId == null || setupId.length() == 0 || setupId.length() > 32 + ? null : setups.findFirstByIdAndUsername(setupId, username); + if (setup == null || setup.isCompleted() + || setup.getCreatedAt() < System.currentTimeMillis() - SETUP_MILLIS) { + throw new ResponseStatusException(404, "No such card setup"); + } + PaymentCustomer customer = customers.findById(username); + return new String[] {customer == null ? "" : customer.getReference(), setup.getReference()}; + } + + /// Closes the setup and saves its card, together or not at all. + @Transactional + private PaymentMethodDto keepCard(String username, String setupId, String id, + PaymentProvider.Card card) { + // Claimed with a conditional update, so a request sent twice saves the + // card once. + if (setups.claim(setupId) != 1) { + throw new ResponseStatusException(404, "No such card setup"); + } + // Counted again here, where the card is saved: starting a setup checks + // the limit too, but several can be started while there is room for + // one. The account's row at the processor is written first, so two + // setups finishing together take turns and the second counts the + // first one's card. Refusing rolls the claim back with everything else. + customers.hold(username); + if (cards.countByUsername(username) >= MAX_CARDS) { + throw new ResponseStatusException(409, "Remove a card before adding another"); + } + boolean first = cards.findFirstByUsernameAndPreferredTrue(username) == null; + // Cards are listed in the order they were added, and two added in the + // same millisecond would have no order: the later is stamped after + // the one before it. + Long newest = cards.newestAt(username); + long after = newest == null ? 0L : newest.longValue() + 1L; + PaymentMethod row = new PaymentMethod(); + row.setId(id); + row.setUsername(username); + row.setBrand(cut(card.brand, 24)); + row.setLast4(cut(card.last4, 4)); + row.setExpMonth(card.expMonth); + row.setExpYear(card.expYear); + row.setToken(card.token); + row.setPreferred(first); + row.setCreatedAt(Math.max(System.currentTimeMillis(), after)); + cards.insert(row); + return card(row); + } + + /// Makes a method the one deliveries are paid with unless they say otherwise. + @Transactional + public PaymentMethodDto makeDefault(String username, String id) throws IOException { + // One at a time for an account: two choices made together would each + // clear the cards it had read and mark its own, and leave two marked. + profiles.hold(username); + PaymentMethod chosen = CASH.equals(id) ? null : owned(username, id); + // The cards are few, and each is written only if its mark changed. + List rows = cards.findByUsernameOrderByCreatedAtAscIdAsc(username); + for (int iter = 0; iter < rows.size(); iter++) { + PaymentMethod row = rows.get(iter); + row.setPreferred(chosen != null && row.getId().equals(chosen.getId())); + } + if (chosen == null) { + return cash(true); + } + chosen.setPreferred(true); + return card(chosen); + } + + /// Forgets a card, here and at the processor. Cash cannot be removed, and + /// neither can the card a delivery under way is to be paid with. + /// + /// The card is detached at the processor before its row is deleted, and + /// inside the transaction that deletes it, as [#forget] deletes a + /// customer: the token is the only way back to the card, so a processor + /// that cannot be reached fails the removal and the card stays, to be + /// removed again. Detaching a card that is already detached succeeds, so + /// a removal whose commit failed after the processor answered finishes + /// the second time. The account's row is held across the request, which + /// holds back only that customer's own delivery requests, and removing a card is + /// rare enough for that. + @Transactional + public PaymentMethodDto remove(String username, String id) throws IOException { + if (CASH.equals(id)) { + throw new ResponseStatusException(400, "Cash is always available"); + } + // Placing an order holds the same row before it picks its card. So an + // order and a removal sent together take turns: either the order is + // there to be seen below, or it no longer finds the card. + profiles.hold(username); + PaymentMethod row = owned(username, id); + // What is answered is the card as it was, no longer anyone's default. + PaymentMethodDto dto = card(row, false); + if (orders.existsByCustomerAndMethodIdAndStateIn(username, id, ORDER_UNDER_WAY)) { + throw new ResponseStatusException(409, "That card is paying for an order under way"); + } + if (deliveries.existsByCustomerAndMethodIdAndStateIn(username, id, UNDER_WAY)) { + throw new ResponseStatusException(409, "That card is paying for a delivery under way"); + } + // A delivery that ended with nobody able to say whether its card was + // charged is asked about again, by the card's token. With the card + // gone the question could not be put: the delivery would be written down + // as unpaid, whether or not the first attempt took the money. + if (awaitsCharge(username, id, retriedSince())) { + throw new ResponseStatusException(409, + "That card is still paying for a delivery. Try again in a few minutes"); + } + PaymentCustomer customer = customers.findById(username); + if (customer != null && customer.getReference().length() > 0) { + try { + provider.detachCard(customer.getReference(), row.getToken()); + } catch (IOException failed) { + // A runtime exception, so the transaction rolls back with it. + throw unavailable("card removal", failed); + } + } + cards.deleteById(row.getId()); + return dto; + } + + /// The method a delivery is asked for with: the one named, or the default when + /// none is. Answers 400 for a method that is not the customer's. + /// + /// @return the method's id and its label, in that order + @Transactional(readOnly = true) + public String[] choose(String username, String wanted) throws IOException { + String id = wanted == null || wanted.trim().length() == 0 ? defaultMethod(username) + : wanted.trim(); + if (CASH.equals(id)) { + return new String[] {CASH, CASH_LABEL}; + } + PaymentMethod row = card(username, id); + if (row == null) { + throw new ResponseStatusException(400, "Choose a way to pay"); + } + return new String[] {id, label(row)}; + } + + /// Everything of a user's that this class keeps, for an account being + /// closed, here and at the processor. Answers 409 while a delivery of theirs + /// is still to be charged; see [#remove]. + /// + /// The processor is asked last, and inside the transaction that deletes + /// the rest. It holds the customer's address, their name and their cards, and + /// the reference deleted here is the only way back to them: forgotten + /// first, a request that then failed would leave that record with nothing + /// pointing at it. So a processor that cannot be reached fails the closure + /// whole and the account stays as it was, to be closed again. The other + /// way round -- the processor has deleted and the commit then fails -- is + /// mended by the same second attempt, since a customer already gone counts + /// as deleted. A closure is rare enough to wait on a request with a row + /// held, which nothing else in this class does. + @Transactional(rollbackFor = IOException.class) + public void forget(String username) throws IOException { + // Only charges still being retried hold a closure up. One left + // pending past the retry window has already been handed to a person + // to settle against the processor's own records, which keep the + // charge and the card used after the customer is deleted here. + // Counting it would refuse the closure for good over something the + // customer can do nothing about. + if (awaitsCharge(username, null, retriedSince())) { + throw new ResponseStatusException(409, + "A delivery is still being paid for. Try again in a few minutes"); + } + PaymentCustomer customer = customers.findById(username); + String reference = customer == null ? "" : customer.getReference(); + cards.deleteByUsername(username); + setups.deleteByUsername(username); + customers.deleteByUsername(username); + if (reference != null && reference.length() > 0) { + provider.forgetCustomer(reference); + } + } + + private static long retriedSince() { + return System.currentTimeMillis() - RETRY_MILLIS; + } + + // --------------------------------------------------------------- charges + + // tag::savor-authorize[] + /// Holds an order's total on the customer's card as the order is placed, + /// with no transaction open. Nothing is taken until the order arrives + /// ([#settle]); an order that never does lets the hold go ([#release]). + /// + /// @return the hold's reference, or [#CASH] for an order paid in cash + /// - `ResponseStatusException` 402: when the card was refused + public String authorize(String orderId, String username, String methodId, long amountCents, + String currency) throws IOException { + if (CASH.equals(methodId)) { + return CASH; + } + String[] references = chargeable(username, methodId); + if (references == null) { + throw new ResponseStatusException(402, "The card is no longer on the account"); + } + try { + return cut(provider.authorize("order-" + orderId, references[0], references[1], + amountCents, currency, "Savor order"), 64); + } catch (PaymentDeclinedException declined) { + throw new ResponseStatusException(402, declined.getMessage()); + } catch (IOException unknown) { + throw unavailable("card check", unknown); + } + } + // end::savor-authorize[] + + /// Lets go of the money held for an order that will not arrive. Safe to + /// repeat. A hold the processor could not be asked about is left for the + /// processor to expire, as every card hold does within days; the order + /// says so. + public void release(String orderId) throws IOException { + CustomerOrder order = order(orderId); + if (order == null) { + return; + } + // A cash order holds nothing, and is PENDING until the courier is + // paid at the door; one that will not arrive is released all the same. + boolean cash = CASH.equals(order.getAuthorizationRef()); + if (!AUTHORIZED.equals(order.getPaymentStatus()) + && !(cash && PENDING.equals(order.getPaymentStatus()))) { + return; + } + if (!cash && !releaseHold(orderId, order.getAuthorizationRef())) { + return; + } + orderPaymentStatus(orderId, RELEASED); + } + + /// Lets go of a hold by its reference, for an order that was never + /// written: the hold is made before the order's row, and a row that + /// failed to commit leaves nothing for [#release] to find it by. + /// + /// @return false when the processor could not be asked, and the hold is + /// left for it to expire + public boolean releaseHold(String orderId, String reference) { + if (reference == null || reference.length() == 0 || CASH.equals(reference)) { + return true; + } + try { + provider.release(reference); + return true; + } catch (IOException failed) { + System.err.println("the hold for order " + orderId + " was not released: " + + failed.getMessage()); + return false; + } + } + + @Transactional(readOnly = true) + private CustomerOrder order(String id) { + return id == null ? null : orders.findById(id); + } + + @Transactional + private void orderPaymentStatus(String id, String status) { + orders.paymentStatus(id, status, System.currentTimeMillis()); + } + + /// Writes what came of a delivery's payment to the delivery and to its + /// order, which share the id: the customer reads the order, the courier + /// the delivery, and both must say the same thing. Called inside the + /// caller's transaction. + private void paymentStatus(String deliveryId, String status) { + deliveries.paymentStatus(deliveryId, status); + orders.paymentStatus(deliveryId, status, System.currentTimeMillis()); + } + + /// Settles a delivery that has just completed and returns [#PAID], [#FAILED], + /// or [#PENDING] when nobody can say yet. + /// A delivery is settled once: its payment is keyed by the delivery, and a delivery + /// completes once. Called with no transaction open, since the card is + /// charged here; what came of the charge is then written in one. + /// + /// Safe to ask for again, which is how a delivery whose settlement was cut + /// short is finished later: the charge carries the delivery as its key, so + /// the processor answers a second request with the first one's charge, and + /// a payment already written is the answer without asking anyone. + /// + /// Only a card the processor refused is a payment that failed. Any other + /// failure -- the processor not reached, its answer lost on the way back -- + /// says nothing about whether the card was charged, so nothing is written + /// and the delivery stays pending for `Deliveries.settleLeftBehind` to ask again + /// under the same key. Written down as failed, a charge that did go through + /// would never be looked for, and the customer would have paid for a delivery + /// that says they did not. + public String settle(Delivery delivery) throws IOException { + Payment kept = payment(delivery.getId()); + if (kept != null) { + return kept.getStatus(); + } + // A delivery is an order's last leg, under the order's id: what is + // taken is the order's total, from the hold made when it was placed. + CustomerOrder order = order(delivery.getId()); + long amount = order == null ? delivery.getFareCents() + delivery.getTipCents() + : order.getTotalCents(); + String status = PAID; + String reference = CASH; + if (!CASH.equals(delivery.getMethodId())) { + try { + String hold = order == null ? "" : order.getAuthorizationRef(); + if (hold != null && hold.length() > 0 && !CASH.equals(hold)) { + provider.capture(hold, amount); + reference = hold; + } else { + reference = charge("delivery-" + delivery.getId(), delivery.getCustomer(), + delivery.getMethodId(), amount, delivery.getCurrency(), "Savor order"); + } + } catch (PaymentDeclinedException declined) { + // The delivery and the amount, never the card. + System.err.println("payment for delivery " + delivery.getId() + " failed: " + + declined.getMessage()); + status = FAILED; + reference = ""; + } catch (IOException unknown) { + System.err.println("payment for delivery " + delivery.getId() + " is not known yet: " + + unknown.getMessage()); + return PENDING; + } + } + keepPayment(delivery.getId(), amount, delivery.getCurrency(), status, reference); + if (order != null) { + orderPaymentStatus(order.getId(), status); + } + return status; + } + + @Transactional + private void keepPayment(String deliveryId, long amount, String currency, String status, + String reference) { + if (payments.findById(deliveryId) != null) { + // Written while this request was at the processor. + return; + } + Payment row = new Payment(); + row.setDeliveryId(deliveryId); + row.setAmountCents(amount); + row.setCurrency(currency); + row.setStatus(status); + row.setProviderRef(reference); + row.setCreatedAt(System.currentTimeMillis()); + payments.insert(row); + } + + /// Tips the courier of a completed delivery, once. Charged to the method the + /// delivery was paid with; a cash delivery's tip is recorded as given in cash. + /// + /// The tip is taken off the delivery again only when the card was refused. A + /// charge that failed any other way may have gone through, so the tip + /// stays on the delivery with no charge recorded for it: the customer is told to + /// try again, and trying again with the same amount asks the processor + /// under the same key, which answers with the first charge if there was + /// one. Until then the delivery cannot be refunded -- see [#claimRefund]. + public ReceiptDto tip(String customer, String deliveryId, long amountCents) throws IOException { + Delivery delivery = claimTip(customer, deliveryId, amountCents); + if (!CASH.equals(delivery.getMethodId())) { + String reference; + try { + reference = charge("tip-" + deliveryId, customer, delivery.getMethodId(), amountCents, + delivery.getCurrency(), "Savor tip"); + } catch (PaymentDeclinedException declined) { + releaseTip(deliveryId); + throw new ResponseStatusException(402, declined.getMessage()); + } catch (IOException unknown) { + throw unavailable("tip", unknown); + } + keepTip(deliveryId, reference); + } + // The courier's earnings changed; their app reads them again. + live.deliveryChanged(deliveryId, delivery.getState(), customer, delivery.getCourier(), ""); + return receipt(customer, false, deliveryId); + } + + /// Sets the tip on the customer's delivery and returns the delivery as it was just + /// before. Committed before the card is charged. + @Transactional + private Delivery claimTip(String customer, String deliveryId, long amountCents) { + Delivery delivery = delivery(deliveryId); + if (delivery == null || !customer.equals(delivery.getCustomer())) { + throw new ResponseStatusException(404, "No such delivery"); + } + if (amountCents < 1L || amountCents > MAX_TIP_CENTS) { + throw new ResponseStatusException(400, "That is not an amount for a tip"); + } + // Not for a courier who has closed the account: the tip would be + // credited to a name nobody can sign in as or cash out. A closure can + // still land while this tip's charge is at the processor, since the + // balance counts a tip only once its charge is recorded; the courier + // would have to close in that second or two, just after cashing out. + // Holding closures until every tip in flight settles would need a + // state of its own, and one that never settles would hold the + // account open for good. + String courier = delivery.getCourier(); + if (courier == null || courier.length() == 0 || profiles.findById(courier) == null) { + throw new ResponseStatusException(409, "That courier can no longer be tipped"); + } + // Claimed before the charge: of two requests, one changes the row and + // only that one charges. + if (deliveries.claimTip(deliveryId, customer, amountCents) != 1 && !tipLeftUnknown(delivery, amountCents)) { + throw new ResponseStatusException(409, "That delivery cannot be tipped now"); + } + return delivery; + } + + /// Whether the delivery already carries this very tip with no charge recorded + /// for it: one whose charge was asked for and never answered. Asking again + /// is safe only for the same amount, the key being the delivery's. + private boolean tipLeftUnknown(Delivery delivery, long amountCents) { + if (delivery.getTipCents() != amountCents || CASH.equals(delivery.getMethodId()) + || !PAID.equals(delivery.getPaymentStatus())) { + return false; + } + Payment payment = payments.findById(delivery.getId()); + return payment != null && (payment.getTipRef() == null || payment.getTipRef().length() == 0); + } + + @Transactional + private void releaseTip(String deliveryId) { + deliveries.releaseTip(deliveryId); + } + + @Transactional + private void keepTip(String deliveryId, String reference) { + Payment payment = payments.findById(deliveryId); + if (payment != null) { + payment.setTipRef(reference); + } + } + + /// Returns what a delivery cost to the card that paid for it, tip included. + /// For an admin. A cash delivery has nothing to return, and a delivery is refunded + /// once. + public ReceiptDto refund(String deliveryId, String reason) throws IOException { + Delivery delivery = claimRefund(deliveryId); + Payment payment = payment(deliveryId); + String why = cut(reason == null ? "" : reason.trim(), 255); + PaymentProvider.Refund fare; + PaymentProvider.Refund tip = null; + try { + fare = provider.refund(payment.getProviderRef(), payment.getAmountCents()); + } catch (IOException failed) { + releaseRefund(deliveryId); + throw unavailable("refund", failed); + } + if (payment.getTipRef().length() > 0) { + try { + tip = provider.refund(payment.getTipRef(), delivery.getTipCents()); + } catch (IOException failed) { + // The fare is back with the customer already. Paid again, the + // delivery would show that money as paid and its courier could cash + // it out; so the fare's refund is kept and the payment marked + // failed, which is what it is until a person with the + // processor's records settles the tip -- see settleRefunds. + keepPartialRefund(deliveryId, fare, why); + throw unavailable("tip refund", failed); + } + } + keepRefund(deliveryId, fare, tip, why); + live.deliveryChanged(deliveryId, delivery.getState(), delivery.getCustomer(), delivery.getCourier(), ""); + return receipt("", true, deliveryId); + } + + /// Asks the processor how each refund it took and had not finished came + /// out, and records the ones that have an answer. + /// + /// A processor can take a refund and only say later whether the bank + /// returned the money; Stripe answers such a refund `pending`, and it can + /// still end `failed` or `canceled`. Recorded as refunded at once, a + /// refund that then failed would show the customer and the admin money that + /// never came back, and nothing would look at it again. So it is kept as + /// [#REFUND_PENDING] and asked about here until it ends: [#REFUNDED] once + /// the fare's refund and the tip's have both succeeded, [#REFUND_FAILED] + /// as soon as either has not. A failed refund is not tried again from + /// here. Part of the money may have gone back, and the processor may + /// answer a repeat with the refund that failed, so what to do about it is + /// for a person with the processor's records. + /// + /// Not a transaction itself, as `Deliveries.settleLeftBehind` is not: the + /// processor is asked with none open, each refund is recorded in one of + /// its own, and what went wrong with the first refund that could not be + /// asked about is reported once the rest have been. + @Scheduled(fixedDelay = 60000, initialDelay = 45000, lock = "savor-refunds") + public void settleRefunds() throws IOException { + List pending = refundsPending(); + Exception failed = null; + for (int iter = 0; iter < pending.size(); iter++) { + Payment payment = pending.get(iter); + try { + String outcome = refundOutcome(payment); + Delivery delivery = outcome == null ? null : endRefund(payment.getDeliveryId(), outcome); + if (delivery != null) { + live.deliveryChanged(delivery.getId(), delivery.getState(), delivery.getCustomer(), delivery.getCourier(), ""); + } + } catch (IOException err) { + failed = failed == null ? err : failed; + } catch (RuntimeException err) { + failed = failed == null ? err : failed; + } + } + if (failed instanceof IOException) { + throw (IOException) failed; + } + if (failed instanceof RuntimeException) { + throw (RuntimeException) failed; + } + } + + /// How a pending refund came out: [#REFUNDED], [#REFUND_FAILED], or null + /// while the processor still does not know. + private String refundOutcome(Payment payment) throws IOException { + String fare = provider.refundStatus(payment.getRefundRef()); + String tip = payment.getTipRefundRef().length() == 0 ? PaymentProvider.REFUND_SUCCEEDED + : provider.refundStatus(payment.getTipRefundRef()); + if (PaymentProvider.REFUND_FAILED.equals(fare) + || PaymentProvider.REFUND_FAILED.equals(tip)) { + return REFUND_FAILED; + } + if (PaymentProvider.REFUND_SUCCEEDED.equals(fare) + && PaymentProvider.REFUND_SUCCEEDED.equals(tip)) { + return REFUNDED; + } + return null; + } + + @Transactional(readOnly = true) + private List refundsPending() { + return payments.findByStatusOrderByCreatedAtAsc(REFUND_PENDING, new PageRequest(0, 50)); + } + + /// Records how a refund ended and returns its delivery, or null when another + /// pass recorded it first. + @Transactional + private Delivery endRefund(String deliveryId, String outcome) { + if (payments.endRefund(deliveryId, outcome, + REFUNDED.equals(outcome) ? System.currentTimeMillis() : 0L) != 1) { + return null; + } + paymentStatus(deliveryId, outcome); + return delivery(deliveryId); + } + + /// Marks the delivery refunded and returns it as it was just before. Committed + /// before the processor is asked for the money: of two requests, one + /// changes the row and only that one refunds. + /// + /// The update comes first and the delivery is read after it. A tip is claimed + /// by an update of the same row, so the two take turns, and the delivery read + /// here says whether a tip got in before this did. One that did and has no + /// charge recorded yet is still at the processor: refunding now would + /// return the fare, miss the tip, and leave a tip charged to a refunded + /// delivery. The refund is refused until the tip has settled either way, and + /// a tip asked for after this commits finds the delivery no longer paid. + /// + /// A tip whose charge never reports back keeps its delivery from being + /// refunded here. That is deliberate: nobody knows whether that card was + /// charged, and it takes a person and the processor's records to say. + @Transactional + private Delivery claimRefund(String deliveryId) { + boolean claimed = deliveries.claimRefund(deliveryId) == 1; + Delivery delivery = delivery(deliveryId); + if (delivery == null) { + throw new ResponseStatusException(404, "No such delivery"); + } + if (CASH.equals(delivery.getMethodId())) { + throw new ResponseStatusException(409, "A delivery paid in cash has nothing to refund"); + } + if (REFUND_PENDING.equals(delivery.getPaymentStatus())) { + throw new ResponseStatusException(409, + "That delivery's refund is still with the payment processor"); + } + if (REFUND_FAILED.equals(delivery.getPaymentStatus())) { + throw new ResponseStatusException(409, + "That delivery's refund failed. Check it with the payment processor"); + } + if (!claimed && !refundUnfinished(delivery)) { + throw new ResponseStatusException(409, "That delivery has no payment to refund"); + } + if (delivery.getTipCents() > 0L) { + Payment payment = payments.findById(deliveryId); + if (payment == null || payment.getTipRef() == null || payment.getTipRef().length() == 0) { + throw new ResponseStatusException(409, + "A tip on that delivery is still being charged. Try again in a moment"); + } + } + return delivery; + } + + /// Whether `delivery` was marked refunded by a request that never recorded + /// the refund: the server stopped between asking the processor and + /// writing the answer down, so neither [#keepRefund] nor [#releaseRefund] + /// ran. Such a delivery would otherwise be refunded in name only and refused + /// for good. It is refunded again instead, which is safe because a refund + /// is keyed by the charge it returns: the processor answers a repeat with + /// the refund it already made, and makes one if it never did. + /// + /// It cannot tell such a delivery from one whose refund is still at the + /// processor, so it also lets a second request through while the first + /// is under way. That is safe for the same reason, and [#releaseRefund] + /// and [#keepRefund] keep the delivery agreeing with the payment however the + /// two end. + private boolean refundUnfinished(Delivery delivery) { + if (!REFUNDED.equals(delivery.getPaymentStatus())) { + return false; + } + Payment payment = payments.findById(delivery.getId()); + return payment != null && !REFUNDED.equals(payment.getStatus()); + } + + @Transactional(readOnly = true) + private Payment payment(String deliveryId) { + return payments.findById(deliveryId); + } + + /// Gives back the claim of a refund the processor did not take: the delivery + /// is paid again -- unless another request has recorded a refund of it + /// meanwhile. + /// + /// Two refunds can overlap: the second is let through by + /// [#refundUnfinished] while the first is at the processor, which is + /// what lets a refund cut short be asked for again. Should one of them + /// record a refund and the other then fail, putting the delivery back to paid + /// would leave a refunded payment on a delivery its courier is paid for, and + /// the fare would be cashed out after it went back to the customer. So the + /// payment decides: the delivery is paid again only while its payment is. + /// + /// The delivery's row is written first, as in [#keepRefund], so the two take + /// turns on it: whichever comes second sees what the first wrote. + @Transactional + private void releaseRefund(String deliveryId) { + if (deliveries.releaseRefund(deliveryId) != 1) { + // No longer marked refunded: a refund was recorded as pending. + return; + } + Payment payment = payments.findById(deliveryId); + if (payment != null && !PAID.equals(payment.getStatus())) { + paymentStatus(deliveryId, payment.getStatus()); + } + } + + /// Records the refunds the processor took. Refunded when both are known + /// to have returned the money; otherwise the payment and its delivery are + /// [#REFUND_PENDING], for [#settleRefunds] to finish. + /// + /// The delivery is written as well, and first, although its claim already + /// marked it refunded: an overlapping request that failed may have put it + /// back to paid in the meantime. See [#releaseRefund]. + @Transactional + private void keepRefund(String deliveryId, PaymentProvider.Refund fare, + PaymentProvider.Refund tip, String reason) { + boolean pending = fare.pending || (tip != null && tip.pending); + paymentStatus(deliveryId, pending ? REFUND_PENDING : REFUNDED); + Payment payment = payments.findById(deliveryId); + payment.setStatus(pending ? REFUND_PENDING : REFUNDED); + payment.setRefundRef(cut(fare.reference, 64)); + payment.setTipRefundRef(tip == null ? "" : cut(tip.reference, 64)); + payment.setRefundReason(reason); + payment.setRefundedAt(pending ? 0L : System.currentTimeMillis()); + } + + /// Records a refund of the fare whose tip could not be refunded: the delivery + /// and its payment are [#REFUND_FAILED], with the fare's refund kept. + @Transactional + private void keepPartialRefund(String deliveryId, PaymentProvider.Refund fare, String reason) { + paymentStatus(deliveryId, REFUND_FAILED); + Payment payment = payments.findById(deliveryId); + payment.setStatus(REFUND_FAILED); + payment.setRefundRef(cut(fare.reference, 64)); + payment.setTipRefundRef(""); + payment.setRefundReason(reason); + payment.setRefundedAt(0L); + } + + // -------------------------------------------------------------- receipts + + /// A customer's receipts, newest first: one for every delivery that completed. + @Transactional(readOnly = true) + public List receipts(String customer) throws IOException { + return receipts(deliveries.findByCustomerAndStateOrderByRequestedAtDesc(customer, DeliveryState.COMPLETED, + new PageRequest(0, 50)), false); + } + + /// Every receipt, newest first, for the admin. + @Transactional(readOnly = true) + public List allReceipts() throws IOException { + return receipts(deliveries.findByStateOrderByRequestedAtDesc(DeliveryState.COMPLETED, + new PageRequest(0, 200)), true); + } + + /// The receipt of one delivery: what it will cost while it is under way, what + /// it cost once it completed. Answers 404 for a delivery that is not the + /// viewer's, and for one that ended without completing, which cost nothing. + @Transactional(readOnly = true) + public ReceiptDto receipt(String viewer, boolean admin, String deliveryId) throws IOException { + Delivery delivery = delivery(deliveryId); + if (delivery == null) { + // Until the kitchen accepts there is no delivery, only the order and + // the hold on its card. + return held(viewer, admin, deliveryId); + } + DeliveryState state = delivery.getState(); + if (!(admin || viewer.equals(delivery.getCustomer())) + || DeliveryState.CANCELLED_BY_CUSTOMER == state || DeliveryState.CANCELLED_BY_COURIER == state + || DeliveryState.CANCELLED_BY_ADMIN == state || DeliveryState.NO_COURIERS == state) { + throw new ResponseStatusException(404, "No such receipt"); + } + Payment payment = payments.findById(deliveryId); + return receipt(delivery, payment == null ? 0L : payment.getCreatedAt(), admin, + new HashMap()); + } + + /// The deliveries' receipts. When each was paid is on its payment and not on + /// the delivery, so the payments of all of them are read at once and matched + /// to their deliveries here. + private List receipts(List completed, boolean admin) { + Map paidAt = new HashMap(); + if (completed.size() > 0) { + List ids = new ArrayList(); + for (int iter = 0; iter < completed.size(); iter++) { + ids.add(completed.get(iter).getId()); + } + List rows = payments.findByDeliveryIdIn(ids); + for (int iter = 0; iter < rows.size(); iter++) { + Payment payment = rows.get(iter); + paidAt.put(payment.getDeliveryId(), Long.valueOf(payment.getCreatedAt())); + } + } + Map couriers = new HashMap(); + List out = new ArrayList(); + for (int iter = 0; iter < completed.size(); iter++) { + Delivery delivery = completed.get(iter); + Long at = paidAt.get(delivery.getId()); + out.add(receipt(delivery, at == null ? 0L : at.longValue(), admin, couriers)); + } + return out; + } + + private ReceiptDto receipt(Delivery delivery, long paidAt, boolean admin, + Map couriers) { + // A delivery is its order's last leg, under the same id; what was paid + // is the order's breakdown. A tip given after the order arrived is on + // the delivery, and counts on top. + CustomerOrder order = orders.findById(delivery.getId()); + long tip = delivery.getTipCents(); + String paid = delivery.getPaymentStatus(); + boolean cash = CASH.equals(delivery.getMethodId()); + String status; + if (PAID.equals(paid)) { + status = cash ? "cash" : "paid"; + } else if (FAILED.equals(paid)) { + status = "failed"; + } else if (REFUNDED.equals(paid)) { + status = "refunded"; + } else if (REFUND_PENDING.equals(paid)) { + status = "refund_pending"; + } else if (REFUND_FAILED.equals(paid)) { + status = "refund_failed"; + } else { + status = held(order); + } + // Every refund state follows a payment that was made. + long paidOn = PAID.equals(paid) || (paid != null && paid.startsWith("REFUND")) + ? paidAt : 0L; + // A tip whose charge was never answered is on the delivery but not on + // the card: the receipt says so and offers to ask again, the same + // amount under the same key, since nothing else ever retries it. + long unanswered = tip > 0 && tipLeftUnknown(delivery, tip) ? tip : 0L; + String courierName = ""; + String vehicle = ""; + String courier = delivery.getCourier(); + if (courier.length() > 0 && DeliveryState.OFFERED != delivery.getState()) { + Profile who = couriers.get(courier); + if (who == null && !couriers.containsKey(courier)) { + who = profiles.findById(courier); + couriers.put(courier, who); + } + if (who != null) { + courierName = who.getDisplayName(); + vehicle = who.getVehicle(); + } + } + if (order == null) { + return new ReceiptDto(delivery.getId(), delivery.getCurrency(), "", 0L, + delivery.getFareCents(), 0L, 0L, 0L, 0L, tip, delivery.getFareCents() + tip, + delivery.getMethodLabel(), status, paidOn, delivery.getDropoffAddress(), + courierName, vehicle, admin ? delivery.getCustomer() : "", unanswered); + } + // The delivery's tip is the one given after the order arrived; the + // checkout tip is already in the order's total. + long extraTip = tip; + return new ReceiptDto(order.getId(), order.getCurrency(), order.getRestaurantName(), + order.getSubtotalCents(), order.getDeliveryFeeCents(), order.getServiceFeeCents(), + order.getSmallOrderCents(), order.getTaxCents(), order.getDiscountCents(), + order.getTipCents() + extraTip, order.getTotalCents() + extraTip, + order.getMethodLabel(), status, paidOn, order.getDropoffAddress(), courierName, + vehicle, admin ? order.getCustomer() : "", unanswered); + } + + /// The receipt of an order no delivery has been made for yet: what it + /// costs, and the card held for it. + private ReceiptDto held(String viewer, boolean admin, String id) { + CustomerOrder order = order(id == null || id.length() > 32 ? null : id); + if (order == null || !(admin || viewer.equals(order.getCustomer())) + || !Arrays.asList(ORDER_UNDER_WAY).contains(order.getState())) { + throw new ResponseStatusException(404, "No such receipt"); + } + return new ReceiptDto(order.getId(), order.getCurrency(), order.getRestaurantName(), + order.getSubtotalCents(), order.getDeliveryFeeCents(), order.getServiceFeeCents(), + order.getSmallOrderCents(), order.getTaxCents(), order.getDiscountCents(), + order.getTipCents(), order.getTotalCents(), order.getMethodLabel(), held(order), + 0L, order.getDropoffAddress(), "", "", admin ? order.getCustomer() : "", 0L); + } + + /// `authorized` while a card is held and not yet charged, `pending` otherwise. + private static String held(CustomerOrder order) { + return order != null && AUTHORIZED.equals(order.getPaymentStatus()) ? "authorized" + : "pending"; + } + + // ------------------------------------------------------------- internals + + /// Charges one of a customer's saved cards. What the charge needs is read in + /// a transaction that has ended by the time the processor is called. + private String charge(String key, String username, String methodId, long amountCents, + String currency, String description) throws IOException { + String[] references = chargeable(username, methodId); + if (references == null) { + throw new PaymentDeclinedException("The card is no longer on the account"); + } + return cut(provider.charge(key, references[0], references[1], amountCents, currency, + description), 64); + } + + /// What the processor calls the customer and one of their cards, in that + /// order, or null when either is gone. + @Transactional(readOnly = true) + private String[] chargeable(String username, String methodId) { + PaymentMethod method = card(username, methodId); + PaymentCustomer customer = customers.findById(username); + if (method == null || customer == null) { + return null; + } + return new String[] {customer.getReference(), method.getToken()}; + } + + private PaymentMethod owned(String username, String id) { + PaymentMethod row = card(username, id); + if (row == null) { + // Someone else's card is told apart from no card by nobody. + throw new ResponseStatusException(404, "No such payment method"); + } + return row; + } + + private static PaymentMethodDto card(PaymentMethod row) { + return card(row, row.isPreferred()); + } + + private static PaymentMethodDto card(PaymentMethod row, boolean isDefault) { + return new PaymentMethodDto(row.getId(), "card", row.getBrand(), row.getLast4(), + row.getExpMonth(), row.getExpYear(), isDefault, label(row)); + } + + private static PaymentMethodDto cash(boolean isDefault) { + return new PaymentMethodDto(CASH, "cash", "", "", 0, 0, isDefault, CASH_LABEL); + } + + private static String label(PaymentMethod method) { + return method.getBrand() + " " + method.getLast4(); + } + + private static String cut(String text, int max) { + return text.length() > max ? text.substring(0, max) : text; + } + + /// Deletes a customer record this server made and did not keep. A failure + /// is logged rather than answered: the request has its own answer, and + /// the record holds no card. + private void forgetQuietly(String reference) { + try { + provider.forgetCustomer(reference); + } catch (IOException failed) { + System.err.println("could not delete an unused payment customer: " + + failed.getMessage()); + } + } + + /// A fault at the processor, as the app is told of it. What went wrong goes + /// to the log; it describes the request, never a card or a key. + private static ResponseStatusException unavailable(String what, IOException failed) { + System.err.println("payment provider failed during " + what + ": " + failed.getMessage()); + return new ResponseStatusException(502, "Payments are unavailable right now"); + } + + /// A delivery, or null when there is none. An id that cannot be one is not + /// looked for. + private Delivery delivery(String id) { + if (id == null || id.length() == 0 || id.length() > 32) { + return null; + } + return deliveries.findById(id); + } + + /// One of an account's cards, or null when the id is not a card of theirs. + private PaymentMethod card(String username, String id) { + if (id == null || id.length() == 0 || id.length() > 32) { + return null; + } + return cards.findFirstByIdAndUsername(id, username); + } + + /// Whether a completed delivery of the customer's is still to be charged to this + /// method -- or to any card, for a null `methodId` -- and will be asked + /// about again: its payment is pending and it ended after `since`. + /// + /// A card tip left unknown counts too, with no time limit: see [#tip]. + /// Its retry charges the delivery's card under the same key, so with the card + /// gone a tip that may have been charged would be cleared as refused. + private boolean awaitsCharge(String customer, String methodId, long since) { + List tipped = deliveries.findByCustomerAndPaymentStatusAndTipCentsGreaterThan(customer, PAID, + 0L); + for (Delivery delivery : tipped) { + if ((methodId == null || methodId.equals(delivery.getMethodId())) + && tipLeftUnknown(delivery, delivery.getTipCents())) { + return true; + } + } + if (methodId == null) { + return deliveries.existsByCustomerAndStateAndPaymentStatusAndUpdatedAtGreaterThanAndMethodIdNot( + customer, DeliveryState.COMPLETED, PENDING, since, CASH); + } + return deliveries.existsByCustomerAndStateAndPaymentStatusAndUpdatedAtGreaterThanAndMethodId( + customer, DeliveryState.COMPLETED, PENDING, since, methodId); + } +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/pay/SimulatedPayments.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/pay/SimulatedPayments.java new file mode 100644 index 00000000000..afce7aa6e34 --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/pay/SimulatedPayments.java @@ -0,0 +1,245 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.pay; + +import com.codenameone.examples.savor.Days; +import com.codenameone.examples.savor.Ids; +import com.codenameone.examples.savor.api.CardDto; + +import java.io.IOException; + +/// A processor that moves no money. What the sample runs on until it is given +/// a real one's key, and what every test but that provider's own runs against. +/// +/// It behaves like the real thing where the app can tell the difference: a +/// card has to look like a card, and the well-known test numbers do what they +/// do at a real processor's test mode. +/// +/// - `4242 4242 4242 4242` is accepted, as is any other number that passes the +/// check digit. +/// - `4000 0000 0000 0002` is declined when it is added. +/// - `4000 0000 0000 0341` is accepted and then declined when it is charged, +/// which is how a payment that fails at the end of a delivery is tried out. +/// - `4000 0000 0000 0119` is accepted and then cannot be charged at all: the +/// processor fails without saying whether it took the money, which is how +/// a payment nobody can vouch for is tried out. +/// - `4000 0000 0000 7726` is charged like any other, and a refund of it is +/// pending when it is made and has succeeded when it is next asked about, +/// which is how a refund the bank has not answered yet is tried out. +/// +/// A refund that is pending and then fails has no card number: a card whose +/// token starts `sim_pm_lost_` does that, and only a test makes one. +/// +/// The card number is read here, in this method, and goes no further: the +/// brand and the last four digits are all that is returned or stored. +public class SimulatedPayments implements PaymentProvider { + static final String DECLINED_NUMBER = "4000000000000002"; + static final String FAILS_LATER_NUMBER = "4000000000000341"; + static final String FAULTY_NUMBER = "4000000000000119"; + static final String SLOW_REFUND_NUMBER = "4000000000007726"; + private static final String FAILING_TOKEN = "sim_pm_fail_"; + private static final String FAULTY_TOKEN = "sim_pm_fault_"; + private static final String SLOW_REFUND_TOKEN = "sim_pm_slow_"; + private static final String LOST_REFUND_TOKEN = "sim_pm_lost_"; + private static final String SLOW_REFUND_CHARGE = "sim_ch_slow_"; + private static final String LOST_REFUND_CHARGE = "sim_ch_lost_"; + private static final String FAILING_HOLD = "sim_hold_fail_"; + private static final String FAULTY_HOLD = "sim_hold_fault_"; + private static final String REFUND = "sim_re_"; + + @Override + public String name() { + return "simulated"; + } + + @Override + public String createCustomer(String username, String displayName) throws IOException { + return "sim_cus_" + Ids.next(); + } + + @Override + public Setup startCardSetup(String customer, String setupId) { + Setup setup = new Setup(); + setup.reference = "sim_seti_" + setupId; + return setup; + } + + @Override + public Card finishCardSetup(String customer, String reference, CardDto typed) + throws IOException { + if (typed == null) { + throw new PaymentDeclinedException("Enter the card's details"); + } + String number = digits(typed.number()); + if (number.length() < 12 || number.length() > 19 || !luhn(number)) { + throw new PaymentDeclinedException("That is not a card number"); + } + int year = typed.expYear() < 100 ? typed.expYear() + 2000 : typed.expYear(); + String today = Days.iso(System.currentTimeMillis()); + int thisYear = Integer.parseInt(today.substring(0, 4)); + int thisMonth = Integer.parseInt(today.substring(5, 7)); + if (typed.expMonth() < 1 || typed.expMonth() > 12 || year > thisYear + 25 + || year < thisYear || (year == thisYear && typed.expMonth() < thisMonth)) { + throw new PaymentDeclinedException("That card has expired"); + } + String cvc = typed.cvc() == null ? "" : typed.cvc().trim(); + if (cvc.length() < 3 || cvc.length() > 4 || digits(cvc).length() != cvc.length()) { + throw new PaymentDeclinedException("Check the card's security code"); + } + if (DECLINED_NUMBER.equals(number)) { + throw new PaymentDeclinedException("Your card was declined"); + } + Card card = new Card(); + card.brand = brand(number); + card.last4 = number.substring(number.length() - 4); + card.expMonth = typed.expMonth(); + card.expYear = year; + card.token = (FAILS_LATER_NUMBER.equals(number) ? FAILING_TOKEN + : FAULTY_NUMBER.equals(number) ? FAULTY_TOKEN + : SLOW_REFUND_NUMBER.equals(number) ? SLOW_REFUND_TOKEN : "sim_pm_") + Ids.next(); + return card; + } + + @Override + public void detachCard(String customer, String token) throws IOException { + // Nothing is kept of a card here; its token is all there is, and the + // server deletes that. + } + + @Override + public String charge(String key, String customer, String token, long amountCents, + String currency, String description) throws IOException { + if (token.startsWith(FAILING_TOKEN)) { + throw new PaymentDeclinedException("Your card was declined"); + } + if (token.startsWith(FAULTY_TOKEN)) { + throw new IOException("The simulated processor did not answer"); + } + // A charge says how its refund is to go, as the card did: the refund + // has only the charge to go by. + return (token.startsWith(SLOW_REFUND_TOKEN) ? SLOW_REFUND_CHARGE + : token.startsWith(LOST_REFUND_TOKEN) ? LOST_REFUND_CHARGE : "sim_ch_") + key; + } + + /// A hold is a charge that has not been taken yet, and its reference + /// refunds the way the charge's would. A card that fails when charged is + /// held without complaint and fails when the hold is taken, as a hold that + /// lapsed while the order was on its way does: that is the failure an + /// order meets after checkout, and the one the tests have to reach. + @Override + public String authorize(String key, String customer, String token, long amountCents, + String currency, String description) throws IOException { + if (token.startsWith(FAILING_TOKEN)) { + return FAILING_HOLD + key; + } + if (token.startsWith(FAULTY_TOKEN)) { + return FAULTY_HOLD + key; + } + return charge(key, customer, token, amountCents, currency, description); + } + + @Override + public void capture(String reference, long amountCents) throws IOException { + // Otherwise nothing to take: the simulated card holds no money. + if (reference.startsWith(FAILING_HOLD)) { + throw new PaymentDeclinedException("Your card was declined"); + } + if (reference.startsWith(FAULTY_HOLD)) { + throw new IOException("The simulated processor did not answer"); + } + } + + @Override + public void release(String reference) { + // Nothing to let go. + } + + @Override + public void forgetCustomer(String customer) { + // Nothing is kept of one here. + } + + @Override + public Refund refund(String chargeReference, long amountCents) throws IOException { + // Named after the charge, as a real processor's refund is keyed by + // it: asking twice is one refund, which is what lets a refund whose + // answer was lost be asked for again. + Refund refund = new Refund(); + refund.reference = REFUND + chargeReference; + refund.pending = chargeReference.startsWith(SLOW_REFUND_CHARGE) + || chargeReference.startsWith(LOST_REFUND_CHARGE); + return refund; + } + + @Override + public String refundStatus(String refundReference) throws IOException { + return refundReference.startsWith(REFUND + LOST_REFUND_CHARGE) ? REFUND_FAILED + : REFUND_SUCCEEDED; + } + + /// The digits of what was typed, with the spaces and dashes people put in a + /// card number taken out. Anything else in it leaves it too short to pass. + private static String digits(String value) { + StringBuilder out = new StringBuilder(); + for (int iter = 0; value != null && iter < value.length(); iter++) { + char c = value.charAt(iter); + if (c >= '0' && c <= '9') { + out.append(c); + } else if (c != ' ' && c != '-') { + return ""; + } + } + return out.toString(); + } + + /// The check digit every card number carries, which catches a mistyped one. + private static boolean luhn(String number) { + int sum = 0; + boolean doubled = false; + for (int iter = number.length() - 1; iter >= 0; iter--) { + int digit = number.charAt(iter) - '0'; + if (doubled) { + digit *= 2; + if (digit > 9) { + digit -= 9; + } + } + sum += digit; + doubled = !doubled; + } + return sum % 10 == 0; + } + + private static String brand(String number) { + if (number.startsWith("4")) { + return "Visa"; + } + if (number.startsWith("34") || number.startsWith("37")) { + return "Amex"; + } + if (number.startsWith("6011") || number.startsWith("65")) { + return "Discover"; + } + int two = Integer.parseInt(number.substring(0, 2)); + if ((two >= 51 && two <= 55) || (two >= 22 && two <= 27)) { + return "Mastercard"; + } + return "Card"; + } +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/pay/StripePayments.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/pay/StripePayments.java new file mode 100644 index 00000000000..cb29ef59157 --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/pay/StripePayments.java @@ -0,0 +1,429 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.pay; + +import com.codename1.backend.Json; +import com.codename1.backend.Web; +import com.codenameone.examples.savor.Ids; +import com.codenameone.examples.savor.api.CardDto; + +import java.io.IOException; +import java.net.URLEncoder; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/// Payments by Stripe, over its REST API. No SDK: a few requests, form encoded, +/// with the account's secret key as a bearer token. +/// +/// **This is an untested stub.** It has never been run against a live Stripe +/// account: the requests follow Stripe's published API, and the tests hold it +/// to canned answers through [Transport], but nothing here has seen a real +/// card, a bank asking its cardholder to approve a charge, or a refund settle. +/// Treat it as a starting point, and run it against a Stripe test-mode key +/// before any money depends on it. +/// +/// Selected with `savor.payments.provider=stripe`. +/// +/// - **A customer** is made the first time a customer adds a card. +/// - **A card is added on Stripe's own page.** [#startCardSetup] opens a +/// Checkout Session in `setup` mode and hands back its address; the app +/// opens it in the browser. The card is typed there, so its number never +/// reaches the app or this server, and neither falls under the card +/// industry's rules for systems that handle one. +/// - **The setup is read back** when the app says the user returned: the +/// session's SetupIntent names the PaymentMethod Stripe saved, and its brand +/// and last four digits are all this server keeps beside its id. +/// - **A delivery is charged** with a PaymentIntent that is `off_session` and +/// confirmed in the same request, because the customer is in a car and not at a +/// payment form. +/// - **A refund** returns a charge to the card. +/// - **A card removed** in the app is detached from the customer, so Stripe +/// no longer offers it for a charge. +/// +/// The secret key is a setting of the server (`savor.payments.stripe.secret`) +/// and is used for the `Authorization` header and nothing else: it is never +/// logged and never part of an error. +/// +/// Everything Stripe-specific is in [#form] strings and the reading of the +/// answers. The network is behind [Transport], which is what lets the tests +/// hold both to canned answers without a key. +public class StripePayments implements PaymentProvider { + private static final String API = "https://api.stripe.com"; + + private final Transport transport; + private final String returnUrl; + + /// @param secret the account's secret key + /// @param returnUrl the address of this server's `/pay/return` page, which + /// Stripe sends the browser to when the user is done + public StripePayments(String secret, String returnUrl) { + this(new WebTransport(secret), returnUrl); + } + + public StripePayments(Transport transport, String returnUrl) { + this.transport = transport; + this.returnUrl = returnUrl; + } + + @Override + public String name() { + return "stripe"; + } + + @Override + public String createCustomer(String username, String displayName) throws IOException { + // Two first cards started together both find the account without a + // customer and both ask for one. Only one reference is kept, and the + // other would be left at the processor with nothing pointing at it. + // With the same key the second request is answered with the first + // one's customer. The key is a digest, since the header is logged in + // places an address should not be, and it covers the name too: the + // processor refuses a key it has seen with other fields, and a customer + // who renamed themselves between two attempts must not be refused. + // The price is one orphan in a case that takes two mishaps: the + // answer to the first attempt is lost, and the customer renames + // themselves before trying again. The second customer is the one + // kept; the first holds no card and no money, and is left at the + // processor rather than looked up by address on every first card. + String key = "customer-" + Ids.sha256(username + "\n" + displayName); + Map made = ask("POST", "/v1/customers", form("email", username, "name", displayName, + "metadata[savor_user]", username), key); + return id(made); + } + + @Override + public void forgetCustomer(String customer) throws IOException { + // Deleting a customer detaches its payment methods too. 404 is the + // answer for one deleted by a request whose answer never arrived. + Reply reply = transport.send("DELETE", "/v1/customers/" + + URLEncoder.encode(customer, "UTF-8"), null, ""); + if (reply.status != 404 && (reply.status < 200 || reply.status >= 300)) { + throw new IOException("The payment processor answered " + reply.status + + " to deleting a customer"); + } + } + + // tag::savor-payment-hosted[] + @Override + public Setup startCardSetup(String customer, String setupId) throws IOException { + Map session = ask("POST", "/v1/checkout/sessions", form("mode", "setup", + "customer", customer, "payment_method_types[0]", "card", + "client_reference_id", setupId, + "success_url", returnUrl + "?result=done", + "cancel_url", returnUrl + "?result=cancelled"), ""); + Setup setup = new Setup(); + setup.hosted = true; + setup.reference = id(session); + setup.url = text(session, "url"); + if (setup.url.length() == 0) { + throw new IOException("The payment processor returned no page to open"); + } + return setup; + } + // end::savor-payment-hosted[] + + @Override + public Card finishCardSetup(String customer, String reference, CardDto typed) + throws IOException { + Map session = ask("GET", "/v1/checkout/sessions/" + URLEncoder.encode(reference, "UTF-8") + + "?expand%5B%5D=setup_intent.payment_method", null, ""); + Object intent = session.get("setup_intent"); + Object method = intent instanceof Map ? ((Map) intent).get("payment_method") : null; + if (!"complete".equals(text(session, "status")) || !(intent instanceof Map) + || !"succeeded".equals(text((Map) intent, "status")) || !(method instanceof Map)) { + throw new PaymentDeclinedException("The card was not added. Try again."); + } + // The session is looked up by a reference this server stored for this + // customer, so this cannot differ -- unless something is very wrong, in + // which case the card is not attached to anybody. + if (!customer.equals(text(session, "customer"))) { + throw new IOException("The payment processor's session is for another customer"); + } + Card card = new Card(); + card.token = id((Map) method); + Object details = ((Map) method).get("card"); + if (details instanceof Map) { + card.brand = brand(text((Map) details, "brand")); + card.last4 = text((Map) details, "last4"); + card.expMonth = (int) number((Map) details, "exp_month"); + card.expYear = (int) number((Map) details, "exp_year"); + } + return card; + } + + @Override + public void detachCard(String customer, String token) throws IOException { + String path = "/v1/payment_methods/" + URLEncoder.encode(token, "UTF-8"); + Reply reply = transport.send("POST", path + "/detach", "", ""); + if ((reply.status >= 200 && reply.status < 300) || reply.status == 404) { + // Detached, or no such card: 404 is the answer for one deleted + // with its customer, or never there. + return; + } + // Stripe refuses to detach a card that is attached to nobody, which + // is what a second request finds after the first one's answer was + // lost. Rather than read that from an error's wording, the card is + // looked at: no longer this customer's, it is detached. + Reply now = transport.send("GET", path, null, ""); + if (now.status == 404) { + return; + } + if (now.status >= 200 && now.status < 300) { + Map method; + try { + method = Json.parseObject(now.body == null ? "" : now.body); + } catch (IOException unreadable) { + method = null; + } + if (method != null && !customer.equals(text(method, "customer"))) { + return; + } + } + throw new IOException("The payment processor answered " + reply.status + + " to detaching a card"); + } + + @Override + public String charge(String key, String customer, String token, long amountCents, + String currency, String description) throws IOException { + Map intent = ask("POST", "/v1/payment_intents", form("amount", String.valueOf(amountCents), + "currency", asciiLower(currency), "customer", customer, "payment_method", token, + "off_session", "true", "confirm", "true", "description", description, + "metadata[savor_charge]", key), key); + if (!"succeeded".equals(text(intent, "status"))) { + // Most often `requires_action`: the bank wants the cardholder to + // approve it, and there is nobody at a screen to do so. + throw new PaymentDeclinedException("The card needs its owner's approval"); + } + return id(intent); + } + + /// A PaymentIntent with `capture_method=manual`: confirmed now, it ends in + /// `requires_capture` with the money held on the card. + @Override + public String authorize(String key, String customer, String token, long amountCents, + String currency, String description) throws IOException { + Map intent = ask("POST", "/v1/payment_intents", form("amount", String.valueOf(amountCents), + "currency", asciiLower(currency), "customer", customer, "payment_method", token, + "off_session", "true", "confirm", "true", "capture_method", "manual", + "description", description, "metadata[savor_charge]", key), key); + if (!"requires_capture".equals(text(intent, "status"))) { + throw new PaymentDeclinedException("The card needs its owner's approval"); + } + return id(intent); + } + + /// Asked again after a lost answer, Stripe refuses to capture an intent + /// twice; so the intent is read first and one already `succeeded` is done. + @Override + public void capture(String reference, long amountCents) throws IOException { + String path = "/v1/payment_intents/" + URLEncoder.encode(reference, "UTF-8"); + if ("succeeded".equals(text(ask("GET", path, null, null), "status"))) { + return; + } + Map intent = ask("POST", path + "/capture", form("amount_to_capture", + String.valueOf(amountCents)), "capture-" + reference); + if (!"succeeded".equals(text(intent, "status"))) { + throw new IOException("The hold was not captured: " + text(intent, "status")); + } + } + + @Override + public void release(String reference) throws IOException { + String path = "/v1/payment_intents/" + URLEncoder.encode(reference, "UTF-8"); + if ("canceled".equals(text(ask("GET", path, null, null), "status"))) { + return; + } + ask("POST", path + "/cancel", form(), "release-" + reference); + } + + @Override + public Refund refund(String chargeReference, long amountCents) throws IOException { + Map answer = ask("POST", "/v1/refunds", form("payment_intent", chargeReference, + "amount", String.valueOf(amountCents)), "refund-" + chargeReference); + String status = refundState(text(answer, "status")); + if (REFUND_FAILED.equals(status)) { + throw new IOException("The refund was not accepted: " + text(answer, "status")); + } + Refund refund = new Refund(); + refund.reference = id(answer); + // A pending refund can still fail or be canceled, so it is not yet a + // refund to record as made: the caller keeps asking. + refund.pending = REFUND_PENDING.equals(status); + return refund; + } + + @Override + public String refundStatus(String refundReference) throws IOException { + return refundState(text(ask("GET", "/v1/refunds/" + + URLEncoder.encode(refundReference, "UTF-8"), null, null), "status")); + } + + /// Stripe's five refund states as three. `requires_action` waits on the + /// cardholder like `pending` waits on the bank, and `canceled` returned no + /// more money than `failed` did. A state this does not know is taken as + /// still pending: asked about again, it is never recorded as money back. + private static String refundState(String stripe) { + if ("succeeded".equals(stripe)) { + return REFUND_SUCCEEDED; + } + if ("failed".equals(stripe) || "canceled".equals(stripe)) { + return REFUND_FAILED; + } + return REFUND_PENDING; + } + + /// Makes a request and returns the object in the answer. + /// + /// A card Stripe refuses is answered with status 402 and an error of type + /// `card_error`, whose message is written for the cardholder; that becomes + /// a [PaymentDeclinedException]. Any other failure is a fault of ours or + /// theirs and is reported by status and code only. + private Map ask(String method, String path, String form, String key) throws IOException { + Reply reply = transport.send(method, path, form, key); + Map body; + try { + body = Json.parseObject(reply.body == null ? "" : reply.body); + } catch (IOException unreadable) { + throw new IOException("The payment processor answered " + reply.status + + " with something that is not JSON"); + } + if (reply.status >= 200 && reply.status < 300) { + return body; + } + Object error = body.get("error"); + if (error instanceof Map) { + Map details = (Map) error; + if ("card_error".equals(text(details, "type"))) { + String message = text(details, "message"); + throw new PaymentDeclinedException(message.length() > 0 ? message + : "Your card was declined"); + } + throw new IOException("The payment processor answered " + reply.status + " " + + text(details, "type") + " " + text(details, "code")); + } + throw new IOException("The payment processor answered " + reply.status); + } + + /// A form body from names and values in turn. Both are escaped: a name + /// such as `metadata[savor_user]` has brackets in it. A space is written + /// as `+`, which a form body allows. + static String form(String... pairs) throws IOException { + StringBuilder out = new StringBuilder(); + for (int iter = 0; iter + 1 < pairs.length; iter += 2) { + if (out.length() > 0) { + out.append('&'); + } + out.append(URLEncoder.encode(pairs[iter], "UTF-8")).append('=') + .append(URLEncoder.encode(pairs[iter + 1] == null ? "" : pairs[iter + 1], + "UTF-8")); + } + return out.toString(); + } + + private static String id(Map object) throws IOException { + String id = text(object, "id"); + if (id.length() == 0) { + throw new IOException("The payment processor's answer has no id"); + } + return id; + } + + private static String text(Map object, String key) { + Object value = object.get(key); + return value instanceof String ? (String) value : ""; + } + + private static long number(Map object, String key) { + Object value = object.get(key); + return value instanceof Number ? ((Number) value).longValue() : 0L; + } + + /// Stripe names brands in lower case (`visa`, `mastercard`, `amex`); this is + /// the name with its first letter raised, which is how a person writes it. + private static String brand(String name) { + if (name.length() == 0) { + return "Card"; + } + char first = name.charAt(0); + if (first >= 'a' && first <= 'z') { + first = (char) (first - ('a' - 'A')); + } + return first + name.substring(1); + } + + /// Stripe wants a currency code in lower case. Folded by hand, because + /// `toLowerCase()` follows the server's locale and a currency code must not. + private static String asciiLower(String text) { + char[] chars = text.toCharArray(); + for (int iter = 0; iter < chars.length; iter++) { + if (chars[iter] >= 'A' && chars[iter] <= 'Z') { + chars[iter] = (char) (chars[iter] + ('a' - 'A')); + } + } + return new String(chars); + } + + /// The network, as this class uses it: one request to the API, one answer. + public interface Transport { + /// @param path the path and query, from `/v1` + /// @param form the form-encoded body, or null for a request without one + /// @param idempotencyKey sent as `Idempotency-Key` when not empty, so a + /// request repeated with the same key is carried out once + Reply send(String method, String path, String form, String idempotencyKey) + throws IOException; + } + + /// An answer: its status and its body, whatever the status. + public static final class Reply { + public final int status; + public final String body; + + public Reply(int status, String body) { + this.status = status; + this.body = body; + } + } + + /// The real network: HTTPS to `api.stripe.com`, as [Web] makes it. + static final class WebTransport implements Transport { + private final String secret; + + WebTransport(String secret) { + this.secret = secret; + } + + @Override + public Reply send(String method, String path, String form, String idempotencyKey) + throws IOException { + List headers = new ArrayList(); + headers.add("Authorization: Bearer " + secret); + if (form != null) { + headers.add("Content-Type: application/x-www-form-urlencoded"); + } + if (idempotencyKey != null && idempotencyKey.length() > 0) { + headers.add("Idempotency-Key: " + idempotencyKey); + } + Web.Result answer = Web.request(method, API + path, headers, + form == null ? null : form.getBytes("UTF-8")); + return new Reply(answer.getStatus(), answer.getBodyAsString()); + } + } +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/push/CloudPushSender.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/push/CloudPushSender.java new file mode 100644 index 00000000000..280e8177633 --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/push/CloudPushSender.java @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.push; + +import com.codename1.backend.Tasks; +import com.codename1.backend.push.CloudPush; +import com.codename1.backend.push.PushNotification; +import com.codename1.backend.push.PushResult; +import com.codename1.backend.push.PushTarget; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +// tag::savor-push-cloud[] +/// Sends through the Codename One push service, which delivers to Firebase, +/// Apple, Huawei and the browsers on the app's behalf. +/// +/// The call to the service is made on a thread of its own, so the request that +/// changed a delivery answers without waiting for it, and a refusal is logged and +/// not thrown: the customer is still told over the live channel and on their next +/// look at the delivery. +public class CloudPushSender implements PushSender { + private final CloudPush push; + + /// @param push the client for the service, holding the app's id and the + /// server key + public CloudPushSender(CloudPush push) { + this.push = push; + } + + @Override + public void send(List targets, final PushNotification message, + final Runnable delivered) { + if (targets == null || targets.isEmpty()) { + return; + } + final List to = new ArrayList(targets); + Tasks.platform(() -> deliver(to, message, delivered)); + } + + private void deliver(List to, PushNotification message, Runnable delivered) { + boolean accepted = false; + try { + PushResult result = push.send(message, to); + accepted = result.isAccepted(); + if (!accepted) { + System.err.println("[push] the push service refused a notification: " + + result.getStatus() + " " + result.getError()); + } + } catch (IOException | RuntimeException err) { + System.err.println("[push] a notification was not sent: " + err); + } + if (accepted && delivered != null) { + try { + delivered.run(); + } catch (RuntimeException err) { + System.err.println("[push] a notification was sent, and what follows it failed: " + + err); + } + } + } +} +// end::savor-push-cloud[] diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/push/ConsolePushSender.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/push/ConsolePushSender.java new file mode 100644 index 00000000000..3b9dd6dae3d --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/push/ConsolePushSender.java @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.push; + +import com.codename1.backend.push.PushNotification; +import com.codename1.backend.push.PushTarget; + +import java.util.List; + +/// Prints each notification on the server's console instead of sending it. +/// +/// For development only: [com.codenameone.examples.savor.Services] hands it +/// out on a development profile alone. +public class ConsolePushSender implements PushSender { + @Override + public void send(List targets, PushNotification message, Runnable delivered) { + if (targets == null || targets.isEmpty()) { + return; + } + System.out.println("[push] to " + targets.size() + " device(s): " + + message.toJson()); + // Printed is as delivered as this sender gets. + if (delivered != null) { + delivered.run(); + } + } +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/push/MisconfiguredPushSender.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/push/MisconfiguredPushSender.java new file mode 100644 index 00000000000..dab84430769 --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/push/MisconfiguredPushSender.java @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.push; + +import com.codename1.backend.push.PushNotification; +import com.codename1.backend.push.PushTarget; + +import java.util.List; + +/// What the server sends with when the push settings are incomplete: nothing +/// goes out, and the log says why. +/// +/// The problem is logged once when the server starts and again on every send, +/// naming the settings to fix. Unlike a text message, a notification that does +/// not go out costs nobody a sign-in, so it is not an error to the caller. +public class MisconfiguredPushSender implements PushSender { + private final String problem; + + /// @param problem what is wrong, naming the settings to fix + public MisconfiguredPushSender(String problem) { + this.problem = problem; + System.err.println("[config] " + problem); + } + + @Override + public void send(List targets, PushNotification message, Runnable delivered) { + if (targets != null && !targets.isEmpty()) { + System.err.println("[config] a push notification was not sent: " + problem); + } + } +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/push/OrderPushes.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/push/OrderPushes.java new file mode 100644 index 00000000000..5b99403e1df --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/push/OrderPushes.java @@ -0,0 +1,597 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.push; + +import com.codename1.backend.ResponseStatusException; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Scheduled; +import com.codename1.backend.annotations.Transactional; +import com.codename1.backend.annotations.Value; +import com.codename1.backend.push.PushNotification; +import com.codename1.backend.push.PushTarget; +import com.codename1.orm.repository.PageRequest; +import com.codenameone.examples.savor.Text; +import com.codenameone.examples.savor.api.DeliveryActivityDto; +import com.codenameone.examples.savor.api.DeliveryActivityKeys; +import com.codenameone.examples.savor.api.DeliveryState; +import com.codenameone.examples.savor.api.OrderState; +import com.codenameone.examples.savor.delivery.Deliveries; +import com.codenameone.examples.savor.domain.CourierState; +import com.codenameone.examples.savor.domain.CustomerOrder; +import com.codenameone.examples.savor.domain.Delivery; +import com.codenameone.examples.savor.domain.DeliveryActivity; +import com.codenameone.examples.savor.domain.Preference; +import com.codenameone.examples.savor.domain.Profile; +import com.codenameone.examples.savor.domain.RestaurantStaff; +import com.codenameone.examples.savor.repository.CourierStateRepository; +import com.codenameone.examples.savor.repository.CustomerOrderRepository; +import com.codenameone.examples.savor.repository.DeliveryActivityRepository; +import com.codenameone.examples.savor.repository.DeliveryRepository; +import com.codenameone.examples.savor.repository.PreferenceRepository; +import com.codenameone.examples.savor.repository.ProfileRepository; +import com.codenameone.examples.savor.repository.RestaurantStaffRepository; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/// The push notifications an order sends, and the Live Activity it keeps up to +/// date on the customer's lock screen from the moment it is placed to the +/// moment it reaches the door. +/// +/// [com.codenameone.examples.savor.order.OrderNews] calls [#orderChanged] once +/// a change to an order has been committed, the same moment it tells the live +/// channel; [Deliveries] calls [#deliveryChanged] for what concerns the courier +/// alone (an offer, a delivery taken back), and +/// [com.codenameone.examples.savor.courier.Couriers] calls [#courierMoved] for +/// every position a courier carrying an order reports. A notification is a +/// courtesy on top of what the app reads from the server, so nothing here ever +/// fails the change it is about: whatever goes wrong is logged. +/// +/// The live channel already tells an app that is open. A notification is for +/// the phone in a pocket: the customer whose food is on its way, the kitchen +/// with a new order, the courier with an offer. +/// +/// The **Notifications** switches in the app's settings decide what is said: +/// order updates for every step, receipts for its end. A switch that is off +/// silences the notification and nothing else, so the Live Activity still +/// follows the order and still ends with it. +@Component +public class OrderPushes { + private static final String CUSTOMER = "customer"; + private static final String COURIER = "courier"; + private static final String MERCHANT = "merchant"; + + /// How long an end the phone was not told of is tried again. iOS ends a + /// Live Activity on its own within twelve hours, so past that there is + /// nothing left on the lock screen to end. + private static final long END_RETRY_MILLIS = 12L * 60 * 60 * 1000; + + /// How old an end has to be before the sweep sends it again: the call + /// that ended the order is still sending it until then. + private static final long END_GRACE_MILLIS = 60000L; + + private static final OrderState[] FOLLOWED = { + OrderState.PLACED, OrderState.ACCEPTED, OrderState.READY, OrderState.PICKED_UP + }; + + private final PushSender sender; + private final PushDevices devices; + private final CustomerOrderRepository orders; + private final DeliveryRepository deliveries; + private final RestaurantStaffRepository staff; + private final DeliveryActivityRepository activities; + private final ProfileRepository profiles; + private final CourierStateRepository couriers; + private final PreferenceRepository preferences; + private final long activityMillis; + private final int offerSeconds; + + /// @param activitySeconds how often, at most, a moving courier updates the + /// customer's Live Activity + /// @param offerSeconds how long a courier has to answer an offer, which is + /// how long the notification of it is worth delivering + public OrderPushes(PushSender sender, PushDevices devices, CustomerOrderRepository orders, + DeliveryRepository deliveries, RestaurantStaffRepository staff, + DeliveryActivityRepository activities, ProfileRepository profiles, + CourierStateRepository couriers, PreferenceRepository preferences, + @Value("${savor.push.activity.seconds:45}") int activitySeconds, + @Value("${savor.offer.seconds:15}") int offerSeconds) { + this.sender = sender; + this.devices = devices; + this.orders = orders; + this.deliveries = deliveries; + this.staff = staff; + this.activities = activities; + this.profiles = profiles; + this.couriers = couriers; + this.preferences = preferences; + this.activityMillis = activitySeconds * 1000L; + this.offerSeconds = offerSeconds; + } + + /// One notification and the devices it goes to. + static final class Outgoing { + final List to; + final PushNotification message; + /// The order whose Live Activity this ends, forgotten once the push + /// service takes it; null for every other message. + final String ends; + + Outgoing(List to, PushNotification message) { + this(to, message, null); + } + + Outgoing(List to, PushNotification message, String ends) { + this.to = to; + this.message = message; + this.ends = ends; + } + } + + // tag::savor-push-order-changed[] + /// Tells the people concerned about a change to an order that has been + /// committed. Called after the transaction, so what it reads is the order + /// as it now is, and a phone that opens the app at once finds it so. + public void orderChanged(String id, OrderState state) { + try { + send(compose(id, state)); + } catch (IOException | RuntimeException err) { + System.err.println("[push] nothing was sent for order " + id + " (" + state + "): " + + err); + } + } + // end::savor-push-order-changed[] + + /// Tells a courier what concerns them alone: an offer, or a delivery an + /// admin took back. Everything the customer and the kitchen hear of goes + /// through [#orderChanged]. + public void deliveryChanged(String id, DeliveryState state, String courier) { + try { + send(composeForCourier(id, state, courier)); + } catch (IOException | RuntimeException err) { + System.err.println("[push] nothing was sent for delivery " + id + " (" + state + "): " + + err); + } + } + + /// Sends again the ends of Live Activities the push provider did not take + /// when their order ended, and forgets those too old to be on screen. + /// + /// Not a transaction itself: the provider is called with none open, and + /// what went wrong with the first end that failed again is reported once + /// the rest have been tried. + @Scheduled(fixedDelay = 60000, initialDelay = 50000, lock = "savor-activity-ends") + public void endLeftBehind() throws IOException { + long now = System.currentTimeMillis(); + dropEndedBefore(now - END_RETRY_MILLIS); + List left = leftBehind(now - END_GRACE_MILLIS); + Exception failed = null; + for (int iter = 0; iter < left.size(); iter++) { + String id = left.get(iter).getDeliveryId(); + try { + send(ending(id)); + } catch (IOException err) { + failed = failed == null ? err : failed; + } catch (RuntimeException err) { + failed = failed == null ? err : failed; + } + } + if (failed instanceof IOException) { + throw (IOException) failed; + } + if (failed instanceof RuntimeException) { + throw (RuntimeException) failed; + } + } + + @Transactional + private void dropEndedBefore(long before) { + activities.dropEndedBefore(before); + } + + @Transactional(readOnly = true) + private List leftBehind(long before) { + return activities.leftBehind(before, new PageRequest(0, 50)); + } + + @Transactional + private void forget(String id) { + activities.deleteByDeliveryId(id); + } + + /// The end of the Live Activity of order `id`, sent quietly: the customer + /// was told of the order's end, or chose not to be, when it happened. + @Transactional(readOnly = true) + private List ending(String id) throws IOException { + List out = new ArrayList(); + DeliveryActivity activity = activities.findById(id); + CustomerOrder order = orders.findById(id); + if (activity == null || order == null) { + return out; + } + Profile who = courierOf(id); + out.add(new Outgoing(devices.targetsOf(order.getCustomer()), quiet(id, order.getState()) + .surface(endOf(activity.getActivityId(), order, who)).build(), id)); + return out; + } + + /// Moves the customer's Live Activity on as the courier rides: the minutes + /// to the door. At most once every `savor.push.activity.seconds`, and only + /// for an order that has one. + public void courierMoved(String orderId, double lat, double lng) { + try { + send(moved(orderId, lat, lng)); + } catch (IOException | RuntimeException err) { + System.err.println("[push] the Live Activity of order " + orderId + + " was not updated: " + err); + } + } + + // tag::savor-push-activity[] + /// Records the Live Activity the customer's phone shows `orderId` in. Only + /// for the order's customer, and only while it is on its way: placed, + /// cooking, waiting for its courier, or riding. + @Transactional + public DeliveryActivityDto attach(String customer, String orderId, DeliveryActivityDto activity) + throws IOException { + String activityId = Text.required(activity == null ? null : activity.activityId(), 190, + "the activity"); + CustomerOrder order = orders.findById(orderId); + if (order == null || !order.getCustomer().equals(customer)) { + throw new ResponseStatusException(404, "No such order"); + } + // The state check and the insert have to be one step against the order + // ending: checked from the order read above, a courier delivering it or + // an admin cancelling it could commit in between, its compose() would + // find no activity to end, and this one would be inserted after it and + // stay on the lock screen. holdWhile checks the state in the database + // and keeps the order's row locked until this commits, so an ending + // either happened first (409 here) or waits and then finds the row. + if (orders.holdWhile(orderId, customer, FOLLOWED) == 0) { + throw new ResponseStatusException(409, "That order is no longer on its way"); + } + // One activity per order, the last one attached. A customer following + // their own order on two phones at once is not a case the sample + // serves: the earlier phone's activity stops moving and the system + // removes it. A row per device is listed in the implementor's guide + // among what a product adds. + DeliveryActivity row = activities.findById(orderId); + if (row == null) { + row = new DeliveryActivity(); + row.setDeliveryId(orderId); + row.setUsername(customer); + row.setActivityId(activityId); + row.setSentAt(System.currentTimeMillis()); + activities.insert(row); + } else { + row.setActivityId(activityId); + row.setSentAt(System.currentTimeMillis()); + } + return new DeliveryActivityDto(activityId); + } + // end::savor-push-activity[] + + /// Hands each message to the sender. The end of a Live Activity forgets + /// it only once the push service has taken the message, which the sender + /// says later, on its own thread. Until then the row stays marked ended, + /// so a refusal, an outage or a server stopped first leaves it for + /// [#endLeftBehind]: the order's transition is committed and never + /// retried, so nothing else would end the activity on the lock screen. + private void send(List outgoing) { + for (Outgoing each : outgoing) { + final String ends = each.ends; + if (each.to.isEmpty()) { + // No phone left to tell: nothing will ever take it. + if (ends != null) { + forget(ends); + } + continue; + } + sender.send(each.to, each.message, ends == null ? null : () -> forget(ends)); + } + } + + /// What to send about a change to an order, read and decided in one + /// transaction. An order that has ended marks its Live Activity ended + /// here; [#send] forgets it once the end has been sent. + @Transactional + private List compose(String id, OrderState state) throws IOException { + List out = new ArrayList(); + CustomerOrder order = orders.findById(id); + // The change this was called for may no longer be the order's latest. + // Each transition commits and then composes on its own thread, so two + // in quick succession can compose out of order: the kitchen accepts, + // an admin cancels and that commits, and only then does the acceptance + // get here. When what was committed since differs from `state`, + // nothing is said: the later transition says what is true. Nothing is + // cleaned up here either, for the same reason: the later call ends the + // Live Activity, and deleting its row first would leave it on screen. + if (order == null || order.getState() != state) { + return out; + } + String customer = order.getCustomer(); + String restaurant = order.getRestaurantName(); + Delivery delivery = deliveries.findById(id); + String courier = delivery == null ? null : delivery.getCourier(); + boolean hasCourier = courier != null && courier.length() > 0; + Profile who = hasCourier ? profiles.findById(courier) : null; + DeliveryActivity activity = activities.findById(id); + String activityId = activity == null ? null : activity.getActivityId(); + Map surface = null; + PushNotification.Builder toCustomer = null; + PushNotification.Builder toKitchen = null; + PushNotification.Builder toCourier = null; + // tag::savor-push-messages[] + switch (state) { + case PLACED: + Profile buyer = profiles.findById(customer); + toKitchen = message(id, state.name(), MERCHANT, "New order", + "An order for " + (buyer == null ? "a customer" : buyer.getDisplayName()) + + ". Open Savor to accept."); + surface = update(activityId, state(order, who, 0)); + break; + case ACCEPTED: + toCustomer = message(id, state.name(), CUSTOMER, restaurant + " is preparing your order", + "Ready in about " + Math.max(1, order.getPrepMinutes()) + " min."); + surface = update(activityId, state(order, who, 0)); + break; + case READY: + toCourier = hasCourier ? message(id, state.name(), COURIER, "Order ready", + "Pick it up at " + restaurant + ".") : null; + surface = update(activityId, state(order, who, 0)); + break; + case PICKED_UP: + int minutes = eta(order, delivery, courier); + toCustomer = message(id, state.name(), CUSTOMER, "Your order is on its way", + (who == null ? "Your courier" : who.getDisplayName()) + + " is bringing it. About " + minutes + " min."); + surface = update(activityId, state(order, who, minutes)); + break; + case DELIVERED: + toCustomer = message(id, state.name(), CUSTOMER, "Your order has arrived", + "Enjoy your meal. Your receipt is in the app."); + surface = endOf(activityId, order, who); + break; + case REJECTED: + toCustomer = message(id, state.name(), CUSTOMER, restaurant + " can't take your order", + "You have not been charged."); + surface = endOf(activityId, order, who); + break; + case NO_COURIERS: + toCustomer = message(id, state.name(), CUSTOMER, "No couriers available", + "Nobody could bring your order right now. You have not been charged."); + toKitchen = message(id, state.name(), MERCHANT, "Order canceled", + "No courier could be found for it."); + surface = endOf(activityId, order, who); + break; + case CANCELLED_BY_CUSTOMER: + toKitchen = message(id, state.name(), MERCHANT, "Order canceled", + "The customer canceled before you answered."); + surface = endOf(activityId, order, who); + break; + case CANCELLED_BY_ADMIN: + toCustomer = message(id, state.name(), CUSTOMER, "Your order was canceled", + "Savor support canceled this order."); + toKitchen = message(id, state.name(), MERCHANT, "Order canceled", + "Savor support canceled this order."); + surface = endOf(activityId, order, who); + break; + default: + break; + } + // end::savor-push-messages[] + if (!state.isActive() && activity != null) { + activities.markEnded(id, System.currentTimeMillis()); + } + if (toCustomer != null && !wants(customer, OrderState.DELIVERED == state)) { + toCustomer = null; + } + if (toCustomer != null) { + if (surface != null) { + toCustomer.surface(surface); + } + out.add(new Outgoing(devices.targetsOf(customer), toCustomer.build(), + surface != null && !state.isActive() ? id : null)); + } else if (surface != null) { + // Nothing to say to the customer, but their lock screen still has + // to follow the order, or let go of it. + out.add(new Outgoing(devices.targetsOf(customer), quiet(id, state).surface(surface) + .build(), state.isActive() ? null : id)); + } + if (toKitchen != null) { + List rows = staff.findByRestaurantId(order.getRestaurantId()); + for (int iter = 0; iter < rows.size(); iter++) { + String member = rows.get(iter).getUsername(); + if (wants(member, false)) { + out.add(new Outgoing(devices.targetsOf(member), toKitchen.build())); + } + } + } + if (toCourier != null && wants(courier, false)) { + out.add(new Outgoing(devices.targetsOf(courier), toCourier.build())); + } + return out; + } + + @Transactional(readOnly = true) + private List composeForCourier(String id, DeliveryState state, String courier) + throws IOException { + List out = new ArrayList(); + if (courier == null || courier.length() == 0 || !wants(courier, false)) { + return out; + } + Delivery delivery = deliveries.findById(id); + // Said only while it is still so: an offer that lapsed or was taken + // back before this ran is not news. + if (delivery == null || delivery.getState() != state + || (DeliveryState.OFFERED == state && !courier.equals(delivery.getCourier()))) { + return out; + } + PushNotification.Builder message = null; + if (DeliveryState.OFFERED == state) { + message = message(id, state.name(), COURIER, "New delivery", + "Pick up at " + delivery.getPickupAddress() + ". Open Savor to accept.") + .ttlSeconds(offerSeconds); + } else if (DeliveryState.CANCELLED_BY_ADMIN == state) { + message = message(id, state.name(), COURIER, "Delivery canceled", + "Savor support canceled this delivery. You're free for the next one."); + } + if (message != null) { + out.add(new Outgoing(devices.targetsOf(courier), message.build())); + } + return out; + } + + // tag::savor-push-preferences[] + /// Whether `username` asked to be told: of receipts when `receipt`, of + /// order updates otherwise. An account that never saved its settings gets + /// both, as the settings screen shows it. + private boolean wants(String username, boolean receipt) throws IOException { + Preference row = preferences.findById(username); + if (row == null) { + return true; + } + return receipt ? row.isNotifyReceipts() : row.isNotifyDeliveries(); + } + // end::savor-push-preferences[] + + /// The silent update of a moving courier, when one is due. + @Transactional + private List moved(String orderId, double lat, double lng) throws IOException { + List out = new ArrayList(); + DeliveryActivity activity = activities.findById(orderId); + if (activity == null) { + return out; + } + long now = System.currentTimeMillis(); + // A conditional update, so that two reports arriving together send one + // update between them. + if (activities.claim(orderId, now, now - activityMillis) != 1) { + return out; + } + CustomerOrder order = orders.findById(orderId); + if (order == null || OrderState.PICKED_UP != order.getState()) { + return out; + } + int minutes = Deliveries.etaMinutes(lat, lng, order.getDropoffLat(), order.getDropoffLng()); + out.add(new Outgoing(devices.targetsOf(order.getCustomer()), quiet(orderId, order.getState()) + .surface(PushNotification.liveUpdate(activity.getActivityId(), + state(order, courierOf(orderId), minutes))) + .build())); + return out; + } + + private Profile courierOf(String orderId) throws IOException { + Delivery delivery = deliveries.findById(orderId); + String courier = delivery == null ? null : delivery.getCourier(); + return courier == null || courier.length() == 0 ? null : profiles.findById(courier); + } + + /// A notification for the customer, the kitchen or the courier of an + /// order: `role` is which, for the app to open the side it is about. + private static PushNotification.Builder message(String id, String state, String role, + String title, String body) { + return PushNotification.builder() + .title(title) + .body(body) + .collapseKey("order-" + id + "-" + role) + .data("orderId", id) + .data("state", state) + .data("role", role); + } + + /// A message the phone acts on without showing anything. + private static PushNotification.Builder quiet(String id, OrderState state) { + return PushNotification.builder() + .silent(true) + .collapseKey("order-" + id + "-activity") + .data("orderId", id) + .data("state", state.name()) + .data("role", CUSTOMER); + } + + private static Map update(String activityId, Map state) { + return activityId == null ? null : PushNotification.liveUpdate(activityId, state); + } + + /// The end of a Live Activity: a delivered order stays on the lock screen + /// a while showing its arrival, one that ended otherwise goes at once. + private static Map endOf(String activityId, CustomerOrder order, Profile who) { + if (activityId == null) { + return null; + } + return OrderState.DELIVERED == order.getState() + ? PushNotification.liveEnd(activityId, state(order, who, 0), false) + : PushNotification.liveEnd(activityId, null, true); + } + + /// The Live Activity's state, in the keys the app laid it out with. + static Map state(CustomerOrder order, Profile who, int minutes) { + Map out = new HashMap(); + String status; + double progress; + switch (order.getState()) { + case ACCEPTED: + status = "Preparing your order"; + progress = DeliveryActivityKeys.PREPARING; + break; + case READY: + status = who == null ? "Ready, finding a courier" : "Ready for pickup"; + progress = DeliveryActivityKeys.READY; + break; + case PICKED_UP: + status = "On its way"; + progress = DeliveryActivityKeys.ON_THE_WAY; + break; + case DELIVERED: + status = "Delivered"; + progress = DeliveryActivityKeys.DONE; + break; + default: + status = "Order placed"; + progress = DeliveryActivityKeys.PLACED; + break; + } + out.put(DeliveryActivityKeys.STATUS, status); + out.put(DeliveryActivityKeys.PROGRESS, Double.valueOf(progress)); + out.put(DeliveryActivityKeys.RESTAURANT, order.getRestaurantName()); + out.put(DeliveryActivityKeys.COURIER, who == null ? "" : who.getDisplayName()); + out.put(DeliveryActivityKeys.CAR, who == null || who.getVehicle() == null ? "" + : who.getVehicle()); + out.put(DeliveryActivityKeys.PLATE, who == null || who.getPlate() == null ? "" + : who.getPlate()); + out.put(DeliveryActivityKeys.ETA, minutes > 0 ? minutes + " min" : ""); + return out; + } + + /// Minutes from where the courier is to the door, at least one; from the + /// restaurant when the courier has not been heard from. + private int eta(CustomerOrder order, Delivery delivery, String courier) { + CourierState at = courier == null || courier.length() == 0 ? null : couriers.findById(courier); + if (at == null) { + return delivery == null ? 1 : Deliveries.etaMinutes(delivery.getPickupLat(), + delivery.getPickupLng(), order.getDropoffLat(), order.getDropoffLng()); + } + return Deliveries.etaMinutes(at.getLat(), at.getLng(), order.getDropoffLat(), + order.getDropoffLng()); + } +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/push/PushDevices.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/push/PushDevices.java new file mode 100644 index 00000000000..30e63516f9a --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/push/PushDevices.java @@ -0,0 +1,174 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.push; + +import com.codename1.backend.ResponseStatusException; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Transactional; +import com.codename1.backend.push.PushTarget; +import com.codenameone.examples.savor.Ids; +import com.codenameone.examples.savor.Text; +import com.codenameone.examples.savor.api.PushDeviceDto; +import com.codenameone.examples.savor.domain.PushDevice; +import com.codenameone.examples.savor.repository.ProfileRepository; +import com.codenameone.examples.savor.repository.PushDeviceRepository; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +// tag::savor-push-devices[] +/// The devices each account is sent notifications on. +/// +/// The app registers its token when somebody signs in and removes it when they +/// sign out. A token registered by a second account moves to it: the phone +/// belongs to whoever signed in on it last, and the one before is not to be +/// told about the new owner's deliveries. +@Component +public class PushDevices { + /// How many devices one account keeps. A new one past this replaces the + /// one heard from longest ago: a token the platform has replaced is never + /// removed by its app, and would otherwise be sent to for good. + static final int MAX_PER_ACCOUNT = 8; + + private final PushDeviceRepository devices; + private final ProfileRepository profiles; + + public PushDevices(PushDeviceRepository devices, ProfileRepository profiles) { + this.devices = devices; + this.profiles = profiles; + } + + @Transactional + public PushDeviceDto register(String username, PushDeviceDto device) throws IOException { + PushTarget target = target(device); + String id = key(target); + long now = System.currentTimeMillis(); + // Before anything is read, the account's profile row is held: a no-op + // update that answers 0 when the profile is gone and otherwise keeps + // the row locked until this commits. It settles two races. + // + // Against closing the account: Accounts.erase holds the same row + // first and deletes it before it commits, so a registration either + // runs first (and erase's cleanup then deletes the device with the + // rest) or waits, finds no profile and is refused. Without it, a + // registration authorized just before the closure could insert its + // device after erase removed the account's, leaving a device of an + // account that no longer exists and that nobody can unregister. + // + // Against itself: two devices registering at once for an account one + // short of the cap would both count the same rows in makeRoom(), both + // skip the eviction and both insert, one past the cap. Behind the hold + // the second counts after the first committed, and evicts. + if (profiles.hold(username) == 0) { + throw new ResponseStatusException(404, "No such account"); + } + // The device row itself is not locked. Two accounts registering one + // token at the same moment -- a registration of the account that + // signed out still in flight as the next one signs in -- can leave it + // with the earlier account. The app settles it: a registration that + // answers after its account signed out is sent again for the account + // signed in now, and that one, sent after the stale one committed, + // lands last (Notifications.signedIn). + PushDevice row = devices.findById(id); + if (row == null || !username.equals(row.getUsername())) { + // A device new to this account, whether its token is new or moves + // here from another account, takes a place under the cap. + makeRoom(username); + } + if (row == null) { + row = new PushDevice(); + row.setId(id); + row.setProvider(target.getProvider()); + row.setToken(target.getToken()); + fill(row, username, device, now); + devices.insert(row); + } else { + fill(row, username, device, now); + } + return new PushDeviceDto(target.getProvider(), target.getToken(), row.getPlatform()); + } + + /// Removes `username`'s devices heard from longest ago until one more fits + /// under [#MAX_PER_ACCOUNT]. + private void makeRoom(String username) { + List held = new ArrayList(devices.findByUsername(username)); + while (held.size() >= MAX_PER_ACCOUNT) { + PushDevice oldest = held.get(0); + for (PushDevice each : held) { + if (each.getUpdatedAt() < oldest.getUpdatedAt()) { + oldest = each; + } + } + devices.deleteById(oldest.getId()); + held.remove(oldest); + } + } + + /// Forgets a device, if it is this account's. Somebody else's is left alone. + @Transactional + public PushDeviceDto unregister(String username, PushDeviceDto device) throws IOException { + PushTarget target = target(device); + devices.deleteByIdAndUsername(key(target), username); + return new PushDeviceDto(target.getProvider(), target.getToken(), ""); + } + + /// Where to reach `username`: every device they registered. + @Transactional(readOnly = true) + public List targets(String username) { + return targetsOf(username); + } + + /// [#targets] for a caller already inside a transaction. + List targetsOf(String username) { + List out = new ArrayList(); + if (username == null || username.length() == 0) { + return out; + } + for (PushDevice each : devices.findByUsername(username)) { + out.add(PushTarget.of(each.getProvider(), each.getToken())); + } + return out; + } + // end::savor-push-devices[] + + private static void fill(PushDevice row, String username, PushDeviceDto device, long now) { + row.setUsername(username); + row.setPlatform(Text.optional(device.platform(), 32, "the platform")); + row.setUpdatedAt(now); + } + + /// The device as a target of the push service, which also spells its + /// provider the one way: `gcm` and `fcm` are one device. + private static PushTarget target(PushDeviceDto device) { + if (device == null) { + throw new ResponseStatusException(400, "Tell us the device"); + } + String token = Text.required(device.token(), 1024, "the device's token"); + try { + return PushTarget.of(Text.required(device.provider(), 16, "the push provider"), token); + } catch (IllegalArgumentException err) { + throw new ResponseStatusException(400, err.getMessage()); + } + } + + private static String key(PushTarget target) throws IOException { + return Ids.sha256(target.getProvider() + ":" + target.getToken()); + } +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/push/PushSender.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/push/PushSender.java new file mode 100644 index 00000000000..7563566c2e5 --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/push/PushSender.java @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.push; + +import com.codename1.backend.push.PushNotification; +import com.codename1.backend.push.PushTarget; + +import java.util.List; + +// tag::savor-push-sender[] +/// Sends push notifications to devices. +/// +/// [com.codenameone.examples.savor.Services] chooses the implementation from +/// `savor.push.provider`. A notification is a courtesy on top of what the +/// app reads from the server, so sending one never fails the delivery it is about: +/// an implementation logs what went wrong and returns. +public interface PushSender { + /// Sends `message` to every one of `targets`. Returns at once; the sending + /// itself may happen later, on another thread. + /// + /// @param delivered run once the push service has taken the message, on + /// whatever thread learnt it; never when it was refused or could not be + /// sent. Null when nobody needs to know. + void send(List targets, PushNotification message, Runnable delivered); +} +// end::savor-push-sender[] diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/push/RecordingPushSender.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/push/RecordingPushSender.java new file mode 100644 index 00000000000..3604773e4fc --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/push/RecordingPushSender.java @@ -0,0 +1,123 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.push; + +import com.codename1.backend.push.PushNotification; +import com.codename1.backend.push.PushTarget; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/// Keeps every notification instead of sending it, for the tests to read. +/// +/// What the `test` profile gets: a test signs a phone in, drives a delivery, and +/// asks this what each device was sent. Refused on any profile that is not a +/// development one. +public class RecordingPushSender implements PushSender { + private final List sent = new ArrayList(); + private int refusals; + private int unconfirmed; + + /// One notification as one device was sent it. + public static final class Sent { + private final String provider; + private final String token; + private final Map message; + + Sent(String provider, String token, Map message) { + this.provider = provider; + this.token = token; + this.message = message; + } + + public String provider() { + return provider; + } + + public String token() { + return token; + } + + /// The notification as it would go on the wire: `title`, `body`, + /// `data`, `surface` and the rest. + public Map message() { + return message; + } + } + + @Override + public void send(List targets, PushNotification message, Runnable delivered) { + // Outside the lock: what follows a delivery writes to the database. + if (record(targets, message) && delivered != null) { + delivered.run(); + } + } + + /// Records a send, and answers whether the push service would confirm it. + private synchronized boolean record(List targets, PushNotification message) { + if (refusals > 0) { + refusals--; + throw new IllegalStateException("The push provider is unavailable"); + } + if (targets != null) { + for (PushTarget target : targets) { + sent.add(new Sent(target.getProvider(), target.getToken(), message.toMap())); + } + } + if (unconfirmed > 0) { + unconfirmed--; + return false; + } + return true; + } + + /// What the device with `token` was sent, oldest first. + public synchronized List sentTo(String token) { + List out = new ArrayList(); + for (Sent each : sent) { + if (each.token.equals(token)) { + out.add(each); + } + } + return out; + } + + /// Everything sent so far, oldest first. + public synchronized List all() { + return new ArrayList(sent); + } + + public synchronized void clear() { + sent.clear(); + refusals = 0; + unconfirmed = 0; + } + + /// Makes the next `count` sends go out and never be confirmed, as a + /// service that refuses them later, on its own thread, does. + public synchronized void leaveUnconfirmed(int count) { + unconfirmed = count; + } + + /// Makes the next `count` sends throw, as a provider that is down does. + public synchronized void refuse(int count) { + refusals = count; + } +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/repository/ClosedAccountRepository.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/repository/ClosedAccountRepository.java new file mode 100644 index 00000000000..4bceac53e7d --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/repository/ClosedAccountRepository.java @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.savor.domain.ClosedAccount; + +/// The hashes of the e-mails that had an account once and may not have another. +@Repository(entity = ClosedAccount.class, key = String.class) +public interface ClosedAccountRepository extends CrudRepository { + +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/repository/CourierApplicationRepository.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/repository/CourierApplicationRepository.java new file mode 100644 index 00000000000..0a71b192ff4 --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/repository/CourierApplicationRepository.java @@ -0,0 +1,103 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Modifying; +import com.codename1.orm.repository.PageRequest; +import com.codename1.orm.repository.Param; +import com.codename1.orm.repository.Query; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.savor.domain.CourierApplication; + +import java.util.List; + +/// Applications to deliver, one per account at most, keyed by the account. +/// +/// Every move of an application from one status to another is one conditional +/// statement answering how many rows it changed, so that of two callers moving +/// the same application out of the same status the count is 1 for one of them +/// and 0 for the other. Each is marked `clearAutomatically`, which empties the +/// session after it, so an application read before a move is read again after +/// it, as the move left it. +@Repository(entity = CourierApplication.class, key = String.class) +public interface CourierApplicationRepository extends CrudRepository { + /// The applications of these accounts; none for a name that made none. + List findByUsernameIn(List usernames); + + /// The applications of every account that has a courier's state. + @Query("select a from CourierApplication a where a.username in " + + "(select d.username from CourierState d)") + List ofCouriers(); + + /// The name and status of every application, as `{username, status}`. + @Query("select a.username, a.status from CourierApplication a") + List statuses(); + + long countByStatus(String status); + + /// Applications of every status, the latest handed in first. Only those + /// of accounts that still have a profile, as the list names the + /// applicant. + @Query("select a from CourierApplication a where a.username in " + + "(select p.username from Profile p) order by a.submittedAt desc, a.username asc") + List newest(PageRequest page); + + /// Applications in one status, the longest waiting first; only those of + /// accounts that still have a profile. + @Query("select a from CourierApplication a where a.status = :status " + + "and a.username in (select p.username from Profile p) " + + "order by a.submittedAt asc, a.username asc") + List inStatus(@Param("status") String status, PageRequest page); + + /// Moves an application from status `from` to `to`, recording the review. + @Modifying(clearAutomatically = true) + @Query("update CourierApplication a set a.status = :to, a.rejectionReason = :reason, " + + "a.reviewedAt = :at, a.reviewedBy = :by " + + "where a.username = :username and a.status = :from") + int move(@Param("username") String username, @Param("from") String from, + @Param("to") String to, @Param("reason") String reason, @Param("at") long at, + @Param("by") String by); + + /// Marks an application `approved` unless it already is. + @Modifying(clearAutomatically = true) + @Query("update CourierApplication a set a.status = :to, a.rejectionReason = '', " + + "a.reviewedAt = :at, a.reviewedBy = :by " + + "where a.username = :username and a.status <> :to") + int approveUnlessApproved(@Param("username") String username, + @Param("to") String approved, @Param("at") long at, @Param("by") String by); + + /// Hands an application in: from the status it was read in to `to`, with + /// the last rejection's reason cleared. + @Modifying(clearAutomatically = true) + @Query("update CourierApplication a set a.status = :to, a.submittedAt = :at, " + + "a.rejectionReason = '' where a.username = :username and a.status = :from") + int submit(@Param("username") String username, @Param("from") String from, + @Param("to") String to, @Param("at") long at); + + /// Makes the rest of the transaction wait its turn behind any other that + /// holds, or moves, the same application. + /// + /// @return 1, or 0 for an account that has no application yet + @Modifying + @Query("update CourierApplication a set a.status = a.status where a.username = :username") + int hold(@Param("username") String username); + + int deleteByUsername(String username); +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/repository/CourierDocumentRepository.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/repository/CourierDocumentRepository.java new file mode 100644 index 00000000000..cf4fe3015a6 --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/repository/CourierDocumentRepository.java @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Modifying; +import com.codename1.orm.repository.Param; +import com.codename1.orm.repository.Query; +import com.codename1.orm.repository.Repository; +import com.codename1.orm.session.Identifier; +import com.codenameone.examples.savor.domain.CourierDocument; + +import java.util.List; + +/// The documents of applications to deliver, keyed by +/// `Identifier.of(username, kind)`. +/// +/// A document's row carries the picture, as Base64 text that can run to a +/// couple of megabytes. Only `findById` reads it. Everything that wants to know +/// which documents there are and how they stand selects those fields alone, so +/// listing two hundred applications does not pull two hundred sets of +/// photographs through the server. +@Repository(entity = CourierDocument.class, key = Identifier.class) +public interface CourierDocumentRepository extends CrudRepository { + /// Which documents an account has uploaded, as `{kind, status, + /// uploadedAt}`, without the pictures. + @Query("select d.kind, d.status, d.uploadedAt from CourierDocument d " + + "where d.username = :username") + List summaries(@Param("username") String username); + + /// The same for several accounts at once, as `{username, kind, status, + /// uploadedAt}`. + @Query("select d.username, d.kind, d.status, d.uploadedAt from CourierDocument d " + + "where d.username in :names") + List summaries(@Param("names") List usernames); + + /// Puts a new picture in place of a stored document's, and answers how + /// many rows that changed: 0 says there was none of that kind to replace. + /// A statement, so that the picture being replaced is never read. + @Modifying(clearAutomatically = true) + @Query("update CourierDocument d set d.contentType = :type, d.status = :status, " + + "d.uploadedAt = :at, d.data = :data where d.username = :username and d.kind = :kind") + int replace(@Param("username") String username, @Param("kind") String kind, + @Param("type") String contentType, @Param("status") String status, + @Param("at") long at, @Param("data") String data); + + /// Sets the status of all of an account's documents. + @Modifying(clearAutomatically = true) + @Query("update CourierDocument d set d.status = :status where d.username = :username") + int markAll(@Param("username") String username, @Param("status") String status); + + int deleteByUsername(String username); +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/repository/CourierHoursRepository.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/repository/CourierHoursRepository.java new file mode 100644 index 00000000000..0aca5cb9fca --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/repository/CourierHoursRepository.java @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Modifying; +import com.codename1.orm.repository.Param; +import com.codename1.orm.repository.Query; +import com.codename1.orm.repository.Repository; +import com.codename1.orm.session.Identifier; +import com.codenameone.examples.savor.domain.CourierHours; + +/// The time each courier spent on line, a row for each day, keyed by +/// `Identifier.of(username, dayNumber)`. +@Repository(entity = CourierHours.class, key = Identifier.class) +public interface CourierHoursRepository extends CrudRepository { + /// Adds time on line to a day that already has a row. The addition is the + /// database's, so reports that overlap are both counted. + /// + /// @return 1, or 0 when the day has no row yet + @Modifying + @Query("update CourierHours h set h.millis = h.millis + :millis " + + "where h.username = :username and h.dayNumber = :day") + int addMillis(@Param("username") String username, @Param("day") long dayNumber, + @Param("millis") long millis); + + int deleteByUsername(String username); +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/repository/CourierStateRepository.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/repository/CourierStateRepository.java new file mode 100644 index 00000000000..723982d6bb5 --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/repository/CourierStateRepository.java @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Modifying; +import com.codename1.orm.repository.Param; +import com.codename1.orm.repository.Query; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.savor.api.DeliveryState; +import com.codenameone.examples.savor.domain.CourierState; + +import java.util.List; + +/// Couriers' live state: whether each is on line, where the car is, and the +/// rating. +@Repository(entity = CourierState.class, key = String.class) +public interface CourierStateRepository extends CrudRepository { + /// Every account that has a state, those on line first and then the ones + /// heard from most recently. + @Query("select d from CourierState d order by d.online desc, d.lastSeen desc") + List onLineFirst(); + + /// The couriers on line, heard from since `fresh`, inside a box of + /// latitude and longitude, whose application stands approved, who still + /// have a profile, and who have no delivery in hand: one offer, or one delivery, + /// at a time. + /// + /// The approval is read here, in the query, and not trusted to have been + /// put right elsewhere: an approval withdrawn takes a courier out of + /// matching at once, whatever their state still says. A state left behind + /// by an account that is gone is not somebody to send a customer to. + /// + /// What the customer asked of the car and the courier is the caller's to + /// apply to the few this answers. + @Query("select d from CourierState d where d.online = TRUE and d.lastSeen >= :fresh " + + "and d.lat between :south and :north and d.lng between :west and :east " + + "and d.username in (select a.username from CourierApplication a " + + "where a.status = :approved) " + + "and d.username in (select p.username from Profile p) " + + "and d.username not in (select r.courier from Delivery r where r.state in :withDelivery)") + List freeInBox(@Param("fresh") long fresh, @Param("south") double south, + @Param("north") double north, @Param("west") double west, + @Param("east") double east, @Param("approved") String approved, + @Param("withDelivery") DeliveryState[] withDelivery); + + /// Makes the rest of the transaction wait its turn behind any other that + /// holds the same courier, as [ProfileRepository#hold] does for an + /// account. + /// + /// @return 1, or 0 for a courier who has no state + @Modifying + @Query("update CourierState d set d.lastSeen = d.lastSeen where d.username = :username") + int hold(@Param("username") String username); + + /// Counts a rating toward a courier's. Arithmetic done by the database, so + /// two ratings arriving together are both counted. + /// + /// @return 1, or 0 for a courier who has no state to count it in + @Modifying + @Query("update CourierState d set d.ratingSum = d.ratingSum + :stars, " + + "d.ratingCount = d.ratingCount + 1 where d.username = :username") + int addRating(@Param("username") String username, @Param("stars") long stars); + + /// Takes a courier off line. Changes nothing for an account that never + /// drove. + @Modifying + @Query("update CourierState d set d.online = FALSE where d.username = :username") + int takeOffLine(@Param("username") String username); + + /// The couriers on line who reported in at or after `seenSince`. + long countByOnlineTrueAndLastSeenGreaterThanEqual(long seenSince); + + int deleteByUsername(String username); +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/repository/CustomerOrderRepository.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/repository/CustomerOrderRepository.java new file mode 100644 index 00000000000..43ab2087711 --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/repository/CustomerOrderRepository.java @@ -0,0 +1,171 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Modifying; +import com.codename1.orm.repository.PageRequest; +import com.codename1.orm.repository.Param; +import com.codename1.orm.repository.Query; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.savor.api.OrderState; +import com.codenameone.examples.savor.domain.CustomerOrder; + +import java.util.List; + +/// Every order. +@Repository(entity = CustomerOrder.class, key = String.class) +public interface CustomerOrderRepository extends CrudRepository { + List findByCustomerAndStateInOrderByPlacedAtDesc(String customer, OrderState[] states); + + List findTop50ByCustomerAndStateInOrderByPlacedAtDesc(String customer, + OrderState[] states); + + List findByRestaurantIdAndStateInOrderByPlacedAtAsc(String restaurantId, + OrderState[] states); + + List findTop50ByRestaurantIdAndStateInOrderByPlacedAtDesc(String restaurantId, + OrderState[] states); + + List findByRestaurantIdAndStateAndDeliveredAtGreaterThanEqual(String restaurantId, + OrderState state, long since); + + List findByStateAndPlacedAtLessThan(OrderState state, long before); + + List findByStateAndAcceptedAtLessThan(OrderState state, long before); + + boolean existsByCustomerAndMethodIdAndStateIn(String customer, String methodId, + OrderState[] states); + + List findByPaymentStatusAndUpdatedAtLessThan(String paymentStatus, long before); + + /// Orders in one of `states` whose payment is one of `statuses` and has + /// not changed since `before`, oldest first: ended orders whose hold was + /// never let go. + @Query("select o from CustomerOrder o where o.state in :states " + + "and o.paymentStatus in :statuses and o.updatedAt < :before " + + "order by o.updatedAt asc") + List unreleased(@Param("states") OrderState[] states, + @Param("statuses") String[] statuses, @Param("before") long before, PageRequest page); + + @Query("select o from CustomerOrder o order by o.placedAt desc") + List newest(PageRequest page); + + List findByPlacedAtGreaterThanEqual(long since); + + long countByCustomerAndStateIn(String customer, OrderState[] states); + + /// Moves an order from one of `from` to `to`, and answers 0 when it was in + /// none of them: the database applies the rule and settles a race in the + /// same statement. + @Modifying(clearAutomatically = true) + @Query("update CustomerOrder o set o.state = :to, o.updatedAt = :now " + + "where o.id = :id and o.state in :from") + int move(@Param("id") String id, @Param("from") OrderState[] from, @Param("to") OrderState to, + @Param("now") long now); + + @Modifying(clearAutomatically = true) + @Query("update CustomerOrder o set o.paymentStatus = :status, o.updatedAt = :now " + + "where o.id = :id") + int paymentStatus(@Param("id") String id, @Param("status") String status, @Param("now") long now); + + /// The kitchen takes an order and says how long it needs. + @Modifying(clearAutomatically = true) + @Query("update CustomerOrder o set o.state = :to, o.updatedAt = :now, o.acceptedAt = :now, " + + "o.prepMinutes = :prep where o.id = :id and o.state in :from") + int accept(@Param("id") String id, @Param("from") OrderState[] from, + @Param("to") OrderState to, @Param("prep") int prep, @Param("now") long now); + + /// The kitchen has finished an order. + @Modifying(clearAutomatically = true) + @Query("update CustomerOrder o set o.state = :to, o.updatedAt = :now, o.readyAt = :now " + + "where o.id = :id and o.state in :from") + int ready(@Param("id") String id, @Param("from") OrderState[] from, + @Param("to") OrderState to, @Param("now") long now); + + /// The order reached the door. + @Modifying(clearAutomatically = true) + @Query("update CustomerOrder o set o.state = :to, o.updatedAt = :now, o.deliveredAt = :now " + + "where o.id = :id and o.state in :from") + int deliver(@Param("id") String id, @Param("from") OrderState[] from, + @Param("to") OrderState to, @Param("now") long now); + + /// An order ends without reaching the door, and why. + @Modifying(clearAutomatically = true) + @Query("update CustomerOrder o set o.state = :to, o.updatedAt = :now, o.reason = :reason " + + "where o.id = :id and o.state in :from") + int end(@Param("id") String id, @Param("from") OrderState[] from, + @Param("to") OrderState to, @Param("reason") String reason, @Param("now") long now); + + /// A customer's stars for a delivered order, once. + @Modifying(clearAutomatically = true) + @Query("update CustomerOrder o set o.stars = :stars, o.updatedAt = :now " + + "where o.id = :id and o.customer = :customer and o.stars = 0") + int rate(@Param("id") String id, @Param("customer") String customer, + @Param("stars") int stars, @Param("now") long now); + + /// The orders placed since `since`, counted by restaurant: `{id, count}`. + @Query("select o.restaurantId, count(o) from CustomerOrder o where o.placedAt >= :since " + + "group by o.restaurantId") + List countByRestaurant(@Param("since") long since); + + long countByPlacedAtGreaterThanEqual(long since); + + long countByStateIn(OrderState[] states); + + /// What customers paid for orders in `state` whose payment is `paid`. + @Query("select coalesce(sum(o.totalCents), 0) from CustomerOrder o " + + "where o.state = :state and o.paymentStatus = :paid") + Long totals(@Param("state") OrderState state, @Param("paid") String paid); + + /// The orders placed since `since`, counted by state: `{state, count}`. + @Query("select o.state, count(o) from CustomerOrder o where o.placedAt >= :since " + + "group by o.state") + List countByState(@Param("since") long since); + + /// One row for each order placed since `since`: `{id, placedAt, state, + /// totalCents, paymentStatus, deliveredAt}`. + /// + /// The charts by day and by hour are counted from this in Java: grouping + /// by a day would mean grouping by `placedAt` divided by the length of + /// one, and a query cannot say that portably -- the number is bound twice, + /// and a database that takes the statement's text at its word does not + /// see the two as one expression. + @Query("select o.id, o.placedAt, o.state, o.totalCents, o.paymentStatus, o.deliveredAt " + + "from CustomerOrder o where o.placedAt >= :since") + List timeline(@Param("since") long since); + + /// Locks the order's row until the transaction ends, if it is still the + /// customer's and in one of `states`; 1 when it is, 0 when it is not. + @Modifying + @Query("update CustomerOrder o set o.customer = :customer " + + "where o.id = :id and o.customer = :customer and o.state in :states") + int holdWhile(@Param("id") String id, @Param("customer") String customer, + @Param("states") OrderState[] states); + + long countByCustomer(String customer); + + long countByCustomerAndState(String customer, OrderState state); + + /// What a customer paid for orders in `state` whose payment is `paid`. + @Query("select coalesce(sum(o.totalCents), 0) from CustomerOrder o " + + "where o.customer = :who and o.state = :state and o.paymentStatus = :paid") + Long spent(@Param("who") String customer, @Param("state") OrderState state, + @Param("paid") String paid); +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/repository/DeliveryActivityRepository.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/repository/DeliveryActivityRepository.java new file mode 100644 index 00000000000..c4e42751c00 --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/repository/DeliveryActivityRepository.java @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Modifying; +import com.codename1.orm.repository.PageRequest; +import com.codename1.orm.repository.Param; +import com.codename1.orm.repository.Query; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.savor.domain.DeliveryActivity; + +import java.util.List; + +/// The Live Activities customers follow their deliveries in, by delivery. +@Repository(entity = DeliveryActivity.class, key = String.class) +public interface DeliveryActivityRepository extends CrudRepository { + int deleteByDeliveryId(String deliveryId); + + int deleteByUsername(String username); + + /// Records that a state went out, unless one went out after `before`: + /// 1 for the caller who gets to send, 0 for anyone else at the same moment. + @Modifying + @Query("update DeliveryActivity a set a.sentAt = :now " + + "where a.deliveryId = :deliveryId and a.sentAt < :before") + int claim(@Param("deliveryId") String deliveryId, @Param("now") long now, + @Param("before") long before); + + /// Records that the delivery ended at `now`, unless that is already recorded. + @Modifying + @Query("update DeliveryActivity a set a.endedAt = :now " + + "where a.deliveryId = :deliveryId and a.endedAt = 0") + int markEnded(@Param("deliveryId") String deliveryId, @Param("now") long now); + + /// The activities whose delivery ended before `before` and that are still + /// here, because the phone was not told: oldest first. + @Query("select a from DeliveryActivity a where a.endedAt > 0 and a.endedAt < :before " + + "order by a.endedAt") + List leftBehind(@Param("before") long before, PageRequest page); + + /// Forgets the activities whose delivery ended before `before`. + @Modifying + @Query("delete from DeliveryActivity a where a.endedAt > 0 and a.endedAt < :before") + int dropEndedBefore(@Param("before") long before); +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/repository/DeliveryEventRepository.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/repository/DeliveryEventRepository.java new file mode 100644 index 00000000000..30c49dfc92e --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/repository/DeliveryEventRepository.java @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Param; +import com.codename1.orm.repository.Query; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.savor.api.DeliveryState; +import com.codenameone.examples.savor.domain.DeliveryEvent; + +import java.util.List; + +/// What happened to each delivery: one row for every state it came into. +@Repository(entity = DeliveryEvent.class, key = String.class) +public interface DeliveryEventRepository extends CrudRepository { + long countByActorAndState(String actor, DeliveryState state); + + /// How many offers each courier has accepted, as `{courier, count}`. + @Query("select e.actor, count(e) from DeliveryEvent e where e.state = :accepted " + + "group by e.actor") + List countByActor(@Param("accepted") DeliveryState accepted); + + /// When each delivery asked for since `since` came into `state`: `{deliveryId, + /// createdAt}`. The entities have no relationship to join along, so the + /// deliveries are named by a nested query. + @Query("select e.deliveryId, e.createdAt from DeliveryEvent e where e.state = :state " + + "and e.deliveryId in (select r.id from Delivery r where r.requestedAt >= :since)") + List reached(@Param("since") long since, @Param("state") DeliveryState state); +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/repository/DeliveryPassedRepository.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/repository/DeliveryPassedRepository.java new file mode 100644 index 00000000000..3c5dc5b342a --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/repository/DeliveryPassedRepository.java @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Query; +import com.codename1.orm.repository.Repository; +import com.codename1.orm.session.Identifier; +import com.codenameone.examples.savor.domain.DeliveryPassed; + +import java.util.List; + +/// The couriers who passed on a delivery or let its offer lapse, keyed by +/// `Identifier.of(deliveryId, courier)`. +@Repository(entity = DeliveryPassed.class, key = Identifier.class) +public interface DeliveryPassedRepository extends CrudRepository { + List findByDeliveryId(String deliveryId); + + long countByCourier(String courier); + + /// How many offers each courier has passed on or let lapse, as `{courier, + /// count}`. + @Query("select p.courier, count(p.deliveryId) from DeliveryPassed p group by p.courier") + List countByCourier(); +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/repository/DeliveryRepository.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/repository/DeliveryRepository.java new file mode 100644 index 00000000000..ddb775291aa --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/repository/DeliveryRepository.java @@ -0,0 +1,351 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Modifying; +import com.codename1.orm.repository.PageRequest; +import com.codename1.orm.repository.Param; +import com.codename1.orm.repository.Query; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.savor.api.DeliveryState; +import com.codenameone.examples.savor.domain.Delivery; + +import java.util.List; + +/// Deliveries. +/// +/// Which state may follow which is `DeliveryStateMachine`'s to say; what is here is +/// the statement that makes a move, told the states it may be made from. Every +/// move is one conditional update, and the count it answers is the whole +/// answer: 1 when this call made the move, 0 when the delivery was somewhere else by +/// then. The database applies the rule and settles a race in the same breath: +/// of two couriers accepting at once, or a customer cancelling as a courier accepts, +/// one statement changes a row and the other changes none. Each is marked +/// `clearAutomatically`, which empties the session after it, so a delivery read +/// before it is read again after it, as the statement left it. +/// +/// A move comes in one of five shapes -- the customer's, the courier's, the +/// courier's taking an offer, the courier's giving one back, and the server's -- +/// each its own statement rather than one assembled at run time. +// tag::savor-repository[] +@Repository(entity = Delivery.class, key = String.class) +public interface DeliveryRepository extends CrudRepository { + /// The delivery a customer has in one of `states`, or null. + Delivery findFirstByCustomerAndStateIn(String customer, DeliveryState[] states); + + /// The delivery a courier has in one of `states`, or null. + Delivery findFirstByCourierAndStateIn(String courier, DeliveryState[] states); + // end::savor-repository[] + + /// Whether the account is the customer of a delivery in one of `asCustomer` or the + /// courier of one in one of `asCourier`. + boolean existsByCustomerAndStateInOrCourierAndStateIn(String customer, DeliveryState[] asCustomer, + String courier, DeliveryState[] asCourier); + + boolean existsByCourierAndStateIn(String courier, DeliveryState[] states); + + /// Whether a delivery of the customer's in one of `states` is to be paid with + /// this method. + boolean existsByCustomerAndMethodIdAndStateIn(String customer, String methodId, + DeliveryState[] states); + + /// Whether a completed delivery of the customer's, whose payment is + /// `paymentStatus` and which ended after `since`, is to be charged to + /// this method. + boolean existsByCustomerAndStateAndPaymentStatusAndUpdatedAtGreaterThanAndMethodId( + String customer, DeliveryState state, String paymentStatus, long since, String methodId); + + /// The same, to be charged to any method but `methodId`. + boolean existsByCustomerAndStateAndPaymentStatusAndUpdatedAtGreaterThanAndMethodIdNot( + String customer, DeliveryState state, String paymentStatus, long since, String methodId); + + /// A customer's deliveries whose payment is `paymentStatus` and that carry a tip. + List findByCustomerAndPaymentStatusAndTipCentsGreaterThan(String customer, + String paymentStatus, long tipCents); + + /// A customer's last fifty deliveries, newest first. + List findTop50ByCustomerOrderByRequestedAtDesc(String customer); + + /// The last fifty of a courier's deliveries in one of `states`, newest first. + List findTop50ByCourierAndStateInOrderByRequestedAtDesc(String courier, + DeliveryState[] states); + + /// A customer's deliveries in `state`, newest first. + List findByCustomerAndStateOrderByRequestedAtDesc(String customer, DeliveryState state, + PageRequest page); + + /// Everyone's deliveries in `state`, newest first. + List findByStateOrderByRequestedAtDesc(DeliveryState state, PageRequest page); + + /// The most recent deliveries of all. + @Query("select r from Delivery r order by r.requestedAt desc") + List newest(PageRequest page); + + /// The deliveries in `state` whose offer ran out before `now`. + List findByStateAndOfferExpiresAtLessThan(DeliveryState state, long now); + + /// The deliveries in `state`, the one that has waited longest first. + List findByStateOrderByRequestedAtAsc(DeliveryState state); + + /// A courier's deliveries in `state` that changed at or after a time. + List findByCourierAndStateAndUpdatedAtGreaterThanEqual(String courier, + DeliveryState state, long since); + + long countByRequestedAtGreaterThanEqual(long since); + + long countByStateIn(DeliveryState[] states); + + /// The deliveries an account took part in, on either side. + long countByCustomerOrCourier(String customer, String courier); + + /// The deliveries an account itself called off, as the customer or as the courier. + long countByCustomerAndStateOrCourierAndState(String customer, DeliveryState asCustomer, String courier, + DeliveryState asCourier); + + // ---------------------------------------------------------------- moves + + /// Moves a delivery of `customer`'s from one of `from` to `to`. + @Modifying(clearAutomatically = true) + @Query("update Delivery r set r.state = :to, r.updatedAt = :now " + + "where r.id = :id and r.state in :from and r.customer = :customer") + int moveAsCustomer(@Param("id") String id, @Param("from") DeliveryState[] from, + @Param("to") DeliveryState to, @Param("now") long now, @Param("customer") String customer); + + /// Moves a delivery of `courier`'s from one of `from` to `to`. + @Modifying(clearAutomatically = true) + @Query("update Delivery r set r.state = :to, r.updatedAt = :now " + + "where r.id = :id and r.state in :from and r.courier = :courier") + int moveAsCourier(@Param("id") String id, @Param("from") DeliveryState[] from, + @Param("to") DeliveryState to, @Param("now") long now, @Param("courier") String courier); + + // tag::savor-delivery-move[] + /// Takes an offer made to `courier`: refused once the offer has lapsed, as + /// of `now`, even in the moment before the sweep takes it back. + @Modifying(clearAutomatically = true) + @Query("update Delivery r set r.state = :to, r.updatedAt = :now " + + "where r.id = :id and r.state in :from and r.courier = :courier " + + "and r.offerExpiresAt >= :now") + int takeOffer(@Param("id") String id, @Param("from") DeliveryState[] from, + @Param("to") DeliveryState to, @Param("now") long now, @Param("courier") String courier); + // end::savor-delivery-move[] + + /// Gives a delivery of `courier`'s back to be offered again, writing + /// `newCourier` (empty: nobody) as its courier. + @Modifying(clearAutomatically = true) + @Query("update Delivery r set r.state = :to, r.updatedAt = :now, r.courier = :newCourier " + + "where r.id = :id and r.state in :from and r.courier = :courier") + int giveBack(@Param("id") String id, @Param("from") DeliveryState[] from, + @Param("to") DeliveryState to, @Param("now") long now, @Param("courier") String courier, + @Param("newCourier") String newCourier); + + /// Holds `customer`'s delivery while it is in one of `states`, answering 0 when + /// it is not. The update writes nothing new; it is there for the row lock + /// it takes until the transaction ends, so a move that would take the delivery + /// out of `states` waits for this transaction, or has already happened + /// and makes it answer 0. The state is checked by the database at that + /// moment, never from a copy read earlier. + @Modifying + @Query("update Delivery r set r.customer = :customer " + + "where r.id = :id and r.customer = :customer and r.state in :states") + int holdWhile(@Param("id") String id, @Param("customer") String customer, + @Param("states") DeliveryState[] states); + + /// A move the server makes itself, whoever has the delivery. + @Modifying(clearAutomatically = true) + @Query("update Delivery r set r.state = :to, r.updatedAt = :now " + + "where r.id = :id and r.state in :from") + int moveByServer(@Param("id") String id, @Param("from") DeliveryState[] from, + @Param("to") DeliveryState to, @Param("now") long now); + + /// Offers a delivery to `courier` until `expiresAt`. + @Modifying(clearAutomatically = true) + @Query("update Delivery r set r.state = :to, r.updatedAt = :now, r.courier = :courier, " + + "r.offerExpiresAt = :expiresAt where r.id = :id and r.state in :from") + int offer(@Param("id") String id, @Param("from") DeliveryState[] from, + @Param("to") DeliveryState to, @Param("now") long now, @Param("courier") String courier, + @Param("expiresAt") long expiresAt); + + // ----------------------------------------------------------- the rest + + /// Gives a completed delivery its stars, if it has none yet. One statement, + /// for the reason a move is one: a delivery rated twice at the same moment is + /// rated once. + @Modifying(clearAutomatically = true) + @Query("update Delivery r set r.stars = :stars where r.id = :id and r.customer = :customer " + + "and r.state = 'COMPLETED' and r.stars = 0") + int rate(@Param("id") String id, @Param("customer") String customer, @Param("stars") int stars); + + /// Completed deliveries whose payment has been pending -- or is still the + /// hold made at checkout, never captured because the server stopped + /// between completing the delivery and settling it -- since before `before` + /// and no longer than since `after`, oldest first: the sweep takes one + /// page at a time, and a backlog read newest first would keep the oldest + /// behind the new arrivals until they aged out of the window unsettled. + @Query("select r from Delivery r where r.state = 'COMPLETED' " + + "and (r.paymentStatus = 'PENDING' or r.paymentStatus = 'AUTHORIZED') " + + "and r.updatedAt < :before and r.updatedAt > :after order by r.updatedAt asc") + List unsettled(@Param("after") long after, @Param("before") long before, + PageRequest page); + + /// Records how the charge for a delivery came out. A statement of its own and + /// not a change to a delivery read earlier, so that it writes this one column + /// whatever else was written to the delivery meanwhile. + @Modifying(clearAutomatically = true) + @Query("update Delivery r set r.paymentStatus = :status where r.id = :id") + int paymentStatus(@Param("id") String id, @Param("status") String status); + + /// Sets the tip of a completed, paid delivery that has none. + /// + /// @return 1 for the one caller whose tip it now is + @Modifying(clearAutomatically = true) + @Query("update Delivery r set r.tipCents = :tip where r.id = :id and r.customer = :customer " + + "and r.state = 'COMPLETED' and r.paymentStatus = 'PAID' and r.tipCents = 0") + int claimTip(@Param("id") String id, @Param("customer") String customer, @Param("tip") long tip); + + /// Takes back a tip that could not be charged. + @Modifying(clearAutomatically = true) + @Query("update Delivery r set r.tipCents = 0 where r.id = :id") + int releaseTip(@Param("id") String id); + + /// Marks a paid delivery refunded. + /// + /// @return 1 for the one caller that is to refund it + @Modifying(clearAutomatically = true) + @Query("update Delivery r set r.paymentStatus = 'REFUNDED' where r.id = :id " + + "and r.paymentStatus = 'PAID'") + int claimRefund(@Param("id") String id); + + /// Gives a refund's claim back: the delivery is paid again, if it is still + /// marked refunded. + /// + /// @return 1 when the delivery was + @Modifying(clearAutomatically = true) + @Query("update Delivery r set r.paymentStatus = 'PAID' where r.id = :id " + + "and r.paymentStatus = 'REFUNDED'") + int releaseRefund(@Param("id") String id); + + // ---------------------------------------------------------------- sums + // A sum over no rows is null and not zero, on every database, so each + // one is wrapped in `coalesce(..., 0)`. + + /// How many deliveries each courier has in `state`, as `{courier, count}`. + @Query("select r.courier, count(r) from Delivery r where r.state = :state group by r.courier") + List countByCourier(@Param("state") DeliveryState state); + + /// How many of a courier's completed deliveries were paid for, and what the + /// courier's share of their fares came to: `{count, cents}`. + /// + /// The share and not the fare: this is the figure the onboarding screen + /// shows as earned, and the fare includes the service's commission, + /// which the courier never sees and cannot cash out. + @Query("select count(r), coalesce(sum(r.courierCents), 0) from Delivery r " + + "where r.courier = :courier and r.state = 'COMPLETED' and r.paymentStatus = 'PAID'") + Object[] paidTotals(@Param("courier") String courier); + + /// [#paidTotals] for the deliveries that ended since a time: `{count, cents}`. + @Query("select count(r), coalesce(sum(r.courierCents), 0) from Delivery r " + + "where r.courier = :courier and r.state = 'COMPLETED' and r.paymentStatus = 'PAID' " + + "and r.updatedAt >= :since") + Object[] paidTotalsSince(@Param("courier") String courier, @Param("since") long since); + + /// What the courier's share of their completed, paid deliveries came to since + /// a time. + @Query("select coalesce(sum(r.courierCents), 0) from Delivery r where r.courier = :courier " + + "and r.state = 'COMPLETED' and r.paymentStatus = 'PAID' and r.updatedAt >= :since") + Long paidSince(@Param("courier") String courier, @Param("since") long since); + + /// The courier's share of the fares of every delivery they completed and that + /// was paid for -- in cash, or by a card whose charge came back. A charge + /// that failed, was refunded, or has not been answered yet is no money of + /// the service's, so it is not the courier's to cash out either. + @Query("select coalesce(sum(r.courierCents), 0) from Delivery r where r.courier = :courier " + + "and r.state = 'COMPLETED' and r.paymentStatus = 'PAID'") + Long courierShare(@Param("courier") String courier); + + /// [#courierShare] for the deliveries paid by card only: what the service + /// holds for the courier. A cash delivery's money is in the courier's hand. + @Query("select coalesce(sum(r.courierCents), 0) from Delivery r where r.courier = :courier " + + "and r.state = 'COMPLETED' and r.paymentStatus = 'PAID' and r.methodId <> 'cash'") + Long cardShare(@Param("courier") String courier); + + /// The tips handed to a courier in cash. + @Query("select coalesce(sum(r.tipCents), 0) from Delivery r where r.courier = :courier " + + "and r.state = 'COMPLETED' and r.methodId = 'cash'") + Long cashTips(@Param("courier") String courier); + + /// The tips put on a card for a courier whose charge has come back, while + /// the delivery is still paid: a refund returns the tip with the fare. + @Query("select coalesce(sum(r.tipCents), 0) from Delivery r where r.courier = :courier " + + "and r.state = 'COMPLETED' and r.methodId <> 'cash' and r.paymentStatus = 'PAID' " + + "and r.id in (select p.deliveryId from Payment p where p.tipRef <> '')") + Long chargedTips(@Param("courier") String courier); + + /// The ids of a courier's deliveries, completed since a time, whose tip was put + /// on a card and whose charge has come back: [#chargedTips] by delivery. + @Query("select r.id from Delivery r where r.courier = :courier and r.state = 'COMPLETED' " + + "and r.updatedAt >= :since and r.methodId <> 'cash' and r.paymentStatus = 'PAID' " + + "and r.id in (select p.deliveryId from Payment p where p.tipRef <> '')") + List tipChargedSince(@Param("courier") String courier, @Param("since") long since); + + /// What customers paid in fares for deliveries in `state` whose payment is `paid`. + @Query("select coalesce(sum(r.fareCents), 0) from Delivery r " + + "where r.state = :state and r.paymentStatus = :paid") + Long fares(@Param("state") DeliveryState state, @Param("paid") String paid); + + /// The fares of the deliveries in `state` asked for since `since`, paid or not. + @Query("select coalesce(sum(r.fareCents), 0) from Delivery r " + + "where r.requestedAt >= :since and r.state = :state") + Long faresSince(@Param("since") long since, @Param("state") DeliveryState state); + + /// The deliveries asked for since `since`, counted by state: `{state, count}`. + @Query("select r.state, count(r) from Delivery r where r.requestedAt >= :since group by r.state") + List countByState(@Param("since") long since); + + /// The deliveries in `state` asked for since `since`, counted by courier: + /// `{courier, count}`. + @Query("select r.courier, count(r) from Delivery r where r.requestedAt >= :since " + + "and r.state = :state group by r.courier") + List countByCourier(@Param("since") long since, @Param("state") DeliveryState state); + + /// One row for each delivery asked for since `since`: `{id, requestedAt, + /// state, fareCents, paymentStatus}`. + /// + /// This is what the charts by day and by hour of the day are counted + /// from, in Java. Grouping by a day would mean grouping by `requestedAt` + /// divided by the length of one, and a query cannot say that portably: + /// the number is sent as a bound value, once in the select list and once + /// in the `group by`, and a database that takes a statement's text at its + /// word does not see those as one expression. + @Query("select r.id, r.requestedAt, r.state, r.fareCents, r.paymentStatus from Delivery r " + + "where r.requestedAt >= :since") + List timeline(@Param("since") long since); + + /// What a customer paid for deliveries in `state` whose payment is `paid`: + /// `{fares, fees, tips}`. + @Query("select coalesce(sum(r.fareCents), 0), coalesce(sum(r.feeCents), 0), " + + "coalesce(sum(r.tipCents), 0) from Delivery r " + + "where r.customer = :who and r.state = :state and r.paymentStatus = :paid") + Object[] spent(@Param("who") String username, @Param("state") DeliveryState state, + @Param("paid") String paid); + + long countByCourier(String courier); + + long countByCourierAndState(String courier, DeliveryState state); +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/repository/DishOptionRepository.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/repository/DishOptionRepository.java new file mode 100644 index 00000000000..852753dc869 --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/repository/DishOptionRepository.java @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.savor.domain.DishOption; + +import java.util.List; + +/// The extras of every dish. +@Repository(entity = DishOption.class, key = String.class) +public interface DishOptionRepository extends CrudRepository { + List findByDishIdOrderByPositionAsc(String dishId); + + List findByDishIdIn(List dishIds); + + int deleteByDishId(String dishId); +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/repository/DishRepository.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/repository/DishRepository.java new file mode 100644 index 00000000000..9834e17361d --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/repository/DishRepository.java @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.savor.domain.Dish; + +import java.util.List; + +/// Every menu's dishes. +@Repository(entity = Dish.class, key = String.class) +public interface DishRepository extends CrudRepository { + List findByRestaurantIdOrderByPositionAsc(String restaurantId); + + long countBySectionId(String sectionId); + + long countByRestaurantId(String restaurantId); + + List findByRestaurantIdIn(List restaurantIds); +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/repository/FavoriteRepository.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/repository/FavoriteRepository.java new file mode 100644 index 00000000000..b3bd5fd8e2a --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/repository/FavoriteRepository.java @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Repository; +import com.codename1.orm.session.Identifier; +import com.codenameone.examples.savor.domain.Favorite; + +import java.util.List; + +/// The restaurants customers keep, keyed by `Identifier.of(username, restaurantId)`. +@Repository(entity = Favorite.class, key = Identifier.class) +public interface FavoriteRepository extends CrudRepository { + List findByUsernameOrderByCreatedAtDesc(String username); + + long countByUsername(String username); + + int deleteByUsername(String username); +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/repository/LiveTicketRepository.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/repository/LiveTicketRepository.java new file mode 100644 index 00000000000..28eda4163e2 --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/repository/LiveTicketRepository.java @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.savor.domain.LiveTicket; + +/// The tickets that open a live connection. Only the hash of a ticket is +/// stored, and it is the row's key. +@Repository(entity = LiveTicket.class, key = String.class) +public interface LiveTicketRepository extends CrudRepository { + /// Deletes a ticket and answers how many rows that removed: 1 for the one + /// caller that used it, 0 for anyone presenting it after. A statement and + /// not a read followed by a removal, because that count is the whole + /// point. + int deleteByTicketHash(String ticketHash); + + /// Deletes the tickets that went stale before `now`. + int deleteByExpiresAtLessThan(long now); + + int deleteByUsername(String username); +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/repository/MenuSectionRepository.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/repository/MenuSectionRepository.java new file mode 100644 index 00000000000..2df066f6dbc --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/repository/MenuSectionRepository.java @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.savor.domain.MenuSection; + +import java.util.List; + +/// The headings of every menu. +@Repository(entity = MenuSection.class, key = String.class) +public interface MenuSectionRepository extends CrudRepository { + List findByRestaurantIdOrderByPositionAsc(String restaurantId); + + long countByRestaurantId(String restaurantId); +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/repository/ModerationEventRepository.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/repository/ModerationEventRepository.java new file mode 100644 index 00000000000..c26c87168d8 --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/repository/ModerationEventRepository.java @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Param; +import com.codename1.orm.repository.Query; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.savor.domain.ModerationEvent; + +import java.util.List; + +/// The record of what the admins did to each account. +@Repository(entity = ModerationEvent.class, key = String.class) +public interface ModerationEventRepository extends CrudRepository { + /// When the newest event of an account was written, or null when it has + /// none. + @Query("select max(e.createdAt) from ModerationEvent e where e.username = :username") + Long newest(@Param("username") String username); + + /// An account's last hundred events, newest first. + List findTop100ByUsernameOrderByCreatedAtDescIdAsc(String username); + + int deleteByUsername(String username); +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/repository/OrderEventRepository.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/repository/OrderEventRepository.java new file mode 100644 index 00000000000..74271ef4b80 --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/repository/OrderEventRepository.java @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.savor.domain.OrderEvent; + +import java.util.List; + +/// What happened to each order: one row for every state it came into. +@Repository(entity = OrderEvent.class, key = String.class) +public interface OrderEventRepository extends CrudRepository { + List findByOrderIdOrderByCreatedAtAsc(String orderId); +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/repository/OrderLineRepository.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/repository/OrderLineRepository.java new file mode 100644 index 00000000000..78ef3923a3c --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/repository/OrderLineRepository.java @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.savor.domain.OrderLine; + +import java.util.List; + +/// The lines of every order. +@Repository(entity = OrderLine.class, key = String.class) +public interface OrderLineRepository extends CrudRepository { + List findByOrderIdOrderByPositionAsc(String orderId); + + List findByOrderIdIn(List orderIds); +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/repository/PaymentCustomerRepository.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/repository/PaymentCustomerRepository.java new file mode 100644 index 00000000000..923d2131d8a --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/repository/PaymentCustomerRepository.java @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Modifying; +import com.codename1.orm.repository.Param; +import com.codename1.orm.repository.Query; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.savor.domain.PaymentCustomer; + +/// The payment processor's names for accounts. +@Repository(entity = PaymentCustomer.class, key = String.class) +public interface PaymentCustomerRepository extends CrudRepository { + /// Writes an account's row without changing it, which holds the row until + /// the transaction ends: whatever else writes it waits its turn. + @Modifying + @Query("update PaymentCustomer c set c.reference = c.reference where c.username = :username") + int hold(@Param("username") String username); + + int deleteByUsername(String username); +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/repository/PaymentMethodRepository.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/repository/PaymentMethodRepository.java new file mode 100644 index 00000000000..38dd2ce9c24 --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/repository/PaymentMethodRepository.java @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Param; +import com.codename1.orm.repository.Query; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.savor.domain.PaymentMethod; + +import java.util.List; + +/// The cards accounts saved. +@Repository(entity = PaymentMethod.class, key = String.class) +public interface PaymentMethodRepository extends CrudRepository { + /// An account's saved cards, oldest first. + List findByUsernameOrderByCreatedAtAscIdAsc(String username); + + long countByUsername(String username); + + /// The card an account pays with by default, or null when that is cash. + PaymentMethod findFirstByUsernameAndPreferredTrue(String username); + + /// One of an account's cards, or null when the id is not a card of theirs. + PaymentMethod findFirstByIdAndUsername(String id, String username); + + /// When the newest of an account's cards was added, or null with none. + @Query("select max(m.createdAt) from PaymentMethod m where m.username = :username") + Long newestAt(@Param("username") String username); + + int deleteByUsername(String username); +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/repository/PaymentRepository.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/repository/PaymentRepository.java new file mode 100644 index 00000000000..0d4acd884bc --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/repository/PaymentRepository.java @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Modifying; +import com.codename1.orm.repository.PageRequest; +import com.codename1.orm.repository.Param; +import com.codename1.orm.repository.Query; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.savor.domain.Payment; + +import java.util.List; + +/// What each delivery was charged, keyed by the delivery. +@Repository(entity = Payment.class, key = String.class) +public interface PaymentRepository extends CrudRepository { + /// The payments of several deliveries, in no order. + List findByDeliveryIdIn(List deliveryIds); + + /// Payments whose refund the processor took and has not finished, the + /// oldest first. + List findByStatusOrderByCreatedAtAsc(String status, PageRequest page); + + /// Records how a pending refund ended. + /// + /// @return 1 for the one caller that recorded it + @Modifying + @Query("update Payment p set p.status = :status, p.refundedAt = :at " + + "where p.deliveryId = :id and p.status = 'REFUND_PENDING'") + int endRefund(@Param("id") String deliveryId, @Param("status") String status, + @Param("at") long at); +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/repository/PaymentSetupRepository.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/repository/PaymentSetupRepository.java new file mode 100644 index 00000000000..9f6aa508e46 --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/repository/PaymentSetupRepository.java @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Modifying; +import com.codename1.orm.repository.Param; +import com.codename1.orm.repository.Query; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.savor.domain.PaymentSetup; + +/// The card setups accounts began with the payment processor. +@Repository(entity = PaymentSetup.class, key = String.class) +public interface PaymentSetupRepository extends CrudRepository { + /// A card setup an account began, or null. + PaymentSetup findFirstByIdAndUsername(String id, String username); + + /// Marks a setup finished, if it is not yet. + /// + /// @return 1 for the one caller that finished it + @Modifying + @Query("update PaymentSetup s set s.completed = TRUE where s.id = :id and s.completed = FALSE") + int claim(@Param("id") String id); + + /// How many setups an account began after `since` and has not finished. + long countByUsernameAndCompletedFalseAndCreatedAtGreaterThan(String username, long since); + + int deleteByUsername(String username); + + /// Deletes the setups that saved their card. + int deleteByCompletedTrue(); + + /// Deletes the setups begun before `before`, finished or not. + int deleteByCreatedAtLessThan(long before); +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/repository/PayoutAccountRepository.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/repository/PayoutAccountRepository.java new file mode 100644 index 00000000000..b3b4a1d9fd1 --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/repository/PayoutAccountRepository.java @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Modifying; +import com.codename1.orm.repository.Param; +import com.codename1.orm.repository.Query; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.savor.domain.PayoutAccount; + +/// Where each courier is paid, keyed by the courier. +@Repository(entity = PayoutAccount.class, key = String.class) +public interface PayoutAccountRepository extends CrudRepository { + /// Makes the rest of the transaction wait its turn behind any other that + /// holds the same courier's payout account. + /// + /// @return 1, or 0 for a courier who has not said where to be paid + @Modifying + @Query("update PayoutAccount a set a.last4 = a.last4 where a.username = :courier") + int hold(@Param("courier") String courier); + + int deleteByUsername(String username); +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/repository/PayoutRepository.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/repository/PayoutRepository.java new file mode 100644 index 00000000000..72fc426df2a --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/repository/PayoutRepository.java @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Param; +import com.codename1.orm.repository.Query; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.savor.domain.Payout; + +import java.util.List; + +/// The payouts made to couriers. +@Repository(entity = Payout.class, key = String.class) +public interface PayoutRepository extends CrudRepository { + /// The last hundred payouts made to a courier, newest first. + List findTop100ByCourierOrderByCreatedAtDescIdAsc(String courier); + + /// Everything ever paid out to a courier. A sum over no rows is nothing at + /// all to a database, hence the zero to fall back on. + @Query("select coalesce(sum(p.amountCents), 0) from Payout p where p.courier = :courier") + Long paidOut(@Param("courier") String courier); +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/repository/PhoneCodeRepository.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/repository/PhoneCodeRepository.java new file mode 100644 index 00000000000..c49497de0de --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/repository/PhoneCodeRepository.java @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Modifying; +import com.codename1.orm.repository.Param; +import com.codename1.orm.repository.Query; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.savor.domain.PhoneCode; + +/// The code an account was texted to prove its phone number. An account has at +/// most one. +@Repository(entity = PhoneCode.class, key = String.class) +public interface PhoneCodeRepository extends CrudRepository { + /// Takes one of the guesses an account's code allows. The database does + /// the sum and applies the limit in the same statement, so of any number + /// of guesses sent at once only as many as are left are let through; + /// nothing is read and written back. + /// + /// The guess is taken of the code whose hash is `codeHash`, and of no + /// other: a new code sent between reading the hash and taking the guess + /// has another hash, and the update changes nothing. + /// + /// The code before it, when one is kept, is guessed at by the same guess, + /// so its count goes up too; its own limit is applied by the caller. + /// + /// Empties the session, so the code is read again after it with the guess + /// counted. + /// + /// @return 1 for a guess taken, 0 when none is left or there is no code + @Modifying(clearAutomatically = true) + @Query("update PhoneCode c set c.attempts = c.attempts + 1, " + + "c.previousAttempts = c.previousAttempts + 1 " + + "where c.username = :username and c.codeHash = :hash and c.attempts < :most") + int takeGuess(@Param("username") String username, @Param("hash") String codeHash, + @Param("most") int most); + + int deleteByUsername(String username); +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/repository/PhotoRepository.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/repository/PhotoRepository.java new file mode 100644 index 00000000000..f5da0568b6d --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/repository/PhotoRepository.java @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Param; +import com.codename1.orm.repository.Query; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.savor.domain.Photo; + +import java.util.List; + +/// The pictures of restaurants and dishes. +@Repository(entity = Photo.class, key = String.class) +public interface PhotoRepository extends CrudRepository { + long countByRestaurantId(String restaurantId); + + /// A restaurant's photos as `{id, createdAt}`, without their bytes. + @Query("select p.id, p.createdAt from Photo p where p.restaurantId = :restaurantId") + List stored(@Param("restaurantId") String restaurantId); +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/repository/PreferenceRepository.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/repository/PreferenceRepository.java new file mode 100644 index 00000000000..638ba2ba9e3 --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/repository/PreferenceRepository.java @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.savor.domain.Preference; + +/// An account's preferences, keyed by its name. An account that never saved any +/// has no row. +@Repository(entity = Preference.class, key = String.class) +public interface PreferenceRepository extends CrudRepository { + int deleteByUsername(String username); +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/repository/PricingRepository.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/repository/PricingRepository.java new file mode 100644 index 00000000000..51818ed7aa0 --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/repository/PricingRepository.java @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Modifying; +import com.codename1.orm.repository.Param; +import com.codename1.orm.repository.Query; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.savor.domain.Pricing; + +/// The admin's prices: one row, whose key is [#ONLY]. A migration creates it, +/// with an `updatedAt` of 0 while the prices are still the settings'. +@Repository(entity = Pricing.class, key = Integer.class) +public interface PricingRepository extends CrudRepository { + /// The key of the one row there is. + Integer ONLY = Integer.valueOf(1); + + /// Writes new prices over the one row, in one statement that reads + /// nothing first: two admins saving together each write theirs, and the + /// later one is what stays. + /// + /// @return 1, or 0 when the row is missing + @Modifying + @Query("update Pricing p set p.baseCents = :base, p.perKmCents = :perKm, " + + "p.surge = :surge, p.smallOrderCents = :smallOrder, " + + "p.smallOrderFeeCents = :smallOrderFee, p.feePercent = :fee, " + + "p.taxPercent = :tax, p.commissionPercent = :commission, " + + "p.updatedAt = :at, p.updatedBy = :by where p.id = :id") + int change(@Param("id") int id, @Param("base") long base, @Param("perKm") long perKm, + @Param("surge") double surge, @Param("smallOrder") long smallOrder, + @Param("smallOrderFee") long smallOrderFee, @Param("fee") double fee, + @Param("tax") double tax, @Param("commission") double commission, + @Param("at") long at, @Param("by") String by); +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/repository/ProfileRepository.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/repository/ProfileRepository.java new file mode 100644 index 00000000000..14997565bf7 --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/repository/ProfileRepository.java @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Modifying; +import com.codename1.orm.repository.Param; +import com.codename1.orm.repository.Query; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.savor.domain.Profile; + +import java.util.List; + +/// Profiles, keyed by the e-mail an account signs in with. +/// +/// A repository holds the queries of one kind of entity and nothing else: no +/// rule about who may do what, and no transaction of its own. The build writes +/// its implementation, and every call runs on the session of the transaction +/// its caller opened, so a service method marked `@Transactional` is what +/// makes several of these calls one unit of work. Called outside one, it is +/// refused. +@Repository(entity = Profile.class, key = String.class) +public interface ProfileRepository extends CrudRepository { + /// Makes the rest of the transaction wait its turn behind any other that + /// holds the same account: the row is written, to the value it has, and a + /// written row is kept by its writer until it commits. For a check and an + /// insert that must not be interleaved with the same pair made for the + /// same account. Call it before anything is read: what is read after it is + /// what the transaction before this one left, while a row read before it + /// was read before the wait. + /// + /// @return 1, or 0 when the account has no profile + @Modifying + @Query("update Profile p set p.createdAt = p.createdAt where p.username = :username") + int hold(@Param("username") String username); + + /// Every profile, newest first. + @Query("select p from Profile p order by p.createdAt desc, p.username asc") + List newestFirst(); + + /// The profiles of these accounts; none for a name that has none. + List findByUsernameIn(List usernames); + + /// The profiles of every account that has a courier's state: the fleet, + /// and not every account there is. + @Query("select p from Profile p where p.username in (select d.username from CourierState d)") + List couriers(); + + /// The display names of some accounts, as `{username, displayName}`. The + /// two fields and not the profiles: a list of applications shows nothing + /// else of them. + @Query("select p.username, p.displayName from Profile p where p.username in :names") + List displayNames(@Param("names") List usernames); + + long countByFlaggedTrue(); + + /// When each account made since `since` was made. + @Query("select p.createdAt from Profile p where p.createdAt >= :since") + List signUps(@Param("since") long since); +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/repository/PromoRepository.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/repository/PromoRepository.java new file mode 100644 index 00000000000..45612ec3cce --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/repository/PromoRepository.java @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.PageRequest; +import com.codename1.orm.repository.Query; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.savor.domain.Promo; + +import java.util.List; + +/// The promotion codes, by code. +@Repository(entity = Promo.class, key = String.class) +public interface PromoRepository extends CrudRepository { + @Query("select p from Promo p order by p.createdAt desc") + List newest(PageRequest page); +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/repository/PromoUseRepository.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/repository/PromoUseRepository.java new file mode 100644 index 00000000000..c44ed712925 --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/repository/PromoUseRepository.java @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Repository; +import com.codename1.orm.session.Identifier; +import com.codenameone.examples.savor.domain.PromoUse; + +/// The codes each account has used, keyed by `Identifier.of(code, username)`. +@Repository(entity = PromoUse.class, key = Identifier.class) +public interface PromoUseRepository extends CrudRepository { + int deleteByUsername(String username); + + int deleteByOrderId(String orderId); +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/repository/PushDeviceRepository.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/repository/PushDeviceRepository.java new file mode 100644 index 00000000000..1db8b77817b --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/repository/PushDeviceRepository.java @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.savor.domain.PushDevice; + +import java.util.List; + +/// The devices each account is sent notifications on. +@Repository(entity = PushDevice.class, key = String.class) +public interface PushDeviceRepository extends CrudRepository { + List findByUsername(String username); + + int deleteByUsername(String username); + + int deleteByIdAndUsername(String id, String username); +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/repository/RestaurantRepository.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/repository/RestaurantRepository.java new file mode 100644 index 00000000000..f71f1241757 --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/repository/RestaurantRepository.java @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Modifying; +import com.codename1.orm.repository.PageRequest; +import com.codename1.orm.repository.Param; +import com.codename1.orm.repository.Query; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.savor.domain.Restaurant; + +import java.util.List; + +/// The restaurants, approved or not. +@Repository(entity = Restaurant.class, key = String.class) +public interface RestaurantRepository extends CrudRepository { + /// The approved restaurants inside a box, for the caller to measure and sort. + @Query("select r from Restaurant r where r.status = :status " + + "and r.lat between :south and :north and r.lng between :west and :east") + List inBox(@Param("status") String status, @Param("south") double south, + @Param("north") double north, @Param("west") double west, @Param("east") double east); + + List findByStatusOrderByCreatedAtDesc(String status); + + @Query("select r from Restaurant r order by r.createdAt desc") + List newest(PageRequest page); + + Restaurant findFirstByApplicantOrderByCreatedAtDesc(String applicant); + + long countByStatus(String status); + + /// Makes the rest of the transaction wait behind any other that is + /// changing the restaurant -- pausing it, suspending it, its hours -- as + /// [ProfileRepository#hold] does for an account. Read the restaurant after + /// it, not before. + /// + /// @return 1, or 0 when there is no such restaurant + @Modifying + @Query("update Restaurant r set r.updatedAt = r.updatedAt where r.id = :id") + int hold(@Param("id") String id); + + /// Adds a rating to a restaurant's running total, in the database. + @Modifying + @Query("update Restaurant r set r.ratingTotal = r.ratingTotal + :stars, " + + "r.ratingCount = r.ratingCount + 1 where r.id = :id") + int addRating(@Param("id") String id, @Param("stars") long stars); +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/repository/RestaurantStaffRepository.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/repository/RestaurantStaffRepository.java new file mode 100644 index 00000000000..1535c74055d --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/repository/RestaurantStaffRepository.java @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Repository; +import com.codename1.orm.session.Identifier; +import com.codenameone.examples.savor.domain.RestaurantStaff; + +import java.util.List; + +/// Who works for which restaurant, keyed by `Identifier.of(restaurantId, username)`. +@Repository(entity = RestaurantStaff.class, key = Identifier.class) +public interface RestaurantStaffRepository extends CrudRepository { + List findByUsername(String username); + + List findByRestaurantId(String restaurantId); + + int deleteByUsername(String username); +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/repository/SavedAddressRepository.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/repository/SavedAddressRepository.java new file mode 100644 index 00000000000..9433f9b3c8b --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/repository/SavedAddressRepository.java @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.savor.domain.SavedAddress; + +import java.util.List; + +/// The places customers keep to be delivered to. +@Repository(entity = SavedAddress.class, key = String.class) +public interface SavedAddressRepository extends CrudRepository { + List findByUsernameOrderByCreatedAtAsc(String username); + + long countByUsername(String username); + + int deleteByUsername(String username); +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/sms/ConsoleSmsSender.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/sms/ConsoleSmsSender.java new file mode 100644 index 00000000000..9b690d37f8a --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/sms/ConsoleSmsSender.java @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.sms; + +/// Prints the message on the server's console instead of sending it. +/// +/// For development only: [com.codenameone.examples.savor.Services] hands it +/// out on the `dev`, `test` and `local` profiles alone, and a production server +/// that asks for it is refused with a configuration error. It is not a stand-in +/// for a provider -- nobody receives the text, which is exactly why a +/// development server may then hand the code back to the app. +public class ConsoleSmsSender implements SmsSender { + @Override + public void send(String phone, String text) { + System.out.println("[sms] to " + phone + ": " + text); + } + + @Override + public boolean delivers() { + return false; + } +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/sms/MessageBirdSmsSender.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/sms/MessageBirdSmsSender.java new file mode 100644 index 00000000000..9a31f908323 --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/sms/MessageBirdSmsSender.java @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.sms; + +import com.codename1.backend.Web; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +/// Sends through MessageBird's (now Bird's) Messages API: a form POST to +/// `https://rest.messagebird.com/messages` carrying `recipients`, `originator` +/// and `body`, authenticated with an `Authorization: AccessKey ...` header. +/// MessageBird answers 201 when it has accepted the message. +/// +/// Settings: `savor.sms.messagebird.key` (a live access key) and +/// `savor.sms.messagebird.originator` (a number, or up to eleven letters and +/// digits). +public class MessageBirdSmsSender implements SmsSender { + private static final String URL = "https://rest.messagebird.com/messages"; + + private final String accessKey; + private final String originator; + + public MessageBirdSmsSender(String accessKey, String originator) { + this.accessKey = accessKey; + this.originator = originator; + } + + @Override + public void send(String phone, String text) throws IOException { + List headers = new ArrayList(); + headers.add("Authorization: AccessKey " + accessKey); + headers.add("Content-Type: application/x-www-form-urlencoded"); + Web.Result sent = Web.request("POST", URL, headers, SmsForm.of("recipients", phone, + "originator", originator, "body", text)); + if (sent.getStatus() != 201) { + // The status only: the body can echo the number back. + throw new IOException("MessageBird answered " + sent.getStatus()); + } + } + + @Override + public boolean delivers() { + return true; + } +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/sms/MisconfiguredSmsSender.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/sms/MisconfiguredSmsSender.java new file mode 100644 index 00000000000..af81c456d99 --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/sms/MisconfiguredSmsSender.java @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.sms; + +import java.io.IOException; + +/// What the server sends with when the SMS settings are incomplete: every +/// message fails, and says why. +/// +/// The problem is logged once when the server starts and again on every send, +/// naming the settings that are missing, so it is in front of whoever reads the +/// log at either moment. The failure is an `IOException` like any provider +/// outage, which the phone verification answers with 503: a server whose SMS +/// settings were forgotten verifies nobody, rather than everybody. +public class MisconfiguredSmsSender implements SmsSender { + private final String problem; + + /// @param problem what is wrong, naming the settings to fix + public MisconfiguredSmsSender(String problem) { + this.problem = problem; + System.err.println("[config] " + problem); + } + + @Override + public void send(String phone, String text) throws IOException { + System.err.println("[config] a text message was not sent: " + problem); + throw new IOException(problem); + } + + /// True, because it stands in for a provider that would: the demo code a + /// development server hands back is only for the console sender. + @Override + public boolean delivers() { + return true; + } +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/sms/SmsForm.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/sms/SmsForm.java new file mode 100644 index 00000000000..67744aec4c5 --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/sms/SmsForm.java @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.sms; + +import java.io.IOException; +import java.net.URLEncoder; + +/// Builds an `application/x-www-form-urlencoded` body, which is what all three +/// providers' send calls take. +final class SmsForm { + private SmsForm() { + } + + /// Names and values in turn, each escaped as UTF-8. + static byte[] of(String... pairs) throws IOException { + StringBuilder out = new StringBuilder(); + for (int iter = 0; iter + 1 < pairs.length; iter += 2) { + if (out.length() > 0) { + out.append('&'); + } + out.append(URLEncoder.encode(pairs[iter], "UTF-8")).append('=') + .append(URLEncoder.encode(pairs[iter + 1], "UTF-8")); + } + return out.toString().getBytes("UTF-8"); + } +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/sms/SmsSender.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/sms/SmsSender.java new file mode 100644 index 00000000000..fa20df998a7 --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/sms/SmsSender.java @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.sms; + +import java.io.IOException; + +// tag::savor-sms-sender[] +/// Sends a text message. The one place the server touches an SMS provider, so +/// changing provider is one class. +public interface SmsSender { + /// - `IOException`: when the message could not be handed to the provider + void send(String phone, String text) throws IOException; + + /// Whether a message really reaches a phone. False for the console sender used in + /// development, which is what lets a development server hand the code back to the + /// app in place of the text message nobody is going to receive. + boolean delivers(); +} +// end::savor-sms-sender[] diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/sms/TwilioSmsSender.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/sms/TwilioSmsSender.java new file mode 100644 index 00000000000..8a721890548 --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/sms/TwilioSmsSender.java @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.sms; + +import com.codename1.backend.Base64; +import com.codename1.backend.Web; + +import java.io.IOException; +import java.net.URLEncoder; +import java.util.ArrayList; +import java.util.List; + +/// Sends through Twilio's Messages API: a form POST to +/// `/2010-04-01/Accounts/{sid}/Messages.json`, authenticated with the account +/// SID and auth token as HTTP Basic credentials. Twilio answers 201 when it +/// has queued the message. +/// +/// Settings: `savor.sms.twilio.sid`, `savor.sms.twilio.token` and +/// `savor.sms.twilio.from` (a Twilio number in E.164 form, or a messaging +/// service's sender). +/// +/// The account's credentials are settings of the server and never leave it: the +/// app asks the server for a code and has no SMS credentials of its own to leak. +public class TwilioSmsSender implements SmsSender { + private final String accountSid; + private final String authToken; + private final String from; + + public TwilioSmsSender(String accountSid, String authToken, String from) { + this.accountSid = accountSid; + this.authToken = authToken; + this.from = from; + } + + @Override + public void send(String phone, String text) throws IOException { + List headers = new ArrayList(); + headers.add("Authorization: Basic " + + Base64.encode((accountSid + ":" + authToken).getBytes("UTF-8"))); + headers.add("Content-Type: application/x-www-form-urlencoded"); + Web.Result sent = Web.request("POST", "https://api.twilio.com/2010-04-01/Accounts/" + + URLEncoder.encode(accountSid, "UTF-8") + "/Messages.json", headers, + SmsForm.of("To", phone, "From", from, "Body", text)); + if (!sent.isSuccess()) { + // The status only: the body can echo the number back. + throw new IOException("Twilio answered " + sent.getStatus()); + } + } + + @Override + public boolean delivers() { + return true; + } +} diff --git a/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/sms/VonageSmsSender.java b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/sms/VonageSmsSender.java new file mode 100644 index 00000000000..d35e9410547 --- /dev/null +++ b/scripts/savor/backend/src/main/java/com/codenameone/examples/savor/sms/VonageSmsSender.java @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.sms; + +import com.codename1.backend.Json; +import com.codename1.backend.Web; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +// tag::savor-sms-provider[] +/// Sends through Vonage's (formerly Nexmo's) SMS API: a form POST to +/// `https://rest.nexmo.com/sms/json` carrying `api_key`, `api_secret`, `to`, +/// `from` and `text`. +/// +/// Vonage answers 200 even for a message it refused; the outcome is the +/// `status` of each entry of `messages`, where `"0"` is success and anything +/// else is an error code. A long text is split into parts and each part has an +/// entry, so every one of them has to be `"0"`. +/// +/// Settings: `savor.sms.vonage.key`, `savor.sms.vonage.secret` and +/// `savor.sms.vonage.from` (a virtual number or an alphanumeric sender id). +/// The number is sent without its leading `+`, as Vonage asks. +public class VonageSmsSender implements SmsSender { + private static final String URL = "https://rest.nexmo.com/sms/json"; + + private final String apiKey; + private final String apiSecret; + private final String from; + + public VonageSmsSender(String apiKey, String apiSecret, String from) { + this.apiKey = apiKey; + this.apiSecret = apiSecret; + this.from = from; + } + + @Override + public void send(String phone, String text) throws IOException { + String to = phone.startsWith("+") ? phone.substring(1) : phone; + List headers = new ArrayList(); + headers.add("Content-Type: application/x-www-form-urlencoded"); + Web.Result sent = Web.request("POST", URL, headers, SmsForm.of("api_key", apiKey, + "api_secret", apiSecret, "to", to, "from", from, "text", text)); + if (sent.getStatus() != 200) { + throw new IOException("Vonage answered " + sent.getStatus()); + } + Object messages = Json.parseObject(sent.getBodyAsString()).get("messages"); + if (!(messages instanceof List) || ((List) messages).isEmpty()) { + throw new IOException("Vonage's answer lists no message"); + } + for (Object entry : (List) messages) { + Object status = entry instanceof Map ? ((Map) entry).get("status") : null; + if (!"0".equals(status)) { + // The code only; `error-text` can echo the number back. + throw new IOException("Vonage refused the message with status " + status); + } + } + } + + @Override + public boolean delivers() { + return true; + } +} +// end::savor-sms-provider[] diff --git a/scripts/savor/backend/src/main/resources/db/migration/V1__accounts.sql b/scripts/savor/backend/src/main/resources/db/migration/V1__accounts.sql new file mode 100644 index 00000000000..e77465b0ff7 --- /dev/null +++ b/scripts/savor/backend/src/main/resources/db/migration/V1__accounts.sql @@ -0,0 +1,76 @@ +-- What the app knows about an account beyond its sign-in. The sign-in itself -- +-- the password hash, the roles, whether the account is enabled -- lives in the +-- security tables the backend creates (cn1.security.schema.enabled), keyed by +-- the same username. +-- +-- Every script here is written once for SQLite, PostgreSQL and MySQL: no +-- generated keys, no native boolean or timestamp. A yes/no is a SMALLINT 0/1 and +-- a time is milliseconds since the epoch in a BIGINT. +CREATE TABLE profile ( + username VARCHAR(190) NOT NULL PRIMARY KEY, + display_name VARCHAR(120) NOT NULL, + phone VARCHAR(32) NOT NULL, + phone_verified SMALLINT NOT NULL, + vehicle VARCHAR(120) NOT NULL, + plate VARCHAR(32) NOT NULL, + created_at BIGINT NOT NULL, + language VARCHAR(8) NOT NULL, + flagged SMALLINT NOT NULL, + flag_reason VARCHAR(255) NOT NULL +); + +-- How one person wants the app and their deliveries. A row exists once they have +-- saved something; until then the defaults are the server's. +CREATE TABLE preference ( + username VARCHAR(190) NOT NULL PRIMARY KEY, + theme VARCHAR(8) NOT NULL, + units VARCHAR(4) NOT NULL, + notify_deliveries SMALLINT NOT NULL, + notify_receipts SMALLINT NOT NULL, + notify_promotions SMALLINT NOT NULL, + default_tip INTEGER NOT NULL +); + +-- The e-mails of accounts that were closed, as hashes. A closed account's deliveries +-- stay under its e-mail, so the address is never registered again; the hash is +-- enough to refuse it without keeping a list of who has left. +CREATE TABLE closed_account ( + username_hash VARCHAR(64) NOT NULL PRIMARY KEY, + closed_at BIGINT NOT NULL +); + +-- The verification code an account is waiting on. Only its hash is kept, and +-- that of the code before it while it is still good: text messages are not +-- delivered in the order they were sent. +CREATE TABLE phone_code ( + username VARCHAR(190) NOT NULL PRIMARY KEY, + code_hash VARCHAR(64) NOT NULL, + expires_at BIGINT NOT NULL, + attempts INTEGER NOT NULL, + sent_at BIGINT NOT NULL, + previous_hash VARCHAR(64) NOT NULL DEFAULT '', + previous_expires_at BIGINT NOT NULL DEFAULT 0, + previous_attempts INTEGER NOT NULL DEFAULT 0 +); + +-- Tickets for opening the live channel. A row is deleted by the connection that +-- presents it, which is what makes a ticket good for one connection only -- and +-- keeping them here and not in memory is what lets a second server instance +-- redeem a ticket the first one issued. +CREATE TABLE live_ticket ( + ticket_hash VARCHAR(64) NOT NULL PRIMARY KEY, + username VARCHAR(190) NOT NULL, + expires_at BIGINT NOT NULL +); + +-- What the admins did to an account and why: flagged it, blocked it, approved +-- or rejected its onboarding application. Never changed once written. +CREATE TABLE moderation_event ( + id VARCHAR(32) NOT NULL PRIMARY KEY, + username VARCHAR(190) NOT NULL, + created_at BIGINT NOT NULL, + actor VARCHAR(190) NOT NULL, + action VARCHAR(16) NOT NULL, + reason VARCHAR(255) NOT NULL +); +CREATE INDEX moderation_event_user ON moderation_event (username, created_at); diff --git a/scripts/savor/backend/src/main/resources/db/migration/V2__couriers.sql b/scripts/savor/backend/src/main/resources/db/migration/V2__couriers.sql new file mode 100644 index 00000000000..293ce5d8cf3 --- /dev/null +++ b/scripts/savor/backend/src/main/resources/db/migration/V2__couriers.sql @@ -0,0 +1,44 @@ +-- A courier's live state: on line or not, and where the car was last seen. +CREATE TABLE courier_state ( + username VARCHAR(190) NOT NULL PRIMARY KEY, + online SMALLINT NOT NULL, + lat DOUBLE PRECISION NOT NULL, + lng DOUBLE PRECISION NOT NULL, + heading DOUBLE PRECISION NOT NULL, + last_seen BIGINT NOT NULL, + rating_sum BIGINT NOT NULL, + rating_count BIGINT NOT NULL +); +-- Matching asks for the couriers on line inside a latitude band. +CREATE INDEX courier_online ON courier_state (online, lat); + +-- An application to deliver: one per account, edited as a draft, handed in, and +-- then approved or rejected by an admin. The COURIER role follows `approved`. +CREATE TABLE courier_application ( + username VARCHAR(190) NOT NULL PRIMARY KEY, + status VARCHAR(16) NOT NULL, + legal_name VARCHAR(120) NOT NULL, + date_of_birth VARCHAR(10) NOT NULL, + licence_number VARCHAR(40) NOT NULL, + licence_expiry VARCHAR(10) NOT NULL, + vehicle_make VARCHAR(40) NOT NULL, + vehicle_model VARCHAR(40) NOT NULL, + vehicle_year INTEGER NOT NULL, + vehicle_color VARCHAR(24) NOT NULL, + vehicle_plate VARCHAR(32) NOT NULL, + rejection_reason VARCHAR(255) NOT NULL, + created_at BIGINT NOT NULL, + submitted_at BIGINT NOT NULL, + reviewed_at BIGINT NOT NULL, + reviewed_by VARCHAR(190) NOT NULL +); +CREATE INDEX courier_application_status ON courier_application (status, submitted_at); + +-- How long a courier was on line on each day (days since the epoch, UTC), added +-- to by every position report. +CREATE TABLE courier_hours ( + username VARCHAR(190) NOT NULL, + day_number BIGINT NOT NULL, + millis BIGINT NOT NULL, + PRIMARY KEY (username, day_number) +); diff --git a/scripts/savor/backend/src/main/resources/db/migration/V4__deliveries.sql b/scripts/savor/backend/src/main/resources/db/migration/V4__deliveries.sql new file mode 100644 index 00000000000..952e422b871 --- /dev/null +++ b/scripts/savor/backend/src/main/resources/db/migration/V4__deliveries.sql @@ -0,0 +1,58 @@ +-- The courier's leg of an order: from the restaurant to the customer's door. +-- It has the id of the order it carries. +CREATE TABLE delivery ( + id VARCHAR(32) NOT NULL PRIMARY KEY, + state VARCHAR(24) NOT NULL, + customer VARCHAR(190) NOT NULL, + -- The courier it is offered to, then the courier who took it. Empty until one + -- is found, and again between one courier passing and the next being asked. + courier VARCHAR(190) NOT NULL, + pickup_lat DOUBLE PRECISION NOT NULL, + pickup_lng DOUBLE PRECISION NOT NULL, + pickup_address VARCHAR(255) NOT NULL, + dropoff_lat DOUBLE PRECISION NOT NULL, + dropoff_lng DOUBLE PRECISION NOT NULL, + dropoff_address VARCHAR(255) NOT NULL, + fare_cents BIGINT NOT NULL, + currency VARCHAR(3) NOT NULL, + distance_meters DOUBLE PRECISION NOT NULL, + duration_seconds DOUBLE PRECISION NOT NULL, + requested_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL, + -- When the offer in hand lapses, or when the search for a courier gives up. + offer_expires_at BIGINT NOT NULL, + search_until BIGINT NOT NULL, + stars SMALLINT NOT NULL, + payment_status VARCHAR(16) NOT NULL, + -- How the order it carries is paid for. The courier is paid the order's + -- delivery fee (`fare_cents`) and its tip, as `courier_cents` and + -- `tip_cents`; the customer's charge is the order's, not this row's. + method_id VARCHAR(32) NOT NULL, + method_label VARCHAR(64) NOT NULL, + fee_cents BIGINT NOT NULL, + tip_cents BIGINT NOT NULL, + courier_cents BIGINT NOT NULL, + -- For the courier: a gate code, which door. + note VARCHAR(255) NOT NULL +); +CREATE INDEX delivery_customer ON delivery (customer, requested_at); +CREATE INDEX delivery_courier ON delivery (courier, requested_at); +CREATE INDEX delivery_state ON delivery (state); + +-- Every change of a delivery's state, for the admin's view of what happened. +CREATE TABLE delivery_event ( + id VARCHAR(32) NOT NULL PRIMARY KEY, + delivery_id VARCHAR(32) NOT NULL, + state VARCHAR(24) NOT NULL, + actor VARCHAR(190) NOT NULL, + created_at BIGINT NOT NULL +); +CREATE INDEX delivery_event_delivery ON delivery_event (delivery_id, created_at); + +-- The couriers who passed on a delivery, or let its offer lapse: they are not asked +-- again for the same delivery. +CREATE TABLE delivery_passed ( + delivery_id VARCHAR(32) NOT NULL, + courier VARCHAR(190) NOT NULL, + PRIMARY KEY (delivery_id, courier) +); diff --git a/scripts/savor/backend/src/main/resources/db/migration/V5__payments.sql b/scripts/savor/backend/src/main/resources/db/migration/V5__payments.sql new file mode 100644 index 00000000000..7dbb5947e92 --- /dev/null +++ b/scripts/savor/backend/src/main/resources/db/migration/V5__payments.sql @@ -0,0 +1,87 @@ +CREATE TABLE payment ( + delivery_id VARCHAR(32) NOT NULL PRIMARY KEY, + amount_cents BIGINT NOT NULL, + currency VARCHAR(3) NOT NULL, + status VARCHAR(16) NOT NULL, + provider_ref VARCHAR(64) NOT NULL, + created_at BIGINT NOT NULL, + -- A delivery's payment grows a tip, which is a second charge, and a refund. + tip_ref VARCHAR(64) NOT NULL, + refund_ref VARCHAR(64) NOT NULL, + -- The tip is refunded on its own, and a refund the processor has not + -- finished is asked about by both references. + tip_refund_ref VARCHAR(64) NOT NULL, + refund_reason VARCHAR(255) NOT NULL, + refunded_at BIGINT NOT NULL +); + +-- The account a customer has with the payment processor, made the first time +-- they add a card. +CREATE TABLE payment_customer ( + username VARCHAR(190) NOT NULL PRIMARY KEY, + reference VARCHAR(64) NOT NULL +); + +-- A saved card: what the processor calls it, and the few things about it that +-- are safe to show. No card number is ever stored here, or anywhere. +CREATE TABLE payment_method ( + id VARCHAR(32) NOT NULL PRIMARY KEY, + username VARCHAR(190) NOT NULL, + brand VARCHAR(24) NOT NULL, + last4 VARCHAR(4) NOT NULL, + exp_month INTEGER NOT NULL, + exp_year INTEGER NOT NULL, + token VARCHAR(64) NOT NULL, + preferred SMALLINT NOT NULL, + created_at BIGINT NOT NULL +); +CREATE INDEX payment_method_user ON payment_method (username, created_at); + +-- An attempt to add a card, between its start and its completion. +CREATE TABLE payment_setup ( + id VARCHAR(32) NOT NULL PRIMARY KEY, + username VARCHAR(190) NOT NULL, + reference VARCHAR(255) NOT NULL, + created_at BIGINT NOT NULL, + completed SMALLINT NOT NULL +); + +-- Where a courier's payouts go, and the payouts made. +CREATE TABLE payout_account ( + username VARCHAR(190) NOT NULL PRIMARY KEY, + holder VARCHAR(120) NOT NULL, + bank_name VARCHAR(80) NOT NULL, + last4 VARCHAR(4) NOT NULL +); + +CREATE TABLE payout ( + id VARCHAR(32) NOT NULL PRIMARY KEY, + courier VARCHAR(190) NOT NULL, + amount_cents BIGINT NOT NULL, + status VARCHAR(16) NOT NULL, + created_at BIGINT NOT NULL, + destination VARCHAR(120) NOT NULL +); +CREATE INDEX payout_courier ON payout (courier, created_at); + +-- What deliveries cost, once an admin has changed it: a single row, id 1. Until +-- there is one the prices are the fare settings of the server. +CREATE TABLE pricing ( + id INTEGER NOT NULL PRIMARY KEY, + -- The delivery fee: a base, and an amount per kilometre from the + -- restaurant to the door, times `surge` while couriers are scarce. + base_cents BIGINT NOT NULL, + per_km_cents BIGINT NOT NULL, + surge DOUBLE PRECISION NOT NULL, + -- An order whose food comes to less than `small_order_cents` pays the + -- difference, up to `small_order_fee_cents`. + small_order_cents BIGINT NOT NULL, + small_order_fee_cents BIGINT NOT NULL, + -- The customer's service fee and the tax, as percentages of the food. + fee_percent DOUBLE PRECISION NOT NULL, + tax_percent DOUBLE PRECISION NOT NULL, + -- What the service keeps of the food; the restaurant is paid the rest. + commission_percent DOUBLE PRECISION NOT NULL, + updated_at BIGINT NOT NULL, + updated_by VARCHAR(190) NOT NULL +); diff --git a/scripts/savor/backend/src/main/resources/db/migration/V6__pricing_row.sql b/scripts/savor/backend/src/main/resources/db/migration/V6__pricing_row.sql new file mode 100644 index 00000000000..0670a6e8691 --- /dev/null +++ b/scripts/savor/backend/src/main/resources/db/migration/V6__pricing_row.sql @@ -0,0 +1,8 @@ +-- The one row of prices, there from the start, so that saving prices only +-- ever updates it. Created on the first save instead, two admins saving +-- together before anyone had would both insert it, and one of them fail. +-- An updated_at of 0 says nobody has set prices yet: the settings' are the +-- ones in force until somebody does. +INSERT INTO pricing (id, base_cents, per_km_cents, surge, small_order_cents, + small_order_fee_cents, fee_percent, tax_percent, commission_percent, updated_at, updated_by) +VALUES (1, 0, 0, 1, 0, 0, 0, 0, 0, 0, ''); diff --git a/scripts/savor/backend/src/main/resources/db/migration/V7__push.sql b/scripts/savor/backend/src/main/resources/db/migration/V7__push.sql new file mode 100644 index 00000000000..bfdb014600d --- /dev/null +++ b/scripts/savor/backend/src/main/resources/db/migration/V7__push.sql @@ -0,0 +1,31 @@ +-- The devices each account is sent push notifications on. Keyed by the hash of +-- the provider and the token, so one phone is on one account at a time: the +-- one that signed in on it last. +CREATE TABLE push_device ( + id VARCHAR(64) NOT NULL PRIMARY KEY, + username VARCHAR(190) NOT NULL, + provider VARCHAR(16) NOT NULL, + token VARCHAR(1024) NOT NULL, + platform VARCHAR(32) NOT NULL, + updated_at BIGINT NOT NULL +); + +CREATE INDEX push_device_username ON push_device (username); + +-- The Live Activity a customer's phone shows a delivery in, while the delivery lasts. +-- sent_at is when the server last sent it a state, which spaces the updates +-- out: the car reports where it is every few seconds, and the lock screen +-- needs to hear about it far less often than that. ended_at is when the delivery +-- ended, 0 while it lasts: the row is kept until the phone has been told, so an +-- end the push provider did not take is sent again. +CREATE TABLE delivery_activity ( + delivery_id VARCHAR(32) NOT NULL PRIMARY KEY, + username VARCHAR(190) NOT NULL, + activity_id VARCHAR(190) NOT NULL, + sent_at BIGINT NOT NULL, + ended_at BIGINT NOT NULL DEFAULT 0 +); + +CREATE INDEX delivery_activity_ended_at ON delivery_activity (ended_at); + +CREATE INDEX delivery_activity_username ON delivery_activity (username); diff --git a/scripts/savor/backend/src/main/resources/db/migration/V8__restaurants.sql b/scripts/savor/backend/src/main/resources/db/migration/V8__restaurants.sql new file mode 100644 index 00000000000..809577e5458 --- /dev/null +++ b/scripts/savor/backend/src/main/resources/db/migration/V8__restaurants.sql @@ -0,0 +1,179 @@ +-- A restaurant on the service. It is listed once an admin approves it, and +-- takes orders while it is open and its staff have not paused it. Every amount +-- is cents in the service's currency. +CREATE TABLE restaurant ( + id VARCHAR(32) NOT NULL PRIMARY KEY, + name VARCHAR(120) NOT NULL, + cuisine VARCHAR(32) NOT NULL, + description VARCHAR(500) NOT NULL, + address VARCHAR(255) NOT NULL, + lat DOUBLE PRECISION NOT NULL, + lng DOUBLE PRECISION NOT NULL, + -- pending, approved, rejected or suspended. + status VARCHAR(16) NOT NULL, + -- Paused by its staff: listed, and not taking orders. + paused SMALLINT NOT NULL, + -- The hours it takes orders, as minutes after local midnight. + opens_at INTEGER NOT NULL, + closes_at INTEGER NOT NULL, + prep_minutes INTEGER NOT NULL, + minimum_cents BIGINT NOT NULL, + -- One to four: how the menu's prices compare. + price_level SMALLINT NOT NULL, + rating_total BIGINT NOT NULL, + rating_count BIGINT NOT NULL, + photo_id VARCHAR(32) NOT NULL, + applicant VARCHAR(190) NOT NULL, + reason VARCHAR(255) NOT NULL, + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL +); +CREATE INDEX restaurant_status ON restaurant (status, lat, lng); +CREATE INDEX restaurant_place ON restaurant (lat, lng); + +-- Who works for a restaurant: takes its orders and keeps its menu. +CREATE TABLE restaurant_staff ( + restaurant_id VARCHAR(32) NOT NULL, + username VARCHAR(190) NOT NULL, + PRIMARY KEY (restaurant_id, username) +); +CREATE INDEX restaurant_staff_username ON restaurant_staff (username); + +-- The menu: sections in the order the restaurant lists them, dishes in each, +-- and the extras a dish can be ordered with. +CREATE TABLE menu_section ( + id VARCHAR(32) NOT NULL PRIMARY KEY, + restaurant_id VARCHAR(32) NOT NULL, + name VARCHAR(80) NOT NULL, + position INTEGER NOT NULL +); +CREATE INDEX menu_section_restaurant ON menu_section (restaurant_id, position); + +CREATE TABLE dish ( + id VARCHAR(32) NOT NULL PRIMARY KEY, + restaurant_id VARCHAR(32) NOT NULL, + section_id VARCHAR(32) NOT NULL, + name VARCHAR(120) NOT NULL, + description VARCHAR(500) NOT NULL, + price_cents BIGINT NOT NULL, + photo_id VARCHAR(32) NOT NULL, + -- Sold out for now: listed, and not orderable. + available SMALLINT NOT NULL, + popular SMALLINT NOT NULL, + position INTEGER NOT NULL +); +CREATE INDEX dish_restaurant ON dish (restaurant_id, section_id, position); + +CREATE TABLE dish_option ( + id VARCHAR(32) NOT NULL PRIMARY KEY, + dish_id VARCHAR(32) NOT NULL, + name VARCHAR(80) NOT NULL, + price_cents BIGINT NOT NULL, + position INTEGER NOT NULL +); +CREATE INDEX dish_option_dish ON dish_option (dish_id, position); + +-- An order. Its lines, prices and address are copied in when it is placed, so a +-- later change of menu or of prices does not rewrite what was bought. Its +-- delivery, once the restaurant accepts it, has the same id. +CREATE TABLE customer_order ( + id VARCHAR(32) NOT NULL PRIMARY KEY, + customer VARCHAR(190) NOT NULL, + restaurant_id VARCHAR(32) NOT NULL, + restaurant_name VARCHAR(120) NOT NULL, + state VARCHAR(24) NOT NULL, + subtotal_cents BIGINT NOT NULL, + delivery_fee_cents BIGINT NOT NULL, + service_fee_cents BIGINT NOT NULL, + small_order_cents BIGINT NOT NULL, + tax_cents BIGINT NOT NULL, + discount_cents BIGINT NOT NULL, + tip_cents BIGINT NOT NULL, + total_cents BIGINT NOT NULL, + commission_cents BIGINT NOT NULL, + currency VARCHAR(3) NOT NULL, + promo_code VARCHAR(32) NOT NULL, + dropoff_lat DOUBLE PRECISION NOT NULL, + dropoff_lng DOUBLE PRECISION NOT NULL, + dropoff_address VARCHAR(255) NOT NULL, + note VARCHAR(255) NOT NULL, + method_id VARCHAR(32) NOT NULL, + method_label VARCHAR(64) NOT NULL, + -- The processor's hold on the card, taken when the order is placed and + -- captured when it is delivered. Empty for cash. + authorization_ref VARCHAR(64) NOT NULL, + -- AUTHORIZED, then PAID, RELEASED, FAILED or REFUNDED. + payment_status VARCHAR(16) NOT NULL, + prep_minutes INTEGER NOT NULL, + placed_at BIGINT NOT NULL, + accepted_at BIGINT NOT NULL, + ready_at BIGINT NOT NULL, + delivered_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL, + reason VARCHAR(255) NOT NULL, + stars SMALLINT NOT NULL +); +CREATE INDEX customer_order_customer ON customer_order (customer, placed_at); +CREATE INDEX customer_order_restaurant ON customer_order (restaurant_id, state, placed_at); +CREATE INDEX customer_order_state ON customer_order (state, updated_at); + +CREATE TABLE order_line ( + id VARCHAR(32) NOT NULL PRIMARY KEY, + order_id VARCHAR(32) NOT NULL, + dish_id VARCHAR(32) NOT NULL, + name VARCHAR(120) NOT NULL, + options VARCHAR(500) NOT NULL, + quantity INTEGER NOT NULL, + unit_cents BIGINT NOT NULL, + line_cents BIGINT NOT NULL, + position INTEGER NOT NULL +); +CREATE INDEX order_line_order ON order_line (order_id, position); + +-- Every change of an order's state, for the admin's view of what happened. +CREATE TABLE order_event ( + id VARCHAR(32) NOT NULL PRIMARY KEY, + order_id VARCHAR(32) NOT NULL, + state VARCHAR(24) NOT NULL, + actor VARCHAR(190) NOT NULL, + created_at BIGINT NOT NULL +); +CREATE INDEX order_event_order ON order_event (order_id, created_at); + +CREATE TABLE favorite ( + username VARCHAR(190) NOT NULL, + restaurant_id VARCHAR(32) NOT NULL, + created_at BIGINT NOT NULL, + PRIMARY KEY (username, restaurant_id) +); + +-- A code a customer types at checkout: a percentage or an amount off the food, +-- for an order of at least `minimum_cents`, once per account. +CREATE TABLE promo ( + code VARCHAR(32) NOT NULL PRIMARY KEY, + percent_off INTEGER NOT NULL, + amount_off_cents BIGINT NOT NULL, + minimum_cents BIGINT NOT NULL, + active SMALLINT NOT NULL, + created_at BIGINT NOT NULL +); + +CREATE TABLE promo_use ( + code VARCHAR(32) NOT NULL, + username VARCHAR(190) NOT NULL, + order_id VARCHAR(32) NOT NULL, + PRIMARY KEY (code, username) +); + +-- A customer's saved places to be delivered to. +CREATE TABLE saved_address ( + id VARCHAR(32) NOT NULL PRIMARY KEY, + username VARCHAR(190) NOT NULL, + label VARCHAR(40) NOT NULL, + address VARCHAR(255) NOT NULL, + lat DOUBLE PRECISION NOT NULL, + lng DOUBLE PRECISION NOT NULL, + note VARCHAR(255) NOT NULL, + created_at BIGINT NOT NULL +); +CREATE INDEX saved_address_username ON saved_address (username, created_at); diff --git a/scripts/savor/backend/src/main/resources/db/migration/mysql/V3__courier_documents.sql b/scripts/savor/backend/src/main/resources/db/migration/mysql/V3__courier_documents.sql new file mode 100644 index 00000000000..b529ebf961e --- /dev/null +++ b/scripts/savor/backend/src/main/resources/db/migration/mysql/V3__courier_documents.sql @@ -0,0 +1,19 @@ +-- The pictures a onboarding application is backed by -- an identity document, a +-- licence, a selfie, the car's registration and its insurance -- one of each +-- kind for an account, as base64 text. +-- +-- In the database and not on a disk beside the server, so every instance of +-- the server reads the same documents and a backup of the database is a backup +-- of them. The server caps what it accepts well below what this column holds. +-- +-- This is the one script written per engine: a document outgrows the 64 KB of +-- MySQL's TEXT, and LONGTEXT is a type the other two do not have. +CREATE TABLE courier_document ( + username VARCHAR(190) NOT NULL, + kind VARCHAR(24) NOT NULL, + content_type VARCHAR(32) NOT NULL, + status VARCHAR(16) NOT NULL, + uploaded_at BIGINT NOT NULL, + data LONGTEXT NOT NULL, + PRIMARY KEY (username, kind) +); diff --git a/scripts/savor/backend/src/main/resources/db/migration/mysql/V9__photos.sql b/scripts/savor/backend/src/main/resources/db/migration/mysql/V9__photos.sql new file mode 100644 index 00000000000..302f1ea31e0 --- /dev/null +++ b/scripts/savor/backend/src/main/resources/db/migration/mysql/V9__photos.sql @@ -0,0 +1,12 @@ +-- Restaurant and dish pictures, as the image file's bytes, for the same reason the +-- onboarding documents are kept in the database (see V3): every instance of +-- the server reads the same pictures, and a backup of the database is a backup +-- of them. Written per engine because each names its binary type differently. +CREATE TABLE photo ( + id VARCHAR(32) NOT NULL PRIMARY KEY, + restaurant_id VARCHAR(32) NOT NULL, + content_type VARCHAR(32) NOT NULL, + created_at BIGINT NOT NULL, + data LONGBLOB NOT NULL +); +CREATE INDEX photo_restaurant ON photo (restaurant_id); diff --git a/scripts/savor/backend/src/main/resources/db/migration/postgresql/V3__courier_documents.sql b/scripts/savor/backend/src/main/resources/db/migration/postgresql/V3__courier_documents.sql new file mode 100644 index 00000000000..3539b395dbf --- /dev/null +++ b/scripts/savor/backend/src/main/resources/db/migration/postgresql/V3__courier_documents.sql @@ -0,0 +1,19 @@ +-- The pictures a onboarding application is backed by -- an identity document, a +-- licence, a selfie, the car's registration and its insurance -- one of each +-- kind for an account, as base64 text. +-- +-- In the database and not on a disk beside the server, so every instance of +-- the server reads the same documents and a backup of the database is a backup +-- of them. The server caps what it accepts well below what this column holds. +-- +-- This is the one script written per engine: a document outgrows the 64 KB of +-- MySQL's TEXT, and LONGTEXT is a type the other two do not have. +CREATE TABLE courier_document ( + username VARCHAR(190) NOT NULL, + kind VARCHAR(24) NOT NULL, + content_type VARCHAR(32) NOT NULL, + status VARCHAR(16) NOT NULL, + uploaded_at BIGINT NOT NULL, + data TEXT NOT NULL, + PRIMARY KEY (username, kind) +); diff --git a/scripts/savor/backend/src/main/resources/db/migration/postgresql/V9__photos.sql b/scripts/savor/backend/src/main/resources/db/migration/postgresql/V9__photos.sql new file mode 100644 index 00000000000..2020d3c9f95 --- /dev/null +++ b/scripts/savor/backend/src/main/resources/db/migration/postgresql/V9__photos.sql @@ -0,0 +1,12 @@ +-- Restaurant and dish pictures, as the image file's bytes, for the same reason the +-- onboarding documents are kept in the database (see V3): every instance of +-- the server reads the same pictures, and a backup of the database is a backup +-- of them. Written per engine because each names its binary type differently. +CREATE TABLE photo ( + id VARCHAR(32) NOT NULL PRIMARY KEY, + restaurant_id VARCHAR(32) NOT NULL, + content_type VARCHAR(32) NOT NULL, + created_at BIGINT NOT NULL, + data BYTEA NOT NULL +); +CREATE INDEX photo_restaurant ON photo (restaurant_id); diff --git a/scripts/savor/backend/src/main/resources/db/migration/sqlite/V3__courier_documents.sql b/scripts/savor/backend/src/main/resources/db/migration/sqlite/V3__courier_documents.sql new file mode 100644 index 00000000000..3539b395dbf --- /dev/null +++ b/scripts/savor/backend/src/main/resources/db/migration/sqlite/V3__courier_documents.sql @@ -0,0 +1,19 @@ +-- The pictures a onboarding application is backed by -- an identity document, a +-- licence, a selfie, the car's registration and its insurance -- one of each +-- kind for an account, as base64 text. +-- +-- In the database and not on a disk beside the server, so every instance of +-- the server reads the same documents and a backup of the database is a backup +-- of them. The server caps what it accepts well below what this column holds. +-- +-- This is the one script written per engine: a document outgrows the 64 KB of +-- MySQL's TEXT, and LONGTEXT is a type the other two do not have. +CREATE TABLE courier_document ( + username VARCHAR(190) NOT NULL, + kind VARCHAR(24) NOT NULL, + content_type VARCHAR(32) NOT NULL, + status VARCHAR(16) NOT NULL, + uploaded_at BIGINT NOT NULL, + data TEXT NOT NULL, + PRIMARY KEY (username, kind) +); diff --git a/scripts/savor/backend/src/main/resources/db/migration/sqlite/V9__photos.sql b/scripts/savor/backend/src/main/resources/db/migration/sqlite/V9__photos.sql new file mode 100644 index 00000000000..5efa3a93624 --- /dev/null +++ b/scripts/savor/backend/src/main/resources/db/migration/sqlite/V9__photos.sql @@ -0,0 +1,12 @@ +-- Restaurant and dish pictures, as the image file's bytes, for the same reason the +-- onboarding documents are kept in the database (see V3): every instance of +-- the server reads the same pictures, and a backup of the database is a backup +-- of them. Written per engine because each names its binary type differently. +CREATE TABLE photo ( + id VARCHAR(32) NOT NULL PRIMARY KEY, + restaurant_id VARCHAR(32) NOT NULL, + content_type VARCHAR(32) NOT NULL, + created_at BIGINT NOT NULL, + data BLOB NOT NULL +); +CREATE INDEX photo_restaurant ON photo (restaurant_id); diff --git a/scripts/savor/backend/src/test/java/com/codenameone/examples/savor/AccessMatrixTest.java b/scripts/savor/backend/src/test/java/com/codenameone/examples/savor/AccessMatrixTest.java new file mode 100644 index 00000000000..b397aba500f --- /dev/null +++ b/scripts/savor/backend/src/test/java/com/codenameone/examples/savor/AccessMatrixTest.java @@ -0,0 +1,173 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor; + +import com.codename1.backend.annotations.Autowired; +import com.codename1.backend.test.BackendTest; +import com.codename1.backend.test.MockMvc; +import com.codename1.backend.test.MockRequestBuilder; +import com.codenameone.examples.savor.account.DemoAccounts; +import org.junit.jupiter.api.Test; + +import static com.codename1.backend.test.MockMvcRequestBuilders.get; +import static com.codename1.backend.test.MockMvcRequestBuilders.post; +import static com.codename1.backend.test.MockMvcRequestBuilders.put; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/// Who may call each of the paths for payments, preferences, onboarding +/// applications, earnings and administration -- every one of them, with tokens +/// earned by signing in. +/// +/// A list and not a sample, because the rule for a path is written once, far +/// from the method it guards, and a path left out of it is open to everybody. +@BackendTest +class AccessMatrixTest { + /// What any signed-in user may call. `GET` first: those are also asked for + /// with a customer's token, and reading changes nothing. + private static final String[] SIGNED_IN = { + "GET /api/account/preferences", + "GET /api/account/profile", + "GET /api/payments/config", + "GET /api/payments/methods", + "GET /api/payments/receipts", + "GET /api/payments/receipts/no-such-delivery", + "GET /api/deliveries/nearby?lat=37.78&lng=-122.41", + "GET /api/onboarding/application", + "GET /api/onboarding/application/documents/licence", + "PUT /api/account/preferences", + "PUT /api/account/profile", + "POST /api/account/password", + "POST /api/account/delete", + "POST /api/account/push", + "POST /api/account/push/remove", + "POST /api/deliveries/no-such-delivery/activity", + "POST /api/payments/methods/setup", + "POST /api/payments/methods/setup/none/complete", + "POST /api/payments/methods/none/default", + "POST /api/payments/methods/none/remove", + "POST /api/orders/no-such-order/tip", + "PUT /api/onboarding/application", + "POST /api/onboarding/application/documents", + "POST /api/onboarding/application/submit", + }; + private static final String[] COURIERS = { + "GET /api/courier/earnings/summary", + "GET /api/courier/payouts", + "GET /api/courier/payout-account", + "POST /api/courier/payouts", + "PUT /api/courier/payout-account", + }; + private static final String[] ADMINS = { + "GET /api/admin/stats/series?days=7", + "GET /api/admin/users/customer@savor.example", + "GET /api/admin/applications", + "GET /api/admin/applications/applicant@savor.example", + "GET /api/admin/applications/applicant@savor.example/documents/licence", + "GET /api/admin/payments", + "GET /api/admin/pricing", + "POST /api/admin/users/customer@savor.example/flag", + "POST /api/admin/applications/applicant@savor.example/approve", + "POST /api/admin/applications/applicant@savor.example/reject", + "POST /api/admin/deliveries/no-such-delivery/cancel", + "POST /api/admin/deliveries/no-such-delivery/refund", + "PUT /api/admin/pricing", + }; + + @Autowired + private MockMvc mvc; + + private static MockRequestBuilder request(String call) { + int space = call.indexOf(' '); + String method = call.substring(0, space); + String path = call.substring(space + 1); + MockRequestBuilder request = "GET".equals(method) ? get(path) + : "PUT".equals(method) ? put(path) : post(path); + return "GET".equals(method) ? request + : request.contentType("application/json").content("{}"); + } + + private int answer(String call, String token) throws Exception { + MockRequestBuilder request = request(call).header("Host", SignIn.HOST); + if (token != null) { + request = request.header("Authorization", "Bearer " + token); + } + return Trips.statusOf(mvc, request); + } + + private void refused(String[] calls, String token, int status) throws Exception { + for (int iter = 0; iter < calls.length; iter++) { + assertEquals(status, answer(calls[iter], token), calls[iter]); + } + } + + /// The readers among `calls` get past the door: whatever they answer, it + /// is not a refusal to say who may ask. + private void admitted(String[] calls, String token) throws Exception { + for (int iter = 0; iter < calls.length; iter++) { + if (calls[iter].startsWith("GET ")) { + int status = answer(calls[iter], token); + assertTrue(status != 401 && status != 403, calls[iter] + " answered " + status); + } + } + } + + @Test + void nothingIsAnsweredWithoutSigningIn() throws Exception { + refused(SIGNED_IN, null, 401); + refused(COURIERS, null, 401); + refused(ADMINS, null, 401); + refused(SIGNED_IN, "not-a-token", 401); + } + + @Test + void aCustomerIsKeptOutOfOnboardingAndAdministration() throws Exception { + String customer = SignIn.token(mvc, DemoAccounts.CUSTOMER, DemoAccounts.PASSWORD); + refused(COURIERS, customer, 403); + refused(ADMINS, customer, 403); + admitted(SIGNED_IN, customer); + // An application is the caller's own: there is no path to another's. + assertEquals(404, answer("GET /api/onboarding/application/documents/licence", customer)); + } + + @Test + void aCourierIsKeptOutOfAdministration() throws Exception { + String courier = SignIn.token(mvc, DemoAccounts.COURIER, DemoAccounts.PASSWORD); + refused(ADMINS, courier, 403); + admitted(COURIERS, courier); + admitted(SIGNED_IN, courier); + } + + @Test + void anAdminReachesAdministrationAndDoesNotDrive() throws Exception { + String admin = SignIn.token(mvc, DemoAccounts.ADMIN, DemoAccounts.PASSWORD); + admitted(ADMINS, admin); + admitted(SIGNED_IN, admin); + refused(COURIERS, admin, 403); + // The applicant's papers are an admin's to read, and nobody else's. + assertEquals(200, answer("GET /api/admin/applications/applicant@savor.example" + + "/documents/licence", admin)); + String applicant = SignIn.token(mvc, DemoAccounts.APPLICANT, DemoAccounts.PASSWORD); + assertEquals(200, answer("GET /api/onboarding/application/documents/licence", applicant)); + assertEquals(403, answer("GET /api/admin/applications/applicant@savor.example" + + "/documents/licence", applicant)); + // Not approved, so not a courier. + refused(COURIERS, applicant, 403); + } +} diff --git a/scripts/savor/backend/src/test/java/com/codenameone/examples/savor/AccessTest.java b/scripts/savor/backend/src/test/java/com/codenameone/examples/savor/AccessTest.java new file mode 100644 index 00000000000..3e0283124bf --- /dev/null +++ b/scripts/savor/backend/src/test/java/com/codenameone/examples/savor/AccessTest.java @@ -0,0 +1,150 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor; + +import com.codename1.backend.annotations.Autowired; +import com.codename1.backend.test.BackendTest; +import com.codename1.backend.test.MockMvc; +import com.codename1.backend.test.MockRequestBuilder; +import com.codenameone.examples.savor.account.Accounts; +import com.codenameone.examples.savor.account.DemoAccounts; +import com.codenameone.examples.savor.live.LiveTickets; +import org.junit.jupiter.api.Test; + +import static com.codename1.backend.test.MockMvcRequestBuilders.get; +import static com.codename1.backend.test.MockMvcRequestBuilders.post; +import static com.codename1.backend.test.MockMvcResultMatchers.jsonPath; +import static com.codename1.backend.test.MockMvcResultMatchers.status; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +/// Who may call what, with tokens earned by signing in: these go through the +/// decoder and the role lookup the app's own requests go through. +@BackendTest +class AccessTest { + @Autowired + private MockMvc mvc; + @Autowired + private Accounts accounts; + @Autowired + private LiveTickets tickets; + + private MockRequestBuilder as(MockRequestBuilder request, String token) { + return request.header("Host", SignIn.HOST).header("Authorization", "Bearer " + token); + } + + @Test + void eachRoleReachesItsOwnPartOfTheApi() throws Exception { + String customer = SignIn.token(mvc, DemoAccounts.CUSTOMER, DemoAccounts.PASSWORD); + String courier = SignIn.token(mvc, DemoAccounts.COURIER, DemoAccounts.PASSWORD); + String admin = SignIn.token(mvc, DemoAccounts.ADMIN, DemoAccounts.PASSWORD); + + mvc.perform(as(get("/api/me"), customer)).andExpect(status().isOk()) + .andExpect(jsonPath("$.courier").value(false)); + mvc.perform(as(get("/api/courier/me"), customer)).andExpect(status().isForbidden()); + mvc.perform(as(get("/api/admin/stats"), customer)).andExpect(status().isForbidden()); + + mvc.perform(as(get("/api/courier/me"), courier)).andExpect(status().isOk()) + .andExpect(jsonPath("$.vehicle").value("Blue Vespa Primavera")); + mvc.perform(as(get("/api/admin/users"), courier)).andExpect(status().isForbidden()); + // A courier is a customer too. + mvc.perform(as(get("/api/orders/active"), courier)).andExpect(status().isOk()); + + mvc.perform(as(get("/api/admin/stats"), admin)).andExpect(status().isOk()) + .andExpect(jsonPath("$.currency").value("USD")); + mvc.perform(as(get("/api/admin/users"), admin)).andExpect(status().isOk()); + mvc.perform(as(get("/api/admin/couriers"), admin)).andExpect(status().isOk()); + mvc.perform(as(get("/api/admin/deliveries"), admin)).andExpect(status().isOk()); + // An admin who does not deliver is not a courier. + mvc.perform(as(get("/api/courier/me"), admin)).andExpect(status().isForbidden()); + } + + @Test + void aRoleGrantedOrAnAccountSuspendedTakesEffectOnTheTokenInHand() throws Exception { + String username = People.customer(accounts, "promoted"); + String token = SignIn.token(mvc, username, People.PASSWORD); + String admin = SignIn.token(mvc, DemoAccounts.ADMIN, DemoAccounts.PASSWORD); + mvc.perform(as(get("/api/courier/me"), token)).andExpect(status().isForbidden()); + + mvc.perform(as(post("/api/admin/users/" + username + "/roles"), admin) + .contentType("application/json").content("{\"courier\":true,\"admin\":false}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.courier").value(true)); + // The same token: the role is read from the account, not from it. + mvc.perform(as(get("/api/courier/me"), token)).andExpect(status().isOk()); + + mvc.perform(as(post("/api/admin/users/" + username + "/suspend"), admin) + .contentType("application/json").content("{\"suspended\":true}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.suspended").value(true)); + mvc.perform(as(get("/api/me"), token)).andExpect(status().isUnauthorized()); + assertNull(SignIn.session(mvc, username, People.PASSWORD)); + + mvc.perform(as(post("/api/admin/users/" + username + "/suspend"), admin) + .contentType("application/json").content("{\"suspended\":false}")) + .andExpect(status().isOk()); + mvc.perform(as(get("/api/me"), token)).andExpect(status().isOk()); + } + + @Test + void anAdminCannotLockThemselvesOut() throws Exception { + String admin = SignIn.token(mvc, DemoAccounts.ADMIN, DemoAccounts.PASSWORD); + mvc.perform(as(post("/api/admin/users/" + DemoAccounts.ADMIN + "/roles"), admin) + .contentType("application/json").content("{\"courier\":false,\"admin\":false}")) + .andExpect(status().isBadRequest()); + mvc.perform(as(post("/api/admin/users/" + DemoAccounts.ADMIN + "/suspend"), admin) + .contentType("application/json").content("{\"suspended\":true}")) + .andExpect(status().isBadRequest()); + mvc.perform(as(post("/api/admin/users/nobody@test.example/suspend"), admin) + .contentType("application/json").content("{\"suspended\":true}")) + .andExpect(status().isNotFound()); + } + + @Test + void aLiveTicketIsGoodOnce() throws Exception { + String customer = SignIn.token(mvc, DemoAccounts.CUSTOMER, DemoAccounts.PASSWORD); + mvc.perform(post("/api/live/ticket")).andExpect(status().isUnauthorized()); + String body = mvc.perform(as(post("/api/live/ticket"), customer)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.expiresInSeconds").value(30)) + .andReturn().getResponse().getContentAsString(); + String ticket = (String) com.codename1.backend.Json.parseObject(body).get("ticket"); + assertEquals(DemoAccounts.CUSTOMER, tickets.redeem(ticket)); + assertNull(tickets.redeem(ticket)); + assertNull(tickets.redeem("not-a-ticket")); + } + + @Test + void placesAreFoundThroughTheServer() throws Exception { + String customer = SignIn.token(mvc, DemoAccounts.CUSTOMER, DemoAccounts.PASSWORD); + mvc.perform(get("/api/geo/search?q=pier&lat=37.8&lng=-122.4")) + .andExpect(status().isUnauthorized()); + mvc.perform(as(get("/api/geo/search?q=PIER&lat=37.8&lng=-122.4"), customer)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].name").value("Pier 39")); + // Too little to search for. + mvc.perform(as(get("/api/geo/search?q=pi&lat=37.8&lng=-122.4"), customer)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$").isEmpty()); + mvc.perform(as(get("/api/geo/reverse?lat=37.7956&lng=-122.3936"), customer)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.name").value("Ferry Building")) + .andExpect(jsonPath("$.lat").value(37.7956)); + } +} diff --git a/scripts/savor/backend/src/test/java/com/codenameone/examples/savor/AccountTest.java b/scripts/savor/backend/src/test/java/com/codenameone/examples/savor/AccountTest.java new file mode 100644 index 00000000000..55e93ca048e --- /dev/null +++ b/scripts/savor/backend/src/test/java/com/codenameone/examples/savor/AccountTest.java @@ -0,0 +1,245 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor; + +import com.codename1.backend.DataSource; +import com.codename1.backend.Json; +import com.codename1.backend.annotations.Autowired; +import com.codename1.backend.test.BackendTest; +import com.codename1.backend.test.MockMvc; +import com.codenameone.examples.savor.account.DemoAccounts; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static com.codename1.backend.test.MockMvcRequestBuilders.get; +import static com.codename1.backend.test.MockMvcRequestBuilders.post; +import static com.codename1.backend.test.MockMvcResultMatchers.jsonPath; +import static com.codename1.backend.test.MockMvcResultMatchers.status; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +/// Opening an account, signing in to it and proving its phone number, through +/// the same requests the app makes. +@BackendTest +class AccountTest { + @Autowired + private MockMvc mvc; + @Autowired + private DataSource db; + + private static String registration(String email, String password, boolean courier) { + return "{\"email\":\"" + email + "\",\"password\":\"" + password + + "\",\"displayName\":\"Test Person\",\"phone\":\"+1 (555) 010-9999\",\"wantsToDeliver\":" + + courier + ",\"vehicle\":\"Grey Vespa\",\"plate\":\"TST 1\"}"; + } + + private String bearer(String username, String password) throws Exception { + return "Bearer " + SignIn.token(mvc, username, password); + } + + @Test + void anAccountIsOpenedThenSignedInTo() throws Exception { + mvc.perform(post("/api/account/register").contentType("application/json") + .content(registration("New.Customer@Example.com", "a-long-password", false))) + .andExpect(status().isOk()) + // The name is the address in lower case, and the phone number is + // stored in one form however it was typed. + .andExpect(jsonPath("$.username").value("new.customer@example.com")) + .andExpect(jsonPath("$.phone").value("+15550109999")) + .andExpect(jsonPath("$.customer").value(true)) + .andExpect(jsonPath("$.courier").value(false)) + .andExpect(jsonPath("$.admin").value(false)) + .andExpect(jsonPath("$.phoneVerified").value(false)); + + // Signing in is not case sensitive about the address either. + mvc.perform(get("/api/me").header("Host", SignIn.HOST).header("Authorization", + bearer("NEW.CUSTOMER@example.com", "a-long-password"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.username").value("new.customer@example.com")) + .andExpect(jsonPath("$.displayName").value("Test Person")); + } + + @Test + void anAccountThatCannotBeOpenedSaysWhy() throws Exception { + mvc.perform(post("/api/account/register").contentType("application/json") + .content(registration("short@example.com", "short", false))) + .andExpect(status().isBadRequest()); + mvc.perform(post("/api/account/register").contentType("application/json") + .content(registration("not-an-address", "a-long-password", false))) + .andExpect(status().isBadRequest()); + mvc.perform(post("/api/account/register").contentType("application/json") + .content(registration(DemoAccounts.CUSTOMER, "a-long-password", false))) + .andExpect(status().isConflict()); + } + + @Test + void aWrongPasswordSignsNobodyIn() throws Exception { + assertNull(SignIn.session(mvc, DemoAccounts.CUSTOMER, "not-the-password")); + assertNotNull(SignIn.session(mvc, DemoAccounts.CUSTOMER, DemoAccounts.PASSWORD)); + } + + @Test + void theApiWantsAToken() throws Exception { + mvc.perform(get("/api/me")).andExpect(status().isUnauthorized()); + mvc.perform(get("/api/me").header("Authorization", "Bearer not-a-token")) + .andExpect(status().isUnauthorized()); + // A session is for signing in. It is not a way into the API. + mvc.perform(get("/api/me").cookie("CN1SESSION", + SignIn.session(mvc, DemoAccounts.CUSTOMER, DemoAccounts.PASSWORD))) + .andExpect(status().isUnauthorized()); + } + + @Test + void aPhoneNumberIsProvedWithTheCodeThatWasSent() throws Exception { + mvc.perform(post("/api/account/register").contentType("application/json") + .content(registration("phone@example.com", "a-long-password", true))) + .andExpect(status().isOk()) + // Asking to deliver opens an application. It does not make a courier. + .andExpect(jsonPath("$.courier").value(false)) + .andExpect(jsonPath("$.courierStatus").value("draft")); + String bearer = bearer("phone@example.com", "a-long-password"); + + String sent = mvc.perform(post("/api/account/phone/start").header("Host", SignIn.HOST) + .header("Authorization", bearer)) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + Map challenge = Json.parseObject(sent); + // A test server sends no text message, so it says what the code was. + String code = (String) challenge.get("demoCode"); + assertNotNull(code); + assertEquals(6, code.length()); + + // A second code straight away is refused: each is a text message. + mvc.perform(post("/api/account/phone/start").header("Host", SignIn.HOST) + .header("Authorization", bearer)) + .andExpect(status().isTooManyRequests()); + + String wrong = "000000".equals(code) ? "000001" : "000000"; + mvc.perform(post("/api/account/phone/verify").header("Host", SignIn.HOST) + .header("Authorization", bearer).contentType("application/json") + .content("{\"code\":\"" + wrong + "\"}")) + .andExpect(status().isBadRequest()); + mvc.perform(post("/api/account/phone/verify").header("Host", SignIn.HOST) + .header("Authorization", bearer).contentType("application/json") + .content("{\"code\":\"" + code + "\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.phoneVerified").value(true)); + // Used up: the same code is not good twice. + mvc.perform(post("/api/account/phone/verify").header("Host", SignIn.HOST) + .header("Authorization", bearer).contentType("application/json") + .content("{\"code\":\"" + code + "\"}")) + .andExpect(status().isBadRequest()); + } + + /// A second code is asked for when the first is slow to arrive, and the + /// first may then arrive last. Both are good until the first one would + /// have expired, and using either uses up both. + @Test + void aCodeStaysGoodWhenAnotherIsSentAfterIt() throws Exception { + mvc.perform(post("/api/account/register").contentType("application/json") + .content(registration("resend@example.com", "a-long-password", false))) + .andExpect(status().isOk()); + String bearer = bearer("resend@example.com", "a-long-password"); + String first = code(bearer); + // The wait between codes has passed. + db.execute("UPDATE phone_code SET sent_at = sent_at - 60000 WHERE username = ?", + new Object[] {"resend@example.com"}); + String second = code(bearer); + + mvc.perform(post("/api/account/phone/verify").header("Host", SignIn.HOST) + .header("Authorization", bearer).contentType("application/json") + .content("{\"code\":\"" + first + "\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.phoneVerified").value(true)); + mvc.perform(post("/api/account/phone/verify").header("Host", SignIn.HOST) + .header("Authorization", bearer).contentType("application/json") + .content("{\"code\":\"" + second + "\"}")) + .andExpect(status().isBadRequest()); + + // Once the first has expired it is no longer good, though the second is. + String third = code(bearer); + db.execute("UPDATE phone_code SET sent_at = sent_at - 60000 WHERE username = ?", + new Object[] {"resend@example.com"}); + String fourth = code(bearer); + db.execute("UPDATE phone_code SET previous_expires_at = 1 WHERE username = ?", + new Object[] {"resend@example.com"}); + if (!third.equals(fourth)) { + mvc.perform(post("/api/account/phone/verify").header("Host", SignIn.HOST) + .header("Authorization", bearer).contentType("application/json") + .content("{\"code\":\"" + third + "\"}")) + .andExpect(status().isBadRequest()); + } + mvc.perform(post("/api/account/phone/verify").header("Host", SignIn.HOST) + .header("Authorization", bearer).contentType("application/json") + .content("{\"code\":\"" + fourth + "\"}")) + .andExpect(status().isOk()); + } + + /// Asking for another code does not give the one before a fresh set of + /// guesses: it keeps what it had left. + @Test + void theCodeBeforeKeepsTheGuessesItHadLeft() throws Exception { + mvc.perform(post("/api/account/register").contentType("application/json") + .content(registration("budget@example.com", "a-long-password", false))) + .andExpect(status().isOk()); + String bearer = bearer("budget@example.com", "a-long-password"); + String first = code(bearer); + // Four of the five guesses (PhoneVerification.MAX_ATTEMPTS) spent. + for (int iter = 0; iter < 4; iter++) { + verify(bearer, other(first, first)).andExpect(status().isBadRequest()); + } + db.execute("UPDATE phone_code SET sent_at = sent_at - 60000 WHERE username = ?", + new Object[] {"budget@example.com"}); + String second = code(bearer); + // The first code's last guess, spent on a wrong one. + verify(bearer, other(first, second)).andExpect(status().isBadRequest()); + // Now the first is out of guesses, though the second is not. + if (!first.equals(second)) { + verify(bearer, first).andExpect(status().isBadRequest()); + } + verify(bearer, second).andExpect(status().isOk()); + } + + /// A six-digit code that is neither of these. + private static String other(String a, String b) { + for (int iter = 0; ; iter++) { + String candidate = String.valueOf(1000000 + iter).substring(1); + if (!candidate.equals(a) && !candidate.equals(b)) { + return candidate; + } + } + } + + private com.codename1.backend.test.ResultActions verify(String bearer, String code) + throws Exception { + return mvc.perform(post("/api/account/phone/verify").header("Host", SignIn.HOST) + .header("Authorization", bearer).contentType("application/json") + .content("{\"code\":\"" + code + "\"}")); + } + + private String code(String bearer) throws Exception { + String sent = mvc.perform(post("/api/account/phone/start").header("Host", SignIn.HOST) + .header("Authorization", bearer)) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + return (String) Json.parseObject(sent).get("demoCode"); + } +} diff --git a/scripts/savor/backend/src/test/java/com/codenameone/examples/savor/DaysTest.java b/scripts/savor/backend/src/test/java/com/codenameone/examples/savor/DaysTest.java new file mode 100644 index 00000000000..43c4e661c20 --- /dev/null +++ b/scripts/savor/backend/src/test/java/com/codenameone/examples/savor/DaysTest.java @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; + +/// Time on line cut at midnight, which is how a courier's hours are credited. +class DaysTest { + /// 2026-03-01 00:00:00 UTC. + private static final long MIDNIGHT = 20513L * Days.MILLIS; + + @Test + void aStretchAcrossMidnightIsCreditedHalfToEachDay() { + // 23:59:40 to 00:00:20: twenty seconds on each side. Credited whole to + // the day it ended in, the new day had all forty. + long day = Days.number(MIDNIGHT); + assertArrayEquals(new long[] {day - 1, 20000L, day, 20000L}, + Days.split(MIDNIGHT - 20000L, MIDNIGHT + 20000L)); + } + + @Test + void aStretchWithinOneDayIsThatDays() { + long day = Days.number(MIDNIGHT); + assertArrayEquals(new long[] {day, 40000L}, + Days.split(MIDNIGHT + 1000L, MIDNIGHT + 41000L)); + // Ending exactly at midnight is all the old day's. + assertArrayEquals(new long[] {day - 1, 40000L}, + Days.split(MIDNIGHT - 40000L, MIDNIGHT)); + // Starting exactly at it is all the new one's. + assertArrayEquals(new long[] {day, 40000L}, Days.split(MIDNIGHT, MIDNIGHT + 40000L)); + assertArrayEquals(new long[0], Days.split(MIDNIGHT, MIDNIGHT)); + } +} diff --git a/scripts/savor/backend/src/test/java/com/codenameone/examples/savor/DeliveryRaceTest.java b/scripts/savor/backend/src/test/java/com/codenameone/examples/savor/DeliveryRaceTest.java new file mode 100644 index 00000000000..0ae2f2f9dcf --- /dev/null +++ b/scripts/savor/backend/src/test/java/com/codenameone/examples/savor/DeliveryRaceTest.java @@ -0,0 +1,264 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor; + +import com.codename1.backend.DataSource; +import com.codename1.backend.ResponseStatusException; +import com.codename1.backend.annotations.Autowired; +import com.codename1.backend.test.BackendTest; +import com.codename1.backend.test.MockMvc; +import com.codenameone.examples.savor.account.Accounts; +import com.codenameone.examples.savor.account.DemoAccounts; +import com.codenameone.examples.savor.api.DeliveryDto; +import com.codenameone.examples.savor.api.DeliveryState; +import com.codenameone.examples.savor.delivery.Deliveries; +import org.junit.jupiter.api.Test; + +import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; + +import static com.codename1.backend.test.MockMvcRequestBuilders.get; +import static com.codename1.backend.test.MockMvcRequestBuilders.post; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/// A delivery answered from several places at the same moment. +/// +/// One statement decides each change of state, and the number of rows it +/// changed is the answer. These tests are the ones that would notice that +/// becoming a read followed by a write: they send the same answer from many +/// threads at once and count how many were told it was theirs. +/// +/// The threads call the service and not the HTTP layer. What is being tested +/// is the transaction each call opens, and the service is where it opens. +@BackendTest +class DeliveryRaceTest { + private static final int THREADS = 8; + /// The outcomes of one call: the delivery was taken, or the status it was + /// refused with. + private static final int TAKEN = 200; + + @Autowired + private MockMvc mvc; + @Autowired + private Accounts accounts; + @Autowired + private Deliveries deliveries; + @Autowired + private DataSource db; + + /// Runs `calls` at the same moment, each on a thread of its own, and waits + /// for them all. + private static void together(Runnable[] calls) throws Exception { + // A count and a flag, polled: the class library a compiled server + // links against has the atomics and no latch. + final AtomicInteger ready = new AtomicInteger(); + final AtomicBoolean go = new AtomicBoolean(); + Thread[] threads = new Thread[calls.length]; + for (int iter = 0; iter < calls.length; iter++) { + final Runnable call = calls[iter]; + threads[iter] = new Thread(new Runnable() { + @Override + public void run() { + ready.incrementAndGet(); + long giveUp = System.currentTimeMillis() + 30000L; + while (!go.get()) { + if (System.currentTimeMillis() > giveUp) { + return; + } + Thread.yield(); + } + call.run(); + } + }, "race-" + iter); + threads[iter].start(); + } + long giveUp = System.currentTimeMillis() + 30000L; + while (ready.get() < calls.length) { + assertTrue(System.currentTimeMillis() < giveUp, "the threads started"); + Thread.sleep(1L); + } + go.set(true); + for (int iter = 0; iter < threads.length; iter++) { + threads[iter].join(60000L); + assertTrue(!threads[iter].isAlive(), "thread " + iter + " finished"); + } + } + + /// A call of `accept` that writes what came of it into `outcomes[slot]`: + /// [#TAKEN], the status of the refusal, or -1 for anything else. + private Runnable accept(final String courier, final String id, final int[] outcomes, + final Throwable[] unexpected, final int slot) { + return new Runnable() { + @Override + public void run() { + try { + DeliveryDto delivery = deliveries.accept(courier, id); + outcomes[slot] = DeliveryState.ACCEPTED == delivery.state() ? TAKEN : -1; + } catch (ResponseStatusException refused) { + outcomes[slot] = refused.getStatus(); + } catch (Throwable err) { + outcomes[slot] = -1; + unexpected[slot] = err; + } + } + }; + } + + private static int count(int[] outcomes, int wanted) { + int found = 0; + for (int iter = 0; iter < outcomes.length; iter++) { + if (outcomes[iter] == wanted) { + found++; + } + } + return found; + } + + private static void noneUnexpected(Throwable[] unexpected) { + for (int iter = 0; iter < unexpected.length; iter++) { + if (unexpected[iter] != null) { + throw new AssertionError("call " + iter + " failed", unexpected[iter]); + } + } + } + + private long events(String id, String state) throws Exception { + Map row = db.queryOne("SELECT COUNT(*) AS n FROM delivery_event WHERE delivery_id = ? " + + "AND state = ?", new Object[] {id, state}); + return ((Number) row.get("n")).longValue(); + } + + /// The state of the delivery of the customer's order `id`. + private String deliveryState(String customer, String id) throws Exception { + Map order = Trips.json(mvc, People.asCustomer(get("/api/orders/" + id), customer)); + Object delivery = order.get("delivery"); + return delivery instanceof Map ? (String) ((Map) delivery).get("state") : null; + } + + /// A delivery offered to `courier`, who is on line beside the pickup. + private String offered(String customer, String courier, double lat, double lng) throws Exception { + Trips.online(mvc, courier, lat, lng); + String id = Trips.ask(mvc, customer, + Trips.trip(lat + 0.0002, lng + 0.0002, lat + 0.02, lng + 0.02)); + assertEquals("OFFERED", deliveryState(customer, id)); + assertEquals(id, Trips.json(mvc, People.asCourier(get("/api/courier/active"), courier)) + .get("id")); + return id; + } + + @Test + void anOfferAcceptedEightTimesAtOnceIsTakenOnce() throws Exception { + // Warsaw. + String customer = People.customer(accounts, "waw-customer"); + String courier = People.courier(accounts, "waw-courier"); + String id = offered(customer, courier, 52.2297, 21.0122); + + int[] outcomes = new int[THREADS]; + Throwable[] unexpected = new Throwable[THREADS]; + Runnable[] calls = new Runnable[THREADS]; + for (int iter = 0; iter < THREADS; iter++) { + calls[iter] = accept(courier, id, outcomes, unexpected, iter); + } + together(calls); + + noneUnexpected(unexpected); + assertEquals(1, count(outcomes, TAKEN), "one call took the delivery"); + assertEquals(THREADS - 1, count(outcomes, 409), "and every other was told it was taken"); + // What happened is on record once, too. + assertEquals(1L, events(id, "ACCEPTED")); + assertEquals("ACCEPTED", deliveryState(customer, id)); + } + + @Test + void anOfferThatLapsedIsNotTakenWhileItIsBeingTakenBack() throws Exception { + // Athens. + String customer = People.customer(accounts, "ath-customer"); + final String courier = People.courier(accounts, "ath-courier"); + final String id = offered(customer, courier, 37.9838, 23.7275); + + // Time is moved on by hand: the offer lapsed, and nothing has swept it + // up yet. Taking it is refused already. + db.execute("UPDATE delivery SET offer_expires_at = 1 WHERE id = ?", new Object[] {id}); + int[] alone = new int[1]; + Throwable[] none = new Throwable[1]; + accept(courier, id, alone, none, 0).run(); + noneUnexpected(none); + if (alone[0] != 404) { + // 404 is the sweep having run between the two lines above: the + // offer is gone and so, to this courier, is the delivery. + assertEquals(409, alone[0]); + } + + // Now the sweep and the courier's answers at the same moment. + int[] outcomes = new int[THREADS]; + Throwable[] unexpected = new Throwable[THREADS + 1]; + Runnable[] calls = new Runnable[THREADS + 1]; + for (int iter = 0; iter < THREADS; iter++) { + calls[iter] = accept(courier, id, outcomes, unexpected, iter); + } + calls[THREADS] = new Runnable() { + @Override + public void run() { + try { + deliveries.tick(); + } catch (Throwable err) { + unexpected[THREADS] = err; + } + } + }; + together(calls); + + noneUnexpected(unexpected); + assertEquals(0, count(outcomes, TAKEN), "nobody took a lapsed offer"); + // Refused as lapsed before the sweep, and as no longer theirs after it. + assertEquals(THREADS, count(outcomes, 409) + count(outcomes, 404)); + assertEquals(0L, events(id, "ACCEPTED")); + // The only courier in reach has let it go by, so it waits. + assertEquals("REQUESTED", deliveryState(customer, id)); + assertEquals("NONE", Trips.json(mvc, People.asCourier(get("/api/courier/active"), courier)) + .get("state")); + mvc.perform(People.asAdmin(Trips.body(post("/api/admin/orders/" + id + "/cancel"), + "{\"reason\":\"Test\"}"), DemoAccounts.ADMIN)); + } + + @Test + void anOfferTakenBackIsNoLongerTheCouriersToTake() throws Exception { + // Brussels. + String customer = People.customer(accounts, "bru-customer"); + String courier = People.courier(accounts, "bru-courier"); + String id = offered(customer, courier, 50.8503, 4.3517); + + // The offer lapses and the sweep takes it back. The delivery is no longer + // this courier's, and a delivery that is not theirs is answered as no delivery + // at all: 404, as it is for any delivery of somebody else's. + db.execute("UPDATE delivery SET offer_expires_at = 1 WHERE id = ?", new Object[] {id}); + deliveries.tick(); + int[] outcome = new int[1]; + Throwable[] none = new Throwable[1]; + accept(courier, id, outcome, none, 0).run(); + noneUnexpected(none); + assertEquals(404, outcome[0]); + assertEquals(0L, events(id, "ACCEPTED")); + assertEquals("REQUESTED", deliveryState(customer, id)); + mvc.perform(People.asAdmin(Trips.body(post("/api/admin/orders/" + id + "/cancel"), + "{\"reason\":\"Test\"}"), DemoAccounts.ADMIN)); + } +} diff --git a/scripts/savor/backend/src/test/java/com/codenameone/examples/savor/DeliveryStateMachineTest.java b/scripts/savor/backend/src/test/java/com/codenameone/examples/savor/DeliveryStateMachineTest.java new file mode 100644 index 00000000000..2bcbb279036 --- /dev/null +++ b/scripts/savor/backend/src/test/java/com/codenameone/examples/savor/DeliveryStateMachineTest.java @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor; + +import com.codenameone.examples.savor.api.DeliveryState; +import com.codenameone.examples.savor.delivery.DeliveryStateMachine; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class DeliveryStateMachineTest { + private static final DeliveryState[] ALL = {DeliveryState.REQUESTED, DeliveryState.OFFERED, + DeliveryState.ACCEPTED, DeliveryState.ARRIVED, DeliveryState.IN_PROGRESS, DeliveryState.COMPLETED, + DeliveryState.CANCELLED_BY_CUSTOMER, DeliveryState.CANCELLED_BY_COURIER, DeliveryState.NO_COURIERS}; + + @Test + void aDeliveryGoesForwardOneStepAtATime() { + assertTrue(DeliveryStateMachine.allowed(DeliveryState.REQUESTED, DeliveryState.OFFERED)); + assertTrue(DeliveryStateMachine.allowed(DeliveryState.OFFERED, DeliveryState.ACCEPTED)); + assertTrue(DeliveryStateMachine.allowed(DeliveryState.ACCEPTED, DeliveryState.ARRIVED)); + assertTrue(DeliveryStateMachine.allowed(DeliveryState.ARRIVED, DeliveryState.IN_PROGRESS)); + assertTrue(DeliveryStateMachine.allowed(DeliveryState.IN_PROGRESS, DeliveryState.COMPLETED)); + // No skipping: a delivery nobody accepted cannot start, and one that has + // not started cannot be completed. + assertFalse(DeliveryStateMachine.allowed(DeliveryState.REQUESTED, DeliveryState.ACCEPTED)); + assertFalse(DeliveryStateMachine.allowed(DeliveryState.OFFERED, DeliveryState.IN_PROGRESS)); + assertFalse(DeliveryStateMachine.allowed(DeliveryState.ACCEPTED, DeliveryState.COMPLETED)); + } + + @Test + void anOfferNobodyTookGoesBackToWaiting() { + assertTrue(DeliveryStateMachine.allowed(DeliveryState.OFFERED, DeliveryState.REQUESTED)); + assertFalse(DeliveryStateMachine.allowed(DeliveryState.ACCEPTED, DeliveryState.REQUESTED)); + } + + @Test + void cancellingStopsWhenTheTripStarts() { + assertTrue(DeliveryStateMachine.allowed(DeliveryState.REQUESTED, DeliveryState.CANCELLED_BY_CUSTOMER)); + assertTrue(DeliveryStateMachine.allowed(DeliveryState.ARRIVED, DeliveryState.CANCELLED_BY_CUSTOMER)); + assertFalse(DeliveryStateMachine.allowed(DeliveryState.IN_PROGRESS, DeliveryState.CANCELLED_BY_CUSTOMER)); + // A courier can only cancel a delivery they took. + assertFalse(DeliveryStateMachine.allowed(DeliveryState.OFFERED, DeliveryState.CANCELLED_BY_COURIER)); + assertTrue(DeliveryStateMachine.allowed(DeliveryState.ACCEPTED, DeliveryState.CANCELLED_BY_COURIER)); + assertFalse(DeliveryStateMachine.allowed(DeliveryState.IN_PROGRESS, DeliveryState.CANCELLED_BY_COURIER)); + } + + @Test + void nothingLeavesAFinishedState() { + DeliveryState[] finished = {DeliveryState.COMPLETED, DeliveryState.CANCELLED_BY_CUSTOMER, + DeliveryState.CANCELLED_BY_COURIER, DeliveryState.NO_COURIERS}; + for (int from = 0; from < finished.length; from++) { + for (int to = 0; to < ALL.length; to++) { + assertFalse(DeliveryStateMachine.allowed(finished[from], ALL[to]), + finished[from] + " -> " + ALL[to]); + } + } + assertEquals(0, DeliveryStateMachine.sources(DeliveryState.NONE).length); + assertTrue(DeliveryState.ARRIVED.isActive()); + assertFalse(DeliveryState.COMPLETED.isActive()); + } +} diff --git a/scripts/savor/backend/src/test/java/com/codenameone/examples/savor/LiveSocketTest.java b/scripts/savor/backend/src/test/java/com/codenameone/examples/savor/LiveSocketTest.java new file mode 100644 index 00000000000..52c82e6d185 --- /dev/null +++ b/scripts/savor/backend/src/test/java/com/codenameone/examples/savor/LiveSocketTest.java @@ -0,0 +1,240 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor; + +import com.codename1.backend.Crypto; +import com.codename1.backend.Tcp; +import com.codename1.backend.annotations.Autowired; +import com.codename1.backend.test.BackendTest; +import com.codename1.backend.test.LocalServerPort; +import com.codename1.backend.test.MockMvc; +import com.codenameone.examples.savor.account.Accounts; +import com.codenameone.examples.savor.account.DemoAccounts; +import com.codenameone.examples.savor.api.DeliveryState; +import com.codenameone.examples.savor.live.LiveHub; +import com.codenameone.examples.savor.live.LiveTickets; +import org.junit.jupiter.api.Test; + +import java.io.IOException; + +import static com.codename1.backend.test.MockMvcRequestBuilders.post; +import static com.codename1.backend.test.MockMvcResultMatchers.status; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/// The live channel over a real socket: a ticket opens it once, and what the +/// server pushes arrives. +@BackendTest(webEnvironment = BackendTest.WebEnvironment.RANDOM_PORT) +class LiveSocketTest { + private static final int TEXT = 0x1; + private static final int CLOSE = 0x8; + + @LocalServerPort + private int port; + @Autowired + private LiveTickets tickets; + @Autowired + private LiveHub hub; + @Autowired + private MockMvc mvc; + @Autowired + private Accounts accounts; + + /// The last frame read: its opcode, and its payload as text. + private int opcode; + private byte[] payload; + + @Test + void aTicketOpensTheChannelOnceAndPushesArrive() throws Exception { + String ticket = tickets.issue(DemoAccounts.CUSTOMER).ticket(); + Tcp customer = open("/ws/live?ticket=" + ticket); + try { + readFrame(customer); + assertEquals("{\"type\":\"ready\"}", text()); + + writeText(customer, "{\"type\":\"ping\"}"); + readFrame(customer); + assertEquals("{\"type\":\"pong\"}", text()); + + // Something happens to a delivery of theirs. + hub.deliveryChanged("delivery-1", DeliveryState.ACCEPTED, DemoAccounts.CUSTOMER, DemoAccounts.COURIER, ""); + readFrame(customer); + assertEquals("{\"type\":\"delivery\",\"id\":\"delivery-1\",\"state\":\"ACCEPTED\"}", text()); + hub.courierMoved(DemoAccounts.CUSTOMER, "delivery-1", 37.8, -122.4, 90); + readFrame(customer); + assertTrue(text().startsWith("{\"type\":\"courier\",\"deliveryId\":\"delivery-1\",\"lat\":37.8"), + text()); + // And to somebody else's, which is none of their business. + hub.deliveryChanged("delivery-2", DeliveryState.ACCEPTED, "other@test.example", DemoAccounts.COURIER, ""); + hub.deliveryChanged("delivery-3", DeliveryState.COMPLETED, DemoAccounts.CUSTOMER, "", ""); + readFrame(customer); + assertTrue(text().indexOf("delivery-3") > 0, text()); + + // The same ticket again opens nothing. + Tcp again = open("/ws/live?ticket=" + ticket); + try { + readFrame(again); + assertEquals(CLOSE, opcode); + assertEquals(4401, closeCode()); + } finally { + again.close(); + } + + // Suspending the account closes what it has open. + hub.drop(DemoAccounts.CUSTOMER); + readFrame(customer); + assertEquals(CLOSE, opcode); + assertEquals(4403, closeCode()); + } finally { + customer.close(); + } + } + + @Test + void changingAnAccountsRolesClosesTheChannelItHasOpen() throws Exception { + // The channel keeps the roles it was opened with, so one opened as an + // admin would go on hearing the fleet after the role was taken away. + String username = People.customer(accounts, "live-roles"); + Tcp channel = open("/ws/live?ticket=" + tickets.issue(username).ticket()); + try { + readFrame(channel); + assertEquals("{\"type\":\"ready\"}", text()); + + mvc.perform(People.asAdmin(Trips.body(post("/api/admin/users/" + username + "/roles"), + "{\"courier\":true,\"admin\":false}"), DemoAccounts.ADMIN)) + .andExpect(status().isOk()); + readFrame(channel); + assertEquals(CLOSE, opcode); + } finally { + channel.close(); + } + } + + @Test + void noTicketNoChannel() throws Exception { + String[] paths = {"/ws/live", "/ws/live?ticket=", "/ws/live?ticket=0123456789abcdef"}; + for (int iter = 0; iter < paths.length; iter++) { + Tcp stranger = open(paths[iter]); + try { + readFrame(stranger); + assertEquals(CLOSE, opcode, paths[iter]); + assertEquals(4401, closeCode()); + } finally { + stranger.close(); + } + } + } + + @Test + void anAdminHearsAboutEveryDeliveryAndTheFleet() throws Exception { + Tcp admin = open("/ws/live?ticket=" + tickets.issue(DemoAccounts.ADMIN).ticket()); + try { + readFrame(admin); + assertEquals("{\"type\":\"ready\"}", text()); + hub.deliveryChanged("delivery-9", DeliveryState.REQUESTED, "someone@test.example", "", ""); + readFrame(admin); + assertTrue(text().indexOf("delivery-9") > 0, text()); + hub.fleetChanged(DemoAccounts.COURIER, true, 37.8, -122.4, 0); + readFrame(admin); + assertTrue(text().startsWith("{\"type\":\"fleet\",\"username\":\"" + DemoAccounts.COURIER + + "\",\"online\":true"), text()); + } finally { + admin.close(); + } + } + + private Tcp open(String target) throws IOException { + Tcp connection = Tcp.connect("127.0.0.1", port, 5000); + byte[] request = ascii("GET " + target + " HTTP/1.1\r\nHost: 127.0.0.1\r\n" + + "Upgrade: websocket\r\nConnection: Upgrade\r\n" + + "Sec-WebSocket-Version: 13\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\r\n"); + connection.write(request, 0, request.length); + StringBuilder head = new StringBuilder(); + // The head ends at the first empty line: CR LF CR LF. + int ending = 0; + while (head.length() < 8192 && ending < 4) { + int c = readByte(connection); + if (c < 0) { + break; + } + head.append((char) c); + ending = c == (ending % 2 == 0 ? '\r' : '\n') ? ending + 1 : (c == '\r' ? 1 : 0); + } + assertTrue(head.toString().startsWith("HTTP/1.1 101"), head.toString()); + return connection; + } + + private String text() throws IOException { + assertEquals(TEXT, opcode); + return new String(payload, "UTF-8"); + } + + private int closeCode() { + return payload.length < 2 ? -1 : ((payload[0] & 0xff) << 8) | (payload[1] & 0xff); + } + + private void readFrame(Tcp connection) throws IOException { + int first = readByte(connection); + if (first < 0) { + throw new IOException("the connection ended with no frame"); + } + opcode = first & 0xf; + int length = readByte(connection) & 0x7f; + if (length == 126) { + length = (readByte(connection) << 8) | readByte(connection); + } + payload = new byte[length]; + int got = 0; + while (got < length) { + int read = connection.read(payload, got, length - got); + if (read <= 0) { + throw new IOException("the connection ended after " + got + " of " + length); + } + got += read; + } + } + + /// A client's frames are masked; a server refuses one that is not. + private static void writeText(Tcp connection, String text) throws IOException { + byte[] body = ascii(text); + byte[] mask = Crypto.randomBytes(4); + byte[] frame = new byte[6 + body.length]; + frame[0] = (byte) (0x80 | TEXT); + frame[1] = (byte) (0x80 | body.length); + System.arraycopy(mask, 0, frame, 2, 4); + for (int iter = 0; iter < body.length; iter++) { + frame[6 + iter] = (byte) (body[iter] ^ mask[iter & 3]); + } + connection.write(frame, 0, frame.length); + } + + private static int readByte(Tcp connection) throws IOException { + byte[] one = new byte[1]; + int read = connection.read(one, 0, 1); + return read <= 0 ? -1 : one[0] & 0xff; + } + + private static byte[] ascii(String value) { + byte[] out = new byte[value.length()]; + for (int iter = 0; iter < out.length; iter++) { + out[iter] = (byte) value.charAt(iter); + } + return out; + } +} diff --git a/scripts/savor/backend/src/test/java/com/codenameone/examples/savor/MatchingTest.java b/scripts/savor/backend/src/test/java/com/codenameone/examples/savor/MatchingTest.java new file mode 100644 index 00000000000..549025d37f9 --- /dev/null +++ b/scripts/savor/backend/src/test/java/com/codenameone/examples/savor/MatchingTest.java @@ -0,0 +1,125 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor; + +import com.codename1.backend.DataSource; +import com.codename1.backend.annotations.Autowired; +import com.codename1.backend.test.BackendTest; +import com.codename1.backend.test.MockMvc; +import com.codenameone.examples.savor.account.Accounts; +import com.codenameone.examples.savor.account.DemoAccounts; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static com.codename1.backend.test.MockMvcRequestBuilders.get; +import static com.codename1.backend.test.MockMvcRequestBuilders.post; +import static com.codename1.backend.test.MockMvcResultMatchers.jsonPath; +import static com.codename1.backend.test.MockMvcResultMatchers.status; +import static org.junit.jupiter.api.Assertions.assertEquals; + +/// Which courier an order's delivery is offered to: the free one nearest the +/// kitchen, approved and still somebody. +@BackendTest +class MatchingTest { + private static final double LAT = 48.2082; + private static final double LNG = 16.3738; + + @Autowired + private MockMvc mvc; + @Autowired + private Accounts accounts; + @Autowired + private DataSource db; + + /// Orders from the kitchen at `lat`, `lng`, has it cooked, and answers the + /// state its delivery is left in; then calls it off, so the customer and + /// the courier are free for the next one. + private String dispatched(String customer, double lat, double lng) throws Exception { + String id = Trips.ask(mvc, customer, Trips.trip(lat, lng, lat + 0.012, lng + 0.016)); + String state = (String) ((Map) Trips.json(mvc, People.asCustomer(get("/api/orders/" + id), + customer)).get("delivery")).get("state"); + mvc.perform(People.asAdmin(Trips.body(post("/api/admin/orders/" + id + "/cancel"), + "{\"reason\":\"Test\"}"), DemoAccounts.ADMIN)).andExpect(status().isOk()); + return state; + } + + @Test + void theCourierNearestTheKitchenIsAsked() throws Exception { + // Vienna: one courier by the door, one by the kitchen. + String customer = People.customer(accounts, "vie-customer"); + String byTheDoor = People.courier(accounts, "vie-door"); + String byTheKitchen = People.courier(accounts, "vie-kitchen"); + Trips.online(mvc, byTheDoor, LAT + 0.012, LNG + 0.016); + Trips.online(mvc, byTheKitchen, LAT + 0.001, LNG); + + String id = Trips.ask(mvc, customer, Trips.trip(LAT, LNG, LAT + 0.012, LNG + 0.016)); + mvc.perform(People.asCourier(get("/api/courier/active"), byTheKitchen)) + .andExpect(jsonPath("$.id").value(id)) + .andExpect(jsonPath("$.state").value("OFFERED")); + mvc.perform(People.asCourier(get("/api/courier/active"), byTheDoor)) + .andExpect(jsonPath("$.state").value("NONE")); + mvc.perform(People.asAdmin(Trips.body(post("/api/admin/orders/" + id + "/cancel"), + "{\"reason\":\"Test\"}"), DemoAccounts.ADMIN)).andExpect(status().isOk()); + + // An approval withdrawn takes a courier out of matching at once, on + // line or not. + Trips.offline(mvc, byTheDoor); + assertEquals("OFFERED", dispatched(customer, LAT, LNG)); + db.execute("UPDATE courier_application SET status = 'rejected' WHERE username = ?", + new Object[] {byTheKitchen}); + assertEquals("REQUESTED", dispatched(customer, LAT, LNG)); + db.execute("UPDATE courier_application SET status = 'approved' WHERE username = ?", + new Object[] {byTheKitchen}); + Trips.offline(mvc, byTheKitchen); + } + + @Test + void aCourierWithNoProfileIsOfferedNothing() throws Exception { + // Zurich. + String customer = People.customer(accounts, "zrh-customer"); + String courier = People.courier(accounts, "zrh-courier"); + Trips.online(mvc, courier, 47.3769, 8.5417); + assertEquals("OFFERED", dispatched(customer, 47.3771, 8.5419)); + + // What an account that is half gone leaves behind: on line, approved, + // and nobody. No order is sent there. + db.execute("DELETE FROM profile WHERE username = ?", new Object[] {courier}); + assertEquals("REQUESTED", dispatched(customer, 47.3771, 8.5419)); + } + + @Test + void aCourierWhoGoesOffLineIsOffLine() throws Exception { + // Two reports close together, so the second counts the time between + // them as time on line: adding that time is a bulk update, and the + // courier's row must already be written when it runs. The pause is + // what makes that time more than nothing: two reports in the same + // millisecond add no time and run no update. + String courier = People.courier(accounts, "ldn-courier"); + Trips.online(mvc, courier, 51.5072, -0.1276); + Thread.sleep(50); + mvc.perform(People.asCourier(Trips.body(post("/api/courier/status"), + People.position(false, 0, 0)), courier)) + .andExpect(jsonPath("$.online").value(false)); + mvc.perform(People.asCourier(get("/api/courier/me"), courier)) + .andExpect(jsonPath("$.online").value(false)); + assertEquals("REQUESTED", dispatched(People.customer(accounts, "ldn-customer"), + 51.5074, -0.1278)); + } +} diff --git a/scripts/savor/backend/src/test/java/com/codenameone/examples/savor/MerchantGuardsTest.java b/scripts/savor/backend/src/test/java/com/codenameone/examples/savor/MerchantGuardsTest.java new file mode 100644 index 00000000000..4c835248088 --- /dev/null +++ b/scripts/savor/backend/src/test/java/com/codenameone/examples/savor/MerchantGuardsTest.java @@ -0,0 +1,224 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor; + +import com.codename1.backend.Base64; +import com.codename1.backend.DataSource; +import com.codename1.backend.ResponseStatusException; +import com.codename1.backend.annotations.Autowired; +import com.codename1.backend.test.BackendTest; +import com.codename1.backend.test.MockMvc; +import com.codenameone.examples.savor.account.Accounts; +import com.codenameone.examples.savor.account.DemoAccounts; +import com.codenameone.examples.savor.api.DeliveryDto; +import com.codenameone.examples.savor.api.PhotoDto; +import com.codenameone.examples.savor.catalog.Catalog; +import com.codenameone.examples.savor.catalog.Restaurants; +import com.codenameone.examples.savor.order.Orders; +import com.codenameone.examples.savor.order.Quotes; +import org.junit.jupiter.api.Test; + +import java.util.Map; +import java.util.TimeZone; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +/// What a merchant may store, see and leave behind. +@BackendTest +class MerchantGuardsTest { + @Autowired + private MockMvc mvc; + @Autowired + private Accounts accounts; + @Autowired + private Restaurants restaurants; + @Autowired + private Orders orders; + @Autowired + private Catalog catalog; + @Autowired + private DataSource db; + + /// A PNG's signature and header chunk declaring `width` by `height`. + private static String png(int width, int height) { + byte[] bytes = new byte[33]; + byte[] start = {(byte) 0x89, 'P', 'N', 'G', 13, 10, 26, 10, 0, 0, 0, 13, 'I', 'H', 'D', 'R'}; + System.arraycopy(start, 0, bytes, 0, start.length); + int[] sides = {width, height}; + for (int side = 0; side < 2; side++) { + for (int b = 0; b < 4; b++) { + bytes[16 + side * 4 + b] = (byte) (sides[side] >>> (24 - b * 8)); + } + } + return Base64.encode(bytes); + } + + private static int statusOf(ThrowingCall call) throws Exception { + try { + call.run(); + } catch (ResponseStatusException refused) { + return refused.getStatus(); + } + return 200; + } + + private interface ThrowingCall { + void run() throws Exception; + } + + @Test + void photosAreCheckedAndPruned() throws Exception { + Trips.Kitchen kitchen = Trips.kitchen(mvc, -41.2865, 174.7762); + assertEquals(413, statusOf(() -> restaurants.upload(kitchen.merchant, + new PhotoDto("", "image/png", png(100000, 100000))))); + String old = restaurants.upload(kitchen.merchant, new PhotoDto("", "image/png", png(8, 8))).id(); + db.execute("UPDATE photo SET created_at = ? WHERE id = ?", new Object[] {1000L, old}); + String fresh = restaurants.upload(kitchen.merchant, + new PhotoDto("", "image/png", png(8, 8))).id(); + // Named by nothing and past its grace: gone. The new one stays. + assertEquals(404, statusOf(() -> catalog.photo(old))); + assertEquals(fresh, catalog.photo(fresh).id()); + } + + @Test + void theOnlyMerchantClosingTakesTheRestaurantOffTheApp() throws Exception { + Trips.Kitchen kitchen = Trips.kitchen(mvc, -41.2965, 174.7862); + accounts.close(kitchen.merchant, People.PASSWORD); + // No longer listed: only a listed restaurant can be suspended... + assertEquals(409, statusOf(() -> restaurants.suspend(DemoAccounts.ADMIN, kitchen.id, true, + "Again"))); + // ...and with nobody left on its staff it is not listed again. + assertEquals(409, statusOf(() -> restaurants.suspend(DemoAccounts.ADMIN, kitchen.id, false, ""))); + } + + @Test + void aMerchantIsNotShownTheCustomersSignIn() throws Exception { + String customer = People.customer(accounts, "wlg-guard-customer"); + String courier = People.courier(accounts, "wlg-guard-courier"); + Trips.Trip trip = Trips.trip(-41.3065, 174.7962, -41.3000, 174.8000); + Trips.online(mvc, courier, trip.fromLat, trip.fromLng); + String id = (String) Trips.complete(mvc, customer, courier, trip).get("id"); + Trips.Kitchen kitchen = Trips.kitchen(mvc, trip.fromLat, trip.fromLng); + DeliveryDto merchant = orders.get(kitchen.merchant, false, id).delivery(); + if (merchant == null) { + fail("The order has no delivery"); + } + assertNull(merchant.customerUsername()); + assertEquals(customer, orders.get(DemoAccounts.ADMIN, true, id).delivery().customerUsername()); + } + + @Test + void aRestaurantWithNoStaffLeftIsNotListedAgain() throws Exception { + Trips.Kitchen kitchen = Trips.kitchen(mvc, -41.3165, 174.8062); + restaurants.suspend(DemoAccounts.ADMIN, kitchen.id, true, "Closing down"); + // What closing the only merchant's account leaves behind. + db.execute("DELETE FROM restaurant_staff WHERE restaurant_id = ?", new Object[] {kitchen.id}); + assertEquals(409, statusOf(() -> restaurants.suspend(DemoAccounts.ADMIN, kitchen.id, false, ""))); + } + + @Test + void aSuspendedRestaurantIsNotListedThroughApproval() throws Exception { + Trips.Kitchen kitchen = Trips.kitchen(mvc, -41.3175, 174.8072); + restaurants.suspend(DemoAccounts.ADMIN, kitchen.id, true, "Closing down"); + db.execute("DELETE FROM restaurant_staff WHERE restaurant_id = ?", new Object[] {kitchen.id}); + assertEquals(409, statusOf(() -> restaurants.approve(DemoAccounts.ADMIN, kitchen.id))); + } + + @Test + void anApplicationWhoseApplicantIsGoneIsNotListed() throws Exception { + Trips.Kitchen kitchen = Trips.kitchen(mvc, -41.3185, 174.8082); + // What an applicant closing their account leaves: an application + // that names nobody who is still here. + db.execute("UPDATE restaurant SET status = ?, applicant = ? WHERE id = ?", + new Object[] {Catalog.PENDING, "gone@savor.example", kitchen.id}); + db.execute("DELETE FROM restaurant_staff WHERE restaurant_id = ?", new Object[] {kitchen.id}); + assertEquals(409, statusOf(() -> restaurants.approve(DemoAccounts.ADMIN, kitchen.id))); + } + + @Test + void theOnlyMerchantOfAListedRestaurantIsNotBlocked() throws Exception { + Trips.Kitchen kitchen = Trips.kitchen(mvc, -41.3195, 174.8092); + assertEquals(409, statusOf(() -> accounts.suspend(DemoAccounts.ADMIN, kitchen.merchant, + true, "Testing"))); + // Suspended first, the restaurant takes no orders, and the merchant can be. + restaurants.suspend(DemoAccounts.ADMIN, kitchen.id, true, "Closing down"); + accounts.suspend(DemoAccounts.ADMIN, kitchen.merchant, true, "Testing"); + } + + @Test + void anApplicationOfABlockedApplicantIsNotListed() throws Exception { + Trips.Kitchen kitchen = Trips.kitchen(mvc, -41.3205, 174.8102); + String applicant = People.customer(accounts, "wlg-blocked-applicant"); + accounts.suspend(DemoAccounts.ADMIN, applicant, true, "Testing"); + db.execute("UPDATE restaurant SET status = ?, applicant = ? WHERE id = ?", + new Object[] {Catalog.PENDING, applicant, kitchen.id}); + db.execute("DELETE FROM restaurant_staff WHERE restaurant_id = ?", new Object[] {kitchen.id}); + assertEquals(409, statusOf(() -> restaurants.approve(DemoAccounts.ADMIN, kitchen.id))); + } + + @Test + void aRatingCountsForTheCourierToo() throws Exception { + String customer = People.customer(accounts, "wlg-rating-customer"); + String courier = People.courier(accounts, "wlg-rating-courier"); + Trips.Trip trip = Trips.trip(-41.3275, 174.8172, -41.3210, 174.8210); + Trips.online(mvc, courier, trip.fromLat, trip.fromLng); + String id = (String) Trips.complete(mvc, customer, courier, trip).get("id"); + orders.rate(customer, id, 4); + Map row = db.queryOne("SELECT rating_count AS n, rating_sum AS s FROM courier_state " + + "WHERE username = ?", new Object[] {courier}); + assertEquals(1L, ((Number) row.get("n")).longValue()); + assertEquals(4L, ((Number) row.get("s")).longValue()); + assertEquals(4L, ((Number) db.queryOne("SELECT stars FROM delivery WHERE id = ?", + new Object[] {id}).get("stars")).longValue()); + } + + @Test + void refundedOrdersAreNotCountedAsSales() throws Exception { + String customer = People.customer(accounts, "wlg-sales-customer"); + String courier = People.courier(accounts, "wlg-sales-courier"); + Trips.Trip trip = Trips.trip(-41.3265, 174.8162, -41.3200, 174.8200); + Trips.online(mvc, courier, trip.fromLat, trip.fromLng); + String id = (String) Trips.complete(mvc, customer, courier, trip).get("id"); + Trips.Kitchen kitchen = Trips.kitchen(mvc, trip.fromLat, trip.fromLng); + long before = restaurants.summary(kitchen.merchant).salesTodayCents(); + assertTrue(before > 0L, "the paid order counts"); + db.execute("UPDATE customer_order SET payment_status = ? WHERE id = ?", new Object[] {"REFUNDED", id}); + assertEquals(0L, restaurants.summary(kitchen.merchant).salesTodayCents()); + } + + @Test + void aFavoriteOutOfRangeIsNotListed() throws Exception { + String customer = People.customer(accounts, "wlg-far-customer"); + Trips.Kitchen kitchen = Trips.kitchen(mvc, -41.3365, 174.8262); + catalog.favorite(customer, kitchen.id, true); + assertEquals(1, catalog.favorites(customer, kitchen.lat, kitchen.lng).size()); + // A degree of latitude is over 100 km: far past where anything delivers. + assertEquals(0, catalog.favorites(customer, kitchen.lat + 1d, kitchen.lng).size()); + } + + @Test + void openingHoursFollowTheConfiguredZone() { + assertEquals(0, Quotes.minuteOfDay(0L, TimeZone.getTimeZone("UTC"))); + assertEquals(120, Quotes.minuteOfDay(0L, TimeZone.getTimeZone("GMT+2"))); + assertEquals(23 * 60, Quotes.minuteOfDay(0L, TimeZone.getTimeZone("GMT-01:00"))); + } +} diff --git a/scripts/savor/backend/src/test/java/com/codenameone/examples/savor/ModerationTest.java b/scripts/savor/backend/src/test/java/com/codenameone/examples/savor/ModerationTest.java new file mode 100644 index 00000000000..a42e92e051b --- /dev/null +++ b/scripts/savor/backend/src/test/java/com/codenameone/examples/savor/ModerationTest.java @@ -0,0 +1,227 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor; + +import com.codename1.backend.ResponseStatusException; +import com.codename1.backend.annotations.Autowired; +import com.codename1.backend.test.BackendTest; +import com.codename1.backend.test.MockMvc; +import com.codename1.backend.test.MockRequestBuilder; +import com.codenameone.examples.savor.account.Accounts; +import com.codenameone.examples.savor.account.DemoAccounts; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +import static com.codename1.backend.test.MockMvcRequestBuilders.get; +import static com.codename1.backend.test.MockMvcRequestBuilders.post; +import static com.codename1.backend.test.MockMvcResultMatchers.jsonPath; +import static com.codename1.backend.test.MockMvcResultMatchers.status; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +/// What an admin does about an account or a delivery: flag it, block it, cancel +/// it, and the record that is kept of each. +@BackendTest +class ModerationTest { + @Autowired + private MockMvc mvc; + @Autowired + private Accounts accounts; + + private MockRequestBuilder admin(MockRequestBuilder request) { + return People.asAdmin(request, DemoAccounts.ADMIN); + } + + @Test + void anAccountIsFlaggedThenBlockedAndTheRecordSaysWhy() throws Exception { + String who = People.customer(accounts, "troublesome"); + String token = SignIn.token(mvc, who, People.PASSWORD); + Map before = Trips.json(mvc, admin(get("/api/admin/stats/series?days=7"))); + + // A flag says why, and changes nothing the account can do. + mvc.perform(admin(Trips.body(post("/api/admin/users/" + who + "/flag"), + "{\"flagged\":true}"))) + .andExpect(status().isBadRequest()); + mvc.perform(admin(Trips.body(post("/api/admin/users/" + who + "/flag"), + "{\"flagged\":true,\"reason\":\"Rude to two couriers\"}"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.flagged").value(true)) + .andExpect(jsonPath("$.flagReason").value("Rude to two couriers")) + .andExpect(jsonPath("$.suspended").value(false)); + mvc.perform(get("/api/me").header("Host", SignIn.HOST) + .header("Authorization", "Bearer " + token)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.flagged").value(true)); + Map after = Trips.json(mvc, admin(get("/api/admin/stats/series?days=7"))); + assertEquals(Trips.number(before, "flaggedUsers") + 1, Trips.number(after, "flaggedUsers")); + + // Not in the middle of an order: nobody could cancel it. Waiting for + // the kitchen is in the middle of one. + String order = (String) Trips.place(mvc, who, + Trips.trip(41.3874, 2.1686, 41.3974, 2.1786)).get("id"); + mvc.perform(admin(Trips.body(post("/api/admin/users/" + who + "/suspend"), + "{\"suspended\":true}"))) + .andExpect(status().isConflict()); + mvc.perform(People.asCustomer(post("/api/orders/" + order + "/cancel"), who)) + .andExpect(status().isOk()); + + // A block is what stops it: the token in hand, and signing in again. + mvc.perform(admin(Trips.body(post("/api/admin/users/" + who + "/suspend"), + "{\"suspended\":true,\"reason\":\"And then a third\"}"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.suspended").value(true)); + mvc.perform(get("/api/me").header("Host", SignIn.HOST) + .header("Authorization", "Bearer " + token)) + .andExpect(status().isUnauthorized()); + mvc.perform(get("/api/payments/methods").header("Host", SignIn.HOST) + .header("Authorization", "Bearer " + token)) + .andExpect(status().isUnauthorized()); + mvc.perform(Trips.body(post("/api/orders").header("Host", SignIn.HOST) + .header("Authorization", "Bearer " + token), + Trips.cart(mvc, Trips.trip(41.3874, 2.1686, 41.3974, 2.1786)))) + .andExpect(status().isUnauthorized()); + assertEquals(null, SignIn.session(mvc, who, People.PASSWORD)); + + // The account's record, newest first: who did what, and why. + Map detail = Trips.json(mvc, admin(get("/api/admin/users/" + who))); + assertEquals(who, ((Map) detail.get("user")).get("username")); + List events = (List) detail.get("events"); + assertEquals(2, events.size()); + Map block = (Map) events.get(0); + assertEquals("block", block.get("action")); + assertEquals("And then a third", block.get("reason")); + assertEquals(DemoAccounts.ADMIN, block.get("by")); + assertTrue(Trips.number(block, "at") > 0L); + assertEquals("flag", ((Map) events.get(1)).get("action")); + + // Both are undone, and that is recorded too. + mvc.perform(admin(Trips.body(post("/api/admin/users/" + who + "/suspend"), + "{\"suspended\":false}"))) + .andExpect(status().isOk()); + mvc.perform(admin(Trips.body(post("/api/admin/users/" + who + "/flag"), + "{\"flagged\":false}"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.flagged").value(false)) + .andExpect(jsonPath("$.flagReason").value("")); + mvc.perform(get("/api/me").header("Host", SignIn.HOST) + .header("Authorization", "Bearer " + token)) + .andExpect(status().isOk()); + mvc.perform(admin(get("/api/admin/users/" + who))) + .andExpect(jsonPath("$.events.length()").value(4)) + .andExpect(jsonPath("$.events[0].action").value("unflag")) + .andExpect(jsonPath("$.events[1].action").value("unblock")); + mvc.perform(admin(get("/api/admin/users/nobody@test.example"))) + .andExpect(status().isNotFound()); + } + + @Test + void anAdminWhoWasJustRemovedChangesNobody() throws Exception { + // Two admins taking each other's role at the same moment were both + // authorized before either committed. The one whose turn comes second + // is looked at again inside its transaction, and by then is no admin. + String first = People.customer(accounts, "admin-one"); + String second = People.customer(accounts, "admin-two"); + accounts.setRoles(DemoAccounts.ADMIN, first, false, true); + accounts.setRoles(DemoAccounts.ADMIN, second, false, true); + accounts.setRoles(first, second, false, false); + try { + accounts.setRoles(second, first, false, false); + fail("an admin who lost the role still withdrew another's"); + } catch (ResponseStatusException refused) { + assertEquals(403, refused.getStatus()); + } + try { + accounts.suspend(second, first, true, ""); + fail("an admin who lost the role still blocked another"); + } catch (ResponseStatusException refused) { + assertEquals(403, refused.getStatus()); + } + assertTrue(accounts.describe(first).admin(), "the first admin is one still"); + assertFalse(accounts.describe(first).suspended(), "and is not blocked"); + } + + @Test + void theDemoDataHasAFlaggedCustomerWithAHistory() throws Exception { + Map detail = Trips.json(mvc, admin(get("/api/admin/users/" + DemoAccounts.FLAGGED_CUSTOMER))); + Map user = (Map) detail.get("user"); + assertEquals(Boolean.TRUE, user.get("flagged")); + assertTrue(Trips.number(detail, "orders") > 0L, "the fortnight gave it orders"); + assertEquals("flag", ((Map) ((List) detail.get("events")).get(0)).get("action")); + } + + @Test + void anAdminCancelsAnOrderThatIsUnderWay() throws Exception { + // Helsinki, where nobody delivers: the order waits for a courier. + String customer = People.customer(accounts, "hel-customer"); + String id = Trips.ask(mvc, customer, Trips.trip(60.1699, 24.9384, 60.1800, 24.9500)); + mvc.perform(admin(Trips.body(post("/api/admin/orders/" + id + "/cancel"), "{}"))) + .andExpect(status().isBadRequest()); + mvc.perform(admin(Trips.body(post("/api/admin/orders/" + id + "/cancel"), + "{\"reason\":\"Reported as a prank\"}"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.state").value("CANCELLED_BY_ADMIN")) + .andExpect(jsonPath("$.delivery.state").value("CANCELLED_BY_ADMIN")); + assertEquals(0, Trips.list(mvc, People.asCustomer(get("/api/orders/active"), customer)).size()); + mvc.perform(People.asCustomer(get("/api/orders/" + id), customer)) + .andExpect(jsonPath("$.state").value("CANCELLED_BY_ADMIN")) + .andExpect(jsonPath("$.paymentStatus").value("RELEASED")); + // Over is over, and an order that never was is not found. + mvc.perform(admin(Trips.body(post("/api/admin/orders/" + id + "/cancel"), + "{\"reason\":\"Again\"}"))) + .andExpect(status().isConflict()); + mvc.perform(admin(Trips.body(post("/api/admin/orders/nosuchorder/cancel"), + "{\"reason\":\"Again\"}"))) + .andExpect(status().isNotFound()); + // An order that ended this way cost nothing, so it has no receipt. + mvc.perform(People.asCustomer(get("/api/payments/receipts/" + id), customer)) + .andExpect(status().isNotFound()); + mvc.perform(admin(get("/api/admin/users/" + customer))) + .andExpect(jsonPath("$.events[0].action").value("cancel_order")) + .andExpect(jsonPath("$.events[0].reason").value("Reported as a prank")); + } + + @Test + void aCourierKeepsTheRoleUntilTheDeliveryTheyAreOnHasEnded() throws Exception { + // Tallinn. + String customer = People.customer(accounts, "tll-customer"); + String courier = People.courier(accounts, "tll-courier"); + Trips.online(mvc, courier, 59.4370, 24.7536); + String id = Trips.ask(mvc, customer, Trips.trip(59.4370, 24.7536, 59.4470, 24.7636)); + mvc.perform(People.asCourier(post("/api/courier/offers/" + id + "/accept"), courier)) + .andExpect(status().isOk()); + + // Withdrawn now, the courier could do nothing more for this customer. + mvc.perform(admin(Trips.body(post("/api/admin/users/" + courier + "/roles"), + "{\"courier\":false,\"admin\":false}"))) + .andExpect(status().isConflict()); + mvc.perform(People.asCourier(post("/api/courier/deliveries/" + id + "/arrived"), courier)) + .andExpect(status().isOk()); + + mvc.perform(admin(Trips.body(post("/api/admin/orders/" + id + "/cancel"), + "{\"reason\":\"Courier is leaving\"}"))) + .andExpect(status().isOk()); + mvc.perform(admin(Trips.body(post("/api/admin/users/" + courier + "/roles"), + "{\"courier\":false,\"admin\":false}"))) + .andExpect(status().isOk()); + } +} diff --git a/scripts/savor/backend/src/test/java/com/codenameone/examples/savor/OnboardingApplicationTest.java b/scripts/savor/backend/src/test/java/com/codenameone/examples/savor/OnboardingApplicationTest.java new file mode 100644 index 00000000000..1e80e56b5cf --- /dev/null +++ b/scripts/savor/backend/src/test/java/com/codenameone/examples/savor/OnboardingApplicationTest.java @@ -0,0 +1,341 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor; + +import com.codename1.backend.annotations.Autowired; +import com.codename1.backend.test.BackendTest; +import com.codename1.backend.test.MockMvc; +import com.codename1.backend.test.MockRequestBuilder; +import com.codenameone.examples.savor.account.Accounts; +import com.codenameone.examples.savor.account.DemoAccounts; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +import static com.codename1.backend.test.MockMvcRequestBuilders.get; +import static com.codename1.backend.test.MockMvcRequestBuilders.post; +import static com.codename1.backend.test.MockMvcRequestBuilders.put; +import static com.codename1.backend.test.MockMvcResultMatchers.jsonPath; +import static com.codename1.backend.test.MockMvcResultMatchers.status; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/// Applying to deliver: a draft, its documents, handing it in, and an admin's +/// answer either way. +@BackendTest +class OnboardingApplicationTest { + /// The smallest things the server takes for a picture: the first bytes of a + /// PNG and of a JPEG, as far as the header that gives their size (one + /// pixel), in base64. + private static final String PNG = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB"; + private static final String JPEG = "/9j/4AAQSkZJRgABAQAAAQABAAD/wAARCAABAAEDASIAAhEBAxEB"; + private static final String[] KINDS = {"photo_id", "licence", "selfie", "vehicle_registration", + "insurance"}; + private static final String FORM = "{\"legalName\":\"Ada Applicant\"," + + "\"dateOfBirth\":\"1990-05-17\",\"licenceNumber\":\"L-1234\"," + + "\"licenceExpiry\":\"2999-01-31\",\"vehicleMake\":\"Honda\"," + + "\"vehicleModel\":\"Civic\",\"vehicleYear\":2020,\"vehicleColor\":\"Grey\"," + + "\"vehiclePlate\":\"APP 1\"}"; + + @Autowired + private MockMvc mvc; + @Autowired + private Accounts accounts; + + private static String picture(String kind, String data) { + return "{\"kind\":\"" + kind + "\",\"contentType\":\"image/png\",\"dataBase64\":\"" + data + + "\"}"; + } + + private MockRequestBuilder admin(MockRequestBuilder request) { + return People.asAdmin(request, DemoAccounts.ADMIN); + } + + private void upload(String who, String kind) throws Exception { + mvc.perform(People.asCustomer(Trips.body(post("/api/onboarding/application/documents"), + picture(kind, PNG)), who)).andExpect(status().isOk()); + } + + @Test + void anApplicationIsDraftedHandedInRejectedCorrectedAndApproved() throws Exception { + String who = People.customer(accounts, "applies"); + String token = SignIn.token(mvc, who, People.PASSWORD); + + // Nothing yet, and nothing to hand in. + mvc.perform(People.asCustomer(get("/api/onboarding/application"), who)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("none")) + .andExpect(jsonPath("$.documents.length()").value(5)) + .andExpect(jsonPath("$.documents[0].status").value("missing")); + mvc.perform(People.asCustomer(get("/api/me"), who)) + .andExpect(jsonPath("$.courierStatus").value("none")); + + // Half a form is a draft. It saves, and it cannot be handed in. + mvc.perform(People.asCustomer(Trips.body(put("/api/onboarding/application"), + "{\"legalName\":\"Ada Applicant\",\"vehicleMake\":\"Honda\"}"), who)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("draft")) + .andExpect(jsonPath("$.legalName").value("Ada Applicant")); + mvc.perform(People.asCustomer(Trips.body(put("/api/onboarding/application"), + "{\"dateOfBirth\":\"17/05/1990\"}"), who)) + .andExpect(status().isBadRequest()); + // Written the right way, and no day that ever was. + mvc.perform(People.asCustomer(Trips.body(put("/api/onboarding/application"), + "{\"dateOfBirth\":\"1990-02-30\"}"), who)) + .andExpect(status().isBadRequest()); + mvc.perform(People.asCustomer(Trips.body(put("/api/onboarding/application"), + "{\"dateOfBirth\":\"1991-02-29\"}"), who)) + .andExpect(status().isBadRequest()); + mvc.perform(People.asCustomer(Trips.body(put("/api/onboarding/application"), + "{\"dateOfBirth\":\"1992-04-31\"}"), who)) + .andExpect(status().isBadRequest()); + mvc.perform(People.asCustomer(Trips.body(put("/api/onboarding/application"), + "{\"dateOfBirth\":\"1992-02-29\"}"), who)) + .andExpect(status().isOk()); + mvc.perform(People.asCustomer(post("/api/onboarding/application/submit"), who)) + .andExpect(status().isBadRequest()); + + // A complete form is still not complete without its documents. + mvc.perform(People.asCustomer(Trips.body(put("/api/onboarding/application"), FORM), who)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.vehicleYear").value(2020)); + mvc.perform(People.asCustomer(post("/api/onboarding/application/submit"), who)) + .andExpect(status().isBadRequest()); + + // A document has to be a picture, of a kind that exists, of a sane size. + mvc.perform(People.asCustomer(Trips.body(post("/api/onboarding/application/documents"), + picture("photo_id", "bm90IGEgcGljdHVyZQ==")), who)) + .andExpect(status().isBadRequest()); + mvc.perform(People.asCustomer(Trips.body(post("/api/onboarding/application/documents"), + picture("passport", PNG)), who)) + .andExpect(status().isBadRequest()); + StringBuilder huge = new StringBuilder(PNG); + while (huge.length() < 2200000) { + huge.append("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"); + } + mvc.perform(People.asCustomer(Trips.body(post("/api/onboarding/application/documents"), + picture("photo_id", huge.toString())), who)) + .andExpect(status().isPayloadTooLarge()); + // A few bytes that say 100000 pixels wide: refused before an admin's + // app tries to decode them. + mvc.perform(People.asCustomer(Trips.body(post("/api/onboarding/application/documents"), + picture("photo_id", "iVBORw0KGgoAAAANSUhEUgABhqAAAAAB")), who)) + .andExpect(status().isPayloadTooLarge()); + // A JPEG that never says its size is no picture. + mvc.perform(People.asCustomer(Trips.body(post("/api/onboarding/application/documents"), + picture("photo_id", "/9j/4AAQSkZJRgABAQAAAQABAAA=")), who)) + .andExpect(status().isBadRequest()); + + for (int iter = 0; iter < 4; iter++) { + upload(who, KINDS[iter]); + } + // Four of five. + mvc.perform(People.asCustomer(post("/api/onboarding/application/submit"), who)) + .andExpect(status().isBadRequest()); + mvc.perform(People.asCustomer(Trips.body(post("/api/onboarding/application/documents"), + picture("insurance", JPEG)), who)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.documents[4].kind").value("insurance")) + .andExpect(jsonPath("$.documents[4].status").value("uploaded")); + mvc.perform(People.asCustomer(get("/api/onboarding/application/documents/insurance"), who)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.contentType").value("image/jpeg")) + .andExpect(jsonPath("$.dataBase64").value(JPEG)); + + mvc.perform(People.asCustomer(post("/api/onboarding/application/submit"), who)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("pending")); + // In an admin's hands now: not the applicant's to change. + mvc.perform(People.asCustomer(Trips.body(put("/api/onboarding/application"), FORM), who)) + .andExpect(status().isConflict()); + mvc.perform(People.asCustomer(Trips.body(post("/api/onboarding/application/documents"), + picture("selfie", PNG)), who)) + .andExpect(status().isConflict()); + // Still not a courier: by the role, and by the token already in hand. + mvc.perform(People.asCustomer(get("/api/me"), who)) + .andExpect(jsonPath("$.courier").value(false)) + .andExpect(jsonPath("$.courierStatus").value("pending")); + mvc.perform(get("/api/courier/me").header("Host", SignIn.HOST) + .header("Authorization", "Bearer " + token)) + .andExpect(status().isForbidden()); + + // The admin finds it among those waiting, and reads a document. + List waiting = Trips.list(mvc, admin(get("/api/admin/applications?status=pending"))); + boolean found = false; + for (int iter = 0; iter < waiting.size(); iter++) { + found |= who.equals(((Map) waiting.get(iter)).get("username")); + } + assertTrue(found, "the application is waiting"); + mvc.perform(admin(get("/api/admin/applications?status=nonsense"))) + .andExpect(status().isBadRequest()); + mvc.perform(admin(get("/api/admin/applications/" + who))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.name").value("applies")) + .andExpect(jsonPath("$.licenceNumber").value("L-1234")); + mvc.perform(admin(get("/api/admin/applications/" + who + "/documents/licence"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.dataBase64").value(PNG)); + mvc.perform(admin(get("/api/admin/applications/nobody@test.example"))) + .andExpect(status().isNotFound()); + + // Rejected, which needs a reason, and the applicant is told it. + mvc.perform(admin(Trips.body(post("/api/admin/applications/" + who + "/reject"), "{}"))) + .andExpect(status().isBadRequest()); + mvc.perform(admin(Trips.body(post("/api/admin/applications/" + who + "/reject"), + "{\"reason\":\"The licence photo is unreadable\"}"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("rejected")) + .andExpect(jsonPath("$.reviewedBy").value(DemoAccounts.ADMIN)); + mvc.perform(admin(post("/api/admin/applications/" + who + "/approve"))) + .andExpect(status().isConflict()); + mvc.perform(People.asCustomer(get("/api/onboarding/application"), who)) + .andExpect(jsonPath("$.status").value("rejected")) + .andExpect(jsonPath("$.rejectionReason").value("The licence photo is unreadable")); + + // Corrected and handed in again, and this time approved. + upload(who, "licence"); + mvc.perform(People.asCustomer(post("/api/onboarding/application/submit"), who)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("pending")) + .andExpect(jsonPath("$.rejectionReason").value("")); + mvc.perform(admin(post("/api/admin/applications/" + who + "/approve"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("approved")) + .andExpect(jsonPath("$.documents[0].status").value("accepted")); + mvc.perform(admin(post("/api/admin/applications/" + who + "/approve"))) + .andExpect(status().isConflict()); + + // Approval is what grants the role: the same token now delivers, on + // the vehicle the application named. + mvc.perform(get("/api/courier/me").header("Host", SignIn.HOST) + .header("Authorization", "Bearer " + token)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.approved").value(true)) + .andExpect(jsonPath("$.vehicle").value("Grey Honda Civic")) + .andExpect(jsonPath("$.plate").value("APP 1")); + mvc.perform(People.asCustomer(get("/api/me"), who)) + .andExpect(jsonPath("$.courier").value(true)) + .andExpect(jsonPath("$.customer").value(true)) + .andExpect(jsonPath("$.courierStatus").value("approved")); + // Both answers are on the account's record. + Map detail = Trips.json(mvc, admin(get("/api/admin/users/" + who))); + List events = (List) detail.get("events"); + assertEquals(2, events.size()); + assertEquals("approve", ((Map) events.get(0)).get("action")); + assertEquals("reject", ((Map) events.get(1)).get("action")); + assertEquals("The licence photo is unreadable", ((Map) events.get(1)).get("reason")); + } + + @Test + void onlyAnApprovedCourierGoesOnLine() throws Exception { + // Holding the role is not enough. This account is let into the + // courier's part of the API and has an application nobody has approved. + String who = People.customer(accounts, "unapproved"); + mvc.perform(People.asCustomer(Trips.body(put("/api/onboarding/application"), FORM), who)) + .andExpect(status().isOk()); + mvc.perform(People.asCourier(Trips.body(post("/api/courier/status"), + People.position(true, 59.9139, 10.7522)), who)) + .andExpect(status().isForbidden()); + mvc.perform(People.asCourier(get("/api/courier/me"), who)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.approved").value(false)) + .andExpect(jsonPath("$.online").value(false)); + + // An admin's switch approves, and withdrawing it takes that back. + mvc.perform(admin(Trips.body(post("/api/admin/users/" + who + "/roles"), + "{\"courier\":true,\"admin\":false}"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.courierStatus").value("approved")); + Trips.online(mvc, who, 59.9139, 10.7522); + mvc.perform(admin(Trips.body(post("/api/admin/users/" + who + "/roles"), + "{\"courier\":false,\"admin\":false}"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.courier").value(false)) + .andExpect(jsonPath("$.courierStatus").value("rejected")); + mvc.perform(People.asCourier(Trips.body(post("/api/courier/status"), + People.position(true, 59.9139, 10.7522)), who)) + .andExpect(status().isForbidden()); + mvc.perform(People.asCourier(get("/api/courier/me"), who)) + .andExpect(jsonPath("$.online").value(false)); + } + + @Test + void anApplicationIsItsOwnersAndTheAdminsOnly() throws Exception { + String owner = People.customer(accounts, "doc-owner"); + String other = People.customer(accounts, "doc-other"); + upload(owner, "photo_id"); + // There is no address by which one applicant reaches another's: each + // sees their own, and the other has none. + mvc.perform(People.asCustomer(get("/api/onboarding/application/documents/photo_id"), other)) + .andExpect(status().isNotFound()); + mvc.perform(People.asCustomer(get("/api/onboarding/application"), other)) + .andExpect(jsonPath("$.status").value("none")) + .andExpect(jsonPath("$.username").value(other)); + mvc.perform(People.asCustomer(get("/api/admin/applications/" + owner + + "/documents/photo_id"), other)) + .andExpect(status().isForbidden()); + mvc.perform(People.asCourier(get("/api/admin/applications/" + owner), other)) + .andExpect(status().isForbidden()); + mvc.perform(get("/api/onboarding/application/documents/photo_id")) + .andExpect(status().isUnauthorized()); + mvc.perform(admin(get("/api/admin/applications/" + owner + "/documents/photo_id"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.kind").value("photo_id")); + mvc.perform(admin(get("/api/admin/applications/" + owner + "/documents/selfie"))) + .andExpect(status().isNotFound()); + } + + /// A save or an upload authorized just before the account closed, and + /// run just after, keeps nothing: the closure already forgot the + /// application, and nothing would forget it again. + @Test + void aClosedAccountKeepsNoApplication() throws Exception { + String who = People.customer(accounts, "applies-gone"); + upload(who, "photo_id"); + accounts.close(who, People.PASSWORD); + mvc.perform(People.asCustomer(Trips.body(put("/api/onboarding/application"), FORM), who)) + .andExpect(status().isNotFound()); + mvc.perform(People.asCustomer(Trips.body(post("/api/onboarding/application/documents"), + picture("selfie", PNG)), who)).andExpect(status().isNotFound()); + mvc.perform(admin(get("/api/admin/applications/" + who))) + .andExpect(status().isNotFound()); + mvc.perform(admin(get("/api/admin/applications/" + who + "/documents/selfie"))) + .andExpect(status().isNotFound()); + } + + @Test + void theDemoApplicantIsWaitingAndTheDemoCouriersAreApproved() throws Exception { + mvc.perform(admin(get("/api/admin/applications/" + DemoAccounts.APPLICANT))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("pending")) + .andExpect(jsonPath("$.documents[2].status").value("uploaded")); + mvc.perform(admin(get("/api/admin/applications/" + DemoAccounts.COURIER))) + .andExpect(jsonPath("$.status").value("approved")) + .andExpect(jsonPath("$.vehicleMake").value("Vespa")); + // Registering to deliver, the way the app asks, opens a draft. + mvc.perform(post("/api/account/register").contentType("application/json") + .content("{\"email\":\"wants@test.example\",\"password\":\"a-long-password\"," + + "\"displayName\":\"Wants\",\"phone\":\"+15550113\"," + + "\"wantsToDeliver\":true}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.courier").value(false)) + .andExpect(jsonPath("$.courierStatus").value("draft")); + } +} diff --git a/scripts/savor/backend/src/test/java/com/codenameone/examples/savor/OrderFlowTest.java b/scripts/savor/backend/src/test/java/com/codenameone/examples/savor/OrderFlowTest.java new file mode 100644 index 00000000000..cf76813dfb2 --- /dev/null +++ b/scripts/savor/backend/src/test/java/com/codenameone/examples/savor/OrderFlowTest.java @@ -0,0 +1,320 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor; + +import com.codename1.backend.DataSource; +import com.codename1.backend.annotations.Autowired; +import com.codename1.backend.test.BackendTest; +import com.codename1.backend.test.MockMvc; +import com.codenameone.examples.savor.account.Accounts; +import com.codenameone.examples.savor.account.DemoAccounts; +import com.codenameone.examples.savor.delivery.Deliveries; +import com.codenameone.examples.savor.order.Orders; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static com.codename1.backend.test.MockMvcRequestBuilders.get; +import static com.codename1.backend.test.MockMvcRequestBuilders.post; +import static com.codename1.backend.test.MockMvcResultMatchers.jsonPath; +import static com.codename1.backend.test.MockMvcResultMatchers.status; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/// An order from the cart to the stars, and the ways it can go otherwise: +/// the order and its delivery are two machines that move together. +@BackendTest +class OrderFlowTest { + @Autowired + private MockMvc mvc; + @Autowired + private Accounts accounts; + @Autowired + private Deliveries deliveries; + @Autowired + private Orders orders; + @Autowired + private DataSource db; + + @Test + void theSeededRestaurantsDeliverToTheDemoAddress() throws Exception { + // Pier 39, where the app sends a browser that has no location of its own. + String found = mvc.perform(People.asCustomer(get("/api/restaurants?lat=37.8087" + + "&lng=-122.4098&sort=distance"), DemoAccounts.CUSTOMER)) + .andExpect(status().isOk()).andReturn().getResponse().getContentAsString(); + assertTrue(found.contains("Ember & Crust"), found); + } + + @Test + void anOrderFromTheCartToTheStars() throws Exception { + // Lisbon. Each test has a city to itself. + String customer = People.customer(accounts, "lis-customer"); + String courier = People.courier(accounts, "lis-courier"); + Trips.Trip trip = Trips.trip(38.7223, -9.1393, 38.7369, -9.1427); + Trips.Kitchen kitchen = Trips.kitchen(mvc, trip.fromLat, trip.fromLng); + String cart = Trips.cart(mvc, trip); + + Map quote = Trips.json(mvc, People.asCustomer(Trips.body(post("/api/orders/quote"), cart), + customer)); + assertEquals("USD", quote.get("currency")); + assertEquals(Trips.DISH_CENTS, Trips.number(quote, "subtotalCents")); + long total = Trips.number(quote, "totalCents"); + assertTrue(total > Trips.DISH_CENTS, "fees on top of the food: " + total); + + // Placed, the order waits for the kitchen; nobody is looking for a + // courier yet, and the card is only held. + Map placed = Trips.place(mvc, customer, trip); + String id = (String) placed.get("id"); + assertEquals("PLACED", placed.get("state")); + assertEquals(total, Trips.number(placed, "totalCents")); + assertEquals(null, placed.get("delivery")); + mvc.perform(People.asMerchant(get("/api/merchant/orders"), kitchen.merchant)) + .andExpect(jsonPath("$[0].id").value(id)); + // Another kitchen's merchant does not see it, or get to answer it. + Trips.Kitchen other = Trips.kitchen(mvc, 38.7000, -9.1500); + mvc.perform(People.asMerchant(Trips.body(post("/api/merchant/orders/" + id + "/accept"), + "{\"prepMinutes\":10}"), other.merchant)).andExpect(status().isNotFound()); + // A courier cannot pick up what is not cooked, or not even sent for. + mvc.perform(People.asCourier(post("/api/courier/offers/" + id + "/accept"), courier)) + .andExpect(status().isNotFound()); + + // On line beside the kitchen, the courier is offered it the moment + // the kitchen takes it. + Trips.online(mvc, courier, 38.7225, -9.1395); + mvc.perform(People.asMerchant(Trips.body(post("/api/merchant/orders/" + id + "/accept"), + "{\"prepMinutes\":12}"), kitchen.merchant)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.state").value("ACCEPTED")) + .andExpect(jsonPath("$.prepMinutes").value(12)); + mvc.perform(People.asCourier(get("/api/courier/active"), courier)) + .andExpect(jsonPath("$.id").value(id)) + .andExpect(jsonPath("$.state").value("OFFERED")); + // Too late for the customer to change their mind. + mvc.perform(People.asCustomer(post("/api/orders/" + id + "/cancel"), customer)) + .andExpect(status().isConflict()); + mvc.perform(People.asCourier(post("/api/courier/offers/" + id + "/accept"), courier)) + .andExpect(jsonPath("$.state").value("ACCEPTED")); + mvc.perform(People.asCourier(post("/api/courier/deliveries/" + id + "/arrived"), courier)) + .andExpect(jsonPath("$.state").value("ARRIVED")); + // The food is not ready, so it cannot leave. + mvc.perform(People.asCourier(post("/api/courier/deliveries/" + id + "/start"), courier)) + .andExpect(status().isConflict()); + mvc.perform(People.asMerchant(post("/api/merchant/orders/" + id + "/ready"), kitchen.merchant)) + .andExpect(jsonPath("$.state").value("READY")); + mvc.perform(People.asCourier(post("/api/courier/deliveries/" + id + "/start"), courier)) + .andExpect(jsonPath("$.state").value("IN_PROGRESS")); + + // On its way: the customer follows the courier and can call them; the + // courier can call the customer; the admin is told neither number. + Map customersView = Trips.json(mvc, People.asCustomer(get("/api/orders/" + id), customer)); + assertEquals("PICKED_UP", customersView.get("state")); + Map delivery = (Map) customersView.get("delivery"); + assertEquals("lis-courier", delivery.get("courierName")); + assertEquals("+15550111", delivery.get("courierPhone")); + Map couriersView = Trips.json(mvc, People.asCourier(get("/api/courier/active"), courier)); + assertEquals("+15550111", couriersView.get("customerPhone")); + Map adminsView = Trips.json(mvc, People.asAdmin(get("/api/orders/" + id), DemoAccounts.ADMIN)); + assertEquals(null, ((Map) adminsView.get("delivery")).get("courierPhone")); + // Nobody else sees the order at all. + String stranger = People.customer(accounts, "lis-stranger"); + mvc.perform(People.asCustomer(get("/api/orders/" + id), stranger)) + .andExpect(status().isNotFound()); + // Not yet something to rate. + mvc.perform(People.asCustomer(Trips.body(post("/api/orders/" + id + "/rate"), "{\"stars\":5}"), + customer)).andExpect(status().isConflict()); + + mvc.perform(People.asCourier(post("/api/courier/deliveries/" + id + "/complete"), courier)) + .andExpect(jsonPath("$.state").value("COMPLETED")) + .andExpect(jsonPath("$.paymentStatus").value("PAID")); + Map delivered = Trips.json(mvc, People.asCustomer(get("/api/orders/" + id), customer)); + assertEquals("DELIVERED", delivered.get("state")); + assertEquals("PAID", delivered.get("paymentStatus")); + assertEquals(null, ((Map) delivered.get("delivery")).get("courierPhone")); + assertEquals(0, Trips.list(mvc, People.asCustomer(get("/api/orders/active"), customer)).size()); + mvc.perform(People.asCustomer(get("/api/orders/history"), customer)) + .andExpect(jsonPath("$[0].id").value(id)) + .andExpect(jsonPath("$[0].state").value("DELIVERED")); + + mvc.perform(People.asCustomer(Trips.body(post("/api/orders/" + id + "/rate"), "{\"stars\":9}"), + customer)).andExpect(status().isBadRequest()); + mvc.perform(People.asCustomer(Trips.body(post("/api/orders/" + id + "/rate"), "{\"stars\":4}"), + customer)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.stars").value(4)); + mvc.perform(People.asCustomer(Trips.body(post("/api/orders/" + id + "/rate"), "{\"stars\":1}"), + customer)).andExpect(status().isConflict()); + + // The courier is paid the delivery fee and the tip, not the food. + Map earned = Trips.json(mvc, People.asCourier(get("/api/courier/earnings"), courier)); + assertEquals(Trips.number(delivered, "deliveryFeeCents") + Trips.number(delivered, "tipCents"), + Trips.number(earned, "totalCents")); + assertEquals(1L, Trips.number(earned, "deliveries")); + } + + @Test + void aKitchenThatSaysNothingTurnsTheOrderDown() throws Exception { + // Seville. + String customer = People.customer(accounts, "svq-customer"); + Trips.Trip trip = Trips.trip(37.3891, -5.9845, 37.3772, -5.9869); + String id = (String) Trips.place(mvc, customer, trip).get("id"); + db.execute("UPDATE customer_order SET placed_at = 1 WHERE id = ?", new Object[] {id}); + orders.sweep(); + mvc.perform(People.asCustomer(get("/api/orders/" + id), customer)) + .andExpect(jsonPath("$.state").value("REJECTED")) + .andExpect(jsonPath("$.paymentStatus").value("RELEASED")); + } + + @Test + void anOfferPassesFromCourierToCourierThenGivesUp() throws Exception { + // Madrid, with two couriers: one beside the kitchen and one a kilometre off. + String customer = People.customer(accounts, "mad-customer"); + String near = People.courier(accounts, "mad-near"); + String far = People.courier(accounts, "mad-far"); + Trips.online(mvc, near, 40.4169, -3.7036); + Trips.online(mvc, far, 40.4260, -3.7036); + + String id = Trips.ask(mvc, customer, Trips.trip(40.4168, -3.7038, 40.4300, -3.6900)); + // The nearer one is asked first, and only that one. + mvc.perform(People.asCourier(get("/api/courier/active"), near)) + .andExpect(jsonPath("$.id").value(id)); + mvc.perform(People.asCourier(get("/api/courier/active"), far)) + .andExpect(jsonPath("$.state").value("NONE")); + // An offer is one courier's. Another cannot take it. + mvc.perform(People.asCourier(post("/api/courier/offers/" + id + "/accept"), far)) + .andExpect(status().isNotFound()); + + // Passing on it sends it straight to the other. + mvc.perform(People.asCourier(post("/api/courier/offers/" + id + "/decline"), near)) + .andExpect(jsonPath("$.state").value("NONE")); + mvc.perform(People.asCourier(get("/api/courier/active"), far)) + .andExpect(jsonPath("$.id").value(id)) + .andExpect(jsonPath("$.state").value("OFFERED")); + + // That one says nothing. Time is moved on by hand: the offer lapsed. + db.execute("UPDATE delivery SET offer_expires_at = 1 WHERE id = ?", new Object[] {id}); + mvc.perform(People.asCourier(post("/api/courier/offers/" + id + "/accept"), far)) + .andExpect(status().isConflict()); + deliveries.tick(); + mvc.perform(People.asCustomer(get("/api/orders/" + id), customer)) + .andExpect(jsonPath("$.state").value("READY")) + .andExpect(jsonPath("$.delivery.state").value("REQUESTED")); + + // And when the search runs out of time, the order ends and the card + // is let go. + db.execute("UPDATE delivery SET search_until = 1 WHERE id = ?", new Object[] {id}); + deliveries.tick(); + mvc.perform(People.asCustomer(get("/api/orders/" + id), customer)) + .andExpect(jsonPath("$.state").value("NO_COURIERS")) + .andExpect(jsonPath("$.paymentStatus").value("RELEASED")); + assertEquals(0, Trips.list(mvc, People.asCustomer(get("/api/orders/active"), customer)).size()); + } + + @Test + void aCourierWhoGivesUpSendsTheOrderOnToTheNext() throws Exception { + // Rome. + String customer = People.customer(accounts, "rom-customer"); + String first = People.courier(accounts, "rom-first"); + String second = People.courier(accounts, "rom-second"); + Trips.online(mvc, first, 41.9028, 12.4964); + String id = Trips.ask(mvc, customer, Trips.trip(41.9030, 12.4960, 41.8902, 12.4922)); + mvc.perform(People.asCourier(post("/api/courier/offers/" + id + "/accept"), first)) + .andExpect(status().isOk()); + Trips.online(mvc, second, 41.9040, 12.4970); + mvc.perform(People.asCourier(post("/api/courier/deliveries/" + id + "/cancel"), first)) + .andExpect(status().isOk()); + mvc.perform(People.asCourier(get("/api/courier/active"), first)) + .andExpect(jsonPath("$.state").value("NONE")); + mvc.perform(People.asCourier(get("/api/courier/active"), second)) + .andExpect(jsonPath("$.id").value(id)) + .andExpect(jsonPath("$.state").value("OFFERED")); + mvc.perform(People.asCustomer(get("/api/orders/" + id), customer)) + .andExpect(jsonPath("$.state").value("READY")); + } + + @Test + void aCustomerCancelsBeforeTheKitchenAnswers() throws Exception { + // Naples. + String customer = People.customer(accounts, "nap-customer"); + String stranger = People.customer(accounts, "nap-stranger"); + Trips.Trip trip = Trips.trip(40.8518, 14.2681, 40.8400, 14.2500); + Trips.Kitchen kitchen = Trips.kitchen(mvc, trip.fromLat, trip.fromLng); + String id = (String) Trips.place(mvc, customer, trip).get("id"); + // Somebody else's order does not exist, to read or to cancel. + mvc.perform(People.asCustomer(post("/api/orders/" + id + "/cancel"), stranger)) + .andExpect(status().isNotFound()); + mvc.perform(People.asCustomer(post("/api/orders/" + id + "/cancel"), customer)) + .andExpect(jsonPath("$.state").value("CANCELLED_BY_CUSTOMER")) + .andExpect(jsonPath("$.paymentStatus").value("RELEASED")); + mvc.perform(People.asMerchant(Trips.body(post("/api/merchant/orders/" + id + "/accept"), + "{\"prepMinutes\":10}"), kitchen.merchant)).andExpect(status().isConflict()); + } + + @Test + void anAdminCancelEndsTheDeliveryToo() throws Exception { + // Milan. + String customer = People.customer(accounts, "mil-customer"); + String courier = People.courier(accounts, "mil-courier"); + Trips.online(mvc, courier, 45.4642, 9.1900); + String id = Trips.ask(mvc, customer, Trips.trip(45.4641, 9.1899, 45.4800, 9.2000)); + mvc.perform(People.asCourier(post("/api/courier/offers/" + id + "/accept"), courier)) + .andExpect(status().isOk()); + mvc.perform(People.asAdmin(Trips.body(post("/api/admin/orders/" + id + "/cancel"), + "{\"reason\":\"Kitchen fire\"}"), DemoAccounts.ADMIN)) + .andExpect(jsonPath("$.state").value("CANCELLED_BY_ADMIN")); + mvc.perform(People.asCourier(get("/api/courier/active"), courier)) + .andExpect(jsonPath("$.state").value("NONE")); + mvc.perform(People.asCourier(post("/api/courier/deliveries/" + id + "/arrived"), courier)) + .andExpect(status().isConflict()); + // Free again, so the next order goes to the same courier. + String next = Trips.ask(mvc, customer, Trips.trip(45.4641, 9.1899, 45.4700, 9.1800)); + mvc.perform(People.asCourier(get("/api/courier/active"), courier)) + .andExpect(jsonPath("$.id").value(next)); + } + + @Test + void anOrderNeedsAProvedPhoneAnOpenKitchenAndADoorInReach() throws Exception { + // Paris. + mvc.perform(Trips.body(post("/api/account/register"), + "{\"email\":\"unproved@test.example\",\"password\":\"a-long-password\"," + + "\"displayName\":\"Unproved\",\"phone\":\"+15550112\"}")) + .andExpect(status().isOk()); + Trips.Trip trip = Trips.trip(48.8566, 2.3522, 48.8606, 2.3376); + mvc.perform(People.asCustomer(Trips.body(post("/api/orders"), Trips.cart(mvc, trip)), + "unproved@test.example")).andExpect(status().isForbidden()); + String customer = People.customer(accounts, "par-customer"); + // Further than anyone delivers. + mvc.perform(People.asCustomer(Trips.body(post("/api/orders/quote"), + Trips.cart(mvc, Trips.trip(48.8566, 2.3522, 49.8566, 2.3522))), customer)) + .andExpect(status().isBadRequest()); + // A closed kitchen takes no orders. + Trips.Kitchen kitchen = Trips.kitchen(mvc, trip.fromLat, trip.fromLng); + mvc.perform(People.asMerchant(post("/api/merchant/paused?paused=true"), kitchen.merchant)) + .andExpect(status().isOk()); + mvc.perform(People.asCustomer(Trips.body(post("/api/orders"), Trips.cart(mvc, trip)), customer)) + .andExpect(status().isConflict()); + mvc.perform(People.asMerchant(post("/api/merchant/paused?paused=false"), kitchen.merchant)) + .andExpect(status().isOk()); + // Three on the way at once, and no more. + for (int iter = 0; iter < 3; iter++) { + Trips.place(mvc, customer, trip); + } + mvc.perform(People.asCustomer(Trips.body(post("/api/orders"), Trips.cart(mvc, trip)), customer)) + .andExpect(status().isConflict()); + } +} diff --git a/scripts/savor/backend/src/test/java/com/codenameone/examples/savor/PaymentsTest.java b/scripts/savor/backend/src/test/java/com/codenameone/examples/savor/PaymentsTest.java new file mode 100644 index 00000000000..dbc80e84014 --- /dev/null +++ b/scripts/savor/backend/src/test/java/com/codenameone/examples/savor/PaymentsTest.java @@ -0,0 +1,821 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor; + +import com.codename1.backend.DataSource; +import com.codename1.backend.annotations.Autowired; +import com.codename1.backend.test.BackendTest; +import com.codename1.backend.test.MockMvc; +import com.codename1.backend.test.MockRequestBuilder; +import com.codenameone.examples.savor.account.Accounts; +import com.codenameone.examples.savor.account.DemoAccounts; +import com.codenameone.examples.savor.pay.Payments; +import com.codenameone.examples.savor.delivery.Deliveries; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +import static com.codename1.backend.test.MockMvcRequestBuilders.get; +import static com.codename1.backend.test.MockMvcRequestBuilders.post; +import static com.codename1.backend.test.MockMvcRequestBuilders.put; +import static com.codename1.backend.test.MockMvcResultMatchers.jsonPath; +import static com.codename1.backend.test.MockMvcResultMatchers.status; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/// Paying: the cards an account keeps, the charge when a delivery completes, the +/// tip after it, the refund an admin can make, and what all of it earns the +/// courier. Against the simulated processor, which is what a server with no +/// key configured runs. +@BackendTest +class PaymentsTest { + @Autowired + private MockMvc mvc; + @Autowired + private Accounts accounts; + @Autowired + private Deliveries deliveries; + @Autowired + private Payments payments; + @Autowired + private DataSource db; + + private MockRequestBuilder admin(MockRequestBuilder request) { + return People.asAdmin(request, DemoAccounts.ADMIN); + } + + private long setupsOf(String username) throws Exception { + Map row = db.queryOne("SELECT COUNT(*) AS n FROM payment_setup WHERE username = ?", + new Object[] {username}); + return ((Number) row.get("n")).longValue(); + } + + /// Sets what the delivery `id` and its order say of their payment, as a + /// write the server lost would leave them. + private void paymentStatus(String id, String status) throws Exception { + db.execute("UPDATE delivery SET payment_status = ? WHERE id = ?", new Object[] {status, id}); + db.execute("UPDATE customer_order SET payment_status = ? WHERE id = ?", + new Object[] {status, id}); + } + + @Test + void openSetupsAreCappedAndTheFinishedAndAbandonedAreSwept() throws Exception { + String customer = People.customer(accounts, "setup-looper"); + // Five open at once is as many as an account may have; each is a + // session made at the processor. + String first = null; + for (int iter = 0; iter < 5; iter++) { + Map setup = Trips.json(mvc, People.asCustomer(post("/api/payments/methods/setup"), + customer)); + first = first == null ? (String) setup.get("id") : first; + } + mvc.perform(People.asCustomer(post("/api/payments/methods/setup"), customer)) + .andExpect(status().is(429)); + // Finishing one makes room for another. + mvc.perform(People.asCustomer(Trips.body(post("/api/payments/methods/setup/" + first + + "/complete"), Trips.card(Trips.VISA)), customer)) + .andExpect(status().isOk()); + String last = (String) Trips.json(mvc, People.asCustomer( + post("/api/payments/methods/setup"), customer)).get("id"); + assertEquals(6L, setupsOf(customer)); + // So does an hour going by: abandoned setups do not count. + db.execute("UPDATE payment_setup SET created_at = ? WHERE username = ? AND id <> ?", + new Object[] {Long.valueOf(System.currentTimeMillis() - 90L * 60L * 1000L), + customer, last}); + Trips.json(mvc, People.asCustomer(post("/api/payments/methods/setup"), customer)); + + // The sweep deletes the finished one, and an abandoned one once it is + // two hours old; younger ones, and the open ones, stay. + payments.sweepSetups(); + assertEquals(6L, setupsOf(customer)); + db.execute("UPDATE payment_setup SET created_at = ? WHERE username = ? AND created_at < ?", + new Object[] {Long.valueOf(System.currentTimeMillis() - 3L * 60L * 60L * 1000L), + customer, Long.valueOf(System.currentTimeMillis() - 60L * 60L * 1000L)}); + payments.sweepSetups(); + assertEquals(2L, setupsOf(customer)); + mvc.perform(People.asCustomer(Trips.body(post("/api/payments/methods/setup/" + last + + "/complete"), Trips.card(Trips.MASTERCARD)), customer)) + .andExpect(status().isOk()); + } + + @Test + void todaysTripsCountTodaysDeliveriesAlone() throws Exception { + // Tallinn. + String customer = People.customer(accounts, "tll-pay-customer"); + String courier = People.courier(accounts, "tll-pay-courier"); + Trips.online(mvc, courier, 59.4370, 24.7536); + String yesterday = (String) Trips.complete(mvc, customer, courier, Trips.trip(59.4370, + 24.7536, 59.4470, 24.7636)).get("id"); + Trips.complete(mvc, customer, courier, Trips.trip(59.4370, 24.7536, 59.4470, 24.7636)); + long now = System.currentTimeMillis(); + db.execute("UPDATE delivery SET updated_at = ? WHERE id = ?", new Object[] { + Long.valueOf(now - now % (24L * 60L * 60L * 1000L) - 60L * 60L * 1000L), yesterday}); + Map earned = Trips.json(mvc, People.asCourier(get("/api/courier/earnings"), courier)); + // Both are trips the courier made; one of them was today's, and the + // header's count is of the deliveries its amount is made of. + assertEquals(2L, Trips.number(earned, "deliveries")); + assertEquals(1L, Trips.number(earned, "todayDeliveries")); + Trips.offline(mvc, courier); + } + + @Test + void cardsAreSavedChosenAndRemoved() throws Exception { + String customer = People.customer(accounts, "card-holder"); + String other = People.customer(accounts, "card-other"); + mvc.perform(People.asCustomer(get("/api/payments/config"), customer)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.provider").value("simulated")) + .andExpect(jsonPath("$.currency").value("USD")); + // Cash, always, and the default until there is a card. + mvc.perform(People.asCustomer(get("/api/payments/methods"), customer)) + .andExpect(jsonPath("$.length()").value(1)) + .andExpect(jsonPath("$[0].id").value("cash")) + .andExpect(jsonPath("$[0].kind").value("cash")) + .andExpect(jsonPath("$[0].isDefault").value(true)); + + // A card that is refused is not saved, and says why; the same setup + // then takes a card that is good. + Map setup = Trips.json(mvc, People.asCustomer(post("/api/payments/methods/setup"), customer)); + assertEquals(Boolean.FALSE, setup.get("hosted")); + String complete = "/api/payments/methods/setup/" + setup.get("id") + "/complete"; + mvc.perform(People.asCustomer(Trips.body(post(complete), Trips.card(Trips.DECLINED)), customer)) + .andExpect(status().is(402)); + mvc.perform(People.asCustomer(Trips.body(post(complete), Trips.card("4242424242424241")), + customer)) + .andExpect(status().is(402)); + // Somebody else cannot finish it for them. + mvc.perform(People.asCustomer(Trips.body(post(complete), Trips.card(Trips.VISA)), other)) + .andExpect(status().isNotFound()); + mvc.perform(People.asCustomer(get("/api/payments/methods"), customer)) + .andExpect(jsonPath("$.length()").value(1)); + Map visa = Trips.json(mvc, People.asCustomer(Trips.body(post(complete), + Trips.card(Trips.VISA)), customer)); + assertEquals("card", visa.get("kind")); + assertEquals("Visa", visa.get("brand")); + assertEquals("4242", visa.get("last4")); + assertEquals("Visa 4242", visa.get("label")); + assertEquals(12L, Trips.number(visa, "expMonth")); + // The first card saved is the one to pay with. + assertEquals(Boolean.TRUE, visa.get("isDefault")); + // A setup is used once. + mvc.perform(People.asCustomer(Trips.body(post(complete), Trips.card(Trips.VISA)), customer)) + .andExpect(status().isNotFound()); + + Map second = Trips.addCard(mvc, customer, Trips.MASTERCARD); + assertEquals("Mastercard", second.get("brand")); + assertEquals(Boolean.FALSE, second.get("isDefault")); + String visaId = (String) visa.get("id"); + String secondId = (String) second.get("id"); + mvc.perform(People.asCustomer(post("/api/payments/methods/" + secondId + "/default"), customer)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.isDefault").value(true)); + mvc.perform(People.asCustomer(get("/api/payments/methods"), customer)) + .andExpect(jsonPath("$.length()").value(3)) + .andExpect(jsonPath("$[0].id").value(visaId)) + .andExpect(jsonPath("$[0].isDefault").value(false)) + .andExpect(jsonPath("$[1].isDefault").value(true)) + .andExpect(jsonPath("$[2].id").value("cash")) + .andExpect(jsonPath("$[2].isDefault").value(false)); + + // A card is its owner's: nobody else sees it, chooses it or removes it. + mvc.perform(People.asCustomer(get("/api/payments/methods"), other)) + .andExpect(jsonPath("$.length()").value(1)); + mvc.perform(People.asCustomer(post("/api/payments/methods/" + visaId + "/default"), other)) + .andExpect(status().isNotFound()); + mvc.perform(People.asCustomer(post("/api/payments/methods/" + visaId + "/remove"), other)) + .andExpect(status().isNotFound()); + mvc.perform(People.asCustomer(Trips.body(post("/api/orders"), Trips.cart(mvc, Trips.trip(52.52, + 13.405, 52.53, 13.42, "\"paymentMethodId\":\"" + visaId + "\""))), other)) + .andExpect(status().isBadRequest()); + + // Removing the default leaves cash as the default; cash stays. + mvc.perform(People.asCustomer(post("/api/payments/methods/" + secondId + "/remove"), customer)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(secondId)); + mvc.perform(People.asCustomer(get("/api/payments/methods"), customer)) + .andExpect(jsonPath("$.length()").value(2)) + .andExpect(jsonPath("$[1].id").value("cash")) + .andExpect(jsonPath("$[1].isDefault").value(true)); + mvc.perform(People.asCustomer(post("/api/payments/methods/cash/remove"), customer)) + .andExpect(status().isBadRequest()); + mvc.perform(People.asCustomer(post("/api/payments/methods/cash/default"), customer)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.isDefault").value(true)); + } + + @Test + void aPlacedOrdersReceiptShowsTheHoldBeforeAnyDelivery() throws Exception { + // Vienna. Nobody has answered the order, so there is no delivery yet. + String customer = People.customer(accounts, "vie-customer"); + Trips.addCard(mvc, customer, Trips.VISA); + String id = (String) Trips.place(mvc, customer, Trips.trip(48.2082, 16.3738, 48.2150, + 16.3800)).get("id"); + Map receipt = Trips.receipt(mvc, customer, id); + assertEquals("authorized", receipt.get("status")); + assertEquals("Visa 4242", receipt.get("methodLabel")); + // Another customer still cannot read it. + String stranger = People.customer(accounts, "vie-stranger"); + mvc.perform(People.asCustomer(get("/api/payments/receipts/" + id), stranger)) + .andExpect(status().isNotFound()); + } + + @Test + void aDeliveryIsChargedTippedAndRefundedAndTheCourierIsPaid() throws Exception { + // Berlin. + String customer = People.customer(accounts, "ber-customer"); + String courier = People.courier(accounts, "ber-courier"); + String stranger = People.customer(accounts, "ber-stranger"); + Trips.addCard(mvc, customer, Trips.VISA); + Trips.online(mvc, courier, 52.5200, 13.4050); + + // No method named: the default, which is now the card. + Map done = Trips.complete(mvc, customer, courier, Trips.trip(52.5200, 13.4050, 52.5300, + 13.4200)); + String id = (String) done.get("id"); + // The courier is shown the delivery fee, which is theirs; the + // customer paid the order's total, held at checkout and taken now. + long fare = Trips.number(done, "fareCents"); + Map order = Trips.json(mvc, People.asCustomer(get("/api/orders/" + id), customer)); + long total = Trips.number(order, "totalCents"); + assertEquals(fare, Trips.number(order, "deliveryFeeCents")); + assertEquals("PAID", done.get("paymentStatus")); + assertEquals("PAID", order.get("paymentStatus")); + assertEquals("Visa 4242", done.get("paymentMethodLabel")); + assertEquals(Boolean.FALSE, done.get("cash")); + assertEquals("B", done.get("courierPhotoInitials")); + + // The receipt adds up, part by part. + Map receipt = Trips.receipt(mvc, customer, id); + assertEquals("paid", receipt.get("status")); + assertEquals("Visa 4242", receipt.get("methodLabel")); + assertEquals(Trips.DISH_CENTS, Trips.number(receipt, "subtotal")); + assertEquals(fare, Trips.number(receipt, "deliveryFee")); + assertEquals(0L, Trips.number(receipt, "tip")); + assertEquals(total, Trips.number(receipt, "subtotal") + Trips.number(receipt, "deliveryFee") + + Trips.number(receipt, "serviceFee") + Trips.number(receipt, "smallOrderFee") + + Trips.number(receipt, "tax") - Trips.number(receipt, "discount")); + assertEquals(total, Trips.number(receipt, "total")); + assertTrue(Trips.number(receipt, "paidAt") > 0L); + assertEquals("There", receipt.get("dropoffName")); + assertEquals("ber-courier", receipt.get("courierName")); + // It is the customer's, and nobody else's -- the courier's included. + mvc.perform(People.asCustomer(get("/api/payments/receipts/" + id), stranger)) + .andExpect(status().isNotFound()); + mvc.perform(People.asCourier(get("/api/payments/receipts/" + id), courier)) + .andExpect(status().isNotFound()); + mvc.perform(People.asCustomer(get("/api/payments/receipts"), stranger)) + .andExpect(jsonPath("$.length()").value(0)); + mvc.perform(People.asCustomer(get("/api/payments/receipts"), customer)) + .andExpect(jsonPath("$.length()").value(1)) + .andExpect(jsonPath("$[0].orderId").value(id)); + + // A tip, once, by the customer. + mvc.perform(People.asCustomer(Trips.body(post("/api/orders/" + id + "/tip"), + "{\"amount\":300}"), stranger)) + .andExpect(status().isNotFound()); + mvc.perform(People.asCustomer(Trips.body(post("/api/orders/" + id + "/tip"), + "{\"amount\":0}"), customer)) + .andExpect(status().isBadRequest()); + Map tipped = Trips.json(mvc, People.asCustomer(Trips.body(post("/api/orders/" + id + "/tip"), + "{\"amount\":300}"), customer)); + assertEquals(300L, Trips.number(tipped, "tip")); + assertEquals(total + 300L, Trips.number(tipped, "total")); + mvc.perform(People.asCustomer(Trips.body(post("/api/orders/" + id + "/tip"), + "{\"amount\":300}"), customer)) + .andExpect(status().isConflict()); + mvc.perform(People.asCustomer(get("/api/orders/" + id), customer)) + .andExpect(jsonPath("$.delivery.tip").value(300)); + + // The courier earns the delivery fee and all the tip: the service's + // commission is taken from the kitchen's side of the order. + long earned = fare + 300L; + Map summary = Trips.json(mvc, People.asCourier(get("/api/courier/earnings/summary"), + courier)); + assertEquals("USD", summary.get("currency")); + assertEquals(earned, Trips.number(summary, "today")); + assertEquals(earned, Trips.number(summary, "week")); + assertEquals(earned, Trips.number(summary, "month")); + assertEquals(earned, Trips.number(summary, "balance")); + assertEquals(1L, Trips.number(summary, "trips")); + assertEquals(300L, Trips.number(summary, "tips")); + assertEquals(1d, ((Number) summary.get("acceptanceRate")).doubleValue()); + List daily = (List) summary.get("daily"); + assertEquals(14, daily.size()); + Map today = (Map) daily.get(13); + assertEquals(Days.iso(System.currentTimeMillis()), today.get("date")); + assertEquals(earned, Trips.number(today, "amount")); + assertEquals(1L, Trips.number(today, "trips")); + assertEquals(0L, Trips.number((Map) daily.get(0), "amount")); + // The older call reports the same share, without the tip. + mvc.perform(People.asCourier(get("/api/courier/earnings"), courier)) + .andExpect(jsonPath("$.totalCents").value((int) (earned - 300L))); + + // Nor can an account that is owed money be closed: nobody could cash + // it out afterwards. + mvc.perform(People.asCourier(Trips.body(post("/api/account/delete"), + "{\"password\":\"" + People.PASSWORD + "\"}"), courier)) + .andExpect(status().isConflict()); + + // Cashing out needs somewhere to send it, and empties the balance. + mvc.perform(People.asCourier(get("/api/courier/payouts"), courier)) + .andExpect(jsonPath("$.length()").value(0)); + mvc.perform(People.asCourier(post("/api/courier/payouts"), courier)) + .andExpect(status().isConflict()); + mvc.perform(People.asCourier(get("/api/courier/payout-account"), courier)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.accountLast4").value("")); + mvc.perform(People.asCourier(Trips.body(put("/api/courier/payout-account"), + "{\"holder\":\"B Courier\",\"bankName\":\"Spree Bank\"," + + "\"accountLast4\":\"12a4\"}"), courier)) + .andExpect(status().isBadRequest()); + mvc.perform(People.asCourier(Trips.body(put("/api/courier/payout-account"), + "{\"holder\":\"B Courier\",\"bankName\":\"Spree Bank\"," + + "\"accountLast4\":\"1234\"}"), courier)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.bankName").value("Spree Bank")); + Map payout = Trips.json(mvc, People.asCourier(post("/api/courier/payouts"), courier)); + assertEquals(earned, Trips.number(payout, "amount")); + assertEquals("paid", payout.get("status")); + assertEquals("Spree Bank ****1234", payout.get("destination")); + mvc.perform(People.asCourier(post("/api/courier/payouts"), courier)) + .andExpect(status().isConflict()); + mvc.perform(People.asCourier(get("/api/courier/payouts"), courier)) + .andExpect(jsonPath("$.length()").value(1)) + .andExpect(jsonPath("$[0].id").value((String) payout.get("id"))); + summary = Trips.json(mvc, People.asCourier(get("/api/courier/earnings/summary"), courier)); + assertEquals(0L, Trips.number(summary, "balance")); + assertEquals(earned, Trips.number(summary, "today")); + + // An admin sees every payment, and returns this one, once, saying why. + List all = Trips.list(mvc, admin(get("/api/admin/payments"))); + boolean listed = false; + for (int iter = 0; iter < all.size(); iter++) { + Map row = (Map) all.get(iter); + if (id.equals(row.get("orderId"))) { + listed = true; + assertEquals(customer, row.get("customerUsername")); + } + } + assertTrue(listed, "the admin's list has the order"); + mvc.perform(admin(Trips.body(post("/api/admin/orders/" + id + "/refund"), "{}"))) + .andExpect(status().isBadRequest()); + mvc.perform(admin(Trips.body(post("/api/admin/orders/" + id + "/refund"), + "{\"reason\":\"It arrived cold\"}"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("refunded")); + mvc.perform(admin(Trips.body(post("/api/admin/orders/" + id + "/refund"), + "{\"reason\":\"Twice\"}"))) + .andExpect(status().isConflict()); + assertEquals("refunded", Trips.receipt(mvc, customer, id).get("status")); + mvc.perform(People.asCustomer(get("/api/orders/" + id), customer)) + .andExpect(jsonPath("$.paymentStatus").value("REFUNDED")); + + // The refund took the fare and the tip back, so neither is earned any + // more: not in the day's totals, and not in the balance, which now owes + // what was cashed out. The trip was still driven. + summary = Trips.json(mvc, People.asCourier(get("/api/courier/earnings/summary"), courier)); + assertEquals(0L, Trips.number(summary, "today")); + assertEquals(0L, Trips.number(summary, "month")); + assertEquals(0L, Trips.number((Map) ((List) summary.get("daily")).get(13), "amount")); + assertEquals(1L, Trips.number((Map) ((List) summary.get("daily")).get(13), "trips")); + assertEquals(0L, Trips.number(summary, "tips")); + assertEquals(-earned, Trips.number(summary, "balance")); + mvc.perform(People.asCourier(post("/api/courier/payouts"), courier)) + .andExpect(status().isConflict()); + Trips.offline(mvc, courier); + } + + /// A card tip whose charge nobody can vouch for is retried with the + /// delivery's card, so that card stays, and so does its account, until the + /// tip is settled one way or the other. + @Test + void aCardWithATipNobodyCanVouchForStays() throws Exception { + // Bergen. + String customer = People.customer(accounts, "bgo-tip-customer"); + String courier = People.courier(accounts, "bgo-tip-courier"); + String card = (String) Trips.addCard(mvc, customer, Trips.VISA).get("id"); + Trips.online(mvc, courier, 60.3913, 5.3221); + String id = (String) Trips.complete(mvc, customer, courier, Trips.trip(60.3913, 5.3221, + 60.4013, 5.3321)).get("id"); + Trips.offline(mvc, courier); + db.execute("UPDATE delivery SET tip_cents = 300 WHERE id = ?", new Object[] {id}); + mvc.perform(People.asCustomer(post("/api/payments/methods/" + card + "/remove"), customer)) + .andExpect(status().isConflict()); + mvc.perform(People.asCustomer(Trips.body(post("/api/account/delete"), + "{\"password\":\"" + People.PASSWORD + "\"}"), customer)) + .andExpect(status().isConflict()); + + // The charge came back: nothing is left to ask about. + db.execute("UPDATE payment SET tip_ref = 'sim_ch_tip' WHERE delivery_id = ?", + new Object[] {id}); + mvc.perform(People.asCustomer(post("/api/payments/methods/" + card + "/remove"), customer)) + .andExpect(status().isOk()); + } + + @Test + void aDeliveryIsNotRefundedWhileItsTipIsBeingCharged() throws Exception { + // Oslo. + String customer = People.customer(accounts, "osl-customer"); + String courier = People.courier(accounts, "osl-courier"); + Trips.addCard(mvc, customer, Trips.VISA); + Trips.online(mvc, courier, 59.9139, 10.7522); + String id = (String) Trips.complete(mvc, customer, courier, Trips.trip(59.9139, 10.7522, + 59.9239, 10.7622)).get("id"); + + // A tip as it stands between being claimed and its charge coming back: + // on the delivery, and with no charge recorded for it. + db.execute("UPDATE delivery SET tip_cents = 300 WHERE id = ?", new Object[] {id}); + mvc.perform(admin(Trips.body(post("/api/admin/orders/" + id + "/refund"), + "{\"reason\":\"Too soon\"}"))) + .andExpect(status().isConflict()); + // Refused whole: the delivery is still paid, so the refund can be asked + // for again and the tip's charge has a paid delivery to land on. + mvc.perform(People.asCustomer(get("/api/orders/" + id), customer)) + .andExpect(jsonPath("$.paymentStatus").value("PAID")); + // Nor is it the courier's to cash out: nobody knows it was charged. + Map before = Trips.json(mvc, People.asCourier(get("/api/courier/earnings/summary"), + courier)); + assertEquals(0L, Trips.number(before, "tips")); + long fareOnly = Trips.number(before, "balance"); + // Nor earned today: the day's total is what the balance grew by. + assertEquals(fareOnly, Trips.number(before, "today")); + assertEquals(fareOnly, Trips.number(before, "month")); + + // The charge came back. + db.execute("UPDATE payment SET tip_ref = 'sim_ch_tip' WHERE delivery_id = ?", + new Object[] {id}); + Map after = Trips.json(mvc, People.asCourier(get("/api/courier/earnings/summary"), + courier)); + assertEquals(300L, Trips.number(after, "tips")); + assertEquals(fareOnly + 300L, Trips.number(after, "balance")); + assertEquals(fareOnly + 300L, Trips.number(after, "today")); + mvc.perform(admin(Trips.body(post("/api/admin/orders/" + id + "/refund"), + "{\"reason\":\"Now\"}"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("refunded")); + Trips.offline(mvc, courier); + } + + @Test + void theAdminSeesWhatTheCourierEarnedAndNoMore() throws Exception { + // Vilnius. + String customer = People.customer(accounts, "vno-customer"); + String courier = People.courier(accounts, "vno-courier"); + Trips.addCard(mvc, customer, Trips.VISA); + Trips.online(mvc, courier, 54.6872, 25.2797); + String paid = (String) Trips.complete(mvc, customer, courier, Trips.trip(54.6872, 25.2797, + 54.6972, 25.2897)).get("id"); + String pending = (String) Trips.complete(mvc, customer, courier, Trips.trip(54.6872, + 25.2797, 54.6972, 25.2897)).get("id"); + String refunded = (String) Trips.complete(mvc, customer, courier, Trips.trip(54.6872, + 25.2797, 54.6972, 25.2897)).get("id"); + // A charge nobody has answered yet, a delivery refunded, and a tip on the + // paid delivery whose charge has not come back. + paymentStatus(pending, "PENDING"); + mvc.perform(admin(Trips.body(post("/api/admin/orders/" + refunded + "/refund"), + "{\"reason\":\"Wrong address\"}"))) + .andExpect(status().isOk()); + db.execute("UPDATE delivery SET tip_cents = 300 WHERE id = ?", new Object[] {paid}); + + long share = ((Number) db.queryOne("SELECT courier_cents FROM delivery WHERE id = ?", + new Object[] {paid}).get("courier_cents")).longValue(); + Map summary = Trips.json(mvc, People.asCourier(get("/api/courier/earnings/summary"), + courier)); + Map detail = Trips.json(mvc, admin(get("/api/admin/users/" + courier))); + // The paid delivery's share alone, on both screens. + assertEquals(share, Trips.number(summary, "balance")); + assertEquals(share, Trips.number(summary, "month")); + assertEquals(share, Trips.number(detail, "earned")); + + // Once the tip's charge is on record it is earned on both. + db.execute("UPDATE payment SET tip_ref = 'sim_ch_tip' WHERE delivery_id = ?", + new Object[] {paid}); + summary = Trips.json(mvc, People.asCourier(get("/api/courier/earnings/summary"), courier)); + detail = Trips.json(mvc, admin(get("/api/admin/users/" + courier))); + assertEquals(share + 300L, Trips.number(summary, "balance")); + assertEquals(share + 300L, Trips.number(detail, "earned")); + Trips.offline(mvc, courier); + } + + @Test + void aRefundTheServerNeverRecordedIsAskedForAgain() throws Exception { + // Bergen. + String customer = People.customer(accounts, "bgo-customer"); + String courier = People.courier(accounts, "bgo-courier"); + Trips.addCard(mvc, customer, Trips.VISA); + Trips.online(mvc, courier, 60.3913, 5.3221); + String id = (String) Trips.complete(mvc, customer, courier, Trips.trip(60.3913, 5.3221, + 60.4013, 5.3321)).get("id"); + + // A refund as a server that stopped half way leaves it: the delivery + // marked, the processor perhaps asked, and nothing written down. + paymentStatus(id, "REFUNDED"); + mvc.perform(admin(Trips.body(post("/api/admin/orders/" + id + "/refund"), + "{\"reason\":\"Again\"}"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("refunded")); + // And once it is recorded, it is refunded once like any other. + mvc.perform(admin(Trips.body(post("/api/admin/orders/" + id + "/refund"), + "{\"reason\":\"Twice\"}"))) + .andExpect(status().isConflict()); + Trips.offline(mvc, courier); + } + + @Test + void aRefundTheProcessorHasNotFinishedIsRecordedOnlyOnceItEnds() throws Exception { + // Gothenburg. + String customer = People.customer(accounts, "got-customer"); + String courier = People.courier(accounts, "got-courier"); + String card = (String) Trips.addCard(mvc, customer, Trips.SLOW_REFUND).get("id"); + String method = "\"paymentMethodId\":\"" + card + "\""; + Trips.online(mvc, courier, 57.7089, 11.9746); + String id = (String) Trips.complete(mvc, customer, courier, Trips.trip(57.7089, 11.9746, + 57.7189, 11.9846, method)).get("id"); + + // Taken, and not yet money back: pending, and not to be asked for twice. + mvc.perform(admin(Trips.body(post("/api/admin/orders/" + id + "/refund"), + "{\"reason\":\"Slow\"}"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("refund_pending")); + mvc.perform(People.asCustomer(get("/api/orders/" + id), customer)) + .andExpect(jsonPath("$.paymentStatus").value("REFUND_PENDING")); + mvc.perform(admin(Trips.body(post("/api/admin/orders/" + id + "/refund"), + "{\"reason\":\"Again\"}"))) + .andExpect(status().isConflict()); + // The processor has finished it by the time it is asked. + payments.settleRefunds(); + assertEquals("refunded", Trips.receipt(mvc, customer, id).get("status")); + + // And one the processor then fails is recorded as failed, never as + // refunded, and is not tried again from here. + db.execute("UPDATE payment_method SET token = 'sim_pm_lost_card' WHERE id = ?", + new Object[] {card}); + String lost = (String) Trips.complete(mvc, customer, courier, Trips.trip(57.7089, 11.9746, + 57.7189, 11.9846, method)).get("id"); + mvc.perform(admin(Trips.body(post("/api/admin/orders/" + lost + "/refund"), + "{\"reason\":\"Lost\"}"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("refund_pending")); + payments.settleRefunds(); + assertEquals("refund_failed", Trips.receipt(mvc, customer, lost).get("status")); + mvc.perform(People.asCustomer(get("/api/orders/" + lost), customer)) + .andExpect(jsonPath("$.paymentStatus").value("REFUND_FAILED")); + mvc.perform(admin(Trips.body(post("/api/admin/orders/" + lost + "/refund"), + "{\"reason\":\"Again\"}"))) + .andExpect(status().isConflict()); + Trips.offline(mvc, courier); + } + + @Test + void aCheckoutSentAgainAnswersWithTheOrderItAlreadyPlaced() throws Exception { + // Porto. + String customer = People.customer(accounts, "opo-again-customer"); + String other = People.customer(accounts, "opo-again-other"); + Trips.addCard(mvc, customer, Trips.VISA); + Trips.addCard(mvc, other, Trips.VISA); + String attempt = "\"attemptId\":\"0123456789abcdef0123456789abcdef\""; + Trips.Trip trip = Trips.trip(41.1579, -8.6291, 41.1679, -8.6191, attempt); + String first = (String) Trips.place(mvc, customer, trip).get("id"); + // The answer was lost and the app asks again: the same order, not a second. + assertEquals(first, Trips.place(mvc, customer, trip).get("id")); + assertEquals("0123456789abcdef0123456789abcdef", first); + Map row = db.queryOne("SELECT COUNT(*) AS n FROM customer_order WHERE customer = ?", + new Object[] {customer}); + assertEquals(1L, ((Number) row.get("n")).longValue()); + // Nobody else gets that order by sending its id. + assertEquals(409, Trips.statusOf(mvc, People.asCustomer(Trips.body(post("/api/orders"), + Trips.cart(mvc, trip)), other))); + } + + @Test + void aDeliveryLeftCompletedAndUnpaidIsSettledLater() throws Exception { + // Lisbon. + String customer = People.customer(accounts, "lis-pay-customer"); + String courier = People.courier(accounts, "lis-pay-courier"); + Trips.addCard(mvc, customer, Trips.VISA); + Trips.online(mvc, courier, 38.7223, -9.1393); + Map done = Trips.complete(mvc, customer, courier, Trips.trip(38.7223, -9.1393, 38.7323, + -9.1293)); + String id = (String) done.get("id"); + long total = Trips.number(Trips.json(mvc, People.asCustomer(get("/api/orders/" + id), + customer)), "totalCents"); + + // The server stopped after the delivery completed and before its payment + // was written. + db.execute("DELETE FROM payment WHERE delivery_id = ?", new Object[] {id}); + paymentStatus(id, "PENDING"); + + // Completed a moment ago, it may still be at the processor: left alone. + deliveries.settleLeftBehind(); + mvc.perform(People.asCustomer(get("/api/orders/" + id), customer)) + .andExpect(jsonPath("$.paymentStatus").value("PENDING")); + + db.execute("UPDATE delivery SET updated_at = ? WHERE id = ?", + new Object[] {Long.valueOf(System.currentTimeMillis() - 5L * 60L * 1000L), id}); + deliveries.settleLeftBehind(); + mvc.perform(People.asCustomer(get("/api/orders/" + id), customer)) + .andExpect(jsonPath("$.paymentStatus").value("PAID")); + Map receipt = Trips.receipt(mvc, customer, id); + assertEquals("paid", receipt.get("status")); + assertEquals(total, Trips.number(receipt, "total")); + + // And once: a second pass finds nothing left to settle. + deliveries.settleLeftBehind(); + assertEquals(total, Trips.number(Trips.receipt(mvc, customer, id), "total")); + Trips.offline(mvc, courier); + } + + @Test + void aChargeNobodyCanVouchForIsLeftPendingAndAskedForAgain() throws Exception { + // Prague. + String customer = People.customer(accounts, "prg-pay-customer"); + String courier = People.courier(accounts, "prg-pay-courier"); + String card = (String) Trips.addCard(mvc, customer, Trips.NO_ANSWER_WHEN_CHARGED).get("id"); + Trips.online(mvc, courier, 50.0755, 14.4378); + Map done = Trips.complete(mvc, customer, courier, Trips.trip(50.0755, 14.4378, 50.0855, + 14.4478, "\"paymentMethodId\":\"" + card + "\"")); + String id = (String) done.get("id"); + long total = Trips.number(Trips.json(mvc, People.asCustomer(get("/api/orders/" + id), + customer)), "totalCents"); + + // The processor failed without saying whether it took the money: not + // paid, and not failed either. + assertEquals("COMPLETED", done.get("state")); + assertEquals("PENDING", done.get("paymentStatus")); + // Not money yet, so not the courier's to cash out. + assertEquals(0L, Trips.number(Trips.json(mvc, People.asCourier( + get("/api/courier/earnings/summary"), courier)), "balance")); + // The card is what the delivery will be asked about with, so it stays, + // and so does the account it belongs to. + mvc.perform(People.asCustomer(post("/api/payments/methods/" + card + "/remove"), customer)) + .andExpect(status().isConflict()); + mvc.perform(People.asCustomer(Trips.body(post("/api/account/delete"), + "{\"password\":\"" + People.PASSWORD + "\"}"), customer)) + .andExpect(status().isConflict()); + db.execute("UPDATE delivery SET updated_at = ? WHERE id = ?", + new Object[] {Long.valueOf(System.currentTimeMillis() - 5L * 60L * 1000L), id}); + deliveries.settleLeftBehind(); + mvc.perform(People.asCustomer(get("/api/orders/" + id), customer)) + .andExpect(jsonPath("$.paymentStatus").value("PENDING")); + + // The processor answers again, and the same delivery is asked about. + // The order is taken from the hold made at checkout, so the hold is + // what answers now. + db.execute("UPDATE customer_order SET authorization_ref = ? WHERE id = ?", + new Object[] {"sim_ch_" + id, id}); + deliveries.settleLeftBehind(); + mvc.perform(People.asCustomer(get("/api/orders/" + id), customer)) + .andExpect(jsonPath("$.paymentStatus").value("PAID")); + assertEquals(total, Trips.number(Trips.receipt(mvc, customer, id), "total")); + // Paid now, and the courier's share is in the balance. + assertEquals(Trips.number(done, "fareCents"), Trips.number(Trips.json(mvc, + People.asCourier(get("/api/courier/earnings/summary"), courier)), "balance")); + // Settled, and the card can go. + mvc.perform(People.asCustomer(post("/api/payments/methods/" + card + "/remove"), customer)) + .andExpect(status().isOk()); + Trips.offline(mvc, courier); + } + + @Test + void aTipNobodyCanVouchForStaysOnTheDeliveryUntilItIsAskedForAgain() throws Exception { + // Vienna. + String customer = People.customer(accounts, "vie-pay-customer"); + String courier = People.courier(accounts, "vie-pay-courier"); + String card = (String) Trips.addCard(mvc, customer, Trips.VISA).get("id"); + Trips.online(mvc, courier, 48.2082, 16.3738); + String id = (String) Trips.complete(mvc, customer, courier, Trips.trip(48.2082, 16.3738, + 48.2182, 16.3838)).get("id"); + + // The processor stops answering, and a tip is asked for. + db.execute("UPDATE payment_method SET token = 'sim_pm_fault_now' WHERE id = ?", + new Object[] {card}); + assertEquals(502, Trips.statusOf(mvc, People.asCustomer(Trips.body( + post("/api/orders/" + id + "/tip"), "{\"amount\":300}"), customer))); + // It may have been charged, so it stays on the delivery, which cannot be + // refunded around it, and cannot be swapped for another amount. + assertEquals(300L, Trips.number(Trips.receipt(mvc, customer, id), "tip")); + mvc.perform(admin(Trips.body(post("/api/admin/orders/" + id + "/refund"), + "{\"reason\":\"Too soon\"}"))) + .andExpect(status().isConflict()); + mvc.perform(People.asCustomer(Trips.body(post("/api/orders/" + id + "/tip"), + "{\"amount\":500}"), customer)) + .andExpect(status().isConflict()); + + // Asked for again with the same amount once the processor is back. + db.execute("UPDATE payment_method SET token = 'sim_pm_recovered' WHERE id = ?", + new Object[] {card}); + mvc.perform(People.asCustomer(Trips.body(post("/api/orders/" + id + "/tip"), + "{\"amount\":300}"), customer)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.tip").value(300)); + mvc.perform(admin(Trips.body(post("/api/admin/orders/" + id + "/refund"), + "{\"reason\":\"Now\"}"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("refunded")); + Trips.offline(mvc, courier); + } + + @Test + void theCardLimitHoldsForSetupsStartedWhileThereWasRoom() throws Exception { + String customer = People.customer(accounts, "card-collector"); + for (int iter = 0; iter < 7; iter++) { + Trips.addCard(mvc, customer, Trips.VISA); + } + // Two setups started with room for one card. + Map first = Trips.json(mvc, People.asCustomer(post("/api/payments/methods/setup"), customer)); + Map second = Trips.json(mvc, People.asCustomer(post("/api/payments/methods/setup"), customer)); + mvc.perform(People.asCustomer(Trips.body(post("/api/payments/methods/setup/" + + first.get("id") + "/complete"), Trips.card(Trips.MASTERCARD)), customer)) + .andExpect(status().isOk()); + mvc.perform(People.asCustomer(Trips.body(post("/api/payments/methods/setup/" + + second.get("id") + "/complete"), Trips.card(Trips.MASTERCARD)), customer)) + .andExpect(status().isConflict()); + // Cash and eight cards. + mvc.perform(People.asCustomer(get("/api/payments/methods"), customer)) + .andExpect(jsonPath("$.length()").value(9)); + } + + @Test + void cashIsRecordedAndACardThatFailsIsReported() throws Exception { + // Dublin. + String customer = People.customer(accounts, "dub-customer"); + String courier = People.courier(accounts, "dub-courier"); + String failing = (String) Trips.addCard(mvc, customer, Trips.FAILS_WHEN_CHARGED).get("id"); + Trips.online(mvc, courier, 53.3498, -6.2603); + + // Cash, asked for by name although a card is the default. + Map cash = Trips.complete(mvc, customer, courier, Trips.trip(53.3498, -6.2603, 53.3600, + -6.2500, "\"paymentMethodId\":\"cash\",\"note\":\"Blue door\"")); + String cashId = (String) cash.get("id"); + assertEquals("PAID", cash.get("paymentStatus")); + assertEquals("Cash", cash.get("paymentMethodLabel")); + assertEquals(Boolean.TRUE, cash.get("cash")); + assertEquals("Blue door", cash.get("note")); + assertEquals("cash", Trips.receipt(mvc, customer, cashId).get("status")); + // Nothing went to a card, so there is nothing to send back to one. + mvc.perform(admin(Trips.body(post("/api/admin/orders/" + cashId + "/refund"), + "{\"reason\":\"Asked nicely\"}"))) + .andExpect(status().isConflict()); + mvc.perform(People.asCustomer(Trips.body(post("/api/orders/" + cashId + "/tip"), + "{\"amount\":150}"), customer)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.tip").value(150)) + .andExpect(jsonPath("$.status").value("cash")); + + // The card that is refused when charged: the delivery still ends, and + // says it was not paid for. + Map unpaid = Trips.complete(mvc, customer, courier, Trips.trip(53.3498, -6.2603, 53.3600, + -6.2500, "\"paymentMethodId\":\"" + failing + "\"")); + String unpaidId = (String) unpaid.get("id"); + assertEquals("COMPLETED", unpaid.get("state")); + assertEquals("FAILED", unpaid.get("paymentStatus")); + assertEquals("failed", Trips.receipt(mvc, customer, unpaidId).get("status")); + mvc.perform(People.asCustomer(Trips.body(post("/api/orders/" + unpaidId + "/tip"), + "{\"amount\":150}"), customer)) + .andExpect(status().isConflict()); + mvc.perform(admin(Trips.body(post("/api/admin/orders/" + unpaidId + "/refund"), + "{\"reason\":\"Nothing to return\"}"))) + .andExpect(status().isConflict()); + // A card an order is held on is not removed until the order ends. It + // is called off before the kitchen takes it: once cooking, only an + // admin can. + String waiting = (String) Trips.place(mvc, customer, Trips.trip(53.3498, -6.2603, 53.3600, + -6.2500, "\"paymentMethodId\":\"" + failing + "\"")).get("id"); + mvc.perform(People.asCustomer(post("/api/payments/methods/" + failing + "/remove"), customer)) + .andExpect(status().isConflict()); + mvc.perform(People.asCustomer(post("/api/orders/" + waiting + "/cancel"), customer)) + .andExpect(status().isOk()); + mvc.perform(People.asCustomer(post("/api/payments/methods/" + failing + "/remove"), customer)) + .andExpect(status().isOk()); + Trips.offline(mvc, courier); + } + + @Test + void theHostedSetupEndsOnAPageThatSaysSo() throws Exception { + String done = mvc.perform(get("/pay/return?result=done")).andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + assertTrue(done.indexOf("Card added") > 0, done); + String cancelled = mvc.perform(get("/pay/return?result=%3Cscript%3E")) + .andExpect(status().isOk()).andReturn().getResponse().getContentAsString(); + // Nothing from the address is written into the page. + assertTrue(cancelled.indexOf("No card was added") > 0, cancelled); + assertEquals(-1, cancelled.indexOf("= 300L && base < 300L + admins, "one of them is in force: " + base); + rows = db.queryOne("SELECT COUNT(*) AS n FROM pricing", new Object[0]); + assertEquals(1L, ((Number) rows.get("n")).longValue(), "and there is one row"); + } finally { + // The other tests share these prices. + fares.save(DemoAccounts.ADMIN, before); + } + } + + @Test + void theDashboardsSeriesHasEveryDayAndEveryHour() throws Exception { + Map week = Trips.json(mvc, admin(get("/api/admin/stats/series?days=7"))); + List days = (List) week.get("days"); + assertEquals(7, days.size()); + // Oldest first, ending today, and a day with nothing in it is there. + Map last = (Map) days.get(6); + assertEquals(Days.iso(System.currentTimeMillis()), last.get("date")); + assertEquals(Days.iso(System.currentTimeMillis() - 6L * 86400000L), + ((Map) days.get(0)).get("date")); + for (int iter = 0; iter < days.size(); iter++) { + Map day = (Map) days.get(iter); + assertTrue(Trips.number(day, "orders") >= Trips.number(day, "delivered") + + Trips.number(day, "cancelled"), "day " + iter); + assertTrue(Trips.number(day, "revenue") >= 0L); + assertNotNull(day.get("newUsers")); + } + assertEquals(24, ((List) week.get("byHour")).size()); + // Busiest first. + List kitchens = (List) week.get("byRestaurant"); + assertTrue(kitchens.size() >= 1); + for (int iter = 1; iter < kitchens.size(); iter++) { + assertTrue(Trips.number((Map) kitchens.get(iter - 1), "count") + >= Trips.number((Map) kitchens.get(iter), "count")); + } + assertEquals("USD", week.get("currency")); + assertNotNull(week.get("byState")); + assertTrue(((List) week.get("topCouriers")).size() <= 5); + // The demo data has somebody waiting to be reviewed and somebody + // flagged, and a fortnight of orders behind them. + assertTrue(Trips.number(week, "pendingApplications") >= 1L); + assertTrue(Trips.number(week, "flaggedUsers") >= 1L); + assertTrue(Trips.number(week, "averageOrder") > 0L); + assertTrue(((Number) week.get("averageDeliveryMinutes")).doubleValue() > 0d); + double rate = ((Number) week.get("completionRate")).doubleValue(); + assertTrue(rate > 0d && rate <= 1d, "completion rate " + rate); + long placed = 0L; + for (int iter = 0; iter < days.size(); iter++) { + placed += Trips.number((Map) days.get(iter), "orders"); + } + assertTrue(placed > 0L, "the week has orders in it"); + + // A silly number is the fortnight; and there is a most. + mvc.perform(admin(get("/api/admin/stats/series?days=0"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.days.length()").value(14)); + mvc.perform(admin(get("/api/admin/stats/series?days=1000"))) + .andExpect(jsonPath("$.days.length()").value(90)) + .andExpect(jsonPath("$.byHour.length()").value(24)); + } +} diff --git a/scripts/savor/backend/src/test/java/com/codenameone/examples/savor/ProcessorFaultsTest.java b/scripts/savor/backend/src/test/java/com/codenameone/examples/savor/ProcessorFaultsTest.java new file mode 100644 index 00000000000..01eefe75bf3 --- /dev/null +++ b/scripts/savor/backend/src/test/java/com/codenameone/examples/savor/ProcessorFaultsTest.java @@ -0,0 +1,215 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor; + +import com.codename1.backend.DataSource; +import com.codename1.backend.ResponseStatusException; +import com.codename1.backend.annotations.Autowired; +import com.codename1.backend.annotations.Bean; +import com.codename1.backend.annotations.Primary; +import com.codename1.backend.test.BackendTest; +import com.codename1.backend.test.MockMvc; +import com.codename1.backend.test.TestConfiguration; +import com.codenameone.examples.savor.account.Accounts; +import com.codenameone.examples.savor.pay.Payments; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.util.Map; + +import static com.codename1.backend.test.MockMvcRequestBuilders.get; +import static com.codename1.backend.test.MockMvcRequestBuilders.post; +import static com.codename1.backend.test.MockMvcResultMatchers.jsonPath; +import static com.codename1.backend.test.MockMvcResultMatchers.status; +import static org.junit.jupiter.api.Assertions.assertEquals; + +/// Paying, against a processor that fails when a test says so: what is left +/// behind when a request to it fails part way, alone or overlapping another. +/// The service is called directly, on the test's thread, so the request a +/// test lines up inside another runs exactly where it is put. +@BackendTest +class ProcessorFaultsTest { + @Autowired + private MockMvc mvc; + @Autowired + private Accounts accounts; + @Autowired + private Payments payments; + @Autowired + private ScriptedPayments processor; + @Autowired + private DataSource db; + + @TestConfiguration + static class Processor { + @Bean + @Primary + ScriptedPayments scriptedPayments() { + return new ScriptedPayments(); + } + } + + /// The status `call` was refused with, or 200 when it was not. + private static int refusal(Runnable call) { + try { + call.run(); + return 200; + } catch (ResponseStatusException refused) { + return refused.getStatus(); + } + } + + private Runnable refund(final String deliveryId) { + return new Runnable() { + @Override + public void run() { + try { + payments.refund(deliveryId, "Overlapping"); + } catch (IOException err) { + throw new IllegalStateException(err); + } + } + }; + } + + private Runnable remove(final String customer, final String card) { + return new Runnable() { + @Override + public void run() { + try { + payments.remove(customer, card); + } catch (IOException err) { + throw new IllegalStateException(err); + } + } + }; + } + + private long balance(String courier) throws Exception { + return Trips.number(Trips.json(mvc, People.asCourier(get("/api/courier/earnings/summary"), + courier)), "balance"); + } + + @Test + void aRemovedCardIsDetachedAtTheProcessorFirst() throws Exception { + processor.reset(); + String customer = People.customer(accounts, "card-detacher"); + String card = (String) Trips.addCard(mvc, customer, Trips.VISA).get("id"); + String token = (String) db.queryOne("SELECT token FROM payment_method WHERE id = ?", + new Object[] {card}).get("token"); + + // A processor that cannot be reached keeps the card: deleted here + // alone, it would stay chargeable there with nothing pointing at it. + processor.detachFails = true; + assertEquals(502, refusal(remove(customer, card))); + mvc.perform(People.asCustomer(get("/api/payments/methods"), customer)) + .andExpect(jsonPath("$.length()").value(2)) + .andExpect(jsonPath("$[0].id").value(card)); + assertEquals(0, processor.detached.size()); + + processor.detachFails = false; + assertEquals(200, refusal(remove(customer, card))); + assertEquals(1, processor.detached.size()); + assertEquals(token, processor.detached.get(0)); + mvc.perform(People.asCustomer(get("/api/payments/methods"), customer)) + .andExpect(jsonPath("$.length()").value(1)); + } + + @Test + void aCardTheLimitRefusesIsDetachedAtTheProcessor() throws Exception { + processor.reset(); + String customer = People.customer(accounts, "card-overflow"); + for (int iter = 0; iter < 7; iter++) { + Trips.addCard(mvc, customer, Trips.VISA); + } + // Two setups started with room for one card, and a third. + Map first = Trips.json(mvc, People.asCustomer(post("/api/payments/methods/setup"), customer)); + Map second = Trips.json(mvc, People.asCustomer(post("/api/payments/methods/setup"), customer)); + Map third = Trips.json(mvc, People.asCustomer(post("/api/payments/methods/setup"), customer)); + mvc.perform(People.asCustomer(Trips.body(post("/api/payments/methods/setup/" + + first.get("id") + "/complete"), Trips.card(Trips.MASTERCARD)), customer)) + .andExpect(status().isOk()); + assertEquals(0, processor.detached.size()); + + // The processor attached the second card before the limit refused it; + // with no row here, it is detached there, and no card that was kept. + mvc.perform(People.asCustomer(Trips.body(post("/api/payments/methods/setup/" + + second.get("id") + "/complete"), Trips.card(Trips.MASTERCARD)), customer)) + .andExpect(status().isConflict()); + assertEquals(1, processor.detached.size()); + Map kept = db.queryOne("SELECT COUNT(*) AS n FROM payment_method WHERE token = ?", + new Object[] {processor.detached.get(0)}); + assertEquals(0L, ((Number) kept.get("n")).longValue()); + + // A detach that fails is logged, and the customer still hears the limit. + processor.detachFails = true; + mvc.perform(People.asCustomer(Trips.body(post("/api/payments/methods/setup/" + + third.get("id") + "/complete"), Trips.card(Trips.MASTERCARD)), customer)) + .andExpect(status().isConflict()); + assertEquals(1, processor.detached.size()); + mvc.perform(People.asCustomer(get("/api/payments/methods"), customer)) + .andExpect(jsonPath("$.length()").value(9)); + processor.reset(); + } + + @Test + void aRefundThatFailsDoesNotUndoOneThatOverlappedIt() throws Exception { + processor.reset(); + // Riga. + String customer = People.customer(accounts, "rix-customer"); + String courier = People.courier(accounts, "rix-courier"); + Trips.addCard(mvc, customer, Trips.VISA); + Trips.online(mvc, courier, 56.9496, 24.1052); + String id = (String) Trips.complete(mvc, customer, courier, Trips.trip(56.9496, 24.1052, + 56.9596, 24.1152)).get("id"); + + // A refund whose processor fails, and while it is at the processor a + // second one -- let through as one that may have been cut short -- + // that succeeds and is recorded. The second answers first. + processor.duringRefund = refund(id); + processor.refundFailures.add(Boolean.FALSE); + processor.refundFailures.add(Boolean.TRUE); + assertEquals(502, refusal(refund(id))); + // The failure gives back nothing: the payment was refunded, so the + // delivery is, and its fare is not the courier's to cash out. + mvc.perform(People.asCustomer(get("/api/orders/" + id), customer)) + .andExpect(jsonPath("$.paymentStatus").value("REFUNDED")); + assertEquals("refunded", Trips.receipt(mvc, customer, id).get("status")); + assertEquals(0L, balance(courier)); + + // The other way round: the overlapping refund fails and gives its + // claim back while the first is still at the processor, and the first + // then succeeds. + final String next = (String) Trips.complete(mvc, customer, courier, Trips.trip(56.9496, + 24.1052, 56.9596, 24.1152)).get("id"); + processor.duringRefund = new Runnable() { + @Override + public void run() { + assertEquals(502, refusal(refund(next))); + } + }; + processor.refundFailures.add(Boolean.TRUE); + processor.refundFailures.add(Boolean.FALSE); + assertEquals(200, refusal(refund(next))); + Map delivery = Trips.json(mvc, People.asCustomer(get("/api/orders/" + next), customer)); + assertEquals("REFUNDED", delivery.get("paymentStatus")); + assertEquals(0L, balance(courier)); + Trips.offline(mvc, courier); + } +} diff --git a/scripts/savor/backend/src/test/java/com/codenameone/examples/savor/PushTest.java b/scripts/savor/backend/src/test/java/com/codenameone/examples/savor/PushTest.java new file mode 100644 index 00000000000..72bc5585f7d --- /dev/null +++ b/scripts/savor/backend/src/test/java/com/codenameone/examples/savor/PushTest.java @@ -0,0 +1,588 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor; + +import com.codename1.backend.DataSource; +import com.codename1.backend.ResponseStatusException; +import com.codename1.backend.Json; +import com.codename1.backend.annotations.Autowired; +import com.codename1.backend.test.BackendTest; +import com.codename1.backend.test.MockMvc; +import com.codename1.backend.test.MockRequestBuilder; +import com.codenameone.examples.savor.account.Accounts; +import com.codenameone.examples.savor.account.DemoAccounts; +import com.codenameone.examples.savor.api.PushDeviceDto; +import com.codenameone.examples.savor.api.OrderState; +import com.codenameone.examples.savor.push.PushDevices; +import com.codenameone.examples.savor.push.PushSender; +import com.codenameone.examples.savor.push.RecordingPushSender; +import com.codenameone.examples.savor.push.OrderPushes; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +import static com.codename1.backend.test.MockMvcRequestBuilders.post; +import static com.codename1.backend.test.MockMvcRequestBuilders.put; +import static com.codename1.backend.test.MockMvcResultMatchers.jsonPath; +import static com.codename1.backend.test.MockMvcResultMatchers.status; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +/// The push notifications an order sends, the devices they go to, and the Live +/// Activity a customer follows it in. +/// +/// The `test` profile keeps every notification instead of sending it, so each +/// test reads what each phone would have been sent. +@BackendTest +class PushTest { + @Autowired + private MockMvc mvc; + @Autowired + private Accounts accounts; + @Autowired + private PushSender sender; + @Autowired + private DataSource db; + @Autowired + private PushDevices pushDevices; + @Autowired + private OrderPushes pushes; + + private RecordingPushSender sent; + + @BeforeEach + void recording() { + assertTrue(sender instanceof RecordingPushSender, + "the test profile records notifications: " + sender); + sent = (RecordingPushSender) sender; + } + + private static MockRequestBuilder body(MockRequestBuilder request, String json) { + return request.contentType("application/json").content(json); + } + + private static String device(String provider, String token) { + return "{\"provider\":\"" + provider + "\",\"token\":\"" + token + + "\",\"platform\":\"and\"}"; + } + + private void register(String username, String provider, String token) throws Exception { + mvc.perform(People.asCustomer(body(post("/api/account/push"), device(provider, token)), + username)).andExpect(status().isOk()); + } + + /// The titles `token` was sent, in order; a silent update counts as "". + private String titles(String token) { + StringBuilder out = new StringBuilder(); + for (RecordingPushSender.Sent each : sent.sentTo(token)) { + Object title = each.message().get("title"); + out.append(out.length() > 0 ? "|" : "").append(title == null ? "" : title); + } + return out.toString(); + } + + private static Map data(RecordingPushSender.Sent message) { + return (Map) message.message().get("data"); + } + + private static Map surface(RecordingPushSender.Sent message) { + return (Map) message.message().get("surface"); + } + + private static double progress(Map command) throws Exception { + return ((Number) Json.parseObject((String) command.get("state")).get("progress")) + .doubleValue(); + } + + private static RecordingPushSender.Sent last(List list) { + return list.get(list.size() - 1); + } + + @Test + void aDeviceIsRegisteredInTheProvidersOwnSpelling() throws Exception { + String customer = People.customer(accounts, "push-reg-customer"); + mvc.perform(People.asCustomer(body(post("/api/account/push"), + device("gcm", "reg-token-1")), customer)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.provider").value("fcm")) + .andExpect(jsonPath("$.token").value("reg-token-1")); + // An alias is the same device, not a second one. + mvc.perform(People.asCustomer(body(post("/api/account/push"), + device("fcm", "reg-token-1")), customer)).andExpect(status().isOk()); + mvc.perform(People.asCustomer(body(post("/api/account/push"), + device("pigeon", "reg-token-2")), customer)).andExpect(status().isBadRequest()); + mvc.perform(People.asCustomer(body(post("/api/account/push"), + device("apns", " ")), customer)).andExpect(status().isBadRequest()); + } + + private void accept(Trips.Trip trip, String id) throws Exception { + Trips.Kitchen at = Trips.kitchen(mvc, trip.fromLat, trip.fromLng); + mvc.perform(People.asMerchant(body(post("/api/merchant/orders/" + id + "/accept"), + "{\"prepMinutes\":15}"), at.merchant)).andExpect(status().isOk()); + } + + private void ready(Trips.Trip trip, String id) throws Exception { + Trips.Kitchen at = Trips.kitchen(mvc, trip.fromLat, trip.fromLng); + mvc.perform(People.asMerchant(post("/api/merchant/orders/" + id + "/ready"), at.merchant)) + .andExpect(status().isOk()); + } + + private void courier(String courier, String id, String step) throws Exception { + mvc.perform(People.asCourier(post(("accept".equals(step) ? "/api/courier/offers/" + : "/api/courier/deliveries/") + id + "/" + step), courier)).andExpect(status().isOk()); + } + + private void follow(String customer, String id, String activity, int expected) throws Exception { + mvc.perform(People.asCustomer(body(post("/api/orders/" + id + "/activity"), + "{\"activityId\":\"" + activity + "\"}"), customer)) + .andExpect(status().is(expected)); + } + + @Test + void theCustomerTheKitchenAndTheCourierAreEachToldTheirPart() throws Exception { + // Reykjavik. Each test has a city to itself. + String customer = People.customer(accounts, "push-rkv-customer"); + String courier = People.courier(accounts, "push-rkv-courier"); + Trips.Trip trip = Trips.trip(64.1467, -21.9428, 64.1597, -21.9548); + Trips.Kitchen kitchen = Trips.kitchen(mvc, trip.fromLat, trip.fromLng); + register(customer, "apns", "rkv-customer-phone"); + register(courier, "fcm", "rkv-courier-phone"); + register(kitchen.merchant, "fcm", "rkv-kitchen-tablet"); + Trips.online(mvc, courier, 64.1466, -21.9426); + + // The kitchen hears of the order, and nobody else does yet. + String id = (String) Trips.place(mvc, customer, trip).get("id"); + List toKitchen = sent.sentTo("rkv-kitchen-tablet"); + assertEquals("New order", titles("rkv-kitchen-tablet")); + assertEquals(id, data(toKitchen.get(0)).get("orderId")); + assertEquals("merchant", data(toKitchen.get(0)).get("role")); + assertTrue(((String) toKitchen.get(0).message().get("body")).contains("push-rkv-customer")); + assertEquals("", titles("rkv-customer-phone")); + + // Accepting it sends for a courier, who has a few seconds to answer. + accept(trip, id); + assertEquals("Kitchen " + kitchenNumber(kitchen) + " is preparing your order", + titles("rkv-customer-phone")); + List toCourier = sent.sentTo("rkv-courier-phone"); + assertEquals("New delivery", titles("rkv-courier-phone")); + assertEquals("OFFERED", data(toCourier.get(0)).get("state")); + assertEquals("courier", data(toCourier.get(0)).get("role")); + assertEquals("fcm", toCourier.get(0).provider()); + assertTrue(((Number) toCourier.get(0).message().get("ttl")).intValue() > 0); + + courier(courier, id, "accept"); + courier(courier, id, "arrived"); + // The courier is told the food is ready; the customer is not bothered. + ready(trip, id); + assertEquals("New delivery|Order ready", titles("rkv-courier-phone")); + courier(courier, id, "start"); + courier(courier, id, "complete"); + assertEquals("Kitchen " + kitchenNumber(kitchen) + " is preparing your order" + + "|Your order is on its way|Your order has arrived", titles("rkv-customer-phone")); + String body = (String) sent.sentTo("rkv-customer-phone").get(1).message().get("body"); + assertTrue(body.contains("push-rkv-courier"), body); + // Without a Live Activity there is nothing to move on. + for (RecordingPushSender.Sent each : sent.sentTo("rkv-customer-phone")) { + assertNull(surface(each)); + assertEquals(id, data(each).get("orderId")); + assertEquals("customer", data(each).get("role")); + } + assertEquals("New order", titles("rkv-kitchen-tablet")); + } + + private static String kitchenNumber(Trips.Kitchen kitchen) { + return kitchen.merchant.substring("kitchen".length(), kitchen.merchant.indexOf('@')); + } + + @Test + void anOrderThatEndsEarlyTellsWhoeverWasWaiting() throws Exception { + // Ljubljana: the customer changes their mind; the kitchen is told. + String customer = People.customer(accounts, "push-lju-customer"); + Trips.Trip trip = Trips.trip(46.0570, 14.5054, 46.0670, 14.5174); + Trips.Kitchen kitchen = Trips.kitchen(mvc, trip.fromLat, trip.fromLng); + register(customer, "fcm", "lju-customer-phone"); + register(kitchen.merchant, "fcm", "lju-kitchen-tablet"); + String id = (String) Trips.place(mvc, customer, trip).get("id"); + mvc.perform(People.asCustomer(post("/api/orders/" + id + "/cancel"), customer)) + .andExpect(status().isOk()); + assertEquals("New order|Order canceled", titles("lju-kitchen-tablet")); + assertEquals("", titles("lju-customer-phone")); + + // Bratislava: an admin calls off an order a courier has; the customer, + // the kitchen and the courier are each told. + String btsCustomer = People.customer(accounts, "push-bts-customer"); + String btsCourier = People.courier(accounts, "push-bts-courier"); + Trips.Trip bts = Trips.trip(48.1487, 17.1076, 48.1601, 17.0909); + register(btsCustomer, "fcm", "bts-customer-phone"); + register(btsCourier, "fcm", "bts-courier-phone"); + Trips.online(mvc, btsCourier, 48.1486, 17.1077); + String taken = Trips.ask(mvc, btsCustomer, bts); + courier(btsCourier, taken, "accept"); + mvc.perform(People.asAdmin(body(post("/api/admin/orders/" + taken + "/cancel"), + "{\"reason\":\"Test\"}"), DemoAccounts.ADMIN)).andExpect(status().isOk()); + assertTrue(titles("bts-courier-phone").endsWith("|Delivery canceled"), + titles("bts-courier-phone")); + assertTrue(titles("bts-customer-phone").endsWith("|Your order was canceled"), + titles("bts-customer-phone")); + } + + @Test + void theLiveActivityFollowsTheOrderToTheDoor() throws Exception { + String customer = People.customer(accounts, "push-cph-customer"); + String courier = People.courier(accounts, "push-cph-courier"); + String other = People.customer(accounts, "push-cph-other"); + register(customer, "apns", "cph-customer-phone"); + Trips.online(mvc, courier, 55.6761, 12.5683); + Trips.Trip trip = Trips.trip(55.6762, 12.5680, 55.6900, 12.5900); + String id = (String) Trips.place(mvc, customer, trip).get("id"); + // Followed from the moment it is placed, and never somebody else's. + follow(other, id, "act-1", 404); + follow(customer, id, "act-1", 200); + + accept(trip, id); + Map command = surface(last(sent.sentTo("cph-customer-phone"))); + assertEquals("live-update", command.get("operation")); + assertEquals("act-1", command.get("id")); + Map state = Json.parseObject((String) command.get("state")); + assertEquals("Preparing your order", state.get("status")); + assertEquals(0.35d, ((Number) state.get("progress")).doubleValue(), 0.001d); + assertTrue(((String) state.get("restaurant")).startsWith("Kitchen ")); + courier(courier, id, "accept"); + ready(trip, id); + assertEquals(0.55d, progress(surface(last(sent.sentTo("cph-customer-phone")))), 0.001d); + courier(courier, id, "arrived"); + courier(courier, id, "start"); + command = surface(last(sent.sentTo("cph-customer-phone"))); + assertEquals(0.8d, progress(command), 0.001d); + state = Json.parseObject((String) command.get("state")); + assertEquals("On its way", state.get("status")); + assertEquals("push-cph-courier", state.get("courier")); + assertEquals("Green Vespa", state.get("car")); + assertEquals("T push-cph-courier", state.get("plate")); + + // The courier rides. The phone has just been updated, so the next + // update waits its turn... + int before = sent.sentTo("cph-customer-phone").size(); + Trips.online(mvc, courier, 55.6765, 12.5682); + assertEquals(before, sent.sentTo("cph-customer-phone").size()); + // ...and once it is due, one report sends one, silently. + db.execute("UPDATE delivery_activity SET sent_at = 0 WHERE delivery_id = ?", new Object[] {id}); + Trips.online(mvc, courier, 55.6764, 12.5681); + Trips.online(mvc, courier, 55.6763, 12.5681); + List toCustomer = sent.sentTo("cph-customer-phone"); + assertEquals(before + 1, toCustomer.size()); + RecordingPushSender.Sent update = last(toCustomer); + assertEquals(Boolean.TRUE, update.message().get("silent")); + state = Json.parseObject((String) surface(update).get("state")); + assertTrue(((String) state.get("eta")).endsWith(" min"), state.toString()); + + courier(courier, id, "complete"); + RecordingPushSender.Sent done = last(sent.sentTo("cph-customer-phone")); + assertEquals("Your order has arrived", done.message().get("title")); + assertEquals("live-end", surface(done).get("operation")); + assertEquals("act-1", surface(done).get("id")); + // And it is forgotten: the courier goes on reporting, the customer + // hears no more. + int after = sent.sentTo("cph-customer-phone").size(); + Trips.online(mvc, courier, 55.6800, 12.5700); + assertEquals(after, sent.sentTo("cph-customer-phone").size()); + } + + @Test + void theSettingsDecideWhatIsSaidButNotWhatIsShown() throws Exception { + // Sofia: a customer who turned order updates off hears nothing of the + // kitchen, while the lock screen still lets go of the order when an + // admin calls it off. + String customer = People.customer(accounts, "push-sof-customer"); + String quiet = "{\"language\":\"en\",\"theme\":\"system\",\"units\":\"km\"," + + "\"notifyDeliveryUpdates\":false,\"notifyReceipts\":true," + + "\"notifyPromotions\":false,\"defaultPaymentMethodId\":\"cash\"," + + "\"defaultTipPercent\":0}"; + mvc.perform(People.asCustomer(body(put("/api/account/preferences"), quiet), customer)) + .andExpect(status().isOk()); + register(customer, "apns", "sof-customer-phone"); + Trips.Trip trip = Trips.trip(42.6978, 23.3220, 42.7054, 23.3112); + String id = (String) Trips.place(mvc, customer, trip).get("id"); + follow(customer, id, "act-sof", 200); + accept(trip, id); + List toCustomer = sent.sentTo("sof-customer-phone"); + assertEquals(1, toCustomer.size()); + assertEquals(Boolean.TRUE, toCustomer.get(0).message().get("silent")); + assertEquals("live-update", surface(toCustomer.get(0)).get("operation")); + mvc.perform(People.asAdmin(body(post("/api/admin/orders/" + id + "/cancel"), + "{\"reason\":\"Test\"}"), DemoAccounts.ADMIN)).andExpect(status().isOk()); + toCustomer = sent.sentTo("sof-customer-phone"); + assertEquals(2, toCustomer.size()); + assertEquals(Boolean.TRUE, toCustomer.get(1).message().get("silent")); + assertEquals("live-end", surface(toCustomer.get(1)).get("operation")); + } + + @Test + void aPhoneBelongsToWhoeverSignedInOnItLast() throws Exception { + String first = People.customer(accounts, "push-ams-first"); + String second = People.customer(accounts, "push-ams-second"); + String courier = People.courier(accounts, "push-ams-courier"); + register(first, "fcm", "ams-shared-phone"); + register(second, "fcm", "ams-shared-phone"); + register(first, "fcm", "ams-first-tablet"); + Trips.online(mvc, courier, 52.3676, 4.9041); + Trips.Trip trip = Trips.trip(52.3677, 4.9040, 52.3800, 4.9100); + String id = (String) Trips.place(mvc, first, trip).get("id"); + accept(trip, id); + String preparing = titles("ams-first-tablet"); + assertEquals("", titles("ams-shared-phone")); + assertTrue(preparing.endsWith(" is preparing your order"), preparing); + + // Signing out forgets the device, and only one's own. + mvc.perform(People.asCustomer(body(post("/api/account/push/remove"), + device("fcm", "ams-shared-phone")), first)).andExpect(status().isOk()); + mvc.perform(People.asCustomer(body(post("/api/account/push/remove"), + device("fcm", "ams-first-tablet")), first)).andExpect(status().isOk()); + courier(courier, id, "accept"); + ready(trip, id); + courier(courier, id, "arrived"); + courier(courier, id, "start"); + assertEquals(preparing, titles("ams-first-tablet")); + + // Closing the account forgets every device it had. + String customer = People.customer(accounts, "push-ams-closing"); + register(customer, "apns", "ams-closing-phone"); + accounts.close(customer, People.PASSWORD); + assertEquals(0L, devices(customer)); + assertEquals(1L, devices(second)); + } + + @Test + void anActivityIsRefusedOnceTheOrderHasEnded() throws Exception { + // Helsinki: the order is delivered. Oslo: the kitchen turns it down. + String customer = People.customer(accounts, "push-hel-customer"); + String courier = People.courier(accounts, "push-hel-courier"); + Trips.online(mvc, courier, 60.1699, 24.9384); + String done = Trips.ask(mvc, customer, Trips.trip(60.1700, 24.9383, 60.1800, 24.9500)); + courier(courier, done, "accept"); + courier(courier, done, "arrived"); + courier(courier, done, "start"); + // Still on the road: the activity is taken. + follow(customer, done, "act-hel", 200); + courier(courier, done, "complete"); + follow(customer, done, "act-hel-2", 409); + + String oslo = People.customer(accounts, "push-osl-customer"); + Trips.Trip trip = Trips.trip(59.9140, 10.7521, 59.9250, 10.7600); + Trips.Kitchen kitchen = Trips.kitchen(mvc, trip.fromLat, trip.fromLng); + String rejected = (String) Trips.place(mvc, oslo, trip).get("id"); + mvc.perform(People.asMerchant(body(post("/api/merchant/orders/" + rejected + "/reject"), + "{\"reason\":\"Out of dough\"}"), kitchen.merchant)).andExpect(status().isOk()); + follow(oslo, rejected, "act-osl", 409); + } + + /// An end the push provider refused is not lost with the activity: the + /// delivery's transition is never retried, so the sweep sends it again. + @Test + void anEndTheProviderRefusedIsSentAgain() throws Exception { + String customer = People.customer(accounts, "push-rey-customer"); + String courier = People.courier(accounts, "push-rey-courier"); + register(customer, "apns", "rey-customer-phone"); + Trips.online(mvc, courier, 64.1466, -21.9426); + String id = Trips.ask(mvc, customer, Trips.trip(64.1467, -21.9425, 64.1500, -21.9000)); + courier(courier, id, "accept"); + follow(customer, id, "act-rey", 200); + + sent.refuse(1); + mvc.perform(People.asAdmin(body(post("/api/admin/orders/" + id + "/cancel"), + "{\"reason\":\"Test\"}"), DemoAccounts.ADMIN)).andExpect(status().isOk()); + assertEquals(1L, activities(id), "the refused end keeps its activity"); + int before = sent.sentTo("rey-customer-phone").size(); + + // Not while the call that ended the order may still be sending it... + pushes.endLeftBehind(); + assertEquals(before, sent.sentTo("rey-customer-phone").size()); + // ...and once that is past, once. + db.execute("UPDATE delivery_activity SET ended_at = ? WHERE delivery_id = ?", + new Object[] {Long.valueOf(System.currentTimeMillis() - 120000L), id}); + pushes.endLeftBehind(); + List toCustomer = sent.sentTo("rey-customer-phone"); + assertEquals(before + 1, toCustomer.size()); + Map command = surface(last(toCustomer)); + assertEquals("live-end", command.get("operation")); + assertEquals("act-rey", command.get("id")); + assertEquals(0L, activities(id)); + pushes.endLeftBehind(); + assertEquals(before + 1, sent.sentTo("rey-customer-phone").size()); + } + + /// A send that returned is not a send the service took: the real sender + /// hands the message to another thread and learns later, and only then is + /// the activity forgotten. + @Test + void anEndTheServiceNeverConfirmedIsKept() throws Exception { + String customer = People.customer(accounts, "push-tll-customer"); + String courier = People.courier(accounts, "push-tll-courier"); + register(customer, "apns", "tll-customer-phone"); + String id = (String) Trips.place(mvc, customer, + Trips.trip(59.4371, 24.7535, 59.4400, 24.7700)).get("id"); + follow(customer, id, "act-tll", 200); + + sent.leaveUnconfirmed(1); + mvc.perform(People.asCustomer(post("/api/orders/" + id + "/cancel"), customer)) + .andExpect(status().isOk()); + assertEquals("live-end", surface(last(sent.sentTo("tll-customer-phone"))).get("operation")); + assertEquals(1L, activities(id), "sent, but never confirmed"); + + db.execute("UPDATE delivery_activity SET ended_at = ? WHERE delivery_id = ?", + new Object[] {Long.valueOf(System.currentTimeMillis() - 120000L), id}); + pushes.endLeftBehind(); + assertEquals(0L, activities(id)); + } + + /// An end still undelivered when iOS would have ended the activity itself + /// is dropped, not sent. + @Test + void anEndTooOldToBeOnScreenIsDropped() throws Exception { + db.execute("INSERT INTO delivery_activity (delivery_id, username, activity_id, sent_at, ended_at) " + + "VALUES ('old-delivery', 'push-old-customer', 'act-old', 0, 1)", new Object[0]); + int before = sent.all().size(); + pushes.endLeftBehind(); + assertEquals(0L, activities("old-delivery")); + assertEquals(before, sent.all().size()); + } + + @Test + void aTokenTakenFromAnotherAccountStillRespectsTheCap() throws Exception { + String full = People.customer(accounts, "push-cap-full"); + String other = People.customer(accounts, "push-cap-other"); + for (int i = 0; i < 8; i++) { + register(full, "fcm", "cap-full-" + i); + } + assertEquals(8L, devices(full)); + // Make one device clearly the one heard from longest ago. + db.execute("UPDATE push_device SET updated_at = 1 WHERE token = ?", + new Object[] {"cap-full-3"}); + register(other, "fcm", "cap-moving-phone"); + register(full, "fcm", "cap-moving-phone"); + assertEquals(8L, devices(full)); + assertEquals(0L, devices(other)); + assertEquals(0L, tokens("cap-full-3")); + assertEquals(1L, tokens("cap-moving-phone")); + } + + /// A registration that reaches the database after the account was closed + /// is refused, and leaves no device behind: the profile it holds first is + /// gone. This is the order a registration authorized just before the + /// closure and run just after it would see. + @Test + void aClosedAccountRegistersNoDevice() throws Exception { + String customer = People.customer(accounts, "push-gone-customer"); + accounts.close(customer, People.PASSWORD); + try { + pushDevices.register(customer, new PushDeviceDto("fcm", "gone-customer-phone", "and")); + fail("a device was registered for a closed account"); + } catch (ResponseStatusException refused) { + assertEquals(404, refused.getStatus()); + } + assertEquals(0L, tokens("gone-customer-phone")); + assertEquals(0L, devices(customer)); + } + + /// Registrations one after another for a full account each make their + /// own room, oldest first. Taken together, the registrations wait for one + /// another behind the account's profile row, so this is also what two of + /// them sent at once come to. + @Test + void eachNewDeviceOfAFullAccountReplacesTheOldest() throws Exception { + String full = People.customer(accounts, "push-cap2-full"); + for (int i = 0; i < 8; i++) { + pushDevices.register(full, new PushDeviceDto("fcm", "cap2-full-" + i, "and")); + } + db.execute("UPDATE push_device SET updated_at = 1 WHERE token = ?", + new Object[] {"cap2-full-5"}); + db.execute("UPDATE push_device SET updated_at = 2 WHERE token = ?", + new Object[] {"cap2-full-2"}); + pushDevices.register(full, new PushDeviceDto("fcm", "cap2-new-a", "and")); + pushDevices.register(full, new PushDeviceDto("apns", "cap2-new-b", "ios")); + assertEquals(8L, devices(full)); + assertEquals(0L, tokens("cap2-full-5")); + assertEquals(0L, tokens("cap2-full-2")); + assertEquals(1L, tokens("cap2-new-a")); + assertEquals(1L, tokens("cap2-new-b")); + } + + /// The notification of a change that is no longer the order's latest says + /// nothing, in either order: composed after the order was cancelled it does + /// not tell the customer their food is cooking, and composed before the + /// cancellation's own it leaves the Live Activity for that one to end. + @Test + void aStaleChangeSaysNothingAndLeavesTheActivityToTheLaterOne() throws Exception { + // Valletta: the acceptance is composed after the cancellation was. + String customer = People.customer(accounts, "push-mla-customer"); + register(customer, "apns", "mla-customer-phone"); + String id = (String) Trips.place(mvc, customer, + Trips.trip(35.8990, 14.5145, 35.9100, 14.5000)).get("id"); + follow(customer, id, "act-mla", 200); + mvc.perform(People.asCustomer(post("/api/orders/" + id + "/cancel"), customer)) + .andExpect(status().isOk()); + int told = sent.sentTo("mla-customer-phone").size(); + assertEquals("live-end", surface(last(sent.sentTo("mla-customer-phone"))) + .get("operation")); + pushes.orderChanged(id, OrderState.ACCEPTED); + assertEquals(told, sent.sentTo("mla-customer-phone").size()); + + // Gdansk: the acceptance is composed after the order was cancelled but + // before the cancellation is. It neither speaks nor forgets the + // activity, and the cancellation then ends it. + String gdnCustomer = People.customer(accounts, "push-gdn-customer"); + register(gdnCustomer, "apns", "gdn-customer-phone"); + String gdn = (String) Trips.place(mvc, gdnCustomer, + Trips.trip(54.3521, 18.6465, 54.3650, 18.6300)).get("id"); + follow(gdnCustomer, gdn, "act-gdn", 200); + int before = sent.sentTo("gdn-customer-phone").size(); + db.execute("UPDATE customer_order SET state = 'CANCELLED_BY_CUSTOMER' WHERE id = ?", + new Object[] {gdn}); + pushes.orderChanged(gdn, OrderState.ACCEPTED); + assertEquals(before, sent.sentTo("gdn-customer-phone").size()); + assertEquals(1L, activities(gdn)); + pushes.orderChanged(gdn, OrderState.CANCELLED_BY_CUSTOMER); + RecordingPushSender.Sent ended = last(sent.sentTo("gdn-customer-phone")); + assertEquals("live-end", surface(ended).get("operation")); + assertEquals("act-gdn", surface(ended).get("id")); + assertEquals(0L, activities(gdn)); + } + + private long activities(String deliveryId) throws Exception { + Map row = db.queryOne("SELECT COUNT(*) AS n FROM delivery_activity WHERE delivery_id = ?", + new Object[] {deliveryId}); + return ((Number) row.get("n")).longValue(); + } + + private long tokens(String token) throws Exception { + Map row = db.queryOne("SELECT COUNT(*) AS n FROM push_device WHERE token = ?", + new Object[] {token}); + return ((Number) row.get("n")).longValue(); + } + + private long devices(String username) throws Exception { + Map row = db.queryOne("SELECT COUNT(*) AS n FROM push_device WHERE username = ?", + new Object[] {username}); + return ((Number) row.get("n")).longValue(); + } +} diff --git a/scripts/savor/backend/src/test/java/com/codenameone/examples/savor/ScriptedPayments.java b/scripts/savor/backend/src/test/java/com/codenameone/examples/savor/ScriptedPayments.java new file mode 100644 index 00000000000..1d96b8101ac --- /dev/null +++ b/scripts/savor/backend/src/test/java/com/codenameone/examples/savor/ScriptedPayments.java @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor; + +import com.codenameone.examples.savor.pay.SimulatedPayments; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +/// The simulated processor with a test's hand on it: the test says whether +/// refunds and card detaches fail, and what happens while a refund is at the +/// processor -- which is how two requests that overlap are lined up one inside +/// the other, in the order a test wants, on one thread. +final class ScriptedPayments extends SimulatedPayments { + /// Run inside the next refund, before it answers, and then forgotten. + Runnable duringRefund; + /// How the coming refunds answer, in the order they answer -- a refund + /// run inside another answers before it. True fails one without saying + /// whether it was made; once the list is empty every refund succeeds. + final List refundFailures = new ArrayList(); + /// Whether detaching a card fails the same way. + boolean detachFails; + /// The tokens of the cards detached, in order. + final List detached = new ArrayList(); + + /// Back to a processor that does what the simulated one does. + void reset() { + duringRefund = null; + refundFailures.clear(); + detachFails = false; + detached.clear(); + } + + @Override + public Refund refund(String chargeReference, long amountCents) throws IOException { + Runnable during = duringRefund; + duringRefund = null; + if (during != null) { + during.run(); + } + if (!refundFailures.isEmpty() && refundFailures.remove(0).booleanValue()) { + throw new IOException("The scripted processor did not answer"); + } + return super.refund(chargeReference, amountCents); + } + + @Override + public void detachCard(String customer, String token) throws IOException { + if (detachFails) { + throw new IOException("The scripted processor did not answer"); + } + detached.add(token); + } +} diff --git a/scripts/savor/backend/src/test/java/com/codenameone/examples/savor/SettingsTest.java b/scripts/savor/backend/src/test/java/com/codenameone/examples/savor/SettingsTest.java new file mode 100644 index 00000000000..6d96ccb0383 --- /dev/null +++ b/scripts/savor/backend/src/test/java/com/codenameone/examples/savor/SettingsTest.java @@ -0,0 +1,212 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor; + +import com.codename1.backend.annotations.Autowired; +import com.codename1.backend.test.BackendTest; +import com.codename1.backend.test.MockMvc; +import com.codenameone.examples.savor.account.Accounts; +import com.codenameone.examples.savor.account.DemoAccounts; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static com.codename1.backend.test.MockMvcRequestBuilders.get; +import static com.codename1.backend.test.MockMvcRequestBuilders.post; +import static com.codename1.backend.test.MockMvcRequestBuilders.put; +import static com.codename1.backend.test.MockMvcResultMatchers.jsonPath; +import static com.codename1.backend.test.MockMvcResultMatchers.status; +import static org.junit.jupiter.api.Assertions.assertEquals; + +/// What someone changes about their own account: how the app behaves for them, +/// who they are, their password, and whether they have an account at all. +@BackendTest +class SettingsTest { + @Autowired + private MockMvc mvc; + @Autowired + private Accounts accounts; + + @Test + void preferencesAreSavedAndReadBack() throws Exception { + String who = People.customer(accounts, "prefers"); + mvc.perform(People.asCustomer(get("/api/account/preferences"), who)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.language").value("en")) + .andExpect(jsonPath("$.theme").value("system")) + .andExpect(jsonPath("$.units").value("km")) + .andExpect(jsonPath("$.notifyDeliveryUpdates").value(true)) + .andExpect(jsonPath("$.defaultPaymentMethodId").value("cash")); + + String card = (String) Trips.addCard(mvc, who, Trips.VISA).get("id"); + String wanted = "{\"language\":\"he\",\"theme\":\"dark\",\"units\":\"mi\"," + + "\"notifyDeliveryUpdates\":false,\"notifyReceipts\":true,\"notifyPromotions\":true," + + "\"defaultPaymentMethodId\":\"cash\",\"defaultTipPercent\":15}"; + mvc.perform(People.asCustomer(Trips.body(put("/api/account/preferences"), wanted), who)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.language").value("he")); + Map saved = Trips.json(mvc, People.asCustomer(get("/api/account/preferences"), who)); + assertEquals("he", saved.get("language")); + assertEquals("dark", saved.get("theme")); + assertEquals("mi", saved.get("units")); + assertEquals(Boolean.FALSE, saved.get("notifyDeliveryUpdates")); + assertEquals(Boolean.TRUE, saved.get("notifyReceipts")); + assertEquals(Boolean.TRUE, saved.get("notifyPromotions")); + assertEquals(15L, Trips.number(saved, "defaultTipPercent")); + // The default way to pay is the mark on the card itself: saving it + // here and reading the cards agree. + assertEquals("cash", saved.get("defaultPaymentMethodId")); + mvc.perform(People.asCustomer(get("/api/payments/methods"), who)) + .andExpect(jsonPath("$[0].id").value(card)) + .andExpect(jsonPath("$[0].isDefault").value(false)) + .andExpect(jsonPath("$[1].isDefault").value(true)); + mvc.perform(People.asCustomer(get("/api/me"), who)) + .andExpect(jsonPath("$.language").value("he")); + + // Each is one of the values it can take, or it is refused whole. + String[] wrong = {"{\"language\":\"xx\"}", "{\"theme\":\"neon\"}", "{\"units\":\"ft\"}", + "{\"defaultTipPercent\":12}", + "{\"defaultPaymentMethodId\":\"someoneelsescard\"}"}; + for (int iter = 0; iter < wrong.length; iter++) { + int answered = Trips.statusOf(mvc, People.asCustomer(Trips.body( + put("/api/account/preferences"), wrong[iter]), who)); + assertEquals(iter == wrong.length - 1 ? 404 : 400, answered, wrong[iter]); + } + mvc.perform(People.asCustomer(get("/api/account/preferences"), who)) + .andExpect(jsonPath("$.language").value("he")) + .andExpect(jsonPath("$.theme").value("dark")); + } + + @Test + void aProfileIsSavedAndReadBack() throws Exception { + String who = People.customer(accounts, "profiled"); + mvc.perform(People.asCustomer(get("/api/account/profile"), who)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.name").value("profiled")); + mvc.perform(People.asCustomer(Trips.body(put("/api/account/profile"), + "{\"name\":\"Pat Profiled\"}"), who)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.displayName").value("Pat Profiled")); + mvc.perform(People.asCustomer(get("/api/account/profile"), who)) + .andExpect(jsonPath("$.name").value("Pat Profiled")); + mvc.perform(People.asCustomer(Trips.body(put("/api/account/profile"), + "{\"name\":\"\"}"), who)) + .andExpect(status().isBadRequest()); + } + + @Test + void aPasswordIsChangedByWhoeverKnowsTheOldOne() throws Exception { + String who = People.customer(accounts, "rekeyed"); + mvc.perform(People.asCustomer(Trips.body(post("/api/account/password"), + "{\"current\":\"not-the-password\",\"replacement\":\"another-long-one\"}"), + who)) + .andExpect(status().isForbidden()); + mvc.perform(People.asCustomer(Trips.body(post("/api/account/password"), + "{\"current\":\"" + People.PASSWORD + "\",\"replacement\":\"short\"}"), who)) + .andExpect(status().isBadRequest()); + mvc.perform(People.asCustomer(Trips.body(post("/api/account/password"), + "{\"current\":\"" + People.PASSWORD + + "\",\"replacement\":\"another-long-one\"}"), who)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.username").value(who)); + assertEquals(null, SignIn.session(mvc, who, People.PASSWORD)); + assertEquals(false, SignIn.session(mvc, who, "another-long-one") == null); + } + + @Test + void aChangedPasswordSignsTheOtherDevicesOut() throws Exception { + String who = People.customer(accounts, "two-devices"); + Map phone = SignIn.tokens(mvc, who, People.PASSWORD); + Map tablet = SignIn.tokens(mvc, who, People.PASSWORD); + + mvc.perform(Trips.body(post("/api/account/password"), + "{\"current\":\"" + People.PASSWORD + + "\",\"replacement\":\"another-long-one\"}") + .header("Host", SignIn.HOST) + .header("Authorization", "Bearer " + phone.get("access_token"))) + .andExpect(status().isOk()); + + // The tablet's refresh token asked for no password, and would have + // gone on working for whoever held the tablet. + mvc.perform(SignIn.formPost("/oauth2/token", "grant_type", "refresh_token", + "refresh_token", (String) tablet.get("refresh_token"), + "client_id", SecurityConfig.CLIENT_ID).header("Host", SignIn.HOST)) + .andExpect(status().isBadRequest()); + // The device the password was changed on stays signed in. + mvc.perform(SignIn.formPost("/oauth2/token", "grant_type", "refresh_token", + "refresh_token", (String) phone.get("refresh_token"), + "client_id", SecurityConfig.CLIENT_ID).header("Host", SignIn.HOST)) + .andExpect(status().isOk()); + } + + @Test + void anAccountIsClosedForGood() throws Exception { + // Warsaw, where nobody delivers. + String who = People.customer(accounts, "leaving"); + String token = SignIn.token(mvc, who, People.PASSWORD); + Trips.addCard(mvc, who, Trips.VISA); + mvc.perform(People.asCustomer(Trips.body(put("/api/onboarding/application"), + "{\"legalName\":\"Lee Leaving\"}"), who)).andExpect(status().isOk()); + + // Not with an order on its way: a kitchen may be cooking it, and the + // card is held for it. + String order = (String) Trips.place(mvc, who, + Trips.trip(52.2297, 21.0122, 52.2400, 21.0300)).get("id"); + String closure = "{\"password\":\"" + People.PASSWORD + "\"}"; + mvc.perform(People.asCustomer(Trips.body(post("/api/account/delete"), closure), who)) + .andExpect(status().isConflict()); + mvc.perform(People.asCustomer(post("/api/orders/" + order + "/cancel"), who)) + .andExpect(status().isOk()); + + // Not on the strength of a token: the password is asked for again. + mvc.perform(People.asCustomer(post("/api/account/delete"), who)) + .andExpect(status().isBadRequest()); + mvc.perform(People.asCustomer(Trips.body(post("/api/account/delete"), "{}"), who)) + .andExpect(status().isForbidden()); + mvc.perform(People.asCustomer(Trips.body(post("/api/account/delete"), + "{\"password\":\"not-the-password\"}"), who)) + .andExpect(status().isForbidden()); + mvc.perform(People.asCustomer(get("/api/me"), who)).andExpect(status().isOk()); + + mvc.perform(People.asCustomer(Trips.body(post("/api/account/delete"), closure), who)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.username").value(who)); + // Gone: the token, the password, and the account an admin would look up. + mvc.perform(get("/api/me").header("Host", SignIn.HOST) + .header("Authorization", "Bearer " + token)) + .andExpect(status().isUnauthorized()); + assertEquals(null, SignIn.session(mvc, who, People.PASSWORD)); + mvc.perform(People.asAdmin(get("/api/admin/users/" + who), DemoAccounts.ADMIN)) + .andExpect(status().isNotFound()); + mvc.perform(People.asAdmin(get("/api/admin/applications/" + who), DemoAccounts.ADMIN)) + .andExpect(status().isNotFound()); + // And the address is retired with it. The orders the account placed are + // kept under it, so whoever registered it next would be handed them. + mvc.perform(post("/api/account/register").contentType("application/json") + .content("{\"email\":\"" + who + "\",\"password\":\"" + People.PASSWORD + + "\",\"displayName\":\"Somebody else\"," + + "\"phone\":\"+15550112\"}")) + .andExpect(status().isConflict()); + + // An admin's account is not closed this way. + mvc.perform(People.asAdmin(Trips.body(post("/api/account/delete"), + "{\"password\":\"" + DemoAccounts.PASSWORD + "\"}"), DemoAccounts.ADMIN)) + .andExpect(status().isConflict()); + } +} diff --git a/scripts/savor/backend/src/test/java/com/codenameone/examples/savor/SignIn.java b/scripts/savor/backend/src/test/java/com/codenameone/examples/savor/SignIn.java new file mode 100644 index 00000000000..5048e8dc148 --- /dev/null +++ b/scripts/savor/backend/src/test/java/com/codenameone/examples/savor/SignIn.java @@ -0,0 +1,100 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor; + +import com.codename1.backend.Json; +import com.codename1.backend.security.oauth2.core.OAuth2Parameters; +import com.codename1.backend.test.MockMvc; +import com.codename1.backend.test.MockRequestBuilder; + +import java.util.Map; + +import static com.codename1.backend.test.MockMvcRequestBuilders.get; +import static com.codename1.backend.test.MockMvcRequestBuilders.post; + +/// Signs a user in the way the app does, for the tests that need a real token: +/// the password for a session, the session for an authorization code, the code +/// for tokens. +final class SignIn { + /// The PKCE pair of RFC 7636's appendix B. + private static final String VERIFIER = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"; + private static final String CHALLENGE = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"; + static final String HOST = "savor.test:8080"; + + private SignIn() { + } + + static MockRequestBuilder formPost(String path, String... pairs) { + StringBuilder body = new StringBuilder(); + for (int i = 0; i < pairs.length; i += 2) { + if (body.length() > 0) { + body.append('&'); + } + body.append(OAuth2Parameters.encode(pairs[i])).append('=') + .append(OAuth2Parameters.encode(pairs[i + 1])); + } + return post(path).contentType("application/x-www-form-urlencoded").content(body.toString()); + } + + /// The session cookie a correct password earns, or null for a wrong one. + static String session(MockMvc mvc, String username, String password) throws Exception { + return mvc.perform(formPost("/login", "username", username, "password", password) + .header("Host", HOST)) + .andReturn().getResponse().getCookie("CN1SESSION"); + } + + /// The token response for a user: `access_token`, `refresh_token` and the + /// rest. + static Map tokens(MockMvc mvc, String username, String password) throws Exception { + String session = session(mvc, username, password); + if (session == null) { + throw new IllegalStateException("could not sign in as " + username); + } + String location = mvc.perform(get("/oauth2/authorize?response_type=code&client_id=" + + SecurityConfig.CLIENT_ID + "&redirect_uri=" + + OAuth2Parameters.encode(SecurityConfig.LOOPBACK_REDIRECT) + + "&scope=openid%20profile&state=s-1&nonce=n-1&code_challenge=" + CHALLENGE + + "&code_challenge_method=S256") + .header("Host", HOST).cookie("CN1SESSION", session)) + .andReturn().getResponse().getRedirectedUrl(); + if (location == null || location.indexOf("code=") < 0) { + throw new IllegalStateException("no authorization code: " + location); + } + int at = location.indexOf("code=") + 5; + int end = location.indexOf('&', at); + String code = location.substring(at, end < 0 ? location.length() : end); + String body = mvc.perform(formPost("/oauth2/token", + "grant_type", "authorization_code", + "code", code, + "redirect_uri", SecurityConfig.LOOPBACK_REDIRECT, + "code_verifier", VERIFIER, + "client_id", SecurityConfig.CLIENT_ID).header("Host", HOST)) + .andReturn().getResponse().getContentAsString(); + return Json.parseObject(body); + } + + /// A bearer token for a user. + static String token(MockMvc mvc, String username, String password) throws Exception { + Object token = tokens(mvc, username, password).get("access_token"); + if (!(token instanceof String)) { + throw new IllegalStateException("no access token for " + username); + } + return (String) token; + } +} diff --git a/scripts/savor/backend/src/test/java/com/codenameone/examples/savor/StripePaymentsTest.java b/scripts/savor/backend/src/test/java/com/codenameone/examples/savor/StripePaymentsTest.java new file mode 100644 index 00000000000..b78b428fd8a --- /dev/null +++ b/scripts/savor/backend/src/test/java/com/codenameone/examples/savor/StripePaymentsTest.java @@ -0,0 +1,332 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor; + +import com.codenameone.examples.savor.pay.PaymentDeclinedException; +import com.codenameone.examples.savor.pay.PaymentProvider; +import com.codenameone.examples.savor.pay.StripePayments; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +/// The Stripe provider, held to what it sends and what it makes of the +/// answers. The network is replaced by a script of canned answers, so this +/// needs no key and reaches nobody: what it proves is the requests' shape and +/// the reading of Stripe's JSON, which is all of the provider there is. +class StripePaymentsTest { + private static final String RETURN = "https://savor.example/pay/return"; + + /// Answers each request with the next canned reply and keeps what was + /// asked. + private static final class Script implements StripePayments.Transport { + final List asked = new ArrayList(); + final List forms = new ArrayList(); + final List keys = new ArrayList(); + private final List replies = new ArrayList(); + + Script reply(int status, String body) { + replies.add(new StripePayments.Reply(status, body)); + return this; + } + + @Override + public StripePayments.Reply send(String method, String path, String form, + String idempotencyKey) throws IOException { + asked.add(method + " " + path); + forms.add(form); + keys.add(idempotencyKey); + if (replies.isEmpty()) { + throw new IOException("Nothing more was expected to be asked"); + } + return replies.remove(0); + } + } + + private static void has(String form, String pair) { + assertTrue(("&" + form + "&").indexOf("&" + pair + "&") >= 0, pair + " in " + form); + } + + @Test + void aCustomerIsMadeWithTheAccountsName() throws Exception { + Script script = new Script().reply(200, "{\"id\":\"cus_123\",\"object\":\"customer\"}"); + StripePayments stripe = new StripePayments(script, RETURN); + assertEquals("stripe", stripe.name()); + assertEquals("cus_123", stripe.createCustomer("ada@savor.example", "Ada & Co")); + assertEquals("POST /v1/customers", script.asked.get(0)); + String form = script.forms.get(0); + // Escaped, name and value both: an ampersand in a name must not start + // another field. + has(form, "email=ada%40savor.example"); + has(form, "name=Ada+%26+Co"); + has(form, "metadata%5Bsavor_user%5D=ada%40savor.example"); + // The same request made twice makes one customer: the same key, which + // does not spell the address out. + String key = script.keys.get(0); + assertTrue(key.startsWith("customer-") && key.indexOf("ada") < 0, key); + script.reply(200, "{\"id\":\"cus_123\",\"object\":\"customer\"}"); + stripe.createCustomer("ada@savor.example", "Ada & Co"); + assertEquals(key, script.keys.get(1)); + script.reply(200, "{\"id\":\"cus_124\",\"object\":\"customer\"}"); + stripe.createCustomer("ada@savor.example", "Ada"); + assertTrue(!key.equals(script.keys.get(2)), "another name is another request"); + } + + @Test + void aCustomerIsDeletedAndOneAlreadyGoneCountsAsDeleted() throws Exception { + Script script = new Script().reply(200, "{\"id\":\"cus_123\",\"deleted\":true}") + .reply(404, "{\"error\":{\"type\":\"invalid_request_error\"," + + "\"code\":\"resource_missing\"}}") + .reply(500, "{}"); + StripePayments stripe = new StripePayments(script, RETURN); + stripe.forgetCustomer("cus_123"); + assertEquals("DELETE /v1/customers/cus_123", script.asked.get(0)); + assertEquals(null, script.forms.get(0)); + // Asked again after an answer that was lost. + stripe.forgetCustomer("cus_123"); + // Anything else is not a deletion, and the closure it is part of fails. + try { + stripe.forgetCustomer("cus_123"); + throw new AssertionError("A processor that failed must not read as deleted"); + } catch (IOException expected) { + assertTrue(expected.getMessage().indexOf("500") >= 0, expected.getMessage()); + } + } + + @Test + void aRemovedCardIsDetachedAndOneAlreadyDetachedCountsAsDetached() throws Exception { + Script script = new Script() + .reply(200, "{\"id\":\"pm_1\",\"object\":\"payment_method\",\"customer\":null}") + // Gone with its customer. + .reply(404, "{\"error\":{\"type\":\"invalid_request_error\"," + + "\"code\":\"resource_missing\"}}") + // Asked again after an answer that was lost: Stripe refuses to + // detach a card attached to nobody, and the card says so. + .reply(400, "{\"error\":{\"type\":\"invalid_request_error\"}}") + .reply(200, "{\"id\":\"pm_1\",\"object\":\"payment_method\",\"customer\":null}") + // Refused while the card is still this customer's: not detached. + .reply(400, "{\"error\":{\"type\":\"invalid_request_error\"}}") + .reply(200, "{\"id\":\"pm_1\",\"object\":\"payment_method\"," + + "\"customer\":\"cus_123\"}") + .reply(500, "{}") + .reply(500, "{}"); + StripePayments stripe = new StripePayments(script, RETURN); + stripe.detachCard("cus_123", "pm_1"); + assertEquals("POST /v1/payment_methods/pm_1/detach", script.asked.get(0)); + stripe.detachCard("cus_123", "pm_1"); + stripe.detachCard("cus_123", "pm_1"); + assertEquals("GET /v1/payment_methods/pm_1", script.asked.get(3)); + for (int iter = 0; iter < 2; iter++) { + try { + stripe.detachCard("cus_123", "pm_1"); + throw new AssertionError("A card still attached must not read as detached"); + } catch (IOException expected) { + assertTrue(expected.getMessage().indexOf("detaching") >= 0, + expected.getMessage()); + } + } + } + + @Test + void aCardIsAddedOnAHostedPageAndReadBack() throws Exception { + Script script = new Script() + .reply(200, "{\"id\":\"cs_test_1\",\"object\":\"checkout.session\"," + + "\"url\":\"https://checkout.stripe.com/c/pay/cs_test_1\"}") + .reply(200, "{\"id\":\"cs_test_1\",\"status\":\"complete\"," + + "\"customer\":\"cus_123\",\"setup_intent\":{\"id\":\"seti_1\"," + + "\"status\":\"succeeded\",\"payment_method\":{\"id\":\"pm_1\"," + + "\"type\":\"card\",\"card\":{\"brand\":\"visa\",\"last4\":\"4242\"," + + "\"exp_month\":8,\"exp_year\":2031}}}}"); + StripePayments stripe = new StripePayments(script, RETURN); + PaymentProvider.Setup setup = stripe.startCardSetup("cus_123", "setup9"); + assertTrue(setup.hosted); + assertEquals("cs_test_1", setup.reference); + assertEquals("https://checkout.stripe.com/c/pay/cs_test_1", setup.url); + assertEquals("POST /v1/checkout/sessions", script.asked.get(0)); + String form = script.forms.get(0); + has(form, "mode=setup"); + has(form, "customer=cus_123"); + has(form, "payment_method_types%5B0%5D=card"); + has(form, "client_reference_id=setup9"); + has(form, "success_url=https%3A%2F%2Fsavor.example%2Fpay%2Freturn%3Fresult%3Ddone"); + has(form, "cancel_url=https%3A%2F%2Fsavor.example%2Fpay%2Freturn%3Fresult%3Dcancelled"); + + // No card is sent: it was typed on the processor's page. + PaymentProvider.Card card = stripe.finishCardSetup("cus_123", "cs_test_1", null); + assertEquals("GET /v1/checkout/sessions/cs_test_1" + + "?expand%5B%5D=setup_intent.payment_method", script.asked.get(1)); + assertEquals(null, script.forms.get(1)); + assertEquals("pm_1", card.token); + assertEquals("Visa", card.brand); + assertEquals("4242", card.last4); + assertEquals(8, card.expMonth); + assertEquals(2031, card.expYear); + } + + @Test + void aSetupNotFinishedOrSomebodyElsesSavesNoCard() throws Exception { + // The user closed the page: the session is still open, with no card. + Script script = new Script().reply(200, "{\"id\":\"cs_test_2\",\"status\":\"open\"," + + "\"customer\":\"cus_123\",\"setup_intent\":null}"); + try { + new StripePayments(script, RETURN).finishCardSetup("cus_123", "cs_test_2", null); + fail("An unfinished setup saved a card"); + } catch (PaymentDeclinedException expected) { + assertTrue(expected.getMessage().indexOf("not added") > 0); + } + script = new Script().reply(200, "{\"id\":\"cs_test_3\",\"status\":\"complete\"," + + "\"customer\":\"cus_999\",\"setup_intent\":{\"status\":\"succeeded\"," + + "\"payment_method\":{\"id\":\"pm_9\",\"card\":{\"brand\":\"visa\"," + + "\"last4\":\"1111\",\"exp_month\":1,\"exp_year\":2030}}}}"); + try { + new StripePayments(script, RETURN).finishCardSetup("cus_123", "cs_test_3", null); + fail("Another customer's session saved a card"); + } catch (PaymentDeclinedException wrong) { + fail("That is a fault, not a refusal"); + } catch (IOException expected) { + assertTrue(expected.getMessage().indexOf("another customer") > 0); + } + // A page to open is the whole point of starting one. + script = new Script().reply(200, "{\"id\":\"cs_test_4\"}"); + try { + new StripePayments(script, RETURN).startCardSetup("cus_123", "setup1"); + fail("A setup with no page was started"); + } catch (IOException expected) { + assertTrue(expected.getMessage().indexOf("no page") > 0); + } + } + + @Test + void aDeliveryIsChargedOnceWithNobodyAtTheScreen() throws Exception { + Script script = new Script().reply(200, "{\"id\":\"pi_1\",\"object\":\"payment_intent\"," + + "\"status\":\"succeeded\",\"amount\":1840}"); + StripePayments stripe = new StripePayments(script, RETURN); + assertEquals("pi_1", stripe.charge("delivery-77", "cus_123", "pm_1", 1840L, "USD", + "Savor delivery")); + assertEquals("POST /v1/payment_intents", script.asked.get(0)); + String form = script.forms.get(0); + has(form, "amount=1840"); + has(form, "currency=usd"); + has(form, "customer=cus_123"); + has(form, "payment_method=pm_1"); + has(form, "off_session=true"); + has(form, "confirm=true"); + has(form, "description=Savor+delivery"); + has(form, "metadata%5Bsavor_charge%5D=delivery-77"); + // The key that makes a charge sent twice a charge made once. + assertEquals("delivery-77", script.keys.get(0)); + } + + @Test + void aChargeTheBankWantsApprovedOrRefusesIsADecline() throws Exception { + Script script = new Script().reply(200, "{\"id\":\"pi_2\"," + + "\"status\":\"requires_action\"}"); + try { + new StripePayments(script, RETURN).charge("k1", "cus_123", "pm_1", 500L, "USD", "x"); + fail("A charge awaiting approval was taken as made"); + } catch (PaymentDeclinedException expected) { + assertTrue(expected.getMessage().indexOf("approval") > 0); + } + // What Stripe answers for a card that is refused: 402, and words + // written for the cardholder. + script = new Script().reply(402, "{\"error\":{\"type\":\"card_error\"," + + "\"code\":\"card_declined\",\"decline_code\":\"insufficient_funds\"," + + "\"message\":\"Your card has insufficient funds.\"}}"); + try { + new StripePayments(script, RETURN).charge("k2", "cus_123", "pm_1", 500L, "USD", "x"); + fail("A declined charge was taken as made"); + } catch (PaymentDeclinedException expected) { + assertEquals("Your card has insufficient funds.", expected.getMessage()); + } + // Anything else is a fault, reported by status and code and without + // the processor's message, which can quote the request. + script = new Script().reply(401, "{\"error\":{\"type\":\"invalid_request_error\"," + + "\"code\":\"api_key_invalid\",\"message\":\"Invalid API Key provided: sk_x\"}}"); + try { + new StripePayments(script, RETURN).charge("k3", "cus_123", "pm_1", 500L, "USD", "x"); + fail("A request the processor rejected was taken as made"); + } catch (PaymentDeclinedException wrong) { + fail("That is a fault, not a refusal"); + } catch (IOException expected) { + assertTrue(expected.getMessage().indexOf("401") > 0, expected.getMessage()); + assertTrue(expected.getMessage().indexOf("api_key_invalid") > 0); + assertEquals(-1, expected.getMessage().indexOf("sk_x")); + } + script = new Script().reply(502, "Bad gateway"); + try { + new StripePayments(script, RETURN).charge("k4", "cus_123", "pm_1", 500L, "USD", "x"); + fail("An answer that is not JSON was taken as a charge"); + } catch (PaymentDeclinedException wrong) { + fail("That is a fault, not a refusal"); + } catch (IOException expected) { + assertTrue(expected.getMessage().indexOf("502") > 0, expected.getMessage()); + } + } + + @Test + void aRefundReturnsTheChargeOnce() throws Exception { + Script script = new Script() + .reply(200, "{\"id\":\"re_1\",\"object\":\"refund\",\"status\":\"succeeded\"}") + .reply(200, "{\"id\":\"re_2\",\"object\":\"refund\",\"status\":\"failed\"}"); + StripePayments stripe = new StripePayments(script, RETURN); + PaymentProvider.Refund made = stripe.refund("pi_1", 1840L); + assertEquals("re_1", made.reference); + assertFalse(made.pending); + assertEquals("POST /v1/refunds", script.asked.get(0)); + has(script.forms.get(0), "payment_intent=pi_1"); + has(script.forms.get(0), "amount=1840"); + assertEquals("refund-pi_1", script.keys.get(0)); + try { + stripe.refund("pi_1", 1840L); + fail("A refund that failed was taken as made"); + } catch (IOException expected) { + assertTrue(expected.getMessage().indexOf("failed") > 0); + } + } + + @Test + void aPendingRefundIsAskedAboutUntilItEnds() throws Exception { + Script script = new Script() + .reply(200, "{\"id\":\"re_3\",\"object\":\"refund\",\"status\":\"pending\"}") + .reply(200, "{\"id\":\"re_4\",\"object\":\"refund\"," + + "\"status\":\"requires_action\"}") + .reply(200, "{\"id\":\"re_3\",\"object\":\"refund\",\"status\":\"pending\"}") + .reply(200, "{\"id\":\"re_3\",\"object\":\"refund\",\"status\":\"succeeded\"}") + .reply(200, "{\"id\":\"re_4\",\"object\":\"refund\",\"status\":\"canceled\"}") + .reply(200, "{\"id\":\"re_5\",\"object\":\"refund\",\"status\":\"failed\"}"); + StripePayments stripe = new StripePayments(script, RETURN); + // Taken, but not yet money back: neither is a refund to record as made. + PaymentProvider.Refund pending = stripe.refund("pi_3", 900L); + assertEquals("re_3", pending.reference); + assertTrue(pending.pending); + assertTrue(stripe.refund("pi_4", 900L).pending); + assertEquals(PaymentProvider.REFUND_PENDING, stripe.refundStatus("re_3")); + assertEquals("GET /v1/refunds/re_3", script.asked.get(2)); + assertEquals(PaymentProvider.REFUND_SUCCEEDED, stripe.refundStatus("re_3")); + // Canceled returned no more money than failed did. + assertEquals(PaymentProvider.REFUND_FAILED, stripe.refundStatus("re_4")); + assertEquals(PaymentProvider.REFUND_FAILED, stripe.refundStatus("re_5")); + } +} diff --git a/scripts/savor/backend/src/test/java/com/codenameone/examples/savor/Trips.java b/scripts/savor/backend/src/test/java/com/codenameone/examples/savor/Trips.java new file mode 100644 index 00000000000..a38cb7c7421 --- /dev/null +++ b/scripts/savor/backend/src/test/java/com/codenameone/examples/savor/Trips.java @@ -0,0 +1,244 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor; + +import com.codename1.backend.Json; +import com.codename1.backend.test.MockMvc; +import com.codename1.backend.test.MockRequestBuilder; + +import com.codenameone.examples.savor.account.DemoAccounts; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static com.codename1.backend.test.MockMvcRequestBuilders.get; +import static com.codename1.backend.test.MockMvcRequestBuilders.post; +import static com.codename1.backend.test.MockMvcResultMatchers.status; + +/// The steps several tests take on the way to what they are about: a courier +/// going on line, a card being saved, an order being cooked and carried to the door. +public final class Trips { + public static final String VISA = "4242424242424242"; + public static final String MASTERCARD = "5555555555554444"; + /// Refused when it is saved. + public static final String DECLINED = "4000000000000002"; + /// Saved without complaint, and refused when it is charged. + public static final String FAILS_WHEN_CHARGED = "4000000000000341"; + /// Saved without complaint, and charging it fails without an answer. + public static final String NO_ANSWER_WHEN_CHARGED = "4000000000000119"; + /// Charged without complaint; a refund of it is pending at first. + public static final String SLOW_REFUND = "4000000000007726"; + + private Trips() { + } + + public static MockRequestBuilder body(MockRequestBuilder request, String json) { + return request.contentType("application/json").content(json); + } + + /// The object a request that succeeds answers with. + public static Map json(MockMvc mvc, MockRequestBuilder request) throws Exception { + return Json.parseObject(mvc.perform(request).andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString()); + } + + /// The list a request that succeeds answers with. + public static List list(MockMvc mvc, MockRequestBuilder request) throws Exception { + return (List) Json.parse(mvc.perform(request).andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString()); + } + + /// The status a request is answered with, whatever it is. + public static int statusOf(MockMvc mvc, MockRequestBuilder request) throws Exception { + return mvc.perform(request).andReturn().getResponse().getStatus(); + } + + public static long number(Map object, String field) { + return ((Number) object.get(field)).longValue(); + } + + /// A restaurant of a test's own, its merchant and the one dish on its menu. + public static final class Kitchen { + public final String merchant; + public final String id; + public final String dish; + public final double lat; + public final double lng; + + Kitchen(String merchant, String id, String dish, double lat, double lng) { + this.merchant = merchant; + this.id = id; + this.dish = dish; + this.lat = lat; + this.lng = lng; + } + } + + /// Where an order is cooked and where it goes. + public static final class Trip { + public final double fromLat; + public final double fromLng; + public final double toLat; + public final double toLng; + /// Further members of the cart, as JSON without the braces, or "". + public final String extra; + + Trip(double fromLat, double fromLng, double toLat, double toLng, String extra) { + this.fromLat = fromLat; + this.fromLng = fromLng; + this.toLat = toLat; + this.toLng = toLng; + this.extra = extra; + } + } + + /// A dish of this price, cooked at the first point and eaten at the second. + public static final long DISH_CENTS = 1800; + + private static final Map KITCHENS = new HashMap<>(); + /// The application the kitchens above were opened in. Classes whose + /// contexts differ (one injects the database, another does not) each start + /// the application afresh, with an empty in-memory database, and a compiled + /// run keeps this class's statics across all of them in one process, so a + /// kitchen remembered from an application that has stopped names a + /// restaurant the running one never had. Each running application has its + /// own MockMvc, which is what tells them apart. + private static MockMvc kitchensOf; + private static int kitchens; + + public static Trip trip(double fromLat, double fromLng, double toLat, double toLng) { + return new Trip(fromLat, fromLng, toLat, toLng, ""); + } + + /// A trip whose cart says more: `extra` is further members of it, as JSON, + /// without the braces. + public static Trip trip(double fromLat, double fromLng, double toLat, double toLng, String extra) { + return new Trip(fromLat, fromLng, toLat, toLng, extra); + } + + /// The restaurant at a point, opened, approved and given a menu the first + /// time it is asked for, and kept for as long as the application it was + /// opened in runs. + public static synchronized Kitchen kitchen(MockMvc mvc, double lat, double lng) throws Exception { + if (kitchensOf != mvc) { + KITCHENS.clear(); + kitchensOf = mvc; + } + String key = lat + "," + lng; + Kitchen known = KITCHENS.get(key); + if (known != null) { + return known; + } + kitchens++; + String merchant = "kitchen" + kitchens + "@test.example"; + mvc.perform(body(post("/api/account/register"), "{\"email\":\"" + merchant + + "\",\"password\":\"" + People.PASSWORD + "\",\"displayName\":\"Kitchen " + + kitchens + "\",\"phone\":\"+15550113\"}")).andExpect(status().isOk()); + String id = (String) json(mvc, People.asCustomer(body(post("/api/restaurants/application"), + "{\"name\":\"Kitchen " + kitchens + "\",\"cuisine\":\"Pizza\",\"description\":\"Test\"," + + "\"address\":\"Here\",\"lat\":" + lat + ",\"lng\":" + lng + ",\"opensAt\":0," + + "\"closesAt\":0,\"prepMinutes\":10,\"minimumCents\":0,\"priceLevel\":2," + + "\"photoId\":\"\"}"), merchant)).get("id"); + mvc.perform(People.asAdmin(post("/api/admin/restaurants/" + id + "/approve"), DemoAccounts.ADMIN)) + .andExpect(status().isOk()); + String section = (String) json(mvc, People.asMerchant(body(post("/api/merchant/sections"), + "{\"name\":\"Mains\",\"dishes\":[]}"), merchant)).get("id"); + String dish = (String) json(mvc, People.asMerchant(body(post("/api/merchant/dishes"), + "{\"sectionId\":\"" + section + "\",\"name\":\"Margherita\",\"description\":\"\"," + + "\"priceCents\":" + DISH_CENTS + ",\"photoId\":\"\",\"available\":true," + + "\"popular\":false,\"options\":[]}"), merchant)).get("id"); + Kitchen made = new Kitchen(merchant, id, dish, lat, lng); + KITCHENS.put(key, made); + return made; + } + + /// The cart of one dish from the trip's kitchen to its door. + public static String cart(MockMvc mvc, Trip trip) throws Exception { + Kitchen at = kitchen(mvc, trip.fromLat, trip.fromLng); + return "{\"restaurantId\":\"" + at.id + "\",\"lines\":[{\"dishId\":\"" + at.dish + + "\",\"quantity\":1,\"optionIds\":[]}],\"dropoffLat\":" + trip.toLat + + ",\"dropoffLng\":" + trip.toLng + ",\"dropoffAddress\":\"There\",\"note\":\"\"" + + (trip.extra.length() == 0 ? "" : "," + trip.extra) + "}"; + } + + /// Places the order and returns it, waiting for its kitchen. + public static Map place(MockMvc mvc, String customer, Trip trip) throws Exception { + return json(mvc, People.asCustomer(body(post("/api/orders"), cart(mvc, trip)), customer)); + } + + /// The kitchen takes the order, which sends for a courier, and cooks it. + public static void cook(MockMvc mvc, Trip trip, String id) throws Exception { + Kitchen at = kitchen(mvc, trip.fromLat, trip.fromLng); + mvc.perform(People.asMerchant(body(post("/api/merchant/orders/" + id + "/accept"), + "{\"prepMinutes\":10}"), at.merchant)).andExpect(status().isOk()); + mvc.perform(People.asMerchant(post("/api/merchant/orders/" + id + "/ready"), at.merchant)) + .andExpect(status().isOk()); + } + + /// Places an order and has its kitchen take it and cook it, so that a + /// courier is being looked for. Returns its id, which its delivery shares. + public static String ask(MockMvc mvc, String customer, Trip trip) throws Exception { + String id = (String) place(mvc, customer, trip).get("id"); + cook(mvc, trip, id); + return id; + } + + public static void online(MockMvc mvc, String courier, double lat, double lng) throws Exception { + mvc.perform(People.asCourier(body(post("/api/courier/status"), + People.position(true, lat, lng)), courier)).andExpect(status().isOk()); + } + + public static void offline(MockMvc mvc, String courier) throws Exception { + mvc.perform(People.asCourier(body(post("/api/courier/status"), + People.position(false, 0, 0)), courier)).andExpect(status().isOk()); + } + + public static String card(String number) { + // A few years off whenever this runs: a card is refused both when it + // has expired and when its date is further away than any card's is. + int year = Integer.parseInt(Days.iso(System.currentTimeMillis()).substring(0, 4)) + 3; + return "{\"number\":\"" + number + "\",\"expMonth\":12,\"expYear\":" + year + + ",\"cvc\":\"123\",\"holder\":\"A Customer\"}"; + } + + /// Saves a card and returns it as the server describes it. + public static Map addCard(MockMvc mvc, String customer, String number) throws Exception { + Map setup = json(mvc, People.asCustomer(post("/api/payments/methods/setup"), customer)); + return json(mvc, People.asCustomer(body(post("/api/payments/methods/setup/" + setup.get("id") + + "/complete"), card(number)), customer)); + } + + /// Orders and has `courier`, who is on line beside the kitchen, take it to + /// the door. Returns the delivery as the courier is shown it on completing. + public static Map complete(MockMvc mvc, String customer, String courier, Trip trip) throws Exception { + String id = ask(mvc, customer, trip); + mvc.perform(People.asCourier(post("/api/courier/offers/" + id + "/accept"), courier)) + .andExpect(status().isOk()); + mvc.perform(People.asCourier(post("/api/courier/deliveries/" + id + "/arrived"), courier)) + .andExpect(status().isOk()); + mvc.perform(People.asCourier(post("/api/courier/deliveries/" + id + "/start"), courier)) + .andExpect(status().isOk()); + return json(mvc, People.asCourier(post("/api/courier/deliveries/" + id + "/complete"), courier)); + } + + public static Map receipt(MockMvc mvc, String customer, String orderId) throws Exception { + return json(mvc, People.asCustomer(get("/api/payments/receipts/" + orderId), customer)); + } +} diff --git a/scripts/savor/backend/src/test/java/com/codenameone/examples/savor/WebSignInTest.java b/scripts/savor/backend/src/test/java/com/codenameone/examples/savor/WebSignInTest.java new file mode 100644 index 00000000000..04a7aa69940 --- /dev/null +++ b/scripts/savor/backend/src/test/java/com/codenameone/examples/savor/WebSignInTest.java @@ -0,0 +1,267 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor; + +import com.codename1.backend.Json; +import com.codename1.backend.annotations.Autowired; +import com.codename1.backend.security.oauth2.core.OAuth2Parameters; +import com.codename1.backend.test.BackendTest; +import com.codename1.backend.test.MockMvc; +import com.codename1.backend.test.MockResponse; +import com.codenameone.examples.savor.account.DemoAccounts; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static com.codename1.backend.test.MockMvcRequestBuilders.get; +import static com.codename1.backend.test.MockMvcResultMatchers.jsonPath; +import static com.codename1.backend.test.MockMvcResultMatchers.status; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/// Signing in from the app running in a browser, which sees only how each +/// request ended: no redirect it can stop at, and no cookie it can read. +@BackendTest +class WebSignInTest { + private static final String VERIFIER = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"; + private static final String CHALLENGE = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"; + private static final String HERE = "http://" + SignIn.HOST + SecurityConfig.WEB_REDIRECT_PATH; + + @Autowired + private MockMvc mvc; + + private String authorize(String session, String redirect) throws Exception { + return mvc.perform(get("/oauth2/authorize?response_type=code&client_id=" + + SecurityConfig.CLIENT_ID + "&redirect_uri=" + OAuth2Parameters.encode(redirect) + + "&scope=openid%20profile&state=s-web&code_challenge=" + CHALLENGE + + "&code_challenge_method=S256") + .header("Host", SignIn.HOST).cookie("CN1SESSION", session)) + .andReturn().getResponse().getRedirectedUrl(); + } + + @Test + void thePasswordIsAnsweredWithAStatus() throws Exception { + MockResponse accepted = mvc.perform(SignIn.formPost("/login", + "username", DemoAccounts.CUSTOMER, "password", DemoAccounts.PASSWORD) + .header("Host", SignIn.HOST)) + .andExpect(status().isOk()).andReturn().getResponse(); + // Not a redirect: a browser would follow one and report the page at its end. + assertNull(accepted.getRedirectedUrl()); + assertNotNull(accepted.getCookie("CN1SESSION")); + + MockResponse refused = mvc.perform(SignIn.formPost("/login", + "username", DemoAccounts.CUSTOMER, "password", "not-the-password") + .header("Host", SignIn.HOST)) + .andExpect(status().isUnauthorized()).andReturn().getResponse(); + assertNull(refused.getRedirectedUrl()); + assertNull(refused.getCookie("CN1SESSION")); + } + + @Test + void theCodeComesBackThroughThisServer() throws Exception { + String session = SignIn.session(mvc, DemoAccounts.CUSTOMER, DemoAccounts.PASSWORD); + String location = authorize(session, HERE); + assertNotNull(location); + assertTrue(location.startsWith(HERE + "?"), location); + + // What the browser does next, and what the app reads. + String answer = mvc.perform(get(location.substring(("http://" + SignIn.HOST).length())) + .header("Host", SignIn.HOST)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.state").value("s-web")) + .andReturn().getResponse().getContentAsString(); + String code = (String) Json.parseObject(answer).get("code"); + assertNotNull(code); + + Map tokens = Json.parseObject(mvc.perform(SignIn.formPost("/oauth2/token", + "grant_type", "authorization_code", + "code", code, + "redirect_uri", HERE, + "code_verifier", VERIFIER, + "client_id", SecurityConfig.CLIENT_ID).header("Host", SignIn.HOST)) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString()); + String token = (String) tokens.get("access_token"); + assertNotNull(token); + mvc.perform(get("/api/me").header("Host", SignIn.HOST) + .header("Authorization", "Bearer " + token)) + .andExpect(status().isOk()); + } + + @Test + void aCodeIsNeverSentToAnotherServer() throws Exception { + String session = SignIn.session(mvc, DemoAccounts.CUSTOMER, DemoAccounts.PASSWORD); + // Same path, somebody else's server: the address has to be this one. + String elsewhere = authorize(session, "http://attacker.example" + SecurityConfig.WEB_REDIRECT_PATH); + assertTrue(elsewhere == null || elsewhere.indexOf("attacker.example") < 0, String.valueOf(elsewhere)); + String otherPath = authorize(session, "http://" + SignIn.HOST + "/somewhere/else"); + assertTrue(otherPath == null || otherPath.indexOf("code=") < 0, String.valueOf(otherPath)); + } + + @Test + void theInstalledAppStillSignsInItsOwnWay() throws Exception { + assertNotNull(SignIn.token(mvc, DemoAccounts.CUSTOMER, DemoAccounts.PASSWORD)); + } + + @Test + void theCodePageSaysWhenThereIsNoCode() throws Exception { + mvc.perform(get(SecurityConfig.WEB_REDIRECT_PATH + "?error=access_denied&state=s-web") + .header("Host", SignIn.HOST)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value("access_denied")); + } + + @Test + void aSignInAnotherSiteSubmittedIsRefused() throws Exception { + MockResponse forged = mvc.perform(SignIn.formPost("/login", + "username", DemoAccounts.CUSTOMER, "password", DemoAccounts.PASSWORD) + .header("Host", SignIn.HOST).header("Origin", "http://attacker.example")) + .andExpect(status().isForbidden()).andReturn().getResponse(); + assertNull(forged.getCookie("CN1SESSION")); + // A page in a sandboxed frame, or opened from a file. + mvc.perform(SignIn.formPost("/login", + "username", DemoAccounts.CUSTOMER, "password", DemoAccounts.PASSWORD) + .header("Host", SignIn.HOST).header("Origin", "null")) + .andExpect(status().isForbidden()); + // The same name on another port or scheme is another site. + mvc.perform(SignIn.formPost("/login", + "username", DemoAccounts.CUSTOMER, "password", DemoAccounts.PASSWORD) + .header("Host", SignIn.HOST).header("Origin", "https://" + SignIn.HOST)) + .andExpect(status().isForbidden()); + mvc.perform(SignIn.formPost("/login", + "username", DemoAccounts.CUSTOMER, "password", DemoAccounts.PASSWORD) + .header("Host", SignIn.HOST).header("Origin", "http://savor.test:9090")) + .andExpect(status().isForbidden()); + // The rest of what changes something on this chain is held to it too. + mvc.perform(SignIn.formPost("/oauth2/token", "grant_type", "authorization_code") + .header("Host", SignIn.HOST).header("Origin", "http://attacker.example")) + .andExpect(status().isForbidden()); + mvc.perform(SignIn.formPost("/logout") + .header("Host", SignIn.HOST).header("Origin", "http://attacker.example")) + .andExpect(status().isForbidden()); + } + + @Test + void aSignInFromThisServersOwnPageIsAccepted() throws Exception { + // A browser spells the host in lower case; the comparison does not care. + MockResponse own = mvc.perform(SignIn.formPost("/login", + "username", DemoAccounts.CUSTOMER, "password", DemoAccounts.PASSWORD) + .header("Host", SignIn.HOST).header("Origin", "HTTP://SAVOR.TEST:8080")) + .andExpect(status().isOk()).andReturn().getResponse(); + assertNotNull(own.getCookie("CN1SESSION")); + // And the installed app, which is not a browser and names no origin. + assertNotNull(SignIn.session(mvc, DemoAccounts.CUSTOMER, DemoAccounts.PASSWORD)); + } + + @Test + void theOriginIsHeldToTheIssuerWhenOneIsConfigured() { + assertTrue(SecurityConfig.sameOrigin("https://savor.example", "https://savor.example")); + assertTrue(SecurityConfig.sameOrigin("https://SAVOR.example", "https://savor.example/auth")); + assertTrue(!SecurityConfig.sameOrigin("https://savor.example.attacker.example", + "https://savor.example")); + assertTrue(!SecurityConfig.sameOrigin("http://savor.example", "https://savor.example")); + assertTrue(!SecurityConfig.sameOrigin("https://savor.example:8443", "https://savor.example")); + // With an issuer the address a request arrived at is never consulted. + assertEquals("https://savor.example", SecurityConfig.here("https://savor.example")); + // With none, and no request in hand, there is no address to accept. + assertNull(SecurityConfig.here("")); + } + + @Test + void theSessionCookieIsNotSentFromOtherSitesOrReadByScripts() throws Exception { + String header = mvc.perform(SignIn.formPost("/login", + "username", DemoAccounts.CUSTOMER, "password", DemoAccounts.PASSWORD) + .header("Host", SignIn.HOST)) + .andReturn().getResponse().getHeader("Set-Cookie"); + assertNotNull(header); + assertTrue(header.indexOf("HttpOnly") > 0, header); + assertTrue(header.indexOf("SameSite=Lax") > 0 || header.indexOf("SameSite=Strict") > 0, header); + } + + @Test + void theSessionEndsWhenTheAppHasItsTokens() throws Exception { + String session = SignIn.session(mvc, DemoAccounts.CUSTOMER, DemoAccounts.PASSWORD); + String location = authorize(session, HERE); + assertTrue(location.indexOf("code=") > 0, location); + String answer = mvc.perform(get(location.substring(("http://" + SignIn.HOST).length())) + .header("Host", SignIn.HOST)) + .andReturn().getResponse().getContentAsString(); + Map tokens = Json.parseObject(mvc.perform(SignIn.formPost("/oauth2/token", + "grant_type", "authorization_code", + "code", (String) Json.parseObject(answer).get("code"), + "redirect_uri", HERE, + "code_verifier", VERIFIER, + "client_id", SecurityConfig.CLIENT_ID).header("Host", SignIn.HOST)) + .andReturn().getResponse().getContentAsString()); + + // Asking nicely does not end it: only a POST does. + mvc.perform(get("/logout").header("Host", SignIn.HOST).cookie("CN1SESSION", session)); + assertTrue(authorize(session, HERE).indexOf("code=") > 0); + + MockResponse ended = mvc.perform(SignIn.formPost("/logout") + .header("Host", SignIn.HOST).header("Origin", "http://" + SignIn.HOST) + .cookie("CN1SESSION", session)) + .andExpect(status().isNoContent()).andReturn().getResponse(); + assertNull(ended.getRedirectedUrl()); + // The cookie earns no more codes... + String after = authorize(session, HERE); + assertTrue(after == null || after.indexOf("code=") < 0, String.valueOf(after)); + // ...and the tokens it earned are untouched. + mvc.perform(get("/api/me").header("Host", SignIn.HOST) + .header("Authorization", "Bearer " + tokens.get("access_token"))) + .andExpect(status().isOk()); + mvc.perform(SignIn.formPost("/oauth2/token", + "grant_type", "refresh_token", + "refresh_token", (String) tokens.get("refresh_token"), + "client_id", SecurityConfig.CLIENT_ID).header("Host", SignIn.HOST)) + .andExpect(status().isOk()); + } + + @Test + void theCodePageIsDataAndNothingElse() throws Exception { + MockResponse page = mvc.perform(get(SecurityConfig.WEB_REDIRECT_PATH + + "?code=%3Cscript%3Ealert(1)%3C/script%3E&state=s").header("Host", SignIn.HOST)) + .andExpect(status().isOk()).andReturn().getResponse(); + assertTrue(String.valueOf(page.getContentType()).startsWith("application/json"), + String.valueOf(page.getContentType())); + assertEquals("nosniff", page.getHeader("X-Content-Type-Options")); + assertEquals("no-store", page.getHeader("Cache-Control")); + } + + @Test + void anIssuerIsTheOnlyWebAddressWhenOneIsConfigured() { + com.codename1.backend.security.oauth2.server.authorization.RegisteredClient app = + com.codename1.backend.security.oauth2.server.authorization.RegisteredClient + .withId("savor").clientId(SecurityConfig.CLIENT_ID) + .clientAuthenticationMethod(com.codename1.backend.security.oauth2.core + .ClientAuthenticationMethod.NONE) + .authorizationGrantType(com.codename1.backend.security.oauth2.core + .AuthorizationGrantType.AUTHORIZATION_CODE) + .redirectUri(SecurityConfig.LOOPBACK_REDIRECT).build(); + java.util.Set uris = SecurityConfig.withWebRedirect(app, "https://savor.example") + .getRedirectUris(); + assertTrue(uris.contains("https://savor.example/signin/code"), uris.toString()); + assertTrue(uris.contains(SecurityConfig.LOOPBACK_REDIRECT), uris.toString()); + assertEquals(2, uris.size(), uris.toString()); + // With none configured and no request in hand, nothing is added. + assertEquals(1, SecurityConfig.withWebRedirect(app, "").getRedirectUris().size()); + } +} diff --git a/scripts/savor/backend/src/test/java/com/codenameone/examples/savor/delivery/OfferRecheckTest.java b/scripts/savor/backend/src/test/java/com/codenameone/examples/savor/delivery/OfferRecheckTest.java new file mode 100644 index 00000000000..ade7ad371d8 --- /dev/null +++ b/scripts/savor/backend/src/test/java/com/codenameone/examples/savor/delivery/OfferRecheckTest.java @@ -0,0 +1,121 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.delivery; + +import com.codename1.backend.DataSource; +import com.codename1.backend.annotations.Autowired; +import com.codename1.backend.test.BackendTest; +import com.codename1.backend.test.MockMvc; +import com.codenameone.examples.savor.People; +import com.codenameone.examples.savor.Trips; +import com.codenameone.examples.savor.account.Accounts; +import com.codenameone.examples.savor.account.DemoAccounts; +import com.codenameone.examples.savor.api.CourierStatusDto; +import com.codenameone.examples.savor.api.DeliveryDto; +import com.codenameone.examples.savor.api.DeliveryState; +import com.codenameone.examples.savor.courier.Couriers; +import com.codenameone.examples.savor.order.Orders; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +/// An offer made to a courier who stopped being fit for the delivery after the +/// search chose them. +/// +/// Choosing and offering are two transactions, and a courier can go off line, +/// ride away, lose their approval or be blocked in between. Each of those is +/// set up here and the offer made to them anyway, which is the second +/// transaction running after the first read them as they were. It sits in +/// this package because the offer is not part of the service's public face. +@BackendTest +class OfferRecheckTest { + // Bologna. + private static final double LAT = 44.4949; + private static final double LNG = 11.3426; + + @Autowired + private MockMvc mvc; + @Autowired + private Accounts accounts; + @Autowired + private Orders orders; + @Autowired + private Deliveries deliveries; + @Autowired + private Couriers couriers; + @Autowired + private DataSource db; + + private void at(String courier, boolean online, double lat, double lng) throws Exception { + couriers.report(courier, new CourierStatusDto(online, lat, lng, 90d)); + } + + private void notOffered(String id, String courier, String why) throws Exception { + assertNull(deliveries.offer(id, courier), why); + assertEquals(DeliveryState.REQUESTED, deliveries.forOrder(id, "", false).state(), why); + } + + @Test + void aCourierNoLongerFitForTheDeliveryIsNotOfferedIt() throws Exception { + String customer = People.customer(accounts, "blq-customer"); + String courier = People.courier(accounts, "blq-courier"); + // Cooked with nobody on line, so it waits. The courier is kept unfit + // from here until the end, so the scheduled search, which runs + // alongside, has nobody to offer it to either. + String id = Trips.ask(mvc, customer, Trips.trip(LAT, LNG, LAT + 0.02, LNG + 0.02)); + assertEquals(DeliveryState.REQUESTED, deliveries.forOrder(id, "", false).state()); + + // On line, a hundred kilometres away. + at(courier, true, LAT + 1d, LNG); + notOffered(id, courier, "a courier out of reach"); + + // In reach, and blocked. + accounts.suspend(DemoAccounts.ADMIN, courier, true, "a test"); + at(courier, true, LAT, LNG); + notOffered(id, courier, "a blocked courier"); + + // No longer approved. + db.execute("UPDATE courier_application SET status = 'rejected' WHERE username = ?", + new Object[] {courier}); + accounts.suspend(DemoAccounts.ADMIN, courier, false, "a test"); + notOffered(id, courier, "a courier whose approval was withdrawn"); + + // Not heard from in a long while, whatever their status says. + db.execute("UPDATE courier_state SET last_seen = 1 WHERE username = ?", + new Object[] {courier}); + db.execute("UPDATE courier_application SET status = 'approved' WHERE username = ?", + new Object[] {courier}); + notOffered(id, courier, "a courier not heard from"); + + // Off line. + at(courier, false, 0d, 0d); + notOffered(id, courier, "a courier off line"); + + // And once fit again, the delivery is theirs: offered here, or by the + // scheduled search if it got there first. + at(courier, true, LAT, LNG); + deliveries.offer(id, courier); + DeliveryDto now = deliveries.forOrder(id, courier, true); + assertEquals(DeliveryState.OFFERED, now.state()); + assertEquals(courier, now.courierUsername()); + orders.cancelByAdmin(DemoAccounts.ADMIN, id, "the test is over"); + at(courier, false, 0d, 0d); + } +} diff --git a/scripts/savor/check-template.sh b/scripts/savor/check-template.sh new file mode 100755 index 00000000000..246ce0a7f1c --- /dev/null +++ b/scripts/savor/check-template.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +# +# Builds what the Initializr generates from this project. +# +# check-template.sh [work-directory] +# +# The Initializr offers this application as a template, from resources that +# scripts/sync-initializr-template.py derives from it. That the resources match +# is one question; this is the other: whether a project generated from them, +# under another name and in another package, still builds. The real generator +# makes the project, and it is then built against this checkout: +# +# the server's tests pass against the contract module +# the app builds from common, without platform modules and with them, and +# the client half of the contract is generated into it +# +# Needs this checkout's artifacts installed (core, javase, css-compiler, the +# Maven plugin, backend, backend-test), a JDK 17 or newer as JAVA_HOME, python3 +# and unzip. +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$(cd "$HERE/../.." && pwd)" +MVN="${MVN:-mvn}" +WORK="${1:-$HERE/target/template-check}" + +log() { echo "[savor-template] $*" >&2; } + +python3 "$ROOT/scripts/sync-initializr-template.py" savor + +# The version this checkout installs, which the generated poms are pointed at: +# the release the Initializr generates against does not have this project's +# build support until one that follows this change. +VERSION="$(sed -n 's:.*\(.*\).*:\1:p' "$ROOT/maven/pom.xml" | head -n 1)" +[ -n "$VERSION" ] || { log "could not read the version from maven/pom.xml"; exit 1; } + +rm -rf "$WORK" +mkdir -p "$WORK" +log "generating the Initializr's downloads" +python3 "$ROOT/scripts/tests/generate-initializr-fixtures.py" "$WORK/fixtures" > "$WORK/fixtures.log" 2>&1 \ + || { tail -n 60 "$WORK/fixtures.log" >&2; log "the generator failed"; exit 1; } + +# extract -- a generated project, pointed at this checkout's version +extract() { + local dir="$WORK/$1" + mkdir -p "$dir" + unzip -q "$WORK/fixtures/SAVOR-$1-INTELLIJ.zip" -d "$dir" + python3 - "$dir/pom.xml" "$VERSION" <<'PY' +import re, sys +path, version = sys.argv[1], sys.argv[2] +text = open(path, encoding='utf-8').read() +for tag in ('cn1.plugin.version', 'cn1.version'): + text, count = re.subn(r'<%s>[^<]*' % (tag, tag), '<%s>%s' % (tag, version, tag), text) + assert count == 1, (tag, count) +open(path, 'w', encoding='utf-8').write(text) +PY + echo "$dir" +} + +# The fixtures are generated as com.example.probe.LauncherProbe. +PACKAGE=com/example/probe + +minimal="$(extract APP_WITH_BACKEND)" +log "running the generated server's tests" +(cd "$minimal" && "$MVN" -B -q -Dcodename1.platform=backend -pl shared,backend -am test) +ls "$minimal"/backend/target/surefire-reports/TEST-*.xml >/dev/null 2>&1 \ + || { log "the generated server's tests did not run"; exit 1; } +[ -f "$minimal/backend/target/generated-sources/cn1-annotations/$PACKAGE/api/OrderApiServer.java" ] \ + || { log "the server's half of the contract was not generated"; exit 1; } + +for layout in APP_WITH_BACKEND FULL; do + project="$WORK/$layout" + [ -d "$project" ] || project="$(extract "$layout")" + log "building the generated app ($layout)" + (cd "$project" && "$MVN" -B -q -Dcodename1.platform=javase -Djava.awt.headless=true -DskipTests package) + for built in "classes/theme.res" "classes/$PACKAGE/api/OrderApiImpl.class" "classes/cn1app/RestClientBootstrap.class"; do + [ -f "$project/common/target/$built" ] \ + || { log "$layout: common/target/$built was not built"; exit 1; } + done +done +log "passed" diff --git a/scripts/savor/common/codenameone_settings.properties b/scripts/savor/common/codenameone_settings.properties new file mode 100644 index 00000000000..d22f91865ef --- /dev/null +++ b/scripts/savor/common/codenameone_settings.properties @@ -0,0 +1,58 @@ +codename1.android.keystore= +codename1.android.keystoreAlias= +codename1.android.keystorePassword= +codename1.arg.and.themeMode=modern +# A server started on this machine is reached over plain HTTP, which Android +# refuses by default. Remove this once the app talks to a server with a +# certificate. +codename1.arg.android.xapplication_attr=android\:usesCleartextTraffic\="true" +codename1.arg.desktop.interactiveScrollbars=true +codename1.arg.desktop.titleBar=native +codename1.arg.ios.newStorageLocation=true +# What iOS shows when someone applying to drive photographs a document. +codename1.arg.ios.NSCameraUsageDescription=The camera photographs the documents of your application to drive. +# What iOS shows when they pick a picture of a document they already have. +codename1.arg.ios.NSPhotoLibraryUsageDescription=Your photo library is where you pick a picture of a document from. +# What iOS shows before Face ID unlocks the sign-in kept in secure storage. +codename1.arg.ios.NSFaceIDUsageDescription=Authenticate to securely access your account +# What iOS shows when the app first asks where the device is. +codename1.arg.ios.NSLocationWhenInUseUsageDescription=Your location is where your order is delivered to, and, when you deliver, where the nearby orders are offered to you. +# While a courier is online the app keeps reporting where they are from the +# background: iOS needs the location background mode and the Always prompt's +# text, Android a foreground service of the location type. +codename1.arg.ios.background_modes=location +codename1.arg.ios.locationUsageDescription=Your location is where your order is delivered to, and, when you deliver, where the nearby orders are offered to you. +codename1.arg.ios.NSLocationAlwaysAndWhenInUseUsageDescription=While you are online as a courier, Savor keeps sharing your location with customers and dispatch, even with the app in the background. +codename1.arg.android.foregroundServiceType=location +# Push notifications. With push included the build adds the +# remote-notification background mode and the push entitlement itself. +codename1.arg.ios.includePush=true +# Android push is off until the app has its Firebase file: a build that +# uses PushClient without google-services.json stops with an error. Once +# native/android/google-services.json is in place, delete this line. +codename1.arg.android.messagingService=none +# The delivery on the lock screen updates as the courier moves. +codename1.arg.ios.surfaces.frequentUpdates=true +codename1.arg.ios.themeMode=modern +codename1.arg.java.version=17 +codename1.arg.nativeTheme=native +codename1.cssTheme=true +codename1.displayName=Savor +codename1.icon=icon.png +codename1.ios.appid=Q5GHSKAL2F.com.codenameone.examples.savor +codename1.ios.certificate= +codename1.ios.certificatePassword= +codename1.ios.debug.certificate= +codename1.ios.debug.certificatePassword= +codename1.ios.debug.provision= +codename1.ios.provision= +codename1.ios.release.certificate= +codename1.ios.release.certificatePassword= +codename1.ios.release.provision= +codename1.kotlin=false +codename1.languageLevel=5 +codename1.mainName=Savor +codename1.packageName=com.codenameone.examples.savor +codename1.secondaryTitle=Hello World +codename1.vendor=CodenameOne +codename1.version=1.0 diff --git a/scripts/savor/common/icon.png b/scripts/savor/common/icon.png new file mode 100644 index 00000000000..1f4fa5dd252 Binary files /dev/null and b/scripts/savor/common/icon.png differ diff --git a/scripts/savor/common/pom.xml b/scripts/savor/common/pom.xml new file mode 100644 index 00000000000..9d8ca235b63 --- /dev/null +++ b/scripts/savor/common/pom.xml @@ -0,0 +1,1375 @@ + + + 4.0.0 + + com.codenameone.examples.savor + savor + 1.0-SNAPSHOT + + com.codenameone.examples.savor + savor-common + 1.0-SNAPSHOT + jar + + + + + com.codenameone + codenameone-core + provided + + + + ${project.groupId} + savor-shared + ${project.version} + + + + + com.codenameone + codenameone-javase + test + + + org.junit.jupiter + junit-jupiter + 5.9.3 + test + + + + + + + + + + + install-codenameone + ${user.home}/.codenameone/guibuilder.jar + + + + org.apache.maven.plugins + maven-antrun-plugin + + + + validate + + run + + + + + + + + + + + + + + + + + + + + + + + + + + + android-compat + + + ${basedir}/src/main/android + + + + + com.codenameone + codenameone-android-compat + provided + + + + + + kotlin + + + + ${basedir}/src/main/kotlin + + + + 2.2.10 + true + + + + org.jetbrains.kotlin + kotlin-stdlib + ${kotlin.version} + + + + + + org.jetbrains + annotations + 13.0 + + + com.codenameone + java-runtime + provided + + + + + + org.codehaus.mojo + properties-maven-plugin + 1.0.0 + + + initialize + + read-project-properties + + + + ${basedir}/codenameone_settings.properties + + + + + + + org.jetbrains.kotlin + kotlin-maven-plugin + ${kotlin.version} + + + compile + + process-resources + + compile + + + + ${project.basedir}/src/main/kotlin + ${project.basedir}/src/main/java + + ${project.basedir}/src/main/android/kotlin + ${project.basedir}/src/main/android/java + ${project.build.directory}/generated-sources/android + + + -no-reflect + -no-jdk + + + + + test-compile + process-test-sources + + test-compile + + + + ${project.basedir}/src/test/kotlin + ${project.basedir}/src/test/java + + + -no-reflect + -no-jdk + + + + + + + + + + + + + javase + + + codename1.platform + javase + + + + javase + + + + + org.codehaus.mojo + exec-maven-plugin + + java + true + + -Xmx1024M + + -classpath + + ${exec.mainClass} + ${cn1.mainClass} + + + + + + + + + + cn1-javase-natives + + + ${basedir}/../javase/pom.xml + + + + + + com.codenameone + codenameone-maven-plugin + + + compile-javase-natives + process-classes + + compile-javase-natives + + + javase + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + + + org.apache.maven.surefire + surefire-junit-platform + 3.0.0-M5 + + + + ${skipTests} + + ${project.build.directory}/cn1-javase/classes + + + + + + + + + cn1-host-javase + + + codename1.platform + javase + + + ${basedir}/../javase/pom.xml + + + + javase + javase + none + + + + + com.codenameone + codenameone-maven-plugin + + + hosted-javase + validate + + hosted-platform + + + javase + + + + build-javase + package + + build + + + javase + + + + + + + + + + cn1-host-android + + + codename1.platform + android + + + ${basedir}/../android/pom.xml + + + + android + android + android-device + + + + + com.codenameone + codenameone-maven-plugin + + + hosted-android + validate + + hosted-platform + + + android + + + + build-android + package + + build + + + android + + + + + + + + + + cn1-host-ios + + + codename1.platform + ios + + + ${basedir}/../ios/pom.xml + + + + ios + ios + ios-device + + + + + com.codenameone + codenameone-maven-plugin + + + hosted-ios + validate + + hosted-platform + + + ios + + + + build-ios + package + + build + + + ios + + + + + + + + + + cn1-host-javascript + + + codename1.platform + javascript + + + ${basedir}/../javascript/pom.xml + + + + javascript + javascript + local-javascript + + + + + com.codenameone + codenameone-maven-plugin + + + hosted-javascript + validate + + hosted-platform + + + javascript + + + + build-javascript + package + + build + + + javascript + + + + + + + + + + cn1-host-win + + + codename1.platform + win + + + ${basedir}/../win/pom.xml + + + + win + win + windows-device + + + + + com.codenameone + codenameone-maven-plugin + + + hosted-win + validate + + hosted-platform + + + win + + + + build-windows + package + + build + + + win + + + + + + + + + + cn1-host-linux + + + codename1.platform + linux + + + ${basedir}/../linux/pom.xml + + + + linux + linux + linux-device + + + + + com.codenameone + codenameone-maven-plugin + + + hosted-linux + validate + + hosted-platform + + + linux + + + + build-linux + package + + build + + + linux + + + + + + + + + + simulator + + javase + com.codename1.impl.javase.Simulator + + + + com.codenameone + codenameone-core + compile + + + com.codenameone + codenameone-javase + runtime + + + com.codenameone + cn1-binaries-javase + ${cn1.version} + pom + runtime + + + + + + com.codenameone + codenameone-maven-plugin + + + prepare-simulator-environment + initialize + + prepare-simulator-classpath + + + javase + + + + + + org.codehaus.mojo + exec-maven-plugin + + + run-in-simulator + verify + + exec + + + ${cn1.hosted.javase.skip} + ${basedir} + java + + -Xmx1024M + -Dcodename1.css.compiler.args.input=${codename1.css.compiler.args.input} + -Dcodename1.css.compiler.args.output=${codename1.css.compiler.args.output} + -Dcodename1.css.compiler.args.merge=${codename1.css.compiler.args.merge} + ${codename1.exec.args.debug} + ${codename1.exec.args.runjdwp.transport} + + @${cn1.hosted.classpathArgFile} + ${exec.mainClass} + ${codename1.mainClass} + + + + + + + + + + + idea-simulator + + com.codename1.impl.javase.Simulator + true + + + + com.codenameone + codenameone-core + compile + + + com.codenameone + codenameone-javase + runtime + + + com.codenameone + cn1-binaries-javase + ${cn1.version} + pom + runtime + + + + + + com.codenameone + codenameone-maven-plugin + + + prepare-simulator-environment + initialize + + prepare-simulator-classpath + + + javase + + + + + + org.codehaus.mojo + exec-maven-plugin + + + run-in-simulator-idea + verify + + java + + + ${cn1.hosted.javase.skip} + + ${project.build.directory}/cn1-javase/classes + + + ${codename1.mainClass} + + + + codename1.css.compiler.args.input + ${codename1.css.compiler.args.input} + + + codename1.css.compiler.args.output + ${codename1.css.compiler.args.output} + + + codename1.css.compiler.args.merge + ${codename1.css.compiler.args.merge} + + + + cn1.class.path + ${cn1.class.path} + + + + + + + + + + + + debug-simulator + + com.codename1.impl.javase.Simulator + true + + + + com.codenameone + codenameone-core + compile + + + com.codenameone + codenameone-javase + runtime + + + com.codenameone + cn1-binaries-javase + ${cn1.version} + pom + runtime + + + + + + com.codenameone + codenameone-maven-plugin + + + prepare-simulator-environment + initialize + + prepare-simulator-classpath + + + javase + + + + + + org.codehaus.mojo + exec-maven-plugin + + + run-in-simulator + verify + + exec + + + ${cn1.hosted.javase.skip} + ${basedir} + java + + -Xdebug + -Xrunjdwp:transport=dt_socket,server=n,address=${jpda.address} + -Xmx1024M + -Dcodename1.css.compiler.args.input=${codename1.css.compiler.args.input} + -Dcodename1.css.compiler.args.output=${codename1.css.compiler.args.output} + -Dcodename1.css.compiler.args.merge=${codename1.css.compiler.args.merge} + @${cn1.hosted.classpathArgFile} + ${exec.mainClass} + ${codename1.mainClass} + + + + + + + + + + + debug-eclipse + + com.codename1.impl.javase.Simulator + true + + + + com.codenameone + codenameone-core + compile + + + com.codenameone + codenameone-javase + runtime + + + com.codenameone + cn1-binaries-javase + ${cn1.version} + pom + runtime + + + + + + com.codenameone + codenameone-maven-plugin + + + prepare-simulator-environment + initialize + + prepare-simulator-classpath + + + javase + + + + + + org.codehaus.mojo + exec-maven-plugin + + + run-in-simulator + verify + + exec + + + ${cn1.hosted.javase.skip} + ${basedir} + java + + -Xdebug + -Xrunjdwp:transport=dt_socket,server=y,address=${jpda.address},suspend=y + -Xmx1024M + -Dcodename1.css.compiler.args.input=${codename1.css.compiler.args.input} + -Dcodename1.css.compiler.args.output=${codename1.css.compiler.args.output} + -Dcodename1.css.compiler.args.merge=${codename1.css.compiler.args.merge} + @${cn1.hosted.classpathArgFile} + ${exec.mainClass} + ${codename1.mainClass} + + + + + + + + + + + run-desktop + + + com.codenameone + codenameone-core + compile + + + com.codenameone + codenameone-javase + runtime + + + com.codenameone + cn1-binaries-javase + ${cn1.version} + pom + runtime + + + + + + com.codenameone + codenameone-maven-plugin + + + generate-icons + generate-sources + + generate-desktop-app-wrapper + + + javase + + + + + + org.codehaus.mojo + exec-maven-plugin + + + run-desktop + verify + + java + + + ${cn1.hosted.javase.skip} + ${codename1.packageName}.${codename1.mainName}Stub + + + ${project.build.directory}/cn1-javase/classes + ${project.build.directory}/cn1-javase/desktop + ${basedir}/src/desktop/resources + ${basedir}/../javase/src/desktop/resources + + + + + + + + + + + executable-jar + + + com.codenameone + codenameone-core + compile + + + com.codenameone + codenameone-javase + runtime + + + com.codenameone + cn1-binaries-javase + ${cn1.version} + pom + runtime + + + + + + com.codenameone + codenameone-maven-plugin + + + generate-icons + generate-sources + + generate-desktop-app-wrapper + + + javase + + + + javase-executable-jar + package + + javase-executable-jar + + + javase + + + + + + + + + + test + + + com.codenameone + codenameone-core + compile + + + com.codenameone + codenameone-javase + runtime + + + com.codenameone + cn1-binaries-javase + ${cn1.version} + pom + runtime + + + + + + com.codenameone + codenameone-maven-plugin + + + cn1-tests + test + + test + + + javase + + + + + + + + + + run-android + + + + com.codenameone + codenameone-maven-plugin + + + + run-android-hosted + validate + + hosted-platform + + + android + + + + + + maven-antrun-plugin + + + adb-install + verify + + run + + + ${cn1.hosted.android.skip} + + Running adb install + + + + + + Trying to start app on device using adb + + + + + + + + + + + + + + + + + + + + ios-debug + + + iphone + + + ios + + + + + ios-release + + + iphone + true + + + ios + true + + + + + javascript + + javascript + javascript + + + + + android + + android + android + + + + + win + + windows + win + + + + + windows + + desktop_windows + javase + + + + + mac + + desktop_macosx + javase + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + 17 + 17 + + + + org.codehaus.mojo + properties-maven-plugin + 1.0.0 + + + initialize + + read-project-properties + + + + ${basedir}/codenameone_settings.properties + + + + + + + + com.codenameone + codenameone-maven-plugin + + + + + transcode-svg + generate-sources + + transcode-svg + + + + + compile-android-res + generate-sources + + compile-android-res + + + + generate-gui-sources + process-sources + + generate-gui-sources + + + + cn1-process-classes + process-classes + + + remap-android + bytecode-compliance + css + + process-annotations + + + + + attach-test-artifact + test + + attach-test-artifact + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + + true + + + + + + + + + + diff --git a/scripts/savor/common/src/desktop/resources/NativeTheme.res b/scripts/savor/common/src/desktop/resources/NativeTheme.res new file mode 100644 index 00000000000..83e067b6969 Binary files /dev/null and b/scripts/savor/common/src/desktop/resources/NativeTheme.res differ diff --git a/scripts/savor/common/src/main/css/theme.css b/scripts/savor/common/src/main/css/theme.css new file mode 100644 index 00000000000..8c7e534c629 --- /dev/null +++ b/scripts/savor/common/src/main/css/theme.css @@ -0,0 +1,1851 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +/* + * The whole look of the app. + * + * COLOURS AND SHAPE -- change these and nothing else to re-skin the app. Every + * rule below takes its colours from here; none names a colour of its own. Each + * colour has a second value for dark mode, used by the block at the end of the + * file. + */ +#Constants { + includeNativeBool: true; + defaultSourceDPIInt: "0"; + /* ONE TYPE SCALE, in millimetres as a phone draws them: 2.1 caption, + 2.5 secondary, 2.75 body, 2.9 control, 3.5 heading, 4.4 title, 7 display. + A desktop draws a millimetre in far fewer pixels and is read from + further away, so every size is a quarter larger there. */ + device-desktop-font-scale: "1.25"; + /* A line too long for its place ends in an ellipsis, not mid-word. */ + endsWith3PointsBool: true; + /* Screens change quickly: nothing waits for an animation. */ + transitionSpeedInt: "200"; + /* The switch, sized from the Switch style's font. */ + switchThumbScaleY: "1.5"; + switchTrackScaleY: "1.8"; + switchTrackScaleX: "3.1"; + switchThumbInsetMM: "0.35"; + switchTrackOffOutlineWidthMM: "0"; + switchTrackOnOutlineWidthMM: "0"; + switchThumbWidthScale: "1.0"; + --brand: #7ac943; + --brand-pressed: #9bdf6c; + /* Dark lettering on the green: white on it reads at barely 2:1. */ + --brand-contrast: #10210a; + --surface: #ffffff; + --surface-alt: #eff0f0; + --surface-press: #e2e4e4; + --text: #1b1c1d; + /* Grub's grey, darkened from #989898 until it reads on white. */ + --text-muted: #6b6e70; + --line: #dbdfdf; + --line-strong: #c4c9c9; + --success: #12805c; + --warning: #b25e09; + --danger: #c5292a; + /* The pale ground a selected card and a badge sit on. */ + --brand-tint: #eef8e6; + --success-tint: #e4f4ed; + --warning-tint: #fcf0df; + --danger-tint: #fcebeb; + + --brand-dark: #8fd65c; + --brand-pressed-dark: #7ac943; + --brand-contrast-dark: #10210a; + --surface-dark: #18191a; + --surface-alt-dark: #242526; + --surface-press-dark: #3a3b3c; + --text-dark: #e4e6eb; + --text-muted-dark: #b0b3b8; + --line-dark: #3a3b3c; + --line-strong-dark: #4e4f50; + --success-dark: #47c79a; + --warning-dark: #f0a24b; + --danger-dark: #ff7a7a; + --brand-tint-dark: #23331a; + --success-tint-dark: #15302a; + --warning-tint-dark: #33270f; + --danger-tint-dark: #3a1f22; + + /* ONE RADIUS for anything card- or button-shaped, a smaller one for what + sits inside a card, and the sheet's. */ + --radius: 2mm; + --radius-small: 1.5mm; + + /* What floats over the map: the shadow under it, the dimming behind an + open menu, and the corners of the sheet. */ + --shadow: #101828; + /* Under a card with square-ish corners. Black, and without a blur where it + is used: that is the one shadow a rounded rectangle draws for itself. + Any other is rendered to pictures of the card when the theme is + compiled, and a build that cannot render them has no card at all. */ + --shadow-card: rgba(0, 0, 0, 0.14); + --scrim: #000000; + --radius-sheet: 4mm; + /* The map's own drawings. The edge is the outline that keeps a pin, the + courier and the route readable over any tile. */ + --map-edge: #ffffff; + --map-edge-dark: #18191a; + --car: #1b1c1d; + --car-dark: #e4e6eb; +} + +/* ---------------------------------------------------------------- surfaces */ + +SvForm { + background-color: var(--surface); + padding: 0; + margin: 0; +} + +SvPage { + background-color: var(--surface); + padding: 3mm 3.6mm 4mm 3.6mm; + margin: 0; +} + +/* The coloured top of the welcome screen. */ +SvHero { + background-color: var(--brand); + padding: 14mm 4.4mm 8mm 4.4mm; + margin: 0; +} + +SvHeroTitle { + color: var(--brand-contrast); + font-family: "native:MainBold"; + font-size: 7mm; + padding: 2.4mm 0 0.8mm 0; + margin: 0; + background-color: transparent; +} + +SvHeroText { + color: var(--brand-contrast); + font-family: "native:MainRegular"; + font-size: 2.9mm; + padding: 0; + margin: 0; + background-color: transparent; +} + +SvHeroIcon { + color: var(--brand-contrast); + padding: 0; + margin: 0; + background-color: transparent; +} + +/* The panel that rises over the map. */ +SvCard { + background-color: var(--surface); + border-radius: var(--radius-sheet) var(--radius-sheet) 0 0; + padding: 3.6mm 3.6mm 4mm 3.6mm; + margin: 0; +} + +/* A panel that floats over the map with a gap around it. */ +SvFloat { + background-color: var(--surface); + border-radius: var(--radius); + padding: 2.4mm 3.2mm 2.4mm 3.2mm; + margin: 2.4mm; +} + +SvPanel { + background-color: var(--surface-alt); + border-radius: var(--radius); + padding: 2.8mm 3mm 2.8mm 3mm; + margin: 1.2mm 0 1.2mm 0; +} + +SvDivider { + background-color: var(--line); + padding: 0; + margin: 2mm 0 2mm 0; +} + +/* -------------------------------------------------------------------- text */ + +SvTitle { + color: var(--text); + font-family: "native:MainBold"; + font-size: 4.4mm; + padding: 0.6mm 0 1mm 0; + margin: 0; + background-color: transparent; +} + +SvHeading { + color: var(--text); + font-family: "native:MainBold"; + font-size: 3.5mm; + padding: 0.4mm 0 0.4mm 0; + margin: 0; + background-color: transparent; +} + +SvText { + color: var(--text); + font-family: "native:MainRegular"; + font-size: 2.75mm; + padding: 0.4mm 0 0.4mm 0; + margin: 0; + background-color: transparent; +} + +SvMuted { + color: var(--text-muted); + font-family: "native:MainRegular"; + font-size: 2.5mm; + padding: 0.4mm 0 0.4mm 0; + margin: 0; + background-color: transparent; +} + +SvLabel { + color: var(--text-muted); + font-family: "native:MainBold"; + font-size: 2.1mm; + padding: 2.6mm 0 1mm 0.3mm; + margin: 0; + background-color: transparent; +} + +SvPrice { + color: var(--text); + font-family: "native:MainBold"; + font-size: 4.4mm; + padding: 0; + margin: 0; + text-align: right; + background-color: transparent; +} + +/* The same, where it is the headline of a sheet: it starts where the + sheet's words start. */ +SvFare { + color: var(--text); + font-family: "native:MainBold"; + font-size: 5.2mm; + padding: 0; + margin: 0; + text-align: left; + background-color: transparent; +} + +SvError { + color: var(--danger); + font-family: "native:MainRegular"; + font-size: 2.5mm; + padding: 1mm 0 1mm 0; + margin: 0; + background-color: transparent; +} + +SvNotice { + color: var(--text); + background-color: var(--surface-alt); + border-radius: var(--radius); + font-family: "native:MainRegular"; + font-size: 2.5mm; + padding: 2.4mm 3mm 2.4mm 3mm; + margin: 2mm 0 1mm 0; +} + +/* ----------------------------------------------------------------- buttons */ + +SvPrimary { + color: var(--brand-contrast); + background-color: var(--brand); + border-radius: var(--radius); + font-family: "native:MainBold"; + font-size: 2.9mm; + text-align: center; + padding: 2.75mm 4mm 2.75mm 4mm; + margin: 1.6mm 0 0.8mm 0; +} + +SvPrimary.pressed { + background-color: var(--brand-pressed); +} + +SvPrimary.disabled { + color: var(--text-muted); + background-color: var(--surface-press); +} + +SvSecondary { + color: var(--text); + background-color: var(--surface-alt); + border-radius: var(--radius); + font-family: "native:MainBold"; + font-size: 2.9mm; + text-align: center; + padding: 2.75mm 4mm 2.75mm 4mm; + margin: 1.6mm 0 0.8mm 0; +} + +SvSecondary.pressed { + background-color: var(--surface-press); +} + +SvDanger { + color: var(--danger); + background-color: var(--danger-tint); + border-radius: var(--radius); + font-family: "native:MainBold"; + font-size: 2.9mm; + text-align: center; + padding: 2.75mm 4mm 2.75mm 4mm; + margin: 1.6mm 0 0.8mm 0; +} + +SvDanger.pressed { + background-color: var(--surface-press); +} + +SvLink { + color: var(--brand); + background-color: transparent; + border-radius: var(--radius); + font-family: "native:MainBold"; + font-size: 2.75mm; + text-align: center; + padding: 2.4mm 2mm 2.4mm 2mm; + margin: 0; +} + +SvSecondary.disabled, SvDanger.disabled, SvLink.disabled { + color: var(--line-strong); +} + +SvLink.pressed { + color: var(--brand-pressed); + background-color: var(--brand-tint); +} + +/* The round buttons that sit on the map. */ +SvMapButton { + color: var(--text); + background: cn1-round-border; + background-color: var(--surface); + cn1-box-shadow-spread: 0.5mm; + cn1-box-shadow-blur: 1.6mm; + cn1-box-shadow-h: 0.5; + cn1-box-shadow-v: 0.7; + cn1-box-shadow-color: var(--shadow); + padding: 2.4mm; + margin: 1.2mm 2.4mm 1.2mm 2.4mm; +} + +SvMapButton.pressed { + background-color: var(--surface-alt); +} + +/* The same button on a bar: flat, because nothing on a bar floats. */ +SvBarButton { + color: var(--text); + background: cn1-round-border; + background-color: var(--surface); + padding: 2.4mm; + margin: 0.6mm 0.6mm 0.6mm 1.2mm; +} + +SvBarButton.pressed { + background-color: var(--surface-alt); +} + +/* "Where to?" -- looks like a field, behaves like a button. */ +SvSearchBar { + color: var(--text); + background-color: var(--surface-alt); + border-radius: var(--radius); + font-family: "native:MainBold"; + font-size: 3.1mm; + text-align: left; + padding: 2.9mm 3.2mm 2.9mm 3.2mm; + margin: 0.8mm 0 0.8mm 0; +} + +SvSearchBar.pressed { + background-color: var(--surface-press); +} + +/* ------------------------------------------------------------------ fields */ + +SvField { + color: var(--text); + background-color: var(--surface-alt); + border-radius: var(--radius); + font-family: "native:MainRegular"; + font-size: 2.9mm; + padding: 2.7mm 3mm 2.7mm 3mm; + margin: 0; +} + +/* The field being typed in says so. */ +SvField.selected { + background-color: var(--brand-tint); +} + +SvField.disabled { + color: var(--text-muted); +} + +SvFieldHint { + color: var(--text-muted); + font-family: "native:MainRegular"; + font-size: 2.9mm; + padding: 2.7mm 3mm 2.7mm 3mm; + margin: 0; + background-color: transparent; +} + +/* ------------------------------------------------------------------- lists */ + +SvRow { + background-color: var(--surface); + border-bottom: 1px solid var(--line); + padding: 2.6mm 0 2.6mm 0; + margin: 0; +} + +SvRow.pressed { + background-color: var(--surface-alt); +} + +SvRowIcon { + color: var(--text-muted); + padding: 0 3mm 0 0.4mm; + margin: 0; + background-color: transparent; +} + +SvRowTitle { + color: var(--text); + font-family: "native:MainBold"; + font-size: 2.75mm; + padding: 0; + margin: 0; + background-color: transparent; +} + +SvRowDetail { + color: var(--text-muted); + font-family: "native:MainRegular"; + font-size: 2.4mm; + padding: 0.3mm 0 0 0; + margin: 0; + background-color: transparent; +} + +SvRowValue { + color: var(--text); + font-family: "native:MainBold"; + font-size: 2.75mm; + text-align: right; + padding: 0 0 0 2mm; + margin: 0; + background-color: transparent; +} + +/* ------------------------------------------------------------------ badges */ + +SvBadge { + color: var(--text-muted); + background: cn1-pill-border; + background-color: var(--surface-alt); + font-family: "native:MainBold"; + font-size: 2.05mm; + padding: 0.7mm 2mm 0.7mm 2mm; + margin: 0 0 0 2mm; +} + +SvBadgeGood { + cn1-derive: SvBadge; + color: var(--success); + background-color: var(--success-tint); +} + +SvBadgeWarn { + cn1-derive: SvBadge; + color: var(--warning); + background-color: var(--warning-tint); +} + +SvBadgeBad { + cn1-derive: SvBadge; + color: var(--danger); + background-color: var(--danger-tint); +} + +/* ------------------------------------------------------------------- admin */ + +SvStat { + background-color: var(--surface-alt); + border-radius: var(--radius); + padding: 2.6mm 2.6mm 2.6mm 2.6mm; + margin: 0.8mm; +} + +SvStatValue { + color: var(--text); + font-family: "native:MainBold"; + font-size: 3.9mm; + padding: 0; + margin: 0; + background-color: transparent; +} + +SvStatLabel { + color: var(--text-muted); + font-family: "native:MainRegular"; + font-size: 2.3mm; + padding: 0.3mm 0 0 0; + margin: 0; + background-color: transparent; +} + +/* --------------------------------------------------------------- title bar */ + +/* The strip the clock and the battery are drawn over. */ +StatusBar { + background-color: var(--surface); + padding: 0; + margin: 0; +} + +Toolbar { + background-color: var(--surface); + border-bottom: 1px solid var(--line); + padding: 0.6mm 0 0.6mm 0; + margin: 0; +} + +Title { + color: var(--text); + font-family: "native:MainBold"; + font-size: 3.05mm; + background-color: transparent; +} + +TitleCommand { + color: var(--text); + background-color: transparent; +} + +BackCommand { + color: var(--text); + background-color: transparent; +} + +/* --------------------------------------------------------------------------- + * MAP. A marker is an icon drawn in its style's colour, and the route is a + * line in SvRoute's. + */ +SvMarkerPickup { + color: var(--success); + background-color: var(--map-edge); +} + +SvMarkerDropoff { + color: var(--danger); + background-color: var(--map-edge); +} + +SvMarkerCar { + color: var(--car); + background-color: var(--map-edge); +} + +SvRoute { + color: var(--brand); + background-color: var(--map-edge); +} + +SvStar { + color: var(--warning); + background-color: transparent; + padding: 1.2mm; + margin: 0.6mm 0 0.6mm 0; +} + +SvStar.pressed { + color: var(--text-muted); +} + +/* The switch: the thumb is the colour, the track the background. */ +Switch { + color: #ffffff; + background-color: var(--line-strong); + font-family: "native:MainRegular"; + font-size: 2.5mm; + padding: 0.6mm 0 0.6mm 2mm; + margin: 0; +} + +Switch.selected { + color: #ffffff; + background-color: var(--brand); +} + +Switch.disabled { + color: var(--surface-alt); + background-color: var(--line); +} + +/* --------------------------------------------------------------------------- + * OVER THE MAP. The clear strip the status bar is drawn in, which the map + * runs under, the bar of floating controls, + * the sheet at the bottom and what goes in each. + */ +SvStatusShade { + background-color: transparent; + padding: 0; + margin: 0; +} + +SvMapBar { + background-color: transparent; + padding: 1.2mm 2.4mm 0 0; + margin: 0; +} + +/* "Where to?" -- a card that floats over the map and behaves like a button. */ +SvSearchCard { + color: var(--text); + background-color: var(--surface); + border-radius: var(--radius); + cn1-box-shadow-spread: 0.5mm; + cn1-box-shadow-blur: 0; + cn1-box-shadow-h: 0.5; + cn1-box-shadow-v: 0.7; + cn1-box-shadow-color: var(--shadow-card); + font-family: "native:MainBold"; + font-size: 3.1mm; + text-align: left; + padding: 3mm 3.2mm 3mm 3.2mm; + margin: 0.5mm 0 0.5mm 0; +} + +SvSearchCard.pressed { + background-color: var(--surface-alt); +} + +/* From where to where, once there is somewhere to go. */ +SvTripCard { + cn1-derive: SvSearchCard; + padding: 1.9mm 3.2mm 1.9mm 3.2mm; +} + +SvTripStop { + color: var(--text); + background-color: transparent; + font-family: "native:MainRegular"; + font-size: 2.6mm; + text-align: left; + padding: 0.7mm 0 0.7mm 0; + margin: 0; +} + +/* The courier's card at the top: on line or off, and today's takings. */ +SvStatusCard { + cn1-derive: SvSearchCard; + padding: 1.7mm 3.2mm 1.7mm 2mm; +} + +SvStatusValue { + color: var(--text); + background-color: transparent; + font-family: "native:MainBold"; + font-size: 3.3mm; + text-align: right; + padding: 0; + margin: 0; +} + +SvStatusDetail { + color: var(--text-muted); + background-color: transparent; + font-family: "native:MainRegular"; + font-size: 2.15mm; + text-align: right; + padding: 0; + margin: 0; +} + +SvMapTitle { + cn1-derive: SvSearchCard; + font-size: 3.05mm; + padding: 3mm 3.2mm 3mm 3.2mm; +} + +/* One short fact, in a pill under the bar. */ +SvChip { + color: var(--text); + background: cn1-pill-border; + background-color: var(--surface); + cn1-box-shadow-spread: 0.4mm; + cn1-box-shadow-blur: 1.4mm; + cn1-box-shadow-h: 0.5; + cn1-box-shadow-v: 0.7; + cn1-box-shadow-color: var(--shadow); + font-family: "native:MainBold"; + font-size: 2.4mm; + padding: 1.4mm 3mm 1.4mm 2.6mm; + margin: 1.2mm 2mm 1mm 2mm; +} + +SvSheet { + background-color: var(--surface); + border-radius: var(--radius-sheet) var(--radius-sheet) 0 0; + padding: 0.4mm 3.6mm 3.2mm 3.6mm; + margin: 0; +} + +/* The handle of the sheet: the colour is the bar's. */ +SvGrip { + color: var(--line-strong); + background-color: transparent; + padding: 1.6mm 0 1.8mm 0; + margin: 0; +} + +/* The countdown: the colour is the time left, the background the time gone. */ +SvRingTrack { + color: var(--line); + background-color: transparent; +} + +SvRing { + color: var(--brand); + background-color: transparent; + font-family: "native:MainBold"; + font-size: 3.3mm; + padding: 0.8mm; + margin: 0 0 0 2.4mm; +} + +SvPulse { + color: var(--brand); + background-color: transparent; + padding: 0.8mm; + margin: 0.8mm 0 0.8mm 0; +} + +/* Home and Work, side by side on the idle sheet. */ +SvShortcut { + color: var(--text); + background-color: var(--surface-alt); + border-radius: var(--radius); + font-family: "native:MainBold"; + font-size: 2.75mm; + text-align: left; + padding: 2.6mm 3mm 2.6mm 3mm; + margin: 2mm 0.8mm 0.8mm 0.8mm; +} + +SvShortcut.pressed { + background-color: var(--surface-press); +} + +SvCourierCard { + background-color: var(--surface-alt); + border-radius: var(--radius); + padding: 2.8mm 3mm 2.8mm 3mm; + margin: 2mm 0 1mm 0; +} + +SvAvatar { + color: var(--brand-contrast); + background: cn1-round-border; + background-color: var(--brand); + font-family: "native:MainBold"; + font-size: 2.9mm; + text-align: center; + padding: 2.3mm; + margin: 0 3mm 0 0; +} + +/* A number plate reads as one, so it is boxed like one. */ +SvPlate { + color: var(--text); + background-color: var(--surface); + border: 1px solid var(--line-strong); + border-radius: var(--radius-small); + font-family: "native:MainBold"; + font-size: 2.5mm; + text-align: center; + padding: 0.9mm 2mm 0.9mm 2mm; + margin: 1mm 0 0 0; +} + +SvActions { + background-color: transparent; + padding: 0; + margin: 0.8mm 0 0 0; +} + +/* Call, message, share: an icon with a word under it. */ +SvAction { + color: var(--text); + background-color: transparent; + border-radius: var(--radius); + font-family: "native:MainRegular"; + font-size: 2.2mm; + text-align: center; + padding: 1.8mm 0 1.8mm 0; + margin: 0; +} + +SvAction.pressed { + color: var(--brand); + background-color: var(--surface-alt); +} + +SvActionDanger { + cn1-derive: SvAction; + color: var(--danger); +} + +SvActionDanger.pressed { + color: var(--danger); + background-color: var(--danger-tint); +} + +/* The one large round control an off-line courier has. */ +SvGo { + color: var(--brand-contrast); + background: cn1-round-border; + background-color: var(--brand); + cn1-box-shadow-spread: 0.6mm; + cn1-box-shadow-blur: 2mm; + cn1-box-shadow-h: 0.5; + cn1-box-shadow-v: 0.7; + cn1-box-shadow-color: var(--shadow); + font-family: "native:MainBold"; + font-size: 4.2mm; + text-align: center; + padding: 5.6mm; + margin: 1.6mm; +} + +SvGo.pressed { + background-color: var(--brand-pressed); +} + +/* ------------------------------------------------------ records and charts */ + +/* A list that fills a screen: rows from edge to edge, each with the page's + margin inside it. */ +SvList { + background-color: var(--surface); + padding: 0 3.6mm 3.2mm 3.6mm; + margin: 0; +} +/* One line of a receipt: what, and how much. */ +SvFact { + background-color: transparent; + padding: 1.1mm 0 1.1mm 0; + margin: 0; +} +SvFactLabel { + color: var(--text-muted); + background-color: transparent; + font-family: "native:MainRegular"; + font-size: 2.6mm; + text-align: left; + padding: 0; + margin: 0; +} +SvFactValue { + color: var(--text); + background-color: transparent; + font-family: "native:MainRegular"; + font-size: 2.6mm; + text-align: right; + padding: 0 0 0 3mm; + margin: 0; +} +/* The line the others add up to. */ +SvFactTotal { + cn1-derive: SvFactValue; + font-family: "native:MainBold"; + font-size: 3.3mm; +} +/* The one thing a screen is about, said large at its top. */ +/* A note to read in the flow of a page: see Ui.callout. */ +SvCallout { + background-color: var(--warning-tint); + border-radius: var(--radius); + padding: 2.4mm 3mm 2.4mm 2.4mm; + margin: 2.4mm 0 1mm 0; +} +SvCalloutIcon { + color: var(--warning); + background-color: transparent; + padding: 0.3mm 2.2mm 0 0; + margin: 0; +} +SvCalloutTitle { + color: var(--text); + background-color: transparent; + font-family: "native:MainBold"; + font-size: 2.5mm; + padding: 0; + margin: 0; +} +SvCalloutText { + color: var(--text); + background-color: transparent; + font-family: "native:MainRegular"; + font-size: 2.6mm; + padding: 0.4mm 0 0 0; + margin: 0; +} + +SvBanner { + background-color: transparent; + padding: 5mm 3mm 3mm 3mm; + margin: 0; +} +SvBannerIcon { + color: var(--brand); + background: cn1-round-border; + background-color: var(--brand-tint); + padding: 3.4mm; + margin: 0 0 2.4mm 0; +} +SvBannerIconGood { + cn1-derive: SvBannerIcon; + color: var(--success); + background-color: var(--success-tint); +} +SvBannerIconWarn { + cn1-derive: SvBannerIcon; + color: var(--warning); + background-color: var(--warning-tint); +} +SvBannerIconBad { + cn1-derive: SvBannerIcon; + color: var(--danger); + background-color: var(--danger-tint); +} +SvBannerTitle { + color: var(--text); + background-color: transparent; + font-family: "native:MainBold"; + font-size: 3.5mm; + text-align: center; + padding: 0.4mm 0 0.4mm 0; + margin: 0; +} +SvBannerText { + color: var(--text-muted); + background-color: transparent; + font-family: "native:MainRegular"; + font-size: 2.6mm; + text-align: center; + padding: 0.4mm 2mm 0.4mm 2mm; + margin: 0; +} +/* The amount a receipt is for. */ +SvAmount { + color: var(--text); + background-color: transparent; + font-family: "native:MainBold"; + font-size: 7mm; + text-align: center; + padding: 1mm 0 0.4mm 0; + margin: 0; +} +/* A group of rows under one heading. */ +SvGroup { + background-color: transparent; + padding: 0; + margin: 0 0 0.8mm 0; +} +/* A chart or a breakdown, boxed with its title. */ +SvTile { + background-color: var(--surface); + border: 1px solid var(--line); + border-radius: var(--radius); + padding: 3mm 3mm 3mm 3mm; + margin: 0.8mm 0.8mm 1.6mm 0.8mm; +} +/* A bar chart: the colour is the bars'. */ +SvChart { + color: var(--brand); + background-color: transparent; + padding: 1.6mm 0 0 0; + margin: 0; +} +SvChartPeak { + color: var(--brand-pressed); + background-color: transparent; +} +SvChartGrid { + color: var(--line); + background-color: transparent; +} +SvChartAxis { + color: var(--text-muted); + background-color: transparent; + font-family: "native:MainRegular"; + font-size: 2mm; + padding: 0.8mm 0 0 0; + margin: 0; +} +/* The number a chart adds up to, over the chart. */ +SvChartValue { + color: var(--text); + background-color: transparent; + font-family: "native:MainBold"; + font-size: 4.2mm; + text-align: left; + padding: 0; + margin: 0; +} +/* A share along a track: the colour is the share, the background the track. */ +SvMeter { + color: var(--brand); + background-color: transparent; + padding: 0.8mm 0 1.6mm 0; + margin: 0; +} +/* Something waiting for the admin, at the top of the dashboard. */ +SvAlert { + color: var(--warning); + background-color: var(--warning-tint); + border-radius: var(--radius); + font-family: "native:MainBold"; + font-size: 2.6mm; + text-align: left; + padding: 2.6mm 3mm 2.6mm 3mm; + margin: 0.8mm; +} +SvAlert.pressed { + background-color: var(--surface-press); +} + +/* ----------------------------------------------------------------- choosing */ + +/* One of several things to pick from, of which one is picked: a kind of car, + a way to pay. */ +SvOption { + background-color: var(--surface); + border: 1px solid var(--line); + border-radius: var(--radius); + padding: 2.4mm 3mm 2.4mm 3mm; + margin: 0.8mm 0 0.8mm 0; +} +SvOption.pressed { + background-color: var(--surface-alt); +} +SvOptionPicked { + cn1-derive: SvOption; + background-color: var(--brand-tint); + border: 2px solid var(--brand); +} +SvOptionIcon { + color: var(--text); + background-color: transparent; + padding: 0 2.4mm 0 0; + margin: 0; +} +/* A payment card, drawn as one. */ +SvCardFace { + background-color: var(--brand); + border-radius: var(--radius); + padding: 4mm 4mm 3.4mm 4mm; + margin: 1mm 0 2.4mm 0; +} +SvCardBrand { + color: var(--brand-contrast); + background-color: transparent; + font-family: "native:MainBold"; + font-size: 2.9mm; + text-align: right; + padding: 0; + margin: 0; +} +SvCardNumber { + color: var(--brand-contrast); + background-color: transparent; + font-family: "native:MainBold"; + font-size: 3.7mm; + text-align: left; + padding: 4.8mm 0 2.4mm 0; + margin: 0; +} +SvCardSmall { + color: var(--brand-contrast); + background-color: transparent; + font-family: "native:MainRegular"; + font-size: 2.3mm; + padding: 0; + margin: 0; +} +/* Two fields side by side: the space between them is theirs. */ +SvPairLeft { + background-color: transparent; + padding: 0 1.2mm 0 0; + margin: 0; +} +SvPairRight { + background-color: transparent; + padding: 0 0 0 1.2mm; + margin: 0; +} +/* A picture of a document, small, beside what it is. */ +SvThumb { + color: var(--text-muted); + background-color: var(--surface-alt); + border-radius: var(--radius-small); + padding: 2mm; + margin: 0 3mm 0 0; +} +/* The same picture, filling the screen. */ +SvPicture { + background-color: var(--scrim); + padding: 0; + margin: 0; +} +/* How far through the steps of a form: "Step 2 of 5". */ +SvStepLabel { + color: var(--brand); + background-color: transparent; + font-family: "native:MainBold"; + font-size: 2.2mm; + text-align: left; + padding: 0 0 0.4mm 0; + margin: 0; +} +/* What is fixed to the foot of a form of several steps. */ +SvFooter { + background-color: var(--surface); + border-top: 1px solid var(--line); + padding: 1mm 3.6mm 1.6mm 3.6mm; + margin: 0; +} + +/* ---------------------------------------------------------------- the menu */ + +/* Dimmed by the colour's own alpha. `opacity` would not do it: a style's + opacity fades what a component draws over its background, and the + background itself is filled at the background's transparency -- so an opaque + black with an opacity is still a black screen. */ +SvScrim { + background-color: rgba(0, 0, 0, 0.45); + padding: 0; + margin: 0; +} + +SvMenu { + background-color: var(--surface); + padding: 0; + margin: 0; +} + +SvMenuTop { + background-color: var(--surface); + border-bottom: 1px solid var(--line); + padding: 5mm 4mm 3.6mm 4mm; + margin: 0; +} + +SvMenuName { + color: var(--text); + background-color: transparent; + font-family: "native:MainBold"; + font-size: 3.5mm; + text-align: left; + padding: 0; + margin: 0; +} + +SvMenuDetail { + color: var(--text-muted); + background-color: transparent; + font-family: "native:MainRegular"; + font-size: 2.4mm; + text-align: left; + padding: 0.2mm 0 0 0; + margin: 0; +} + +/* Which of the three apps this is. */ +SvModeBadge { + color: var(--brand); + background: cn1-pill-border; + background-color: var(--brand-tint); + font-family: "native:MainBold"; + font-size: 2.1mm; + text-align: center; + padding: 0.8mm 2.4mm 0.8mm 2.4mm; + margin: 2.4mm 0 0 0; +} + +SvMenuLabel { + color: var(--text-muted); + background-color: transparent; + font-family: "native:MainBold"; + font-size: 2.1mm; + text-align: left; + padding: 3.6mm 4mm 1mm 4mm; + margin: 0; +} + +SvMenuItem { + color: var(--text); + background-color: transparent; + font-family: "native:MainRegular"; + font-size: 2.9mm; + text-align: left; + padding: 2.6mm 4mm 2.6mm 4mm; + margin: 0; +} + +SvMenuItem.pressed { + background-color: var(--surface-alt); +} + +SvMenuBottom { + background-color: transparent; + border-top: 1px solid var(--line); + padding: 0.8mm 0 1.6mm 0; + margin: 0; +} + +/* The top of a home screen that has no map: the menu, and what this is. */ +SvHomeBar { + background-color: var(--surface); + border-bottom: 1px solid var(--line); + padding: 0.6mm 3.6mm 0.6mm 0; + margin: 0; +} + +/* ---------------------------------------------------------------- settings */ + +SvSection { + color: var(--text); + background-color: transparent; + font-family: "native:MainBold"; + font-size: 3.1mm; + text-align: left; + padding: 5mm 0 0.8mm 0; + margin: 0; +} + +/* A few options side by side, one of them chosen. */ +SvSegments { + background-color: var(--surface-alt); + border-radius: var(--radius); + padding: 0.5mm; + margin: 0.8mm 0 1.6mm 0; +} + +SvSegment { + color: var(--text-muted); + background-color: transparent; + border-radius: 1.9mm; + font-family: "native:MainBold"; + font-size: 2.3mm; + text-align: center; + padding: 2mm 1.2mm 2mm 1.2mm; + margin: 0.3mm; +} + +/* ".selected" is the FOCUSED state in a Codename One theme, not the chosen + one: a chosen toggle wears ".pressed". Styled like the chosen option, the + focused one looked chosen too, so two options of one row seemed picked. */ +SvSegment.selected { + color: var(--text-muted); + background-color: transparent; +} + +/* A toggle that is on wears the pressed style, so the two say the same. */ +SvSegment.pressed { + color: var(--text); + background-color: var(--surface); +} + + +SvSegment.disabled { + color: var(--line-strong); +} + +/* The outline of rows that are on their way: the colour is the bars'. */ +SvSkeleton { + color: var(--surface-alt); + background-color: transparent; + padding: 1mm 0 0 0; + margin: 0; +} + +/* "Pull to refresh", over a list being pulled. */ +PullToRefresh { + color: var(--text-muted); + background-color: transparent; + font-family: "native:MainRegular"; + font-size: 2.3mm; + text-align: center; + padding: 1.6mm; + margin: 0; +} + +/* What the app says in passing, over the foot of the screen. */ +ToastBar { + background-color: var(--text); + border-radius: var(--radius); + padding: 2.4mm 3mm 2.4mm 3mm; + margin: 2.4mm 3.6mm 3.2mm 3.6mm; +} + +ToastBarMessage { + color: var(--surface); + background-color: transparent; + font-family: "native:MainRegular"; + font-size: 2.5mm; + padding: 0; + margin: 0; +} + +/* ------------------------------------------------- the admin console, wide */ + +/* On a tablet on its side, a desktop or a browser the console is three + columns: where to go, a list, and what was picked from it. */ +SvSide { + background-color: var(--surface-alt); + border-right: 1px solid var(--line); + padding: 2.4mm 1.6mm 2.4mm 1.6mm; + margin: 0; +} + +SvSideName { + color: var(--text); + background-color: transparent; + font-family: "native:MainBold"; + font-size: 3.05mm; + padding: 0.8mm 2.4mm 0 2.4mm; + margin: 0; +} + +SvSideDetail { + color: var(--text-muted); + background-color: transparent; + font-family: "native:MainRegular"; + font-size: 2.3mm; + padding: 0 2.4mm 2.4mm 2.4mm; + margin: 0; +} + +SvSideLabel { + color: var(--text-muted); + background-color: transparent; + font-family: "native:MainBold"; + font-size: 2.15mm; + padding: 3.2mm 2.4mm 0.8mm 2.4mm; + margin: 0; +} + +SvSideItem { + color: var(--text); + background-color: transparent; + border-radius: var(--radius-small); + font-family: "native:MainRegular"; + font-size: 2.8mm; + text-align: left; + padding: 1.9mm 2.4mm 1.9mm 2.4mm; + margin: 0.2mm 0 0.2mm 0; +} + +SvSideItem.pressed { + background-color: var(--line); +} + +SvSidePicked { + cn1-derive: SvSideItem; + color: var(--brand-contrast); + background-color: var(--brand); + font-family: "native:MainBold"; +} + +SvMaster { + background-color: var(--surface); + border-right: 1px solid var(--line); + padding: 0; + margin: 0; +} + +SvPaneBar { + background-color: var(--surface); + border-bottom: 1px solid var(--line); + padding: 1.2mm 4mm 1.2mm 4mm; + margin: 0; +} + +SvPaneTitle { + color: var(--text); + background-color: transparent; + font-family: "native:MainBold"; + font-size: 3.5mm; + padding: 1.6mm 0 1.6mm 0; + margin: 0; +} + +/* The row of a list whose detail is open beside it. */ +SvRowPicked { + cn1-derive: SvRow; + background-color: var(--surface-alt); +} + +/* + * THE DESKTOP'S OWN LOOK. On macOS, Windows and Linux, and in a desktop + * browser, the platform's theme is under this one (the nativeTheme=native build + * hint), and the admin console wears it: Layouts.dress() gives each component + * the SvDesk twin of its style. A twin derives from the platform's control + * where there is one -- a button, a field, a segmented control, a group box -- + * and takes its colours from the palette every desktop theme declares, so the + * same rules are Aqua on a Mac, Fluent on Windows and Adwaita on Linux. Nothing + * here names the brand; the accent is the platform's. + * + * The values in SvDeskPalette are only what the compiler needs to resolve + * var(): each use is bound to the name, and the installed theme's own value + * replaces it when the app runs. They are declared outside #Constants so that + * they are not exported over the theme's. + */ +SvDeskPalette { + --window-bg-color: #ececec; + --window-bg-color-dark: #1e1e1e; + --view-bg-color: #ffffff; + --view-bg-color-dark: #1e1e1e; + --control-bg-color: #ffffff; + --control-bg-color-dark: #2c2c2c; + --headerbar-bg-color: #e8e8e8; + --headerbar-bg-color-dark: #2a2a2a; + --text-color: #1a1a1a; + --text-color-dark: #ffffff; + --text-secondary-color: #5d5d5d; + --text-secondary-color-dark: #c0c0c0; + --separator-color: #d8d8d8; + --separator-color-dark: #3a3a3a; + --accent-color: #007aff; + --accent-color-dark: #0a84ff; + --accent-fg-color: #ffffff; + --accent-fg-color-dark: #ffffff; + background-color: transparent; + padding: 0; + margin: 0; +} + +SvDeskForm { background-color: var(--window-bg-color); padding: 0; margin: 0; } +SvDeskPage { background-color: var(--window-bg-color); padding: 3mm 5mm 4mm 5mm; margin: 0; } +SvDeskList { background-color: var(--view-bg-color); padding: 0 3mm 2mm 3mm; margin: 0; } +SvDeskMaster { background-color: var(--view-bg-color); border-right: 1px solid #c6c6c8; padding: 0; margin: 0; } +SvDeskHomeBar { background-color: var(--headerbar-bg-color); border-bottom: 1px solid #c6c6c8; padding: 1mm 4mm 1mm 0; margin: 0; } +SvDeskPaneBar { background-color: var(--window-bg-color); border-bottom: 1px solid #c6c6c8; padding: 1mm 5mm 1mm 5mm; margin: 0; } +SvDeskPaneTitle { color: var(--text-color); background-color: transparent; font-family: "native:MainBold"; font-size: 3.2mm; padding: 1.6mm 0 1.6mm 0; margin: 0; } +SvDeskBarButton { cn1-derive: Button; padding: 1.2mm; margin: 0 2mm 0 0; } + +SvDeskSide { background-color: var(--headerbar-bg-color); border-right: 1px solid #c6c6c8; padding: 2.5mm 2mm 2.5mm 2mm; margin: 0; } +SvDeskSideName { color: var(--text-color); background-color: transparent; font-family: "native:MainBold"; font-size: 2.7mm; padding: 1mm 2.6mm 0 2.6mm; margin: 0; } +SvDeskSideDetail { color: var(--text-secondary-color); background-color: transparent; font-family: "native:MainRegular"; font-size: 2.25mm; padding: 0 2.6mm 2mm 2.6mm; margin: 0; } +SvDeskSideLabel { color: var(--text-secondary-color); background-color: transparent; font-family: "native:MainBold"; font-size: 2.15mm; padding: 3mm 2.6mm 1mm 2.6mm; margin: 0; } +SvDeskSideItem { color: var(--text-color); background-color: transparent; border-radius: 1.4mm; font-family: "native:MainRegular"; font-size: 2.65mm; text-align: left; padding: 1.6mm 2.6mm 1.6mm 2.6mm; margin: 0.2mm 0 0.2mm 0; } +SvDeskSideItem.pressed { background-color: var(--separator-color); } +SvDeskSidePicked { cn1-derive: SvDeskSideItem; color: var(--accent-fg-color); background-color: var(--accent-color); } + +SvDeskPrimary { cn1-derive: RaisedButton; text-align: center; margin: 2mm 0 1mm 0; } +SvDeskSecondary { cn1-derive: Button; text-align: center; margin: 1.5mm 0 1mm 0; } +SvDeskDanger { cn1-derive: Button; color: var(--danger); text-align: center; margin: 1.5mm 0 1mm 0; } +SvDeskLink { cn1-derive: Link; padding: 1.6mm 0 1.6mm 0; } +SvDeskField { cn1-derive: TextField; margin: 0; } +SvDeskFieldHint { cn1-derive: TextHint; } +SvDeskSegments { cn1-derive: TabsContainer; border-radius: 1.6mm; padding: 0.4mm; margin: 1mm 0 2mm 0; } +/* Four of these share the master pane of the console, which is narrow: the + platform's own tab padding leaves "Cancelled" no room and it was drawn cut + off at both ends. */ +SvDeskSegment { cn1-derive: Tab; text-align: center; padding: 1.4mm 0.4mm 1.4mm 0.4mm; margin: 0; } +/* Something waiting is the one thing on the console that is not the + platform's grey: it wears the warning tint the phone's does. */ +SvDeskAlert { color: var(--warning); background-color: var(--warning-tint); border-radius: 1.6mm; font-family: "native:MainBold"; font-size: 2.6mm; text-align: left; padding: 2mm 3mm 2mm 3mm; margin: 1mm 0 1mm 0; } +SvDeskAlert.pressed { color: var(--warning); background-color: var(--warning-tint); border-radius: 1.6mm; font-family: "native:MainBold"; font-size: 2.6mm; text-align: left; padding: 2mm 3mm 2mm 3mm; margin: 1mm 0 1mm 0; } + +SvDeskRow { background-color: transparent; border-bottom: 1px solid #d8d8d8; padding: 2mm 1mm 2mm 1mm; margin: 0; } +SvDeskRow.pressed { background-color: var(--separator-color); } +SvDeskRowPicked { background-color: var(--separator-color); border-bottom: 1px solid #d8d8d8; border-radius: 1.4mm; padding: 2mm 1mm 2mm 1mm; margin: 0; } +SvDeskRowIcon { color: var(--text-secondary-color); background-color: transparent; padding: 0 2.6mm 0 0; margin: 0; } +SvDeskRowTitle { color: var(--text-color); background-color: transparent; font-family: "native:MainRegular"; font-size: 2.7mm; padding: 0; margin: 0; } +SvDeskRowDetail { color: var(--text-secondary-color); background-color: transparent; font-family: "native:MainRegular"; font-size: 2.3mm; padding: 0.4mm 0 0 0; margin: 0; } +SvDeskRowValue { color: var(--text-color); background-color: transparent; font-family: "native:MainBold"; font-size: 2.65mm; text-align: right; padding: 0; margin: 0; } + +SvDeskSection { color: var(--text-color); background-color: transparent; font-family: "native:MainBold"; font-size: 2.8mm; text-align: left; padding: 4mm 0 1.2mm 0; margin: 0; } +SvDeskTitle { color: var(--text-color); background-color: transparent; font-family: "native:MainBold"; font-size: 4.4mm; padding: 0 0 2mm 0; margin: 0; } +SvDeskHeading { color: var(--text-color); background-color: transparent; font-family: "native:MainBold"; font-size: 3.2mm; padding: 0; margin: 0; } +SvDeskText { color: var(--text-color); background-color: transparent; font-family: "native:MainRegular"; font-size: 2.65mm; padding: 1mm 0 1mm 0; margin: 0; } +SvDeskMuted { color: var(--text-secondary-color); background-color: transparent; font-family: "native:MainRegular"; font-size: 2.4mm; padding: 0.5mm 0 0.5mm 0; margin: 0; } +SvDeskLabel { color: var(--text-secondary-color); background-color: transparent; font-family: "native:MainRegular"; font-size: 2.4mm; padding: 2.4mm 0 0.8mm 0; margin: 0; } +SvDeskError { color: var(--danger); background-color: transparent; font-family: "native:MainRegular"; font-size: 2.5mm; padding: 1mm 0 1mm 0; margin: 0; } +SvDeskNotice { cn1-derive: GroupBox; color: var(--text-color); font-family: "native:MainRegular"; font-size: 2.55mm; padding: 2.4mm; margin: 2mm 0 1mm 0; } +SvDeskPanel { cn1-derive: GroupBox; padding: 2.6mm 3mm 2.6mm 3mm; margin: 1mm 0 1mm 0; } +SvDeskFact { background-color: transparent; padding: 1.2mm 0 1.2mm 0; margin: 0; } +SvDeskFactLabel { color: var(--text-secondary-color); background-color: transparent; font-family: "native:MainRegular"; font-size: 2.55mm; padding: 0; margin: 0; } +SvDeskFactValue { color: var(--text-color); background-color: transparent; font-family: "native:MainRegular"; font-size: 2.55mm; text-align: right; padding: 0; margin: 0; } +SvDeskFactTotal { color: var(--text-color); background-color: transparent; font-family: "native:MainBold"; font-size: 2.7mm; text-align: right; padding: 0; margin: 0; } +SvDeskAmount { color: var(--text-color); background-color: transparent; font-family: "native:MainBold"; font-size: 6.4mm; text-align: center; padding: 2mm 0 1mm 0; margin: 0; } +SvDeskBannerIcon { color: var(--text-secondary-color); background-color: transparent; padding: 6mm 0 2mm 0; margin: 0; } +SvDeskBannerTitle { color: var(--text-color); background-color: transparent; font-family: "native:MainBold"; font-size: 3.05mm; text-align: center; padding: 0; margin: 0; } +SvDeskBannerText { color: var(--text-secondary-color); background-color: transparent; font-family: "native:MainRegular"; font-size: 2.5mm; text-align: center; padding: 1mm 0 0 0; margin: 0; } + +/* The numbers and the charts have no control on any desktop: they keep their + shape, in a group box, in the platform's colours. */ +SvDeskTile { cn1-derive: GroupBox; padding: 3mm 3.4mm 3mm 3.4mm; margin: 1.2mm; } +SvDeskStat { cn1-derive: GroupBox; padding: 2.6mm 3mm 2.6mm 3mm; margin: 1.2mm; } +SvDeskStatValue { color: var(--text-color); background-color: transparent; font-family: "native:MainBold"; font-size: 4.3mm; padding: 0; margin: 0; } +SvDeskStatLabel { color: var(--text-secondary-color); background-color: transparent; font-family: "native:MainRegular"; font-size: 2.3mm; padding: 0.6mm 0 0 0; margin: 0; } +SvDeskChartValue { color: var(--text-color); background-color: transparent; font-family: "native:MainBold"; font-size: 4.3mm; padding: 0.6mm 0 0 0; margin: 0; } +SvDeskChartAxis { color: var(--text-secondary-color); background-color: transparent; font-family: "native:MainRegular"; font-size: 2.1mm; padding: 0.6mm 0 0 0; margin: 0; } + + +/* + * DARK MODE. The same rules, with the second value of each colour. A rule added + * above that names a colour needs its line here. + */ +/* THE CUSTOMER'S SCREENS -- Grub's look: photos on cards, the green for what + can be tapped, grey for everything that is only read. */ +SvHomeHeader { background-color: var(--surface); padding: 1mm 3mm 1.5mm 1mm; margin: 0; } +SvDeliverTo { color: var(--text); font-family: "native:MainBold"; font-size: 2.9mm; padding: 0; margin: 0; background-color: transparent; } +SvGreeting { color: var(--text); font-family: "native:MainBold"; font-size: 4.4mm; padding: 1mm 0 0 0; margin: 0; background-color: transparent; } +SvMutedSmall { color: var(--text-muted); font-family: "native:MainRegular"; font-size: 2.1mm; padding: 0.3mm 0 0.3mm 0; margin: 0; background-color: transparent; } +SvMetaLine { color: var(--text); font-family: "native:MainRegular"; font-size: 2.1mm; padding: 0.3mm 0 0 0; margin: 0; background-color: transparent; } +SvCuisineRow { padding: 2mm 0 2mm 0; margin: 0; background-color: transparent; } +SvCuisine { color: var(--text); background-color: var(--surface-alt); border-radius: var(--radius); font-family: "native:MainRegular"; font-size: 2.1mm; text-align: center; padding: 1.6mm 2.2mm 1.6mm 2.2mm; margin: 0 1.6mm 0 0; } +SvCuisine.pressed { background-color: var(--surface-press); } +SvCuisineSelected { color: var(--brand-contrast); background-color: var(--brand); border-radius: var(--radius); font-family: "native:MainBold"; font-size: 2.1mm; text-align: center; padding: 1.6mm 2.2mm 1.6mm 2.2mm; margin: 0 1.6mm 0 0; } +SvRestaurantCard { background-color: var(--surface); border-radius: var(--radius); padding: 0; margin: 0 0 3mm 0; } +SvRestaurantCard.pressed { background-color: var(--surface-alt); } +SvRestaurantPhoto { color: #f3e7d3; background-color: transparent; border-radius: var(--radius); padding: 0; margin: 0; } +SvRestaurantWords { padding: 1.4mm 0.5mm 0 0.5mm; margin: 0; background-color: transparent; } +SvRestaurantName { color: var(--text); font-family: "native:MainBold"; font-size: 2.9mm; padding: 0; margin: 0; background-color: transparent; } +SvRestaurantTitle { color: var(--text); font-family: "native:MainBold"; font-size: 4.4mm; padding: 2mm 0 0.5mm 0; margin: 0; background-color: transparent; } +SvPhotoBadge { color: var(--brand); background: cn1-pill-border; background-color: var(--surface); padding: 1mm; margin: 0; } +SvClosedBadge { color: var(--surface); background: cn1-pill-border; background-color: var(--text); font-family: "native:MainBold"; font-size: 2.05mm; padding: 0.7mm 2mm 0.7mm 2mm; margin: 0; } +SvPopularBadge { color: var(--brand-contrast); background: cn1-pill-border; background-color: var(--brand-tint); font-family: "native:MainBold"; font-size: 2.05mm; padding: 0.5mm 1.6mm 0.5mm 1.6mm; margin: 0 0 0 1.6mm; } +SvHeroPhoto { color: #f3e7d3; background-color: transparent; padding: 0; margin: 0; } +SvSectionChips { padding: 2mm 0 1mm 0; margin: 0; background-color: transparent; } +SvSectionChip { color: var(--text); background: cn1-pill-border; background-color: var(--surface-alt); font-family: "native:MainBold"; font-size: 2.5mm; padding: 1.2mm 3mm 1.2mm 3mm; margin: 0 1.6mm 0 0; } +SvSectionChip.pressed { background-color: var(--surface-press); } +SvMenuSection { color: var(--text); font-family: "native:MainBold"; font-size: 3.5mm; padding: 4mm 0 1mm 0; margin: 0; background-color: transparent; } +SvDishRow { background-color: var(--surface); border-bottom: 1px solid var(--line); padding: 2.6mm 0 2.6mm 0; margin: 0; } +SvDishRow.pressed { background-color: var(--surface-alt); } +SvDishRowOff { background-color: var(--surface); border-bottom: 1px solid var(--line); padding: 2.6mm 0 2.6mm 0; margin: 0; opacity: 0.5; } +SvDishName { color: var(--text); font-family: "native:MainBold"; font-size: 2.75mm; padding: 0; margin: 0; background-color: transparent; } +SvDishPrice { color: var(--text); font-family: "native:MainRegular"; font-size: 2.5mm; padding: 0.6mm 0 0 0; margin: 0; background-color: transparent; } +SvDishPhoto { color: #f3e7d3; background-color: transparent; border-radius: var(--radius); padding: 0; margin: 0 0 0 2mm; } +SvOrderThumb { color: #f3e7d3; background-color: transparent; border-radius: var(--radius); padding: 0; margin: 0 2.4mm 0 0; } +SvOrderRow { background-color: var(--surface); border-bottom: 1px solid var(--line); padding: 2.6mm 3mm 2.6mm 3mm; margin: 0; } +SvOrderRow.pressed { background-color: var(--surface-alt); } +SvActiveOrder { background-color: var(--brand-tint); border-radius: var(--radius); padding: 2mm 3mm 2mm 3mm; margin: 2mm 0 0 0; } +SvActiveOrder.pressed { background-color: var(--surface-alt); } +SvCartButton { color: var(--brand-contrast); background-color: var(--brand); border-radius: var(--radius); font-family: "native:MainBold"; font-size: 2.9mm; text-align: center; padding: 2.75mm 4mm 2.75mm 4mm; margin: 1.6mm 3mm 1.6mm 3mm; } +SvCartButton.pressed { background-color: var(--brand-pressed); } +SvCartLine { background-color: var(--surface); border-bottom: 1px solid var(--line); padding: 2mm 0 2mm 0; margin: 0; } +SvAddBar { background-color: var(--surface); border-top: 1px solid var(--line); padding: 1mm 3mm 1mm 3mm; margin: 0; } +SvStepper { background: cn1-pill-border; background-color: var(--surface-alt); padding: 0 1mm 0 1mm; margin: 1.6mm 2mm 0.8mm 0; } +SvQuantity { color: var(--text); font-family: "native:MainBold"; font-size: 2.9mm; text-align: center; padding: 2mm 1.6mm 2mm 1.6mm; margin: 0; background-color: transparent; } +SvSearchField { color: var(--text); background: cn1-pill-border; background-color: var(--surface-alt); font-family: "native:MainRegular"; font-size: 2.9mm; padding: 2mm 3mm 2mm 3mm; margin: 0; } +SvTabBar { background-color: var(--surface); border-top: 1px solid var(--line); padding: 0.6mm 0 0.6mm 0; margin: 0; } +SvTab { color: var(--text-muted); background-color: transparent; font-family: "native:MainRegular"; font-size: 2.05mm; text-align: center; padding: 1mm 0 1mm 0; margin: 0; } +SvTab.pressed { color: var(--text); } +SvTabSelected { color: var(--brand-pressed); background-color: transparent; font-family: "native:MainBold"; font-size: 2.05mm; text-align: center; padding: 1mm 0 1mm 0; margin: 0; } +SvSheetTitle { color: var(--text); font-family: "native:MainBold"; font-size: 3.5mm; padding: 0; margin: 0; background-color: transparent; } +SvSteps { padding: 2mm 0 1mm 0; margin: 0; background-color: transparent; } +SvStep { padding: 0 0.8mm 0 0.8mm; margin: 0; background-color: transparent; } +SvStepDone { padding: 0 0.8mm 0 0.8mm; margin: 0; background-color: transparent; } +SvStepBar { background-color: var(--line); border-radius: 0.5mm; padding: 0.5mm; margin: 0 0 1.2mm 0; } +SvStepBarDone { background-color: var(--brand); border-radius: 0.5mm; padding: 0.5mm; margin: 0 0 1.2mm 0; } +SvStepText { color: var(--text); font-family: "native:MainBold"; font-size: 2.1mm; text-align: center; padding: 0; margin: 0; background-color: transparent; } +SvStepTextTodo { color: var(--text-muted); font-family: "native:MainRegular"; font-size: 2.1mm; text-align: center; padding: 0; margin: 0; background-color: transparent; } +SvDiscount { color: var(--success); font-family: "native:MainRegular"; font-size: 2.75mm; padding: 0; margin: 0; background-color: transparent; } +SvTotal { color: var(--text); font-family: "native:MainBold"; font-size: 3.5mm; padding: 0; margin: 0; background-color: transparent; } + +/* THE KITCHEN'S QUEUE -- down the left on a wide screen, the order beside it. */ +SvQueuePane { background-color: var(--surface); border-right: 1px solid var(--line); padding: 0; margin: 0; } +SvDetailPane { background-color: var(--surface-alt); padding: 0; margin: 0; } +SvQueueRow { background-color: var(--surface); border-bottom: 1px solid var(--line); padding: 2.6mm 3mm 2.6mm 3mm; margin: 0; } +SvQueueRow.pressed { background-color: var(--surface-alt); } +SvQueueRowPicked { background-color: var(--brand-tint); border-bottom: 1px solid var(--line); padding: 2.6mm 3mm 2.6mm 3mm; margin: 0; } + +@media (prefers-color-scheme: dark) { + SvForm { background-color: var(--surface-dark); } + SvPage { background-color: var(--surface-dark); } + SvHero { background-color: var(--brand-dark); } + SvHeroTitle { color: var(--brand-contrast-dark); } + SvHeroText { color: var(--brand-contrast-dark); } + SvHeroIcon { color: var(--brand-contrast-dark); } + SvCard { background-color: var(--surface-dark); } + SvFloat { background-color: var(--surface-dark); } + SvPanel { background-color: var(--surface-alt-dark); } + SvDivider { background-color: var(--line-dark); } + SvTitle { color: var(--text-dark); } + SvHeading { color: var(--text-dark); } + SvText { color: var(--text-dark); } + SvMuted { color: var(--text-muted-dark); } + SvLabel { color: var(--text-muted-dark); } + SvPrice { color: var(--text-dark); } + SvFare { color: var(--text-dark); } + SvError { color: var(--danger-dark); } + SvNotice { color: var(--text-dark); background-color: var(--surface-alt-dark); } + SvPrimary { color: var(--brand-contrast-dark); background-color: var(--brand-dark); } + SvPrimary.pressed { background-color: var(--brand-pressed-dark); } + SvPrimary.disabled { color: var(--text-muted-dark); background-color: var(--surface-press-dark); } + SvSecondary { color: var(--text-dark); background-color: var(--surface-alt-dark); } + SvSecondary.pressed { background-color: var(--surface-press-dark); } + SvDanger { color: var(--danger-dark); background-color: var(--danger-tint-dark); } + SvDanger.pressed { background-color: var(--surface-press-dark); } + SvLink { color: var(--brand-dark); } + SvLink.pressed { color: var(--brand-pressed-dark); background-color: var(--brand-tint-dark); } + SvMapButton { background: cn1-round-border; color: var(--text-dark); background-color: var(--surface-dark); } + SvMapButton.pressed { background: cn1-round-border; background-color: var(--surface-alt-dark); } + SvBarButton { background: cn1-round-border; color: var(--text-dark); background-color: var(--surface-dark); } + SvBarButton.pressed { background: cn1-round-border; background-color: var(--surface-alt-dark); } + SvSearchBar { color: var(--text-dark); background-color: var(--surface-alt-dark); } + SvSearchBar.pressed { background-color: var(--surface-press-dark); } + SvField { color: var(--text-dark); background-color: var(--surface-alt-dark); } + SvFieldHint { color: var(--text-muted-dark); } + SvRow { background-color: var(--surface-dark); border-bottom: 1px solid var(--line-dark); } + SvRow.pressed { background-color: var(--surface-alt-dark); } + SvRowIcon { color: var(--text-muted-dark); } + SvRowTitle { color: var(--text-dark); } + SvRowDetail { color: var(--text-muted-dark); } + SvRowValue { color: var(--text-dark); } + SvBadge { background: cn1-pill-border; color: var(--text-muted-dark); background-color: var(--surface-alt-dark); } + SvBadgeGood { background: cn1-pill-border; color: var(--success-dark); background-color: var(--success-tint-dark); } + SvBadgeWarn { background: cn1-pill-border; color: var(--warning-dark); background-color: var(--warning-tint-dark); } + SvBadgeBad { background: cn1-pill-border; color: var(--danger-dark); background-color: var(--danger-tint-dark); } + SvStat { background-color: var(--surface-alt-dark); } + SvStatValue { color: var(--text-dark); } + SvStatLabel { color: var(--text-muted-dark); } + StatusBar { background-color: var(--surface-dark); } + Toolbar { background-color: var(--surface-dark); border-bottom: 1px solid var(--line-dark); } + Title { color: var(--text-dark); } + TitleCommand { color: var(--text-dark); } + BackCommand { color: var(--text-dark); } + SvMarkerPickup { color: var(--success-dark); background-color: var(--map-edge-dark); } + SvMarkerDropoff { color: var(--danger-dark); background-color: var(--map-edge-dark); } + SvMarkerCar { color: var(--car-dark); background-color: var(--map-edge-dark); } + SvRoute { color: var(--brand-dark); background-color: var(--map-edge-dark); } + SvSearchCard { color: var(--text-dark); background-color: var(--surface-alt-dark); } + SvSearchCard.pressed { background-color: var(--line-dark); } + SvTripCard { color: var(--text-dark); background-color: var(--surface-alt-dark); } + SvTripStop { color: var(--text-dark); } + SvStatusCard { color: var(--text-dark); background-color: var(--surface-alt-dark); } + SvStatusValue { color: var(--text-dark); } + SvStatusDetail { color: var(--text-muted-dark); } + SvMapTitle { color: var(--text-dark); background-color: var(--surface-alt-dark); } + SvChip { background: cn1-pill-border; color: var(--text-dark); background-color: var(--surface-alt-dark); } + SvSheet { background-color: var(--surface-dark); } + SvGrip { color: var(--line-strong-dark); } + SvRing { color: var(--brand-dark); } + SvRingTrack { color: var(--line-dark); } + SvPulse { color: var(--brand-dark); } + SvShortcut { color: var(--text-dark); background-color: var(--surface-alt-dark); } + SvShortcut.pressed { background-color: var(--surface-press-dark); } + SvCourierCard { background-color: var(--surface-alt-dark); } + SvAvatar { background: cn1-round-border; color: var(--brand-contrast-dark); background-color: var(--brand-dark); } + SvPlate { color: var(--text-dark); background-color: var(--surface-dark); border: 1px solid var(--line-strong-dark); } + SvAction { color: var(--text-dark); } + SvAction.pressed { color: var(--brand-dark); background-color: var(--surface-alt-dark); } + SvActionDanger { color: var(--danger-dark); } + SvActionDanger.pressed { color: var(--danger-dark); background-color: var(--danger-tint-dark); } + SvGo { background: cn1-round-border; color: var(--brand-contrast-dark); background-color: var(--brand-dark); } + SvGo.pressed { background: cn1-round-border; background-color: var(--brand-pressed-dark); } + SvMenu { background-color: var(--surface-dark); } + SvMenuTop { background-color: var(--surface-dark); border-bottom: 1px solid var(--line-dark); } + SvMenuName { color: var(--text-dark); } + SvMenuDetail { color: var(--text-muted-dark); } + SvModeBadge { background: cn1-pill-border; color: var(--brand-dark); background-color: var(--brand-tint-dark); } + SvMenuLabel { color: var(--text-muted-dark); } + SvMenuItem { color: var(--text-dark); } + SvMenuItem.pressed { background-color: var(--surface-alt-dark); } + SvMenuBottom { border-top: 1px solid var(--line-dark); } + SvHomeBar { background-color: var(--surface-dark); border-bottom: 1px solid var(--line-dark); } + SvSection { color: var(--text-dark); } + SvSegments { background-color: var(--surface-alt-dark); } + SvSegment { color: var(--text-muted-dark); } + SvSegment.selected { color: var(--text-muted-dark); background-color: transparent; } + SvSegment.pressed { color: var(--text-dark); background-color: var(--surface-press-dark); } + SvList { background-color: var(--surface-dark); } + SvFactLabel { color: var(--text-muted-dark); } + SvFactValue { color: var(--text-dark); } + SvFactTotal { color: var(--text-dark); } + SvCallout { background-color: var(--warning-tint-dark); } + SvCalloutIcon { color: var(--warning-dark); } + SvCalloutTitle { color: var(--text-dark); } + SvCalloutText { color: var(--text-dark); } + SvBannerIcon { background: cn1-round-border; color: var(--brand-dark); background-color: var(--brand-tint-dark); } + SvBannerIconGood { background: cn1-round-border; color: var(--success-dark); background-color: var(--success-tint-dark); } + SvBannerIconWarn { background: cn1-round-border; color: var(--warning-dark); background-color: var(--warning-tint-dark); } + SvBannerIconBad { background: cn1-round-border; color: var(--danger-dark); background-color: var(--danger-tint-dark); } + SvBannerTitle { color: var(--text-dark); } + SvBannerText { color: var(--text-muted-dark); } + SvAmount { color: var(--text-dark); } + SvTile { background-color: var(--surface-dark); border: 1px solid var(--line-dark); } + SvChart { color: var(--brand-dark); } + SvChartPeak { color: var(--brand-pressed-dark); } + SvChartGrid { color: var(--line-dark); } + SvChartAxis { color: var(--text-muted-dark); } + SvChartValue { color: var(--text-dark); } + SvMeter { color: var(--brand-dark); } + SvAlert { color: var(--warning-dark); background-color: var(--warning-tint-dark); } + SvAlert.pressed { background-color: var(--surface-press-dark); } + SvOption { background-color: var(--surface-dark); border: 1px solid var(--line-dark); } + SvOption.pressed { background-color: var(--surface-alt-dark); } + SvOptionPicked { background-color: var(--brand-tint-dark); border: 2px solid var(--brand-dark); } + SvOptionIcon { color: var(--text-dark); } + SvCardFace { background-color: var(--brand-dark); } + SvCardBrand { color: var(--brand-contrast-dark); } + SvCardNumber { color: var(--brand-contrast-dark); } + SvCardSmall { color: var(--brand-contrast-dark); } + SvThumb { color: var(--text-muted-dark); background-color: var(--surface-alt-dark); } + SvStepLabel { color: var(--brand-dark); } + SvFooter { background-color: var(--surface-dark); border-top: 1px solid var(--line-dark); } + SvStar { color: var(--warning-dark); } + SvSide { background-color: var(--surface-alt-dark); border-right: 1px solid var(--line-dark); } + SvSideName { color: var(--text-dark); } + SvSideDetail { color: var(--text-muted-dark); } + SvSideLabel { color: var(--text-muted-dark); } + SvSideItem { color: var(--text-dark); } + SvSideItem.pressed { background-color: var(--line-dark); } + SvSidePicked { color: var(--brand-contrast-dark); background-color: var(--brand-dark); } + SvMaster { background-color: var(--surface-dark); border-right: 1px solid var(--line-dark); } + SvPaneBar { background-color: var(--surface-dark); border-bottom: 1px solid var(--line-dark); } + SvPaneTitle { color: var(--text-dark); } + SvRowPicked { background-color: var(--surface-alt-dark); } + SvSecondary.disabled, SvDanger.disabled, SvLink.disabled { color: var(--line-strong-dark); } + SvField.selected { background-color: var(--brand-tint-dark); } + SvField.disabled { color: var(--text-muted-dark); } + SvStar.pressed { color: var(--text-muted-dark); } + Switch { color: #ffffff; background-color: var(--line-strong-dark); } + Switch.selected { color: #ffffff; background-color: var(--brand-dark); } + Switch.disabled { color: var(--text-muted-dark); background-color: var(--line-dark); } + SvSegment.disabled { color: var(--line-strong-dark); } + SvSkeleton { color: var(--surface-alt-dark); } + PullToRefresh { color: var(--text-muted-dark); } + ToastBar { background-color: var(--text-dark); } + ToastBarMessage { color: var(--surface-dark); } + SvDeskForm { background-color: var(--window-bg-color-dark); } + SvDeskPage { background-color: var(--window-bg-color-dark); } + SvDeskList { background-color: var(--view-bg-color-dark); } + SvDeskMaster { background-color: var(--view-bg-color-dark); border-right: 1px solid #3a3a3a; } + SvDeskHomeBar { background-color: var(--headerbar-bg-color-dark); border-bottom: 1px solid #3a3a3a; } + SvDeskPaneBar { background-color: var(--window-bg-color-dark); border-bottom: 1px solid #3a3a3a; } + SvDeskPaneTitle { color: var(--text-color-dark); } + SvDeskSide { background-color: var(--headerbar-bg-color-dark); border-right: 1px solid #3a3a3a; } + SvDeskSideName { color: var(--text-color-dark); } + SvDeskSideDetail { color: var(--text-secondary-color-dark); } + SvDeskSideLabel { color: var(--text-secondary-color-dark); } + SvDeskSideItem { color: var(--text-color-dark); } + SvDeskSideItem.pressed { background-color: var(--separator-color-dark); } + SvDeskSidePicked { color: var(--accent-fg-color-dark); background-color: var(--accent-color-dark); } + SvDeskDanger { color: var(--danger-dark); } + SvDeskRow { border-bottom: 1px solid #3a3a3a; } + SvDeskRow.pressed { background-color: var(--separator-color-dark); } + SvDeskRowPicked { background-color: var(--separator-color-dark); border-bottom: 1px solid #3a3a3a; } + SvDeskAlert { color: var(--warning-dark); background-color: var(--warning-tint-dark); } + SvDeskAlert.pressed { color: var(--warning-dark); background-color: var(--warning-tint-dark); } + SvDeskRowIcon { color: var(--text-secondary-color-dark); } + SvDeskRowTitle { color: var(--text-color-dark); } + SvDeskRowDetail { color: var(--text-secondary-color-dark); } + SvDeskRowValue { color: var(--text-color-dark); } + SvDeskSection { color: var(--text-color-dark); } + SvDeskTitle { color: var(--text-color-dark); } + SvDeskHeading { color: var(--text-color-dark); } + SvDeskText { color: var(--text-color-dark); } + SvDeskMuted { color: var(--text-secondary-color-dark); } + SvDeskLabel { color: var(--text-secondary-color-dark); } + SvDeskError { color: var(--danger-dark); } + SvDeskNotice { color: var(--text-color-dark); } + SvDeskFactLabel { color: var(--text-secondary-color-dark); } + SvDeskFactValue { color: var(--text-color-dark); } + SvDeskFactTotal { color: var(--text-color-dark); } + SvDeskAmount { color: var(--text-color-dark); } + SvDeskBannerIcon { color: var(--text-secondary-color-dark); } + SvDeskBannerTitle { color: var(--text-color-dark); } + SvDeskBannerText { color: var(--text-secondary-color-dark); } + SvDeskStatValue { color: var(--text-color-dark); } + SvDeskStatLabel { color: var(--text-secondary-color-dark); } + SvDeskChartValue { color: var(--text-color-dark); } + SvDeskChartAxis { color: var(--text-secondary-color-dark); } + SvHomeHeader { background-color: var(--surface-dark); } + SvDeliverTo { color: var(--text-dark); } + SvGreeting { color: var(--text-dark); } + SvMutedSmall { color: var(--text-muted-dark); } + SvMetaLine { color: var(--text-dark); } + SvCuisine { color: var(--text-dark); background-color: var(--surface-alt-dark); } + SvCuisine.pressed { background-color: var(--surface-press-dark); } + SvCuisineSelected { color: var(--brand-contrast-dark); background-color: var(--brand-dark); } + SvRestaurantCard { background-color: var(--surface-dark); } + SvRestaurantCard.pressed { background-color: var(--surface-alt-dark); } + SvRestaurantPhoto { color: #f3e7d3; background-color: transparent; } + SvRestaurantName { color: var(--text-dark); } + SvRestaurantTitle { color: var(--text-dark); } + SvPhotoBadge { color: var(--brand-dark); background-color: var(--surface-dark); } + SvClosedBadge { color: var(--surface-dark); background-color: var(--text-dark); } + SvPopularBadge { color: var(--brand-dark); background-color: var(--brand-tint-dark); } + SvHeroPhoto { color: #f3e7d3; background-color: transparent; } + SvSectionChip { color: var(--text-dark); background-color: var(--surface-alt-dark); } + SvSectionChip.pressed { background-color: var(--surface-press-dark); } + SvMenuSection { color: var(--text-dark); } + SvDishRow { background-color: var(--surface-dark); border-bottom: 1px solid var(--line-dark); } + SvDishRow.pressed { background-color: var(--surface-alt-dark); } + SvDishRowOff { background-color: var(--surface-dark); border-bottom: 1px solid var(--line-dark); } + SvDishName { color: var(--text-dark); } + SvDishPrice { color: var(--text-dark); } + SvDishPhoto { color: #f3e7d3; background-color: transparent; } + SvOrderThumb { color: #f3e7d3; background-color: transparent; } + SvOrderRow { background-color: var(--surface-dark); border-bottom: 1px solid var(--line-dark); } + SvOrderRow.pressed { background-color: var(--surface-alt-dark); } + SvActiveOrder { background-color: var(--brand-tint-dark); } + SvActiveOrder.pressed { background-color: var(--surface-alt-dark); } + SvCartButton { color: var(--brand-contrast-dark); background-color: var(--brand-dark); } + SvCartButton.pressed { background-color: var(--brand-pressed-dark); } + SvCartLine { background-color: var(--surface-dark); border-bottom: 1px solid var(--line-dark); } + SvAddBar { background-color: var(--surface-dark); border-top: 1px solid var(--line-dark); } + SvStepper { background-color: var(--surface-alt-dark); } + SvQuantity { color: var(--text-dark); } + SvSearchField { color: var(--text-dark); background-color: var(--surface-alt-dark); } + SvTabBar { background-color: var(--surface-dark); border-top: 1px solid var(--line-dark); } + SvTab { color: var(--text-muted-dark); } + SvTab.pressed { color: var(--text-dark); } + SvTabSelected { color: var(--brand-dark); } + SvSheetTitle { color: var(--text-dark); } + SvStepBar { background-color: var(--line-dark); } + SvStepBarDone { background-color: var(--brand-dark); } + SvStepText { color: var(--text-dark); } + SvDiscount { color: var(--success-dark); } + SvTotal { color: var(--text-dark); } + SvQueuePane { background-color: var(--surface-dark); border-right: 1px solid var(--line-dark); } + SvDetailPane { background-color: var(--surface-alt-dark); } + SvQueueRow { background-color: var(--surface-dark); border-bottom: 1px solid var(--line-dark); } + SvQueueRow.pressed { background-color: var(--surface-alt-dark); } + SvQueueRowPicked { background-color: var(--brand-tint-dark); border-bottom: 1px solid var(--line-dark); } +} diff --git a/scripts/savor/common/src/main/java/com/codenameone/examples/savor/AppConfig.java b/scripts/savor/common/src/main/java/com/codenameone/examples/savor/AppConfig.java new file mode 100644 index 00000000000..f96dbd910a8 --- /dev/null +++ b/scripts/savor/common/src/main/java/com/codenameone/examples/savor/AppConfig.java @@ -0,0 +1,155 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor; + +import com.codename1.io.Preferences; +import com.codename1.ui.CN; + +/// The few things about the app that are settings and not code. +public final class AppConfig { + /// Where the server is when nothing says otherwise: a server started on + /// this machine with `cn1:backend`. Change this to your deployment's address + /// before you ship; until then the settings screen can point a build at + /// another server, which is how a phone reaches a laptop on the same network. + public static final String DEFAULT_SERVER = "http://localhost:8080"; + /// The id the server knows this app by. The same constant is in the + /// server's `SecurityConfig`. + public static final String CLIENT_ID = "savor-app"; + /// The address the server hands the authorization code back to. The app + /// reads the code out of the redirect itself and never opens this address, + /// so it only has to be one the server has on its list. + public static final String REDIRECT_URI = "http://127.0.0.1/callback"; + /// The path the server hands the code back on when the app runs in a + /// browser. The same constant is in the server's `SecurityConfig`. + public static final String WEB_REDIRECT_PATH = "/signin/code"; + // tag::savor-push-key[] + /// The app's push key, from the Push section of the Codename One console. + /// Empty, the app registers for no notifications: everything still works, + /// and a phone in a pocket simply hears nothing until it is opened. + public static final String PUSH_APP_KEY = ""; + // end::savor-push-key[] + /// Where the map opens when the device will not say where it is. + public static final double DEFAULT_LAT = 37.8080; + public static final double DEFAULT_LNG = -122.4120; + + private static final String SERVER_KEY = "savor.server"; + private static final String MODE_KEY = "savor.mode"; + private static final String MODE_OWNER_KEY = "savor.mode.owner"; + + private AppConfig() { + } + + /// The server's address, with no trailing slash. A `savor.server.url` + /// system property wins, which is what the tests use; then the address saved + /// from the settings screen; then, in a browser, the address the page came + /// from, since the server that hosts the app is the server it talks to; + /// then [#DEFAULT_SERVER]. + public static String serverUrl() { + String url = System.getProperty("savor.server.url"); + if (url == null || url.length() == 0) { + String origin = pageOrigin(); + // In a browser it is always the page's own server: see + // serverSwitchable(). An address saved before that is ignored. + url = origin != null ? origin : Preferences.get(SERVER_KEY, DEFAULT_SERVER); + } + return clean(url); + } + + /// Whether the app may be pointed at another server. Not in a browser: + /// there the authorization code is sent back to the page's own server + /// ([#redirectUri]), and another server has not registered that address + /// as one of its own, so every sign-in after a switch would be refused. + /// The hosted app is the server's own, and talks to that server. + public static boolean serverSwitchable() { + return pageOrigin() == null; + } + + /// The address of the page the app is running in -- `https://host`, with + /// no path -- or null when it is not running in a browser. + public static String pageOrigin() { + String origin = CN.getProperty("browser.window.location.origin", null); + if (origin == null || !(origin.regionMatches(true, 0, "http://", 0, 7) + || origin.regionMatches(true, 0, "https://", 0, 8))) { + return null; + } + return clean(origin); + } + + /// Where the server is asked to send the authorization code. + /// + /// The installed app names the loopback address and reads the code off the + /// redirect without following it. A browser follows a redirect itself and + /// will not show the page an answer from another origin, so there the code + /// is sent to the server the page came from, which hands it back. + public static String redirectUri() { + String origin = pageOrigin(); + return origin == null ? REDIRECT_URI : origin + WEB_REDIRECT_PATH; + } + + public static void setServerUrl(String url) { + Preferences.set(SERVER_KEY, clean(url)); + } + + /// The mode the account `owner` (see + /// [com.codenameone.examples.savor.net.Session#owner(String)]) chose last + /// -- `customer`, `courier` or `admin` -- or + /// empty when they have chosen none, or the choice on record is someone + /// else's. A choice recorded with no name is anyone's: that is how a test + /// puts the app in a mode before it signs in. + public static String mode(String owner) { + String chosenBy = Preferences.get(MODE_OWNER_KEY, ""); + if (chosenBy.length() > 0 && !chosenBy.equals(owner)) { + return ""; + } + return Preferences.get(MODE_KEY, ""); + } + + /// Records `mode` as chosen by whoever signs in next. + public static void setMode(String mode) { + setMode(mode, ""); + } + + public static void setMode(String mode, String username) { + Preferences.set(MODE_KEY, mode == null ? "" : mode); + Preferences.set(MODE_OWNER_KEY, username == null ? "" : username); + } + + /// Whether `url` names the server `current` does, as [#setServerUrl] + /// would store it. + public static boolean sameServer(String url, String current) { + return clean(url).equals(current); + } + + private static String clean(String url) { + String text = url == null ? "" : url.trim(); + while (text.endsWith("/")) { + text = text.substring(0, text.length() - 1); + } + if (text.length() == 0) { + return DEFAULT_SERVER; + } + // Compared without folding the case: a scheme is ASCII, and + // toLowerCase() follows the device's language. + if (!text.regionMatches(true, 0, "http://", 0, 7) + && !text.regionMatches(true, 0, "https://", 0, 8)) { + text = "http://" + text; + } + return text; + } +} diff --git a/scripts/savor/common/src/main/java/com/codenameone/examples/savor/Prefs.java b/scripts/savor/common/src/main/java/com/codenameone/examples/savor/Prefs.java new file mode 100644 index 00000000000..57371c583a4 --- /dev/null +++ b/scripts/savor/common/src/main/java/com/codenameone/examples/savor/Prefs.java @@ -0,0 +1,290 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor; + +import com.codename1.io.Preferences; +import com.codenameone.examples.savor.api.PlaceDto; +import java.util.ArrayList; +import java.util.List; + +/// What the user has chosen in Settings, and the places they go back to. +/// +/// Every screen reads and writes a preference through this class and through +/// nothing else, so where the values live is decided in one place. Today that +/// is the device: `Preferences`, which survives a restart and is gone with the +/// app. A server that keeps the same values for the account plugs in at +/// [#onChange]: it is told the name of each value as it changes, and puts the +/// account's values in with the setters when it has fetched them. No screen +/// has to change for that. +public final class Prefs { + /// Told when a value changes, with the value's name. + public interface Listener { + void changed(String name); + } + + public static final String LANGUAGE = "language"; + public static final String THEME = "theme"; + public static final String UNITS = "units"; + public static final String NOTIFY_DELIVERY_UPDATES = "notifyDeliveryUpdates"; + public static final String NOTIFY_RECEIPTS = "notifyReceipts"; + public static final String NOTIFY_PROMOTIONS = "notifyPromotions"; + public static final String PAYMENT_METHOD = "defaultPaymentMethodId"; + public static final String TIP_PERCENT = "defaultTipPercent"; + /// Where orders go unless the cart says otherwise. Kept on the device: + /// the addresses the account saved are the server's. + public static final String DELIVER_TO = "deliverTo"; + public static final String RECENTS = "recents"; + /// Whose the recent destinations are: see [#ownedBy]. + private static final String RECENTS_OWNER = "recentsOwner"; + + public static final String THEME_SYSTEM = "system"; + public static final String THEME_LIGHT = "light"; + public static final String THEME_DARK = "dark"; + public static final String UNITS_KM = "km"; + public static final String UNITS_MILES = "mi"; + /// The one way to pay that needs nothing set up. + public static final String PAYMENT_CASH = "cash"; + + private static final String PREFIX = "savor.pref."; + /// The plain values that are a copy of the account's; see [#ownedBy]. + private static final String[] ACCOUNT_OWNED = {NOTIFY_DELIVERY_UPDATES, NOTIFY_RECEIPTS, + NOTIFY_PROMOTIONS, PAYMENT_METHOD, TIP_PERCENT}; + /// How many delivery addresses are remembered. + private static final int RECENT_LIMIT = 4; + + private static Listener listener; + + private Prefs() { + } + + /// Names the one listener told of every change. A value put in by the + /// listener itself is reported to it like any other, so a listener that + /// stores what it is told must not answer by setting the value again. + public static void onChange(Listener told) { + listener = told; + } + + // -------------------------------------------------------------- appearance + + /// The language chosen, as its two-letter code; empty while the user has + /// chosen none and the device's language decides. + public static String language() { + return text(LANGUAGE, ""); + } + + public static void setLanguage(String code) { + put(LANGUAGE, code); + } + + /// [#THEME_SYSTEM], [#THEME_LIGHT] or [#THEME_DARK]. + public static String theme() { + return text(THEME, THEME_SYSTEM); + } + + public static void setTheme(String theme) { + put(THEME, theme); + } + + /// [#UNITS_KM] or [#UNITS_MILES]. + public static String units() { + return text(UNITS, UNITS_KM); + } + + public static void setUnits(String units) { + put(UNITS, units); + } + + // ----------------------------------------------------------- notifications + + public static boolean notifyDeliveryUpdates() { + return flag(NOTIFY_DELIVERY_UPDATES, true); + } + + public static void setNotifyDeliveryUpdates(boolean on) { + put(NOTIFY_DELIVERY_UPDATES, on); + } + + public static boolean notifyReceipts() { + return flag(NOTIFY_RECEIPTS, true); + } + + public static void setNotifyReceipts(boolean on) { + put(NOTIFY_RECEIPTS, on); + } + + public static boolean notifyPromotions() { + return flag(NOTIFY_PROMOTIONS, false); + } + + public static void setNotifyPromotions(boolean on) { + put(NOTIFY_PROMOTIONS, on); + } + + // --------------------------------------------------------------- payment + + /// The id of the way the user pays unless they say otherwise for an order. + public static String paymentMethod() { + return text(PAYMENT_METHOD, PAYMENT_CASH); + } + + public static void setPaymentMethod(String id) { + put(PAYMENT_METHOD, id); + } + + /// The tip offered first at checkout, as a percentage of the food. + public static int tipPercent() { + return Preferences.get(PREFIX + TIP_PERCENT, 0); + } + + public static void setTipPercent(int percent) { + if (percent != tipPercent()) { + Preferences.set(PREFIX + TIP_PERCENT, percent); + changed(TIP_PERCENT); + } + } + + // ---------------------------------------------------------------- places + + /// Where orders are delivered, or null until the customer has said. + public static PlaceDto deliverTo() { + return place(DELIVER_TO); + } + + public static void setDeliverTo(PlaceDto place) { + store(DELIVER_TO, place); + changed(DELIVER_TO); + } + + /// The last few places delivered to, the newest first. + public static List recents() { + List all = new ArrayList(); + for (int iter = 0; iter < RECENT_LIMIT; iter++) { + PlaceDto place = place(RECENTS + "." + iter); + if (place != null) { + all.add(place); + } + } + return all; + } + + /// Remembers `place` as the destination chosen last. + public static void addRecent(PlaceDto place) { + if (place == null || place.name() == null || place.name().length() == 0) { + return; + } + List all = recents(); + for (int iter = all.size() - 1; iter >= 0; iter--) { + if (place.name().equals(all.get(iter).name())) { + all.remove(iter); + } + } + all.add(0, place); + for (int iter = 0; iter < RECENT_LIMIT; iter++) { + store(RECENTS + "." + iter, iter < all.size() ? all.get(iter) : null); + } + changed(RECENTS); + } + + /// Says whose device this is now, by the name they signed in with; empty + /// for nobody. When the account changes, what the device holds of the + /// last one is dropped and not shown to the next: the recent + /// destinations, which are kept here only and say where a person goes, + /// and the copy of everything the account keeps on the server -- home, + /// work, the card paid with, the delivery options. + /// That copy is replaced when the new account's values arrive, but they + /// may not arrive, and then the last person's would be on screen and go + /// out with the next save as the new person's own. How the app looks + /// (language, theme, units) is the device's and stays. The same account + /// signing in again finds its own still there. + /// + /// The listener is told of the recent destinations alone. It sends what + /// it is told of to the server, and values dropped here were not changed + /// by anyone: reported, they would be saved over the new account's. + /// + /// @param account the account, with its server, as + /// [com.codenameone.examples.savor.net.Session#owner(String)] names + /// it, or empty for none + public static void ownedBy(String account) { + String owner = account == null ? "" : account; + if (owner.equals(text(RECENTS_OWNER, ""))) { + return; + } + for (int iter = 0; iter < RECENT_LIMIT; iter++) { + store(RECENTS + "." + iter, null); + } + store(DELIVER_TO, null); + for (String name : ACCOUNT_OWNED) { + Preferences.delete(PREFIX + name); + } + Preferences.set(PREFIX + RECENTS_OWNER, owner); + changed(RECENTS); + } + + // ------------------------------------------------------------- the store + + private static String text(String name, String fallback) { + return Preferences.get(PREFIX + name, fallback); + } + + private static boolean flag(String name, boolean fallback) { + return Preferences.get(PREFIX + name, fallback); + } + + private static void put(String name, String value) { + String wanted = value == null ? "" : value.trim(); + if (!wanted.equals(text(name, null))) { + Preferences.set(PREFIX + name, wanted); + changed(name); + } + } + + private static void put(String name, boolean value) { + // Compared with the opposite as the fallback, so a value never stored + // is stored the first time whatever it is. + if (flag(name, !value) != value) { + Preferences.set(PREFIX + name, value); + changed(name); + } + } + + private static PlaceDto place(String name) { + String title = text(name + ".name", ""); + if (title.length() == 0) { + return null; + } + PlaceDto place = new PlaceDto(title, text(name + ".address", ""), + Preferences.get(PREFIX + name + ".lat", 0d), + Preferences.get(PREFIX + name + ".lng", 0d)); + return place; + } + + private static void store(String name, PlaceDto place) { + Preferences.set(PREFIX + name + ".name", place == null ? "" : place.name()); + Preferences.set(PREFIX + name + ".address", + place == null || place.address() == null ? "" : place.address()); + Preferences.set(PREFIX + name + ".lat", place == null ? 0d : place.lat()); + Preferences.set(PREFIX + name + ".lng", place == null ? 0d : place.lng()); + } + + private static void changed(String name) { + if (listener != null) { + listener.changed(name); + } + } +} diff --git a/scripts/savor/common/src/main/java/com/codenameone/examples/savor/Savor.java b/scripts/savor/common/src/main/java/com/codenameone/examples/savor/Savor.java new file mode 100644 index 00000000000..d7a8c717603 --- /dev/null +++ b/scripts/savor/common/src/main/java/com/codenameone/examples/savor/Savor.java @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor; + +import com.codename1.system.Lifecycle; +import com.codenameone.examples.savor.courier.Shift; +import com.codenameone.examples.savor.map.Locator; +import com.codenameone.examples.savor.net.Account; +import com.codenameone.examples.savor.net.Session; +import com.codenameone.examples.savor.notify.Notifications; +import com.codenameone.examples.savor.pay.CardForm; +import com.codenameone.examples.savor.ui.Lang; +import com.codenameone.examples.savor.ui.Look; +import com.codenameone.examples.savor.ui.Nav; + +/// The app. It opens on whatever the stored session allows: the mode the user +/// was last in when they are still signed in, the welcome screen when not. +/// +/// Where things are: +/// +/// - `net` -- the session, and the typed clients generated from the contracts in +/// the `shared` module. Nothing else in the app builds a URL. +/// - `live` -- the WebSocket the server pushes changes down. +/// - `notify` -- push notifications, for a phone that is not looking. +/// - `ui`, `customer`, `courier`, `admin` -- the screens. +/// - `map` -- the map, its markers and the device's position. +/// - `src/main/css/theme.css` -- every colour, font and margin. +/// - `src/main/l10n` -- every word, in each language the app speaks. +/// - `Prefs` -- what the user chose in Settings. +/// - `Telemetry` -- what is reported about how the app is used, once the user +/// has agreed to it. +public class Savor extends Lifecycle { + @Override + public void runApp() { + // Before the first screen is built: a screen keeps the look and the + // words it was built with. + Look.apply(); + Lang.apply(); + Telemetry.start(); + Session.start(AppConfig.serverUrl()); + Account.start(); + Nav.launch(); + } + + /// Coming back to the app is how a card added on the payment provider's + /// page is noticed: the browser it opened in tells the app nothing. + /// + /// It is also when the app asks for its push token, which the platform + /// answers at once when nothing has changed, and when the position is + /// looked for again if going to the background put the search down. + @Override + public void start() { + super.start(); + CardForm.resumed(); + Shift.resumed(); + Locator.resumed(); + Notifications.start(); + } + + /// The app is put away, which is the last sure moment to send the usage + /// reports gathered since the last batch. + /// + /// A courier on line goes on reporting where the car is from the + /// background; anyone else's search for the position is put down until + /// the app is back. + @Override + public void stop() { + super.stop(); + Telemetry.flush(); + Shift.paused(); + Locator.paused(); + } +} diff --git a/scripts/savor/common/src/main/java/com/codenameone/examples/savor/Telemetry.java b/scripts/savor/common/src/main/java/com/codenameone/examples/savor/Telemetry.java new file mode 100644 index 00000000000..aee9bc63970 --- /dev/null +++ b/scripts/savor/common/src/main/java/com/codenameone/examples/savor/Telemetry.java @@ -0,0 +1,353 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor; + +import com.codename1.analytics.Analytics; +import com.codename1.analytics.AnalyticsConsent; +import com.codename1.analytics.AnalyticsEvent; +import com.codename1.analytics.AnalyticsProvider; +import com.codename1.analytics.CodenameOneAnalyticsProvider; +import com.codename1.analytics.LoggingAnalyticsProvider; +import com.codename1.ui.CN; +import com.codenameone.examples.savor.api.DeliveryState; +import com.codenameone.examples.savor.ui.Lang; + +/// What the app reports about how it is used, and whether it may. +/// +/// This is the only class that talks to `com.codename1.analytics`. A screen +/// says what happened by calling one of the methods here, so the whole +/// vocabulary -- every event name and every parameter -- is in this file, and +/// it is here that the one rule is kept: nothing that says who someone is or +/// where they are ever goes into an event. No name, e-mail address, phone +/// number, address, position, card number or delivery id; an amount of money goes +/// as the bracket it falls in. What is known about the user is two things: the +/// mode they are in and the language they read. +/// +/// Nothing is reported until the user has said yes. The library starts with +/// consent withheld and drops every call made without it, so a method here is +/// always safe to call. The answer is asked for once, after the first sign-in +/// on a device ([#asked()], `ui.ConsentForm`), can be changed in Settings at +/// any time ([#allow(boolean)]), and is kept by the library on the device, in +/// `Preferences`, with the made-up id that tells one installation from the +/// next. Deleting the account forgets both ([#forget()]). +/// +/// On a device the reports go to Codename One's own service, which shows them +/// in the developer console. That service knows which app is reporting from +/// the identity a build stamps into it, so the simulator, which has none, only +/// writes what it would have sent to the log. +public final class Telemetry { + private static final String ACCOUNT = "account"; + private static final String DELIVERY = "delivery"; + private static final String PAYMENT = "payment"; + private static final String COURIER = "courier"; + private static final String ADMIN = "admin"; + private static final String SETTINGS = "settings"; + + /// The screen reported last, which the next one names as where it came from. + private static String screen; + /// The screen that is showing, reported or not. + private static String showing; + private static String role; + /// The role and the language as they were last reported. + private static String described; + private static boolean started; + + private Telemetry() { + } + + /// Says where the reports go. Called once, when the app starts. + public static void start() { + if (started) { + return; + } + started = true; + if (CN.isSimulator()) { + Analytics.addProvider(new LoggingAnalyticsProvider()); + } else { + Analytics.addProvider(new CodenameOneAnalyticsProvider()); + } + } + + /// Sends the reports to `providers` and nowhere else. For a test, which + /// wants to see what was reported and wants none of it to leave. + public static void use(AnalyticsProvider... providers) { + started = true; + Analytics.clearProviders(); + for (AnalyticsProvider provider : providers) { + Analytics.addProvider(provider); + } + screen = null; + described = null; + } + + // --------------------------------------------------------------- consent + + /// Whether the user has answered, either way. + public static boolean asked() { + return Analytics.getConsent() != null; + } + + /// Whether the user said yes. + public static boolean allowed() { + AnalyticsConsent consent = Analytics.getConsent(); + return consent != null && consent.isAnalytics(); + } + + /// Records the user's answer. A yes covers how the app is used and the + /// errors it runs into; nothing here follows a person or serves an + /// advertisement, and those are never granted. + public static void allow(boolean yes) { + boolean was = allowed(); + Analytics.setConsent(AnalyticsConsent.builder() + .analytics(yes) + .crashReporting(yes) + .personalization(false) + .adStorage(false) + .build()); + if (yes && !was) { + // What was dropped while the answer was no: who is using the app, + // in the two words kept about them, and the screen they are on. + screen = null; + described = null; + describe(); + if (showing != null) { + screen(showing); + } + } + } + + /// Takes the answer back, so that it is asked for again. + public static void unask() { + Analytics.setConsent(null); + } + + /// The account is gone: the installation gets a new id, which cuts what is + /// reported from now on from everything reported before, and whoever signs + /// in next is asked for themselves. + public static void forget() { + // What is waiting was gathered under the id that is about to go, and + // is sent under it. + Analytics.flush(); + Analytics.resetClientId(); + Analytics.setConsent(null); + role = null; + screen = null; + described = null; + } + + /// Sends what is waiting. Reports are gathered and sent a batch at a + /// time; this is for when the app is put away. + public static void flush() { + Analytics.flush(); + } + + // ------------------------------------------------------- screens and who + + /// A screen is showing. `name` is the one the screen was built with, which + /// is not translated and does not change with its title. + public static void screen(String name) { + if (name == null || name.length() == 0) { + return; + } + showing = name; + if (name.equals(screen) || !allowed()) { + return; + } + Analytics.screen(name, screen); + screen = name; + } + + /// The user is in `mode`: customer, courier or admin. + public static void role(String mode) { + role = mode; + describe(); + } + + /// Reports the role and the language, when either is news: the home + /// screen is built again for every change of look, and says its mode each + /// time. + private static void describe() { + if (role == null || !allowed()) { + return; + } + String language = Lang.current(); + String now = role + "/" + language; + if (!now.equals(described)) { + described = now; + Analytics.setUserProperty("role", role); + Analytics.setUserProperty("language", language); + } + } + + // --------------------------------------------------------------- account + + public static void signedIn() { + send(ACCOUNT, "login", "method", "password"); + } + + /// @param reason `user` when they chose to, `expired` when the session + /// ended under them, `deleted` when the account was closed + public static void signedOut(String reason) { + send(ACCOUNT, "logout", "reason", reason); + role = null; + } + + /// @param onboarding whether they came to drive + public static void registered(boolean onboarding) { + send(ACCOUNT, "sign_up", "method", "password", "intent", onboarding ? "courier" : "customer"); + } + + public static void phoneVerified() { + send(ACCOUNT, "phone_verified"); + } + + // ------------------------------------------------------------------ deliveries + + /// @param lines how many dishes the cart holds + public static void orderQuoted(int lines) { + send(DELIVERY, "order_quoted", "lines", Integer.valueOf(lines)); + } + + public static void orderPlaced(boolean cash, boolean promo) { + send(DELIVERY, "order_placed", "payment", cash ? "cash" : "card", "promo", + promo ? "yes" : "no"); + } + + /// @param by `customer`, `courier` or `admin` + /// @param state the state the delivery was in + public static void deliveryCancelled(String by, DeliveryState state) { + send(DELIVERY, "delivery_cancelled", "by", by, "state", state == null ? "" : state.name()); + } + + /// @param side whose device saw it end: `customer` or `courier`. A delivery that + /// both of them use the app for is reported by both, so a count of + /// deliveries takes one side. + public static void deliveryCompleted(String side, long fareCents, boolean cash) { + send(DELIVERY, "delivery_completed", "side", side, "fare", fare(fareCents), "payment", + cash ? "cash" : "card"); + } + + public static void rated(int stars) { + send(DELIVERY, "rating_given", "stars", Integer.valueOf(stars)); + } + + // --------------------------------------------------------------- payment + + /// @param where `delivery` at the end of the delivery, `receipt` from the receipt + /// @param percent the share of the fare, or 0 for an amount typed in + public static void tipped(String where, int percent) { + send(PAYMENT, "tip_added", "where", where, "share", + percent > 0 ? percent + "_percent" : "custom"); + } + + public static void cardAdded() { + send(PAYMENT, "card_added"); + } + + public static void cardRemoved() { + send(PAYMENT, "card_removed"); + } + + // --------------------------------------------------------------- onboarding + + /// @param again whether this is an application sent back, being put right + public static void applicationStarted(boolean again) { + send(COURIER, "courier_application_started", "resubmission", Boolean.valueOf(again)); + } + + public static void applicationSubmitted() { + send(COURIER, "courier_application_submitted"); + } + + public static void courierOnline(boolean online) { + send(COURIER, online ? "courier_online" : "courier_offline"); + } + + public static void offerAnswered(boolean accepted) { + send(COURIER, accepted ? "offer_accepted" : "offer_declined"); + } + + public static void payoutRequested(long cents) { + send(COURIER, "payout_requested", "amount", fare(cents)); + } + + // ----------------------------------------------------------------- admin + + public static void applicationReviewed(boolean approved) { + send(ADMIN, approved ? "admin_application_approved" : "admin_application_rejected"); + } + + public static void userFlagged(boolean flagged) { + send(ADMIN, flagged ? "admin_user_flagged" : "admin_user_unflagged"); + } + + public static void userBlocked(boolean blocked) { + send(ADMIN, blocked ? "admin_user_blocked" : "admin_user_unblocked"); + } + + /// @param from the screen it was done from + public static void deliveryRefunded(String from) { + send(ADMIN, "admin_delivery_refunded", "from", from); + } + + public static void pricingChanged() { + send(ADMIN, "admin_pricing_changed"); + } + + // -------------------------------------------------------------- settings + + public static void languageChanged(String code) { + send(SETTINGS, "language_changed", "language", code); + describe(); + } + + public static void themeChanged(String theme) { + send(SETTINGS, "theme_changed", "theme", theme); + } + + // ---------------------------------------------------------------- errors + + /// Something failed that the app caught and carried on from. Only what + /// kind of failure it was is reported: a message can quote what the user + /// typed, or the file it was reading. + public static void error(Throwable failed) { + if (failed != null) { + Analytics.crash(failed, failed.getClass().getName(), false); + } + } + + // ------------------------------------------------------------- the store + + /// The bracket an amount of money falls in, in whole units of the + /// currency. The amount itself, beside a time, can be matched to a delivery. + static String fare(long cents) { + long units = cents / 100; + return units < 5 ? "under_5" : units < 10 ? "5_to_10" : units < 20 ? "10_to_20" + : units < 35 ? "20_to_35" : units < 50 ? "35_to_50" : "50_or_more"; + } + + /// Reports `name` with `pairs`: a parameter's name, then its value. + private static void send(String category, String name, Object... pairs) { + AnalyticsEvent.Builder event = AnalyticsEvent.create(name).category(category); + for (int iter = 0; iter + 1 < pairs.length; iter += 2) { + event.param((String) pairs[iter], pairs[iter + 1]); + } + Analytics.event(event.build()); + } +} diff --git a/scripts/savor/common/src/main/java/com/codenameone/examples/savor/admin/AdminDeliveriesForm.java b/scripts/savor/common/src/main/java/com/codenameone/examples/savor/admin/AdminDeliveriesForm.java new file mode 100644 index 00000000000..f19af7641c2 --- /dev/null +++ b/scripts/savor/common/src/main/java/com/codenameone/examples/savor/admin/AdminDeliveriesForm.java @@ -0,0 +1,184 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.admin; + +import com.codename1.ui.Container; +import com.codename1.ui.FontImage; +import com.codename1.ui.Form; +import com.codename1.ui.Label; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.layouts.FlowLayout; +import com.codenameone.examples.savor.Telemetry; +import com.codenameone.examples.savor.api.ReasonDto; +import com.codenameone.examples.savor.api.DeliveryDto; +import com.codenameone.examples.savor.api.DeliveryState; +import com.codenameone.examples.savor.net.Api; +import com.codenameone.examples.savor.net.Net; +import com.codenameone.examples.savor.ui.Lang; +import com.codenameone.examples.savor.ui.Ui; +import java.util.List; + +/// Every delivery, for an admin: which are under way, which finished and which +/// did not, and for each what can still be done about it -- cancelling one that +/// is under way, returning the money for one that is paid. +public final class AdminDeliveriesForm { + private static final String[] FILTERS = {"all", "active", "completed", "cancelled"}; + private static final String[] FILTER_NAMES = {"All", "Under way", "Completed", "Cancelled"}; + + private final Panes panes; + private final Container list = Ui.list("deliveries"); + private List deliveries; + private String filter = FILTERS[0]; + + private AdminDeliveriesForm(Panes panes) { + this.panes = panes; + Container top = new Container(BoxLayout.y()); + top.setUIID("SvPage"); + top.add(Ui.choice("filter", FILTER_NAMES, FILTERS, filter, value -> { + filter = value; + fill(); + })); + Ui.pull(list, this::load); + panes.list("Deliveries", "Deliveries", Panes.listed(top, list), this::load); + } + + static void show(Form previous) { + show(Panes.phone(previous)); + } + + static void show(Panes panes) { + new AdminDeliveriesForm(panes); + } + + private void load() { + Api.admin().deliveries(Net.to(found -> { + deliveries = found; + fill(); + }, Ui.failed(list, this::load))); + } + + private void fill() { + list.removeAll(); + int shown = 0; + for (int iter = 0; deliveries != null && iter < deliveries.size(); iter++) { + final DeliveryDto delivery = deliveries.get(iter); + if (!passes(delivery)) { + continue; + } + String people = delivery.courierName() == null || delivery.courierName().length() == 0 + ? delivery.customerName() + : Lang.tr("{0} with {1}", delivery.customerName(), delivery.courierName()); + Container value = new Container(BoxLayout.y()); + value.add(Ui.plain(Ui.money(delivery.total() > 0 ? delivery.total() : delivery.fareCents(), + delivery.currency()), "SvRowValue")); + value.add(Ui.badge(Ui.state(delivery.state()), Ui.stateBadge(delivery.state()))); + final Container picked = Ui.row(FontImage.MATERIAL_PLACE, delivery.dropoffAddress(), + Ui.when(delivery.requestedAt()) + Ui.DOT + people, value); + list.add(Ui.tap(picked, "delivery-" + shown, e -> { + panes.picked(picked); + show(delivery); + })); + shown++; + } + if (shown == 0 && deliveries != null) { + list.add(Ui.empty(FontImage.MATERIAL_RECEIPT_LONG, "No deliveries here", null)); + } + panes.refresh(); + } + + private boolean passes(DeliveryDto delivery) { + if ("active".equals(filter)) { + return delivery.state().isActive(); + } + if ("completed".equals(filter)) { + return DeliveryState.COMPLETED == delivery.state(); + } + if ("cancelled".equals(filter)) { + return !delivery.state().isActive() && DeliveryState.COMPLETED != delivery.state(); + } + return true; + } + + private void show(final DeliveryDto delivery) { + Container page = Ui.page(); + Label total = Ui.plain(Ui.money(delivery.total() > 0 ? delivery.total() : delivery.fareCents(), + delivery.currency()), "SvAmount"); + page.add(total); + Label state = Ui.badge(Ui.state(delivery.state()), Ui.stateBadge(delivery.state())); + state.setName("deliveryState"); + page.add(FlowLayout.encloseCenter(state)); + page.add(FlowLayout.encloseCenter(Ui.plain(Ui.when(delivery.requestedAt()), "SvMuted"))); + + Ui.section(page, "Route"); + page.add(Ui.row(FontImage.MATERIAL_TRIP_ORIGIN, delivery.pickupAddress(), null, null)); + page.add(Ui.row(FontImage.MATERIAL_PLACE, delivery.dropoffAddress(), + Ui.distance(delivery.distanceMeters()) + Ui.DOT + Ui.minutes(delivery.durationSeconds()), + null)); + Ui.section(page, "People"); + page.add(Ui.row(FontImage.MATERIAL_PERSON, delivery.customerName(), delivery.customerUsername(), null)); + if (delivery.courierName() != null && delivery.courierName().length() > 0) { + page.add(Ui.row(FontImage.MATERIAL_TWO_WHEELER, delivery.courierName(), + delivery.vehicle() + (delivery.plate() == null || delivery.plate().length() == 0 ? "" + : Ui.DOT + delivery.plate()), null)); + } + Ui.section(page, "Payment"); + page.add(Ui.fact("Delivery fee", Ui.money(delivery.fareCents(), delivery.currency()))); + page.add(Ui.fact("Tip", Ui.money(delivery.tip(), delivery.currency()))); + page.add(Ui.fact("Paid with", delivery.cash() ? Lang.tr("Cash") : delivery.paymentMethodLabel())); + page.add(Ui.fact("Payment", Lang.tr(payment(delivery.paymentStatus())))); + + if (delivery.state().isActive()) { + // The order is what is called off: its delivery shares its id and + // ends with it, and the hold on the card is let go. + page.add(Ui.danger("Cancel this order", "cancelOrder", e -> panes.reason( + "Cancel order", Lang.tr("The customer, the kitchen and the courier are all " + + "told, and nobody is charged."), "Cancel order", why -> { + ReasonDto reason = new ReasonDto(why); + Api.admin().cancelOrder(delivery.id(), reason, Net.to(cancelled -> { + Telemetry.deliveryCancelled("admin", delivery.state()); + panes.home(); + }, Ui::fail)); + }))); + } + if ("PAID".equals(delivery.paymentStatus()) && !delivery.cash()) { + page.add(Ui.danger("Refund this order", "refund", e -> panes.reason("Refund", + Lang.tr("{0} goes back to the card it came from.", Ui.money( + delivery.total() > 0 ? delivery.total() : delivery.fareCents(), delivery.currency())), + "Refund", why -> { + ReasonDto reason = new ReasonDto(why); + Api.admin().refundOrder(delivery.id(), reason, Net.to(refunded -> { + Telemetry.deliveryRefunded("deliveries"); + // The processor may not have returned the money + // yet; the receipt says which. + Ui.say("refund_pending".equals(refunded.status()) + ? "The refund is pending" : "The order was refunded"); + panes.home(); + }, Ui::fail)); + }))); + } + panes.detail("Delivery", "DeliveryDetail", page); + } + + private static String payment(String status) { + return "PAID".equals(status) ? "Paid" : "REFUNDED".equals(status) ? "Refunded" + : "REFUND_PENDING".equals(status) ? "Refund pending" + : "REFUND_FAILED".equals(status) ? "Refund failed" + : "FAILED".equals(status) ? "Payment failed" : "Payment pending"; + } +} diff --git a/scripts/savor/common/src/main/java/com/codenameone/examples/savor/admin/AdminForm.java b/scripts/savor/common/src/main/java/com/codenameone/examples/savor/admin/AdminForm.java new file mode 100644 index 00000000000..cc8e734e8bd --- /dev/null +++ b/scripts/savor/common/src/main/java/com/codenameone/examples/savor/admin/AdminForm.java @@ -0,0 +1,201 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.admin; + +import com.codename1.ui.CN; +import com.codename1.ui.Container; +import com.codename1.ui.FontImage; +import com.codename1.ui.Form; +import com.codename1.ui.Label; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.layouts.FlowLayout; +import com.codename1.ui.util.UITimer; +import com.codenameone.examples.savor.ui.Layouts; +import com.codenameone.examples.savor.ui.Menu; +import com.codenameone.examples.savor.ui.Nav; +import com.codenameone.examples.savor.ui.Ui; + +/// Administering: what needs an answer, how the service is doing right now and +/// over the last weeks, and the way to everything an admin looks after. +/// +/// Everything here is behind `/api/admin`, which the server opens to the +/// `ADMIN` role and to nobody else. That is where the protection is. This +/// screen is reached only when `/api/me` says the user is an admin, but that is +/// so the others are not shown something useless -- not what keeps them out. +/// +/// It is laid out for the room there is. This is the phone's: the [Dashboard] +/// with the places under it and in a drawer, each a screen of its own. On a +/// wide screen -- a tablet on its side, a desktop, a browser -- it is the +/// [Console] instead, with the places down the side and a list beside what was +/// picked from it. Which one is decided when the screen is built, and a window +/// resized across the line, or a device turned, builds it again. +public class AdminForm implements Nav.Home { + private static final int POLL_MILLIS = 5000; + + private final Console console; + private final Form form; + private Dashboard dashboard; + + public AdminForm() { + if (Layouts.wide()) { + console = new Console(); + form = console.form(); + return; + } + console = null; + form = Ui.home("Admin"); + final Menu menu = new Menu(form, Nav.ADMIN); + dashboard = new Dashboard(false, new Dashboard.Places() { + @Override + public void applications() { + ApplicationsForm.show(form); + } + + @Override + public void flagged() { + UsersForm.show(form, UsersForm.FLAGGED); + } + }); + menu.item(FontImage.MATERIAL_INSIGHTS, "Dashboard", "home", () -> { }); + menu.item(FontImage.MATERIAL_STOREFRONT, "Restaurants", "restaurants", + () -> RestaurantsForm.show(form)); + menu.item(FontImage.MATERIAL_ASSIGNMENT_IND, "Couriers", "applications", + () -> ApplicationsForm.show(form)); + menu.item(FontImage.MATERIAL_GROUP, "People", "users", () -> UsersForm.show(form, "")); + menu.item(FontImage.MATERIAL_RECEIPT_LONG, "Orders", "deliveries", + () -> AdminDeliveriesForm.show(form)); + menu.item(FontImage.MATERIAL_PAYMENTS, "Payments", "payments", + () -> PaymentsForm.show(form)); + menu.item(FontImage.MATERIAL_MAP, "Live map", "fleet", this::fleet); + menu.item(FontImage.MATERIAL_LOCAL_OFFER, "Promos", "promos", () -> PromosForm.show(form)); + menu.item(FontImage.MATERIAL_SELL, "Pricing", "pricing", () -> PricingForm.show(form)); + + // The bar a title bar would be, with the menu where the map screens + // have it, and the mode said beside the title: an admin who also delivers + // should never have to wonder which app this is. + Container bar = new Container(new BorderLayout()); + bar.setUIID("SvHomeBar"); + bar.add(BorderLayout.WEST, FlowLayout.encloseCenterMiddle(menu.flatButton())); + bar.add(BorderLayout.CENTER, FlowLayout.encloseLeftMiddle( + Ui.label("Operations", "SvHeading"))); + // In the menu the badge stands under a name and keeps a gap above it; + // here it is alone on the bar's centre line. + Label admin = Ui.badge("Admin", "SvModeBadge"); + admin.getAllStyles().setMargin(0, 0, 0, 0); + bar.add(BorderLayout.EAST, FlowLayout.encloseCenterMiddle(admin)); + + Container page = dashboard.page(); + Ui.section(page, "Manage"); + Container manage = new Container(BoxLayout.y()); + manage.add(Ui.tap(Ui.row(FontImage.MATERIAL_STOREFRONT, "Restaurants", + "Restaurants asking to join, and the ones selling", null), "restaurants", + e -> RestaurantsForm.show(form))); + manage.add(Ui.tap(Ui.row(FontImage.MATERIAL_ASSIGNMENT_IND, "Couriers", + "People who asked to deliver, and their papers", null), "applications", + e -> ApplicationsForm.show(form))); + manage.add(Ui.tap(Ui.row(FontImage.MATERIAL_GROUP, "People", + "Roles, flags, and blocking an account", null), "users", + e -> UsersForm.show(form, ""))); + manage.add(Ui.tap(Ui.row(FontImage.MATERIAL_RECEIPT_LONG, "Orders", + "The most recent, in every state", null), "deliveries", + e -> AdminDeliveriesForm.show(form))); + manage.add(Ui.tap(Ui.row(FontImage.MATERIAL_PAYMENTS, "Payments", + "What was charged, and refunds", null), "payments", + e -> PaymentsForm.show(form))); + manage.add(Ui.tap(Ui.row(FontImage.MATERIAL_MAP, "Live map", + "Where every courier on line is", null), "fleet", e -> fleet())); + manage.add(Ui.tap(Ui.row(FontImage.MATERIAL_LOCAL_OFFER, "Promos", + "Codes customers type at checkout", null), "promos", + e -> PromosForm.show(form))); + manage.add(Ui.tap(Ui.row(FontImage.MATERIAL_SELL, "Pricing", + "What a delivery costs, and the service's share", null), "pricing", + e -> PricingForm.show(form))); + page.add(manage); + + Container screen = new Container(new BorderLayout()); + // The strip the status bar is drawn in is the bar's colour, not + // whatever is behind the screen. + screen.setUIID("SvForm"); + screen.setSafeArea(true); + screen.add(BorderLayout.NORTH, bar); + screen.add(BorderLayout.CENTER, page); + form.add(screen); + menu.install(); + // A narrow window on a desktop is still a desktop's. + Layouts.dress(form); + form.addShowListener(e -> { + if (Layouts.wide()) { + // Come back to from a screen that was made wide meanwhile. + CN.callSerially(Nav::home); + return; + } + dashboard.shown(); + }); + Layouts.onChange(form, Nav::home); + UITimer.timer(POLL_MILLIS, true, form, dashboard::load); + } + + @Override + public Form form() { + return form; + } + + @Override + public void start() { + if (console != null) { + console.start(); + return; + } + dashboard.start(); + // The window was a console a moment ago, on one of its places: that + // place is where the admin still is. + final String place = Console.resumed(); + if (place != null) { + CN.callSerially(() -> open(place)); + } + } + + public void show() { + start(); + form.show(); + } + + private void open(String place) { + if ("restaurants".equals(place)) { + RestaurantsForm.show(form); + } else if ("promos".equals(place)) { + PromosForm.show(form); + } else if ("applications".equals(place)) { + ApplicationsForm.show(form); + } else if ("users".equals(place)) { + UsersForm.show(form, ""); + } else if ("deliveries".equals(place)) { + AdminDeliveriesForm.show(form); + } else if ("payments".equals(place)) { + PaymentsForm.show(form); + } else if ("pricing".equals(place)) { + PricingForm.show(form); + } + } + + private void fleet() { + new FleetForm(form).show(); + } +} diff --git a/scripts/savor/common/src/main/java/com/codenameone/examples/savor/admin/ApplicationsForm.java b/scripts/savor/common/src/main/java/com/codenameone/examples/savor/admin/ApplicationsForm.java new file mode 100644 index 00000000000..9b8eb0fd394 --- /dev/null +++ b/scripts/savor/common/src/main/java/com/codenameone/examples/savor/admin/ApplicationsForm.java @@ -0,0 +1,253 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.admin; + +import com.codename1.components.ScaleImageLabel; +import com.codename1.io.Log; +import com.codename1.ui.Container; +import com.codename1.ui.FontImage; +import com.codename1.ui.Form; +import com.codename1.ui.Image; +import com.codename1.ui.Label; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.layouts.FlowLayout; +import com.codename1.ui.plaf.Style; +import com.codename1.util.Base64; +import com.codenameone.examples.savor.Telemetry; +import com.codenameone.examples.savor.api.DocumentContentDto; +import com.codenameone.examples.savor.api.DocumentDto; +import com.codenameone.examples.savor.api.CourierApplicationDto; +import com.codenameone.examples.savor.api.ReasonDto; +import com.codenameone.examples.savor.net.Api; +import com.codenameone.examples.savor.net.Net; +import com.codenameone.examples.savor.ui.Lang; +import com.codenameone.examples.savor.ui.Ui; +import java.util.List; + +/// The people who asked to drive, and the decision about each. +/// +/// An application is approved or sent back with a reason. Approving is what +/// makes the account a courier; nothing else in the app or on the server does. +/// The documents are pictures only an admin and the applicant can fetch, one +/// at a time, when they are opened. +public final class ApplicationsForm { + private static final String[] KINDS = {"photo_id", "licence", "selfie", + "vehicle_registration", "insurance"}; + private static final String[] KIND_NAMES = {"Photo ID", "Driving licence", "A photo of you", + "Vehicle registration", "Insurance"}; + + private final Panes panes; + private final Container list = Ui.list("applications"); + private String status = "pending"; + + private ApplicationsForm(Panes panes) { + this.panes = panes; + Container top = new Container(BoxLayout.y()); + top.setUIID("SvPage"); + top.add(Ui.choice("status", new String[] {"Waiting", "Approved", "Sent back"}, + new String[] {"pending", "approved", "rejected"}, status, value -> { + status = value; + load(); + })); + Ui.pull(list, this::load); + panes.list("Applications", "Applications", Panes.listed(top, list), this::load); + } + + static void show(Form previous) { + show(Panes.phone(previous)); + } + + static void show(Panes panes) { + new ApplicationsForm(panes); + } + + private void load() { + final String asked = status; + Api.admin().applications(asked, Net.to(found -> { + if (asked.equals(status)) { + fill(found); + } + }, Ui.failed(list, this::load))); + } + + private void fill(List found) { + list.removeAll(); + if (found == null || found.isEmpty()) { + list.add(Ui.empty(FontImage.MATERIAL_INBOX, "Nothing here", + "pending".equals(status) ? "Every application has had its answer." : null)); + } + for (int iter = 0; found != null && iter < found.size(); iter++) { + final CourierApplicationDto application = found.get(iter); + Container row = Ui.row((char) 0, application.name(), car(application), + Ui.badge(word(application.status()), badge(application.status()))); + row.add(BorderLayout.WEST, FlowLayout.encloseCenterMiddle( + Ui.avatar(application.name()))); + final Container picked = row; + list.add(Ui.tap(row, "application-" + application.username(), e -> { + panes.picked(picked); + review(application.username()); + })); + } + panes.refresh(); + } + + /// One application in full, fetched fresh: it may have been answered by + /// another admin since the list was drawn. + private void review(final String username) { + Api.admin().application(username, Net.to(application -> show(application), Ui::fail)); + } + + private void show(final CourierApplicationDto application) { + Container page = Ui.page(); + Label state = Ui.badge(word(application.status()), badge(application.status())); + state.setName("applicationStatus"); + page.add(Ui.person(application.name(), application.username(), state)); + if ("rejected".equals(application.status())) { + page.add(Ui.text(application.rejectionReason(), "SvNotice")); + } + + Ui.section(page, "Applicant"); + page.add(Ui.fact("Legal name", application.legalName())); + page.add(Ui.fact("Date of birth", application.dateOfBirth())); + page.add(Ui.fact("Licence", application.licenceNumber())); + page.add(Ui.fact("Licence expires", application.licenceExpiry())); + if (application.submittedAt() > 0) { + page.add(Ui.fact("Submitted", Ui.when(application.submittedAt()))); + } + if (application.reviewedAt() > 0) { + page.add(Ui.fact("Reviewed", Ui.when(application.reviewedAt()))); + page.add(Ui.fact("Reviewed by", application.reviewedBy())); + } + + Ui.section(page, "Vehicle"); + page.add(Ui.fact("Vehicle", car(application))); + page.add(Ui.fact("Licence plate", application.vehiclePlate())); + + Ui.section(page, "Documents"); + for (int iter = 0; iter < KINDS.length; iter++) { + final String kind = KINDS[iter]; + final String title = KIND_NAMES[iter]; + String had = status(application, kind); + boolean missing = "missing".equals(had); + Container row = Ui.row(FontImage.MATERIAL_IMAGE, title, + missing ? "Not provided" : "Tap to open", Ui.badge( + missing ? "Missing" : "Provided", + missing ? "SvBadgeBad" : "SvBadgeGood")); + if (missing) { + page.add(row); + } else { + page.add(Ui.tap(row, "document-" + kind, + e -> picture(application.username(), kind, title))); + } + } + + if ("pending".equals(application.status())) { + page.add(Ui.primary("Approve", "approve", e -> Api.admin().approve( + application.username(), Net.to(approved -> { + Telemetry.applicationReviewed(true); + Ui.say(Lang.tr("{0} can now deliver", application.name())); + panes.home(); + }, Ui::fail)))); + page.add(Ui.danger("Send back", "reject", e -> panes.reason("Send back", + Lang.tr("{0} sees what you write, and can apply again once it is put " + + "right.", application.name()), "Send back", why -> { + ReasonDto reason = new ReasonDto(why); + Api.admin().reject(application.username(), reason, Net.to(rejected -> { + Telemetry.applicationReviewed(false); + panes.home(); + }, Ui::fail)); + }))); + } + panes.detail(application.name(), "ApplicationReview", page); + } + + /// A document, as large as the screen allows. + private void picture(String username, String kind, String title) { + final Container stage = new Container(new BorderLayout()); + stage.setUIID("SvPicture"); + panes.over(title, "Document", stage); + Api.admin().document(username, kind, Net.to(content -> { + Image image = decode(content); + if (image == null) { + stage.add(BorderLayout.CENTER, Ui.empty(FontImage.MATERIAL_BROKEN_IMAGE, + "This picture could not be opened", null)); + } else { + ScaleImageLabel shown = new ScaleImageLabel(image); + shown.setUIID("SvPicture"); + shown.setBackgroundType(Style.BACKGROUND_IMAGE_SCALED_FIT); + shown.setName("picture"); + stage.add(BorderLayout.CENTER, shown); + } + panes.refresh(); + }, Ui::fail)); + } + + private static Image decode(DocumentContentDto content) { + if (content == null || content.dataBase64() == null || content.dataBase64().length() == 0) { + return null; + } + try { + byte[] data = Base64.decode(content.dataBase64().getBytes("UTF-8")); + return data == null ? null : Image.createImage(data, 0, data.length); + } catch (java.io.UnsupportedEncodingException impossible) { + Log.e(impossible); + return null; + } catch (RuntimeException unreadable) { + // Not a picture after all; the screen says so. + Log.e(unreadable); + Telemetry.error(unreadable); + return null; + } + } + + private static String status(CourierApplicationDto application, String kind) { + for (int iter = 0; application.documents() != null && iter < application.documents().size(); + iter++) { + DocumentDto document = application.documents().get(iter); + if (kind.equals(document.kind())) { + return document.status(); + } + } + return "missing"; + } + + private static String car(CourierApplicationDto application) { + StringBuilder car = new StringBuilder(); + String[] words = {application.vehicleColor(), application.vehicleMake(), + application.vehicleModel(), + application.vehicleYear() > 0 ? String.valueOf(application.vehicleYear()) : ""}; + for (int iter = 0; iter < words.length; iter++) { + if (words[iter] != null && words[iter].length() > 0) { + car.append(car.length() > 0 ? " " : "").append(words[iter]); + } + } + return car.toString(); + } + + static String word(String status) { + return "pending".equals(status) ? "Waiting" : "approved".equals(status) ? "Approved" + : "rejected".equals(status) ? "Sent back" : "Draft"; + } + + static String badge(String status) { + return "pending".equals(status) ? "SvBadgeWarn" : "approved".equals(status) + ? "SvBadgeGood" : "rejected".equals(status) ? "SvBadgeBad" : "SvBadge"; + } +} diff --git a/scripts/savor/common/src/main/java/com/codenameone/examples/savor/admin/Console.java b/scripts/savor/common/src/main/java/com/codenameone/examples/savor/admin/Console.java new file mode 100644 index 00000000000..d10642057dd --- /dev/null +++ b/scripts/savor/common/src/main/java/com/codenameone/examples/savor/admin/Console.java @@ -0,0 +1,440 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.admin; + +import com.codename1.ui.Button; +import com.codename1.ui.CN; +import com.codename1.ui.Command; +import com.codename1.ui.Component; +import com.codename1.ui.Container; +import com.codename1.ui.FontImage; +import com.codename1.ui.Form; +import com.codename1.ui.geom.Dimension; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.util.UITimer; +import com.codenameone.examples.savor.api.UserDto; +import com.codenameone.examples.savor.net.Session; +import com.codenameone.examples.savor.ui.Lang; +import com.codenameone.examples.savor.ui.Layouts; +import com.codenameone.examples.savor.ui.Nav; +import com.codenameone.examples.savor.ui.SettingsForm; +import com.codenameone.examples.savor.ui.Ui; +import java.util.ArrayList; +import java.util.List; + +/// The admin console on a wide screen: a tablet on its side, a desktop window, +/// a browser. +/// +/// One screen, in three parts. The places an admin goes are always in view +/// down the side, where a phone keeps them in a drawer. Beside them is the +/// place that is open: the dashboard, or a list. And beside a list is what was +/// picked from it -- an application, an account, a delivery, a receipt -- so that +/// working through a list does not mean going back to it each time. +/// +/// The parts themselves are the phone's. Each is written against [Panes], and +/// the ones here put a list and its detail side by side where the phone's +/// make each a screen. +/// +/// On a desktop the window has a menu bar, and the places are in it too, each +/// with a key. Where the platform's own theme is installed the console wears +/// it: see [Layouts#dress(Container)]. +final class Console { + private static final int POLL_MILLIS = 5000; + private static final String HOME = "home"; + private static final String FLEET = "fleet"; + private static final float SIDE_MM = 56f; + private static final float MASTER_MM = 100f; + + /// The places, in the order they are listed: an id, and what it is called. + private static final String[][] PLACES = { + {HOME, "Dashboard"}, {"restaurants", "Restaurants"}, {"applications", "Couriers"}, + {"users", "People"}, {"deliveries", "Orders"}, {"payments", "Payments"}, + {FLEET, "Live map"}, {"promos", "Promos"}, {"pricing", "Pricing"}}; + private static final char[] ICONS = {FontImage.MATERIAL_INSIGHTS, + FontImage.MATERIAL_STOREFRONT, FontImage.MATERIAL_ASSIGNMENT_IND, FontImage.MATERIAL_GROUP, + FontImage.MATERIAL_RECEIPT_LONG, FontImage.MATERIAL_PAYMENTS, FontImage.MATERIAL_MAP, + FontImage.MATERIAL_LOCAL_OFFER, FontImage.MATERIAL_SELL}; + + /// The place that was open when the window stopped being wide, or the + /// device was turned: where the console opens when it is wide again. + private static String resume = HOME; + + private final Form form = new Form("", new BorderLayout()); + private final Container side; + private final Container body = new Container(new BorderLayout()); + private final Button[] items = new Button[PLACES.length]; + private final Wide panes = new Wide(); + private final Dashboard dashboard; + /// Whether the window, and not the app, has the title bar and the menu. + private final boolean window; + private String place = HOME; + + Console() { + form.setUIID("SvForm"); + form.setName("Admin"); + Ui.watch(form); + form.setScrollable(false); + window = CN.isDesktop() && form.getToolbar().getParent() == null; + if (!window) { + // Everything a title bar would say is down the side. + form.getToolbar().hideToolbar(); + } + dashboard = new Dashboard(true, new Dashboard.Places() { + @Override + public void applications() { + go("applications"); + } + + @Override + public void flagged() { + mark("users"); + UsersForm.show(panes, UsersForm.FLAGGED); + } + }); + side = side(); + Container screen = new Container(new BorderLayout()); + screen.setSafeArea(true); + screen.add(BorderLayout.WEST, side); + screen.add(BorderLayout.CENTER, body); + form.add(BorderLayout.CENTER, screen); + if (window) { + menus(); + } + form.addShowListener(e -> { + if (!Layouts.wide()) { + // It was left wide, for the map or the settings, and the + // window was made narrow while it was away. + narrowed(); + return; + } + if (HOME.equals(place)) { + dashboard.shown(); + } + }); + form.addSizeChangedListener(e -> dashboard.fit()); + Layouts.onChange(form, this::narrowed); + UITimer.timer(POLL_MILLIS, true, form, () -> { + if (HOME.equals(place)) { + dashboard.load(); + } + }); + } + + Form form() { + return form; + } + + /// Fills the console in, at the place it was last on. + void start() { + dashboard.start(); + String to = resume; + resume = HOME; + go(to); + } + + private void narrowed() { + resume = place; + Nav.home(); + } + + /// The place a narrow console should open, having just stopped being a + /// wide one; null when there is none, which is the dashboard. + static String resumed() { + String to = resume; + resume = HOME; + return HOME.equals(to) ? null : to; + } + + /// Opens one of the places. + private void go(String id) { + if (FLEET.equals(id)) { + // A map wants the whole window. + new FleetForm(form).show(); + return; + } + mark(id); + if ("restaurants".equals(id)) { + RestaurantsForm.show(panes); + } else if ("promos".equals(id)) { + PromosForm.show(panes); + } else if ("applications".equals(id)) { + ApplicationsForm.show(panes); + } else if ("users".equals(id)) { + UsersForm.show(panes, ""); + } else if ("deliveries".equals(id)) { + AdminDeliveriesForm.show(panes); + } else if ("payments".equals(id)) { + PaymentsForm.show(panes); + } else if ("pricing".equals(id)) { + PricingForm.show(panes); + } else { + panes.clear(); + body.removeAll(); + body.add(BorderLayout.CENTER, dashboard.page()); + title("Dashboard"); + Ui.viewed("Admin"); + panes.refresh(); + if (form.isVisible() && CN.getCurrentForm() == form) { + dashboard.shown(); + } + } + } + + /// Shows `id` as the place that is open. + private void mark(String id) { + place = id; + for (int iter = 0; iter < items.length; iter++) { + items[iter].setUIID(PLACES[iter][0].equals(id) ? "SvSidePicked" : "SvSideItem"); + } + Layouts.dress(side); + // An icon is drawn in the colour of the style it was made for. + for (int iter = 0; iter < items.length; iter++) { + Ui.icon(items[iter], ICONS[iter], 3.4f); + } + } + + private void title(String title) { + if (window) { + form.setTitle(Lang.tr(title)); + } + } + + private Container side() { + Container panel = new Container(new BorderLayout()) { + @Override + protected Dimension calcPreferredSize() { + Dimension size = super.calcPreferredSize(); + size.setWidth(CN.convertToPixels(SIDE_MM)); + return size; + } + }; + panel.setUIID("SvSide"); + panel.setName("side"); + UserDto user = Session.user(); + Container who = new Container(BoxLayout.y()); + who.add(Ui.plain(user.displayName(), "SvSideName")); + who.add(Ui.plain(user.username(), "SvSideDetail")); + + Container list = new Container(BoxLayout.y()); + list.setScrollableY(true); + list.setScrollVisible(false); + for (int iter = 0; iter < PLACES.length; iter++) { + final String id = PLACES[iter][0]; + items[iter] = entry(ICONS[iter], PLACES[iter][1], "nav-" + id, () -> go(id)); + list.add(items[iter]); + } + // The other modes this account's roles open. Everyone may order. + list.add(Ui.label("Switch to", "SvSideLabel")); + list.add(entry(FontImage.MATERIAL_RESTAURANT, "Ordering", "switch-" + Nav.CUSTOMER, + () -> Nav.switchTo(Nav.CUSTOMER))); + if (user.courier()) { + list.add(entry(FontImage.MATERIAL_TWO_WHEELER, "Delivering", "switch-" + Nav.COURIER, + () -> Nav.switchTo(Nav.COURIER))); + } + + Container bottom = new Container(BoxLayout.y()); + bottom.add(entry(FontImage.MATERIAL_SETTINGS, "Settings", "menu-settings", + () -> SettingsForm.show(form))); + bottom.add(entry(FontImage.MATERIAL_LOGOUT, "Sign out", "menu-signOut", Nav::signOut)); + + panel.add(BorderLayout.NORTH, who); + panel.add(BorderLayout.CENTER, list); + panel.add(BorderLayout.SOUTH, bottom); + return panel; + } + + private static Button entry(char icon, String text, String name, final Runnable go) { + Button entry = new Button(text, "SvSideItem"); + entry.setName(name); + Ui.icon(entry, icon, 3.4f); + entry.addActionListener(e -> go.run()); + return entry; + } + + /// The window's menu bar: the places under Go, each with a key, and + /// Settings and signing out where each platform keeps them. Called only + /// where the window has a menu bar, which is where commands put in the + /// title bar's side menu are shown. + private void menus() { + for (int iter = 0; iter < PLACES.length; iter++) { + final String id = PLACES[iter][0]; + Command go = Command.create(Lang.tr(PLACES[iter][1]), null, e -> go(id)); + go.setDesktopMenu(Lang.tr("Go")); + go.setDesktopShortcut((char) ('1' + iter)); + form.getToolbar().addCommandToSideMenu(go); + } + Command settings = Command.create(Lang.tr("Settings"), null, + e -> SettingsForm.show(form)); + settings.setDesktopMenu(Command.DESKTOP_MENU_PREFERENCES); + settings.setDesktopShortcut(','); + form.getToolbar().addCommandToSideMenu(settings); + Command signOut = Command.create(Lang.tr("Sign out"), null, e -> Nav.signOut()); + signOut.setDesktopMenu(Command.DESKTOP_MENU_FILE); + form.getToolbar().addCommandToSideMenu(signOut); + } + + /// One thing shown in the detail's place. + private static final class Shown { + final String title; + final String name; + final Component content; + + Shown(String title, String name, Component content) { + this.title = title; + this.name = name; + this.content = content; + } + } + + /// The panes of a wide screen: the list on the left of the room, and what + /// is opened from it on the right. + private final class Wide extends Panes { + private final List open = new ArrayList(); + private final Container detail = new Container(new BorderLayout()); + private Runnable shown; + private Component picked; + + void clear() { + open.clear(); + shown = null; + picked = null; + } + + @Override + void list(String title, String name, Component content, Runnable shown) { + clear(); + this.shown = shown; + Container master = new Container(new BorderLayout()) { + @Override + protected Dimension calcPreferredSize() { + Dimension size = super.calcPreferredSize(); + // A list is as wide as a phone, and never most of the room. + size.setWidth(Math.min(CN.convertToPixels(MASTER_MM), + body.getWidth() > 0 ? body.getWidth() / 2 : Integer.MAX_VALUE)); + return size; + } + }; + master.setUIID("SvMaster"); + master.setName(name); + master.add(BorderLayout.CENTER, content); + body.removeAll(); + body.add(BorderLayout.WEST, master); + body.add(BorderLayout.CENTER, detail); + title(title); + Ui.viewed(name); + show(); + shown.run(); + } + + @Override + void page(String title, String name, Component content) { + clear(); + open.add(new Shown(title, name, content)); + body.removeAll(); + body.add(BorderLayout.CENTER, detail); + title(title); + show(); + } + + @Override + void detail(String title, String name, Component content) { + open.clear(); + over(title, name, content); + } + + @Override + void over(String title, String name, Component content) { + open.add(new Shown(title, name, content)); + show(); + } + + @Override + void back() { + if (!open.isEmpty()) { + open.remove(open.size() - 1); + } + show(); + } + + @Override + void home() { + open.clear(); + picked = null; + show(); + if (shown != null) { + shown.run(); + } + } + + @Override + void refresh() { + Layouts.dress(form); + Ui.refresh(form); + } + + @Override + Form form() { + return form; + } + + @Override + void picked(Component row) { + if (picked != null) { + picked.setUIID("SvRow"); + } + picked = row; + row.setUIID("SvRowPicked"); + } + + /// Puts what is on top in the detail's place, or says that nothing is + /// picked. + private void show() { + detail.removeAll(); + if (open.isEmpty()) { + Container none = Ui.empty(FontImage.MATERIAL_TOUCH_APP, "Nothing selected", + "Pick a row from the list to see it here."); + none.setName("nothing"); + detail.add(BorderLayout.CENTER, none); + } else { + Shown top = open.get(open.size() - 1); + Ui.viewed(top.name); + Container bar = new Container(new BorderLayout()); + bar.setUIID("SvPaneBar"); + // Something shown over the detail is left the way it is on a + // phone: back. The detail itself has nowhere to go back to. + if (open.size() > 1) { + Button back = Ui.flat(FontImage.MATERIAL_ARROW_BACK, "pane-back", + e -> back()); + bar.add(BorderLayout.WEST, back); + } + bar.add(BorderLayout.CENTER, Ui.label(top.title, "SvPaneTitle")); + Container holder = new Container(new BorderLayout()); + holder.setName(top.name); + // It may still be in the holder it was shown in before + // something was put over it. + top.content.remove(); + holder.add(BorderLayout.CENTER, top.content); + detail.add(BorderLayout.NORTH, bar); + detail.add(BorderLayout.CENTER, holder); + } + refresh(); + } + } +} diff --git a/scripts/savor/common/src/main/java/com/codenameone/examples/savor/admin/Dashboard.java b/scripts/savor/common/src/main/java/com/codenameone/examples/savor/admin/Dashboard.java new file mode 100644 index 00000000000..eebfa35f219 --- /dev/null +++ b/scripts/savor/common/src/main/java/com/codenameone/examples/savor/admin/Dashboard.java @@ -0,0 +1,364 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.admin; + +import com.codename1.ui.Button; +import com.codename1.ui.CN; +import com.codename1.ui.Container; +import com.codename1.ui.FontImage; +import com.codename1.ui.Form; +import com.codename1.ui.Label; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.layouts.FlowLayout; +import com.codename1.ui.layouts.GridLayout; +import com.codenameone.examples.savor.api.AdminStatsDto; +import com.codenameone.examples.savor.api.DayStatsDto; +import com.codenameone.examples.savor.api.CourierDto; +import com.codenameone.examples.savor.api.NameCountDto; +import com.codenameone.examples.savor.api.StatsSeriesDto; +import com.codenameone.examples.savor.live.LiveChannel; +import com.codenameone.examples.savor.net.Api; +import com.codenameone.examples.savor.net.Net; +import com.codenameone.examples.savor.ui.Chart; +import com.codenameone.examples.savor.ui.Lang; +import com.codenameone.examples.savor.ui.Layouts; +import com.codenameone.examples.savor.ui.StatRow; +import com.codenameone.examples.savor.ui.Ui; +import java.util.List; + +/// The numbers of the admin console: what needs an answer, how the service is +/// doing right now, and how it has done over the last weeks. +/// +/// It is the same on a phone and on a wide screen, laid out for the room there +/// is. On a phone everything is one column, with the four numbers two by two. +/// On a wide screen the numbers are one row and the charts are side by side, +/// in as many columns as fit -- see [#fit()], which is what a resized window +/// calls. +final class Dashboard extends LiveChannel.Adapter { + /// A chart is not drawn narrower than this, in millimetres. + private static final float TILE_MM = 88f; + + /// Where the things that want attention lead. + interface Places { + void applications(); + + void flagged(); + } + + private final boolean wide; + private final Places places; + private final Container page; + private final Container alerts = new Container(BoxLayout.y()); + private final StatRow numbers; + private final Container series = new Container(BoxLayout.y()); + private int days = 14; + private int columns; + private StatsSeriesDto last; + + /// @param wide whether to lay out for a wide screen + Dashboard(boolean wide, Places places) { + this.wide = wide; + this.places = places; + numbers = Ui.stats(wide ? 4 : 2); + if (wide) { + // Not Ui.page(): that keeps to a column, and this is the one page + // that is meant to use the width. + page = new Container(BoxLayout.y()); + page.setUIID("SvPage"); + page.setScrollableY(true); + page.setScrollVisible(false); + } else { + page = Ui.page(); + page.setSafeArea(false); + } + page.setName("dashboard"); + alerts.setName("alerts"); + page.add(alerts); + numbers.setName("stats"); + Ui.section(page, "Right now"); + page.add(numbers); + Ui.section(page, "Trends"); + Container range = Ui.choice("range", new String[] {"7 days", "14 days", "30 days"}, + new String[] {"7", "14", "30"}, String.valueOf(days), value -> { + days = Integer.parseInt(value); + loadSeries(); + }); + // On a wide screen the choice is as wide as it needs to be, not the + // whole width of the window. + page.add(wide ? FlowLayout.encloseIn(range) : range); + series.setName("series"); + page.add(series); + } + + /// The page, for the screen to place. + Container page() { + return page; + } + + /// Shows the numbers as nothing, before the first answer. + void start() { + fill(new AdminStatsDto(0, 0, 0, 0, 0, 0, 0L, null)); + } + + /// The page came into view: everything is fetched again. + void shown() { + LiveChannel.listen(this); + load(); + loadSeries(); + } + + @Override + public void deliveryChanged(String id, String state) { + load(); + } + + void load() { + Api.admin().stats(Net.to(this::fill, (status, message) -> { })); + } + + private void loadSeries() { + final int asked = days; + Api.admin().statsSeries(asked, Net.to(found -> { + if (asked == days) { + fill(found); + } + }, (status, message) -> { })); + } + + /// The room changed: the charts are laid out again if a different number + /// of them now fits side by side. + void fit() { + if (wide && last != null && columns != columns()) { + fill(last); + } + } + + private int columns() { + if (!wide) { + return 1; + } + int width = page.getWidth() > 0 ? page.getWidth() : CN.getDisplayWidth() * 3 / 4; + return Layouts.columns(width, TILE_MM, 3); + } + + private void refresh() { + Layouts.dress(page); + Form form = page.getComponentForm(); + if (form != null) { + Ui.refresh(form); + } + } + + private void fill(AdminStatsDto stats) { + numbers.clear(); + numbers.stat(String.valueOf(stats.ordersToday()), "Orders today", "ordersToday"); + numbers.stat(String.valueOf(stats.ordersActive()), "Under way", "ordersActive"); + numbers.stat(stats.couriersOnline() + " / " + stats.couriers(), "Couriers online", + "couriersOnline"); + numbers.stat(Ui.money(stats.revenueCents(), stats.currency()), "Orders paid", + "revenue"); + refresh(); + } + + private void fill(StatsSeriesDto found) { + last = found; + alerts.removeAll(); + if (found.pendingApplications() > 0) { + alerts.add(alert(FontImage.MATERIAL_ASSIGNMENT_IND, found.pendingApplications() == 1 + ? Lang.tr("1 application to review") + : Lang.tr("{0} applications to review", + String.valueOf(found.pendingApplications())), "alert-applications", + places::applications)); + } + if (found.flaggedUsers() > 0) { + alerts.add(alert(FontImage.MATERIAL_FLAG, found.flaggedUsers() == 1 + ? Lang.tr("1 flagged account") : Lang.tr("{0} flagged accounts", + String.valueOf(found.flaggedUsers())), "alert-flagged", + places::flagged)); + } + series.removeAll(); + int count = found.days() == null ? 0 : found.days().size(); + double[] orders = new double[count]; + double[] revenue = new double[count]; + int placed = 0; + int delivered = 0; + int cancelled = 0; + long taken = 0; + int joined = 0; + for (int iter = 0; iter < count; iter++) { + DayStatsDto day = found.days().get(iter); + orders[iter] = day.orders(); + revenue[iter] = day.revenue(); + placed += day.orders(); + delivered += day.delivered(); + cancelled += day.cancelled(); + taken += day.revenue(); + joined += day.newUsers(); + } + + StatRow rates = Ui.stats(3); + rates.stat(Math.round(found.completionRate() * 100d) + "%", "Delivered", + "completionRate"); + rates.stat(Ui.money(found.averageOrder(), found.currency()), "Average order", + "averageOrder"); + rates.stat(Ui.minutes(found.averageDeliveryMinutes() * 60d), "Average delivery", + "averageDelivery"); + + // One column on a phone, in the order they are added. On a wide screen + // as many columns as fit, dealt out in turn: each tile keeps its own + // height, which a grid of tiles would not let it. + columns = columns(); + Container[] column = new Container[columns]; + if (columns == 1) { + column[0] = series; + } else { + series.add(rates); + Container grid = new Container(new GridLayout(1, columns)); + for (int iter = 0; iter < columns; iter++) { + column[iter] = new Container(BoxLayout.y()); + grid.add(column[iter]); + } + series.add(grid); + } + int next = 0; + String from = Lang.tr("{0} days ago", String.valueOf(Math.max(1, count - 1))); + String to = Lang.tr("Today"); + column[next++ % columns].add(tile("Orders", String.valueOf(placed), "seriesOrders", + Lang.tr("{0} delivered, {1} cancelled", String.valueOf(delivered), + String.valueOf(cancelled)), + Chart.of("chartOrders", orders, from, to))); + column[next++ % columns].add(tile("Sales", Ui.money(taken, found.currency()), + "seriesRevenue", Lang.tr("{0} new accounts", String.valueOf(joined)), + Chart.of("chartRevenue", revenue, from, to))); + if (columns == 1) { + series.add(rates); + } + + int hours = found.byHour() == null ? 0 : found.byHour().size(); + double[] hourly = new double[hours]; + for (int iter = 0; iter < hours; iter++) { + Integer value = found.byHour().get(iter); + hourly[iter] = value == null ? 0 : value.intValue(); + } + column[next++ % columns].add(tile("Orders by hour", null, null, + "The busy hours, in UTC", Chart.of("chartHours", hourly, "00:00", "23:00"))); + column[next++ % columns].add(shares("By outcome", found.byState(), true)); + column[next++ % columns].add(shares("Busiest restaurants", found.byRestaurant(), false)); + + if (found.topCouriers() != null && !found.topCouriers().isEmpty()) { + Container top = box("Top couriers"); + for (int iter = 0; iter < found.topCouriers().size(); iter++) { + CourierDto courier = found.topCouriers().get(iter); + Container row = Ui.row((char) 0, courier.displayName(), courier.vehicle(), + Ui.plain(Lang.tr("{0} deliveries", String.valueOf(courier.deliveries())), + "SvRowValue")); + row.add(BorderLayout.WEST, FlowLayout.encloseCenterMiddle( + Ui.avatar(courier.displayName()))); + top.add(row); + } + column[next++ % columns].add(top); + } + refresh(); + } + + /// Something that wants the admin's attention, as a button that goes to it. + private static Button alert(char icon, String text, String name, final Runnable go) { + Button alert = new Button("", "SvAlert"); + alert.setShouldLocalize(false); + alert.setText(text); + alert.setName(name); + Ui.icon(alert, icon, 3.4f); + alert.addActionListener(e -> go.run()); + return alert; + } + + private static Container box(String title) { + Container box = new Container(BoxLayout.y()); + box.setUIID("SvTile"); + box.add(Ui.label(title, "SvStatLabel")); + return box; + } + + /// A chart with what it adds up to written over it. + private static Container tile(String title, String value, String name, String detail, + Container chart) { + Container tile = box(title); + if (value != null) { + Label total = Ui.plain(value, "SvChartValue"); + total.setName(name); + tile.add(total); + } + tile.add(Ui.plain(Lang.tr(detail), "SvMuted")); + tile.add(chart); + return tile; + } + + /// An order's state as the admin reads it. The counts are of orders + /// (Statistics), not deliveries, and the customer's wording ("You + /// cancelled it") is not the admin's. Empty for a name it does not know. + static String outcome(String name) { + if ("PLACED".equals(name)) { + return Lang.tr("Waiting for the restaurant"); + } + if ("ACCEPTED".equals(name)) { + return Lang.tr("Being prepared"); + } + if ("READY".equals(name)) { + return Lang.tr("Ready, waiting for the courier"); + } + if ("PICKED_UP".equals(name)) { + return Lang.tr("On its way"); + } + if ("DELIVERED".equals(name)) { + return Lang.tr("Delivered"); + } + if ("REJECTED".equals(name)) { + return Lang.tr("Rejected"); + } + if ("CANCELLED_BY_CUSTOMER".equals(name)) { + return Lang.tr("Cancelled by customer"); + } + if ("CANCELLED_BY_ADMIN".equals(name)) { + return Lang.tr("Cancelled by Savor"); + } + if ("NO_COURIERS".equals(name)) { + return Lang.tr("No courier was free"); + } + return ""; + } + + /// How a total divides: each part with its count and its share as a bar. + private static Container shares(String title, List parts, boolean states) { + Container tile = box(title); + long total = 0; + for (int iter = 0; parts != null && iter < parts.size(); iter++) { + total += parts.get(iter).count(); + } + for (int iter = 0; parts != null && iter < parts.size(); iter++) { + NameCountDto part = parts.get(iter); + String name = states ? outcome(part.name()) : part.name(); + tile.add(Ui.fact(name.length() == 0 ? part.name() : name, + String.valueOf(part.count()))); + // A total of nothing has no shares; the bars are then empty. + tile.add(new Chart.Meter(total == 0 ? 0 : part.count() / (double) total)); + } + return tile; + } +} diff --git a/scripts/savor/common/src/main/java/com/codenameone/examples/savor/admin/FleetForm.java b/scripts/savor/common/src/main/java/com/codenameone/examples/savor/admin/FleetForm.java new file mode 100644 index 00000000000..d29504f6e7b --- /dev/null +++ b/scripts/savor/common/src/main/java/com/codenameone/examples/savor/admin/FleetForm.java @@ -0,0 +1,144 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.admin; + +import com.codename1.maps.LatLng; +import com.codename1.ui.Command; +import com.codename1.ui.FontImage; +import com.codename1.ui.Form; +import com.codename1.ui.Label; +import com.codenameone.examples.savor.api.CourierDto; +import com.codenameone.examples.savor.live.LiveChannel; +import com.codenameone.examples.savor.map.CarMarker; +import com.codenameone.examples.savor.map.Locator; +import com.codenameone.examples.savor.map.MapStage; +import com.codenameone.examples.savor.map.Maps; +import com.codenameone.examples.savor.net.Api; +import com.codenameone.examples.savor.net.Net; +import com.codenameone.examples.savor.ui.Lang; +import com.codenameone.examples.savor.ui.Ui; +import com.codename1.ui.util.UITimer; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/// Every courier online, on one map, moving as the couriers report in. +public class FleetForm extends LiveChannel.Adapter { + private static final int POLL_MILLIS = 10000; + + private final Form form = Ui.home("Fleet"); + private final MapStage stage; + private final Label count = Ui.label("", "SvHeading"); + private final Map cars = new HashMap(); + + FleetForm(final Form previous) { + // No title bar to carry the back arrow, so the round button over the + // map is it -- and the screen's back command too, which is what the + // device's own back button and gesture run. + Command back = Command.create("", null, e -> previous.showBack()); + form.setBackCommand(back); + stage = new MapStage(Locator.here(), Maps.CITY_ZOOM, + Ui.round(FontImage.MATERIAL_ARROW_BACK, "back", e -> previous.showBack())); + stage.header(Ui.label("Live map", "SvMapTitle")); + stage.addRound(FontImage.MATERIAL_ZOOM_OUT_MAP, "recentre", this::frame); + count.setName("fleetCount"); + stage.sheet().peek().add(count); + stage.sheet().peek().add(Ui.label("Couriers move as they report in.", "SvMuted")); + stage.sheet().ready(false); + form.add(stage.layers()); + form.addShowListener(e -> { + LiveChannel.listen(this); + load(); + }); + // The channel moves the cars; this catches the one whose courier went + // quiet without saying so. + UITimer.timer(POLL_MILLIS, true, form, this::load); + } + + void show() { + counted(); + form.show(); + } + + private void load() { + Api.admin().couriers(Net.to(this::fill, Ui::fail)); + } + + private void fill(List couriers) { + Map gone = new HashMap(cars); + for (int iter = 0; couriers != null && iter < couriers.size(); iter++) { + CourierDto courier = couriers.get(iter); + if (courier.online()) { + gone.remove(courier.username()); + place(courier.username(), courier.lat(), courier.lng(), courier.heading()); + } + } + for (Map.Entry left : gone.entrySet()) { + left.getValue().remove(); + cars.remove(left.getKey()); + } + counted(); + } + + @Override + public void fleetChanged(String username, boolean online, double lat, double lng, + double heading) { + if (online) { + place(username, lat, lng, heading); + } else { + CarMarker car = cars.remove(username); + if (car != null) { + car.remove(); + } + } + counted(); + } + + private void place(String username, double lat, double lng, double heading) { + LatLng at = new LatLng(lat, lng); + CarMarker car = cars.get(username); + if (car == null) { + cars.put(username, new CarMarker(stage.map, form, at, heading)); + } else { + car.moveTo(at, heading); + } + } + + /// Moves the camera to take in every car there is. + private void frame() { + List all = new ArrayList(); + for (CarMarker car : cars.values()) { + all.add(car.position()); + } + if (all.isEmpty()) { + stage.map.moveCamera(Locator.here(), Maps.CITY_ZOOM); + } else { + stage.frame(all.toArray(new LatLng[all.size()])); + } + } + + private void counted() { + int size = cars.size(); + count.setText(size == 0 ? Lang.tr("No couriers online") : size == 1 + ? Lang.tr("1 courier online") : Lang.tr("{0} couriers online", String.valueOf(size))); + stage.map.repaint(); + Ui.refresh(form); + } +} diff --git a/scripts/savor/common/src/main/java/com/codenameone/examples/savor/admin/Panes.java b/scripts/savor/common/src/main/java/com/codenameone/examples/savor/admin/Panes.java new file mode 100644 index 00000000000..2c5a5e3d564 --- /dev/null +++ b/scripts/savor/common/src/main/java/com/codenameone/examples/savor/admin/Panes.java @@ -0,0 +1,170 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.admin; + +import com.codename1.ui.Component; +import com.codename1.ui.Container; +import com.codename1.ui.Form; +import com.codename1.ui.layouts.BorderLayout; +import com.codenameone.examples.savor.ui.Layouts; +import com.codenameone.examples.savor.ui.Nav; +import com.codenameone.examples.savor.ui.Ui; +import java.util.ArrayList; +import java.util.List; + +/// Where a part of the admin console shows what it has. +/// +/// Every part is a list and what is opened from it: an application, an +/// account, a delivery. On a phone each of those is a screen of its own, reached +/// from the one before and left with the back arrow. On a wide screen the list +/// stays where it is and what is opened from it is shown beside it. The parts +/// are written once, against this, and do not know which they are in: +/// +/// - [#list] shows the list. +/// - [#detail] shows what was picked from it, in place of whatever was open. +/// - [#over] shows something on top of that: a document, the question "why?". +/// - [#back] leaves the last of those; [#home] leaves all of them. +/// +/// [#phone(Form)] is the phone's. The wide one is [Console]'s. +abstract class Panes { + /// Shows a part's list. + /// + /// @param name what a test waits for + /// @param shown run each time the list comes into view, to fetch what is in it + abstract void list(String title, String name, Component content, Runnable shown); + + /// Shows a part that is one page and has nothing to open: the prices. + abstract void page(String title, String name, Component content); + + /// Shows what was picked from the list, in place of whatever was open. + abstract void detail(String title, String name, Component content); + + /// Shows `content` on top of what is open. + abstract void over(String title, String name, Component content); + + /// Leaves what is on top. + abstract void back(); + + /// Back to the list alone, which is fetched again. + abstract void home(); + + /// Lays out again what is showing, after it changed. + abstract void refresh(); + + /// The screen all of this is on, for something that needs one to return to. + abstract Form form(); + + /// Marks `row` as the one that is open, where the list stays in view. + void picked(Component row) { + } + + /// Asks why, on top of what is open. See [Ui#reason]. + void reason(String title, String text, String action, Ui.Reason reason) { + over(title, "Reason", Ui.reasonPage(text, action, reason)); + } + + /// The panes of a phone: a screen each. + /// + /// @param previous where the back arrow of the list leads + static Panes phone(final Form previous) { + return new Panes() { + private final List
screens = new ArrayList(); + + @Override + void list(String title, String name, Component content, final Runnable shown) { + Form form = Ui.form(title, name); + Ui.back(form, previous); + form.add(BorderLayout.CENTER, content); + form.addShowListener(e -> shown.run()); + watch(form); + screens.clear(); + screens.add(form); + form.show(); + } + + @Override + void page(String title, String name, Component content) { + list(title, name, content, () -> { }); + } + + @Override + void detail(String title, String name, Component content) { + while (screens.size() > 1) { + screens.remove(screens.size() - 1); + } + over(title, name, content); + } + + @Override + void over(String title, String name, Component content) { + Form form = Ui.form(title, name); + Ui.back(form, this::back); + form.add(BorderLayout.CENTER, content); + watch(form); + screens.add(form); + form.show(); + } + + /// A screen made for a narrow window is of no use to a wide one: + /// the console is built again, as the wide one. And on a desktop + /// a narrow window still wears the desktop's look. + private void watch(Form form) { + Layouts.dress(form); + Layouts.onChange(form, Nav::home); + } + + @Override + void back() { + if (screens.size() > 1) { + screens.remove(screens.size() - 1); + } + form().showBack(); + } + + @Override + void home() { + while (screens.size() > 1) { + screens.remove(screens.size() - 1); + } + form().showBack(); + } + + @Override + void refresh() { + Layouts.dress(form()); + Ui.refresh(form()); + } + + @Override + Form form() { + return screens.get(screens.size() - 1); + } + }; + } + + /// A list with what filters it above it, as the content of [#list]. + static Container listed(Component filters, Component list) { + Container content = new Container(new BorderLayout()); + if (filters != null) { + content.add(BorderLayout.NORTH, filters); + } + content.add(BorderLayout.CENTER, list); + return content; + } +} diff --git a/scripts/savor/common/src/main/java/com/codenameone/examples/savor/admin/PaymentsForm.java b/scripts/savor/common/src/main/java/com/codenameone/examples/savor/admin/PaymentsForm.java new file mode 100644 index 00000000000..7f3303e5450 --- /dev/null +++ b/scripts/savor/common/src/main/java/com/codenameone/examples/savor/admin/PaymentsForm.java @@ -0,0 +1,96 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.admin; + +import com.codename1.ui.Container; +import com.codename1.ui.FontImage; +import com.codename1.ui.Form; +import com.codename1.ui.layouts.BoxLayout; +import com.codenameone.examples.savor.Telemetry; +import com.codenameone.examples.savor.api.ReasonDto; +import com.codenameone.examples.savor.api.ReceiptDto; +import com.codenameone.examples.savor.net.Api; +import com.codenameone.examples.savor.net.Net; +import com.codenameone.examples.savor.pay.ReceiptForm; +import com.codenameone.examples.savor.ui.Lang; +import com.codenameone.examples.savor.ui.Ui; +import java.util.List; + +/// What was charged, newest first, and for each whether it went through. A +/// payment opens as its receipt, which is where an admin returns the money. +public final class PaymentsForm { + private PaymentsForm() { + } + + static void show(Form previous) { + show(Panes.phone(previous)); + } + + static void show(final Panes panes) { + final Container list = Ui.list("payments"); + final Runnable[] load = new Runnable[1]; + load[0] = () -> Api.admin().payments(Net.to(found -> fill(panes, list, found), + Ui.failed(list, () -> load[0].run()))); + panes.list("Payments", "Payments", Panes.listed(null, list), load[0]); + } + + /// A payment, opened: its receipt, and the way to return the money. + private static void open(final Panes panes, final ReceiptDto payment) { + Container page = ReceiptForm.page(payment, true); + if ("paid".equals(payment.status())) { + page.add(Ui.danger("Refund this order", "refund", e -> panes.reason("Refund", + Lang.tr("{0} goes back to the card it came from.", + Ui.money(payment.total(), payment.currency())), "Refund", why -> { + ReasonDto reason = new ReasonDto(why); + Api.admin().refundOrder(payment.orderId(), reason, Net.to(refunded -> { + Telemetry.deliveryRefunded("payments"); + open(panes, refunded); + }, Ui::fail)); + }))); + } + panes.detail("Receipt", "Receipt", page); + } + + private static void fill(final Panes panes, Container list, List payments) { + list.removeAll(); + if (payments == null || payments.isEmpty()) { + list.add(Ui.empty(FontImage.MATERIAL_PAYMENTS, "No payments yet", + "A delivery shows up here when it is paid for.")); + } + for (int iter = 0; payments != null && iter < payments.size(); iter++) { + final ReceiptDto payment = payments.get(iter); + Container value = new Container(BoxLayout.y()); + value.add(Ui.plain(Ui.money(payment.total(), payment.currency()), "SvRowValue")); + value.add(Ui.badge(ReceiptForm.status(payment.status()), + ReceiptForm.badge(payment.status()))); + String who = payment.customerUsername() == null ? "" : payment.customerUsername(); + String how = "cash".equals(payment.status()) ? Lang.tr("Cash") : payment.methodLabel(); + final Container picked = Ui.row("cash".equals(payment.status()) + ? FontImage.MATERIAL_PAYMENTS : FontImage.MATERIAL_CREDIT_CARD, + payment.dropoffName(), + who + (who.length() > 0 && how != null && how.length() > 0 ? Ui.DOT : "") + + (how == null ? "" : how), value); + list.add(Ui.tap(picked, "payment-" + iter, e -> { + panes.picked(picked); + open(panes, payment); + })); + } + panes.refresh(); + } +} diff --git a/scripts/savor/common/src/main/java/com/codenameone/examples/savor/admin/PricingForm.java b/scripts/savor/common/src/main/java/com/codenameone/examples/savor/admin/PricingForm.java new file mode 100644 index 00000000000..99d940a6f81 --- /dev/null +++ b/scripts/savor/common/src/main/java/com/codenameone/examples/savor/admin/PricingForm.java @@ -0,0 +1,165 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.admin; + +import com.codename1.components.SpanLabel; +import com.codename1.ui.Button; +import com.codename1.ui.Container; +import com.codename1.ui.Form; +import com.codename1.ui.TextArea; +import com.codename1.ui.TextField; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.layouts.GridLayout; +import com.codenameone.examples.savor.Telemetry; +import com.codenameone.examples.savor.api.PricingDto; +import com.codenameone.examples.savor.net.Api; +import com.codenameone.examples.savor.net.Net; +import com.codenameone.examples.savor.ui.Lang; +import com.codenameone.examples.savor.ui.Ui; + +/// What a delivery costs: the parts of a fare, the service's share, and what the +/// roomier cars and the busy hours multiply it by. +/// +/// A change is for the deliveries asked for from then on; a delivery already quoted +/// keeps its price. The server refuses numbers that could only be a mistake, +/// and says which. +public final class PricingForm { + private PricingForm() { + } + + static void show(final Form previous) { + show(Panes.phone(previous)); + } + + static void show(final Panes panes) { + final Container page = Ui.page(); + panes.page("Pricing", "Pricing", page); + Api.admin().pricing(Net.to(pricing -> fill(panes, page, pricing), Ui::fail)); + } + + private static void fill(final Panes panes, final Container page, final PricingDto pricing) { + page.removeAll(); + Ui.section(page, Lang.tr("Delivery, in {0}", pricing.currency())); + Container pair = new Container(new GridLayout(1, 2)); + Container left = side("SvPairLeft"); + Container right = side("SvPairRight"); + final TextField base = money(left, "Delivery fee", "baseCents", pricing.baseCents(), null); + final TextField perKm = money(right, "Per kilometre", "perKmCents", pricing.perKmCents(), + null); + pair.add(left).add(right); + page.add(pair); + final TextField surge = number(page, "Surge, while demand is high", "surge", + pricing.surge()); + page.add(Ui.text("The delivery fee is the courier's, all of it.", "SvMuted")); + + Ui.section(page, "Small orders"); + pair = new Container(new GridLayout(1, 2)); + left = side("SvPairLeft"); + right = side("SvPairRight"); + final TextField small = money(left, "Below", "smallOrderCents", pricing.smallOrderCents(), + null); + final TextField smallFee = money(right, "Fee", "smallOrderFeeCents", + pricing.smallOrderFeeCents(), null); + pair.add(left).add(right); + page.add(pair); + + Ui.section(page, "The service's share"); + pair = new Container(new GridLayout(1, 2)); + left = side("SvPairLeft"); + right = side("SvPairRight"); + final TextField fee = number(left, "Service fee, %", "serviceFeePercent", + pricing.serviceFeePercent()); + final TextField commission = number(right, "Commission, %", "commissionPercent", + pricing.commissionPercent()); + pair.add(left).add(right); + page.add(pair); + final TextField tax = number(page, "Tax, %", "taxPercent", pricing.taxPercent()); + page.add(Ui.text("The service fee is a share of the food, paid by the customer. The " + + "commission is kept from what the restaurant is paid.", "SvMuted")); + + final SpanLabel error = Ui.text("", "SvError"); + error.setName("error"); + final Button save = Ui.primary("Save prices", "submit", null); + save.addActionListener(e -> { + PricingDto wanted = new PricingDto(pricing.currency(), cents(base), cents(perKm), + value(surge), cents(small), cents(smallFee), value(fee), value(tax), + value(commission)); + save.setEnabled(false); + error.setText(""); + Api.admin().savePricing(wanted, Net.to(saved -> { + Telemetry.pricingChanged(); + Ui.say("The new prices are in force"); + fill(panes, page, saved); + }, (status, message) -> { + save.setEnabled(true); + error.setText(message); + panes.refresh(); + })); + }); + page.add(error).add(save); + panes.refresh(); + } + + private static Container side(String uiid) { + Container side = new Container(BoxLayout.y()); + side.setUIID(uiid); + return side; + } + + private static TextField money(Container to, String caption, String name, long cents, + String hint) { + TextField field = Ui.field(to, caption, name, hint == null ? "0.00" : hint, + TextArea.DECIMAL); + long part = cents % 100; + field.setText((cents / 100) + "." + (part < 10 ? "0" : "") + part); + return field; + } + + private static TextField number(Container to, String caption, String name, double value) { + TextField field = Ui.field(to, caption, name, "1.0", TextArea.DECIMAL); + // Two decimal places are all a percentage or a multiplier needs. + long hundredths = Math.round(value * 100d); + long part = hundredths % 100; + field.setText((hundredths / 100) + "." + (part < 10 ? "0" : "") + part); + return field; + } + + private static long cents(TextField field) { + return Math.round(value(field) * 100d); + } + + /// The number typed, or -1 for anything that is not one, which the server + /// then refuses by name. + private static double value(TextField field) { + String text = field.getText().trim().replace(',', '.'); + boolean point = false; + if (text.length() == 0 || text.length() > 12) { + return -1; + } + for (int iter = 0; iter < text.length(); iter++) { + char letter = text.charAt(iter); + if (letter == '.' && !point) { + point = true; + } else if (letter < '0' || letter > '9') { + return -1; + } + } + return ".".equals(text) ? -1 : Double.parseDouble(text); + } +} diff --git a/scripts/savor/common/src/main/java/com/codenameone/examples/savor/admin/PromosForm.java b/scripts/savor/common/src/main/java/com/codenameone/examples/savor/admin/PromosForm.java new file mode 100644 index 00000000000..ba404873c95 --- /dev/null +++ b/scripts/savor/common/src/main/java/com/codenameone/examples/savor/admin/PromosForm.java @@ -0,0 +1,160 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.admin; + +import com.codename1.ui.Container; +import com.codename1.ui.FontImage; +import com.codename1.ui.Form; +import com.codename1.ui.Label; +import com.codename1.ui.TextArea; +import com.codename1.ui.TextField; +import com.codename1.ui.layouts.BoxLayout; +import com.codenameone.examples.savor.api.PromoDto; +import com.codenameone.examples.savor.net.Api; +import com.codenameone.examples.savor.net.Net; +import com.codenameone.examples.savor.ui.Lang; +import com.codenameone.examples.savor.ui.Ui; + +import java.util.List; + +/// The promo codes a customer can type at checkout: a share off, or an +/// amount off, above a smallest order. A code is switched off, never removed, +/// so the orders that used it still say what it was. +final class PromosForm { + private final Panes panes; + private final Container list = Ui.list("promos"); + + private PromosForm(Panes panes) { + this.panes = panes; + Container top = new Container(BoxLayout.y()); + top.setUIID("SvPage"); + top.add(Ui.secondary("New promo", "new-promo", e -> edit(null))); + Ui.pull(list, this::load); + panes.list("Promos", "Promos", Panes.listed(top, list), this::load); + } + + static void show(Form previous) { + show(Panes.phone(previous)); + } + + static void show(Panes panes) { + new PromosForm(panes); + } + + private void load() { + Api.admin().promos(Net.to(this::fill, Ui.failed(list, this::load))); + } + + private void fill(List found) { + list.removeAll(); + if (found == null || found.isEmpty()) { + list.add(Ui.empty(FontImage.MATERIAL_SELL, "No promos", null)); + } + for (int iter = 0; found != null && iter < found.size(); iter++) { + final PromoDto promo = found.get(iter); + Container row = Ui.row(FontImage.MATERIAL_SELL, promo.code(), describe(promo), + Ui.badge(promo.active() ? Lang.tr("On") : Lang.tr("Off"), + promo.active() ? "SvBadgeGood" : "SvBadgeBad")); + final Container picked = row; + list.add(Ui.tap(row, "promo-" + promo.code(), e -> { + panes.picked(picked); + edit(promo); + })); + } + panes.refresh(); + } + + static String describe(PromoDto promo) { + String off = promo.percentOff() > 0 ? Lang.tr("{0}% off", String.valueOf(promo.percentOff())) + : Lang.tr("{0} off", Ui.money(promo.amountOffCents(), null)); + return promo.minimumCents() > 0 ? off + Ui.DOT + + Lang.tr("over {0}", Ui.money(promo.minimumCents(), null)) : off; + } + + private void edit(final PromoDto promo) { + Container page = Ui.page(); + final TextField code = Ui.field(page, "Code", "promo-code", "WELCOME10"); + final TextField percent = Ui.field(page, "Percent off", "promo-percent", "10"); + percent.setConstraint(TextArea.NUMERIC); + final TextField amount = Ui.field(page, "Or an amount off", "promo-amount", "5.00"); + amount.setConstraint(TextArea.DECIMAL); + final TextField minimum = Ui.field(page, "Smallest order", "promo-minimum", "15.00"); + minimum.setConstraint(TextArea.DECIMAL); + final boolean[] active = {promo == null || promo.active()}; + page.add(Ui.row((char) 0, Lang.tr("Active"), null, Ui.toggle("promo-active", active[0], + value -> active[0] = value))); + if (promo != null) { + code.setText(promo.code()); + code.setEditable(false); + percent.setText(String.valueOf(promo.percentOff())); + amount.setText(amount(promo.amountOffCents())); + minimum.setText(amount(promo.minimumCents())); + } + final Label error = new Label("", "SvError"); + page.add(error); + page.add(Ui.primary("Save", "save-promo", e -> { + int share = number(percent.getText()); + long off = cents(amount.getText()); + String typed = code.getText().trim(); + if (typed.length() == 0 || share < 0 || share > 100 || off < 0 + || share == 0 && off == 0) { + error.setText(Lang.tr("A code, and either a percent or an amount off")); + page.revalidate(); + return; + } + Api.admin().savePromo(new PromoDto(typed, share, share > 0 ? 0 : off, + Math.max(0, cents(minimum.getText())), active[0]), Net.to(saved -> { + Ui.say("Saved"); + panes.home(); + }, Ui::fail)); + })); + panes.detail(promo == null ? Lang.tr("New promo") : promo.code(), "PromoEdit", page); + } + + private static String amount(long cents) { + long part = cents % 100; + return cents / 100 + "." + (part < 10 ? "0" : "") + part; + } + + private static int number(String text) { + try { + return text.trim().length() == 0 ? 0 : Integer.parseInt(text.trim()); + } catch (NumberFormatException wrong) { + return -1; + } + } + + private static long cents(String text) { + String clean = text.trim().replace(',', '.'); + if (clean.length() == 0) { + return 0; + } + int dot = clean.indexOf('.'); + try { + if (dot < 0) { + return Long.parseLong(clean) * 100; + } + String part = (clean.substring(dot + 1) + "00").substring(0, 2); + return Long.parseLong(dot == 0 ? "0" : clean.substring(0, dot)) * 100 + + Long.parseLong(part); + } catch (NumberFormatException wrong) { + return -1; + } + } +} diff --git a/scripts/savor/common/src/main/java/com/codenameone/examples/savor/admin/RestaurantsForm.java b/scripts/savor/common/src/main/java/com/codenameone/examples/savor/admin/RestaurantsForm.java new file mode 100644 index 00000000000..7ece962befa --- /dev/null +++ b/scripts/savor/common/src/main/java/com/codenameone/examples/savor/admin/RestaurantsForm.java @@ -0,0 +1,168 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.admin; + +import com.codename1.ui.Container; +import com.codename1.ui.FontImage; +import com.codename1.ui.Form; +import com.codename1.ui.Label; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.layouts.FlowLayout; +import com.codenameone.examples.savor.api.ReasonDto; +import com.codenameone.examples.savor.api.RestaurantDto; +import com.codenameone.examples.savor.api.SuspendDto; +import com.codenameone.examples.savor.customer.Food; +import com.codenameone.examples.savor.net.Api; +import com.codenameone.examples.savor.net.Net; +import com.codenameone.examples.savor.ui.Lang; +import com.codenameone.examples.savor.ui.Ui; + +import java.util.List; + +/// The restaurants on the platform: the ones asking to join, to approve or +/// send back, and the ones selling, to suspend or let back. +final class RestaurantsForm { + private final Panes panes; + private final Container list = Ui.list("restaurants"); + private String status = "PENDING"; + + private RestaurantsForm(Panes panes) { + this.panes = panes; + Container top = new Container(BoxLayout.y()); + top.setUIID("SvPage"); + top.add(Ui.choice("restaurant-status", + new String[] {"Waiting", "Selling", "Suspended", "Sent back"}, + new String[] {"PENDING", "APPROVED", "SUSPENDED", "REJECTED"}, status, value -> { + status = value; + load(); + })); + Ui.pull(list, this::load); + panes.list("Restaurants", "Restaurants", Panes.listed(top, list), this::load); + } + + static void show(Form previous) { + show(Panes.phone(previous)); + } + + static void show(Panes panes) { + new RestaurantsForm(panes); + } + + private void load() { + final String asked = status; + Api.admin().restaurants(asked, Net.to(found -> { + if (asked.equals(status)) { + fill(found); + } + }, Ui.failed(list, this::load))); + } + + private void fill(List found) { + list.removeAll(); + if (found == null || found.isEmpty()) { + list.add(Ui.empty(FontImage.MATERIAL_STOREFRONT, "Nothing here", + "PENDING".equals(status) ? "Every restaurant has had its answer." : null)); + } + for (int iter = 0; found != null && iter < found.size(); iter++) { + final RestaurantDto restaurant = found.get(iter); + Container row = Ui.row((char) 0, restaurant.name(), + restaurant.cuisine() + Ui.DOT + restaurant.address(), + Ui.badge(word(restaurant.status()), badge(restaurant.status()))); + Label icon = new Label("", "SvRowIcon"); + Ui.icon(icon, Food.icon(restaurant.cuisine()), 4f); + row.add(BorderLayout.WEST, FlowLayout.encloseCenterMiddle(icon)); + final Container picked = row; + list.add(Ui.tap(row, "restaurant-" + restaurant.id(), e -> { + panes.picked(picked); + review(restaurant); + })); + } + panes.refresh(); + } + + static String word(String status) { + if ("PENDING".equals(status)) { + return Lang.tr("Waiting"); + } + if ("APPROVED".equals(status)) { + return Lang.tr("Selling"); + } + if ("SUSPENDED".equals(status)) { + return Lang.tr("Suspended"); + } + return Lang.tr("Sent back"); + } + + static String badge(String status) { + return "APPROVED".equals(status) ? "SvBadgeGood" + : "PENDING".equals(status) ? "SvBadgeWarn" : "SvBadgeBad"; + } + + private void review(final RestaurantDto restaurant) { + Container page = Ui.page(); + Label state = Ui.badge(word(restaurant.status()), badge(restaurant.status())); + state.setName("restaurantStatus"); + page.add(Ui.plain(restaurant.name(), "SvRestaurantTitle")); + page.add(state); + if (restaurant.reason() != null && restaurant.reason().length() > 0) { + page.add(Ui.text(restaurant.reason(), "SvNotice")); + } + Ui.section(page, "Restaurant"); + page.add(Ui.fact("Cuisine", restaurant.cuisine())); + page.add(Ui.fact("Address", restaurant.address())); + page.add(Ui.fact("Hours", hour(restaurant.opensAt()) + " - " + hour(restaurant.closesAt()))); + page.add(Ui.fact("Time to cook", Lang.tr("{0} min", String.valueOf(restaurant.prepMinutes())))); + page.add(Ui.fact("Rating", restaurant.ratingCount() == 0 ? Lang.tr("New") + : Ui.tenths(restaurant.rating()) + " (" + restaurant.ratingCount() + ")")); + if (restaurant.description() != null && restaurant.description().length() > 0) { + page.add(Ui.text(restaurant.description(), "SvMuted")); + } + Net.Ok done = saved -> { + Ui.say(Lang.tr("{0}: {1}", saved.name(), word(saved.status()))); + panes.home(); + }; + if ("PENDING".equals(restaurant.status())) { + page.add(Ui.primary("Approve", "approve-restaurant", + e -> Api.admin().approveRestaurant(restaurant.id(), Net.to(done, Ui::fail)))); + page.add(Ui.danger("Send back", "reject-restaurant", e -> panes.reason("Send back", + Lang.tr("{0} sees what you write, and can apply again once it is put right.", + restaurant.name()), "Send back", + why -> Api.admin().rejectRestaurant(restaurant.id(), new ReasonDto(why), + Net.to(done, Ui::fail))))); + } else if ("APPROVED".equals(restaurant.status())) { + page.add(Ui.danger("Suspend", "suspend-restaurant", e -> panes.reason("Suspend", + Lang.tr("{0} stops appearing to customers until it is let back.", + restaurant.name()), "Suspend", + why -> Api.admin().suspendRestaurant(restaurant.id(), new SuspendDto(true, why), + Net.to(done, Ui::fail))))); + } else if ("SUSPENDED".equals(restaurant.status())) { + page.add(Ui.primary("Let back", "unsuspend-restaurant", + e -> Api.admin().suspendRestaurant(restaurant.id(), new SuspendDto(false, null), + Net.to(done, Ui::fail)))); + } + panes.detail(restaurant.name(), "RestaurantReview", page); + } + + private static String hour(int minutes) { + int hours = minutes / 60; + int rest = minutes % 60; + return (hours < 10 ? "0" : "") + hours + ":" + (rest < 10 ? "0" : "") + rest; + } +} diff --git a/scripts/savor/common/src/main/java/com/codenameone/examples/savor/admin/UsersForm.java b/scripts/savor/common/src/main/java/com/codenameone/examples/savor/admin/UsersForm.java new file mode 100644 index 00000000000..1bb4c8e03e0 --- /dev/null +++ b/scripts/savor/common/src/main/java/com/codenameone/examples/savor/admin/UsersForm.java @@ -0,0 +1,339 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.admin; + +import com.codename1.components.SpanLabel; +import com.codename1.components.Switch; +import com.codename1.ui.Button; +import com.codename1.ui.Container; +import com.codename1.ui.FontImage; +import com.codename1.ui.Form; +import com.codename1.ui.Label; +import com.codename1.ui.TextField; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.layouts.FlowLayout; +import com.codename1.ui.layouts.GridLayout; +import com.codenameone.examples.savor.Telemetry; +import com.codenameone.examples.savor.api.FlagDto; +import com.codenameone.examples.savor.api.ModerationEventDto; +import com.codenameone.examples.savor.api.RoleChangeDto; +import com.codenameone.examples.savor.api.SuspendDto; +import com.codenameone.examples.savor.api.UserDetailDto; +import com.codenameone.examples.savor.api.UserDto; +import com.codenameone.examples.savor.net.Api; +import com.codenameone.examples.savor.net.Net; +import com.codenameone.examples.savor.ui.Lang; +import com.codenameone.examples.savor.ui.StatRow; +import com.codenameone.examples.savor.ui.Ui; +import java.util.List; + +/// The accounts, and what an admin decides about each: its roles, whether it +/// is flagged for attention, and whether it may be used at all. +/// +/// All of it takes effect at once. The server reads an account's roles and its +/// block on every request, not from the token the app is holding, so a courier +/// made an admin has the admin screens the next time their app asks who they +/// are, and a blocked account's next call is refused. +/// +/// A flag and a block are both kept with a reason and with who gave it; the +/// account's screen shows that history, newest first. +public final class UsersForm { + static final String ALL = "all"; + static final String FLAGGED = "flagged"; + private static final String[] FILTERS = {ALL, "customers", "couriers", "admins", FLAGGED, + "blocked"}; + private static final String[] FILTER_NAMES = {"Everyone", "Customers", "Couriers", "Admins", + "Flagged", "Blocked"}; + + private final Panes panes; + private final Container list = Ui.list("users"); + private final TextField search; + private List users; + private String filter; + + private UsersForm(Panes panes, String filter) { + this.panes = panes; + this.filter = filter == null || filter.length() == 0 ? ALL : filter; + Container top = new Container(BoxLayout.y()); + top.setUIID("SvPage"); + search = new TextField("", Lang.tr("Search by name or e-mail"), 24, TextField.ANY); + search.setUIID("SvField"); + search.getHintLabel().setUIID("SvFieldHint"); + search.setName("search"); + search.addDataChangedListener((type, index) -> fill()); + top.add(search); + top.add(Ui.choice("filter", FILTER_NAMES, FILTERS, this.filter, value -> { + this.filter = value; + fill(); + }, 3)); + panes.list("People", "Users", Panes.listed(top, list), + this::load); + } + + private void load() { + Api.admin().users(Net.to(found -> { + users = found; + fill(); + }, Ui.failed(list, this::load))); + } + + /// @param filter one of the filters to open on, or empty for everyone + static void show(Form previous, String filter) { + show(Panes.phone(previous), filter); + } + + static void show(Panes panes, String filter) { + new UsersForm(panes, filter); + } + + private void fill() { + list.removeAll(); + String wanted = search.getText().trim(); + int shown = 0; + for (int iter = 0; users != null && iter < users.size(); iter++) { + final UserDto user = users.get(iter); + if (!passes(user) || !matches(user, wanted)) { + continue; + } + shown++; + Container marks = new Container(new FlowLayout(Label.RIGHT, Label.CENTER)); + if (user.flagged()) { + marks.add(Ui.badge("Flagged", "SvBadgeWarn")); + } + marks.add(Ui.badge(roles(user), user.suspended() ? "SvBadgeBad" : "SvBadge")); + Container row = Ui.row((char) 0, user.displayName(), user.username(), marks); + row.add(BorderLayout.WEST, FlowLayout.encloseCenterMiddle( + Ui.avatar(user.displayName()))); + final Container picked = row; + list.add(Ui.tap(row, "user-" + user.username(), e -> { + panes.picked(picked); + open(user.username()); + })); + } + if (shown == 0 && users != null) { + list.add(Ui.empty(FontImage.MATERIAL_PERSON_SEARCH, "Nobody matches", null)); + } + panes.refresh(); + } + + private boolean passes(UserDto user) { + if (FLAGGED.equals(filter)) { + return user.flagged(); + } + if ("blocked".equals(filter)) { + return user.suspended(); + } + if ("admins".equals(filter)) { + return user.admin(); + } + if ("couriers".equals(filter)) { + return user.courier(); + } + if ("customers".equals(filter)) { + return !user.courier() && !user.admin(); + } + return true; + } + + /// Whether `wanted` is in the name or the address, whatever the case. Both + /// are compared a letter at a time: a name is not a protocol token, but + /// folding it by the device's language would still miss an `I` in Turkish. + private static boolean matches(UserDto user, String wanted) { + return wanted.length() == 0 || contains(user.displayName(), wanted) + || contains(user.username(), wanted); + } + + private static boolean contains(String text, String wanted) { + if (text == null) { + return false; + } + for (int at = 0; at + wanted.length() <= text.length(); at++) { + if (text.regionMatches(true, at, wanted, 0, wanted.length())) { + return true; + } + } + return false; + } + + static String roles(UserDto user) { + if (user.suspended()) { + return "Blocked"; + } + return user.admin() ? "Admin" : user.courier() ? "Courier" : "Customer"; + } + + /// One account in full, fetched each time it is shown. + private void open(final String username) { + Api.admin().user(username, Net.to(detail -> show(detail), Ui::fail)); + } + + private void show(final UserDetailDto detail) { + final UserDto user = detail.user(); + Container page = Ui.page(); + + Label standing = Ui.badge(user.suspended() ? "Blocked" : user.flagged() ? "Flagged" + : "In good standing", user.suspended() ? "SvBadgeBad" : user.flagged() + ? "SvBadgeWarn" : "SvBadgeGood"); + standing.setName("standing"); + page.add(Ui.person(user.displayName(), user.username(), standing)); + if (user.flagged() && user.flagReason() != null && user.flagReason().length() > 0) { + SpanLabel why = Ui.text(user.flagReason(), "SvNotice"); + why.setName("flagReason"); + page.add(why); + } + + StatRow numbers = Ui.stats(3); + numbers.stat(String.valueOf(detail.deliveries()), "Deliveries", "userDeliveries"); + numbers.stat(String.valueOf(detail.cancellations()), "Cancelled", + "userCancellations"); + numbers.stat(detail.rating() > 0 ? Ui.tenths(detail.rating()) : "-", "Rating", + "userRating"); + page.add(numbers); + StatRow money = Ui.stats(2); + money.stat(Ui.money(detail.spent(), null), "Spent on orders", "userSpent"); + money.stat(Ui.money(detail.earned(), null), "Earned delivering", "userEarned"); + page.add(money); + + Ui.section(page, "Contact"); + page.add(Ui.row(FontImage.MATERIAL_PHONE, user.phone(), + user.phoneVerified() ? "Verified" : "Not verified", null)); + if (user.courier()) { + page.add(Ui.row(FontImage.MATERIAL_DIRECTIONS_CAR, user.vehicle(), user.plate(), null)); + } + + Ui.section(page, "Access"); + final Switch courier = toggle("courier", user.courier()); + final Switch admin = toggle("admin", user.admin()); + page.add(Ui.row(FontImage.MATERIAL_DIRECTIONS_CAR, "Courier", + "May go online and take deliveries", courier)); + page.add(Ui.row(FontImage.MATERIAL_INSIGHTS, "Admin", + "May see and change everything here", admin)); + // A change the server refuses -- an admin removing their own role, say + // -- is put back, so the switches always show what is true. Putting + // one back is itself a change, which `settling` keeps from being sent. + final boolean[] settling = new boolean[1]; + // The roles as the server last confirmed them: courier, admin. + final boolean[] held = {user.courier(), user.admin()}; + final Runnable roles = () -> { + if (settling[0]) { + return; + } + RoleChangeDto change = new RoleChangeDto(courier.isValue(), admin.isValue()); + Api.admin().setRoles(user.username(), change, Net.to(saved -> { + held[0] = saved.courier(); + held[1] = saved.admin(); + }, (status, message) -> { + Ui.fail(status, message); + settling[0] = true; + courier.setValue(held[0]); + admin.setValue(held[1]); + settling[0] = false; + })); + }; + courier.addChangeListener(e -> roles.run()); + admin.addChangeListener(e -> roles.run()); + + Ui.section(page, "Moderation"); + page.add(Ui.text("A flag marks the account for the admins and changes nothing for " + + "its owner. A block signs it out and refuses it until lifted.", "SvMuted")); + Container actions = new Container(new GridLayout(1, 2)); + actions.add(pair("SvPairLeft", user.flagged() + ? Ui.secondary("Remove flag", "unflag", e -> flag(user, false, "")) + : Ui.secondary("Flag", "flag", e -> panes.reason("Flag account", + Lang.tr("Why does {0} need the admins' attention?", user.displayName()), + "Flag", why -> flag(user, true, why))))); + actions.add(pair("SvPairRight", user.suspended() + ? Ui.secondary("Unblock", "unblock", e -> block(user, false, "")) + : Ui.danger("Block", "block", e -> panes.reason("Block account", + Lang.tr("{0} is signed out everywhere and cannot sign in again " + + "until the block is lifted.", user.displayName()), "Block", + why -> block(user, true, why))))); + page.add(actions); + + Ui.section(page, "History"); + List events = detail.events(); + if (events == null || events.isEmpty()) { + page.add(Ui.label("Nothing has been done to this account.", "SvMuted")); + } + for (int iter = 0; events != null && iter < events.size(); iter++) { + ModerationEventDto event = events.get(iter); + Container row = Ui.row(icon(event.action()), action(event.action()), + event.reason() == null || event.reason().length() == 0 ? null : event.reason(), + null); + row.setName("event-" + iter); + Container when = new Container(BoxLayout.y()); + when.add(Ui.plain(Ui.when(event.at()), "SvRowDetail")); + when.add(Ui.plain(event.by(), "SvRowDetail")); + row.add(BorderLayout.EAST, when); + page.add(row); + } + panes.detail(user.displayName(), "User", page); + } + + private void flag(UserDto user, boolean flagged, String why) { + FlagDto change = new FlagDto(flagged, why); + Api.admin().flag(user.username(), change, Net.to(saved -> { + Telemetry.userFlagged(flagged); + open(user.username()); + }, Ui::fail)); + } + + private void block(UserDto user, boolean blocked, String why) { + SuspendDto change = new SuspendDto(blocked, why); + Api.admin().suspend(user.username(), change, Net.to(saved -> { + Telemetry.userBlocked(blocked); + open(user.username()); + }, Ui::fail)); + } + + private static Container pair(String uiid, Button button) { + Container side = new Container(new BorderLayout()); + side.setUIID(uiid); + side.add(BorderLayout.CENTER, button); + return side; + } + + private static char icon(String action) { + return "block".equals(action) ? FontImage.MATERIAL_BLOCK + : "unblock".equals(action) ? FontImage.MATERIAL_LOCK_OPEN + : "flag".equals(action) ? FontImage.MATERIAL_FLAG + : "unflag".equals(action) ? FontImage.MATERIAL_OUTLINED_FLAG + : "approve".equals(action) ? FontImage.MATERIAL_CHECK_CIRCLE + : "reject".equals(action) ? FontImage.MATERIAL_CANCEL + : "listed".equals(action) ? FontImage.MATERIAL_STOREFRONT + : "cancel_order".equals(action) ? FontImage.MATERIAL_REMOVE_SHOPPING_CART + : FontImage.MATERIAL_MANAGE_ACCOUNTS; + } + + private static String action(String action) { + return "block".equals(action) ? "Blocked" : "unblock".equals(action) ? "Unblocked" + : "flag".equals(action) ? "Flagged" : "unflag".equals(action) ? "Flag removed" + : "approve".equals(action) ? "Approved to deliver" : "reject".equals(action) + ? "Application sent back" : "listed".equals(action) ? "Restaurant listed" + : "cancel_order".equals(action) ? "Order cancelled" : "Roles changed"; + } + + private static Switch toggle(String name, boolean value) { + Switch toggle = new Switch(); + toggle.setName(name); + toggle.setValue(value); + return toggle; + } +} diff --git a/scripts/savor/common/src/main/java/com/codenameone/examples/savor/courier/ApplicationForm.java b/scripts/savor/common/src/main/java/com/codenameone/examples/savor/courier/ApplicationForm.java new file mode 100644 index 00000000000..080239800fe --- /dev/null +++ b/scripts/savor/common/src/main/java/com/codenameone/examples/savor/courier/ApplicationForm.java @@ -0,0 +1,431 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.courier; + +import com.codename1.components.SpanLabel; +import com.codename1.l10n.ParseException; +import com.codename1.l10n.SimpleDateFormat; +import com.codename1.ui.Button; +import com.codename1.ui.CN; +import com.codename1.ui.Container; +import com.codename1.ui.Display; +import com.codename1.ui.FontImage; +import com.codename1.ui.Form; +import com.codename1.ui.Image; +import com.codename1.ui.Label; +import com.codename1.ui.TextArea; +import com.codename1.ui.TextField; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.layouts.FlowLayout; +import com.codename1.ui.layouts.GridLayout; +import com.codename1.ui.spinner.Picker; +import com.codename1.util.Base64; +import com.codenameone.examples.savor.Telemetry; +import com.codenameone.examples.savor.api.DocumentDto; +import com.codenameone.examples.savor.api.DocumentUploadDto; +import com.codenameone.examples.savor.api.CourierApplicationDto; +import com.codenameone.examples.savor.api.UserDto; +import com.codenameone.examples.savor.net.Api; +import com.codenameone.examples.savor.net.Net; +import com.codenameone.examples.savor.net.Session; +import com.codenameone.examples.savor.ui.Chart; +import com.codenameone.examples.savor.ui.Lang; +import com.codenameone.examples.savor.ui.Photos; +import com.codenameone.examples.savor.ui.Ui; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; + +/// The application to deliver, a step at a time: you, your licence, your vehicle, +/// your documents, and a last look before it is handed in. +/// +/// Each step is saved as it is left, so an application can be put down half +/// way and picked up on another day or another phone. The server keeps the +/// draft and decides what "complete" means: handing in an application that is +/// missing something comes back with what, in words, and that is shown as it +/// is rather than worked out again here. +public final class ApplicationForm { + /// The documents asked for, in the order the server lists them. + public static final String[] KINDS = {"photo_id", "licence", "selfie", + "vehicle_registration", "insurance"}; + private static final String[] KIND_NAMES = {"Photo ID", "Driving licence", "A photo of you", + "Vehicle registration", "Insurance"}; + private static final String[] KIND_HINTS = {"A passport or an identity card", + "The side with your photo on it", "Your face, in good light, no sunglasses", + "The paper that says the vehicle is yours", "The page with the policy's dates on it"}; + private static final char[] KIND_ICONS = {FontImage.MATERIAL_BADGE, + FontImage.MATERIAL_CREDIT_CARD, FontImage.MATERIAL_FACE, + FontImage.MATERIAL_DESCRIPTION, FontImage.MATERIAL_VERIFIED_USER}; + private static final String[] STEPS = {"About you", "Your licence", "Your vehicle", + "Your documents", "Review and submit"}; + private static final String ISO = "yyyy-MM-dd"; + + private final Form form = Ui.form("Deliver with Savor", "Application"); + private final Runnable done; + private final Container body = Ui.page(); + private final Label stepLabel = Ui.plain("", "SvStepLabel"); + private final Label stepTitle = Ui.label("", "SvTitle"); + private final Container progress = new Container(new BorderLayout()); + private final SpanLabel error = Ui.text("", "SvError"); + private final Button next = Ui.primary("Continue", "next", e -> forward()); + private final Button back = Ui.link("Back", "backStep", e -> backward()); + /// The pictures taken on this visit, to show beside their documents. + private final Map thumbs = new HashMap(); + private CourierApplicationDto application; + private int step; + /// Reads what the step on screen has in its fields into [#application]. + private Runnable collect = () -> { }; + + private ApplicationForm(CourierApplicationDto application, Runnable done) { + this.application = application; + this.done = done; + Ui.back(form, () -> done.run()); + UserDto user = Session.user(); + if (application.legalName() == null || application.legalName().length() == 0) { + this.application = filled(user.displayName(), application.dateOfBirth(), + application.licenceNumber(), application.licenceExpiry()); + } + stepLabel.setName("step"); + error.setName("error"); + Container head = new Container(BoxLayout.y()); + head.setUIID("SvPage"); + head.add(stepLabel).add(stepTitle).add(progress); + Container foot = new Container(BoxLayout.y()); + foot.setUIID("SvFooter"); + foot.setSafeArea(true); + foot.add(error).add(next).add(back); + form.add(BorderLayout.NORTH, head); + form.add(BorderLayout.CENTER, body); + form.add(BorderLayout.SOUTH, foot); + } + + /// @param done run when the user leaves, with the application handed in or + /// not; it shows the screen to go back to + public static void show(Form previous, CourierApplicationDto application, Runnable done) { + ApplicationForm wizard = new ApplicationForm(application, done); + wizard.display(); + wizard.form.show(); + } + + // -------------------------------------------------------------- the steps + + private void display() { + stepLabel.setText(Lang.tr("Step {0} of {1}", String.valueOf(step + 1), + String.valueOf(STEPS.length))); + stepTitle.setText(STEPS[step]); + progress.removeAll(); + progress.add(BorderLayout.CENTER, new Chart.Meter((step + 1) / (double) STEPS.length)); + body.removeAll(); + // A new step starts at its top, wherever the last one was left. + body.scrollRectToVisible(0, 0, 1, 1, body); + error.setText(""); + collect = () -> { }; + if (step == 0) { + you(); + } else if (step == 1) { + licence(); + } else if (step == 2) { + vehicle(); + } else if (step == 3) { + documents(); + } else { + review(); + } + next.setText(step == STEPS.length - 1 ? "Submit application" : "Continue"); + next.setName(step == STEPS.length - 1 ? "submitApplication" : "next"); + back.setHidden(step == 0); + Ui.refresh(form); + } + + private void you() { + body.add(Ui.text("As it is written on your licence. Customers see only your first name.", + "SvMuted")); + final TextField name = Ui.field(body, "Legal name", "legalName", ""); + name.setText(application.legalName()); + final Picker born = date(body, "Date of birth", "dateOfBirth", application.dateOfBirth()); + collect = () -> application = filled(name.getText().trim(), iso(born), + application.licenceNumber(), application.licenceExpiry()); + } + + private void licence() { + body.add(Ui.text("The number is on the front of the card.", "SvMuted")); + final TextField number = Ui.field(body, "Licence number", "licenceNumber", ""); + number.setText(application.licenceNumber()); + final Picker expiry = date(body, "Expires", "licenceExpiry", application.licenceExpiry()); + collect = () -> application = filled(application.legalName(), + application.dateOfBirth(), number.getText().trim(), iso(expiry)); + } + + private void vehicle() { + Container pair = new Container(new GridLayout(1, 2)); + Container left = side("SvPairLeft"); + Container right = side("SvPairRight"); + final TextField make = Ui.field(left, "Make", "vehicleMake", "Vespa"); + final TextField model = Ui.field(right, "Model", "vehicleModel", "Primavera"); + pair.add(left).add(right); + body.add(pair); + Container second = new Container(new GridLayout(1, 2)); + left = side("SvPairLeft"); + right = side("SvPairRight"); + final TextField year = Ui.field(left, "Year", "vehicleYear", "2021", TextArea.NUMERIC); + final TextField colour = Ui.field(right, "Colour", "vehicleColor", "Blue"); + second.add(left).add(right); + body.add(second); + Container third = new Container(new GridLayout(1, 2)); + left = side("SvPairLeft"); + right = side("SvPairRight"); + final TextField plate = Ui.field(left, "Licence plate", "vehiclePlate", "7ABC123"); + third.add(left).add(right); + body.add(third); + make.setText(application.vehicleMake()); + model.setText(application.vehicleModel()); + year.setText(application.vehicleYear() > 0 + ? String.valueOf(application.vehicleYear()) : ""); + colour.setText(application.vehicleColor()); + plate.setText(application.vehiclePlate()); + collect = () -> { + CourierApplicationDto a = application; + application = new CourierApplicationDto(a.username(), a.name(), a.status(), + a.legalName(), a.dateOfBirth(), a.licenceNumber(), a.licenceExpiry(), + make.getText().trim(), model.getText().trim(), number(year.getText()), + colour.getText().trim(), plate.getText().trim(), a.documents(), a.rejectionReason(), + a.submittedAt(), a.reviewedAt(), a.reviewedBy()); + }; + } + + /// The application as it stands, with the applicant's and the licence's + /// details as given. + private CourierApplicationDto filled(String legalName, String dateOfBirth, + String licenceNumber, String licenceExpiry) { + CourierApplicationDto a = application; + return new CourierApplicationDto(a.username(), a.name(), a.status(), legalName, + dateOfBirth, licenceNumber, licenceExpiry, a.vehicleMake(), a.vehicleModel(), + a.vehicleYear(), a.vehicleColor(), a.vehiclePlate(), a.documents(), + a.rejectionReason(), a.submittedAt(), a.reviewedAt(), a.reviewedBy()); + } + + private void documents() { + body.add(Ui.text("Photograph each one flat, with all four corners in the picture.", + "SvMuted")); + for (int iter = 0; iter < KINDS.length; iter++) { + body.add(document(iter)); + } + } + + /// One document: what it is, whether we have it, and the two ways to give + /// it to us. + private Container document(final int index) { + final String kind = KINDS[index]; + String status = status(kind); + boolean have = !"missing".equals(status); + Container box = new Container(BoxLayout.y()); + box.setUIID("SvOption"); + Label picture = new Label("", "SvThumb"); + Image thumb = thumbs.get(kind); + if (thumb != null) { + int side = CN.convertToPixels(9f); + picture.setIcon(thumb.fill(side, side)); + } else { + Ui.icon(picture, KIND_ICONS[index], 4f); + } + Label state = Ui.badge(have ? "rejected".equals(status) ? "Rejected" + : "accepted".equals(status) ? "Accepted" : "Added" : "Needed", + have ? "rejected".equals(status) ? "SvBadgeBad" : "SvBadgeGood" : "SvBadgeWarn"); + state.setName("status-" + kind); + // The state stands over the name and not beside it: the name is long + // in some languages and has to keep the whole line. + state.getAllStyles().setMargin(0, 0, 0, 0); + Container lines = new Container(BoxLayout.y()); + lines.add(FlowLayout.encloseIn(state)); + lines.add(Ui.label(KIND_NAMES[index], "SvRowTitle")); + lines.add(Ui.text(KIND_HINTS[index], "SvRowDetail")); + Container top = new Container(new BorderLayout()); + top.add(BorderLayout.WEST, FlowLayout.encloseCenterMiddle(picture)); + top.add(BorderLayout.CENTER, lines); + box.add(top); + Container ways = new Container(new GridLayout(1, 2)); + Button camera = Ui.link(have ? "Retake" : "Camera", "camera-" + kind, + e -> Photos.camera((jpeg, taken) -> upload(kind, jpeg, taken))); + Ui.icon(camera, FontImage.MATERIAL_PHOTO_CAMERA, 3f); + Button gallery = Ui.link("Gallery", "gallery-" + kind, + e -> Photos.gallery((jpeg, taken) -> upload(kind, jpeg, taken))); + Ui.icon(gallery, FontImage.MATERIAL_PHOTO_LIBRARY, 3f); + ways.add(camera).add(gallery); + box.add(ways); + return box; + } + + private void upload(final String kind, byte[] jpeg, final Image picture) { + DocumentUploadDto document = new DocumentUploadDto(kind, "image/jpeg", + Base64.encodeNoNewline(jpeg)); + Api.onboarding().upload(document, Net.to(saved -> { + application = saved; + thumbs.put(kind, picture); + if (step == 3) { + display(); + } + }, this::failed)); + } + + private void review() { + body.add(Ui.text("Have a last look. You can change anything until it is handed in.", + "SvMuted")); + Ui.section(body, "You"); + body.add(Ui.fact("Legal name", application.legalName())); + body.add(Ui.fact("Date of birth", application.dateOfBirth())); + body.add(Ui.fact("Licence", application.licenceNumber())); + body.add(Ui.fact("Licence expires", application.licenceExpiry())); + Ui.section(body, "Your vehicle"); + body.add(Ui.fact("Vehicle", ApplyForm.car(application))); + body.add(Ui.fact("Licence plate", application.vehiclePlate())); + Ui.section(body, "Documents"); + int have = KINDS.length - ApplyForm.missing(application); + Container documents = Ui.fact("Photos added", have + " / " + KINDS.length); + documents.getComponentAt(1).setName("documentsAdded"); + body.add(documents); + body.add(Ui.text("By submitting you confirm that all of this is true and that the " + + "documents are your own.", "SvMuted")); + } + + // ----------------------------------------------------------- moving along + + private void forward() { + collect.run(); + // What the step asks for, asked for here: the server would take a + // half-empty draft, and say what is missing only at the very end. + if (incomplete()) { + error.setText(Lang.tr("Fill in everything on this step to continue.")); + Ui.refresh(form); + return; + } + next.setEnabled(false); + error.setText(""); + if (step == STEPS.length - 1) { + Api.onboarding().submit(Net.to(submitted -> { + Telemetry.applicationSubmitted(); + next.setEnabled(true); + Ui.say("Application sent. We will be in touch."); + done.run(); + }, this::failed)); + return; + } + if (step == 3) { + // Nothing typed on this step: each picture was sent as it was taken. + next.setEnabled(true); + step++; + display(); + return; + } + Api.onboarding().save(application, Net.to(saved -> { + application = saved; + next.setEnabled(true); + step++; + display(); + }, this::failed)); + } + + private boolean incomplete() { + if (step == 0) { + return empty(application.legalName()) || empty(application.dateOfBirth()); + } + if (step == 1) { + return empty(application.licenceNumber()) || empty(application.licenceExpiry()); + } + if (step == 2) { + return empty(application.vehicleMake()) || empty(application.vehicleModel()) + || empty(application.vehicleColor()) || empty(application.vehiclePlate()) + || application.vehicleYear() <= 0; + } + return false; + } + + private static boolean empty(String text) { + return text == null || text.length() == 0; + } + + private void backward() { + collect.run(); + if (step > 0) { + step--; + display(); + } + } + + private void failed(int status, String message) { + next.setEnabled(true); + // The server's own sentence: what is missing, or what is wrong with it. + error.setText(Lang.tr(message)); + Ui.refresh(form); + } + + private String status(String kind) { + for (int iter = 0; application.documents() != null && iter < application.documents().size(); + iter++) { + DocumentDto document = application.documents().get(iter); + if (kind.equals(document.kind())) { + return document.status(); + } + } + return "missing"; + } + + private static Container side(String uiid) { + Container side = new Container(BoxLayout.y()); + side.setUIID(uiid); + return side; + } + + /// A field for a date, which opens the platform's own way of choosing one. + private static Picker date(Container to, String caption, String name, String value) { + Picker picker = new Picker(); + picker.setType(Display.PICKER_TYPE_DATE); + picker.setUIID("SvField"); + picker.setName(name); + picker.setFormatter(new SimpleDateFormat("d MMM yyyy")); + if (value != null && value.length() > 0) { + try { + picker.setDate(new SimpleDateFormat(ISO).parse(value)); + } catch (ParseException unreadable) { + // Left on today: the server would not have stored such a date. + picker.setDate(new Date()); + } + } + to.add(Ui.label(caption, "SvLabel")).add(picker); + return picker; + } + + private static String iso(Picker picker) { + Date date = picker.getDate(); + return date == null ? "" : new SimpleDateFormat(ISO).format(date); + } + + private static int number(String text) { + String trimmed = text == null ? "" : text.trim(); + if (trimmed.length() == 0 || trimmed.length() > 6) { + return 0; + } + for (int iter = 0; iter < trimmed.length(); iter++) { + if (trimmed.charAt(iter) < '0' || trimmed.charAt(iter) > '9') { + return 0; + } + } + return Integer.parseInt(trimmed); + } +} diff --git a/scripts/savor/common/src/main/java/com/codenameone/examples/savor/courier/ApplyForm.java b/scripts/savor/common/src/main/java/com/codenameone/examples/savor/courier/ApplyForm.java new file mode 100644 index 00000000000..4ab45227d54 --- /dev/null +++ b/scripts/savor/common/src/main/java/com/codenameone/examples/savor/courier/ApplyForm.java @@ -0,0 +1,230 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.courier; + +import com.codename1.ui.Container; +import com.codename1.ui.FontImage; +import com.codename1.ui.Form; +import com.codename1.ui.Label; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.layouts.FlowLayout; +import com.codename1.ui.util.UITimer; +import com.codenameone.examples.savor.Telemetry; +import com.codenameone.examples.savor.api.DocumentDto; +import com.codenameone.examples.savor.api.CourierApplicationDto; +import com.codenameone.examples.savor.net.Api; +import com.codenameone.examples.savor.net.Net; +import com.codenameone.examples.savor.net.Session; +import com.codenameone.examples.savor.ui.Lang; +import com.codenameone.examples.savor.ui.Menu; +import com.codenameone.examples.savor.ui.Nav; +import com.codenameone.examples.savor.ui.Ui; + +/// The courier's side of the app for someone who is not a courier yet: where +/// their application stands, and the way on from there. +/// +/// Whether anyone delivers is the server's decision and an admin's: an +/// application is filled in, handed in, and approved or sent back with a +/// reason. This screen shows which of those it is. There is no way from here +/// to going online -- the screen that has that button is not built for an +/// account the server does not call a courier, and the server refuses the call +/// from one anyway. +public class ApplyForm implements Nav.Home { + private static final int POLL_MILLIS = 5000; + + private final Form form = Ui.home("Apply"); + private final Menu menu = new Menu(form, Nav.COURIER); + private final Container page = Ui.page(); + private String showing = ""; + + public ApplyForm() { + menu.item(FontImage.MATERIAL_ASSIGNMENT, "Your application", "home", () -> { }); + Container bar = new Container(new BorderLayout()); + bar.setUIID("SvHomeBar"); + bar.add(BorderLayout.WEST, FlowLayout.encloseCenterMiddle(menu.flatButton())); + bar.add(BorderLayout.CENTER, FlowLayout.encloseLeftMiddle( + Ui.label("Deliver with Savor", "SvHeading"))); + page.setSafeArea(false); + Container screen = new Container(new BorderLayout()); + // The strip the status bar is drawn in is the bar's colour, not + // whatever is behind the screen. + screen.setUIID("SvForm"); + screen.setSafeArea(true); + screen.add(BorderLayout.NORTH, bar); + screen.add(BorderLayout.CENTER, page); + form.add(screen); + menu.install(); + form.addShowListener(e -> load()); + // An answer can come at any moment, and is worth showing when it does. + UITimer.timer(POLL_MILLIS, true, form, this::load); + } + + @Override + public Form form() { + return form; + } + + @Override + public void start() { + load(); + } + + private void load() { + Api.onboarding().application(Net.to(this::fill, (status, message) -> { })); + } + + private void fill(final CourierApplicationDto application) { + String status = application.status() == null ? "none" : application.status(); + if ("approved".equals(status)) { + // The account is a courier's now: say so to the app, and let it + // build the screen a courier gets. + if (!"approved".equals(showing)) { + showing = "approved"; + Session.refreshUser(Nav::home, Ui::fail); + } + return; + } + String now = status + ":" + application.submittedAt() + ":" + missing(application); + if (now.equals(showing)) { + return; + } + showing = now; + page.removeAll(); + if ("pending".equals(status)) { + pending(application); + } else if ("rejected".equals(status)) { + rejected(application); + } else { + invite(application, "draft".equals(status) && started(application)); + } + Ui.refresh(form); + } + + /// Not applied yet, or part of the way through. + private void invite(final CourierApplicationDto application, boolean started) { + Container banner = Ui.banner(FontImage.MATERIAL_DIRECTIONS_CAR, + started ? "Finish your application" : "Earn on your own schedule", + started ? Lang.tr("{0} of 5 documents added. Pick up where you left off.", + String.valueOf(5 - missing(application))) + : "Tell us about you and your vehicle, add five photos, and we take it " + + "from there.", "SvBannerIcon"); + page.add(banner); + Label state = Ui.label(started ? "Draft" : "Not started", "SvBadge"); + state.setName("applicationStatus"); + page.add(FlowLayout.encloseCenter(state)); + Ui.section(page, "What you will need"); + page.add(Ui.row(FontImage.MATERIAL_BADGE, "A driving licence", + "Its number, its expiry date and a photo of it", null)); + page.add(Ui.row(FontImage.MATERIAL_TWO_WHEELER, "A bike, a scooter or a car", + "Registered and insured, with the papers to show it", null)); + page.add(Ui.row(FontImage.MATERIAL_PHOTO_CAMERA, "About ten minutes", + "Most of it is taking five photos", null)); + final boolean begun = started; + page.add(Ui.primary(started ? "Continue application" : "Start application", "apply", + e -> { + if (!begun) { + Telemetry.applicationStarted(false); + } + ApplicationForm.show(form, application, this::refreshed); + })); + } + + private void pending(CourierApplicationDto application) { + page.add(Ui.banner(FontImage.MATERIAL_HOURGLASS_TOP, "Application under review", + "We have everything we asked for. Someone on our team is reading it.", + "SvBannerIconWarn")); + Label state = Ui.label("Under review", "SvBadgeWarn"); + state.setName("applicationStatus"); + page.add(FlowLayout.encloseCenter(state)); + Ui.section(page, "What happens next"); + page.add(Ui.row(FontImage.MATERIAL_CHECK_CIRCLE, "Application received", + application.submittedAt() > 0 ? Ui.when(application.submittedAt()) : null, null)); + page.add(Ui.row(FontImage.MATERIAL_FACT_CHECK, "Documents checked", + "Usually within two working days", null)); + page.add(Ui.row(FontImage.MATERIAL_DIRECTIONS_CAR, "You go online", + "This screen becomes your courier's home the moment you are approved", null)); + Ui.section(page, "Your vehicle"); + page.add(Ui.row(FontImage.MATERIAL_DIRECTIONS_CAR, car(application), + application.vehiclePlate(), null)); + } + + private void rejected(final CourierApplicationDto application) { + page.add(Ui.banner(FontImage.MATERIAL_ERROR_OUTLINE, "We could not approve this yet", + "Read what was missing, put it right, and send the application again.", + "SvBannerIconBad")); + Label state = Ui.label("Changes needed", "SvBadgeBad"); + state.setName("applicationStatus"); + page.add(FlowLayout.encloseCenter(state)); + Ui.section(page, "What the reviewer said"); + Container said = new Container(BoxLayout.y()); + said.setUIID("SvPanel"); + com.codename1.components.SpanLabel reason = Ui.text("", "SvText"); + // The reviewer's own words, in whatever language they wrote them. + reason.setShouldLocalize(false); + reason.setText(application.rejectionReason()); + reason.setName("rejectionReason"); + said.add(reason); + page.add(said); + page.add(Ui.primary("Fix and resubmit", "apply", e -> { + Telemetry.applicationStarted(true); + ApplicationForm.show(form, application, this::refreshed); + })); + } + + /// Back from the form: whatever was done there, show how things stand now. + private void refreshed() { + showing = ""; + form.showBack(); + } + + private static boolean started(CourierApplicationDto application) { + return application.legalName() != null && application.legalName().length() > 0 + || missing(application) < 5; + } + + static int missing(CourierApplicationDto application) { + int missing = 0; + for (int iter = 0; application.documents() != null && iter < application.documents().size(); + iter++) { + DocumentDto document = application.documents().get(iter); + if ("missing".equals(document.status())) { + missing++; + } + } + return application.documents() == null ? 5 : missing; + } + + static String car(CourierApplicationDto application) { + StringBuilder car = new StringBuilder(); + append(car, application.vehicleColor()); + append(car, application.vehicleMake()); + append(car, application.vehicleModel()); + if (application.vehicleYear() > 0) { + append(car, String.valueOf(application.vehicleYear())); + } + return car.toString(); + } + + private static void append(StringBuilder to, String word) { + if (word != null && word.length() > 0) { + to.append(to.length() > 0 ? " " : "").append(word); + } + } +} diff --git a/scripts/savor/common/src/main/java/com/codenameone/examples/savor/courier/CourierForm.java b/scripts/savor/common/src/main/java/com/codenameone/examples/savor/courier/CourierForm.java new file mode 100644 index 00000000000..2292327e5c7 --- /dev/null +++ b/scripts/savor/common/src/main/java/com/codenameone/examples/savor/courier/CourierForm.java @@ -0,0 +1,607 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.courier; + +import com.codename1.maps.LatLng; +import com.codename1.maps.Marker; +import com.codename1.ui.Button; +import com.codename1.ui.CN; +import com.codename1.ui.Container; +import com.codename1.ui.FontImage; +import com.codename1.ui.Form; +import com.codename1.ui.Label; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.layouts.FlowLayout; +import com.codename1.ui.layouts.GridLayout; +import com.codename1.ui.util.UITimer; +import com.codenameone.examples.savor.Telemetry; +import com.codenameone.examples.savor.api.CourierStatusDto; +import com.codenameone.examples.savor.api.EarningsDto; +import com.codenameone.examples.savor.api.DeliveryDto; +import com.codenameone.examples.savor.api.DeliveryState; +import com.codenameone.examples.savor.live.LiveChannel; +import com.codenameone.examples.savor.map.CarMarker; +import com.codenameone.examples.savor.map.Locator; +import com.codenameone.examples.savor.map.MapStage; +import com.codenameone.examples.savor.map.Maps; +import com.codenameone.examples.savor.map.Routes; +import com.codenameone.examples.savor.net.Api; +import com.codenameone.examples.savor.net.Net; +import com.codenameone.examples.savor.ui.BottomSheet; +import com.codenameone.examples.savor.ui.Lang; +import com.codenameone.examples.savor.ui.Menu; +import com.codenameone.examples.savor.ui.Nav; +import com.codenameone.examples.savor.ui.Pulse; +import com.codenameone.examples.savor.ui.DeliveryListForm; +import com.codenameone.examples.savor.ui.Ring; +import com.codenameone.examples.savor.ui.StatRow; +import com.codenameone.examples.savor.ui.Ui; +import java.io.IOException; + +/// Onboarding: go online, answer the offer, carry the order from kitchen to door. +/// +/// While online the app reports the car's position every few seconds. That +/// report is three things at once to the server: where to draw the car on the +/// customer's map, how far this courier is from the next request, and the sign the +/// courier is still there -- a courier who stops reporting stops being offered +/// deliveries. +/// +/// An offer is the server's to make and to withdraw. It arrives as a delivery in +/// the `OFFERED` state with a number of seconds to answer in; when they run out +/// the server offers the delivery to the next courier, and this screen finds out +/// the way it finds out everything, by asking what the courier's delivery now is. +/// +/// Each step of a delivery has one thing to do next, and the sheet shows that one +/// thing as its only large button. +public class CourierForm extends LiveChannel.Adapter implements Nav.Home { + private static final int TICK_MILLIS = 3000; + /// How fast the made-up car drives where there is no real position: + /// brisk, so a demonstration does not take the time a real delivery would. + private static final double SIMULATED_SPEED = 25; + /// Meters a second in town, for a time of arrival worked out from a + /// distance: 30 km/h. + private static final double CITY_SPEED = 8.3; + private static final Net.Failed QUIET = (status, message) -> { }; + + private final Form form = Ui.home("Courier"); + private final Menu menu = new Menu(form, Nav.COURIER); + private final MapStage stage; + private final BottomSheet sheet; + private final CarMarker car; + + private LatLng here = Locator.here(); + /// A status report is waiting for its answer; see [#report]. + private boolean reporting; + /// The report asked for while one was in flight, null for none. + private Boolean wantedNext; + private Net.Failed failedNext; + private double heading; + private boolean online; + private DeliveryDto delivery; + private EarningsDto earnings; + /// What the last finished delivery paid, to say so until the next one starts. + private String lastFare; + private String showing = ""; + /// Where the car is being driven to, as `deliveryId:state`, and the way there. + private String target = ""; + private Routes.Way way; + private double travelled; + private Marker pickupMarker; + private Marker dropoffMarker; + private Maps.Line line; + private Ring countdown; + private int ticks; + + public CourierForm() { + menu.item(FontImage.MATERIAL_TWO_WHEELER, "Deliver", "home", () -> { }); + menu.item(FontImage.MATERIAL_HISTORY, "Your deliveries", "history", + () -> DeliveryListForm.show(form, "Your deliveries", cb -> Api.courier().history(cb), false)); + menu.item(FontImage.MATERIAL_PAYMENTS, "Earnings", "earnings", + () -> EarningsForm.show(form)); + stage = new MapStage(here, Maps.STREET_ZOOM, menu.button()); + sheet = stage.sheet(); + car = new CarMarker(stage.map, form, here, heading); + stage.addRound(FontImage.MATERIAL_MY_LOCATION, "recentre", this::recentre); + form.add(stage.layers()); + menu.install(); + form.addShowListener(e -> { + LiveChannel.listen(this); + refresh(); + }); + UITimer.timer(TICK_MILLIS, true, form, this::tick); + UITimer.timer(1000, true, form, this::second); + } + + @Override + public Form form() { + return form; + } + + @Override + public void start() { + display(); + // Picks up where the courier left off: still online, perhaps mid-delivery. + // Nothing leaves the courier's side until that is known. + Shift.checking(true); + Api.courier().me(Net.to(me -> { + // Too late: the user has left for another mode or signed out, and + // Shift was ended then. Starting it again here would report a + // courier with no courier's screen. + if (!Nav.isHome(form)) { + return; + } + online = me.online(); + shift(); + showing = ""; + display(); + refresh(); + }, (status, message) -> Shift.checking(false))); + loadEarnings(); + } + + public void show() { + start(); + form.show(); + } + + // ------------------------------------------------------------ the server + + private void tick() { + ticks++; + if (!online) { + return; + } + move(); + // A heartbeat only while staying online is still what the courier + // wants; one sent after "Go offline" would put them back on line. + if (!reporting && wantedNext == null) { + report(true, QUIET); + } + if (!LiveChannel.connected() || ticks % 3 == 0) { + refresh(); + } + } + + /// Tells the server where the car is, and whether it is taking deliveries. + /// One report at a time: two in flight could reach the server in either + /// order, and a heartbeat landing after "Go offline" would undo it. A report + /// asked for meanwhile waits and goes when the one in flight answers, and + /// the latest wish replaces any earlier one still waiting. + private void report(final boolean wanted, final Net.Failed failed) { + if (reporting) { + wantedNext = Boolean.valueOf(wanted); + failedNext = failed; + return; + } + reporting = true; + final CourierStatusDto status = new CourierStatusDto(wanted, here.getLatitude(), + here.getLongitude(), heading); + Api.courier().status(status, Net.to(me -> { + reported(); + if (wantedNext == null && online != me.online()) { + online = me.online(); + shift(); + Telemetry.courierOnline(online); + showing = ""; + display(); + } + }, (code, message) -> { + reported(); + failed.failed(code, message); + })); + } + + /// The report in flight answered: the one waiting, if any, goes now. + private void reported() { + reporting = false; + if (wantedNext != null) { + boolean next = wantedNext.booleanValue(); + Net.Failed then = failedNext; + wantedNext = null; + failedNext = null; + report(next, then); + } + } + + /// "Go online": only from a position the phone really reported. Until there + /// is one, Locator.here() is the middle of the demo city, and the server + /// would offer this courier deliveries there. Made-up positions are the + /// exception, since they are all there is. + private void goOnline() { + if (!Locator.simulated()) { + if (Locator.state() != Locator.FOUND) { + Ui.say("Waiting for your location"); + return; + } + here = Locator.here(); + } + report(true, Ui::fail); + } + + private void refresh() { + Api.courier().active(Net.to(this::update, (status, message) -> Shift.checking(false))); + } + + /// Keeps the phone reporting in the background exactly while the server + /// has this courier on line. + private void shift() { + if (online) { + Shift.begin(); + } else { + Shift.end(); + } + } + + @Override + public void deliveryChanged(String id, String state) { + refresh(); + } + + private void update(DeliveryDto latest) { + boolean none = latest == null || DeliveryState.NONE == latest.state(); + if (none && delivery != null && DeliveryState.OFFERED == delivery.state()) { + Ui.say("The request went to another courier"); + } else if (none && delivery != null) { + Ui.say("The customer cancelled"); + } + delivery = none ? null : latest; + Shift.setTaken(delivery != null && (DeliveryState.ACCEPTED == delivery.state() + || DeliveryState.ARRIVED == delivery.state() + || DeliveryState.IN_PROGRESS == delivery.state())); + if (delivery != null) { + lastFare = null; + } + aim(); + display(); + } + + private void act(Net.Call call) { + act(call, () -> { }); + } + + /// The same, with something to do first when the server agrees. + private void act(Net.Call call, final Runnable agreed) { + Net.send(call, changed -> { + agreed.run(); + update(changed); + }, (status, message) -> { + Ui.fail(status, message); + refresh(); + }); + } + + private void loadEarnings() { + Api.courier().earnings(Net.to(earned -> { + earnings = earned; + showing = ""; + display(); + }, QUIET)); + } + + // --------------------------------------------------------------- the map + + /// Points the car at wherever the delivery needs it next: the pickup once the + /// delivery is accepted, the destination once the customer is aboard. + private void aim() { + String wanted = delivery == null ? "" : delivery.id() + ":" + delivery.state(); + if (wanted.equals(target)) { + return; + } + target = wanted; + way = null; + travelled = 0; + if (pickupMarker != null) { + stage.map.removeMarker(pickupMarker); + pickupMarker = null; + } + if (dropoffMarker != null) { + stage.map.removeMarker(dropoffMarker); + dropoffMarker = null; + } + if (line != null) { + line.remove(stage.map); + line = null; + } + if (delivery == null) { + stage.center(here, Maps.STREET_ZOOM); + return; + } + final LatLng pickup = new LatLng(delivery.pickupLat(), delivery.pickupLng()); + final LatLng dropoff = new LatLng(delivery.dropoffLat(), delivery.dropoffLng()); + pickupMarker = Maps.pickup(stage.map, pickup); + dropoffMarker = Maps.dropoff(stage.map, dropoff); + stage.frame(here, pickup, dropoff); + if (DeliveryState.ARRIVED == delivery.state()) { + return; + } + final LatLng to = DeliveryState.IN_PROGRESS == delivery.state() ? dropoff : pickup; + final String aimed = target; + Routes.find(here, to, route -> { + if (!aimed.equals(target) || delivery == null) { + return; + } + line = Maps.route(stage.map, route.points); + // An offer shows the way; only an accepted delivery is driven. + if (DeliveryState.OFFERED != delivery.state()) { + way = route; + } + }); + } + + /// Moves the car. On a phone the phone has moved it already; where there + /// is no real position, this drives it along the route. + private void move() { + LatLng now; + if (!Locator.simulated()) { + now = Locator.here(); + } else if (way != null) { + travelled += SIMULATED_SPEED * TICK_MILLIS / 1000d; + now = Routes.along(way.points, travelled); + } else { + return; + } + if (Maps.meters(here, now) > 2) { + heading = Maps.heading(here, now); + } + here = now; + car.moveTo(here, heading); + chip(); + } + + private void recentre() { + if (delivery == null) { + stage.center(here, Maps.STREET_ZOOM); + } else { + stage.frame(here, new LatLng(delivery.pickupLat(), delivery.pickupLng()), + new LatLng(delivery.dropoffLat(), delivery.dropoffLng())); + } + } + + /// How far, and about how long, to where the delivery needs the car next. + private void chip() { + if (delivery == null || DeliveryState.OFFERED == delivery.state() + || DeliveryState.ARRIVED == delivery.state()) { + stage.chip(null, (char) 0); + return; + } + double meters = Maps.meters(here, next()); + stage.chip(Ui.minutes(meters / CITY_SPEED) + Ui.DOT + Ui.distance(meters), + FontImage.MATERIAL_NAVIGATION); + } + + private LatLng next() { + return DeliveryState.IN_PROGRESS == delivery.state() + ? new LatLng(delivery.dropoffLat(), delivery.dropoffLng()) + : new LatLng(delivery.pickupLat(), delivery.pickupLng()); + } + + // ------------------------------------------------------------- the sheet + + private void second() { + // The offer has run out: the server has moved on, so ask what is left. + if (countdown != null && countdown.secondsLeft() == 0) { + countdown = null; + refresh(); + } + } + + private void display() { + String now = (online ? "on" : "off") + ":" + (delivery == null ? "" : delivery.id() + delivery.state()); + if (now.equals(showing)) { + return; + } + showing = now; + sheet.reset(); + countdown = null; + header(); + chip(); + if (delivery == null) { + waiting(); + } else if (DeliveryState.OFFERED == delivery.state()) { + offer(); + } else { + trip(); + } + sheet.ready(false); + } + + /// The card at the top: online or off, and what today has made. + private void header() { + // Two rows: the badge and the day's takings, then what they came from + // on a line of its own. Side by side the three did not fit the card at + // a large font, and the figures were pushed back over the badge. + Container card = new Container(BoxLayout.y()); + card.setUIID("SvStatusCard"); + Container top = new Container(new BorderLayout()); + top.add(BorderLayout.WEST, FlowLayout.encloseCenterMiddle( + Ui.badge(online ? "Online" : "Offline", online ? "SvBadgeGood" : "SvBadge"))); + card.add(top); + if (earnings != null) { + top.add(BorderLayout.CENTER, Ui.label(Ui.money(earnings.todayCents(), earnings.currency()), + "SvStatusValue")); + card.add(Ui.label(Lang.tr("Today, {0} deliveries", String.valueOf(earnings.todayDeliveries())), + "SvStatusDetail")); + } + stage.header(card); + } + + private void waiting() { + Container peek = sheet.peek(); + Label title = Ui.label(online ? "Looking for deliveries" : "You are offline", "SvHeading"); + title.setName("courierState"); + peek.add(title); + if (lastFare != null) { + peek.add(Ui.text(Lang.tr("Delivered: {0} earned", lastFare), "SvNotice")); + } + if (online) { + peek.add(Ui.label("Stay close to busy streets; orders go to the nearest courier.", + "SvMuted")); + peek.add(FlowLayout.encloseCenter(new Pulse())); + } else { + peek.add(Ui.label("Go online to start taking deliveries.", "SvMuted")); + Button go = new Button("GO", "SvGo"); + go.setName("goOnline"); + go.addActionListener(e -> goOnline()); + peek.add(FlowLayout.encloseCenter(go)); + } + if (earnings != null) { + StatRow numbers = Ui.stats(3); + numbers.stat(Ui.money(earnings.todayCents(), earnings.currency()), "Today", + "earnedToday"); + numbers.stat(Ui.money(earnings.weekCents(), earnings.currency()), "This week", + "earnedWeek"); + numbers.stat(String.valueOf(earnings.deliveries()), "Deliveries", "deliveries"); + peek.add(numbers); + } + if (online) { + peek.add(Ui.secondary("Go offline", "goOffline", e -> report(false, Ui::fail))); + } + } + + private void offer() { + final String id = delivery.id(); + Container peek = sheet.peek(); + Label title = Ui.label("Delivery request", "SvHeading"); + title.setName("courierState"); + countdown = new Ring(); + countdown.setName("countdown"); + countdown.start(delivery.offerExpiresInSeconds(), delivery.offerExpiresInSeconds()); + Label fare = Ui.label(Ui.money(delivery.fareCents(), delivery.currency()), "SvFare"); + fare.setName("fare"); + Container words = new Container(BoxLayout.y()); + words.add(title); + words.add(fare); + Container top = new Container(new BorderLayout()); + top.add(BorderLayout.CENTER, words); + top.add(BorderLayout.EAST, FlowLayout.encloseCenterMiddle(countdown)); + peek.add(top); + double away = Maps.meters(here, new LatLng(delivery.pickupLat(), delivery.pickupLng())); + peek.add(Ui.row(FontImage.MATERIAL_STOREFRONT, delivery.pickupAddress(), + Lang.tr("{0} away, {1}", Ui.distance(away), Ui.minutes(away / CITY_SPEED)), null)); + peek.add(Ui.row(FontImage.MATERIAL_PLACE, delivery.dropoffAddress(), + Ui.distance(delivery.distanceMeters()) + Ui.DOT + Ui.minutes(delivery.durationSeconds()), + null)); + peek.add(Ui.primary("Accept", "accept", e -> act(cb -> Api.courier().accept(id, cb), + () -> Telemetry.offerAnswered(true)))); + peek.add(Ui.link("Decline", "decline", e -> act(cb -> Api.courier().decline(id, cb), + () -> Telemetry.offerAnswered(false)))); + sheet.more().add(Ui.row(FontImage.MATERIAL_PERSON, delivery.customerName(), asked(), null)); + sheet.more().add(Ui.row(delivery.cash() ? FontImage.MATERIAL_PAYMENTS + : FontImage.MATERIAL_CREDIT_CARD, delivery.cash() ? "Cash at the door" : "Paid by card", + null, null)); + } + + private void trip() { + final String id = delivery.id(); + DeliveryState state = delivery.state(); + Container peek = sheet.peek(); + Label title = Ui.label(DeliveryState.ACCEPTED == state + ? Lang.tr("Head to the restaurant") + : DeliveryState.ARRIVED == state + ? Lang.tr("Collect the order for {0}", delivery.customerName()) + : Lang.tr("Deliver to {0}", delivery.customerName()), "SvHeading"); + title.setName("courierState"); + peek.add(title); + boolean aboard = DeliveryState.IN_PROGRESS == state; + peek.add(Ui.row(aboard ? FontImage.MATERIAL_PLACE : FontImage.MATERIAL_STOREFRONT, + aboard ? delivery.dropoffAddress() : delivery.pickupAddress(), + aboard ? "Door" : "Restaurant", null)); + if (delivery.cash()) { + // Said before the delivery ends, so that it is not a surprise at the door. + Label cash = Ui.label(Lang.tr("Collect {0} in cash", Ui.money( + delivery.total() > 0 ? delivery.total() : delivery.fareCents(), delivery.currency())), + "SvNotice"); + cash.setName("collectCash"); + Ui.icon(cash, FontImage.MATERIAL_PAYMENTS, 3f); + peek.add(cash); + } + if (!aboard) { + Container reach = new Container(new GridLayout(1, 2)); + reach.setUIID("SvActions"); + reach.add(Ui.action(FontImage.MATERIAL_CALL, "Call", "call", e -> call())); + reach.add(Ui.action(FontImage.MATERIAL_SMS, "Message", "message", e -> message())); + peek.add(reach); + } + if (DeliveryState.ACCEPTED == state) { + peek.add(Ui.primary("I am at the restaurant", "arrived", + e -> act(cb -> Api.courier().arrived(id, cb)))); + } else if (DeliveryState.ARRIVED == state) { + // Refused with the reason until the kitchen has marked it ready. + peek.add(Ui.primary("I have the food", "start", + e -> act(cb -> Api.courier().start(id, cb)))); + } else { + peek.add(Ui.primary("Delivered", "complete", + e -> Api.courier().complete(id, Net.to(finished -> { + Telemetry.deliveryCompleted("courier", finished.fareCents(), finished.cash()); + lastFare = Ui.money(finished.fareCents(), finished.currency()); + Ui.say(finished.cash() ? Lang.tr("Collect {0} in cash", Ui.money( + finished.total() > 0 ? finished.total() : finished.fareCents(), + finished.currency())) : Lang.tr("Delivered: {0} earned", lastFare)); + delivery = null; + aim(); + display(); + loadEarnings(); + }, Ui::fail)))); + } + Container more = sheet.more(); + more.add(Ui.row(FontImage.MATERIAL_PERSON, delivery.customerName(), asked(), null)); + more.add(Ui.row(FontImage.MATERIAL_PLACE, delivery.dropoffAddress(), + Ui.distance(delivery.distanceMeters()) + Ui.DOT + Ui.minutes(delivery.durationSeconds()), + Ui.label(Ui.money(delivery.fareCents(), delivery.currency()), "SvRowValue"))); + // A customer who is aboard is taken where they are going: there is no + // cancelling from here, only finishing. + if (!aboard) { + more.add(Ui.link("Cancel delivery", "cancel", + e -> Api.courier().cancel(id, Net.to(cancelled -> { + Telemetry.deliveryCancelled("courier", state); + delivery = null; + aim(); + display(); + }, Ui::fail)))); + } + } + + private void call() { + String number = delivery == null ? null : delivery.customerPhone(); + if (number == null || number.length() == 0 || !CN.canDial()) { + Ui.say("Calling is not available on this device"); + return; + } + CN.dial(number); + } + + private void message() { + String number = delivery == null ? null : delivery.customerPhone(); + if (number == null || number.length() == 0) { + Ui.say("Your customer cannot be messaged right now"); + return; + } + try { + CN.sendSMS(number, Lang.tr("Hi, this is your Savor courier.")); + } catch (IOException failed) { + Ui.say("Messages are not available on this device"); + } + } + + /// What the customer asked of the courier, in a line. + private String asked() { + return delivery.note() == null ? "" : delivery.note(); + } +} diff --git a/scripts/savor/common/src/main/java/com/codenameone/examples/savor/courier/EarningsForm.java b/scripts/savor/common/src/main/java/com/codenameone/examples/savor/courier/EarningsForm.java new file mode 100644 index 00000000000..544c652de0e --- /dev/null +++ b/scripts/savor/common/src/main/java/com/codenameone/examples/savor/courier/EarningsForm.java @@ -0,0 +1,181 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.courier; + +import com.codename1.components.SpanLabel; +import com.codename1.ui.Button; +import com.codename1.ui.Container; +import com.codename1.ui.FontImage; +import com.codename1.ui.Form; +import com.codename1.ui.Label; +import com.codename1.ui.TextArea; +import com.codename1.ui.TextField; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; +import com.codenameone.examples.savor.Telemetry; +import com.codenameone.examples.savor.api.EarningsSummaryDto; +import com.codenameone.examples.savor.api.PayoutAccountDto; +import com.codenameone.examples.savor.api.PayoutDto; +import com.codenameone.examples.savor.net.Api; +import com.codenameone.examples.savor.net.Net; +import com.codenameone.examples.savor.pay.Cards; +import com.codenameone.examples.savor.ui.Chart; +import com.codenameone.examples.savor.ui.Lang; +import com.codenameone.examples.savor.ui.StatRow; +import com.codenameone.examples.savor.ui.Ui; +import java.util.List; + +/// What delivering has paid: what is waiting to be cashed out, the last +/// fortnight a day at a time, and the payouts already sent. +/// +/// The sums are the server's. It works them out from the deliveries it recorded, +/// less its commission, so this screen can show them and cannot change them. +public final class EarningsForm { + private final Form form = Ui.form("Earnings", "Earnings"); + private final Container page = Ui.page(); + private PayoutAccountDto account; + + private EarningsForm(Form previous) { + Ui.back(form, previous); + form.add(BorderLayout.CENTER, page); + } + + public static void show(Form previous) { + EarningsForm earnings = new EarningsForm(previous); + earnings.form.show(); + earnings.load(); + } + + private void load() { + Api.courier().payoutAccount(Net.to(found -> { + account = found; + Api.courier().earningsSummary(Net.to(summary -> Api.courier().payouts(Net.to( + payouts -> fill(summary, payouts), Ui::fail)), Ui::fail)); + }, Ui::fail)); + } + + private void fill(final EarningsSummaryDto summary, List payouts) { + page.removeAll(); + final String currency = summary.currency(); + Container balance = new Container(BoxLayout.y()); + balance.setUIID("SvPanel"); + balance.add(Ui.label("Ready to cash out", "SvStatLabel")); + Label amount = Ui.plain(Ui.money(summary.balance(), currency), "SvChartValue"); + amount.setName("balance"); + balance.add(amount); + final boolean banked = account != null && account.accountLast4() != null + && account.accountLast4().length() > 0; + Button cash = Ui.primary("Cash out", "cashOut", e -> { + if (!banked) { + account(); + return; + } + Api.courier().cashOut(Net.to(sent -> { + Telemetry.payoutRequested(sent.amount()); + Ui.say(Lang.tr("{0} is on its way to your bank", Ui.money(sent.amount(), + currency))); + load(); + }, Ui::fail)); + }); + cash.setEnabled(summary.balance() > 0); + balance.add(cash); + page.add(balance); + + StatRow periods = Ui.stats(3); + periods.stat(Ui.money(summary.today(), currency), "Today", "earnedToday"); + periods.stat(Ui.money(summary.week(), currency), "This week", "earnedWeek"); + periods.stat(Ui.money(summary.month(), currency), "This month", "earnedMonth"); + page.add(periods); + + Ui.section(page, "The last two weeks"); + int days = summary.daily() == null ? 0 : summary.daily().size(); + double[] amounts = new double[days]; + for (int iter = 0; iter < days; iter++) { + amounts[iter] = summary.daily().get(iter).amount(); + } + page.add(Chart.of("daily", amounts, Lang.tr("{0} days ago", String.valueOf( + Math.max(1, days - 1))), Lang.tr("Today"))); + + StatRow numbers = Ui.stats(2); + numbers.stat(String.valueOf(summary.trips()), "Deliveries", "deliveries"); + numbers.stat(Ui.money(summary.tips(), currency), "Tips", "tips"); + numbers.stat(summary.rating() > 0 ? Ui.tenths(summary.rating()) : "-", "Rating", + "rating"); + numbers.stat(Math.round(summary.acceptanceRate() * 100d) + "%", "Requests accepted", + "acceptance"); + page.add(numbers); + + Ui.section(page, "Payouts"); + page.add(Ui.tap(Ui.row(FontImage.MATERIAL_ACCOUNT_BALANCE, + banked ? account.bankName() : Lang.tr("Add a bank account"), + banked ? Lang.tr("Account ending {0}", account.accountLast4()) + : Lang.tr("Where your earnings are sent"), null), "payoutAccount", + e -> account())); + for (int iter = 0; payouts != null && iter < payouts.size(); iter++) { + PayoutDto payout = payouts.get(iter); + Container row = Ui.row(FontImage.MATERIAL_NORTH_EAST, Ui.when(payout.createdAt()), + payout.destination(), Ui.plain(Ui.money(payout.amount(), currency), + "SvRowValue")); + row.setName("payout-" + iter); + page.add(row); + } + if (payouts == null || payouts.isEmpty()) { + page.add(Ui.label("Nothing has been paid out yet.", "SvMuted")); + } + Ui.refresh(form); + } + + /// Where payouts go. The account number is typed in full and only its + /// last four digits leave the phone: the rest is the bank's business, and + /// a sample server has no bank. + private void account() { + final Form edit = Ui.form("Bank account", "PayoutAccount"); + Ui.back(edit, form); + Container fields = Ui.page(); + final TextField holder = Ui.field(fields, "Account holder", "holder", ""); + final TextField bank = Ui.field(fields, "Bank", "bankName", ""); + final TextField number = Ui.field(fields, "Account number", "accountNumber", "", + TextArea.NUMERIC); + if (account != null) { + holder.setText(account.holder()); + bank.setText(account.bankName()); + } + final SpanLabel error = Ui.text("", "SvError"); + fields.add(error); + fields.add(Ui.primary("Save", "submit", e -> { + String digits = Cards.digits(number.getText()); + if (digits.length() < 4) { + error.setText(Lang.tr("Type the account number.")); + edit.revalidate(); + return; + } + PayoutAccountDto wanted = new PayoutAccountDto(holder.getText().trim(), + bank.getText().trim(), digits.substring(digits.length() - 4)); + Api.courier().savePayoutAccount(wanted, Net.to(saved -> { + form.showBack(); + load(); + }, (status, message) -> { + error.setText(message); + edit.revalidate(); + })); + })); + edit.add(BorderLayout.CENTER, fields); + edit.show(); + } +} diff --git a/scripts/savor/common/src/main/java/com/codenameone/examples/savor/courier/Shift.java b/scripts/savor/common/src/main/java/com/codenameone/examples/savor/courier/Shift.java new file mode 100644 index 00000000000..231b63b3f6e --- /dev/null +++ b/scripts/savor/common/src/main/java/com/codenameone/examples/savor/courier/Shift.java @@ -0,0 +1,204 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.courier; + +import com.codename1.background.ForegroundService; +import com.codename1.maps.LatLng; +import com.codename1.ui.CN; +import com.codenameone.examples.savor.api.CourierStatusDto; +import com.codenameone.examples.savor.map.Locator; +import com.codenameone.examples.savor.map.Maps; +import com.codenameone.examples.savor.net.Api; +import com.codenameone.examples.savor.net.Net; + +import java.util.concurrent.atomic.AtomicBoolean; + +// tag::savor-shift[] +/// A courier's time on line, as far as the phone is concerned: the position +/// keeps going to the server while the app is in the background. +/// +/// In the foreground the courier screen reports every few seconds itself. Put +/// away -- the courier switched to a navigation app, or locked the phone -- that +/// screen stops, and this takes over: it follows the position in the +/// background and reports it when the car has moved [#MIN_METERS], and at +/// least every [#KEEP_ALIVE_MILLIS] even when it has not, because a courier who +/// goes quiet for a minute stops being offered deliveries. +/// +/// - On iOS the `location` background mode keeps the app running while it +/// tracks, and each new position is the moment to report. +/// - On Android a foreground service keeps it running, with the notification +/// the system requires, and wakes it for the reports a parked car would not +/// send. +/// - The simulator and the browser stand still and report nothing here. +public final class Shift { + /// How far the car moves before the server hears of it again. + static final double MIN_METERS = 25d; + /// The longest the server goes without hearing, well inside the minute + /// after which it stops offering this courier deliveries. + static final long KEEP_ALIVE_MILLIS = 20000L; + private static final String CHANNEL = "savor-onboarding"; + private static final Net.Failed QUIET = (status, message) -> { }; + + private static boolean on; + private static boolean taken; + private static boolean checking; + private static boolean background; + private static long sentAt; + private static LatLng sentFrom; + private static ForegroundService service; + /// Read by the service's own thread, so not a plain field. + private static final AtomicBoolean RUNNING = new AtomicBoolean(); + private static final Locator.Watcher WATCHER = (state, where) -> { + if (state == Locator.FOUND && background) { + report(where); + } + }; + + private Shift() { + } + + /// The courier went on line. + public static void begin() { + if (on) { + return; + } + on = true; + sentFrom = null; + Locator.track(true); + Locator.watch(WATCHER); + if (ForegroundService.isSupported() && "and".equals(CN.getPlatformName()) + && !CN.isSimulator()) { + RUNNING.set(true); + service = ForegroundService.start(CHANNEL, "You're online", + "Savor shares your location with customers while you deliver.", null, + Shift::keepAlive); + } + } + + /// The courier went off line, signed out or left the courier's side of the + /// app. Nothing goes on following where the phone is. + public static void end() { + if (!on) { + return; + } + on = false; + taken = false; + checking = false; + Locator.unwatch(WATCHER); + Locator.track(false); + RUNNING.set(false); + if (service != null) { + service.stop(); + service = null; + } + } + + /// Leaving the courier's side of the app, with the session still there: + /// the server is told this courier is off line before the phone stops + /// reporting. Otherwise it would go on offering deliveries to a phone that is + /// no longer listening until the reports went stale, a minute, and each + /// customer would wait out an offer nobody sees. One request, not waited + /// for; if it is lost the minute is the fallback. + public static void leave() { + if (on) { + LatLng where = Locator.here(); + Api.courier().status(new CourierStatusDto(false, where.getLatitude(), + where.getLongitude(), Locator.heading()), Net.to(me -> { }, QUIET)); + } + end(); + } + + /// Whether the courier has a delivery taken and not yet finished. The + /// server refuses to put such a courier off line, so leaving the courier's + /// side then would stop the reports the customer is following while the + /// delivery stays open: Nav refuses to switch mode or sign out instead. + /// + /// Until the courier's side has asked the server, it is not known: a + /// restarted app may be mid-delivery. That counts as taken, see + /// [#checking]. + public static boolean taken() { + return taken || checking; + } + + public static void setTaken(boolean busy) { + taken = busy; + checking = false; + } + + /// The courier's side is asking the server whether a delivery is under + /// way; leaving waits for the answer. A failed lookup ends the wait: a + /// phone that cannot reach the server cannot learn more, and keeping the + /// courier from signing out for good would be worse. + public static void checking(boolean asking) { + checking = asking; + } + + public static boolean isOn() { + return on; + } + + /// The app went to the background, and the reports are this class's. + public static void paused() { + background = true; + } + + /// The app is back, and the courier screen reports again. + public static void resumed() { + background = false; + } + // end::savor-shift[] + + /// The foreground service's task, on a thread of its own: wakes now and + /// then for the car that is not moving, which brings no new positions. + private static void keepAlive(ForegroundService running) { + while (RUNNING.get() && running.isRunning()) { + try { + Thread.sleep(KEEP_ALIVE_MILLIS); + } catch (InterruptedException stop) { + return; + } + if (RUNNING.get()) { + CN.callSerially(() -> { + if (on && background) { + report(Locator.here()); + } + }); + } + } + } + + /// Reports `where` if the car has moved far enough or long enough has + /// passed. The answer says whether the server still has this courier on + /// line; one that does not ends the shift here too. + private static void report(LatLng where) { + long now = System.currentTimeMillis(); + boolean moved = sentFrom == null || Maps.meters(sentFrom, where) >= MIN_METERS; + if (!moved && now - sentAt < KEEP_ALIVE_MILLIS) { + return; + } + sentAt = now; + sentFrom = where; + Api.courier().status(new CourierStatusDto(true, where.getLatitude(), where.getLongitude(), + Locator.heading()), Net.to(me -> { + if (!me.online()) { + end(); + } + }, QUIET)); + } +} diff --git a/scripts/savor/common/src/main/java/com/codenameone/examples/savor/customer/Cart.java b/scripts/savor/common/src/main/java/com/codenameone/examples/savor/customer/Cart.java new file mode 100644 index 00000000000..913f205e970 --- /dev/null +++ b/scripts/savor/common/src/main/java/com/codenameone/examples/savor/customer/Cart.java @@ -0,0 +1,210 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.customer; + +import com.codenameone.examples.savor.api.CartDto; +import com.codenameone.examples.savor.api.CartLineDto; +import com.codenameone.examples.savor.api.DishDto; +import com.codenameone.examples.savor.api.DishOptionDto; +import com.codenameone.examples.savor.api.RestaurantDto; + +import java.util.ArrayList; +import java.util.List; +import java.util.Random; + +/// What the customer is about to order: dishes from one restaurant. +/// +/// Kept in memory and nowhere else. The prices here are for showing while the +/// customer chooses; what is charged is the server's quote, which prices every +/// line again from the menu as it is then. +public final class Cart { + /// One dish, with the options picked for it and how many. + public static final class Line { + public final DishDto dish; + public final List options; + public int quantity; + + Line(DishDto dish, List options, int quantity) { + this.dish = dish; + this.options = options; + this.quantity = quantity; + } + + /// The price of one, with its options. + public long unitCents() { + long cents = dish.priceCents(); + for (int iter = 0; iter < options.size(); iter++) { + cents += options.get(iter).priceCents(); + } + return cents; + } + + /// The options, as a line under the dish's name. + public String optionNames() { + StringBuilder names = new StringBuilder(); + for (int iter = 0; iter < options.size(); iter++) { + names.append(iter == 0 ? "" : ", ").append(options.get(iter).name()); + } + return names.toString(); + } + + boolean same(DishDto other, List picked) { + if (!dish.id().equals(other.id()) || picked.size() != options.size()) { + return false; + } + for (int iter = 0; iter < picked.size(); iter++) { + if (!picked.get(iter).id().equals(options.get(iter).id())) { + return false; + } + } + return true; + } + } + + /// Told whenever the cart changes, so the bar that shows it can follow. + public interface Listener { + void changed(); + } + + private static RestaurantDto restaurant; + private static final List LINES = new ArrayList(); + private static final Random RANDOM = new Random(); + private static String attempt; + private static Listener listener; + + private Cart() { + } + + public static void listen(Listener current) { + listener = current; + } + + /// The restaurant the cart is from, or null while it is empty. + public static RestaurantDto restaurant() { + return LINES.isEmpty() ? null : restaurant; + } + + public static List lines() { + return LINES; + } + + /// Whether adding from `other` would mean starting again: a cart is from + /// one kitchen, because one courier carries it. + public static boolean fromElsewhere(RestaurantDto other) { + return !LINES.isEmpty() && !restaurant.id().equals(other.id()); + } + + /// The most of one line the server takes (Quotes.MAX_QUANTITY). Adding the + /// same dish again, or stepping it up, stops here rather than building a + /// cart that can be neither priced nor placed. + public static final int MAX_QUANTITY = 50; + + /// Adds `quantity` of `dish` with `options`, emptying a cart from another + /// restaurant first. The same dish with the same options is one line. + public static void add(RestaurantDto from, DishDto dish, List options, + int quantity) { + if (fromElsewhere(from)) { + LINES.clear(); + attempt = null; + } + restaurant = from; + for (int iter = 0; iter < LINES.size(); iter++) { + if (LINES.get(iter).same(dish, options)) { + LINES.get(iter).quantity = Math.min(MAX_QUANTITY, + LINES.get(iter).quantity + quantity); + changed(); + return; + } + } + LINES.add(new Line(dish, options, Math.min(MAX_QUANTITY, quantity))); + changed(); + } + + /// Changes how many of `line` there are; none takes it out. + public static void set(Line line, int quantity) { + // Callers may ask for more; MAX_QUANTITY is what one line can hold. + if (quantity <= 0) { + LINES.remove(line); + } else { + line.quantity = Math.min(MAX_QUANTITY, quantity); + } + changed(); + } + + public static void clear() { + LINES.clear(); + attempt = null; + changed(); + } + + public static int count() { + int count = 0; + for (int iter = 0; iter < LINES.size(); iter++) { + count += LINES.get(iter).quantity; + } + return count; + } + + /// The food, before fees, as the menu last said. + public static long subtotalCents() { + long cents = 0; + for (int iter = 0; iter < LINES.size(); iter++) { + cents += LINES.get(iter).unitCents() * LINES.get(iter).quantity; + } + return cents; + } + + /// The cart as the server prices and places it. + public static CartDto dto(double lat, double lng, String address, String note, String promo, + long tipCents, String paymentMethodId) { + List lines = new ArrayList(); + for (int iter = 0; iter < LINES.size(); iter++) { + Line line = LINES.get(iter); + List ids = new ArrayList(); + for (int opt = 0; opt < line.options.size(); opt++) { + ids.add(line.options.get(opt).id()); + } + lines.add(new CartLineDto(line.dish.id(), line.quantity, ids)); + } + return new CartDto(restaurant == null ? "" : restaurant.id(), lines, lat, lng, address, + note, promo, tipCents, paymentMethodId, attempt()); + } + + /// The checkout this cart is placed by: 32 hex digits the server uses as + /// the order's id. A placement whose answer was lost is sent again with the + /// same one, and the server answers with the order it already wrote rather + /// than writing and charging a second. A new one is drawn once the cart is + /// emptied. + static String attempt() { + if (attempt == null) { + char[] out = new char[32]; + for (int iter = 0; iter < out.length; iter++) { + out[iter] = "0123456789abcdef".charAt(RANDOM.nextInt(16)); + } + attempt = new String(out); + } + return attempt; + } + + private static void changed() { + if (listener != null) { + listener.changed(); + } + } +} diff --git a/scripts/savor/common/src/main/java/com/codenameone/examples/savor/customer/CheckoutForm.java b/scripts/savor/common/src/main/java/com/codenameone/examples/savor/customer/CheckoutForm.java new file mode 100644 index 00000000000..23ea740fa1c --- /dev/null +++ b/scripts/savor/common/src/main/java/com/codenameone/examples/savor/customer/CheckoutForm.java @@ -0,0 +1,317 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.customer; + +import com.codename1.maps.LatLng; +import com.codename1.ui.Button; +import com.codename1.ui.Container; +import com.codename1.ui.FontImage; +import com.codename1.ui.Form; +import com.codename1.ui.Label; +import com.codename1.ui.TextField; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; +import com.codenameone.examples.savor.Prefs; +import com.codenameone.examples.savor.Telemetry; +import com.codenameone.examples.savor.api.CartDto; +import com.codenameone.examples.savor.api.PaymentMethodDto; +import com.codenameone.examples.savor.api.QuoteDto; +import com.codenameone.examples.savor.net.Api; +import com.codenameone.examples.savor.net.Net; +import com.codenameone.examples.savor.pay.WalletForm; +import com.codenameone.examples.savor.ui.Lang; +import com.codenameone.examples.savor.ui.Nav; +import com.codenameone.examples.savor.ui.Skeleton; +import com.codenameone.examples.savor.ui.Ui; + +/// The cart, and everything the order still needs before the kitchen sees +/// it: where it goes, how it is paid, a promo, a tip, and what it all comes to +/// as the server prices it. +public final class CheckoutForm { + private static final String[] TIPS = {"0", "10", "15", "20"}; + private static final String[] TIP_NAMES = {"None", "10%", "15%", "20%"}; + + private final Form form; + private final Form previous; + private final Container lines = new Container(BoxLayout.y()); + private final Container totals = new Container(BoxLayout.y()); + private final TextField note = new TextField("", Lang.tr("Notes for the courier")); + private final TextField promo = new TextField("", Lang.tr("Promo code")); + private final Button method = new Button("", "SvRowValue"); + private final Button place; + private String methodId; + private String promoCode; + private int tipPercent = Prefs.tipPercent(); + private boolean placing; + /// Whether the totals shown are the price of the cart as it is now. + private boolean quoted; + /// The food's price as the server last quoted it, or -1 before any quote. + /// The cart keeps the prices the menu had when it was read, and a merchant + /// may have changed them since. + private long pricedSubtotal = -1; + /// The number of the latest quote asked for. + private int quotes; + + private CheckoutForm(Form previous) { + this.previous = previous; + form = Ui.form(Lang.tr("Your order"), "checkout"); + Ui.back(form, previous); + Container page = Ui.page(); + page.add(Ui.plain(Cart.restaurant() == null ? "" : Cart.restaurant().name(), + "SvRestaurantTitle")); + page.add(lines); + + Ui.section(page, Lang.tr("Deliver to")); + String to = CustomerForm.destinationName(); + page.add(Ui.row(FontImage.MATERIAL_PLACE, to == null ? Lang.tr("Current location") : to, + null, null)); + note.setName("note"); + note.setUIID("SvField"); + note.getHintLabel().setUIID("SvFieldHint"); + page.add(note); + + Ui.section(page, Lang.tr("Payment")); + method.setName("payWith"); + method.addActionListener(e -> WalletForm.pick(form, methodId, picked -> { + pick(picked); + form.showBack(); + })); + page.add(Ui.row(FontImage.MATERIAL_CREDIT_CARD, Lang.tr("Pay with"), null, method)); + + Ui.section(page, Lang.tr("Promo")); + Container promoRow = new Container(new BorderLayout()); + promo.setName("promo"); + promo.setUIID("SvField"); + promo.getHintLabel().setUIID("SvFieldHint"); + promoRow.add(BorderLayout.CENTER, promo); + promoRow.add(BorderLayout.EAST, Ui.link("Apply", "apply-promo", e -> { + promoCode = promo.getText().trim().length() == 0 ? null : promo.getText().trim(); + quote(); + })); + page.add(promoRow); + + Ui.section(page, Lang.tr("Tip your courier")); + page.add(Ui.choice("tip", trAll(TIP_NAMES), TIPS, String.valueOf(tipPercent), picked -> { + tipPercent = Integer.parseInt(picked); + quote(); + })); + page.add(Ui.plain(Lang.tr("All of the tip goes to the courier."), "SvMutedSmall")); + + Ui.section(page, Lang.tr("Summary")); + page.add(totals); + form.add(BorderLayout.CENTER, page); + + place = Ui.primary("Place order", "place", e -> place()); + Container bar = new Container(new BorderLayout()); + bar.setUIID("SvAddBar"); + bar.setSafeArea(true); + bar.add(BorderLayout.CENTER, place); + form.add(BorderLayout.SOUTH, bar); + fillLines(); + loadMethods(); + quote(); + } + + /// Opens the cart over `previous`. + public static void show(Form previous) { + if (Cart.count() == 0) { + return; + } + new CheckoutForm(previous).form.show(); + } + + private static String[] trAll(String[] texts) { + String[] done = new String[texts.length]; + for (int iter = 0; iter < texts.length; iter++) { + done[iter] = Lang.tr(texts[iter]); + } + return done; + } + + private void fillLines() { + lines.removeAll(); + for (final Cart.Line line : Cart.lines()) { + Container row = new Container(new BorderLayout()); + row.setUIID("SvCartLine"); + Container words = new Container(BoxLayout.y()); + words.add(Ui.plain(line.dish.name(), "SvDishName")); + if (line.options.size() > 0) { + words.add(Ui.plain(line.optionNames(), "SvMutedSmall")); + } + words.add(Ui.plain(Ui.money(line.unitCents() * line.quantity, null), "SvDishPrice")); + row.add(BorderLayout.CENTER, words); + Container stepper = new Container(BoxLayout.x()); + stepper.setUIID("SvStepper"); + stepper.add(Ui.flat(line.quantity == 1 ? FontImage.MATERIAL_DELETE_OUTLINE + : FontImage.MATERIAL_REMOVE, "less-" + line.dish.id(), e -> change(line, -1))); + stepper.add(new Label(String.valueOf(line.quantity), "SvQuantity")); + stepper.add(Ui.flat(FontImage.MATERIAL_ADD, "more-" + line.dish.id(), + e -> change(line, 1))); + row.add(BorderLayout.EAST, stepper); + lines.add(row); + } + lines.add(Ui.link("Add items", "add-items", e -> previous.showBack())); + form.revalidateLater(); + } + + private void change(Cart.Line line, int by) { + Cart.set(line, line.quantity + by); + if (Cart.count() == 0) { + previous.showBack(); + return; + } + fillLines(); + quote(); + } + + private void loadMethods() { + Api.payments().methods(Net.to(found -> { + for (PaymentMethodDto each : found) { + if (methodId == null && each.isDefault()) { + pick(each); + } + } + if (methodId == null && !found.isEmpty()) { + pick(found.get(0)); + } + }, Ui::fail)); + } + + private void pick(PaymentMethodDto picked) { + methodId = picked.id(); + method.setText(WalletForm.label(picked)); + form.revalidateLater(); + } + + private long tipCents() { + long subtotal = pricedSubtotal >= 0 ? pricedSubtotal : Cart.subtotalCents(); + return subtotal * tipPercent / 100; + } + + private CartDto cart() { + LatLng to = CustomerForm.destination(); + String address = CustomerForm.destinationName(); + return Cart.dto(to.getLatitude(), to.getLongitude(), + address == null ? Lang.tr("Current location") : address, + note.getText().trim(), promoCode, tipCents(), methodId); + } + + /// Prices the cart as it is now. Until that price is back the order cannot + /// be placed: the button would still show the old total, and the server + /// would charge for the changed cart. Only the latest request's answer is + /// used; one overtaken by a later change is dropped. + private void quote() { + final int asked = ++quotes; + quoted = false; + place.setEnabled(false); + totals.removeAll(); + if (!CustomerForm.located()) { + // Priced, and then placed, against the fallback city otherwise. + totals.add(Ui.plain(Lang.tr("Choose where the food goes"), "SvMuted")); + form.revalidateLater(); + return; + } + totals.add(new Skeleton(3)); + form.revalidateLater(); + Api.orders().quote(cart(), Net.to(quote -> { + if (asked != quotes) { + return; + } + long before = pricedSubtotal; + pricedSubtotal = quote.subtotalCents(); + if (before != pricedSubtotal && quote.tipCents() != tipCents()) { + // The tip was worked out from prices the server no longer + // charges: asked again with the percentage of the real food + // price, which converges, as the subtotal does not move with it. + quote(); + return; + } + totals(quote); + quoted = true; + place.setEnabled(!placing); + }, (status, message) -> { + if (asked != quotes) { + return; + } + totals.removeAll(); + totals.add(Ui.text(message, "SvError")); + form.revalidateLater(); + })); + } + + private void totals(QuoteDto quote) { + String currency = quote.currency(); + totals.removeAll(); + totals.add(Ui.fact(Lang.tr("Food"), Ui.money(quote.subtotalCents(), currency))); + totals.add(Ui.fact(Lang.tr("Delivery"), quote.deliveryFeeCents() == 0 ? Lang.tr("Free") + : Ui.money(quote.deliveryFeeCents(), currency))); + totals.add(Ui.fact(Lang.tr("Service"), Ui.money(quote.serviceFeeCents(), currency))); + if (quote.smallOrderCents() > 0) { + totals.add(Ui.fact(Lang.tr("Small order"), Ui.money(quote.smallOrderCents(), currency))); + } + totals.add(Ui.fact(Lang.tr("Tax"), Ui.money(quote.taxCents(), currency))); + if (quote.discountCents() > 0) { + totals.add(Ui.fact(Lang.tr("Discount"), "-" + Ui.money(quote.discountCents(), currency), + "SvDiscount")); + } + if (quote.tipCents() > 0) { + totals.add(Ui.fact(Lang.tr("Tip"), Ui.money(quote.tipCents(), currency))); + } + totals.add(Ui.fact(Lang.tr("Total"), Ui.money(quote.totalCents(), currency), "SvTotal")); + if (quote.promoMessage() != null && quote.promoMessage().length() > 0) { + totals.add(Ui.plain(quote.promoMessage(), "SvMutedSmall")); + } + totals.add(Ui.plain(Lang.tr("Arrives in about {0} min", String.valueOf(quote.etaMinutes())), + "SvMutedSmall")); + Telemetry.orderQuoted(Cart.lines().size()); + place.setText(Lang.tr("Place order") + Ui.DOT + Ui.money(quote.totalCents(), currency)); + form.revalidateLater(); + } + + private void place() { + if (placing || !quoted) { + return; + } + if (!CustomerForm.located()) { + Ui.say("Choose where the food goes"); + return; + } + if (methodId == null) { + Ui.say("Choose how to pay"); + return; + } + placing = true; + place.setEnabled(false); + Prefs.setTipPercent(tipPercent); + Api.orders().place(cart(), Net.to(order -> { + placing = false; + Telemetry.orderPlaced("cash".equals(methodId), promoCode != null); + Cart.clear(); + // The checkout is done with: back from the order goes home. + Form home = Nav.build(); + if (home != null) { + TrackForm.show(home, order.id()); + } + }, (status, message) -> { + placing = false; + place.setEnabled(quoted); + Ui.fail(status, message); + })); + } +} diff --git a/scripts/savor/common/src/main/java/com/codenameone/examples/savor/customer/CustomerForm.java b/scripts/savor/common/src/main/java/com/codenameone/examples/savor/customer/CustomerForm.java new file mode 100644 index 00000000000..a30c9047b88 --- /dev/null +++ b/scripts/savor/common/src/main/java/com/codenameone/examples/savor/customer/CustomerForm.java @@ -0,0 +1,507 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.customer; + +import com.codename1.maps.LatLng; +import com.codename1.ui.Button; +import com.codename1.ui.CN; +import com.codename1.ui.Component; +import com.codename1.ui.Container; +import com.codename1.ui.FontImage; +import com.codename1.ui.Form; +import com.codename1.ui.Label; +import com.codename1.ui.TextField; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.layouts.GridLayout; +import com.codename1.ui.util.UITimer; +import com.codenameone.examples.savor.Prefs; +import com.codenameone.examples.savor.api.OrderDto; +import com.codenameone.examples.savor.api.PlaceDto; +import com.codenameone.examples.savor.api.RestaurantSummaryDto; +import com.codenameone.examples.savor.api.UserDto; +import com.codenameone.examples.savor.live.LiveChannel; +import com.codenameone.examples.savor.map.Locator; +import com.codenameone.examples.savor.merchant.RestaurantApplicationForm; +import com.codenameone.examples.savor.net.Api; +import com.codenameone.examples.savor.net.Net; +import com.codenameone.examples.savor.net.Session; +import com.codenameone.examples.savor.pay.ReceiptForm; +import com.codenameone.examples.savor.pay.WalletForm; +import com.codenameone.examples.savor.ui.Lang; +import com.codenameone.examples.savor.ui.Menu; +import com.codenameone.examples.savor.ui.Nav; +import com.codenameone.examples.savor.ui.Skeleton; +import com.codenameone.examples.savor.ui.ProfileForm; +import com.codenameone.examples.savor.ui.Ui; + +import java.util.List; + +/// The customer's home: where the food is coming to, what is near, and what +/// is already on its way. +/// +/// Four tabs share the screen -- the restaurants near the address, a search, +/// the orders, and the favorites -- under one "Deliver to" header, with the +/// cart's button over all of them while something is in it. +public final class CustomerForm extends LiveChannel.Adapter implements Nav.Home, Cart.Listener { + private static final Net.Failed QUIET = (status, message) -> { }; + /// The watcher waiting for the first address, at most one for every home. + private static Locator.Watcher locating; + /// How often an order on its way is asked about while the live channel is + /// down. With it up, a frame says when to ask. + private static final int POLL_MILLIS = 15000; + private static final String[] SORTS = {"distance", "rating", "fee", "time"}; + private static final String[] SORT_NAMES = {"Nearest", "Rating", "Fee", "Fastest"}; + + private final Form form = Ui.home("Customer"); + private final Menu menu = new Menu(form, Nav.CUSTOMER); + private final Container body = new Container(new BorderLayout()); + private final Container tabs = new Container(new GridLayout(1, 4)); + private final Button cart = new Button("", "SvCartButton"); + private final Label deliverTo = new Label("", "SvDeliverTo"); + private String cuisine; + private String sort = SORTS[0]; + private String query = ""; + private int tab; + private Container restaurants; + private Container active; + + public CustomerForm() { + menu.item(FontImage.MATERIAL_HOME, "Restaurants", "home", () -> show(0)); + menu.item(FontImage.MATERIAL_RECEIPT_LONG, "Your orders", "history", () -> show(2)); + menu.item(FontImage.MATERIAL_FAVORITE, "Favorites", "favorites", () -> show(3)); + menu.item(FontImage.MATERIAL_ACCOUNT_BALANCE_WALLET, "Wallet", "wallet", + () -> WalletForm.show(form)); + menu.item(FontImage.MATERIAL_PERSON, "Profile and addresses", "profile", + () -> ProfileForm.show(form, () -> form.showBack())); + UserDto user = Session.user(); + if (user != null && !user.courier()) { + menu.item(FontImage.MATERIAL_TWO_WHEELER, "Deliver with Savor", "deliver", + () -> Nav.switchTo(Nav.COURIER)); + } + if (user != null && !user.merchant()) { + menu.item(FontImage.MATERIAL_STOREFRONT, "List your restaurant", "list-restaurant", + () -> RestaurantApplicationForm.show(form)); + } + // The header and the tab bar each reach into the strip the device + // keeps clear at their edge, padded by it, so the strip wears their + // colour; padding the whole screen left both strips to whatever lay + // behind it, and the list showed through under the tabs. + Container screen = new Container(new BorderLayout()); + Container top = header(); + top.setSafeArea(true); + screen.add(BorderLayout.NORTH, top); + screen.add(BorderLayout.CENTER, body); + Container south = new Container(BoxLayout.y()); + cart.setName("cart"); + cart.addActionListener(e -> CheckoutForm.show(form)); + south.add(cart); + tabs.setUIID("SvTabBar"); + tab(FontImage.MATERIAL_HOME, "Home", "tab-home", 0); + tab(FontImage.MATERIAL_SEARCH, "Search", "tab-search", 1); + tab(FontImage.MATERIAL_RECEIPT_LONG, "Orders", "tab-orders", 2); + tab(FontImage.MATERIAL_FAVORITE_BORDER, "Favorites", "tab-favorites", 3); + tabs.setSafeArea(true); + south.add(tabs); + screen.add(BorderLayout.SOUTH, south); + form.add(screen); + menu.install(); + form.addShowListener(e -> { + LiveChannel.listen(this); + Cart.listen(this); + changed(); + where(); + show(tab); + }); + UITimer.timer(POLL_MILLIS, true, form, () -> { + if (!LiveChannel.connected() && tab == 0) { + loadActive(); + } + }); + } + + @Override + public void start() { + if (Prefs.deliverTo() != null) { + return; + } + // No address chosen yet: the one the phone is at, in words, once the + // server has put a name on it. Only a position the device reported + // counts: until then Locator.here() is the middle of the demo city, + // and saving that would send the food there. Made-up positions are + // the exception, since they are all there is. + if (Locator.simulated()) { + name(Locator.here()); + return; + } + stopLocating(); + locating = new Locator.Watcher() { + @Override + public void located(int state, LatLng where) { + if (state == Locator.FOUND) { + stopLocating(); + name(where); + } else if (state == Locator.DENIED || state == Locator.UNAVAILABLE) { + // No position to be had: the customer picks an address. + stopLocating(); + } + } + }; + Locator.watch(locating); + } + + /// Stops the first-address lookup, if one is still waiting. Nav calls this + /// whenever the home is discarded (rebuilt, another mode, signed out), so a + /// search that never answered does not keep the receiver running on screens + /// that have no use for it, and rebuilt homes do not pile up watchers. + public static void stopLocating() { + if (locating != null) { + Locator.unwatch(locating); + locating = null; + } + } + + private void name(final LatLng here) { + Api.geo().reverse(here.getLatitude(), here.getLongitude(), Net.to(place -> { + if (place != null && Prefs.deliverTo() == null) { + Prefs.setDeliverTo(new PlaceDto(place.name(), place.address(), + here.getLatitude(), here.getLongitude())); + where(); + } + }, QUIET)); + } + + @Override + public Form form() { + return form; + } + + /// Where the food goes: the chosen address, or where the phone is. + public static LatLng destination() { + PlaceDto to = Prefs.deliverTo(); + return to == null ? Locator.here() : new LatLng(to.lat(), to.lng()); + } + + /// Whether [#destination] is a real place to send food: a chosen address, + /// or a fix the phone really has. Without either it is the demo city + /// [Locator] falls back on, which is fine for browsing and wrong for an + /// order. The simulator's made-up position counts as found. + public static boolean located() { + return Prefs.deliverTo() != null || Locator.simulated() + || Locator.state() == Locator.FOUND; + } + + /// The chosen address in words, or null with none chosen. + public static String destinationName() { + PlaceDto to = Prefs.deliverTo(); + return to == null ? null : to.address() != null && to.address().length() > 0 + ? to.address() : to.name(); + } + + private Container header() { + Container header = new Container(new BorderLayout()); + header.setUIID("SvHomeHeader"); + header.add(BorderLayout.WEST, menu.flatButton()); + Container words = new Container(BoxLayout.y()); + words.add(Ui.plain(Lang.tr("Deliver to"), "SvMutedSmall")); + Ui.icon(deliverTo, FontImage.MATERIAL_EXPAND_MORE, 3.2f); + deliverTo.setTextPosition(Component.LEFT); + words.add(deliverTo); + header.add(BorderLayout.CENTER, Ui.tap(words, "deliver-to", + e -> PlaceSearchForm.show(form, destination(), picked -> { + Prefs.setDeliverTo(picked); + form.showBack(); + }))); + return header; + } + + private void where() { + String name = destinationName(); + deliverTo.setText(name == null ? Lang.tr("Current location") : name); + Ui.relayout(deliverTo.getParent()); + } + + private void tab(char icon, String text, String name, final int index) { + Button button = new Button(Lang.tr(text), "SvTab"); + button.setName(name); + FontImage.setMaterialIcon(button, icon, 3.6f); + button.setTextPosition(Component.BOTTOM); + button.addActionListener(e -> show(index)); + tabs.add(button); + } + + private void show(int index) { + tab = index; + for (int iter = 0; iter < tabs.getComponentCount(); iter++) { + tabs.getComponentAt(iter).setUIID(iter == index ? "SvTabSelected" : "SvTab"); + } + body.removeAll(); + if (index == 0) { + body.add(BorderLayout.CENTER, home()); + } else if (index == 1) { + body.add(BorderLayout.CENTER, search()); + } else if (index == 2) { + body.add(BorderLayout.CENTER, orders()); + } else { + body.add(BorderLayout.CENTER, favorites()); + } + form.revalidate(); + } + + /// Good morning, afternoon or evening, by the phone's clock. + static String greeting(String name) { + int hour = java.util.Calendar.getInstance().get(java.util.Calendar.HOUR_OF_DAY); + String part = hour < 5 ? "Good evening" : hour < 12 ? "Good morning" + : hour < 18 ? "Good afternoon" : "Good evening"; + return name == null || name.length() == 0 ? Lang.tr(part) + : Lang.tr(part) + ", " + firstName(name); + } + + private static String firstName(String name) { + int space = name.indexOf(' '); + return space > 0 ? name.substring(0, space) : name; + } + + private Container home() { + Container page = Ui.page(); + page.setName("restaurants"); + UserDto user = Session.user(); + page.add(Ui.plain(greeting(user == null ? null : user.displayName()), "SvGreeting")); + page.add(Ui.plain(Lang.tr("What would you like to eat?"), "SvMuted")); + active = new Container(BoxLayout.y()); + page.add(active); + page.add(cuisines()); + page.add(Ui.choice("sort", tr(SORT_NAMES), SORTS, sort, picked -> { + sort = picked; + loadRestaurants(); + })); + restaurants = new Container(BoxLayout.y()); + restaurants.add(new Skeleton(3)); + page.add(restaurants); + Ui.pull(page, () -> { + loadActive(); + loadRestaurants(); + }); + loadActive(); + loadRestaurants(); + return page; + } + + private static String[] tr(String[] texts) { + String[] done = new String[texts.length]; + for (int iter = 0; iter < texts.length; iter++) { + done[iter] = Lang.tr(texts[iter]); + } + return done; + } + + private Container cuisines() { + Container row = new Container(BoxLayout.x()); + row.setUIID("SvCuisineRow"); + row.setScrollableX(true); + row.setScrollVisible(false); + row.setName("cuisines"); + for (int iter = 0; iter < Food.CUISINES.length; iter++) { + final String name = Food.CUISINES[iter]; + boolean picked = name.equals(cuisine); + Button chip = new Button(Lang.tr(name), picked ? "SvCuisineSelected" : "SvCuisine"); + chip.setName("cuisine-" + name); + FontImage.setMaterialIcon(chip, Food.icon(name), 5f); + chip.setTextPosition(Component.BOTTOM); + chip.addActionListener(e -> { + cuisine = name.equals(cuisine) ? null : name; + show(0); + }); + row.add(chip); + } + return row; + } + + private void loadActive() { + if (active == null) { + return; + } + final Container into = active; + Api.orders().active(Net.to(found -> { + into.removeAll(); + for (final OrderDto order : found) { + Container banner = Ui.banner(FontImage.MATERIAL_DELIVERY_DINING, + order.restaurantName(), Food.state(order.state()), "SvBannerIcon"); + banner.setUIID("SvActiveOrder"); + into.add(Ui.tap(banner, "active-" + order.id(), + e -> TrackForm.show(form, order.id()))); + } + form.revalidateLater(); + }, QUIET)); + } + + private void loadRestaurants() { + final Container into = restaurants; + LatLng to = destination(); + Api.catalog().restaurants(to.getLatitude(), to.getLongitude(), cuisine, null, sort, + Net.to(found -> fill(into, found, cuisine == null + ? Lang.tr("Nothing delivers here yet") + : Lang.tr("No {0} near you", Lang.tr(cuisine))), + Ui.failed(into, this::loadRestaurants))); + } + + private void fill(Container into, List found, String none) { + into.removeAll(); + if (found.isEmpty()) { + into.add(Ui.empty(FontImage.MATERIAL_STOREFRONT, none, null)); + } + for (final RestaurantSummaryDto restaurant : found) { + into.add(Food.card(restaurant, null, "restaurant-" + restaurant.id(), + e -> RestaurantForm.show(form, restaurant.id(), null))); + } + Ui.refresh(form); + } + + private Container search() { + Container screen = new Container(new BorderLayout()); + final TextField field = new TextField(query, Lang.tr("Restaurants, dishes, cuisines")); + field.setName("search"); + field.setUIID("SvSearchField"); + FontImage.setMaterialIcon(field.getHintLabel(), FontImage.MATERIAL_SEARCH); + final Container results = Ui.list("search-results"); + results.removeAll(); + results.add(Ui.empty(FontImage.MATERIAL_SEARCH, Lang.tr("Find something to eat"), null)); + Runnable run = () -> { + query = field.getText().trim(); + if (query.length() < 2) { + // Back to the empty search: results for a longer term would no + // longer match what the field shows. + results.removeAll(); + results.add(Ui.empty(FontImage.MATERIAL_SEARCH, Lang.tr("Find something to eat"), null)); + results.revalidate(); + return; + } + LatLng to = destination(); + results.removeAll(); + results.add(new Skeleton(3)); + results.revalidate(); + // An answer for an earlier term that arrives late is dropped: the + // results are always for what is in the field. + final String asked = query; + Api.catalog().restaurants(to.getLatitude(), to.getLongitude(), null, asked, sort, + Net.to(found -> { + if (asked.equals(query)) { + fill(results, found, Lang.tr("Nothing matches \"{0}\"", asked)); + } + }, (status, message) -> { + if (asked.equals(query)) { + Ui.failed(results, null).failed(status, message); + } + })); + }; + field.setDoneListener(e -> run.run()); + field.addDataChangedListener((type, index) -> UITimer.timer(400, false, form, () -> { + if (!field.getText().trim().equals(query)) { + run.run(); + } + })); + Container top = new Container(new BorderLayout()); + top.setUIID("SvSearchBar"); + top.add(BorderLayout.CENTER, field); + screen.add(BorderLayout.NORTH, top); + screen.add(BorderLayout.CENTER, results); + if (query.length() >= 2) { + run.run(); + } + return screen; + } + + private Container orders() { + final Container list = Ui.list("orders"); + final Runnable[] load = new Runnable[1]; + load[0] = () -> Api.orders().history(Net.to(found -> { + list.removeAll(); + if (found.isEmpty()) { + list.add(Ui.empty(FontImage.MATERIAL_RECEIPT_LONG, Lang.tr("No orders yet"), + Lang.tr("What you order shows here."))); + } + for (final OrderDto order : found) { + list.add(orderRow(order)); + } + Ui.refresh(form); + }, Ui.failed(list, () -> load[0].run()))); + Ui.pull(list, load[0]); + load[0].run(); + return list; + } + + private Container orderRow(final OrderDto order) { + Container row = new Container(new BorderLayout()); + row.setUIID("SvOrderRow"); + row.add(BorderLayout.WEST, Food.photo(null, null, "SvOrderThumb", 12f, 12f)); + Container words = new Container(BoxLayout.y()); + words.add(Ui.plain(order.restaurantName(), "SvRowTitle")); + words.add(Ui.plain(Ui.money(order.totalCents(), order.currency()) + Ui.DOT + + Ui.day(order.placedAt()), "SvMutedSmall")); + words.add(Ui.plain(Food.state(order.state()), "SvMutedSmall")); + row.add(BorderLayout.CENTER, words); + boolean coming = Food.active(order.state()); + if (coming) { + return Ui.tap(row, "order-" + order.id(), e -> TrackForm.show(form, order.id())); + } + Button again = Ui.link("Reorder", "reorder-" + order.id(), + e -> RestaurantForm.show(form, order.restaurantId(), order.lines())); + row.add(BorderLayout.EAST, again); + return Ui.tap(row, "order-" + order.id(), e -> { + if (order.state() == com.codenameone.examples.savor.api.OrderState.DELIVERED) { + ReceiptForm.open(form, order.id()); + } else { + TrackForm.show(form, order.id()); + } + }); + } + + private Container favorites() { + final Container list = Ui.list("favorites"); + final Runnable[] load = new Runnable[1]; + load[0] = () -> { + LatLng to = destination(); + Api.orders().favorites(to.getLatitude(), to.getLongitude(), Net.to(found -> fill(list, + found, Lang.tr("Tap the heart on a restaurant to keep it here")), + Ui.failed(list, () -> load[0].run()))); + }; + Ui.pull(list, load[0]); + load[0].run(); + return list; + } + + @Override + public void changed() { + int count = Cart.count(); + cart.setHidden(count == 0); + cart.setVisible(count > 0); + if (count > 0) { + cart.setText(Lang.tr("View cart ({0})", String.valueOf(count)) + Ui.DOT + + Ui.money(Cart.subtotalCents(), null)); + } + form.revalidateLater(); + } + + @Override + public void orderChanged(String id, String state) { + if (CN.getCurrentForm() == form && tab == 0) { + loadActive(); + } + } +} diff --git a/scripts/savor/common/src/main/java/com/codenameone/examples/savor/customer/DishForm.java b/scripts/savor/common/src/main/java/com/codenameone/examples/savor/customer/DishForm.java new file mode 100644 index 00000000000..558df3b0763 --- /dev/null +++ b/scripts/savor/common/src/main/java/com/codenameone/examples/savor/customer/DishForm.java @@ -0,0 +1,120 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.customer; + +import com.codename1.components.Switch; +import com.codename1.ui.Button; +import com.codename1.ui.Container; +import com.codename1.ui.Dialog; +import com.codename1.ui.FontImage; +import com.codename1.ui.Form; +import com.codename1.ui.Label; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; +import com.codenameone.examples.savor.api.DishDto; +import com.codenameone.examples.savor.api.DishOptionDto; +import com.codenameone.examples.savor.api.RestaurantDto; +import com.codenameone.examples.savor.ui.Lang; +import com.codenameone.examples.savor.ui.Ui; + +import java.util.ArrayList; +import java.util.List; + +/// One dish: what comes with it, how many, and the bar that adds it to the +/// order at the price it comes to. +public final class DishForm { + private final DishDto dish; + private final List picked = new ArrayList(); + private final Label count = new Label("1", "SvQuantity"); + private final Button add; + private int quantity = 1; + + private DishForm(final Form previous, final RestaurantDto restaurant, DishDto dish) { + this.dish = dish; + final Form form = Ui.form(dish.name(), "dish"); + Ui.back(form, previous); + Container page = Ui.page(); + page.add(Food.photo(dish.photoId(), restaurant.cuisine(), "SvHeroPhoto", 100f, 42f)); + page.add(Ui.plain(dish.name(), "SvRestaurantTitle")); + page.add(Ui.plain(Ui.money(dish.priceCents(), null), "SvDishPrice")); + if (dish.description() != null && dish.description().length() > 0) { + page.add(Ui.text(dish.description(), "SvMuted")); + } + if (dish.options() != null && !dish.options().isEmpty()) { + Ui.section(page, Lang.tr("Add extras")); + for (final DishOptionDto option : dish.options()) { + Switch on = Ui.toggle("option-" + option.id(), false, value -> { + if (value) { + picked.add(option); + } else { + picked.remove(option); + } + price(); + }); + page.add(Ui.row(FontImage.MATERIAL_ADD_CIRCLE_OUTLINE, option.name(), + option.priceCents() == 0 ? null + : "+" + Ui.money(option.priceCents(), null), on)); + } + } + form.add(BorderLayout.CENTER, page); + + Container bar = new Container(new BorderLayout()); + bar.setUIID("SvAddBar"); + bar.setSafeArea(true); + Container stepper = new Container(BoxLayout.x()); + stepper.setUIID("SvStepper"); + stepper.add(Ui.flat(FontImage.MATERIAL_REMOVE, "less", e -> step(-1))); + stepper.add(count); + stepper.add(Ui.flat(FontImage.MATERIAL_ADD, "more", e -> step(1))); + bar.add(BorderLayout.WEST, stepper); + add = Ui.primary("", "add-to-order", e -> { + if (Cart.fromElsewhere(restaurant) && !Dialog.show(Lang.tr("Start a new cart?"), + Lang.tr("Your cart has food from {0}.", Cart.restaurant().name()), + Lang.tr("New cart"), Lang.tr("Keep it"))) { + return; + } + Cart.add(restaurant, this.dish, new ArrayList(picked), quantity); + previous.showBack(); + }); + bar.add(BorderLayout.CENTER, add); + form.add(BorderLayout.SOUTH, bar); + price(); + form.show(); + } + + /// Opens `dish` of `restaurant` over `previous`. + public static void show(Form previous, RestaurantDto restaurant, DishDto dish) { + new DishForm(previous, restaurant, dish); + } + + private void step(int by) { + quantity = Math.max(1, Math.min(20, quantity + by)); + count.setText(String.valueOf(quantity)); + price(); + } + + private void price() { + long unit = dish.priceCents(); + for (int iter = 0; iter < picked.size(); iter++) { + unit += picked.get(iter).priceCents(); + } + add.setText(Lang.tr("Add to order") + Ui.DOT + Ui.money(unit * quantity, null)); + Ui.relayout(add.getParent()); + } +} diff --git a/scripts/savor/common/src/main/java/com/codenameone/examples/savor/customer/Food.java b/scripts/savor/common/src/main/java/com/codenameone/examples/savor/customer/Food.java new file mode 100644 index 00000000000..34de1ede67f --- /dev/null +++ b/scripts/savor/common/src/main/java/com/codenameone/examples/savor/customer/Food.java @@ -0,0 +1,245 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.customer; + +import com.codename1.ui.CN; +import com.codename1.ui.Component; +import com.codename1.ui.Container; +import com.codename1.ui.FontImage; +import com.codename1.ui.Graphics; +import com.codename1.ui.Label; +import com.codename1.ui.events.ActionEvent; +import com.codename1.ui.events.ActionListener; +import com.codename1.ui.geom.GeneralPath; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.layouts.LayeredLayout; +import com.codenameone.examples.savor.api.OrderState; +import com.codenameone.examples.savor.api.RestaurantSummaryDto; +import com.codenameone.examples.savor.ui.Lang; +import com.codenameone.examples.savor.ui.Ui; + +/// What the customer's screens share: the cuisines, a restaurant as a card, and +/// an order's state in words. +public final class Food { + /// The cuisines of the home screen's row, in its order: the server's + /// `DemoRestaurants.CUISINES`, which a restaurant is filed under. + public static final String[] CUISINES = {"Pizza", "Burgers", "Sushi", "Chicken", "Kebab", + "Rice", "Steak", "Tacos", "Salads", "Donuts", "Dessert", "Coffee"}; + private static final char[] ICONS = {FontImage.MATERIAL_LOCAL_PIZZA, + FontImage.MATERIAL_LUNCH_DINING, FontImage.MATERIAL_SET_MEAL, + FontImage.MATERIAL_DINNER_DINING, FontImage.MATERIAL_KEBAB_DINING, + FontImage.MATERIAL_RICE_BOWL, FontImage.MATERIAL_OUTDOOR_GRILL, FontImage.MATERIAL_TAPAS, + FontImage.MATERIAL_ECO, FontImage.MATERIAL_BAKERY_DINING, FontImage.MATERIAL_ICECREAM, + FontImage.MATERIAL_LOCAL_CAFE}; + + private Food() { + } + + /// The icon of `cuisine`, or a plate for one the row does not have. + public static char icon(String cuisine) { + for (int iter = 0; iter < CUISINES.length; iter++) { + if (CUISINES[iter].equalsIgnoreCase(cuisine)) { + return ICONS[iter]; + } + } + return FontImage.MATERIAL_RESTAURANT; + } + + /// A photo's place: the cuisine's icon on a tinted ground until the photo + /// comes, and for good when there is none. + public static Label photo(String photoId, String cuisine, String uiid, float widthMM, + float heightMM) { + Label photo = new Cover(uiid, cover(cuisine)); + // Light on the cover's dark ground, in either theme. + photo.getAllStyles().setFgColor(COVER_INK); + Ui.icon(photo, icon(cuisine), Math.min(heightMM, widthMM) * 0.42f); + photo.setAlignment(Component.CENTER); + int width = CN.convertToPixels(widthMM); + int height = CN.convertToPixels(heightMM); + photo.setPreferredW(width); + photo.setPreferredH(height); + Pictures.into(photo, photoId, width, height); + return photo; + } + + /// Two deep tones per cuisine, top to bottom, for the cover a restaurant + /// or a dish has until it has a photograph: dark and warm, the way a + /// menu's own photography is lit, rather than a pale tint behind an icon. + private static final int[][] COVERS = { + {0x7a2e1d, 0x2b1410}, {0x6b3f1f, 0x24160d}, {0x1f3d4a, 0x0e1a20}, + {0x6e4a1c, 0x251a0c}, {0x5c2a2a, 0x1f0f10}, {0x3d4a2a, 0x151a0f}, + {0x4a2418, 0x170c09}, {0x6b5317, 0x241c08}, {0x2f4f35, 0x0f1c12}, + {0x6a3350, 0x22111b}, {0x4d2e4a, 0x1a0f19}, {0x4a3424, 0x1a120c}}; + + private static final int COVER_INK = 0xf3e7d3; + + private static int[] cover(String cuisine) { + for (int iter = 0; iter < CUISINES.length; iter++) { + if (CUISINES[iter].equalsIgnoreCase(cuisine)) { + return COVERS[iter % COVERS.length]; + } + } + return new int[] {0x33302c, 0x121110}; + } + + /// A photo's place that paints its cover itself while it shows an icon, + /// and keeps the photograph [Pictures] gives it to the same rounded card. + private static final class Cover extends Label { + private final int[] tones; + + Cover(String uiid, int[] tones) { + super("", uiid); + this.tones = tones; + } + + @Override + public void paint(Graphics g) { + int x = getX(); + int y = getY(); + int w = getWidth(); + int h = getHeight(); + int[] clip = g.getClip(); + // A hero runs edge to edge; everything else is a card, and its + // photograph is rounded with it. + if (!"SvHeroPhoto".equals(getUIID()) && g.isShapeClipSupported()) { + float r = CN.convertToPixels(2f); + GeneralPath shape = new GeneralPath(); + shape.moveTo(x + r, y); + shape.lineTo(x + w - r, y); + shape.quadTo(x + w, y, x + w, y + r); + shape.lineTo(x + w, y + h - r); + shape.quadTo(x + w, y + h, x + w - r, y + h); + shape.lineTo(x + r, y + h); + shape.quadTo(x, y + h, x, y + h - r); + shape.lineTo(x, y + r); + shape.quadTo(x, y, x + r, y); + shape.closePath(); + g.setClip(shape); + } + if (getIcon() instanceof FontImage) { + g.fillLinearGradient(tones[0], tones[1], x, y, w, h, false); + } + super.paint(g); + g.setClip(clip); + } + } + + /// `$`, `$$` or `$$$`. + public static String priceLevel(int level) { + StringBuilder signs = new StringBuilder(); + for (int iter = 0; iter < Math.max(1, Math.min(4, level)); iter++) { + signs.append('$'); + } + return signs.toString(); + } + + /// The line under a restaurant's name: rating, time, fee. + public static String meta(double rating, long ratings, int eta, long feeCents, + String currency) { + String stars = ratings == 0 ? Lang.tr("New") : Ui.tenths(rating) + " (" + ratings + ")"; + return stars + Ui.DOT + Lang.tr("{0} min", String.valueOf(eta)) + Ui.DOT + + (feeCents == 0 ? Lang.tr("Free delivery") + : Lang.tr("{0} delivery", Ui.money(feeCents, currency))); + } + + /// A restaurant in a list: its photo across the top, its name, and what + /// it costs to have it come. + public static Container card(final RestaurantSummaryDto restaurant, String currency, + String name, ActionListener open) { + Container card = new Container(new BorderLayout()); + card.setUIID("SvRestaurantCard"); + Container top = new Container(new LayeredLayout()); + Label photo = photo(restaurant.photoId(), restaurant.cuisine(), "SvRestaurantPhoto", + 86f, 30f); + top.add(photo); + if (restaurant.favorite()) { + Label heart = new Label("", "SvPhotoBadge"); + Ui.icon(heart, FontImage.MATERIAL_FAVORITE, 3.6f); + top.add(heart); + ((LayeredLayout) top.getLayout()).setInsets(heart, "1.5mm 1.5mm auto auto"); + } + if (!restaurant.open()) { + Label closed = new Label(Lang.tr("Closed"), "SvClosedBadge"); + top.add(closed); + ((LayeredLayout) top.getLayout()).setInsets(closed, "auto auto 1.5mm 1.5mm"); + } + card.add(BorderLayout.NORTH, top); + Container words = new Container(BoxLayout.y()); + words.setUIID("SvRestaurantWords"); + Container title = new Container(new BorderLayout()); + title.add(BorderLayout.CENTER, Ui.plain(restaurant.name(), "SvRestaurantName")); + title.add(BorderLayout.EAST, Ui.plain(priceLevel(restaurant.priceLevel()), "SvMutedSmall")); + words.add(title); + words.add(Ui.plain(restaurant.cuisine() + Ui.DOT + Ui.distance(restaurant.distanceMeters()), + "SvMutedSmall")); + words.add(Ui.text(meta(restaurant.rating(), restaurant.ratingCount(), + restaurant.etaMinutes(), restaurant.deliveryFeeCents(), currency), "SvMetaLine")); + card.add(BorderLayout.CENTER, words); + return Ui.tap(card, name, open); + } + + /// Where an order is, in a few words, for the customer. + public static String state(OrderState state) { + if (state == null) { + return ""; + } + switch (state) { + case PLACED: + return Lang.tr("Waiting for the restaurant"); + case ACCEPTED: + return Lang.tr("Being prepared"); + case READY: + return Lang.tr("Ready, waiting for the courier"); + case PICKED_UP: + return Lang.tr("On its way"); + case DELIVERED: + return Lang.tr("Delivered"); + case REJECTED: + return Lang.tr("The restaurant could not take it"); + case NO_COURIERS: + return Lang.tr("No courier was free"); + case CANCELLED_BY_CUSTOMER: + return Lang.tr("You cancelled it"); + default: + return Lang.tr("Cancelled"); + } + } + + /// Which of the tracker's four steps `state` has reached: 0 placed, 1 + /// cooking, 2 on its way, 3 delivered; -1 for an order that ended without + /// arriving. + public static int step(OrderState state) { + if (state == OrderState.PLACED) { + return 0; + } + if (state == OrderState.ACCEPTED || state == OrderState.READY) { + return 1; + } + if (state == OrderState.PICKED_UP) { + return 2; + } + return state == OrderState.DELIVERED ? 3 : -1; + } + + /// Whether the order is still coming. + public static boolean active(OrderState state) { + return state != null && state.isActive(); + } +} diff --git a/scripts/savor/common/src/main/java/com/codenameone/examples/savor/customer/Pictures.java b/scripts/savor/common/src/main/java/com/codenameone/examples/savor/customer/Pictures.java new file mode 100644 index 00000000000..849ca8ad184 --- /dev/null +++ b/scripts/savor/common/src/main/java/com/codenameone/examples/savor/customer/Pictures.java @@ -0,0 +1,114 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.savor.customer; + +import com.codename1.ui.Image; +import com.codename1.ui.Label; +import com.codename1.util.Base64; +import com.codenameone.examples.savor.api.PhotoDto; +import com.codenameone.examples.savor.net.Api; +import com.codenameone.examples.savor.net.Net; + +import java.io.UnsupportedEncodingException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/// The photos of restaurants and dishes, fetched from the server once each. +/// +/// Every photo is the server's: a merchant uploads it, and the seed data +/// carries its own. Nothing is loaded from anywhere else, so a photo is as +/// available as the menu it belongs to. +public final class Pictures { + /// Photos kept decoded. A list of restaurants scrolls past a few dozen; + /// more than that and the oldest goes. + private static final int KEPT = 48; + private static final Map DECODED = new HashMap(); + private static final List ORDER = new ArrayList(); + /// The labels waiting for a photo already asked for. + private static final Map> WAITING = new HashMap>(); + + private Pictures() { + } + + /// Shows photo `id` on `label`, cropped to fill `width` by `height` + /// pixels. The label keeps what it shows -- its style's background -- until + /// the photo comes, and keeps it for good when there is none. + public static void into(final Label label, String id, final int width, final int height) { + if (id == null || id.length() == 0) { + return; + } + Image known = DECODED.get(id); + if (known != null) { + label.setIcon(known.fill(width, height)); + return; + } + // The size goes with the label: the same photo is a banner on one + // screen and a thumbnail on another. + label.putClientProperty("savor.photo.size", new int[] {width, height}); + List