Skip to content

Commit 5436613

Browse files
feat: upload banner api temporary
1 parent 372562d commit 5436613

11 files changed

Lines changed: 283 additions & 14 deletions

File tree

apps/api/cmd/api/main.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ func main() {
6868
authService := services.NewAuthService(userRepo, accountRepo, sessionRepo, txm, client, logger, &cfg.Auth)
6969
userService := services.NewUserService(userRepo, logger)
7070
eventInterestService := services.NewEventInterestService(eventInterestRepo, logger)
71-
eventService := services.NewEventService(eventRepo, userRepo, logger)
71+
eventService := services.NewEventService(eventRepo, userRepo, r2Client, &cfg.CoreBuckets, logger)
7272
emailService := services.NewEmailService(taskQueueClient, logger)
7373
applicationService := services.NewApplicationService(applicationRepo, eventService, txm, r2Client, &cfg.CoreBuckets, logger)
7474

apps/api/internal/api/api.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ func (api *API) setupRoutes(mw *mw.Middleware) {
105105

106106
// Admin-only
107107
r.With(ensureEventAdmin).Patch("/", api.Handlers.Event.UpdateEventById)
108+
r.With(ensureEventAdmin).Post("/banner", api.Handlers.Event.UploadEventBanner)
108109
r.With(ensureEventAdmin).Get("/staff", api.Handlers.Event.GetEventStaffUsers)
109110
r.With(ensureEventAdmin).Post("/roles", api.Handlers.Event.AssignEventRole)
110111

apps/api/internal/api/handlers/events.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -435,3 +435,54 @@ func (h *EventHandler) AssignEventRole(w http.ResponseWriter, r *http.Request) {
435435

436436
w.WriteHeader(http.StatusOK)
437437
}
438+
439+
const maxBannerUploadSize = 5 << 20 // 5 Mb
440+
441+
type EventBannerUploadResponse struct {
442+
BannerUrl string `json:"banner_url"`
443+
}
444+
445+
func (h *EventHandler) UploadEventBanner(w http.ResponseWriter, r *http.Request) {
446+
eventIdStr := chi.URLParam(r, "eventId")
447+
if eventIdStr == "" {
448+
res.SendError(w, http.StatusBadRequest, res.NewError("missing_event_id", "The event ID is missing from the URL!"))
449+
return
450+
}
451+
eventId, err := uuid.Parse(eventIdStr)
452+
if err != nil {
453+
res.SendError(w, http.StatusBadRequest, res.NewError("invalid_event_id", "The event ID is not a valid UUID"))
454+
return
455+
}
456+
457+
if err := r.ParseMultipartForm(maxBannerUploadSize); err != nil {
458+
res.SendError(w, http.StatusBadRequest, res.NewError("invalid_request", "could not parse multipart form"))
459+
return
460+
}
461+
462+
bannerFile, header, err := r.FormFile("image")
463+
if err != nil {
464+
res.SendError(w, http.StatusBadRequest, res.NewError("invalid_request", "invalid resume file"))
465+
return
466+
}
467+
defer bannerFile.Close()
468+
469+
url, err := h.eventService.UploadBanner(r.Context(), eventId, bannerFile, header)
470+
switch err {
471+
case services.ErrFailedToUploadBanner:
472+
res.SendError(w, http.StatusInternalServerError, res.NewError("internal_err", "Something went wrong on our end"))
473+
return
474+
case services.ErrUnexpectedFileType:
475+
res.SendError(w, http.StatusBadRequest, res.NewError("file_error", err.Error()))
476+
return
477+
case nil:
478+
// Continue
479+
default:
480+
res.SendError(w, http.StatusInternalServerError, res.NewError("internal_err", "Something went wrong on our end"))
481+
return
482+
}
483+
484+
res.Send(w, http.StatusOK, EventBannerUploadResponse{
485+
BannerUrl: *url,
486+
})
487+
488+
}

apps/api/internal/api/response/response.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ func SendError(w http.ResponseWriter, status int, errorResponse ErrorResponse) {
3131

3232
// Send marshals any successful payload struct to JSON, sets the status code,
3333
// and writes the response.
34-
func Send(w http.ResponseWriter, status int, payload interface{}) {
34+
func Send(w http.ResponseWriter, status int, payload any) {
3535
w.Header().Set("Content-Type", "application/json; charset=utf-8")
3636
w.WriteHeader(status)
3737

apps/api/internal/config/config.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ type CoreBuckets struct {
3535
Avatars string `env:"USER_AVATARS" envDefault:"core-user-avatars-dev"`
3636
ApplicationResumes string `env:"APPLICATION_RESUMES" envDefault:"core-application-resumes-dev"`
3737
EventAssets string `env:"EVENT_ASSETS" envDefault:"core-event-assets-dev"`
38+
AvatarsBaseUrl string `env:"USER_AVATARS_BASE_URL"`
39+
EventAssetsBaseUrl string `env:"EVENT_ASSETS_BASE_URL"`
3840
}
3941

4042
type Config struct {

apps/api/internal/services/events.go

Lines changed: 69 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,54 @@
11
package services
22

33
import (
4+
"bytes"
45
"context"
56
"errors"
7+
"fmt"
8+
"io"
9+
"mime"
10+
"mime/multipart"
11+
"path/filepath"
12+
"strings"
613
"time"
714

815
"github.com/google/uuid"
916
"github.com/rs/zerolog"
17+
"github.com/swamphacks/core/apps/api/internal/config"
1018
ctxu "github.com/swamphacks/core/apps/api/internal/ctxutils"
1119
"github.com/swamphacks/core/apps/api/internal/db/repository"
1220
"github.com/swamphacks/core/apps/api/internal/db/sqlc"
21+
"github.com/swamphacks/core/apps/api/internal/storage"
1322
)
1423

1524
var (
16-
ErrFailedToCreateEvent = errors.New("failed to create event")
17-
ErrFailedToGetEvent = errors.New("failed to get event")
18-
ErrFailedToUpdateEvent = errors.New("failed to update event")
19-
ErrFailedToDeleteEvent = errors.New("failed to delete event")
20-
ErrFailedToParseUUID = errors.New("failed to parse uuid")
21-
ErrMissingFields = errors.New("missing fields")
22-
ErrMissingPerms = errors.New("missing perms")
25+
ErrFailedToCreateEvent = errors.New("failed to create event")
26+
ErrFailedToGetEvent = errors.New("failed to get event")
27+
ErrFailedToUpdateEvent = errors.New("failed to update event")
28+
ErrFailedToDeleteEvent = errors.New("failed to delete event")
29+
ErrFailedToParseUUID = errors.New("failed to parse uuid")
30+
ErrMissingFields = errors.New("missing fields")
31+
ErrMissingPerms = errors.New("missing perms")
32+
ErrFailedToUploadBanner = errors.New("failed to upload banner")
33+
ErrUnexpectedFileType = errors.New("did not expect this file type")
2334

2435
ErrFailedToSubmitApplication = errors.New("failed to submit application")
2536
)
2637

2738
type EventService struct {
2839
eventRepo *repository.EventRepository
2940
userRepo *repository.UserRepository
41+
storage storage.Storage
42+
buckets *config.CoreBuckets
3043
logger zerolog.Logger
3144
}
3245

33-
func NewEventService(eventRepo *repository.EventRepository, userRepo *repository.UserRepository, logger zerolog.Logger) *EventService {
46+
func NewEventService(eventRepo *repository.EventRepository, userRepo *repository.UserRepository, storage storage.Storage, buckets *config.CoreBuckets, logger zerolog.Logger) *EventService {
3447
return &EventService{
3548
eventRepo: eventRepo,
3649
userRepo: userRepo,
50+
storage: storage,
51+
buckets: buckets,
3752
logger: logger.With().Str("service", "EventService").Str("component", "events").Logger(),
3853
}
3954
}
@@ -197,3 +212,49 @@ func (s *EventService) IsApplicationsOpen(ctx context.Context, eventId uuid.UUID
197212
open := now.After(event.ApplicationOpen) && now.Before(event.ApplicationClose)
198213
return open, nil
199214
}
215+
216+
func (s *EventService) UploadBanner(ctx context.Context, eventId uuid.UUID, banner multipart.File, header *multipart.FileHeader) (*string, error) {
217+
bannerFileBuffer := bytes.NewBuffer(nil)
218+
219+
fileName := header.Filename
220+
fileExt := strings.ToLower(filepath.Ext(fileName))
221+
222+
switch fileExt {
223+
case ".jpg", ".png", ".jpeg":
224+
// Do nothing
225+
default:
226+
return nil, ErrUnexpectedFileType
227+
}
228+
229+
fileType := mime.TypeByExtension(fileExt)
230+
231+
if fileType == "" {
232+
return nil, ErrFailedToUploadBanner
233+
}
234+
235+
if _, err := io.Copy(bannerFileBuffer, banner); err != nil {
236+
return nil, ErrFailedToUploadBanner
237+
}
238+
239+
uploadKey := fmt.Sprintf("%s/banner%s", eventId, fileExt)
240+
241+
err := s.storage.Store(ctx, s.buckets.EventAssets, uploadKey, bannerFileBuffer.Bytes(), &fileType)
242+
if err != nil {
243+
return nil, ErrFailedToUploadBanner
244+
}
245+
246+
// Reconstrust URL
247+
url := fmt.Sprintf("%s/%s", s.buckets.EventAssetsBaseUrl, uploadKey)
248+
249+
err = s.eventRepo.UpdateEventById(ctx, sqlc.UpdateEventByIdParams{
250+
ID: eventId,
251+
BannerDoUpdate: true,
252+
Banner: &url,
253+
})
254+
if err != nil {
255+
return nil, ErrFailedToUpdateEvent
256+
}
257+
258+
return &url, nil
259+
260+
}
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import { Button } from "@/components/ui/Button";
2+
import { useState } from "react";
3+
import { Button as RAC_Button, FileTrigger } from "react-aria-components";
4+
import { useUploadEventBanner } from "../hooks/useUploadEventBanner";
5+
import { toast } from "react-toastify";
6+
7+
interface Props {
8+
bannerUrl: string | null;
9+
eventId: string;
10+
}
11+
12+
const EventBannerUploader = ({ bannerUrl, eventId }: Props) => {
13+
// State to hold the selected file
14+
const [file, setFile] = useState<File | null | undefined>(undefined); // File and null can be uploaded, undefined means no change
15+
const { mutateAsync } = useUploadEventBanner(eventId);
16+
17+
const handleFileSelection = (e: FileList | null) => {
18+
let files = e ? Array.from(e) : [];
19+
setFile(files[0] || null);
20+
};
21+
22+
const handleUpload = async () => {
23+
if (file) {
24+
console.log("Uploading file:", file);
25+
await mutateAsync(file, {
26+
onSuccess: (data) => {
27+
bannerUrl = data.banner_url;
28+
setFile(undefined); // Reset file state after successful upload
29+
toast.success("Banner uploaded successfully!", {
30+
position: "bottom-right",
31+
});
32+
},
33+
onError: () => {
34+
toast.error("Failed to upload banner. Please try again.", {
35+
position: "bottom-right",
36+
});
37+
},
38+
});
39+
} else {
40+
toast.info("No file selected for upload.", {
41+
position: "bottom-right",
42+
});
43+
}
44+
};
45+
46+
return (
47+
<div className="max-w-lg flex flex-col gap-2">
48+
<p>Banner</p>
49+
{bannerUrl || file ? (
50+
<>
51+
<FileTrigger
52+
acceptedFileTypes={["image/png", "image/jpeg", "image/jpg"]}
53+
allowsMultiple={false}
54+
onSelect={handleFileSelection}
55+
>
56+
<RAC_Button className="relative w-full h-58 group p-0 overflow-hidden rounded-sm cursor-pointer">
57+
<img
58+
src={bannerUrl || (file && URL.createObjectURL(file)) || ""}
59+
alt="Event Banner"
60+
className="object-cover w-full h-full rounded-sm border border-neutral-300 dark:border-neutral-700"
61+
/>
62+
{/* Overlay */}
63+
<div className="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity flex flex-row justify-center items-center">
64+
<span className="text-white">Change Banner</span>
65+
</div>
66+
</RAC_Button>
67+
</FileTrigger>
68+
<Button
69+
variant="danger"
70+
size="sm"
71+
className="rounded-sm"
72+
onPress={() => setFile(null)}
73+
>
74+
Remove Banner
75+
</Button>
76+
</>
77+
) : (
78+
<FileTrigger
79+
acceptedFileTypes={["image/*"]}
80+
onSelect={handleFileSelection}
81+
>
82+
<RAC_Button
83+
className="w-full h-58 flex items-center justify-center bg-neutral-100 dark:bg-neutral-800 border border-dashed border-neutral-300 dark:border-neutral-700 rounded-sm cursor-pointer select-none hover:dark:bg-neutral-700 hover:bg-neutral-200 transition-colors"
84+
>
85+
<span className="text-neutral-500">No Banner Uploaded</span>
86+
</RAC_Button>
87+
</FileTrigger>
88+
)}
89+
{file !== undefined && (
90+
<Button
91+
variant="primary"
92+
size="sm"
93+
className="rounded-sm"
94+
onPress={handleUpload}
95+
>
96+
Save Banner Changes
97+
</Button>
98+
)}
99+
<p className="text-sm text-neutral-500">
100+
Recommended dimensions: 1200x300px. Max file size: 5MB. Supported
101+
formats: JPG, JPEG, PNG.
102+
</p>
103+
</div>
104+
);
105+
};
106+
107+
export default EventBannerUploader;

apps/web/src/features/Event/components/EventSettingsForm.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -246,7 +246,7 @@ const EventSettingsForm = ({ event }: Props) => {
246246
isSelected={field.state.value}
247247
onChange={field.handleChange}
248248
>
249-
{field.state.value ? "Event is Public" : "Event is Private"}
249+
{field.state.value ? "Event is published" : "Event is unpublished"}
250250
</Switch>
251251
)}
252252
</form.Field>
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { useMutation, useQueryClient } from "@tanstack/react-query";
2+
import type { Event } from "../schemas/event";
3+
import { api } from "@/lib/ky";
4+
import z from "zod";
5+
6+
//TODO: change this into a openapi spec
7+
const BannerUploadResponseSchema = z.object({
8+
banner_url: z.url(),
9+
});
10+
11+
export const useUploadEventBanner = (eventId: string) => {
12+
const queryClient = useQueryClient();
13+
14+
const eventQueryKey = ["event", eventId] as const;
15+
16+
const uploadBanner = async (bannerFile: File) => {
17+
const formData = new FormData();
18+
formData.append("image", bannerFile);
19+
20+
const result = await api
21+
.post(`events/${eventId}/banner`, {
22+
body: formData,
23+
})
24+
.json();
25+
26+
return BannerUploadResponseSchema.parse(result);
27+
};
28+
29+
return useMutation({
30+
mutationFn: uploadBanner,
31+
onSuccess: (data) => {
32+
queryClient.setQueryData<Event>(eventQueryKey, (old) => {
33+
if (!old) return;
34+
35+
return {
36+
...old,
37+
banner: data.banner_url,
38+
};
39+
});
40+
41+
// Also return the new banner URL
42+
return data.banner_url;
43+
},
44+
});
45+
};

apps/web/src/features/Event/schemas/event.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export const EventSchema = z.object({
1717
is_published: z.boolean(),
1818
created_at: z.coerce.date().nullable(),
1919
updated_at: z.coerce.date().nullable(),
20+
banner: z.url().nullable(),
2021
});
2122

2223
export type Event = z.infer<typeof EventSchema>;

0 commit comments

Comments
 (0)