diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 000000000..de2c6c4ec Binary files /dev/null and b/.DS_Store differ diff --git a/.github/.DS_Store b/.github/.DS_Store new file mode 100644 index 000000000..4208cde9e Binary files /dev/null and b/.github/.DS_Store differ diff --git a/Pfp-frontend-refactor.MD b/Pfp-frontend-refactor.MD new file mode 100644 index 000000000..e69de29bb diff --git a/backend/internal/handler/s3.go b/backend/internal/handler/s3.go index fa13299e5..cfbe36d77 100644 --- a/backend/internal/handler/s3.go +++ b/backend/internal/handler/s3.go @@ -96,7 +96,7 @@ func (h *S3Handler) GetUploadURL(c *fiber.Ctx) error { // @Tags s3 // @Accept json // @Produce json -// @Param key path string true "File key (full path after /presigned-url/)" +// @Param key path string true "File key (full path after /presigned-get-url/)" // @Success 200 {string} string "Presigned URL" // @Failure 400 {object} map[string]string // @Failure 500 {object} map[string]string diff --git a/backend/internal/handler/users_test.go b/backend/internal/handler/users_test.go index 5257b738d..2b83c059c 100644 --- a/backend/internal/handler/users_test.go +++ b/backend/internal/handler/users_test.go @@ -427,7 +427,7 @@ func TestUsersHandler_UpdateProfilePicture(t *testing.T) { }, } - app := fiber.New() + app := fiber.New(fiber.Config{ErrorHandler: errs.ErrorHandler}) h := NewUsersHandler(mock, &mockS3Storage{}) app.Put("/users/:userId/profile-picture", h.UpdateProfilePicture) diff --git a/backend/internal/service/server.go b/backend/internal/service/server.go index 709735b64..ad0ffec5f 100644 --- a/backend/internal/service/server.go +++ b/backend/internal/service/server.go @@ -17,11 +17,10 @@ import ( "github.com/generate/selfserve/internal/service/clerk" notificationssvc "github.com/generate/selfserve/internal/service/notifications" - "github.com/generate/selfserve/internal/storage/redis" - s3storage "github.com/generate/selfserve/internal/service/s3" opensearchstorage "github.com/generate/selfserve/internal/service/storage/opensearch" storage "github.com/generate/selfserve/internal/service/storage/postgres" + "github.com/generate/selfserve/internal/storage/redis" "github.com/generate/selfserve/internal/validation" "github.com/goccy/go-json" "github.com/gofiber/fiber/v2" @@ -44,8 +43,6 @@ type App struct { func InitApp(cfg *config.Config) (*App, error) { validation.Init() - // Init DB/repository(ies) - repo, err := storage.NewRepository(cfg.DB) if err != nil { return nil, err @@ -68,7 +65,7 @@ func InitApp(cfg *config.Config) (*App, error) { app := setupApp() setupClerk(cfg) - if err = setupRoutes(app, repo, genkitInstance, cfg, s3Store, openSearchRepos); err != nil { //nolint:wsl + if err = setupRoutes(app, repo, genkitInstance, cfg, s3Store, openSearchRepos); err != nil { if e := repo.Close(); e != nil { return nil, errors.Join(err, e) } diff --git a/backend/internal/service/storage/postgres/repo_types.go b/backend/internal/service/storage/postgres/repo_types.go index 1a631e4f9..1b801c17e 100644 --- a/backend/internal/service/storage/postgres/repo_types.go +++ b/backend/internal/service/storage/postgres/repo_types.go @@ -64,6 +64,7 @@ type S3Storage interface { GeneratePresignedGetURL(ctx context.Context, in models.PresignedURLInput) (string, error) DeleteFile(ctx context.Context, key string) error } + type RoomsRepository interface { FindRoomsWithOptionalGuestBookingsByFloor(ctx context.Context, filter *models.FilterRoomsRequest, hotelID string, cursorRoomNumber int) ([]*models.RoomWithOptionalGuestBooking, error) FindAllFloors(ctx context.Context, hotelID string) ([]int, error) diff --git a/backend/internal/service/storage/postgres/s3/s3storage.go b/backend/internal/service/storage/postgres/s3/s3storage.go new file mode 100644 index 000000000..a0be1178b --- /dev/null +++ b/backend/internal/service/storage/postgres/s3/s3storage.go @@ -0,0 +1,86 @@ +package s3 + +import ( + "context" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + awsConfig "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/credentials" + "github.com/aws/aws-sdk-go-v2/service/s3" + "github.com/generate/selfserve/config" +) + +type Storage struct { + Client *s3.Client + BucketName string + URL *s3.PresignClient +} + +func NewS3Storage(cfg config.S3) (*Storage, error) { + // Create AWS config with your credentials + awsCfg, err := awsConfig.LoadDefaultConfig(context.Background(), + awsConfig.WithRegion(cfg.Region), + awsConfig.WithCredentialsProvider(credentials.NewStaticCredentialsProvider( + cfg.AccessKeyID, + cfg.SecretAccessKey, + "", + )), + ) + if err != nil { + return nil, err + } + + // Create S3 client + client := s3.NewFromConfig(awsCfg) + + return &Storage{ + Client: client, + BucketName: cfg.BucketName, + URL: s3.NewPresignClient(client), + }, nil +} + +func (s *Storage) GeneratePresignedURL(ctx context.Context, key string, expiration time.Duration) (string, error) { + + presignedURL, err := s.URL.PresignPutObject(ctx, &s3.PutObjectInput{ + Bucket: aws.String(s.BucketName), + Key: aws.String(key), + }, func(opts *s3.PresignOptions) { + opts.Expires = expiration + }, +) + + if err != nil { + return "", err + } + + return presignedURL.URL, nil +} + +func (s *Storage) GeneratePresignedGetURL(ctx context.Context, key string, expiration time.Duration) (string, error) { + presignedURL, err := s.URL.PresignGetObject(ctx, &s3.GetObjectInput{ + Bucket: aws.String(s.BucketName), + Key: aws.String(key), + }, func(opts *s3.PresignOptions) { opts.Expires = expiration }, +) + if err != nil { + return "", err + } + return presignedURL.URL, nil +} + + +func (s *Storage) DeleteFile(ctx context.Context, key string) (error) { + _, err := s.Client.DeleteObject(ctx, &s3.DeleteObjectInput{ + Bucket: aws.String(s.BucketName), + Key: aws.String(key), + }) + if err != nil { + return err + } + + return nil +} + + diff --git a/clients/web/src/routeTree.gen.ts b/clients/web/src/routeTree.gen.ts index d03f3cd37..c6556ccb0 100644 --- a/clients/web/src/routeTree.gen.ts +++ b/clients/web/src/routeTree.gen.ts @@ -14,6 +14,7 @@ import { Route as SignInRouteImport } from './routes/sign-in' import { Route as NoOrgRouteImport } from './routes/no-org' import { Route as ProtectedRouteImport } from './routes/_protected' import { Route as IndexRouteImport } from './routes/index' +import { Route as FlowsPfpRouteImport } from './routes/flows.pfp' import { Route as ProtectedTestApiRouteImport } from './routes/_protected/test-api' import { Route as ProtectedSettingsRouteImport } from './routes/_protected/settings' import { Route as ProtectedRoomsRouteImport } from './routes/_protected/rooms' @@ -47,6 +48,11 @@ const IndexRoute = IndexRouteImport.update({ path: '/', getParentRoute: () => rootRouteImport, } as any) +const FlowsPfpRoute = FlowsPfpRouteImport.update({ + id: '/flows/pfp', + path: '/flows/pfp', + getParentRoute: () => rootRouteImport, +} as any) const ProtectedTestApiRoute = ProtectedTestApiRouteImport.update({ id: '/test-api', path: '/test-api', @@ -98,6 +104,7 @@ export interface FileRoutesByFullPath { '/rooms': typeof ProtectedRoomsRouteWithChildren '/settings': typeof ProtectedSettingsRoute '/test-api': typeof ProtectedTestApiRoute + '/flows/pfp': typeof FlowsPfpRoute '/guests/$guestId': typeof ProtectedGuestsGuestIdRoute '/guests/': typeof ProtectedGuestsIndexRoute '/rooms/': typeof ProtectedRoomsIndexRoute @@ -111,6 +118,7 @@ export interface FileRoutesByTo { '/profile': typeof ProtectedProfileRoute '/settings': typeof ProtectedSettingsRoute '/test-api': typeof ProtectedTestApiRoute + '/flows/pfp': typeof FlowsPfpRoute '/guests/$guestId': typeof ProtectedGuestsGuestIdRoute '/guests': typeof ProtectedGuestsIndexRoute '/rooms': typeof ProtectedRoomsIndexRoute @@ -127,6 +135,7 @@ export interface FileRoutesById { '/_protected/rooms': typeof ProtectedRoomsRouteWithChildren '/_protected/settings': typeof ProtectedSettingsRoute '/_protected/test-api': typeof ProtectedTestApiRoute + '/flows/pfp': typeof FlowsPfpRoute '/_protected/guests/$guestId': typeof ProtectedGuestsGuestIdRoute '/_protected/guests/': typeof ProtectedGuestsIndexRoute '/_protected/rooms/': typeof ProtectedRoomsIndexRoute @@ -143,6 +152,7 @@ export interface FileRouteTypes { | '/rooms' | '/settings' | '/test-api' + | '/flows/pfp' | '/guests/$guestId' | '/guests/' | '/rooms/' @@ -156,6 +166,7 @@ export interface FileRouteTypes { | '/profile' | '/settings' | '/test-api' + | '/flows/pfp' | '/guests/$guestId' | '/guests' | '/rooms' @@ -171,6 +182,7 @@ export interface FileRouteTypes { | '/_protected/rooms' | '/_protected/settings' | '/_protected/test-api' + | '/flows/pfp' | '/_protected/guests/$guestId' | '/_protected/guests/' | '/_protected/rooms/' @@ -182,6 +194,7 @@ export interface RootRouteChildren { NoOrgRoute: typeof NoOrgRoute SignInRoute: typeof SignInRoute SignUpRoute: typeof SignUpRoute + FlowsPfpRoute: typeof FlowsPfpRoute } declare module '@tanstack/react-router' { @@ -221,6 +234,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof IndexRouteImport parentRoute: typeof rootRouteImport } + '/flows/pfp': { + id: '/flows/pfp' + path: '/flows/pfp' + fullPath: '/flows/pfp' + preLoaderRoute: typeof FlowsPfpRouteImport + parentRoute: typeof rootRouteImport + } '/_protected/test-api': { id: '/_protected/test-api' path: '/test-api' @@ -322,6 +342,7 @@ const rootRouteChildren: RootRouteChildren = { NoOrgRoute: NoOrgRoute, SignInRoute: SignInRoute, SignUpRoute: SignUpRoute, + FlowsPfpRoute: FlowsPfpRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/clients/web/src/routes/flows.pfp.tsx b/clients/web/src/routes/flows.pfp.tsx new file mode 100644 index 000000000..5f7007e2d --- /dev/null +++ b/clients/web/src/routes/flows.pfp.tsx @@ -0,0 +1,287 @@ +import { createFileRoute } from '@tanstack/react-router' +import { useEffect, useRef, useState } from 'react' + +// @ts-ignore - Environment variable injected by bundler +const API_BASE_URL = process.env.API_BASE_URL || 'http://localhost:8080' + +export const Route = createFileRoute('/flows/pfp')({ component: PfpFlow }) + +function PfpFlow() { + const [profilePicUrl, setProfilePicUrl] = useState(null) + const [status, setStatus] = useState('') + const [isLoading, setIsLoading] = useState(false) + const [isInitialLoading, setIsInitialLoading] = useState(true) + const fileInputRef = useRef(null) + + // Fetch profile picture on component mount + useEffect(() => { + const fetchProfilePicture = async () => { + try { + const res = await fetch( + `${API_BASE_URL}/api/v1/users/${TEST_USER_ID}/profile-picture`, + ) + if (res.ok) { + const data = await res.json() + setProfilePicUrl(data.presigned_url) + } else if (res.status === 404) { + // No profile picture found, which is fine + setProfilePicUrl(null) + } else { + console.error('Failed to fetch profile picture') + } + } catch (err) { + console.error('Error fetching profile picture:', err) + } finally { + setIsInitialLoading(false) + } + } + + fetchProfilePicture() + }, []) + + const getFileExtension = (filename: string): string => { + const ext = filename.split('.').pop()?.toLowerCase() || 'jpg' + return ['jpg', 'jpeg', 'png', 'webp'].includes(ext) ? ext : 'jpg' + } + + const handleUpload = async () => { + const file = fileInputRef.current?.files?.[0] + if (!file) { + setStatus('Please select a file first') + return + } + + setIsLoading(true) + setStatus('Getting upload URL...') + + try { + // Step 1: Get presigned upload URL from backend + const ext = getFileExtension(file.name) + const uploadUrlRes = await fetch( + `${API_BASE_URL}/api/v1/s3/upload-url/${TEST_USER_ID}?ext=${ext}`, + ) + if (!uploadUrlRes.ok) { + throw new Error('Failed to get upload URL') + } + const { presigned_url, key } = await uploadUrlRes.json() + + // Step 2: Upload file directly to S3 + setStatus('Uploading to S3...') + const uploadRes = await fetch(presigned_url, { + method: 'PUT', + body: file, + headers: { + 'Content-Type': file.type, + }, + }) + if (!uploadRes.ok) { + throw new Error('Failed to upload to S3') + } + + // Step 3: Save the key to the user's profile + setStatus('Saving to profile...') + const saveRes = await fetch( + `${API_BASE_URL}/api/v1/users/${TEST_USER_ID}/profile-picture`, + { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ key }), + }, + ) + if (!saveRes.ok) { + throw new Error('Failed to save profile picture') + } + + // Step 4: Get a presigned URL to display the image + setStatus('Fetching display URL...') + const displayUrlRes = await fetch( + `${API_BASE_URL}/api/v1/s3/presigned-get-url/${key}`, + ) + if (!displayUrlRes.ok) { + throw new Error('Failed to get display URL') + } + const { presigned_url: displayUrl } = await displayUrlRes.json() + + setProfilePicUrl(displayUrl) + setStatus('Upload complete!') + } catch (err) { + setStatus( + `Error: ${err instanceof Error ? err.message : 'Unknown error'}`, + ) + } finally { + setIsLoading(false) + } + } + + const handleRemove = async () => { + setIsLoading(true) + setStatus('Removing profile picture...') + + try { + const res = await fetch( + `${API_BASE_URL}/api/v1/users/${TEST_USER_ID}/profile-picture`, + { method: 'DELETE' }, + ) + if (!res.ok) { + throw new Error('Failed to remove profile picture') + } + + setProfilePicUrl(null) + setStatus('Profile picture removed!') + if (fileInputRef.current) { + fileInputRef.current.value = '' + } + } catch (err) { + setStatus( + `Error: ${err instanceof Error ? err.message : 'Unknown error'}`, + ) + } finally { + setIsLoading(false) + } + } + + const handleFileSelect = () => { + if (fileInputRef.current) { + fileInputRef.current.value = '' + fileInputRef.current.click() + } + } + + return ( +
+

+ Profile Picture +

+

+ User ID: {TEST_USER_ID} +

+ + {/* Current Profile Picture */} +
+
+ Current Picture +
+ {isInitialLoading ? ( +
+ Loading... +
+ ) : profilePicUrl ? ( +
+ Profile +
+ ) : ( +
+ No picture +
+ )} +
+ + {/* Actions */} +
+
+ Actions +
+
+ { + if (e.target.files?.[0]) { + handleUpload() + } + }} + /> + + +
+
+ + {/* Status */} + {status && ( +
+ {status} +
+ )} +
+ ) +}