first commit
All checks were successful
CI / backend (push) Successful in 13m19s
CI / frontend (push) Successful in 11m3s
CI / docker (push) Successful in 1m58s

This commit is contained in:
Cauê Faleiros
2026-06-03 16:31:42 -03:00
commit 8c7e5fbbe4
92 changed files with 18226 additions and 0 deletions

View File

@@ -0,0 +1,42 @@
package auth
import (
"context"
"errors"
"fmt"
"log/slog"
"strings"
"mira/backend/internal/config"
"mira/backend/internal/users"
)
func BootstrapSuperAdmin(ctx context.Context, cfg config.Config, store users.Store, logger *slog.Logger) error {
email := strings.TrimSpace(cfg.SuperAdminEmail)
password := cfg.SuperAdminPassword
if email == "" || password == "" {
if cfg.AppEnv == "production" {
return errors.New("SUPER_ADMIN_EMAIL and SUPER_ADMIN_PASSWORD are required in production")
}
logger.Warn("super admin bootstrap skipped because credentials are not configured")
return nil
}
if len(password) < 12 {
return fmt.Errorf("SUPER_ADMIN_PASSWORD must have at least 12 characters")
}
hash, err := HashPassword(password)
if err != nil {
return fmt.Errorf("hash super admin password: %w", err)
}
user, err := store.UpsertSuperAdmin(ctx, email, hash)
if err != nil {
return fmt.Errorf("upsert super admin: %w", err)
}
logger.Info("super admin bootstrapped", "user_id", user.ID, "email", user.Email)
return nil
}

View File

@@ -0,0 +1,43 @@
package auth
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
)
const ContextClaimsKey = "auth_claims"
const ContextUserIDKey = "auth_user_id"
const ContextRoleKey = "auth_role"
func RequireAuth(tokens TokenService) gin.HandlerFunc {
return func(c *gin.Context) {
header := c.GetHeader("Authorization")
if header == "" || !strings.HasPrefix(header, "Bearer ") {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"message": "Autenticacao obrigatoria."})
return
}
claims, err := tokens.ParseAccessToken(strings.TrimPrefix(header, "Bearer "))
if err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"message": "Sessao invalida ou expirada."})
return
}
c.Set(ContextClaimsKey, claims)
c.Set(ContextUserIDKey, claims.UserID)
c.Set(ContextRoleKey, claims.Role)
c.Next()
}
}
func ClaimsFromContext(c *gin.Context) (Claims, bool) {
value, ok := c.Get(ContextClaimsKey)
if !ok {
return Claims{}, false
}
claims, ok := value.(Claims)
return claims, ok
}

View File

@@ -0,0 +1,12 @@
package auth
import "golang.org/x/crypto/bcrypt"
func HashPassword(password string) (string, error) {
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
return string(hash), err
}
func CheckPassword(password string, hash string) bool {
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
}

View File

@@ -0,0 +1,134 @@
package auth
import (
"errors"
"net/http"
"strings"
"mira/backend/internal/users"
"github.com/gin-gonic/gin"
)
type Routes struct {
users users.Store
tokens TokenService
}
func NewRoutes(users users.Store, tokens TokenService) Routes {
return Routes{
users: users,
tokens: tokens,
}
}
func (r Routes) Register(router *gin.RouterGroup) {
router.POST("/login", r.login)
router.POST("/invitations/accept", r.acceptInvitation)
router.POST("/logout", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "Sessao encerrada."})
})
router.POST("/refresh", func(c *gin.Context) {
c.JSON(http.StatusNotImplemented, gin.H{"message": "Renovacao de sessao ainda nao implementada."})
})
}
type loginRequest struct {
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required"`
}
func (r Routes) login(c *gin.Context) {
var input loginRequest
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"message": "Informe e-mail e senha validos."})
return
}
user, err := r.users.FindByEmail(c.Request.Context(), strings.TrimSpace(input.Email))
if errors.Is(err, users.ErrNotFound) {
c.JSON(http.StatusUnauthorized, gin.H{"message": "E-mail ou senha invalidos."})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel fazer login."})
return
}
if !CheckPassword(input.Password, user.PasswordHash) || user.Status != "active" {
c.JSON(http.StatusUnauthorized, gin.H{"message": "E-mail ou senha invalidos."})
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",
"user": gin.H{
"id": user.ID,
"email": user.Email,
"name": user.Name,
"role": user.Role,
"status": user.Status,
},
})
}
type acceptInvitationRequest struct {
Token string `json:"token" binding:"required"`
Name string `json:"name" binding:"required,min=2"`
Password string `json:"password" binding:"required,min=12"`
}
func (r Routes) acceptInvitation(c *gin.Context) {
var input acceptInvitationRequest
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"message": "Informe nome, senha e token validos."})
return
}
passwordHash, err := HashPassword(input.Password)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel proteger a senha."})
return
}
user, err := r.users.AcceptInvitation(
c.Request.Context(),
users.HashInvitationToken(strings.TrimSpace(input.Token)),
strings.TrimSpace(input.Name),
passwordHash,
)
if errors.Is(err, users.ErrInvitationNotFound) {
c.JSON(http.StatusBadRequest, gin.H{"message": "Convite invalido ou expirado."})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel aceitar o convite."})
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",
"user": gin.H{
"id": user.ID,
"email": user.Email,
"name": user.Name,
"role": user.Role,
"status": user.Status,
},
})
}

View File

@@ -0,0 +1,70 @@
package auth
import (
"errors"
"time"
"github.com/golang-jwt/jwt/v5"
)
type Claims struct {
UserID string `json:"user_id"`
Email string `json:"email"`
Role string `json:"role"`
jwt.RegisteredClaims
}
type TokenService struct {
secret []byte
accessTTL time.Duration
}
func NewTokenService(secret string, accessTTL time.Duration) TokenService {
return TokenService{
secret: []byte(secret),
accessTTL: accessTTL,
}
}
func (s TokenService) IssueAccessToken(userID string, email string, role string) (string, error) {
if len(s.secret) == 0 {
return "", errors.New("jwt secret is not configured")
}
now := time.Now()
claims := Claims{
UserID: userID,
Email: email,
Role: role,
RegisteredClaims: jwt.RegisteredClaims{
Subject: userID,
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(now.Add(s.accessTTL)),
},
}
return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(s.secret)
}
func (s TokenService) ParseAccessToken(tokenValue string) (Claims, error) {
if len(s.secret) == 0 {
return Claims{}, errors.New("jwt secret is not configured")
}
token, err := jwt.ParseWithClaims(tokenValue, &Claims{}, func(token *jwt.Token) (interface{}, error) {
if token.Method != jwt.SigningMethodHS256 {
return nil, errors.New("unexpected signing method")
}
return s.secret, nil
})
if err != nil {
return Claims{}, err
}
claims, ok := token.Claims.(*Claims)
if !ok || !token.Valid {
return Claims{}, errors.New("invalid token")
}
return *claims, nil
}

View File

@@ -0,0 +1,165 @@
package calendar
import (
"context"
"errors"
"time"
"github.com/jackc/pgx/v5"
)
var ErrAttachmentNotFound = errors.New("attachment not found")
type Attachment struct {
ID string `json:"id"`
CalendarItemID string `json:"calendar_item_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 CreateAttachmentInput struct {
CalendarItemID string
OriginalFilename string
StoredFilename string
MimeType string
SizeBytes int64
StorageDriver string
StoragePath string
CreatedBy string
}
func (s Store) CreateAttachment(ctx context.Context, input CreateAttachmentInput) (Attachment, error) {
row := s.db.QueryRow(ctx, `
INSERT INTO calendar_item_attachments (
calendar_item_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,
calendar_item_id::text,
original_filename,
stored_filename,
mime_type,
size_bytes,
storage_driver,
storage_path,
created_by::text,
created_at
`, input.CalendarItemID, input.OriginalFilename, input.StoredFilename, input.MimeType, input.SizeBytes, input.StorageDriver, input.StoragePath, input.CreatedBy)
return scanAttachment(row)
}
func (s Store) ListAttachments(ctx context.Context, calendarItemID string, viewerUserID string) ([]Attachment, error) {
rows, err := s.db.Query(ctx, `
SELECT
calendar_item_attachments.id::text,
calendar_item_attachments.calendar_item_id::text,
calendar_item_attachments.original_filename,
calendar_item_attachments.stored_filename,
calendar_item_attachments.mime_type,
calendar_item_attachments.size_bytes,
calendar_item_attachments.storage_driver,
calendar_item_attachments.storage_path,
calendar_item_attachments.created_by::text,
calendar_item_attachments.created_at
FROM calendar_item_attachments
INNER JOIN calendar_items ON calendar_items.id = calendar_item_attachments.calendar_item_id
WHERE calendar_item_attachments.calendar_item_id = $1::uuid
AND (
$2 = ''
OR EXISTS (
SELECT 1
FROM client_users
WHERE client_users.client_id = calendar_items.client_id
AND client_users.user_id = $2::uuid
)
)
ORDER BY calendar_item_attachments.created_at DESC
`, calendarItemID, viewerUserID)
if err != nil {
return nil, err
}
defer rows.Close()
attachments := []Attachment{}
for rows.Next() {
attachment, err := scanAttachment(rows)
if err != nil {
return nil, err
}
attachments = append(attachments, attachment)
}
return attachments, rows.Err()
}
func (s Store) FindAttachment(ctx context.Context, id string, viewerUserID string) (Attachment, error) {
row := s.db.QueryRow(ctx, `
SELECT
calendar_item_attachments.id::text,
calendar_item_attachments.calendar_item_id::text,
calendar_item_attachments.original_filename,
calendar_item_attachments.stored_filename,
calendar_item_attachments.mime_type,
calendar_item_attachments.size_bytes,
calendar_item_attachments.storage_driver,
calendar_item_attachments.storage_path,
calendar_item_attachments.created_by::text,
calendar_item_attachments.created_at
FROM calendar_item_attachments
INNER JOIN calendar_items ON calendar_items.id = calendar_item_attachments.calendar_item_id
WHERE calendar_item_attachments.id = $1::uuid
AND (
$2 = ''
OR 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 scanAttachment(row)
}
type attachmentScanner interface {
Scan(dest ...any) error
}
func scanAttachment(row attachmentScanner) (Attachment, error) {
var attachment Attachment
err := row.Scan(
&attachment.ID,
&attachment.CalendarItemID,
&attachment.OriginalFilename,
&attachment.StoredFilename,
&attachment.MimeType,
&attachment.SizeBytes,
&attachment.StorageDriver,
&attachment.StoragePath,
&attachment.CreatedBy,
&attachment.CreatedAt,
)
if errors.Is(err, pgx.ErrNoRows) {
return Attachment{}, ErrAttachmentNotFound
}
if err != nil {
return Attachment{}, err
}
return attachment, nil
}

View File

@@ -0,0 +1,23 @@
package calendar
import (
"context"
"encoding/json"
)
func (s Store) RecordAudit(ctx context.Context, actorUserID string, action string, entityType string, entityID string, metadata map[string]any) error {
payload := "{}"
if metadata != nil {
bytes, err := json.Marshal(metadata)
if err != nil {
return err
}
payload = string(bytes)
}
_, err := s.db.Exec(ctx, `
INSERT INTO audit_logs (actor_user_id, action, entity_type, entity_id, metadata)
VALUES (NULLIF($1, '')::uuid, $2, $3, NULLIF($4, '')::uuid, $5::jsonb)
`, actorUserID, action, entityType, entityID, payload)
return err
}

View File

@@ -0,0 +1,248 @@
package calendar
import (
"context"
"errors"
"time"
"github.com/jackc/pgx/v5"
)
var ErrCustomDateNotFound = errors.New("custom date not found")
type CustomDate struct {
ID string `json:"id"`
ClientID *string `json:"client_id"`
Name string `json:"name"`
Description string `json:"description"`
Date string `json:"date"`
RecursAnnually bool `json:"recurs_annually"`
Visibility string `json:"visibility"`
Category string `json:"category"`
CreatedBy *string `json:"created_by"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (s Store) ListCustomDatesByYear(ctx context.Context, year int, clientID string, viewerUserID string) ([]CustomDate, error) {
rows, err := s.db.Query(ctx, `
SELECT
id::text,
client_id::text,
name,
description,
CASE
WHEN recurs_annually THEN make_date(
$1,
EXTRACT(MONTH FROM date)::int,
LEAST(
EXTRACT(DAY FROM date)::int,
EXTRACT(DAY FROM (date_trunc('month', make_date($1, EXTRACT(MONTH FROM date)::int, 1)) + interval '1 month - 1 day'))::int
)
)::text
ELSE date::text
END,
recurs_annually,
visibility,
category,
created_by::text,
created_at,
updated_at
FROM commemorative_dates
WHERE (
(date >= make_date($1, 1, 1) AND date < make_date($1 + 1, 1, 1))
OR (recurs_annually AND date < make_date($1 + 1, 1, 1))
)
AND ($2 = '' OR client_id IS NULL OR client_id::text = $2)
AND (
$3 = ''
OR (
visibility = 'client'
AND (
client_id IS NULL
OR EXISTS (
SELECT 1
FROM client_users
WHERE client_users.client_id = commemorative_dates.client_id
AND client_users.user_id = $3::uuid
)
)
)
)
ORDER BY date ASC, name ASC
`, year, clientID, viewerUserID)
if err != nil {
return nil, err
}
defer rows.Close()
dates := []CustomDate{}
for rows.Next() {
date, err := scanCustomDate(rows)
if err != nil {
return nil, err
}
dates = append(dates, date)
}
return dates, rows.Err()
}
func (s Store) CreateCustomDate(ctx context.Context, input CreateCustomDateInput) (CustomDate, error) {
row := s.db.QueryRow(ctx, `
INSERT INTO commemorative_dates (
client_id,
name,
description,
date,
recurs_annually,
visibility,
category,
created_by
)
VALUES (NULLIF($1, '')::uuid, $2, $3, $4, $5, $6, $7, NULLIF($8, '')::uuid)
RETURNING
id::text,
client_id::text,
name,
description,
date::text,
recurs_annually,
visibility,
category,
created_by::text,
created_at,
updated_at
`,
input.ClientID,
input.Name,
input.Description,
input.Date,
input.RecursAnnually,
input.Visibility,
input.Category,
input.CreatedBy,
)
return scanCustomDate(row)
}
type UpdateCustomDateInput struct {
ID string
ClientID string
Name string
Description string
Date string
RecursAnnually bool
Visibility string
}
func (s Store) UpdateCustomDate(ctx context.Context, input UpdateCustomDateInput) (CustomDate, error) {
row := s.db.QueryRow(ctx, `
UPDATE commemorative_dates
SET
client_id = NULLIF($2, '')::uuid,
name = $3,
description = $4,
date = $5::date,
recurs_annually = $6,
visibility = $7,
updated_at = now()
WHERE id = $1::uuid
AND category != 'feriados-brasil'
RETURNING
id::text,
client_id::text,
name,
description,
date::text,
recurs_annually,
visibility,
category,
created_by::text,
created_at,
updated_at
`, input.ID, input.ClientID, input.Name, input.Description, input.Date, input.RecursAnnually, input.Visibility)
return scanCustomDate(row)
}
func (s Store) DeleteCustomDate(ctx context.Context, id string) error {
command, err := s.db.Exec(ctx, `
DELETE FROM commemorative_dates
WHERE id = $1::uuid
AND category != 'feriados-brasil'
`, id)
if err != nil {
return err
}
if command.RowsAffected() == 0 {
return ErrCustomDateNotFound
}
return nil
}
func (s Store) UpsertExternalCommemorativeDates(ctx context.Context, dates []ExternalCommemorativeDate) error {
for _, date := range dates {
if date.Name == "" || date.Date == "" {
continue
}
if _, err := s.db.Exec(ctx, `
INSERT INTO commemorative_dates (
client_id,
name,
description,
date,
recurs_annually,
visibility,
category
)
VALUES (NULL, $1, $2, $3::date, false, 'client', 'feriados-brasil')
ON CONFLICT DO NOTHING
`, date.Name, date.Description, date.Date); err != nil {
return err
}
}
return nil
}
type CreateCustomDateInput struct {
ClientID string
Name string
Description string
Date string
RecursAnnually bool
Visibility string
Category string
CreatedBy string
}
type customDateScanner interface {
Scan(dest ...any) error
}
func scanCustomDate(row customDateScanner) (CustomDate, error) {
var date CustomDate
err := row.Scan(
&date.ID,
&date.ClientID,
&date.Name,
&date.Description,
&date.Date,
&date.RecursAnnually,
&date.Visibility,
&date.Category,
&date.CreatedBy,
&date.CreatedAt,
&date.UpdatedAt,
)
if errors.Is(err, pgx.ErrNoRows) {
return CustomDate{}, ErrCustomDateNotFound
}
if err != nil {
return CustomDate{}, err
}
return date, nil
}

View File

@@ -0,0 +1,329 @@
package calendar
import (
"context"
"errors"
"time"
"github.com/jackc/pgx/v5"
)
var ErrCalendarItemNotFound = errors.New("calendar item not found")
type CalendarItem struct {
ID string `json:"id"`
ClientID string `json:"client_id"`
ClientName string `json:"client_name"`
Title string `json:"title"`
Description string `json:"description"`
ContentType string `json:"content_type"`
Status string `json:"status"`
OwnerID *string `json:"owner_id"`
ScheduledDate string `json:"scheduled_date"`
ScheduledAt *time.Time `json:"scheduled_at"`
CopyText string `json:"copy_text"`
InternalNotes string `json:"internal_notes"`
ClientNotes string `json:"client_notes"`
CreatedBy *string `json:"created_by"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type ListCalendarItemsFilter struct {
ClientID string
ViewerUserID string
From string
To string
}
func (s Store) ListCalendarItems(ctx context.Context, filter ListCalendarItemsFilter) ([]CalendarItem, error) {
rows, err := s.db.Query(ctx, `
SELECT
calendar_items.id::text,
calendar_items.client_id::text,
clients.name,
calendar_items.title,
calendar_items.description,
calendar_items.content_type,
calendar_items.status,
calendar_items.owner_id::text,
calendar_items.scheduled_date::text,
calendar_items.scheduled_at,
calendar_items.copy_text,
calendar_items.internal_notes,
calendar_items.client_notes,
calendar_items.created_by::text,
calendar_items.created_at,
calendar_items.updated_at
FROM calendar_items
INNER JOIN clients ON clients.id = calendar_items.client_id
WHERE calendar_items.scheduled_date >= $1::date
AND calendar_items.scheduled_date <= $2::date
AND ($3 = '' OR calendar_items.client_id::text = $3)
AND (
$4 = ''
OR EXISTS (
SELECT 1
FROM client_users
WHERE client_users.client_id = calendar_items.client_id
AND client_users.user_id = $4::uuid
)
)
ORDER BY calendar_items.scheduled_date ASC, calendar_items.created_at ASC
`, filter.From, filter.To, filter.ClientID, filter.ViewerUserID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []CalendarItem{}
for rows.Next() {
item, err := scanCalendarItem(rows)
if err != nil {
return nil, err
}
items = append(items, item)
}
return items, rows.Err()
}
func (s Store) FindCalendarItem(ctx context.Context, id string, viewerUserID string) (CalendarItem, error) {
row := s.db.QueryRow(ctx, `
SELECT
calendar_items.id::text,
calendar_items.client_id::text,
clients.name,
calendar_items.title,
calendar_items.description,
calendar_items.content_type,
calendar_items.status,
calendar_items.owner_id::text,
calendar_items.scheduled_date::text,
calendar_items.scheduled_at,
calendar_items.copy_text,
calendar_items.internal_notes,
calendar_items.client_notes,
calendar_items.created_by::text,
calendar_items.created_at,
calendar_items.updated_at
FROM calendar_items
INNER JOIN clients ON clients.id = calendar_items.client_id
WHERE calendar_items.id = $1::uuid
AND (
$2 = ''
OR 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 scanCalendarItem(row)
}
type CreateCalendarItemInput struct {
ClientID string
Title string
Description string
ContentType string
Status string
ScheduledDate string
CopyText string
InternalNotes string
ClientNotes string
CreatedBy string
}
func (s Store) CreateCalendarItem(ctx context.Context, input CreateCalendarItemInput) (CalendarItem, error) {
row := s.db.QueryRow(ctx, `
INSERT INTO calendar_items (
client_id,
title,
description,
content_type,
status,
scheduled_date,
copy_text,
internal_notes,
client_notes,
created_by
)
VALUES ($1::uuid, $2, $3, $4, $5, $6::date, $7, $8, $9, NULLIF($10, '')::uuid)
RETURNING
calendar_items.id::text,
calendar_items.client_id::text,
(SELECT name FROM clients WHERE clients.id = calendar_items.client_id),
calendar_items.title,
calendar_items.description,
calendar_items.content_type,
calendar_items.status,
calendar_items.owner_id::text,
calendar_items.scheduled_date::text,
calendar_items.scheduled_at,
calendar_items.copy_text,
calendar_items.internal_notes,
calendar_items.client_notes,
calendar_items.created_by::text,
calendar_items.created_at,
calendar_items.updated_at
`,
input.ClientID,
input.Title,
input.Description,
input.ContentType,
input.Status,
input.ScheduledDate,
input.CopyText,
input.InternalNotes,
input.ClientNotes,
input.CreatedBy,
)
return scanCalendarItem(row)
}
type UpdateCalendarItemInput struct {
ID string
Title string
UpdateTitle bool
Description string
UpdateDescription bool
Status string
UpdateStatus bool
ScheduledDate string
UpdateScheduledDate bool
CopyText string
UpdateCopyText bool
InternalNotes string
UpdateInternalNotes bool
ClientNotes string
UpdateClientNotes bool
}
func (s Store) UpdateCalendarItem(ctx context.Context, input UpdateCalendarItemInput) (CalendarItem, error) {
row := s.db.QueryRow(ctx, `
UPDATE calendar_items
SET
title = CASE WHEN $2 THEN $3 ELSE title END,
description = CASE WHEN $4 THEN $5 ELSE description END,
status = CASE WHEN $6 THEN $7 ELSE status END,
scheduled_date = CASE WHEN $8 THEN NULLIF($9, '')::date ELSE scheduled_date END,
copy_text = CASE WHEN $10 THEN $11 ELSE copy_text END,
internal_notes = CASE WHEN $12 THEN $13 ELSE internal_notes END,
client_notes = CASE WHEN $14 THEN $15 ELSE client_notes END,
updated_at = now()
WHERE id = $1::uuid
RETURNING
calendar_items.id::text,
calendar_items.client_id::text,
(SELECT name FROM clients WHERE clients.id = calendar_items.client_id),
calendar_items.title,
calendar_items.description,
calendar_items.content_type,
calendar_items.status,
calendar_items.owner_id::text,
calendar_items.scheduled_date::text,
calendar_items.scheduled_at,
calendar_items.copy_text,
calendar_items.internal_notes,
calendar_items.client_notes,
calendar_items.created_by::text,
calendar_items.created_at,
calendar_items.updated_at
`,
input.ID,
input.UpdateTitle,
input.Title,
input.UpdateDescription,
input.Description,
input.UpdateStatus,
input.Status,
input.UpdateScheduledDate,
input.ScheduledDate,
input.UpdateCopyText,
input.CopyText,
input.UpdateInternalNotes,
input.InternalNotes,
input.UpdateClientNotes,
input.ClientNotes,
)
return scanCalendarItem(row)
}
func (s Store) CancelCalendarItem(ctx context.Context, id string) (CalendarItem, error) {
row := s.db.QueryRow(ctx, `
UPDATE calendar_items
SET status = 'cancelado', updated_at = now()
WHERE id = $1::uuid
RETURNING
calendar_items.id::text,
calendar_items.client_id::text,
(SELECT name FROM clients WHERE clients.id = calendar_items.client_id),
calendar_items.title,
calendar_items.description,
calendar_items.content_type,
calendar_items.status,
calendar_items.owner_id::text,
calendar_items.scheduled_date::text,
calendar_items.scheduled_at,
calendar_items.copy_text,
calendar_items.internal_notes,
calendar_items.client_notes,
calendar_items.created_by::text,
calendar_items.created_at,
calendar_items.updated_at
`, id)
return scanCalendarItem(row)
}
func (s Store) DeleteCalendarItem(ctx context.Context, id string) error {
command, err := s.db.Exec(ctx, `
DELETE FROM calendar_items
WHERE id = $1::uuid
`, id)
if err != nil {
return err
}
if command.RowsAffected() == 0 {
return ErrCalendarItemNotFound
}
return nil
}
type calendarItemScanner interface {
Scan(dest ...any) error
}
func scanCalendarItem(row calendarItemScanner) (CalendarItem, error) {
var item CalendarItem
err := row.Scan(
&item.ID,
&item.ClientID,
&item.ClientName,
&item.Title,
&item.Description,
&item.ContentType,
&item.Status,
&item.OwnerID,
&item.ScheduledDate,
&item.ScheduledAt,
&item.CopyText,
&item.InternalNotes,
&item.ClientNotes,
&item.CreatedBy,
&item.CreatedAt,
&item.UpdatedAt,
)
if errors.Is(err, pgx.ErrNoRows) {
return CalendarItem{}, ErrCalendarItemNotFound
}
if err != nil {
return CalendarItem{}, err
}
return item, nil
}

View File

@@ -0,0 +1,136 @@
package calendar
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
)
type Holiday struct {
Date string `json:"date"`
Name string `json:"name"`
Type string `json:"type"`
}
type Provider interface {
NationalHolidays(ctx context.Context, year int) ([]Holiday, error)
}
type CommemorativeProvider interface {
CommemorativeDates(ctx context.Context, year int) ([]ExternalCommemorativeDate, error)
}
type ExternalCommemorativeDate struct {
Date string
Name string
Description string
SourceID string
}
type BrasilAPIProvider struct {
baseURL string
client *http.Client
}
type FeriadosBrasilProvider struct {
baseURL string
client *http.Client
}
func NewFeriadosBrasilProvider() FeriadosBrasilProvider {
return FeriadosBrasilProvider{
baseURL: "https://raw.githubusercontent.com/joaopbini/feriados-brasil/master/dados/comemorativas/json",
client: &http.Client{
Timeout: 10 * time.Second,
},
}
}
type feriadosBrasilCommemorativeDate struct {
ID string `json:"id"`
Data string `json:"data"`
Nome string `json:"nome"`
Tipo string `json:"tipo"`
Descricao string `json:"descricao"`
UF string `json:"uf"`
CodigoIBGE *int `json:"codigo_ibge"`
}
func (p FeriadosBrasilProvider) CommemorativeDates(ctx context.Context, year int) ([]ExternalCommemorativeDate, error) {
url := fmt.Sprintf("%s/%d.json", p.baseURL, year)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
resp, err := p.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("feriados-brasil returned status %d", resp.StatusCode)
}
var records []feriadosBrasilCommemorativeDate
if err := json.NewDecoder(resp.Body).Decode(&records); err != nil {
return nil, err
}
dates := make([]ExternalCommemorativeDate, 0, len(records))
for _, record := range records {
if strings.ToUpper(strings.TrimSpace(record.Tipo)) != "COMEMORATIVA" {
continue
}
parsed, err := time.Parse("02/01/2006", strings.TrimSpace(record.Data))
if err != nil {
continue
}
dates = append(dates, ExternalCommemorativeDate{
Date: parsed.Format("2006-01-02"),
Name: strings.TrimSpace(record.Nome),
Description: strings.TrimSpace(record.Descricao),
SourceID: strings.TrimSpace(record.ID),
})
}
return dates, nil
}
func NewBrasilAPIProvider(baseURL string) BrasilAPIProvider {
return BrasilAPIProvider{
baseURL: baseURL,
client: &http.Client{
Timeout: 10 * time.Second,
},
}
}
func (p BrasilAPIProvider) NationalHolidays(ctx context.Context, year int) ([]Holiday, error) {
url := fmt.Sprintf("%s/api/feriados/v1/%d", p.baseURL, year)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
resp, err := p.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("brasilapi returned status %d", resp.StatusCode)
}
var holidays []Holiday
if err := json.NewDecoder(resp.Body).Decode(&holidays); err != nil {
return nil, err
}
return holidays, nil
}

View File

@@ -0,0 +1,740 @@
package calendar
import (
"crypto/rand"
"encoding/hex"
"errors"
"io"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"mira/backend/internal/auth"
"mira/backend/internal/users"
"github.com/gin-gonic/gin"
)
type Routes struct {
store Store
provider Provider
commemorative CommemorativeProvider
uploadMaxSizeBytes int64
uploadAllowedMIMEs map[string]bool
storageDriver string
storageLocalPath string
}
type RouteOptions struct {
UploadMaxSizeMB int
UploadAllowedMIMEs []string
StorageDriver string
StorageLocalPath string
}
func NewRoutes(store Store, provider Provider, commemorative CommemorativeProvider, options RouteOptions) Routes {
allowedMIMEs := make(map[string]bool, len(options.UploadAllowedMIMEs))
for _, mime := range options.UploadAllowedMIMEs {
allowedMIMEs[strings.TrimSpace(mime)] = true
}
if options.UploadMaxSizeMB <= 0 {
options.UploadMaxSizeMB = 10
}
if options.StorageDriver == "" {
options.StorageDriver = "local"
}
if options.StorageLocalPath == "" {
options.StorageLocalPath = "/var/lib/mira/uploads"
}
return Routes{
store: store,
provider: provider,
commemorative: commemorative,
uploadMaxSizeBytes: int64(options.UploadMaxSizeMB) * 1024 * 1024,
uploadAllowedMIMEs: allowedMIMEs,
storageDriver: options.StorageDriver,
storageLocalPath: options.StorageLocalPath,
}
}
func (r Routes) Register(router *gin.RouterGroup, requireAuth gin.HandlerFunc) {
router.Use(requireAuth)
router.GET("/week", func(c *gin.Context) {
c.JSON(http.StatusNotImplemented, gin.H{"message": "Dashboard semanal ainda nao implementado."})
})
router.GET("/holidays", r.holidays)
router.GET("/custom-dates", r.customDates)
router.POST("/custom-dates", r.createCustomDate)
router.PATCH("/custom-dates/:customDateId", r.updateCustomDate)
router.DELETE("/custom-dates/:customDateId", r.deleteCustomDate)
router.GET("/items", r.calendarItems)
router.POST("/items", r.createCalendarItem)
router.GET("/items/:itemId", r.getCalendarItem)
router.PATCH("/items/:itemId", r.updateCalendarItem)
router.DELETE("/items/:itemId", r.cancelCalendarItem)
router.GET("/items/:itemId/attachments", r.listAttachments)
router.POST("/items/:itemId/attachments", r.uploadAttachment)
router.GET("/attachments/:attachmentId/download", r.downloadAttachment)
}
func (r Routes) holidays(c *gin.Context) {
year := time.Now().Year()
if value := c.Query("year"); value != "" {
parsed, err := strconv.Atoi(value)
if err != nil || parsed < 1900 || parsed > 2199 {
c.JSON(http.StatusBadRequest, gin.H{"message": "Ano invalido."})
return
}
year = parsed
}
holidays, err := r.store.ListHolidaysByYear(c.Request.Context(), year)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel carregar feriados."})
return
}
if len(holidays) == 0 {
imported, err := r.provider.NationalHolidays(c.Request.Context(), year)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"message": "Nao foi possivel consultar os feriados nacionais."})
return
}
if err := r.store.UpsertHolidays(c.Request.Context(), "brasilapi", imported); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel salvar feriados."})
return
}
holidays, err = r.store.ListHolidaysByYear(c.Request.Context(), year)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel carregar feriados."})
return
}
}
c.JSON(http.StatusOK, gin.H{"holidays": holidays})
}
func (r Routes) customDates(c *gin.Context) {
claims, ok := auth.ClaimsFromContext(c)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"message": "Autenticacao obrigatoria."})
return
}
year := time.Now().Year()
if value := c.Query("year"); value != "" {
parsed, err := strconv.Atoi(value)
if err != nil || parsed < 1900 || parsed > 2199 {
c.JSON(http.StatusBadRequest, gin.H{"message": "Ano invalido."})
return
}
year = parsed
}
viewerUserID := ""
if claims.Role == users.RoleClientViewer {
viewerUserID = claims.UserID
}
if r.commemorative != nil {
initialDates, err := r.store.ListCustomDatesByYear(c.Request.Context(), year, "", viewerUserID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel carregar datas personalizadas."})
return
}
hasImported := false
for _, date := range initialDates {
if date.Category == "feriados-brasil" {
hasImported = true
break
}
}
if !hasImported {
imported, err := r.commemorative.CommemorativeDates(c.Request.Context(), year)
if err == nil && len(imported) > 0 {
_ = r.store.UpsertExternalCommemorativeDates(c.Request.Context(), imported)
}
}
}
dates, err := r.store.ListCustomDatesByYear(c.Request.Context(), year, c.Query("client_id"), viewerUserID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel carregar datas personalizadas."})
return
}
c.JSON(http.StatusOK, gin.H{"custom_dates": dates})
}
type createCustomDateRequest struct {
ClientID string `json:"client_id"`
Name string `json:"name" binding:"required,min=2"`
Description string `json:"description"`
Date string `json:"date" binding:"required"`
RecursAnnually bool `json:"recurs_annually"`
Visibility string `json:"visibility"`
Category string `json:"category"`
}
func (r Routes) createCustomDate(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
}
var input createCustomDateRequest
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"message": "Informe os dados da data personalizada."})
return
}
if _, err := time.Parse("2006-01-02", input.Date); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"message": "Data invalida. Use o formato YYYY-MM-DD."})
return
}
visibility := strings.TrimSpace(input.Visibility)
if visibility == "" {
visibility = "agency"
}
if visibility != "agency" && visibility != "client" {
c.JSON(http.StatusBadRequest, gin.H{"message": "Visibilidade invalida."})
return
}
category := strings.TrimSpace(input.Category)
if category == "" {
category = "custom"
}
date, err := r.store.CreateCustomDate(c.Request.Context(), CreateCustomDateInput{
ClientID: strings.TrimSpace(input.ClientID),
Name: strings.TrimSpace(input.Name),
Description: strings.TrimSpace(input.Description),
Date: input.Date,
RecursAnnually: input.RecursAnnually,
Visibility: visibility,
Category: category,
CreatedBy: claims.UserID,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel criar a data personalizada."})
return
}
_ = r.store.RecordAudit(c.Request.Context(), claims.UserID, "custom_date.created", "commemorative_date", date.ID, map[string]any{
"name": date.Name,
"client_id": date.ClientID,
})
c.JSON(http.StatusCreated, gin.H{"custom_date": date})
}
type updateCustomDateRequest struct {
ClientID string `json:"client_id"`
Name string `json:"name" binding:"required,min=2"`
Description string `json:"description"`
Date string `json:"date" binding:"required"`
RecursAnnually bool `json:"recurs_annually"`
Visibility string `json:"visibility"`
}
func (r Routes) updateCustomDate(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
}
var input updateCustomDateRequest
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"message": "Informe os dados da data personalizada."})
return
}
if _, err := time.Parse("2006-01-02", input.Date); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"message": "Data invalida. Use o formato YYYY-MM-DD."})
return
}
visibility := strings.TrimSpace(input.Visibility)
if visibility == "" {
visibility = "agency"
}
if visibility != "agency" && visibility != "client" {
c.JSON(http.StatusBadRequest, gin.H{"message": "Visibilidade invalida."})
return
}
date, err := r.store.UpdateCustomDate(c.Request.Context(), UpdateCustomDateInput{
ID: c.Param("customDateId"),
ClientID: strings.TrimSpace(input.ClientID),
Name: strings.TrimSpace(input.Name),
Description: strings.TrimSpace(input.Description),
Date: input.Date,
RecursAnnually: input.RecursAnnually,
Visibility: visibility,
})
if errors.Is(err, ErrCustomDateNotFound) {
c.JSON(http.StatusNotFound, gin.H{"message": "Data personalizada nao encontrada."})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel atualizar a data personalizada."})
return
}
_ = r.store.RecordAudit(c.Request.Context(), claims.UserID, "custom_date.updated", "commemorative_date", date.ID, map[string]any{
"name": date.Name,
"client_id": date.ClientID,
})
c.JSON(http.StatusOK, gin.H{"custom_date": date})
}
func (r Routes) deleteCustomDate(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
}
err := r.store.DeleteCustomDate(c.Request.Context(), c.Param("customDateId"))
if errors.Is(err, ErrCustomDateNotFound) {
c.JSON(http.StatusNotFound, gin.H{"message": "Data personalizada nao encontrada."})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel excluir a data personalizada."})
return
}
_ = r.store.RecordAudit(c.Request.Context(), claims.UserID, "custom_date.deleted", "commemorative_date", c.Param("customDateId"), nil)
c.JSON(http.StatusOK, gin.H{"message": "Data personalizada excluida."})
}
func (r Routes) calendarItems(c *gin.Context) {
claims, ok := auth.ClaimsFromContext(c)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"message": "Autenticacao obrigatoria."})
return
}
from := strings.TrimSpace(c.Query("from"))
to := strings.TrimSpace(c.Query("to"))
if from == "" || to == "" {
year := time.Now().Year()
if value := c.Query("year"); value != "" {
parsed, err := strconv.Atoi(value)
if err != nil || parsed < 1900 || parsed > 2199 {
c.JSON(http.StatusBadRequest, gin.H{"message": "Ano invalido."})
return
}
year = parsed
}
from = time.Date(year, 1, 1, 0, 0, 0, 0, time.UTC).Format("2006-01-02")
to = time.Date(year, 12, 31, 0, 0, 0, 0, time.UTC).Format("2006-01-02")
}
if _, err := time.Parse("2006-01-02", from); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"message": "Data inicial invalida. Use o formato YYYY-MM-DD."})
return
}
if _, err := time.Parse("2006-01-02", to); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"message": "Data final invalida. Use o formato YYYY-MM-DD."})
return
}
viewerUserID := ""
if claims.Role == users.RoleClientViewer {
viewerUserID = claims.UserID
}
items, err := r.store.ListCalendarItems(c.Request.Context(), ListCalendarItemsFilter{
ClientID: strings.TrimSpace(c.Query("client_id")),
ViewerUserID: viewerUserID,
From: from,
To: to,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel carregar postagens."})
return
}
c.JSON(http.StatusOK, gin.H{"items": items})
}
type createCalendarItemRequest struct {
ClientID string `json:"client_id" binding:"required"`
Title string `json:"title" binding:"required,min=2"`
Description string `json:"description"`
ContentType string `json:"content_type"`
Status string `json:"status"`
ScheduledDate string `json:"scheduled_date" binding:"required"`
CopyText string `json:"copy_text"`
InternalNotes string `json:"internal_notes"`
ClientNotes string `json:"client_notes"`
}
func (r Routes) createCalendarItem(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
}
var input createCalendarItemRequest
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"message": "Informe os dados da postagem."})
return
}
if _, err := time.Parse("2006-01-02", input.ScheduledDate); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"message": "Data invalida. Use o formato YYYY-MM-DD."})
return
}
contentType := strings.TrimSpace(input.ContentType)
if contentType == "" {
contentType = "post"
}
status := strings.TrimSpace(input.Status)
if status == "" {
status = "rascunho"
}
if !isValidCalendarItemStatus(status) {
c.JSON(http.StatusBadRequest, gin.H{"message": "Status invalido."})
return
}
item, err := r.store.CreateCalendarItem(c.Request.Context(), CreateCalendarItemInput{
ClientID: strings.TrimSpace(input.ClientID),
Title: strings.TrimSpace(input.Title),
Description: strings.TrimSpace(input.Description),
ContentType: contentType,
Status: status,
ScheduledDate: input.ScheduledDate,
CopyText: strings.TrimSpace(input.CopyText),
InternalNotes: strings.TrimSpace(input.InternalNotes),
ClientNotes: strings.TrimSpace(input.ClientNotes),
CreatedBy: claims.UserID,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel criar a postagem."})
return
}
_ = r.store.RecordAudit(c.Request.Context(), claims.UserID, "calendar_item.created", "calendar_item", item.ID, map[string]any{
"title": item.Title,
"client_id": item.ClientID,
"status": item.Status,
})
c.JSON(http.StatusCreated, gin.H{"item": item})
}
func (r Routes) getCalendarItem(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
}
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
}
c.JSON(http.StatusOK, gin.H{"item": item})
}
type updateCalendarItemRequest struct {
Title *string `json:"title"`
Description *string `json:"description"`
Status *string `json:"status"`
ScheduledDate *string `json:"scheduled_date"`
CopyText *string `json:"copy_text"`
InternalNotes *string `json:"internal_notes"`
ClientNotes *string `json:"client_notes"`
}
func (r Routes) updateCalendarItem(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
}
var input updateCalendarItemRequest
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"message": "Dados invalidos."})
return
}
update := UpdateCalendarItemInput{ID: c.Param("itemId")}
if input.Title != nil {
update.UpdateTitle = true
update.Title = strings.TrimSpace(*input.Title)
if update.Title == "" {
c.JSON(http.StatusBadRequest, gin.H{"message": "Titulo invalido."})
return
}
}
if input.Description != nil {
update.UpdateDescription = true
update.Description = strings.TrimSpace(*input.Description)
}
if input.Status != nil {
update.UpdateStatus = true
update.Status = strings.TrimSpace(*input.Status)
if !isValidCalendarItemStatus(update.Status) {
c.JSON(http.StatusBadRequest, gin.H{"message": "Status invalido."})
return
}
}
if input.ScheduledDate != nil {
update.UpdateScheduledDate = true
update.ScheduledDate = strings.TrimSpace(*input.ScheduledDate)
if _, err := time.Parse("2006-01-02", update.ScheduledDate); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"message": "Data invalida. Use o formato YYYY-MM-DD."})
return
}
}
if input.CopyText != nil {
update.UpdateCopyText = true
update.CopyText = strings.TrimSpace(*input.CopyText)
}
if input.InternalNotes != nil {
update.UpdateInternalNotes = true
update.InternalNotes = strings.TrimSpace(*input.InternalNotes)
}
if input.ClientNotes != nil {
update.UpdateClientNotes = true
update.ClientNotes = strings.TrimSpace(*input.ClientNotes)
}
item, err := r.store.UpdateCalendarItem(c.Request.Context(), update)
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 atualizar a postagem."})
return
}
_ = r.store.RecordAudit(c.Request.Context(), claims.UserID, "calendar_item.updated", "calendar_item", item.ID, map[string]any{
"title": item.Title,
"status": item.Status,
})
c.JSON(http.StatusOK, gin.H{"item": item})
}
func (r Routes) cancelCalendarItem(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
}
item, err := r.store.FindCalendarItem(c.Request.Context(), c.Param("itemId"), "")
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
}
if err := r.store.DeleteCalendarItem(c.Request.Context(), item.ID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel excluir a postagem."})
return
}
_ = r.store.RecordAudit(c.Request.Context(), claims.UserID, "calendar_item.deleted", "calendar_item", item.ID, map[string]any{
"title": item.Title,
"status": item.Status,
})
c.JSON(http.StatusOK, gin.H{"message": "Postagem excluida."})
}
func (r Routes) listAttachments(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
}
attachments, err := r.store.ListAttachments(c.Request.Context(), c.Param("itemId"), viewerUserID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel carregar anexos."})
return
}
c.JSON(http.StatusOK, gin.H{"attachments": attachments})
}
func (r Routes) uploadAttachment(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
}
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, r.uploadMaxSizeBytes)
file, header, err := c.Request.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"message": "Envie um arquivo valido."})
return
}
defer file.Close()
if header.Size > r.uploadMaxSizeBytes {
c.JSON(http.StatusBadRequest, gin.H{"message": "Arquivo acima do limite permitido."})
return
}
head := make([]byte, 512)
n, err := file.Read(head)
if err != nil && err != io.EOF {
c.JSON(http.StatusBadRequest, gin.H{"message": "Nao foi possivel ler o arquivo."})
return
}
if _, err := file.Seek(0, io.SeekStart); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel processar o arquivo."})
return
}
mimeType := http.DetectContentType(head[:n])
if len(r.uploadAllowedMIMEs) > 0 && !r.uploadAllowedMIMEs[mimeType] {
c.JSON(http.StatusBadRequest, gin.H{"message": "Tipo de arquivo nao permitido."})
return
}
storedFilename, err := randomStoredFilename(header.Filename)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel preparar o arquivo."})
return
}
itemDir := filepath.Join(r.storageLocalPath, c.Param("itemId"))
if err := os.MkdirAll(itemDir, 0o750); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel preparar o armazenamento."})
return
}
storagePath := filepath.Join(itemDir, storedFilename)
destination, err := os.OpenFile(storagePath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o640)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel salvar o arquivo."})
return
}
defer destination.Close()
sizeBytes, err := io.Copy(destination, file)
if err != nil {
_ = os.Remove(storagePath)
c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel salvar o arquivo."})
return
}
attachment, err := r.store.CreateAttachment(c.Request.Context(), CreateAttachmentInput{
CalendarItemID: c.Param("itemId"),
OriginalFilename: filepath.Base(header.Filename),
StoredFilename: storedFilename,
MimeType: mimeType,
SizeBytes: sizeBytes,
StorageDriver: r.storageDriver,
StoragePath: storagePath,
CreatedBy: claims.UserID,
})
if err != nil {
_ = os.Remove(storagePath)
c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel registrar o anexo."})
return
}
_ = r.store.RecordAudit(c.Request.Context(), claims.UserID, "attachment.created", "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.StatusCreated, gin.H{"attachment": attachment})
}
func (r Routes) downloadAttachment(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.FindAttachment(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 randomStoredFilename(original string) (string, error) {
bytes := make([]byte, 16)
if _, err := rand.Read(bytes); err != nil {
return "", err
}
extension := strings.ToLower(filepath.Ext(original))
return hex.EncodeToString(bytes) + extension, nil
}
func isValidCalendarItemStatus(status string) bool {
switch status {
case "rascunho", "planejado", "em_producao", "em_revisao", "aprovado", "publicado", "cancelado":
return true
default:
return false
}
}

View File

@@ -0,0 +1,83 @@
package calendar
import (
"context"
"encoding/json"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
type HolidayRecord struct {
ID string `json:"id"`
Date string `json:"date"`
Name string `json:"name"`
Type string `json:"type"`
Source string `json:"source"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type Store struct {
db *pgxpool.Pool
}
func NewStore(db *pgxpool.Pool) Store {
return Store{db: db}
}
func (s Store) ListHolidaysByYear(ctx context.Context, year int) ([]HolidayRecord, error) {
rows, err := s.db.Query(ctx, `
SELECT id::text, date::text, name, type, source, created_at, updated_at
FROM holidays
WHERE date >= make_date($1, 1, 1)
AND date < make_date($1 + 1, 1, 1)
ORDER BY date ASC, name ASC
`, year)
if err != nil {
return nil, err
}
defer rows.Close()
holidays := []HolidayRecord{}
for rows.Next() {
var holiday HolidayRecord
if err := rows.Scan(
&holiday.ID,
&holiday.Date,
&holiday.Name,
&holiday.Type,
&holiday.Source,
&holiday.CreatedAt,
&holiday.UpdatedAt,
); err != nil {
return nil, err
}
holidays = append(holidays, holiday)
}
return holidays, rows.Err()
}
func (s Store) UpsertHolidays(ctx context.Context, source string, holidays []Holiday) error {
for _, holiday := range holidays {
payload, err := json.Marshal(holiday)
if err != nil {
return err
}
if _, err := s.db.Exec(ctx, `
INSERT INTO holidays (date, name, type, source, raw_payload)
VALUES ($1, $2, $3, $4, $5::jsonb)
ON CONFLICT (date, source, name)
DO UPDATE SET
type = EXCLUDED.type,
raw_payload = EXCLUDED.raw_payload,
updated_at = now()
`, holiday.Date, holiday.Name, holiday.Type, source, string(payload)); err != nil {
return err
}
}
return nil
}

View File

@@ -0,0 +1,18 @@
package clients
import "time"
type Client struct {
ID string `json:"id"`
Name string `json:"name"`
Slug string `json:"slug"`
Status string `json:"status"`
WebsiteURL string `json:"website_url"`
InstagramURL string `json:"instagram_url"`
FacebookURL string `json:"facebook_url"`
LinkedinURL string `json:"linkedin_url"`
Color string `json:"color"`
Notes string `json:"notes"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}

View File

@@ -0,0 +1,196 @@
package clients
import (
"errors"
"net/http"
"regexp"
"strings"
"mira/backend/internal/auth"
"mira/backend/internal/users"
"github.com/gin-gonic/gin"
)
type Routes struct {
store Store
}
func NewRoutes(store Store) Routes {
return Routes{store: store}
}
func (r Routes) Register(router *gin.RouterGroup, requireAuth gin.HandlerFunc) {
router.Use(requireAuth)
router.GET("", r.list)
router.POST("", r.create)
router.GET("/:clientId", r.get)
router.PATCH("/:clientId", r.update)
router.DELETE("/:clientId", r.delete)
}
func (r Routes) list(c *gin.Context) {
claims, ok := auth.ClaimsFromContext(c)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"message": "Autenticacao obrigatoria."})
return
}
var clients []Client
var err error
if claims.Role == users.RoleSuperAdmin || claims.Role == users.RoleAgencyUser {
clients, err = r.store.List(c.Request.Context())
} else {
clients, err = r.store.ListForUser(c.Request.Context(), claims.UserID)
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel listar clientes."})
return
}
c.JSON(http.StatusOK, gin.H{"clients": clients})
}
type createClientRequest struct {
Name string `json:"name" binding:"required,min=2"`
}
func (r Routes) create(c *gin.Context) {
claims, ok := auth.ClaimsFromContext(c)
if !ok || claims.Role != users.RoleSuperAdmin {
c.JSON(http.StatusForbidden, gin.H{"message": "Permissao insuficiente."})
return
}
var input createClientRequest
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"message": "Informe um nome de cliente valido."})
return
}
client, err := r.store.Create(c.Request.Context(), input.Name)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel criar o cliente."})
return
}
c.JSON(http.StatusCreated, gin.H{"client": client})
}
func (r Routes) get(c *gin.Context) {
if !r.canReadClient(c, c.Param("clientId")) {
c.JSON(http.StatusForbidden, gin.H{"message": "Permissao insuficiente."})
return
}
client, err := r.store.FindByID(c.Request.Context(), c.Param("clientId"))
if errors.Is(err, ErrNotFound) {
c.JSON(http.StatusNotFound, gin.H{"message": "Cliente nao encontrado."})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel carregar o cliente."})
return
}
c.JSON(http.StatusOK, gin.H{"client": client})
}
type updateClientRequest struct {
Name string `json:"name"`
Status string `json:"status"`
WebsiteURL string `json:"website_url"`
InstagramURL string `json:"instagram_url"`
FacebookURL string `json:"facebook_url"`
LinkedinURL string `json:"linkedin_url"`
Color string `json:"color"`
Notes string `json:"notes"`
}
func (r Routes) update(c *gin.Context) {
claims, ok := auth.ClaimsFromContext(c)
if !ok || claims.Role != users.RoleSuperAdmin {
c.JSON(http.StatusForbidden, gin.H{"message": "Permissao insuficiente."})
return
}
var input updateClientRequest
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"message": "Dados invalidos."})
return
}
color := strings.TrimSpace(input.Color)
if color != "" && !hexColorPattern.MatchString(color) {
c.JSON(http.StatusBadRequest, gin.H{"message": "Cor invalida."})
return
}
client, err := r.store.Update(c.Request.Context(), c.Param("clientId"), input.Name, input.Status, input.WebsiteURL, input.InstagramURL, input.FacebookURL, input.LinkedinURL, color, input.Notes)
if errors.Is(err, ErrNotFound) {
c.JSON(http.StatusNotFound, gin.H{"message": "Cliente nao encontrado."})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel atualizar o cliente."})
return
}
c.JSON(http.StatusOK, gin.H{"client": client})
}
var hexColorPattern = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
func (r Routes) archive(c *gin.Context) {
claims, ok := auth.ClaimsFromContext(c)
if !ok || claims.Role != users.RoleSuperAdmin {
c.JSON(http.StatusForbidden, gin.H{"message": "Permissao insuficiente."})
return
}
client, err := r.store.Archive(c.Request.Context(), c.Param("clientId"))
if errors.Is(err, ErrNotFound) {
c.JSON(http.StatusNotFound, gin.H{"message": "Cliente nao encontrado."})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel arquivar o cliente."})
return
}
c.JSON(http.StatusOK, gin.H{"client": client})
}
func (r Routes) delete(c *gin.Context) {
claims, ok := auth.ClaimsFromContext(c)
if !ok || claims.Role != users.RoleSuperAdmin {
c.JSON(http.StatusForbidden, gin.H{"message": "Permissao insuficiente."})
return
}
err := r.store.Delete(c.Request.Context(), c.Param("clientId"))
if errors.Is(err, ErrNotFound) {
c.JSON(http.StatusNotFound, gin.H{"message": "Cliente nao encontrado."})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel excluir o cliente."})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Cliente excluido."})
}
func (r Routes) canReadClient(c *gin.Context, clientID string) bool {
claims, ok := auth.ClaimsFromContext(c)
if !ok {
return false
}
if claims.Role == users.RoleSuperAdmin || claims.Role == users.RoleAgencyUser {
return true
}
allowed, err := r.store.UserCanAccess(c.Request.Context(), claims.UserID, clientID)
return err == nil && allowed
}

View File

@@ -0,0 +1,201 @@
package clients
import (
"context"
"errors"
"regexp"
"strings"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
var ErrNotFound = errors.New("client not found")
type Store struct {
db *pgxpool.Pool
}
func NewStore(db *pgxpool.Pool) Store {
return Store{db: db}
}
func (s Store) List(ctx context.Context) ([]Client, error) {
rows, err := s.db.Query(ctx, `
SELECT id::text, name, slug, status, website_url, instagram_url, facebook_url, linkedin_url, color, notes, created_at, updated_at
FROM clients
ORDER BY name ASC
`)
if err != nil {
return nil, err
}
defer rows.Close()
clients := []Client{}
for rows.Next() {
client, err := scanClient(rows)
if err != nil {
return nil, err
}
clients = append(clients, client)
}
return clients, rows.Err()
}
func (s Store) ListForUser(ctx context.Context, userID string) ([]Client, error) {
rows, err := s.db.Query(ctx, `
SELECT clients.id::text, clients.name, clients.slug, clients.status, clients.website_url, clients.instagram_url, clients.facebook_url, clients.linkedin_url, clients.color, clients.notes, clients.created_at, clients.updated_at
FROM clients
INNER JOIN client_users ON client_users.client_id = clients.id
WHERE client_users.user_id = $1::uuid
ORDER BY clients.name ASC
`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
clients := []Client{}
for rows.Next() {
client, err := scanClient(rows)
if err != nil {
return nil, err
}
clients = append(clients, client)
}
return clients, rows.Err()
}
func (s Store) UserCanAccess(ctx context.Context, userID string, clientID string) (bool, error) {
var exists bool
err := s.db.QueryRow(ctx, `
SELECT EXISTS (
SELECT 1
FROM client_users
WHERE user_id = $1::uuid
AND client_id = $2::uuid
)
`, userID, clientID).Scan(&exists)
return exists, err
}
func (s Store) FindByID(ctx context.Context, id string) (Client, error) {
row := s.db.QueryRow(ctx, `
SELECT id::text, name, slug, status, website_url, instagram_url, facebook_url, linkedin_url, color, notes, created_at, updated_at
FROM clients
WHERE id = $1
`, id)
return scanClient(row)
}
func (s Store) Create(ctx context.Context, name string) (Client, error) {
slug := slugify(name)
row := s.db.QueryRow(ctx, `
INSERT INTO clients (name, slug, status)
VALUES ($1, $2, 'active')
RETURNING id::text, name, slug, status, website_url, instagram_url, facebook_url, linkedin_url, color, notes, created_at, updated_at
`, strings.TrimSpace(name), slug)
return scanClient(row)
}
func (s Store) Update(ctx context.Context, id string, name string, status string, websiteURL string, instagramURL string, facebookURL string, linkedinURL string, color string, notes string) (Client, error) {
row := s.db.QueryRow(ctx, `
UPDATE clients
SET
name = COALESCE(NULLIF($2, ''), name),
slug = CASE WHEN NULLIF($2, '') IS NULL THEN slug ELSE $3 END,
status = COALESCE(NULLIF($4, ''), status),
website_url = $5,
instagram_url = $6,
facebook_url = $7,
linkedin_url = $8,
color = COALESCE(NULLIF($9, ''), color),
notes = $10,
updated_at = now()
WHERE id = $1
RETURNING id::text, name, slug, status, website_url, instagram_url, facebook_url, linkedin_url, color, notes, created_at, updated_at
`, id, strings.TrimSpace(name), slugify(name), strings.TrimSpace(status), strings.TrimSpace(websiteURL), strings.TrimSpace(instagramURL), strings.TrimSpace(facebookURL), strings.TrimSpace(linkedinURL), strings.TrimSpace(color), strings.TrimSpace(notes))
return scanClient(row)
}
func (s Store) Archive(ctx context.Context, id string) (Client, error) {
row := s.db.QueryRow(ctx, `
UPDATE clients
SET status = 'archived', updated_at = now()
WHERE id = $1
RETURNING id::text, name, slug, status, website_url, instagram_url, facebook_url, linkedin_url, color, notes, created_at, updated_at
`, id)
return scanClient(row)
}
func (s Store) Delete(ctx context.Context, id string) error {
result, err := s.db.Exec(ctx, `
DELETE FROM clients
WHERE id = $1
`, id)
if err != nil {
return err
}
if result.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
type scanner interface {
Scan(dest ...any) error
}
func scanClient(row scanner) (Client, error) {
var client Client
err := row.Scan(
&client.ID,
&client.Name,
&client.Slug,
&client.Status,
&client.WebsiteURL,
&client.InstagramURL,
&client.FacebookURL,
&client.LinkedinURL,
&client.Color,
&client.Notes,
&client.CreatedAt,
&client.UpdatedAt,
)
if errors.Is(err, pgx.ErrNoRows) {
return Client{}, ErrNotFound
}
if err != nil {
return Client{}, err
}
return client, nil
}
func slugify(value string) string {
value = strings.ToLower(strings.TrimSpace(value))
replacements := map[string]string{
"á": "a", "à": "a", "â": "a", "ã": "a", "ä": "a",
"é": "e", "è": "e", "ê": "e", "ë": "e",
"í": "i", "ì": "i", "î": "i", "ï": "i",
"ó": "o", "ò": "o", "ô": "o", "õ": "o", "ö": "o",
"ú": "u", "ù": "u", "û": "u", "ü": "u",
"ç": "c",
}
for from, to := range replacements {
value = strings.ReplaceAll(value, from, to)
}
value = regexp.MustCompile(`[^a-z0-9]+`).ReplaceAllString(value, "-")
value = strings.Trim(value, "-")
if value == "" {
return "cliente"
}
return value
}

View File

@@ -0,0 +1,119 @@
package config
import (
"log/slog"
"os"
"strconv"
"strings"
"time"
)
type Config struct {
AppEnv string
AppPublicURL string
AppTimezone string
HTTPAddr string
LogLevel slog.Level
DatabaseURL string
SuperAdminEmail string
SuperAdminPassword string
JWTSecret string
AccessTokenTTL time.Duration
RefreshTokenTTL time.Duration
CORSAllowedOrigins []string
CalendarProvider string
CalendarAPIBaseURL string
CalendarAPIKey string
CalendarCacheTTL time.Duration
UploadMaxSizeMB int
UploadAllowedMIMEs []string
StorageDriver string
StorageLocalPath string
SMTPHost string
SMTPPort string
SMTPUser string
SMTPPassword string
SMTPFromEmail string
SMTPFromName string
}
func Load() Config {
return Config{
AppEnv: env("APP_ENV", "development"),
AppPublicURL: env("APP_PUBLIC_URL", "http://localhost:5173"),
AppTimezone: env("APP_TIMEZONE", "America/Sao_Paulo"),
HTTPAddr: env("HTTP_ADDR", ":8080"),
LogLevel: logLevel(env("LOG_LEVEL", "info")),
DatabaseURL: env("DATABASE_URL", "postgres://mira:mira@localhost:5432/mira?sslmode=disable"),
SuperAdminEmail: env("SUPER_ADMIN_EMAIL", ""),
SuperAdminPassword: env("SUPER_ADMIN_PASSWORD", ""),
JWTSecret: env("JWT_SECRET", ""),
AccessTokenTTL: minutes("JWT_ACCESS_TOKEN_TTL_MINUTES", 15),
RefreshTokenTTL: hours("JWT_REFRESH_TOKEN_TTL_DAYS", 7*24),
CORSAllowedOrigins: list("CORS_ALLOWED_ORIGINS", "http://localhost:5173"),
CalendarProvider: env("CALENDAR_PROVIDER", "brasilapi"),
CalendarAPIBaseURL: env("CALENDAR_API_BASE_URL", "https://brasilapi.com.br"),
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"),
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", ""),
SMTPFromName: env("SMTP_FROM_NAME", "Mira"),
}
}
func env(key, fallback string) string {
value := strings.TrimSpace(os.Getenv(key))
if value == "" {
return fallback
}
return value
}
func list(key, fallback string) []string {
raw := env(key, fallback)
parts := strings.Split(raw, ",")
values := make([]string, 0, len(parts))
for _, part := range parts {
value := strings.TrimSpace(part)
if value != "" {
values = append(values, value)
}
}
return values
}
func integer(key string, fallback int) int {
value, err := strconv.Atoi(env(key, ""))
if err != nil {
return fallback
}
return value
}
func minutes(key string, fallback int) time.Duration {
return time.Duration(integer(key, fallback)) * time.Minute
}
func hours(key string, fallback int) time.Duration {
return time.Duration(integer(key, fallback)) * time.Hour
}
func logLevel(value string) slog.Level {
switch strings.ToLower(value) {
case "debug":
return slog.LevelDebug
case "warn":
return slog.LevelWarn
case "error":
return slog.LevelError
default:
return slog.LevelInfo
}
}

View File

@@ -0,0 +1,77 @@
package database
import (
"context"
"fmt"
"log/slog"
"os"
"path/filepath"
"sort"
"strings"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
type DB struct {
Pool *pgxpool.Pool
}
func Connect(ctx context.Context, databaseURL string) (*DB, error) {
cfg, err := pgxpool.ParseConfig(databaseURL)
if err != nil {
return nil, fmt.Errorf("parse database url: %w", err)
}
cfg.MaxConns = 10
cfg.MinConns = 1
cfg.MaxConnLifetime = time.Hour
cfg.MaxConnIdleTime = 30 * time.Minute
pool, err := pgxpool.NewWithConfig(ctx, cfg)
if err != nil {
return nil, fmt.Errorf("create database pool: %w", err)
}
if err := pool.Ping(ctx); err != nil {
pool.Close()
return nil, fmt.Errorf("ping database: %w", err)
}
return &DB{Pool: pool}, nil
}
func (db *DB) Close() {
if db != nil && db.Pool != nil {
db.Pool.Close()
}
}
func (db *DB) Migrate(ctx context.Context, logger *slog.Logger, migrationsDir string) error {
files, err := filepath.Glob(filepath.Join(migrationsDir, "*.sql"))
if err != nil {
return fmt.Errorf("list migrations: %w", err)
}
sort.Strings(files)
for _, file := range files {
sql, err := os.ReadFile(file)
if err != nil {
return fmt.Errorf("read migration %s: %w", file, err)
}
statement := strings.TrimSpace(string(sql))
if statement == "" {
continue
}
if _, err := db.Pool.Exec(ctx, statement); err != nil {
return fmt.Errorf("run migration %s: %w", file, err)
}
logger.Info("migration applied", "file", filepath.Base(file))
}
return nil
}

View File

@@ -0,0 +1,75 @@
package httpserver
import (
"log/slog"
"net/http"
"mira/backend/internal/auth"
"mira/backend/internal/calendar"
"mira/backend/internal/clients"
"mira/backend/internal/config"
"mira/backend/internal/database"
"mira/backend/internal/security"
"mira/backend/internal/users"
"github.com/gin-gonic/gin"
)
func New(cfg config.Config, logger *slog.Logger, db *database.DB, userStore users.Store, clientStore clients.Store, calendarStore calendar.Store) *gin.Engine {
if cfg.AppEnv == "production" {
gin.SetMode(gin.ReleaseMode)
}
router := gin.New()
router.Use(gin.Recovery())
router.Use(security.Headers())
router.Use(security.CORS(cfg.CORSAllowedOrigins))
router.GET("/healthz", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "ok"})
})
router.GET("/readyz", func(c *gin.Context) {
if err := db.Pool.Ping(c.Request.Context()); err != nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"status": "not_ready"})
return
}
c.JSON(http.StatusOK, gin.H{"status": "ready"})
})
tokenService := auth.NewTokenService(cfg.JWTSecret, cfg.AccessTokenTTL)
requireAuth := auth.RequireAuth(tokenService)
authRoutes := auth.NewRoutes(userStore, tokenService)
userRoutes := users.NewRoutes(userStore, cfg.AppPublicURL)
clientRoutes := clients.NewRoutes(clientStore)
calendarRoutes := calendar.NewRoutes(calendarStore, calendar.NewBrasilAPIProvider(cfg.CalendarAPIBaseURL), calendar.NewFeriadosBrasilProvider(), calendar.RouteOptions{
UploadMaxSizeMB: cfg.UploadMaxSizeMB,
UploadAllowedMIMEs: cfg.UploadAllowedMIMEs,
StorageDriver: cfg.StorageDriver,
StorageLocalPath: cfg.StorageLocalPath,
})
api := router.Group("/api/v1")
authRoutes.Register(api.Group("/auth"))
api.GET("/me", requireAuth, func(c *gin.Context) {
claims, ok := auth.ClaimsFromContext(c)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"message": "Autenticacao obrigatoria."})
return
}
user, err := userStore.FindByID(c.Request.Context(), claims.UserID)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"message": "Usuario nao encontrado."})
return
}
c.JSON(http.StatusOK, gin.H{"user": user})
})
userRoutes.Register(api.Group("/users"), requireAuth)
clientRoutes.Register(api.Group("/clients"), requireAuth)
calendarRoutes.Register(api.Group("/calendar"), requireAuth)
logger.Info("routes registered")
return router
}

View File

@@ -0,0 +1,43 @@
package security
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
)
func Headers() gin.HandlerFunc {
return func(c *gin.Context) {
c.Header("Content-Security-Policy", "default-src 'self'; frame-ancestors 'none'")
c.Header("X-Content-Type-Options", "nosniff")
c.Header("X-Frame-Options", "DENY")
c.Header("Referrer-Policy", "strict-origin-when-cross-origin")
c.Header("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
c.Next()
}
}
func CORS(allowedOrigins []string) gin.HandlerFunc {
allowed := map[string]bool{}
for _, origin := range allowedOrigins {
allowed[strings.TrimSpace(origin)] = true
}
return func(c *gin.Context) {
origin := c.GetHeader("Origin")
if allowed[origin] {
c.Header("Access-Control-Allow-Origin", origin)
c.Header("Access-Control-Allow-Credentials", "true")
c.Header("Access-Control-Allow-Headers", "Authorization, Content-Type")
c.Header("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS")
}
if c.Request.Method == http.MethodOptions {
c.AbortWithStatus(http.StatusNoContent)
return
}
c.Next()
}
}

View File

@@ -0,0 +1,209 @@
package users
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"errors"
"time"
"github.com/jackc/pgx/v5"
)
var ErrInvitationNotFound = errors.New("invitation not found")
type Invitation struct {
ID string `json:"id"`
Email string `json:"email"`
Role string `json:"role"`
ClientID *string `json:"client_id"`
ClientName *string `json:"client_name"`
ExpiresAt time.Time `json:"expires_at"`
AcceptedAt *time.Time `json:"accepted_at"`
CreatedBy *string `json:"created_by"`
CreatedAt time.Time `json:"created_at"`
InvitationURL string `json:"invitation_url,omitempty"`
}
type CreateInvitationInput struct {
Email string
Role string
ClientID string
TokenHash string
ExpiresAt time.Time
CreatedBy string
}
func NewInvitationToken() (string, string, error) {
bytes := make([]byte, 32)
if _, err := rand.Read(bytes); err != nil {
return "", "", err
}
token := base64.RawURLEncoding.EncodeToString(bytes)
return token, HashInvitationToken(token), nil
}
func HashInvitationToken(token string) string {
sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:])
}
func (s Store) CreateInvitation(ctx context.Context, input CreateInvitationInput) (Invitation, error) {
row := s.db.QueryRow(ctx, `
INSERT INTO invitations (email, role, client_id, token_hash, expires_at, created_by)
VALUES (lower($1), $2, NULLIF($3, '')::uuid, $4, $5, NULLIF($6, '')::uuid)
RETURNING
invitations.id::text,
invitations.email,
invitations.role,
invitations.client_id::text,
(SELECT name FROM clients WHERE clients.id = invitations.client_id),
invitations.expires_at,
invitations.accepted_at,
invitations.created_by::text,
invitations.created_at
`, input.Email, input.Role, input.ClientID, input.TokenHash, input.ExpiresAt, input.CreatedBy)
return scanInvitation(row)
}
func (s Store) ListInvitations(ctx context.Context) ([]Invitation, error) {
rows, err := s.db.Query(ctx, `
SELECT
invitations.id::text,
invitations.email,
invitations.role,
invitations.client_id::text,
clients.name,
invitations.expires_at,
invitations.accepted_at,
invitations.created_by::text,
invitations.created_at
FROM invitations
LEFT JOIN clients ON clients.id = invitations.client_id
ORDER BY invitations.created_at DESC
`)
if err != nil {
return nil, err
}
defer rows.Close()
invitations := []Invitation{}
for rows.Next() {
invitation, err := scanInvitation(rows)
if err != nil {
return nil, err
}
invitations = append(invitations, invitation)
}
return invitations, rows.Err()
}
func (s Store) AcceptInvitation(ctx context.Context, tokenHash string, name string, passwordHash string) (User, error) {
tx, err := s.db.Begin(ctx)
if err != nil {
return User{}, err
}
defer tx.Rollback(ctx)
var invitation Invitation
var rawTokenHash string
err = tx.QueryRow(ctx, `
SELECT
invitations.id::text,
invitations.email,
invitations.role,
invitations.client_id::text,
(SELECT name FROM clients WHERE clients.id = invitations.client_id),
invitations.expires_at,
invitations.accepted_at,
invitations.created_by::text,
invitations.created_at,
invitations.token_hash
FROM invitations
WHERE invitations.token_hash = $1
AND invitations.accepted_at IS NULL
AND invitations.expires_at > now()
FOR UPDATE
`, tokenHash).Scan(
&invitation.ID,
&invitation.Email,
&invitation.Role,
&invitation.ClientID,
&invitation.ClientName,
&invitation.ExpiresAt,
&invitation.AcceptedAt,
&invitation.CreatedBy,
&invitation.CreatedAt,
&rawTokenHash,
)
if errors.Is(err, pgx.ErrNoRows) {
return User{}, ErrInvitationNotFound
}
if err != nil {
return User{}, err
}
user, err := scanUser(tx.QueryRow(ctx, `
INSERT INTO users (email, name, password_hash, role, status)
VALUES ($1, $2, $3, $4, 'active')
RETURNING id::text, email, name, password_hash, role, status, created_at, updated_at
`, invitation.Email, name, passwordHash, invitation.Role))
if err != nil {
return User{}, err
}
if invitation.ClientID != nil {
if _, err := tx.Exec(ctx, `
INSERT INTO client_users (client_id, user_id)
VALUES ($1::uuid, $2::uuid)
ON CONFLICT DO NOTHING
`, *invitation.ClientID, user.ID); err != nil {
return User{}, err
}
}
if _, err := tx.Exec(ctx, `
UPDATE invitations
SET accepted_at = now()
WHERE token_hash = $1
`, rawTokenHash); err != nil {
return User{}, err
}
if err := tx.Commit(ctx); err != nil {
return User{}, err
}
return user, nil
}
type invitationScanner interface {
Scan(dest ...any) error
}
func scanInvitation(row invitationScanner) (Invitation, error) {
var invitation Invitation
err := row.Scan(
&invitation.ID,
&invitation.Email,
&invitation.Role,
&invitation.ClientID,
&invitation.ClientName,
&invitation.ExpiresAt,
&invitation.AcceptedAt,
&invitation.CreatedBy,
&invitation.CreatedAt,
)
if errors.Is(err, pgx.ErrNoRows) {
return Invitation{}, ErrInvitationNotFound
}
if err != nil {
return Invitation{}, err
}
return invitation, nil
}

View File

@@ -0,0 +1,20 @@
package users
import "time"
const (
RoleSuperAdmin = "super_admin"
RoleAgencyUser = "agency_user"
RoleClientViewer = "client_viewer"
)
type User struct {
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
PasswordHash string `json:"-"`
Role string `json:"role"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}

View File

@@ -0,0 +1,118 @@
package users
import (
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
)
type Routes struct {
store Store
appPublicURL string
}
func NewRoutes(store Store, appPublicURL string) Routes {
return Routes{
store: store,
appPublicURL: strings.TrimRight(appPublicURL, "/"),
}
}
func (r Routes) Register(router *gin.RouterGroup, requireAuth gin.HandlerFunc) {
router.Use(requireAuth)
router.GET("", r.list)
router.GET("/invitations", r.listInvitations)
router.POST("/invitations", r.createInvitation)
}
func (r Routes) list(c *gin.Context) {
if c.GetString("auth_role") != RoleSuperAdmin {
c.JSON(http.StatusForbidden, gin.H{"message": "Permissao insuficiente."})
return
}
users, err := r.store.List(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel listar usuarios."})
return
}
c.JSON(http.StatusOK, gin.H{"users": users})
}
func (r Routes) listInvitations(c *gin.Context) {
if c.GetString("auth_role") != RoleSuperAdmin {
c.JSON(http.StatusForbidden, gin.H{"message": "Permissao insuficiente."})
return
}
invitations, err := r.store.ListInvitations(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel listar convites."})
return
}
c.JSON(http.StatusOK, gin.H{"invitations": invitations})
}
type createInvitationRequest struct {
Email string `json:"email" binding:"required,email"`
Role string `json:"role" binding:"required"`
ClientID string `json:"client_id"`
}
func (r Routes) createInvitation(c *gin.Context) {
if c.GetString("auth_role") != RoleSuperAdmin {
c.JSON(http.StatusForbidden, gin.H{"message": "Permissao insuficiente."})
return
}
var input createInvitationRequest
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"message": "Informe os dados do convite."})
return
}
role := strings.TrimSpace(input.Role)
if role != RoleAgencyUser && role != RoleClientViewer {
c.JSON(http.StatusBadRequest, gin.H{"message": "Perfil invalido para convite."})
return
}
if role == RoleClientViewer && strings.TrimSpace(input.ClientID) == "" {
c.JSON(http.StatusBadRequest, gin.H{"message": "Convites de cliente precisam de um cliente vinculado."})
return
}
token, tokenHash, err := NewInvitationToken()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel gerar o convite."})
return
}
invitation, err := r.store.CreateInvitation(c.Request.Context(), CreateInvitationInput{
Email: strings.TrimSpace(input.Email),
Role: role,
ClientID: strings.TrimSpace(input.ClientID),
TokenHash: tokenHash,
ExpiresAt: time.Now().Add(7 * 24 * time.Hour),
CreatedBy: c.GetString("auth_user_id"),
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel criar o convite."})
return
}
invitation.InvitationURL = r.invitationURL(token)
c.JSON(http.StatusCreated, gin.H{"invitation": invitation})
}
func (r Routes) invitationURL(token string) string {
if r.appPublicURL == "" {
return "/accept-invitation?token=" + token
}
return r.appPublicURL + "/accept-invitation?token=" + token
}

View File

@@ -0,0 +1,100 @@
package users
import (
"context"
"errors"
"strings"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
var ErrNotFound = errors.New("user not found")
type Store struct {
db *pgxpool.Pool
}
func NewStore(db *pgxpool.Pool) Store {
return Store{db: db}
}
func (s Store) FindByEmail(ctx context.Context, email string) (User, error) {
row := s.db.QueryRow(ctx, `
SELECT id::text, email, name, password_hash, role, status, created_at, updated_at
FROM users
WHERE lower(email) = lower($1)
`, strings.TrimSpace(email))
return scanUser(row)
}
func (s Store) FindByID(ctx context.Context, id string) (User, error) {
row := s.db.QueryRow(ctx, `
SELECT id::text, email, name, password_hash, role, status, created_at, updated_at
FROM users
WHERE id = $1
`, id)
return scanUser(row)
}
func (s Store) List(ctx context.Context) ([]User, error) {
rows, err := s.db.Query(ctx, `
SELECT id::text, email, name, password_hash, role, status, created_at, updated_at
FROM users
ORDER BY created_at DESC
`)
if err != nil {
return nil, err
}
defer rows.Close()
users := []User{}
for rows.Next() {
user, err := scanUser(rows)
if err != nil {
return nil, err
}
users = append(users, user)
}
return users, rows.Err()
}
func (s Store) UpsertSuperAdmin(ctx context.Context, email string, passwordHash string) (User, error) {
row := s.db.QueryRow(ctx, `
INSERT INTO users (email, name, password_hash, role, status)
VALUES ($1, 'Super Admin', $2, 'super_admin', 'active')
ON CONFLICT (email)
DO UPDATE SET
password_hash = EXCLUDED.password_hash,
role = 'super_admin',
status = 'active',
updated_at = now()
RETURNING id::text, email, name, password_hash, role, status, created_at, updated_at
`, strings.TrimSpace(email), passwordHash)
return scanUser(row)
}
func scanUser(row pgx.Row) (User, error) {
var user User
err := row.Scan(
&user.ID,
&user.Email,
&user.Name,
&user.PasswordHash,
&user.Role,
&user.Status,
&user.CreatedAt,
&user.UpdatedAt,
)
if errors.Is(err, pgx.ErrNoRows) {
return User{}, ErrNotFound
}
if err != nil {
return User{}, err
}
return user, nil
}