Skip to content

Commit d89cd9c

Browse files
feat: added venue owner status change
1 parent acf141c commit d89cd9c

File tree

5 files changed

+358
-1
lines changed

5 files changed

+358
-1
lines changed

cmd/api/admin_dashboard.go

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@ package main
22

33
import (
44
"context"
5+
"khel/internal/domain/venues"
6+
"khel/internal/params"
57
"net/http"
8+
"strings"
69
"time"
710
)
811

@@ -30,3 +33,99 @@ func (app *application) adminOverviewHandler(w http.ResponseWriter, r *http.Requ
3033

3134
_ = app.jsonResponse(w, http.StatusOK, out)
3235
}
36+
37+
// Filters applied to the venue list (admin)
38+
type AdminVenueListFilters struct {
39+
Sport string `json:"sport"` // "" when not filtered
40+
Status string `json:"status"` // "" when not filtered
41+
}
42+
43+
// Full paginated response for admin venue listing
44+
type VenueListWithMetaResponse struct {
45+
Venues []VenueListResponse `json:"venues"`
46+
Pagination params.Pagination `json:"pagination"`
47+
Filters AdminVenueListFilters `json:"filters"`
48+
}
49+
50+
// @Summary List venues (admin)
51+
// @Description Paginated list of venues with optional filters (sport, status).
52+
// @Tags superadmin-venue
53+
// @Produce json
54+
// @Param sport query string false "Filter by sport type"
55+
// @Param status query string false "Filter by venue status (active|requested|inactive)"
56+
// @Param page query int false "Page number" default(1)
57+
// @Param limit query int false "Items per page" default(15)
58+
// @Success 200 {object} VenueListWithMetaResponse
59+
// @Security ApiKeyAuth
60+
// @Router /superadmin/venues/ [get]
61+
func (app *application) AdminlistVenuesHandler(w http.ResponseWriter, r *http.Request) {
62+
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
63+
defer cancel()
64+
65+
q := r.URL.Query()
66+
67+
// ✅ shared pagination
68+
p := params.ParsePagination(q)
69+
70+
// ✅ optional sport
71+
var sportPtr *string
72+
if s := strings.TrimSpace(q.Get("sport")); s != "" {
73+
sportPtr = &s
74+
}
75+
76+
// ✅ optional status (default handled in repo)
77+
var statusPtr *string
78+
if s := strings.TrimSpace(q.Get("status")); s != "" {
79+
switch s {
80+
case "active", "requested", "inactive":
81+
statusPtr = &s
82+
default:
83+
app.badRequestResponse(w, r, errInvalidRequest("invalid status"))
84+
return
85+
}
86+
}
87+
88+
filter := venues.AdminVenueFilter{
89+
Sport: sportPtr,
90+
Status: statusPtr,
91+
Pagination: p,
92+
}
93+
94+
result, err := app.store.Venues.ListWithTotal(ctx, filter)
95+
if err != nil {
96+
app.internalServerError(w, r, err)
97+
return
98+
}
99+
100+
// ✅ compute pagination meta from total
101+
p.ComputeMeta(result.Total)
102+
103+
// ✅ Convert to response format (no favorites)
104+
respVenues := make([]VenueListResponse, 0, len(result.Venues))
105+
for _, v := range result.Venues {
106+
respVenues = append(respVenues, VenueListResponse{
107+
ID: v.ID,
108+
Name: v.Name,
109+
Address: v.Address,
110+
Location: []float64{v.Longitude, v.Latitude},
111+
ImageURLs: v.ImageURLs,
112+
OpenTime: v.OpenTime,
113+
PhoneNumber: v.PhoneNumber,
114+
Sport: v.Sport,
115+
TotalReviews: v.TotalReviews,
116+
AverageRating: v.AverageRating,
117+
// IsFavorite removed ✅
118+
})
119+
}
120+
121+
out := VenueListWithMetaResponse{
122+
Venues: respVenues,
123+
Pagination: p,
124+
Filters: AdminVenueListFilters{
125+
Sport: strings.TrimSpace(q.Get("sport")),
126+
Status: strings.TrimSpace(q.Get("status")),
127+
},
128+
}
129+
130+
_ = app.jsonResponse(w, http.StatusOK, out)
131+
}

cmd/api/api.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,7 @@ func (app *application) mount() http.Handler {
198198
// Routes that require venue ownership
199199
r.Route("/{venueID}", func(r chi.Router) {
200200
r.Use(app.IsOwnerMiddleware)
201+
r.Patch("/status", app.updateVenueStatusOwnerHandler)
201202
r.Post("/bookings/manual", app.createManualBookingHandler)
202203
r.Get("/pricing", app.getVenuePricing)
203204
r.Delete("/", app.deleteVenueHandler)
@@ -403,6 +404,7 @@ func (app *application) mount() http.Handler {
403404
r.Get("/overview", app.adminOverviewHandler)
404405

405406
r.Get("/app-reviews", app.getAllAppReviewsHandler)
407+
r.Get("/venues", app.AdminlistVenuesHandler)
406408

407409
})
408410

cmd/api/venues.go

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -238,7 +238,7 @@ type VenueListResponse struct {
238238
Sport string `json:"sport"`
239239
TotalReviews int `json:"total_reviews"`
240240
AverageRating float64 `json:"average_rating"`
241-
IsFavorite bool `json:"is_favorite"`
241+
IsFavorite bool `json:"is_favorite,omitempty"`
242242
}
243243

244244
// @Summary List venues
@@ -732,3 +732,85 @@ func (app *application) fullTextSearchVenuesHandler(w http.ResponseWriter, r *ht
732732
"search_type": "full_text",
733733
})
734734
}
735+
736+
type updateVenueStatusPayload struct {
737+
Status string `json:"status"` // "requested" or "active"
738+
}
739+
740+
// UpdateVenueStatusOwner godoc
741+
//
742+
// @Summary Owner updates venue status
743+
// @Description Allows venue owner to change status only between requested and active.
744+
// @Tags Venue-Owner
745+
// @Accept json
746+
// @Produce json
747+
// @Param venueID path int64 true "Venue ID"
748+
// @Param payload body updateVenueStatusPayload true "New status (requested|active)"
749+
// @Success 200 {object} map[string]string
750+
// @Failure 400 {object} error
751+
// @Failure 401 {object} error
752+
// @Failure 403 {object} error
753+
// @Failure 404 {object} error
754+
// @Failure 500 {object} error
755+
// @Security ApiKeyAuth
756+
// @Router /venues/{venueID}/status [patch]
757+
func (app *application) updateVenueStatusOwnerHandler(w http.ResponseWriter, r *http.Request) {
758+
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
759+
defer cancel()
760+
761+
user := getUserFromContext(r)
762+
if user == nil {
763+
app.unauthorizedErrorResponse(w, r, fmt.Errorf("unauthorized"))
764+
return
765+
}
766+
767+
venueIDStr := chi.URLParam(r, "venueID")
768+
venueID, err := strconv.ParseInt(venueIDStr, 10, 64)
769+
if err != nil || venueID <= 0 {
770+
app.badRequestResponse(w, r, fmt.Errorf("invalid venueID"))
771+
return
772+
}
773+
774+
var payload updateVenueStatusPayload
775+
if err := readJSON(w, r, &payload); err != nil {
776+
app.badRequestResponse(w, r, err)
777+
return
778+
}
779+
780+
next := strings.TrimSpace(payload.Status)
781+
if next != "requested" && next != "active" {
782+
app.badRequestResponse(w, r, errInvalidRequest("status must be requested or active"))
783+
return
784+
}
785+
786+
// ✅ Optional: friendly ownership check first (nicer errors)
787+
isOwner, err := app.store.Venues.IsOwner(ctx, venueID, user.ID)
788+
if err != nil {
789+
// if you return "venue not found" from IsOwner, map that to 404
790+
if strings.Contains(strings.ToLower(err.Error()), "not found") {
791+
app.notFoundResponse(w, r, err)
792+
return
793+
}
794+
app.internalServerError(w, r, err)
795+
return
796+
}
797+
if !isOwner {
798+
app.forbiddenResponse(w, r)
799+
return
800+
}
801+
802+
// ✅ DB enforces transition rules too
803+
if err := app.store.Venues.UpdateVenueStatusOwner(ctx, venueID, user.ID, next); err != nil {
804+
// If transition invalid or already same status, treat as 400 (better UX)
805+
if strings.Contains(err.Error(), "not allowed") || strings.Contains(err.Error(), "invalid") {
806+
app.badRequestResponse(w, r, errInvalidRequest("status change not allowed"))
807+
return
808+
}
809+
app.internalServerError(w, r, err)
810+
return
811+
}
812+
813+
_ = app.jsonResponse(w, http.StatusOK, map[string]string{
814+
"message": "venue status updated",
815+
})
816+
}

internal/domain/venues/store.go

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -444,6 +444,112 @@ func (r *Repository) GetVenueDetail(ctx context.Context, venueID int64) (*VenueD
444444
return &vd, nil
445445
}
446446

447+
func (r *Repository) ListWithTotal(ctx context.Context, filter AdminVenueFilter) (*AdminVenueListResult, error) {
448+
var (
449+
where []string
450+
args []interface{}
451+
argCounter = 1
452+
)
453+
454+
// ✅ Default behavior: if status not provided, show only active.
455+
if filter.Status == nil || strings.TrimSpace(*filter.Status) == "" {
456+
where = append(where, "v.status = 'active'")
457+
} else {
458+
// ✅ allow explicit status filter
459+
where = append(where, fmt.Sprintf("v.status = $%d", argCounter))
460+
args = append(args, strings.TrimSpace(*filter.Status))
461+
argCounter++
462+
}
463+
464+
// ✅ optional sport filter
465+
if filter.Sport != nil && strings.TrimSpace(*filter.Sport) != "" {
466+
where = append(where, fmt.Sprintf("v.sport = $%d", argCounter))
467+
args = append(args, strings.TrimSpace(*filter.Sport))
468+
argCounter++
469+
}
470+
471+
whereSQL := " WHERE " + strings.Join(where, " AND ")
472+
473+
// ---- 1) total count ----
474+
countQ := `SELECT COUNT(*) FROM venues v` + whereSQL
475+
476+
var total int
477+
if err := r.db.QueryRow(ctx, countQ, args...).Scan(&total); err != nil {
478+
return nil, fmt.Errorf("count venues: %w", err)
479+
}
480+
481+
// ---- 2) list data ----
482+
limitPos := argCounter
483+
offsetPos := argCounter + 1
484+
485+
dataQ := fmt.Sprintf(`
486+
WITH venue_stats AS (
487+
SELECT venue_id, COUNT(*) AS total_reviews, AVG(rating) AS average_rating
488+
FROM reviews
489+
GROUP BY venue_id
490+
)
491+
SELECT
492+
v.id,
493+
v.name,
494+
v.address,
495+
ST_X(v.location::geometry) AS longitude,
496+
ST_Y(v.location::geometry) AS latitude,
497+
v.image_urls,
498+
v.open_time,
499+
v.phone_number,
500+
v.sport,
501+
COALESCE(vs.total_reviews, 0) AS total_reviews,
502+
COALESCE(vs.average_rating, 0) AS average_rating
503+
FROM venues v
504+
LEFT JOIN venue_stats vs ON v.id = vs.venue_id
505+
%s
506+
ORDER BY v.created_at DESC
507+
LIMIT $%d OFFSET $%d
508+
`, whereSQL, limitPos, offsetPos)
509+
510+
args2 := append([]interface{}{}, args...)
511+
args2 = append(args2, filter.Pagination.Limit, filter.Pagination.Offset)
512+
513+
rows, err := r.db.Query(ctx, dataQ, args2...)
514+
if err != nil {
515+
return nil, fmt.Errorf("list venues: %w", err)
516+
}
517+
defer rows.Close()
518+
519+
var out []VenueListing
520+
for rows.Next() {
521+
var v VenueListing
522+
var openTime sql.NullString
523+
524+
if err := rows.Scan(
525+
&v.ID,
526+
&v.Name,
527+
&v.Address,
528+
&v.Longitude,
529+
&v.Latitude,
530+
&v.ImageURLs,
531+
&openTime,
532+
&v.PhoneNumber,
533+
&v.Sport,
534+
&v.TotalReviews,
535+
&v.AverageRating,
536+
); err != nil {
537+
return nil, fmt.Errorf("scan venue row: %w", err)
538+
}
539+
540+
if openTime.Valid {
541+
v.OpenTime = &openTime.String
542+
}
543+
out = append(out, v)
544+
}
545+
546+
if err := rows.Err(); err != nil {
547+
return nil, fmt.Errorf("rows venues: %w", err)
548+
}
549+
550+
return &AdminVenueListResult{Venues: out, Total: total}, nil
551+
}
552+
447553
func (r *Repository) GetImageURLs(ctx context.Context, venueID int64) ([]string, error) {
448554
query := `SELECT image_urls FROM venues WHERE id = $1`
449555

@@ -768,3 +874,48 @@ func (r *Repository) FullTextSearchVenues(ctx context.Context, query string) ([]
768874

769875
return out, nil
770876
}
877+
878+
func (r *Repository) UpdateVenueStatusOwner(
879+
ctx context.Context,
880+
venueID int64,
881+
ownerID int64,
882+
nextStatus string,
883+
) error {
884+
nextStatus = strings.TrimSpace(nextStatus)
885+
886+
// ✅ Owner is only allowed requested <-> active
887+
if nextStatus != string(VenueStatusRequested) && nextStatus != string(VenueStatusActive) {
888+
return fmt.Errorf("invalid status transition")
889+
}
890+
891+
/**
892+
* ✅ Enforce transitions at SQL level:
893+
* - requested -> active
894+
* - active -> requested
895+
* ✅ Ensure owner_id matches (only the owner can mutate).
896+
* ✅ CAST: text -> venue_status
897+
*/
898+
q := `
899+
UPDATE venues
900+
SET status = $1::venue_status,
901+
updated_at = NOW()
902+
WHERE id = $2
903+
AND owner_id = $3
904+
AND (
905+
($1::venue_status = 'active'::venue_status AND status = 'requested'::venue_status)
906+
OR ($1::venue_status = 'requested'::venue_status AND status = 'active'::venue_status)
907+
)
908+
`
909+
910+
ct, err := r.db.Exec(ctx, q, nextStatus, venueID, ownerID)
911+
if err != nil {
912+
return fmt.Errorf("update venue status: %w", err)
913+
}
914+
915+
if ct.RowsAffected() == 0 {
916+
// Could be: venue not found, not owner, or invalid transition
917+
return fmt.Errorf("status change not allowed")
918+
}
919+
920+
return nil
921+
}

0 commit comments

Comments
 (0)