diff --git a/internal/auth/handlers.go b/internal/auth/handlers.go index 4b25773..38b414e 100644 --- a/internal/auth/handlers.go +++ b/internal/auth/handlers.go @@ -6,6 +6,12 @@ import ( ory "github.com/ory/client-go" ) +func LoginHandler(oryClient *ory.APIClient, tunnelURL string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, tunnelURL+"/ui/login", http.StatusSeeOther) + } +} + func LogOutHandler(oryClient *ory.APIClient, tunnelURL string) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { cookies := r.Header.Get("Cookie") diff --git a/internal/db/bookings.sql.go b/internal/db/bookings.sql.go index 4488a35..bceef5a 100644 --- a/internal/db/bookings.sql.go +++ b/internal/db/bookings.sql.go @@ -12,29 +12,33 @@ import ( ) const createBooking = `-- name: CreateBooking :one -INSERT INTO bookings (user_id, service_id, event_date, address, notes, status) -VALUES ($1, $2, $3, $4, $5, $6) -RETURNING id, user_id, service_id, event_date, address, notes, created_at, status +INSERT INTO bookings (user_id, service_id, event_date, address, notes, status, service_option, event_type) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8) +RETURNING id, user_id, service_id, event_date, address, notes, created_at, status, service_option, event_type ` type CreateBookingParams struct { - UserID sql.NullInt64 `json:"user_id"` - ServiceID sql.NullInt64 `json:"service_id"` - EventDate time.Time `json:"event_date"` - Address sql.NullString `json:"address"` - Notes sql.NullString `json:"notes"` - Status sql.NullString `json:"status"` + UserID sql.NullInt64 `json:"user_id"` + ServiceID sql.NullInt64 `json:"service_id"` + EventDate time.Time `json:"event_date"` + Address sql.NullString `json:"address"` + Notes sql.NullString `json:"notes"` + Status sql.NullString `json:"status"` + ServiceOption sql.NullString `json:"service_option"` + EventType sql.NullString `json:"event_type"` } type CreateBookingRow struct { - ID int64 `json:"id"` - UserID sql.NullInt64 `json:"user_id"` - ServiceID sql.NullInt64 `json:"service_id"` - EventDate time.Time `json:"event_date"` - Address sql.NullString `json:"address"` - Notes sql.NullString `json:"notes"` - CreatedAt sql.NullTime `json:"created_at"` - Status sql.NullString `json:"status"` + ID int64 `json:"id"` + UserID sql.NullInt64 `json:"user_id"` + ServiceID sql.NullInt64 `json:"service_id"` + EventDate time.Time `json:"event_date"` + Address sql.NullString `json:"address"` + Notes sql.NullString `json:"notes"` + CreatedAt sql.NullTime `json:"created_at"` + Status sql.NullString `json:"status"` + ServiceOption sql.NullString `json:"service_option"` + EventType sql.NullString `json:"event_type"` } func (q *Queries) CreateBooking(ctx context.Context, arg CreateBookingParams) (CreateBookingRow, error) { @@ -45,6 +49,8 @@ func (q *Queries) CreateBooking(ctx context.Context, arg CreateBookingParams) (C arg.Address, arg.Notes, arg.Status, + arg.ServiceOption, + arg.EventType, ) var i CreateBookingRow err := row.Scan( @@ -56,25 +62,29 @@ func (q *Queries) CreateBooking(ctx context.Context, arg CreateBookingParams) (C &i.Notes, &i.CreatedAt, &i.Status, + &i.ServiceOption, + &i.EventType, ) return i, err } const getBooking = `-- name: GetBooking :one -SELECT id, user_id, service_id, event_date, address, notes, created_at, status +SELECT id, user_id, service_id, event_date, address, notes, created_at, status, service_option, event_type FROM bookings WHERE id = $1 ` type GetBookingRow struct { - ID int64 `json:"id"` - UserID sql.NullInt64 `json:"user_id"` - ServiceID sql.NullInt64 `json:"service_id"` - EventDate time.Time `json:"event_date"` - Address sql.NullString `json:"address"` - Notes sql.NullString `json:"notes"` - CreatedAt sql.NullTime `json:"created_at"` - Status sql.NullString `json:"status"` + ID int64 `json:"id"` + UserID sql.NullInt64 `json:"user_id"` + ServiceID sql.NullInt64 `json:"service_id"` + EventDate time.Time `json:"event_date"` + Address sql.NullString `json:"address"` + Notes sql.NullString `json:"notes"` + CreatedAt sql.NullTime `json:"created_at"` + Status sql.NullString `json:"status"` + ServiceOption sql.NullString `json:"service_option"` + EventType sql.NullString `json:"event_type"` } func (q *Queries) GetBooking(ctx context.Context, id int64) (GetBookingRow, error) { @@ -89,26 +99,30 @@ func (q *Queries) GetBooking(ctx context.Context, id int64) (GetBookingRow, erro &i.Notes, &i.CreatedAt, &i.Status, + &i.ServiceOption, + &i.EventType, ) return i, err } const listBookingsByUser = `-- name: ListBookingsByUser :many -SELECT id, user_id, service_id, event_date, address, notes, created_at, status +SELECT id, user_id, service_id, event_date, address, notes, created_at, status, service_option, event_type FROM bookings WHERE user_id = $1 ORDER BY event_date DESC ` type ListBookingsByUserRow struct { - ID int64 `json:"id"` - UserID sql.NullInt64 `json:"user_id"` - ServiceID sql.NullInt64 `json:"service_id"` - EventDate time.Time `json:"event_date"` - Address sql.NullString `json:"address"` - Notes sql.NullString `json:"notes"` - CreatedAt sql.NullTime `json:"created_at"` - Status sql.NullString `json:"status"` + ID int64 `json:"id"` + UserID sql.NullInt64 `json:"user_id"` + ServiceID sql.NullInt64 `json:"service_id"` + EventDate time.Time `json:"event_date"` + Address sql.NullString `json:"address"` + Notes sql.NullString `json:"notes"` + CreatedAt sql.NullTime `json:"created_at"` + Status sql.NullString `json:"status"` + ServiceOption sql.NullString `json:"service_option"` + EventType sql.NullString `json:"event_type"` } func (q *Queries) ListBookingsByUser(ctx context.Context, userID sql.NullInt64) ([]ListBookingsByUserRow, error) { @@ -129,6 +143,8 @@ func (q *Queries) ListBookingsByUser(ctx context.Context, userID sql.NullInt64) &i.Notes, &i.CreatedAt, &i.Status, + &i.ServiceOption, + &i.EventType, ); err != nil { return nil, err } @@ -153,6 +169,8 @@ SELECT b.notes, b.created_at, b.status, + b.service_option, + b.event_type, s.name as service_name, s.description as service_description, s.price_cents as service_price_cents @@ -171,6 +189,8 @@ type ListBookingsWithServiceByUserRow struct { Notes sql.NullString `json:"notes"` CreatedAt sql.NullTime `json:"created_at"` Status sql.NullString `json:"status"` + ServiceOption sql.NullString `json:"service_option"` + EventType sql.NullString `json:"event_type"` ServiceName string `json:"service_name"` ServiceDescription sql.NullString `json:"service_description"` ServicePriceCents int32 `json:"service_price_cents"` @@ -194,6 +214,8 @@ func (q *Queries) ListBookingsWithServiceByUser(ctx context.Context, userID sql. &i.Notes, &i.CreatedAt, &i.Status, + &i.ServiceOption, + &i.EventType, &i.ServiceName, &i.ServiceDescription, &i.ServicePriceCents, diff --git a/internal/db/models.go b/internal/db/models.go index 30d211a..fc3c3a3 100644 --- a/internal/db/models.go +++ b/internal/db/models.go @@ -10,14 +10,16 @@ import ( ) type Booking struct { - ID int64 `json:"id"` - UserID sql.NullInt64 `json:"user_id"` - ServiceID sql.NullInt64 `json:"service_id"` - EventDate time.Time `json:"event_date"` - Notes sql.NullString `json:"notes"` - CreatedAt sql.NullTime `json:"created_at"` - Address sql.NullString `json:"address"` - Status sql.NullString `json:"status"` + ID int64 `json:"id"` + UserID sql.NullInt64 `json:"user_id"` + ServiceID sql.NullInt64 `json:"service_id"` + EventDate time.Time `json:"event_date"` + Notes sql.NullString `json:"notes"` + CreatedAt sql.NullTime `json:"created_at"` + Address sql.NullString `json:"address"` + Status sql.NullString `json:"status"` + ServiceOption sql.NullString `json:"service_option"` + EventType sql.NullString `json:"event_type"` } type GalleryPhoto struct { diff --git a/internal/db/queries/bookings.sql b/internal/db/queries/bookings.sql index c3448fb..9958077 100644 --- a/internal/db/queries/bookings.sql +++ b/internal/db/queries/bookings.sql @@ -1,10 +1,10 @@ -- name: CreateBooking :one -INSERT INTO bookings (user_id, service_id, event_date, address, notes, status) -VALUES ($1, $2, $3, $4, $5, $6) -RETURNING id, user_id, service_id, event_date, address, notes, created_at, status; +INSERT INTO bookings (user_id, service_id, event_date, address, notes, status, service_option, event_type) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8) +RETURNING id, user_id, service_id, event_date, address, notes, created_at, status, service_option, event_type; -- name: ListBookingsByUser :many -SELECT id, user_id, service_id, event_date, address, notes, created_at, status +SELECT id, user_id, service_id, event_date, address, notes, created_at, status, service_option, event_type FROM bookings WHERE user_id = $1 ORDER BY event_date DESC; @@ -19,6 +19,8 @@ SELECT b.notes, b.created_at, b.status, + b.service_option, + b.event_type, s.name as service_name, s.description as service_description, s.price_cents as service_price_cents @@ -28,6 +30,6 @@ WHERE b.user_id = $1 ORDER BY b.event_date DESC; -- name: GetBooking :one -SELECT id, user_id, service_id, event_date, address, notes, created_at, status +SELECT id, user_id, service_id, event_date, address, notes, created_at, status, service_option, event_type FROM bookings WHERE id = $1; diff --git a/internal/handlers/booking.go b/internal/handlers/booking.go index 7718733..b43259a 100644 --- a/internal/handlers/booking.go +++ b/internal/handlers/booking.go @@ -5,67 +5,136 @@ import ( "encoding/json" "html/template" "net/http" - "strconv" + "regexp" + "strings" "time" - "decor-by-hannahs/internal/auth" + ory "github.com/ory/client-go" + "decor-by-hannahs/internal/db" ) -func BookingPageHandler(q *db.Queries, tmpl *template.Template) http.HandlerFunc { +func validateAustralianAddress(address string) (bool, string) { + if len(strings.TrimSpace(address)) < 10 { + return false, "Address is too short. Please provide a complete address." + } + + australianStates := []string{"NSW", "VIC", "QLD", "SA", "WA", "TAS", "NT", "ACT"} + hasState := false + upperAddress := strings.ToUpper(address) + for _, state := range australianStates { + if strings.Contains(upperAddress, state) { + hasState = true + break + } + } + + postcodePattern := regexp.MustCompile(`\b\d{4}\b`) + hasPostcode := postcodePattern.MatchString(address) + + if !hasState && !hasPostcode { + return false, "Please include an Australian state (NSW, VIC, QLD, etc.) and postcode in your address." + } + + if !hasState { + return false, "Please include an Australian state (NSW, VIC, QLD, SA, WA, TAS, NT, or ACT) in your address." + } + + if !hasPostcode { + return false, "Please include a 4-digit postcode in your address." + } + + return true, "" +} + +func BookingPageHandler(q *db.Queries, tmpl *template.Template, oryClient *ory.APIClient) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { type data struct { - Title string - ActivePage string + Title string + ActivePage string + Authenticated bool + OryLoginURL string + } + if err := tmpl.ExecuteTemplate(w, "booking.tmpl", data{ + Title: "Book a Service", + ActivePage: "booking", + Authenticated: isAuthenticated(r, oryClient), + OryLoginURL: "/login", + }); err != nil { + http.Error(w, "Failed to render page", http.StatusInternalServerError) + return } - _ = tmpl.ExecuteTemplate(w, "booking.tmpl", data{Title: "Book a Service", ActivePage: "booking"}) } } -func BookingAPIHandler(q *db.Queries, tmpl *template.Template) http.HandlerFunc { +func BookingAPIHandler(q *db.Queries, tmpl *template.Template, oryClient *ory.APIClient) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method is not allowed", http.StatusMethodNotAllowed) return } - user, err := auth.GetUser(r.Context()) - if err != nil { + cookies := r.Header.Get("Cookie") + session, _, err := oryClient.FrontendAPI.ToSession(r.Context()).Cookie(cookies).Execute() + + if err != nil || session == nil || session.Active == nil || !*session.Active { http.Error(w, "Unauthorized", http.StatusUnauthorized) return } + email := getEmailFromSession(session) + if email == "" { + http.Error(w, "No email in session", http.StatusBadRequest) + return + } + + oryID := sql.NullString{String: session.Identity.Id, Valid: true} + user, err := q.GetUserByOryID(r.Context(), oryID) + if err != nil { + user, err = q.CreateUser(r.Context(), db.CreateUserParams{ + Email: email, + OryIdentityID: oryID, + }) + if err != nil { + http.Error(w, "Failed to get or create user", http.StatusInternalServerError) + return + } + } + var in struct { - ServiceID string `json:"service_id"` - EventDate string `json:"event_date"` - Address string `json:"address"` - Notes string `json:"notes"` + ServiceOption string `json:"service_option"` + EventType string `json:"event_type"` + EventDate string `json:"event_date"` + Address string `json:"address"` + Notes string `json:"notes"` } if err := json.NewDecoder(r.Body).Decode(&in); err != nil { http.Error(w, "Bad request", http.StatusBadRequest) return } + + if valid, msg := validateAustralianAddress(in.Address); !valid { + http.Error(w, msg, http.StatusBadRequest) + return + } + eventTime, err := time.Parse(time.RFC3339, in.EventDate) if err != nil { http.Error(w, "Invalid date", http.StatusBadRequest) return } - svc, err := strconv.ParseInt(in.ServiceID, 10, 64) - if err != nil { - http.Error(w, "Invalid Service ID", http.StatusBadRequest) - return - } - svcID := sql.NullInt64{Int64: svc, Valid: true} userID := sql.NullInt64{Int64: user.ID, Valid: true} booking, err := q.CreateBooking(r.Context(), db.CreateBookingParams{ - UserID: userID, - ServiceID: svcID, - EventDate: eventTime, - Address: sqlNullString(in.Address), - Notes: sqlNullString(in.Notes), - Status: sql.NullString{String: "pending", Valid: true}, + UserID: userID, + ServiceID: sql.NullInt64{}, + EventDate: eventTime, + Address: sqlNullString(in.Address), + Notes: sqlNullString(in.Notes), + Status: sql.NullString{String: "pending", Valid: true}, + ServiceOption: sqlNullString(in.ServiceOption), + EventType: sqlNullString(in.EventType), }) if err != nil { http.Error(w, "Failed to create booking", http.StatusInternalServerError) diff --git a/internal/handlers/helpers.go b/internal/handlers/helpers.go index 5925f3d..f81a143 100644 --- a/internal/handlers/helpers.go +++ b/internal/handlers/helpers.go @@ -4,6 +4,9 @@ import ( "database/sql" "encoding/json" "net/http" + "os" + + ory "github.com/ory/client-go" ) func sqlNullString(s string) sql.NullString { @@ -13,6 +16,13 @@ func sqlNullString(s string) sql.NullString { return sql.NullString{String: s, Valid: true} } +func nullStringToString(ns sql.NullString) string { + if ns.Valid { + return ns.String + } + return "" +} + func sqlNullUUID(s string) sql.NullString { if s == "" { return sql.NullString{} @@ -24,3 +34,24 @@ func writeJSON(w http.ResponseWriter, v interface{}) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(v) } + +func getOryAPIURL() string { + oryAPIURL := os.Getenv("ORY_API_URL") + if oryAPIURL == "" { + tunnelPort := os.Getenv("TUNNEL_PORT") + if tunnelPort == "" { + tunnelPort = "4000" + } + oryAPIURL = "http://localhost:" + tunnelPort + } + return oryAPIURL +} + +func isAuthenticated(r *http.Request, oryClient *ory.APIClient) bool { + cookies := r.Header.Get("Cookie") + session, _, err := oryClient.FrontendAPI.ToSession(r.Context()).Cookie(cookies).Execute() + if err != nil { + return false + } + return session != nil && session.Active != nil && *session.Active +} diff --git a/internal/handlers/home.go b/internal/handlers/home.go index e9cc415..109148c 100644 --- a/internal/handlers/home.go +++ b/internal/handlers/home.go @@ -8,10 +8,12 @@ import ( "path/filepath" "sort" + ory "github.com/ory/client-go" + "decor-by-hannahs/internal/db" ) -func HomeHandler(q *db.Queries, tmpl *template.Template) http.HandlerFunc { +func HomeHandler(q *db.Queries, tmpl *template.Template, oryClient *ory.APIClient) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { log.Printf("HomeHandler called: method=%s path=%s", r.Method, r.URL.Path) if r.URL.Path != "/" { @@ -42,11 +44,19 @@ func HomeHandler(q *db.Queries, tmpl *template.Template) http.HandlerFunc { }) type data struct { - Title string - Photos []Photo - ActivePage string + Title string + Photos []Photo + ActivePage string + Authenticated bool + OryLoginURL string } - if err := tmpl.ExecuteTemplate(w, "home.tmpl", data{Title: "Home", Photos: photos, ActivePage: "home"}); err != nil { + if err := tmpl.ExecuteTemplate(w, "home.tmpl", data{ + Title: "Home", + Photos: photos, + ActivePage: "home", + Authenticated: isAuthenticated(r, oryClient), + OryLoginURL: "/login", + }); err != nil { log.Printf("Error executing home.tmpl: %v", err) http.Error(w, "Failed to render page", http.StatusInternalServerError) } diff --git a/internal/handlers/profile.go b/internal/handlers/profile.go index 1ca6daa..cb6d45d 100644 --- a/internal/handlers/profile.go +++ b/internal/handlers/profile.go @@ -1,77 +1,99 @@ package handlers import ( + "database/sql" "html/template" "log" "net/http" - "os" "strconv" - "decor-by-hannahs/internal/auth" + ory "github.com/ory/client-go" + "decor-by-hannahs/internal/db" ) type BookingWithService struct { - ID int64 - ServiceName string - ServiceDescription string - ServicePriceCents int32 - EventDate string - Address string - Notes string - Status string - CreatedAt string + ID int64 + ServiceName string + ServiceDescription string + ServicePriceCents int32 + ServiceOption string + EventType string + EventDate string + Address string + Notes string + Status string + CreatedAt string } type ProfileData struct { - Authenticated bool - Email string - DisplayName string - ID string - ActivePage string - Bookings []BookingWithService - OrySettingsURL string + Authenticated bool + Email string + DisplayName string + ID string + ActivePage string + Bookings []BookingWithService + OrySettingsURL string + OryLoginURL string } -func ProfileHandler(q *db.Queries, tmpl *template.Template) http.HandlerFunc { +func ProfileHandler(q *db.Queries, tmpl *template.Template, oryClient *ory.APIClient) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - pd := ProfileData{ActivePage: "profile"} + oryAPIURL := getOryAPIURL() + pd := ProfileData{ + ActivePage: "profile", + OryLoginURL: "/login", + OrySettingsURL: oryAPIURL + "/ui/settings", + } - user, err := auth.GetUser(r.Context()) - if err == nil { - pd.Authenticated = true - pd.Email = user.Email - pd.ID = strconv.FormatInt(user.ID, 10) - pd.DisplayName = user.Email - - bookings, err := q.ListBookingsWithServiceByUser(r.Context(), user.ID) - if err != nil { - log.Printf("Error fetching bookings: %v", err) - } else { - for _, b := range bookings { - pd.Bookings = append(pd.Bookings, BookingWithService{ - ID: b.ID, - ServiceName: b.ServiceName, - ServiceDescription: nullStringToString(b.ServiceDescription), - ServicePriceCents: b.ServicePriceCents, - EventDate: b.EventDate.Format("January 2, 2006"), - Address: nullStringToString(b.Address), - Notes: nullStringToString(b.Notes), - Status: b.Status, - CreatedAt: b.CreatedAt.Time.Format("Jan 2, 2006"), + cookies := r.Header.Get("Cookie") + session, _, err := oryClient.FrontendAPI.ToSession(r.Context()).Cookie(cookies).Execute() + + if err == nil && session != nil && session.Active != nil && *session.Active { + email := getEmailFromSession(session) + if email != "" { + oryID := sql.NullString{String: session.Identity.Id, Valid: true} + user, err := q.GetUserByOryID(r.Context(), oryID) + if err != nil { + user, err = q.CreateUser(r.Context(), db.CreateUserParams{ + Email: email, + OryIdentityID: oryID, }) + if err != nil { + log.Printf("Failed to create user: %v", err) + } else { + log.Printf("Created new user: %s (ID: %d)", email, user.ID) + } } - } - oryAPIURL := os.Getenv("ORY_API_URL") - if oryAPIURL == "" { - tunnelPort := os.Getenv("TUNNEL_PORT") - if tunnelPort == "" { - tunnelPort = "4000" + if err == nil { + pd.Authenticated = true + pd.Email = user.Email + pd.ID = strconv.FormatInt(user.ID, 10) + pd.DisplayName = user.Email + + bookings, err := q.ListBookingsWithServiceByUser(r.Context(), sql.NullInt64{Int64: user.ID, Valid: true}) + if err != nil { + log.Printf("Error fetching bookings: %v", err) + } else { + for _, b := range bookings { + pd.Bookings = append(pd.Bookings, BookingWithService{ + ID: b.ID, + ServiceName: b.ServiceName, + ServiceDescription: nullStringToString(b.ServiceDescription), + ServicePriceCents: b.ServicePriceCents, + ServiceOption: nullStringToString(b.ServiceOption), + EventType: nullStringToString(b.EventType), + EventDate: b.EventDate.Format("January 2, 2006"), + Address: nullStringToString(b.Address), + Notes: nullStringToString(b.Notes), + Status: nullStringToString(b.Status), + CreatedAt: b.CreatedAt.Time.Format("Jan 2, 2006"), + }) + } + } } - oryAPIURL = "http://localhost:" + tunnelPort } - pd.OrySettingsURL = oryAPIURL + "/ui/settings" } if err := tmpl.ExecuteTemplate(w, "profile.tmpl", pd); err != nil { @@ -80,3 +102,18 @@ func ProfileHandler(q *db.Queries, tmpl *template.Template) http.HandlerFunc { } } } + +func getEmailFromSession(session *ory.Session) string { + if session.Identity.Traits == nil { + return "" + } + traits, ok := session.Identity.Traits.(map[string]interface{}) + if !ok { + return "" + } + email, ok := traits["email"].(string) + if !ok { + return "" + } + return email +} diff --git a/internal/tmpl/_partials.tmpl b/internal/tmpl/_partials.tmpl index 56a42d9..0a70383 100644 --- a/internal/tmpl/_partials.tmpl +++ b/internal/tmpl/_partials.tmpl @@ -27,10 +27,17 @@ Book + {{if .Authenticated}} Profile + {{else}} + + + Login + + {{end}} @@ -58,7 +65,11 @@ Catalog Gallery Book + {{if .Authenticated}} Profile + {{else}} + Login + {{end}}