diff --git a/cmd/api/api.go b/cmd/api/api.go index e48d1ed8..25caa8b2 100644 --- a/cmd/api/api.go +++ b/cmd/api/api.go @@ -144,6 +144,7 @@ func (app *application) mount() http.Handler { r.Route("/public", func(r chi.Router) { r.Use(app.APIKeyMiddleware) r.Get("/schedule", app.getPublicScheduleHandler) + r.Get("/sponsors", app.getPublicSponsorsHandler) }) // Auth endpoints not handled by SuperTokens @@ -218,6 +219,17 @@ func (app *application) mount() http.Handler { r.Delete("/{scheduleID}", app.deleteScheduleHandler) }) }) + + // Sponsors + r.Route("/sponsors", func(r chi.Router) { + r.Get("/", app.listSponsorsHandler) + + // TODO: Protect Under a AdminSponsorEditPermissionMiddleware + r.Post("/", app.createSponsorHandler) + r.Put("/{sponsorID}", app.updateSponsorHandler) + r.Delete("/{sponsorID}", app.deleteSponsorHandler) + r.Post("/{sponsorID}/logo-upload-url", app.generateLogoUploadURLHandler) + }) }) }) @@ -251,7 +263,6 @@ func (app *application) mount() http.Handler { r.Get("/", app.searchUsersHandler) r.Patch("/{userID}/role", app.updateUserRoleHandler) }) - }) }) }) diff --git a/cmd/api/public.go b/cmd/api/public.go index c4a0f1c5..0c056296 100644 --- a/cmd/api/public.go +++ b/cmd/api/public.go @@ -18,3 +18,18 @@ import ( func (app *application) getPublicScheduleHandler(w http.ResponseWriter, r *http.Request) { app.listScheduleHandler(w, r) } + +// getPublicSponsorsHandler returns all sponsors (public, API key auth) +// +// @Summary Get sponsors (Public) +// @Description Returns all sponsors, ordered by display order, with public logo URLs +// @Tags public +// @Produce json +// @Param X-API-Key header string true "API Key" +// @Success 200 {object} SponsorListResponse +// @Failure 401 {object} object{error=string} +// @Failure 500 {object} object{error=string} +// @Router /public/sponsors [get] +func (app *application) getPublicSponsorsHandler(w http.ResponseWriter, r *http.Request) { + app.listSponsorsHandler(w, r) +} diff --git a/cmd/api/resume_test.go b/cmd/api/resume_test.go index 731630d8..b2a96dce 100644 --- a/cmd/api/resume_test.go +++ b/cmd/api/resume_test.go @@ -28,6 +28,7 @@ func TestGenerateResumeUploadURL(t *testing.T) { mockApps.On("GetByUserID", user.ID).Return(application, nil).Once() mockGCS.On( "GenerateUploadURL", + mock.Anything, mock.MatchedBy(func(path string) bool { return strings.HasPrefix(path, "resumes/"+user.ID+"/") && strings.HasSuffix(path, ".pdf") }), @@ -103,7 +104,7 @@ func TestGenerateResumeUploadURL(t *testing.T) { user := newTestUser() application := &store.Application{ID: "app-1", UserID: user.ID, Status: store.StatusDraft} mockApps.On("GetByUserID", user.ID).Return(application, nil).Once() - mockGCS.On("GenerateUploadURL", mock.AnythingOfType("string")).Return("", errors.New("gcs failed")).Once() + mockGCS.On("GenerateUploadURL", mock.Anything, mock.AnythingOfType("string")).Return("", errors.New("gcs failed")).Once() req, err := http.NewRequest(http.MethodPost, "/", nil) require.NoError(t, err) @@ -133,7 +134,7 @@ func TestDeleteResume(t *testing.T) { } mockApps.On("GetByUserID", user.ID).Return(application, nil).Once() - mockGCS.On("DeleteObject", resumePath).Return(nil).Once() + mockGCS.On("DeleteObject", mock.Anything, resumePath).Return(nil).Once() mockApps.On("Update", mock.AnythingOfType("*store.Application")).Run(func(args mock.Arguments) { updated := args.Get(0).(*store.Application) assert.Nil(t, updated.ResumePath) @@ -202,7 +203,7 @@ func TestGetResumeDownloadURL(t *testing.T) { resumePath := "resumes/user-1/file.pdf" application := &store.Application{ID: "app-1", ResumePath: &resumePath} mockApps.On("GetByID", "app-1").Return(application, nil).Once() - mockGCS.On("GenerateDownloadURL", resumePath).Return("https://download.example.com", nil).Once() + mockGCS.On("GenerateDownloadURL", mock.Anything, resumePath).Return("https://download.example.com", nil).Once() req, err := http.NewRequest(http.MethodGet, "/", nil) require.NoError(t, err) diff --git a/cmd/api/sponsors.go b/cmd/api/sponsors.go new file mode 100644 index 00000000..79a4404f --- /dev/null +++ b/cmd/api/sponsors.go @@ -0,0 +1,316 @@ +package main + +import ( + "errors" + "fmt" + "net/http" + + "github.com/go-chi/chi" + "github.com/hackutd/portal/internal/gcs" + "github.com/hackutd/portal/internal/store" +) + +type SponsorPayload struct { + Name string `json:"name" validate:"required,min=1,max=100"` + Tier string `json:"tier" validate:"required,min=1,max=50"` + WebsiteURL string `json:"website_url" validate:"omitempty,url"` + LogoPath string `json:"logo_path"` + Description string `json:"description"` + DisplayOrder int `json:"display_order" validate:"min=0"` +} + +type SponsorResponse struct { + store.Sponsor + LogoURL string `json:"logo_url,omitempty"` +} + +type SponsorListResponse struct { + Sponsors []SponsorResponse `json:"sponsors"` +} + +type LogoUploadURLPayload struct { + ContentType string `json:"content_type" validate:"required"` +} + +type LogoUploadURLResponse struct { + UploadURL string `json:"upload_url"` + LogoPath string `json:"logo_path"` + ContentType string `json:"content_type"` +} + +// listSponsorsHandler returns all sponsors (Super Admin) +// +// @Summary List sponsors (Super Admin) +// @Description Returns all sponsors ordered by display order +// @Tags superadmin/sponsors +// @Produce json +// @Success 200 {object} SponsorListResponse +// @Failure 401 {object} object{error=string} +// @Failure 403 {object} object{error=string} +// @Failure 500 {object} object{error=string} +// @Security CookieAuth +// @Router /superadmin/sponsors [get] +func (app *application) listSponsorsHandler(w http.ResponseWriter, r *http.Request) { + sponsors, err := app.store.Sponsors.List(r.Context()) + if err != nil { + app.internalServerError(w, r, err) + return + } + + response := make([]SponsorResponse, len(sponsors)) + for i, s := range sponsors { + response[i] = SponsorResponse{Sponsor: s} + if s.LogoPath != "" && app.gcsClient != nil { + response[i].LogoURL = app.gcsClient.GeneratePublicURL(s.LogoPath) + } + } + + if err := app.jsonResponse(w, http.StatusOK, SponsorListResponse{Sponsors: response}); err != nil { + app.internalServerError(w, r, err) + } +} + +// createSponsorHandler creates a new sponsor (Super Admin) +// +// @Summary Create sponsor (Super Admin) +// @Description Creates a new sponsor +// @Tags superadmin/sponsors +// @Accept json +// @Produce json +// @Param sponsor body SponsorPayload true "Sponsor to create" +// @Success 201 {object} SponsorResponse +// @Failure 400 {object} object{error=string} +// @Failure 401 {object} object{error=string} +// @Failure 403 {object} object{error=string} +// @Failure 500 {object} object{error=string} +// @Security CookieAuth +// @Router /superadmin/sponsors [post] +func (app *application) createSponsorHandler(w http.ResponseWriter, r *http.Request) { + var payload SponsorPayload + if err := readJSON(w, r, &payload); err != nil { + app.badRequestResponse(w, r, err) + return + } + + if err := Validate.Struct(payload); err != nil { + app.badRequestResponse(w, r, err) + return + } + + sponsor := &store.Sponsor{ + Name: payload.Name, + Tier: payload.Tier, + LogoPath: payload.LogoPath, + WebsiteURL: payload.WebsiteURL, + Description: payload.Description, + DisplayOrder: payload.DisplayOrder, + } + + if err := app.store.Sponsors.Create(r.Context(), sponsor); err != nil { + app.internalServerError(w, r, err) + return + } + + resp := SponsorResponse{Sponsor: *sponsor} + if sponsor.LogoPath != "" && app.gcsClient != nil { + resp.LogoURL = app.gcsClient.GeneratePublicURL(sponsor.LogoPath) + } + + if err := app.jsonResponse(w, http.StatusCreated, resp); err != nil { + app.internalServerError(w, r, err) + } +} + +// updateSponsorHandler updates an existing sponsor (Super Admin) +// +// @Summary Update sponsor (Super Admin) +// @Description Updates an existing sponsor +// @Tags superadmin/sponsors +// @Accept json +// @Produce json +// @Param sponsorID path string true "Sponsor ID" +// @Param sponsor body SponsorPayload true "Sponsor updates" +// @Success 200 {object} SponsorResponse +// @Failure 400 {object} object{error=string} +// @Failure 401 {object} object{error=string} +// @Failure 403 {object} object{error=string} +// @Failure 404 {object} object{error=string} +// @Failure 500 {object} object{error=string} +// @Security CookieAuth +// @Router /superadmin/sponsors/{sponsorID} [put] +func (app *application) updateSponsorHandler(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "sponsorID") + if id == "" { + app.badRequestResponse(w, r, errors.New("missing sponsor ID")) + return + } + + var payload SponsorPayload + if err := readJSON(w, r, &payload); err != nil { + app.badRequestResponse(w, r, err) + return + } + + if err := Validate.Struct(payload); err != nil { + app.badRequestResponse(w, r, err) + return + } + + sponsor := &store.Sponsor{ + ID: id, + Name: payload.Name, + Tier: payload.Tier, + LogoPath: payload.LogoPath, + WebsiteURL: payload.WebsiteURL, + Description: payload.Description, + DisplayOrder: payload.DisplayOrder, + } + + if err := app.store.Sponsors.Update(r.Context(), sponsor); err != nil { + if errors.Is(err, store.ErrNotFound) { + app.notFoundResponse(w, r, errors.New("sponsor not found")) + return + } + app.internalServerError(w, r, err) + return + } + + // Re-fetch to get created_at if needed, but we have everything else. + // We can construct response directly or fetch fresh. + // Since update returns updated_at, we might need to fetch if we want consistent CreatedAt. + // Let's just return what we have, usually UI updates local state. + // But to get the correct LogoURL and timestamps, let's fetch. + updatedSponsor, err := app.store.Sponsors.GetByID(r.Context(), id) + if err != nil { + app.internalServerError(w, r, err) + return + } + + resp := SponsorResponse{Sponsor: *updatedSponsor} + if updatedSponsor.LogoPath != "" && app.gcsClient != nil { + resp.LogoURL = app.gcsClient.GeneratePublicURL(updatedSponsor.LogoPath) + } + + if err := app.jsonResponse(w, http.StatusOK, resp); err != nil { + app.internalServerError(w, r, err) + } +} + +// deleteSponsorHandler deletes a sponsor (Super Admin) +// +// @Summary Delete sponsor (Super Admin) +// @Description Deletes a sponsor and their logo from GCS +// @Tags superadmin/sponsors +// @Param sponsorID path string true "Sponsor ID" +// @Success 204 +// @Failure 401 {object} object{error=string} +// @Failure 403 {object} object{error=string} +// @Failure 404 {object} object{error=string} +// @Failure 500 {object} object{error=string} +// @Security CookieAuth +// @Router /superadmin/sponsors/{sponsorID} [delete] +func (app *application) deleteSponsorHandler(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "sponsorID") + if id == "" { + app.badRequestResponse(w, r, errors.New("missing sponsor ID")) + return + } + + sponsor, err := app.store.Sponsors.GetByID(r.Context(), id) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + app.notFoundResponse(w, r, errors.New("sponsor not found")) + return + } + app.internalServerError(w, r, err) + return + } + + if err := app.store.Sponsors.Delete(r.Context(), id); err != nil { + app.internalServerError(w, r, err) + return + } + + if sponsor.LogoPath != "" && app.gcsClient != nil { + if err := app.gcsClient.DeleteObject(r.Context(), sponsor.LogoPath); err != nil { + app.logger.Warnw("failed to delete sponsor logo", "error", err, "path", sponsor.LogoPath) + } + } + + w.WriteHeader(http.StatusNoContent) +} + +// generateLogoUploadURLHandler returns a signed upload URL for a sponsor logo (Super Admin) +// +// @Summary Generate logo upload URL (Super Admin) +// @Description Generates a signed GCS upload URL for a sponsor logo. +// @Tags superadmin/sponsors +// @Accept json +// @Produce json +// @Param sponsorID path string true "Sponsor ID" +// @Param body body LogoUploadURLPayload true "Upload URL request" +// @Success 200 {object} LogoUploadURLResponse +// @Failure 400 {object} object{error=string} +// @Failure 401 {object} object{error=string} +// @Failure 403 {object} object{error=string} +// @Failure 404 {object} object{error=string} +// @Failure 500 {object} object{error=string} +// @Security CookieAuth +// @Router /superadmin/sponsors/{sponsorID}/logo-upload-url [post] +func (app *application) generateLogoUploadURLHandler(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "sponsorID") + if id == "" { + app.badRequestResponse(w, r, errors.New("missing sponsor ID")) + return + } + + if _, err := app.store.Sponsors.GetByID(r.Context(), id); err != nil { + if errors.Is(err, store.ErrNotFound) { + app.notFoundResponse(w, r, errors.New("sponsor not found")) + return + } + app.internalServerError(w, r, err) + return + } + + if app.gcsClient == nil { + writeJSONError(w, http.StatusServiceUnavailable, "gcs not configured") + return + } + + var payload LogoUploadURLPayload + if err := readJSON(w, r, &payload); err != nil { + app.badRequestResponse(w, r, err) + return + } + if err := Validate.Struct(payload); err != nil { + app.badRequestResponse(w, r, err) + return + } + if !gcs.AllowedImageContentTypes[payload.ContentType] { + app.badRequestResponse(w, r, fmt.Errorf("unsupported content type: %s", payload.ContentType)) + return + } + + randomID, err := randomHex(16) + if err != nil { + app.internalServerError(w, r, err) + return + } + + objectPath := fmt.Sprintf("sponsors/%s/%s", id, randomID) + + uploadURL, err := app.gcsClient.GenerateImageUploadURL(r.Context(), objectPath, payload.ContentType) + if err != nil { + app.internalServerError(w, r, err) + return + } + + if err := app.jsonResponse(w, http.StatusOK, LogoUploadURLResponse{ + UploadURL: uploadURL, + LogoPath: objectPath, + ContentType: payload.ContentType, + }); err != nil { + app.internalServerError(w, r, err) + } +} diff --git a/cmd/api/sponsors_test.go b/cmd/api/sponsors_test.go new file mode 100644 index 00000000..eff1428b --- /dev/null +++ b/cmd/api/sponsors_test.go @@ -0,0 +1,367 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "strings" + "testing" + "time" + + "github.com/go-chi/chi" + "github.com/hackutd/portal/internal/gcs" + "github.com/hackutd/portal/internal/store" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +// withSponsorRouteParam is a helper to add a URL parameter to a request for testing. +func withSponsorRouteParam(req *http.Request, sponsorID string) *http.Request { + rctx := chi.NewRouteContext() + rctx.URLParams.Add("sponsorID", sponsorID) + return req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) +} + +func newTestSponsor(id string) store.Sponsor { + return store.Sponsor{ + ID: id, + Name: "Test Sponsor", + Tier: "Gold", + LogoPath: "sponsors/test-sponsor/logo.png", + WebsiteURL: "https://example.com", + Description: "A test sponsor.", + DisplayOrder: 1, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } +} + +func TestListSponsors(t *testing.T) { + app := newTestApplication(t) + mockSponsors := app.store.Sponsors.(*store.MockSponsorsStore) + mockGCS := app.gcsClient.(*gcs.MockClient) + + t.Run("should list all sponsors", func(t *testing.T) { + sponsors := []store.Sponsor{newTestSponsor("sponsor-1"), newTestSponsor("sponsor-2")} + sponsors[1].LogoPath = "" // Test one without a logo + + mockSponsors.On("List").Return(sponsors, nil).Once() + mockGCS.On("GeneratePublicURL", sponsors[0].LogoPath).Return("https://public.url/logo.png").Once() + + req, err := http.NewRequest(http.MethodGet, "/", nil) + require.NoError(t, err) + req = setUserContext(req, newSuperAdminUser()) + + rr := executeRequest(req, http.HandlerFunc(app.listSponsorsHandler)) + checkResponseCode(t, http.StatusOK, rr.Code) + + var body struct { + Data SponsorListResponse `json:"data"` + } + err = json.NewDecoder(rr.Body).Decode(&body) + require.NoError(t, err) + assert.Len(t, body.Data.Sponsors, 2) + assert.Equal(t, "https://public.url/logo.png", body.Data.Sponsors[0].LogoURL) + assert.Empty(t, body.Data.Sponsors[1].LogoURL) + + mockSponsors.AssertExpectations(t) + mockGCS.AssertExpectations(t) + }) +} + +func TestGetPublicSponsors(t *testing.T) { + app := newTestApplication(t) + mockSponsors := app.store.Sponsors.(*store.MockSponsorsStore) + mockGCS := app.gcsClient.(*gcs.MockClient) + mux := app.mount() + + t.Run("should return sponsors with valid api key", func(t *testing.T) { + sponsors := []store.Sponsor{newTestSponsor("sponsor-1")} + mockSponsors.On("List").Return(sponsors, nil).Once() + mockGCS.On("GeneratePublicURL", sponsors[0].LogoPath).Return("https://public.url/logo.png").Once() + + req, err := http.NewRequest(http.MethodGet, "/v1/public/sponsors", nil) + require.NoError(t, err) + req.Header.Set("X-API-Key", "test-api-key") + + rr := executeRequest(req, mux) + checkResponseCode(t, http.StatusOK, rr.Code) + + var body struct { + Data SponsorListResponse `json:"data"` + } + err = json.NewDecoder(rr.Body).Decode(&body) + require.NoError(t, err) + assert.Len(t, body.Data.Sponsors, 1) + + mockSponsors.AssertExpectations(t) + mockGCS.AssertExpectations(t) + }) + + t.Run("should return 401 with invalid api key", func(t *testing.T) { + req, err := http.NewRequest(http.MethodGet, "/v1/public/sponsors", nil) + require.NoError(t, err) + req.Header.Set("X-API-Key", "wrong-key") + + rr := executeRequest(req, mux) + checkResponseCode(t, http.StatusUnauthorized, rr.Code) + }) +} + +func TestCreateSponsor(t *testing.T) { + app := newTestApplication(t) + mockSponsors := app.store.Sponsors.(*store.MockSponsorsStore) + + t.Run("should create a sponsor", func(t *testing.T) { + mockSponsors.On("Create", mock.AnythingOfType("*store.Sponsor")).Run(func(args mock.Arguments) { + sponsor := args.Get(0).(*store.Sponsor) + sponsor.ID = "new-sponsor" // Simulate DB setting ID + }).Return(nil).Once() + + body := `{"name":"New Sponsor","tier":"Platinum","website_url":"https://new.com","display_order":10}` + req, err := http.NewRequest(http.MethodPost, "/", strings.NewReader(body)) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + req = setUserContext(req, newSuperAdminUser()) + + rr := executeRequest(req, http.HandlerFunc(app.createSponsorHandler)) + checkResponseCode(t, http.StatusCreated, rr.Code) + + var respBody struct { + Data SponsorResponse `json:"data"` + } + err = json.NewDecoder(rr.Body).Decode(&respBody) + require.NoError(t, err) + assert.Equal(t, "new-sponsor", respBody.Data.ID) + assert.Equal(t, "New Sponsor", respBody.Data.Name) + + mockSponsors.AssertExpectations(t) + }) + + t.Run("should return 400 for invalid payload", func(t *testing.T) { + body := `{"name":""}` // Name is required + req, err := http.NewRequest(http.MethodPost, "/", strings.NewReader(body)) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + req = setUserContext(req, newSuperAdminUser()) + + rr := executeRequest(req, http.HandlerFunc(app.createSponsorHandler)) + checkResponseCode(t, http.StatusBadRequest, rr.Code) + }) +} + +func TestUpdateSponsor(t *testing.T) { + app := newTestApplication(t) + mockSponsors := app.store.Sponsors.(*store.MockSponsorsStore) + + t.Run("should update a sponsor", func(t *testing.T) { + sponsorID := "sponsor-to-update" + updatedSponsor := newTestSponsor(sponsorID) + updatedSponsor.Name = "Updated Name" + + mockSponsors.On("Update", mock.AnythingOfType("*store.Sponsor")).Return(nil).Once() + mockSponsors.On("GetByID", sponsorID).Return(&updatedSponsor, nil).Once() + + mockGCS := app.gcsClient.(*gcs.MockClient) + mockGCS.On("GeneratePublicURL", updatedSponsor.LogoPath). + Return("https://public.url/logo.png").Once() + + body := `{"name":"Updated Name","tier":"Gold","website_url":"https://example.com","display_order":1}` + req, err := http.NewRequest(http.MethodPut, "/", strings.NewReader(body)) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + req = setUserContext(req, newSuperAdminUser()) + req = withSponsorRouteParam(req, sponsorID) + + rr := executeRequest(req, http.HandlerFunc(app.updateSponsorHandler)) + checkResponseCode(t, http.StatusOK, rr.Code) + + var respBody struct { + Data SponsorResponse `json:"data"` + } + err = json.NewDecoder(rr.Body).Decode(&respBody) + require.NoError(t, err) + assert.Equal(t, "Updated Name", respBody.Data.Name) + assert.Equal(t, "https://public.url/logo.png", respBody.Data.LogoURL) + + mockSponsors.AssertExpectations(t) + mockGCS.AssertExpectations(t) + }) + + t.Run("should return 404 if sponsor not found", func(t *testing.T) { + mockSponsors.On("Update", mock.AnythingOfType("*store.Sponsor")).Return(store.ErrNotFound).Once() + + body := `{"name":"Updated Name","tier":"Gold","display_order":1}` + req, err := http.NewRequest(http.MethodPut, "/", strings.NewReader(body)) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + req = setUserContext(req, newSuperAdminUser()) + req = withSponsorRouteParam(req, "nonexistent") + + rr := executeRequest(req, http.HandlerFunc(app.updateSponsorHandler)) + checkResponseCode(t, http.StatusNotFound, rr.Code) + + mockSponsors.AssertExpectations(t) + }) +} + +func TestDeleteSponsor(t *testing.T) { + app := newTestApplication(t) + mockSponsors := app.store.Sponsors.(*store.MockSponsorsStore) + mockGCS := app.gcsClient.(*gcs.MockClient) + + t.Run("should delete a sponsor and its logo", func(t *testing.T) { + sponsor := newTestSponsor("sponsor-to-delete") + + mockSponsors.On("GetByID", sponsor.ID).Return(&sponsor, nil).Once() + mockSponsors.On("Delete", sponsor.ID).Return(nil).Once() + mockGCS.On("DeleteObject", mock.Anything, sponsor.LogoPath).Return(nil).Once() + + req, err := http.NewRequest(http.MethodDelete, "/", nil) + require.NoError(t, err) + req = setUserContext(req, newSuperAdminUser()) + req = withSponsorRouteParam(req, sponsor.ID) + + rr := executeRequest(req, http.HandlerFunc(app.deleteSponsorHandler)) + checkResponseCode(t, http.StatusNoContent, rr.Code) + + mockSponsors.AssertExpectations(t) + mockGCS.AssertExpectations(t) + }) + + t.Run("should return 404 if sponsor not found", func(t *testing.T) { + mockSponsors.On("GetByID", "nonexistent").Return(nil, store.ErrNotFound).Once() + + req, err := http.NewRequest(http.MethodDelete, "/", nil) + require.NoError(t, err) + req = setUserContext(req, newSuperAdminUser()) + req = withSponsorRouteParam(req, "nonexistent") + + rr := executeRequest(req, http.HandlerFunc(app.deleteSponsorHandler)) + checkResponseCode(t, http.StatusNotFound, rr.Code) + + mockSponsors.AssertExpectations(t) + }) +} + +func TestGenerateLogoUploadURL(t *testing.T) { + app := newTestApplication(t) + mockSponsors := app.store.Sponsors.(*store.MockSponsorsStore) + mockGCS := app.gcsClient.(*gcs.MockClient) + + t.Run("should generate an upload url", func(t *testing.T) { + sponsor := newTestSponsor("sponsor-1") + mockSponsors.On("GetByID", sponsor.ID).Return(&sponsor, nil).Once() + mockGCS.On("GenerateImageUploadURL", mock.Anything, mock.MatchedBy(func(path string) bool { + return strings.HasPrefix(path, "sponsors/"+sponsor.ID+"/") + }), "image/png").Return("https://upload.url/logo", nil).Once() + + reqBody := `{"content_type":"image/png"}` + req, err := http.NewRequest(http.MethodPost, "/", strings.NewReader(reqBody)) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + req = setUserContext(req, newSuperAdminUser()) + req = withSponsorRouteParam(req, sponsor.ID) + + rr := executeRequest(req, http.HandlerFunc(app.generateLogoUploadURLHandler)) + checkResponseCode(t, http.StatusOK, rr.Code) + + var body struct { + Data LogoUploadURLResponse `json:"data"` + } + err = json.NewDecoder(rr.Body).Decode(&body) + require.NoError(t, err) + assert.Equal(t, "https://upload.url/logo", body.Data.UploadURL) + assert.True(t, strings.HasPrefix(body.Data.LogoPath, "sponsors/"+sponsor.ID+"/")) + assert.Equal(t, "image/png", body.Data.ContentType) + + mockSponsors.AssertExpectations(t) + mockGCS.AssertExpectations(t) + }) + + t.Run("should return 404 if sponsor not found", func(t *testing.T) { + mockSponsors.On("GetByID", "nonexistent").Return(nil, store.ErrNotFound).Once() + + req, err := http.NewRequest(http.MethodPost, "/", nil) + require.NoError(t, err) + req = setUserContext(req, newSuperAdminUser()) + req = withSponsorRouteParam(req, "nonexistent") + + rr := executeRequest(req, http.HandlerFunc(app.generateLogoUploadURLHandler)) + checkResponseCode(t, http.StatusNotFound, rr.Code) + + mockSponsors.AssertExpectations(t) + }) + + t.Run("should return 503 if gcs is not configured", func(t *testing.T) { + sponsor := newTestSponsor("sponsor-1") + mockSponsors.On("GetByID", sponsor.ID).Return(&sponsor, nil).Once() + app.gcsClient = nil + + req, err := http.NewRequest(http.MethodPost, "/", nil) + require.NoError(t, err) + req = setUserContext(req, newSuperAdminUser()) + req = withSponsorRouteParam(req, sponsor.ID) + + rr := executeRequest(req, http.HandlerFunc(app.generateLogoUploadURLHandler)) + checkResponseCode(t, http.StatusServiceUnavailable, rr.Code) + + app.gcsClient = mockGCS // Restore for other tests + mockSponsors.AssertExpectations(t) + }) + + t.Run("should return 500 if url generation fails", func(t *testing.T) { + sponsor := newTestSponsor("sponsor-1") + mockSponsors.On("GetByID", sponsor.ID).Return(&sponsor, nil).Once() + mockGCS.On("GenerateImageUploadURL", mock.Anything, mock.AnythingOfType("string"), "image/png").Return("", errors.New("gcs error")).Once() + + reqBody := `{"content_type":"image/png"}` + req, err := http.NewRequest(http.MethodPost, "/", strings.NewReader(reqBody)) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + req = setUserContext(req, newSuperAdminUser()) + req = withSponsorRouteParam(req, sponsor.ID) + + rr := executeRequest(req, http.HandlerFunc(app.generateLogoUploadURLHandler)) + checkResponseCode(t, http.StatusInternalServerError, rr.Code) + + mockSponsors.AssertExpectations(t) + mockGCS.AssertExpectations(t) + }) + + t.Run("should return 400 for missing content_type", func(t *testing.T) { + sponsor := newTestSponsor("sponsor-1") + mockSponsors.On("GetByID", sponsor.ID).Return(&sponsor, nil).Once() + + reqBody := `{}` + req, err := http.NewRequest(http.MethodPost, "/", strings.NewReader(reqBody)) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + req = setUserContext(req, newSuperAdminUser()) + req = withSponsorRouteParam(req, sponsor.ID) + + rr := executeRequest(req, http.HandlerFunc(app.generateLogoUploadURLHandler)) + checkResponseCode(t, http.StatusBadRequest, rr.Code) + + mockSponsors.AssertExpectations(t) + }) + + t.Run("should return 400 for unsupported content_type", func(t *testing.T) { + sponsor := newTestSponsor("sponsor-1") + mockSponsors.On("GetByID", sponsor.ID).Return(&sponsor, nil).Once() + + reqBody := `{"content_type":"application/pdf"}` + req, err := http.NewRequest(http.MethodPost, "/", strings.NewReader(reqBody)) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + req = setUserContext(req, newSuperAdminUser()) + req = withSponsorRouteParam(req, sponsor.ID) + + rr := executeRequest(req, http.HandlerFunc(app.generateLogoUploadURLHandler)) + checkResponseCode(t, http.StatusBadRequest, rr.Code) + + mockSponsors.AssertExpectations(t) + }) +} diff --git a/cmd/migrate/migrations/000023_create_sponsors.down.sql b/cmd/migrate/migrations/000023_create_sponsors.down.sql new file mode 100644 index 00000000..d60d4181 --- /dev/null +++ b/cmd/migrate/migrations/000023_create_sponsors.down.sql @@ -0,0 +1,2 @@ +DROP TRIGGER IF EXISTS set_updated_at_sponsors ON sponsors; +DROP TABLE sponsors; diff --git a/cmd/migrate/migrations/000023_create_sponsors.up.sql b/cmd/migrate/migrations/000023_create_sponsors.up.sql new file mode 100644 index 00000000..b4eae8e4 --- /dev/null +++ b/cmd/migrate/migrations/000023_create_sponsors.up.sql @@ -0,0 +1,15 @@ +CREATE TABLE sponsors ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL, + tier TEXT NOT NULL DEFAULT 'standard', + logo_path TEXT, + website_url TEXT, + description TEXT, + display_order INT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TRIGGER set_updated_at_sponsors + BEFORE UPDATE ON sponsors FOR EACH ROW + EXECUTE FUNCTION set_updated_at(); \ No newline at end of file diff --git a/docs/docs.go b/docs/docs.go index 9ee77088..07674148 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -1990,6 +1990,57 @@ const docTemplate = `{ } } }, + "/public/sponsors": { + "get": { + "description": "Returns all sponsors, ordered by display order, with public logo URLs", + "produces": [ + "application/json" + ], + "tags": [ + "public" + ], + "summary": "Get sponsors (Public)", + "parameters": [ + { + "type": "string", + "description": "API Key", + "name": "X-API-Key", + "in": "header", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/main.SponsorListResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + } + } + } + }, "/superadmin/applications/assign": { "post": { "security": [ @@ -2949,6 +3000,417 @@ const docTemplate = `{ } } }, + "/superadmin/sponsors": { + "get": { + "security": [ + { + "CookieAuth": [] + } + ], + "description": "Returns all sponsors ordered by display order", + "produces": [ + "application/json" + ], + "tags": [ + "superadmin/sponsors" + ], + "summary": "List sponsors (Super Admin)", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/main.SponsorListResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + }, + "403": { + "description": "Forbidden", + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + } + } + }, + "post": { + "security": [ + { + "CookieAuth": [] + } + ], + "description": "Creates a new sponsor", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "superadmin/sponsors" + ], + "summary": "Create sponsor (Super Admin)", + "parameters": [ + { + "description": "Sponsor to create", + "name": "sponsor", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/main.SponsorPayload" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/main.SponsorResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + }, + "403": { + "description": "Forbidden", + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + } + } + } + }, + "/superadmin/sponsors/{sponsorID}": { + "put": { + "security": [ + { + "CookieAuth": [] + } + ], + "description": "Updates an existing sponsor", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "superadmin/sponsors" + ], + "summary": "Update sponsor (Super Admin)", + "parameters": [ + { + "type": "string", + "description": "Sponsor ID", + "name": "sponsorID", + "in": "path", + "required": true + }, + { + "description": "Sponsor updates", + "name": "sponsor", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/main.SponsorPayload" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/main.SponsorResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + }, + "403": { + "description": "Forbidden", + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + } + } + }, + "delete": { + "security": [ + { + "CookieAuth": [] + } + ], + "description": "Deletes a sponsor and their logo from GCS", + "tags": [ + "superadmin/sponsors" + ], + "summary": "Delete sponsor (Super Admin)", + "parameters": [ + { + "type": "string", + "description": "Sponsor ID", + "name": "sponsorID", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + }, + "403": { + "description": "Forbidden", + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + } + } + } + }, + "/superadmin/sponsors/{sponsorID}/logo-upload-url": { + "post": { + "security": [ + { + "CookieAuth": [] + } + ], + "description": "Generates a signed GCS upload URL for a sponsor logo.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "superadmin/sponsors" + ], + "summary": "Generate logo upload URL (Super Admin)", + "parameters": [ + { + "type": "string", + "description": "Sponsor ID", + "name": "sponsorID", + "in": "path", + "required": true + }, + { + "description": "Upload URL request", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/main.LogoUploadURLPayload" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/main.LogoUploadURLResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + }, + "403": { + "description": "Forbidden", + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + } + } + } + }, "/superadmin/users": { "get": { "security": [ @@ -3424,6 +3886,31 @@ const docTemplate = `{ } } }, + "main.LogoUploadURLPayload": { + "type": "object", + "required": [ + "content_type" + ], + "properties": { + "content_type": { + "type": "string" + } + } + }, + "main.LogoUploadURLResponse": { + "type": "object", + "properties": { + "content_type": { + "type": "string" + }, + "logo_path": { + "type": "string" + }, + "upload_url": { + "type": "string" + } + } + }, "main.NotesListResponse": { "type": "object", "properties": { @@ -3635,6 +4122,84 @@ const docTemplate = `{ } } }, + "main.SponsorListResponse": { + "type": "object", + "properties": { + "sponsors": { + "type": "array", + "items": { + "$ref": "#/definitions/main.SponsorResponse" + } + } + } + }, + "main.SponsorPayload": { + "type": "object", + "required": [ + "name", + "tier" + ], + "properties": { + "description": { + "type": "string" + }, + "display_order": { + "type": "integer", + "minimum": 0 + }, + "logo_path": { + "type": "string" + }, + "name": { + "type": "string", + "maxLength": 100, + "minLength": 1 + }, + "tier": { + "type": "string", + "maxLength": 50, + "minLength": 1 + }, + "website_url": { + "type": "string" + } + } + }, + "main.SponsorResponse": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "description": { + "type": "string" + }, + "display_order": { + "type": "integer" + }, + "id": { + "type": "string" + }, + "logo_path": { + "type": "string" + }, + "logo_url": { + "type": "string" + }, + "name": { + "type": "string" + }, + "tier": { + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "website_url": { + "type": "string" + } + } + }, "main.SubmitVotePayload": { "type": "object", "required": [ diff --git a/internal/gcs/client.go b/internal/gcs/client.go index eb75ee72..37dd82ef 100644 --- a/internal/gcs/client.go +++ b/internal/gcs/client.go @@ -12,11 +12,20 @@ const ( signedURLExpiry = 15 * time.Minute resumeContentType = "application/pdf" maxResumeSizeBytes = 5 * 1024 * 1024 + maxImageSizeBytes = 2 * 1024 * 1024 ) +var AllowedImageContentTypes = map[string]bool{ + "image/png": true, + "image/jpeg": true, + "image/webp": true, + "image/gif": true, +} + type GCSClient struct { - client *storage.Client - bucket *storage.BucketHandle + client *storage.Client + bucket *storage.BucketHandle + bucketName string } func New(ctx context.Context, bucketName string) (*GCSClient, error) { @@ -26,8 +35,9 @@ func New(ctx context.Context, bucketName string) (*GCSClient, error) { } return &GCSClient{ - client: client, - bucket: client.Bucket(bucketName), + client: client, + bucket: client.Bucket(bucketName), + bucketName: bucketName, }, nil } @@ -48,6 +58,23 @@ func (c *GCSClient) GenerateUploadURL(_ context.Context, objectPath string) (str return url, nil } +func (c *GCSClient) GenerateImageUploadURL(_ context.Context, objectPath string, contentType string) (string, error) { + url, err := c.bucket.SignedURL(objectPath, &storage.SignedURLOptions{ + Method: "PUT", + Expires: time.Now().Add(signedURLExpiry), + ContentType: contentType, + Headers: []string{ + fmt.Sprintf("x-goog-content-length-range:0,%d", maxImageSizeBytes), + }, + Scheme: storage.SigningSchemeV4, + }) + if err != nil { + return "", err + } + + return url, nil +} + func (c *GCSClient) GenerateDownloadURL(_ context.Context, objectPath string) (string, error) { url, err := c.bucket.SignedURL(objectPath, &storage.SignedURLOptions{ Method: "GET", @@ -68,3 +95,7 @@ func (c *GCSClient) DeleteObject(ctx context.Context, objectPath string) error { func (c *GCSClient) Close() error { return c.client.Close() } + +func (c *GCSClient) GeneratePublicURL(objectPath string) string { + return fmt.Sprintf("https://storage.googleapis.com/%s/%s", c.bucketName, objectPath) +} diff --git a/internal/gcs/gcs.go b/internal/gcs/gcs.go index 65ddb268..9b573de2 100644 --- a/internal/gcs/gcs.go +++ b/internal/gcs/gcs.go @@ -4,6 +4,8 @@ import "context" type Client interface { GenerateUploadURL(ctx context.Context, objectPath string) (string, error) + GenerateImageUploadURL(ctx context.Context, objectPath string, contentType string) (string, error) GenerateDownloadURL(ctx context.Context, objectPath string) (string, error) DeleteObject(ctx context.Context, objectPath string) error + GeneratePublicURL(objectPath string) string } diff --git a/internal/gcs/mock.go b/internal/gcs/mock.go index 4aede657..c4630d22 100644 --- a/internal/gcs/mock.go +++ b/internal/gcs/mock.go @@ -10,17 +10,32 @@ type MockClient struct { mock.Mock } -func (m *MockClient) GenerateUploadURL(_ context.Context, objectPath string) (string, error) { - args := m.Called(objectPath) +func (m *MockClient) GenerateUploadURL(ctx context.Context, objectPath string) (string, error) { + args := m.Called(ctx, objectPath) return args.String(0), args.Error(1) } -func (m *MockClient) GenerateDownloadURL(_ context.Context, objectPath string) (string, error) { - args := m.Called(objectPath) +func (m *MockClient) GenerateImageUploadURL(ctx context.Context, objectPath string, contentType string) (string, error) { + args := m.Called(ctx, objectPath, contentType) return args.String(0), args.Error(1) } -func (m *MockClient) DeleteObject(_ context.Context, objectPath string) error { - args := m.Called(objectPath) +func (m *MockClient) GenerateDownloadURL(ctx context.Context, objectPath string) (string, error) { + args := m.Called(ctx, objectPath) + return args.String(0), args.Error(1) +} + +func (m *MockClient) DeleteObject(ctx context.Context, objectPath string) error { + args := m.Called(ctx, objectPath) return args.Error(0) } + +func (m *MockClient) Close() error { + args := m.Called() + return args.Error(0) +} + +func (m *MockClient) GeneratePublicURL(objectPath string) string { + args := m.Called(objectPath) + return args.String(0) +} diff --git a/internal/store/mock_store.go b/internal/store/mock_store.go index c01e027e..dfd84144 100644 --- a/internal/store/mock_store.go +++ b/internal/store/mock_store.go @@ -352,6 +352,42 @@ func (m *MockScheduleStore) Delete(ctx context.Context, id string) error { return args.Error(0) } +// MockSponsorsStore is a mock implementation of the Sponsors interface +type MockSponsorsStore struct { + mock.Mock +} + +func (m *MockSponsorsStore) List(ctx context.Context) ([]Sponsor, error) { + args := m.Called() + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).([]Sponsor), args.Error(1) +} + +func (m *MockSponsorsStore) Create(ctx context.Context, sponsor *Sponsor) error { + args := m.Called(sponsor) + return args.Error(0) +} + +func (m *MockSponsorsStore) Update(ctx context.Context, sponsor *Sponsor) error { + args := m.Called(sponsor) + return args.Error(0) +} + +func (m *MockSponsorsStore) Delete(ctx context.Context, id string) error { + args := m.Called(id) + return args.Error(0) +} + +func (m *MockSponsorsStore) GetByID(ctx context.Context, id string) (*Sponsor, error) { + args := m.Called(id) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*Sponsor), args.Error(1) +} + // returns a Storage with all mock implementations func NewMockStore() Storage { return Storage{ @@ -361,5 +397,6 @@ func NewMockStore() Storage { ApplicationReviews: &MockApplicationReviewsStore{}, Scans: &MockScansStore{}, Schedule: &MockScheduleStore{}, + Sponsors: &MockSponsorsStore{}, } } diff --git a/internal/store/sponsors.go b/internal/store/sponsors.go new file mode 100644 index 00000000..7ad3df19 --- /dev/null +++ b/internal/store/sponsors.go @@ -0,0 +1,149 @@ +package store + +import ( + "context" + "database/sql" + "errors" + "time" +) + +type Sponsor struct { + ID string `json:"id"` + Name string `json:"name"` + Tier string `json:"tier"` + LogoPath string `json:"logo_path"` + WebsiteURL string `json:"website_url"` + Description string `json:"description"` + DisplayOrder int `json:"display_order"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type SponsorsStore struct { + db *sql.DB +} + +func (s *SponsorsStore) List(ctx context.Context) ([]Sponsor, error) { + + ctx, cancel := context.WithTimeout(ctx, QueryTimeoutDuration) + defer cancel() + + query := ` + SELECT id, name, tier, logo_path, website_url, description, display_order, created_at, updated_at + FROM sponsors + ORDER BY display_order ASC + ` + + rows, err := s.db.QueryContext(ctx, query) + if err != nil { + return nil, err + } + defer rows.Close() + + var sponsors []Sponsor + for rows.Next() { + var sponsor Sponsor + if err := rows.Scan( + &sponsor.ID, &sponsor.Name, &sponsor.Tier, &sponsor.LogoPath, + &sponsor.WebsiteURL, &sponsor.Description, &sponsor.DisplayOrder, + &sponsor.CreatedAt, &sponsor.UpdatedAt, + ); err != nil { + return nil, err + } + sponsors = append(sponsors, sponsor) + } + + if sponsors == nil { + sponsors = []Sponsor{} + } + + return sponsors, rows.Err() +} + +func (s *SponsorsStore) Create(ctx context.Context, sponsor *Sponsor) error { + ctx, cancel := context.WithTimeout(ctx, QueryTimeoutDuration) + defer cancel() + + query := ` + INSERT INTO sponsors (name, tier, logo_path, website_url, description, display_order) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING id, created_at, updated_at + ` + + return s.db.QueryRowContext(ctx, query, + sponsor.Name, sponsor.Tier, sponsor.LogoPath, sponsor.WebsiteURL, sponsor.Description, sponsor.DisplayOrder, + ).Scan(&sponsor.ID, &sponsor.CreatedAt, &sponsor.UpdatedAt) +} + +func (s *SponsorsStore) Update(ctx context.Context, sponsor *Sponsor) error { + ctx, cancel := context.WithTimeout(ctx, QueryTimeoutDuration) + defer cancel() + + query := ` + UPDATE sponsors + SET name = $1, tier = $2, logo_path = $3, website_url = $4, description = $5, display_order = $6 + WHERE id = $7 + RETURNING updated_at + ` + + err := s.db.QueryRowContext(ctx, query, + sponsor.Name, sponsor.Tier, sponsor.LogoPath, sponsor.WebsiteURL, sponsor.Description, sponsor.DisplayOrder, sponsor.ID, + ).Scan(&sponsor.UpdatedAt) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return ErrNotFound + } + return err + } + + return nil +} + +func (s *SponsorsStore) Delete(ctx context.Context, id string) error { + ctx, cancel := context.WithTimeout(ctx, QueryTimeoutDuration) + defer cancel() + + query := `DELETE FROM sponsors WHERE id = $1` + + result, err := s.db.ExecContext(ctx, query, id) + if err != nil { + return err + } + + rows, err := result.RowsAffected() + if err != nil { + return err + } + + if rows == 0 { + return ErrNotFound + } + + return nil +} + +func (s *SponsorsStore) GetByID(ctx context.Context, id string) (*Sponsor, error) { + ctx, cancel := context.WithTimeout(ctx, QueryTimeoutDuration) + defer cancel() + + query := ` + SELECT id, name, tier, logo_path, website_url, description, display_order, created_at, updated_at + FROM sponsors + WHERE id = $1 + ` + + var sponsor Sponsor + err := s.db.QueryRowContext(ctx, query, id).Scan( + &sponsor.ID, &sponsor.Name, &sponsor.Tier, &sponsor.LogoPath, + &sponsor.WebsiteURL, &sponsor.Description, &sponsor.DisplayOrder, + &sponsor.CreatedAt, &sponsor.UpdatedAt, + ) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound + } + return nil, err + } + + return &sponsor, nil +} diff --git a/internal/store/storage.go b/internal/store/storage.go index e01a8965..50860391 100644 --- a/internal/store/storage.go +++ b/internal/store/storage.go @@ -75,6 +75,13 @@ type Storage struct { Update(ctx context.Context, item *ScheduleItem) error Delete(ctx context.Context, id string) error } + Sponsors interface { + List(ctx context.Context) ([]Sponsor, error) + Create(ctx context.Context, sponsor *Sponsor) error + Update(ctx context.Context, sponsor *Sponsor) error + Delete(ctx context.Context, id string) error + GetByID(ctx context.Context, id string) (*Sponsor, error) + } } func NewStorage(db *sql.DB) Storage { @@ -85,5 +92,6 @@ func NewStorage(db *sql.DB) Storage { ApplicationReviews: &ApplicationReviewsStore{db: db}, Scans: &ScansStore{db: db}, Schedule: &ScheduleStore{db: db}, + Sponsors: &SponsorsStore{db: db}, } }