first commit
This commit is contained in:
165
backend/internal/calendar/attachments.go
Normal file
165
backend/internal/calendar/attachments.go
Normal 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
|
||||
}
|
||||
23
backend/internal/calendar/audit.go
Normal file
23
backend/internal/calendar/audit.go
Normal 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
|
||||
}
|
||||
248
backend/internal/calendar/custom_dates.go
Normal file
248
backend/internal/calendar/custom_dates.go
Normal 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
|
||||
}
|
||||
329
backend/internal/calendar/items.go
Normal file
329
backend/internal/calendar/items.go
Normal 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
|
||||
}
|
||||
136
backend/internal/calendar/provider.go
Normal file
136
backend/internal/calendar/provider.go
Normal 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
|
||||
}
|
||||
740
backend/internal/calendar/routes.go
Normal file
740
backend/internal/calendar/routes.go
Normal 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
|
||||
}
|
||||
}
|
||||
83
backend/internal/calendar/store.go
Normal file
83
backend/internal/calendar/store.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user