/* * SPDX-FileCopyrightText: 2023 jordi fita mas * SPDX-License-Identifier: AGPL-3.0-only */ package booking import ( "context" "errors" "fmt" "net/http" "strconv" "time" "dev.tandem.ws/tandem/camper/pkg/auth" "dev.tandem.ws/tandem/camper/pkg/database" "dev.tandem.ws/tandem/camper/pkg/form" httplib "dev.tandem.ws/tandem/camper/pkg/http" "dev.tandem.ws/tandem/camper/pkg/locale" "dev.tandem.ws/tandem/camper/pkg/template" ) type PublicHandler struct { } func NewPublicHandler() *PublicHandler { return &PublicHandler{} } func (h *PublicHandler) Handler(user *auth.User, company *auth.Company, conn *database.Conn) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var head string head, r.URL.Path = httplib.ShiftPath(r.URL.Path) switch head { case "": switch r.Method { case http.MethodGet: page, err := newPublicPage(r, company, conn, user.Locale) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } page.MustRender(w, r, user, company, conn) case http.MethodPost: requestPayment(w, r, user, company, conn) default: httplib.MethodNotAllowed(w, r, http.MethodGet, http.MethodPost) } default: http.NotFound(w, r) } }) } type publicPage struct { *template.PublicPage Form *bookingForm HasErrors bool Environment string } func newPublicPage(r *http.Request, company *auth.Company, conn *database.Conn, l *locale.Locale) (*publicPage, error) { f, err := newBookingForm(r, company, conn, l) if err != nil { return nil, err } return newPublicPageWithForm(f), nil } func newPublicPageWithForm(form *bookingForm) *publicPage { return &publicPage{ PublicPage: template.NewPublicPage(), Form: form, } } func (p *publicPage) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn) { p.Setup(r, user, company, conn) if err := conn.QueryRow(r.Context(), "select environment from redsys where company_id = $1", company.ID).Scan(&p.Environment); err != nil && !database.ErrorIsNotFound(err) { panic(err) } if httplib.IsHTMxRequest(r) { template.MustRenderPublicNoLayout(w, r, user, company, "booking/fields.gohtml", p) } else { template.MustRenderPublicFiles(w, r, user, company, p, "booking/page.gohtml", "booking/fields.gohtml") } } type bookingForm struct { CampsiteType *form.Select PaymentSlug *form.Input Dates *DateFields Guests *bookingGuestFields Options *bookingOptionFields Customer *bookingCustomerFields Cart *bookingCart } type DateFields struct { MinNights int MaxNights int ArrivalDate *bookingDateInput DepartureDate *bookingDateInput } type bookingDateInput struct { *form.Input MinDate time.Time MaxDate time.Time } type bookingGuestFields struct { MaxGuests int OverflowAllowed bool Overflow bool NumberAdults *form.Input NumberTeenagers *form.Input NumberChildren *form.Input NumberDogs *form.Input ACSICard *form.Checkbox Error error } type bookingOptionFields struct { Legend string ZonePreferences *form.Input Options []*campsiteTypeOption } type campsiteTypeOption struct { ID int Label string Min int Max int Input *form.Input Subtotal string } type bookingCustomerFields struct { FullName *form.Input Address *form.Input PostalCode *form.Input City *form.Input Country *form.Select Email *form.Input Phone *form.Input Agreement *form.Checkbox } func newEmptyBookingForm(ctx context.Context, conn *database.Conn, company *auth.Company, l *locale.Locale) *bookingForm { return &bookingForm{ CampsiteType: &form.Select{ Name: "campsite_type", Options: form.MustGetOptions(ctx, conn, "select type.slug, coalesce(i18n.name, type.name) as l10n_name from campsite_type as type left join campsite_type_i18n as i18n on type.campsite_type_id = i18n.campsite_type_id and i18n.lang_tag = $1 where company_id = $2 and active order by position, l10n_name", l.Language, company.ID), }, PaymentSlug: &form.Input{ Name: "payment_slug", }, } } func newBookingForm(r *http.Request, company *auth.Company, conn *database.Conn, l *locale.Locale) (*bookingForm, error) { if err := r.ParseForm(); err != nil { return nil, err } f := newEmptyBookingForm(r.Context(), conn, company, l) f.CampsiteType.FillValue(r) f.PaymentSlug.FillValue(r) campsiteType := f.CampsiteType.String() if campsiteType == "" { return f, nil } if !f.CampsiteType.ValidOptionsSelected() { f.CampsiteType.Error = errors.New(l.Gettext("Selected campsite type is not valid.")) return f, nil } var err error f.Dates, err = NewDateFields(r.Context(), conn, campsiteType) if err != nil { return nil, err } f.Dates.FillValues(r, l) if f.Dates.ArrivalDate.Val == "" || f.Dates.ArrivalDate.Error != nil || f.Dates.DepartureDate.Val == "" || f.Dates.DepartureDate.Error != nil { return f, nil } f.Guests, err = newBookingGuestFields(r.Context(), conn, campsiteType, f.Dates.ArrivalDate.Val, f.Dates.DepartureDate.Val) if err != nil { return nil, err } f.Guests.FillValues(r, l) f.Options, err = newBookingOptionFields(r.Context(), conn, campsiteType, l) if err != nil { return nil, err } if f.Options != nil { f.Options.FillValues(r) } f.Customer = newBookingCustomerFields(r.Context(), conn, l) f.Customer.FillValues(r) f.Cart, err = newBookingCart(r.Context(), conn, f, campsiteType) if err != nil { return nil, err } return f, nil } func (f *bookingForm) Valid(ctx context.Context, conn *database.Conn, l *locale.Locale) (bool, error) { v := form.NewValidator(l) if f.Dates == nil { return false, errors.New("no booking date fields") } if f.Guests == nil { return false, errors.New("no guests fields") } if f.Customer == nil { return false, errors.New("no customer fields") } if f.Cart == nil { return false, errors.New("no booking cart") } v.CheckSelectedOptions(f.CampsiteType, l.GettextNoop("Selected campsite type is not valid.")) f.Dates.Valid(v, l) f.Guests.Valid(v, l) if err := f.Customer.Valid(ctx, conn, v, l); err != nil { return false, err } if f.Options != nil { f.Options.Valid(v, l) } return v.AllOK, nil } func NewDateFields(ctx context.Context, conn *database.Conn, campsiteType string) (*DateFields, error) { row := conn.QueryRow(ctx, ` select lower(bookable_nights), upper(bookable_nights) - 1, greatest(min(lower(season_range)), current_timestamp::date), max(upper(season_range)) from campsite_type join campsite_type_cost using (campsite_type_id) join season_calendar using (season_id) where campsite_type.slug = $1 and season_range >> daterange(date_trunc('year', current_timestamp)::date, date_trunc('year', current_timestamp)::date + 1) group by bookable_nights; `, campsiteType) f := &DateFields{ ArrivalDate: &bookingDateInput{ Input: &form.Input{Name: "arrival_date"}, }, DepartureDate: &bookingDateInput{ Input: &form.Input{Name: "departure_date"}, }, } if err := row.Scan(&f.MinNights, &f.MaxNights, &f.ArrivalDate.MinDate, &f.DepartureDate.MaxDate); err != nil { return nil, err } f.ArrivalDate.MaxDate = f.DepartureDate.MaxDate.AddDate(0, 0, -f.MinNights) f.DepartureDate.MinDate = f.ArrivalDate.MinDate.AddDate(0, 0, f.MinNights) return f, nil } func (f *DateFields) FillValues(r *http.Request, l *locale.Locale) { f.ArrivalDate.FillValue(r) f.DepartureDate.FillValue(r) f.AdjustValues(l) } func (f *DateFields) AdjustValues(l *locale.Locale) { if f.ArrivalDate.Val != "" { arrivalDate, err := time.Parse(database.ISODateFormat, f.ArrivalDate.Val) if err != nil { f.ArrivalDate.Error = errors.New(l.Gettext("Arrival date must be a valid date.")) return } f.DepartureDate.MinDate = arrivalDate.AddDate(0, 0, f.MinNights) maxDate := arrivalDate.AddDate(0, 0, f.MaxNights) if maxDate.Before(f.DepartureDate.MaxDate) { f.DepartureDate.MaxDate = maxDate } if f.DepartureDate.Val == "" { f.DepartureDate.Val = f.DepartureDate.MinDate.Format(database.ISODateFormat) } else { departureDate, err := time.Parse(database.ISODateFormat, f.DepartureDate.Val) if err != nil { f.DepartureDate.Error = errors.New(l.Gettext("Departure date must be a valid date.")) return } if departureDate.Before(f.DepartureDate.MinDate) { f.DepartureDate.Val = f.DepartureDate.MinDate.Format(database.ISODateFormat) } else if departureDate.After(f.DepartureDate.MaxDate) { f.DepartureDate.Val = f.DepartureDate.MaxDate.Format(database.ISODateFormat) } } } } func (f *DateFields) Valid(v *form.Validator, l *locale.Locale) { var validBefore bool if v.CheckRequired(f.ArrivalDate.Input, l.GettextNoop("Arrival date can not be empty")) { if v.CheckValidDate(f.ArrivalDate.Input, l.GettextNoop("Arrival date must be a valid date.")) { if v.CheckMinDate(f.ArrivalDate.Input, f.ArrivalDate.MinDate, fmt.Sprintf(l.Gettext("Arrival date must be %s or after."), f.ArrivalDate.MinDate.Format(database.ISODateFormat))) { v.CheckMaxDate(f.ArrivalDate.Input, f.ArrivalDate.MaxDate, fmt.Sprintf(l.Gettext("Arrival date must be %s or before."), f.ArrivalDate.MaxDate.Format(database.ISODateFormat))) } } } if v.CheckRequired(f.DepartureDate.Input, l.GettextNoop("Departure date can not be empty")) { if v.CheckValidDate(f.DepartureDate.Input, l.GettextNoop("Departure date must be a valid date.")) && validBefore { if v.CheckMinDate(f.DepartureDate.Input, f.DepartureDate.MinDate, fmt.Sprintf(l.Gettext("Departure date must be %s or after."), f.DepartureDate.MinDate.Format(database.ISODateFormat))) { v.CheckMaxDate(f.DepartureDate.Input, f.DepartureDate.MaxDate, fmt.Sprintf(l.Gettext("Departure date must be %s or before."), f.DepartureDate.MaxDate.Format(database.ISODateFormat))) } } } } func newBookingGuestFields(ctx context.Context, conn *database.Conn, campsiteType string, arrivalDate string, departureDate string) (*bookingGuestFields, error) { f := &bookingGuestFields{ NumberAdults: &form.Input{Name: "number_adults"}, NumberTeenagers: &form.Input{Name: "number_teenagers"}, NumberChildren: &form.Input{Name: "number_children"}, } row := conn.QueryRow(ctx, ` select max_campers , overflow_allowed , pet.cost_per_night is not null as dogs_allowed , exists ( select 1 from acsi_calendar where acsi_calendar.campsite_type_id = campsite_type.campsite_type_id and acsi_range && daterange($2::date, $3::date) ) as acsi_allowed from campsite_type left join campsite_type_pet_cost as pet using (campsite_type_id) where slug = $1 `, campsiteType, arrivalDate, departureDate) var dogsAllowed bool var ACSIAllowed bool if err := row.Scan(&f.MaxGuests, &f.OverflowAllowed, &dogsAllowed, &ACSIAllowed); err != nil { return nil, err } if dogsAllowed { f.NumberDogs = &form.Input{Name: "number_dogs"} } if ACSIAllowed { f.ACSICard = &form.Checkbox{Name: "acsi_card"} } return f, nil } func (f *bookingGuestFields) FillValues(r *http.Request, l *locale.Locale) { numGuests := 0 numGuests += fillNumericField(f.NumberAdults, r, 1) numGuests += fillNumericField(f.NumberTeenagers, r, 0) numGuests += fillNumericField(f.NumberChildren, r, 0) if f.NumberDogs != nil { fillNumericField(f.NumberDogs, r, 0) } if f.ACSICard != nil { f.ACSICard.FillValue(r) } f.AdjustValues(numGuests, l) } func (f *bookingGuestFields) AdjustValues(numGuests int, l *locale.Locale) { if numGuests > f.MaxGuests { if f.OverflowAllowed { f.Overflow = true } else { f.Error = fmt.Errorf(l.Gettext("There can be at most %d guests in this accommodation."), f.MaxGuests) } } } func fillNumericField(input *form.Input, r *http.Request, min int) int { input.FillValue(r) if input.Val == "" { input.Val = strconv.Itoa(min) return min } val, err := strconv.Atoi(input.Val) if err != nil { input.Val = strconv.Itoa(min) val = min } return val } func (f *bookingGuestFields) Valid(v *form.Validator, l *locale.Locale) { if v.CheckRequired(f.NumberAdults, l.GettextNoop("Number of adults can not be empty")) { if v.CheckValidInteger(f.NumberAdults, l.GettextNoop("Number of adults must be an integer.")) { v.CheckMinInteger(f.NumberAdults, 1, l.GettextNoop("There must be at least one adult.")) } } if v.CheckRequired(f.NumberTeenagers, l.GettextNoop("Number of teenagers can not be empty")) { if v.CheckValidInteger(f.NumberTeenagers, l.GettextNoop("Number of teenagers must be an integer.")) { v.CheckMinInteger(f.NumberTeenagers, 0, l.GettextNoop("Number of teenagers can not be negative.")) } } if v.CheckRequired(f.NumberChildren, l.GettextNoop("Number of children can not be empty")) { if v.CheckValidInteger(f.NumberChildren, l.GettextNoop("Number of children must be an integer.")) { v.CheckMinInteger(f.NumberChildren, 0, l.GettextNoop("Number of children can not be negative.")) } } if f.NumberDogs != nil && v.CheckRequired(f.NumberDogs, l.GettextNoop("Number of dogs can not be empty")) { if v.CheckValidInteger(f.NumberDogs, l.GettextNoop("Number of dogs must be an integer.")) { v.CheckMinInteger(f.NumberDogs, 0, l.GettextNoop("Number of dogs can not be negative.")) } } } func newBookingOptionFields(ctx context.Context, conn *database.Conn, campsiteType string, l *locale.Locale) (*bookingOptionFields, error) { f := &bookingOptionFields{} row := conn.QueryRow(ctx, ` select coalesce(i18n.name, campsite_type.name) , ask_zone_preferences from campsite_type left join campsite_type_i18n as i18n on i18n.campsite_type_id = campsite_type.campsite_type_id and i18n.lang_tag = $1 where slug = $2 `, l.Language, campsiteType) var askZonePreferences bool if err := row.Scan(&f.Legend, &askZonePreferences); err != nil { return nil, err } if askZonePreferences { f.ZonePreferences = &form.Input{Name: "zone_preferences"} } rows, err := conn.Query(ctx, ` select option.campsite_type_option_id , 'campsite_type_option_' || option.campsite_type_option_id , coalesce(i18n.name, option.name) as l10_name , lower(range)::text , lower(range) , upper(range) from campsite_type_option as option join campsite_type using (campsite_type_id) left join campsite_type_option_i18n as i18n on i18n.campsite_type_option_id = option.campsite_type_option_id and i18n.lang_tag = $1 where slug = $2 and campsite_type.active order by option.position, l10_name `, l.Language, campsiteType) if err != nil { return nil, err } defer rows.Close() for rows.Next() { option := &campsiteTypeOption{ Input: &form.Input{}, } if err = rows.Scan(&option.ID, &option.Input.Name, &option.Label, &option.Input.Val, &option.Min, &option.Max); err != nil { return nil, err } f.Options = append(f.Options, option) } if rows.Err() != nil { return nil, rows.Err() } if f.ZonePreferences == nil && len(f.Options) == 0 { return nil, nil } return f, nil } func (f *bookingOptionFields) FillValues(r *http.Request) { if f.ZonePreferences != nil { f.ZonePreferences.FillValue(r) } for _, option := range f.Options { fillNumericField(option.Input, r, option.Min) } } func (f *bookingOptionFields) FillFromDatabase(ctx context.Context, conn *database.Conn, bookingID int) error { rows, err := conn.Query(ctx, ` select campsite_type_option.campsite_type_option_id , coalesce(units, lower(range))::text , to_price(coalesce(subtotal, 0), decimal_digits) from booking join campsite_type_option using (campsite_type_id) left join booking_option on booking.booking_id = booking_option.booking_id and booking_option.campsite_type_option_id = campsite_type_option.campsite_type_option_id join currency using (currency_code) where booking.booking_id = $1 `, bookingID) if err != nil { return err } defer rows.Close() for rows.Next() { var id int var units string var subtotal string if err = rows.Scan(&id, &units, &subtotal); err != nil { return err } for _, option := range f.Options { if option.ID == id { option.Input.Val = units option.Subtotal = subtotal break } } } return nil } func (f *bookingOptionFields) Valid(v *form.Validator, l *locale.Locale) { for _, option := range f.Options { if v.CheckRequired(option.Input, fmt.Sprintf(l.Gettext("%s can not be empty"), option.Label)) { if v.CheckValidInteger(option.Input, fmt.Sprintf(l.Gettext("%s must be an integer."), option.Label)) { if v.CheckMinInteger(option.Input, option.Min, fmt.Sprintf(l.Gettext("%s must be %d or greater."), option.Label, option.Min)) { v.CheckMaxInteger(option.Input, option.Max, fmt.Sprintf(l.Gettext("%s must be at most %d."), option.Label, option.Max)) } } } } } func newBookingCustomerFields(ctx context.Context, conn *database.Conn, l *locale.Locale) *bookingCustomerFields { return &bookingCustomerFields{ FullName: &form.Input{ Name: "full_name", }, Address: &form.Input{ Name: "address", }, PostalCode: &form.Input{ Name: "postal_code", }, City: &form.Input{ Name: "city", }, Country: &form.Select{ Name: "country", Options: form.MustGetCountryOptions(ctx, conn, l), }, Email: &form.Input{ Name: "email", }, Phone: &form.Input{ Name: "phone", }, Agreement: &form.Checkbox{ Name: "agreement", }, } } func (f *bookingCustomerFields) FillValues(r *http.Request) { f.FullName.FillValue(r) f.Address.FillValue(r) f.PostalCode.FillValue(r) f.City.FillValue(r) f.Country.FillValue(r) f.Email.FillValue(r) f.Phone.FillValue(r) f.Agreement.FillValue(r) } func (f *bookingCustomerFields) Valid(ctx context.Context, conn *database.Conn, v *form.Validator, l *locale.Locale) error { var country string if v.CheckSelectedOptions(f.Country, l.GettextNoop("Selected country is not valid.")) { country = f.Country.Selected[0] } if v.CheckRequired(f.FullName, l.GettextNoop("Full name can not be empty.")) { v.CheckMinLength(f.FullName, 1, l.GettextNoop("Full name must have at least one letter.")) } v.CheckRequired(f.Address, l.GettextNoop("Address can not be empty.")) v.CheckRequired(f.City, l.GettextNoop("Town or village can not be empty.")) if v.CheckRequired(f.PostalCode, l.GettextNoop("Postcode can not be empty.")) && country != "" { if _, err := v.CheckValidPostalCode(ctx, conn, f.PostalCode, country, l.GettextNoop("This postcode is not valid.")); err != nil { return err } } if v.CheckRequired(f.Email, l.GettextNoop("Email can not be empty.")) { v.CheckValidEmail(f.Email, l.GettextNoop("This email is not valid. It should be like name@domain.com.")) } if v.CheckRequired(f.Phone, l.GettextNoop("Phone can not be empty.")) && country != "" { if _, err := v.CheckValidPhone(ctx, conn, f.Phone, country, l.GettextNoop("This phone number is not valid.")); err != nil { return err } } v.Check(f.Agreement, f.Agreement.Checked, l.GettextNoop("It is mandatory to agree to the reservation conditions.")) return nil }