From 39bf90994fe7bf793b2870b019c309bee0598337 Mon Sep 17 00:00:00 2001 From: Mel <97147377+MelissaAutumn@users.noreply.github.com> Date: Fri, 17 Nov 2023 10:14:54 -0800 Subject: [PATCH] Zoom Instant Meeting Link Generation (#170) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Implement the Zoom API oauth flow, so we can connect zoom accounts to a subscriber. This also adds an external_connections table, so we can store these types of connections in the future. * Implement creating Zoom meeting links from schedule and appointment booking flow. * 💚 improve checkbox styling and appointment creation fields * 🌐 complete German translation * 💚 improve checkbox styling and schedule creation fields Co-authored-by: Andreas * Clean up some backend linting, and remove a unused requirement. * Moved meeting_link_id and added meeting_link_url to slots, if available they will override an appointment or schedule's location_url. Also swapped meeting_link_provider to be an encrypted string enum (had de-serializing issues with int enums) * Hook up the new envs for stage --------- Co-authored-by: Andreas MĂŒller --- .aws/task-definition.json | 12 ++ backend/.env.example | 158 +++++++------- backend/requirements.txt | 2 + .../controller/{ => apis}/google_client.py | 8 +- .../controller/apis/zoom_client.py | 91 ++++++++ .../src/appointment/controller/calendar.py | 10 +- backend/src/appointment/controller/mailer.py | 18 +- backend/src/appointment/database/models.py | 47 ++++- backend/src/appointment/database/repo.py | 54 ++++- backend/src/appointment/database/schemas.py | 24 ++- backend/src/appointment/dependencies/auth.py | 7 +- .../src/appointment/dependencies/google.py | 2 +- backend/src/appointment/dependencies/zoom.py | 31 +++ backend/src/appointment/main.py | 7 +- ...102-f9c5471478d0_modify_schedules_table.py | 1 - ...96baa7ecd5_create_external_connections_.py | 49 +++++ ...36eef5da9_add_meeting_link_provider_to_.py | 33 +++ ...5d26beef0_add_meeting_link_provider_to_.py | 34 +++ .../versions/2023_11_14_2255-7e426358642e_.py | 20 ++ ...33a37c43c_add_meeting_link_id_to_slots_.py | 33 +++ backend/src/appointment/routes/account.py | 19 ++ backend/src/appointment/routes/api.py | 55 ++++- backend/src/appointment/routes/google.py | 2 +- backend/src/appointment/routes/schedule.py | 70 ++++++- backend/src/appointment/routes/zoom.py | 74 +++++++ backend/src/appointment/secrets.py | 8 + .../email/errors/zoom_invite_failed.jinja2 | 5 + frontend/src/assets/main.css | 196 +++++++++--------- .../src/components/AppointmentCreation.vue | 50 +++-- frontend/src/components/ScheduleCreation.vue | 34 ++- frontend/src/components/SettingsAccount.vue | 48 ++++- frontend/src/definitions.js | 7 + frontend/src/locales/de.json | 9 +- frontend/src/locales/en.json | 9 +- 34 files changed, 1001 insertions(+), 226 deletions(-) rename backend/src/appointment/controller/{ => apis}/google_client.py (96%) create mode 100644 backend/src/appointment/controller/apis/zoom_client.py create mode 100644 backend/src/appointment/dependencies/zoom.py create mode 100644 backend/src/appointment/migrations/versions/2023_11_02_2121-9a96baa7ecd5_create_external_connections_.py create mode 100644 backend/src/appointment/migrations/versions/2023_11_13_2225-d0c36eef5da9_add_meeting_link_provider_to_.py create mode 100644 backend/src/appointment/migrations/versions/2023_11_14_1907-6da5d26beef0_add_meeting_link_provider_to_.py create mode 100644 backend/src/appointment/migrations/versions/2023_11_14_2255-7e426358642e_.py create mode 100644 backend/src/appointment/migrations/versions/2023_11_15_2052-14c33a37c43c_add_meeting_link_id_to_slots_.py create mode 100644 backend/src/appointment/routes/zoom.py create mode 100644 backend/src/appointment/templates/email/errors/zoom_invite_failed.jinja2 diff --git a/.aws/task-definition.json b/.aws/task-definition.json index bace6db52..0b7d3573a 100644 --- a/.aws/task-definition.json +++ b/.aws/task-definition.json @@ -51,6 +51,14 @@ { "name": "SENTRY_DSN", "value": "https://5dddca3ecc964284bb8008bc2beef808@o4505428107853824.ingest.sentry.io/4505428124827648" + }, + { + "name": "ZOOM_API_ENABLED", + "value": true + }, + { + "name": "ZOOM_AUTH_CALLBACK", + "value": "https://appointment.day/zoom/callback" } ], "secrets": [ @@ -73,6 +81,10 @@ { "name": "GOOGLE_OAUTH_SECRETS", "valueFrom": "arn:aws:secretsmanager:us-east-1:768512802988:secret:staging/appointment/google-cal-oauth-VevaSo" + }, + { + "name": "ZOOM_SECRETS", + "valueFrom": "arn:aws:secretsmanager:us-east-1:768512802988:secret:staging/appointment/zoom-S862zi" } ], "mountPoints": [], diff --git a/backend/.env.example b/backend/.env.example index 2e009756e..f4123e2c5 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,74 +1,84 @@ -# Appointment backend configuration. - -# -- GENERAL -- -# Logging level: DEBUG|INFO|WARNING|ERROR|CRITICAL -LOG_LEVEL=ERROR -LOG_USE_STREAM=1 - -# -- FRONTEND -- -FRONTEND_URL=http://localhost:8080 -# Leave blank for no short url -SHORT_BASE_URL= - -# -- DATABASE -- -DATABASE_URL= -DATABASE_SECRETS= -# Secret phrase for database encryption (e.g. create it by running `openssl rand -hex 32`) -DB_SECRET= - -# -- AUTH0 -- -# Management API -AUTH0_API_CLIENT_ID= -AUTH0_API_SECRET= -# Auth API -AUTH0_API_DOMAIN= -AUTH0_API_AUDIENCE= -# Role keys, configurable in Auth0 User Management -> Roles -AUTH0_API_ROLE_ADMIN= -AUTH0_API_ROLE_BASIC= -AUTH0_API_ROLE_PLUS= -AUTH0_API_ROLE_PRO= - -# -- MAIL -- -# Connection security: SSL|STARTTLS|NONE -SMTP_SECURITY=SSL -# Address and port of the SMTP server -SMTP_URL= -SMTP_PORT= -# SMTP user credentials -SMTP_USER= -SMTP_PASS= -# Authorized email address for sending emails -SMTP_SENDER= - -# -- TIERS -- -# Max number of calendars to be simultanously connected for members of the basic tier -TIER_BASIC_CALENDAR_LIMIT=3 -# Max number of calendars to be simultanously connected for members of the plus tier -TIER_PLUS_CALENDAR_LIMIT=5 -# Max number of calendars to be simultanously connected for members of the pro tier -TIER_PRO_CALENDAR_LIMIT=10 - -# -- GOOGLE AUTH -- -GOOGLE_AUTH_CLIENT_ID= -GOOGLE_AUTH_SECRET= -GOOGLE_AUTH_PROJECT_ID= -GOOGLE_AUTH_CALLBACK=http://localhost:5000/google/callback - -# -- SIGNED URL SECRET -- -# Shared secret for url signing (e.g. create it by running `openssl rand -hex 32`) -SIGNED_SECRET= -# If empty, sentry will be disabled -SENTRY_DSN= -# Possible values: prod, dev -APP_ENV=dev - -# -- TESTING -- -AUTH0_TEST_USER= -AUTH0_TEST_PASS= -CALDAV_TEST_PRINCIPAL_URL= -CALDAV_TEST_CALENDAR_URL= -CALDAV_TEST_USER= -CALDAV_TEST_PASS= -GOOGLE_TEST_USER= -GOOGLE_TEST_PASS= +# Appointment backend configuration. + +# -- GENERAL -- +# Logging level: DEBUG|INFO|WARNING|ERROR|CRITICAL +LOG_LEVEL=ERROR +LOG_USE_STREAM=1 + +# -- FRONTEND -- +FRONTEND_URL=http://localhost:8080 +# Leave blank for no short url +SHORT_BASE_URL= + +# -- DATABASE -- +DATABASE_URL= +DATABASE_SECRETS= +# Secret phrase for database encryption (e.g. create it by running `openssl rand -hex 32`) +DB_SECRET= + +# -- AUTH0 -- +# Management API +AUTH0_API_CLIENT_ID= +AUTH0_API_SECRET= +# Auth API +AUTH0_API_DOMAIN= +AUTH0_API_AUDIENCE= +# Role keys, configurable in Auth0 User Management -> Roles +AUTH0_API_ROLE_ADMIN= +AUTH0_API_ROLE_BASIC= +AUTH0_API_ROLE_PLUS= +AUTH0_API_ROLE_PRO= + +# -- MAIL -- + +# Service email for emails on behalf of Thunderbird Appointment +SERVICE_EMAIL=no-reply@appointment.day + +# Connection security: SSL|STARTTLS|NONE +SMTP_SECURITY=SSL +# Address and port of the SMTP server +SMTP_URL= +SMTP_PORT= +# SMTP user credentials +SMTP_USER= +SMTP_PASS= +# Authorized email address for sending emails, leave empty to default to organizer +SMTP_SENDER= + +# -- TIERS -- +# Max number of calendars to be simultanously connected for members of the basic tier +TIER_BASIC_CALENDAR_LIMIT=3 +# Max number of calendars to be simultanously connected for members of the plus tier +TIER_PLUS_CALENDAR_LIMIT=5 +# Max number of calendars to be simultanously connected for members of the pro tier +TIER_PRO_CALENDAR_LIMIT=10 + +# -- GOOGLE AUTH -- +GOOGLE_AUTH_CLIENT_ID= +GOOGLE_AUTH_SECRET= +GOOGLE_AUTH_PROJECT_ID= +GOOGLE_AUTH_CALLBACK=http://localhost:5000/google/callback + +# -- Zoom API -- +ZOOM_API_ENABLED=False +ZOOM_AUTH_CLIENT_ID= +ZOOM_AUTH_SECRET= +ZOOM_AUTH_CALLBACK=http://localhost:8090/zoom/callback + +# -- SIGNED URL SECRET -- +# Shared secret for url signing (e.g. create it by running `openssl rand -hex 32`) +SIGNED_SECRET= +# If empty, sentry will be disabled +SENTRY_DSN= +# Possible values: prod, dev +APP_ENV=dev + +# -- TESTING -- +AUTH0_TEST_USER= +AUTH0_TEST_PASS= +CALDAV_TEST_PRINCIPAL_URL= +CALDAV_TEST_CALENDAR_URL= +CALDAV_TEST_USER= +CALDAV_TEST_PASS= +GOOGLE_TEST_USER= +GOOGLE_TEST_PASS= diff --git a/backend/requirements.txt b/backend/requirements.txt index 245af1f13..ec79985e2 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -17,3 +17,5 @@ sqlalchemy-utils==0.39.0 sqlalchemy==1.4.40 uvicorn==0.20.0 validators==0.20.0 +oauthlib==3.2.2 +requests-oauthlib==1.3.1 diff --git a/backend/src/appointment/controller/google_client.py b/backend/src/appointment/controller/apis/google_client.py similarity index 96% rename from backend/src/appointment/controller/google_client.py rename to backend/src/appointment/controller/apis/google_client.py index e96507858..157961937 100644 --- a/backend/src/appointment/controller/google_client.py +++ b/backend/src/appointment/controller/apis/google_client.py @@ -4,10 +4,10 @@ from google_auth_oauthlib.flow import Flow from googleapiclient.discovery import build from googleapiclient.errors import HttpError -from ..database import repo -from ..database.models import CalendarProvider -from ..database.schemas import CalendarConnection -from ..exceptions.google_api import GoogleScopeChanged, GoogleInvalidCredentials +from ...database import repo +from ...database.models import CalendarProvider +from ...database.schemas import CalendarConnection +from ...exceptions.google_api import GoogleScopeChanged, GoogleInvalidCredentials class GoogleClient: diff --git a/backend/src/appointment/controller/apis/zoom_client.py b/backend/src/appointment/controller/apis/zoom_client.py new file mode 100644 index 000000000..79130a31f --- /dev/null +++ b/backend/src/appointment/controller/apis/zoom_client.py @@ -0,0 +1,91 @@ +import json + +from requests_oauthlib import OAuth2Session +from ...database import models, repo +from ...database.database import SessionLocal + + +class ZoomClient: + OAUTH_AUTHORIZATION_URL = "https://zoom.us/oauth/authorize" + OAUTH_DEVICE_AUTHORIZATION_URL = "https://zoom.us/oauth/devicecode" + OAUTH_TOKEN_URL = "https://zoom.us/oauth/token" + OAUTH_DEVICE_VERIFY_URL = "https://zoom.us/oauth_device" + OAUTH_REQUEST_URL = "https://api.zoom.us/v2" + + SCOPES = [ + "user:read", + "user_info:read", + "meeting:write" + ] + + client: OAuth2Session | None = None + subscriber_id: int | None = None + + def __init__(self, client_id, client_secret, callback_url): + self.client_id = client_id + self.client_secret = client_secret + self.callback_url = callback_url + self.subscriber_id = None + self.client = None + + def setup(self, subscriber_id=None, token=None): + """Setup our oAuth session""" + if type(token) is str: + token = json.loads(token) + + self.subscriber_id = subscriber_id + self.client = OAuth2Session(self.client_id, redirect_uri=self.callback_url, scope=self.SCOPES, + auto_refresh_url=self.OAUTH_TOKEN_URL, + auto_refresh_kwargs={"client_id": self.client_id, "client_secret": self.client_secret}, + token=token, + token_updater=self.token_saver) + + pass + + def get_redirect_url(self, state): + url, state = self.client.authorization_url(self.OAUTH_AUTHORIZATION_URL, state=state) + + return url, state + + def get_credentials(self, code: str): + return self.client.fetch_token(self.OAUTH_TOKEN_URL, code, client_secret=self.client_secret, include_client_id=True) + + def token_saver(self, token): + """requests-oauth automagically calls this function when it has a new refresh token for us. + This makes it a bit awkward but we make it work...""" + self.client.token = token + + # Need a subscriber attached to this request in order to save a token + if self.subscriber_id is None: + return + + with SessionLocal() as db: + repo.update_subscriber_external_connection_token(db, json.dumps(token), self.subscriber_id, models.ExternalConnectionType.zoom) + + def get_me(self): + return self.client.get(f'{self.OAUTH_REQUEST_URL}/users/me').json() + + def create_meeting(self, title, start_time, duration, timezone = None): + # https://developers.zoom.us/docs/api/rest/reference/zoom-api/methods/#operation/meetingCreate + + response = self.client.post(f'{self.OAUTH_REQUEST_URL}/users/me/meetings', json={ + 'type': 2, # Scheduled Meeting + 'default_password': True, + 'duration': duration, + 'start_time': f"{start_time}Z", # Make it UTC + 'topic': title[:200], # Max 200 chars + 'settings': { + 'private_meeting': True, + 'registrants_confirmation_email': False, + 'registrants_email_notification': False, + } + }) + + response.raise_for_status() + + return response.json() + + def get_meeting(self, meeting_id): + response = self.client.get(f'{self.OAUTH_REQUEST_URL}/meetings/{meeting_id}') + response.raise_for_status() + return response.json() diff --git a/backend/src/appointment/controller/calendar.py b/backend/src/appointment/controller/calendar.py index d3c2d3688..063268237 100644 --- a/backend/src/appointment/controller/calendar.py +++ b/backend/src/appointment/controller/calendar.py @@ -3,14 +3,13 @@ Handle connection to a CalDAV server. """ import json -import logging from caldav import DAVClient from google.oauth2.credentials import Credentials from icalendar import Calendar, Event, vCalAddress, vText from datetime import datetime, timedelta, timezone from dateutil.parser import parse -from .google_client import GoogleClient +from .apis.google_client import GoogleClient from ..database import schemas from ..database.models import CalendarProvider from ..controller.mailer import Attachment, InvitationMail @@ -264,6 +263,13 @@ def create_vevent( event.add("dtstamp", datetime.utcnow()) event["description"] = appointment.details event["organizer"] = org + + # Prefer the slot meeting link url over the appointment location url + location_url = slot.meeting_link_url if slot.meeting_link_url is not None else appointment.location_url + + if location_url != "" or location_url is not None: + event.add('location', location_url) + cal.add_component(event) return cal.to_ical() diff --git a/backend/src/appointment/controller/mailer.py b/backend/src/appointment/controller/mailer.py index 3d023cc75..a03b72f87 100644 --- a/backend/src/appointment/controller/mailer.py +++ b/backend/src/appointment/controller/mailer.py @@ -10,7 +10,6 @@ import jinja2 import validators -from datetime import datetime from html import escape from email import encoders from email.mime.base import MIMEBase @@ -139,6 +138,23 @@ def html(self): return get_template("invite.jinja2").render() +class ZoomMeetingFailedMail(Mailer): + def __init__(self, appointment_title, *args, **kwargs): + """init Mailer with invitation specific defaults""" + defaultKwargs = { + "subject": "[TBA] Zoom Meeting Link Creation Error", + } + super(ZoomMeetingFailedMail, self).__init__(*args, **defaultKwargs, **kwargs) + + self.appointment_title = appointment_title + + def html(self): + return get_template("errors/zoom_invite_failed.jinja2").render(title=self.appointment_title) + + def text(self): + return f"Unfortunately there was an error creating your Zoom meeting for your upcoming appointment: {self.appointment_title}" + + class ConfirmationMail(Mailer): def __init__(self, confirmUrl, denyUrl, attendee, date, *args, **kwargs): """init Mailer with confirmation specific defaults""" diff --git a/backend/src/appointment/database/models.py b/backend/src/appointment/database/models.py index 518073440..c4a2cb79d 100644 --- a/backend/src/appointment/database/models.py +++ b/backend/src/appointment/database/models.py @@ -6,7 +6,7 @@ import os import uuid from sqlalchemy import Column, ForeignKey, Integer, String, DateTime, Enum, Boolean, JSON, Date, Time -from sqlalchemy_utils import StringEncryptedType +from sqlalchemy_utils import StringEncryptedType, ChoiceType from sqlalchemy_utils.types.encrypted.encrypted_type import AesEngine from sqlalchemy.orm import relationship from sqlalchemy.sql import func @@ -61,6 +61,17 @@ class DayOfWeek(enum.Enum): Sunday = 7 +class ExternalConnectionType(enum.Enum): + zoom = 1 + google = 2 + + +class MeetingLinkProviderType(enum.StrEnum): + none = 'none' + zoom = 'zoom' + google_meet = 'google_meet' + + class Subscriber(Base): __tablename__ = "subscribers" @@ -78,6 +89,11 @@ class Subscriber(Base): calendars = relationship("Calendar", cascade="all,delete", back_populates="owner") slots = relationship("Slot", cascade="all,delete", back_populates="subscriber") + external_connections = relationship("ExternalConnections", cascade="all,delete", back_populates="owner") + + def get_external_connection(self, type: ExternalConnectionType): + """Retrieves the first found external connection by type or returns None if not found""" + return next(filter(lambda ec: ec.type == type, self.external_connections), None) class Calendar(Base): @@ -119,6 +135,9 @@ class Appointment(Base): keep_open = Column(Boolean) status = Column(Enum(AppointmentStatus), default=AppointmentStatus.draft) + # What (if any) meeting link will we generate once the meeting is booked + meeting_link_provider = Column(StringEncryptedType(ChoiceType(MeetingLinkProviderType), secret, AesEngine, "pkcs5", length=255), default=MeetingLinkProviderType.none, index=False) + calendar = relationship("Calendar", back_populates="appointments") slots = relationship("Slot", cascade="all,delete", back_populates="appointment") @@ -144,6 +163,12 @@ class Slot(Base): time_updated = Column(DateTime, server_default=func.now(), onupdate=func.now()) start = Column(DateTime) duration = Column(Integer) + + # provider specific id we can use to query against their service + meeting_link_id = Column(StringEncryptedType(String, secret, AesEngine, "pkcs5", length=1024), index=False) + # meeting link override for a appointment or schedule's location url + meeting_link_url = Column(StringEncryptedType(String, secret, AesEngine, "pkcs5", length=2048)) + # columns for availability bookings booking_tkn = Column(StringEncryptedType(String, secret, AesEngine, "pkcs5", length=512), index=False) booking_expires_at = Column(DateTime) @@ -151,6 +176,7 @@ class Slot(Base): appointment = relationship("Appointment", back_populates="slots") schedule = relationship("Schedule", back_populates="slots") + attendee = relationship("Attendee", cascade="all,delete", back_populates="slots") subscriber = relationship("Subscriber", back_populates="slots") @@ -176,6 +202,9 @@ class Schedule(Base): time_created = Column(DateTime, server_default=func.now()) time_updated = Column(DateTime, server_default=func.now(), onupdate=func.now()) + # What (if any) meeting link will we generate once the meeting is booked + meeting_link_provider = Column(StringEncryptedType(ChoiceType(MeetingLinkProviderType), secret, AesEngine, "pkcs5", length=255), default=MeetingLinkProviderType.none, index=False) + calendar = relationship("Calendar", back_populates="schedules") availabilities = relationship("Availability", cascade="all,delete", back_populates="schedule") slots = relationship("Slot", cascade="all,delete", back_populates="schedule") @@ -200,3 +229,19 @@ class Availability(Base): time_updated = Column(DateTime, server_default=func.now(), onupdate=func.now()) schedule = relationship("Schedule", back_populates="availabilities") + + +class ExternalConnections(Base): + """This table holds all external service connections to a subscriber.""" + __tablename__ = "external_connections" + + id = Column(Integer, primary_key=True, index=True) + owner_id = Column(Integer, ForeignKey("subscribers.id")) + name = Column(StringEncryptedType(String, secret, AesEngine, "pkcs5", length=255), index=False) + type = Column(Enum(ExternalConnectionType), index=True) + type_id = Column(StringEncryptedType(String, secret, AesEngine, "pkcs5", length=255), index=True) + token = Column(StringEncryptedType(String, secret, AesEngine, "pkcs5", length=2048), index=False) + time_created = Column(DateTime, server_default=func.now()) + time_updated = Column(DateTime, server_default=func.now(), onupdate=func.now()) + + owner = relationship("Subscriber", back_populates="external_connections") diff --git a/backend/src/appointment/database/repo.py b/backend/src/appointment/database/repo.py index ccdaca25c..8aab41897 100644 --- a/backend/src/appointment/database/repo.py +++ b/backend/src/appointment/database/repo.py @@ -4,12 +4,12 @@ """ import os import re -import logging from datetime import timedelta, datetime from fastapi import HTTPException from sqlalchemy.orm import Session from . import models, schemas +from .schemas import ExternalConnection from ..controller.auth import sign_url @@ -569,3 +569,55 @@ def schedule_has_slot(db: Session, schedule_id: int, slot_id: int): """check if slot belongs to schedule""" db_slot = get_slot(db, slot_id) return db_slot and db_slot.schedule_id == schedule_id + + +"""External Connections repository functions""" + + +def create_subscriber_external_connection(db: Session, external_connection: ExternalConnection): + db_external_connection = models.ExternalConnections( + **external_connection.dict() + ) + db.add(db_external_connection) + db.commit() + db.refresh(db_external_connection) + return db_external_connection + + +def update_subscriber_external_connection_token(db: Session, token: str, subscriber_id: int, type: models.ExternalConnectionType, type_id: str | None = None): + db_results = get_external_connections_by_type(db, subscriber_id, type, type_id) + if db_results is None or len(db_results) == 0: + return None + + db_external_connection = db_results[0] + db_external_connection.token = token + db.commit() + db.refresh(db_external_connection) + return db_external_connection + + +def delete_external_connections_by_type_id(db: Session, subscriber_id: int, type: models.ExternalConnectionType, type_id: str): + connections = get_external_connections_by_type(db, subscriber_id, type, type_id) + + # There should be one by type id, but just in case.. + for connection in connections: + db.delete(connection) + db.commit() + + return True + + +def get_external_connections_by_type(db: Session, subscriber_id: int, type: models.ExternalConnectionType, type_id: str | None): + """Return a subscribers external connections by type, and optionally type id""" + query = ( + db.query(models.ExternalConnections) + .filter(models.ExternalConnections.owner_id == subscriber_id) + .filter(models.ExternalConnections.type == type) + ) + + if type_id is not None: + query = query.filter(models.ExternalConnections.type_id == type_id) + + result = query.all() + + return result diff --git a/backend/src/appointment/database/schemas.py b/backend/src/appointment/database/schemas.py index 483af9d41..806146a0b 100644 --- a/backend/src/appointment/database/schemas.py +++ b/backend/src/appointment/database/schemas.py @@ -12,6 +12,8 @@ LocationType, random_slug, SubscriberLevel, + ExternalConnectionType, + MeetingLinkProviderType, ) @@ -42,6 +44,8 @@ class SlotBase(BaseModel): booking_tkn: str | None = None booking_expires_at: datetime | None = None booking_status: BookingStatus | None = BookingStatus.none + meeting_link_id: str | None = None + meeting_link_url: str | None = None class Slot(SlotBase): @@ -77,6 +81,8 @@ class AppointmentBase(BaseModel): title: str details: str | None = None slug: str | None = Field(default_factory=random_slug) + # Needed for ical creation + location_url: str | None = None class AppointmentFull(AppointmentBase): @@ -86,10 +92,10 @@ class AppointmentFull(AppointmentBase): location_suggestions: str | None = None location_selected: str | None = None location_name: str | None = None - location_url: str | None = None location_phone: str | None = None keep_open: bool | None = True status: AppointmentStatus | None = AppointmentStatus.draft + meeting_link_provider: MeetingLinkProviderType | None = MeetingLinkProviderType.none class Appointment(AppointmentFull): @@ -145,6 +151,7 @@ class ScheduleBase(BaseModel): farthest_booking: int | None = None weekdays: list[int] | None = [1, 2, 3, 4, 5] slot_duration: int | None = None + meeting_link_provider: MeetingLinkProviderType | None = MeetingLinkProviderType.none class Config: json_encoders = { @@ -268,3 +275,18 @@ class FileDownload(BaseModel): name: str content_type: str data: str + + +class ExternalConnection(BaseModel): + owner_id: int + name: str + type: ExternalConnectionType + type_id: str + token: str + + +class ExternalConnectionOut(BaseModel): + owner_id: int + name: str + type: str + type_id: str diff --git a/backend/src/appointment/dependencies/auth.py b/backend/src/appointment/dependencies/auth.py index e370ac9f7..8a0febb65 100644 --- a/backend/src/appointment/dependencies/auth.py +++ b/backend/src/appointment/dependencies/auth.py @@ -1,4 +1,4 @@ -from fastapi import Depends, Security +from fastapi import Depends, Security, Request from fastapi_auth0 import Auth0User from sqlalchemy.orm import Session @@ -11,9 +11,12 @@ def get_subscriber( + request: Request, db: Session = Depends(get_db), _=Depends(auth.auth0.implicit_scheme), user: Auth0User = Security(auth.auth0.get_user), ): """Automatically retrieve and return the subscriber based on the authenticated Auth0 user""" - return repo.get_subscriber_by_email(db, user.email) + user = repo.get_subscriber_by_email(db, user.email) + + return user diff --git a/backend/src/appointment/dependencies/google.py b/backend/src/appointment/dependencies/google.py index 993af213b..95c37ac79 100644 --- a/backend/src/appointment/dependencies/google.py +++ b/backend/src/appointment/dependencies/google.py @@ -1,7 +1,7 @@ import logging import os -from ..controller.google_client import GoogleClient +from ..controller.apis.google_client import GoogleClient _google_client = GoogleClient( diff --git a/backend/src/appointment/dependencies/zoom.py b/backend/src/appointment/dependencies/zoom.py new file mode 100644 index 000000000..852ce07c7 --- /dev/null +++ b/backend/src/appointment/dependencies/zoom.py @@ -0,0 +1,31 @@ +import logging +import os + +from fastapi import Depends + +from .auth import get_subscriber +from ..controller.apis.zoom_client import ZoomClient +from ..database.models import Subscriber, ExternalConnectionType + + +def get_zoom_client( + subscriber: Subscriber = Depends(get_subscriber) +): + """Returns a zoom client instance. This is a stateful dependency, and requires a new instance per request""" + try: + _zoom_client = ZoomClient( + os.getenv("ZOOM_AUTH_CLIENT_ID"), + os.getenv("ZOOM_AUTH_SECRET"), + os.getenv("ZOOM_AUTH_CALLBACK") + ) + + # Grab our zoom connection if it's available, we only support one zoom connection...hopefully + zoom_connection = subscriber.get_external_connection(ExternalConnectionType.zoom) + token = zoom_connection.token if zoom_connection is not None else None + + _zoom_client.setup(subscriber.id, token) + except Exception as e: + logging.error(f"[routes.zoom] Zoom Client could not be setup, bad credentials?\nError: {str(e)}") + raise e + + return _zoom_client diff --git a/backend/src/appointment/main.py b/backend/src/appointment/main.py index 16af8ebb9..6fb4d0e64 100644 --- a/backend/src/appointment/main.py +++ b/backend/src/appointment/main.py @@ -32,8 +32,7 @@ # init logging level = os.getenv("LOG_LEVEL", "ERROR") use_log_stream = os.getenv("LOG_USE_STREAM", False) -# TODO: limit log file size -# https://docs.python.org/3/library/logging.handlers.html#rotatingfilehandler + log_config = { "format": "%(asctime)s %(levelname)-8s %(message)s", "level": getattr(logging, level), @@ -68,6 +67,7 @@ def server(): from .routes import account from .routes import google from .routes import schedule + from .routes import zoom # init app app = FastAPI() @@ -87,6 +87,7 @@ def server(): allow_headers=["*"], ) + @app.exception_handler(RefreshError) async def catch_google_refresh_errors(request, exc): """Catch google refresh errors, and use our error instead.""" @@ -97,6 +98,8 @@ async def catch_google_refresh_errors(request, exc): app.include_router(account.router, prefix="/account") app.include_router(google.router, prefix="/google") app.include_router(schedule.router, prefix="/schedule") + if os.getenv("ZOOM_API_ENABLED"): + app.include_router(zoom.router, prefix="/zoom") return app diff --git a/backend/src/appointment/migrations/versions/2023_07_27_1102-f9c5471478d0_modify_schedules_table.py b/backend/src/appointment/migrations/versions/2023_07_27_1102-f9c5471478d0_modify_schedules_table.py index 5db3e001a..de9c3ecc0 100644 --- a/backend/src/appointment/migrations/versions/2023_07_27_1102-f9c5471478d0_modify_schedules_table.py +++ b/backend/src/appointment/migrations/versions/2023_07_27_1102-f9c5471478d0_modify_schedules_table.py @@ -8,7 +8,6 @@ import os from alembic import op import sqlalchemy as sa -from sqlalchemy import DateTime from sqlalchemy_utils import StringEncryptedType from sqlalchemy_utils.types.encrypted.encrypted_type import AesEngine from database.models import LocationType diff --git a/backend/src/appointment/migrations/versions/2023_11_02_2121-9a96baa7ecd5_create_external_connections_.py b/backend/src/appointment/migrations/versions/2023_11_02_2121-9a96baa7ecd5_create_external_connections_.py new file mode 100644 index 000000000..d0be44707 --- /dev/null +++ b/backend/src/appointment/migrations/versions/2023_11_02_2121-9a96baa7ecd5_create_external_connections_.py @@ -0,0 +1,49 @@ +"""create external_connections table + +Revision ID: 9a96baa7ecd5 +Revises: 3789c9fd57c5 +Create Date: 2023-11-02 21:21:24.792951 + +""" +import os + +from alembic import op +import sqlalchemy as sa +from database.models import ExternalConnectionType +from sqlalchemy import func, ForeignKey +from sqlalchemy_utils import StringEncryptedType +from sqlalchemy_utils.types.encrypted.encrypted_type import AesEngine + + +def secret(): + return os.getenv("DB_SECRET") + + +# revision identifiers, used by Alembic. +revision = '9a96baa7ecd5' +down_revision = '3789c9fd57c5' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table('external_connections', + sa.Column('id', sa.Integer, primary_key=True, index=True), + sa.Column('owner_id', sa.Integer, ForeignKey("subscribers.id")), + sa.Column('name', + StringEncryptedType(sa.String, secret, AesEngine, "pkcs5", length=255), + index=False), + sa.Column('type', sa.Enum(ExternalConnectionType), index=True), + sa.Column('type_id', + StringEncryptedType(sa.String, secret, AesEngine, "pkcs5", length=255), + index=True), + sa.Column('token', + StringEncryptedType(sa.String, secret, AesEngine, "pkcs5", length=2048), + index=False), + sa.Column('time_created', sa.DateTime, server_default=func.now()), + sa.Column('time_updated', sa.DateTime, server_default=func.now()), + ) + + +def downgrade() -> None: + op.drop_table('external_connections') diff --git a/backend/src/appointment/migrations/versions/2023_11_13_2225-d0c36eef5da9_add_meeting_link_provider_to_.py b/backend/src/appointment/migrations/versions/2023_11_13_2225-d0c36eef5da9_add_meeting_link_provider_to_.py new file mode 100644 index 000000000..c086d9103 --- /dev/null +++ b/backend/src/appointment/migrations/versions/2023_11_13_2225-d0c36eef5da9_add_meeting_link_provider_to_.py @@ -0,0 +1,33 @@ +"""add meeting_link_provider to appointment table + +Revision ID: d0c36eef5da9 +Revises: 9a96baa7ecd5 +Create Date: 2023-11-13 22:25:05.485397 + +""" +import os + +from alembic import op +import sqlalchemy as sa + +from sqlalchemy_utils import StringEncryptedType, ChoiceType +from sqlalchemy_utils.types.encrypted.encrypted_type import AesEngine + +from database.models import MeetingLinkProviderType + + +def secret(): + return os.getenv("DB_SECRET") + +# revision identifiers, used by Alembic. +revision = 'd0c36eef5da9' +down_revision = '9a96baa7ecd5' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column('appointments', sa.Column("meeting_link_provider", StringEncryptedType(ChoiceType(MeetingLinkProviderType), secret, AesEngine, "pkcs5", length=255), index=False)) + +def downgrade() -> None: + op.drop_column('appointments', 'meeting_link_provider') diff --git a/backend/src/appointment/migrations/versions/2023_11_14_1907-6da5d26beef0_add_meeting_link_provider_to_.py b/backend/src/appointment/migrations/versions/2023_11_14_1907-6da5d26beef0_add_meeting_link_provider_to_.py new file mode 100644 index 000000000..f35d81c46 --- /dev/null +++ b/backend/src/appointment/migrations/versions/2023_11_14_1907-6da5d26beef0_add_meeting_link_provider_to_.py @@ -0,0 +1,34 @@ +"""add meeting_link_provider to schedules table + +Revision ID: 6da5d26beef0 +Revises: d0c36eef5da9 +Create Date: 2023-11-14 19:07:56.496112 + +""" +import os +from alembic import op +import sqlalchemy as sa + + +from sqlalchemy_utils import StringEncryptedType, ChoiceType +from sqlalchemy_utils.types.encrypted.encrypted_type import AesEngine + +from database.models import MeetingLinkProviderType + + +def secret(): + return os.getenv("DB_SECRET") + +# revision identifiers, used by Alembic. +revision = '6da5d26beef0' +down_revision = 'd0c36eef5da9' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column('schedules', sa.Column("meeting_link_provider", StringEncryptedType(ChoiceType(MeetingLinkProviderType), secret, AesEngine, "pkcs5", length=255), index=False)) + + +def downgrade() -> None: + op.drop_column('schedules', 'meeting_link_provider') diff --git a/backend/src/appointment/migrations/versions/2023_11_14_2255-7e426358642e_.py b/backend/src/appointment/migrations/versions/2023_11_14_2255-7e426358642e_.py new file mode 100644 index 000000000..5e0ea639d --- /dev/null +++ b/backend/src/appointment/migrations/versions/2023_11_14_2255-7e426358642e_.py @@ -0,0 +1,20 @@ +"""Merge migration + +Revision ID: 7e426358642e +Revises: 2b1d96fb4058, 6da5d26beef0 +Create Date: 2023-11-14 22:55:24.387190 + +""" +# revision identifiers, used by Alembic. +revision = '7e426358642e' +down_revision = ('2b1d96fb4058', '6da5d26beef0') +branch_labels = None +depends_on = None + + +def upgrade() -> None: + pass + + +def downgrade() -> None: + pass diff --git a/backend/src/appointment/migrations/versions/2023_11_15_2052-14c33a37c43c_add_meeting_link_id_to_slots_.py b/backend/src/appointment/migrations/versions/2023_11_15_2052-14c33a37c43c_add_meeting_link_id_to_slots_.py new file mode 100644 index 000000000..fb56d7b0c --- /dev/null +++ b/backend/src/appointment/migrations/versions/2023_11_15_2052-14c33a37c43c_add_meeting_link_id_to_slots_.py @@ -0,0 +1,33 @@ +"""add meeting_link_id to slots table + +Revision ID: 14c33a37c43c +Revises: 7e426358642e +Create Date: 2023-11-15 20:52:50.545477 + +""" +import os +from alembic import op +import sqlalchemy as sa +from sqlalchemy_utils import StringEncryptedType +from sqlalchemy_utils.types.encrypted.encrypted_type import AesEngine + + +def secret(): + return os.getenv("DB_SECRET") + +# revision identifiers, used by Alembic. +revision = '14c33a37c43c' +down_revision = '7e426358642e' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column('slots', sa.Column('meeting_link_id', StringEncryptedType(sa.String, secret, AesEngine, "pkcs5", length=1024), index=False)) + # A location_url override for generated meeting link urls + op.add_column('slots', sa.Column('meeting_link_url', StringEncryptedType(sa.String, secret, AesEngine, "pkcs5", length=2048), index=False)) + + +def downgrade() -> None: + op.drop_column('slots', 'meeting_link_id') + op.drop_column('slots', 'meeting_link_url') diff --git a/backend/src/appointment/routes/account.py b/backend/src/appointment/routes/account.py index 4dd2cc21e..b41630f87 100644 --- a/backend/src/appointment/routes/account.py +++ b/backend/src/appointment/routes/account.py @@ -1,3 +1,6 @@ +import os +from collections import defaultdict + from fastapi import APIRouter, Depends, HTTPException from ..controller import data @@ -7,6 +10,7 @@ from ..dependencies.database import get_db from ..database.models import Subscriber +from ..database import schemas from fastapi.responses import StreamingResponse @@ -15,6 +19,21 @@ router = APIRouter() +@router.get("/external-connections") +def get_external_connections(subscriber: Subscriber = Depends(get_subscriber)): + # This could be moved to a helper function in the future + # Create a list of supported external connections + external_connections = defaultdict(list) + + if os.getenv('ZOOM_API_ENABLED'): + external_connections['Zoom'] = [] + + for ec in subscriber.external_connections: + external_connections[ec.type.name].append(schemas.ExternalConnectionOut(owner_id=ec.owner_id, type=ec.type.name, + type_id=ec.type_id, name=ec.name)) + + return external_connections + @router.get("/download") def download_data(db: Session = Depends(get_db), subscriber: Subscriber = Depends(get_subscriber)): """Download your account data in zip format! Returns a streaming response with the zip buffer.""" diff --git a/backend/src/appointment/routes/api.py b/backend/src/appointment/routes/api.py index 353461c82..ed4f21782 100644 --- a/backend/src/appointment/routes/api.py +++ b/backend/src/appointment/routes/api.py @@ -1,10 +1,16 @@ +import logging import os import secrets import validators +from requests import HTTPError +from sentry_sdk import capture_exception +from sqlalchemy.exc import SQLAlchemyError # database from sqlalchemy.orm import Session + +from ..controller.mailer import ZoomMeetingFailedMail from ..database import repo, schemas # authentication @@ -13,12 +19,13 @@ from fastapi import APIRouter, Depends, HTTPException, Security, Body from fastapi_auth0 import Auth0User from datetime import timedelta, timezone -from ..controller.google_client import GoogleClient +from ..controller.apis.google_client import GoogleClient from ..controller.auth import signed_url_by_subscriber -from ..database.models import Subscriber, CalendarProvider +from ..database.models import Subscriber, CalendarProvider, MeetingLinkProviderType, ExternalConnectionType from ..dependencies.google import get_google_client from ..dependencies.auth import get_subscriber, auth from ..dependencies.database import get_db +from ..dependencies.zoom import get_zoom_client router = APIRouter() @@ -294,6 +301,8 @@ def create_my_calendar_appointment( raise HTTPException(status_code=403, detail="Calendar not owned by subscriber") if not repo.calendar_is_connected(db, calendar_id=a_s.appointment.calendar_id): raise HTTPException(status_code=403, detail="Calendar connection is not active") + if a_s.appointment.meeting_link_provider == MeetingLinkProviderType.zoom and subscriber.get_external_connection(ExternalConnectionType.zoom) is None: + raise HTTPException(status_code=400, detail="You need a connected Zoom account in order to create a meeting link") return repo.create_calendar_appointment(db=db, appointment=a_s.appointment, slots=a_s.slots) @@ -364,6 +373,7 @@ def update_public_appointment_slot( s_a: schemas.SlotAttendee, db: Session = Depends(get_db), google_client: GoogleClient = Depends(get_google_client), + subscriber: Subscriber = Depends(get_subscriber) ): """endpoint to update a time slot for an appointment via public link and create an event in remote calendar""" db_appointment = repo.get_public_appointment(db, slug=slug) @@ -378,7 +388,43 @@ def update_public_appointment_slot( raise HTTPException(status_code=403, detail="Time slot not available anymore") if not validators.email(s_a.attendee.email): raise HTTPException(status_code=400, detail="No valid email provided") + slot = repo.get_slot(db=db, slot_id=s_a.slot_id) + + # grab the subscriber + organizer = repo.get_subscriber_by_appointment(db=db, appointment_id=db_appointment.id) + + location_url = db_appointment.location_url + + if db_appointment.meeting_link_provider == MeetingLinkProviderType.zoom: + try: + zoom_client = get_zoom_client(subscriber) + response = zoom_client.create_meeting(db_appointment.title, slot.start.isoformat(), slot.duration, subscriber.timezone) + if 'id' in response: + slot.meeting_link_url = zoom_client.get_meeting(response['id'])['join_url'] + slot.meeting_link_id = response['id'] + + location_url = slot.meeting_link_url + + # TODO: If we move to a model-based db functions replace this with a .save() + # Save the updated slot information + db.add(slot) + db.commit() + except HTTPError as err: # Not fatal, just a bummer + logging.error("Zoom meeting creation error: ", err) + + # Ensure sentry captures the error too! + if os.getenv('SENTRY_DSN') != '': + capture_exception(err) + + # Notify the organizer that the meeting link could not be created! + mail = ZoomMeetingFailedMail(sender=os.getenv('SERVICE_EMAIL'), to=organizer.email, appointment_title=db_appointment.title) + mail.send() + except SQLAlchemyError as err: # Not fatal, but could make things tricky + logging.error("Failed to save the zoom meeting link to the appointment: ", err) + if os.getenv('SENTRY_DSN') != '': + capture_exception(err) + event = schemas.Event( title=db_appointment.title, start=slot.start.replace(tzinfo=timezone.utc).isoformat(), @@ -389,12 +435,10 @@ def update_public_appointment_slot( suggestions=db_appointment.location_suggestions, selected=db_appointment.location_selected, name=db_appointment.location_name, - url=db_appointment.location_url, + url=location_url, phone=db_appointment.location_phone, ), ) - # grab the subscriber - organizer = repo.get_subscriber_by_appointment(db=db, appointment_id=db_appointment.id) # create remote event if db_calendar.provider == CalendarProvider.google: @@ -430,6 +474,7 @@ def public_appointment_serve_ics(slug: str, slot_id: int, db: Session = Depends( if slot is None: raise HTTPException(status_code=404, detail="Time slot not found") organizer = repo.get_subscriber_by_appointment(db=db, appointment_id=db_appointment.id) + return schemas.FileDownload( name="invite", content_type="text/calendar", diff --git a/backend/src/appointment/routes/google.py b/backend/src/appointment/routes/google.py index b361b450e..562b27eb7 100644 --- a/backend/src/appointment/routes/google.py +++ b/backend/src/appointment/routes/google.py @@ -4,7 +4,7 @@ from fastapi import APIRouter, Depends from fastapi.responses import RedirectResponse -from ..controller.google_client import GoogleClient +from ..controller.apis.google_client import GoogleClient from ..database import repo from sqlalchemy.orm import Session diff --git a/backend/src/appointment/routes/schedule.py b/backend/src/appointment/routes/schedule.py index 9a4f83a2f..ffea63bab 100644 --- a/backend/src/appointment/routes/schedule.py +++ b/backend/src/appointment/routes/schedule.py @@ -1,20 +1,25 @@ + from fastapi import APIRouter, Depends, HTTPException, Body import logging import os +from requests import HTTPError +from sentry_sdk import capture_exception +from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.orm import Session from ..controller.calendar import CalDavConnector, Tools, GoogleConnector -from ..controller.google_client import GoogleClient -from ..controller.mailer import ConfirmationMail, RejectionMail +from ..controller.apis.google_client import GoogleClient +from ..controller.mailer import ConfirmationMail, RejectionMail, ZoomMeetingFailedMail from ..controller.auth import signed_url_by_subscriber from ..database import repo, schemas -from ..database.models import Subscriber, Schedule, CalendarProvider, random_slug, BookingStatus +from ..database.models import Subscriber, CalendarProvider, random_slug, BookingStatus, MeetingLinkProviderType, ExternalConnectionType from ..dependencies.auth import get_subscriber from ..dependencies.database import get_db from ..dependencies.google import get_google_client from datetime import datetime, timedelta, timezone from zoneinfo import ZoneInfo -from urllib.parse import quote_plus + +from ..dependencies.zoom import get_zoom_client router = APIRouter() @@ -76,6 +81,8 @@ def update_schedule( raise HTTPException(status_code=404, detail="Schedule not found") if not repo.schedule_is_owned(db, schedule_id=id, subscriber_id=subscriber.id): raise HTTPException(status_code=403, detail="Schedule not owned by subscriber") + if schedule.meeting_link_provider == MeetingLinkProviderType.zoom and subscriber.get_external_connection(ExternalConnectionType.zoom) is None: + raise HTTPException(status_code=400, detail="You need a connected Zoom account in order to create a meeting link") return repo.update_calendar_schedule(db=db, schedule=schedule, schedule_id=id) @@ -90,23 +97,31 @@ def read_schedule_availabilities( if not subscriber: raise HTTPException(status_code=401, detail="Invalid profile link") schedules = repo.get_schedules_by_subscriber(db, subscriber_id=subscriber.id) + try: schedule = schedules[0] # for now we only process the first existing schedule except IndexError: raise HTTPException(status_code=404, detail="Schedule not found") + # check if schedule is enabled if not schedule.active: raise HTTPException(status_code=404, detail="Schedule not found") + # calculate theoretically possible slots from schedule config availableSlots = Tools.available_slots_from_schedule(schedule) + # get all events from all connected calendars in scheduled date range calendars = repo.get_calendars_by_subscriber(db, subscriber.id, False) + if not calendars or len(calendars) == 0: raise HTTPException(status_code=404, detail="No calendars found") + existingEvents = Tools.existing_events_for_schedule(schedule, calendars, subscriber, google_client, db) actualSlots = Tools.events_set_difference(availableSlots, existingEvents) + if not actualSlots or len(actualSlots) == 0: raise HTTPException(status_code=404, detail="No possible booking slots found") + return schemas.AppointmentOut( title=schedule.name, details=schedule.details, @@ -136,11 +151,13 @@ def request_schedule_availability_slot( # get calendar db_calendar = repo.get_calendar(db, calendar_id=schedule.calendar_id) if db_calendar is None: - raise HTTPException(status_code=401, detail="Calendar not found") + raise HTTPException(status_code=404, detail="Calendar not found") + # check if slot still available, might already be taken at this time slot = schemas.SlotBase(**s_a.slot.dict()) if repo.schedule_slot_exists(db, slot, schedule.id): raise HTTPException(status_code=403, detail="Slot not available") + # create slot in db with token and expiration date token = random_slug() slot.booking_tkn = token @@ -168,7 +185,7 @@ def request_schedule_availability_slot( @router.put("/public/availability/booking", response_model=schemas.AvailabilitySlotAttendee) -def request_schedule_availability_slot( +def decide_on_schedule_availability_slot( data: schemas.AvailabilitySlotConfirmation, db: Session = Depends(get_db), google_client: GoogleClient = Depends(get_google_client), @@ -203,7 +220,7 @@ def request_schedule_availability_slot( raise HTTPException(status_code=404, detail="Booking slot not found") # TODO: check booking expiration date # check if request was denied - if data.confirmed == False: + if data.confirmed is False: # human readable date in subscribers timezone # TODO: handle locale date representation date = slot.start.replace(tzinfo=timezone.utc).astimezone(ZoneInfo(subscriber.timezone)).strftime("%c") @@ -220,6 +237,38 @@ def request_schedule_availability_slot( # otherwise, confirm slot and create event else: slot = repo.book_slot(db, slot.id) + + location_url = schedule.location_url + + # FIXME: This is just duplicated from the appointment code. We should find a nice way to merge the two. + if schedule.meeting_link_provider == MeetingLinkProviderType.zoom: + try: + zoom_client = get_zoom_client(subscriber) + response = zoom_client.create_meeting(schedule.name, slot.start.isoformat(), slot.duration, + subscriber.timezone) + if 'id' in response: + location_url = zoom_client.get_meeting(response['id'])['join_url'] + slot.meeting_link_id = response['id'] + slot.meeting_link_url = location_url + + db.add(slot) + db.commit() + except HTTPError as err: # Not fatal, just a bummer + logging.error("Zoom meeting creation error: ", err) + + # Ensure sentry captures the error too! + if os.getenv('SENTRY_DSN') != '': + capture_exception(err) + + # Notify the organizer that the meeting link could not be created! + mail = ZoomMeetingFailedMail(sender=os.getenv('SERVICE_EMAIL'), to=subscriber.email, + appointment_title=schedule.name) + mail.send() + except SQLAlchemyError as err: # Not fatal, but could make things tricky + logging.error("Failed to save the zoom meeting link to the appointment: ", err) + if os.getenv('SENTRY_DSN') != '': + capture_exception(err) + event = schemas.Event( title=schedule.name, start=slot.start.replace(tzinfo=timezone.utc).isoformat(), @@ -227,7 +276,7 @@ def request_schedule_availability_slot( description=schedule.details, location=schemas.EventLocation( type=schedule.location_type, - url=schedule.location_url, + url=location_url, name=None, ), ) @@ -245,7 +294,7 @@ def request_schedule_availability_slot( con.create_event(event=event, attendee=slot.attendee, organizer=subscriber) # send mail with .ics attachment to attendee - appointment = schemas.AppointmentBase(title=schedule.name, details=schedule.details) + appointment = schemas.AppointmentBase(title=schedule.name, details=schedule.details, location_url=location_url) Tools().send_vevent(appointment, slot, subscriber, slot.attendee) return schemas.AvailabilitySlotAttendee( @@ -276,7 +325,8 @@ def schedule_serve_ics( db_calendar = repo.get_calendar(db, calendar_id=schedule.calendar_id) if db_calendar is None: raise HTTPException(status_code=404, detail="Calendar not found") - appointment = schemas.AppointmentBase(title=schedule.name, details=schedule.details) + + appointment = schemas.AppointmentBase(title=schedule.name, details=schedule.details, location_url=schedule.location_url) return schemas.FileDownload( name="invite", content_type="text/calendar", diff --git a/backend/src/appointment/routes/zoom.py b/backend/src/appointment/routes/zoom.py new file mode 100644 index 000000000..81dfe32fe --- /dev/null +++ b/backend/src/appointment/routes/zoom.py @@ -0,0 +1,74 @@ +import json +import os + +from fastapi import APIRouter, Depends +from fastapi.responses import RedirectResponse +from sqlalchemy.orm import Session + +from ..controller.apis.zoom_client import ZoomClient +from ..controller.auth import sign_url +from ..database import repo, schemas +from ..database.models import Subscriber, ExternalConnectionType +from ..dependencies.auth import get_subscriber +from ..dependencies.database import get_db +from ..dependencies.zoom import get_zoom_client + +router = APIRouter() + + +@router.get("/auth") +def zoom_auth( + subscriber: Subscriber = Depends(get_subscriber), + zoom_client: ZoomClient = Depends(get_zoom_client), +): + """Starts the zoom oauth process""" + + url, state = zoom_client.get_redirect_url(state=sign_url(str(subscriber.id))) + + return {'url': url} + + +@router.get("/callback") +def zoom_callback( + code: str, + state: str, + zoom_client: ZoomClient = Depends(get_zoom_client), + subscriber: Subscriber = Depends(get_subscriber), + db=Depends(get_db), +): + if sign_url(str(subscriber.id)) != state: + raise RuntimeError("States do not match!") + + creds = zoom_client.get_credentials(code) + + # Get the zoom user info, so we can associate their id with their appointment subscriber + zoom_user_info = zoom_client.get_me() + + external_connection_schema = schemas.ExternalConnection( + name=zoom_user_info['email'], + type=ExternalConnectionType.zoom, + type_id=zoom_user_info['id'], + owner_id=subscriber.id, + token=json.dumps(creds) + ) + + if len(repo.get_external_connections_by_type(db, subscriber.id, external_connection_schema.type, external_connection_schema.type_id)) == 0: + repo.create_subscriber_external_connection(db, external_connection_schema) + + return RedirectResponse(f"{os.getenv('FRONTEND_URL', 'http://localhost:8080')}/settings/account") + + +@router.post("/disconnect") +def disconnect_account( + db: Session = Depends(get_db), + subscriber: Subscriber = Depends(get_subscriber), +): + """We only have one zoom account, so we can just remove it. If it doesn't even exist return false.""" + zoom_connection = subscriber.get_external_connection(ExternalConnectionType.zoom) + + if zoom_connection: + repo.delete_external_connections_by_type_id(db, subscriber.id, zoom_connection.type, zoom_connection.type_id) + else: + return False + + return True diff --git a/backend/src/appointment/secrets.py b/backend/src/appointment/secrets.py index 7ca677bda..288382866 100644 --- a/backend/src/appointment/secrets.py +++ b/backend/src/appointment/secrets.py @@ -53,3 +53,11 @@ def normalize_secrets(): os.environ["GOOGLE_AUTH_SECRET"] = secrets.get("secret") os.environ["GOOGLE_AUTH_PROJECT_ID"] = secrets.get("project_id") os.environ["GOOGLE_AUTH_CALLBACK"] = secrets.get("callback_url") + + zoom_secrets = os.getenv("ZOOM_SECRETS") + + if zoom_secrets: + secrets = json.loads(zoom_secrets) + + os.environ["ZOOM_AUTH_CLIENT_ID"] = secrets.get("client_id") + os.environ["ZOOM_AUTH_SECRET"] = secrets.get("secret") diff --git a/backend/src/appointment/templates/email/errors/zoom_invite_failed.jinja2 b/backend/src/appointment/templates/email/errors/zoom_invite_failed.jinja2 new file mode 100644 index 000000000..d3d6c6c89 --- /dev/null +++ b/backend/src/appointment/templates/email/errors/zoom_invite_failed.jinja2 @@ -0,0 +1,5 @@ + + +

Unfortunately there was an error creating your Zoom meeting for your upcoming appointment: {{ title }}.

+ + diff --git a/frontend/src/assets/main.css b/frontend/src/assets/main.css index 6b81ca770..f0d12fc61 100644 --- a/frontend/src/assets/main.css +++ b/frontend/src/assets/main.css @@ -1,98 +1,98 @@ -@tailwind base; -@tailwind components; -@tailwind utilities; - - -@layer base { - input, select, textarea { - @apply transition-all dark:text-white bg-white dark:bg-gray-700 !border-gray-300 dark:!border-gray-500 focus:!ring-teal-500 focus:!border-teal-500 checked:!border-teal-500; - } - - hr { - @apply border-t-gray-300 dark:border-t-gray-500 - } - - @font-face { - font-family: 'Raleway'; - src: url('./fonts/Raleway-Regular.ttf'); - font-weight: 400; - } - @font-face { - font-family: 'Open Sans'; - src: url('./fonts/OpenSans-LightItalic.ttf'); - font-weight: 300; - font-style: italic; - } - @font-face { - font-family: 'Open Sans'; - src: url('./fonts/OpenSans-Light.ttf'); - font-weight: 300; - } - @font-face { - font-family: 'Open Sans'; - src: url('./fonts/OpenSans-Italic.ttf'); - font-weight: 400; - font-style: italic; - } - @font-face { - font-family: 'Open Sans'; - src: url('./fonts/OpenSans-Regular.ttf'); - font-weight: 400; - } - @font-face { - font-family: 'Open Sans'; - src: url('./fonts/OpenSans-MediumItalic.ttf'); - font-weight: 500; - font-style: italic; - } - @font-face { - font-family: 'Open Sans'; - src: url('./fonts/OpenSans-Medium.ttf'); - font-weight: 500; - } - @font-face { - font-family: 'Open Sans'; - src: url('./fonts/OpenSans-SemiBoldItalic.ttf'); - font-weight: 600; - font-style: italic; - } - @font-face { - font-family: 'Open Sans'; - src: url('./fonts/OpenSans-SemiBold.ttf'); - font-weight: 600; - } - @font-face { - font-family: 'Open Sans'; - src: url('./fonts/OpenSans-BoldItalic.ttf'); - font-weight: 700; - font-style: italic; - } - @font-face { - font-family: 'Open Sans'; - src: url('./fonts/OpenSans-Bold.ttf'); - font-weight: 700; - } - @font-face { - font-family: 'Open Sans'; - src: url('./fonts/OpenSans-ExtraBoldItalic.ttf'); - font-weight: 800; - font-style: italic; - } - @font-face { - font-family: 'Open Sans'; - src: url('./fonts/OpenSans-ExtraBold.ttf'); - font-weight: 800; - } -} - -@layer components { - .position-center { - @apply top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2; - } - .flex-center { - @apply flex justify-center items-center; - } - .place-holder { - @apply placeholder:text-gray-300 dark:placeholder:text-gray-500; - } -} +@tailwind base; +@tailwind components; +@tailwind utilities; + + +@layer base { + input, select, textarea { + @apply transition-all dark:text-white bg-white dark:bg-gray-700 !border-gray-300 dark:!border-gray-500 focus:!ring-teal-500 focus:!border-teal-500 checked:!border-teal-500 checked:!bg-teal-500; + } + + hr { + @apply border-t-gray-300 dark:border-t-gray-500 + } + + @font-face { + font-family: 'Raleway'; + src: url('./fonts/Raleway-Regular.ttf'); + font-weight: 400; + } + @font-face { + font-family: 'Open Sans'; + src: url('./fonts/OpenSans-LightItalic.ttf'); + font-weight: 300; + font-style: italic; + } + @font-face { + font-family: 'Open Sans'; + src: url('./fonts/OpenSans-Light.ttf'); + font-weight: 300; + } + @font-face { + font-family: 'Open Sans'; + src: url('./fonts/OpenSans-Italic.ttf'); + font-weight: 400; + font-style: italic; + } + @font-face { + font-family: 'Open Sans'; + src: url('./fonts/OpenSans-Regular.ttf'); + font-weight: 400; + } + @font-face { + font-family: 'Open Sans'; + src: url('./fonts/OpenSans-MediumItalic.ttf'); + font-weight: 500; + font-style: italic; + } + @font-face { + font-family: 'Open Sans'; + src: url('./fonts/OpenSans-Medium.ttf'); + font-weight: 500; + } + @font-face { + font-family: 'Open Sans'; + src: url('./fonts/OpenSans-SemiBoldItalic.ttf'); + font-weight: 600; + font-style: italic; + } + @font-face { + font-family: 'Open Sans'; + src: url('./fonts/OpenSans-SemiBold.ttf'); + font-weight: 600; + } + @font-face { + font-family: 'Open Sans'; + src: url('./fonts/OpenSans-BoldItalic.ttf'); + font-weight: 700; + font-style: italic; + } + @font-face { + font-family: 'Open Sans'; + src: url('./fonts/OpenSans-Bold.ttf'); + font-weight: 700; + } + @font-face { + font-family: 'Open Sans'; + src: url('./fonts/OpenSans-ExtraBoldItalic.ttf'); + font-weight: 800; + font-style: italic; + } + @font-face { + font-family: 'Open Sans'; + src: url('./fonts/OpenSans-ExtraBold.ttf'); + font-weight: 800; + } +} + +@layer components { + .position-center { + @apply top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2; + } + .flex-center { + @apply flex justify-center items-center; + } + .place-holder { + @apply placeholder:text-gray-300 dark:placeholder:text-gray-500; + } +} diff --git a/frontend/src/components/AppointmentCreation.vue b/frontend/src/components/AppointmentCreation.vue index 6340cba58..f4bbf4cdc 100644 --- a/frontend/src/components/AppointmentCreation.vue +++ b/frontend/src/components/AppointmentCreation.vue @@ -36,8 +36,8 @@

-