Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions .claude/IMPROVEMENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# Possible Improvements (deferred)

Items surfaced by the dead-code sweep that were resolved pragmatically and may
be worth revisiting. Each entry: what was chosen, what would be ideal, rough
effort to migrate.

## Email verification flow — half-built, removed

**Removed:** `AuthHandler.VerifyEmail` + `POST /api/v1/auth/verify-email` route.

**State at time of removal:** the handler read a Redis key `verify:<token>` and
called `users.VerifyEmail(userID)` to flip `email_verified=true`. **No code
anywhere in the project ever wrote the `verify:<token>` Redis key**, because
there was no email-sending integration. Registration auto-verifies via
`h.users.VerifyEmail(c.Context(), u.ID)` (auth.go after register), making the
HTTP endpoint unreachable in practice.

**To rebuild later:**
1. Add an SMTP/transactional-email integration (config already has SMTP env
vars per `cmd/health/main.go`).
2. On registration, generate a token, write it to Redis with TTL, and email a
`?token=…` link.
3. Restore the verify-email handler (the deleted version is recoverable from
git history before this cleanup).
4. Stop auto-verifying in the registration handler.
5. Wire a frontend page that POSTs the token. Add a `email_verified` gate to
the relevant routes/middleware.

**DB:** `users.email_verified` and `users.email_verified_at` columns remain
(migration 015) — no schema change needed to restore.

**Effort:** ~half a day, mostly on the email-sender side.

---

## Password reset — placeholder, kept

`AuthHandler.ResetPassword` (auth.go) returns success **without actually
resetting any password**. It's a security smell: the UI thinks the reset
worked, but the password is unchanged. `ForgotPassword` is also a no-op (good
fail-silent UX, but no email is sent).

Not removed because the routes/UI exist and removing them would visibly break
the "forgot password" link. Keep until a real reset flow is built.

**To fix:** same email-sender prerequisite as email verification, plus token
storage and password update in the handler.

Comment on lines +36 to +48
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

ResetPassword silently succeeds without changing the password — prioritize fixing this.

Leaving a route registered that reports success to the client while performing a no-op password change is a significant security UX defect: users who think they've changed their password haven't, and may be locked out or falsely confident their account is secured. This is more urgent than most of the other deferred items and shouldn't wait for the full email-sender implementation — a simple 501 / "not yet implemented" error response is safer than a false 200.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.claude/IMPROVEMENTS.md around lines 36 - 48, AuthHandler.ResetPassword in
auth.go currently returns a successful response without changing any password;
change the handler to stop returning 200-success and instead return a clear 501
Not Implemented (or equivalent error) response and log a descriptive message so
the UI does not falsely report success. Locate the ResetPassword method (and
similarly ForgotPassword if it currently no-ops) and replace the silent success
path with a short-circuit that returns the 501 response and an explanatory log
entry; leave the routes in place so UI links don’t break and add a TODO comment
referencing the future work: email-sender prerequisite, token storage, and
actual password update flow.

---

## Generic admin upload — replaced cover-specific

**Was:** `POST /api/v1/admin/blog/:id/cover` (`BlogHandler.AdminUploadCover`),
which required the post to already exist.

**Is:** `POST /api/v1/admin/upload` (`AdminHandler.Upload`), which accepts a
`prefix` form field (allowlist: `uploads`, `covers`, `banners`, `linktree`,
`brand-kit`, `products`) and returns `{url}`. The frontend
(`AdminBlog.vue:uploadCover`) was already calling this URL — the cover-
specific route was dead in practice and the frontend was broken (404). Fixed
by aligning backend with the upload-before-create pattern the frontend uses.

**Trade-off:** prefix is now client-controlled (with allowlist). For S3 / CDN
migration this surface is the right place to add presigned URLs.

---

## `health_checks` table — orphaned

Migration `008_create_health.up.sql` creates a `health_checks` table for
storing service health snapshots. **No Go code reads or writes it.** The
`cmd/health` binary is a synchronous probe that returns JSON to a caller —
it doesn't persist history.

Not removed because dropping a migration in-place breaks production. To clean
up properly, write a new migration `0XX_drop_health_checks.up.sql` that
`DROP TABLE IF EXISTS health_checks` and the matching `.down.sql`.

**Alternative:** wire `cmd/health` to insert into the table on each run, which
gives free historical health data. Probably more useful than dropping it.

---

## Test-coverage gaps (informational)

Many handler methods in `internal/handlers/admin.go`, `blog.go`, `auth.go`
have 0% coverage from the unit-test suite. They are alive (registered routes
with frontend callers) but lack regression coverage. Not dead code; flagged
for the test-debt backlog.

3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
.dagrobin/db
.dagrobin/
.rtk/
.env
.env.local
.env.production
Expand Down
29 changes: 26 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
lint lint-backend lint-frontend \
fmt fmt-backend fmt-frontend \
typecheck tidy vet \
deadcode deadcode-backend deadcode-frontend \
check ci clean help

# === Local Development (tudo via Docker, 1 porta) ===
Expand Down Expand Up @@ -93,9 +94,11 @@ test-frontend:
lint: lint-backend lint-frontend

lint-backend:
@command -v golangci-lint >/dev/null 2>&1 || { \
echo "golangci-lint not found. Install: brew install golangci-lint"; exit 1; }
cd backend && golangci-lint run ./...
@GOLANGCI=$$(command -v golangci-lint || ls $$(go env GOPATH)/bin/golangci-lint 2>/dev/null); \
if [ -z "$$GOLANGCI" ]; then \
echo "golangci-lint not found. Install: brew install golangci-lint"; exit 1; \
fi; \
cd backend && $$GOLANGCI run ./...

lint-frontend: typecheck

Expand All @@ -117,6 +120,26 @@ vet:
tidy:
cd backend && go mod tidy

## Dead code detection
## Backend: golangci-lint (unused/unparam/ineffassign — already wired in lint-backend)
## + x/tools/deadcode for reachability from main entrypoints.
## Frontend: knip (unused exports/files/deps) + depcheck (orphan package.json deps).
deadcode: deadcode-backend deadcode-frontend

deadcode-backend:
@echo "==> staticcheck/unused/unparam (via golangci-lint)"
@$(MAKE) lint-backend
@echo ""
@echo "==> deadcode (reachability from ./cmd entrypoints)"
cd backend && go run golang.org/x/tools/cmd/deadcode@latest ./cmd/...

deadcode-frontend:
@echo "==> knip (unused exports/files/deps)"
cd frontend && bunx --bun knip --reporter compact || true
@echo ""
@echo "==> depcheck (orphan package.json deps)"
cd frontend && bunx --bun depcheck || true

## Aggregate checks used locally and in CI
check: fmt-backend lint test

Expand Down
5 changes: 2 additions & 3 deletions backend/cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ func main() {
envConfigHandler := handlers.NewEnvConfigHandler(securitySettingsRepo, flagRepo, cfg)
flagHandler := handlers.NewFeatureFlagHandler(flagRepo)
waitlistHandler := handlers.NewWaitlistHandler(waitlistRepo)
adminHandler := handlers.NewAdminHandler(userRepo, bannerRepo, linktreeRepo, brandKitRepo, emailGroupRepo, emailSubRepo, userGroupRepo, cfg)
adminHandler := handlers.NewAdminHandler(userRepo, bannerRepo, linktreeRepo, brandKitRepo, emailGroupRepo, emailSubRepo, userGroupRepo, storageBackend, cfg)
storeHandler := handlers.NewStoreHandler(productRepo, categoryRepo, orderRepo, couponRepo, cfg)
couponHandler := handlers.NewCouponHandler(couponRepo)
paymentHandler := handlers.NewPaymentHandler(orderRepo, pixConfigRepo, cfg, storageBackend)
Expand Down Expand Up @@ -238,7 +238,6 @@ func main() {
middleware.RateLimit(rdb, middleware.RateLimitConfig{Max: cfg.RateLimitForgot, Window: time.Hour, KeyFunc: middleware.KeyByEmail}),
authHandler.ForgotPassword)
auth.Post("/reset-password", authHandler.ResetPassword)
auth.Post("/verify-email", authHandler.VerifyEmail)

// User panel routes
user := api.Group("/user", middleware.RequireAuth(cfg))
Expand Down Expand Up @@ -365,7 +364,7 @@ func main() {
admin.Post("/blog", blogHandler.AdminCreatePost)
admin.Put("/blog/:id", blogHandler.AdminUpdatePost)
admin.Delete("/blog/:id", blogHandler.AdminDeletePost)
admin.Post("/blog/:id/cover", blogHandler.AdminUploadCover)
admin.Post("/upload", adminHandler.Upload)
admin.Post("/blog/ai-generate", middleware.RequireFeature(flagRepo, "ai_blog_enabled"), blogHandler.AdminAIGenerate)

// Jobs routes
Expand Down
38 changes: 38 additions & 0 deletions backend/internal/handlers/admin.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package handlers

import (
"errors"
"log"
"strconv"

"github.com/afa/blueprint/backend/internal/domain"
Expand All @@ -16,6 +18,7 @@ type AdminHandler struct {
emailGroups domain.EmailGroupRepository
emailSubs domain.EmailSubscriptionRepository
userGroups domain.UserGroupRepository
storage domain.Storage
cfg *config.Config
}

Expand All @@ -27,6 +30,7 @@ func NewAdminHandler(
emailGroups domain.EmailGroupRepository,
emailSubs domain.EmailSubscriptionRepository,
userGroups domain.UserGroupRepository,
storage domain.Storage,
cfg *config.Config,
) *AdminHandler {
return &AdminHandler{
Expand All @@ -37,10 +41,44 @@ func NewAdminHandler(
emailGroups: emailGroups,
emailSubs: emailSubs,
userGroups: userGroups,
storage: storage,
cfg: cfg,
}
}

// Upload handles generic admin file uploads. The frontend sends a multipart
// form with a `file` field and an optional `prefix` (subfolder) field. Only
// a fixed allowlist of prefixes is accepted.
func (h *AdminHandler) Upload(c *fiber.Ctx) error {
file, err := c.FormFile("file")
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, "file is required")
}

prefix := c.FormValue("prefix", "uploads")
allowed := map[string]bool{
"uploads": true,
"covers": true,
"banners": true,
"linktree": true,
"brand-kit": true,
"products": true,
}
if !allowed[prefix] {
return fiber.NewError(fiber.StatusBadRequest, "invalid prefix")
}

url, err := UploadFormFile(c.Context(), h.storage, file, prefix)
if err != nil {
if errors.Is(err, domain.ErrInvalidInput) {
return fiber.NewError(fiber.StatusBadRequest, "invalid upload")
}
log.Printf("admin.Upload: upload failed (prefix=%s, file=%q): %v", prefix, file.Filename, err)
return fiber.NewError(fiber.StatusInternalServerError, "upload failed")
}
return c.JSON(fiber.Map{"url": url})
}

// pagination helpers
func paginate(c *fiber.Ctx) (page, limit, offset int) {
page, _ = strconv.Atoi(c.Query("page", "1"))
Expand Down
41 changes: 0 additions & 41 deletions backend/internal/handlers/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -280,44 +280,3 @@ func (h *AuthHandler) ResetPassword(c *fiber.Ctx) error {
// Placeholder: in production, validate token and update password
return c.JSON(fiber.Map{"message": "password reset successful"})
}

func (h *AuthHandler) VerifyEmail(c *fiber.Ctx) error {
var req struct {
Token string `json:"token"`
}
if err := c.BodyParser(&req); err != nil || req.Token == "" {
return c.Status(400).JSON(fiber.Map{"error": "token is required"})
}

if h.rdb == nil {
return c.Status(500).JSON(fiber.Map{"error": "verification not available"})
}

userID, err := h.rdb.Get(c.Context(), "verify:"+req.Token).Result()
if err != nil {
return c.Status(400).JSON(fiber.Map{"error": "invalid or expired token"})
}

if err := h.users.VerifyEmail(c.Context(), userID); err != nil {
return c.Status(500).JSON(fiber.Map{"error": "verification failed"})
}

h.rdb.Del(c.Context(), "verify:"+req.Token)

u, err := h.users.FindByID(c.Context(), userID)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": "verification failed"})
}

accessToken, refreshToken, err := h.generateTokens(u.ID, u.Role)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": "could not generate tokens"})
}
h.setTokenCookies(c, accessToken, refreshToken)

return c.JSON(fiber.Map{
"message": "email verified",
"user": toUserResponse(u),
"access_token": accessToken,
})
}
1 change: 0 additions & 1 deletion backend/internal/handlers/blog.go
Original file line number Diff line number Diff line change
Expand Up @@ -371,7 +371,6 @@ func (h *BlogHandler) AdminUploadCover(c *fiber.Ctx) error {
}
return c.JSON(fiber.Map{"cover_image": url})
}

func (h *BlogHandler) AdminAIGenerate(c *fiber.Ctx) error {
if h.cfg.OpenAIKey == "" {
return fiber.NewError(fiber.StatusServiceUnavailable, "OPENAI_KEY is not configured")
Expand Down
38 changes: 0 additions & 38 deletions backend/internal/testutil/mocks.go
Original file line number Diff line number Diff line change
Expand Up @@ -163,44 +163,6 @@ func (m *MockFeatureFlagRepo) Set(_ context.Context, key string, enabled bool) e
return nil
}

// ---- MockWaitlistRepo ----

type MockWaitlistRepo struct {
mu sync.RWMutex
entries []domain.WaitlistEntry
}

func NewMockWaitlistRepo() *MockWaitlistRepo {
return &MockWaitlistRepo{}
}

func (m *MockWaitlistRepo) Add(_ context.Context, entry *domain.WaitlistEntry) error {
m.mu.Lock()
defer m.mu.Unlock()
entry.CreatedAt = time.Now()
m.entries = append(m.entries, *entry)
return nil
}

func (m *MockWaitlistRepo) ExistsByEmail(_ context.Context, email string) (bool, error) {
m.mu.RLock()
defer m.mu.RUnlock()
for _, e := range m.entries {
if e.Email == email {
return true, nil
}
}
return false, nil
}

func (m *MockWaitlistRepo) List(_ context.Context) ([]domain.WaitlistEntry, error) {
m.mu.RLock()
defer m.mu.RUnlock()
result := make([]domain.WaitlistEntry, len(m.entries))
copy(result, m.entries)
return result, nil
}

// ---- MockProductRepo ----

type MockProductRepo struct {
Expand Down
4 changes: 0 additions & 4 deletions backend/pkg/logger/logger.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,6 @@ func (l *Logger) Error(ctx context.Context, msg string, meta ...map[string]inter
l.write(ctx, "error", msg, meta)
}

func (l *Logger) Debug(ctx context.Context, msg string, meta ...map[string]interface{}) {
l.write(ctx, "debug", msg, meta)
}

func (l *Logger) write(ctx context.Context, level, msg string, meta []map[string]interface{}) {
_ = ctx
// Always write to stdout
Expand Down
16 changes: 0 additions & 16 deletions backend/pkg/middleware/feature_flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,19 +46,3 @@ func RequireFeature(repo domain.FeatureFlagRepository, key string) fiber.Handler
return c.Next()
}
}

func RequireAnyFeature(repo domain.FeatureFlagRepository, keys ...string) fiber.Handler {
return func(c *fiber.Ctx) error {
for _, key := range keys {
enabled, err := IsFeatureEnabled(c.Context(), repo, key)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "failed to check feature flag")
}
if enabled {
return c.Next()
}
}

return fiber.NewError(fiber.StatusNotFound, "feature not available")
}
}
Loading
Loading