Skip to content

Commit

Permalink
Schedule booking confirmation (#519)
Browse files Browse the repository at this point in the history
* ➕ Add schedule booking confirmation

* ➕ Enhance schedule API to handle bookings without confirmation

* 🔨 Show proper success message depending on confirmation status

* 🔨 Make field nullable and fix fn return

* 🌐 Update frontend/src/locales/en.json

Co-authored-by: Mel <[email protected]>

* 🌐 Update German translation

* 🔨 Assume appointment mustn't be null

---------

Co-authored-by: Mel <[email protected]>
  • Loading branch information
devmount and MelissaAutumn authored Jul 12, 2024
1 parent 77592ed commit 35b09c6
Show file tree
Hide file tree
Showing 13 changed files with 164 additions and 57 deletions.
1 change: 1 addition & 0 deletions backend/src/appointment/database/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,7 @@ class Schedule(Base):
farthest_booking: int = Column(Integer, default=20160) # in minutes, defaults to 2 weeks
weekdays: str | dict = Column(JSON, default='[1,2,3,4,5]') # list of ISO weekdays, Mo-Su => 1-7
slot_duration: int = Column(Integer, default=30) # defaults to 30 minutes
booking_confirmation: bool = Column(Boolean, index=True, nullable=False, default=True)

# What (if any) meeting link will we generate once the meeting is booked
meeting_link_provider: MeetingLinkProviderType = Column(
Expand Down
2 changes: 2 additions & 0 deletions backend/src/appointment/database/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ class AppointmentOut(AppointmentBase):
owner_name: str | None = None
slots: list[SlotBase | SlotOut] = []
slot_duration: int
booking_confirmation: bool


""" SCHEDULE model schemas
Expand Down Expand Up @@ -170,6 +171,7 @@ class ScheduleBase(BaseModel):
weekdays: list[int] | None = Field(min_length=1, default=[1, 2, 3, 4, 5])
slot_duration: int | None = None
meeting_link_provider: MeetingLinkProviderType | None = MeetingLinkProviderType.none
booking_confirmation: bool = True

class Config:
json_encoders = {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
"""schedule booking confirmation
Revision ID: fb1feb76c467
Revises: 0c22678e25db
Create Date: 2024-07-04 15:01:47.090876
"""
from alembic import op
import sqlalchemy as sa


# revision identifiers, used by Alembic.
revision = 'fb1feb76c467'
down_revision = '0c22678e25db'
branch_labels = None
depends_on = None


def upgrade() -> None:
op.add_column('schedules', sa.Column('booking_confirmation', sa.Boolean, nullable=False, default=True, index=True))


def downgrade() -> None:
op.drop_column('schedules', 'booking_confirmation')
1 change: 1 addition & 0 deletions backend/src/appointment/routes/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -475,6 +475,7 @@ def read_public_appointment(slug: str, db: Session = Depends(get_db)):
owner_name=s.name,
slots=slots,
slot_duration=slots[0].duration if len(slots) > 0 else 0,
booking_confirmation=False
)


Expand Down
132 changes: 82 additions & 50 deletions backend/src/appointment/routes/schedule.py
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,8 @@ def read_schedule_availabilities(
redis=Depends(get_redis),
google_client: GoogleClient = Depends(get_google_client),
):
"""Returns the calculated availability for the first schedule from a subscribers public profile link"""
"""Returns the calculated availability for the first schedule from a subscribers public profile link
"""
# Raise a schedule not found exception if the schedule owner does not have a timezone set.
if subscriber.timezone is None:
raise validation.ScheduleNotFoundException()
Expand Down Expand Up @@ -180,12 +181,14 @@ def read_schedule_availabilities(
if not actual_slots or len(actual_slots) == 0:
raise validation.SlotNotFoundException()

# TODO: dedicate an own schema to this endpoint
return schemas.AppointmentOut(
title=schedule.name,
details=schedule.details,
owner_name=subscriber.name,
slots=actual_slots,
slot_duration=schedule.slot_duration,
booking_confirmation=schedule.booking_confirmation
)


Expand All @@ -198,7 +201,8 @@ def request_schedule_availability_slot(
redis=Depends(get_redis),
google_client=Depends(get_google_client),
):
"""endpoint to request a time slot for a schedule via public link and send confirmation mail to owner"""
"""endpoint to request a time slot for a schedule via public link and send confirmation mail to owner if set
"""

# Raise a schedule not found exception if the schedule owner does not have a timezone set.
if subscriber.timezone is None:
Expand All @@ -216,8 +220,8 @@ def request_schedule_availability_slot(
raise validation.ScheduleNotFoundException()

# get calendar
db_calendar = repo.calendar.get(db, calendar_id=schedule.calendar_id)
if db_calendar is None:
calendar = repo.calendar.get(db, calendar_id=schedule.calendar_id)
if calendar is None:
raise validation.CalendarNotFoundException()

# check if slot still available, might already be taken at this time
Expand All @@ -226,7 +230,7 @@ def request_schedule_availability_slot(
raise validation.SlotAlreadyTakenException()

# We need to verify that the time is actually available on the remote calendar
if db_calendar.provider == CalendarProvider.google:
if calendar.provider == CalendarProvider.google:
external_connection = utils.list_first(repo.external_connection.get_by_type(db, subscriber.id, schemas.ExternalConnectionType.google))

if external_connection is None or external_connection.token is None:
Expand All @@ -236,19 +240,19 @@ def request_schedule_availability_slot(
db=db,
redis_instance=redis,
google_client=google_client,
remote_calendar_id=db_calendar.user,
remote_calendar_id=calendar.user,
subscriber_id=subscriber.id,
calendar_id=db_calendar.id,
calendar_id=calendar.id,
google_tkn=external_connection.token,
)
else:
con = CalDavConnector(
redis_instance=redis,
subscriber_id=subscriber.id,
calendar_id=db_calendar.id,
url=db_calendar.url,
user=db_calendar.user,
password=db_calendar.password,
calendar_id=calendar.id,
url=calendar.url,
user=calendar.user,
password=calendar.password,
)

# Ok we need to clear the cache for all calendars, because we need to recheck them.
Expand All @@ -273,32 +277,20 @@ def request_schedule_availability_slot(
# create attendee for this slot
attendee = repo.slot.update(db, slot.id, s_a.attendee)

# generate confirm and deny links with encoded booking token and signed owner url
url = f'{signed_url_by_subscriber(subscriber)}/confirm/{slot.id}/{token}'

# human readable date in subscribers timezone
# TODO: handle locale date representation
date = slot.start.replace(tzinfo=timezone.utc).astimezone(ZoneInfo(subscriber.timezone)).strftime('%c')
date = f'{date}, {slot.duration} minutes ({subscriber.timezone})'

# human readable date in attendee timezone
# TODO: handle locale date representation
attendee_date = slot.start.replace(tzinfo=timezone.utc).astimezone(ZoneInfo(slot.attendee.timezone)).strftime('%c')
attendee_date = f'{attendee_date}, {slot.duration} minutes ({slot.attendee.timezone})'

# Create a pending appointment
attendee_name = slot.attendee.name if slot.attendee.name is not None else slot.attendee.email
subscriber_name = subscriber.name if subscriber.name is not None else subscriber.email
title = f'Appointment - {subscriber_name} and {attendee_name}'
status = models.AppointmentStatus.opened if schedule.booking_confirmation else models.AppointmentStatus.closed

appointment = repo.appointment.create(
db,
schemas.AppointmentFull(
title=title,
details=schedule.details,
calendar_id=db_calendar.id,
calendar_id=calendar.id,
duration=slot.duration,
status=models.AppointmentStatus.opened,
status=status,
location_type=schedule.location_type,
location_url=schedule.location_url,
),
Expand All @@ -308,17 +300,41 @@ def request_schedule_availability_slot(
slot.appointment_id = appointment.id
db.add(slot)
db.commit()
db.refresh(slot)

# Sending confirmation email to owner
background_tasks.add_task(
send_confirmation_email, url=url, attendee_name=attendee.name, attendee_email=attendee.email, date=date,
to=subscriber.preferred_email
)
# generate confirm and deny links with encoded booking token and signed owner url
url = f'{signed_url_by_subscriber(subscriber)}/confirm/{slot.id}/{token}'

# Sending pending email to attendee
background_tasks.add_task(
send_pending_email, owner_name=subscriber.name, date=attendee_date, to=slot.attendee.email
)
# If bookings are configured to be confirmed by the owner for this schedule,
# send emails to owner for confirmation and attendee for information
if schedule.booking_confirmation:

# human readable date in subscribers timezone
# TODO: handle locale date representation
date = slot.start.replace(tzinfo=timezone.utc).astimezone(ZoneInfo(subscriber.timezone)).strftime('%c')
date = f'{date}, {slot.duration} minutes ({subscriber.timezone})'

# human readable date in attendee timezone
# TODO: handle locale date representation
attendee_date = slot.start.replace(tzinfo=timezone.utc).astimezone(ZoneInfo(slot.attendee.timezone)).strftime('%c')
attendee_date = f'{attendee_date}, {slot.duration} minutes ({slot.attendee.timezone})'

# Sending confirmation email to owner
background_tasks.add_task(
send_confirmation_email, url=url, attendee_name=attendee.name, attendee_email=attendee.email, date=date,
to=subscriber.preferred_email
)

# Sending pending email to attendee
background_tasks.add_task(
send_pending_email, owner_name=subscriber.name, date=attendee_date, to=slot.attendee.email
)

# If no confirmation is needed, directly confirm the booking and send invitation mail
else:
handle_schedule_availability_decision(
True, calendar, schedule, subscriber, slot, db, redis, google_client, background_tasks
)

# Mini version of slot, so we can grab the newly created slot id for tests
return schemas.SlotOut(
Expand All @@ -338,7 +354,6 @@ def decide_on_schedule_availability_slot(
google_client: GoogleClient = Depends(get_google_client),
):
"""endpoint to react to owners decision to a request of a time slot of his public link
if confirmed: create an event in remote calendar and send invitation mail
"""
subscriber = repo.subscriber.verify_link(db, data.owner_url)
if not subscriber:
Expand Down Expand Up @@ -373,9 +388,37 @@ def decide_on_schedule_availability_slot(
):
raise validation.SlotNotFoundException()

# handle decision, do the actual booking if confirmed and send invitation mail
handle_schedule_availability_decision(
data.confirmed, calendar, schedule, subscriber, slot, db, redis, google_client, background_tasks
)

return schemas.AvailabilitySlotAttendee(
slot=schemas.SlotBase(start=slot.start, duration=slot.duration),
attendee=schemas.AttendeeBase(
email=slot.attendee.email, name=slot.attendee.name, timezone=slot.attendee.timezone
),
)


def handle_schedule_availability_decision(
confirmed: bool,
calendar,
schedule,
subscriber,
slot,
db,
redis,
google_client,
background_tasks
):
"""Actual handling of the availability decision
if confirmed: create an event in remote calendar and send invitation mail
"""

# TODO: check booking expiration date
# check if request was denied
if data.confirmed is False:
if 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')
Expand All @@ -391,13 +434,7 @@ def decide_on_schedule_availability_slot(
# delete the scheduled slot to make the time available again
repo.slot.delete(db, slot.id)

# Early return
return schemas.AvailabilitySlotAttendee(
slot=schemas.SlotBase(start=slot.start, duration=slot.duration),
attendee=schemas.AttendeeBase(
email=slot.attendee.email, name=slot.attendee.name, timezone=slot.attendee.timezone
),
)
return True

# otherwise, confirm slot and create event
location_url = schedule.location_url
Expand Down Expand Up @@ -504,9 +541,4 @@ def decide_on_schedule_availability_slot(

Tools().send_vevent(background_tasks, slot.appointment, slot, subscriber, slot.attendee)

return schemas.AvailabilitySlotAttendee(
slot=schemas.SlotBase(start=slot.start, duration=slot.duration),
attendee=schemas.AttendeeBase(
email=slot.attendee.email, name=slot.attendee.name, timezone=slot.attendee.timezone
),
)
return True
1 change: 1 addition & 0 deletions frontend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ npm run build
### Post-CSS

We use post-css to enhance our css. Any post-css that isn't in a SFC must be in a `.pcss` file and imported into the scoped style like so:

```css
@import '@/assets/styles/custom-media.pcss';

Expand Down
30 changes: 29 additions & 1 deletion frontend/src/components/ScheduleCreation.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,14 @@
<div class="flex flex-col gap-4 px-1 py-4">
<div class="flex items-center justify-around text-center text-xl font-semibold text-teal-500">
<span class="pl-3">{{ t("heading.generalAvailability") }}</span>
<switch-toggle v-if="existing" class="mt-0.5 pr-3" :active="schedule.active" no-legend @changed="toggleActive"/>
<switch-toggle
v-if="existing"
class="mt-0.5 pr-3"
:active="schedule.active"
no-legend
@changed="toggleActive"
:title="t(schedule.active ? 'label.deactivateSchedule' : 'label.activateSchedule')"
/>
</div>
<alert-box
@close="scheduleCreationError = ''"
Expand Down Expand Up @@ -284,6 +291,20 @@
</label>
</div>
</div>
<!-- option to deactivate confirmation -->
<div class="px-4">
<switch-toggle
class="my-1 pr-3 text-sm font-medium text-gray-500 dark:text-gray-300"
:active="schedule.booking_confirmation"
:label="t('label.bookingConfirmation')"
:disabled="!scheduleInput.active"
@changed="toggleBookingConfirmation"
no-legend
/>
<div class="text-xs">
{{ t('text.ownerNeedsToConfirmBooking') }}
</div>
</div>
</div>
<!-- Snack-ish Bar - The dark info bubble at the bottom of this form -->
<!-- First time no calendars -->
Expand Down Expand Up @@ -444,6 +465,7 @@ const defaultSchedule = {
weekdays: [1, 2, 3, 4, 5],
slot_duration: defaultSlotDuration,
meeting_link_provider: meetingLinkProviderType.none,
booking_confirmation: true,
};
const scheduleInput = ref({ ...defaultSchedule });
// For comparing changes, and resetting to default.
Expand Down Expand Up @@ -676,6 +698,11 @@ const toggleZoomLinkCreation = () => {
scheduleInput.value.meeting_link_provider = meetingLinkProviderType.none;
};
// handle schedule booking confirmation activation / deactivation
const toggleBookingConfirmation = (newValue) => {
scheduleInput.value.booking_confirmation = newValue;
};
// track if steps were already visited
watch(
() => scheduleInput.value.active,
Expand Down Expand Up @@ -705,6 +732,7 @@ watch(
scheduleInput.value.start_time,
scheduleInput.value.end_time,
scheduleInput.value.weekdays,
scheduleInput.value.booking_confirmation,
props.activeDate,
],
() => {
Expand Down
4 changes: 3 additions & 1 deletion frontend/src/components/bookingView/BookingViewSuccess.vue
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
<template>
<div class="flex-center min-w-[50%] flex-col gap-12">
<div class="text-2xl font-semibold text-teal-500">
<span>{{ t('info.bookingSuccessfullyRequested') }}</span>
<span v-if="requested">{{ t('info.bookingSuccessfullyRequested') }}</span>
<span v-else>{{ t('info.bookingSuccessfullyConfirmed') }}</span>
</div>
<div class="flex w-full max-w-sm flex-col gap-1 rounded-lg shadow-lg">
<div class="flex h-14 items-center justify-around rounded-t-md bg-teal-500">
Expand Down Expand Up @@ -46,6 +47,7 @@ const dj = inject(dayjsKey);
defineProps({
selectedEvent: Object,
attendeeEmail: String,
requested: Boolean, // True if we are requesting a booking, false if already confirmed
});
</script>
Loading

0 comments on commit 35b09c6

Please sign in to comment.