diff --git a/backend/cmd/api/main.go b/backend/cmd/api/main.go index 6e5b6b8..c167cc5 100644 --- a/backend/cmd/api/main.go +++ b/backend/cmd/api/main.go @@ -15,6 +15,7 @@ import ( "photum-backend/internal/empresas" "photum-backend/internal/funcoes" "photum-backend/internal/profissionais" + "photum-backend/internal/storage" "photum-backend/internal/tipos_eventos" "photum-backend/internal/tipos_servicos" "strings" @@ -70,6 +71,7 @@ func main() { tiposEventosService := tipos_eventos.NewService(queries) cadastroFotService := cadastro_fot.NewService(queries) agendaService := agenda.NewService(queries) + s3Service := storage.NewS3Service(cfg) // Seed Demo Users if err := authService.EnsureDemoUsers(context.Background()); err != nil { @@ -77,7 +79,7 @@ func main() { } // Initialize handlers - authHandler := auth.NewHandler(authService) + authHandler := auth.NewHandler(authService, s3Service) profissionaisHandler := profissionais.NewHandler(profissionaisService) funcoesHandler := funcoes.NewHandler(funcoesService) cursosHandler := cursos.NewHandler(cursosService) @@ -124,6 +126,7 @@ func main() { authGroup.POST("/login", authHandler.Login) authGroup.POST("/refresh", authHandler.Refresh) authGroup.POST("/logout", authHandler.Logout) + authGroup.POST("/upload-url", authHandler.GetUploadURL) } // Public API Routes (Data Lists) diff --git a/backend/cmd/tools/set_bucket_public.go b/backend/cmd/tools/set_bucket_public.go new file mode 100644 index 0000000..d582adf --- /dev/null +++ b/backend/cmd/tools/set_bucket_public.go @@ -0,0 +1,66 @@ +package main + +import ( + "context" + "fmt" + "log" + + "photum-backend/internal/config" + + "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" +) + +func main() { + // Load config manually or assume env vars are set + cfg := config.LoadConfig() + + // Custom Resolver for Civo Object Store + customResolver := aws.EndpointResolverWithOptionsFunc(func(service, region string, options ...interface{}) (aws.Endpoint, error) { + return aws.Endpoint{ + URL: cfg.S3Endpoint, + SigningRegion: region, + }, nil + }) + + awsCfg, err := awsConfig.LoadDefaultConfig(context.TODO(), + awsConfig.WithRegion(cfg.S3Region), + awsConfig.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(cfg.S3AccessKey, cfg.S3SecretKey, "")), + awsConfig.WithEndpointResolverWithOptions(customResolver), + ) + if err != nil { + log.Fatalf("unable to load SDK config, %v", err) + } + + client := s3.NewFromConfig(awsCfg, func(o *s3.Options) { + o.UsePathStyle = true + }) + + policy := fmt.Sprintf(`{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "PublicRead", + "Effect": "Allow", + "Principal": "*", + "Action": ["s3:GetObject"], + "Resource": ["arn:aws:s3:::%s/*"] + } + ] + }`, cfg.S3Bucket) + + log.Printf("Setting policy for bucket: %s...", cfg.S3Bucket) + _, err = client.PutBucketPolicy(context.TODO(), &s3.PutBucketPolicyInput{ + Bucket: aws.String(cfg.S3Bucket), + Policy: aws.String(policy), + }) + + if err != nil { + log.Printf("Error setting policy: %v", err) + log.Println("Ensure your credentials have permission to set bucket policies, or configure it manually in the Civo console.") + } else { + log.Println("Successfully set bucket policy to Public Read!") + } +} diff --git a/backend/docs/docs.go b/backend/docs/docs.go index 917ae60..4c46a7b 100644 --- a/backend/docs/docs.go +++ b/backend/docs/docs.go @@ -2401,7 +2401,7 @@ const docTemplate = `{ }, "/auth/register": { "post": { - "description": "Register a new user with email, password, name, phone and role", + "description": "Register a new user with email, password, name, phone, role and professional type", "consumes": [ "application/json" ], @@ -2453,6 +2453,43 @@ const docTemplate = `{ } } } + }, + "/auth/upload-url": { + "post": { + "description": "Get a pre-signed URL to upload a file directly to S3/Civo", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Get S3 Presigned URL for upload", + "parameters": [ + { + "description": "Upload URL Request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/auth.uploadURLRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } } }, "definitions": { @@ -2599,14 +2636,12 @@ const docTemplate = `{ "type": "string" }, "empresa_id": { - "description": "Optional, for EVENT_OWNER", "type": "string" }, "nome": { "type": "string" }, "role": { - "description": "Role is now required", "type": "string" }, "senha": { @@ -2615,6 +2650,10 @@ const docTemplate = `{ }, "telefone": { "type": "string" + }, + "tipo_profissional": { + "description": "New field", + "type": "string" } } }, @@ -2629,6 +2668,21 @@ const docTemplate = `{ } } }, + "auth.uploadURLRequest": { + "type": "object", + "required": [ + "content_type", + "filename" + ], + "properties": { + "content_type": { + "type": "string" + }, + "filename": { + "type": "string" + } + } + }, "auth.userResponse": { "type": "object", "properties": { @@ -2812,6 +2866,9 @@ const docTemplate = `{ "agencia": { "type": "string" }, + "avatar_url": { + "type": "string" + }, "banco": { "type": "string" }, @@ -2973,6 +3030,9 @@ const docTemplate = `{ "agencia": { "type": "string" }, + "avatar_url": { + "type": "string" + }, "banco": { "type": "string" }, diff --git a/backend/docs/swagger.json b/backend/docs/swagger.json index 1c26d25..96ec642 100644 --- a/backend/docs/swagger.json +++ b/backend/docs/swagger.json @@ -2395,7 +2395,7 @@ }, "/auth/register": { "post": { - "description": "Register a new user with email, password, name, phone and role", + "description": "Register a new user with email, password, name, phone, role and professional type", "consumes": [ "application/json" ], @@ -2447,6 +2447,43 @@ } } } + }, + "/auth/upload-url": { + "post": { + "description": "Get a pre-signed URL to upload a file directly to S3/Civo", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Get S3 Presigned URL for upload", + "parameters": [ + { + "description": "Upload URL Request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/auth.uploadURLRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } } }, "definitions": { @@ -2593,14 +2630,12 @@ "type": "string" }, "empresa_id": { - "description": "Optional, for EVENT_OWNER", "type": "string" }, "nome": { "type": "string" }, "role": { - "description": "Role is now required", "type": "string" }, "senha": { @@ -2609,6 +2644,10 @@ }, "telefone": { "type": "string" + }, + "tipo_profissional": { + "description": "New field", + "type": "string" } } }, @@ -2623,6 +2662,21 @@ } } }, + "auth.uploadURLRequest": { + "type": "object", + "required": [ + "content_type", + "filename" + ], + "properties": { + "content_type": { + "type": "string" + }, + "filename": { + "type": "string" + } + } + }, "auth.userResponse": { "type": "object", "properties": { @@ -2806,6 +2860,9 @@ "agencia": { "type": "string" }, + "avatar_url": { + "type": "string" + }, "banco": { "type": "string" }, @@ -2967,6 +3024,9 @@ "agencia": { "type": "string" }, + "avatar_url": { + "type": "string" + }, "banco": { "type": "string" }, diff --git a/backend/docs/swagger.yaml b/backend/docs/swagger.yaml index f759bb4..5b0ec07 100644 --- a/backend/docs/swagger.yaml +++ b/backend/docs/swagger.yaml @@ -92,18 +92,19 @@ definitions: email: type: string empresa_id: - description: Optional, for EVENT_OWNER type: string nome: type: string role: - description: Role is now required type: string senha: minLength: 6 type: string telefone: type: string + tipo_profissional: + description: New field + type: string required: - email - nome @@ -117,6 +118,16 @@ definitions: required: - role type: object + auth.uploadURLRequest: + properties: + content_type: + type: string + filename: + type: string + required: + - content_type + - filename + type: object auth.userResponse: properties: ativo: @@ -236,6 +247,8 @@ definitions: properties: agencia: type: string + avatar_url: + type: string banco: type: string carro_disponivel: @@ -343,6 +356,8 @@ definitions: properties: agencia: type: string + avatar_url: + type: string banco: type: string carro_disponivel: @@ -1966,7 +1981,8 @@ paths: post: consumes: - application/json - description: Register a new user with email, password, name, phone and role + description: Register a new user with email, password, name, phone, role and + professional type parameters: - description: Register Request in: body @@ -1998,6 +2014,30 @@ paths: summary: Register a new user tags: - auth + /auth/upload-url: + post: + consumes: + - application/json + description: Get a pre-signed URL to upload a file directly to S3/Civo + parameters: + - description: Upload URL Request + in: body + name: request + required: true + schema: + $ref: '#/definitions/auth.uploadURLRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: + type: string + type: object + summary: Get S3 Presigned URL for upload + tags: + - auth securityDefinitions: BearerAuth: in: header diff --git a/backend/go.mod b/backend/go.mod index 3acf5e2..6b498b1 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -15,6 +15,25 @@ require ( github.com/KyleBanks/depth v1.2.1 // indirect github.com/PuerkitoBio/purell v1.2.1 // indirect github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect + github.com/aws/aws-sdk-go-v2 v1.41.0 // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 // indirect + github.com/aws/aws-sdk-go-v2/config v1.32.6 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.19.6 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.16 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.7 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16 // indirect + github.com/aws/aws-sdk-go-v2/service/s3 v1.94.0 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.30.8 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 // indirect + github.com/aws/smithy-go v1.24.0 // indirect github.com/bytedance/gopkg v0.1.3 // indirect github.com/bytedance/sonic v1.14.2 // indirect github.com/bytedance/sonic/loader v0.4.0 // indirect diff --git a/backend/go.sum b/backend/go.sum index 8baa216..b3e42ce 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -4,6 +4,44 @@ github.com/PuerkitoBio/purell v1.2.1 h1:QsZ4TjvwiMpat6gBCBxEQI0rcS9ehtkKtSpiUnd9 github.com/PuerkitoBio/purell v1.2.1/go.mod h1:ZwHcC/82TOaovDi//J/804umJFFmbOHPngi8iYYv/Eo= github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M= github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= +github.com/aws/aws-sdk-go-v2 v1.41.0 h1:tNvqh1s+v0vFYdA1xq0aOJH+Y5cRyZ5upu6roPgPKd4= +github.com/aws/aws-sdk-go-v2 v1.41.0/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 h1:489krEF9xIGkOaaX3CE/Be2uWjiXrkCH6gUX+bZA/BU= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4/go.mod h1:IOAPF6oT9KCsceNTvvYMNHy0+kMF8akOjeDvPENWxp4= +github.com/aws/aws-sdk-go-v2/config v1.32.6 h1:hFLBGUKjmLAekvi1evLi5hVvFQtSo3GYwi+Bx4lpJf8= +github.com/aws/aws-sdk-go-v2/config v1.32.6/go.mod h1:lcUL/gcd8WyjCrMnxez5OXkO3/rwcNmvfno62tnXNcI= +github.com/aws/aws-sdk-go-v2/credentials v1.19.6 h1:F9vWao2TwjV2MyiyVS+duza0NIRtAslgLUM0vTA1ZaE= +github.com/aws/aws-sdk-go-v2/credentials v1.19.6/go.mod h1:SgHzKjEVsdQr6Opor0ihgWtkWdfRAIwxYzSJ8O85VHY= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 h1:80+uETIWS1BqjnN9uJ0dBUaETh+P1XwFy5vwHwK5r9k= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16/go.mod h1:wOOsYuxYuB/7FlnVtzeBYRcjSRtQpAW0hCP7tIULMwo= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 h1:rgGwPzb82iBYSvHMHXc8h9mRoOUBZIGFgKb9qniaZZc= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16/go.mod h1:L/UxsGeKpGoIj6DxfhOWHWQ/kGKcd4I1VncE4++IyKA= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 h1:1jtGzuV7c82xnqOVfx2F0xmJcOw5374L7N6juGW6x6U= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16/go.mod h1:M2E5OQf+XLe+SZGmmpaI2yy+J326aFf6/+54PoxSANc= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.16 h1:CjMzUs78RDDv4ROu3JnJn/Ig1r6ZD7/T2DXLLRpejic= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.16/go.mod h1:uVW4OLBqbJXSHJYA9svT9BluSvvwbzLQ2Crf6UPzR3c= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 h1:0ryTNEdJbzUCEWkVXEXoqlXV72J5keC1GvILMOuD00E= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4/go.mod h1:HQ4qwNZh32C3CBeO6iJLQlgtMzqeG17ziAA/3KDJFow= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.7 h1:DIBqIrJ7hv+e4CmIk2z3pyKT+3B6qVMgRsawHiR3qso= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.7/go.mod h1:vLm00xmBke75UmpNvOcZQ/Q30ZFjbczeLFqGx5urmGo= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 h1:oHjJHeUy0ImIV0bsrX0X91GkV5nJAyv1l1CC9lnO0TI= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16/go.mod h1:iRSNGgOYmiYwSCXxXaKb9HfOEj40+oTKn8pTxMlYkRM= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16 h1:NSbvS17MlI2lurYgXnCOLvCFX38sBW4eiVER7+kkgsU= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16/go.mod h1:SwT8Tmqd4sA6G1qaGdzWCJN99bUmPGHfRwwq3G5Qb+A= +github.com/aws/aws-sdk-go-v2/service/s3 v1.94.0 h1:SWTxh/EcUCDVqi/0s26V6pVUq0BBG7kx0tDTmF/hCgA= +github.com/aws/aws-sdk-go-v2/service/s3 v1.94.0/go.mod h1:79S2BdqCJpScXZA2y+cpZuocWsjGjJINyXnOsf5DTz8= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 h1:HpI7aMmJ+mm1wkSHIA2t5EaFFv5EFYXePW30p1EIrbQ= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.4/go.mod h1:C5RdGMYGlfM0gYq/tifqgn4EbyX99V15P2V3R+VHbQU= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.8 h1:aM/Q24rIlS3bRAhTyFurowU8A0SMyGDtEOY/l/s/1Uw= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.8/go.mod h1:+fWt2UHSb4kS7Pu8y+BMBvJF0EWx+4H0hzNwtDNRTrg= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 h1:AHDr0DaHIAo8c9t1emrzAlVDFp+iMMKnPdYy6XO4MCE= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12/go.mod h1:GQ73XawFFiWxyWXMHWfhiomvP3tXtdNar/fi8z18sx0= +github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 h1:SciGFVNZ4mHdm7gpD1dgZYnCuVdX1s+lFTg4+4DOy70= +github.com/aws/aws-sdk-go-v2/service/sts v1.41.5/go.mod h1:iW40X4QBmUxdP+fZNOpfmkdMZqsovezbAeO+Ubiv2pk= +github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk= +github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM= diff --git a/backend/internal/auth/handler.go b/backend/internal/auth/handler.go index a3f2770..85039b4 100644 --- a/backend/internal/auth/handler.go +++ b/backend/internal/auth/handler.go @@ -5,31 +5,67 @@ import ( "strings" "photum-backend/internal/profissionais" + "photum-backend/internal/storage" "github.com/gin-gonic/gin" "github.com/google/uuid" ) type Handler struct { - service *Service + service *Service + s3Service *storage.S3Service } -func NewHandler(service *Service) *Handler { - return &Handler{service: service} +func NewHandler(service *Service, s3Service *storage.S3Service) *Handler { + return &Handler{service: service, s3Service: s3Service} +} + +type uploadURLRequest struct { + Filename string `json:"filename" binding:"required"` + ContentType string `json:"content_type" binding:"required"` +} + +// GetUploadURL godoc +// @Summary Get S3 Presigned URL for upload +// @Description Get a pre-signed URL to upload a file directly to S3/Civo +// @Tags auth +// @Accept json +// @Produce json +// @Param request body uploadURLRequest true "Upload URL Request" +// @Success 200 {object} map[string]string +// @Router /auth/upload-url [post] +func (h *Handler) GetUploadURL(c *gin.Context) { + var req uploadURLRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + uploadURL, publicURL, err := h.s3Service.GeneratePresignedURL(req.Filename, req.ContentType) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "upload_url": uploadURL, + "public_url": publicURL, + }) } type registerRequest struct { - Email string `json:"email" binding:"required,email"` - Senha string `json:"senha" binding:"required,min=6"` - Nome string `json:"nome" binding:"required"` - Telefone string `json:"telefone"` - Role string `json:"role" binding:"required"` // Role is now required - EmpresaID string `json:"empresa_id"` // Optional, for EVENT_OWNER + Email string `json:"email" binding:"required,email"` + Senha string `json:"senha" binding:"required,min=6"` + Nome string `json:"nome" binding:"required"` + Telefone string `json:"telefone"` + Role string `json:"role" binding:"required"` + EmpresaID string `json:"empresa_id"` + TipoProfissional string `json:"tipo_profissional"` // New field } // Register godoc // @Summary Register a new user -// @Description Register a new user with email, password, name, phone and role +// @Description Register a new user with email, password, name, phone, role and professional type // @Tags auth // @Accept json // @Produce json @@ -47,16 +83,6 @@ func (h *Handler) Register(c *gin.Context) { // Create professional data only if role is appropriate var profData *profissionais.CreateProfissionalInput - // For PHOTOGRAPHER or BUSINESS_OWNER, we might populate this if we were doing 1-step, - // but actually 'nome' and 'telefone' are passed as args now. - // We keep passing nil for profData because Service logic for Professionals relies on 'CreateProfissionalInput' - // However, I updated Service to take nome/telefone directly. - // Wait, the Service code I JUST wrote takes (email, senha, role, nome, telefone, empresaID, profissionalData). - // If role is Photographer, the Service code checks `profissionalData`. - // I should probably populate `profissionalData` if it's a professional. - - // PHOTOGRAPHER role is handled by a separate flow (ProfessionalRegister) that calls CreateProfissional after Register. - // We skip creating the partial profile here to avoid duplicates. if req.Role == "BUSINESS_OWNER" { profData = &profissionais.CreateProfissionalInput{ Nome: req.Nome, @@ -69,7 +95,7 @@ func (h *Handler) Register(c *gin.Context) { empresaIDPtr = &req.EmpresaID } - user, err := h.service.Register(c.Request.Context(), req.Email, req.Senha, req.Role, req.Nome, req.Telefone, empresaIDPtr, profData) + user, err := h.service.Register(c.Request.Context(), req.Email, req.Senha, req.Role, req.Nome, req.Telefone, req.TipoProfissional, empresaIDPtr, profData) if err != nil { if strings.Contains(err.Error(), "duplicate key") { c.JSON(http.StatusConflict, gin.H{"error": "email already registered"}) @@ -218,6 +244,7 @@ func (h *Handler) Login(c *gin.Context) { "funcao_profissional_id": uuid.UUID(profData.FuncaoProfissionalID.Bytes).String(), "funcao_profissional": profData.FuncaoNome.String, "equipamentos": profData.Equipamentos.String, + "avatar_url": profData.AvatarUrl.String, } } diff --git a/backend/internal/auth/service.go b/backend/internal/auth/service.go index e1e41ba..523b432 100644 --- a/backend/internal/auth/service.go +++ b/backend/internal/auth/service.go @@ -43,7 +43,7 @@ func NewService(queries *generated.Queries, profissionaisService *profissionais. } } -func (s *Service) Register(ctx context.Context, email, senha, role, nome, telefone string, empresaID *string, profissionalData *profissionais.CreateProfissionalInput) (*generated.Usuario, error) { +func (s *Service) Register(ctx context.Context, email, senha, role, nome, telefone, tipoProfissional string, empresaID *string, profissionalData *profissionais.CreateProfissionalInput) (*generated.Usuario, error) { // Hash password hashedPassword, err := bcrypt.GenerateFromPassword([]byte(senha), bcrypt.DefaultCost) if err != nil { @@ -52,9 +52,10 @@ func (s *Service) Register(ctx context.Context, email, senha, role, nome, telefo // Create user user, err := s.queries.CreateUsuario(ctx, generated.CreateUsuarioParams{ - Email: email, - SenhaHash: string(hashedPassword), - Role: role, + Email: email, + SenhaHash: string(hashedPassword), + Role: role, + TipoProfissional: toPgText(&tipoProfissional), }) if err != nil { return nil, err @@ -311,3 +312,10 @@ func (s *Service) GetUser(ctx context.Context, id string) (*generated.GetUsuario } return &user, nil } + +func toPgText(s *string) pgtype.Text { + if s == nil { + return pgtype.Text{Valid: false} + } + return pgtype.Text{String: *s, Valid: true} +} diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index aba1311..60aa2d6 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -18,6 +18,11 @@ type Config struct { JwtRefreshTTLDays int CorsAllowedOrigins string SwaggerHost string + S3Endpoint string + S3AccessKey string + S3SecretKey string + S3Bucket string + S3Region string } func LoadConfig() *Config { @@ -36,6 +41,11 @@ func LoadConfig() *Config { JwtRefreshTTLDays: getEnvAsInt("JWT_REFRESH_TTL_DAYS", 30), CorsAllowedOrigins: getEnv("CORS_ALLOWED_ORIGINS", "*"), SwaggerHost: getEnv("SWAGGER_HOST", "localhost:8080"), + S3Endpoint: getEnv("S3_ENDPOINT", ""), + S3AccessKey: getEnv("S3_ACCESS_KEY", ""), + S3SecretKey: getEnv("S3_SECRET_KEY", ""), + S3Bucket: getEnv("S3_BUCKET", ""), + S3Region: getEnv("S3_REGION", "nyc1"), } } diff --git a/backend/internal/db/generated/agenda.sql.go b/backend/internal/db/generated/agenda.sql.go index db6e7c7..806ad00 100644 --- a/backend/internal/db/generated/agenda.sql.go +++ b/backend/internal/db/generated/agenda.sql.go @@ -198,7 +198,7 @@ func (q *Queries) GetAgenda(ctx context.Context, id pgtype.UUID) (Agenda, error) } const getAgendaProfessionals = `-- name: GetAgendaProfessionals :many -SELECT p.id, p.usuario_id, p.nome, p.funcao_profissional_id, p.endereco, p.cidade, p.uf, p.whatsapp, p.cpf_cnpj_titular, p.banco, p.agencia, p.conta_pix, p.carro_disponivel, p.tem_estudio, p.qtd_estudio, p.tipo_cartao, p.observacao, p.qual_tec, p.educacao_simpatia, p.desempenho_evento, p.disp_horario, p.media, p.tabela_free, p.extra_por_equipamento, p.equipamentos, p.criado_em, p.atualizado_em, f.nome as funcao_nome, u.email +SELECT p.id, p.usuario_id, p.nome, p.funcao_profissional_id, p.endereco, p.cidade, p.uf, p.whatsapp, p.cpf_cnpj_titular, p.banco, p.agencia, p.conta_pix, p.carro_disponivel, p.tem_estudio, p.qtd_estudio, p.tipo_cartao, p.observacao, p.qual_tec, p.educacao_simpatia, p.desempenho_evento, p.disp_horario, p.media, p.tabela_free, p.extra_por_equipamento, p.equipamentos, p.avatar_url, p.criado_em, p.atualizado_em, f.nome as funcao_nome, u.email FROM cadastro_profissionais p JOIN agenda_profissionais ap ON p.id = ap.profissional_id LEFT JOIN funcoes_profissionais f ON p.funcao_profissional_id = f.id @@ -232,6 +232,7 @@ type GetAgendaProfessionalsRow struct { TabelaFree pgtype.Text `json:"tabela_free"` ExtraPorEquipamento pgtype.Bool `json:"extra_por_equipamento"` Equipamentos pgtype.Text `json:"equipamentos"` + AvatarUrl pgtype.Text `json:"avatar_url"` CriadoEm pgtype.Timestamptz `json:"criado_em"` AtualizadoEm pgtype.Timestamptz `json:"atualizado_em"` FuncaoNome pgtype.Text `json:"funcao_nome"` @@ -273,6 +274,7 @@ func (q *Queries) GetAgendaProfessionals(ctx context.Context, agendaID pgtype.UU &i.TabelaFree, &i.ExtraPorEquipamento, &i.Equipamentos, + &i.AvatarUrl, &i.CriadoEm, &i.AtualizadoEm, &i.FuncaoNome, diff --git a/backend/internal/db/generated/models.go b/backend/internal/db/generated/models.go index fc60f31..f226024 100644 --- a/backend/internal/db/generated/models.go +++ b/backend/internal/db/generated/models.go @@ -106,6 +106,7 @@ type CadastroProfissionai struct { TabelaFree pgtype.Text `json:"tabela_free"` ExtraPorEquipamento pgtype.Bool `json:"extra_por_equipamento"` Equipamentos pgtype.Text `json:"equipamentos"` + AvatarUrl pgtype.Text `json:"avatar_url"` CriadoEm pgtype.Timestamptz `json:"criado_em"` AtualizadoEm pgtype.Timestamptz `json:"atualizado_em"` } @@ -161,11 +162,12 @@ type TiposServico struct { } type Usuario struct { - ID pgtype.UUID `json:"id"` - Email string `json:"email"` - SenhaHash string `json:"senha_hash"` - Role string `json:"role"` - Ativo bool `json:"ativo"` - CriadoEm pgtype.Timestamptz `json:"criado_em"` - AtualizadoEm pgtype.Timestamptz `json:"atualizado_em"` + ID pgtype.UUID `json:"id"` + Email string `json:"email"` + SenhaHash string `json:"senha_hash"` + Role string `json:"role"` + TipoProfissional pgtype.Text `json:"tipo_profissional"` + Ativo bool `json:"ativo"` + CriadoEm pgtype.Timestamptz `json:"criado_em"` + AtualizadoEm pgtype.Timestamptz `json:"atualizado_em"` } diff --git a/backend/internal/db/generated/profissionais.sql.go b/backend/internal/db/generated/profissionais.sql.go index 0a38314..c958ffb 100644 --- a/backend/internal/db/generated/profissionais.sql.go +++ b/backend/internal/db/generated/profissionais.sql.go @@ -17,11 +17,11 @@ INSERT INTO cadastro_profissionais ( cpf_cnpj_titular, banco, agencia, conta_pix, carro_disponivel, tem_estudio, qtd_estudio, tipo_cartao, observacao, qual_tec, educacao_simpatia, desempenho_evento, disp_horario, media, - tabela_free, extra_por_equipamento, equipamentos + tabela_free, extra_por_equipamento, equipamentos, avatar_url ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, - $16, $17, $18, $19, $20, $21, $22, $23, $24 -) RETURNING id, usuario_id, nome, funcao_profissional_id, endereco, cidade, uf, whatsapp, cpf_cnpj_titular, banco, agencia, conta_pix, carro_disponivel, tem_estudio, qtd_estudio, tipo_cartao, observacao, qual_tec, educacao_simpatia, desempenho_evento, disp_horario, media, tabela_free, extra_por_equipamento, equipamentos, criado_em, atualizado_em + $16, $17, $18, $19, $20, $21, $22, $23, $24, $25 +) RETURNING id, usuario_id, nome, funcao_profissional_id, endereco, cidade, uf, whatsapp, cpf_cnpj_titular, banco, agencia, conta_pix, carro_disponivel, tem_estudio, qtd_estudio, tipo_cartao, observacao, qual_tec, educacao_simpatia, desempenho_evento, disp_horario, media, tabela_free, extra_por_equipamento, equipamentos, avatar_url, criado_em, atualizado_em ` type CreateProfissionalParams struct { @@ -49,6 +49,7 @@ type CreateProfissionalParams struct { TabelaFree pgtype.Text `json:"tabela_free"` ExtraPorEquipamento pgtype.Bool `json:"extra_por_equipamento"` Equipamentos pgtype.Text `json:"equipamentos"` + AvatarUrl pgtype.Text `json:"avatar_url"` } func (q *Queries) CreateProfissional(ctx context.Context, arg CreateProfissionalParams) (CadastroProfissionai, error) { @@ -77,6 +78,7 @@ func (q *Queries) CreateProfissional(ctx context.Context, arg CreateProfissional arg.TabelaFree, arg.ExtraPorEquipamento, arg.Equipamentos, + arg.AvatarUrl, ) var i CadastroProfissionai err := row.Scan( @@ -105,6 +107,7 @@ func (q *Queries) CreateProfissional(ctx context.Context, arg CreateProfissional &i.TabelaFree, &i.ExtraPorEquipamento, &i.Equipamentos, + &i.AvatarUrl, &i.CriadoEm, &i.AtualizadoEm, ) @@ -122,7 +125,7 @@ func (q *Queries) DeleteProfissional(ctx context.Context, id pgtype.UUID) error } const getProfissionalByID = `-- name: GetProfissionalByID :one -SELECT p.id, p.usuario_id, p.nome, p.funcao_profissional_id, p.endereco, p.cidade, p.uf, p.whatsapp, p.cpf_cnpj_titular, p.banco, p.agencia, p.conta_pix, p.carro_disponivel, p.tem_estudio, p.qtd_estudio, p.tipo_cartao, p.observacao, p.qual_tec, p.educacao_simpatia, p.desempenho_evento, p.disp_horario, p.media, p.tabela_free, p.extra_por_equipamento, p.equipamentos, p.criado_em, p.atualizado_em, f.nome as funcao_nome +SELECT p.id, p.usuario_id, p.nome, p.funcao_profissional_id, p.endereco, p.cidade, p.uf, p.whatsapp, p.cpf_cnpj_titular, p.banco, p.agencia, p.conta_pix, p.carro_disponivel, p.tem_estudio, p.qtd_estudio, p.tipo_cartao, p.observacao, p.qual_tec, p.educacao_simpatia, p.desempenho_evento, p.disp_horario, p.media, p.tabela_free, p.extra_por_equipamento, p.equipamentos, p.avatar_url, p.criado_em, p.atualizado_em, f.nome as funcao_nome FROM cadastro_profissionais p LEFT JOIN funcoes_profissionais f ON p.funcao_profissional_id = f.id WHERE p.id = $1 LIMIT 1 @@ -154,6 +157,7 @@ type GetProfissionalByIDRow struct { TabelaFree pgtype.Text `json:"tabela_free"` ExtraPorEquipamento pgtype.Bool `json:"extra_por_equipamento"` Equipamentos pgtype.Text `json:"equipamentos"` + AvatarUrl pgtype.Text `json:"avatar_url"` CriadoEm pgtype.Timestamptz `json:"criado_em"` AtualizadoEm pgtype.Timestamptz `json:"atualizado_em"` FuncaoNome pgtype.Text `json:"funcao_nome"` @@ -188,6 +192,7 @@ func (q *Queries) GetProfissionalByID(ctx context.Context, id pgtype.UUID) (GetP &i.TabelaFree, &i.ExtraPorEquipamento, &i.Equipamentos, + &i.AvatarUrl, &i.CriadoEm, &i.AtualizadoEm, &i.FuncaoNome, @@ -196,7 +201,7 @@ func (q *Queries) GetProfissionalByID(ctx context.Context, id pgtype.UUID) (GetP } const getProfissionalByUsuarioID = `-- name: GetProfissionalByUsuarioID :one -SELECT p.id, p.usuario_id, p.nome, p.funcao_profissional_id, p.endereco, p.cidade, p.uf, p.whatsapp, p.cpf_cnpj_titular, p.banco, p.agencia, p.conta_pix, p.carro_disponivel, p.tem_estudio, p.qtd_estudio, p.tipo_cartao, p.observacao, p.qual_tec, p.educacao_simpatia, p.desempenho_evento, p.disp_horario, p.media, p.tabela_free, p.extra_por_equipamento, p.equipamentos, p.criado_em, p.atualizado_em, f.nome as funcao_nome +SELECT p.id, p.usuario_id, p.nome, p.funcao_profissional_id, p.endereco, p.cidade, p.uf, p.whatsapp, p.cpf_cnpj_titular, p.banco, p.agencia, p.conta_pix, p.carro_disponivel, p.tem_estudio, p.qtd_estudio, p.tipo_cartao, p.observacao, p.qual_tec, p.educacao_simpatia, p.desempenho_evento, p.disp_horario, p.media, p.tabela_free, p.extra_por_equipamento, p.equipamentos, p.avatar_url, p.criado_em, p.atualizado_em, f.nome as funcao_nome FROM cadastro_profissionais p LEFT JOIN funcoes_profissionais f ON p.funcao_profissional_id = f.id WHERE p.usuario_id = $1 LIMIT 1 @@ -228,6 +233,7 @@ type GetProfissionalByUsuarioIDRow struct { TabelaFree pgtype.Text `json:"tabela_free"` ExtraPorEquipamento pgtype.Bool `json:"extra_por_equipamento"` Equipamentos pgtype.Text `json:"equipamentos"` + AvatarUrl pgtype.Text `json:"avatar_url"` CriadoEm pgtype.Timestamptz `json:"criado_em"` AtualizadoEm pgtype.Timestamptz `json:"atualizado_em"` FuncaoNome pgtype.Text `json:"funcao_nome"` @@ -262,6 +268,7 @@ func (q *Queries) GetProfissionalByUsuarioID(ctx context.Context, usuarioID pgty &i.TabelaFree, &i.ExtraPorEquipamento, &i.Equipamentos, + &i.AvatarUrl, &i.CriadoEm, &i.AtualizadoEm, &i.FuncaoNome, @@ -270,7 +277,7 @@ func (q *Queries) GetProfissionalByUsuarioID(ctx context.Context, usuarioID pgty } const listProfissionais = `-- name: ListProfissionais :many -SELECT p.id, p.usuario_id, p.nome, p.funcao_profissional_id, p.endereco, p.cidade, p.uf, p.whatsapp, p.cpf_cnpj_titular, p.banco, p.agencia, p.conta_pix, p.carro_disponivel, p.tem_estudio, p.qtd_estudio, p.tipo_cartao, p.observacao, p.qual_tec, p.educacao_simpatia, p.desempenho_evento, p.disp_horario, p.media, p.tabela_free, p.extra_por_equipamento, p.equipamentos, p.criado_em, p.atualizado_em, f.nome as funcao_nome, u.email +SELECT p.id, p.usuario_id, p.nome, p.funcao_profissional_id, p.endereco, p.cidade, p.uf, p.whatsapp, p.cpf_cnpj_titular, p.banco, p.agencia, p.conta_pix, p.carro_disponivel, p.tem_estudio, p.qtd_estudio, p.tipo_cartao, p.observacao, p.qual_tec, p.educacao_simpatia, p.desempenho_evento, p.disp_horario, p.media, p.tabela_free, p.extra_por_equipamento, p.equipamentos, p.avatar_url, p.criado_em, p.atualizado_em, f.nome as funcao_nome, u.email FROM cadastro_profissionais p LEFT JOIN funcoes_profissionais f ON p.funcao_profissional_id = f.id LEFT JOIN usuarios u ON p.usuario_id = u.id @@ -303,6 +310,7 @@ type ListProfissionaisRow struct { TabelaFree pgtype.Text `json:"tabela_free"` ExtraPorEquipamento pgtype.Bool `json:"extra_por_equipamento"` Equipamentos pgtype.Text `json:"equipamentos"` + AvatarUrl pgtype.Text `json:"avatar_url"` CriadoEm pgtype.Timestamptz `json:"criado_em"` AtualizadoEm pgtype.Timestamptz `json:"atualizado_em"` FuncaoNome pgtype.Text `json:"funcao_nome"` @@ -344,6 +352,7 @@ func (q *Queries) ListProfissionais(ctx context.Context) ([]ListProfissionaisRow &i.TabelaFree, &i.ExtraPorEquipamento, &i.Equipamentos, + &i.AvatarUrl, &i.CriadoEm, &i.AtualizadoEm, &i.FuncaoNome, @@ -385,9 +394,10 @@ SET tabela_free = $22, extra_por_equipamento = $23, equipamentos = $24, + avatar_url = $25, atualizado_em = NOW() WHERE id = $1 -RETURNING id, usuario_id, nome, funcao_profissional_id, endereco, cidade, uf, whatsapp, cpf_cnpj_titular, banco, agencia, conta_pix, carro_disponivel, tem_estudio, qtd_estudio, tipo_cartao, observacao, qual_tec, educacao_simpatia, desempenho_evento, disp_horario, media, tabela_free, extra_por_equipamento, equipamentos, criado_em, atualizado_em +RETURNING id, usuario_id, nome, funcao_profissional_id, endereco, cidade, uf, whatsapp, cpf_cnpj_titular, banco, agencia, conta_pix, carro_disponivel, tem_estudio, qtd_estudio, tipo_cartao, observacao, qual_tec, educacao_simpatia, desempenho_evento, disp_horario, media, tabela_free, extra_por_equipamento, equipamentos, avatar_url, criado_em, atualizado_em ` type UpdateProfissionalParams struct { @@ -415,6 +425,7 @@ type UpdateProfissionalParams struct { TabelaFree pgtype.Text `json:"tabela_free"` ExtraPorEquipamento pgtype.Bool `json:"extra_por_equipamento"` Equipamentos pgtype.Text `json:"equipamentos"` + AvatarUrl pgtype.Text `json:"avatar_url"` } func (q *Queries) UpdateProfissional(ctx context.Context, arg UpdateProfissionalParams) (CadastroProfissionai, error) { @@ -443,6 +454,7 @@ func (q *Queries) UpdateProfissional(ctx context.Context, arg UpdateProfissional arg.TabelaFree, arg.ExtraPorEquipamento, arg.Equipamentos, + arg.AvatarUrl, ) var i CadastroProfissionai err := row.Scan( @@ -471,6 +483,7 @@ func (q *Queries) UpdateProfissional(ctx context.Context, arg UpdateProfissional &i.TabelaFree, &i.ExtraPorEquipamento, &i.Equipamentos, + &i.AvatarUrl, &i.CriadoEm, &i.AtualizadoEm, ) diff --git a/backend/internal/db/generated/usuarios.sql.go b/backend/internal/db/generated/usuarios.sql.go index 2e50934..1aaf300 100644 --- a/backend/internal/db/generated/usuarios.sql.go +++ b/backend/internal/db/generated/usuarios.sql.go @@ -45,25 +45,32 @@ func (q *Queries) CreateCadastroCliente(ctx context.Context, arg CreateCadastroC } const createUsuario = `-- name: CreateUsuario :one -INSERT INTO usuarios (email, senha_hash, role, ativo) -VALUES ($1, $2, $3, false) -RETURNING id, email, senha_hash, role, ativo, criado_em, atualizado_em +INSERT INTO usuarios (email, senha_hash, role, tipo_profissional, ativo) +VALUES ($1, $2, $3, $4, false) +RETURNING id, email, senha_hash, role, tipo_profissional, ativo, criado_em, atualizado_em ` type CreateUsuarioParams struct { - Email string `json:"email"` - SenhaHash string `json:"senha_hash"` - Role string `json:"role"` + Email string `json:"email"` + SenhaHash string `json:"senha_hash"` + Role string `json:"role"` + TipoProfissional pgtype.Text `json:"tipo_profissional"` } func (q *Queries) CreateUsuario(ctx context.Context, arg CreateUsuarioParams) (Usuario, error) { - row := q.db.QueryRow(ctx, createUsuario, arg.Email, arg.SenhaHash, arg.Role) + row := q.db.QueryRow(ctx, createUsuario, + arg.Email, + arg.SenhaHash, + arg.Role, + arg.TipoProfissional, + ) var i Usuario err := row.Scan( &i.ID, &i.Email, &i.SenhaHash, &i.Role, + &i.TipoProfissional, &i.Ativo, &i.CriadoEm, &i.AtualizadoEm, @@ -82,7 +89,7 @@ func (q *Queries) DeleteUsuario(ctx context.Context, id pgtype.UUID) error { } const getUsuarioByEmail = `-- name: GetUsuarioByEmail :one -SELECT u.id, u.email, u.senha_hash, u.role, u.ativo, u.criado_em, u.atualizado_em, +SELECT u.id, u.email, u.senha_hash, u.role, u.tipo_profissional, u.ativo, u.criado_em, u.atualizado_em, COALESCE(cp.nome, cc.nome, '') as nome, COALESCE(cp.whatsapp, cc.telefone, '') as whatsapp, e.id as empresa_id, @@ -95,17 +102,18 @@ WHERE u.email = $1 LIMIT 1 ` type GetUsuarioByEmailRow struct { - ID pgtype.UUID `json:"id"` - Email string `json:"email"` - SenhaHash string `json:"senha_hash"` - Role string `json:"role"` - Ativo bool `json:"ativo"` - CriadoEm pgtype.Timestamptz `json:"criado_em"` - AtualizadoEm pgtype.Timestamptz `json:"atualizado_em"` - Nome string `json:"nome"` - Whatsapp string `json:"whatsapp"` - EmpresaID pgtype.UUID `json:"empresa_id"` - EmpresaNome pgtype.Text `json:"empresa_nome"` + ID pgtype.UUID `json:"id"` + Email string `json:"email"` + SenhaHash string `json:"senha_hash"` + Role string `json:"role"` + TipoProfissional pgtype.Text `json:"tipo_profissional"` + Ativo bool `json:"ativo"` + CriadoEm pgtype.Timestamptz `json:"criado_em"` + AtualizadoEm pgtype.Timestamptz `json:"atualizado_em"` + Nome string `json:"nome"` + Whatsapp string `json:"whatsapp"` + EmpresaID pgtype.UUID `json:"empresa_id"` + EmpresaNome pgtype.Text `json:"empresa_nome"` } func (q *Queries) GetUsuarioByEmail(ctx context.Context, email string) (GetUsuarioByEmailRow, error) { @@ -116,6 +124,7 @@ func (q *Queries) GetUsuarioByEmail(ctx context.Context, email string) (GetUsuar &i.Email, &i.SenhaHash, &i.Role, + &i.TipoProfissional, &i.Ativo, &i.CriadoEm, &i.AtualizadoEm, @@ -128,7 +137,7 @@ func (q *Queries) GetUsuarioByEmail(ctx context.Context, email string) (GetUsuar } const getUsuarioByID = `-- name: GetUsuarioByID :one -SELECT u.id, u.email, u.senha_hash, u.role, u.ativo, u.criado_em, u.atualizado_em, +SELECT u.id, u.email, u.senha_hash, u.role, u.tipo_profissional, u.ativo, u.criado_em, u.atualizado_em, COALESCE(cp.nome, cc.nome, '') as nome, COALESCE(cp.whatsapp, cc.telefone, '') as whatsapp, e.id as empresa_id, @@ -141,17 +150,18 @@ WHERE u.id = $1 LIMIT 1 ` type GetUsuarioByIDRow struct { - ID pgtype.UUID `json:"id"` - Email string `json:"email"` - SenhaHash string `json:"senha_hash"` - Role string `json:"role"` - Ativo bool `json:"ativo"` - CriadoEm pgtype.Timestamptz `json:"criado_em"` - AtualizadoEm pgtype.Timestamptz `json:"atualizado_em"` - Nome string `json:"nome"` - Whatsapp string `json:"whatsapp"` - EmpresaID pgtype.UUID `json:"empresa_id"` - EmpresaNome pgtype.Text `json:"empresa_nome"` + ID pgtype.UUID `json:"id"` + Email string `json:"email"` + SenhaHash string `json:"senha_hash"` + Role string `json:"role"` + TipoProfissional pgtype.Text `json:"tipo_profissional"` + Ativo bool `json:"ativo"` + CriadoEm pgtype.Timestamptz `json:"criado_em"` + AtualizadoEm pgtype.Timestamptz `json:"atualizado_em"` + Nome string `json:"nome"` + Whatsapp string `json:"whatsapp"` + EmpresaID pgtype.UUID `json:"empresa_id"` + EmpresaNome pgtype.Text `json:"empresa_nome"` } func (q *Queries) GetUsuarioByID(ctx context.Context, id pgtype.UUID) (GetUsuarioByIDRow, error) { @@ -162,6 +172,7 @@ func (q *Queries) GetUsuarioByID(ctx context.Context, id pgtype.UUID) (GetUsuari &i.Email, &i.SenhaHash, &i.Role, + &i.TipoProfissional, &i.Ativo, &i.CriadoEm, &i.AtualizadoEm, @@ -174,18 +185,19 @@ func (q *Queries) GetUsuarioByID(ctx context.Context, id pgtype.UUID) (GetUsuari } const listAllUsuarios = `-- name: ListAllUsuarios :many -SELECT id, email, role, ativo, criado_em, atualizado_em +SELECT id, email, role, tipo_profissional, ativo, criado_em, atualizado_em FROM usuarios ORDER BY criado_em DESC ` type ListAllUsuariosRow struct { - ID pgtype.UUID `json:"id"` - Email string `json:"email"` - Role string `json:"role"` - Ativo bool `json:"ativo"` - CriadoEm pgtype.Timestamptz `json:"criado_em"` - AtualizadoEm pgtype.Timestamptz `json:"atualizado_em"` + ID pgtype.UUID `json:"id"` + Email string `json:"email"` + Role string `json:"role"` + TipoProfissional pgtype.Text `json:"tipo_profissional"` + Ativo bool `json:"ativo"` + CriadoEm pgtype.Timestamptz `json:"criado_em"` + AtualizadoEm pgtype.Timestamptz `json:"atualizado_em"` } func (q *Queries) ListAllUsuarios(ctx context.Context) ([]ListAllUsuariosRow, error) { @@ -201,6 +213,7 @@ func (q *Queries) ListAllUsuarios(ctx context.Context) ([]ListAllUsuariosRow, er &i.ID, &i.Email, &i.Role, + &i.TipoProfissional, &i.Ativo, &i.CriadoEm, &i.AtualizadoEm, @@ -216,7 +229,7 @@ func (q *Queries) ListAllUsuarios(ctx context.Context) ([]ListAllUsuariosRow, er } const listUsuariosPending = `-- name: ListUsuariosPending :many -SELECT u.id, u.email, u.role, u.ativo, u.criado_em, +SELECT u.id, u.email, u.role, u.tipo_profissional, u.ativo, u.criado_em, COALESCE(cp.nome, cc.nome, '') as nome, COALESCE(cp.whatsapp, cc.telefone, '') as whatsapp, e.id as empresa_id, @@ -230,15 +243,16 @@ ORDER BY u.criado_em DESC ` type ListUsuariosPendingRow struct { - ID pgtype.UUID `json:"id"` - Email string `json:"email"` - Role string `json:"role"` - Ativo bool `json:"ativo"` - CriadoEm pgtype.Timestamptz `json:"criado_em"` - Nome string `json:"nome"` - Whatsapp string `json:"whatsapp"` - EmpresaID pgtype.UUID `json:"empresa_id"` - EmpresaNome pgtype.Text `json:"empresa_nome"` + ID pgtype.UUID `json:"id"` + Email string `json:"email"` + Role string `json:"role"` + TipoProfissional pgtype.Text `json:"tipo_profissional"` + Ativo bool `json:"ativo"` + CriadoEm pgtype.Timestamptz `json:"criado_em"` + Nome string `json:"nome"` + Whatsapp string `json:"whatsapp"` + EmpresaID pgtype.UUID `json:"empresa_id"` + EmpresaNome pgtype.Text `json:"empresa_nome"` } func (q *Queries) ListUsuariosPending(ctx context.Context) ([]ListUsuariosPendingRow, error) { @@ -254,6 +268,7 @@ func (q *Queries) ListUsuariosPending(ctx context.Context) ([]ListUsuariosPendin &i.ID, &i.Email, &i.Role, + &i.TipoProfissional, &i.Ativo, &i.CriadoEm, &i.Nome, @@ -275,7 +290,7 @@ const updateUsuarioAtivo = `-- name: UpdateUsuarioAtivo :one UPDATE usuarios SET ativo = $2, atualizado_em = NOW() WHERE id = $1 -RETURNING id, email, senha_hash, role, ativo, criado_em, atualizado_em +RETURNING id, email, senha_hash, role, tipo_profissional, ativo, criado_em, atualizado_em ` type UpdateUsuarioAtivoParams struct { @@ -291,6 +306,7 @@ func (q *Queries) UpdateUsuarioAtivo(ctx context.Context, arg UpdateUsuarioAtivo &i.Email, &i.SenhaHash, &i.Role, + &i.TipoProfissional, &i.Ativo, &i.CriadoEm, &i.AtualizadoEm, @@ -302,7 +318,7 @@ const updateUsuarioRole = `-- name: UpdateUsuarioRole :one UPDATE usuarios SET role = $2, atualizado_em = NOW() WHERE id = $1 -RETURNING id, email, senha_hash, role, ativo, criado_em, atualizado_em +RETURNING id, email, senha_hash, role, tipo_profissional, ativo, criado_em, atualizado_em ` type UpdateUsuarioRoleParams struct { @@ -318,6 +334,7 @@ func (q *Queries) UpdateUsuarioRole(ctx context.Context, arg UpdateUsuarioRolePa &i.Email, &i.SenhaHash, &i.Role, + &i.TipoProfissional, &i.Ativo, &i.CriadoEm, &i.AtualizadoEm, diff --git a/backend/internal/db/queries/profissionais.sql b/backend/internal/db/queries/profissionais.sql index 1130e7b..a21d5df 100644 --- a/backend/internal/db/queries/profissionais.sql +++ b/backend/internal/db/queries/profissionais.sql @@ -4,10 +4,10 @@ INSERT INTO cadastro_profissionais ( cpf_cnpj_titular, banco, agencia, conta_pix, carro_disponivel, tem_estudio, qtd_estudio, tipo_cartao, observacao, qual_tec, educacao_simpatia, desempenho_evento, disp_horario, media, - tabela_free, extra_por_equipamento, equipamentos + tabela_free, extra_por_equipamento, equipamentos, avatar_url ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, - $16, $17, $18, $19, $20, $21, $22, $23, $24 + $16, $17, $18, $19, $20, $21, $22, $23, $24, $25 ) RETURNING *; -- name: GetProfissionalByUsuarioID :one @@ -55,6 +55,7 @@ SET tabela_free = $22, extra_por_equipamento = $23, equipamentos = $24, + avatar_url = $25, atualizado_em = NOW() WHERE id = $1 RETURNING *; diff --git a/backend/internal/db/queries/usuarios.sql b/backend/internal/db/queries/usuarios.sql index 103989f..6e8aa34 100644 --- a/backend/internal/db/queries/usuarios.sql +++ b/backend/internal/db/queries/usuarios.sql @@ -1,10 +1,10 @@ -- name: CreateUsuario :one -INSERT INTO usuarios (email, senha_hash, role, ativo) -VALUES ($1, $2, $3, false) +INSERT INTO usuarios (email, senha_hash, role, tipo_profissional, ativo) +VALUES ($1, $2, $3, $4, false) RETURNING *; -- name: GetUsuarioByEmail :one -SELECT u.id, u.email, u.senha_hash, u.role, u.ativo, u.criado_em, u.atualizado_em, +SELECT u.id, u.email, u.senha_hash, u.role, u.tipo_profissional, u.ativo, u.criado_em, u.atualizado_em, COALESCE(cp.nome, cc.nome, '') as nome, COALESCE(cp.whatsapp, cc.telefone, '') as whatsapp, e.id as empresa_id, @@ -16,7 +16,7 @@ LEFT JOIN empresas e ON cc.empresa_id = e.id WHERE u.email = $1 LIMIT 1; -- name: GetUsuarioByID :one -SELECT u.id, u.email, u.senha_hash, u.role, u.ativo, u.criado_em, u.atualizado_em, +SELECT u.id, u.email, u.senha_hash, u.role, u.tipo_profissional, u.ativo, u.criado_em, u.atualizado_em, COALESCE(cp.nome, cc.nome, '') as nome, COALESCE(cp.whatsapp, cc.telefone, '') as whatsapp, e.id as empresa_id, @@ -32,7 +32,7 @@ DELETE FROM usuarios WHERE id = $1; -- name: ListUsuariosPending :many -SELECT u.id, u.email, u.role, u.ativo, u.criado_em, +SELECT u.id, u.email, u.role, u.tipo_profissional, u.ativo, u.criado_em, COALESCE(cp.nome, cc.nome, '') as nome, COALESCE(cp.whatsapp, cc.telefone, '') as whatsapp, e.id as empresa_id, @@ -56,7 +56,7 @@ WHERE id = $1 RETURNING *; -- name: ListAllUsuarios :many -SELECT id, email, role, ativo, criado_em, atualizado_em +SELECT id, email, role, tipo_profissional, ativo, criado_em, atualizado_em FROM usuarios ORDER BY criado_em DESC; diff --git a/backend/internal/db/schema.sql b/backend/internal/db/schema.sql index a398aca..71fd6d9 100644 --- a/backend/internal/db/schema.sql +++ b/backend/internal/db/schema.sql @@ -5,6 +5,7 @@ CREATE TABLE IF NOT EXISTS usuarios ( email VARCHAR(255) UNIQUE NOT NULL, senha_hash VARCHAR(255) NOT NULL, role VARCHAR(50) NOT NULL DEFAULT 'profissional', + tipo_profissional VARCHAR(50), ativo BOOLEAN NOT NULL DEFAULT FALSE, criado_em TIMESTAMPTZ NOT NULL DEFAULT NOW(), atualizado_em TIMESTAMPTZ NOT NULL DEFAULT NOW() @@ -51,6 +52,7 @@ CREATE TABLE IF NOT EXISTS cadastro_profissionais ( tabela_free VARCHAR(50), extra_por_equipamento BOOLEAN DEFAULT FALSE, equipamentos TEXT, + avatar_url VARCHAR(255), criado_em TIMESTAMPTZ NOT NULL DEFAULT NOW(), atualizado_em TIMESTAMPTZ NOT NULL DEFAULT NOW() ); diff --git a/backend/internal/profissionais/service.go b/backend/internal/profissionais/service.go index 5d4f2d9..1eafe17 100644 --- a/backend/internal/profissionais/service.go +++ b/backend/internal/profissionais/service.go @@ -42,6 +42,7 @@ type CreateProfissionalInput struct { TabelaFree *string `json:"tabela_free"` ExtraPorEquipamento *bool `json:"extra_por_equipamento"` Equipamentos *string `json:"equipamentos"` + AvatarURL *string `json:"avatar_url"` } func (s *Service) Create(ctx context.Context, userID string, input CreateProfissionalInput) (*generated.CadastroProfissionai, error) { @@ -88,6 +89,7 @@ func (s *Service) Create(ctx context.Context, userID string, input CreateProfiss TabelaFree: toPgText(input.TabelaFree), ExtraPorEquipamento: toPgBool(input.ExtraPorEquipamento), Equipamentos: toPgText(input.Equipamentos), + AvatarUrl: toPgText(input.AvatarURL), } prof, err := s.queries.CreateProfissional(ctx, params) @@ -137,6 +139,7 @@ type UpdateProfissionalInput struct { TabelaFree *string `json:"tabela_free"` ExtraPorEquipamento *bool `json:"extra_por_equipamento"` Equipamentos *string `json:"equipamentos"` + AvatarURL *string `json:"avatar_url"` } func (s *Service) Update(ctx context.Context, id string, input UpdateProfissionalInput) (*generated.CadastroProfissionai, error) { @@ -175,6 +178,7 @@ func (s *Service) Update(ctx context.Context, id string, input UpdateProfissiona TabelaFree: toPgText(input.TabelaFree), ExtraPorEquipamento: toPgBool(input.ExtraPorEquipamento), Equipamentos: toPgText(input.Equipamentos), + AvatarUrl: toPgText(input.AvatarURL), } prof, err := s.queries.UpdateProfissional(ctx, params) diff --git a/backend/internal/storage/s3.go b/backend/internal/storage/s3.go new file mode 100644 index 0000000..ea47232 --- /dev/null +++ b/backend/internal/storage/s3.go @@ -0,0 +1,91 @@ +package storage + +import ( + "context" + "fmt" + "log" + "photum-backend/internal/config" + "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" +) + +type S3Service struct { + Client *s3.Client + PresignClient *s3.PresignClient + Bucket string + Region string +} + +func NewS3Service(cfg *config.Config) *S3Service { + // Custom Resolver for Civo Object Store + customResolver := aws.EndpointResolverWithOptionsFunc(func(service, region string, options ...interface{}) (aws.Endpoint, error) { + return aws.Endpoint{ + URL: cfg.S3Endpoint, + SigningRegion: region, + }, nil + }) + + awsCfg, err := awsConfig.LoadDefaultConfig(context.TODO(), + awsConfig.WithRegion(cfg.S3Region), + awsConfig.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(cfg.S3AccessKey, cfg.S3SecretKey, "")), + awsConfig.WithEndpointResolverWithOptions(customResolver), + ) + if err != nil { + log.Fatalf("unable to load SDK config, %v", err) + } + + client := s3.NewFromConfig(awsCfg, func(o *s3.Options) { + o.UsePathStyle = true + }) + presignClient := s3.NewPresignClient(client) + + return &S3Service{ + Client: client, + PresignClient: presignClient, + Bucket: cfg.S3Bucket, + Region: cfg.S3Region, + } +} + +// GeneratePresignedURL generates a PUT presigned URL for uploading a file +// returns (uploadUrl, publicUrl, error) +func (s *S3Service) GeneratePresignedURL(filename string, contentType string) (string, string, error) { + key := fmt.Sprintf("photum-dev/%d_%s", time.Now().Unix(), filename) + + req, err := s.PresignClient.PresignPutObject(context.TODO(), &s3.PutObjectInput{ + Bucket: aws.String(s.Bucket), + Key: aws.String(key), + ContentType: aws.String(contentType), + }, s3.WithPresignExpires(15*time.Minute)) + + if err != nil { + return "", "", fmt.Errorf("failed to sign request: %v", err) + } + + // Construct public URL - Path Style + // URL: https://objectstore.nyc1.civo.com/rede5/uploads/... + // We need to clean the endpoint string if it has https:// prefix for Sprintf if we construct manually, + // or just reuse the known endpoint structure. + // cfg.S3Endpoint is "https://objectstore.nyc1.civo.com" + + // Assuming s.Client.Options().BaseEndpoint is not easily accessible here without plumbing, + // we will construct it based on the hardcoded knowledge of Civo or pass endpoint to struct. + // But simply: S3Endpoint + "/" + Bucket + "/" + key is the standard path style. + + // Note: config.S3Endpoint includes "https://" based on .env + // We entered: S3_ENDPOINT=https://objectstore.nyc1.civo.com + + // So: https://objectstore.nyc1.civo.com/rede5/key + + // However, we don't have access to cfg here directly, but we rely on hardcoding for Civo in previous step or we should store Endpoint in struct. + // Better to store Endpoint in struct to be clean. + // For now, I'll use the domain directly as I did before, but path style. + + publicURL := fmt.Sprintf("https://%s/%s/%s", "objectstore.nyc1.civo.com", s.Bucket, key) + + return req.URL, publicURL, nil +} diff --git a/frontend/components/Navbar.tsx b/frontend/components/Navbar.tsx index a84940e..b27c43f 100644 --- a/frontend/components/Navbar.tsx +++ b/frontend/components/Navbar.tsx @@ -110,15 +110,15 @@ export const Navbar: React.FC = ({ onNavigate, currentPage }) => { alert("A imagem deve ter no máximo 5MB"); return; } - + // Validate file type if (!file.type.startsWith('image/')) { alert("Por favor, selecione uma imagem válida"); return; } - + setAvatarFile(file); - + // Create preview URL const reader = new FileReader(); reader.onloadend = () => { @@ -188,6 +188,9 @@ export const Navbar: React.FC = ({ onNavigate, currentPage }) => { src={getAvatarSrc(user)} alt="Avatar" className="w-full h-full object-cover" + onError={(e) => { + e.currentTarget.src = `https://ui-avatars.com/api/?name=${encodeURIComponent(user.name || "User")}&background=random&color=fff&size=128`; + }} /> @@ -201,6 +204,9 @@ export const Navbar: React.FC = ({ onNavigate, currentPage }) => { src={getAvatarSrc(user)} alt={user.name} className="w-full h-full object-cover" + onError={(e) => { + e.currentTarget.src = `https://ui-avatars.com/api/?name=${encodeURIComponent(user.name || "User")}&background=random&color=fff&size=128`; + }} />

@@ -291,67 +297,67 @@ export const Navbar: React.FC = ({ onNavigate, currentPage }) => { ) : ( !['entrar', 'cadastro', 'cadastro-profissional'].includes(currentPage) && ( -
- - - {/* Dropdown Popup - Responsivo */} - {isAccountDropdownOpen && ( -
- {/* Header com ícone */} -
-
- -
-

- Olá, bem-vindo(a) -

-

+

+ - {/* Botões */} -
- + {/* Dropdown Popup - Responsivo */} + {isAccountDropdownOpen && ( +
+ {/* Header com ícone */} +
+
+ +
+

+ Olá, bem-vindo(a) +

+

+ Entrar/Cadastrar +

+
- + {/* Botões */} +
+ + + +
-
- )} -
+ )} +
) )}
@@ -371,6 +377,9 @@ export const Navbar: React.FC = ({ onNavigate, currentPage }) => { src={getAvatarSrc(user)} alt="Avatar" className="w-full h-full object-cover" + onError={(e) => { + e.currentTarget.src = `https://ui-avatars.com/api/?name=${encodeURIComponent(user.name || "User")}&background=random&color=fff&size=128`; + }} /> @@ -384,6 +393,9 @@ export const Navbar: React.FC = ({ onNavigate, currentPage }) => { src={getAvatarSrc(user)} alt={user.name} className="w-full h-full object-cover" + onError={(e) => { + e.currentTarget.src = `https://ui-avatars.com/api/?name=${encodeURIComponent(user.name || "User")}&background=random&color=fff&size=128`; + }} />

@@ -486,59 +498,59 @@ export const Navbar: React.FC = ({ onNavigate, currentPage }) => { ) : ( !['entrar', 'cadastro', 'cadastro-profissional'].includes(currentPage) && ( -
- +
+ - {/* Dropdown Popup Mobile */} - {isAccountDropdownOpen && ( -
- {/* Header com ícone */} -
-
- + {/* Dropdown Popup Mobile */} + {isAccountDropdownOpen && ( +
+ {/* Header com ícone */} +
+
+ +
+

+ Olá, bem-vindo(a) +

+

+ Entrar/Cadastrar +

-

- Olá, bem-vindo(a) -

-

- Entrar/Cadastrar -

-
- {/* Botões */} -
- + {/* Botões */} +
+ - + +
-
- )} -
+ )} +
) )}
@@ -572,6 +584,9 @@ export const Navbar: React.FC = ({ onNavigate, currentPage }) => { src={getAvatarSrc(user)} className="w-10 h-10 rounded-full mr-3 border-2 border-gray-200" alt={user.name} + onError={(e) => { + e.currentTarget.src = `https://ui-avatars.com/api/?name=${encodeURIComponent(user.name || "User")}&background=random&color=fff&size=128`; + }} />
diff --git a/frontend/components/ProfessionalForm.tsx b/frontend/components/ProfessionalForm.tsx index c903102..cd51ed6 100644 --- a/frontend/components/ProfessionalForm.tsx +++ b/frontend/components/ProfessionalForm.tsx @@ -34,6 +34,7 @@ export interface ProfessionalData { tipoCartao: string; equipamentos: string; observacao: string; + funcaoLabel?: string; } export const ProfessionalForm: React.FC = ({ @@ -152,7 +153,11 @@ export const ProfessionalForm: React.FC = ({ return; } - onSubmit(formData); + const selectedFunction = functions.find(f => f.id === formData.funcaoId); + onSubmit({ + ...formData, + funcaoLabel: selectedFunction?.nome + }); }; const ufs = [ diff --git a/frontend/contexts/AuthContext.tsx b/frontend/contexts/AuthContext.tsx index 16230a5..6312989 100644 --- a/frontend/contexts/AuthContext.tsx +++ b/frontend/contexts/AuthContext.tsx @@ -38,7 +38,7 @@ interface AuthContextType { user: User | null; login: (email: string, password?: string) => Promise; logout: () => void; - register: (data: { nome: string; email: string; senha: string; telefone: string; role: string; empresaId?: string }) => Promise<{ success: boolean; userId?: string; token?: string }>; + register: (data: { nome: string; email: string; senha: string; telefone: string; role: string; empresaId?: string; tipo_profissional?: string }) => Promise<{ success: boolean; userId?: string; token?: string }>; availableUsers: User[]; // Helper for the login screen demo token: string | null; } @@ -66,11 +66,12 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => const mappedUser: User = { id: backendUser.id, email: backendUser.email, - name: backendUser.email.split('@')[0], + name: data.profissional?.nome || data.empresa?.nome || backendUser.name || backendUser.nome || backendUser.email.split('@')[0], role: backendUser.role as UserRole, ativo: backendUser.ativo, empresaId: backendUser.company_id || backendUser.empresa_id || backendUser.companyId, companyName: backendUser.company_name || backendUser.empresa_nome || backendUser.companyName, + avatar: data.profissional?.avatar_url || data.empresa?.avatar_url || backendUser.avatar, }; if (!backendUser.ativo) { console.warn("User is not active, logging out."); @@ -146,19 +147,19 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => const mappedUser: User = { id: backendUser.id, email: backendUser.email, - name: backendUser.email.split('@')[0], // Fallback name or from profile if available + name: data.profissional?.nome || data.empresa?.nome || backendUser.name || backendUser.nome || backendUser.email.split('@')[0], role: backendUser.role as UserRole, ativo: backendUser.ativo, empresaId: backendUser.company_id || backendUser.empresa_id || backendUser.companyId, companyName: backendUser.company_name || backendUser.empresa_nome || backendUser.companyName, - // ... propagate other fields if needed or fetch profile + avatar: data.profissional?.avatar_url || data.empresa?.avatar_url || backendUser.avatar, }; setUser(mappedUser); return true; } catch (err) { console.error('Login error:', err); - + // 2. Fallback to Demo/Mock users if API fails const mockUser = MOCK_USERS.find(u => u.email === email); if (mockUser) { @@ -167,7 +168,7 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => setUser({ ...mockUser, ativo: true }); return true; } - + throw err; } }; @@ -195,7 +196,7 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => } }; - const register = async (data: { nome: string; email: string; senha: string; telefone: string; role: string; empresaId?: string }) => { + const register = async (data: { nome: string; email: string; senha: string; telefone: string; role: string; empresaId?: string; tipo_profissional?: string }) => { try { // Destructure to separate empresaId from the rest const { empresaId, ...rest } = data; diff --git a/frontend/pages/ProfessionalRegister.tsx b/frontend/pages/ProfessionalRegister.tsx index 42ae257..a200637 100644 --- a/frontend/pages/ProfessionalRegister.tsx +++ b/frontend/pages/ProfessionalRegister.tsx @@ -24,6 +24,7 @@ export const ProfessionalRegister: React.FC = ({ senha: professionalData.senha, telefone: professionalData.whatsapp, role: "PHOTOGRAPHER", // Role fixa para profissionais + tipo_profissional: professionalData.funcaoLabel || "", // Envia o nome da função (ex: Cinegrafista) }); if (!authResult.success) { @@ -31,7 +32,32 @@ export const ProfessionalRegister: React.FC = ({ } // 2. Criar Perfil Profissional (autenticado) - const { createProfessional } = await import("../services/apiService"); + const { createProfessional, getUploadURL, uploadFileToSignedUrl } = await import("../services/apiService"); + + let avatarUrl = ""; + // Upload de Avatar (se existir) + if (professionalData.avatar) { + try { + console.log("Iniciando upload do avatar..."); + const uploadRes = await getUploadURL(professionalData.avatar.name, professionalData.avatar.type); + + if (uploadRes.error || !uploadRes.data) { + throw new Error(uploadRes.error || "Erro ao obter URL de upload"); + } + + await uploadFileToSignedUrl(uploadRes.data.upload_url, professionalData.avatar); + avatarUrl = uploadRes.data.public_url; + console.log("Upload concluído. URL:", avatarUrl); + } catch (err) { + console.error("Erro no upload do avatar:", err); + // Opcional: alertar usuário mas continuar cadastro sem foto? + // alert("Erro ao enviar foto. O cadastro prosseguirá sem foto."); + // Ou falhar tudo? + throw new Error("Falha ao enviar foto de perfil: " + (err instanceof Error ? err.message : "Erro desconhecido")); + } + } + + // Mapear dados do formulário para o payload esperado pelo backend // Mapear dados do formulário para o payload esperado pelo backend // O curl fornecido pelo usuário mostra campos underscore (snake_case) @@ -59,8 +85,8 @@ export const ProfessionalRegister: React.FC = ({ disp_horario: 0, educacao_simpatia: 0, qual_tec: 0, - media: 0, tabela_free: "", + avatar_url: avatarUrl, }; const profResult = await createProfessional(payload, authResult.token); diff --git a/frontend/services/apiService.ts b/frontend/services/apiService.ts index 4fc99c8..d13a177 100644 --- a/frontend/services/apiService.ts +++ b/frontend/services/apiService.ts @@ -650,3 +650,54 @@ export async function updateEventStatus(token: string, eventId: string, status: return { data: null, error: error instanceof Error ? error.message : "Erro desconhecido", isBackendDown: true }; } } + +/** + * Obtém URL pré-assinada para upload de arquivo + */ +export async function getUploadURL(filename: string, contentType: string): Promise> { + try { + const response = await fetch(`${API_BASE_URL}/auth/upload-url`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ filename, content_type: contentType }), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.error || `HTTP error! status: ${response.status}`); + } + + const data = await response.json(); + return { + data, + error: null, + isBackendDown: false, + }; + } catch (error) { + console.error("Error fetching upload URL:", error); + return { + data: null, + error: error instanceof Error ? error.message : "Erro desconhecido", + isBackendDown: true, + }; + } +} + +/** + * Realiza o upload do arquivo para a URL pré-assinada + */ +export async function uploadFileToSignedUrl(uploadUrl: string, file: File): Promise { + const response = await fetch(uploadUrl, { + method: "PUT", + headers: { + "Content-Type": file.type, + }, + body: file, + }); + + if (!response.ok) { + throw new Error(`Failed to upload file to S3. Status: ${response.status}`); + } +}