/* * SPDX-FileCopyrightText: 2023 jordi fita mas * SPDX-License-Identifier: AGPL-3.0-only */ package booking import ( "context" "errors" "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" ) 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: f, err := newAdminBookingForm(r, conn, company, user.Locale) if err != nil { panic(err) } f.MustRender(w, r, user, company) 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) { bookings, err := collectBookingEntries(r.Context(), conn, user.Locale.Language) if err != nil { panic(err) } page := bookingIndex(bookings) page.MustRender(w, r, user, company) } func collectBookingEntries(ctx context.Context, conn *database.Conn, lang language.Tag) ([]*bookingEntry, error) { rows, err := conn.Query(ctx, ` select 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 order by stay desc `, lang) if err != nil { return nil, err } defer rows.Close() var entries []*bookingEntry for rows.Next() { entry := &bookingEntry{} if err = rows.Scan(&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 { Reference string URL string ArrivalDate time.Time DepartureDate time.Time HolderName string Status string StatusLabel string } type bookingIndex []*bookingEntry 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", } ods, err := writeTableOds(page, columns, user.Locale, func(sb *strings.Builder, entry *bookingEntry) error { if err := writeCellString(sb, entry.Reference); err != nil { return err } writeCellDate(sb, entry.ArrivalDate) writeCellDate(sb, entry.DepartureDate) if err := writeCellString(sb, entry.HolderName); err != nil { return err } if err := writeCellString(sb, entry.StatusLabel); err != nil { return err } return nil }) if err != nil { panic(err) } mustWriteOdsResponse(w, ods, user.Locale.Pgettext("bookings.ods", "filename")) default: template.MustRenderAdmin(w, r, user, company, "booking/index.gohtml", page) } } type adminBookingForm struct { *bookingForm ID int Campsites []*CampsiteEntry selected []int Months []*Month Error error } 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 { arrivalDate, _ := time.Parse(database.ISODateFormat, inner.Dates.ArrivalDate.Val) from := arrivalDate.AddDate(0, 0, -1) departureDate, _ := time.Parse(database.ISODateFormat, inner.Dates.DepartureDate.Val) to := departureDate.AddDate(0, 0, 2) f.Months = CollectMonths(from, to) f.Campsites, err = CollectCampsiteEntries(r.Context(), company, conn, from, to, inner.CampsiteType.String()) if err != nil { return nil, err } selected := r.Form["campsite"] 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 f, 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) { 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 } 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()) if err := performAddBooking(r.Context(), tx, f); err == nil { if err := tx.Commit(r.Context()); err != nil { panic(err) } } else { if err := tx.Rollback(r.Context()); err != nil { panic(err) } panic(err) } httplib.Redirect(w, r, "/admin/bookings", http.StatusSeeOther) } func performAddBooking(ctx context.Context, tx *database.Tx, f *adminBookingForm) error { var bookingID int var err error bookingID, err = tx.AddBookingFromPayment(ctx, f.PaymentSlug.Val) if err != nil { return err } return tx.EditBooking( ctx, bookingID, 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 (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.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, dates *DateFields, selectedCampsites []int) (bool, error) { return conn.GetBool(ctx, "select not exists (select 1 from camper.booking_campsite where campsite_id = any ($3) and stay && daterange($1::date, $2::date))", dates.ArrivalDate, dates.DepartureDate, selectedCampsites) }