├── .github └── workflows │ ├── js-client-test.yml │ ├── release.yml │ ├── server-code-coverage.yml │ └── server-test.yml ├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── codecov.yml ├── docs ├── deployment.md ├── flow.drawio ├── flow.svg ├── google.md ├── logo.png ├── roadmap.md └── todos.md ├── examples ├── booking.md ├── calendar-events.md ├── jwt.md └── reminders.md └── scheduler ├── .dockerignore ├── Cargo.lock ├── Cargo.toml ├── Dockerfile ├── clients ├── javascript │ ├── .npmignore │ ├── jest.config.js │ ├── lib │ │ ├── accountClient.ts │ │ ├── baseClient.ts │ │ ├── calendarClient.ts │ │ ├── domain │ │ │ ├── account.ts │ │ │ ├── calendar.ts │ │ │ ├── calendarEvent.ts │ │ │ ├── index.ts │ │ │ ├── metadata.ts │ │ │ ├── permissions.ts │ │ │ ├── schedule.ts │ │ │ ├── service.ts │ │ │ └── user.ts │ │ ├── eventClient.ts │ │ ├── healthClient.ts │ │ ├── index.ts │ │ ├── scheduleClient.ts │ │ ├── serviceClient.ts │ │ └── userClient.ts │ ├── package-lock.json │ ├── package.json │ ├── tests │ │ ├── account.spec.ts │ │ ├── calendar.spec.ts │ │ ├── calendarEvent.spec.ts │ │ ├── config │ │ │ ├── test_private_rsa_key.pem │ │ │ └── test_public_rsa_key.crt │ │ ├── health.spec.ts │ │ ├── helpers │ │ │ ├── fixtures.ts │ │ │ └── utils.ts │ │ ├── schedule.spec.ts │ │ ├── service.spec.ts │ │ └── user.spec.ts │ ├── tsconfig.json │ └── tsconfig.release.json └── rust │ ├── Cargo.toml │ └── src │ ├── account.rs │ ├── base.rs │ ├── calendar.rs │ ├── event.rs │ ├── lib.rs │ ├── schedule.rs │ ├── service.rs │ ├── shared.rs │ ├── status.rs │ └── user.rs ├── crates ├── api │ ├── Cargo.toml │ ├── config │ │ ├── test_private_rsa_key.pem │ │ └── test_public_rsa_key.crt │ └── src │ │ ├── account │ │ ├── add_account_integration.rs │ │ ├── create_account.rs │ │ ├── delete_account_webhook.rs │ │ ├── get_account.rs │ │ ├── mod.rs │ │ ├── remove_account_integration.rs │ │ ├── set_account_pub_key.rs │ │ └── set_account_webhook.rs │ │ ├── calendar │ │ ├── add_sync_calendar.rs │ │ ├── create_calendar.rs │ │ ├── delete_calendar.rs │ │ ├── get_calendar.rs │ │ ├── get_calendar_events.rs │ │ ├── get_calendars_by_meta.rs │ │ ├── get_google_calendars.rs │ │ ├── get_outlook_calendars.rs │ │ ├── mod.rs │ │ ├── remove_sync_calendar.rs │ │ └── update_calendar.rs │ │ ├── error.rs │ │ ├── event │ │ ├── create_event.rs │ │ ├── delete_event.rs │ │ ├── get_event.rs │ │ ├── get_event_instances.rs │ │ ├── get_events_by_meta.rs │ │ ├── get_upcoming_reminders.rs │ │ ├── mod.rs │ │ ├── subscribers │ │ │ └── mod.rs │ │ ├── sync_event_reminders.rs │ │ └── update_event.rs │ │ ├── job_schedulers.rs │ │ ├── lib.rs │ │ ├── schedule │ │ ├── create_schedule.rs │ │ ├── delete_schedule.rs │ │ ├── get_schedule.rs │ │ ├── get_schedules_by_meta.rs │ │ ├── mod.rs │ │ └── update_schedule.rs │ │ ├── service │ │ ├── add_busy_calendar.rs │ │ ├── add_user_to_service.rs │ │ ├── create_service.rs │ │ ├── create_service_event_intend.rs │ │ ├── delete_service.rs │ │ ├── get_service.rs │ │ ├── get_service_bookingslots.rs │ │ ├── get_services_by_meta.rs │ │ ├── mod.rs │ │ ├── remove_busy_calendar.rs │ │ ├── remove_service_event_intend.rs │ │ ├── remove_user_from_service.rs │ │ ├── update_service.rs │ │ └── update_service_user.rs │ │ ├── shared │ │ ├── auth │ │ │ ├── mod.rs │ │ │ ├── policy.rs │ │ │ └── route_guards.rs │ │ ├── controller.rs │ │ ├── guard.rs │ │ ├── mod.rs │ │ └── usecase.rs │ │ ├── status │ │ └── mod.rs │ │ └── user │ │ ├── create_user.rs │ │ ├── delete_user.rs │ │ ├── get_me.rs │ │ ├── get_user.rs │ │ ├── get_user_freebusy.rs │ │ ├── get_users_by_meta.rs │ │ ├── mod.rs │ │ ├── oauth_integration.rs │ │ ├── remove_integration.rs │ │ └── update_user.rs ├── api_structs │ ├── Cargo.toml │ └── src │ │ ├── account │ │ ├── api.rs │ │ ├── dtos.rs │ │ └── mod.rs │ │ ├── calendar │ │ ├── api.rs │ │ ├── dtos.rs │ │ └── mod.rs │ │ ├── event │ │ ├── api.rs │ │ ├── dtos.rs │ │ └── mod.rs │ │ ├── lib.rs │ │ ├── schedule │ │ ├── api.rs │ │ ├── dtos.rs │ │ └── mod.rs │ │ ├── service │ │ ├── api.rs │ │ ├── dtos.rs │ │ └── mod.rs │ │ ├── status │ │ ├── api.rs │ │ ├── dtos.rs │ │ └── mod.rs │ │ └── user │ │ ├── api.rs │ │ ├── dtos.rs │ │ └── mod.rs ├── domain │ ├── Cargo.toml │ └── src │ │ ├── account.rs │ │ ├── booking_slots.rs │ │ ├── calendar.rs │ │ ├── date.rs │ │ ├── event.rs │ │ ├── event_instance.rs │ │ ├── lib.rs │ │ ├── providers │ │ ├── google.rs │ │ ├── mod.rs │ │ └── outlook.rs │ │ ├── reminder.rs │ │ ├── schedule.rs │ │ ├── scheduling │ │ ├── mod.rs │ │ └── round_robin.rs │ │ ├── service.rs │ │ ├── shared │ │ ├── entity.rs │ │ ├── metadata.rs │ │ ├── mod.rs │ │ └── recurrence.rs │ │ ├── timespan.rs │ │ └── user.rs ├── infra │ ├── Cargo.toml │ ├── migrations │ │ ├── 00000000000000_initial_setup.sql │ │ └── 20210822154522_init_tables.sql │ └── src │ │ ├── config.rs │ │ ├── lib.rs │ │ ├── repos │ │ ├── account │ │ │ ├── mod.rs │ │ │ └── postgres.rs │ │ ├── account_integrations │ │ │ ├── mod.rs │ │ │ └── postgres.rs │ │ ├── calendar │ │ │ ├── mod.rs │ │ │ └── postgres.rs │ │ ├── calendar_synced │ │ │ ├── mod.rs │ │ │ └── postgres.rs │ │ ├── event │ │ │ ├── calendar_event │ │ │ │ ├── mod.rs │ │ │ │ └── postgres.rs │ │ │ ├── event_reminders_expansion_jobs │ │ │ │ ├── mod.rs │ │ │ │ └── postgres.rs │ │ │ ├── event_synced │ │ │ │ ├── mod.rs │ │ │ │ └── postgres.rs │ │ │ ├── mod.rs │ │ │ └── reminder │ │ │ │ ├── mod.rs │ │ │ │ └── postgres.rs │ │ ├── mod.rs │ │ ├── reservation │ │ │ ├── mod.rs │ │ │ └── postgres.rs │ │ ├── schedule │ │ │ ├── mod.rs │ │ │ └── postgres.rs │ │ ├── service │ │ │ ├── mod.rs │ │ │ └── postgres.rs │ │ ├── service_user │ │ │ ├── mod.rs │ │ │ └── postgres.rs │ │ ├── service_user_busy_calendars │ │ │ ├── mod.rs │ │ │ └── postgres.rs │ │ ├── shared │ │ │ ├── mod.rs │ │ │ └── query_structs.rs │ │ ├── user │ │ │ ├── mod.rs │ │ │ └── postgres.rs │ │ └── user_integrations │ │ │ ├── mod.rs │ │ │ └── postgres.rs │ │ ├── services │ │ ├── google_calendar │ │ │ ├── auth_provider.rs │ │ │ ├── calendar_api.rs │ │ │ └── mod.rs │ │ ├── mod.rs │ │ └── outlook_calendar │ │ │ ├── auth_provider.rs │ │ │ ├── calendar_api.rs │ │ │ └── mod.rs │ │ └── system │ │ └── mod.rs └── utils │ ├── Cargo.toml │ └── src │ └── lib.rs ├── integrations ├── docker-compose.yml └── wait-for.sh ├── musl.Dockerfile ├── src ├── bin │ └── migrate.rs ├── main.rs └── telemetry.rs └── tests ├── api.rs ├── collective_team_scheduling.rs ├── group_team_scheduling.rs ├── helpers ├── mod.rs ├── setup.rs └── utils.rs └── round_robin_scheduling.rs /.github/workflows/js-client-test.yml: -------------------------------------------------------------------------------- 1 | name: JavaScript client tests 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - master 8 | jobs: 9 | test: 10 | name: test 11 | runs-on: ${{matrix.os}} 12 | 13 | strategy: 14 | matrix: 15 | os: [ubuntu-20.04, ubuntu-18.04] 16 | node-version: [10, 12, 14] 17 | 18 | services: 19 | postgres: 20 | image: postgres:13 21 | env: 22 | POSTGRES_PASSWORD: postgres 23 | POSTGRES_DB: nettuscheduler 24 | ports: 25 | - 5432:5432 26 | env: 27 | PORT: 5000 28 | DATABASE_URL: postgresql://postgres:postgres@localhost/nettuscheduler 29 | steps: 30 | - name: Checkout repository 31 | uses: actions/checkout@v2 32 | - name: Setup Node.js ${{ matrix.node-version }} 33 | uses: actions/setup-node@v2 34 | with: 35 | node-version: ${{ matrix.node-version }} 36 | cache: "npm" 37 | cache-dependency-path: scheduler/clients/javascript/package-lock.json 38 | 39 | - uses: actions/cache@v2 40 | with: 41 | path: | 42 | ~/.cargo/bin/ 43 | ~/.cargo/registry/index/ 44 | ~/.cargo/registry/cache/ 45 | ~/.cargo/git/db/ 46 | scheduler/target/ 47 | key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} 48 | 49 | - name: Start server and run JS client tests 50 | env: 51 | PORT: 5000 52 | run: | 53 | cd scheduler 54 | 55 | # run the migrations first 56 | cargo install sqlx-cli --no-default-features --features postgres || true 57 | (cd crates/infra && sqlx migrate run) 58 | 59 | export CREATE_ACCOUNT_SECRET_CODE=opqI5r3e7v1z2h3P 60 | export RUST_LOG=error,tracing=info 61 | 62 | cargo build 63 | ./target/debug/nettu_scheduler &> output.log & 64 | echo "Started server in background" 65 | 66 | sleep 10 67 | 68 | - name: Run JavaScript client tests 69 | run: | 70 | cd scheduler/clients/javascript 71 | npm i -g typescript 72 | npm i 73 | 74 | npm run test 75 | -------------------------------------------------------------------------------- /.github/workflows/server-code-coverage.yml: -------------------------------------------------------------------------------- 1 | name: Server test and code coverage 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | jobs: 8 | test: 9 | name: test 10 | runs-on: ubuntu-latest 11 | services: 12 | postgres: 13 | image: postgres:13 14 | env: 15 | POSTGRES_PASSWORD: postgres 16 | POSTGRES_DB: nettuscheduler 17 | ports: 18 | - 5432:5432 19 | env: 20 | PORT: 5000 21 | DATABASE_URL: postgresql://postgres:postgres@localhost:5432/nettuscheduler 22 | steps: 23 | - name: Checkout repository 24 | uses: actions/checkout@v2 25 | 26 | - uses: actions/cache@v2 27 | with: 28 | path: | 29 | ~/.cargo/bin/ 30 | ~/.cargo/registry/index/ 31 | ~/.cargo/registry/cache/ 32 | ~/.cargo/git/db/ 33 | scheduler/target/ 34 | key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} 35 | 36 | - name: Generate code coverage 37 | run: | 38 | cd scheduler 39 | 40 | # https://github.com/xd009642/tarpaulin/issues/756 41 | cargo install cargo-tarpaulin --version 0.18.0-alpha3 || true 42 | 43 | # run the migrations first 44 | cargo install sqlx-cli --no-default-features --features postgres || true 45 | (cd crates/infra && sqlx migrate run) 46 | 47 | # cargo install cargo-tarpaulin 48 | cargo tarpaulin --avoid-cfg-tarpaulin --verbose --all-features --workspace --timeout 120 --out Xml 49 | 50 | - name: Upload to codecov.io 51 | uses: codecov/codecov-action@v1 52 | with: 53 | fail_ci_if_error: true 54 | -------------------------------------------------------------------------------- /.github/workflows/server-test.yml: -------------------------------------------------------------------------------- 1 | name: Server test 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - master 8 | jobs: 9 | test: 10 | name: test 11 | runs-on: ${{matrix.os}} 12 | 13 | strategy: 14 | matrix: 15 | os: [ubuntu-20.04, ubuntu-18.04] 16 | 17 | services: 18 | postgres: 19 | image: postgres:13 20 | env: 21 | POSTGRES_PASSWORD: postgres 22 | POSTGRES_DB: nettuscheduler 23 | ports: 24 | - 5432:5432 25 | 26 | env: 27 | PORT: 5000 28 | DATABASE_URL: postgresql://postgres:postgres@localhost/nettuscheduler 29 | 30 | steps: 31 | - name: Checkout repository 32 | uses: actions/checkout@v2 33 | 34 | - uses: actions/cache@v2 35 | with: 36 | path: | 37 | ~/.cargo/bin/ 38 | ~/.cargo/registry/index/ 39 | ~/.cargo/registry/cache/ 40 | ~/.cargo/git/db/ 41 | scheduler/target/ 42 | key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} 43 | 44 | - name: Run migrations 45 | run: | 46 | cd scheduler 47 | 48 | # run the migrations first 49 | cargo install sqlx-cli --no-default-features --features postgres || true 50 | (cd crates/infra && sqlx migrate run) 51 | 52 | - name: Install nighly toolchain 53 | run: | 54 | rustup default nightly 55 | # Go back to stable 56 | rustup default stable 57 | 58 | - name: Formatting 59 | run: | 60 | cd scheduler 61 | cargo fmt --all -- --check 62 | 63 | - name: Clippy 64 | run: | 65 | cd scheduler 66 | cargo clippy --all -- --deny "warnings" 67 | 68 | - name: Unused dependencies 69 | run: | 70 | cd scheduler 71 | cargo install cargo-udeps --locked 72 | cargo +nightly udeps --all-targets 73 | 74 | # - name: Outdated dependencies 75 | # run: | 76 | # cd scheduler 77 | # # cargo outdated --exit-code 1 --workspace 78 | # cargo outdated --workspace 79 | 80 | - name: Run server tests 81 | run: | 82 | cd scheduler 83 | cargo test --all 84 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | target/ 2 | cobertura.xml 3 | node_modules/ 4 | dist/ 5 | playground/ 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Fredrik Meringdal 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | export DATABASE_URL=postgresql://postgres:postgres@localhost:5432/nettuscheduler 2 | 3 | setup: _setup_db 4 | 5 | test: _setup_db 6 | @cd scheduler && cargo test --all 7 | 8 | check: _setup_db 9 | @cd scheduler && cargo +nightly fmt 10 | @cd scheduler && cargo clippy --verbose 11 | @cd scheduler && cargo +nightly udeps --all-targets 12 | @cd scheduler && cargo outdated -wR 13 | @cd scheduler && cargo update --dry-run 14 | 15 | check_nightly: 16 | @cd scheduler && cargo +nightly clippy 17 | 18 | install_all_prerequisite: 19 | @cargo install sqlx-cli --no-default-features --features postgres || true 20 | @cargo install cargo-outdated || true 21 | @cargo install cargo-udeps cargo-outdated || true 22 | 23 | _setup_db: 24 | @docker-compose -f scheduler/integrations/docker-compose.yml up -d 25 | @cd scheduler/crates/infra && sqlx migrate run -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | codecov: 2 | require_ci_to_pass: yes 3 | 4 | coverage: 5 | precision: 2 6 | round: down 7 | range: "70...100" 8 | 9 | parsers: 10 | gcov: 11 | branch_detection: 12 | conditional: yes 13 | loop: yes 14 | method: no 15 | macro: yes 16 | 17 | comment: 18 | layout: "reach,diff,flags,files,footer" 19 | behavior: default 20 | require_changes: no 21 | -------------------------------------------------------------------------------- /docs/deployment.md: -------------------------------------------------------------------------------- 1 | ## Deployment of Nettu Scheduler Server 2 | 3 | Use the docker image: `fmeringdal/nettu-scheduler`. 4 | 5 | Or build from source: 6 | ```bash 7 | cd scheduler 8 | cargo run --release 9 | ``` 10 | 11 | Then set up a postgres db with the init script specified [here](../scheduler/crates/infra/migrations/dbinit.sql). 12 | Lastly provide the following environment variables to the `nettu scheduler` server: 13 | ```bash 14 | # The connection string to the database 15 | DATABASE_URL 16 | ``` 17 | 18 | -------------------------------------------------------------------------------- /docs/google.md: -------------------------------------------------------------------------------- 1 | ## Google calendar integration 2 | - List google calendars 3 | - Import google calendar to nettu calendar 4 | - Sync all updates to nettu calendar to google calendar 5 | - Receive updates from google calendar 6 | - Oauth 7 | - Watch calendar lists to detect deleted calendars 8 | - Create google calendar meeting if enabled 9 | 10 | 11 | ## CalDav -------------------------------------------------------------------------------- /docs/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fmeringdal/nettu-scheduler/4bcfb3e391112413fd4c80098910bbd26ae2bd7e/docs/logo.png -------------------------------------------------------------------------------- /docs/roadmap.md: -------------------------------------------------------------------------------- 1 | ## Upcoming features 2 | - Get proposed meeting times between users 3 | - Shared Calendars / Calendar events 4 | - Resource policies -------------------------------------------------------------------------------- /docs/todos.md: -------------------------------------------------------------------------------- 1 | 2 | ## Todos 3 | 4 | - needs list methods for calendars, schedules and services 5 | - more recurrence validation in rrule.rs crate 6 | -------------------------------------------------------------------------------- /examples/calendar-events.md: -------------------------------------------------------------------------------- 1 | ## Calendar Events 2 | 3 | `Calendar Event`s are events in a `Calendar` and can be either a single event or a recurring event. It is also possible to store `Metadata` on the `Calendar Event` to add additional fields if desired (e.g. title, description, videolink, etc.), and also query on those fields. 4 | 5 | 6 | ```js 7 | import { NettuClient, Frequenzy, config } from "@nettu/scheduler-sdk"; 8 | 9 | const client = NettuClient({ apiKey: "YOUR_API_KEY" }); 10 | 11 | // Create a User 12 | const userRes = await client.user.create(); 13 | const { user } = userRes.data!; 14 | 15 | // Create the Calendar that the CalendarEvent will belong to 16 | const calendarRes = await client.calendar.create(user.id, { 17 | // Starts on Monday 18 | weekStart: 0, 19 | // Timezone for the calendar 20 | timezone: "UTC" 21 | }); 22 | const { calendar } = calendarRes.data!; 23 | 24 | // Create a CalendarEvent that repeats daily 25 | const eventRes = await client.events.create(user.id, { 26 | calendarId: calendar.id, 27 | startTs: 0, 28 | duration: 1000 * 60 * 30, // 30 minutes in millis 29 | recurrence: { 30 | freq: Frequenzy.Daily, 31 | interval: 1 32 | }, 33 | metadata: { 34 | mykey: "myvalue" 35 | } 36 | }); 37 | const { event } = eventRes.data!; 38 | 39 | // Retrieve event instances in a given Timespan 40 | const instancesRes = await client.events.getInstances(event.id, { 41 | startTs: 0, // unix timestamp 0 -> 1970.1.1 42 | endTs: 1000 * 60 * 60 * 24 43 | }); 44 | 45 | const { instances } = instancesRes.data!; 46 | console.log(instances); 47 | 48 | // Retrieve CalendarEvents by metadata 49 | const skip = 0; 50 | const limit = 100; 51 | const eventMetaQuery = await client.events.findByMeta({ 52 | key: "mykey", 53 | value: "myvalue" 54 | }, skip, limit); 55 | 56 | const { events } = eventMetaQuery.data!; 57 | console.log(events); 58 | 59 | ``` 60 | -------------------------------------------------------------------------------- /examples/jwt.md: -------------------------------------------------------------------------------- 1 | ## Json Web Token 2 | 3 | Json Web Tokens are useful if you want your end-users to interact with the nettu scheduler API from the browser. Just upload your public RSA key (only RS256 algorithm is supported) to the `nettu scheduler` and then create jwt by signing the data with your private RSA key. 4 | 5 | This is how your server side code might look like 6 | 7 | ```js 8 | import jwt from "jsonwebtoken"; 9 | import { NettuClient, Permissions } from "@nettu/scheduler-sdk"; 10 | 11 | const client = NettuClient({ apiKey: "YOUR_API_KEY" }); 12 | 13 | // Upload your public rsa signing key 14 | await client.account.setPublicSigningKey("YOUR_PUBLIC_KEY"); 15 | 16 | // A handler in your server that generates JWT for authenticated 17 | // users 18 | const handleJWTRequest = async(user) => { 19 | // Check if your user is already associated with a 20 | // nettu user. Create one if not. 21 | if(!user.schedulerUserId) { 22 | const userRes = await client.user.create(); 23 | user.schedulerUserId = userRes.data!.user.id; 24 | } 25 | 26 | const token = jwt.sign({ 27 | // The nettu scheduler user id (the subject for this token) 28 | nettuSchedulerUserId: user.schedulerUserId, 29 | exp: 5609418990073, 30 | iat: 19, 31 | // Policy (a.k.a claims) 32 | schedulerPolicy: { 33 | allow: [Permissions.All], 34 | reject: [Permissions.DeleteCalendar] 35 | } 36 | }, PRIV_KEY, { 37 | algorithm: "RS256" 38 | }); 39 | 40 | const accountRes = await client.account.me(); 41 | const { account } = accountRes.data!; 42 | 43 | // Return the token back to the frontend 44 | return { 45 | token, 46 | accountId: account.id 47 | }; 48 | } 49 | 50 | ``` 51 | 52 | 53 | This is what your frontend code might look like 54 | ```js 55 | import { NettuUserClient } from "@nettu/scheduler-sdk"; 56 | 57 | const getJWT = async() => { 58 | // HERE GOES LOGIC FOR CALLING YOUR SERVER ENDPOINT WHICH RETURNS A JWT AND ACCOUNT ID 59 | } 60 | 61 | const { token, accountId } = await getJWT(); 62 | 63 | // Construct the nettu user client 64 | const client = NettuUserClient({ 65 | token, 66 | nettuAccount: accountId 67 | }); 68 | 69 | // Create calendar as user 70 | const calendarRes = await userClient.calendar.create({ 71 | timezone: "UTC" 72 | }); 73 | const { calendar } = calendarRes.data!; 74 | 75 | // This action is not allowed by the policy 76 | const { status } = await userClient.calendar.remove(calendar.id); 77 | console.log(status === 401); 78 | ``` 79 | -------------------------------------------------------------------------------- /examples/reminders.md: -------------------------------------------------------------------------------- 1 | ## Reminders 2 | 3 | Your server can receive reminders before calendar events in the form of webhooks. 4 | This means that `nettu scheduler` will not notify your users through other means like email, 5 | phone etc. That is supposed to be done by your server (if needed) which owns the complete user resources. 6 | 7 | ```js 8 | import { NettuClient, Frequenzy } from "@nettu/scheduler-sdk"; 9 | 10 | const client = NettuClient({ apiKey: "YOUR_API_KEY" }); 11 | 12 | // Set webhook url 13 | const accountRes = await client.account.setWebhook("https://test.com/some_path"); 14 | const { account } = accountRes.data!; 15 | // A generated key used for verifying the webhook the request 16 | const key = account.settings.webhook!.key; 17 | 18 | 19 | const userRes = await client.user.create(); 20 | const { user } = userRes.data!; 21 | 22 | const calendarRes = await client.calendar.create(user.id, { 23 | // Starts on monday 24 | weekStart: 0, 25 | // Timezone for the calendar 26 | timezone: "UTC" 27 | }); 28 | const { calendar } = calendarRes.data!; 29 | 30 | await client.events.create(user.id, { 31 | calendarId: calendar.id, 32 | startTs: 0, 33 | duration: 1000 * 60 * 30, // 30 minutes in millis 34 | recurrence: { 35 | freq: Frequenzy.Daily, 36 | interval: 1 37 | }, 38 | reminders: [{ 39 | delta: -15, // Your webhook url will be called with this CalendarEvent 15 minutes before an occurence of this event 40 | identifier: "your_unqiue_identifer" // Some unique identifier that you will receive along with the webhook 41 | }], 42 | metadata: { 43 | mykey: "myvalue" 44 | } 45 | }); 46 | 47 | // Your endpoint that Nettu Scheduler service will call 48 | // req.body = { 49 | // reminders: { 50 | // event: CalendarEvent, 51 | // identifier: string 52 | // }[] 53 | // } 54 | const webhookReceiverController = (req) => { 55 | if(req.headers["nettu-scheduler-webhook-key"] !== key) return; 56 | // Handle reminder by sending email to participants or whatever your app needs to do 57 | 58 | } 59 | 60 | ``` 61 | -------------------------------------------------------------------------------- /scheduler/.dockerignore: -------------------------------------------------------------------------------- 1 | target 2 | -------------------------------------------------------------------------------- /scheduler/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "nettu_scheduler" 3 | version = "0.1.0" 4 | authors = ["Fredrik Meringdal"] 5 | edition = "2018" 6 | default-run = "nettu_scheduler" 7 | 8 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 9 | 10 | [workspace] 11 | members = [ 12 | "crates/api", 13 | "crates/api_structs", 14 | "crates/domain", 15 | "crates/infra", 16 | "crates/utils", 17 | "clients/rust", 18 | ] 19 | 20 | [dependencies] 21 | nettu_scheduler_api = { path = "./crates/api" } 22 | nettu_scheduler_domain = { path = "./crates/domain" } 23 | nettu_scheduler_infra = { path = "./crates/infra" } 24 | actix-web = "4.0.0-beta.8" 25 | tracing = "0.1.19" 26 | tracing-subscriber = { version = "0.2.12", features = ["registry", "env-filter"] } 27 | tracing-bunyan-formatter = "0.2.4" 28 | tracing-log = "0.1.1" 29 | openssl-probe = "0.1.2" 30 | 31 | [dev-dependencies] 32 | nettu_scheduler_sdk = { path = "./clients/rust" } 33 | chrono = "0.4.19" 34 | chrono-tz = "0.5.3" 35 | -------------------------------------------------------------------------------- /scheduler/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ubuntu:20.04 2 | 3 | ARG DEBIAN_FRONTEND=noninteractive 4 | 5 | # Update default packages 6 | RUN apt-get update \ 7 | && apt-get install -y build-essential curl pkg-config libssl-dev \ 8 | && apt-get -y dist-upgrade \ 9 | && rm -rf /var/lib/apt/lists/* 10 | 11 | ENV PATH="/root/.cargo/bin:${PATH}" 12 | 13 | WORKDIR /home/rust/ 14 | 15 | COPY . . 16 | 17 | ENV DATABASE_URL "postgresql://postgres:postgres@172.17.0.1:5432/nettuscheduler" 18 | 19 | RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y \ 20 | && cargo build --release \ 21 | && cp /home/rust/target/release/nettu_scheduler . \ 22 | && cp /home/rust/target/release/migrate . \ 23 | && rm -rf /home/rust/target \ 24 | && rustup self uninstall -y 25 | 26 | ENTRYPOINT ["/bin/sh", "-c" , "./migrate && ./nettu_scheduler"] 27 | -------------------------------------------------------------------------------- /scheduler/clients/javascript/.npmignore: -------------------------------------------------------------------------------- 1 | support 2 | test 3 | examples 4 | .gitignore 5 | History.md 6 | *.test.js -------------------------------------------------------------------------------- /scheduler/clients/javascript/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: "ts-jest", 3 | testEnvironment: "node", 4 | bail: true, 5 | }; 6 | -------------------------------------------------------------------------------- /scheduler/clients/javascript/lib/accountClient.ts: -------------------------------------------------------------------------------- 1 | import { Account } from "./domain/account"; 2 | import { NettuBaseClient } from "./baseClient"; 3 | 4 | type AccountResponse = { 5 | account: Account; 6 | }; 7 | 8 | type CreateAccountResponse = { 9 | account: Account; 10 | secretApiKey: string; 11 | }; 12 | 13 | type CreateAccountRequest = { 14 | code: string; 15 | }; 16 | 17 | type GoogleIntegration = { 18 | clientId: string; 19 | clientSecret: string; 20 | redirectUri: string; 21 | }; 22 | 23 | export class NettuAccountClient extends NettuBaseClient { 24 | // data will be something in the future 25 | public create(data: CreateAccountRequest) { 26 | return this.post("/account", data); 27 | } 28 | 29 | public setPublicSigningKey(publicSigningKey?: string) { 30 | return this.put("/account/pubkey", { 31 | publicJwtKey: publicSigningKey, 32 | }); 33 | } 34 | 35 | public removePublicSigningKey() { 36 | return this.setPublicSigningKey(); 37 | } 38 | 39 | public setWebhook(url: string) { 40 | return this.put(`/account/webhook`, { 41 | webhookUrl: url, 42 | }); 43 | } 44 | 45 | public connectGoogle(data: GoogleIntegration) { 46 | return this.put(`/account/integration/google`, data); 47 | } 48 | 49 | public removeWebhook() { 50 | return this.delete(`/account/webhook`); 51 | } 52 | 53 | public me() { 54 | return this.get(`/account`); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /scheduler/clients/javascript/lib/baseClient.ts: -------------------------------------------------------------------------------- 1 | import axios, { AxiosResponse } from "axios"; 2 | import { config } from "."; 3 | 4 | export abstract class NettuBaseClient { 5 | private readonly credentials: ICredentials; 6 | 7 | constructor(credentials: ICredentials) { 8 | this.credentials = credentials; 9 | } 10 | 11 | private getAxiosConfig = () => ({ 12 | validateStatus: () => true, // allow all status codes without throwing error 13 | headers: this.credentials.createAuthHeaders(), 14 | }); 15 | 16 | protected async get(path: string): Promise> { 17 | const res = await axios.get(config.baseUrl + path, this.getAxiosConfig()); 18 | return new APIResponse(res); 19 | } 20 | 21 | protected async delete(path: string): Promise> { 22 | const res = await axios.delete( 23 | config.baseUrl + path, 24 | this.getAxiosConfig() 25 | ); 26 | return new APIResponse(res); 27 | } 28 | 29 | protected async deleteWithBody( 30 | path: string, 31 | data: any 32 | ): Promise> { 33 | const { headers, validateStatus } = this.getAxiosConfig(); 34 | const res = await axios({ 35 | method: "DELETE", 36 | data, 37 | url: config.baseUrl + path, 38 | headers, 39 | validateStatus, 40 | }); 41 | return new APIResponse(res); 42 | } 43 | 44 | protected async post(path: string, data: any): Promise> { 45 | const res = await axios.post( 46 | config.baseUrl + path, 47 | data, 48 | this.getAxiosConfig() 49 | ); 50 | return new APIResponse(res); 51 | } 52 | 53 | protected async put(path: string, data: any): Promise> { 54 | const res = await axios.put( 55 | config.baseUrl + path, 56 | data, 57 | this.getAxiosConfig() 58 | ); 59 | return new APIResponse(res); 60 | } 61 | } 62 | 63 | export class APIResponse { 64 | readonly data?: T; // Could be a failed response and therefore nullable 65 | readonly status: number; 66 | readonly res: AxiosResponse; 67 | 68 | constructor(res: AxiosResponse) { 69 | this.res = res; 70 | this.data = res.data; 71 | this.status = res.status; 72 | } 73 | } 74 | 75 | export class UserCreds implements ICredentials { 76 | private readonly nettuAccount: string; 77 | private readonly token?: string; 78 | 79 | constructor(nettuAccount: string, token?: string) { 80 | this.nettuAccount = nettuAccount; 81 | this.token = token; 82 | } 83 | 84 | createAuthHeaders() { 85 | const creds: any = { 86 | "nettu-account": this.nettuAccount, 87 | }; 88 | if (this.token) { 89 | creds["authorization"] = `Bearer ${this.token}`; 90 | } 91 | 92 | return Object.freeze(creds); 93 | } 94 | } 95 | 96 | export class AccountCreds implements ICredentials { 97 | private readonly apiKey: string; 98 | 99 | constructor(apiKey: string) { 100 | this.apiKey = apiKey; 101 | } 102 | 103 | createAuthHeaders() { 104 | return Object.freeze({ 105 | "x-api-key": this.apiKey, 106 | }); 107 | } 108 | } 109 | 110 | export interface ICredentials { 111 | createAuthHeaders(): object; 112 | } 113 | 114 | export class EmptyCreds implements ICredentials { 115 | createAuthHeaders() { 116 | return Object.freeze({}); 117 | } 118 | } 119 | 120 | export interface ICredentials { 121 | createAuthHeaders(): object; 122 | } 123 | -------------------------------------------------------------------------------- /scheduler/clients/javascript/lib/domain/account.ts: -------------------------------------------------------------------------------- 1 | export interface Account { 2 | id: string; 3 | publicJwtKey?: string; 4 | settings: { 5 | webhook?: { 6 | url: string; 7 | key: string; 8 | }; 9 | }; 10 | } 11 | 12 | export enum IntegrationProvider { 13 | Google = "google", 14 | Outlook = "outlook", 15 | } 16 | -------------------------------------------------------------------------------- /scheduler/clients/javascript/lib/domain/calendar.ts: -------------------------------------------------------------------------------- 1 | import { Metadata } from "./metadata"; 2 | 3 | export interface Calendar { 4 | id: string; 5 | userId: string; 6 | settings: { 7 | weekStart: string; 8 | timezone: string; 9 | }; 10 | metadata: Metadata; 11 | } 12 | 13 | export enum GoogleCalendarAccessRole { 14 | Owner = "owner", 15 | Writer = "writer", 16 | Reader = "reader", 17 | FreeBusyReader = "freeBusyReader", 18 | } 19 | 20 | export interface GoogleCalendarListEntry { 21 | id: string; 22 | access_role: GoogleCalendarAccessRole; 23 | summary: string; 24 | summaryOverride?: string; 25 | description?: string; 26 | location?: string; 27 | timeZone?: string; 28 | colorId?: string; 29 | backgroundColor?: string; 30 | foregroundColor?: string; 31 | hidden?: boolean; 32 | selected?: boolean; 33 | primary?: boolean; 34 | deleted?: boolean; 35 | } 36 | 37 | export enum OutlookCalendarAccessRole { 38 | Writer = "writer", 39 | Reader = "reader", 40 | } 41 | 42 | export interface OutlookCalendar { 43 | id: string; 44 | name: string; 45 | color: string; 46 | changeKey: string; 47 | canShare: boolean; 48 | canViewPrivateItems: boolean; 49 | hexColor: string; 50 | canEdit: boolean; 51 | } 52 | -------------------------------------------------------------------------------- /scheduler/clients/javascript/lib/domain/calendarEvent.ts: -------------------------------------------------------------------------------- 1 | import { Metadata } from "./metadata" 2 | 3 | export enum Frequenzy { 4 | Daily = "daily", 5 | Weekly = "weekly", 6 | Monthly = "monthly", 7 | Yearly = "yearly", 8 | } 9 | 10 | export interface RRuleOptions { 11 | freq: Frequenzy; 12 | interval: number; 13 | count?: number; 14 | until?: number; 15 | bysetpos?: number[]; 16 | byweekday?: number[]; 17 | bymonthday?: number[]; 18 | bymonth?: number[]; 19 | byyearday?: number[]; 20 | byweekno?: number[]; 21 | } 22 | 23 | export interface CalendarEvent { 24 | id: string; 25 | startTs: number; 26 | duration: number; 27 | busy: boolean; 28 | updated: number; 29 | created: number; 30 | exdates: number[]; 31 | calendarId: string; 32 | userId: string; 33 | metadata: Metadata; 34 | recurrence?: RRuleOptions; 35 | reminder?: { 36 | minutesBefore: number; 37 | } 38 | } 39 | 40 | export interface CalendarEventInstance { 41 | startTs: number; 42 | endTs: number; 43 | busy: boolean; 44 | } 45 | -------------------------------------------------------------------------------- /scheduler/clients/javascript/lib/domain/index.ts: -------------------------------------------------------------------------------- 1 | export { Account, IntegrationProvider } from "./account"; 2 | export { 3 | Calendar, 4 | GoogleCalendarAccessRole, 5 | GoogleCalendarListEntry, 6 | OutlookCalendar, 7 | OutlookCalendarAccessRole, 8 | } from "./calendar"; 9 | export { 10 | CalendarEvent, 11 | CalendarEventInstance, 12 | Frequenzy, 13 | RRuleOptions, 14 | } from "./calendarEvent"; 15 | export { 16 | Schedule, 17 | ScheduleRule, 18 | ScheduleRuleInterval, 19 | Time, 20 | ScheduleRuleVariant, 21 | Weekday, 22 | } from "./schedule"; 23 | export { 24 | Service, 25 | UserServiceResource, 26 | BusyCalendar, 27 | BusyCalendarProvider, 28 | } from "./service"; 29 | export { Permissions } from "./permissions"; 30 | export { User } from "./user"; 31 | -------------------------------------------------------------------------------- /scheduler/clients/javascript/lib/domain/metadata.ts: -------------------------------------------------------------------------------- 1 | export type Metadata = { 2 | [key: string]: string 3 | }; -------------------------------------------------------------------------------- /scheduler/clients/javascript/lib/domain/permissions.ts: -------------------------------------------------------------------------------- 1 | export enum Permissions { 2 | All = "*", 3 | CreateCalendar = "CreateCalendar", 4 | DeleteCalendar = "DeleteCalendar", 5 | UpdateCalendar = "UpdateCalendar", 6 | CreateCalendarEvent = "CreateCalendarEvent", 7 | DeleteCalendarEvent = "DeleteCalendarEvent", 8 | UpdateCalendarEvent = "UpdateCalendarEvent", 9 | CreateSchedule = "CreateSchedule", 10 | UpdateSchedule = "UpdateSchedule", 11 | DeleteSchedule = "DeleteSchedule", 12 | } 13 | -------------------------------------------------------------------------------- /scheduler/clients/javascript/lib/domain/schedule.ts: -------------------------------------------------------------------------------- 1 | import { Metadata } from "./metadata"; 2 | 3 | export interface Schedule { 4 | id: string; 5 | timezone: string; 6 | rules: ScheduleRule[]; 7 | metadata: Metadata; 8 | } 9 | 10 | export enum ScheduleRuleVariant { 11 | WDay = "WDay", 12 | Date = "Date", 13 | } 14 | 15 | export enum Weekday { 16 | Mon = "Mon", 17 | Tue = "Tue", 18 | Wed = "Wed", 19 | Thu = "Thu", 20 | Fri = "Fri", 21 | Sat = "Sat", 22 | Sun = "Sun", 23 | } 24 | 25 | export interface ScheduleRule { 26 | variant: { 27 | type: ScheduleRuleVariant; 28 | value: string; 29 | }; 30 | intervals: ScheduleRuleInterval[]; 31 | } 32 | 33 | export interface Time { 34 | hours: number; 35 | minutes: number; 36 | } 37 | 38 | export interface ScheduleRuleInterval { 39 | start: Time; 40 | end: Time; 41 | } 42 | -------------------------------------------------------------------------------- /scheduler/clients/javascript/lib/domain/service.ts: -------------------------------------------------------------------------------- 1 | import { Metadata } from "./metadata"; 2 | 3 | export type TimePlan = { 4 | variant: "Calendar" | "Schedule" | "Empty"; 5 | id: string; 6 | }; 7 | 8 | export interface UserServiceResource { 9 | id: string; 10 | userId: string; 11 | availability: TimePlan; 12 | bufferBefore?: number; 13 | bufferAfter?: number; 14 | closestBookingTime: number; 15 | furthestBookingTime: number; 16 | } 17 | 18 | export enum BusyCalendarProvider { 19 | Google = "Google", 20 | Outlook = "Outlook", 21 | Nettu = "Nettu", 22 | } 23 | 24 | export interface BusyCalendar { 25 | provider: BusyCalendarProvider; 26 | id: string; 27 | } 28 | 29 | export interface Service { 30 | id: string; 31 | accountId: string; 32 | users: UserServiceResource[]; 33 | metadata: Metadata; 34 | } 35 | -------------------------------------------------------------------------------- /scheduler/clients/javascript/lib/domain/user.ts: -------------------------------------------------------------------------------- 1 | import { Metadata } from "./metadata"; 2 | 3 | export type User = { 4 | id: string; 5 | metadata: Metadata; 6 | } -------------------------------------------------------------------------------- /scheduler/clients/javascript/lib/eventClient.ts: -------------------------------------------------------------------------------- 1 | import { NettuBaseClient } from "./baseClient"; 2 | import { 3 | CalendarEvent, 4 | CalendarEventInstance, 5 | RRuleOptions, 6 | } from "./domain/calendarEvent"; 7 | import { Metadata } from "./domain/metadata"; 8 | 9 | interface EventReminder { 10 | delta: number; 11 | identifier: string; 12 | } 13 | 14 | type CreateCalendarEventReq = { 15 | calendarId: string; 16 | startTs: number; 17 | duration: number; 18 | busy?: boolean; 19 | recurrence?: RRuleOptions; 20 | serviceId?: boolean; 21 | reminders?: EventReminder[]; 22 | metadata?: Metadata; 23 | }; 24 | 25 | type UpdateCalendarEventReq = { 26 | startTs?: number; 27 | duration?: number; 28 | busy?: boolean; 29 | recurrence?: RRuleOptions; 30 | serviceId?: boolean; 31 | exdates?: number[]; 32 | reminders?: EventReminder[]; 33 | metadata?: Metadata; 34 | }; 35 | 36 | export type Timespan = { 37 | startTs: number; 38 | endTs: number; 39 | }; 40 | 41 | type GetEventInstancesResponse = { 42 | instances: CalendarEventInstance[]; 43 | }; 44 | 45 | type EventReponse = { 46 | event: CalendarEvent; 47 | }; 48 | 49 | export class NettuEventClient extends NettuBaseClient { 50 | public update(eventId: string, data: UpdateCalendarEventReq) { 51 | return this.put(`/user/events/${eventId}`, data); 52 | } 53 | 54 | public create(userId: string, data: CreateCalendarEventReq) { 55 | return this.post(`/user/${userId}/events`, data); 56 | } 57 | 58 | public findById(eventId: string) { 59 | return this.get(`/user/events/${eventId}`); 60 | } 61 | 62 | public findByMeta( 63 | meta: { 64 | key: string; 65 | value: string; 66 | }, 67 | skip: number, 68 | limit: number 69 | ) { 70 | return this.get<{ events: CalendarEvent[] }>( 71 | `/events/meta?skip=${skip}&limit=${limit}&key=${meta.key}&value=${meta.value}` 72 | ); 73 | } 74 | 75 | public remove(eventId: string) { 76 | return this.delete(`/user/events/${eventId}`); 77 | } 78 | 79 | public getInstances(eventId: string, timespan: Timespan) { 80 | return this.get( 81 | `/user/events/${eventId}/instances?startTs=${timespan.startTs}&endTs=${timespan.endTs}` 82 | ); 83 | } 84 | } 85 | 86 | export class NettuEventUserClient extends NettuBaseClient { 87 | public update(eventId: string, data: UpdateCalendarEventReq) { 88 | return this.put(`/events/${eventId}`, data); 89 | } 90 | 91 | public create(data: CreateCalendarEventReq) { 92 | return this.post("/events", data); 93 | } 94 | 95 | public findById(eventId: string) { 96 | return this.get(`/events/${eventId}`); 97 | } 98 | 99 | public remove(eventId: string) { 100 | return this.delete(`/events/${eventId}`); 101 | } 102 | 103 | public getInstances(eventId: string, timespan: Timespan) { 104 | return this.get( 105 | `/events/${eventId}/instances?startTs=${timespan.startTs}&endTs=${timespan.endTs}` 106 | ); 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /scheduler/clients/javascript/lib/healthClient.ts: -------------------------------------------------------------------------------- 1 | import { NettuBaseClient } from "./baseClient"; 2 | 3 | export class NettuHealthClient extends NettuBaseClient { 4 | public async checkStatus(): Promise { 5 | const res = await this.get("/"); 6 | return res.status; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /scheduler/clients/javascript/lib/index.ts: -------------------------------------------------------------------------------- 1 | import { NettuAccountClient } from "./accountClient"; 2 | import { 3 | AccountCreds, 4 | EmptyCreds, 5 | ICredentials, 6 | UserCreds, 7 | } from "./baseClient"; 8 | import { NettuCalendarClient, NettuCalendarUserClient } from "./calendarClient"; 9 | import { NettuEventClient, NettuEventUserClient } from "./eventClient"; 10 | import { NettuHealthClient } from "./healthClient"; 11 | import { NettuScheduleUserClient, NettuScheduleClient } from "./scheduleClient"; 12 | import { NettuServiceUserClient, NettuServiceClient } from "./serviceClient"; 13 | import { NettuUserClient as _NettuUserClient, NettuUserUserClient } from "./userClient"; 14 | 15 | export * from "./domain"; 16 | 17 | type PartialCredentials = { 18 | apiKey?: string; 19 | nettuAccount?: string; 20 | token?: string; 21 | }; 22 | 23 | export interface INettuUserClient { 24 | calendar: NettuCalendarUserClient; 25 | events: NettuEventUserClient; 26 | service: NettuServiceUserClient; 27 | schedule: NettuScheduleUserClient; 28 | user: NettuUserUserClient; 29 | } 30 | 31 | export interface INettuClient { 32 | account: NettuAccountClient; 33 | calendar: NettuCalendarClient; 34 | events: NettuEventClient; 35 | health: NettuHealthClient; 36 | service: NettuServiceClient; 37 | schedule: NettuScheduleClient; 38 | user: _NettuUserClient; 39 | } 40 | 41 | type ClientConfig = { 42 | baseUrl: string; 43 | }; 44 | 45 | export const config: ClientConfig = { 46 | baseUrl: "http://localhost:5000/api/v1", 47 | }; 48 | 49 | export const NettuUserClient = ( 50 | partialCreds?: PartialCredentials 51 | ): INettuUserClient => { 52 | const creds = createCreds(partialCreds); 53 | 54 | return Object.freeze({ 55 | calendar: new NettuCalendarUserClient(creds), 56 | events: new NettuEventUserClient(creds), 57 | service: new NettuServiceUserClient(creds), 58 | schedule: new NettuScheduleUserClient(creds), 59 | user: new NettuUserUserClient(creds) 60 | }); 61 | }; 62 | 63 | export const NettuClient = ( 64 | partialCreds?: PartialCredentials 65 | ): INettuClient => { 66 | const creds = createCreds(partialCreds); 67 | 68 | return Object.freeze({ 69 | account: new NettuAccountClient(creds), 70 | events: new NettuEventClient(creds), 71 | calendar: new NettuCalendarClient(creds), 72 | user: new _NettuUserClient(creds), 73 | service: new NettuServiceClient(creds), 74 | schedule: new NettuScheduleClient(creds), 75 | health: new NettuHealthClient(creds), 76 | }); 77 | }; 78 | 79 | const createCreds = (creds?: PartialCredentials): ICredentials => { 80 | creds = creds ? creds : {}; 81 | if (creds.apiKey) { 82 | return new AccountCreds(creds.apiKey); 83 | } else if (creds.nettuAccount) { 84 | return new UserCreds(creds.nettuAccount, creds.token); 85 | } else { 86 | // throw new Error("No api key or nettu account provided to nettu client."); 87 | return new EmptyCreds(); 88 | } 89 | }; 90 | -------------------------------------------------------------------------------- /scheduler/clients/javascript/lib/scheduleClient.ts: -------------------------------------------------------------------------------- 1 | import { NettuBaseClient } from "./baseClient"; 2 | import { Schedule, ScheduleRule } from "./domain/schedule"; 3 | 4 | interface UpdateScheduleRequest { 5 | rules?: ScheduleRule[]; 6 | timezone?: string; 7 | } 8 | 9 | interface CreateScheduleRequest { 10 | timezone: string; 11 | rules?: ScheduleRule[]; 12 | } 13 | 14 | type ScheduleResponse = { 15 | schedule: Schedule; 16 | } 17 | 18 | export class NettuScheduleClient extends NettuBaseClient { 19 | public async create(userId: string, req: CreateScheduleRequest) { 20 | return await this.post(`/user/${userId}/schedule`, req); 21 | } 22 | 23 | public async update(scheduleId: string, update: UpdateScheduleRequest) { 24 | return await this.put(`/user/schedule/${scheduleId}`, update); 25 | } 26 | 27 | public async remove(scheduleId: string) { 28 | return await this.delete(`/user/schedule/${scheduleId}`); 29 | } 30 | 31 | public async find(scheduleId: string) { 32 | return await this.get(`/user/schedule/${scheduleId}`); 33 | } 34 | } 35 | 36 | export class NettuScheduleUserClient extends NettuBaseClient { 37 | public async create(req: CreateScheduleRequest) { 38 | return await this.post(`/schedule`, req); 39 | } 40 | 41 | public async update(scheduleId: string, update: UpdateScheduleRequest) { 42 | return await this.put(`/schedule/${scheduleId}`, update); 43 | } 44 | 45 | public async remove(scheduleId: string) { 46 | return await this.delete(`/schedule/${scheduleId}`); 47 | } 48 | 49 | public async find(scheduleId: string) { 50 | return await this.get(`/schedule/${scheduleId}`); 51 | } 52 | } -------------------------------------------------------------------------------- /scheduler/clients/javascript/lib/userClient.ts: -------------------------------------------------------------------------------- 1 | import { CalendarEventInstance } from "./domain/calendarEvent"; 2 | import { NettuBaseClient } from "./baseClient"; 3 | import { Metadata } from "./domain/metadata"; 4 | import { User } from "./domain/user"; 5 | import { IntegrationProvider } from "."; 6 | 7 | type GetUserFeebusyReq = { 8 | startTs: number; 9 | endTs: number; 10 | calendarIds?: string[]; 11 | }; 12 | 13 | type GetUserFeebusyResponse = { 14 | busy: CalendarEventInstance[]; 15 | }; 16 | 17 | type UpdateUserRequest = { 18 | metadata?: Metadata; 19 | }; 20 | 21 | type CreateUserRequest = { 22 | metadata?: Metadata; 23 | }; 24 | 25 | type UserResponse = { 26 | user: User; 27 | }; 28 | 29 | export class NettuUserClient extends NettuBaseClient { 30 | public create(data?: CreateUserRequest) { 31 | data = data ? data : {}; 32 | return this.post(`/user`, data); 33 | } 34 | 35 | public find(userId: string) { 36 | return this.get(`/user/${userId}`); 37 | } 38 | 39 | public update(userId: string, data: UpdateUserRequest) { 40 | return this.put(`/user/${userId}`, data); 41 | } 42 | 43 | public findByMeta( 44 | meta: { 45 | key: string; 46 | value: string; 47 | }, 48 | skip: number, 49 | limit: number 50 | ) { 51 | return this.get( 52 | `/user/meta?skip=${skip}&limit=${limit}&key=${meta.key}&value=${meta.value}` 53 | ); 54 | } 55 | 56 | public remove(userId: string) { 57 | return this.delete(`/user/${userId}`); 58 | } 59 | 60 | public freebusy(userId: string, req: GetUserFeebusyReq) { 61 | let queryString = `startTs=${req.startTs}&endTs=${req.endTs}`; 62 | if (req.calendarIds && req.calendarIds.length > 0) { 63 | queryString += `&calendarIds=${req.calendarIds.join(",")}`; 64 | } 65 | return this.get( 66 | `/user/${userId}/freebusy?${queryString}` 67 | ); 68 | } 69 | 70 | public oauth(userId: string, code: string, provider: IntegrationProvider) { 71 | const body = { code, provider }; 72 | return this.post(`user/${userId}/oauth`, body); 73 | } 74 | 75 | public removeIntegration(userId: string, provider: IntegrationProvider) { 76 | return this.delete(`user/${userId}/oauth/${provider}`); 77 | } 78 | } 79 | 80 | export class NettuUserUserClient extends NettuBaseClient { 81 | public me() { 82 | return this.get(`/me`); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /scheduler/clients/javascript/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@nettu/sdk-scheduler", 3 | "version": "0.5.3", 4 | "description": "The Nettu Scheduler Javascript library provides convenient access to the Nettu Scheudler API from server-side JavaScript applications or web applications", 5 | "main": "dist/index.js", 6 | "scripts": { 7 | "build": "node_modules/.bin/tsc --p ./tsconfig.release.json", 8 | "deploy": "npm run build && npm publish", 9 | "test": "jest tests/ -i --verbose" 10 | }, 11 | "repository": { 12 | "type": "git", 13 | "url": "git+https://github.com/fmeringdal/nettu-scheduler-js-client.git" 14 | }, 15 | "contributors": [ 16 | "Fredrik Meringdal " 17 | ], 18 | "files": [ 19 | "dist/" 20 | ], 21 | "keywords": [], 22 | "author": "", 23 | "license": "ISC", 24 | "dependencies": { 25 | "axios": "^0.21.1" 26 | }, 27 | "devDependencies": { 28 | "@types/jest": "^26.0.24", 29 | "@types/jsonwebtoken": "^8.5.0", 30 | "jest": "^26.6.3", 31 | "jsonwebtoken": "^8.5.1", 32 | "ts-jest": "^26.4.4", 33 | "typescript": "^4.1.3" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /scheduler/clients/javascript/tests/account.spec.ts: -------------------------------------------------------------------------------- 1 | import { NettuClient } from "../lib"; 2 | import { 3 | setupAccount, 4 | setupUserClientForAccount, 5 | CREATE_ACCOUNT_CODE, 6 | } from "./helpers/fixtures"; 7 | import { readPrivateKey, readPublicKey } from "./helpers/utils"; 8 | 9 | describe("Account API", () => { 10 | const client = NettuClient(); 11 | 12 | it("should create account", async () => { 13 | const { status, data } = await client.account.create({ 14 | code: CREATE_ACCOUNT_CODE, 15 | }); 16 | expect(status).toBe(201); 17 | expect(data!).toBeDefined(); 18 | }); 19 | 20 | it("should find account", async () => { 21 | const { status, data } = await client.account.create({ 22 | code: CREATE_ACCOUNT_CODE, 23 | }); 24 | const accountClient = NettuClient({ apiKey: data!.secretApiKey }); 25 | const res = await accountClient.account.me(); 26 | expect(res.status).toBe(200); 27 | expect(res.data!.account.id).toBe(data!.account.id); 28 | }); 29 | 30 | it("should not find account when not signed in", async () => { 31 | const res = await client.account.me(); 32 | expect(res.status).toBe(401); 33 | }); 34 | 35 | it("should upload account public key and be able to remove it", async () => { 36 | const { client } = await setupAccount(); 37 | const publicKey = await readPublicKey(); 38 | await client.account.setPublicSigningKey(publicKey); 39 | let res = await client.account.me(); 40 | expect(res.data!.account.publicJwtKey!).toBe(publicKey); 41 | const userRes = await client.user.create(); 42 | const user = userRes.data!.user; 43 | // validate that a user can now use token to interact with api 44 | const privateKey = await readPrivateKey(); 45 | const { client: userClient } = setupUserClientForAccount( 46 | privateKey, 47 | user.id, 48 | res.data!.account.id 49 | ); 50 | const { status } = await userClient.calendar.create({ timezone: "UTC" }); 51 | expect(status).toBe(201); 52 | // now disable public key and dont allow jwt token anymore 53 | await client.account.removePublicSigningKey(); 54 | res = await client.account.me(); 55 | expect(res.data!.account.publicJwtKey).toBeNull(); 56 | 57 | const { status: status2 } = await userClient.calendar.create({ 58 | timezone: "UTC", 59 | }); 60 | expect(status2).toBe(401); 61 | }); 62 | }); 63 | -------------------------------------------------------------------------------- /scheduler/clients/javascript/tests/calendar.spec.ts: -------------------------------------------------------------------------------- 1 | import { INettuClient, NettuClient, config, INettuUserClient } from "../lib"; 2 | import { setupUserClient } from "./helpers/fixtures"; 3 | 4 | describe("Calendar API", () => { 5 | let client: INettuUserClient; 6 | let userId: string; 7 | const unauthClient = NettuClient(); 8 | 9 | beforeAll(async () => { 10 | const data = await setupUserClient(); 11 | client = data.userClient; 12 | userId = data.userId; 13 | }); 14 | 15 | it("should not create calendar for unauthenticated user", async () => { 16 | const res = await unauthClient.calendar.create(userId, { 17 | timezone: "UTC", 18 | }); 19 | expect(res.status).toBe(401); 20 | }); 21 | 22 | it("should create calendar for authenticated user", async () => { 23 | const res = await client.calendar.create({ 24 | timezone: "UTC", 25 | }); 26 | expect(res.status).toBe(201); 27 | expect(res.data!.calendar.id).toBeDefined(); 28 | }); 29 | 30 | it("should delete calendar for authenticated user and not for unauthenticated user", async () => { 31 | let res = await client.calendar.create({ 32 | timezone: "UTC", 33 | }); 34 | const calendarId = res.data!.calendar.id; 35 | res = await unauthClient.calendar.remove(calendarId); 36 | expect(res.status).toBe(401); 37 | res = await client.calendar.remove(calendarId); 38 | expect(res.status).toBe(200); 39 | res = await client.calendar.remove(calendarId); 40 | expect(res.status).toBe(404); 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /scheduler/clients/javascript/tests/config/test_private_rsa_key.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | MIIEpQIBAAKCAQEAzyc1uSB+WUsaegRlimwMaGgWqXkHOS3MVk3CgotZc292ujqI 3 | muQYtFVCtKQodn0xOEC0NQq9KJXqgf9HyfU29h5UCRqW1ZeWjILLDy0gTC/slOUd 4 | 6vjMMuCCU/os0AoUmPOAEpAeQiXWWXpRqaMMECbc7YI/cbIiHHPMhzQSxkDAc9yc 5 | DuzJ1QytHubmsNIGBE0io/LX3zszjCyoPsgDKAiVttzwaCoXUrhK+FYWh8jDXri6 6 | Tc/aKJ1/ZHJM8MHGzt2dRJe83nYfUApUlEnaQXB/w/i/JinAXz+qF787bWQvpWQ/ 7 | hkGehBWB2BqlyHAZ0OKSs2SaXVaJQIqXyMyzmwIDAQABAoIBAAab/qfQdJeOwOKB 8 | v2eiOOcf4xE3LlbRskJSqtEVdx4qwUQB2BfxDSS7z6wJzMyzA94Cmn1SwWRJHDlX 9 | lsfHziAeKZo8wfFAq+oBxk7Opsgng0ng4Yp8s68v4JijU8izeaLDqiNte7mqkWM7 10 | dt2NuTXOt5/QVwvenh4AR9dMfwjaO1fKJZ29P0KHx9sbee1LOiI0iN3ZQ7rhAIPL 11 | tCaRMTGQ+lBCWe9ueYV689t5TSDioq89+cEnqjVWLeL5usk4xQtElJbbB2ZiIbkf 12 | wHtF87QFqOpC/vp6qalm87AywZFnBI4lC0PtqXtAchaJBQ807tuPSPGGixbVK5BE 13 | 8BB0SzECgYEA5pHfvi86WHthC5gi46Bn+vZBmbbb3Qx9F3DNqct39EQVsD6H7pCw 14 | mRc0YdsuYGHcnTqaQiQ04+5u+Gy38+ijb7Mu2r+rUuqVbCyMOdV1f5u/K3JXYuFt 15 | r/L/aUfM9Bz6/Q6Lyo8ToUldX/GYQNUl5SsWP1OB0y4A041VTVJX/kMCgYEA5gAq 16 | +REs31G1IhdQbzKNnimChwryTXIOYBR8/ugw3jFs+Sb1HwGpnRcYf6ZhDqRMbmOz 17 | lsD/zJnfxxoOHmTaHVEbnftcCcuIiZqO7PQn2pbVk4CIHE1ZPw9/K7xs+0QYpOsg 18 | RdVl46b0sYM0s4W3Z6Pxuj0OaP2YwKo1ngi3G8kCgYEA0VdarQOWVuXWi79a1g86 19 | uUpC73xuDTocjV7W7DYXuEjk5DsyEfF+1dCSt9JYPhw8QOkHS8wx1U0TpiyXrDXp 20 | xi4K+YOS2tqwRiIAQzZC01Smcp0DKH0CqQDY007kkDOL0p0VYRkcupCw3b6t/RdJ 21 | q9O+BEsekY2wJGOrMmP0Dp8CgYEAuMGnw32FgzraezEpPrnoQwXrQVmMvKODYrDy 22 | m72fC82+UQJ3Y1ntizAzUM8xJhbbAs36RH5yvUNaHFEUyFuRTn2J5sU08PVbj9Xl 23 | O/kBTrldhWh5berAZ0SmjlaFYO4ZsdjiitZaS54g77uLCS6/3nQ2yLklKzeTjijs 24 | ey9bD+kCgYEApf137WASHV+BcVU6uTEzt/hVUT5ScjYv7+i7g7/8dkNl16WNgDxf 25 | 1x3lfjFC/djiXuidRJn1fnioUh1Z1ar//tvjGa9qUdV4247NwbX0XSFZ9Uwgushv 26 | CS38YoUcT+taMrT6IgHQpuEfUgQSNlHOjh9kt4rlVI9Wa59Z2sl7OXY= 27 | -----END RSA PRIVATE KEY----- 28 | -------------------------------------------------------------------------------- /scheduler/clients/javascript/tests/config/test_public_rsa_key.crt: -------------------------------------------------------------------------------- 1 | -----BEGIN PUBLIC KEY----- 2 | MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzyc1uSB+WUsaegRlimwM 3 | aGgWqXkHOS3MVk3CgotZc292ujqImuQYtFVCtKQodn0xOEC0NQq9KJXqgf9HyfU2 4 | 9h5UCRqW1ZeWjILLDy0gTC/slOUd6vjMMuCCU/os0AoUmPOAEpAeQiXWWXpRqaMM 5 | ECbc7YI/cbIiHHPMhzQSxkDAc9ycDuzJ1QytHubmsNIGBE0io/LX3zszjCyoPsgD 6 | KAiVttzwaCoXUrhK+FYWh8jDXri6Tc/aKJ1/ZHJM8MHGzt2dRJe83nYfUApUlEna 7 | QXB/w/i/JinAXz+qF787bWQvpWQ/hkGehBWB2BqlyHAZ0OKSs2SaXVaJQIqXyMyz 8 | mwIDAQAB 9 | -----END PUBLIC KEY----- 10 | -------------------------------------------------------------------------------- /scheduler/clients/javascript/tests/health.spec.ts: -------------------------------------------------------------------------------- 1 | import { NettuClient } from "../lib"; 2 | 3 | describe("Health API", () => { 4 | const client = NettuClient(); 5 | 6 | it("should report healthy status", async () => { 7 | const status = await client.health.checkStatus(); 8 | expect(status).toBe(200); 9 | }); 10 | }); 11 | -------------------------------------------------------------------------------- /scheduler/clients/javascript/tests/helpers/fixtures.ts: -------------------------------------------------------------------------------- 1 | import { NettuClient, NettuUserClient } from "../../lib"; 2 | import { readPrivateKey, readPublicKey } from "./utils"; 3 | import * as jwt from "jsonwebtoken"; 4 | 5 | export const CREATE_ACCOUNT_CODE = 6 | process.env["CREATE_ACCOUNT_SECRET_CODE"] || "opqI5r3e7v1z2h3P"; 7 | 8 | export const setupAccount = async () => { 9 | const client = NettuClient(); 10 | const account = await client.account.create({ code: CREATE_ACCOUNT_CODE }); 11 | return { 12 | client: NettuClient({ apiKey: account.data!.secretApiKey }), 13 | accountId: account.data!.account.id, 14 | }; 15 | }; 16 | 17 | export const setupUserClient = async () => { 18 | const { client, accountId } = await setupAccount(); 19 | const publicKey = await readPublicKey(); 20 | await client.account.setPublicSigningKey(publicKey); 21 | const privateKey = await readPrivateKey(); 22 | const userRes = await client.user.create(); 23 | const user = userRes.data!.user; 24 | const { client: userClient } = setupUserClientForAccount( 25 | privateKey, 26 | user.id, 27 | accountId 28 | ); 29 | 30 | return { 31 | accountClient: client, 32 | userClient, 33 | userId: user.id, 34 | accountId, 35 | }; 36 | }; 37 | 38 | export const setupUserClientForAccount = ( 39 | privateKey: string, 40 | userId: string, 41 | accountId: string 42 | ) => { 43 | const token = jwt.sign( 44 | { 45 | nettuSchedulerUserId: userId, 46 | schedulerPolicy: { 47 | allow: ["*"], 48 | }, 49 | }, 50 | privateKey, 51 | { 52 | algorithm: "RS256", 53 | expiresIn: "1h", 54 | } 55 | ); 56 | return { 57 | token, 58 | client: NettuUserClient({ token, nettuAccount: accountId }), 59 | }; 60 | }; 61 | 62 | export const createAccountAndUser = async () => { 63 | const data = await setupUserClient(); 64 | const user = await data.accountClient.user.create(); 65 | return { 66 | ...data, 67 | user, 68 | }; 69 | }; 70 | -------------------------------------------------------------------------------- /scheduler/clients/javascript/tests/helpers/utils.ts: -------------------------------------------------------------------------------- 1 | import * as fs from "fs"; 2 | 3 | const configFolder = __dirname + "/../config"; 4 | 5 | export const readFile = async (path: string) => { 6 | return fs.readFileSync(path).toString(); 7 | }; 8 | 9 | export const readPublicKey = async () => 10 | await readFile(`${configFolder}/test_public_rsa_key.crt`); 11 | 12 | export const readPrivateKey = async (): Promise => 13 | await readFile(`${configFolder}/test_private_rsa_key.pem`); 14 | -------------------------------------------------------------------------------- /scheduler/clients/javascript/tests/schedule.spec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | INettuUserClient, 3 | NettuClient, 4 | ScheduleRuleVariant, 5 | Weekday, 6 | } from "../lib"; 7 | import { setupUserClient } from "./helpers/fixtures"; 8 | 9 | describe("Schedule API", () => { 10 | let client: INettuUserClient; 11 | const unauthClient = NettuClient(); 12 | let userId: string; 13 | 14 | beforeAll(async () => { 15 | const data = await setupUserClient(); 16 | client = data.userClient; 17 | userId = data.userId; 18 | }); 19 | 20 | it("should not create schedule for unauthenticated user", async () => { 21 | const res = await unauthClient.schedule.create(userId, { 22 | timezone: "Europe/Berlin", 23 | }); 24 | expect(res.status).toBe(401); 25 | }); 26 | 27 | it("should create schedule for authenticated user", async () => { 28 | const res = await client.schedule.create({ 29 | timezone: "Europe/Berlin", 30 | }); 31 | expect(res.status).toBe(201); 32 | expect(res.data!.schedule.id).toBeDefined(); 33 | expect(res.data!.schedule.rules.length).toBe(7); 34 | }); 35 | 36 | it("should delete schedule for authenticated user and not for unauthenticated user", async () => { 37 | let { data } = await client.schedule.create({ 38 | timezone: "Europe/Berlin", 39 | }); 40 | const scheduleId = data!.schedule.id; 41 | 42 | let res = await unauthClient.schedule.remove(scheduleId); 43 | expect(res.status).toBe(401); 44 | res = await client.schedule.remove(scheduleId); 45 | expect(res.status).toBe(200); 46 | res = await client.schedule.remove(scheduleId); 47 | expect(res.status).toBe(404); 48 | }); 49 | 50 | it("should update schedule", async () => { 51 | const { data } = await client.schedule.create({ 52 | timezone: "Europe/Berlin", 53 | }); 54 | const scheduleId = data!.schedule.id; 55 | const updatedScheduleRes = await client.schedule.update(scheduleId, { 56 | rules: [ 57 | { 58 | variant: { 59 | type: ScheduleRuleVariant.WDay, 60 | value: Weekday.Mon, 61 | }, 62 | intervals: [ 63 | { 64 | start: { 65 | hours: 10, 66 | minutes: 0, 67 | }, 68 | end: { 69 | hours: 12, 70 | minutes: 30, 71 | }, 72 | }, 73 | ], 74 | }, 75 | ], 76 | timezone: "UTC", 77 | }); 78 | const updatedSchedule = updatedScheduleRes.data!.schedule; 79 | 80 | expect(updatedSchedule!.id).toBe(scheduleId); 81 | expect(updatedSchedule!.timezone).toBe("UTC"); 82 | expect(updatedSchedule!.rules.length).toBe(1); 83 | }); 84 | }); 85 | -------------------------------------------------------------------------------- /scheduler/clients/javascript/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "CommonJS", 4 | "target": "ES5", 5 | "declaration": true, 6 | "declarationDir": "dist", 7 | "outDir": "dist", 8 | "strict": true, 9 | "alwaysStrict": true, 10 | "sourceMap": true, 11 | "allowSyntheticDefaultImports": true, 12 | "moduleResolution": "node", 13 | "downlevelIteration": true, 14 | "lib": [ 15 | "es2015", 16 | "dom", 17 | "esnext.asynciterable", 18 | "es2017.object", 19 | ], 20 | "noImplicitAny": true, 21 | "resolveJsonModule": true 22 | }, 23 | "include": [ 24 | "lib/**/*", 25 | "tests/**/*" 26 | ], 27 | "exclude": [ 28 | "node_modules" 29 | ] 30 | } 31 | -------------------------------------------------------------------------------- /scheduler/clients/javascript/tsconfig.release.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "CommonJS", 4 | "target": "ES5", 5 | "declaration": true, 6 | "declarationDir": "dist", 7 | "outDir": "dist", 8 | "strict": true, 9 | "alwaysStrict": true, 10 | "sourceMap": true, 11 | "allowSyntheticDefaultImports": true, 12 | "moduleResolution": "node", 13 | "downlevelIteration": true, 14 | "lib": [ 15 | "es2015", 16 | "dom", 17 | "esnext.asynciterable", 18 | "es2017.object", 19 | ], 20 | "noImplicitAny": true, 21 | "resolveJsonModule": true 22 | }, 23 | "include": [ 24 | "lib/**/*", 25 | ], 26 | "exclude": [ 27 | "node_modules" 28 | ] 29 | } 30 | -------------------------------------------------------------------------------- /scheduler/clients/rust/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "nettu_scheduler_sdk" 3 | version = "0.4.1" 4 | description = "Nettu scheduler sdk" 5 | license = "MIT" 6 | authors = ["Fredrik Meringdal"] 7 | edition = "2018" 8 | 9 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 10 | 11 | [dependencies] 12 | nettu_scheduler_api_structs = { path = "../../crates/api_structs", version = "0.3.1" } 13 | nettu_scheduler_domain = { path = "../../crates/domain", version = "0.2.1" } 14 | reqwest = { version = "0.11.4", features = ["json"] } 15 | serde = "1.0.123" 16 | -------------------------------------------------------------------------------- /scheduler/clients/rust/src/account.rs: -------------------------------------------------------------------------------- 1 | use crate::{APIResponse, BaseClient}; 2 | use nettu_scheduler_api_structs::*; 3 | use reqwest::StatusCode; 4 | use std::sync::Arc; 5 | 6 | #[derive(Clone)] 7 | pub struct AccountClient { 8 | base: Arc, 9 | } 10 | 11 | impl AccountClient { 12 | pub(crate) fn new(base: Arc) -> Self { 13 | Self { base } 14 | } 15 | 16 | pub async fn get(&self) -> APIResponse { 17 | self.base.get("account".into(), StatusCode::OK).await 18 | } 19 | 20 | pub async fn create(&self, code: &str) -> APIResponse { 21 | let body = create_account::RequestBody { code: code.into() }; 22 | self.base 23 | .post(body, "account".into(), StatusCode::CREATED) 24 | .await 25 | } 26 | 27 | pub async fn create_webhook(&self, url: &str) -> APIResponse { 28 | let body = set_account_webhook::RequestBody { 29 | webhook_url: url.into(), 30 | }; 31 | self.base 32 | .put(body, "account/webhook".into(), StatusCode::OK) 33 | .await 34 | } 35 | 36 | pub async fn delete_webhook(&self) -> APIResponse { 37 | self.base 38 | .delete("account/webhook".into(), StatusCode::OK) 39 | .await 40 | } 41 | 42 | pub async fn set_account_pub_key( 43 | &self, 44 | key: Option, 45 | ) -> APIResponse { 46 | let body = set_account_pub_key::RequestBody { 47 | public_jwt_key: key, 48 | }; 49 | self.base 50 | .put(body, "account/pubkey".into(), StatusCode::OK) 51 | .await 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /scheduler/clients/rust/src/schedule.rs: -------------------------------------------------------------------------------- 1 | use crate::{APIResponse, BaseClient, ScheduleRule, ID}; 2 | use nettu_scheduler_api_structs::*; 3 | use nettu_scheduler_domain::Metadata; 4 | use nettu_scheduler_domain::Tz; 5 | use reqwest::StatusCode; 6 | use std::sync::Arc; 7 | 8 | #[derive(Clone)] 9 | pub struct ScheduleClient { 10 | base: Arc, 11 | } 12 | 13 | pub struct CreateScheduleInput { 14 | pub timezone: Tz, 15 | pub rules: Option>, 16 | pub user_id: ID, 17 | pub metadata: Option, 18 | } 19 | 20 | pub struct UpdateScheduleInput { 21 | pub timezone: Option, 22 | pub rules: Option>, 23 | pub schedule_id: ID, 24 | pub metadata: Option, 25 | } 26 | 27 | impl ScheduleClient { 28 | pub(crate) fn new(base: Arc) -> Self { 29 | Self { base } 30 | } 31 | 32 | pub async fn get(&self, schedule_id: ID) -> APIResponse { 33 | self.base 34 | .get(format!("user/schedule/{}", schedule_id), StatusCode::OK) 35 | .await 36 | } 37 | 38 | pub async fn delete(&self, schedule_id: ID) -> APIResponse { 39 | self.base 40 | .delete(format!("user/schedule/{}", schedule_id), StatusCode::OK) 41 | .await 42 | } 43 | 44 | pub async fn update( 45 | &self, 46 | input: UpdateScheduleInput, 47 | ) -> APIResponse { 48 | let body = update_schedule::RequestBody { 49 | timezone: input.timezone, 50 | rules: input.rules, 51 | metadata: input.metadata, 52 | }; 53 | 54 | self.base 55 | .put( 56 | body, 57 | format!("user/schedule/{}", input.schedule_id), 58 | StatusCode::OK, 59 | ) 60 | .await 61 | } 62 | 63 | pub async fn create( 64 | &self, 65 | input: CreateScheduleInput, 66 | ) -> APIResponse { 67 | let body = create_schedule::RequestBody { 68 | timezone: input.timezone, 69 | rules: input.rules, 70 | metadata: input.metadata, 71 | }; 72 | let path = create_schedule::PathParams { 73 | user_id: input.user_id, 74 | }; 75 | 76 | self.base 77 | .post( 78 | body, 79 | format!("user/{}/schedule", path.user_id), 80 | StatusCode::CREATED, 81 | ) 82 | .await 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /scheduler/clients/rust/src/shared.rs: -------------------------------------------------------------------------------- 1 | pub struct KVMetadata { 2 | pub key: String, 3 | pub value: String, 4 | } 5 | 6 | pub struct MetadataFindInput { 7 | pub limit: usize, 8 | pub skip: usize, 9 | pub metadata: KVMetadata, 10 | } 11 | 12 | impl MetadataFindInput { 13 | pub(crate) fn to_query_string(&self) -> String { 14 | format!( 15 | "skip={}&limit={}&key={}&value={}", 16 | self.skip, self.limit, self.metadata.key, self.metadata.value 17 | ) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /scheduler/clients/rust/src/status.rs: -------------------------------------------------------------------------------- 1 | use crate::{APIResponse, BaseClient}; 2 | use nettu_scheduler_api_structs::*; 3 | use reqwest::StatusCode; 4 | use std::sync::Arc; 5 | 6 | #[derive(Clone)] 7 | pub struct StatusClient { 8 | base: Arc, 9 | } 10 | 11 | impl StatusClient { 12 | pub(crate) fn new(base: Arc) -> Self { 13 | Self { base } 14 | } 15 | 16 | pub async fn check_health(&self) -> APIResponse { 17 | self.base.get("".into(), StatusCode::OK).await 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /scheduler/crates/api/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "nettu_scheduler_api" 3 | version = "0.1.0" 4 | authors = ["Fredrik Meringdal"] 5 | edition = "2018" 6 | 7 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 8 | 9 | [dependencies] 10 | nettu_scheduler_api_structs = { path = "../api_structs" } 11 | nettu_scheduler_domain = { path = "../domain" } 12 | nettu_scheduler_infra = { path = "../infra" } 13 | serde = { version = "1.0", features = ["derive"] } 14 | futures = "0.3" 15 | actix-web = "4.0.0-beta.8" 16 | actix-cors = "0.6.0-beta.2" 17 | awc = "3.0.0-beta.7" 18 | async-trait = "0.1.42" 19 | rrule="0.5.8" 20 | chrono = { version = "0.4.19", features = ["serde"] } 21 | chrono-tz = "0.5.3" 22 | anyhow = "1.0.0" 23 | jsonwebtoken = "7" 24 | thiserror = "1.0" 25 | tracing = "0.1.25" 26 | tracing-actix-web = "0.4.0-beta.10" 27 | tracing-futures = "0.2.5" 28 | 29 | [dev-dependencies] 30 | serial_test = "*" -------------------------------------------------------------------------------- /scheduler/crates/api/config/test_private_rsa_key.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | MIIEpQIBAAKCAQEAzyc1uSB+WUsaegRlimwMaGgWqXkHOS3MVk3CgotZc292ujqI 3 | muQYtFVCtKQodn0xOEC0NQq9KJXqgf9HyfU29h5UCRqW1ZeWjILLDy0gTC/slOUd 4 | 6vjMMuCCU/os0AoUmPOAEpAeQiXWWXpRqaMMECbc7YI/cbIiHHPMhzQSxkDAc9yc 5 | DuzJ1QytHubmsNIGBE0io/LX3zszjCyoPsgDKAiVttzwaCoXUrhK+FYWh8jDXri6 6 | Tc/aKJ1/ZHJM8MHGzt2dRJe83nYfUApUlEnaQXB/w/i/JinAXz+qF787bWQvpWQ/ 7 | hkGehBWB2BqlyHAZ0OKSs2SaXVaJQIqXyMyzmwIDAQABAoIBAAab/qfQdJeOwOKB 8 | v2eiOOcf4xE3LlbRskJSqtEVdx4qwUQB2BfxDSS7z6wJzMyzA94Cmn1SwWRJHDlX 9 | lsfHziAeKZo8wfFAq+oBxk7Opsgng0ng4Yp8s68v4JijU8izeaLDqiNte7mqkWM7 10 | dt2NuTXOt5/QVwvenh4AR9dMfwjaO1fKJZ29P0KHx9sbee1LOiI0iN3ZQ7rhAIPL 11 | tCaRMTGQ+lBCWe9ueYV689t5TSDioq89+cEnqjVWLeL5usk4xQtElJbbB2ZiIbkf 12 | wHtF87QFqOpC/vp6qalm87AywZFnBI4lC0PtqXtAchaJBQ807tuPSPGGixbVK5BE 13 | 8BB0SzECgYEA5pHfvi86WHthC5gi46Bn+vZBmbbb3Qx9F3DNqct39EQVsD6H7pCw 14 | mRc0YdsuYGHcnTqaQiQ04+5u+Gy38+ijb7Mu2r+rUuqVbCyMOdV1f5u/K3JXYuFt 15 | r/L/aUfM9Bz6/Q6Lyo8ToUldX/GYQNUl5SsWP1OB0y4A041VTVJX/kMCgYEA5gAq 16 | +REs31G1IhdQbzKNnimChwryTXIOYBR8/ugw3jFs+Sb1HwGpnRcYf6ZhDqRMbmOz 17 | lsD/zJnfxxoOHmTaHVEbnftcCcuIiZqO7PQn2pbVk4CIHE1ZPw9/K7xs+0QYpOsg 18 | RdVl46b0sYM0s4W3Z6Pxuj0OaP2YwKo1ngi3G8kCgYEA0VdarQOWVuXWi79a1g86 19 | uUpC73xuDTocjV7W7DYXuEjk5DsyEfF+1dCSt9JYPhw8QOkHS8wx1U0TpiyXrDXp 20 | xi4K+YOS2tqwRiIAQzZC01Smcp0DKH0CqQDY007kkDOL0p0VYRkcupCw3b6t/RdJ 21 | q9O+BEsekY2wJGOrMmP0Dp8CgYEAuMGnw32FgzraezEpPrnoQwXrQVmMvKODYrDy 22 | m72fC82+UQJ3Y1ntizAzUM8xJhbbAs36RH5yvUNaHFEUyFuRTn2J5sU08PVbj9Xl 23 | O/kBTrldhWh5berAZ0SmjlaFYO4ZsdjiitZaS54g77uLCS6/3nQ2yLklKzeTjijs 24 | ey9bD+kCgYEApf137WASHV+BcVU6uTEzt/hVUT5ScjYv7+i7g7/8dkNl16WNgDxf 25 | 1x3lfjFC/djiXuidRJn1fnioUh1Z1ar//tvjGa9qUdV4247NwbX0XSFZ9Uwgushv 26 | CS38YoUcT+taMrT6IgHQpuEfUgQSNlHOjh9kt4rlVI9Wa59Z2sl7OXY= 27 | -----END RSA PRIVATE KEY----- 28 | -------------------------------------------------------------------------------- /scheduler/crates/api/config/test_public_rsa_key.crt: -------------------------------------------------------------------------------- 1 | -----BEGIN PUBLIC KEY----- 2 | MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzyc1uSB+WUsaegRlimwM 3 | aGgWqXkHOS3MVk3CgotZc292ujqImuQYtFVCtKQodn0xOEC0NQq9KJXqgf9HyfU2 4 | 9h5UCRqW1ZeWjILLDy0gTC/slOUd6vjMMuCCU/os0AoUmPOAEpAeQiXWWXpRqaMM 5 | ECbc7YI/cbIiHHPMhzQSxkDAc9ycDuzJ1QytHubmsNIGBE0io/LX3zszjCyoPsgD 6 | KAiVttzwaCoXUrhK+FYWh8jDXri6Tc/aKJ1/ZHJM8MHGzt2dRJe83nYfUApUlEna 7 | QXB/w/i/JinAXz+qF787bWQvpWQ/hkGehBWB2BqlyHAZ0OKSs2SaXVaJQIqXyMyz 8 | mwIDAQAB 9 | -----END PUBLIC KEY----- 10 | -------------------------------------------------------------------------------- /scheduler/crates/api/src/account/create_account.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | error::NettuError, 3 | shared::usecase::{execute, UseCase}, 4 | }; 5 | use actix_web::{web, HttpResponse}; 6 | use nettu_scheduler_api_structs::create_account::{APIResponse, RequestBody}; 7 | use nettu_scheduler_domain::Account; 8 | use nettu_scheduler_infra::NettuContext; 9 | 10 | pub async fn create_account_controller( 11 | ctx: web::Data, 12 | body: web::Json, 13 | ) -> Result { 14 | let usecase = CreateAccountUseCase { code: body.0.code }; 15 | execute(usecase, &ctx) 16 | .await 17 | .map(|account| HttpResponse::Created().json(APIResponse::new(account))) 18 | .map_err(NettuError::from) 19 | } 20 | 21 | #[derive(Debug)] 22 | struct CreateAccountUseCase { 23 | code: String, 24 | } 25 | 26 | #[derive(Debug)] 27 | enum UseCaseError { 28 | StorageError, 29 | InvalidCreateAccountCode, 30 | } 31 | 32 | impl From for NettuError { 33 | fn from(e: UseCaseError) -> Self { 34 | match e { 35 | UseCaseError::InvalidCreateAccountCode => { 36 | Self::Unauthorized("Invalid code provided".into()) 37 | } 38 | UseCaseError::StorageError => Self::InternalError, 39 | } 40 | } 41 | } 42 | 43 | #[async_trait::async_trait(?Send)] 44 | impl UseCase for CreateAccountUseCase { 45 | type Response = Account; 46 | 47 | type Error = UseCaseError; 48 | 49 | const NAME: &'static str = "CreateAccount"; 50 | 51 | async fn execute(&mut self, ctx: &NettuContext) -> Result { 52 | if self.code != ctx.config.create_account_secret_code { 53 | return Err(UseCaseError::InvalidCreateAccountCode); 54 | } 55 | let account = Account::new(); 56 | let res = ctx.repos.accounts.insert(&account).await; 57 | 58 | res.map(|_| account).map_err(|_| UseCaseError::StorageError) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /scheduler/crates/api/src/account/delete_account_webhook.rs: -------------------------------------------------------------------------------- 1 | use super::set_account_webhook::SetAccountWebhookUseCase; 2 | use crate::error::NettuError; 3 | use crate::shared::auth::protect_account_route; 4 | use crate::shared::usecase::execute; 5 | use actix_web::{web, HttpResponse}; 6 | use nettu_scheduler_api_structs::delete_account_webhook::APIResponse; 7 | use nettu_scheduler_infra::NettuContext; 8 | 9 | pub async fn delete_account_webhook_controller( 10 | http_req: web::HttpRequest, 11 | ctx: web::Data, 12 | ) -> Result { 13 | let account = protect_account_route(&http_req, &ctx).await?; 14 | 15 | let usecase = SetAccountWebhookUseCase { 16 | account, 17 | webhook_url: None, 18 | }; 19 | 20 | execute(usecase, &ctx) 21 | .await 22 | .map(|account| HttpResponse::Ok().json(APIResponse::new(account))) 23 | .map_err(NettuError::from) 24 | } 25 | -------------------------------------------------------------------------------- /scheduler/crates/api/src/account/get_account.rs: -------------------------------------------------------------------------------- 1 | use crate::error::NettuError; 2 | use crate::shared::auth::protect_account_route; 3 | use actix_web::{web, HttpResponse}; 4 | use nettu_scheduler_api_structs::get_account::APIResponse; 5 | use nettu_scheduler_infra::NettuContext; 6 | 7 | pub async fn get_account_controller( 8 | http_req: web::HttpRequest, 9 | ctx: web::Data, 10 | ) -> Result { 11 | let account = protect_account_route(&http_req, &ctx).await?; 12 | 13 | Ok(HttpResponse::Ok().json(APIResponse::new(account))) 14 | } 15 | -------------------------------------------------------------------------------- /scheduler/crates/api/src/account/mod.rs: -------------------------------------------------------------------------------- 1 | mod add_account_integration; 2 | mod create_account; 3 | mod delete_account_webhook; 4 | mod get_account; 5 | mod remove_account_integration; 6 | mod set_account_pub_key; 7 | mod set_account_webhook; 8 | 9 | use actix_web::web; 10 | use add_account_integration::add_account_integration_controller; 11 | use create_account::create_account_controller; 12 | use delete_account_webhook::delete_account_webhook_controller; 13 | use get_account::get_account_controller; 14 | use remove_account_integration::remove_account_integration_controller; 15 | use set_account_pub_key::set_account_pub_key_controller; 16 | use set_account_webhook::set_account_webhook_controller; 17 | 18 | pub fn configure_routes(cfg: &mut web::ServiceConfig) { 19 | cfg.route("/account", web::post().to(create_account_controller)); 20 | cfg.route("/account", web::get().to(get_account_controller)); 21 | cfg.route( 22 | "/account/pubkey", 23 | web::put().to(set_account_pub_key_controller), 24 | ); 25 | cfg.route( 26 | "/account/webhook", 27 | web::put().to(set_account_webhook_controller), 28 | ); 29 | cfg.route( 30 | "/account/webhook", 31 | web::delete().to(delete_account_webhook_controller), 32 | ); 33 | cfg.route( 34 | "/account/integration", 35 | web::put().to(add_account_integration_controller), 36 | ); 37 | cfg.route( 38 | "/account/integration/{provider}", 39 | web::delete().to(remove_account_integration_controller), 40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /scheduler/crates/api/src/account/remove_account_integration.rs: -------------------------------------------------------------------------------- 1 | use crate::shared::usecase::{execute, UseCase}; 2 | use crate::{error::NettuError, shared::auth::protect_account_route}; 3 | use actix_web::{web, HttpResponse}; 4 | use nettu_scheduler_api_structs::remove_account_integration::{APIResponse, PathParams}; 5 | use nettu_scheduler_domain::{Account, IntegrationProvider}; 6 | use nettu_scheduler_infra::NettuContext; 7 | 8 | pub async fn remove_account_integration_controller( 9 | http_req: web::HttpRequest, 10 | mut path: web::Path, 11 | ctx: web::Data, 12 | ) -> Result { 13 | let account = protect_account_route(&http_req, &ctx).await?; 14 | 15 | let usecase = RemoveAccountIntegrationUseCase { 16 | account, 17 | provider: std::mem::take(&mut path.provider), 18 | }; 19 | 20 | execute(usecase, &ctx) 21 | .await 22 | .map(|_| { 23 | HttpResponse::Ok().json(APIResponse::from( 24 | "Provider integration removed from account", 25 | )) 26 | }) 27 | .map_err(NettuError::from) 28 | } 29 | 30 | #[derive(Debug)] 31 | pub struct RemoveAccountIntegrationUseCase { 32 | pub account: Account, 33 | pub provider: IntegrationProvider, 34 | } 35 | 36 | #[derive(Debug, PartialEq)] 37 | pub enum UseCaseError { 38 | StorageError, 39 | IntegrationNotFound, 40 | } 41 | 42 | impl From for NettuError { 43 | fn from(e: UseCaseError) -> Self { 44 | match e { 45 | UseCaseError::StorageError => Self::InternalError, 46 | UseCaseError::IntegrationNotFound => Self::NotFound( 47 | "Did not find an integration between the given account and provider".into(), 48 | ), 49 | } 50 | } 51 | } 52 | 53 | impl From for UseCaseError { 54 | fn from(_: anyhow::Error) -> Self { 55 | UseCaseError::StorageError 56 | } 57 | } 58 | 59 | #[async_trait::async_trait(?Send)] 60 | impl UseCase for RemoveAccountIntegrationUseCase { 61 | type Response = (); 62 | 63 | type Error = UseCaseError; 64 | 65 | const NAME: &'static str = "RemoveAccountIntegration"; 66 | 67 | async fn execute(&mut self, ctx: &NettuContext) -> Result { 68 | let acc_integrations = ctx 69 | .repos 70 | .account_integrations 71 | .find(&self.account.id) 72 | .await?; 73 | if !acc_integrations.iter().any(|i| i.provider == self.provider) { 74 | return Err(UseCaseError::IntegrationNotFound); 75 | } 76 | 77 | ctx.repos 78 | .account_integrations 79 | .delete(&self.account.id, self.provider.clone()) 80 | .await?; 81 | Ok(()) 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /scheduler/crates/api/src/account/set_account_pub_key.rs: -------------------------------------------------------------------------------- 1 | use crate::shared::usecase::{execute, UseCase}; 2 | use crate::{error::NettuError, shared::auth::protect_account_route}; 3 | use actix_web::{web, HttpResponse}; 4 | use nettu_scheduler_api_structs::set_account_pub_key::{APIResponse, RequestBody}; 5 | use nettu_scheduler_domain::{Account, PEMKey}; 6 | use nettu_scheduler_infra::NettuContext; 7 | 8 | pub async fn set_account_pub_key_controller( 9 | http_req: web::HttpRequest, 10 | ctx: web::Data, 11 | body: web::Json, 12 | ) -> Result { 13 | let account = protect_account_route(&http_req, &ctx).await?; 14 | 15 | let usecase = SetAccountPubKeyUseCase { 16 | account, 17 | public_jwt_key: body.public_jwt_key.clone(), 18 | }; 19 | 20 | execute(usecase, &ctx) 21 | .await 22 | .map(|account| HttpResponse::Ok().json(APIResponse::new(account))) 23 | .map_err(NettuError::from) 24 | } 25 | 26 | #[derive(Debug)] 27 | struct SetAccountPubKeyUseCase { 28 | pub account: Account, 29 | pub public_jwt_key: Option, 30 | } 31 | 32 | #[derive(Debug)] 33 | enum UseCaseError { 34 | InvalidPemKey, 35 | StorageError, 36 | } 37 | 38 | impl From for NettuError { 39 | fn from(e: UseCaseError) -> Self { 40 | match e { 41 | UseCaseError::InvalidPemKey => { 42 | Self::BadClientData("Malformed public pem key provided".into()) 43 | } 44 | UseCaseError::StorageError => Self::InternalError, 45 | } 46 | } 47 | } 48 | 49 | #[async_trait::async_trait(?Send)] 50 | impl UseCase for SetAccountPubKeyUseCase { 51 | type Response = Account; 52 | 53 | type Error = UseCaseError; 54 | 55 | const NAME: &'static str = "SetAccountPublicKey"; 56 | 57 | async fn execute(&mut self, ctx: &NettuContext) -> Result { 58 | let key = if let Some(key) = &self.public_jwt_key { 59 | match PEMKey::new(key.clone()) { 60 | Ok(key) => Some(key), 61 | Err(_) => return Err(UseCaseError::InvalidPemKey), 62 | } 63 | } else { 64 | None 65 | }; 66 | self.account.set_public_jwt_key(key); 67 | 68 | match ctx.repos.accounts.save(&self.account).await { 69 | Ok(_) => Ok(self.account.clone()), 70 | Err(_) => Err(UseCaseError::StorageError), 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /scheduler/crates/api/src/calendar/get_calendar.rs: -------------------------------------------------------------------------------- 1 | use crate::shared::{ 2 | auth::{account_can_modify_calendar, protect_account_route}, 3 | usecase::{execute, UseCase}, 4 | }; 5 | use crate::{error::NettuError, shared::auth::protect_route}; 6 | use actix_web::{web, HttpRequest, HttpResponse}; 7 | use nettu_scheduler_api_structs::get_calendar::{APIResponse, PathParams}; 8 | use nettu_scheduler_domain::{Calendar, ID}; 9 | use nettu_scheduler_infra::NettuContext; 10 | 11 | pub async fn get_calendar_admin_controller( 12 | http_req: web::HttpRequest, 13 | path: web::Path, 14 | ctx: web::Data, 15 | ) -> Result { 16 | let account = protect_account_route(&http_req, &ctx).await?; 17 | let cal = account_can_modify_calendar(&account, &path.calendar_id, &ctx).await?; 18 | 19 | let usecase = GetCalendarUseCase { 20 | user_id: cal.user_id, 21 | calendar_id: cal.id, 22 | }; 23 | 24 | execute(usecase, &ctx) 25 | .await 26 | .map(|calendar| HttpResponse::Ok().json(APIResponse::new(calendar))) 27 | .map_err(NettuError::from) 28 | } 29 | 30 | pub async fn get_calendar_controller( 31 | http_req: HttpRequest, 32 | path: web::Path, 33 | ctx: web::Data, 34 | ) -> Result { 35 | let (user, _policy) = protect_route(&http_req, &ctx).await?; 36 | 37 | let usecase = GetCalendarUseCase { 38 | user_id: user.id.clone(), 39 | calendar_id: path.calendar_id.clone(), 40 | }; 41 | 42 | execute(usecase, &ctx) 43 | .await 44 | .map(|calendar| HttpResponse::Ok().json(APIResponse::new(calendar))) 45 | .map_err(NettuError::from) 46 | } 47 | 48 | #[derive(Debug)] 49 | struct GetCalendarUseCase { 50 | pub user_id: ID, 51 | pub calendar_id: ID, 52 | } 53 | 54 | #[derive(Debug)] 55 | enum UseCaseError { 56 | NotFound(ID), 57 | } 58 | impl From for NettuError { 59 | fn from(e: UseCaseError) -> Self { 60 | match e { 61 | UseCaseError::NotFound(calendar_id) => Self::NotFound(format!( 62 | "The calendar with id: {}, was not found.", 63 | calendar_id 64 | )), 65 | } 66 | } 67 | } 68 | 69 | #[async_trait::async_trait(?Send)] 70 | impl UseCase for GetCalendarUseCase { 71 | type Response = Calendar; 72 | 73 | type Error = UseCaseError; 74 | 75 | const NAME: &'static str = "GetCalendar"; 76 | 77 | async fn execute(&mut self, ctx: &NettuContext) -> Result { 78 | let cal = ctx.repos.calendars.find(&self.calendar_id).await; 79 | match cal { 80 | Some(cal) if cal.user_id == self.user_id => Ok(cal), 81 | _ => Err(UseCaseError::NotFound(self.calendar_id.clone())), 82 | } 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /scheduler/crates/api/src/calendar/get_calendars_by_meta.rs: -------------------------------------------------------------------------------- 1 | use crate::{error::NettuError, shared::auth::protect_account_route}; 2 | use actix_web::{web, HttpRequest, HttpResponse}; 3 | use nettu_scheduler_api_structs::get_calendars_by_meta::*; 4 | use nettu_scheduler_domain::Metadata; 5 | use nettu_scheduler_infra::{MetadataFindQuery, NettuContext}; 6 | 7 | pub async fn get_calendars_by_meta_controller( 8 | http_req: HttpRequest, 9 | query_params: web::Query, 10 | ctx: web::Data, 11 | ) -> Result { 12 | let account = protect_account_route(&http_req, &ctx).await?; 13 | 14 | let query = MetadataFindQuery { 15 | account_id: account.id, 16 | metadata: Metadata::new_kv(query_params.0.key, query_params.0.value), 17 | limit: query_params.0.limit.unwrap_or(20), 18 | skip: query_params.0.skip.unwrap_or(0), 19 | }; 20 | let calendars = ctx.repos.calendars.find_by_metadata(query).await; 21 | Ok(HttpResponse::Ok().json(APIResponse::new(calendars))) 22 | } 23 | -------------------------------------------------------------------------------- /scheduler/crates/api/src/calendar/get_google_calendars.rs: -------------------------------------------------------------------------------- 1 | use crate::shared::{ 2 | auth::{account_can_modify_user, protect_account_route}, 3 | usecase::{execute, UseCase}, 4 | }; 5 | use crate::{error::NettuError, shared::auth::protect_route}; 6 | use actix_web::{web, HttpRequest, HttpResponse}; 7 | use nettu_scheduler_api_structs::get_google_calendars::{APIResponse, PathParams, QueryParams}; 8 | use nettu_scheduler_domain::{ 9 | providers::google::{GoogleCalendarAccessRole, GoogleCalendarListEntry}, 10 | User, 11 | }; 12 | use nettu_scheduler_infra::{google_calendar::GoogleCalendarProvider, NettuContext}; 13 | 14 | pub async fn get_google_calendars_admin_controller( 15 | http_req: web::HttpRequest, 16 | path: web::Path, 17 | query: web::Query, 18 | ctx: web::Data, 19 | ) -> Result { 20 | let account = protect_account_route(&http_req, &ctx).await?; 21 | let user = account_can_modify_user(&account, &path.user_id, &ctx).await?; 22 | 23 | let usecase = GetGoogleCalendarsUseCase { 24 | user, 25 | min_access_role: query.0.min_access_role, 26 | }; 27 | 28 | execute(usecase, &ctx) 29 | .await 30 | .map(|calendars| HttpResponse::Ok().json(APIResponse::new(calendars))) 31 | .map_err(NettuError::from) 32 | } 33 | 34 | pub async fn get_google_calendars_controller( 35 | http_req: HttpRequest, 36 | query: web::Query, 37 | ctx: web::Data, 38 | ) -> Result { 39 | let (user, _policy) = protect_route(&http_req, &ctx).await?; 40 | 41 | let usecase = GetGoogleCalendarsUseCase { 42 | user, 43 | min_access_role: query.0.min_access_role, 44 | }; 45 | 46 | execute(usecase, &ctx) 47 | .await 48 | .map(|calendars| HttpResponse::Ok().json(APIResponse::new(calendars))) 49 | .map_err(NettuError::from) 50 | } 51 | 52 | #[derive(Debug)] 53 | struct GetGoogleCalendarsUseCase { 54 | pub user: User, 55 | pub min_access_role: GoogleCalendarAccessRole, 56 | } 57 | 58 | #[derive(Debug)] 59 | enum UseCaseError { 60 | UserNotConnectedToGoogle, 61 | GoogleQuery, 62 | } 63 | 64 | impl From for NettuError { 65 | fn from(e: UseCaseError) -> Self { 66 | match e { 67 | UseCaseError::UserNotConnectedToGoogle => { 68 | Self::BadClientData("The user is not connected to google.".into()) 69 | } 70 | UseCaseError::GoogleQuery => Self::InternalError, 71 | } 72 | } 73 | } 74 | 75 | #[async_trait::async_trait(?Send)] 76 | impl UseCase for GetGoogleCalendarsUseCase { 77 | type Response = Vec; 78 | 79 | type Error = UseCaseError; 80 | 81 | const NAME: &'static str = "GetGoogleCalendars"; 82 | 83 | async fn execute(&mut self, ctx: &NettuContext) -> Result { 84 | let provider = GoogleCalendarProvider::new(&self.user, ctx) 85 | .await 86 | .map_err(|_| UseCaseError::UserNotConnectedToGoogle)?; 87 | 88 | provider 89 | .list(self.min_access_role.clone()) 90 | .await 91 | .map_err(|_| UseCaseError::GoogleQuery) 92 | .map(|res| res.items) 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /scheduler/crates/api/src/calendar/get_outlook_calendars.rs: -------------------------------------------------------------------------------- 1 | use crate::shared::{ 2 | auth::{account_can_modify_user, protect_account_route}, 3 | usecase::{execute, UseCase}, 4 | }; 5 | use crate::{error::NettuError, shared::auth::protect_route}; 6 | use actix_web::{web, HttpRequest, HttpResponse}; 7 | use nettu_scheduler_api_structs::get_outlook_calendars::{APIResponse, PathParams, QueryParams}; 8 | use nettu_scheduler_domain::{ 9 | providers::outlook::{OutlookCalendar, OutlookCalendarAccessRole}, 10 | User, 11 | }; 12 | use nettu_scheduler_infra::{outlook_calendar::OutlookCalendarProvider, NettuContext}; 13 | 14 | pub async fn get_outlook_calendars_admin_controller( 15 | http_req: web::HttpRequest, 16 | path: web::Path, 17 | query: web::Query, 18 | ctx: web::Data, 19 | ) -> Result { 20 | let account = protect_account_route(&http_req, &ctx).await?; 21 | let user = account_can_modify_user(&account, &path.user_id, &ctx).await?; 22 | 23 | let usecase = GetOutlookCalendarsUseCase { 24 | user, 25 | min_access_role: query.0.min_access_role, 26 | }; 27 | 28 | execute(usecase, &ctx) 29 | .await 30 | .map(|calendars| HttpResponse::Ok().json(APIResponse::new(calendars))) 31 | .map_err(NettuError::from) 32 | } 33 | 34 | pub async fn get_outlook_calendars_controller( 35 | http_req: HttpRequest, 36 | query: web::Query, 37 | ctx: web::Data, 38 | ) -> Result { 39 | let (user, _policy) = protect_route(&http_req, &ctx).await?; 40 | 41 | let usecase = GetOutlookCalendarsUseCase { 42 | user, 43 | min_access_role: query.0.min_access_role, 44 | }; 45 | 46 | execute(usecase, &ctx) 47 | .await 48 | .map(|calendars| HttpResponse::Ok().json(APIResponse::new(calendars))) 49 | .map_err(NettuError::from) 50 | } 51 | 52 | #[derive(Debug)] 53 | struct GetOutlookCalendarsUseCase { 54 | pub user: User, 55 | pub min_access_role: OutlookCalendarAccessRole, 56 | } 57 | 58 | #[derive(Debug)] 59 | enum UseCaseError { 60 | UserNotConnectedToOutlook, 61 | OutlookQuery, 62 | } 63 | 64 | impl From for NettuError { 65 | fn from(e: UseCaseError) -> Self { 66 | match e { 67 | UseCaseError::UserNotConnectedToOutlook => { 68 | Self::BadClientData("The user is not connected to outlook.".into()) 69 | } 70 | UseCaseError::OutlookQuery => Self::InternalError, 71 | } 72 | } 73 | } 74 | 75 | #[async_trait::async_trait(?Send)] 76 | impl UseCase for GetOutlookCalendarsUseCase { 77 | type Response = Vec; 78 | 79 | type Error = UseCaseError; 80 | 81 | const NAME: &'static str = "GetOutlookCalendars"; 82 | 83 | async fn execute(&mut self, ctx: &NettuContext) -> Result { 84 | let provider = OutlookCalendarProvider::new(&self.user, ctx) 85 | .await 86 | .map_err(|_| UseCaseError::UserNotConnectedToOutlook)?; 87 | 88 | provider 89 | .list(self.min_access_role.clone()) 90 | .await 91 | .map_err(|_| UseCaseError::OutlookQuery) 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /scheduler/crates/api/src/error.rs: -------------------------------------------------------------------------------- 1 | use actix_web::{ 2 | http::{header, StatusCode}, 3 | HttpResponse, 4 | }; 5 | use thiserror::Error; 6 | 7 | #[derive(Error, Debug)] 8 | pub enum NettuError { 9 | #[error("Internal server error")] 10 | InternalError, 11 | #[error("Invalid data provided: Error message: `{0}`")] 12 | BadClientData(String), 13 | #[error("There was a conflict with the request. Error message: `{0}`")] 14 | Conflict(String), 15 | #[error("Unauthorized request. Error message: `{0}`")] 16 | Unauthorized(String), 17 | #[error( 18 | "Unidentifiable client. Must include the `nettu-account` header. Error message: `{0}`" 19 | )] 20 | UnidentifiableClient(String), 21 | #[error("404 Not found. Error message: `{0}`")] 22 | NotFound(String), 23 | } 24 | 25 | impl actix_web::error::ResponseError for NettuError { 26 | fn status_code(&self) -> StatusCode { 27 | match *self { 28 | Self::InternalError => StatusCode::INTERNAL_SERVER_ERROR, 29 | Self::BadClientData(_) => StatusCode::BAD_REQUEST, 30 | Self::Unauthorized(_) => StatusCode::UNAUTHORIZED, 31 | Self::Conflict(_) => StatusCode::CONFLICT, 32 | Self::NotFound(_) => StatusCode::NOT_FOUND, 33 | Self::UnidentifiableClient(_) => StatusCode::UNAUTHORIZED, 34 | } 35 | } 36 | 37 | fn error_response(&self) -> HttpResponse { 38 | HttpResponse::build(self.status_code()) 39 | .insert_header((header::CONTENT_TYPE, "text/html; charset=utf-8")) 40 | .body(self.to_string()) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /scheduler/crates/api/src/event/get_event.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | error::NettuError, 3 | shared::{ 4 | auth::{account_can_modify_event, protect_account_route, protect_route}, 5 | usecase::{execute, UseCase}, 6 | }, 7 | }; 8 | use actix_web::{web, HttpRequest, HttpResponse}; 9 | use nettu_scheduler_api_structs::get_event::*; 10 | use nettu_scheduler_domain::{CalendarEvent, ID}; 11 | use nettu_scheduler_infra::NettuContext; 12 | 13 | pub async fn get_event_admin_controller( 14 | http_req: HttpRequest, 15 | path_params: web::Path, 16 | ctx: web::Data, 17 | ) -> Result { 18 | let account = protect_account_route(&http_req, &ctx).await?; 19 | let e = account_can_modify_event(&account, &path_params.event_id, &ctx).await?; 20 | 21 | let usecase = GetEventUseCase { 22 | user_id: e.user_id, 23 | event_id: e.id, 24 | }; 25 | 26 | execute(usecase, &ctx) 27 | .await 28 | .map(|event| HttpResponse::Ok().json(APIResponse::new(event))) 29 | .map_err(NettuError::from) 30 | } 31 | 32 | pub async fn get_event_controller( 33 | http_req: HttpRequest, 34 | path_params: web::Path, 35 | ctx: web::Data, 36 | ) -> Result { 37 | let (user, _policy) = protect_route(&http_req, &ctx).await?; 38 | 39 | let usecase = GetEventUseCase { 40 | event_id: path_params.event_id.clone(), 41 | user_id: user.id.clone(), 42 | }; 43 | 44 | execute(usecase, &ctx) 45 | .await 46 | .map(|calendar_event| HttpResponse::Ok().json(APIResponse::new(calendar_event))) 47 | .map_err(NettuError::from) 48 | } 49 | 50 | #[derive(Debug)] 51 | pub struct GetEventUseCase { 52 | pub event_id: ID, 53 | pub user_id: ID, 54 | } 55 | 56 | #[derive(Debug)] 57 | pub enum UseCaseError { 58 | NotFound(ID), 59 | } 60 | 61 | impl From for NettuError { 62 | fn from(e: UseCaseError) -> Self { 63 | match e { 64 | UseCaseError::NotFound(event_id) => Self::NotFound(format!( 65 | "The calendar event with id: {}, was not found.", 66 | event_id 67 | )), 68 | } 69 | } 70 | } 71 | 72 | #[async_trait::async_trait(?Send)] 73 | impl UseCase for GetEventUseCase { 74 | type Response = CalendarEvent; 75 | 76 | type Error = UseCaseError; 77 | 78 | const NAME: &'static str = "GetEvent"; 79 | 80 | async fn execute(&mut self, ctx: &NettuContext) -> Result { 81 | let e = ctx.repos.events.find(&self.event_id).await; 82 | match e { 83 | Some(event) if event.user_id == self.user_id => Ok(event), 84 | _ => Err(UseCaseError::NotFound(self.event_id.clone())), 85 | } 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /scheduler/crates/api/src/event/get_events_by_meta.rs: -------------------------------------------------------------------------------- 1 | use crate::{error::NettuError, shared::auth::protect_account_route}; 2 | use actix_web::{web, HttpRequest, HttpResponse}; 3 | use nettu_scheduler_api_structs::get_events_by_meta::*; 4 | use nettu_scheduler_domain::Metadata; 5 | use nettu_scheduler_infra::{MetadataFindQuery, NettuContext}; 6 | 7 | pub async fn get_events_by_meta_controller( 8 | http_req: HttpRequest, 9 | query_params: web::Query, 10 | ctx: web::Data, 11 | ) -> Result { 12 | let account = protect_account_route(&http_req, &ctx).await?; 13 | 14 | let query = MetadataFindQuery { 15 | account_id: account.id, 16 | metadata: Metadata::new_kv(query_params.0.key, query_params.0.value), 17 | limit: query_params.0.limit.unwrap_or(20), 18 | skip: query_params.0.skip.unwrap_or(0), 19 | }; 20 | let events = ctx.repos.events.find_by_metadata(query).await; 21 | Ok(HttpResponse::Ok().json(APIResponse::new(events))) 22 | } 23 | -------------------------------------------------------------------------------- /scheduler/crates/api/src/event/mod.rs: -------------------------------------------------------------------------------- 1 | mod create_event; 2 | mod delete_event; 3 | mod get_event; 4 | mod get_event_instances; 5 | mod get_events_by_meta; 6 | pub mod get_upcoming_reminders; 7 | mod subscribers; 8 | pub mod sync_event_reminders; 9 | mod update_event; 10 | 11 | use actix_web::web; 12 | use create_event::{create_event_admin_controller, create_event_controller}; 13 | use delete_event::{delete_event_admin_controller, delete_event_controller}; 14 | use get_event::{get_event_admin_controller, get_event_controller}; 15 | use get_event_instances::{get_event_instances_admin_controller, get_event_instances_controller}; 16 | use get_events_by_meta::get_events_by_meta_controller; 17 | use update_event::{update_event_admin_controller, update_event_controller}; 18 | 19 | pub fn configure_routes(cfg: &mut web::ServiceConfig) { 20 | cfg.route("/events", web::post().to(create_event_controller)); 21 | cfg.route( 22 | "/user/{user_id}/events", 23 | web::post().to(create_event_admin_controller), 24 | ); 25 | 26 | cfg.route("/events/meta", web::get().to(get_events_by_meta_controller)); 27 | 28 | cfg.route("/events/{event_id}", web::get().to(get_event_controller)); 29 | cfg.route( 30 | "/user/events/{event_id}", 31 | web::get().to(get_event_admin_controller), 32 | ); 33 | 34 | cfg.route( 35 | "/events/{event_id}", 36 | web::delete().to(delete_event_controller), 37 | ); 38 | cfg.route( 39 | "/user/events/{event_id}", 40 | web::delete().to(delete_event_admin_controller), 41 | ); 42 | 43 | cfg.route("/events/{event_id}", web::put().to(update_event_controller)); 44 | cfg.route( 45 | "/user/events/{event_id}", 46 | web::put().to(update_event_admin_controller), 47 | ); 48 | 49 | cfg.route( 50 | "/events/{event_id}/instances", 51 | web::get().to(get_event_instances_controller), 52 | ); 53 | cfg.route( 54 | "/user/events/{event_id}/instances", 55 | web::get().to(get_event_instances_admin_controller), 56 | ); 57 | } 58 | -------------------------------------------------------------------------------- /scheduler/crates/api/src/schedule/get_schedule.rs: -------------------------------------------------------------------------------- 1 | use crate::shared::{ 2 | auth::{account_can_modify_schedule, protect_account_route}, 3 | usecase::{execute, UseCase}, 4 | }; 5 | use crate::{error::NettuError, shared::auth::protect_route}; 6 | use actix_web::{web, HttpRequest, HttpResponse}; 7 | use nettu_scheduler_api_structs::get_schedule::*; 8 | use nettu_scheduler_domain::{Schedule, ID}; 9 | use nettu_scheduler_infra::NettuContext; 10 | 11 | pub async fn get_schedule_admin_controller( 12 | http_req: HttpRequest, 13 | path: web::Path, 14 | ctx: web::Data, 15 | ) -> Result { 16 | let account = protect_account_route(&http_req, &ctx).await?; 17 | let schedule = account_can_modify_schedule(&account, &path.schedule_id, &ctx).await?; 18 | 19 | let usecase = GetScheduleUseCase { 20 | schedule_id: schedule.id, 21 | }; 22 | 23 | execute(usecase, &ctx) 24 | .await 25 | .map(|schedule| HttpResponse::Ok().json(APIResponse::new(schedule))) 26 | .map_err(NettuError::from) 27 | } 28 | 29 | pub async fn get_schedule_controller( 30 | http_req: HttpRequest, 31 | req: web::Path, 32 | ctx: web::Data, 33 | ) -> Result { 34 | let (_user, _policy) = protect_route(&http_req, &ctx).await?; 35 | 36 | let usecase = GetScheduleUseCase { 37 | schedule_id: req.schedule_id.clone(), 38 | }; 39 | 40 | execute(usecase, &ctx) 41 | .await 42 | .map(|schedule| HttpResponse::Ok().json(APIResponse::new(schedule))) 43 | .map_err(NettuError::from) 44 | } 45 | 46 | #[derive(Debug)] 47 | struct GetScheduleUseCase { 48 | pub schedule_id: ID, 49 | } 50 | 51 | #[derive(Debug)] 52 | enum UseCaseError { 53 | NotFound(ID), 54 | } 55 | 56 | impl From for NettuError { 57 | fn from(e: UseCaseError) -> Self { 58 | match e { 59 | UseCaseError::NotFound(schedule_id) => Self::NotFound(format!( 60 | "The schedule with id: {}, was not found.", 61 | schedule_id 62 | )), 63 | } 64 | } 65 | } 66 | 67 | #[async_trait::async_trait(?Send)] 68 | impl UseCase for GetScheduleUseCase { 69 | type Response = Schedule; 70 | 71 | type Error = UseCaseError; 72 | 73 | const NAME: &'static str = "GetSchedule"; 74 | 75 | async fn execute(&mut self, ctx: &NettuContext) -> Result { 76 | let schedule = ctx.repos.schedules.find(&self.schedule_id).await; 77 | match schedule { 78 | Some(schedule) => Ok(schedule), 79 | _ => Err(UseCaseError::NotFound(self.schedule_id.clone())), 80 | } 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /scheduler/crates/api/src/schedule/get_schedules_by_meta.rs: -------------------------------------------------------------------------------- 1 | use crate::{error::NettuError, shared::auth::protect_account_route}; 2 | use actix_web::{web, HttpRequest, HttpResponse}; 3 | use nettu_scheduler_api_structs::get_schedules_by_meta::*; 4 | use nettu_scheduler_domain::Metadata; 5 | use nettu_scheduler_infra::{MetadataFindQuery, NettuContext}; 6 | 7 | pub async fn get_schedules_by_meta_controller( 8 | http_req: HttpRequest, 9 | query_params: web::Query, 10 | ctx: web::Data, 11 | ) -> Result { 12 | let account = protect_account_route(&http_req, &ctx).await?; 13 | 14 | let query = MetadataFindQuery { 15 | account_id: account.id, 16 | metadata: Metadata::new_kv(query_params.0.key, query_params.0.value), 17 | limit: query_params.0.limit.unwrap_or(20), 18 | skip: query_params.0.skip.unwrap_or(0), 19 | }; 20 | let schedules = ctx.repos.schedules.find_by_metadata(query).await; 21 | Ok(HttpResponse::Ok().json(APIResponse::new(schedules))) 22 | } 23 | -------------------------------------------------------------------------------- /scheduler/crates/api/src/schedule/mod.rs: -------------------------------------------------------------------------------- 1 | mod create_schedule; 2 | mod delete_schedule; 3 | mod get_schedule; 4 | mod get_schedules_by_meta; 5 | mod update_schedule; 6 | 7 | use actix_web::web; 8 | use create_schedule::{create_schedule_admin_controller, create_schedule_controller}; 9 | use delete_schedule::{delete_schedule_admin_controller, delete_schedule_controller}; 10 | use get_schedule::{get_schedule_admin_controller, get_schedule_controller}; 11 | use get_schedules_by_meta::get_schedules_by_meta_controller; 12 | use update_schedule::{update_schedule_admin_controller, update_schedule_controller}; 13 | 14 | pub fn configure_routes(cfg: &mut web::ServiceConfig) { 15 | cfg.route("/schedule", web::post().to(create_schedule_controller)); 16 | cfg.route( 17 | "/user/{user_id}/schedule", 18 | web::post().to(create_schedule_admin_controller), 19 | ); 20 | 21 | cfg.route( 22 | "/schedule/meta", 23 | web::get().to(get_schedules_by_meta_controller), 24 | ); 25 | 26 | cfg.route( 27 | "/schedule/{schedule_id}", 28 | web::get().to(get_schedule_controller), 29 | ); 30 | cfg.route( 31 | "/user/schedule/{schedule_id}", 32 | web::get().to(get_schedule_admin_controller), 33 | ); 34 | 35 | cfg.route( 36 | "/schedule/{schedule_id}", 37 | web::delete().to(delete_schedule_controller), 38 | ); 39 | cfg.route( 40 | "/user/schedule/{schedule_id}", 41 | web::delete().to(delete_schedule_admin_controller), 42 | ); 43 | 44 | cfg.route( 45 | "/schedule/{schedule_id}", 46 | web::put().to(update_schedule_controller), 47 | ); 48 | cfg.route( 49 | "/user/schedule/{schedule_id}", 50 | web::put().to(update_schedule_admin_controller), 51 | ); 52 | } 53 | -------------------------------------------------------------------------------- /scheduler/crates/api/src/service/create_service.rs: -------------------------------------------------------------------------------- 1 | use crate::shared::usecase::{execute, UseCase}; 2 | use crate::{error::NettuError, shared::auth::protect_account_route}; 3 | use actix_web::{web, HttpRequest, HttpResponse}; 4 | use nettu_scheduler_api_structs::create_service::*; 5 | use nettu_scheduler_domain::{Account, Metadata, Service, ServiceMultiPersonOptions}; 6 | use nettu_scheduler_infra::NettuContext; 7 | 8 | pub async fn create_service_controller( 9 | http_req: HttpRequest, 10 | body: web::Json, 11 | ctx: web::Data, 12 | ) -> Result { 13 | let account = protect_account_route(&http_req, &ctx).await?; 14 | 15 | let body = body.0; 16 | let usecase = CreateServiceUseCase { 17 | account, 18 | metadata: body.metadata.unwrap_or_default(), 19 | multi_person: body.multi_person.unwrap_or_default(), 20 | }; 21 | 22 | execute(usecase, &ctx) 23 | .await 24 | .map(|usecase_res| HttpResponse::Created().json(APIResponse::new(usecase_res.service))) 25 | .map_err(NettuError::from) 26 | } 27 | 28 | #[derive(Debug)] 29 | struct CreateServiceUseCase { 30 | account: Account, 31 | multi_person: ServiceMultiPersonOptions, 32 | metadata: Metadata, 33 | } 34 | #[derive(Debug)] 35 | struct UseCaseRes { 36 | pub service: Service, 37 | } 38 | 39 | #[derive(Debug)] 40 | enum UseCaseError { 41 | StorageError, 42 | } 43 | 44 | impl From for NettuError { 45 | fn from(e: UseCaseError) -> Self { 46 | match e { 47 | UseCaseError::StorageError => Self::InternalError, 48 | } 49 | } 50 | } 51 | 52 | #[async_trait::async_trait(?Send)] 53 | impl UseCase for CreateServiceUseCase { 54 | type Response = UseCaseRes; 55 | 56 | type Error = UseCaseError; 57 | 58 | const NAME: &'static str = "CreateService"; 59 | 60 | async fn execute(&mut self, ctx: &NettuContext) -> Result { 61 | let mut service = Service::new(self.account.id.clone()); 62 | service.metadata = self.metadata.clone(); 63 | service.multi_person = self.multi_person.clone(); 64 | 65 | ctx.repos 66 | .services 67 | .insert(&service) 68 | .await 69 | .map(|_| UseCaseRes { service }) 70 | .map_err(|_| UseCaseError::StorageError) 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /scheduler/crates/api/src/service/delete_service.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | error::NettuError, 3 | shared::{ 4 | auth::protect_account_route, 5 | usecase::{execute, UseCase}, 6 | }, 7 | }; 8 | use actix_web::{web, HttpRequest, HttpResponse}; 9 | use nettu_scheduler_api_structs::delete_service::*; 10 | use nettu_scheduler_domain::{Account, Service, ID}; 11 | use nettu_scheduler_infra::NettuContext; 12 | 13 | pub async fn delete_service_controller( 14 | http_req: HttpRequest, 15 | path_params: web::Path, 16 | ctx: web::Data, 17 | ) -> Result { 18 | let account = protect_account_route(&http_req, &ctx).await?; 19 | 20 | let usecase = DeleteServiceUseCase { 21 | account, 22 | service_id: path_params.service_id.clone(), 23 | }; 24 | 25 | execute(usecase, &ctx) 26 | .await 27 | .map(|usecase_res| HttpResponse::Ok().json(APIResponse::new(usecase_res.service))) 28 | .map_err(NettuError::from) 29 | } 30 | 31 | #[derive(Debug)] 32 | struct DeleteServiceUseCase { 33 | account: Account, 34 | service_id: ID, 35 | } 36 | 37 | #[derive(Debug)] 38 | struct UseCaseRes { 39 | pub service: Service, 40 | } 41 | 42 | #[derive(Debug)] 43 | enum UseCaseError { 44 | NotFound(ID), 45 | StorageError, 46 | } 47 | 48 | impl From for NettuError { 49 | fn from(e: UseCaseError) -> Self { 50 | match e { 51 | UseCaseError::NotFound(id) => { 52 | Self::NotFound(format!("The service with id: {} was not found.", id)) 53 | } 54 | UseCaseError::StorageError => Self::InternalError, 55 | } 56 | } 57 | } 58 | 59 | #[async_trait::async_trait(?Send)] 60 | impl UseCase for DeleteServiceUseCase { 61 | type Response = UseCaseRes; 62 | 63 | type Error = UseCaseError; 64 | 65 | const NAME: &'static str = "DeleteService"; 66 | 67 | async fn execute(&mut self, ctx: &NettuContext) -> Result { 68 | let res = ctx.repos.services.find(&self.service_id).await; 69 | match res { 70 | Some(service) if service.account_id == self.account.id => { 71 | ctx.repos 72 | .services 73 | .delete(&self.service_id) 74 | .await 75 | .map_err(|_| UseCaseError::StorageError)?; 76 | 77 | Ok(UseCaseRes { service }) 78 | } 79 | _ => Err(UseCaseError::NotFound(self.service_id.clone())), 80 | } 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /scheduler/crates/api/src/service/get_service.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | error::NettuError, 3 | shared::{ 4 | auth::protect_account_route, 5 | usecase::{execute, UseCase}, 6 | }, 7 | }; 8 | use actix_web::{web, HttpRequest, HttpResponse}; 9 | use nettu_scheduler_api_structs::get_service::*; 10 | use nettu_scheduler_domain::{Account, ServiceWithUsers, ID}; 11 | use nettu_scheduler_infra::NettuContext; 12 | 13 | pub async fn get_service_controller( 14 | http_req: HttpRequest, 15 | path_params: web::Path, 16 | ctx: web::Data, 17 | ) -> Result { 18 | let account = protect_account_route(&http_req, &ctx).await?; 19 | 20 | let usecase = GetServiceUseCase { 21 | account, 22 | service_id: path_params.service_id.clone(), 23 | }; 24 | 25 | execute(usecase, &ctx) 26 | .await 27 | .map(|usecase_res| HttpResponse::Ok().json(APIResponse::new(usecase_res.service))) 28 | .map_err(NettuError::from) 29 | } 30 | 31 | #[derive(Debug)] 32 | struct GetServiceUseCase { 33 | account: Account, 34 | service_id: ID, 35 | } 36 | 37 | #[derive(Debug)] 38 | struct UseCaseRes { 39 | pub service: ServiceWithUsers, 40 | } 41 | 42 | #[derive(Debug)] 43 | enum UseCaseError { 44 | NotFound(ID), 45 | } 46 | 47 | impl From for NettuError { 48 | fn from(e: UseCaseError) -> Self { 49 | match e { 50 | UseCaseError::NotFound(id) => { 51 | Self::NotFound(format!("The service with id: {} was not found.", id)) 52 | } 53 | } 54 | } 55 | } 56 | 57 | #[async_trait::async_trait(?Send)] 58 | impl UseCase for GetServiceUseCase { 59 | type Response = UseCaseRes; 60 | 61 | type Error = UseCaseError; 62 | const NAME: &'static str = "GetService"; 63 | 64 | async fn execute(&mut self, ctx: &NettuContext) -> Result { 65 | let res = ctx.repos.services.find_with_users(&self.service_id).await; 66 | match res { 67 | Some(service) if service.account_id == self.account.id => Ok(UseCaseRes { service }), 68 | _ => Err(UseCaseError::NotFound(self.service_id.clone())), 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /scheduler/crates/api/src/service/get_services_by_meta.rs: -------------------------------------------------------------------------------- 1 | use crate::{error::NettuError, shared::auth::protect_account_route}; 2 | use actix_web::{web, HttpRequest, HttpResponse}; 3 | use nettu_scheduler_api_structs::get_services_by_meta::*; 4 | use nettu_scheduler_domain::Metadata; 5 | use nettu_scheduler_infra::{MetadataFindQuery, NettuContext}; 6 | 7 | pub async fn get_services_by_meta_controller( 8 | http_req: HttpRequest, 9 | query_params: web::Query, 10 | ctx: web::Data, 11 | ) -> Result { 12 | let account = protect_account_route(&http_req, &ctx).await?; 13 | 14 | let query = MetadataFindQuery { 15 | account_id: account.id, 16 | metadata: Metadata::new_kv(query_params.0.key, query_params.0.value), 17 | limit: query_params.0.limit.unwrap_or(20), 18 | skip: query_params.0.skip.unwrap_or(0), 19 | }; 20 | let services = ctx.repos.services.find_by_metadata(query).await; 21 | Ok(HttpResponse::Ok().json(APIResponse::new(services))) 22 | } 23 | -------------------------------------------------------------------------------- /scheduler/crates/api/src/service/mod.rs: -------------------------------------------------------------------------------- 1 | mod add_busy_calendar; 2 | mod add_user_to_service; 3 | mod create_service; 4 | mod create_service_event_intend; 5 | mod delete_service; 6 | mod get_service; 7 | mod get_service_bookingslots; 8 | mod get_services_by_meta; 9 | mod remove_busy_calendar; 10 | mod remove_service_event_intend; 11 | mod remove_user_from_service; 12 | mod update_service; 13 | mod update_service_user; 14 | 15 | use actix_web::web; 16 | use add_busy_calendar::add_busy_calendar_controller; 17 | use add_user_to_service::add_user_to_service_controller; 18 | use create_service::create_service_controller; 19 | use create_service_event_intend::create_service_event_intend_controller; 20 | use delete_service::delete_service_controller; 21 | use get_service::get_service_controller; 22 | use get_service_bookingslots::get_service_bookingslots_controller; 23 | use get_services_by_meta::get_services_by_meta_controller; 24 | use remove_busy_calendar::remove_busy_calendar_controller; 25 | use remove_service_event_intend::remove_service_event_intend_controller; 26 | use remove_user_from_service::remove_user_from_service_controller; 27 | use update_service::update_service_controller; 28 | use update_service_user::update_service_user_controller; 29 | 30 | pub fn configure_routes(cfg: &mut web::ServiceConfig) { 31 | cfg.route("/service", web::post().to(create_service_controller)); 32 | cfg.route( 33 | "/service/meta", 34 | web::get().to(get_services_by_meta_controller), 35 | ); 36 | cfg.route( 37 | "/service/{service_id}", 38 | web::get().to(get_service_controller), 39 | ); 40 | cfg.route( 41 | "/service/{service_id}", 42 | web::put().to(update_service_controller), 43 | ); 44 | cfg.route( 45 | "/service/{service_id}", 46 | web::delete().to(delete_service_controller), 47 | ); 48 | cfg.route( 49 | "/service/{service_id}/users", 50 | web::post().to(add_user_to_service_controller), 51 | ); 52 | cfg.route( 53 | "/service/{service_id}/users/{user_id}", 54 | web::delete().to(remove_user_from_service_controller), 55 | ); 56 | cfg.route( 57 | "/service/{service_id}/users/{user_id}", 58 | web::put().to(update_service_user_controller), 59 | ); 60 | cfg.route( 61 | "/service/{service_id}/users/{user_id}/busy", 62 | web::put().to(add_busy_calendar_controller), 63 | ); 64 | cfg.route( 65 | "/service/{service_id}/users/{user_id}/busy", 66 | web::delete().to(remove_busy_calendar_controller), 67 | ); 68 | cfg.route( 69 | "/service/{service_id}/booking", 70 | web::get().to(get_service_bookingslots_controller), 71 | ); 72 | cfg.route( 73 | "/service/{service_id}/booking-intend", 74 | web::post().to(create_service_event_intend_controller), 75 | ); 76 | cfg.route( 77 | "/service/{service_id}/booking-intend", 78 | web::delete().to(remove_service_event_intend_controller), 79 | ); 80 | } 81 | -------------------------------------------------------------------------------- /scheduler/crates/api/src/service/remove_service_event_intend.rs: -------------------------------------------------------------------------------- 1 | use crate::error::NettuError; 2 | use crate::shared::{ 3 | auth::protect_account_route, 4 | usecase::{execute, UseCase}, 5 | }; 6 | use actix_web::{web, HttpRequest, HttpResponse}; 7 | use nettu_scheduler_api_structs::remove_service_event_intend::*; 8 | use nettu_scheduler_domain::{Account, ID}; 9 | use nettu_scheduler_infra::NettuContext; 10 | 11 | pub async fn remove_service_event_intend_controller( 12 | http_req: HttpRequest, 13 | query_params: web::Query, 14 | mut path_params: web::Path, 15 | ctx: web::Data, 16 | ) -> Result { 17 | let account = protect_account_route(&http_req, &ctx).await?; 18 | 19 | let query = query_params.0; 20 | let usecase = RemoveServiceEventIntendUseCase { 21 | account, 22 | service_id: std::mem::take(&mut path_params.service_id), 23 | timestamp: query.timestamp, 24 | }; 25 | 26 | execute(usecase, &ctx) 27 | .await 28 | .map(|_| HttpResponse::Ok().json(APIResponse::default())) 29 | .map_err(NettuError::from) 30 | } 31 | 32 | #[derive(Debug)] 33 | struct RemoveServiceEventIntendUseCase { 34 | pub account: Account, 35 | pub service_id: ID, 36 | pub timestamp: i64, 37 | } 38 | 39 | #[derive(Debug)] 40 | struct UseCaseRes {} 41 | 42 | #[derive(Debug)] 43 | enum UseCaseError { 44 | ServiceNotFound, 45 | StorageError, 46 | } 47 | 48 | impl From for NettuError { 49 | fn from(e: UseCaseError) -> Self { 50 | match e { 51 | UseCaseError::ServiceNotFound => { 52 | Self::NotFound("The requested service was not found".into()) 53 | } 54 | UseCaseError::StorageError => Self::InternalError, 55 | } 56 | } 57 | } 58 | 59 | #[async_trait::async_trait(?Send)] 60 | impl UseCase for RemoveServiceEventIntendUseCase { 61 | type Response = UseCaseRes; 62 | 63 | type Error = UseCaseError; 64 | 65 | const NAME: &'static str = "RemoveServiceEventIntend"; 66 | 67 | async fn execute(&mut self, ctx: &NettuContext) -> Result { 68 | match ctx.repos.services.find(&self.service_id).await { 69 | Some(s) if s.account_id == self.account.id => (), 70 | _ => return Err(UseCaseError::ServiceNotFound), 71 | }; 72 | ctx.repos 73 | .reservations 74 | .decrement(&self.service_id, self.timestamp) 75 | .await 76 | .map(|_| UseCaseRes {}) 77 | .map_err(|_| UseCaseError::StorageError) 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /scheduler/crates/api/src/service/remove_user_from_service.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | error::NettuError, 3 | shared::{ 4 | auth::protect_account_route, 5 | usecase::{execute, UseCase}, 6 | }, 7 | }; 8 | use actix_web::{web, HttpRequest, HttpResponse}; 9 | use nettu_scheduler_api_structs::remove_user_from_service::*; 10 | use nettu_scheduler_domain::{Account, ID}; 11 | use nettu_scheduler_infra::NettuContext; 12 | 13 | pub async fn remove_user_from_service_controller( 14 | http_req: HttpRequest, 15 | mut path: web::Path, 16 | ctx: web::Data, 17 | ) -> Result { 18 | let account = protect_account_route(&http_req, &ctx).await?; 19 | 20 | let usecase = RemoveUserFromServiceUseCase { 21 | account, 22 | service_id: std::mem::take(&mut path.service_id), 23 | user_id: std::mem::take(&mut path.user_id), 24 | }; 25 | 26 | execute(usecase, &ctx) 27 | .await 28 | .map(|_usecase_res| HttpResponse::Ok().json(APIResponse::from("User removed from service"))) 29 | .map_err(NettuError::from) 30 | } 31 | 32 | #[derive(Debug)] 33 | struct RemoveUserFromServiceUseCase { 34 | pub account: Account, 35 | pub service_id: ID, 36 | pub user_id: ID, 37 | } 38 | 39 | #[derive(Debug)] 40 | struct UseCaseRes {} 41 | 42 | #[derive(Debug)] 43 | enum UseCaseError { 44 | ServiceNotFound, 45 | UserNotFound, 46 | } 47 | 48 | impl From for NettuError { 49 | fn from(e: UseCaseError) -> Self { 50 | match e { 51 | UseCaseError::ServiceNotFound => { 52 | Self::NotFound("The requested service was not found".to_string()) 53 | } 54 | UseCaseError::UserNotFound => { 55 | Self::NotFound("The specified user was not found in the service".to_string()) 56 | } 57 | } 58 | } 59 | } 60 | 61 | #[async_trait::async_trait(?Send)] 62 | impl UseCase for RemoveUserFromServiceUseCase { 63 | type Response = UseCaseRes; 64 | 65 | type Error = UseCaseError; 66 | 67 | const NAME: &'static str = "RemoveUserFromService"; 68 | 69 | async fn execute(&mut self, ctx: &NettuContext) -> Result { 70 | let service = match ctx.repos.services.find(&self.service_id).await { 71 | Some(service) if service.account_id == self.account.id => service, 72 | _ => return Err(UseCaseError::ServiceNotFound), 73 | }; 74 | 75 | ctx.repos 76 | .service_users 77 | .delete(&service.id, &self.user_id) 78 | .await 79 | .map(|_| UseCaseRes {}) 80 | .map_err(|_| UseCaseError::UserNotFound) 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /scheduler/crates/api/src/shared/auth/mod.rs: -------------------------------------------------------------------------------- 1 | mod policy; 2 | mod route_guards; 3 | 4 | pub use policy::{Permission, Policy}; 5 | pub use route_guards::{ 6 | account_can_modify_calendar, account_can_modify_event, account_can_modify_schedule, 7 | account_can_modify_user, protect_account_route, protect_public_account_route, protect_route, 8 | }; 9 | -------------------------------------------------------------------------------- /scheduler/crates/api/src/shared/controller.rs: -------------------------------------------------------------------------------- 1 | // use actix_web::{web, HttpResponse}; 2 | // use serde::Deserialize; 3 | 4 | // use crate::{error::NettuError, user::create_user::CreateUserUseCase}; 5 | 6 | // use super::usecase::UseCase; 7 | 8 | // // #[async_trait::async_trait(?Send)] 9 | // // pub trait Controller { 10 | // // type PathParams: for<'de> Deserialize<'de>; 11 | // // type Body: for<'de> Deserialize<'de>; 12 | // // type QueryParams: for<'de> Deserialize<'de>; 13 | 14 | // // fn handler( 15 | // // path: Self::PathParams, 16 | // // body: Self::Body, 17 | // // query: Self::QueryParams, 18 | // // ) -> Result; 19 | 20 | // // fn handle_error(e: U::Error) -> NettuError; 21 | // // fn handle_ok(res: U::Response) -> HttpResponse; 22 | 23 | // // async fn execute_controller( 24 | // // path: web::Path, 25 | // // body: web::Json, 26 | // // query: web::Query, 27 | // // ) -> Result { 28 | // // // Err(NettuError::Conflict("dfasf".into())) 29 | // // Ok(HttpResponse::Ok().finish()) 30 | // // } 31 | // // } 32 | 33 | // #[async_trait::async_trait(?Send)] 34 | // pub trait APIController: UseCase { 35 | // fn handle_error(e: Self::Error) -> NettuError; 36 | // fn handle_ok(res: Self::Response) -> HttpResponse; 37 | 38 | // async fn execute_controller( 39 | // path: web::Path

, 40 | // body: web::Json, 41 | // query: web::Query, 42 | // ) -> Result { 43 | // // Err(NettuError::Conflict("dfasf".into())) 44 | // Ok(HttpResponse::Ok().finish()) 45 | // } 46 | // } 47 | 48 | // #[derive(Debug, Deserialize)] 49 | // struct Params {} 50 | 51 | // // struct Dummy; 52 | // // impl Controller for Dummy { 53 | // // type PathParams = Params; 54 | // // type Body = Params; 55 | // // type QueryParams = Params; 56 | 57 | // // fn handle_error(e: ::Error) -> NettuError { 58 | // // todo!() 59 | // // } 60 | 61 | // // fn handle_ok(res: ::Response) -> HttpResponse { 62 | // // todo!() 63 | // // } 64 | 65 | // // fn handler( 66 | // // path: Self::PathParams, 67 | // // body: Self::Body, 68 | // // query: Self::QueryParams, 69 | // // ) -> Result { 70 | // // Err(NettuError::Conflict("".into())) 71 | // // } 72 | // // } 73 | 74 | // pub fn configure_routes(cfg: &mut web::ServiceConfig) { 75 | // // cfg.route("/calendar", web::post().to(Dummy::execute_controller)); 76 | // } 77 | -------------------------------------------------------------------------------- /scheduler/crates/api/src/shared/guard.rs: -------------------------------------------------------------------------------- 1 | use crate::error::NettuError; 2 | use nettu_scheduler_domain::ID; 3 | 4 | pub struct Guard {} 5 | 6 | impl Guard { 7 | pub fn against_malformed_id(val: String) -> Result { 8 | val.parse() 9 | .map_err(|e| NettuError::BadClientData(format!("{}", e))) 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /scheduler/crates/api/src/shared/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod auth; 2 | mod guard; 3 | pub mod usecase; 4 | pub use guard::Guard; 5 | // mod controller; 6 | -------------------------------------------------------------------------------- /scheduler/crates/api/src/status/mod.rs: -------------------------------------------------------------------------------- 1 | use actix_web::{web, HttpResponse}; 2 | use nettu_scheduler_api_structs::get_service_health::*; 3 | 4 | async fn status() -> HttpResponse { 5 | HttpResponse::Ok().json(APIResponse { 6 | message: "Yo! We are up!\r\n".into(), 7 | }) 8 | } 9 | 10 | pub fn configure_routes(cfg: &mut web::ServiceConfig) { 11 | cfg.route("/", web::get().to(status)); 12 | } 13 | -------------------------------------------------------------------------------- /scheduler/crates/api/src/user/create_user.rs: -------------------------------------------------------------------------------- 1 | use crate::shared::usecase::{execute, UseCase}; 2 | use crate::{error::NettuError, shared::auth::protect_account_route}; 3 | use actix_web::{web, HttpRequest, HttpResponse}; 4 | use nettu_scheduler_api_structs::create_user::*; 5 | use nettu_scheduler_domain::{Metadata, User, ID}; 6 | use nettu_scheduler_infra::NettuContext; 7 | 8 | pub async fn create_user_controller( 9 | http_req: HttpRequest, 10 | body: web::Json, 11 | ctx: web::Data, 12 | ) -> Result { 13 | let account = protect_account_route(&http_req, &ctx).await?; 14 | 15 | let usecase = CreateUserUseCase { 16 | account_id: account.id, 17 | metadata: body.0.metadata.unwrap_or_default(), 18 | }; 19 | 20 | execute(usecase, &ctx) 21 | .await 22 | .map(|usecase_res| HttpResponse::Created().json(APIResponse::new(usecase_res.user))) 23 | .map_err(NettuError::from) 24 | } 25 | 26 | #[derive(Debug)] 27 | pub struct CreateUserUseCase { 28 | pub account_id: ID, 29 | pub metadata: Metadata, 30 | } 31 | 32 | #[derive(Debug)] 33 | pub struct UseCaseRes { 34 | pub user: User, 35 | } 36 | 37 | #[derive(Debug)] 38 | pub enum UseCaseError { 39 | StorageError, 40 | UserAlreadyExists, 41 | } 42 | 43 | impl From for NettuError { 44 | fn from(e: UseCaseError) -> Self { 45 | match e { 46 | UseCaseError::StorageError => Self::InternalError, 47 | UseCaseError::UserAlreadyExists => Self::Conflict( 48 | "A user with that userId already exist. UserIds need to be unique.".into(), 49 | ), 50 | } 51 | } 52 | } 53 | #[async_trait::async_trait(?Send)] 54 | impl UseCase for CreateUserUseCase { 55 | type Response = UseCaseRes; 56 | type Error = UseCaseError; 57 | 58 | const NAME: &'static str = "CreateUser"; 59 | 60 | async fn execute(&mut self, ctx: &NettuContext) -> Result { 61 | let mut user = User::new(self.account_id.clone()); 62 | user.metadata = self.metadata.clone(); 63 | 64 | if let Some(_existing_user) = ctx.repos.users.find(&user.id).await { 65 | return Err(UseCaseError::UserAlreadyExists); 66 | } 67 | 68 | let res = ctx.repos.users.insert(&user).await; 69 | match res { 70 | Ok(_) => Ok(UseCaseRes { user }), 71 | Err(_) => Err(UseCaseError::StorageError), 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /scheduler/crates/api/src/user/delete_user.rs: -------------------------------------------------------------------------------- 1 | use crate::shared::usecase::{execute, UseCase}; 2 | use crate::{error::NettuError, shared::auth::protect_account_route}; 3 | use actix_web::{web, HttpRequest, HttpResponse}; 4 | use nettu_scheduler_api_structs::delete_user::*; 5 | use nettu_scheduler_domain::{Account, User, ID}; 6 | use nettu_scheduler_infra::NettuContext; 7 | 8 | pub async fn delete_user_controller( 9 | http_req: HttpRequest, 10 | path_params: web::Path, 11 | ctx: web::Data, 12 | ) -> Result { 13 | let account = protect_account_route(&http_req, &ctx).await?; 14 | 15 | let usecase = DeleteUserUseCase { 16 | account, 17 | user_id: path_params.user_id.clone(), 18 | }; 19 | execute(usecase, &ctx) 20 | .await 21 | .map(|usecase_res| HttpResponse::Ok().json(APIResponse::new(usecase_res.user))) 22 | .map_err(NettuError::from) 23 | } 24 | 25 | #[derive(Debug)] 26 | struct DeleteUserUseCase { 27 | account: Account, 28 | user_id: ID, 29 | } 30 | 31 | #[derive(Debug)] 32 | struct UseCaseRes { 33 | pub user: User, 34 | } 35 | 36 | #[derive(Debug)] 37 | enum UseCaseError { 38 | StorageError, 39 | UserNotFound(ID), 40 | } 41 | 42 | impl From for NettuError { 43 | fn from(e: UseCaseError) -> Self { 44 | match e { 45 | UseCaseError::StorageError => Self::InternalError, 46 | UseCaseError::UserNotFound(id) => { 47 | Self::NotFound(format!("A user with id: {}, was not found.", id)) 48 | } 49 | } 50 | } 51 | } 52 | 53 | #[async_trait::async_trait(?Send)] 54 | impl UseCase for DeleteUserUseCase { 55 | type Response = UseCaseRes; 56 | 57 | type Error = UseCaseError; 58 | 59 | const NAME: &'static str = "DeleteUser"; 60 | 61 | async fn execute(&mut self, ctx: &NettuContext) -> Result { 62 | let user = match ctx.repos.users.find(&self.user_id).await { 63 | Some(u) if u.account_id == self.account.id => { 64 | match ctx.repos.users.delete(&self.user_id).await { 65 | Some(u) => u, 66 | None => return Err(UseCaseError::StorageError), 67 | } 68 | } 69 | _ => return Err(UseCaseError::UserNotFound(self.user_id.clone())), 70 | }; 71 | 72 | Ok(UseCaseRes { user }) 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /scheduler/crates/api/src/user/get_me.rs: -------------------------------------------------------------------------------- 1 | use crate::{error::NettuError, shared::auth::protect_route}; 2 | use actix_web::{web, HttpRequest, HttpResponse}; 3 | use nettu_scheduler_api_structs::get_me::*; 4 | use nettu_scheduler_infra::NettuContext; 5 | 6 | pub async fn get_me_controller( 7 | http_req: HttpRequest, 8 | ctx: web::Data, 9 | ) -> Result { 10 | let (user, _) = protect_route(&http_req, &ctx).await?; 11 | 12 | Ok(HttpResponse::Ok().json(APIResponse::new(user))) 13 | } 14 | -------------------------------------------------------------------------------- /scheduler/crates/api/src/user/get_user.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | error::NettuError, 3 | shared::{ 4 | auth::protect_account_route, 5 | usecase::{execute, UseCase}, 6 | }, 7 | }; 8 | use actix_web::{web, HttpRequest, HttpResponse}; 9 | use nettu_scheduler_api_structs::get_user::*; 10 | use nettu_scheduler_domain::{Account, User, ID}; 11 | use nettu_scheduler_infra::NettuContext; 12 | 13 | pub async fn get_user_controller( 14 | http_req: HttpRequest, 15 | path_params: web::Path, 16 | ctx: web::Data, 17 | ) -> Result { 18 | let account = protect_account_route(&http_req, &ctx).await?; 19 | 20 | let usecase = GetUserUseCase { 21 | account, 22 | user_id: path_params.user_id.clone(), 23 | }; 24 | execute(usecase, &ctx) 25 | .await 26 | .map(|usecase_res| HttpResponse::Ok().json(APIResponse::new(usecase_res.user))) 27 | .map_err(NettuError::from) 28 | } 29 | 30 | #[derive(Debug)] 31 | struct GetUserUseCase { 32 | account: Account, 33 | user_id: ID, 34 | } 35 | 36 | #[derive(Debug)] 37 | struct UseCaseRes { 38 | pub user: User, 39 | } 40 | 41 | #[derive(Debug)] 42 | enum UseCaseError { 43 | UserNotFound(ID), 44 | } 45 | 46 | impl From for NettuError { 47 | fn from(e: UseCaseError) -> Self { 48 | match e { 49 | UseCaseError::UserNotFound(id) => { 50 | Self::NotFound(format!("A user with id: {}, was not found.", id)) 51 | } 52 | } 53 | } 54 | } 55 | 56 | #[async_trait::async_trait(?Send)] 57 | impl UseCase for GetUserUseCase { 58 | type Response = UseCaseRes; 59 | 60 | type Error = UseCaseError; 61 | 62 | const NAME: &'static str = "GetUser"; 63 | 64 | async fn execute(&mut self, ctx: &NettuContext) -> Result { 65 | let user = match ctx.repos.users.find(&self.user_id).await { 66 | Some(u) if u.account_id == self.account.id => u, 67 | _ => return Err(UseCaseError::UserNotFound(self.user_id.clone())), 68 | }; 69 | 70 | Ok(UseCaseRes { user }) 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /scheduler/crates/api/src/user/get_users_by_meta.rs: -------------------------------------------------------------------------------- 1 | use crate::{error::NettuError, shared::auth::protect_account_route}; 2 | use actix_web::{web, HttpRequest, HttpResponse}; 3 | use nettu_scheduler_api_structs::get_users_by_meta::*; 4 | use nettu_scheduler_domain::Metadata; 5 | use nettu_scheduler_infra::{MetadataFindQuery, NettuContext}; 6 | 7 | pub async fn get_users_by_meta_controller( 8 | http_req: HttpRequest, 9 | query_params: web::Query, 10 | ctx: web::Data, 11 | ) -> Result { 12 | let account = protect_account_route(&http_req, &ctx).await?; 13 | 14 | let query = MetadataFindQuery { 15 | account_id: account.id, 16 | metadata: Metadata::new_kv(query_params.0.key, query_params.0.value), 17 | limit: query_params.0.limit.unwrap_or(20), 18 | skip: query_params.0.skip.unwrap_or(0), 19 | }; 20 | let users = ctx.repos.users.find_by_metadata(query).await; 21 | Ok(HttpResponse::Ok().json(APIResponse::new(users))) 22 | } 23 | -------------------------------------------------------------------------------- /scheduler/crates/api/src/user/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod create_user; 2 | mod delete_user; 3 | mod get_me; 4 | mod get_user; 5 | mod get_user_freebusy; 6 | mod get_users_by_meta; 7 | mod oauth_integration; 8 | mod remove_integration; 9 | mod update_user; 10 | 11 | use actix_web::web; 12 | use create_user::create_user_controller; 13 | use delete_user::delete_user_controller; 14 | use get_me::get_me_controller; 15 | use get_user::get_user_controller; 16 | use get_user_freebusy::get_freebusy_controller; 17 | pub use get_user_freebusy::parse_vec_query_value; 18 | use get_users_by_meta::get_users_by_meta_controller; 19 | use oauth_integration::*; 20 | use remove_integration::{remove_integration_admin_controller, remove_integration_controller}; 21 | use update_user::update_user_controller; 22 | 23 | pub fn configure_routes(cfg: &mut web::ServiceConfig) { 24 | cfg.route("/user", web::post().to(create_user_controller)); 25 | cfg.route("/me", web::get().to(get_me_controller)); 26 | cfg.route("/user/meta", web::get().to(get_users_by_meta_controller)); 27 | cfg.route("/user/{user_id}", web::get().to(get_user_controller)); 28 | cfg.route("/user/{user_id}", web::put().to(update_user_controller)); 29 | cfg.route("/user/{user_id}", web::delete().to(delete_user_controller)); 30 | cfg.route( 31 | "/user/{user_id}/freebusy", 32 | web::get().to(get_freebusy_controller), 33 | ); 34 | 35 | // Oauth 36 | cfg.route("/me/oauth", web::post().to(oauth_integration_controller)); 37 | cfg.route( 38 | "/me/oauth/{provider}", 39 | web::delete().to(remove_integration_controller), 40 | ); 41 | cfg.route( 42 | "/user/{user_id}/oauth", 43 | web::post().to(oauth_integration_admin_controller), 44 | ); 45 | cfg.route( 46 | "/user/{user_id}/oauth/{provider}", 47 | web::delete().to(remove_integration_admin_controller), 48 | ); 49 | } 50 | -------------------------------------------------------------------------------- /scheduler/crates/api/src/user/update_user.rs: -------------------------------------------------------------------------------- 1 | use crate::shared::usecase::{execute, UseCase}; 2 | use crate::{error::NettuError, shared::auth::protect_account_route}; 3 | use actix_web::{web, HttpRequest, HttpResponse}; 4 | use nettu_scheduler_api_structs::update_user::*; 5 | use nettu_scheduler_domain::{Metadata, User, ID}; 6 | use nettu_scheduler_infra::NettuContext; 7 | 8 | pub async fn update_user_controller( 9 | http_req: HttpRequest, 10 | body: web::Json, 11 | mut path: web::Path, 12 | ctx: web::Data, 13 | ) -> Result { 14 | let account = protect_account_route(&http_req, &ctx).await?; 15 | 16 | let usecase = UpdateUserUseCase { 17 | account_id: account.id, 18 | user_id: std::mem::take(&mut path.user_id), 19 | metadata: body.0.metadata, 20 | }; 21 | 22 | execute(usecase, &ctx) 23 | .await 24 | .map(|usecase_res| HttpResponse::Ok().json(APIResponse::new(usecase_res.user))) 25 | .map_err(NettuError::from) 26 | } 27 | 28 | #[derive(Debug)] 29 | pub struct UpdateUserUseCase { 30 | pub account_id: ID, 31 | pub user_id: ID, 32 | pub metadata: Option, 33 | } 34 | 35 | #[derive(Debug)] 36 | pub struct UseCaseRes { 37 | pub user: User, 38 | } 39 | 40 | #[derive(Debug)] 41 | pub enum UseCaseError { 42 | StorageError, 43 | UserNotFound(ID), 44 | } 45 | 46 | impl From for NettuError { 47 | fn from(e: UseCaseError) -> Self { 48 | match e { 49 | UseCaseError::StorageError => Self::InternalError, 50 | UseCaseError::UserNotFound(id) => { 51 | Self::Conflict(format!("A user with id {} was not found", id)) 52 | } 53 | } 54 | } 55 | } 56 | 57 | #[async_trait::async_trait(?Send)] 58 | impl UseCase for UpdateUserUseCase { 59 | type Response = UseCaseRes; 60 | type Error = UseCaseError; 61 | 62 | const NAME: &'static str = "UpdateUser"; 63 | 64 | async fn execute(&mut self, ctx: &NettuContext) -> Result { 65 | let mut user = match ctx 66 | .repos 67 | .users 68 | .find_by_account_id(&self.user_id, &self.account_id) 69 | .await 70 | { 71 | Some(user) => user, 72 | None => return Err(UseCaseError::UserNotFound(self.user_id.clone())), 73 | }; 74 | 75 | if let Some(metadata) = &self.metadata { 76 | user.metadata = metadata.clone(); 77 | } 78 | 79 | ctx.repos 80 | .users 81 | .save(&user) 82 | .await 83 | .map(|_| UseCaseRes { user }) 84 | .map_err(|_| UseCaseError::StorageError) 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /scheduler/crates/api_structs/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "nettu_scheduler_api_structs" 3 | version = "0.3.1" 4 | description = "Nettu scheduler api types" 5 | license = "MIT" 6 | authors = ["Fredrik Meringdal"] 7 | edition = "2018" 8 | 9 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 10 | 11 | [dependencies] 12 | serde = { version = "1.0", features = ["derive"] } 13 | nettu_scheduler_domain = { path = "../domain", version = "0.2.1" } -------------------------------------------------------------------------------- /scheduler/crates/api_structs/src/account/api.rs: -------------------------------------------------------------------------------- 1 | use crate::dtos::AccountDTO; 2 | use nettu_scheduler_domain::Account; 3 | use serde::{Deserialize, Serialize}; 4 | 5 | #[derive(Deserialize, Serialize)] 6 | #[serde(rename_all = "camelCase")] 7 | pub struct AccountResponse { 8 | pub account: AccountDTO, 9 | } 10 | 11 | impl AccountResponse { 12 | pub fn new(account: Account) -> Self { 13 | Self { 14 | account: AccountDTO::new(&account), 15 | } 16 | } 17 | } 18 | 19 | pub mod create_account { 20 | use super::*; 21 | 22 | #[derive(Deserialize, Serialize)] 23 | #[serde(rename_all = "camelCase")] 24 | pub struct RequestBody { 25 | pub code: String, 26 | } 27 | 28 | #[derive(Serialize, Deserialize)] 29 | #[serde(rename_all = "camelCase")] 30 | pub struct APIResponse { 31 | pub account: AccountDTO, 32 | pub secret_api_key: String, 33 | } 34 | 35 | impl APIResponse { 36 | pub fn new(account: Account) -> Self { 37 | Self { 38 | account: AccountDTO::new(&account), 39 | secret_api_key: account.secret_api_key, 40 | } 41 | } 42 | } 43 | } 44 | 45 | pub mod get_account { 46 | use super::*; 47 | 48 | pub type APIResponse = AccountResponse; 49 | } 50 | 51 | pub mod set_account_pub_key { 52 | use super::*; 53 | 54 | #[derive(Debug, Deserialize, Serialize)] 55 | #[serde(rename_all = "camelCase")] 56 | pub struct RequestBody { 57 | pub public_jwt_key: Option, 58 | } 59 | 60 | pub type APIResponse = AccountResponse; 61 | } 62 | 63 | pub mod set_account_webhook { 64 | use super::*; 65 | 66 | #[derive(Debug, Deserialize, Serialize)] 67 | #[serde(rename_all = "camelCase")] 68 | pub struct RequestBody { 69 | pub webhook_url: String, 70 | } 71 | 72 | pub type APIResponse = AccountResponse; 73 | } 74 | 75 | pub mod delete_account_webhook { 76 | use super::*; 77 | 78 | pub type APIResponse = AccountResponse; 79 | } 80 | 81 | pub mod add_account_integration { 82 | use nettu_scheduler_domain::IntegrationProvider; 83 | 84 | use super::*; 85 | 86 | #[derive(Debug, Deserialize, Serialize)] 87 | #[serde(rename_all = "camelCase")] 88 | pub struct RequestBody { 89 | pub client_id: String, 90 | pub client_secret: String, 91 | pub redirect_uri: String, 92 | pub provider: IntegrationProvider, 93 | } 94 | 95 | pub type APIResponse = String; 96 | } 97 | 98 | pub mod remove_account_integration { 99 | use super::*; 100 | use nettu_scheduler_domain::IntegrationProvider; 101 | 102 | #[derive(Debug, Deserialize, Serialize)] 103 | pub struct PathParams { 104 | pub provider: IntegrationProvider, 105 | } 106 | 107 | pub type APIResponse = String; 108 | } 109 | -------------------------------------------------------------------------------- /scheduler/crates/api_structs/src/account/dtos.rs: -------------------------------------------------------------------------------- 1 | use nettu_scheduler_domain::{Account, AccountSettings, AccountWebhookSettings, PEMKey, ID}; 2 | use serde::{Deserialize, Serialize}; 3 | 4 | #[derive(Debug, Serialize, Deserialize, Clone)] 5 | #[serde(rename_all = "camelCase")] 6 | pub struct AccountDTO { 7 | pub id: ID, 8 | pub public_jwt_key: Option, 9 | pub settings: AccountSettingsDTO, 10 | } 11 | 12 | impl AccountDTO { 13 | pub fn new(account: &Account) -> Self { 14 | Self { 15 | id: account.id.clone(), 16 | public_jwt_key: account.public_jwt_key.clone(), 17 | settings: AccountSettingsDTO::new(&account.settings), 18 | } 19 | } 20 | } 21 | 22 | #[derive(Debug, Serialize, Deserialize, Clone)] 23 | #[serde(rename_all = "camelCase")] 24 | pub struct AccountSettingsDTO { 25 | pub webhook: Option, 26 | } 27 | 28 | impl AccountSettingsDTO { 29 | pub fn new(settings: &AccountSettings) -> Self { 30 | let webhook_settings = settings 31 | .webhook 32 | .as_ref() 33 | .map(|webhook| AccountWebhookSettingsDTO::new(webhook)); 34 | 35 | Self { 36 | webhook: webhook_settings, 37 | } 38 | } 39 | } 40 | 41 | #[derive(Debug, Serialize, Deserialize, Clone)] 42 | #[serde(rename_all = "camelCase")] 43 | pub struct AccountWebhookSettingsDTO { 44 | pub url: String, 45 | pub key: String, 46 | } 47 | 48 | impl AccountWebhookSettingsDTO { 49 | pub fn new(settings: &AccountWebhookSettings) -> Self { 50 | Self { 51 | url: settings.url.clone(), 52 | key: settings.key.clone(), 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /scheduler/crates/api_structs/src/account/mod.rs: -------------------------------------------------------------------------------- 1 | pub(crate) mod api; 2 | pub(crate) mod dtos; 3 | -------------------------------------------------------------------------------- /scheduler/crates/api_structs/src/calendar/dtos.rs: -------------------------------------------------------------------------------- 1 | use nettu_scheduler_domain::{Calendar, CalendarSettings, Metadata, Tz, Weekday, ID}; 2 | use serde::{Deserialize, Serialize}; 3 | 4 | #[derive(Debug, Deserialize, Serialize, Clone)] 5 | #[serde(rename_all = "camelCase")] 6 | pub struct CalendarDTO { 7 | pub id: ID, 8 | pub user_id: ID, 9 | pub settings: CalendarSettingsDTO, 10 | pub metadata: Metadata, 11 | } 12 | 13 | #[derive(Debug, Deserialize, Serialize, Clone)] 14 | #[serde(rename_all = "camelCase")] 15 | pub struct CalendarSettingsDTO { 16 | pub week_start: Weekday, 17 | pub timezone: Tz, 18 | } 19 | 20 | impl CalendarDTO { 21 | pub fn new(calendar: Calendar) -> Self { 22 | Self { 23 | id: calendar.id.clone(), 24 | user_id: calendar.user_id.clone(), 25 | settings: CalendarSettingsDTO::new(&calendar.settings), 26 | metadata: calendar.metadata, 27 | } 28 | } 29 | } 30 | 31 | impl CalendarSettingsDTO { 32 | pub fn new(settings: &CalendarSettings) -> Self { 33 | Self { 34 | week_start: settings.week_start, 35 | timezone: settings.timezone, 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /scheduler/crates/api_structs/src/calendar/mod.rs: -------------------------------------------------------------------------------- 1 | pub(crate) mod api; 2 | pub(crate) mod dtos; 3 | -------------------------------------------------------------------------------- /scheduler/crates/api_structs/src/event/dtos.rs: -------------------------------------------------------------------------------- 1 | use nettu_scheduler_domain::{ 2 | CalendarEvent, CalendarEventReminder, EventInstance, Metadata, RRuleOptions, ID, 3 | }; 4 | use serde::{Deserialize, Serialize}; 5 | 6 | #[derive(Debug, Deserialize, Serialize, Clone)] 7 | #[serde(rename_all = "camelCase")] 8 | pub struct CalendarEventDTO { 9 | pub id: ID, 10 | pub start_ts: i64, 11 | pub duration: i64, 12 | pub busy: bool, 13 | pub updated: i64, 14 | pub created: i64, 15 | pub recurrence: Option, 16 | pub exdates: Vec, 17 | pub calendar_id: ID, 18 | pub user_id: ID, 19 | pub reminders: Vec, 20 | pub metadata: Metadata, 21 | } 22 | 23 | impl CalendarEventDTO { 24 | pub fn new(event: CalendarEvent) -> Self { 25 | Self { 26 | id: event.id.clone(), 27 | start_ts: event.start_ts, 28 | duration: event.duration, 29 | busy: event.busy, 30 | updated: event.updated, 31 | created: event.created, 32 | recurrence: event.recurrence, 33 | exdates: event.exdates, 34 | calendar_id: event.calendar_id.clone(), 35 | user_id: event.user_id.clone(), 36 | reminders: event.reminders, 37 | metadata: event.metadata, 38 | } 39 | } 40 | } 41 | 42 | #[derive(Serialize, Deserialize, Debug, Clone)] 43 | #[serde(rename_all = "camelCase")] 44 | pub struct EventWithInstancesDTO { 45 | pub event: CalendarEventDTO, 46 | pub instances: Vec, 47 | } 48 | -------------------------------------------------------------------------------- /scheduler/crates/api_structs/src/event/mod.rs: -------------------------------------------------------------------------------- 1 | pub(crate) mod api; 2 | pub(crate) mod dtos; 3 | -------------------------------------------------------------------------------- /scheduler/crates/api_structs/src/lib.rs: -------------------------------------------------------------------------------- 1 | mod account; 2 | mod calendar; 3 | mod event; 4 | mod schedule; 5 | mod service; 6 | mod status; 7 | mod user; 8 | pub mod dtos { 9 | pub use crate::account::dtos::*; 10 | pub use crate::calendar::dtos::*; 11 | pub use crate::event::dtos::*; 12 | pub use crate::schedule::dtos::*; 13 | pub use crate::service::dtos::*; 14 | pub use crate::user::dtos::*; 15 | } 16 | pub use crate::account::api::*; 17 | pub use crate::calendar::api::*; 18 | pub use crate::event::api::*; 19 | pub use crate::schedule::api::*; 20 | pub use crate::service::api::*; 21 | pub use crate::status::api::*; 22 | pub use crate::user::api::*; 23 | -------------------------------------------------------------------------------- /scheduler/crates/api_structs/src/schedule/api.rs: -------------------------------------------------------------------------------- 1 | use crate::dtos::ScheduleDTO; 2 | use nettu_scheduler_domain::{Schedule, Tz, ID}; 3 | use serde::{Deserialize, Serialize}; 4 | 5 | #[derive(Deserialize, Serialize)] 6 | #[serde(rename_all = "camelCase")] 7 | pub struct ScheduleResponse { 8 | pub schedule: ScheduleDTO, 9 | } 10 | 11 | impl ScheduleResponse { 12 | pub fn new(schedule: Schedule) -> Self { 13 | Self { 14 | schedule: ScheduleDTO::new(schedule), 15 | } 16 | } 17 | } 18 | 19 | pub mod create_schedule { 20 | use nettu_scheduler_domain::{Metadata, ScheduleRule}; 21 | 22 | use super::*; 23 | 24 | #[derive(Deserialize)] 25 | pub struct PathParams { 26 | pub user_id: ID, 27 | } 28 | 29 | #[derive(Serialize, Deserialize)] 30 | #[serde(rename_all = "camelCase")] 31 | pub struct RequestBody { 32 | pub timezone: Tz, 33 | pub rules: Option>, 34 | pub metadata: Option, 35 | } 36 | 37 | pub type APIResponse = ScheduleResponse; 38 | } 39 | 40 | pub mod delete_schedule { 41 | use super::*; 42 | 43 | #[derive(Deserialize)] 44 | pub struct PathParams { 45 | pub schedule_id: ID, 46 | } 47 | 48 | pub type APIResponse = ScheduleResponse; 49 | } 50 | 51 | pub mod get_schedule { 52 | use super::*; 53 | 54 | #[derive(Deserialize)] 55 | pub struct PathParams { 56 | pub schedule_id: ID, 57 | } 58 | 59 | pub type APIResponse = ScheduleResponse; 60 | } 61 | 62 | pub mod update_schedule { 63 | use super::*; 64 | use nettu_scheduler_domain::{Metadata, ScheduleRule}; 65 | 66 | #[derive(Deserialize)] 67 | pub struct PathParams { 68 | pub schedule_id: ID, 69 | } 70 | 71 | #[derive(Deserialize, Serialize)] 72 | #[serde(rename_all = "camelCase")] 73 | pub struct RequestBody { 74 | pub timezone: Option, 75 | pub rules: Option>, 76 | pub metadata: Option, 77 | } 78 | 79 | pub type APIResponse = ScheduleResponse; 80 | } 81 | 82 | pub mod get_schedules_by_meta { 83 | use super::*; 84 | 85 | #[derive(Deserialize)] 86 | #[serde(rename_all = "camelCase")] 87 | pub struct QueryParams { 88 | pub key: String, 89 | pub value: String, 90 | #[serde(default)] 91 | pub skip: Option, 92 | pub limit: Option, 93 | } 94 | 95 | #[derive(Deserialize, Serialize)] 96 | #[serde(rename_all = "camelCase")] 97 | pub struct APIResponse { 98 | pub schedules: Vec, 99 | } 100 | 101 | impl APIResponse { 102 | pub fn new(schedules: Vec) -> Self { 103 | Self { 104 | schedules: schedules.into_iter().map(ScheduleDTO::new).collect(), 105 | } 106 | } 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /scheduler/crates/api_structs/src/schedule/dtos.rs: -------------------------------------------------------------------------------- 1 | use nettu_scheduler_domain::{Metadata, Schedule, ScheduleRule, Tz, ID}; 2 | use serde::{Deserialize, Serialize}; 3 | 4 | #[derive(Debug, Serialize, Deserialize, Clone)] 5 | #[serde(rename_all = "camelCase")] 6 | pub struct ScheduleDTO { 7 | pub id: ID, 8 | pub user_id: ID, 9 | pub rules: Vec, 10 | pub timezone: Tz, 11 | pub metadata: Metadata, 12 | } 13 | 14 | impl ScheduleDTO { 15 | pub fn new(schedule: Schedule) -> Self { 16 | Self { 17 | id: schedule.id.clone(), 18 | user_id: schedule.user_id.clone(), 19 | rules: schedule.rules, 20 | timezone: schedule.timezone, 21 | metadata: schedule.metadata, 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /scheduler/crates/api_structs/src/schedule/mod.rs: -------------------------------------------------------------------------------- 1 | pub(crate) mod api; 2 | pub(crate) mod dtos; 3 | -------------------------------------------------------------------------------- /scheduler/crates/api_structs/src/service/dtos.rs: -------------------------------------------------------------------------------- 1 | use nettu_scheduler_domain::{Metadata, Service, ServiceResource, ServiceWithUsers, TimePlan, ID}; 2 | use serde::{Deserialize, Serialize}; 3 | 4 | #[derive(Deserialize, Serialize, Debug, Clone)] 5 | #[serde(rename_all = "camelCase")] 6 | pub struct ServiceResourceDTO { 7 | pub user_id: ID, 8 | pub service_id: ID, 9 | pub availability: TimePlan, 10 | pub buffer_after: i64, 11 | pub buffer_before: i64, 12 | pub closest_booking_time: i64, 13 | pub furthest_booking_time: Option, 14 | } 15 | 16 | impl ServiceResourceDTO { 17 | pub fn new(resource: ServiceResource) -> Self { 18 | Self { 19 | user_id: resource.user_id, 20 | service_id: resource.service_id, 21 | availability: resource.availability, 22 | buffer_after: resource.buffer_after, 23 | buffer_before: resource.buffer_before, 24 | closest_booking_time: resource.closest_booking_time, 25 | furthest_booking_time: resource.furthest_booking_time, 26 | } 27 | } 28 | } 29 | 30 | #[derive(Deserialize, Serialize, Debug, Clone)] 31 | #[serde(rename_all = "camelCase")] 32 | pub struct ServiceDTO { 33 | pub id: ID, 34 | pub metadata: Metadata, 35 | } 36 | 37 | impl ServiceDTO { 38 | pub fn new(service: Service) -> Self { 39 | Self { 40 | id: service.id, 41 | metadata: service.metadata, 42 | } 43 | } 44 | } 45 | 46 | #[derive(Deserialize, Serialize, Debug, Clone)] 47 | #[serde(rename_all = "camelCase")] 48 | pub struct ServiceWithUsersDTO { 49 | pub id: ID, 50 | pub users: Vec, 51 | pub metadata: Metadata, 52 | } 53 | 54 | impl ServiceWithUsersDTO { 55 | pub fn new(service: ServiceWithUsers) -> Self { 56 | Self { 57 | id: service.id, 58 | users: service 59 | .users 60 | .into_iter() 61 | .map(ServiceResourceDTO::new) 62 | .collect(), 63 | metadata: service.metadata, 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /scheduler/crates/api_structs/src/service/mod.rs: -------------------------------------------------------------------------------- 1 | pub(crate) mod api; 2 | pub(crate) mod dtos; 3 | -------------------------------------------------------------------------------- /scheduler/crates/api_structs/src/status/api.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | pub mod get_service_health { 4 | use super::*; 5 | 6 | #[derive(Deserialize, Serialize)] 7 | #[serde(rename_all = "camelCase")] 8 | pub struct APIResponse { 9 | pub message: String, 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /scheduler/crates/api_structs/src/status/dtos.rs: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /scheduler/crates/api_structs/src/status/mod.rs: -------------------------------------------------------------------------------- 1 | pub(crate) mod api; 2 | pub(crate) mod dtos; 3 | -------------------------------------------------------------------------------- /scheduler/crates/api_structs/src/user/dtos.rs: -------------------------------------------------------------------------------- 1 | use nettu_scheduler_domain::{Metadata, User, ID}; 2 | use serde::{Deserialize, Serialize}; 3 | 4 | #[derive(Deserialize, Serialize, Debug, Clone)] 5 | #[serde(rename_all = "camelCase")] 6 | pub struct UserDTO { 7 | pub id: ID, 8 | pub metadata: Metadata, 9 | } 10 | 11 | impl UserDTO { 12 | pub fn new(user: User) -> Self { 13 | Self { 14 | id: user.id, 15 | metadata: user.metadata, 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /scheduler/crates/api_structs/src/user/mod.rs: -------------------------------------------------------------------------------- 1 | pub(crate) mod api; 2 | pub(crate) mod dtos; 3 | -------------------------------------------------------------------------------- /scheduler/crates/domain/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "nettu_scheduler_domain" 3 | version = "0.2.1" 4 | description = "Nettu scheduler domain" 5 | license = "MIT" 6 | authors = ["Fredrik Meringdal"] 7 | edition = "2018" 8 | 9 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 10 | 11 | [dependencies] 12 | nettu_scheduler_utils = { path = "../utils", version = "0.1.0" } 13 | serde = { version = "1.0", features = ["derive"] } 14 | rrule = "0.5.8" 15 | chrono = { version = "0.4.19", features = ["serde"] } 16 | chrono-tz = { version = "0.5.3", features = ["serde"] } 17 | anyhow = "1.0.0" 18 | url = "2.2.0" 19 | uuid = { version="0.8.2", features = ["v4"]} 20 | jsonwebtoken = "7" 21 | thiserror = "1.0" 22 | itertools = "0.10.1" 23 | rand = "0.8.4" 24 | -------------------------------------------------------------------------------- /scheduler/crates/domain/src/calendar.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | shared::{ 3 | entity::{Entity, ID}, 4 | metadata::Metadata, 5 | }, 6 | IntegrationProvider, Meta, Weekday, 7 | }; 8 | use chrono_tz::{Tz, UTC}; 9 | use serde::{Deserialize, Serialize}; 10 | 11 | #[derive(Debug, Clone)] 12 | pub struct Calendar { 13 | pub id: ID, 14 | pub user_id: ID, 15 | pub account_id: ID, 16 | pub settings: CalendarSettings, 17 | pub metadata: Metadata, 18 | } 19 | 20 | impl Meta for Calendar { 21 | fn metadata(&self) -> &Metadata { 22 | &self.metadata 23 | } 24 | fn account_id(&self) -> &ID { 25 | &self.account_id 26 | } 27 | } 28 | 29 | #[derive(Debug, Clone, Deserialize, Serialize)] 30 | pub struct SyncedCalendar { 31 | pub provider: IntegrationProvider, 32 | pub calendar_id: ID, 33 | pub user_id: ID, 34 | pub ext_calendar_id: String, 35 | } 36 | 37 | #[derive(Debug, Clone, Serialize, Deserialize)] 38 | pub struct CalendarSettings { 39 | pub week_start: Weekday, 40 | pub timezone: Tz, 41 | } 42 | 43 | impl Default for CalendarSettings { 44 | fn default() -> Self { 45 | Self { 46 | week_start: Weekday::Mon, 47 | timezone: UTC, 48 | } 49 | } 50 | } 51 | 52 | impl Calendar { 53 | pub fn new(user_id: &ID, account_id: &ID) -> Self { 54 | Self { 55 | id: Default::default(), 56 | user_id: user_id.clone(), 57 | account_id: account_id.clone(), 58 | settings: Default::default(), 59 | metadata: Default::default(), 60 | } 61 | } 62 | } 63 | 64 | impl Entity for Calendar { 65 | fn id(&self) -> ID { 66 | self.id.clone() 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /scheduler/crates/domain/src/date.rs: -------------------------------------------------------------------------------- 1 | use chrono::prelude::*; 2 | use chrono_tz::Tz; 3 | 4 | pub fn is_valid_date(datestr: &str) -> anyhow::Result<(i32, u32, u32)> { 5 | let datestr = String::from(datestr); 6 | let dates = datestr.split('-').collect::>(); 7 | if dates.len() != 3 { 8 | return Err(anyhow::Error::msg(datestr)); 9 | } 10 | let year = dates[0].parse(); 11 | let month = dates[1].parse(); 12 | let day = dates[2].parse(); 13 | 14 | if year.is_err() || month.is_err() || day.is_err() { 15 | return Err(anyhow::Error::msg(datestr)); 16 | } 17 | 18 | let year = year.unwrap(); 19 | let month = month.unwrap(); 20 | let day = day.unwrap(); 21 | if !(1970..=2100).contains(&year) || month < 1 || month > 12 { 22 | return Err(anyhow::Error::msg(datestr)); 23 | } 24 | 25 | let month_length = get_month_length(year, month); 26 | 27 | if day < 1 || day > month_length { 28 | return Err(anyhow::Error::msg(datestr)); 29 | } 30 | 31 | Ok((year, month, day)) 32 | } 33 | 34 | pub fn is_leap_year(year: i32) -> bool { 35 | year % 400 == 0 || (year % 100 != 0 && year % 4 == 0) 36 | } 37 | 38 | // month: January -> 1 39 | pub fn get_month_length(year: i32, month: u32) -> u32 { 40 | match month - 1 { 41 | 0 => 31, 42 | 1 => { 43 | if is_leap_year(year) { 44 | 29 45 | } else { 46 | 28 47 | } 48 | } 49 | 2 => 31, 50 | 3 => 30, 51 | 4 => 31, 52 | 5 => 30, 53 | 6 => 31, 54 | 7 => 31, 55 | 8 => 30, 56 | 9 => 31, 57 | 10 => 30, 58 | 11 => 31, 59 | _ => panic!("Invalid month"), 60 | } 61 | } 62 | 63 | pub fn format_date(date: &DateTime) -> String { 64 | format!("{}-{}-{}", date.year(), date.month(), date.day()) 65 | } 66 | 67 | #[cfg(test)] 68 | mod test { 69 | use super::*; 70 | 71 | #[test] 72 | fn it_accepts_valid_dates() { 73 | let valid_dates = vec![ 74 | "2018-1-1", 75 | "2025-12-31", 76 | "2020-1-12", 77 | "2020-2-29", 78 | "2020-02-2", 79 | "2020-02-02", 80 | "2020-2-09", 81 | ]; 82 | 83 | for date in &valid_dates { 84 | assert!(is_valid_date(date).is_ok()); 85 | } 86 | } 87 | 88 | #[test] 89 | fn it_rejects_invalid_dates() { 90 | let valid_dates = vec![ 91 | "2018--1-1", 92 | "2020-1-32", 93 | "2020-2-30", 94 | "2020-0-1", 95 | "2020-1-0", 96 | ]; 97 | 98 | for date in &valid_dates { 99 | assert!(is_valid_date(date).is_err()); 100 | } 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /scheduler/crates/domain/src/lib.rs: -------------------------------------------------------------------------------- 1 | mod account; 2 | pub mod booking_slots; 3 | mod calendar; 4 | mod date; 5 | mod event; 6 | mod event_instance; 7 | pub mod providers; 8 | mod reminder; 9 | mod schedule; 10 | pub mod scheduling; 11 | mod service; 12 | mod shared; 13 | mod timespan; 14 | mod user; 15 | 16 | pub use account::{Account, AccountIntegration, AccountSettings, AccountWebhookSettings, PEMKey}; 17 | pub use calendar::{Calendar, CalendarSettings, SyncedCalendar}; 18 | pub use date::format_date; 19 | pub use event::{CalendarEvent, CalendarEventReminder, SyncedCalendarEvent}; 20 | pub use event_instance::{ 21 | get_free_busy, CompatibleInstances, EventInstance, EventWithInstances, FreeBusy, 22 | }; 23 | pub use reminder::{EventRemindersExpansionJob, Reminder}; 24 | pub use schedule::{Schedule, ScheduleRule}; 25 | pub use service::{ 26 | BusyCalendar, Service, ServiceMultiPersonOptions, ServiceResource, ServiceWithUsers, TimePlan, 27 | }; 28 | pub use shared::entity::{Entity, ID}; 29 | pub use shared::metadata::{Meta, Metadata}; 30 | pub use shared::recurrence::{RRuleFrequency, RRuleOptions, WeekDay}; 31 | pub use timespan::TimeSpan; 32 | pub use user::{IntegrationProvider, User, UserIntegration}; 33 | 34 | pub use chrono::{Month, Weekday}; 35 | pub use chrono_tz::Tz; 36 | -------------------------------------------------------------------------------- /scheduler/crates/domain/src/providers/google.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | #[derive(Debug, Clone, Deserialize, Serialize)] 4 | #[serde(rename_all = "camelCase")] 5 | pub enum GoogleCalendarAccessRole { 6 | Owner, 7 | Writer, 8 | Reader, 9 | FreeBusyReader, 10 | } 11 | 12 | #[derive(Debug, Deserialize, Serialize)] 13 | #[serde(rename_all = "camelCase")] 14 | pub struct GoogleCalendarListEntry { 15 | pub id: String, 16 | pub access_role: GoogleCalendarAccessRole, 17 | pub summary: String, 18 | pub summary_override: Option, 19 | pub description: Option, 20 | pub location: Option, 21 | pub time_zone: Option, 22 | pub color_id: Option, 23 | pub background_color: Option, 24 | pub foreground_color: Option, 25 | pub hidden: Option, 26 | pub selected: Option, 27 | pub primary: Option, 28 | pub deleted: Option, 29 | } 30 | -------------------------------------------------------------------------------- /scheduler/crates/domain/src/providers/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod google; 2 | pub mod outlook; 3 | -------------------------------------------------------------------------------- /scheduler/crates/domain/src/reminder.rs: -------------------------------------------------------------------------------- 1 | use crate::shared::entity::ID; 2 | 3 | /// A `Reminder` represents a specific time before the occurrence a 4 | /// `CalendarEvent` at which the owner `Account` should be notified. 5 | #[derive(Debug, Clone, PartialEq)] 6 | pub struct Reminder { 7 | /// The `CalendarEvent` this `Reminder` is associated with 8 | pub event_id: ID, 9 | /// The `Account` this `Reminder` is associated with and which 10 | /// should receive a webhook notification at `remind_at` 11 | pub account_id: ID, 12 | /// The timestamp at which the `Account` should be notified. 13 | /// This is usually some minutes before a `CalendarEvent` 14 | pub remind_at: i64, 15 | /// This field is needed to avoid sending duplicate `Reminder`s to the `Account`. 16 | /// For more info see the db schema comments 17 | pub version: i64, 18 | /// User defined identifier to be able to separate reminders at same timestamp for the same 19 | /// event. 20 | /// For example: "ask_for_booking_review" or "send_invoice" 21 | pub identifier: String, 22 | } 23 | 24 | #[derive(Debug, Clone, PartialEq)] 25 | pub struct EventRemindersExpansionJob { 26 | pub event_id: ID, 27 | pub timestamp: i64, 28 | pub version: i64, 29 | } 30 | -------------------------------------------------------------------------------- /scheduler/crates/domain/src/scheduling/mod.rs: -------------------------------------------------------------------------------- 1 | mod round_robin; 2 | pub use round_robin::*; 3 | -------------------------------------------------------------------------------- /scheduler/crates/domain/src/shared/entity.rs: -------------------------------------------------------------------------------- 1 | use serde::{de::Visitor, Deserialize, Serialize}; 2 | use std::{fmt::Display, str::FromStr}; 3 | use thiserror::Error; 4 | use uuid::Uuid; 5 | 6 | pub trait Entity { 7 | fn id(&self) -> T; 8 | fn eq(&self, other: &Self) -> bool { 9 | self.id() == other.id() 10 | } 11 | } 12 | 13 | #[derive(Debug, Clone)] 14 | pub struct ID(Uuid); 15 | 16 | impl AsMut for ID { 17 | fn as_mut(&mut self) -> &mut Uuid { 18 | &mut self.0 19 | } 20 | } 21 | 22 | impl AsRef for ID { 23 | fn as_ref(&self) -> &Uuid { 24 | &self.0 25 | } 26 | } 27 | 28 | impl From for Uuid { 29 | fn from(e: ID) -> Self { 30 | e.0 31 | } 32 | } 33 | 34 | impl From for ID { 35 | fn from(e: Uuid) -> Self { 36 | Self(e) 37 | } 38 | } 39 | 40 | impl Default for ID { 41 | fn default() -> Self { 42 | Self(Uuid::new_v4()) 43 | } 44 | } 45 | 46 | impl Display for ID { 47 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 48 | write!(f, "{}", self.0.to_string()) 49 | } 50 | } 51 | 52 | #[derive(Error, Debug)] 53 | pub enum InvalidIDError { 54 | #[error("ID: {0} is malformed")] 55 | Malformed(String), 56 | } 57 | 58 | impl FromStr for ID { 59 | type Err = InvalidIDError; 60 | 61 | fn from_str(s: &str) -> Result { 62 | s.parse::() 63 | .map(Self) 64 | .map_err(|_| InvalidIDError::Malformed(s.to_string())) 65 | } 66 | } 67 | 68 | impl PartialEq for ID { 69 | fn eq(&self, other: &Self) -> bool { 70 | self.to_string() == other.to_string() 71 | } 72 | } 73 | 74 | impl Serialize for ID { 75 | fn serialize(&self, serializer: S) -> Result 76 | where 77 | S: serde::Serializer, 78 | { 79 | serializer.serialize_str(&self.to_string()) 80 | } 81 | } 82 | 83 | impl<'de> Deserialize<'de> for ID { 84 | fn deserialize(deserializer: D) -> Result 85 | where 86 | D: serde::Deserializer<'de>, 87 | { 88 | struct IDVisitor; 89 | 90 | impl<'de> Visitor<'de> for IDVisitor { 91 | type Value = ID; 92 | 93 | fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { 94 | formatter.write_str("A valid string id representation") 95 | } 96 | 97 | fn visit_str(self, value: &str) -> Result 98 | where 99 | E: serde::de::Error, 100 | { 101 | value 102 | .parse::() 103 | .map_err(|_| E::custom(format!("Malformed id: {}", value))) 104 | } 105 | } 106 | 107 | deserializer.deserialize_str(IDVisitor) 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /scheduler/crates/domain/src/shared/metadata.rs: -------------------------------------------------------------------------------- 1 | use crate::{Entity, ID}; 2 | use serde::{Deserialize, Serialize}; 3 | use std::collections::HashMap; 4 | 5 | #[derive(Serialize, Deserialize, Default, Debug, Clone)] 6 | pub struct Metadata { 7 | #[serde(flatten)] 8 | pub inner: HashMap, 9 | } 10 | 11 | impl Metadata { 12 | pub fn new() -> Self { 13 | Self { 14 | inner: HashMap::new(), 15 | } 16 | } 17 | 18 | pub fn new_kv(key: String, value: String) -> Self { 19 | let mut inner = HashMap::new(); 20 | inner.insert(key, value); 21 | Self::from(inner) 22 | } 23 | } 24 | 25 | impl From> for Metadata { 26 | fn from(inner: HashMap) -> Self { 27 | Self { inner } 28 | } 29 | } 30 | 31 | pub trait Meta: Entity { 32 | fn metadata(&self) -> &Metadata; 33 | /// Retrieves the account_id associated with this entity, which 34 | /// is useful to know when querying on the metadata 35 | fn account_id(&self) -> &ID; 36 | } 37 | -------------------------------------------------------------------------------- /scheduler/crates/domain/src/shared/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod entity; 2 | pub mod metadata; 3 | pub mod recurrence; 4 | -------------------------------------------------------------------------------- /scheduler/crates/domain/src/timespan.rs: -------------------------------------------------------------------------------- 1 | use chrono::prelude::*; 2 | use chrono::DateTime; 3 | use chrono_tz::Tz; 4 | use serde::{Deserialize, Serialize}; 5 | use std::error::Error; 6 | 7 | /// A `TimeSpan` type represents a time interval (duration of time) 8 | #[derive(Debug, Clone, Serialize, Deserialize)] 9 | #[serde(rename_all = "camelCase")] 10 | pub struct TimeSpan { 11 | start_ts: i64, 12 | end_ts: i64, 13 | duration: i64, 14 | } 15 | 16 | impl TimeSpan { 17 | pub fn new(start_ts: i64, end_ts: i64) -> Self { 18 | Self { 19 | start_ts, 20 | end_ts, 21 | duration: end_ts - start_ts, 22 | } 23 | } 24 | 25 | /// Duration of this `TimeSpan` is greater than a given duration 26 | pub fn greater_than(&self, duration: i64) -> bool { 27 | self.duration > duration 28 | } 29 | 30 | fn create_datetime_from_millis(timestamp_millis: i64, tz: &Tz) -> DateTime { 31 | tz.timestamp_millis(timestamp_millis) 32 | } 33 | 34 | pub fn as_datetime(&self, tz: &Tz) -> TimeSpanDateTime { 35 | TimeSpanDateTime { 36 | start: TimeSpan::create_datetime_from_millis(self.start_ts, tz), 37 | end: TimeSpan::create_datetime_from_millis(self.end_ts, tz), 38 | } 39 | } 40 | 41 | pub fn start(&self) -> i64 { 42 | self.start_ts 43 | } 44 | 45 | pub fn end(&self) -> i64 { 46 | self.end_ts 47 | } 48 | } 49 | 50 | #[derive(Debug)] 51 | pub struct InvalidTimeSpanError(i64, i64); 52 | 53 | impl Error for InvalidTimeSpanError {} 54 | 55 | impl std::fmt::Display for InvalidTimeSpanError { 56 | fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { 57 | write!(f, "Provided timespan start_ts: {} and end_ts: {} is invalid. It should be between 1 hour and 40 days.", self.0, self.1) 58 | } 59 | } 60 | 61 | #[derive(Debug)] 62 | pub struct TimeSpanDateTime { 63 | pub start: DateTime, 64 | pub end: DateTime, 65 | } 66 | -------------------------------------------------------------------------------- /scheduler/crates/domain/src/user.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | shared::entity::{Entity, ID}, 3 | Meta, Metadata, 4 | }; 5 | use serde::{Deserialize, Serialize}; 6 | 7 | #[derive(Debug, Clone, Default)] 8 | pub struct User { 9 | pub id: ID, 10 | pub account_id: ID, 11 | pub metadata: Metadata, 12 | } 13 | 14 | impl User { 15 | pub fn new(account_id: ID) -> Self { 16 | Self { 17 | account_id, 18 | ..Default::default() 19 | } 20 | } 21 | } 22 | 23 | impl Entity for User { 24 | fn id(&self) -> ID { 25 | self.id.clone() 26 | } 27 | } 28 | 29 | impl Meta for User { 30 | fn metadata(&self) -> &Metadata { 31 | &self.metadata 32 | } 33 | fn account_id(&self) -> &ID { 34 | &self.account_id 35 | } 36 | } 37 | 38 | #[derive(Debug, Clone, Serialize, Deserialize)] 39 | pub struct UserIntegration { 40 | pub user_id: ID, 41 | pub account_id: ID, 42 | pub provider: IntegrationProvider, 43 | pub refresh_token: String, 44 | pub access_token: String, 45 | pub access_token_expires_ts: i64, 46 | } 47 | 48 | #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] 49 | #[serde(rename_all = "camelCase")] 50 | pub enum IntegrationProvider { 51 | Google, 52 | Outlook, 53 | } 54 | 55 | impl Default for IntegrationProvider { 56 | fn default() -> Self { 57 | IntegrationProvider::Google 58 | } 59 | } 60 | 61 | impl From for String { 62 | fn from(e: IntegrationProvider) -> Self { 63 | match e { 64 | IntegrationProvider::Google => "google".into(), 65 | IntegrationProvider::Outlook => "outlook".into(), 66 | } 67 | } 68 | } 69 | 70 | impl From for IntegrationProvider { 71 | fn from(e: String) -> IntegrationProvider { 72 | match &e[..] { 73 | "google" => IntegrationProvider::Google, 74 | "outlook" => IntegrationProvider::Outlook, 75 | _ => unreachable!("Invalid provider"), 76 | } 77 | } 78 | } 79 | 80 | // #[derive(Debug, Clone, Serialize, Deserialize)] 81 | // pub struct UserGoogleIntegrationData { 82 | // pub refresh_token: String, 83 | // pub access_token: String, 84 | // pub access_token_expires_ts: i64, 85 | // } 86 | 87 | // #[derive(Debug, Clone, Serialize, Deserialize)] 88 | // pub struct UserOutlookIntegrationData { 89 | // pub refresh_token: String, 90 | // pub access_token: String, 91 | // pub access_token_expires_ts: i64, 92 | // } 93 | -------------------------------------------------------------------------------- /scheduler/crates/infra/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "nettu_scheduler_infra" 3 | version = "0.1.0" 4 | authors = ["Fredrik Meringdal"] 5 | edition = "2018" 6 | 7 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 8 | 9 | [dependencies] 10 | nettu_scheduler_utils = { path = "../utils" } 11 | nettu_scheduler_domain = { path = "../domain" } 12 | serde = { version = "1.0", features = ["derive"] } 13 | serde_json = "1" 14 | async-trait = "0.1.42" 15 | chrono = { version = "0.4.19", features = ["serde"] } 16 | chrono-tz = { version = "0.5.3", features = ["serde"] } 17 | anyhow = "1.0.0" 18 | tokio = { version = "1.10.0", features = ["macros"] } 19 | tracing = "0.1.25" 20 | reqwest = { version = "0.11.4", features = ["json"] } 21 | uuid = { version = "0.8.2", features = ["serde"] } 22 | futures = "0.3" 23 | sqlx = { version = "0.5.6", features = ["postgres", "runtime-actix-rustls", "uuid", "json"] } 24 | -------------------------------------------------------------------------------- /scheduler/crates/infra/migrations/00000000000000_initial_setup.sql: -------------------------------------------------------------------------------- 1 | CREATE OR REPLACE FUNCTION manage_updated_at(_tbl regclass) RETURNS VOID AS $$ 2 | BEGIN 3 | EXECUTE format('CREATE TRIGGER set_updated_at BEFORE UPDATE ON %s 4 | FOR EACH ROW EXECUTE PROCEDURE set_updated_at()', _tbl); 5 | END; 6 | $$ LANGUAGE plpgsql; 7 | 8 | CREATE OR REPLACE FUNCTION set_updated_at() RETURNS trigger AS $$ 9 | BEGIN 10 | IF ( 11 | NEW IS DISTINCT FROM OLD AND 12 | NEW.updated_at IS NOT DISTINCT FROM OLD.updated_at 13 | ) THEN 14 | NEW.updated_at := CURRENT_TIMESTAMP; 15 | END IF; 16 | RETURN NEW; 17 | END; 18 | $$ LANGUAGE plpgsql; 19 | 20 | CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; 21 | 22 | ------------------ PROCEDURES 23 | 24 | -- immutable_columns() will make the column names immutable which are passed as 25 | -- parameters when the trigger is created. It raises error code 23601 which is a 26 | -- class 23 integrity constraint violation: immutable column 27 | 28 | create or replace function 29 | immutable_columns() 30 | returns trigger 31 | as $$ 32 | declare 33 | col_name text; 34 | new_value text; 35 | old_value text; 36 | begin 37 | foreach col_name in array tg_argv loop 38 | execute format('SELECT $1.%I', col_name) into new_value using new; 39 | execute format('SELECT $1.%I', col_name) into old_value using old; 40 | if new_value is distinct from old_value then 41 | raise exception 'immutable column: %.%', tg_table_name, col_name using 42 | errcode = '23601', 43 | schema = tg_table_schema, 44 | table = tg_table_name, 45 | column = col_name; 46 | end if; 47 | end loop; 48 | return new; 49 | end; 50 | $$ language plpgsql; 51 | 52 | comment on function 53 | immutable_columns() 54 | is 55 | 'function used in before update triggers to make columns immutable'; -------------------------------------------------------------------------------- /scheduler/crates/infra/src/config.rs: -------------------------------------------------------------------------------- 1 | use nettu_scheduler_utils::create_random_secret; 2 | use tracing::{info, warn}; 3 | 4 | #[derive(Debug, Clone)] 5 | pub struct Config { 6 | /// Secret code used to create new `Account`s 7 | pub create_account_secret_code: String, 8 | /// Port for the application to run on 9 | pub port: usize, 10 | /// Maximum allowed duration in millis for querying event instances. 11 | /// This is used to avoid having clients ask for `CalendarEvents` in a 12 | /// timespan of several years which will take a lot of time to compute 13 | /// and is also not very useful information to query about anyways. 14 | pub event_instances_query_duration_limit: i64, 15 | /// Maximum allowed duration in millis for querying booking slots 16 | /// This is used to avoid having clients ask for `BookingSlot`s in a 17 | /// timespan of several years which will take a lot of time to compute 18 | /// and is also not very useful information to query about anyways. 19 | pub booking_slots_query_duration_limit: i64, 20 | } 21 | 22 | impl Config { 23 | pub fn new() -> Self { 24 | let create_account_secret_code = match std::env::var("CREATE_ACCOUNT_SECRET_CODE") { 25 | Ok(code) => code, 26 | Err(_) => { 27 | info!("Did not find CREATE_ACCOUNT_SECRET_CODE environment variable. Going to create one."); 28 | let code = create_random_secret(16); 29 | info!( 30 | "Secret code for creating accounts was generated and set to: {}", 31 | code 32 | ); 33 | code 34 | } 35 | }; 36 | let default_port = "5000"; 37 | let port = std::env::var("PORT").unwrap_or_else(|_| default_port.into()); 38 | let port = match port.parse::() { 39 | Ok(port) => port, 40 | Err(_) => { 41 | warn!( 42 | "The given PORT: {} is not valid, falling back to the default port: {}.", 43 | port, default_port 44 | ); 45 | default_port.parse::().unwrap() 46 | } 47 | }; 48 | 49 | const DAYS_62: i64 = 1000 * 60 * 60 * 24 * 62; 50 | const DAYS_101: i64 = 1000 * 60 * 60 * 24 * 101; 51 | 52 | Self { 53 | create_account_secret_code, 54 | port, 55 | event_instances_query_duration_limit: DAYS_62, 56 | booking_slots_query_duration_limit: DAYS_101, 57 | } 58 | } 59 | } 60 | 61 | impl Default for Config { 62 | fn default() -> Self { 63 | Self::new() 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /scheduler/crates/infra/src/lib.rs: -------------------------------------------------------------------------------- 1 | mod config; 2 | mod repos; 3 | mod services; 4 | mod system; 5 | 6 | pub use config::Config; 7 | use repos::Repos; 8 | pub use repos::{BusyCalendarIdentifier, ExternalBusyCalendarIdentifier, MetadataFindQuery}; 9 | pub use services::*; 10 | use sqlx::migrate::MigrateError; 11 | use sqlx::postgres::PgPoolOptions; 12 | use std::sync::Arc; 13 | pub use system::ISys; 14 | use system::RealSys; 15 | 16 | #[derive(Clone)] 17 | pub struct NettuContext { 18 | pub repos: Repos, 19 | pub config: Config, 20 | pub sys: Arc, 21 | } 22 | 23 | struct ContextParams { 24 | pub postgres_connection_string: String, 25 | } 26 | 27 | impl NettuContext { 28 | async fn create(params: ContextParams) -> Self { 29 | let repos = Repos::create_postgres(¶ms.postgres_connection_string) 30 | .await 31 | .expect("Postgres credentials must be set and valid"); 32 | Self { 33 | repos, 34 | config: Config::new(), 35 | sys: Arc::new(RealSys {}), 36 | } 37 | } 38 | } 39 | 40 | /// Will setup the infrastructure context given the environment 41 | pub async fn setup_context() -> NettuContext { 42 | NettuContext::create(ContextParams { 43 | postgres_connection_string: get_psql_connection_string(), 44 | }) 45 | .await 46 | } 47 | 48 | fn get_psql_connection_string() -> String { 49 | const PSQL_CONNECTION_STRING: &str = "DATABASE_URL"; 50 | 51 | std::env::var(PSQL_CONNECTION_STRING) 52 | .unwrap_or_else(|_| panic!("{} env var to be present.", PSQL_CONNECTION_STRING)) 53 | } 54 | 55 | pub async fn run_migration() -> Result<(), MigrateError> { 56 | let pool = PgPoolOptions::new() 57 | .max_connections(5) 58 | .connect(&get_psql_connection_string()) 59 | .await 60 | .expect("TO CONNECT TO POSTGRES"); 61 | 62 | sqlx::migrate!().run(&pool).await 63 | } 64 | -------------------------------------------------------------------------------- /scheduler/crates/infra/src/repos/account/mod.rs: -------------------------------------------------------------------------------- 1 | mod postgres; 2 | 3 | use nettu_scheduler_domain::Account; 4 | use nettu_scheduler_domain::ID; 5 | pub use postgres::PostgresAccountRepo; 6 | 7 | #[async_trait::async_trait] 8 | pub trait IAccountRepo: Send + Sync { 9 | async fn insert(&self, account: &Account) -> anyhow::Result<()>; 10 | async fn save(&self, account: &Account) -> anyhow::Result<()>; 11 | async fn find(&self, account_id: &ID) -> Option; 12 | async fn find_many(&self, account_ids: &[ID]) -> anyhow::Result>; 13 | async fn delete(&self, account_id: &ID) -> Option; 14 | async fn find_by_apikey(&self, api_key: &str) -> Option; 15 | } 16 | 17 | #[cfg(test)] 18 | mod tests { 19 | use crate::setup_context; 20 | use nettu_scheduler_domain::{Account, Entity, PEMKey}; 21 | 22 | #[tokio::test] 23 | async fn create_and_delete() { 24 | let ctx = setup_context().await; 25 | let account = Account::default(); 26 | 27 | // Insert 28 | assert!(ctx.repos.accounts.insert(&account).await.is_ok()); 29 | 30 | // Different find methods 31 | let res = ctx.repos.accounts.find(&account.id).await.unwrap(); 32 | assert!(res.eq(&account)); 33 | let res = ctx 34 | .repos 35 | .accounts 36 | .find_many(&[account.id.clone()]) 37 | .await 38 | .unwrap(); 39 | assert!(res[0].eq(&account)); 40 | let res = ctx 41 | .repos 42 | .accounts 43 | .find_by_apikey(&account.secret_api_key) 44 | .await 45 | .unwrap(); 46 | assert!(res.eq(&account)); 47 | 48 | // Delete 49 | let res = ctx.repos.accounts.delete(&account.id).await; 50 | assert!(res.is_some()); 51 | assert!(res.unwrap().eq(&account)); 52 | 53 | // Find 54 | assert!(ctx.repos.accounts.find(&account.id).await.is_none()); 55 | } 56 | 57 | #[tokio::test] 58 | async fn update() { 59 | let ctx = setup_context().await; 60 | let mut account = Account::default(); 61 | 62 | // Insert 63 | assert!(ctx.repos.accounts.insert(&account).await.is_ok()); 64 | 65 | let pubkey = std::fs::read("../api/config/test_public_rsa_key.crt").unwrap(); 66 | let pubkey = String::from_utf8(pubkey).unwrap(); 67 | 68 | let pubkey = PEMKey::new(pubkey).unwrap(); 69 | account.set_public_jwt_key(Some(pubkey)); 70 | 71 | // Save 72 | assert!(ctx.repos.accounts.save(&account).await.is_ok()); 73 | 74 | // Find 75 | assert!(ctx 76 | .repos 77 | .accounts 78 | .find(&account.id) 79 | .await 80 | .unwrap() 81 | .eq(&account)); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /scheduler/crates/infra/src/repos/account_integrations/mod.rs: -------------------------------------------------------------------------------- 1 | mod postgres; 2 | 3 | use nettu_scheduler_domain::ID; 4 | use nettu_scheduler_domain::{AccountIntegration, IntegrationProvider}; 5 | pub use postgres::PostgresAccountIntegrationRepo; 6 | 7 | #[async_trait::async_trait] 8 | pub trait IAccountIntegrationRepo: Send + Sync { 9 | async fn insert(&self, integration: &AccountIntegration) -> anyhow::Result<()>; 10 | async fn find(&self, account_id: &ID) -> anyhow::Result>; 11 | async fn delete(&self, account_id: &ID, provider: IntegrationProvider) -> anyhow::Result<()>; 12 | } 13 | 14 | #[cfg(test)] 15 | mod tests { 16 | use crate::setup_context; 17 | use nettu_scheduler_domain::{Account, AccountIntegration, IntegrationProvider}; 18 | 19 | #[tokio::test] 20 | async fn test_account_integrations() { 21 | let ctx = setup_context().await; 22 | 23 | let account = Account::new(); 24 | ctx.repos 25 | .accounts 26 | .insert(&account) 27 | .await 28 | .expect("To insert account"); 29 | 30 | for provider in [IntegrationProvider::Google, IntegrationProvider::Outlook] { 31 | let acc_integration = AccountIntegration { 32 | account_id: account.id.clone(), 33 | client_id: "".into(), 34 | client_secret: "".into(), 35 | redirect_uri: "".into(), 36 | provider: provider.clone(), 37 | }; 38 | assert!(ctx 39 | .repos 40 | .account_integrations 41 | .insert(&acc_integration) 42 | .await 43 | .is_ok()); 44 | assert!(ctx 45 | .repos 46 | .account_integrations 47 | .insert(&acc_integration) 48 | .await 49 | .is_err()); 50 | } 51 | let acc_integrations = ctx 52 | .repos 53 | .account_integrations 54 | .find(&account.id) 55 | .await 56 | .expect("To find account integrations"); 57 | assert_eq!(acc_integrations.len(), 2); 58 | assert_eq!(acc_integrations[0].account_id, account.id); 59 | assert_eq!(acc_integrations[1].account_id, account.id); 60 | assert!(acc_integrations 61 | .iter() 62 | .find(|c| c.provider == IntegrationProvider::Google) 63 | .is_some()); 64 | assert!(acc_integrations 65 | .iter() 66 | .find(|c| c.provider == IntegrationProvider::Outlook) 67 | .is_some()); 68 | 69 | assert!(ctx 70 | .repos 71 | .account_integrations 72 | .delete(&account.id, IntegrationProvider::Google) 73 | .await 74 | .is_ok()); 75 | assert!(ctx 76 | .repos 77 | .account_integrations 78 | .delete(&account.id, IntegrationProvider::Google) 79 | .await 80 | .is_err()); 81 | 82 | // Find after delete 83 | let acc_integrations = ctx 84 | .repos 85 | .account_integrations 86 | .find(&account.id) 87 | .await 88 | .expect("To find account integrations"); 89 | assert_eq!(acc_integrations.len(), 1); 90 | assert_eq!(acc_integrations[0].account_id, account.id); 91 | assert_eq!(acc_integrations[0].provider, IntegrationProvider::Outlook); 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /scheduler/crates/infra/src/repos/calendar/mod.rs: -------------------------------------------------------------------------------- 1 | mod postgres; 2 | 3 | use super::shared::query_structs::MetadataFindQuery; 4 | use nettu_scheduler_domain::{Calendar, ID}; 5 | pub use postgres::PostgresCalendarRepo; 6 | 7 | #[async_trait::async_trait] 8 | pub trait ICalendarRepo: Send + Sync { 9 | async fn insert(&self, calendar: &Calendar) -> anyhow::Result<()>; 10 | async fn save(&self, calendar: &Calendar) -> anyhow::Result<()>; 11 | async fn find(&self, calendar_id: &ID) -> Option; 12 | async fn find_by_user(&self, user_id: &ID) -> Vec; 13 | async fn delete(&self, calendar_id: &ID) -> anyhow::Result<()>; 14 | async fn find_by_metadata(&self, query: MetadataFindQuery) -> Vec; 15 | } 16 | 17 | #[cfg(test)] 18 | mod tests { 19 | use crate::setup_context; 20 | use nettu_scheduler_domain::{Account, Calendar, Entity, User}; 21 | 22 | #[tokio::test] 23 | async fn create_and_delete() { 24 | let ctx = setup_context().await; 25 | let account = Account::default(); 26 | ctx.repos.accounts.insert(&account).await.unwrap(); 27 | let user = User::new(account.id.clone()); 28 | ctx.repos.users.insert(&user).await.unwrap(); 29 | let calendar = Calendar::new(&user.id, &account.id); 30 | 31 | // Insert 32 | assert!(ctx.repos.calendars.insert(&calendar).await.is_ok()); 33 | 34 | // Different find methods 35 | let res = ctx.repos.calendars.find(&calendar.id).await.unwrap(); 36 | assert!(res.eq(&calendar)); 37 | let res = ctx.repos.calendars.find_by_user(&user.id).await; 38 | assert!(res[0].eq(&calendar)); 39 | 40 | // Delete 41 | let res = ctx.repos.calendars.delete(&calendar.id).await; 42 | assert!(res.is_ok()); 43 | 44 | // Find 45 | assert!(ctx.repos.calendars.find(&calendar.id).await.is_none()); 46 | } 47 | 48 | #[tokio::test] 49 | async fn update() { 50 | let ctx = setup_context().await; 51 | let account = Account::default(); 52 | ctx.repos.accounts.insert(&account).await.unwrap(); 53 | let user = User::new(account.id.clone()); 54 | ctx.repos.users.insert(&user).await.unwrap(); 55 | let mut calendar = Calendar::new(&user.id, &account.id); 56 | 57 | // Insert 58 | assert!(ctx.repos.calendars.insert(&calendar).await.is_ok()); 59 | calendar.settings.week_start = calendar.settings.week_start.succ(); 60 | 61 | // Save 62 | assert!(ctx.repos.calendars.save(&calendar).await.is_ok()); 63 | 64 | let updated_calendar = ctx.repos.calendars.find(&calendar.id).await.unwrap(); 65 | assert_eq!( 66 | updated_calendar.settings.week_start, 67 | calendar.settings.week_start 68 | ); 69 | } 70 | 71 | #[tokio::test] 72 | async fn delete_by_user() { 73 | let ctx = setup_context().await; 74 | let account = Account::default(); 75 | ctx.repos.accounts.insert(&account).await.unwrap(); 76 | let user = User::new(account.id.clone()); 77 | ctx.repos.users.insert(&user).await.unwrap(); 78 | let calendar = Calendar::new(&user.id, &account.id); 79 | 80 | // Insert 81 | assert!(ctx.repos.calendars.insert(&calendar).await.is_ok()); 82 | 83 | // Delete 84 | let res = ctx.repos.users.delete(&user.id).await; 85 | assert!(res.is_some()); 86 | 87 | // Find 88 | assert!(ctx.repos.calendars.find(&calendar.id).await.is_none()); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /scheduler/crates/infra/src/repos/event/event_reminders_expansion_jobs/postgres.rs: -------------------------------------------------------------------------------- 1 | use super::IEventRemindersGenerationJobsRepo; 2 | use nettu_scheduler_domain::EventRemindersExpansionJob; 3 | use sqlx::{types::Uuid, FromRow, PgPool}; 4 | use tracing::error; 5 | 6 | pub struct PostgresEventReminderGenerationJobsRepo { 7 | pool: PgPool, 8 | } 9 | 10 | impl PostgresEventReminderGenerationJobsRepo { 11 | pub fn new(pool: PgPool) -> Self { 12 | Self { pool } 13 | } 14 | } 15 | 16 | #[derive(Debug, FromRow)] 17 | struct JobRaw { 18 | event_uid: Uuid, 19 | timestamp: i64, 20 | version: i64, 21 | } 22 | 23 | impl From for EventRemindersExpansionJob { 24 | fn from(e: JobRaw) -> Self { 25 | Self { 26 | event_id: e.event_uid.into(), 27 | timestamp: e.timestamp, 28 | version: e.version, 29 | } 30 | } 31 | } 32 | 33 | #[async_trait::async_trait] 34 | impl IEventRemindersGenerationJobsRepo for PostgresEventReminderGenerationJobsRepo { 35 | async fn bulk_insert(&self, jobs: &[EventRemindersExpansionJob]) -> anyhow::Result<()> { 36 | for job in jobs { 37 | sqlx::query!( 38 | r#" 39 | INSERT INTO calendar_event_reminder_generation_jobs 40 | (event_uid, timestamp, version) 41 | VALUES($1, $2, $3) 42 | "#, 43 | job.event_id.as_ref(), 44 | job.timestamp, 45 | job.version as _ 46 | ) 47 | .execute(&self.pool) 48 | .await 49 | .map_err(|e| { 50 | error!( 51 | "Unable to insert calendar event reminder expansion job: {:?}. DB returned error: {:?}", 52 | job, e 53 | ); 54 | e 55 | })?; 56 | } 57 | Ok(()) 58 | } 59 | 60 | async fn delete_all_before(&self, before: i64) -> Vec { 61 | sqlx::query_as!( 62 | JobRaw, 63 | r#" 64 | DELETE FROM calendar_event_reminder_generation_jobs AS j 65 | WHERE j.timestamp <= $1 66 | RETURNING * 67 | "#, 68 | before, 69 | ) 70 | .fetch_all(&self.pool) 71 | .await 72 | .map_err(|e| { 73 | error!( 74 | "Unable to delete calendar event reminder expansion job before timestamp: {}. DB returned error: {:?}", 75 | before, e 76 | ); 77 | e 78 | }) 79 | .unwrap_or_default() 80 | .into_iter() 81 | .map(|job| job.into()) 82 | .collect() 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /scheduler/crates/infra/src/repos/event/event_synced/postgres.rs: -------------------------------------------------------------------------------- 1 | use super::IEventSyncedRepo; 2 | use nettu_scheduler_domain::{SyncedCalendarEvent, ID}; 3 | use sqlx::{types::Uuid, FromRow, PgPool}; 4 | use tracing::error; 5 | 6 | pub struct PostgresEventSyncedRepo { 7 | pool: PgPool, 8 | } 9 | 10 | impl PostgresEventSyncedRepo { 11 | pub fn new(pool: PgPool) -> Self { 12 | Self { pool } 13 | } 14 | } 15 | 16 | #[derive(Debug, FromRow)] 17 | struct SyncedEventRaw { 18 | event_uid: Uuid, 19 | calendar_uid: Uuid, 20 | user_uid: Uuid, 21 | ext_calendar_id: String, 22 | ext_calendar_event_id: String, 23 | provider: String, 24 | } 25 | 26 | impl From for SyncedCalendarEvent { 27 | fn from(e: SyncedEventRaw) -> Self { 28 | Self { 29 | event_id: e.event_uid.into(), 30 | user_id: e.user_uid.into(), 31 | calendar_id: e.calendar_uid.into(), 32 | ext_calendar_id: e.ext_calendar_id, 33 | ext_event_id: e.ext_calendar_event_id, 34 | provider: e.provider.into(), 35 | } 36 | } 37 | } 38 | 39 | #[async_trait::async_trait] 40 | impl IEventSyncedRepo for PostgresEventSyncedRepo { 41 | async fn insert(&self, e: &SyncedCalendarEvent) -> anyhow::Result<()> { 42 | let provider: String = e.provider.clone().into(); 43 | sqlx::query!( 44 | r#" 45 | INSERT INTO externally_synced_calendar_events( 46 | event_uid, 47 | calendar_uid, 48 | ext_calendar_id, 49 | ext_calendar_event_id, 50 | provider 51 | ) 52 | VALUES($1, $2, $3, $4, $5) 53 | "#, 54 | e.event_id.as_ref(), 55 | e.calendar_id.as_ref(), 56 | e.ext_calendar_id, 57 | e.ext_event_id, 58 | provider as _ 59 | ) 60 | .execute(&self.pool) 61 | .await 62 | .map_err(|err| { 63 | error!( 64 | "Unable to insert syunced calendar event: {:?}. DB returned error: {:?}", 65 | e, err 66 | ); 67 | err 68 | })?; 69 | 70 | Ok(()) 71 | } 72 | 73 | async fn find_by_event(&self, event_id: &ID) -> anyhow::Result> { 74 | let synced_events: Vec = sqlx::query_as!( 75 | SyncedEventRaw, 76 | r#" 77 | SELECT e.*, c.user_uid FROM externally_synced_calendar_events AS e 78 | INNER JOIN calendars AS c 79 | ON c.calendar_uid = e.calendar_uid 80 | WHERE e.event_uid = $1 81 | "#, 82 | event_id.as_ref(), 83 | ) 84 | .fetch_all(&self.pool) 85 | .await 86 | .map_err(|e| { 87 | error!( 88 | "Unable to find synced calendar events for calendar event with id: {}. DB returned error: {:?}", 89 | event_id, e 90 | ); 91 | e 92 | }) 93 | ?; 94 | 95 | Ok(synced_events.into_iter().map(|e| e.into()).collect()) 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /scheduler/crates/infra/src/repos/event/mod.rs: -------------------------------------------------------------------------------- 1 | mod calendar_event; 2 | mod event_reminders_expansion_jobs; 3 | mod event_synced; 4 | mod reminder; 5 | 6 | pub use calendar_event::IEventRepo; 7 | pub use calendar_event::PostgresEventRepo; 8 | pub use event_reminders_expansion_jobs::IEventRemindersGenerationJobsRepo; 9 | pub use event_reminders_expansion_jobs::PostgresEventReminderGenerationJobsRepo; 10 | pub use event_synced::IEventSyncedRepo; 11 | pub use event_synced::PostgresEventSyncedRepo; 12 | pub use reminder::IReminderRepo; 13 | pub use reminder::PostgresReminderRepo; 14 | -------------------------------------------------------------------------------- /scheduler/crates/infra/src/repos/reservation/postgres.rs: -------------------------------------------------------------------------------- 1 | use super::IReservationRepo; 2 | use nettu_scheduler_domain::ID; 3 | use sqlx::{types::Uuid, FromRow, PgPool}; 4 | use tracing::error; 5 | 6 | pub struct PostgresReservationRepo { 7 | pool: PgPool, 8 | } 9 | 10 | impl PostgresReservationRepo { 11 | pub fn new(pool: PgPool) -> Self { 12 | Self { pool } 13 | } 14 | } 15 | 16 | #[derive(Debug, FromRow)] 17 | struct ReservationRaw { 18 | count: i64, 19 | timestamp: i64, 20 | service_uid: Uuid, 21 | } 22 | 23 | #[async_trait::async_trait] 24 | impl IReservationRepo for PostgresReservationRepo { 25 | async fn increment(&self, service_id: &ID, timestamp: i64) -> anyhow::Result<()> { 26 | sqlx::query!( 27 | r#" 28 | INSERT INTO service_reservations(service_uid, timestamp) 29 | VALUES($1, $2) 30 | ON CONFLICT(service_uid, timestamp) DO UPDATE SET count = service_reservations.count + 1 31 | "#, 32 | service_id.as_ref(), 33 | timestamp 34 | ) 35 | .execute(&self.pool) 36 | .await 37 | .map_err(|err| { 38 | error!( 39 | "Unable to increment reservation count for service id: {} at timestamp {}. DB returned error: {:?}", 40 | service_id, timestamp, err 41 | ); 42 | err 43 | })?; 44 | 45 | Ok(()) 46 | } 47 | 48 | async fn decrement(&self, service_id: &ID, timestamp: i64) -> anyhow::Result<()> { 49 | sqlx::query_as!( 50 | ReservationRaw, 51 | r#" 52 | UPDATE service_reservations as r 53 | SET count = count - 1 54 | WHERE r.service_uid = $1 AND r.timestamp = $2 55 | "#, 56 | service_id.as_ref(), 57 | timestamp, 58 | ) 59 | .execute(&self.pool) 60 | .await 61 | .map_err(|err| { 62 | error!( 63 | "Unable to decrement reservation count for service id: {} at timestamp {}. DB returned error: {:?}", 64 | service_id, timestamp, err 65 | ); 66 | err 67 | })?; 68 | Ok(()) 69 | } 70 | 71 | async fn count(&self, service_id: &ID, timestamp: i64) -> anyhow::Result { 72 | let reservation: Option = sqlx::query_as!( 73 | ReservationRaw, 74 | r#" 75 | SELECT * FROM service_reservations as r 76 | WHERE r.service_uid = $1 AND 77 | r.timestamp = $2 78 | "#, 79 | service_id.as_ref(), 80 | timestamp, 81 | ) 82 | .fetch_optional(&self.pool) 83 | .await 84 | .map_err(|err| { 85 | error!( 86 | "Unable to retrieve reservation count for service id: {} at timestamp {}. DB returned error: {:?}", 87 | service_id, timestamp, err 88 | ); 89 | err 90 | })?; 91 | 92 | let count = reservation.map(|r| r.count).unwrap_or(0); 93 | Ok(count as usize) 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /scheduler/crates/infra/src/repos/service/mod.rs: -------------------------------------------------------------------------------- 1 | mod postgres; 2 | 3 | use super::shared::query_structs::MetadataFindQuery; 4 | use nettu_scheduler_domain::{Service, ServiceWithUsers, ID}; 5 | pub use postgres::PostgresServiceRepo; 6 | 7 | #[async_trait::async_trait] 8 | pub trait IServiceRepo: Send + Sync { 9 | async fn insert(&self, service: &Service) -> anyhow::Result<()>; 10 | async fn save(&self, service: &Service) -> anyhow::Result<()>; 11 | async fn find(&self, service_id: &ID) -> Option; 12 | async fn find_with_users(&self, service_id: &ID) -> Option; 13 | async fn delete(&self, service_id: &ID) -> anyhow::Result<()>; 14 | async fn find_by_metadata(&self, query: MetadataFindQuery) -> Vec; 15 | } 16 | 17 | #[cfg(test)] 18 | mod tests { 19 | use crate::setup_context; 20 | use nettu_scheduler_domain::{Account, Metadata, Service, ServiceResource, TimePlan, User}; 21 | 22 | #[tokio::test] 23 | async fn create_and_delete() { 24 | let ctx = setup_context().await; 25 | let account = Account::default(); 26 | ctx.repos 27 | .accounts 28 | .insert(&account) 29 | .await 30 | .expect("To insert account"); 31 | let service = Service::new(account.id.clone()); 32 | 33 | // Insert 34 | assert!(ctx.repos.services.insert(&service).await.is_ok()); 35 | 36 | // Get by id 37 | let mut service = ctx 38 | .repos 39 | .services 40 | .find(&service.id) 41 | .await 42 | .expect("To get service"); 43 | 44 | let user = User::new(account.id.clone()); 45 | ctx.repos.users.insert(&user).await.unwrap(); 46 | 47 | let timeplan = TimePlan::Empty; 48 | let resource = ServiceResource::new(user.id.clone(), service.id.clone(), timeplan); 49 | assert!(ctx.repos.service_users.insert(&resource).await.is_ok()); 50 | 51 | let mut metadata = Metadata::new(); 52 | metadata.inner.insert("foo".to_string(), "bar".to_string()); 53 | service.metadata = metadata; 54 | ctx.repos 55 | .services 56 | .save(&service) 57 | .await 58 | .expect("To save service"); 59 | 60 | let service = ctx 61 | .repos 62 | .services 63 | .find_with_users(&service.id) 64 | .await 65 | .expect("To get service"); 66 | assert_eq!( 67 | *service.metadata.inner.get("foo").unwrap(), 68 | "bar".to_string() 69 | ); 70 | assert_eq!(service.users.len(), 1); 71 | 72 | ctx.repos 73 | .users 74 | .delete(&user.id) 75 | .await 76 | .expect("To delete user"); 77 | 78 | let service = ctx 79 | .repos 80 | .services 81 | .find_with_users(&service.id) 82 | .await 83 | .expect("To get service"); 84 | assert!(service.users.is_empty()); 85 | 86 | ctx.repos 87 | .services 88 | .delete(&service.id) 89 | .await 90 | .expect("To delete service"); 91 | 92 | assert!(ctx.repos.services.find(&service.id).await.is_none()); 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /scheduler/crates/infra/src/repos/service_user/mod.rs: -------------------------------------------------------------------------------- 1 | mod postgres; 2 | 3 | use nettu_scheduler_domain::{ServiceResource, ID}; 4 | pub use postgres::PostgresServiceUserRepo; 5 | pub use postgres::ServiceUserRaw; 6 | 7 | #[async_trait::async_trait] 8 | pub trait IServiceUserRepo: Send + Sync { 9 | async fn insert(&self, user: &ServiceResource) -> anyhow::Result<()>; 10 | async fn save(&self, user: &ServiceResource) -> anyhow::Result<()>; 11 | async fn find(&self, service_id: &ID, user_id: &ID) -> Option; 12 | async fn find_by_user(&self, user_id: &ID) -> Vec; 13 | async fn delete(&self, service_id: &ID, user_uid: &ID) -> anyhow::Result<()>; 14 | } 15 | 16 | #[cfg(test)] 17 | mod tests { 18 | use crate::setup_context; 19 | use nettu_scheduler_domain::{ 20 | Account, Calendar, Entity, Service, ServiceResource, TimePlan, User, 21 | }; 22 | 23 | #[tokio::test] 24 | async fn crud() { 25 | let ctx = setup_context().await; 26 | let account = Account::default(); 27 | ctx.repos.accounts.insert(&account).await.unwrap(); 28 | let user = User::new(account.id.clone()); 29 | ctx.repos.users.insert(&user).await.unwrap(); 30 | let service = Service::new(account.id.clone()); 31 | ctx.repos.services.insert(&service).await.unwrap(); 32 | 33 | let service_user = 34 | ServiceResource::new(user.id.clone(), service.id.clone(), TimePlan::Empty); 35 | // Insert 36 | assert!(ctx.repos.service_users.insert(&service_user).await.is_ok()); 37 | 38 | // Find 39 | let res = ctx 40 | .repos 41 | .service_users 42 | .find(&service.id, &user.id) 43 | .await 44 | .unwrap(); 45 | assert!(res.eq(&service_user)); 46 | 47 | // Find by user 48 | let find_by_user = ctx.repos.service_users.find_by_user(&user.id).await; 49 | assert_eq!(find_by_user.len(), 1); 50 | assert!(find_by_user[0].eq(&service_user)); 51 | 52 | // Update 53 | let calendar = Calendar::new(&user.id, &account.id); 54 | ctx.repos.calendars.insert(&calendar).await.unwrap(); 55 | 56 | let mut service_user = res; 57 | service_user.buffer_after = 60; 58 | service_user.availability = TimePlan::Calendar(calendar.id.clone()); 59 | assert!(ctx.repos.service_users.save(&service_user).await.is_ok()); 60 | 61 | let updated_service_user = ctx 62 | .repos 63 | .service_users 64 | .find(&service.id, &user.id) 65 | .await 66 | .unwrap(); 67 | assert_eq!(updated_service_user.buffer_after, service_user.buffer_after); 68 | assert_eq!(updated_service_user.user_id, service_user.user_id); 69 | assert_eq!(updated_service_user.service_id, service_user.service_id); 70 | 71 | // Delete 72 | assert!(ctx 73 | .repos 74 | .service_users 75 | .delete(&service.id, &user.id) 76 | .await 77 | .is_ok()); 78 | 79 | // Find after delete 80 | assert!(ctx 81 | .repos 82 | .service_users 83 | .find(&service.id, &user.id) 84 | .await 85 | .is_none()); 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /scheduler/crates/infra/src/repos/shared/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod query_structs; 2 | -------------------------------------------------------------------------------- /scheduler/crates/infra/src/repos/shared/query_structs.rs: -------------------------------------------------------------------------------- 1 | use nettu_scheduler_domain::{Metadata, ID}; 2 | 3 | #[derive(Debug, Clone)] 4 | pub struct MetadataFindQuery { 5 | pub metadata: Metadata, 6 | pub skip: usize, 7 | pub limit: usize, 8 | pub account_id: ID, 9 | } 10 | -------------------------------------------------------------------------------- /scheduler/crates/infra/src/services/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod google_calendar; 2 | pub mod outlook_calendar; 3 | 4 | use nettu_scheduler_domain::IntegrationProvider; 5 | use serde::Deserialize; 6 | 7 | #[derive(Debug)] 8 | pub struct FreeBusyProviderQuery { 9 | pub calendar_ids: Vec, 10 | pub start: i64, 11 | pub end: i64, 12 | } 13 | 14 | // https://docs.microsoft.com/en-us/graph/auth-v2-user#token-request 15 | pub struct CodeTokenRequest { 16 | pub client_id: String, 17 | pub client_secret: String, 18 | pub redirect_uri: String, 19 | pub code: String, 20 | } 21 | 22 | // https://docs.microsoft.com/en-us/graph/auth-v2-user#token-response 23 | #[derive(Debug, Deserialize)] 24 | pub struct CodeTokenResponse { 25 | pub access_token: String, 26 | pub scope: String, 27 | pub token_type: String, 28 | pub expires_in: i64, 29 | pub refresh_token: String, 30 | } 31 | 32 | #[async_trait::async_trait] 33 | pub trait ProviderOAuth { 34 | async fn exchange_code_token(&self, req: CodeTokenRequest) -> Result; 35 | } 36 | 37 | #[async_trait::async_trait] 38 | impl ProviderOAuth for IntegrationProvider { 39 | async fn exchange_code_token(&self, req: CodeTokenRequest) -> Result { 40 | match *self { 41 | Self::Google => google_calendar::auth_provider::exchange_code_token(req).await, 42 | Self::Outlook => outlook_calendar::auth_provider::exchange_code_token(req).await, 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /scheduler/crates/infra/src/services/outlook_calendar/mod.rs: -------------------------------------------------------------------------------- 1 | use crate::NettuContext; 2 | pub mod auth_provider; 3 | mod calendar_api; 4 | 5 | use self::calendar_api::{FreeBusyRequest, ListCalendarsResponse, OutlookCalendarEventAttributes}; 6 | use super::FreeBusyProviderQuery; 7 | use calendar_api::OutlookCalendarRestApi; 8 | use nettu_scheduler_domain::{ 9 | providers::outlook::{OutlookCalendarAccessRole, OutlookCalendarEvent}, 10 | CalendarEvent, CompatibleInstances, User, 11 | }; 12 | 13 | // https://docs.microsoft.com/en-us/graph/api/resources/event?view=graph-rest-1.0 14 | 15 | pub struct OutlookCalendarProvider { 16 | api: OutlookCalendarRestApi, 17 | } 18 | 19 | impl OutlookCalendarProvider { 20 | pub async fn new(user: &User, ctx: &NettuContext) -> Result { 21 | let access_token = match auth_provider::get_access_token(user, ctx).await { 22 | Some(token) => token, 23 | None => return Err(()), 24 | }; 25 | Ok(Self { 26 | api: OutlookCalendarRestApi::new(access_token), 27 | }) 28 | } 29 | 30 | pub async fn freebusy(&self, query: FreeBusyProviderQuery) -> CompatibleInstances { 31 | let body = FreeBusyRequest { 32 | time_min: query.start, 33 | time_max: query.end, 34 | time_zone: "UTC".to_string(), 35 | calendars: query.calendar_ids, 36 | }; 37 | self.api.freebusy(&body).await.unwrap_or_default() 38 | } 39 | 40 | pub async fn create_event( 41 | &self, 42 | calendar_id: String, 43 | event: CalendarEvent, 44 | ) -> Result { 45 | let google_calendar_event: OutlookCalendarEventAttributes = event.into(); 46 | self.api.insert(calendar_id, &google_calendar_event).await 47 | } 48 | 49 | pub async fn update_event( 50 | &self, 51 | calendar_id: String, 52 | event_id: String, 53 | event: CalendarEvent, 54 | ) -> Result { 55 | let google_calendar_event: OutlookCalendarEventAttributes = event.into(); 56 | self.api 57 | .update(calendar_id, event_id, &google_calendar_event) 58 | .await 59 | } 60 | 61 | pub async fn delete_event(&self, calendar_id: String, event_id: String) -> Result<(), ()> { 62 | self.api.remove(calendar_id, event_id).await 63 | } 64 | 65 | pub async fn list( 66 | &self, 67 | min_access_role: OutlookCalendarAccessRole, 68 | ) -> Result { 69 | let mut calendars = self.api.list().await?; 70 | calendars.retain(|cal| match min_access_role { 71 | OutlookCalendarAccessRole::Reader => true, 72 | OutlookCalendarAccessRole::Writer => cal.can_edit, 73 | }); 74 | 75 | Ok(calendars) 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /scheduler/crates/infra/src/system/mod.rs: -------------------------------------------------------------------------------- 1 | use chrono::Utc; 2 | 3 | // Mocking out time so that it is possible to run tests that depend on time. 4 | pub trait ISys: Send + Sync { 5 | /// The current timestamp in millis 6 | fn get_timestamp_millis(&self) -> i64; 7 | } 8 | 9 | /// System that gets the real time and is used when not testing 10 | pub struct RealSys {} 11 | impl ISys for RealSys { 12 | fn get_timestamp_millis(&self) -> i64 { 13 | Utc::now().timestamp_millis() 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /scheduler/crates/utils/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "nettu_scheduler_utils" 3 | description = "Nettu scheduler utils" 4 | license = "MIT" 5 | version = "0.1.0" 6 | authors = ["Fredrik Meringdal"] 7 | edition = "2018" 8 | 9 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 10 | 11 | [dependencies] 12 | rand = "0.8.0" 13 | -------------------------------------------------------------------------------- /scheduler/crates/utils/src/lib.rs: -------------------------------------------------------------------------------- 1 | extern crate rand; 2 | 3 | use rand::Rng; 4 | 5 | const CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ\ 6 | abcdefghijklmnopqrstuvwxyz\ 7 | 0123456789"; 8 | 9 | pub fn create_random_secret(secret_len: usize) -> String { 10 | let mut rng = rand::thread_rng(); 11 | 12 | (0..secret_len) 13 | .map(|_| { 14 | let idx = rng.gen_range(0..CHARSET.len()); 15 | CHARSET[idx] as char 16 | }) 17 | .collect() 18 | } 19 | 20 | #[cfg(test)] 21 | mod tests { 22 | use super::*; 23 | 24 | #[test] 25 | fn it_creates_random_secret() { 26 | let len = 30; 27 | let sec1 = create_random_secret(len); 28 | let sec2 = create_random_secret(len); 29 | assert_eq!(sec1.len(), 30); 30 | assert_eq!(sec2.len(), 30); 31 | assert_ne!(sec2, sec1); 32 | 33 | let len = 47; 34 | assert_eq!(len, create_random_secret(len).len()) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /scheduler/integrations/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.7" 2 | services: 3 | postgres: 4 | image: postgres:13 5 | ports: 6 | - "5432:5432" 7 | volumes: 8 | - postgresdata:/var/lib/postgresql/data 9 | restart: always 10 | environment: 11 | POSTGRES_USER: postgres 12 | POSTGRES_PASSWORD: postgres 13 | POSTGRES_DB: nettuscheduler 14 | # app: 15 | # build: .. 16 | # ports: 17 | # - "5000:5000" 18 | # environment: 19 | # DATABASE_URL: postgresql://postgres:5432/nettuscheduler 20 | # volumes: 21 | # - ../:/var/application 22 | # command: bash -c "cargo watch -x run" 23 | # init: true 24 | # entrypoint: 25 | # - "integrations/wait-for.sh" 26 | # - "postgres:5432" 27 | # - "--" 28 | volumes: 29 | postgresdata: 30 | -------------------------------------------------------------------------------- /scheduler/integrations/wait-for.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | TIMEOUT=15 4 | QUIET=0 5 | 6 | echoerr() { 7 | if [ "$QUIET" -ne 1 ]; then printf "%s\n" "$*" 1>&2; fi 8 | } 9 | 10 | usage() { 11 | exitcode="$1" 12 | cat << USAGE >&2 13 | Usage: 14 | $cmdname host:port [-t timeout] [-- command args] 15 | -q | --quiet Do not output any status messages 16 | -t TIMEOUT | --timeout=timeout Timeout in seconds, zero for no timeout 17 | -- COMMAND ARGS Execute command with args after the test finishes 18 | USAGE 19 | exit "$exitcode" 20 | } 21 | 22 | wait_for() { 23 | for i in `seq $TIMEOUT` ; do 24 | nc -z "$HOST" "$PORT" > /dev/null 2>&1 25 | result=$? 26 | echo "Result $result: port: $PORT" 27 | if [ $result -eq 0 ] ; then 28 | if [ $# -gt 0 ] ; then 29 | exec "$@" 30 | fi 31 | echo "Done: port: $PORT" 32 | exit 0 33 | fi 34 | echo "waiting for port: $PORT" 35 | echo "going to sleep" 36 | sleep 1 37 | done 38 | echo "Operation timed out for $HOST:$PORT" >&2 39 | exit 1 40 | } 41 | 42 | while [ $# -gt 0 ] 43 | do 44 | case "$1" in 45 | *:* ) 46 | HOST=$(printf "%s\n" "$1"| cut -d : -f 1) 47 | PORT=$(printf "%s\n" "$1"| cut -d : -f 2) 48 | shift 1 49 | ;; 50 | -q | --quiet) 51 | QUIET=1 52 | shift 1 53 | ;; 54 | -t) 55 | TIMEOUT="$2" 56 | if [ "$TIMEOUT" = "" ]; then break; fi 57 | shift 2 58 | ;; 59 | --timeout=*) 60 | TIMEOUT="${1#*=}" 61 | shift 1 62 | ;; 63 | --) 64 | shift 65 | break 66 | ;; 67 | --help) 68 | usage 0 69 | ;; 70 | *) 71 | echoerr "Unknown argument: $1" 72 | usage 1 73 | ;; 74 | esac 75 | done 76 | 77 | if [ "$HOST" = "" -o "$PORT" = "" ]; then 78 | echoerr "Error: you need to provide a host and port to test." 79 | usage 2 80 | fi 81 | 82 | wait_for "$@" -------------------------------------------------------------------------------- /scheduler/musl.Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ekidd/rust-musl-builder as builder 2 | 3 | WORKDIR /home/rust/ 4 | 5 | COPY . . 6 | 7 | USER root 8 | RUN chown -R rust:rust . 9 | USER rust 10 | 11 | ENV DATABASE_URL "postgresql://postgres:postgres@172.17.0.1:5432/nettuscheduler" 12 | 13 | # RUN cargo test 14 | RUN cargo build --release 15 | 16 | # Size optimization 17 | RUN strip target/x86_64-unknown-linux-musl/release/nettu_scheduler 18 | 19 | # Start building the final image 20 | FROM scratch 21 | WORKDIR /home/rust/ 22 | COPY --from=builder /home/rust/target/x86_64-unknown-linux-musl/release/nettu_scheduler . 23 | 24 | ENTRYPOINT ["./nettu_scheduler"] 25 | -------------------------------------------------------------------------------- /scheduler/src/bin/migrate.rs: -------------------------------------------------------------------------------- 1 | use nettu_scheduler_infra::run_migration; 2 | 3 | #[actix_web::main] 4 | async fn main() -> () { 5 | run_migration().await.unwrap() 6 | } 7 | -------------------------------------------------------------------------------- /scheduler/src/main.rs: -------------------------------------------------------------------------------- 1 | mod telemetry; 2 | 3 | use nettu_scheduler_api::Application; 4 | use nettu_scheduler_infra::setup_context; 5 | use telemetry::{get_subscriber, init_subscriber}; 6 | 7 | #[actix_web::main] 8 | async fn main() -> std::io::Result<()> { 9 | openssl_probe::init_ssl_cert_env_vars(); 10 | 11 | let subscriber = get_subscriber("nettu_scheduler_server".into(), "info".into()); 12 | init_subscriber(subscriber); 13 | 14 | let context = setup_context().await; 15 | 16 | let app = Application::new(context).await?; 17 | app.start().await 18 | } 19 | -------------------------------------------------------------------------------- /scheduler/src/telemetry.rs: -------------------------------------------------------------------------------- 1 | // Copied from: https://github.com/LukeMathWalker/zero-to-production/blob/main/src/telemetry.rs 2 | 3 | use tracing::subscriber::set_global_default; 4 | use tracing::Subscriber; 5 | use tracing_bunyan_formatter::{BunyanFormattingLayer, JsonStorageLayer}; 6 | use tracing_log::LogTracer; 7 | use tracing_subscriber::{layer::SubscriberExt, EnvFilter, Registry}; 8 | 9 | /// Compose multiple layers into a `tracing`'s subscriber. 10 | /// 11 | /// # Implementation Notes 12 | /// 13 | /// We are using `impl Subscriber` as return type to avoid having to spell out the actual 14 | /// type of the returned subscriber, which is indeed quite complex. 15 | pub fn get_subscriber(name: String, env_filter: String) -> impl Subscriber + Sync + Send { 16 | let env_filter = 17 | EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(env_filter)); 18 | let formatting_layer = BunyanFormattingLayer::new(name, std::io::stdout); 19 | Registry::default() 20 | .with(env_filter) 21 | .with(JsonStorageLayer) 22 | .with(formatting_layer) 23 | } 24 | 25 | /// Register a subscriber as global default to process span data. 26 | /// 27 | /// It should only be called once! 28 | pub fn init_subscriber(subscriber: impl Subscriber + Sync + Send) { 29 | LogTracer::init().expect("Failed to set logger"); 30 | set_global_default(subscriber).expect("Failed to set subscriber"); 31 | } 32 | -------------------------------------------------------------------------------- /scheduler/tests/helpers/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod setup; 2 | pub mod utils; 3 | -------------------------------------------------------------------------------- /scheduler/tests/helpers/setup.rs: -------------------------------------------------------------------------------- 1 | use nettu_scheduler_api::Application; 2 | use nettu_scheduler_infra::{setup_context, Config, NettuContext}; 3 | use nettu_scheduler_sdk::NettuSDK; 4 | 5 | pub struct TestApp { 6 | pub config: Config, 7 | pub ctx: NettuContext, 8 | } 9 | 10 | // Launch the application as a background task 11 | pub async fn spawn_app() -> (TestApp, NettuSDK, String) { 12 | let mut ctx = setup_context().await; 13 | ctx.config.port = 0; // Random port 14 | 15 | let config = ctx.config.clone(); 16 | let context = ctx.clone(); 17 | let application = Application::new(ctx) 18 | .await 19 | .expect("Failed to build application."); 20 | 21 | let address = format!("http://localhost:{}", application.port()); 22 | let _ = actix_web::rt::spawn(async move { 23 | application 24 | .start() 25 | .await 26 | .expect("Expected application to start"); 27 | }); 28 | 29 | let app = TestApp { 30 | config, 31 | ctx: context, 32 | }; 33 | let sdk = NettuSDK::new(address.clone(), ""); 34 | (app, sdk, address) 35 | } 36 | -------------------------------------------------------------------------------- /scheduler/tests/helpers/utils.rs: -------------------------------------------------------------------------------- 1 | use chrono::{DateTime, Utc}; 2 | use nettu_scheduler_sdk::User; 3 | 4 | pub fn format_datetime(dt: &DateTime) -> String { 5 | // https://docs.rs/chrono/0.4.19/chrono/format/strftime/index.html 6 | // 2001-07-08 7 | dt.format("%F").to_string() 8 | } 9 | 10 | pub fn assert_equal_user_lists(users1: &[User], users2: &[User]) { 11 | assert_eq!(users1.len(), users2.len()); 12 | let mut users1 = users1.to_owned(); 13 | users1.sort_by_key(|u| u.id.to_string()); 14 | let mut users2 = users2.to_owned(); 15 | users2.sort_by_key(|u| u.id.to_string()); 16 | for (user1, user2) in users1.iter().zip(users2) { 17 | assert_eq!(user1.id, user2.id); 18 | } 19 | } 20 | --------------------------------------------------------------------------------