/* * SPDX-FileCopyrightText: 2023 jordi fita mas * SPDX-License-Identifier: AGPL-3.0-only */ package booking import ( "context" "dev.tandem.ws/tandem/camper/pkg/ods" "errors" "fmt" "net/http" "strconv" "strings" "time" "golang.org/x/text/language" "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" "dev.tandem.ws/tandem/camper/pkg/uuid" ) type AdminHandler struct { } func NewAdminHandler() *AdminHandler { return &AdminHandler{} } func (h *AdminHandler) 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: serveBookingIndex(w, r, user, company, conn) case http.MethodPost: addBooking(w, r, user, company, conn) default: httplib.MethodNotAllowed(w, r, http.MethodGet, http.MethodPost) } case "new": switch r.Method { case http.MethodGet: serveAdminBookingForm(w, r, user, company, conn, 0, "/admin/bookings/new") default: httplib.MethodNotAllowed(w, r, http.MethodGet) } default: if !uuid.Valid(head) { http.NotFound(w, r) return } h.bookingHandler(user, company, conn, head).ServeHTTP(w, r) } }) } func serveAdminBookingForm(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn, id int, url string) { f, err := newAdminBookingForm(r, conn, company, user.Locale) if err != nil { panic(err) } f.ID = id f.URL = url f.MustRender(w, r, user, company) } func (h *AdminHandler) bookingHandler(user *auth.User, company *auth.Company, conn *database.Conn, slug string) 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: if err := r.ParseForm(); err != nil { panic(err) } if len(r.Form) > 0 { // Act as if it was a new form, because everything needed to render form fields is // already passed as in request query. id, err := conn.GetInt(r.Context(), "select booking_id from booking where slug = $1", slug) if err != nil { if database.ErrorIsNotFound(err) { http.NotFound(w, r) return } panic(err) } serveAdminBookingForm(w, r, user, company, conn, id, "/admin/bookings/"+slug) return } f := newEmptyAdminBookingForm(r.Context(), conn, company, user.Locale) if err := f.FillFromDatabase(r.Context(), conn, company, slug, user.Locale); err != nil { if database.ErrorIsNotFound(err) { http.NotFound(w, r) return } panic(err) } f.MustRender(w, r, user, company) case http.MethodPut: updateBooking(w, r, user, company, conn, slug) default: httplib.MethodNotAllowed(w, r, http.MethodGet, http.MethodPut) } case "check-in": switch r.Method { case http.MethodGet: serveCheckInForm(w, r, user, company, conn, slug) case http.MethodPost: checkInBooking(w, r, user, company, conn, slug) default: httplib.MethodNotAllowed(w, r, http.MethodGet, http.MethodPost) } case "guest": switch r.Method { case http.MethodGet: serveGuestForm(w, r, user, company, conn, slug) default: httplib.MethodNotAllowed(w, r, http.MethodGet) } default: http.NotFound(w, r) } }) } func serveBookingIndex(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn) { filters := newFilterForm(r.Context(), conn, company, user.Locale) if err := filters.Parse(r); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } bookings, err := collectBookingEntries(r.Context(), conn, user.Locale.Language, filters) if err != nil { panic(err) } page := &bookingIndex{ Bookings: filters.buildCursor(bookings), Filters: filters, } page.MustRender(w, r, user, company) } func collectBookingEntries(ctx context.Context, conn *database.Conn, lang language.Tag, filters *filterForm) ([]*bookingEntry, error) { where, args := filters.BuildQuery([]interface{}{lang.String()}) rows, err := conn.Query(ctx, fmt.Sprintf(` select booking_id , left(slug::text, 10) , '/admin/bookings/' || slug , lower(stay) , upper(stay) , holder_name , booking.booking_status , coalesce(i18n.name, status.name) from booking join booking_status as status using (booking_status) left join booking_status_i18n as i18n on status.booking_status = i18n.booking_status and i18n.lang_tag = $1 where (%s) order by lower(stay) desc , booking_id desc LIMIT %d `, where, filters.perPage+1), args...) if err != nil { return nil, err } defer rows.Close() var entries []*bookingEntry for rows.Next() { entry := &bookingEntry{} if err = rows.Scan(&entry.ID, &entry.Reference, &entry.URL, &entry.ArrivalDate, &entry.DepartureDate, &entry.HolderName, &entry.Status, &entry.StatusLabel); err != nil { return nil, err } entries = append(entries, entry) } return entries, nil } type bookingEntry struct { ID int Reference string URL string ArrivalDate time.Time DepartureDate time.Time HolderName string Status string StatusLabel string } type bookingIndex struct { Bookings []*bookingEntry Filters *filterForm } func (page bookingIndex) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company) { switch r.URL.Query().Get("format") { case "ods": columns := []string{ "Reference", "Arrival Date", "Departure Date", "Holder Name", "Status", } table, err := ods.WriteTable(page.Bookings, columns, user.Locale, func(sb *strings.Builder, entry *bookingEntry) error { if err := ods.WriteCellString(sb, entry.Reference); err != nil { return err } ods.WriteCellDate(sb, entry.ArrivalDate) ods.WriteCellDate(sb, entry.DepartureDate) if err := ods.WriteCellString(sb, entry.HolderName); err != nil { return err } if err := ods.WriteCellString(sb, entry.StatusLabel); err != nil { return err } return nil }) if err != nil { panic(err) } ods.MustWriteResponse(w, table, user.Locale.Pgettext("bookings.ods", "filename")) default: if httplib.IsHTMxRequest(r) && page.Filters.pagination { template.MustRenderAdminNoLayout(w, r, user, company, "booking/results.gohtml", page) } else { template.MustRenderAdminFiles(w, r, user, company, page, "booking/index.gohtml", "booking/results.gohtml") } } } type filterForm struct { locale *locale.Locale company *auth.Company perPage int pagination bool HolderName *form.Input BookingStatus *form.Select FromDate *form.Input ToDate *form.Input Cursor *form.Input } func newFilterForm(ctx context.Context, conn *database.Conn, company *auth.Company, locale *locale.Locale) *filterForm { return &filterForm{ locale: locale, company: company, perPage: 25, HolderName: &form.Input{ Name: "holder_name", }, BookingStatus: &form.Select{ Name: "booking_status", Options: mustGetBookingStatusOptions(ctx, conn, locale), }, FromDate: &form.Input{ Name: "from_date", }, ToDate: &form.Input{ Name: "to_date", }, Cursor: &form.Input{ Name: "cursor", }, } } func mustGetBookingStatusOptions(ctx context.Context, conn *database.Conn, locale *locale.Locale) []*form.Option { return form.MustGetOptions(ctx, conn, ` select booking_status.booking_status , isi18n.name from booking_status join booking_status_i18n isi18n using(booking_status) where isi18n.lang_tag = $1 and booking_status <> 'created' order by booking_status`, locale.Language) } func (f *filterForm) Parse(r *http.Request) error { if err := r.ParseForm(); err != nil { return err } f.HolderName.FillValue(r) f.BookingStatus.FillValue(r) f.FromDate.FillValue(r) f.ToDate.FillValue(r) f.Cursor.FillValue(r) f.pagination = f.Cursor.Val != "" return nil } func (f *filterForm) BuildQuery(args []interface{}) (string, []interface{}) { var where []string appendWhere := func(expression string, value interface{}) { args = append(args, value) where = append(where, fmt.Sprintf(expression, len(args))) } maybeAppendWhere := func(expression string, value string, conv func(string) interface{}) { if value != "" { if conv == nil { appendWhere(expression, value) } else { appendWhere(expression, conv(value)) } } } appendWhere("booking.company_id = $%d", f.company.ID) maybeAppendWhere("booking.holder_name ILIKE $%d", f.HolderName.Val, func(v string) interface{} { return "%" + v + "%" }) if len(f.BookingStatus.Selected) == 0 { where = append(where, "booking.booking_status <> 'created'") } else { maybeAppendWhere("booking.booking_status = $%d", f.BookingStatus.String(), nil) } maybeAppendWhere("lower(stay) >= $%d", f.FromDate.Val, nil) maybeAppendWhere("lower(stay) <= $%d", f.ToDate.Val, nil) if f.Cursor.Val != "" { params := strings.Split(f.Cursor.Val, ";") if len(params) == 2 { where = append(where, fmt.Sprintf("(lower(stay), booking_id) < ($%d, $%d)", len(args)+1, len(args)+2)) args = append(args, params[0]) args = append(args, params[1]) } } return strings.Join(where, ") AND ("), args } func (f *filterForm) buildCursor(bookings []*bookingEntry) []*bookingEntry { if len(bookings) <= f.perPage { f.Cursor.Val = "" return bookings } bookings = bookings[:f.perPage] last := bookings[f.perPage-1] f.Cursor.Val = fmt.Sprintf("%s;%d", last.ArrivalDate.Format(database.ISODateFormat), last.ID) return bookings } func (f *filterForm) HasValue() bool { return f.HolderName.Val != "" || (len(f.BookingStatus.Selected) > 0 && f.BookingStatus.Selected[0] != "") || f.FromDate.Val != "" || f.ToDate.Val != "" } type adminBookingForm struct { *bookingForm ID int URL string Status string Campsites []*CampsiteEntry selected []int Months []*Month Error error } func newEmptyAdminBookingForm(ctx context.Context, conn *database.Conn, company *auth.Company, l *locale.Locale) *adminBookingForm { return &adminBookingForm{ bookingForm: newEmptyBookingForm(ctx, conn, company, l), } } func newAdminBookingForm(r *http.Request, conn *database.Conn, company *auth.Company, l *locale.Locale) (*adminBookingForm, error) { inner, err := newBookingForm(r, company, conn, l) if err != nil { return nil, err } if inner.Options != nil { for _, option := range inner.Options.Options { option.Subtotal = findSubtotal(option.ID, inner.Cart) } } f := &adminBookingForm{ bookingForm: inner, } // Dates and Campsite are valid if inner.Guests != nil { selected := r.Form["campsite"] if err = f.FetchCampsites(r.Context(), conn, company, selected); err != nil { return nil, err } } return f, nil } func (f *adminBookingForm) FetchCampsites(ctx context.Context, conn *database.Conn, company *auth.Company, selected []string) error { arrivalDate, _ := time.Parse(database.ISODateFormat, f.Dates.ArrivalDate.Val) from := arrivalDate.AddDate(0, 0, -1) departureDate, _ := time.Parse(database.ISODateFormat, f.Dates.DepartureDate.Val) to := departureDate.AddDate(0, 0, 2) f.Months = CollectMonths(from, to) var err error f.Campsites, err = CollectCampsiteEntries(ctx, company, conn, from, to, f.CampsiteType.String()) if err != nil { return err } for _, s := range selected { ID, _ := strconv.Atoi(s) for _, c := range f.Campsites { if c.ID == ID { f.selected = append(f.selected, c.ID) c.Selected = true break } } } return nil } func findSubtotal(ID int, cart *bookingCart) string { none := "0.0" if cart == nil || cart.Draft == nil { return none } for _, option := range cart.Draft.Options { if option.ID == ID { return option.Subtotal } } return none } func (f *adminBookingForm) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company) { if httplib.IsHTMxRequest(r) { template.MustRenderAdminNoLayoutFiles(w, r, user, company, f, "booking/fields.gohtml", "booking/grid.gohtml") } else { template.MustRenderAdminFiles(w, r, user, company, f, "booking/form.gohtml", "booking/fields.gohtml", "booking/grid.gohtml") } } func addBooking(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn) { processAdminBookingForm(w, r, user, company, conn, 0, func(ctx context.Context, tx *database.Tx, f *adminBookingForm) error { var err error f.ID, err = tx.AddBookingFromPayment(ctx, f.PaymentSlug.Val) if err != nil { return err } return tx.EditBooking( ctx, f.ID, f.Customer.FullName.Val, f.Customer.Address.Val, f.Customer.PostalCode.Val, f.Customer.City.Val, f.Customer.Country.String(), f.Customer.Email.Val, f.Customer.Phone.Val, language.Make("und"), "confirmed", f.selected, ) }) } func updateBooking(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn, slug string) { var bookingID int var bookingStatus string var langTag string row := conn.QueryRow(r.Context(), "select booking_id, booking_status, lang_tag from booking where slug = $1", slug) if err := row.Scan(&bookingID, &bookingStatus, &langTag); err != nil { if database.ErrorIsNotFound(err) { http.NotFound(w, r) return } panic(err) } processAdminBookingForm(w, r, user, company, conn, bookingID, func(ctx context.Context, tx *database.Tx, f *adminBookingForm) error { var err error _, err = tx.EditBookingFromPayment(ctx, slug, f.PaymentSlug.Val) if err != nil { return err } if bookingStatus == "created" { bookingStatus = "confirmed" } return tx.EditBooking( ctx, f.ID, f.Customer.FullName.Val, f.Customer.Address.Val, f.Customer.PostalCode.Val, f.Customer.City.Val, f.Customer.Country.String(), f.Customer.Email.Val, f.Customer.Phone.Val, language.Make(langTag), bookingStatus, f.selected, ) }) } func processAdminBookingForm(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn, bookingID int, act func(ctx context.Context, tx *database.Tx, f *adminBookingForm) error) { f, err := newAdminBookingForm(r, conn, company, user.Locale) if err != nil { panic(err) } if err := user.VerifyCSRFToken(r); err != nil { http.Error(w, err.Error(), http.StatusForbidden) return } f.ID = bookingID if ok, err := f.Valid(r.Context(), conn, user.Locale); err != nil { panic(err) } else if !ok { if !httplib.IsHTMxRequest(r) { w.WriteHeader(http.StatusUnprocessableEntity) } f.MustRender(w, r, user, company) return } tx := conn.MustBegin(r.Context()) defer tx.Rollback(r.Context()) if err := act(r.Context(), tx, f); err != nil { panic(err) } tx.MustCommit(r.Context()) httplib.Redirect(w, r, "/admin/bookings", http.StatusSeeOther) } func (f *adminBookingForm) 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 f.Options != nil { f.Options.Valid(v, l) } var country string if f.Customer.Country.ValidOptionsSelected() { country = f.Customer.Country.Selected[0] } if v.CheckRequired(f.Customer.FullName, l.GettextNoop("Full name can not be empty.")) { v.CheckMinLength(f.Customer.FullName, 1, l.GettextNoop("Full name must have at least one letter.")) } if f.Customer.PostalCode.Val != "" { if country == "" { v.Check(f.Customer.PostalCode, false, l.GettextNoop("Country can not be empty to validate the postcode.")) } else if _, err := v.CheckValidPostalCode(ctx, conn, f.Customer.PostalCode, country, l.GettextNoop("This postcode is not valid.")); err != nil { return false, err } } if f.Customer.Email.Val != "" { v.CheckValidEmail(f.Customer.Email, l.GettextNoop("This email is not valid. It should be like name@domain.com.")) } if f.Customer.Phone.Val != "" { if country == "" { v.Check(f.Customer.Phone, false, l.GettextNoop("Country can not be empty to validate the phone.")) } else if _, err := v.CheckValidPhone(ctx, conn, f.Customer.Phone, country, l.GettextNoop("This phone number is not valid.")); err != nil { return false, err } } if len(f.selected) == 0 { f.Error = errors.New(l.Gettext("You must select at least one accommodation.")) v.AllOK = false } else if f.Dates.ArrivalDate.Error == nil && f.Dates.DepartureDate.Error == nil { if available, err := datesAvailable(ctx, conn, f.ID, f.Dates, f.selected); err != nil { return false, err } else if !available { f.Error = errors.New(l.Gettext("The selected accommodations have no available openings in the requested dates.")) v.AllOK = false } } return v.AllOK, nil } func datesAvailable(ctx context.Context, conn *database.Conn, bookingID int, dates *DateFields, selectedCampsites []int) (bool, error) { return conn.GetBool(ctx, ` select not exists ( select 1 from camper.booking_campsite where booking_id <> $1 and campsite_id = any ($4) and stay && daterange($2::date, $3::date) ) `, bookingID, dates.ArrivalDate, dates.DepartureDate, selectedCampsites, ) } func (f *adminBookingForm) FillFromDatabase(ctx context.Context, conn *database.Conn, company *auth.Company, slug string, l *locale.Locale) error { f.Cart = &bookingCart{Draft: &paymentDraft{}} f.Customer = newBookingCustomerFields(ctx, conn, l) var arrivalDate string var departureDate string var acsiCard bool var zonePreferences string var selected []string row := conn.QueryRow(ctx, ` select booking_id , '/admin/bookings/' || booking.slug , booking_status , array[campsite_type.slug::text] , lower(booking.stay)::text , upper(booking.stay)::text , upper(booking.stay) - lower(booking.stay) , to_price(subtotal_nights, decimal_digits) , number_adults , to_price(subtotal_adults, decimal_digits) , number_teenagers , to_price(subtotal_teenagers, decimal_digits) , number_children , to_price(subtotal_children, decimal_digits) , number_dogs , to_price(subtotal_dogs, decimal_digits) , to_price(subtotal_tourist_tax, decimal_digits) , to_price(total, decimal_digits) , acsi_card , holder_name , coalesce(address, '') , coalesce(postal_code, '') , coalesce(city, '') , array[coalesce(country_code::text, '')] , coalesce(email::text, '') , coalesce(phone::text, '') , zone_preferences , array_agg(coalesce(campsite_id::text, '')) from booking join campsite_type using (campsite_type_id) left join campsite_type_pet_cost as pet using (campsite_type_id) left join booking_campsite using (booking_id) join currency using (currency_code) where booking.slug = $1 group by booking_id , campsite_type.slug , booking.stay , subtotal_nights , number_adults , subtotal_adults , number_teenagers , subtotal_teenagers , number_children , subtotal_children , number_dogs , subtotal_dogs , subtotal_tourist_tax , total , acsi_card , holder_name , address , postal_code , city , country_code , email , phone , zone_preferences , decimal_digits `, slug) if err := row.Scan( &f.ID, &f.URL, &f.Status, &f.CampsiteType.Selected, &arrivalDate, &departureDate, &f.Cart.Draft.NumNights, &f.Cart.Draft.Nights, &f.Cart.Draft.NumAdults, &f.Cart.Draft.Adults, &f.Cart.Draft.NumTeenagers, &f.Cart.Draft.Teenagers, &f.Cart.Draft.NumChildren, &f.Cart.Draft.Children, &f.Cart.Draft.NumDogs, &f.Cart.Draft.Dogs, &f.Cart.Draft.TouristTax, &f.Cart.Draft.Total, &acsiCard, &f.Customer.FullName.Val, &f.Customer.Address.Val, &f.Customer.PostalCode.Val, &f.Customer.City.Val, &f.Customer.Country.Selected, &f.Customer.Email.Val, &f.Customer.Phone.Val, &zonePreferences, &selected, ); err != nil { return err } var err error f.Dates, err = NewDateFields(ctx, conn, f.CampsiteType.String()) if err != nil { return err } f.Dates.ArrivalDate.Val = arrivalDate f.Dates.DepartureDate.Val = departureDate f.Dates.AdjustValues(l) f.Guests, err = newBookingGuestFields(ctx, conn, f.CampsiteType.String(), arrivalDate, departureDate) if err != nil { return err } f.Guests.NumberAdults.Val = strconv.Itoa(f.Cart.Draft.NumAdults) f.Guests.NumberTeenagers.Val = strconv.Itoa(f.Cart.Draft.NumTeenagers) f.Guests.NumberChildren.Val = strconv.Itoa(f.Cart.Draft.NumChildren) if f.Guests.NumberDogs != nil { f.Guests.NumberDogs.Val = strconv.Itoa(f.Cart.Draft.NumDogs) } if f.Guests.ACSICard != nil { f.Guests.ACSICard.Checked = acsiCard } f.Guests.AdjustValues(f.Cart.Draft.NumAdults+f.Cart.Draft.NumTeenagers+f.Cart.Draft.NumChildren, l) f.Options, err = newBookingOptionFields(ctx, conn, f.CampsiteType.String(), l) if err != nil { return err } if f.Options != nil { if f.Options.ZonePreferences != nil { f.Options.ZonePreferences.Val = zonePreferences } if err = f.Options.FillFromDatabase(ctx, conn, f.ID); err != nil { return err } } if err = f.FetchCampsites(ctx, conn, company, selected); err != nil { return err } return nil }