From 5bc4a551af8f063b81839c6342f1485efe7a4b89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Faleiros?= Date: Tue, 9 Jun 2026 15:57:45 -0300 Subject: [PATCH] Revert "Build post chat and attachment workflows" This reverts commit 0b920da187e8cf2bd11f2f8211307ab09f1882ca. --- .env.example | 14 +- backend/internal/auth/rate_limit.go | 68 - backend/internal/auth/routes.go | 135 +- backend/internal/auth/sessions.go | 112 -- backend/internal/calendar/attachments.go | 20 - backend/internal/calendar/chat.go | 377 ----- backend/internal/calendar/chat_events.go | 55 - backend/internal/calendar/routes.go | 576 +------- backend/internal/config/config.go | 16 +- backend/internal/httpserver/server.go | 14 +- backend/internal/mail/smtp.go | 150 -- backend/internal/security/headers.go | 2 +- backend/internal/users/invitations.go | 2 - backend/internal/users/routes.go | 14 +- backend/migrations/005_refresh_tokens.sql | 11 - .../006_calendar_item_chat_messages.sql | 11 - .../migrations/007_chat_message_features.sql | 19 - docker-compose.yml | 4 +- frontend/nginx.conf | 1 - frontend/src/App.tsx | 125 +- frontend/src/components/layout/Sidebar.tsx | 46 +- frontend/src/lib/api.ts | 2 - frontend/src/lib/auth.ts | 29 +- frontend/src/lib/calendar.ts | 135 +- frontend/src/lib/users.ts | 2 - frontend/src/views/AcceptInvitationView.tsx | 11 +- frontend/src/views/CalendarView.tsx | 3 +- frontend/src/views/DashboardView.tsx | 12 +- frontend/src/views/PostDetailView.tsx | 1246 +---------------- frontend/src/views/UsersView.tsx | 13 - 30 files changed, 204 insertions(+), 3021 deletions(-) delete mode 100644 backend/internal/auth/rate_limit.go delete mode 100644 backend/internal/auth/sessions.go delete mode 100644 backend/internal/calendar/chat.go delete mode 100644 backend/internal/calendar/chat_events.go delete mode 100644 backend/internal/mail/smtp.go delete mode 100644 backend/migrations/005_refresh_tokens.sql delete mode 100644 backend/migrations/006_calendar_item_chat_messages.sql delete mode 100644 backend/migrations/007_chat_message_features.sql diff --git a/.env.example b/.env.example index 05d4774..e46572c 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,5 @@ APP_ENV=production -APP_PUBLIC_URL=http://localhost:5173 +APP_PUBLIC_URL=https://app.example.com.br APP_TIMEZONE=America/Sao_Paulo HTTP_ADDR=:8080 LOG_LEVEL=info @@ -15,7 +15,7 @@ POSTGRES_PASSWORD=change-me-secure-password DATABASE_URL=postgres://mira:change-me-secure-password@postgres:5432/mira?sslmode=disable JWT_SECRET=change-me-with-a-long-random-secret -JWT_ACCESS_TOKEN_TTL_MINUTES=480 +JWT_ACCESS_TOKEN_TTL_MINUTES=15 JWT_REFRESH_TOKEN_TTL_DAYS=7 CORS_ALLOWED_ORIGINS=https://app.example.com.br @@ -25,15 +25,15 @@ CALENDAR_API_BASE_URL=https://brasilapi.com.br CALENDAR_API_KEY= CALENDAR_CACHE_TTL_HOURS=24 -SMTP_HOST= -SMTP_PORT= +SMTP_HOST=smtp.example.com.br +SMTP_PORT=587 SMTP_USER= -SMTP_PASS= -MAIL_FROM= +SMTP_PASSWORD= +SMTP_FROM_EMAIL=no-reply@example.com.br SMTP_FROM_NAME=Mira UPLOAD_MAX_SIZE_MB=10 -UPLOAD_ALLOWED_MIME_TYPES=image/jpeg,image/png,image/webp,application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document +UPLOAD_ALLOWED_MIME_TYPES=image/jpeg,image/png,image/webp,application/pdf STORAGE_DRIVER=local STORAGE_LOCAL_PATH=/var/lib/mira/uploads diff --git a/backend/internal/auth/rate_limit.go b/backend/internal/auth/rate_limit.go deleted file mode 100644 index 518e0f4..0000000 --- a/backend/internal/auth/rate_limit.go +++ /dev/null @@ -1,68 +0,0 @@ -package auth - -import ( - "strings" - "sync" - "time" -) - -type LoginRateLimiter struct { - mu sync.Mutex - attempts map[string]loginAttempt - limit int - window time.Duration -} - -type loginAttempt struct { - Count int - ExpiresAt time.Time -} - -func NewLoginRateLimiter(limit int, window time.Duration) *LoginRateLimiter { - return &LoginRateLimiter{ - attempts: map[string]loginAttempt{}, - limit: limit, - window: window, - } -} - -func (l *LoginRateLimiter) IsBlocked(key string) bool { - l.mu.Lock() - defer l.mu.Unlock() - - attempt, ok := l.attempts[key] - if !ok || time.Now().After(attempt.ExpiresAt) { - delete(l.attempts, key) - return false - } - - return attempt.Count >= l.limit -} - -func (l *LoginRateLimiter) RecordFailure(key string) { - l.mu.Lock() - defer l.mu.Unlock() - - now := time.Now() - attempt, ok := l.attempts[key] - if !ok || now.After(attempt.ExpiresAt) { - l.attempts[key] = loginAttempt{ - Count: 1, - ExpiresAt: now.Add(l.window), - } - return - } - - attempt.Count++ - l.attempts[key] = attempt -} - -func (l *LoginRateLimiter) Clear(key string) { - l.mu.Lock() - defer l.mu.Unlock() - delete(l.attempts, key) -} - -func LoginRateLimitKey(ip string, email string) string { - return strings.TrimSpace(ip) + ":" + strings.ToLower(strings.TrimSpace(email)) -} diff --git a/backend/internal/auth/routes.go b/backend/internal/auth/routes.go index 8ae9853..1fd94ed 100644 --- a/backend/internal/auth/routes.go +++ b/backend/internal/auth/routes.go @@ -4,40 +4,35 @@ import ( "errors" "net/http" "strings" - "time" "mira/backend/internal/users" "github.com/gin-gonic/gin" ) -const refreshCookieName = "mira_refresh_token" - type Routes struct { - users users.Store - sessions SessionStore - tokens TokenService - refreshTTL time.Duration - secureCookies bool - loginRateLimiter *LoginRateLimiter + users users.Store + tokens TokenService } -func NewRoutes(users users.Store, sessions SessionStore, tokens TokenService, refreshTTL time.Duration, secureCookies bool) Routes { +func NewRoutes(users users.Store, tokens TokenService) Routes { return Routes{ - users: users, - sessions: sessions, - tokens: tokens, - refreshTTL: refreshTTL, - secureCookies: secureCookies, - loginRateLimiter: NewLoginRateLimiter(5, 15*time.Minute), + users: users, + tokens: tokens, } } func (r Routes) Register(router *gin.RouterGroup) { router.POST("/login", r.login) router.POST("/invitations/accept", r.acceptInvitation) - router.POST("/logout", r.logout) - router.POST("/refresh", r.refresh) + + router.POST("/logout", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"message": "Sessao encerrada."}) + }) + + router.POST("/refresh", func(c *gin.Context) { + c.JSON(http.StatusNotImplemented, gin.H{"message": "Renovacao de sessao ainda nao implementada."}) + }) } type loginRequest struct { @@ -52,16 +47,8 @@ func (r Routes) login(c *gin.Context) { return } - email := strings.TrimSpace(input.Email) - rateLimitKey := LoginRateLimitKey(c.ClientIP(), email) - if r.loginRateLimiter.IsBlocked(rateLimitKey) { - c.JSON(http.StatusTooManyRequests, gin.H{"message": "Muitas tentativas de login. Tente novamente em alguns minutos."}) - return - } - - user, err := r.users.FindByEmail(c.Request.Context(), email) + user, err := r.users.FindByEmail(c.Request.Context(), strings.TrimSpace(input.Email)) if errors.Is(err, users.ErrNotFound) { - r.loginRateLimiter.RecordFailure(rateLimitKey) c.JSON(http.StatusUnauthorized, gin.H{"message": "E-mail ou senha invalidos."}) return } @@ -70,17 +57,15 @@ func (r Routes) login(c *gin.Context) { return } if !CheckPassword(input.Password, user.PasswordHash) || user.Status != "active" { - r.loginRateLimiter.RecordFailure(rateLimitKey) c.JSON(http.StatusUnauthorized, gin.H{"message": "E-mail ou senha invalidos."}) return } - token, err := r.createSession(c, user) + token, err := r.tokens.IssueAccessToken(user.ID, user.Email, user.Role) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel criar a sessao."}) return } - r.loginRateLimiter.Clear(rateLimitKey) c.JSON(http.StatusOK, gin.H{ "access_token": token, @@ -129,8 +114,15 @@ func (r Routes) acceptInvitation(c *gin.Context) { return } + token, err := r.tokens.IssueAccessToken(user.ID, user.Email, user.Role) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel criar a sessao."}) + return + } + c.JSON(http.StatusOK, gin.H{ - "message": "Senha criada com sucesso. Faca login para acessar o Mira.", + "access_token": token, + "token_type": "Bearer", "user": gin.H{ "id": user.ID, "email": user.Email, @@ -140,84 +132,3 @@ func (r Routes) acceptInvitation(c *gin.Context) { }, }) } - -func (r Routes) refresh(c *gin.Context) { - refreshToken, err := c.Cookie(refreshCookieName) - if err != nil || strings.TrimSpace(refreshToken) == "" { - c.JSON(http.StatusUnauthorized, gin.H{"message": "Sessao expirada."}) - return - } - - tokenHash := HashRefreshToken(refreshToken) - session, err := r.sessions.FindValidRefreshSession(c.Request.Context(), tokenHash) - if errors.Is(err, ErrRefreshTokenNotFound) { - r.clearRefreshCookie(c) - c.JSON(http.StatusUnauthorized, gin.H{"message": "Sessao expirada."}) - return - } - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel renovar a sessao."}) - return - } - - if err := r.sessions.RevokeRefreshSession(c.Request.Context(), tokenHash); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel renovar a sessao."}) - return - } - - accessToken, err := r.createSession(c, session.User) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel renovar a sessao."}) - return - } - - c.JSON(http.StatusOK, gin.H{ - "access_token": accessToken, - "token_type": "Bearer", - "user": gin.H{ - "id": session.User.ID, - "email": session.User.Email, - "name": session.User.Name, - "role": session.User.Role, - "status": session.User.Status, - }, - }) -} - -func (r Routes) logout(c *gin.Context) { - refreshToken, err := c.Cookie(refreshCookieName) - if err == nil && strings.TrimSpace(refreshToken) != "" { - _ = r.sessions.RevokeRefreshSession(c.Request.Context(), HashRefreshToken(refreshToken)) - } - r.clearRefreshCookie(c) - c.JSON(http.StatusOK, gin.H{"message": "Sessao encerrada."}) -} - -func (r Routes) createSession(c *gin.Context, user users.User) (string, error) { - refreshToken, err := GenerateRefreshToken() - if err != nil { - return "", err - } - - if err := r.sessions.CreateRefreshSession( - c.Request.Context(), - user.ID, - HashRefreshToken(refreshToken), - time.Now().Add(r.refreshTTL), - ); err != nil { - return "", err - } - - r.setRefreshCookie(c, refreshToken) - return r.tokens.IssueAccessToken(user.ID, user.Email, user.Role) -} - -func (r Routes) setRefreshCookie(c *gin.Context, token string) { - c.SetSameSite(http.SameSiteLaxMode) - c.SetCookie(refreshCookieName, token, int(r.refreshTTL.Seconds()), "/api/v1/auth", "", r.secureCookies, true) -} - -func (r Routes) clearRefreshCookie(c *gin.Context) { - c.SetSameSite(http.SameSiteLaxMode) - c.SetCookie(refreshCookieName, "", 0, "/api/v1/auth", "", r.secureCookies, true) -} diff --git a/backend/internal/auth/sessions.go b/backend/internal/auth/sessions.go deleted file mode 100644 index 750a3fb..0000000 --- a/backend/internal/auth/sessions.go +++ /dev/null @@ -1,112 +0,0 @@ -package auth - -import ( - "context" - "crypto/rand" - "crypto/sha256" - "encoding/base64" - "encoding/hex" - "errors" - "time" - - "mira/backend/internal/users" - - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgxpool" -) - -var ErrRefreshTokenNotFound = errors.New("refresh token not found") - -type RefreshSession struct { - ID string - TokenHash string - User users.User - ExpiresAt time.Time - RevokedAt *time.Time -} - -type SessionStore struct { - db *pgxpool.Pool -} - -func NewSessionStore(db *pgxpool.Pool) SessionStore { - return SessionStore{db: db} -} - -func GenerateRefreshToken() (string, error) { - bytes := make([]byte, 32) - if _, err := rand.Read(bytes); err != nil { - return "", err - } - return base64.RawURLEncoding.EncodeToString(bytes), nil -} - -func HashRefreshToken(token string) string { - hash := sha256.Sum256([]byte(token)) - return hex.EncodeToString(hash[:]) -} - -func (s SessionStore) CreateRefreshSession(ctx context.Context, userID string, tokenHash string, expiresAt time.Time) error { - _, err := s.db.Exec(ctx, ` - INSERT INTO refresh_tokens (user_id, token_hash, expires_at) - VALUES ($1::uuid, $2, $3) - `, userID, tokenHash, expiresAt) - return err -} - -func (s SessionStore) FindValidRefreshSession(ctx context.Context, tokenHash string) (RefreshSession, error) { - row := s.db.QueryRow(ctx, ` - SELECT - refresh_tokens.id::text, - refresh_tokens.token_hash, - users.id::text, - users.email, - users.name, - users.password_hash, - users.role, - users.status, - users.created_at, - users.updated_at, - refresh_tokens.expires_at, - refresh_tokens.revoked_at - FROM refresh_tokens - INNER JOIN users ON users.id = refresh_tokens.user_id - WHERE refresh_tokens.token_hash = $1 - AND refresh_tokens.revoked_at IS NULL - AND refresh_tokens.expires_at > now() - AND users.status = 'active' - `, tokenHash) - - var session RefreshSession - err := row.Scan( - &session.ID, - &session.TokenHash, - &session.User.ID, - &session.User.Email, - &session.User.Name, - &session.User.PasswordHash, - &session.User.Role, - &session.User.Status, - &session.User.CreatedAt, - &session.User.UpdatedAt, - &session.ExpiresAt, - &session.RevokedAt, - ) - if errors.Is(err, pgx.ErrNoRows) { - return RefreshSession{}, ErrRefreshTokenNotFound - } - if err != nil { - return RefreshSession{}, err - } - return session, nil -} - -func (s SessionStore) RevokeRefreshSession(ctx context.Context, tokenHash string) error { - _, err := s.db.Exec(ctx, ` - UPDATE refresh_tokens - SET revoked_at = now() - WHERE token_hash = $1 - AND revoked_at IS NULL - `, tokenHash) - return err -} diff --git a/backend/internal/calendar/attachments.go b/backend/internal/calendar/attachments.go index 3506bdd..ed7bf6f 100644 --- a/backend/internal/calendar/attachments.go +++ b/backend/internal/calendar/attachments.go @@ -137,26 +137,6 @@ func (s Store) FindAttachment(ctx context.Context, id string, viewerUserID strin return scanAttachment(row) } -func (s Store) DeleteAttachment(ctx context.Context, id string) (Attachment, error) { - row := s.db.QueryRow(ctx, ` - DELETE FROM calendar_item_attachments - WHERE id = $1::uuid - RETURNING - id::text, - calendar_item_id::text, - original_filename, - stored_filename, - mime_type, - size_bytes, - storage_driver, - storage_path, - created_by::text, - created_at - `, id) - - return scanAttachment(row) -} - type attachmentScanner interface { Scan(dest ...any) error } diff --git a/backend/internal/calendar/chat.go b/backend/internal/calendar/chat.go deleted file mode 100644 index 08c7882..0000000 --- a/backend/internal/calendar/chat.go +++ /dev/null @@ -1,377 +0,0 @@ -package calendar - -import ( - "context" - "errors" - "time" - - "github.com/jackc/pgx/v5" -) - -var ErrChatMessageNotFound = errors.New("chat message not found") - -const ( - ChatChannelExternal = "external" - ChatChannelInternal = "internal" -) - -type ChatMessage struct { - ID string `json:"id"` - CalendarItemID string `json:"calendar_item_id"` - Channel string `json:"channel"` - Body string `json:"body"` - CreatedBy *string `json:"created_by"` - AuthorName string `json:"author_name"` - AuthorRole string `json:"author_role"` - CreatedAt time.Time `json:"created_at"` - EditedAt *time.Time `json:"edited_at"` - DeletedAt *time.Time `json:"deleted_at"` - Attachments []ChatAttachment `json:"attachments"` -} - -type ChatAttachment struct { - ID string `json:"id"` - ChatMessageID string `json:"chat_message_id"` - OriginalFilename string `json:"original_filename"` - StoredFilename string `json:"stored_filename"` - MimeType string `json:"mime_type"` - SizeBytes int64 `json:"size_bytes"` - StorageDriver string `json:"storage_driver"` - StoragePath string `json:"-"` - CreatedBy *string `json:"created_by"` - CreatedAt time.Time `json:"created_at"` -} - -type CreateChatMessageInput struct { - CalendarItemID string - Channel string - Body string - CreatedBy string -} - -type CreateChatAttachmentInput struct { - ChatMessageID string - OriginalFilename string - StoredFilename string - MimeType string - SizeBytes int64 - StorageDriver string - StoragePath string - CreatedBy string -} - -func (s Store) ListChatMessages(ctx context.Context, calendarItemID string, channel string) ([]ChatMessage, error) { - rows, err := s.db.Query(ctx, ` - SELECT - calendar_item_chat_messages.id::text, - calendar_item_chat_messages.calendar_item_id::text, - calendar_item_chat_messages.channel, - calendar_item_chat_messages.body, - calendar_item_chat_messages.created_by::text, - COALESCE(users.name, 'Usuario removido'), - COALESCE(users.role, ''), - calendar_item_chat_messages.created_at, - calendar_item_chat_messages.edited_at, - calendar_item_chat_messages.deleted_at - FROM calendar_item_chat_messages - LEFT JOIN users ON users.id = calendar_item_chat_messages.created_by - WHERE calendar_item_chat_messages.calendar_item_id = $1::uuid - AND calendar_item_chat_messages.channel = $2 - ORDER BY calendar_item_chat_messages.created_at ASC - `, calendarItemID, channel) - if err != nil { - return nil, err - } - defer rows.Close() - - messages := []ChatMessage{} - for rows.Next() { - message, err := scanChatMessage(rows) - if err != nil { - return nil, err - } - messages = append(messages, message) - } - - if err := rows.Err(); err != nil { - return nil, err - } - - if err := s.attachChatAttachments(ctx, messages); err != nil { - return nil, err - } - - return messages, nil -} - -func (s Store) CreateChatMessage(ctx context.Context, input CreateChatMessageInput) (ChatMessage, error) { - row := s.db.QueryRow(ctx, ` - INSERT INTO calendar_item_chat_messages ( - calendar_item_id, - channel, - body, - created_by - ) - VALUES ($1::uuid, $2, $3, NULLIF($4, '')::uuid) - RETURNING - calendar_item_chat_messages.id::text, - calendar_item_chat_messages.calendar_item_id::text, - calendar_item_chat_messages.channel, - calendar_item_chat_messages.body, - calendar_item_chat_messages.created_by::text, - COALESCE((SELECT users.name FROM users WHERE users.id = calendar_item_chat_messages.created_by), 'Usuario removido'), - COALESCE((SELECT users.role FROM users WHERE users.id = calendar_item_chat_messages.created_by), ''), - calendar_item_chat_messages.created_at, - calendar_item_chat_messages.edited_at, - calendar_item_chat_messages.deleted_at - `, input.CalendarItemID, input.Channel, input.Body, input.CreatedBy) - - return scanChatMessage(row) -} - -func (s Store) FindChatMessage(ctx context.Context, id string) (ChatMessage, error) { - row := s.db.QueryRow(ctx, ` - SELECT - calendar_item_chat_messages.id::text, - calendar_item_chat_messages.calendar_item_id::text, - calendar_item_chat_messages.channel, - calendar_item_chat_messages.body, - calendar_item_chat_messages.created_by::text, - COALESCE(users.name, 'Usuario removido'), - COALESCE(users.role, ''), - calendar_item_chat_messages.created_at, - calendar_item_chat_messages.edited_at, - calendar_item_chat_messages.deleted_at - FROM calendar_item_chat_messages - LEFT JOIN users ON users.id = calendar_item_chat_messages.created_by - WHERE calendar_item_chat_messages.id = $1::uuid - `, id) - - message, err := scanChatMessage(row) - if err != nil { - return ChatMessage{}, err - } - messages := []ChatMessage{message} - if err := s.attachChatAttachments(ctx, messages); err != nil { - return ChatMessage{}, err - } - return messages[0], nil -} - -func (s Store) UpdateChatMessage(ctx context.Context, id string, body string) (ChatMessage, error) { - row := s.db.QueryRow(ctx, ` - UPDATE calendar_item_chat_messages - SET body = $2, edited_at = now() - WHERE id = $1::uuid - AND deleted_at IS NULL - RETURNING - calendar_item_chat_messages.id::text, - calendar_item_chat_messages.calendar_item_id::text, - calendar_item_chat_messages.channel, - calendar_item_chat_messages.body, - calendar_item_chat_messages.created_by::text, - COALESCE((SELECT users.name FROM users WHERE users.id = calendar_item_chat_messages.created_by), 'Usuario removido'), - COALESCE((SELECT users.role FROM users WHERE users.id = calendar_item_chat_messages.created_by), ''), - calendar_item_chat_messages.created_at, - calendar_item_chat_messages.edited_at, - calendar_item_chat_messages.deleted_at - `, id, body) - - message, err := scanChatMessage(row) - if err != nil { - return ChatMessage{}, err - } - messages := []ChatMessage{message} - if err := s.attachChatAttachments(ctx, messages); err != nil { - return ChatMessage{}, err - } - return messages[0], nil -} - -func (s Store) DeleteChatMessage(ctx context.Context, id string) (ChatMessage, error) { - row := s.db.QueryRow(ctx, ` - UPDATE calendar_item_chat_messages - SET deleted_at = now(), body = '' - WHERE id = $1::uuid - AND deleted_at IS NULL - RETURNING - calendar_item_chat_messages.id::text, - calendar_item_chat_messages.calendar_item_id::text, - calendar_item_chat_messages.channel, - calendar_item_chat_messages.body, - calendar_item_chat_messages.created_by::text, - COALESCE((SELECT users.name FROM users WHERE users.id = calendar_item_chat_messages.created_by), 'Usuario removido'), - COALESCE((SELECT users.role FROM users WHERE users.id = calendar_item_chat_messages.created_by), ''), - calendar_item_chat_messages.created_at, - calendar_item_chat_messages.edited_at, - calendar_item_chat_messages.deleted_at - `, id) - - return scanChatMessage(row) -} - -func (s Store) CreateChatAttachment(ctx context.Context, input CreateChatAttachmentInput) (ChatAttachment, error) { - row := s.db.QueryRow(ctx, ` - INSERT INTO calendar_item_chat_attachments ( - chat_message_id, - original_filename, - stored_filename, - mime_type, - size_bytes, - storage_driver, - storage_path, - created_by - ) - VALUES ($1::uuid, $2, $3, $4, $5, $6, $7, NULLIF($8, '')::uuid) - RETURNING - id::text, - chat_message_id::text, - original_filename, - stored_filename, - mime_type, - size_bytes, - storage_driver, - storage_path, - created_by::text, - created_at - `, input.ChatMessageID, input.OriginalFilename, input.StoredFilename, input.MimeType, input.SizeBytes, input.StorageDriver, input.StoragePath, input.CreatedBy) - - return scanChatAttachment(row) -} - -func (s Store) FindChatAttachment(ctx context.Context, id string, viewerUserID string) (ChatAttachment, error) { - row := s.db.QueryRow(ctx, ` - SELECT - calendar_item_chat_attachments.id::text, - calendar_item_chat_attachments.chat_message_id::text, - calendar_item_chat_attachments.original_filename, - calendar_item_chat_attachments.stored_filename, - calendar_item_chat_attachments.mime_type, - calendar_item_chat_attachments.size_bytes, - calendar_item_chat_attachments.storage_driver, - calendar_item_chat_attachments.storage_path, - calendar_item_chat_attachments.created_by::text, - calendar_item_chat_attachments.created_at - FROM calendar_item_chat_attachments - INNER JOIN calendar_item_chat_messages ON calendar_item_chat_messages.id = calendar_item_chat_attachments.chat_message_id - INNER JOIN calendar_items ON calendar_items.id = calendar_item_chat_messages.calendar_item_id - WHERE calendar_item_chat_attachments.id = $1::uuid - AND calendar_item_chat_messages.deleted_at IS NULL - AND ( - $2 = '' - OR ( - calendar_item_chat_messages.channel = 'external' - AND EXISTS ( - SELECT 1 - FROM client_users - WHERE client_users.client_id = calendar_items.client_id - AND client_users.user_id = $2::uuid - ) - ) - ) - `, id, viewerUserID) - - return scanChatAttachment(row) -} - -func (s Store) attachChatAttachments(ctx context.Context, messages []ChatMessage) error { - if len(messages) == 0 { - return nil - } - - ids := make([]string, 0, len(messages)) - indexByID := map[string]int{} - for index, message := range messages { - ids = append(ids, message.ID) - indexByID[message.ID] = index - } - - rows, err := s.db.Query(ctx, ` - SELECT - id::text, - chat_message_id::text, - original_filename, - stored_filename, - mime_type, - size_bytes, - storage_driver, - storage_path, - created_by::text, - created_at - FROM calendar_item_chat_attachments - WHERE chat_message_id::text = ANY($1) - ORDER BY created_at ASC - `, ids) - if err != nil { - return err - } - defer rows.Close() - - for rows.Next() { - attachment, err := scanChatAttachment(rows) - if err != nil { - return err - } - index, ok := indexByID[attachment.ChatMessageID] - if ok { - messages[index].Attachments = append(messages[index].Attachments, attachment) - } - } - - return rows.Err() -} - -type chatMessageScanner interface { - Scan(dest ...any) error -} - -func scanChatMessage(row chatMessageScanner) (ChatMessage, error) { - var message ChatMessage - err := row.Scan( - &message.ID, - &message.CalendarItemID, - &message.Channel, - &message.Body, - &message.CreatedBy, - &message.AuthorName, - &message.AuthorRole, - &message.CreatedAt, - &message.EditedAt, - &message.DeletedAt, - ) - if errors.Is(err, pgx.ErrNoRows) { - return ChatMessage{}, ErrChatMessageNotFound - } - if err != nil { - return ChatMessage{}, err - } - message.Attachments = []ChatAttachment{} - return message, nil -} - -type chatAttachmentScanner interface { - Scan(dest ...any) error -} - -func scanChatAttachment(row chatAttachmentScanner) (ChatAttachment, error) { - var attachment ChatAttachment - err := row.Scan( - &attachment.ID, - &attachment.ChatMessageID, - &attachment.OriginalFilename, - &attachment.StoredFilename, - &attachment.MimeType, - &attachment.SizeBytes, - &attachment.StorageDriver, - &attachment.StoragePath, - &attachment.CreatedBy, - &attachment.CreatedAt, - ) - if errors.Is(err, pgx.ErrNoRows) { - return ChatAttachment{}, ErrAttachmentNotFound - } - if err != nil { - return ChatAttachment{}, err - } - return attachment, nil -} diff --git a/backend/internal/calendar/chat_events.go b/backend/internal/calendar/chat_events.go deleted file mode 100644 index b2f1168..0000000 --- a/backend/internal/calendar/chat_events.go +++ /dev/null @@ -1,55 +0,0 @@ -package calendar - -import "sync" - -type ChatEventHub struct { - mu sync.Mutex - subscribers map[string]map[chan struct{}]struct{} -} - -func NewChatEventHub() *ChatEventHub { - return &ChatEventHub{ - subscribers: map[string]map[chan struct{}]struct{}{}, - } -} - -func (h *ChatEventHub) Subscribe(itemID string, channel string) (chan struct{}, func()) { - key := chatEventKey(itemID, channel) - events := make(chan struct{}, 1) - - h.mu.Lock() - if h.subscribers[key] == nil { - h.subscribers[key] = map[chan struct{}]struct{}{} - } - h.subscribers[key][events] = struct{}{} - h.mu.Unlock() - - return events, func() { - h.mu.Lock() - defer h.mu.Unlock() - - delete(h.subscribers[key], events) - if len(h.subscribers[key]) == 0 { - delete(h.subscribers, key) - } - close(events) - } -} - -func (h *ChatEventHub) Publish(itemID string, channel string) { - key := chatEventKey(itemID, channel) - - h.mu.Lock() - defer h.mu.Unlock() - - for events := range h.subscribers[key] { - select { - case events <- struct{}{}: - default: - } - } -} - -func chatEventKey(itemID string, channel string) string { - return itemID + ":" + channel -} diff --git a/backend/internal/calendar/routes.go b/backend/internal/calendar/routes.go index af6dcae..7e8b7a9 100644 --- a/backend/internal/calendar/routes.go +++ b/backend/internal/calendar/routes.go @@ -22,7 +22,6 @@ type Routes struct { store Store provider Provider commemorative CommemorativeProvider - chatEvents *ChatEventHub uploadMaxSizeBytes int64 uploadAllowedMIMEs map[string]bool storageDriver string @@ -55,7 +54,6 @@ func NewRoutes(store Store, provider Provider, commemorative CommemorativeProvid store: store, provider: provider, commemorative: commemorative, - chatEvents: NewChatEventHub(), uploadMaxSizeBytes: int64(options.UploadMaxSizeMB) * 1024 * 1024, uploadAllowedMIMEs: allowedMIMEs, storageDriver: options.StorageDriver, @@ -82,15 +80,7 @@ func (r Routes) Register(router *gin.RouterGroup, requireAuth gin.HandlerFunc) { router.DELETE("/items/:itemId", r.cancelCalendarItem) router.GET("/items/:itemId/attachments", r.listAttachments) router.POST("/items/:itemId/attachments", r.uploadAttachment) - router.GET("/items/:itemId/chat", r.listChatMessages) - router.GET("/items/:itemId/chat/stream", r.streamChatMessages) - router.POST("/items/:itemId/chat", r.createChatMessage) - router.PATCH("/chat/messages/:messageId", r.updateChatMessage) - router.DELETE("/chat/messages/:messageId", r.deleteChatMessage) - router.POST("/chat/messages/:messageId/attachments", r.uploadChatAttachment) - router.GET("/chat/attachments/:attachmentId/download", r.downloadChatAttachment) router.GET("/attachments/:attachmentId/download", r.downloadAttachment) - router.DELETE("/attachments/:attachmentId", r.deleteAttachment) } func (r Routes) holidays(c *gin.Context) { @@ -614,376 +604,6 @@ func (r Routes) listAttachments(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"attachments": attachments}) } -func (r Routes) listChatMessages(c *gin.Context) { - claims, ok := auth.ClaimsFromContext(c) - if !ok { - c.JSON(http.StatusUnauthorized, gin.H{"message": "Autenticacao obrigatoria."}) - return - } - - channel := strings.TrimSpace(c.Query("channel")) - if channel == "" { - channel = ChatChannelExternal - } - if !isValidChatChannel(channel) { - c.JSON(http.StatusBadRequest, gin.H{"message": "Canal de chat invalido."}) - return - } - if channel == ChatChannelInternal && claims.Role == users.RoleClientViewer { - c.JSON(http.StatusForbidden, gin.H{"message": "Permissao insuficiente."}) - return - } - - viewerUserID := "" - if claims.Role == users.RoleClientViewer { - viewerUserID = claims.UserID - } - - item, err := r.store.FindCalendarItem(c.Request.Context(), c.Param("itemId"), viewerUserID) - if errors.Is(err, ErrCalendarItemNotFound) { - c.JSON(http.StatusNotFound, gin.H{"message": "Postagem nao encontrada."}) - return - } - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel carregar a postagem."}) - return - } - - messages, err := r.store.ListChatMessages(c.Request.Context(), item.ID, channel) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel carregar mensagens."}) - return - } - - c.JSON(http.StatusOK, gin.H{"messages": messages}) -} - -func (r Routes) streamChatMessages(c *gin.Context) { - claims, ok := auth.ClaimsFromContext(c) - if !ok { - c.JSON(http.StatusUnauthorized, gin.H{"message": "Autenticacao obrigatoria."}) - return - } - - channel := strings.TrimSpace(c.Query("channel")) - if channel == "" { - channel = ChatChannelExternal - } - if !isValidChatChannel(channel) { - c.JSON(http.StatusBadRequest, gin.H{"message": "Canal de chat invalido."}) - return - } - if channel == ChatChannelInternal && claims.Role == users.RoleClientViewer { - c.JSON(http.StatusForbidden, gin.H{"message": "Permissao insuficiente."}) - return - } - - viewerUserID := "" - if claims.Role == users.RoleClientViewer { - viewerUserID = claims.UserID - } - - item, err := r.store.FindCalendarItem(c.Request.Context(), c.Param("itemId"), viewerUserID) - if errors.Is(err, ErrCalendarItemNotFound) { - c.JSON(http.StatusNotFound, gin.H{"message": "Postagem nao encontrada."}) - return - } - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel carregar a postagem."}) - return - } - - events, unsubscribe := r.chatEvents.Subscribe(item.ID, channel) - defer unsubscribe() - - c.Header("Content-Type", "text/event-stream") - c.Header("Cache-Control", "no-cache") - c.Header("Connection", "keep-alive") - c.Header("X-Accel-Buffering", "no") - - flusher, ok := c.Writer.(http.Flusher) - if !ok { - c.JSON(http.StatusInternalServerError, gin.H{"message": "Streaming nao suportado."}) - return - } - - _, _ = c.Writer.Write([]byte("event: ready\ndata: ok\n\n")) - flusher.Flush() - - heartbeat := time.NewTicker(25 * time.Second) - defer heartbeat.Stop() - - for { - select { - case <-c.Request.Context().Done(): - return - case <-events: - _, _ = c.Writer.Write([]byte("event: chat\ndata: refresh\n\n")) - flusher.Flush() - case <-heartbeat.C: - _, _ = c.Writer.Write([]byte("event: ping\ndata: keepalive\n\n")) - flusher.Flush() - } - } -} - -type createChatMessageRequest struct { - Channel string `json:"channel"` - Body string `json:"body" binding:"max=4000"` -} - -func (r Routes) createChatMessage(c *gin.Context) { - claims, ok := auth.ClaimsFromContext(c) - if !ok { - c.JSON(http.StatusUnauthorized, gin.H{"message": "Autenticacao obrigatoria."}) - return - } - - var input createChatMessageRequest - if err := c.ShouldBindJSON(&input); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"message": "Informe uma mensagem valida."}) - return - } - - channel := strings.TrimSpace(input.Channel) - if channel == "" { - channel = ChatChannelExternal - } - if !isValidChatChannel(channel) { - c.JSON(http.StatusBadRequest, gin.H{"message": "Canal de chat invalido."}) - return - } - if channel == ChatChannelInternal && claims.Role == users.RoleClientViewer { - c.JSON(http.StatusForbidden, gin.H{"message": "Permissao insuficiente."}) - return - } - - body := strings.TrimSpace(input.Body) - if body == "" { - c.JSON(http.StatusBadRequest, gin.H{"message": "Informe uma mensagem valida."}) - return - } - - viewerUserID := "" - if claims.Role == users.RoleClientViewer { - viewerUserID = claims.UserID - } - - item, err := r.store.FindCalendarItem(c.Request.Context(), c.Param("itemId"), viewerUserID) - if errors.Is(err, ErrCalendarItemNotFound) { - c.JSON(http.StatusNotFound, gin.H{"message": "Postagem nao encontrada."}) - return - } - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel carregar a postagem."}) - return - } - - message, err := r.store.CreateChatMessage(c.Request.Context(), CreateChatMessageInput{ - CalendarItemID: item.ID, - Channel: channel, - Body: body, - CreatedBy: claims.UserID, - }) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel enviar a mensagem."}) - return - } - - _ = r.store.RecordAudit(c.Request.Context(), claims.UserID, "chat_message.created", "calendar_item", item.ID, map[string]any{ - "channel": channel, - }) - - r.chatEvents.Publish(item.ID, channel) - - c.JSON(http.StatusCreated, gin.H{"message": message}) -} - -type updateChatMessageRequest struct { - Body string `json:"body" binding:"required,min=1,max=4000"` -} - -func (r Routes) updateChatMessage(c *gin.Context) { - claims, ok := auth.ClaimsFromContext(c) - if !ok { - c.JSON(http.StatusUnauthorized, gin.H{"message": "Autenticacao obrigatoria."}) - return - } - - var input updateChatMessageRequest - if err := c.ShouldBindJSON(&input); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"message": "Informe uma mensagem valida."}) - return - } - - message, err := r.authorizedChatMessage(c, claims, c.Param("messageId"), true) - if err != nil { - return - } - if message.DeletedAt != nil { - c.JSON(http.StatusNotFound, gin.H{"message": "Mensagem nao encontrada."}) - return - } - if len(message.Attachments) > 0 { - c.JSON(http.StatusBadRequest, gin.H{"message": "Mensagens com anexos nao podem ser editadas."}) - return - } - - updated, err := r.store.UpdateChatMessage(c.Request.Context(), message.ID, strings.TrimSpace(input.Body)) - if errors.Is(err, ErrChatMessageNotFound) { - c.JSON(http.StatusNotFound, gin.H{"message": "Mensagem nao encontrada."}) - return - } - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel editar a mensagem."}) - return - } - - r.chatEvents.Publish(updated.CalendarItemID, updated.Channel) - - c.JSON(http.StatusOK, gin.H{"message": updated}) -} - -func (r Routes) deleteChatMessage(c *gin.Context) { - claims, ok := auth.ClaimsFromContext(c) - if !ok { - c.JSON(http.StatusUnauthorized, gin.H{"message": "Autenticacao obrigatoria."}) - return - } - - message, err := r.authorizedChatMessage(c, claims, c.Param("messageId"), true) - if err != nil { - return - } - if message.DeletedAt != nil { - c.JSON(http.StatusNotFound, gin.H{"message": "Mensagem nao encontrada."}) - return - } - - deleted, err := r.store.DeleteChatMessage(c.Request.Context(), message.ID) - if errors.Is(err, ErrChatMessageNotFound) { - c.JSON(http.StatusNotFound, gin.H{"message": "Mensagem nao encontrada."}) - return - } - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel excluir a mensagem."}) - return - } - - r.chatEvents.Publish(deleted.CalendarItemID, deleted.Channel) - - c.JSON(http.StatusOK, gin.H{"message": deleted}) -} - -func (r Routes) uploadChatAttachment(c *gin.Context) { - claims, ok := auth.ClaimsFromContext(c) - if !ok { - c.JSON(http.StatusUnauthorized, gin.H{"message": "Autenticacao obrigatoria."}) - return - } - - message, err := r.authorizedChatMessage(c, claims, c.Param("messageId"), false) - if err != nil { - return - } - if message.DeletedAt != nil { - c.JSON(http.StatusNotFound, gin.H{"message": "Mensagem nao encontrada."}) - return - } - if message.CreatedBy == nil || *message.CreatedBy != claims.UserID { - c.JSON(http.StatusForbidden, gin.H{"message": "Permissao insuficiente."}) - return - } - - upload, err := r.saveUploadedFile(c, filepath.Join("chat", message.ID)) - if err != nil { - return - } - - attachment, err := r.store.CreateChatAttachment(c.Request.Context(), CreateChatAttachmentInput{ - ChatMessageID: message.ID, - OriginalFilename: upload.originalFilename, - StoredFilename: upload.storedFilename, - MimeType: upload.mimeType, - SizeBytes: upload.sizeBytes, - StorageDriver: r.storageDriver, - StoragePath: upload.storagePath, - CreatedBy: claims.UserID, - }) - if err != nil { - _ = os.Remove(upload.storagePath) - c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel registrar o anexo."}) - return - } - - r.chatEvents.Publish(message.CalendarItemID, message.Channel) - - c.JSON(http.StatusCreated, gin.H{"attachment": attachment}) -} - -func (r Routes) downloadChatAttachment(c *gin.Context) { - claims, ok := auth.ClaimsFromContext(c) - if !ok { - c.JSON(http.StatusUnauthorized, gin.H{"message": "Autenticacao obrigatoria."}) - return - } - - viewerUserID := "" - if claims.Role == users.RoleClientViewer { - viewerUserID = claims.UserID - } - - attachment, err := r.store.FindChatAttachment(c.Request.Context(), c.Param("attachmentId"), viewerUserID) - if errors.Is(err, ErrAttachmentNotFound) { - c.JSON(http.StatusNotFound, gin.H{"message": "Anexo nao encontrado."}) - return - } - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel carregar o anexo."}) - return - } - - c.Header("Content-Type", attachment.MimeType) - c.Header("Content-Disposition", `attachment; filename="`+strings.ReplaceAll(attachment.OriginalFilename, `"`, "")+`"`) - c.File(attachment.StoragePath) -} - -func (r Routes) authorizedChatMessage(c *gin.Context, claims auth.Claims, messageID string, requireOwner bool) (ChatMessage, error) { - message, err := r.store.FindChatMessage(c.Request.Context(), messageID) - if errors.Is(err, ErrChatMessageNotFound) { - c.JSON(http.StatusNotFound, gin.H{"message": "Mensagem nao encontrada."}) - return ChatMessage{}, err - } - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel carregar a mensagem."}) - return ChatMessage{}, err - } - if message.Channel == ChatChannelInternal && claims.Role == users.RoleClientViewer { - c.JSON(http.StatusForbidden, gin.H{"message": "Permissao insuficiente."}) - return ChatMessage{}, errors.New("forbidden") - } - - viewerUserID := "" - if claims.Role == users.RoleClientViewer { - viewerUserID = claims.UserID - } - if _, err := r.store.FindCalendarItem(c.Request.Context(), message.CalendarItemID, viewerUserID); err != nil { - if errors.Is(err, ErrCalendarItemNotFound) { - c.JSON(http.StatusNotFound, gin.H{"message": "Postagem nao encontrada."}) - } else { - c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel carregar a postagem."}) - } - return ChatMessage{}, err - } - - if requireOwner && claims.Role != users.RoleSuperAdmin && (message.CreatedBy == nil || *message.CreatedBy != claims.UserID) { - c.JSON(http.StatusForbidden, gin.H{"message": "Permissao insuficiente."}) - return ChatMessage{}, errors.New("forbidden") - } - - return message, nil -} - func (r Routes) uploadAttachment(c *gin.Context) { claims, ok := auth.ClaimsFromContext(c) if !ok || (claims.Role != users.RoleSuperAdmin && claims.Role != users.RoleAgencyUser) { @@ -991,23 +611,75 @@ func (r Routes) uploadAttachment(c *gin.Context) { return } - upload, err := r.saveUploadedFile(c, c.Param("itemId")) + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, r.uploadMaxSizeBytes) + file, header, err := c.Request.FormFile("file") if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"message": "Envie um arquivo valido."}) + return + } + defer file.Close() + + if header.Size > r.uploadMaxSizeBytes { + c.JSON(http.StatusBadRequest, gin.H{"message": "Arquivo acima do limite permitido."}) + return + } + + head := make([]byte, 512) + n, err := file.Read(head) + if err != nil && err != io.EOF { + c.JSON(http.StatusBadRequest, gin.H{"message": "Nao foi possivel ler o arquivo."}) + return + } + if _, err := file.Seek(0, io.SeekStart); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel processar o arquivo."}) + return + } + + mimeType := http.DetectContentType(head[:n]) + if len(r.uploadAllowedMIMEs) > 0 && !r.uploadAllowedMIMEs[mimeType] { + c.JSON(http.StatusBadRequest, gin.H{"message": "Tipo de arquivo nao permitido."}) + return + } + + storedFilename, err := randomStoredFilename(header.Filename) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel preparar o arquivo."}) + return + } + + itemDir := filepath.Join(r.storageLocalPath, c.Param("itemId")) + if err := os.MkdirAll(itemDir, 0o750); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel preparar o armazenamento."}) + return + } + + storagePath := filepath.Join(itemDir, storedFilename) + destination, err := os.OpenFile(storagePath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o640) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel salvar o arquivo."}) + return + } + defer destination.Close() + + sizeBytes, err := io.Copy(destination, file) + if err != nil { + _ = os.Remove(storagePath) + c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel salvar o arquivo."}) return } attachment, err := r.store.CreateAttachment(c.Request.Context(), CreateAttachmentInput{ CalendarItemID: c.Param("itemId"), - OriginalFilename: upload.originalFilename, - StoredFilename: upload.storedFilename, - MimeType: upload.mimeType, - SizeBytes: upload.sizeBytes, + OriginalFilename: filepath.Base(header.Filename), + StoredFilename: storedFilename, + MimeType: mimeType, + SizeBytes: sizeBytes, StorageDriver: r.storageDriver, - StoragePath: upload.storagePath, + StoragePath: storagePath, CreatedBy: claims.UserID, }) if err != nil { - _ = os.Remove(upload.storagePath) + _ = os.Remove(storagePath) c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel registrar o anexo."}) return } @@ -1022,96 +694,6 @@ func (r Routes) uploadAttachment(c *gin.Context) { c.JSON(http.StatusCreated, gin.H{"attachment": attachment}) } -type savedUpload struct { - originalFilename string - storedFilename string - mimeType string - sizeBytes int64 - storagePath string -} - -func (r Routes) saveUploadedFile(c *gin.Context, relativeDir string) (savedUpload, error) { - c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, r.uploadMaxSizeBytes) - file, header, err := c.Request.FormFile("file") - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"message": "Envie um arquivo valido."}) - return savedUpload{}, err - } - defer file.Close() - - if header.Size > r.uploadMaxSizeBytes { - c.JSON(http.StatusBadRequest, gin.H{"message": "Arquivo acima do limite permitido."}) - return savedUpload{}, errors.New("file too large") - } - - head := make([]byte, 512) - n, err := file.Read(head) - if err != nil && err != io.EOF { - c.JSON(http.StatusBadRequest, gin.H{"message": "Nao foi possivel ler o arquivo."}) - return savedUpload{}, err - } - if _, err := file.Seek(0, io.SeekStart); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel processar o arquivo."}) - return savedUpload{}, err - } - - mimeType := normalizeUploadMimeType(http.DetectContentType(head[:n]), header.Filename) - if len(r.uploadAllowedMIMEs) > 0 && !r.uploadAllowedMIMEs[mimeType] { - c.JSON(http.StatusBadRequest, gin.H{"message": "Tipo de arquivo nao permitido."}) - return savedUpload{}, errors.New("invalid mime") - } - - storedFilename, err := randomStoredFilename(header.Filename) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel preparar o arquivo."}) - return savedUpload{}, err - } - - storageDir := filepath.Join(r.storageLocalPath, filepath.Clean(relativeDir)) - if err := os.MkdirAll(storageDir, 0o750); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel preparar o armazenamento."}) - return savedUpload{}, err - } - - storagePath := filepath.Join(storageDir, storedFilename) - destination, err := os.OpenFile(storagePath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o640) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel salvar o arquivo."}) - return savedUpload{}, err - } - defer destination.Close() - - sizeBytes, err := io.Copy(destination, file) - if err != nil { - _ = os.Remove(storagePath) - c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel salvar o arquivo."}) - return savedUpload{}, err - } - - return savedUpload{ - originalFilename: filepath.Base(header.Filename), - storedFilename: storedFilename, - mimeType: mimeType, - sizeBytes: sizeBytes, - storagePath: storagePath, - }, nil -} - -func normalizeUploadMimeType(detected string, filename string) string { - switch strings.ToLower(filepath.Ext(filename)) { - case ".docx": - if detected == "application/zip" || detected == "application/octet-stream" { - return "application/vnd.openxmlformats-officedocument.wordprocessingml.document" - } - case ".doc": - if detected == "application/octet-stream" { - return "application/msword" - } - } - - return detected -} - func (r Routes) downloadAttachment(c *gin.Context) { claims, ok := auth.ClaimsFromContext(c) if !ok { @@ -1139,37 +721,6 @@ func (r Routes) downloadAttachment(c *gin.Context) { c.File(attachment.StoragePath) } -func (r Routes) deleteAttachment(c *gin.Context) { - claims, ok := auth.ClaimsFromContext(c) - if !ok || (claims.Role != users.RoleSuperAdmin && claims.Role != users.RoleAgencyUser) { - c.JSON(http.StatusForbidden, gin.H{"message": "Permissao insuficiente."}) - return - } - - attachment, err := r.store.DeleteAttachment(c.Request.Context(), c.Param("attachmentId")) - if errors.Is(err, ErrAttachmentNotFound) { - c.JSON(http.StatusNotFound, gin.H{"message": "Anexo nao encontrado."}) - return - } - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel excluir o anexo."}) - return - } - - if attachment.StorageDriver == "local" { - _ = os.Remove(attachment.StoragePath) - } - - _ = r.store.RecordAudit(c.Request.Context(), claims.UserID, "attachment.deleted", "calendar_item_attachment", attachment.ID, map[string]any{ - "calendar_item_id": attachment.CalendarItemID, - "filename": attachment.OriginalFilename, - "mime_type": attachment.MimeType, - "size_bytes": attachment.SizeBytes, - }) - - c.JSON(http.StatusOK, gin.H{"attachment": attachment}) -} - func randomStoredFilename(original string) (string, error) { bytes := make([]byte, 16) if _, err := rand.Read(bytes); err != nil { @@ -1187,12 +738,3 @@ func isValidCalendarItemStatus(status string) bool { return false } } - -func isValidChatChannel(channel string) bool { - switch channel { - case ChatChannelExternal, ChatChannelInternal: - return true - default: - return false - } -} diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index a7a2268..d9d6dc9 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -48,7 +48,7 @@ func Load() Config { SuperAdminEmail: env("SUPER_ADMIN_EMAIL", ""), SuperAdminPassword: env("SUPER_ADMIN_PASSWORD", ""), JWTSecret: env("JWT_SECRET", ""), - AccessTokenTTL: minutes("JWT_ACCESS_TOKEN_TTL_MINUTES", 480), + AccessTokenTTL: minutes("JWT_ACCESS_TOKEN_TTL_MINUTES", 15), RefreshTokenTTL: hours("JWT_REFRESH_TOKEN_TTL_DAYS", 7*24), CORSAllowedOrigins: list("CORS_ALLOWED_ORIGINS", "http://localhost:5173"), CalendarProvider: env("CALENDAR_PROVIDER", "brasilapi"), @@ -56,14 +56,14 @@ func Load() Config { CalendarAPIKey: env("CALENDAR_API_KEY", ""), CalendarCacheTTL: hours("CALENDAR_CACHE_TTL_HOURS", 24), UploadMaxSizeMB: integer("UPLOAD_MAX_SIZE_MB", 10), - UploadAllowedMIMEs: list("UPLOAD_ALLOWED_MIME_TYPES", "image/jpeg,image/png,image/webp,application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document"), + UploadAllowedMIMEs: list("UPLOAD_ALLOWED_MIME_TYPES", "image/jpeg,image/png,image/webp,application/pdf"), StorageDriver: env("STORAGE_DRIVER", "local"), StorageLocalPath: env("STORAGE_LOCAL_PATH", "/var/lib/mira/uploads"), SMTPHost: env("SMTP_HOST", ""), SMTPPort: env("SMTP_PORT", "587"), SMTPUser: env("SMTP_USER", ""), - SMTPPassword: envAlias("SMTP_PASS", "SMTP_PASSWORD", ""), - SMTPFromEmail: envAlias("MAIL_FROM", "SMTP_FROM_EMAIL", ""), + SMTPPassword: env("SMTP_PASSWORD", ""), + SMTPFromEmail: env("SMTP_FROM_EMAIL", ""), SMTPFromName: env("SMTP_FROM_NAME", "Mira"), } } @@ -76,14 +76,6 @@ func env(key, fallback string) string { return value } -func envAlias(primary string, secondary string, fallback string) string { - value := strings.TrimSpace(os.Getenv(primary)) - if value != "" { - return value - } - return env(secondary, fallback) -} - func list(key, fallback string) []string { raw := env(key, fallback) parts := strings.Split(raw, ",") diff --git a/backend/internal/httpserver/server.go b/backend/internal/httpserver/server.go index 84cd258..eddfcb4 100644 --- a/backend/internal/httpserver/server.go +++ b/backend/internal/httpserver/server.go @@ -9,7 +9,6 @@ import ( "mira/backend/internal/clients" "mira/backend/internal/config" "mira/backend/internal/database" - "mira/backend/internal/mail" "mira/backend/internal/security" "mira/backend/internal/users" @@ -40,17 +39,8 @@ func New(cfg config.Config, logger *slog.Logger, db *database.DB, userStore user tokenService := auth.NewTokenService(cfg.JWTSecret, cfg.AccessTokenTTL) requireAuth := auth.RequireAuth(tokenService) - sessionStore := auth.NewSessionStore(db.Pool) - authRoutes := auth.NewRoutes(userStore, sessionStore, tokenService, cfg.RefreshTokenTTL, cfg.AppEnv == "production") - mailSender := mail.NewSender(mail.SMTPConfig{ - Host: cfg.SMTPHost, - Port: cfg.SMTPPort, - User: cfg.SMTPUser, - Password: cfg.SMTPPassword, - From: cfg.SMTPFromEmail, - FromName: cfg.SMTPFromName, - }) - userRoutes := users.NewRoutes(userStore, cfg.AppPublicURL, mailSender) + authRoutes := auth.NewRoutes(userStore, tokenService) + userRoutes := users.NewRoutes(userStore, cfg.AppPublicURL) clientRoutes := clients.NewRoutes(clientStore) calendarRoutes := calendar.NewRoutes(calendarStore, calendar.NewBrasilAPIProvider(cfg.CalendarAPIBaseURL), calendar.NewFeriadosBrasilProvider(), calendar.RouteOptions{ UploadMaxSizeMB: cfg.UploadMaxSizeMB, diff --git a/backend/internal/mail/smtp.go b/backend/internal/mail/smtp.go deleted file mode 100644 index 1b7833e..0000000 --- a/backend/internal/mail/smtp.go +++ /dev/null @@ -1,150 +0,0 @@ -package mail - -import ( - "crypto/tls" - "fmt" - "net" - "net/mail" - "net/smtp" - "strings" -) - -type SMTPConfig struct { - Host string - Port string - User string - Password string - From string - FromName string -} - -type Sender struct { - cfg SMTPConfig -} - -func NewSender(cfg SMTPConfig) Sender { - if strings.TrimSpace(cfg.Port) == "" { - cfg.Port = "587" - } - return Sender{cfg: cfg} -} - -func (s Sender) IsConfigured() bool { - return strings.TrimSpace(s.cfg.Host) != "" && - strings.TrimSpace(s.cfg.Port) != "" && - strings.TrimSpace(s.cfg.From) != "" -} - -func (s Sender) SendInvitation(to string, invitationURL string) error { - subject := "Convite para acessar o Mira" - textBody := "Voce foi convidado para acessar o Mira.\n\nCrie sua senha pelo link abaixo:\n" + invitationURL + "\n\nEste convite expira em 7 dias." - htmlBody := `

Voce foi convidado para acessar o Mira.

Criar minha senha

Este convite expira em 7 dias.

` - - return s.send(to, subject, textBody, htmlBody) -} - -func (s Sender) send(to string, subject string, textBody string, htmlBody string) error { - if !s.IsConfigured() { - return nil - } - - from := mail.Address{Name: strings.TrimSpace(s.cfg.FromName), Address: strings.TrimSpace(s.cfg.From)} - recipient := mail.Address{Address: strings.TrimSpace(to)} - boundary := "mira-mail-boundary" - message := strings.Join([]string{ - "From: " + from.String(), - "To: " + recipient.String(), - "Subject: " + subject, - "MIME-Version: 1.0", - "Content-Type: multipart/alternative; boundary=\"" + boundary + "\"", - "", - "--" + boundary, - "Content-Type: text/plain; charset=UTF-8", - "", - textBody, - "--" + boundary, - "Content-Type: text/html; charset=UTF-8", - "", - htmlBody, - "--" + boundary + "--", - "", - }, "\r\n") - - host := strings.TrimSpace(s.cfg.Host) - port := strings.TrimSpace(s.cfg.Port) - addr := net.JoinHostPort(host, port) - var auth smtp.Auth - if strings.TrimSpace(s.cfg.User) != "" || strings.TrimSpace(s.cfg.Password) != "" { - auth = smtp.PlainAuth("", strings.TrimSpace(s.cfg.User), strings.TrimSpace(s.cfg.Password), host) - } - - if port == "465" { - if err := sendMailTLS(addr, host, auth, from.Address, []string{recipient.Address}, []byte(message)); err != nil { - return fmt.Errorf("send smtp email: %w", err) - } - return nil - } - - if err := sendMailStartTLS(addr, host, auth, from.Address, []string{recipient.Address}, []byte(message)); err != nil { - return fmt.Errorf("send smtp email: %w", err) - } - return nil -} - -func sendMailStartTLS(addr string, host string, auth smtp.Auth, from string, to []string, message []byte) error { - client, err := smtp.Dial(addr) - if err != nil { - return err - } - defer client.Quit() - - if ok, _ := client.Extension("STARTTLS"); ok { - if err := client.StartTLS(&tls.Config{ServerName: host, MinVersion: tls.VersionTLS12}); err != nil { - return err - } - } - - return sendMailWithClient(client, auth, from, to, message) -} - -func sendMailTLS(addr string, host string, auth smtp.Auth, from string, to []string, message []byte) error { - conn, err := tls.Dial("tcp", addr, &tls.Config{ServerName: host, MinVersion: tls.VersionTLS12}) - if err != nil { - return err - } - - client, err := smtp.NewClient(conn, host) - if err != nil { - _ = conn.Close() - return err - } - defer client.Quit() - - return sendMailWithClient(client, auth, from, to, message) -} - -func sendMailWithClient(client *smtp.Client, auth smtp.Auth, from string, to []string, message []byte) error { - if auth != nil { - if err := client.Auth(auth); err != nil { - return err - } - } - if err := client.Mail(from); err != nil { - return err - } - for _, recipient := range to { - if err := client.Rcpt(recipient); err != nil { - return err - } - } - - writer, err := client.Data() - if err != nil { - return err - } - if _, err := writer.Write(message); err != nil { - _ = writer.Close() - return err - } - return writer.Close() -} diff --git a/backend/internal/security/headers.go b/backend/internal/security/headers.go index ae3e98d..13e908c 100644 --- a/backend/internal/security/headers.go +++ b/backend/internal/security/headers.go @@ -9,7 +9,7 @@ import ( func Headers() gin.HandlerFunc { return func(c *gin.Context) { - c.Header("Content-Security-Policy", "default-src 'self'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'") + c.Header("Content-Security-Policy", "default-src 'self'; frame-ancestors 'none'") c.Header("X-Content-Type-Options", "nosniff") c.Header("X-Frame-Options", "DENY") c.Header("Referrer-Policy", "strict-origin-when-cross-origin") diff --git a/backend/internal/users/invitations.go b/backend/internal/users/invitations.go index 20ce8ff..63415d1 100644 --- a/backend/internal/users/invitations.go +++ b/backend/internal/users/invitations.go @@ -25,8 +25,6 @@ type Invitation struct { CreatedBy *string `json:"created_by"` CreatedAt time.Time `json:"created_at"` InvitationURL string `json:"invitation_url,omitempty"` - EmailSent bool `json:"email_sent"` - EmailError string `json:"email_error,omitempty"` } type CreateInvitationInput struct { diff --git a/backend/internal/users/routes.go b/backend/internal/users/routes.go index 84374b5..28134d2 100644 --- a/backend/internal/users/routes.go +++ b/backend/internal/users/routes.go @@ -5,22 +5,18 @@ import ( "strings" "time" - "mira/backend/internal/mail" - "github.com/gin-gonic/gin" ) type Routes struct { store Store appPublicURL string - mailSender mail.Sender } -func NewRoutes(store Store, appPublicURL string, mailSender mail.Sender) Routes { +func NewRoutes(store Store, appPublicURL string) Routes { return Routes{ store: store, appPublicURL: strings.TrimRight(appPublicURL, "/"), - mailSender: mailSender, } } @@ -110,14 +106,6 @@ func (r Routes) createInvitation(c *gin.Context) { } invitation.InvitationURL = r.invitationURL(token) - invitation.EmailSent = false - if r.mailSender.IsConfigured() { - if err := r.mailSender.SendInvitation(invitation.Email, invitation.InvitationURL); err != nil { - invitation.EmailError = "Convite criado, mas nao foi possivel enviar o e-mail." - } else { - invitation.EmailSent = true - } - } c.JSON(http.StatusCreated, gin.H{"invitation": invitation}) } diff --git a/backend/migrations/005_refresh_tokens.sql b/backend/migrations/005_refresh_tokens.sql deleted file mode 100644 index 05ec6fc..0000000 --- a/backend/migrations/005_refresh_tokens.sql +++ /dev/null @@ -1,11 +0,0 @@ -CREATE TABLE IF NOT EXISTS refresh_tokens ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - token_hash TEXT NOT NULL UNIQUE, - expires_at TIMESTAMPTZ NOT NULL, - revoked_at TIMESTAMPTZ, - created_at TIMESTAMPTZ NOT NULL DEFAULT now() -); - -CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user_id ON refresh_tokens (user_id); -CREATE INDEX IF NOT EXISTS idx_refresh_tokens_token_hash ON refresh_tokens (token_hash); diff --git a/backend/migrations/006_calendar_item_chat_messages.sql b/backend/migrations/006_calendar_item_chat_messages.sql deleted file mode 100644 index ab34901..0000000 --- a/backend/migrations/006_calendar_item_chat_messages.sql +++ /dev/null @@ -1,11 +0,0 @@ -CREATE TABLE IF NOT EXISTS calendar_item_chat_messages ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - calendar_item_id UUID NOT NULL REFERENCES calendar_items(id) ON DELETE CASCADE, - channel TEXT NOT NULL CHECK (channel IN ('external', 'internal')), - body TEXT NOT NULL, - created_by UUID REFERENCES users(id) ON DELETE SET NULL, - created_at TIMESTAMPTZ NOT NULL DEFAULT now() -); - -CREATE INDEX IF NOT EXISTS idx_calendar_item_chat_messages_item_channel - ON calendar_item_chat_messages (calendar_item_id, channel, created_at); diff --git a/backend/migrations/007_chat_message_features.sql b/backend/migrations/007_chat_message_features.sql deleted file mode 100644 index 64f487b..0000000 --- a/backend/migrations/007_chat_message_features.sql +++ /dev/null @@ -1,19 +0,0 @@ -ALTER TABLE calendar_item_chat_messages -ADD COLUMN IF NOT EXISTS edited_at TIMESTAMPTZ, -ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ; - -CREATE TABLE IF NOT EXISTS calendar_item_chat_attachments ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - chat_message_id UUID NOT NULL REFERENCES calendar_item_chat_messages(id) ON DELETE CASCADE, - original_filename TEXT NOT NULL, - stored_filename TEXT NOT NULL, - mime_type TEXT NOT NULL, - size_bytes BIGINT NOT NULL CHECK (size_bytes >= 0), - storage_driver TEXT NOT NULL, - storage_path TEXT NOT NULL, - created_by UUID REFERENCES users(id) ON DELETE SET NULL, - created_at TIMESTAMPTZ NOT NULL DEFAULT now() -); - -CREATE INDEX IF NOT EXISTS idx_calendar_item_chat_attachments_message - ON calendar_item_chat_attachments (chat_message_id, created_at); diff --git a/docker-compose.yml b/docker-compose.yml index bceb764..db8ced4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -41,8 +41,8 @@ services: SMTP_HOST: ${SMTP_HOST} SMTP_PORT: ${SMTP_PORT} SMTP_USER: ${SMTP_USER} - SMTP_PASS: ${SMTP_PASS} - MAIL_FROM: ${MAIL_FROM} + SMTP_PASSWORD: ${SMTP_PASSWORD} + SMTP_FROM_EMAIL: ${SMTP_FROM_EMAIL} SMTP_FROM_NAME: ${SMTP_FROM_NAME} UPLOAD_MAX_SIZE_MB: ${UPLOAD_MAX_SIZE_MB} UPLOAD_ALLOWED_MIME_TYPES: ${UPLOAD_ALLOWED_MIME_TYPES} diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 9a37058..a3c8e9b 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -9,7 +9,6 @@ server { add_header X-Frame-Options "DENY" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always; add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; - add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self'; img-src 'self' blob: data:; connect-src 'self' http://localhost:8081 http://127.0.0.1:8081; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always; location / { try_files $uri $uri/ /index.html; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2deda89..c3d32e4 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -10,63 +10,21 @@ import { ClientDashboardView } from "./views/ClientDashboardView"; import { PostDetailView } from "./views/PostDetailView"; import { Topbar } from "./components/layout/Topbar"; import { Sidebar } from "./components/layout/Sidebar"; -import { clearStoredToken, getStoredToken, loadCurrentUser, logout, refreshSession, type AuthUser } from "@/lib/auth"; +import { clearStoredToken, getStoredToken, loadCurrentUser, type AuthUser } from "@/lib/auth"; import { listClients, type Client } from "@/lib/clients"; -const APP_STATE_STORAGE_KEY = "mira_app_state"; -const validViews = new Set(["dashboard", "clients", "client-dashboard", "post-detail", "calendar", "users"]); - -type StoredAppState = { - currentView: string; - selectedClientId: string | null; - selectedPostId: string | null; -}; - -function readStoredAppState(): StoredAppState { - const fallback = { - currentView: "dashboard", - selectedClientId: null, - selectedPostId: null, - }; - - try { - const raw = localStorage.getItem(APP_STATE_STORAGE_KEY); - if (!raw) return fallback; - - const parsed = JSON.parse(raw) as Partial; - const currentView = typeof parsed.currentView === "string" && validViews.has(parsed.currentView) - ? parsed.currentView - : fallback.currentView; - const selectedClientId = typeof parsed.selectedClientId === "string" ? parsed.selectedClientId : null; - const selectedPostId = typeof parsed.selectedPostId === "string" ? parsed.selectedPostId : null; - - if (currentView === "client-dashboard" && !selectedClientId) return fallback; - if (currentView === "post-detail" && !selectedPostId) return fallback; - - return { - currentView, - selectedClientId, - selectedPostId, - }; - } catch { - return fallback; - } -} - export default function App() { - const storedAppState = readStoredAppState(); const [user, setUser] = useState(null); const [clients, setClients] = useState([]); - const [selectedClientId, setSelectedClientId] = useState(storedAppState.selectedClientId); + const [selectedClientId, setSelectedClientId] = useState(null); const [isCheckingSession, setIsCheckingSession] = useState(true); const [clientsError, setClientsError] = useState(""); - const [currentView, setCurrentView] = useState(storedAppState.currentView); - const [selectedPostId, setSelectedPostId] = useState(storedAppState.selectedPostId); + const [currentView, setCurrentView] = useState("dashboard"); // 'dashboard' | 'calendar' | 'users' + const [selectedPostId, setSelectedPostId] = useState(null); const invitationToken = window.location.pathname === "/accept-invitation" ? new URLSearchParams(window.location.search).get("token") : null; - const isClientViewer = user?.role === "client_viewer"; async function refreshClients(token = getStoredToken()) { if (!token) return; @@ -87,16 +45,7 @@ export default function App() { const token = getStoredToken(); if (!token) { - refreshSession() - .then((response) => { - setUser(response.user); - return refreshClients(response.access_token); - }) - .catch(() => { - clearStoredToken(); - setUser(null); - }) - .finally(() => setIsCheckingSession(false)); + setIsCheckingSession(false); return; } @@ -106,51 +55,14 @@ export default function App() { return refreshClients(token); }) .catch(() => { - return refreshSession() - .then((response) => { - setUser(response.user); - return refreshClients(response.access_token); - }) - .catch(() => { - clearStoredToken(); - setUser(null); - }); + clearStoredToken(); + setUser(null); }) .finally(() => setIsCheckingSession(false)); }, []); - useEffect(() => { - localStorage.setItem( - APP_STATE_STORAGE_KEY, - JSON.stringify({ - currentView, - selectedClientId, - selectedPostId, - }), - ); - }, [currentView, selectedClientId, selectedPostId]); - - useEffect(() => { - if (currentView === "client-dashboard" && !selectedClientId) { - setCurrentView("clients"); - } - if (currentView === "post-detail" && !selectedPostId) { - setCurrentView("calendar"); - } - }, [currentView, selectedClientId, selectedPostId]); - - useEffect(() => { - if (!isClientViewer) return; - - setSelectedClientId((current) => current ?? clients[0]?.id ?? null); - if (currentView === "clients" || currentView === "users" || currentView === "client-dashboard") { - setCurrentView("dashboard"); - } - }, [clients, currentView, isClientViewer]); - function handleLogout() { - void logout(); - localStorage.removeItem(APP_STATE_STORAGE_KEY); + clearStoredToken(); setUser(null); setClients([]); setSelectedClientId(null); @@ -163,9 +75,9 @@ export default function App() { void refreshClients(); } - function handleInvitationAccepted() { - clearStoredToken(); - setUser(null); + function handleInvitationAccepted(loadedUser: AuthUser) { + setUser(loadedUser); + void refreshClients(); } function handleClientCreated(client: Client) { @@ -254,14 +166,9 @@ export default function App() {
{currentView === "dashboard" && ( - + )} - {!isClientViewer && currentView === "clients" && ( + {currentView === "clients" && ( )} - {!isClientViewer && currentView === "client-dashboard" && selectedClientId && ( + {currentView === "client-dashboard" && selectedClientId && ( client.id === selectedClientId) ?? null} selectedClientId={selectedClientId} @@ -279,12 +186,12 @@ export default function App() { /> )} {currentView === "post-detail" && selectedPostId && ( - setCurrentView("calendar")} user={user} /> + setCurrentView("calendar")} /> )} {currentView === "calendar" && ( )} - {!isClientViewer && currentView === "users" && } + {currentView === "users" && }
diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index c59bc4c..2c9fbff 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -21,18 +21,12 @@ type NavProps = { export function Sidebar({ currentView, onViewChange, user, onLogout, clients, selectedClientId, onClientSelect, clientsError }: NavProps) { const [clientSearch, setClientSearch] = useState(""); - const isClientViewer = user.role === "client_viewer"; - const navItems = isClientViewer - ? [ - { id: "dashboard", label: "Dashboard Semanal", icon: LayoutDashboard }, - { id: "calendar", label: "Calendรกrio", icon: CalendarDays }, - ] - : [ - { id: "dashboard", label: "Dashboard Semanal", icon: LayoutDashboard }, - { id: "clients", label: "Clientes", icon: Building2 }, - { id: "calendar", label: "Calendรกrio", icon: CalendarDays }, - { id: "users", label: "Pessoas", icon: Users }, - ]; + const navItems = [ + { id: "dashboard", label: "Dashboard Semanal", icon: LayoutDashboard }, + { id: "clients", label: "Clientes", icon: Building2 }, + { id: "calendar", label: "Calendรกrio", icon: CalendarDays }, + { id: "users", label: "Pessoas", icon: Users }, + ]; const filteredClients = useMemo(() => { const term = clientSearch.trim().toLowerCase(); @@ -49,24 +43,20 @@ export function Sidebar({ currentView, onViewChange, user, onLogout, clients, se Mira - {!isClientViewer && ( -
-
- - setClientSearch(event.target.value)} - /> -
+
+
+ + setClientSearch(event.target.value)} + />
- )} +
- )}
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 84afcd1..7b644e0 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -15,7 +15,6 @@ export async function apiRequest(path: string, options: RequestOptions = {}): const response = await fetch(`${API_BASE_URL}${path}`, { ...options, headers, - credentials: "include", }); const data = await response.json().catch(() => ({})); @@ -41,7 +40,6 @@ export async function apiFormRequest(path: string, formData: FormData, token? method: "POST", headers, body: formData, - credentials: "include", }); const data = await response.json().catch(() => ({})); diff --git a/frontend/src/lib/auth.ts b/frontend/src/lib/auth.ts index f14d798..37bc087 100644 --- a/frontend/src/lib/auth.ts +++ b/frontend/src/lib/auth.ts @@ -16,35 +16,20 @@ type LoginResponse = { user: AuthUser; }; -type AcceptInvitationResponse = { - message: string; - user: AuthUser; -}; - type MeResponse = { user: AuthUser; }; export function getStoredToken() { - return sessionStorage.getItem(TOKEN_STORAGE_KEY); + return localStorage.getItem(TOKEN_STORAGE_KEY); } export function storeToken(token: string) { - localStorage.removeItem(TOKEN_STORAGE_KEY); - sessionStorage.setItem(TOKEN_STORAGE_KEY, token); + localStorage.setItem(TOKEN_STORAGE_KEY, token); } export function clearStoredToken() { localStorage.removeItem(TOKEN_STORAGE_KEY); - sessionStorage.removeItem(TOKEN_STORAGE_KEY); -} - -export async function logout() { - await apiRequest<{ message: string }>("/auth/logout", { - method: "POST", - }).catch(() => undefined); - localStorage.removeItem(TOKEN_STORAGE_KEY); - sessionStorage.removeItem(TOKEN_STORAGE_KEY); } export async function login(email: string, password: string) { @@ -58,19 +43,11 @@ export async function login(email: string, password: string) { } export async function acceptInvitation(token: string, name: string, password: string) { - const response = await apiRequest("/auth/invitations/accept", { + const response = await apiRequest("/auth/invitations/accept", { method: "POST", body: JSON.stringify({ token, name, password }), }); - return response; -} - -export async function refreshSession() { - const response = await apiRequest("/auth/refresh", { - method: "POST", - }); - storeToken(response.access_token); return response; } diff --git a/frontend/src/lib/calendar.ts b/frontend/src/lib/calendar.ts index 360e435..a58817f 100644 --- a/frontend/src/lib/calendar.ts +++ b/frontend/src/lib/calendar.ts @@ -1,4 +1,4 @@ -import { API_BASE_URL, ApiError, apiFormRequest, apiRequest } from "@/lib/api"; +import { API_BASE_URL, apiFormRequest, apiRequest } from "@/lib/api"; export type Holiday = { id: string; @@ -51,34 +51,6 @@ export type Attachment = { created_at: string; }; -export type ChatChannel = "external" | "internal"; - -export type ChatMessage = { - id: string; - calendar_item_id: string; - channel: ChatChannel; - body: string; - created_by: string | null; - author_name: string; - author_role: string; - created_at: string; - edited_at: string | null; - deleted_at: string | null; - attachments: ChatAttachment[] | null; -}; - -export type ChatAttachment = { - id: string; - chat_message_id: string; - original_filename: string; - stored_filename: string; - mime_type: string; - size_bytes: number; - storage_driver: string; - created_by: string | null; - created_at: string; -}; - export type CreateCustomDateInput = { client_id?: string; name: string; @@ -148,18 +120,6 @@ type AttachmentResponse = { attachment: Attachment; }; -type ChatAttachmentResponse = { - attachment: ChatAttachment; -}; - -type ChatMessagesResponse = { - messages: ChatMessage[]; -}; - -type ChatMessageResponse = { - message: ChatMessage; -}; - export async function listHolidays(token: string, year: number) { const response = await apiRequest(`/calendar/holidays?year=${year}`, { method: "GET", @@ -291,99 +251,6 @@ export async function uploadAttachment(token: string, itemId: string, file: File return response.attachment; } -export async function deleteAttachment(token: string, attachmentId: string) { - const response = await apiRequest(`/calendar/attachments/${attachmentId}`, { - method: "DELETE", - token, - }); - - return response.attachment; -} - -export async function listChatMessages(token: string, itemId: string, channel: ChatChannel) { - const params = new URLSearchParams({ channel }); - const response = await apiRequest(`/calendar/items/${itemId}/chat?${params.toString()}`, { - method: "GET", - token, - }); - - return response.messages; -} - -export async function createChatMessage(token: string, itemId: string, channel: ChatChannel, body: string) { - const response = await apiRequest(`/calendar/items/${itemId}/chat`, { - method: "POST", - token, - body: JSON.stringify({ channel, body }), - }); - - return response.message; -} - -export async function updateChatMessage(token: string, messageId: string, body: string) { - const response = await apiRequest(`/calendar/chat/messages/${messageId}`, { - method: "PATCH", - token, - body: JSON.stringify({ body }), - }); - - return response.message; -} - -export async function deleteChatMessage(token: string, messageId: string) { - const response = await apiRequest(`/calendar/chat/messages/${messageId}`, { - method: "DELETE", - token, - }); - - return response.message; -} - -export async function uploadChatAttachment(token: string, messageId: string, file: File) { - const formData = new FormData(); - formData.set("file", file); - - const response = await apiFormRequest(`/calendar/chat/messages/${messageId}/attachments`, formData, token); - return response.attachment; -} - -export async function downloadAttachmentBlob(token: string, attachmentId: string) { - return downloadProtectedBlob(token, attachmentDownloadURL(attachmentId)); -} - -export async function downloadChatAttachmentBlob(token: string, attachmentId: string) { - return downloadProtectedBlob(token, chatAttachmentDownloadURL(attachmentId)); -} - -async function downloadProtectedBlob(token: string, url: string) { - const response = await fetch(url, { - headers: { - Authorization: `Bearer ${token}`, - }, - credentials: "include", - }); - - if (!response.ok) { - const data = await response.json().catch(() => ({})); - const message = - typeof data === "object" && data !== null && "message" in data - ? String(data.message) - : "Nao foi possivel baixar o anexo."; - throw new ApiError(message, response.status); - } - - return response.blob(); -} - export function attachmentDownloadURL(attachmentId: string) { return `${API_BASE_URL}/calendar/attachments/${attachmentId}/download`; } - -export function chatAttachmentDownloadURL(attachmentId: string) { - return `${API_BASE_URL}/calendar/chat/attachments/${attachmentId}/download`; -} - -export function chatStreamURL(itemId: string, channel: ChatChannel) { - const params = new URLSearchParams({ channel }); - return `${API_BASE_URL}/calendar/items/${itemId}/chat/stream?${params.toString()}`; -} diff --git a/frontend/src/lib/users.ts b/frontend/src/lib/users.ts index 7e84abe..5eaa47c 100644 --- a/frontend/src/lib/users.ts +++ b/frontend/src/lib/users.ts @@ -11,8 +11,6 @@ export type Invitation = { accepted_at: string | null; created_at: string; invitation_url?: string; - email_sent: boolean; - email_error?: string; }; type UsersResponse = { diff --git a/frontend/src/views/AcceptInvitationView.tsx b/frontend/src/views/AcceptInvitationView.tsx index abde313..c20c985 100644 --- a/frontend/src/views/AcceptInvitationView.tsx +++ b/frontend/src/views/AcceptInvitationView.tsx @@ -3,11 +3,11 @@ import { ArrowRight, CalendarDays } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -import { acceptInvitation } from "@/lib/auth"; +import { acceptInvitation, type AuthUser } from "@/lib/auth"; type AcceptInvitationViewProps = { token: string; - onAccepted: () => void; + onAccepted: (user: AuthUser) => void; }; export function AcceptInvitationView({ token, onAccepted }: AcceptInvitationViewProps) { @@ -22,10 +22,9 @@ export function AcceptInvitationView({ token, onAccepted }: AcceptInvitationView setIsSubmitting(true); setError(""); try { - await acceptInvitation(token, name, password); - onAccepted(); + const response = await acceptInvitation(token, name, password); + onAccepted(response.user); window.history.replaceState({}, "", "/"); - window.location.assign("/"); } catch (err) { setError(err instanceof Error ? err.message : "Nรฃo foi possรญvel aceitar o convite."); } finally { @@ -78,7 +77,7 @@ export function AcceptInvitationView({ token, onAccepted }: AcceptInvitationView )} diff --git a/frontend/src/views/CalendarView.tsx b/frontend/src/views/CalendarView.tsx index a3734fb..bd4c306 100644 --- a/frontend/src/views/CalendarView.tsx +++ b/frontend/src/views/CalendarView.tsx @@ -50,7 +50,6 @@ type CalendarEvent = { const monthFormatter = new Intl.DateTimeFormat("pt-BR", { month: "long", year: "numeric" }); const dateFormatter = new Intl.DateTimeFormat("pt-BR", { day: "2-digit", month: "long", year: "numeric" }); -const attachmentAcceptTypes = "image/jpeg,image/png,image/webp,application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document,.doc,.docx"; const statusLabels: Record = { rascunho: "Rascunho", planejado: "Planejado", @@ -924,7 +923,7 @@ export function CalendarView({ clients, selectedClientId, user, onPostSelect }:
- +
{attachments.length > 0 && ( diff --git a/frontend/src/views/DashboardView.tsx b/frontend/src/views/DashboardView.tsx index f45da49..6dd3535 100644 --- a/frontend/src/views/DashboardView.tsx +++ b/frontend/src/views/DashboardView.tsx @@ -20,7 +20,6 @@ type DashboardViewProps = { title?: string; description?: string; compact?: boolean; - canCreatePost?: boolean; onViewChange: (view: string) => void; onPostSelect: (postId: string) => void; }; @@ -42,7 +41,6 @@ export function DashboardView({ title = "Dashboard Semanal", description, compact = false, - canCreatePost = true, onViewChange, onPostSelect, }: DashboardViewProps) { @@ -121,12 +119,10 @@ export function DashboardView({ Filtros - {canCreatePost && ( - - )} +
diff --git a/frontend/src/views/PostDetailView.tsx b/frontend/src/views/PostDetailView.tsx index 4d40213..b8d05e4 100644 --- a/frontend/src/views/PostDetailView.tsx +++ b/frontend/src/views/PostDetailView.tsx @@ -1,40 +1,25 @@ -import { useEffect, useMemo, useRef, useState, type FormEvent, type KeyboardEvent as ReactKeyboardEvent } from "react"; -import { ArrowLeft, Car, ChevronDown, ChevronLeft, ChevronRight, Clock3, Download, FileText, Hand, Heart, Image as ImageIcon, Laugh, MoreVertical, Paperclip, Pizza, Send, Smile, Trophy, Trash2, Upload, X, Pencil } from "lucide-react"; +import { useEffect, useMemo, useState, type FormEvent } from "react"; +import { ArrowLeft, Download, FileText, Image as ImageIcon, Upload } from "lucide-react"; import { Badge } from "@/components/ui/badge"; -import { Button, buttonVariants } from "@/components/ui/button"; +import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"; -import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Textarea } from "@/components/ui/textarea"; -import { getStoredToken, type AuthUser } from "@/lib/auth"; +import { getStoredToken } from "@/lib/auth"; import { - createChatMessage, - deleteAttachment, - deleteChatMessage, - downloadAttachmentBlob, - downloadChatAttachmentBlob, + attachmentDownloadURL, getCalendarItem, - listChatMessages, listAttachments, - chatStreamURL, - updateCalendarItem, - updateChatMessage, - uploadChatAttachment, uploadAttachment, type Attachment, type CalendarItem, - type ChatAttachment, - type ChatChannel, - type ChatMessage, } from "@/lib/calendar"; type PostDetailViewProps = { itemId: string; onBack: () => void; - user: AuthUser; }; const statusLabels: Record = { @@ -48,185 +33,18 @@ const statusLabels: Record = { }; const dateFormatter = new Intl.DateTimeFormat("pt-BR", { day: "2-digit", month: "long", year: "numeric" }); -const chatTimeFormatter = new Intl.DateTimeFormat("pt-BR", { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit" }); -const emojiCategories = [ - { - id: "recent", - label: "Recentes", - icon: "๐Ÿ‘", - emojis: [], - }, - { - id: "smileys", - label: "Rostos", - icon: "๐Ÿ˜Š", - emojis: [ - "๐Ÿ˜€", "๐Ÿ˜ƒ", "๐Ÿ˜„", "๐Ÿ˜", "๐Ÿ˜†", "๐Ÿ˜…", "๐Ÿคฃ", "๐Ÿ˜‚", "๐Ÿ™‚", "๐Ÿ™ƒ", "๐Ÿ˜‰", "๐Ÿ˜Š", "๐Ÿ˜‡", "๐Ÿฅฐ", "๐Ÿ˜", "๐Ÿคฉ", - "๐Ÿ˜˜", "๐Ÿ˜—", "โ˜บ๏ธ", "๐Ÿ˜š", "๐Ÿ˜™", "๐Ÿฅฒ", "๐Ÿ˜‹", "๐Ÿ˜›", "๐Ÿ˜œ", "๐Ÿคช", "๐Ÿ˜", "๐Ÿค‘", "๐Ÿค—", "๐Ÿคญ", "๐Ÿซข", "๐Ÿซฃ", - "๐Ÿคซ", "๐Ÿค”", "๐Ÿซก", "๐Ÿค", "๐Ÿคจ", "๐Ÿ˜", "๐Ÿ˜‘", "๐Ÿ˜ถ", "๐Ÿซฅ", "๐Ÿ˜", "๐Ÿ˜’", "๐Ÿ™„", "๐Ÿ˜ฌ", "๐Ÿ˜ฎโ€๐Ÿ’จ", "๐Ÿคฅ", "๐Ÿ˜Œ", - "๐Ÿ˜”", "๐Ÿ˜ช", "๐Ÿคค", "๐Ÿ˜ด", "๐Ÿ˜ท", "๐Ÿค’", "๐Ÿค•", "๐Ÿคข", "๐Ÿคฎ", "๐Ÿคง", "๐Ÿฅต", "๐Ÿฅถ", "๐Ÿฅด", "๐Ÿ˜ต", "๐Ÿคฏ", "๐Ÿค ", - "๐Ÿฅณ", "๐Ÿฅธ", "๐Ÿ˜Ž", "๐Ÿค“", "๐Ÿง", "๐Ÿ˜•", "๐Ÿซค", "๐Ÿ˜Ÿ", "๐Ÿ™", "โ˜น๏ธ", "๐Ÿ˜ฎ", "๐Ÿ˜ฏ", "๐Ÿ˜ฒ", "๐Ÿ˜ณ", "๐Ÿฅบ", "๐Ÿฅน", - "๐Ÿ˜ฆ", "๐Ÿ˜ง", "๐Ÿ˜จ", "๐Ÿ˜ฐ", "๐Ÿ˜ฅ", "๐Ÿ˜ข", "๐Ÿ˜ญ", "๐Ÿ˜ฑ", "๐Ÿ˜–", "๐Ÿ˜ฃ", "๐Ÿ˜ž", "๐Ÿ˜“", "๐Ÿ˜ฉ", "๐Ÿ˜ซ", "๐Ÿฅฑ", "๐Ÿ˜ค", - "๐Ÿ˜ก", "๐Ÿ˜ ", "๐Ÿคฌ", "๐Ÿ˜ˆ", "๐Ÿ‘ฟ", "๐Ÿ’€", "โ˜ ๏ธ", "๐Ÿ’ฉ", "๐Ÿคก", "๐Ÿ‘ป", "๐Ÿ‘ฝ", "๐Ÿค–", "๐Ÿ˜บ", "๐Ÿ˜ธ", "๐Ÿ˜น", "๐Ÿ˜ป", - ], - }, - { - id: "people", - label: "Pessoas", - icon: "๐Ÿ‘‹", - emojis: [ - "๐Ÿ‘‹", "๐Ÿคš", "๐Ÿ–๏ธ", "โœ‹", "๐Ÿ––", "๐Ÿซฑ", "๐Ÿซฒ", "๐Ÿซณ", "๐Ÿซด", "๐Ÿ‘Œ", "๐ŸคŒ", "๐Ÿค", "โœŒ๏ธ", "๐Ÿคž", "๐Ÿซฐ", "๐ŸคŸ", - "๐Ÿค˜", "๐Ÿค™", "๐Ÿ‘ˆ", "๐Ÿ‘‰", "๐Ÿ‘†", "๐Ÿ–•", "๐Ÿ‘‡", "โ˜๏ธ", "๐Ÿซต", "๐Ÿ‘", "๐Ÿ‘Ž", "โœŠ", "๐Ÿ‘Š", "๐Ÿค›", "๐Ÿคœ", "๐Ÿ‘", - "๐Ÿ™Œ", "๐Ÿซถ", "๐Ÿ‘", "๐Ÿคฒ", "๐Ÿค", "๐Ÿ™", "โœ๏ธ", "๐Ÿ’…", "๐Ÿคณ", "๐Ÿ’ช", "๐Ÿฆพ", "๐Ÿฆต", "๐Ÿฆถ", "๐Ÿ‘‚", "๐Ÿฆป", "๐Ÿ‘ƒ", - "๐Ÿง ", "๐Ÿซ€", "๐Ÿซ", "๐Ÿฆท", "๐Ÿฆด", "๐Ÿ‘€", "๐Ÿ‘๏ธ", "๐Ÿ‘…", "๐Ÿ‘„", "๐Ÿซฆ", "๐Ÿ‘ถ", "๐Ÿง’", "๐Ÿ‘ฆ", "๐Ÿ‘ง", "๐Ÿง‘", "๐Ÿ‘ฑ", - "๐Ÿ‘จ", "๐Ÿง”", "๐Ÿ‘ฉ", "๐Ÿง“", "๐Ÿ‘ด", "๐Ÿ‘ต", "๐Ÿ™", "๐Ÿ™Ž", "๐Ÿ™…", "๐Ÿ™†", "๐Ÿ’", "๐Ÿ™‹", "๐Ÿง", "๐Ÿ™‡", "๐Ÿคฆ", "๐Ÿคท", - "๐Ÿ‘จโ€๐Ÿ’ป", "๐Ÿ‘ฉโ€๐Ÿ’ป", "๐Ÿ‘จโ€๐Ÿ’ผ", "๐Ÿ‘ฉโ€๐Ÿ’ผ", "๐Ÿ‘จโ€๐ŸŽจ", "๐Ÿ‘ฉโ€๐ŸŽจ", "๐Ÿ‘จโ€๐Ÿš€", "๐Ÿ‘ฉโ€๐Ÿš€", "๐Ÿง‘โ€โš•๏ธ", "๐Ÿง‘โ€๐Ÿซ", "๐Ÿง‘โ€๐Ÿณ", "๐Ÿง‘โ€๐Ÿ”ง", - ], - }, - { - id: "symbols", - label: "Sรญmbolos", - icon: "โค๏ธ", - emojis: [ - "โค๏ธ", "๐Ÿงก", "๐Ÿ’›", "๐Ÿ’š", "๐Ÿ’™", "๐Ÿ’œ", "๐Ÿ–ค", "๐Ÿค", "๐ŸคŽ", "๐Ÿ’”", "โฃ๏ธ", "๐Ÿ’•", "๐Ÿ’ž", "๐Ÿ’“", "๐Ÿ’—", "๐Ÿ’–", - "๐Ÿ’˜", "๐Ÿ’", "๐Ÿ’Ÿ", "โ˜ฎ๏ธ", "โœ๏ธ", "โ˜ช๏ธ", "๐Ÿ•‰๏ธ", "โ˜ธ๏ธ", "โœก๏ธ", "๐Ÿ”ฏ", "๐Ÿ•Ž", "โ˜ฏ๏ธ", "โ˜ฆ๏ธ", "๐Ÿ›", "โ›Ž", "โ™ˆ", - "โ™‰", "โ™Š", "โ™‹", "โ™Œ", "โ™", "โ™Ž", "โ™", "โ™", "โ™‘", "โ™’", "โ™“", "๐Ÿ†”", "โš›๏ธ", "๐Ÿ‰‘", "โ˜ข๏ธ", "โ˜ฃ๏ธ", - "๐Ÿ“ด", "๐Ÿ“ณ", "๐Ÿˆถ", "๐Ÿˆš", "๐Ÿˆธ", "๐Ÿˆบ", "๐Ÿˆท๏ธ", "โœด๏ธ", "๐Ÿ†š", "๐Ÿ’ฎ", "๐Ÿ‰", "ใŠ™๏ธ", "ใŠ—๏ธ", "๐Ÿˆด", "๐Ÿˆต", "๐Ÿˆน", - "๐Ÿˆฒ", "๐Ÿ…ฐ๏ธ", "๐Ÿ…ฑ๏ธ", "๐Ÿ†Ž", "๐Ÿ†‘", "๐Ÿ…พ๏ธ", "๐Ÿ†˜", "โŒ", "โญ•", "๐Ÿ›‘", "โ›”", "๐Ÿ“›", "๐Ÿšซ", "๐Ÿ’ฏ", "๐Ÿ’ข", "โ™จ๏ธ", - "๐Ÿšท", "๐Ÿšฏ", "๐Ÿšณ", "๐Ÿšฑ", "๐Ÿ”ž", "๐Ÿ“ต", "๐Ÿšญ", "โ—", "โ•", "โ“", "โ”", "โ€ผ๏ธ", "โ‰๏ธ", "๐Ÿ”…", "๐Ÿ”†", "ใ€ฝ๏ธ", - "โš ๏ธ", "๐Ÿšธ", "๐Ÿ”ฑ", "โšœ๏ธ", "๐Ÿ”ฐ", "โœ…", "๐Ÿˆฏ", "๐Ÿ’น", "โ‡๏ธ", "โœณ๏ธ", "โŽ", "๐ŸŒ", "๐Ÿ’ ", "โ“‚๏ธ", "๐ŸŒ€", "๐Ÿ’ค", - ], - }, - { - id: "objects", - label: "Objetos", - icon: "๐Ÿ“Ž", - emojis: [ - "๐Ÿ’Œ", "๐Ÿ•ณ๏ธ", "๐Ÿ’ฃ", "๐Ÿ›€", "๐Ÿ›Œ", "๐Ÿ”ช", "๐Ÿบ", "๐Ÿ—บ๏ธ", "๐Ÿงญ", "๐Ÿงฑ", "๐Ÿ’ˆ", "๐Ÿ›ข๏ธ", "๐Ÿ›Ž๏ธ", "๐Ÿงณ", "โŒ›", "โณ", - "โŒš", "โฐ", "โฑ๏ธ", "โฒ๏ธ", "๐Ÿ•ฐ๏ธ", "๐ŸŒก๏ธ", "โ›ฑ๏ธ", "๐Ÿงจ", "๐ŸŽˆ", "๐ŸŽ‰", "๐ŸŽŠ", "๐ŸŽŽ", "๐ŸŽ", "๐ŸŽ", "๐Ÿงง", "๐ŸŽ€", - "๐ŸŽ", "๐ŸŽ—๏ธ", "๐ŸŽŸ๏ธ", "๐ŸŽซ", "๐Ÿ”ฎ", "๐Ÿช„", "๐Ÿงฟ", "๐Ÿชฌ", "๐Ÿ•น๏ธ", "๐Ÿงธ", "๐Ÿช…", "๐Ÿชฉ", "๐Ÿ–ผ๏ธ", "๐ŸŽจ", "๐Ÿงต", "๐Ÿชก", - "๐Ÿงถ", "๐Ÿชข", "๐Ÿ‘“", "๐Ÿ•ถ๏ธ", "๐Ÿฅฝ", "๐Ÿฅผ", "๐Ÿฆบ", "๐Ÿ‘”", "๐Ÿ‘•", "๐Ÿ‘–", "๐Ÿงฃ", "๐Ÿงค", "๐Ÿงฅ", "๐Ÿงฆ", "๐Ÿ‘—", "๐Ÿ‘˜", - "๐Ÿฅป", "๐Ÿฉฑ", "๐Ÿฉฒ", "๐Ÿฉณ", "๐Ÿ‘™", "๐Ÿ‘š", "๐Ÿชญ", "๐Ÿ‘›", "๐Ÿ‘œ", "๐Ÿ‘", "๐Ÿ›๏ธ", "๐ŸŽ’", "๐Ÿฉด", "๐Ÿ‘ž", "๐Ÿ‘Ÿ", "๐Ÿฅพ", - "๐Ÿฅฟ", "๐Ÿ‘ ", "๐Ÿ‘ก", "๐Ÿฉฐ", "๐Ÿ‘ข", "๐Ÿ‘‘", "๐Ÿ‘’", "๐ŸŽฉ", "๐ŸŽ“", "๐Ÿงข", "๐Ÿช–", "โ›‘๏ธ", "๐Ÿ“ฟ", "๐Ÿ’„", "๐Ÿ’", "๐Ÿ’Ž", - "๐Ÿ“ฑ", "๐Ÿ’ป", "โŒจ๏ธ", "๐Ÿ–ฅ๏ธ", "๐Ÿ–จ๏ธ", "๐Ÿ–ฑ๏ธ", "๐Ÿ–ฒ๏ธ", "๐Ÿ’ฝ", "๐Ÿ’พ", "๐Ÿ’ฟ", "๐Ÿ“€", "๐Ÿ“ท", "๐Ÿ“ธ", "๐Ÿ“น", "๐ŸŽฅ", "๐Ÿ“ž", - "โ˜Ž๏ธ", "๐Ÿ“Ÿ", "๐Ÿ“ ", "๐Ÿ“บ", "๐Ÿ“ป", "๐ŸŽ™๏ธ", "๐ŸŽš๏ธ", "๐ŸŽ›๏ธ", "๐Ÿงญ", "๐Ÿ“ก", "๐Ÿ”‹", "๐Ÿชซ", "๐Ÿ”Œ", "๐Ÿ’ก", "๐Ÿ”ฆ", "๐Ÿ•ฏ๏ธ", - ], - }, - { - id: "food", - label: "Comida", - icon: "๐Ÿ•", - emojis: [ - "๐Ÿ", "๐ŸŽ", "๐Ÿ", "๐ŸŠ", "๐Ÿ‹", "๐ŸŒ", "๐Ÿ‰", "๐Ÿ‡", "๐Ÿ“", "๐Ÿซ", "๐Ÿˆ", "๐Ÿ’", "๐Ÿ‘", "๐Ÿฅญ", "๐Ÿ", "๐Ÿฅฅ", - "๐Ÿฅ", "๐Ÿ…", "๐Ÿ†", "๐Ÿฅ‘", "๐Ÿฅฆ", "๐Ÿซ›", "๐Ÿฅฌ", "๐Ÿฅ’", "๐ŸŒถ๏ธ", "๐Ÿซ‘", "๐ŸŒฝ", "๐Ÿฅ•", "๐Ÿซ’", "๐Ÿง„", "๐Ÿง…", "๐Ÿฅ”", - "๐Ÿ ", "๐Ÿซ˜", "๐Ÿฅ", "๐Ÿฅฏ", "๐Ÿž", "๐Ÿฅ–", "๐Ÿฅจ", "๐Ÿง€", "๐Ÿฅš", "๐Ÿณ", "๐Ÿงˆ", "๐Ÿฅž", "๐Ÿง‡", "๐Ÿฅ“", "๐Ÿฅฉ", "๐Ÿ—", - "๐Ÿ–", "๐Ÿฆด", "๐ŸŒญ", "๐Ÿ”", "๐ŸŸ", "๐Ÿ•", "๐Ÿซ“", "๐Ÿฅช", "๐Ÿฅ™", "๐Ÿง†", "๐ŸŒฎ", "๐ŸŒฏ", "๐Ÿซ”", "๐Ÿฅ—", "๐Ÿฅ˜", "๐Ÿซ•", - "๐Ÿฅซ", "๐Ÿ", "๐Ÿœ", "๐Ÿฒ", "๐Ÿ›", "๐Ÿฃ", "๐Ÿฑ", "๐ŸฅŸ", "๐Ÿฆช", "๐Ÿค", "๐Ÿ™", "๐Ÿš", "๐Ÿ˜", "๐Ÿฅ", "๐Ÿฅ ", "๐Ÿฅฎ", - "๐Ÿข", "๐Ÿก", "๐Ÿง", "๐Ÿจ", "๐Ÿฆ", "๐Ÿฅง", "๐Ÿง", "๐Ÿฐ", "๐ŸŽ‚", "๐Ÿฎ", "๐Ÿญ", "๐Ÿฌ", "๐Ÿซ", "๐Ÿฟ", "๐Ÿฉ", "๐Ÿช", - "๐ŸŒฐ", "๐Ÿฅœ", "๐Ÿฏ", "๐Ÿฅ›", "๐Ÿผ", "๐Ÿซ–", "โ˜•", "๐Ÿต", "๐Ÿงƒ", "๐Ÿฅค", "๐Ÿง‹", "๐Ÿถ", "๐Ÿบ", "๐Ÿป", "๐Ÿฅ‚", "๐Ÿท", - ], - }, - { - id: "activity", - label: "Atividades", - icon: "โšฝ", - emojis: [ - "โšฝ", "๐Ÿ€", "๐Ÿˆ", "โšพ", "๐ŸฅŽ", "๐ŸŽพ", "๐Ÿ", "๐Ÿ‰", "๐Ÿฅ", "๐ŸŽฑ", "๐Ÿช€", "๐Ÿ“", "๐Ÿธ", "๐Ÿ’", "๐Ÿ‘", "๐Ÿฅ", - "๐Ÿ", "๐Ÿชƒ", "๐Ÿฅ…", "โ›ณ", "๐Ÿช", "๐Ÿน", "๐ŸŽฃ", "๐Ÿคฟ", "๐ŸฅŠ", "๐Ÿฅ‹", "๐ŸŽฝ", "๐Ÿ›น", "๐Ÿ›ผ", "๐Ÿ›ท", "โ›ธ๏ธ", "๐ŸฅŒ", - "๐ŸŽฟ", "โ›ท๏ธ", "๐Ÿ‚", "๐Ÿช‚", "๐Ÿ‹๏ธ", "๐Ÿคผ", "๐Ÿคธ", "โ›น๏ธ", "๐Ÿคบ", "๐Ÿคพ", "๐ŸŒ๏ธ", "๐Ÿ‡", "๐Ÿง˜", "๐Ÿ„", "๐ŸŠ", "๐Ÿคฝ", - "๐Ÿšฃ", "๐Ÿง—", "๐Ÿšต", "๐Ÿšด", "๐Ÿ†", "๐Ÿฅ‡", "๐Ÿฅˆ", "๐Ÿฅ‰", "๐Ÿ…", "๐ŸŽ–๏ธ", "๐Ÿต๏ธ", "๐ŸŽ—๏ธ", "๐ŸŽซ", "๐ŸŽŸ๏ธ", "๐ŸŽช", "๐Ÿคน", - "๐ŸŽญ", "๐Ÿฉฐ", "๐ŸŽจ", "๐ŸŽฌ", "๐ŸŽค", "๐ŸŽง", "๐ŸŽผ", "๐ŸŽน", "๐Ÿฅ", "๐Ÿช˜", "๐ŸŽท", "๐ŸŽบ", "๐Ÿช—", "๐ŸŽธ", "๐Ÿช•", "๐ŸŽป", - ], - }, - { - id: "travel", - label: "Viagem", - icon: "๐Ÿš—", - emojis: [ - "๐Ÿš—", "๐Ÿš•", "๐Ÿš™", "๐ŸšŒ", "๐ŸšŽ", "๐ŸŽ๏ธ", "๐Ÿš“", "๐Ÿš‘", "๐Ÿš’", "๐Ÿš", "๐Ÿ›ป", "๐Ÿšš", "๐Ÿš›", "๐Ÿšœ", "๐Ÿฆฏ", "๐Ÿฆฝ", - "๐Ÿฆผ", "๐Ÿ›ด", "๐Ÿšฒ", "๐Ÿ›ต", "๐Ÿ๏ธ", "๐Ÿ›บ", "๐Ÿšจ", "๐Ÿš”", "๐Ÿš", "๐Ÿš˜", "๐Ÿš–", "๐Ÿšก", "๐Ÿš ", "๐ŸšŸ", "๐Ÿšƒ", "๐Ÿš‹", - "๐Ÿšž", "๐Ÿš", "๐Ÿš„", "๐Ÿš…", "๐Ÿšˆ", "๐Ÿš‚", "๐Ÿš†", "๐Ÿš‡", "๐ŸšŠ", "๐Ÿš‰", "โœˆ๏ธ", "๐Ÿ›ซ", "๐Ÿ›ฌ", "๐Ÿ›ฉ๏ธ", "๐Ÿ’บ", "๐Ÿ›ฐ๏ธ", - "๐Ÿš€", "๐Ÿ›ธ", "๐Ÿš", "๐Ÿ›ถ", "โ›ต", "๐Ÿšค", "๐Ÿ›ฅ๏ธ", "๐Ÿ›ณ๏ธ", "โ›ด๏ธ", "๐Ÿšข", "โš“", "๐Ÿ›Ÿ", "๐Ÿช", "โ›ฝ", "๐Ÿšง", "๐Ÿšฆ", - "๐Ÿšฅ", "๐Ÿš", "๐Ÿ—บ๏ธ", "๐Ÿ—ฟ", "๐Ÿ—ฝ", "๐Ÿ—ผ", "๐Ÿฐ", "๐Ÿฏ", "๐ŸŸ๏ธ", "๐ŸŽก", "๐ŸŽข", "๐ŸŽ ", "โ›ฒ", "โ›ฑ๏ธ", "๐Ÿ–๏ธ", "๐Ÿ๏ธ", - "๐Ÿœ๏ธ", "๐ŸŒ‹", "โ›ฐ๏ธ", "๐Ÿ”๏ธ", "๐Ÿ—ป", "๐Ÿ•๏ธ", "โ›บ", "๐Ÿ›–", "๐Ÿ ", "๐Ÿก", "๐Ÿ˜๏ธ", "๐Ÿš๏ธ", "๐Ÿ—๏ธ", "๐Ÿญ", "๐Ÿข", "๐Ÿฌ", - ], - }, -] as const; -const recentEmojisStorageKey = "mira_recent_emojis"; -const maxRecentEmojis = 32; -const attachmentAcceptTypes = "image/jpeg,image/png,image/webp,application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document,.doc,.docx"; -type EmojiCategoryId = (typeof emojiCategories)[number]["id"]; - -function readStoredRecentEmojis() { - if (typeof window === "undefined") return []; - - try { - const stored = window.localStorage.getItem(recentEmojisStorageKey); - if (!stored) return []; - - const parsed = JSON.parse(stored); - return Array.isArray(parsed) ? parsed.filter((item): item is string => typeof item === "string").slice(0, maxRecentEmojis) : []; - } catch { - return []; - } -} - -export function PostDetailView({ itemId, onBack, user }: PostDetailViewProps) { +export function PostDetailView({ itemId, onBack }: PostDetailViewProps) { const [item, setItem] = useState(null); const [attachments, setAttachments] = useState([]); - const [chatChannel, setChatChannel] = useState("external"); - const [chatMessages, setChatMessages] = useState>({ external: [], internal: [] }); - const [chatDraft, setChatDraft] = useState(""); - const [chatAttachmentFile, setChatAttachmentFile] = useState(null); - const [editingMessage, setEditingMessage] = useState(null); - const [showEmojiPicker, setShowEmojiPicker] = useState(false); - const [emojiCategoryId, setEmojiCategoryId] = useState("recent"); - const [emojiSearch, setEmojiSearch] = useState(""); - const [recentEmojis, setRecentEmojis] = useState(readStoredRecentEmojis); const [previewAttachment, setPreviewAttachment] = useState(null); - const [previewChatAttachment, setPreviewChatAttachment] = useState(null); - const [attachmentToDelete, setAttachmentToDelete] = useState(null); const [isLoading, setIsLoading] = useState(false); const [isUploading, setIsUploading] = useState(false); - const [isLoadingChat, setIsLoadingChat] = useState(false); - const [isSendingChat, setIsSendingChat] = useState(false); - const [isUpdatingStatus, setIsUpdatingStatus] = useState(false); const [error, setError] = useState(""); const [attachmentError, setAttachmentError] = useState(""); - const [chatError, setChatError] = useState(""); - const [attachmentObjectURLs, setAttachmentObjectURLs] = useState>({}); - const [chatAttachmentObjectURLs, setChatAttachmentObjectURLs] = useState>({}); - const canUseInternalChat = user.role !== "client_viewer"; - const canEditCalendar = user.role === "super_admin" || user.role === "agency_user"; - const visibleChatMessages = chatMessages[chatChannel]; - const visibleChatImageAttachments = useMemo( - () => visibleChatMessages.flatMap((message) => (message.attachments ?? []).filter((attachment) => isImageMime(attachment.mime_type))), - [visibleChatMessages], - ); - const selectedEmojiCategory = emojiCategories.find((category) => category.id === emojiCategoryId) ?? emojiCategories[0]; - const visibleEmojis = useMemo(() => { - const query = emojiSearch.trim().toLocaleLowerCase("pt-BR"); - if (!query) return emojiCategoryId === "recent" ? recentEmojis : [...selectedEmojiCategory.emojis]; - - const searchableCategories = emojiCategories.filter((category) => category.id !== "recent"); - const searchedEmojis = searchableCategories - .filter((category) => category.id.includes(query) || category.label.toLocaleLowerCase("pt-BR").includes(query) || category.emojis.some((emoji) => emoji.includes(query))) - .flatMap((category) => category.emojis.filter((emoji) => !query || emoji.includes(query) || category.id.includes(query) || category.label.toLocaleLowerCase("pt-BR").includes(query))); - - return [...new Set([...recentEmojis.filter((emoji) => emoji.includes(query)), ...searchedEmojis])]; - }, [emojiCategoryId, emojiSearch, recentEmojis, selectedEmojiCategory]); const imageAttachments = useMemo(() => attachments.filter((attachment) => isImageAttachment(attachment)), [attachments]); const fileAttachments = useMemo(() => attachments.filter((attachment) => !isImageAttachment(attachment)), [attachments]); - const previewIndex = previewAttachment - ? imageAttachments.findIndex((attachment) => attachment.id === previewAttachment.id) - : -1; - const canNavigatePreview = imageAttachments.length > 1 && previewIndex >= 0; - const chatPreviewIndex = previewChatAttachment - ? visibleChatImageAttachments.findIndex((attachment) => attachment.id === previewChatAttachment.id) - : -1; - const canNavigateChatPreview = visibleChatImageAttachments.length > 1 && chatPreviewIndex >= 0; useEffect(() => { const token = getStoredToken(); @@ -245,206 +63,12 @@ export function PostDetailView({ itemId, onBack, user }: PostDetailViewProps) { .finally(() => setIsLoading(false)); }, [itemId]); - useEffect(() => { - if (!canUseInternalChat && chatChannel === "internal") { - setChatChannel("external"); - } - }, [canUseInternalChat, chatChannel]); - - useEffect(() => { - window.localStorage.setItem(recentEmojisStorageKey, JSON.stringify(recentEmojis)); - }, [recentEmojis]); - - useEffect(() => { - if (!showEmojiPicker) return; - - function handleEscapeKey(event: KeyboardEvent) { - if (event.key === "Escape") { - setShowEmojiPicker(false); - } - } - - window.addEventListener("keydown", handleEscapeKey); - return () => window.removeEventListener("keydown", handleEscapeKey); - }, [showEmojiPicker]); - - useEffect(() => { - const token = getStoredToken(); - if (!token) return; - let cancelled = false; - let reconnectTimer: number | null = null; - const streamAbortController = new AbortController(); - - async function loadMessages(silent = false) { - if (!silent) { - setIsLoadingChat(true); - } - setChatError(""); - - try { - const messages = await listChatMessages(token, itemId, chatChannel); - if (cancelled) return; - setChatMessages((current) => ({ ...current, [chatChannel]: messages })); - } catch (err) { - if (!cancelled && !silent) { - setChatError(err instanceof Error ? err.message : "Nรฃo foi possรญvel carregar mensagens."); - } - } finally { - if (!cancelled && !silent) { - setIsLoadingChat(false); - } - } - } - - async function connectChatStream() { - try { - const response = await fetch(chatStreamURL(itemId, chatChannel), { - headers: { - Authorization: `Bearer ${token}`, - }, - credentials: "include", - signal: streamAbortController.signal, - }); - if (!response.ok || !response.body) { - throw new Error("stream unavailable"); - } - - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ""; - - while (!cancelled) { - const { done, value } = await reader.read(); - if (done) break; - - buffer += decoder.decode(value, { stream: true }); - let eventEndIndex = buffer.indexOf("\n\n"); - while (eventEndIndex >= 0) { - const eventBlock = buffer.slice(0, eventEndIndex); - buffer = buffer.slice(eventEndIndex + 2); - - if (eventBlock.includes("event: chat")) { - void loadMessages(true); - } - - eventEndIndex = buffer.indexOf("\n\n"); - } - } - } catch { - if (cancelled) return; - } - - if (!cancelled) { - reconnectTimer = window.setTimeout(() => { - void connectChatStream(); - }, 3000); - } - } - - void loadMessages(); - void connectChatStream(); - - return () => { - cancelled = true; - streamAbortController.abort(); - if (reconnectTimer !== null) { - window.clearTimeout(reconnectTimer); - } - }; - }, [chatChannel, itemId]); - - useEffect(() => { - const token = getStoredToken(); - const objectURLs: string[] = []; - let cancelled = false; - - setAttachmentObjectURLs({}); - - if (!token || imageAttachments.length === 0) { - return () => { - cancelled = true; - }; - } - - Promise.all( - imageAttachments.map(async (attachment) => { - try { - const blob = await downloadAttachmentBlob(token, attachment.id); - const objectURL = URL.createObjectURL(blob); - - if (cancelled) { - URL.revokeObjectURL(objectURL); - return null; - } - - objectURLs.push(objectURL); - return [attachment.id, objectURL] as const; - } catch { - return null; - } - }), - ).then((entries) => { - if (cancelled) return; - setAttachmentObjectURLs(Object.fromEntries(entries.filter((entry): entry is [string, string] => entry !== null))); - }); - - return () => { - cancelled = true; - objectURLs.forEach((objectURL) => URL.revokeObjectURL(objectURL)); - }; - }, [imageAttachments]); - - useEffect(() => { - const token = getStoredToken(); - const objectURLs: string[] = []; - let cancelled = false; - - setChatAttachmentObjectURLs({}); - - if (!token || visibleChatImageAttachments.length === 0) { - return () => { - cancelled = true; - }; - } - - Promise.all( - visibleChatImageAttachments.map(async (attachment) => { - try { - const blob = await downloadChatAttachmentBlob(token, attachment.id); - const objectURL = URL.createObjectURL(blob); - - if (cancelled) { - URL.revokeObjectURL(objectURL); - return null; - } - - objectURLs.push(objectURL); - return [attachment.id, objectURL] as const; - } catch { - return null; - } - }), - ).then((entries) => { - if (cancelled) return; - setChatAttachmentObjectURLs(Object.fromEntries(entries.filter((entry): entry is [string, string] => entry !== null))); - }); - - return () => { - cancelled = true; - objectURLs.forEach((objectURL) => URL.revokeObjectURL(objectURL)); - }; - }, [visibleChatImageAttachments]); - async function handleUploadAttachment(event: FormEvent) { event.preventDefault(); const token = getStoredToken(); const fileInput = event.currentTarget.elements.namedItem("attachment") as HTMLInputElement | null; const file = fileInput?.files?.[0]; - if (!token) return; - if (!file) { - setAttachmentError("Selecione um arquivo antes de enviar."); - return; - } + if (!token || !file) return; setIsUploading(true); setAttachmentError(""); @@ -459,171 +83,6 @@ export function PostDetailView({ itemId, onBack, user }: PostDetailViewProps) { } } - async function handleDownloadAttachment(attachment: Attachment) { - const token = getStoredToken(); - if (!token) return; - - setAttachmentError(""); - try { - const blob = await downloadAttachmentBlob(token, attachment.id); - const objectURL = URL.createObjectURL(blob); - const link = document.createElement("a"); - link.href = objectURL; - link.download = attachment.original_filename; - document.body.appendChild(link); - link.click(); - link.remove(); - window.setTimeout(() => URL.revokeObjectURL(objectURL), 0); - } catch (err) { - setAttachmentError(err instanceof Error ? err.message : "Nรฃo foi possรญvel baixar o anexo."); - } - } - - async function handleUpdateStatus(status: CalendarItem["status"]) { - const token = getStoredToken(); - if (!token || !item || item.status === status) return; - - setIsUpdatingStatus(true); - setError(""); - try { - const updated = await updateCalendarItem(token, item.id, { status }); - setItem(updated); - } catch (err) { - setError(err instanceof Error ? err.message : "Nรฃo foi possรญvel atualizar o status."); - } finally { - setIsUpdatingStatus(false); - } - } - - async function handleDeleteAttachment(attachment: Attachment) { - const token = getStoredToken(); - if (!token) return; - - setAttachmentError(""); - try { - await deleteAttachment(token, attachment.id); - setAttachments((current) => current.filter((item) => item.id !== attachment.id)); - if (previewAttachment?.id === attachment.id) { - setPreviewAttachment(null); - } - setAttachmentToDelete(null); - } catch (err) { - setAttachmentError(err instanceof Error ? err.message : "Nรฃo foi possรญvel excluir o anexo."); - } - } - - async function handleSendChatMessage(event: FormEvent) { - event.preventDefault(); - const token = getStoredToken(); - const body = chatDraft.trim(); - if (!token || (!body && !chatAttachmentFile)) return; - - setIsSendingChat(true); - setChatError(""); - try { - if (editingMessage) { - const message = await updateChatMessage(token, editingMessage.id, body); - setChatMessages((current) => ({ ...current, [chatChannel]: current[chatChannel].map((item) => (item.id === message.id ? message : item)) })); - setEditingMessage(null); - } else { - const messageBody = body || `Anexo: ${chatAttachmentFile?.name ?? ""}`; - let message = await createChatMessage(token, itemId, chatChannel, messageBody); - if (chatAttachmentFile) { - const attachment = await uploadChatAttachment(token, message.id, chatAttachmentFile); - message = { ...message, attachments: [...(message.attachments ?? []), attachment] }; - } - setChatMessages((current) => ({ ...current, [chatChannel]: [...current[chatChannel], message] })); - } - setChatDraft(""); - setChatAttachmentFile(null); - setShowEmojiPicker(false); - } catch (err) { - setChatError(err instanceof Error ? err.message : "Nรฃo foi possรญvel enviar a mensagem."); - } finally { - setIsSendingChat(false); - } - } - - async function handleDeleteChatMessage(message: ChatMessage) { - const token = getStoredToken(); - if (!token) return; - - setChatError(""); - try { - const deleted = await deleteChatMessage(token, message.id); - setChatMessages((current) => ({ ...current, [chatChannel]: current[chatChannel].map((item) => (item.id === deleted.id ? deleted : item)) })); - } catch (err) { - setChatError(err instanceof Error ? err.message : "Nรฃo foi possรญvel excluir a mensagem."); - } - } - - async function handleDownloadChatAttachment(attachment: ChatAttachment) { - const token = getStoredToken(); - if (!token) return; - - setChatError(""); - try { - const blob = await downloadChatAttachmentBlob(token, attachment.id); - const objectURL = URL.createObjectURL(blob); - const link = document.createElement("a"); - link.href = objectURL; - link.download = attachment.original_filename; - document.body.appendChild(link); - link.click(); - link.remove(); - window.setTimeout(() => URL.revokeObjectURL(objectURL), 0); - } catch (err) { - setChatError(err instanceof Error ? err.message : "Nรฃo foi possรญvel baixar o anexo."); - } - } - - function startEditingChatMessage(message: ChatMessage) { - setEditingMessage(message); - setChatDraft(message.body); - setChatAttachmentFile(null); - } - - function cancelEditingChatMessage() { - setEditingMessage(null); - setChatDraft(""); - } - - function addEmojiToChatDraft(emoji: string) { - setChatDraft((current) => `${current}${emoji}`); - setRecentEmojis((current) => [emoji, ...current.filter((item) => item !== emoji)].slice(0, maxRecentEmojis)); - } - - function handleChatDraftKeyDown(event: ReactKeyboardEvent) { - if (event.key !== "Enter" || event.shiftKey) return; - - event.preventDefault(); - event.currentTarget.form?.requestSubmit(); - } - - function showPreviousPreviewAttachment() { - if (!canNavigatePreview) return; - const previousIndex = previewIndex === 0 ? imageAttachments.length - 1 : previewIndex - 1; - setPreviewAttachment(imageAttachments[previousIndex]); - } - - function showNextPreviewAttachment() { - if (!canNavigatePreview) return; - const nextIndex = previewIndex === imageAttachments.length - 1 ? 0 : previewIndex + 1; - setPreviewAttachment(imageAttachments[nextIndex]); - } - - function showPreviousChatPreviewAttachment() { - if (!canNavigateChatPreview) return; - const previousIndex = chatPreviewIndex === 0 ? visibleChatImageAttachments.length - 1 : chatPreviewIndex - 1; - setPreviewChatAttachment(visibleChatImageAttachments[previousIndex]); - } - - function showNextChatPreviewAttachment() { - if (!canNavigateChatPreview) return; - const nextIndex = chatPreviewIndex === visibleChatImageAttachments.length - 1 ? 0 : chatPreviewIndex + 1; - setPreviewChatAttachment(visibleChatImageAttachments[nextIndex]); - } - if (isLoading && !item) { return

Carregando postagem...

; } @@ -644,159 +103,6 @@ export function PostDetailView({ itemId, onBack, user }: PostDetailViewProps) { if (!item) return null; - const chatCard = ( - - - Chat - setChatChannel(value as ChatChannel)}> - - Externo - {canUseInternalChat && Interno} - - - - - setChatChannel(value as ChatChannel)}> - - - - {canUseInternalChat && ( - - - - )} - - - {chatError && ( -
- {chatError} -
- )} - -
- {showEmojiPicker && ( -
- setEmojiSearch(event.target.value)} - placeholder="Buscar emoji" - className="mb-2 h-8" - /> -
- {emojiCategories.map((category) => ( - - ))} -
-
- {visibleEmojis.map((emoji, index) => ( - - ))} - {visibleEmojis.length === 0 && ( -

- {emojiCategoryId === "recent" && emojiSearch.trim() === "" ? "Os emojis usados aparecem aqui." : "Nenhum emoji encontrado."} -

- )} -
-
- )} - - - {editingMessage && ( -
- Editando mensagem - -
- )} - - {chatAttachmentFile && ( -
- {chatAttachmentFile.name} - -
- )} - -
- - {!editingMessage && ( - - )} -