From 0b920da187e8cf2bd11f2f8211307ab09f1882ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Faleiros?= Date: Mon, 8 Jun 2026 16:44:33 -0300 Subject: [PATCH] Build post chat and attachment workflows --- .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, 3021 insertions(+), 204 deletions(-) create mode 100644 backend/internal/auth/rate_limit.go create mode 100644 backend/internal/auth/sessions.go create mode 100644 backend/internal/calendar/chat.go create mode 100644 backend/internal/calendar/chat_events.go create mode 100644 backend/internal/mail/smtp.go create mode 100644 backend/migrations/005_refresh_tokens.sql create mode 100644 backend/migrations/006_calendar_item_chat_messages.sql create mode 100644 backend/migrations/007_chat_message_features.sql diff --git a/.env.example b/.env.example index e46572c..05d4774 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,5 @@ APP_ENV=production -APP_PUBLIC_URL=https://app.example.com.br +APP_PUBLIC_URL=http://localhost:5173 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=15 +JWT_ACCESS_TOKEN_TTL_MINUTES=480 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.example.com.br -SMTP_PORT=587 +SMTP_HOST= +SMTP_PORT= SMTP_USER= -SMTP_PASSWORD= -SMTP_FROM_EMAIL=no-reply@example.com.br +SMTP_PASS= +MAIL_FROM= SMTP_FROM_NAME=Mira UPLOAD_MAX_SIZE_MB=10 -UPLOAD_ALLOWED_MIME_TYPES=image/jpeg,image/png,image/webp,application/pdf +UPLOAD_ALLOWED_MIME_TYPES=image/jpeg,image/png,image/webp,application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document 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 new file mode 100644 index 0000000..518e0f4 --- /dev/null +++ b/backend/internal/auth/rate_limit.go @@ -0,0 +1,68 @@ +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 1fd94ed..8ae9853 100644 --- a/backend/internal/auth/routes.go +++ b/backend/internal/auth/routes.go @@ -4,35 +4,40 @@ 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 - tokens TokenService + users users.Store + sessions SessionStore + tokens TokenService + refreshTTL time.Duration + secureCookies bool + loginRateLimiter *LoginRateLimiter } -func NewRoutes(users users.Store, tokens TokenService) Routes { +func NewRoutes(users users.Store, sessions SessionStore, tokens TokenService, refreshTTL time.Duration, secureCookies bool) Routes { return Routes{ - users: users, - tokens: tokens, + users: users, + sessions: sessions, + tokens: tokens, + refreshTTL: refreshTTL, + secureCookies: secureCookies, + loginRateLimiter: NewLoginRateLimiter(5, 15*time.Minute), } } func (r Routes) Register(router *gin.RouterGroup) { router.POST("/login", r.login) router.POST("/invitations/accept", r.acceptInvitation) - - 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."}) - }) + router.POST("/logout", r.logout) + router.POST("/refresh", r.refresh) } type loginRequest struct { @@ -47,8 +52,16 @@ func (r Routes) login(c *gin.Context) { return } - user, err := r.users.FindByEmail(c.Request.Context(), strings.TrimSpace(input.Email)) + 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) if errors.Is(err, users.ErrNotFound) { + r.loginRateLimiter.RecordFailure(rateLimitKey) c.JSON(http.StatusUnauthorized, gin.H{"message": "E-mail ou senha invalidos."}) return } @@ -57,15 +70,17 @@ 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.tokens.IssueAccessToken(user.ID, user.Email, user.Role) + token, err := r.createSession(c, user) 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, @@ -114,15 +129,8 @@ 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{ - "access_token": token, - "token_type": "Bearer", + "message": "Senha criada com sucesso. Faca login para acessar o Mira.", "user": gin.H{ "id": user.ID, "email": user.Email, @@ -132,3 +140,84 @@ 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 new file mode 100644 index 0000000..750a3fb --- /dev/null +++ b/backend/internal/auth/sessions.go @@ -0,0 +1,112 @@ +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 ed7bf6f..3506bdd 100644 --- a/backend/internal/calendar/attachments.go +++ b/backend/internal/calendar/attachments.go @@ -137,6 +137,26 @@ 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 new file mode 100644 index 0000000..08c7882 --- /dev/null +++ b/backend/internal/calendar/chat.go @@ -0,0 +1,377 @@ +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 new file mode 100644 index 0000000..b2f1168 --- /dev/null +++ b/backend/internal/calendar/chat_events.go @@ -0,0 +1,55 @@ +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 7e8b7a9..af6dcae 100644 --- a/backend/internal/calendar/routes.go +++ b/backend/internal/calendar/routes.go @@ -22,6 +22,7 @@ type Routes struct { store Store provider Provider commemorative CommemorativeProvider + chatEvents *ChatEventHub uploadMaxSizeBytes int64 uploadAllowedMIMEs map[string]bool storageDriver string @@ -54,6 +55,7 @@ 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, @@ -80,7 +82,15 @@ 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) { @@ -604,6 +614,376 @@ 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) { @@ -611,75 +991,23 @@ func (r Routes) uploadAttachment(c *gin.Context) { return } - c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, r.uploadMaxSizeBytes) - file, header, err := c.Request.FormFile("file") + upload, err := r.saveUploadedFile(c, c.Param("itemId")) 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: filepath.Base(header.Filename), - StoredFilename: storedFilename, - MimeType: mimeType, - SizeBytes: sizeBytes, + OriginalFilename: upload.originalFilename, + StoredFilename: upload.storedFilename, + MimeType: upload.mimeType, + SizeBytes: upload.sizeBytes, StorageDriver: r.storageDriver, - StoragePath: storagePath, + StoragePath: upload.storagePath, CreatedBy: claims.UserID, }) if err != nil { - _ = os.Remove(storagePath) + _ = os.Remove(upload.storagePath) c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel registrar o anexo."}) return } @@ -694,6 +1022,96 @@ 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 { @@ -721,6 +1139,37 @@ 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 { @@ -738,3 +1187,12 @@ 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 d9d6dc9..a7a2268 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", 15), + AccessTokenTTL: minutes("JWT_ACCESS_TOKEN_TTL_MINUTES", 480), 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"), + UploadAllowedMIMEs: list("UPLOAD_ALLOWED_MIME_TYPES", "image/jpeg,image/png,image/webp,application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document"), 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: env("SMTP_PASSWORD", ""), - SMTPFromEmail: env("SMTP_FROM_EMAIL", ""), + SMTPPassword: envAlias("SMTP_PASS", "SMTP_PASSWORD", ""), + SMTPFromEmail: envAlias("MAIL_FROM", "SMTP_FROM_EMAIL", ""), SMTPFromName: env("SMTP_FROM_NAME", "Mira"), } } @@ -76,6 +76,14 @@ 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 eddfcb4..84cd258 100644 --- a/backend/internal/httpserver/server.go +++ b/backend/internal/httpserver/server.go @@ -9,6 +9,7 @@ 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" @@ -39,8 +40,17 @@ func New(cfg config.Config, logger *slog.Logger, db *database.DB, userStore user tokenService := auth.NewTokenService(cfg.JWTSecret, cfg.AccessTokenTTL) requireAuth := auth.RequireAuth(tokenService) - authRoutes := auth.NewRoutes(userStore, tokenService) - userRoutes := users.NewRoutes(userStore, cfg.AppPublicURL) + 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) 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 new file mode 100644 index 0000000..1b7833e --- /dev/null +++ b/backend/internal/mail/smtp.go @@ -0,0 +1,150 @@ +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 13e908c..ae3e98d 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'; frame-ancestors 'none'") + c.Header("Content-Security-Policy", "default-src 'self'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'") 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 63415d1..20ce8ff 100644 --- a/backend/internal/users/invitations.go +++ b/backend/internal/users/invitations.go @@ -25,6 +25,8 @@ 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 28134d2..84374b5 100644 --- a/backend/internal/users/routes.go +++ b/backend/internal/users/routes.go @@ -5,18 +5,22 @@ 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) Routes { +func NewRoutes(store Store, appPublicURL string, mailSender mail.Sender) Routes { return Routes{ store: store, appPublicURL: strings.TrimRight(appPublicURL, "/"), + mailSender: mailSender, } } @@ -106,6 +110,14 @@ 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 new file mode 100644 index 0000000..05ec6fc --- /dev/null +++ b/backend/migrations/005_refresh_tokens.sql @@ -0,0 +1,11 @@ +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 new file mode 100644 index 0000000..ab34901 --- /dev/null +++ b/backend/migrations/006_calendar_item_chat_messages.sql @@ -0,0 +1,11 @@ +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 new file mode 100644 index 0000000..64f487b --- /dev/null +++ b/backend/migrations/007_chat_message_features.sql @@ -0,0 +1,19 @@ +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 db8ced4..bceb764 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_PASSWORD: ${SMTP_PASSWORD} - SMTP_FROM_EMAIL: ${SMTP_FROM_EMAIL} + SMTP_PASS: ${SMTP_PASS} + MAIL_FROM: ${MAIL_FROM} 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 a3c8e9b..9a37058 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -9,6 +9,7 @@ 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 c3d32e4..2deda89 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -10,21 +10,63 @@ 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, type AuthUser } from "@/lib/auth"; +import { clearStoredToken, getStoredToken, loadCurrentUser, logout, refreshSession, 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(null); + const [selectedClientId, setSelectedClientId] = useState(storedAppState.selectedClientId); const [isCheckingSession, setIsCheckingSession] = useState(true); const [clientsError, setClientsError] = useState(""); - const [currentView, setCurrentView] = useState("dashboard"); // 'dashboard' | 'calendar' | 'users' - const [selectedPostId, setSelectedPostId] = useState(null); + const [currentView, setCurrentView] = useState(storedAppState.currentView); + const [selectedPostId, setSelectedPostId] = useState(storedAppState.selectedPostId); 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; @@ -45,7 +87,16 @@ export default function App() { const token = getStoredToken(); if (!token) { - setIsCheckingSession(false); + refreshSession() + .then((response) => { + setUser(response.user); + return refreshClients(response.access_token); + }) + .catch(() => { + clearStoredToken(); + setUser(null); + }) + .finally(() => setIsCheckingSession(false)); return; } @@ -55,14 +106,51 @@ export default function App() { return refreshClients(token); }) .catch(() => { - clearStoredToken(); - setUser(null); + return refreshSession() + .then((response) => { + setUser(response.user); + return refreshClients(response.access_token); + }) + .catch(() => { + 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() { - clearStoredToken(); + void logout(); + localStorage.removeItem(APP_STATE_STORAGE_KEY); setUser(null); setClients([]); setSelectedClientId(null); @@ -75,9 +163,9 @@ export default function App() { void refreshClients(); } - function handleInvitationAccepted(loadedUser: AuthUser) { - setUser(loadedUser); - void refreshClients(); + function handleInvitationAccepted() { + clearStoredToken(); + setUser(null); } function handleClientCreated(client: Client) { @@ -166,9 +254,14 @@ export default function App() {
{currentView === "dashboard" && ( - + )} - {currentView === "clients" && ( + {!isClientViewer && currentView === "clients" && ( )} - {currentView === "client-dashboard" && selectedClientId && ( + {!isClientViewer && currentView === "client-dashboard" && selectedClientId && ( client.id === selectedClientId) ?? null} selectedClientId={selectedClientId} @@ -186,12 +279,12 @@ export default function App() { /> )} {currentView === "post-detail" && selectedPostId && ( - setCurrentView("calendar")} /> + setCurrentView("calendar")} user={user} /> )} {currentView === "calendar" && ( )} - {currentView === "users" && } + {!isClientViewer && currentView === "users" && }
diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index 2c9fbff..c59bc4c 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -21,12 +21,18 @@ type NavProps = { export function Sidebar({ currentView, onViewChange, user, onLogout, clients, selectedClientId, onClientSelect, clientsError }: NavProps) { const [clientSearch, setClientSearch] = useState(""); - 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 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 filteredClients = useMemo(() => { const term = clientSearch.trim().toLowerCase(); @@ -43,20 +49,24 @@ export function Sidebar({ currentView, onViewChange, user, onLogout, clients, se Mira -
-
- - setClientSearch(event.target.value)} - /> + {!isClientViewer && ( +
+
+ + setClientSearch(event.target.value)} + /> +
-
+ )}
+ )}
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 7b644e0..84afcd1 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -15,6 +15,7 @@ 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(() => ({})); @@ -40,6 +41,7 @@ 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 37bc087..f14d798 100644 --- a/frontend/src/lib/auth.ts +++ b/frontend/src/lib/auth.ts @@ -16,20 +16,35 @@ type LoginResponse = { user: AuthUser; }; +type AcceptInvitationResponse = { + message: string; + user: AuthUser; +}; + type MeResponse = { user: AuthUser; }; export function getStoredToken() { - return localStorage.getItem(TOKEN_STORAGE_KEY); + return sessionStorage.getItem(TOKEN_STORAGE_KEY); } export function storeToken(token: string) { - localStorage.setItem(TOKEN_STORAGE_KEY, token); + localStorage.removeItem(TOKEN_STORAGE_KEY); + sessionStorage.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) { @@ -43,11 +58,19 @@ 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 a58817f..360e435 100644 --- a/frontend/src/lib/calendar.ts +++ b/frontend/src/lib/calendar.ts @@ -1,4 +1,4 @@ -import { API_BASE_URL, apiFormRequest, apiRequest } from "@/lib/api"; +import { API_BASE_URL, ApiError, apiFormRequest, apiRequest } from "@/lib/api"; export type Holiday = { id: string; @@ -51,6 +51,34 @@ 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; @@ -120,6 +148,18 @@ 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", @@ -251,6 +291,99 @@ 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 5eaa47c..7e84abe 100644 --- a/frontend/src/lib/users.ts +++ b/frontend/src/lib/users.ts @@ -11,6 +11,8 @@ 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 c20c985..abde313 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, type AuthUser } from "@/lib/auth"; +import { acceptInvitation } from "@/lib/auth"; type AcceptInvitationViewProps = { token: string; - onAccepted: (user: AuthUser) => void; + onAccepted: () => void; }; export function AcceptInvitationView({ token, onAccepted }: AcceptInvitationViewProps) { @@ -22,9 +22,10 @@ export function AcceptInvitationView({ token, onAccepted }: AcceptInvitationView setIsSubmitting(true); setError(""); try { - const response = await acceptInvitation(token, name, password); - onAccepted(response.user); + await acceptInvitation(token, name, password); + onAccepted(); window.history.replaceState({}, "", "/"); + window.location.assign("/"); } catch (err) { setError(err instanceof Error ? err.message : "Nรฃo foi possรญvel aceitar o convite."); } finally { @@ -77,7 +78,7 @@ export function AcceptInvitationView({ token, onAccepted }: AcceptInvitationView )} diff --git a/frontend/src/views/CalendarView.tsx b/frontend/src/views/CalendarView.tsx index bd4c306..a3734fb 100644 --- a/frontend/src/views/CalendarView.tsx +++ b/frontend/src/views/CalendarView.tsx @@ -50,6 +50,7 @@ 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", @@ -923,7 +924,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 6dd3535..f45da49 100644 --- a/frontend/src/views/DashboardView.tsx +++ b/frontend/src/views/DashboardView.tsx @@ -20,6 +20,7 @@ type DashboardViewProps = { title?: string; description?: string; compact?: boolean; + canCreatePost?: boolean; onViewChange: (view: string) => void; onPostSelect: (postId: string) => void; }; @@ -41,6 +42,7 @@ export function DashboardView({ title = "Dashboard Semanal", description, compact = false, + canCreatePost = true, onViewChange, onPostSelect, }: DashboardViewProps) { @@ -119,10 +121,12 @@ export function DashboardView({ Filtros - + {canCreatePost && ( + + )}
diff --git a/frontend/src/views/PostDetailView.tsx b/frontend/src/views/PostDetailView.tsx index b8d05e4..4d40213 100644 --- a/frontend/src/views/PostDetailView.tsx +++ b/frontend/src/views/PostDetailView.tsx @@ -1,25 +1,40 @@ -import { useEffect, useMemo, useState, type FormEvent } from "react"; -import { ArrowLeft, Download, FileText, Image as ImageIcon, Upload } from "lucide-react"; +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 { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; +import { Button, buttonVariants } 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 } from "@/lib/auth"; +import { getStoredToken, type AuthUser } from "@/lib/auth"; import { - attachmentDownloadURL, + createChatMessage, + deleteAttachment, + deleteChatMessage, + downloadAttachmentBlob, + downloadChatAttachmentBlob, 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 = { @@ -33,18 +48,185 @@ 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; -export function PostDetailView({ itemId, onBack }: PostDetailViewProps) { +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) { 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(); @@ -63,12 +245,206 @@ export function PostDetailView({ itemId, onBack }: 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 || !file) return; + if (!token) return; + if (!file) { + setAttachmentError("Selecione um arquivo antes de enviar."); + return; + } setIsUploading(true); setAttachmentError(""); @@ -83,6 +459,171 @@ export function PostDetailView({ itemId, onBack }: 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...

; } @@ -103,6 +644,159 @@ export function PostDetailView({ itemId, onBack }: 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 && ( + + )} +