diff --git a/apps/api/cmd/server/main.go b/apps/api/cmd/server/main.go index da2d318..3c86665 100644 --- a/apps/api/cmd/server/main.go +++ b/apps/api/cmd/server/main.go @@ -15,6 +15,8 @@ import ( "financeiro-carvalho/internal/db" "financeiro-carvalho/internal/handler" "financeiro-carvalho/internal/migration" + "financeiro-carvalho/internal/repository" + "financeiro-carvalho/internal/service" "financeiro-carvalho/static" ) @@ -46,10 +48,17 @@ func main() { r.Use(middleware.Logger) r.Use(middleware.Recoverer) + categoryRepo := repository.NewCategoryRepository(pool) + categorySvc := service.NewCategoryService(categoryRepo) + categoryHandler := handler.NewCategoryHandler(categorySvc) + r.Get("/health", handler.Health) r.Route("/api", func(r chi.Router) { - // API routes added here as features are built + r.Get("/categories", categoryHandler.List) + r.Post("/categories", categoryHandler.Create) + r.Put("/categories/{id}", categoryHandler.Update) + r.Delete("/categories/{id}", categoryHandler.Delete) }) // Serve Vue SPA — non-API routes fall through to index.html diff --git a/apps/api/internal/handler/category.go b/apps/api/internal/handler/category.go new file mode 100644 index 0000000..1de1888 --- /dev/null +++ b/apps/api/internal/handler/category.go @@ -0,0 +1,92 @@ +package handler + +import ( + "encoding/json" + "errors" + "net/http" + "strconv" + + "github.com/go-chi/chi/v5" + + "financeiro-carvalho/internal/model" + "financeiro-carvalho/internal/repository" + "financeiro-carvalho/internal/service" +) + +type CategoryHandler struct { + svc *service.CategoryService +} + +func NewCategoryHandler(svc *service.CategoryService) *CategoryHandler { + return &CategoryHandler{svc: svc} +} + +func (h *CategoryHandler) List(w http.ResponseWriter, r *http.Request) { + cats, err := h.svc.List(r.Context()) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to list categories") + return + } + if cats == nil { + cats = []model.Category{} + } + respondJSON(w, http.StatusOK, cats) +} + +func (h *CategoryHandler) Create(w http.ResponseWriter, r *http.Request) { + var in model.CategoryInput + if err := json.NewDecoder(r.Body).Decode(&in); err != nil { + respondError(w, http.StatusBadRequest, "invalid JSON") + return + } + cat, err := h.svc.Create(r.Context(), in) + if err != nil { + respondError(w, http.StatusBadRequest, err.Error()) + return + } + respondJSON(w, http.StatusCreated, cat) +} + +func (h *CategoryHandler) Update(w http.ResponseWriter, r *http.Request) { + id, err := strconv.Atoi(chi.URLParam(r, "id")) + if err != nil { + respondError(w, http.StatusBadRequest, "invalid id") + return + } + var in model.CategoryInput + if err := json.NewDecoder(r.Body).Decode(&in); err != nil { + respondError(w, http.StatusBadRequest, "invalid JSON") + return + } + cat, err := h.svc.Update(r.Context(), id, in) + if errors.Is(err, repository.ErrNotFound) { + respondError(w, http.StatusNotFound, "category not found") + return + } + if err != nil { + respondError(w, http.StatusBadRequest, err.Error()) + return + } + respondJSON(w, http.StatusOK, cat) +} + +func (h *CategoryHandler) Delete(w http.ResponseWriter, r *http.Request) { + id, err := strconv.Atoi(chi.URLParam(r, "id")) + if err != nil { + respondError(w, http.StatusBadRequest, "invalid id") + return + } + err = h.svc.Delete(r.Context(), id) + switch { + case errors.Is(err, repository.ErrNotFound): + respondError(w, http.StatusNotFound, "category not found") + case errors.Is(err, service.ErrDefaultCategory): + respondError(w, http.StatusConflict, err.Error()) + case errors.Is(err, service.ErrHasTransactions): + respondError(w, http.StatusConflict, err.Error()) + case err != nil: + respondError(w, http.StatusInternalServerError, "failed to delete") + default: + w.WriteHeader(http.StatusNoContent) + } +} diff --git a/apps/api/internal/handler/respond.go b/apps/api/internal/handler/respond.go new file mode 100644 index 0000000..6fe1a9f --- /dev/null +++ b/apps/api/internal/handler/respond.go @@ -0,0 +1,16 @@ +package handler + +import ( + "encoding/json" + "net/http" +) + +func respondJSON(w http.ResponseWriter, status int, data any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(data) +} + +func respondError(w http.ResponseWriter, status int, msg string) { + respondJSON(w, status, map[string]string{"error": msg}) +} diff --git a/apps/api/internal/model/category.go b/apps/api/internal/model/category.go new file mode 100644 index 0000000..18f3162 --- /dev/null +++ b/apps/api/internal/model/category.go @@ -0,0 +1,17 @@ +package model + +import "time" + +type Category struct { + ID int `json:"id"` + Name string `json:"name"` + Color string `json:"color"` + IsDefault bool `json:"is_default"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type CategoryInput struct { + Name string `json:"name"` + Color string `json:"color"` +} diff --git a/apps/api/internal/repository/category.go b/apps/api/internal/repository/category.go new file mode 100644 index 0000000..8e2d7d8 --- /dev/null +++ b/apps/api/internal/repository/category.go @@ -0,0 +1,106 @@ +package repository + +import ( + "context" + "errors" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + + "financeiro-carvalho/internal/model" +) + +var ErrNotFound = errors.New("not found") + +type CategoryRepository interface { + List(ctx context.Context) ([]model.Category, error) + GetByID(ctx context.Context, id int) (*model.Category, error) + Create(ctx context.Context, name, color string) (*model.Category, error) + Update(ctx context.Context, id int, name, color string) (*model.Category, error) + Delete(ctx context.Context, id int) error + HasTransactions(ctx context.Context, id int) (bool, error) +} + +type categoryRepo struct{ db *pgxpool.Pool } + +func NewCategoryRepository(db *pgxpool.Pool) CategoryRepository { + return &categoryRepo{db: db} +} + +func (r *categoryRepo) List(ctx context.Context) ([]model.Category, error) { + rows, err := r.db.Query(ctx, ` + SELECT id, name, color, is_default, created_at, updated_at + FROM categories + ORDER BY is_default DESC, name ASC`) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []model.Category + for rows.Next() { + var c model.Category + if err := rows.Scan(&c.ID, &c.Name, &c.Color, &c.IsDefault, &c.CreatedAt, &c.UpdatedAt); err != nil { + return nil, err + } + out = append(out, c) + } + return out, rows.Err() +} + +func (r *categoryRepo) GetByID(ctx context.Context, id int) (*model.Category, error) { + var c model.Category + err := r.db.QueryRow(ctx, ` + SELECT id, name, color, is_default, created_at, updated_at + FROM categories WHERE id = $1`, id). + Scan(&c.ID, &c.Name, &c.Color, &c.IsDefault, &c.CreatedAt, &c.UpdatedAt) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + return &c, err +} + +func (r *categoryRepo) Create(ctx context.Context, name, color string) (*model.Category, error) { + var c model.Category + err := r.db.QueryRow(ctx, ` + INSERT INTO categories (name, color) + VALUES ($1, $2) + RETURNING id, name, color, is_default, created_at, updated_at`, + name, color). + Scan(&c.ID, &c.Name, &c.Color, &c.IsDefault, &c.CreatedAt, &c.UpdatedAt) + return &c, err +} + +func (r *categoryRepo) Update(ctx context.Context, id int, name, color string) (*model.Category, error) { + var c model.Category + err := r.db.QueryRow(ctx, ` + UPDATE categories + SET name = $1, color = $2, updated_at = NOW() + WHERE id = $3 + RETURNING id, name, color, is_default, created_at, updated_at`, + name, color, id). + Scan(&c.ID, &c.Name, &c.Color, &c.IsDefault, &c.CreatedAt, &c.UpdatedAt) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + return &c, err +} + +func (r *categoryRepo) Delete(ctx context.Context, id int) error { + tag, err := r.db.Exec(ctx, `DELETE FROM categories WHERE id = $1`, id) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + return nil +} + +func (r *categoryRepo) HasTransactions(ctx context.Context, id int) (bool, error) { + var count int + err := r.db.QueryRow(ctx, + `SELECT COUNT(*) FROM transactions WHERE category_id = $1`, id). + Scan(&count) + return count > 0, err +} diff --git a/apps/api/internal/service/category.go b/apps/api/internal/service/category.go new file mode 100644 index 0000000..f46224b --- /dev/null +++ b/apps/api/internal/service/category.go @@ -0,0 +1,83 @@ +package service + +import ( + "context" + "errors" + "strings" + + "financeiro-carvalho/internal/model" + "financeiro-carvalho/internal/repository" +) + +var ( + ErrDefaultCategory = errors.New("default categories cannot be deleted") + ErrHasTransactions = errors.New("category has linked transactions") + ErrEmptyName = errors.New("name is required") + ErrInvalidColor = errors.New("color must be a valid hex color (e.g. #FF5733)") +) + +type CategoryService struct { + repo repository.CategoryRepository +} + +func NewCategoryService(repo repository.CategoryRepository) *CategoryService { + return &CategoryService{repo: repo} +} + +func (s *CategoryService) List(ctx context.Context) ([]model.Category, error) { + return s.repo.List(ctx) +} + +func (s *CategoryService) Create(ctx context.Context, in model.CategoryInput) (*model.Category, error) { + if err := validateInput(in); err != nil { + return nil, err + } + return s.repo.Create(ctx, strings.TrimSpace(in.Name), in.Color) +} + +func (s *CategoryService) Update(ctx context.Context, id int, in model.CategoryInput) (*model.Category, error) { + if err := validateInput(in); err != nil { + return nil, err + } + return s.repo.Update(ctx, id, strings.TrimSpace(in.Name), in.Color) +} + +func (s *CategoryService) Delete(ctx context.Context, id int) error { + cat, err := s.repo.GetByID(ctx, id) + if err != nil { + return err + } + if cat.IsDefault { + return ErrDefaultCategory + } + hasTx, err := s.repo.HasTransactions(ctx, id) + if err != nil { + return err + } + if hasTx { + return ErrHasTransactions + } + return s.repo.Delete(ctx, id) +} + +func validateInput(in model.CategoryInput) error { + if strings.TrimSpace(in.Name) == "" { + return ErrEmptyName + } + if !isValidHexColor(in.Color) { + return ErrInvalidColor + } + return nil +} + +func isValidHexColor(c string) bool { + if len(c) != 7 || c[0] != '#' { + return false + } + for _, ch := range c[1:] { + if !((ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'f') || (ch >= 'A' && ch <= 'F')) { + return false + } + } + return true +} diff --git a/apps/api/internal/service/category_test.go b/apps/api/internal/service/category_test.go new file mode 100644 index 0000000..27ab14e --- /dev/null +++ b/apps/api/internal/service/category_test.go @@ -0,0 +1,131 @@ +package service_test + +import ( + "context" + "testing" + + "financeiro-carvalho/internal/model" + "financeiro-carvalho/internal/repository" + "financeiro-carvalho/internal/service" +) + +// mockCategoryRepo implements repository.CategoryRepository in memory. +type mockCategoryRepo struct { + categories []model.Category + txCount map[int]int // category_id → transaction count +} + +func newMockRepo(cats []model.Category) *mockCategoryRepo { + return &mockCategoryRepo{categories: cats, txCount: map[int]int{}} +} + +func (m *mockCategoryRepo) List(_ context.Context) ([]model.Category, error) { + return m.categories, nil +} + +func (m *mockCategoryRepo) GetByID(_ context.Context, id int) (*model.Category, error) { + for _, c := range m.categories { + if c.ID == id { + cp := c + return &cp, nil + } + } + return nil, repository.ErrNotFound +} + +func (m *mockCategoryRepo) Create(_ context.Context, name, color string) (*model.Category, error) { + c := model.Category{ID: len(m.categories) + 1, Name: name, Color: color} + m.categories = append(m.categories, c) + return &c, nil +} + +func (m *mockCategoryRepo) Update(_ context.Context, id int, name, color string) (*model.Category, error) { + for i, c := range m.categories { + if c.ID == id { + m.categories[i].Name = name + m.categories[i].Color = color + cp := m.categories[i] + return &cp, nil + } + } + return nil, repository.ErrNotFound +} + +func (m *mockCategoryRepo) Delete(_ context.Context, id int) error { + for i, c := range m.categories { + if c.ID == id { + m.categories = append(m.categories[:i], m.categories[i+1:]...) + return nil + } + } + return repository.ErrNotFound +} + +func (m *mockCategoryRepo) HasTransactions(_ context.Context, id int) (bool, error) { + return m.txCount[id] > 0, nil +} + +// ── tests ────────────────────────────────────────────────────────────────────── + +func TestDeleteDefaultCategory_Rejected(t *testing.T) { + repo := newMockRepo([]model.Category{ + {ID: 1, Name: "Saúde", Color: "#EF4444", IsDefault: true}, + }) + svc := service.NewCategoryService(repo) + + err := svc.Delete(context.Background(), 1) + if err != service.ErrDefaultCategory { + t.Fatalf("expected ErrDefaultCategory, got %v", err) + } +} + +func TestDeleteCategoryWithTransactions_Rejected(t *testing.T) { + repo := newMockRepo([]model.Category{ + {ID: 2, Name: "Lazer", Color: "#10B981", IsDefault: false}, + }) + repo.txCount[2] = 3 + svc := service.NewCategoryService(repo) + + err := svc.Delete(context.Background(), 2) + if err != service.ErrHasTransactions { + t.Fatalf("expected ErrHasTransactions, got %v", err) + } +} + +func TestDeleteUserCategory_OK(t *testing.T) { + repo := newMockRepo([]model.Category{ + {ID: 3, Name: "Minha Cat", Color: "#123456", IsDefault: false}, + }) + svc := service.NewCategoryService(repo) + + if err := svc.Delete(context.Background(), 3); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestCreateCategory_EmptyName_Rejected(t *testing.T) { + svc := service.NewCategoryService(newMockRepo(nil)) + _, err := svc.Create(context.Background(), model.CategoryInput{Name: " ", Color: "#123456"}) + if err != service.ErrEmptyName { + t.Fatalf("expected ErrEmptyName, got %v", err) + } +} + +func TestCreateCategory_InvalidColor_Rejected(t *testing.T) { + svc := service.NewCategoryService(newMockRepo(nil)) + _, err := svc.Create(context.Background(), model.CategoryInput{Name: "X", Color: "red"}) + if err != service.ErrInvalidColor { + t.Fatalf("expected ErrInvalidColor, got %v", err) + } +} + +func TestCreateCategory_OK(t *testing.T) { + svc := service.NewCategoryService(newMockRepo(nil)) + cat, err := svc.Create(context.Background(), model.CategoryInput{Name: "Nova", Color: "#AABBCC"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cat.Name != "Nova" || cat.Color != "#AABBCC" { + t.Fatalf("unexpected result: %+v", cat) + } +} diff --git a/apps/web/src/App.vue b/apps/web/src/App.vue index 7c2aa3f..3ba4774 100644 --- a/apps/web/src/App.vue +++ b/apps/web/src/App.vue @@ -1,3 +1,34 @@ + + + + + + diff --git a/apps/web/src/router/index.ts b/apps/web/src/router/index.ts index 29788a1..bc11dc7 100644 --- a/apps/web/src/router/index.ts +++ b/apps/web/src/router/index.ts @@ -9,6 +9,11 @@ const router = createRouter({ name: 'home', component: HomeView, }, + { + path: '/categorias', + name: 'categories', + component: () => import('../views/CategoriesView.vue'), + }, ], }) diff --git a/apps/web/src/services/api.ts b/apps/web/src/services/api.ts new file mode 100644 index 0000000..ba3e99e --- /dev/null +++ b/apps/web/src/services/api.ts @@ -0,0 +1,20 @@ +const BASE = '/api' + +async function request(method: string, path: string, body?: unknown): Promise { + const res = await fetch(`${BASE}${path}`, { + method, + headers: body ? { 'Content-Type': 'application/json' } : {}, + body: body ? JSON.stringify(body) : undefined, + }) + if (res.status === 204) return undefined as T + const data = await res.json() + if (!res.ok) throw new Error(data.error ?? 'Erro desconhecido') + return data as T +} + +export const api = { + get: (path: string) => request('GET', path), + post: (path: string, body: unknown) => request('POST', path, body), + put: (path: string, body: unknown) => request('PUT', path, body), + delete: (path: string) => request('DELETE', path), +} diff --git a/apps/web/src/stores/categories.ts b/apps/web/src/stores/categories.ts new file mode 100644 index 0000000..b3ce1c4 --- /dev/null +++ b/apps/web/src/stores/categories.ts @@ -0,0 +1,50 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { api } from '@/services/api' + +export interface Category { + id: number + name: string + color: string + is_default: boolean + created_at: string + updated_at: string +} + +export const useCategoriesStore = defineStore('categories', () => { + const categories = ref([]) + const loading = ref(false) + const error = ref(null) + + async function fetchAll() { + loading.value = true + error.value = null + try { + categories.value = await api.get('/categories') + } catch (e: any) { + error.value = e.message + } finally { + loading.value = false + } + } + + async function create(name: string, color: string) { + const cat = await api.post('/categories', { name, color }) + categories.value.push(cat) + return cat + } + + async function update(id: number, name: string, color: string) { + const cat = await api.put(`/categories/${id}`, { name, color }) + const idx = categories.value.findIndex((c) => c.id === id) + if (idx !== -1) categories.value[idx] = cat + return cat + } + + async function remove(id: number) { + await api.delete(`/categories/${id}`) + categories.value = categories.value.filter((c) => c.id !== id) + } + + return { categories, loading, error, fetchAll, create, update, remove } +}) diff --git a/apps/web/src/views/CategoriesView.vue b/apps/web/src/views/CategoriesView.vue new file mode 100644 index 0000000..7bdacf1 --- /dev/null +++ b/apps/web/src/views/CategoriesView.vue @@ -0,0 +1,159 @@ + + + + +