diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 2c6fc94..30e57eb 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -36,16 +36,21 @@ jobs:
go-version: '1.23'
cache: true
+ - name: Install Task
+ run: |
+ sh -c "$(curl --location https://taskfile.dev/install.sh)" -- -d -b ~/.local/bin
+ echo "$HOME/.local/bin" >> $GITHUB_PATH
+
- name: Install dependencies
run: |
go mod download
go mod tidy
- - name: Run go fmt
+ - name: Run go fmt check
run: |
unformatted=$(gofmt -s -l $(find . -name '*.go' -not -path './vendor/*' -not -path './.git/*' 2>/dev/null))
if [ -n "$unformatted" ]; then
- echo "Please run 'go fmt ./...'"
+ echo "Please run 'task fmt' to format code"
echo "$unformatted"
exit 1
fi
@@ -56,8 +61,7 @@ jobs:
- name: Run tests
env:
POSTGRES_DSN: "postgres://postgres:postgres@localhost:5432/linkkeeper_test?sslmode=disable"
- run: |
- go test -v -race -coverprofile=coverage.out -covermode=atomic ./...
+ run: task test:coverage
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v4
@@ -80,6 +84,11 @@ jobs:
with:
go-version: '1.23'
+ - name: Install Task
+ run: |
+ sh -c "$(curl --location https://taskfile.dev/install.sh)" -- -d -b ~/.local/bin
+ echo "$HOME/.local/bin" >> $GITHUB_PATH
+
- name: Run golangci-lint
uses: golangci/golangci-lint-action@v4
with:
@@ -101,14 +110,16 @@ jobs:
go-version: '1.23'
cache: true
- - name: Build api-service
- run: go build -v -o bin/api-service ./cmd/api-service
-
- - name: Build user-service
- run: go build -v -o bin/user-service ./cmd/user-service
+ - name: Install Task
+ run: |
+ sh -c "$(curl --location https://taskfile.dev/install.sh)" -- -d -b ~/.local/bin
+ echo "$HOME/.local/bin" >> $GITHUB_PATH
- - name: Build bot-service
- run: go build -v -o bin/bot-service ./cmd/bot-service
+ - name: Build all services
+ run: |
+ task api:build
+ task user:build
+ task bot:build
docker:
name: Docker Build
diff --git a/Makefile b/Makefile
deleted file mode 100644
index 72ca7a0..0000000
--- a/Makefile
+++ /dev/null
@@ -1,41 +0,0 @@
-.PHONY: help install-hooks test test-coverage lint fmt clean
-
-help:
- @echo "Available commands:"
- @echo " make install-hooks - Install pre-commit hooks"
- @echo " make test - Run all tests"
- @echo " make test-coverage - Run tests with coverage report"
- @echo " make lint - Run linters"
- @echo " make fmt - Format code"
- @echo " make clean - Clean build artifacts"
-
-install-hooks:
- @echo "Installing pre-commit hooks..."
- pip install pre-commit || pip3 install pre-commit
- pre-commit install
- @echo "Hooks installed successfully!"
-
-test:
- @echo "Running tests..."
- go test -v -race -short ./...
-
-test-coverage:
- @echo "Running tests with coverage..."
- go test -v -race -coverprofile=coverage.out -covermode=atomic ./...
- go tool cover -html=coverage.out -o coverage.html
- @echo "Coverage report generated: coverage.html"
-
-lint:
- @echo "Running linters..."
- golangci-lint run --timeout=5m
-
-fmt:
- @echo "Formatting code..."
- gofmt -s -w .
- goimports -w .
-
-clean:
- @echo "Cleaning build artifacts..."
- rm -rf bin/
- rm -f coverage.out coverage.html
- go clean -cache -testcache
diff --git a/Taskfile.yml b/Taskfile.yml
index 85b938f..4914171 100644
--- a/Taskfile.yml
+++ b/Taskfile.yml
@@ -239,7 +239,12 @@ tasks:
desc: Check Go code with linter
cmds:
- go vet ./...
- - golangci-lint run --timeout=5m || echo "golangci-lint not installed. Run: brew install golangci-lint"
+ - |
+ if ! command -v golangci-lint &> /dev/null; then
+ echo "golangci-lint not installed. Run: brew install golangci-lint"
+ exit 1
+ fi
+ golangci-lint run --timeout=5m
test:
desc: Run all tests
@@ -261,8 +266,9 @@ tasks:
cmds:
- go test -v -race -coverprofile=coverage.out -covermode=atomic ./...
- go tool cover -html=coverage.out -o coverage.html
- - echo "Coverage report generated: coverage.html"
- - go tool cover -func=coverage.out | grep total
+ - |
+ echo "Coverage report generated: coverage.html"
+ go tool cover -func=coverage.out | grep total || true
test:watch:
desc: Watch and run tests on file changes
diff --git a/coverage.html b/coverage.html
new file mode 100644
index 0000000..e53d1b1
--- /dev/null
+++ b/coverage.html
@@ -0,0 +1,1915 @@
+
+
+
+
+
+
+
package main
+
+import (
+ "context"
+ "os"
+ "os/signal"
+ "syscall"
+ "time"
+
+ repo "github.com/danilovid/linkkeeper/internal/api-service/repository"
+ "github.com/danilovid/linkkeeper/internal/api-service/transport/http"
+ "github.com/danilovid/linkkeeper/internal/api-service/usecase"
+ "github.com/danilovid/linkkeeper/pkg/config"
+ "github.com/danilovid/linkkeeper/pkg/database/postgresql"
+ "github.com/danilovid/linkkeeper/pkg/httpclient"
+ "github.com/danilovid/linkkeeper/pkg/logger"
+)
+
+const shutdownTimeout = 5 * time.Second
+
+func main() {
+ cfg := config.New()
+
+ logger.Init()
+
+ db := postgresql.New(cfg.PostgresDSN, &repo.LinkModel{})
+ linkRepo := repo.NewLinkRepo(db)
+ linkSvc := usecase.NewLinkService(linkRepo)
+
+ httpSrv := http.NewServer(linkSvc)
+ srv := httpclient.New(cfg.HTTPAddr, httpSrv.Handler(), nil)
+
+ go func() {
+ logger.L().Info().Str("addr", cfg.HTTPAddr).Msg("api listening")
+ if err := srv.ListenAndServe(); err != nil {
+ logger.L().Fatal().Err(err).Msg("http server")
+ }
+ }()
+
+ waitForShutdown(func() {
+ ctx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
+ defer cancel()
+ _ = srv.Shutdown(ctx)
+ })
+}
+
+func waitForShutdown(fn func()) {
+ ch := make(chan os.Signal, 1)
+ signal.Notify(ch, syscall.SIGINT, syscall.SIGTERM)
+ <-ch
+ fn()
+}
+
+
+
package main
+
+import (
+ "os"
+ "strconv"
+ "time"
+
+ "github.com/danilovid/linkkeeper/internal/bot-service/bot"
+ "github.com/danilovid/linkkeeper/pkg/logger"
+)
+
+const defaultTimeout = 10 * time.Second
+
+func main() {
+ logger.Init()
+
+ cfg := bot.Config{
+ Token: os.Getenv("TELEGRAM_TOKEN"),
+ APIBaseURL: os.Getenv("API_BASE_URL"),
+ UserServiceURL: os.Getenv("USER_SERVICE_URL"),
+ Timeout: readTimeout(),
+ }
+
+ w, err := bot.NewWrapper(&cfg)
+ if err != nil {
+ logger.L().Fatal().Err(err).Msg("init bot")
+ }
+
+ logger.L().Info().Msg("bot started")
+ if err := w.Start(); err != nil {
+ logger.L().Fatal().Err(err).Msg("bot stopped")
+ }
+}
+
+func readTimeout() time.Duration {
+ raw := os.Getenv("BOT_TIMEOUT_SECONDS")
+ if raw == "" {
+ return defaultTimeout
+ }
+ seconds, err := strconv.Atoi(raw)
+ if err != nil || seconds <= 0 {
+ return defaultTimeout
+ }
+ return time.Duration(seconds) * time.Second
+}
+
+
+
package main
+
+import (
+ "context"
+ "os"
+ "os/signal"
+ "syscall"
+ "time"
+
+ repo "github.com/danilovid/linkkeeper/internal/user-service/repository"
+ "github.com/danilovid/linkkeeper/internal/user-service/transport/http"
+ "github.com/danilovid/linkkeeper/internal/user-service/usecase"
+ "github.com/danilovid/linkkeeper/pkg/config"
+ "github.com/danilovid/linkkeeper/pkg/database/postgresql"
+ "github.com/danilovid/linkkeeper/pkg/httpclient"
+ "github.com/danilovid/linkkeeper/pkg/logger"
+
+ userservice "github.com/danilovid/linkkeeper/internal/user-service"
+)
+
+const shutdownTimeout = 5 * time.Second
+
+func main() {
+ cfg := config.New()
+
+ logger.Init()
+
+ db := postgresql.New(cfg.PostgresDSN, &userservice.UserModel{})
+ userRepo := repo.NewUserRepo(db)
+ userSvc := usecase.NewUserService(userRepo)
+
+ httpSrv := http.NewServer(userSvc)
+ srv := httpclient.New(cfg.HTTPAddr, httpSrv.Handler(), nil)
+
+ go func() {
+ logger.L().Info().Str("addr", cfg.HTTPAddr).Msg("user-service listening")
+ if err := srv.ListenAndServe(); err != nil {
+ logger.L().Fatal().Err(err).Msg("http server")
+ }
+ }()
+
+ waitForShutdown(func() {
+ ctx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
+ defer cancel()
+ _ = srv.Shutdown(ctx)
+ })
+}
+
+func waitForShutdown(fn func()) {
+ ch := make(chan os.Signal, 1)
+ signal.Notify(ch, syscall.SIGINT, syscall.SIGTERM)
+ <-ch
+ fn()
+}
+
+
+
package repository
+
+import (
+ "context"
+ "errors"
+ "time"
+
+ "github.com/google/uuid"
+ "gorm.io/gorm"
+
+ apiservice "github.com/danilovid/linkkeeper/internal/api-service"
+)
+
+type LinkRepo struct {
+ db *gorm.DB
+}
+
+func NewLinkRepo(db *gorm.DB) *LinkRepo {
+ return &LinkRepo{db: db}
+}
+
+type LinkModel struct {
+ ID string `gorm:"type:uuid;primaryKey"`
+ URL string `gorm:"not null"`
+ Resource string `gorm:"not null;default:''"`
+ Views int64 `gorm:"not null;default:0"`
+ ViewedAt *time.Time `gorm:"default:null"`
+ CreatedAt time.Time `gorm:"autoCreateTime"`
+ UpdatedAt time.Time `gorm:"autoUpdateTime"`
+}
+
+func (r *LinkRepo) Create(ctx context.Context, input apiservice.LinkCreateInput) (apiservice.Link, error) {
+ model := LinkModel{
+ ID: uuid.NewString(),
+ URL: input.URL,
+ Resource: input.Resource,
+ }
+ if err := r.db.WithContext(ctx).Create(&model).Error; err != nil {
+ return apiservice.Link{}, err
+ }
+ return toLink(model), nil
+}
+
+func (r *LinkRepo) GetByID(ctx context.Context, id string) (apiservice.Link, error) {
+ var model LinkModel
+ if err := r.db.WithContext(ctx).First(&model, "id = ?", id).Error; err != nil {
+ return apiservice.Link{}, mapErr(err)
+ }
+ return toLink(model), nil
+}
+
+func (r *LinkRepo) List(ctx context.Context, limit, offset int) ([]apiservice.Link, error) {
+ var models []LinkModel
+ if err := r.db.WithContext(ctx).
+ Order("created_at desc").
+ Limit(limit).
+ Offset(offset).
+ Find(&models).Error; err != nil {
+ return nil, err
+ }
+ out := make([]apiservice.Link, 0, len(models))
+ for _, m := range models {
+ out = append(out, toLink(m))
+ }
+ return out, nil
+}
+
+func (r *LinkRepo) Random(ctx context.Context, resource string) (apiservice.Link, error) {
+ var model LinkModel
+ q := r.db.WithContext(ctx).Model(&LinkModel{})
+ if resource != "" {
+ q = q.Where("resource = ?", resource)
+ }
+ if err := q.Order("random()").Limit(1).Take(&model).Error; err != nil {
+ return apiservice.Link{}, mapErr(err)
+ }
+ return toLink(model), nil
+}
+
+func (r *LinkRepo) Update(ctx context.Context, id string, input apiservice.LinkUpdateInput) (apiservice.Link, error) {
+ updates := map[string]any{}
+ if input.URL != nil {
+ updates["url"] = *input.URL
+ }
+ if input.Resource != nil {
+ updates["resource"] = *input.Resource
+ }
+ if len(updates) > 0 {
+ res := r.db.WithContext(ctx).
+ Model(&LinkModel{}).
+ Where("id = ?", id).
+ Updates(updates)
+ if res.Error != nil {
+ return apiservice.Link{}, res.Error
+ }
+ if res.RowsAffected == 0 {
+ return apiservice.Link{}, apiservice.ErrNotFound
+ }
+ }
+ return r.GetByID(ctx, id)
+}
+
+func (r *LinkRepo) Delete(ctx context.Context, id string) error {
+ res := r.db.WithContext(ctx).Delete(&LinkModel{}, "id = ?", id)
+ if res.Error != nil {
+ return res.Error
+ }
+ if res.RowsAffected == 0 {
+ return apiservice.ErrNotFound
+ }
+ return nil
+}
+
+func (r *LinkRepo) MarkViewed(ctx context.Context, id string) (apiservice.Link, error) {
+ now := time.Now()
+ res := r.db.WithContext(ctx).
+ Model(&LinkModel{}).
+ Where("id = ?", id).
+ Updates(map[string]any{
+ "views": gorm.Expr("views + 1"),
+ "viewed_at": &now,
+ })
+ if res.Error != nil {
+ return apiservice.Link{}, res.Error
+ }
+ if res.RowsAffected == 0 {
+ return apiservice.Link{}, apiservice.ErrNotFound
+ }
+ return r.GetByID(ctx, id)
+}
+
+func (r *LinkRepo) GetViewStats(ctx context.Context, days int) ([]apiservice.ViewStats, error) {
+ if days <= 0 {
+ days = 53
+ }
+ if days > 365 {
+ days = 365
+ }
+
+ type resultRow struct {
+ Date string `gorm:"column:date"`
+ Count int64 `gorm:"column:count"`
+ }
+ var results []resultRow
+ startDate := time.Now().AddDate(0, 0, -days+1).Truncate(24 * time.Hour)
+ err := r.db.WithContext(ctx).
+ Model(&LinkModel{}).
+ Select("DATE(viewed_at)::text as date, COUNT(*)::bigint as count").
+ Where("viewed_at IS NOT NULL").
+ Where("viewed_at >= ?", startDate).
+ Group("DATE(viewed_at)").
+ Order("date ASC").
+ Scan(&results).Error
+
+ if err != nil {
+ return nil, err
+ }
+
+ statsMap := make(map[string]int64)
+ for _, r := range results {
+ statsMap[r.Date] = r.Count
+ }
+
+ var maxCount int64
+ for _, count := range statsMap {
+ if count > maxCount {
+ maxCount = count
+ }
+ }
+
+ now := time.Now()
+ stats := make([]apiservice.ViewStats, 0, days)
+ for i := days - 1; i >= 0; i-- {
+ date := now.AddDate(0, 0, -i)
+ dateStr := date.Format("2006-01-02")
+
+ count := statsMap[dateStr]
+ level := 0
+ if maxCount > 0 {
+ ratio := float64(count) / float64(maxCount)
+ if ratio > 0.8 {
+ level = 4
+ } else if ratio > 0.6 {
+ level = 3
+ } else if ratio > 0.4 {
+ level = 2
+ } else if ratio > 0.2 {
+ level = 1
+ }
+ }
+
+ stats = append(stats, apiservice.ViewStats{
+ Date: dateStr,
+ Count: count,
+ Level: level,
+ })
+ }
+
+ return stats, nil
+}
+
+func mapErr(err error) error {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return apiservice.ErrNotFound
+ }
+ return err
+}
+
+func toLink(m LinkModel) apiservice.Link {
+ return apiservice.Link{
+ ID: m.ID,
+ URL: m.URL,
+ Resource: m.Resource,
+ Views: m.Views,
+ ViewedAt: m.ViewedAt,
+ CreatedAt: m.CreatedAt,
+ UpdatedAt: m.UpdatedAt,
+ }
+}
+
+
+
package http
+
+import (
+ "net/http"
+ "time"
+
+ "github.com/gorilla/mux"
+ "github.com/justinas/alice"
+
+ apiservice "github.com/danilovid/linkkeeper/internal/api-service"
+ "github.com/danilovid/linkkeeper/pkg/logger"
+ "github.com/rs/cors"
+)
+
+type Server struct {
+ uc apiservice.LinkService
+ router *mux.Router
+ handler http.Handler
+}
+
+func NewServer(uc apiservice.LinkService) *Server {
+ r := mux.NewRouter()
+ s := &Server{
+ uc: uc,
+ router: r,
+ }
+ s.routes()
+ return s
+}
+
+func (s *Server) Handler() http.Handler {
+ return s.handler
+}
+
+func (s *Server) routes() {
+ s.router.StrictSlash(true)
+
+ corsOpts := cors.Options{
+ AllowedOrigins: []string{"*"},
+ AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
+ AllowedHeaders: []string{"Origin", "Accept", "Content-Type", "X-Requested-With", "Authorization", "X-Auth-Key"},
+ }
+
+ s.handler = alice.New(
+ requestLogger,
+ cors.New(corsOpts).Handler,
+ ).Then(s.router)
+
+ s.router.HandleFunc("/health", Health).Methods(http.MethodGet)
+
+ api := s.router.PathPrefix("/api/v1").Subrouter()
+ api.HandleFunc("/links", s.Create()).Methods(http.MethodPost)
+ api.HandleFunc("/links", s.List()).Methods(http.MethodGet)
+ api.HandleFunc("/links/random", s.Random()).Methods(http.MethodGet)
+ api.HandleFunc("/links/{id}", s.Get()).Methods(http.MethodGet)
+ api.HandleFunc("/links/{id}", s.Update()).Methods(http.MethodPatch)
+ api.HandleFunc("/links/{id}", s.Delete()).Methods(http.MethodDelete)
+ api.HandleFunc("/links/{id}/viewed", s.MarkViewed()).Methods(http.MethodPost)
+ api.HandleFunc("/stats/views", s.GetViewStats()).Methods(http.MethodGet)
+}
+
+func requestLogger(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ start := time.Now()
+ next.ServeHTTP(w, r)
+ logger.L().Info().
+ Str("method", r.Method).
+ Str("path", r.URL.Path).
+ Dur("duration", time.Since(start)).
+ Msg("request")
+ })
+}
+
+
+
package http
+
+import (
+ "encoding/json"
+ "errors"
+ "net/http"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/gorilla/mux"
+
+ apiservice "github.com/danilovid/linkkeeper/internal/api-service"
+)
+
+type createLinkRequest struct {
+ URL string `json:"url"`
+ Resource string `json:"resource"`
+}
+
+type updateLinkRequest struct {
+ URL *string `json:"url"`
+ Resource *string `json:"resource"`
+}
+
+type linkResponse struct {
+ ID string `json:"id"`
+ URL string `json:"url"`
+ Resource string `json:"resource,omitempty"`
+ Views int64 `json:"views"`
+ ViewedAt *time.Time `json:"viewed_at,omitempty"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+}
+
+func Health(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusOK)
+}
+
+func (s *Server) Create() http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req createLinkRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ http.Error(w, "bad json", http.StatusBadRequest)
+ return
+ }
+ req.URL = strings.TrimSpace(req.URL)
+ req.Resource = strings.TrimSpace(req.Resource)
+ input := apiservice.LinkCreateInput{
+ URL: req.URL,
+ Resource: req.Resource,
+ }
+ link, err := s.uc.Create(r.Context(), input)
+ if err != nil {
+ writeError(w, err)
+ return
+ }
+ writeJSON(w, http.StatusCreated, toLinkResponse(link))
+ }
+}
+
+func (s *Server) Get() http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ id := mux.Vars(r)["id"]
+ link, err := s.uc.GetByID(r.Context(), id)
+ if err != nil {
+ writeError(w, err)
+ return
+ }
+ writeJSON(w, http.StatusOK, toLinkResponse(link))
+ }
+}
+
+func (s *Server) List() http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ limit := parseIntDefault(r.URL.Query().Get("limit"), 50)
+ offset := parseIntDefault(r.URL.Query().Get("offset"), 0)
+ if limit > 200 {
+ limit = 200
+ }
+ links, err := s.uc.List(r.Context(), limit, offset)
+ if err != nil {
+ writeError(w, err)
+ return
+ }
+ resp := make([]linkResponse, 0, len(links))
+ for _, link := range links {
+ resp = append(resp, toLinkResponse(link))
+ }
+ writeJSON(w, http.StatusOK, resp)
+ }
+}
+
+func (s *Server) Random() http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ resource := strings.TrimSpace(r.URL.Query().Get("resource"))
+ link, err := s.uc.Random(r.Context(), resource)
+ if err != nil {
+ writeError(w, err)
+ return
+ }
+ writeJSON(w, http.StatusOK, toLinkResponse(link))
+ }
+}
+
+func (s *Server) Update() http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ id := mux.Vars(r)["id"]
+ var req updateLinkRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ http.Error(w, "bad json", http.StatusBadRequest)
+ return
+ }
+ if req.URL != nil {
+ trimmed := strings.TrimSpace(*req.URL)
+ req.URL = &trimmed
+ }
+ if req.Resource != nil {
+ trimmed := strings.TrimSpace(*req.Resource)
+ req.Resource = &trimmed
+ }
+ input := apiservice.LinkUpdateInput{
+ URL: req.URL,
+ Resource: req.Resource,
+ }
+ link, err := s.uc.Update(r.Context(), id, input)
+ if err != nil {
+ writeError(w, err)
+ return
+ }
+ writeJSON(w, http.StatusOK, toLinkResponse(link))
+ }
+}
+
+func (s *Server) Delete() http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ id := mux.Vars(r)["id"]
+ if err := s.uc.Delete(r.Context(), id); err != nil {
+ writeError(w, err)
+ return
+ }
+ w.WriteHeader(http.StatusNoContent)
+ }
+}
+
+func (s *Server) MarkViewed() http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ id := mux.Vars(r)["id"]
+ link, err := s.uc.MarkViewed(r.Context(), id)
+ if err != nil {
+ writeError(w, err)
+ return
+ }
+ writeJSON(w, http.StatusOK, toLinkResponse(link))
+ }
+}
+
+func (s *Server) GetViewStats() http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ days := parseIntDefault(r.URL.Query().Get("days"), 53)
+ if days > 365 {
+ days = 365
+ }
+ stats, err := s.uc.GetViewStats(r.Context(), days)
+ if err != nil {
+ writeError(w, err)
+ return
+ }
+ writeJSON(w, http.StatusOK, stats)
+ }
+}
+
+func writeJSON(w http.ResponseWriter, status int, v any) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(status)
+ _ = json.NewEncoder(w).Encode(v)
+}
+
+func writeError(w http.ResponseWriter, err error) {
+ switch {
+ case errors.Is(err, apiservice.ErrNotFound):
+ http.Error(w, "not found", http.StatusNotFound)
+ case errors.Is(err, apiservice.ErrInvalidInput):
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ default:
+ http.Error(w, "internal error", http.StatusInternalServerError)
+ }
+}
+
+func toLinkResponse(link apiservice.Link) linkResponse {
+ return linkResponse{
+ ID: link.ID,
+ URL: link.URL,
+ Resource: link.Resource,
+ Views: link.Views,
+ ViewedAt: link.ViewedAt,
+ CreatedAt: link.CreatedAt,
+ UpdatedAt: link.UpdatedAt,
+ }
+}
+
+func parseIntDefault(raw string, def int) int {
+ if raw == "" {
+ return def
+ }
+ parsed, err := strconv.Atoi(raw)
+ if err != nil {
+ return def
+ }
+ return parsed
+}
+
+
+
package usecase
+
+import (
+ "context"
+ "fmt"
+
+ apiservice "github.com/danilovid/linkkeeper/internal/api-service"
+)
+
+type LinkService struct {
+ repo apiservice.LinkRepository
+}
+
+func NewLinkService(repo apiservice.LinkRepository) *LinkService {
+ return &LinkService{repo: repo}
+}
+
+func (s *LinkService) Create(ctx context.Context, input apiservice.LinkCreateInput) (apiservice.Link, error) {
+ if err := validateCreate(input); err != nil {
+ return apiservice.Link{}, err
+ }
+ return s.repo.Create(ctx, input)
+}
+
+func (s *LinkService) GetByID(ctx context.Context, id string) (apiservice.Link, error) {
+ return s.repo.GetByID(ctx, id)
+}
+
+func (s *LinkService) List(ctx context.Context, limit, offset int) ([]apiservice.Link, error) {
+ return s.repo.List(ctx, limit, offset)
+}
+
+func (s *LinkService) Random(ctx context.Context, resource string) (apiservice.Link, error) {
+ return s.repo.Random(ctx, resource)
+}
+
+func (s *LinkService) Update(ctx context.Context, id string, input apiservice.LinkUpdateInput) (apiservice.Link, error) {
+ if err := validateUpdate(input); err != nil {
+ return apiservice.Link{}, err
+ }
+ return s.repo.Update(ctx, id, input)
+}
+
+func (s *LinkService) Delete(ctx context.Context, id string) error {
+ return s.repo.Delete(ctx, id)
+}
+
+func (s *LinkService) MarkViewed(ctx context.Context, id string) (apiservice.Link, error) {
+ return s.repo.MarkViewed(ctx, id)
+}
+
+func (s *LinkService) GetViewStats(ctx context.Context, days int) ([]apiservice.ViewStats, error) {
+ if days <= 0 {
+ days = 53
+ }
+ return s.repo.GetViewStats(ctx, days)
+}
+
+func validateCreate(input apiservice.LinkCreateInput) error {
+ if input.URL == "" {
+ return fmt.Errorf("%w: url is required", apiservice.ErrInvalidInput)
+ }
+ return nil
+}
+
+func validateUpdate(input apiservice.LinkUpdateInput) error {
+ if input.URL == nil && input.Resource == nil {
+ return fmt.Errorf("%w: no fields to update", apiservice.ErrInvalidInput)
+ }
+ if input.URL != nil && *input.URL == "" {
+ return fmt.Errorf("%w: url is required", apiservice.ErrInvalidInput)
+ }
+ return nil
+}
+
+
+
package api
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/url"
+ "strings"
+ "time"
+)
+
+type Client struct {
+ baseURL string
+ http *http.Client
+}
+
+type Link struct {
+ ID string `json:"id"`
+ URL string `json:"url"`
+ Resource string `json:"resource"`
+}
+
+func NewClient(baseURL string, timeout time.Duration) *Client {
+ return &Client{
+ baseURL: strings.TrimRight(baseURL, "/"),
+ http: &http.Client{Timeout: timeout},
+ }
+}
+
+func (c *Client) CreateLink(ctx context.Context, url string) (string, error) {
+ payload, err := json.Marshal(map[string]string{"url": url})
+ if err != nil {
+ return "", err
+ }
+ req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/api/v1/links", bytes.NewReader(payload))
+ if err != nil {
+ return "", err
+ }
+ req.Header.Set("Content-Type", "application/json")
+ resp, err := c.http.Do(req)
+ if err != nil {
+ return "", err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode >= 300 {
+ return "", fmt.Errorf("api status: %s", resp.Status)
+ }
+ var out struct {
+ ID string `json:"id"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
+ return "", err
+ }
+ return out.ID, nil
+}
+
+func (c *Client) MarkViewed(ctx context.Context, id string) error {
+ req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/api/v1/links/"+id+"/viewed", http.NoBody)
+ if err != nil {
+ return err
+ }
+ resp, err := c.http.Do(req)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode >= 300 {
+ return fmt.Errorf("api status: %s", resp.Status)
+ }
+ return nil
+}
+
+func (c *Client) RandomLink(ctx context.Context, resource string) (Link, error) {
+ requestURL := c.baseURL + "/api/v1/links/random"
+ if resource != "" {
+ requestURL += "?resource=" + url.QueryEscape(resource)
+ }
+ req, err := http.NewRequestWithContext(ctx, "GET", requestURL, http.NoBody)
+ if err != nil {
+ return Link{}, err
+ }
+ resp, err := c.http.Do(req)
+ if err != nil {
+ return Link{}, err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode >= 300 {
+ return Link{}, fmt.Errorf("api status: %s", resp.Status)
+ }
+ var out Link
+ if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
+ return Link{}, err
+ }
+ return out, nil
+}
+
+
+
package bot
+
+import (
+ "errors"
+ "strings"
+ "time"
+)
+
+type Config struct {
+ Token string
+ APIBaseURL string
+ UserServiceURL string
+ Timeout time.Duration
+}
+
+func (c *Config) Validate() error {
+ if strings.TrimSpace(c.Token) == "" {
+ return errors.New("missing TELEGRAM_TOKEN")
+ }
+ if strings.TrimSpace(c.APIBaseURL) == "" {
+ return errors.New("missing API_BASE_URL")
+ }
+ if strings.TrimSpace(c.UserServiceURL) == "" {
+ return errors.New("missing USER_SERVICE_URL")
+ }
+ if c.Timeout <= 0 {
+ c.Timeout = 10 * time.Second
+ }
+ return nil
+}
+
+
+
package bot
+
+import (
+ "context"
+ "strings"
+
+ "github.com/danilovid/linkkeeper/internal/bot-service/api"
+ "github.com/danilovid/linkkeeper/internal/bot-service/user"
+ "github.com/danilovid/linkkeeper/pkg/logger"
+ tb "gopkg.in/telebot.v4"
+ "gopkg.in/telebot.v4/middleware"
+)
+
+type Wrapper struct {
+ bot *tb.Bot
+ config *Config
+ api *api.Client
+ userService *user.Client
+}
+
+var (
+ menu = &tb.ReplyMarkup{ResizeKeyboard: true}
+
+ btnSave = menu.Text("💾 Save link")
+ btnViewed = menu.Text("✅ Mark viewed")
+ btnRandom = menu.Text("🎲 Random")
+ btnRandomArticle = menu.Text("📰 Random article")
+ btnRandomVideo = menu.Text("🎬 Random video")
+)
+
+func NewWrapper(config *Config) (*Wrapper, error) {
+ if err := config.Validate(); err != nil {
+ return nil, err
+ }
+
+ settings := tb.Settings{
+ Token: config.Token,
+ Poller: &tb.LongPoller{Timeout: config.Timeout},
+ }
+
+ b, err := tb.NewBot(settings)
+ if err != nil {
+ return nil, err
+ }
+
+ b.Use(middleware.Logger())
+ b.Use(middleware.AutoRespond())
+
+ w := &Wrapper{
+ bot: b,
+ config: config,
+ api: api.NewClient(config.APIBaseURL, config.Timeout),
+ userService: user.NewClient(config.UserServiceURL, config.Timeout),
+ }
+ w.prepare()
+ return w, nil
+}
+
+func (w *Wrapper) Start() error {
+ w.bot.Start()
+ return nil
+}
+
+func (w *Wrapper) prepare() {
+ menu.Reply(
+ menu.Row(btnSave, btnViewed),
+ menu.Row(btnRandom),
+ menu.Row(btnRandomArticle, btnRandomVideo),
+ )
+
+ w.bot.Handle("/start", func(c tb.Context) error {
+ sender := c.Sender()
+ if sender != nil {
+ ctx := context.Background()
+ _, err := w.userService.GetOrCreateUser(
+ ctx,
+ sender.ID,
+ sender.Username,
+ sender.FirstName,
+ sender.LastName,
+ )
+ if err != nil {
+ logger.L().Error().Err(err).Int64("telegram_id", sender.ID).Msg("failed to get or create user")
+ } else {
+ logger.L().Info().Int64("telegram_id", sender.ID).Str("username", sender.Username).Msg("user registered or retrieved")
+ }
+ }
+ return c.Send("Welcome! Choose an action:", menu)
+ })
+
+ w.bot.Handle("/save", func(c tb.Context) error {
+ url := strings.TrimSpace(c.Message().Payload)
+ if url == "" {
+ return c.Send("usage: /save <url>")
+ }
+ ctx := context.Background()
+ id, err := w.api.CreateLink(ctx, url)
+ if err != nil {
+ logger.L().Error().Err(err).Str("url", url).Msg("create link failed")
+ return c.Send("failed to save link")
+ }
+ return c.Send("saved ✅ id: " + id)
+ })
+
+ w.bot.Handle("/viewed", func(c tb.Context) error {
+ id := strings.TrimSpace(c.Message().Payload)
+ if id == "" {
+ return c.Send("usage: /viewed <id>")
+ }
+ ctx := context.Background()
+ if err := w.api.MarkViewed(ctx, id); err != nil {
+ logger.L().Error().Err(err).Str("id", id).Msg("mark viewed failed")
+ return c.Send("failed to mark viewed")
+ }
+ return c.Send("marked viewed ✅")
+ })
+
+ w.bot.Handle("/random", func(c tb.Context) error {
+ resource := strings.TrimSpace(c.Message().Payload)
+ ctx := context.Background()
+ link, err := w.api.RandomLink(ctx, resource)
+ if err != nil {
+ logger.L().Error().Err(err).Str("resource", resource).Msg("random link failed")
+ return c.Send("failed to get random link")
+ }
+ if link.URL == "" {
+ return c.Send("no links found")
+ }
+ msg := "random ✅\n" + link.URL + "\nID: " + link.ID
+ if link.Resource != "" {
+ msg += "\nResource: " + link.Resource
+ }
+ return c.Send(msg, menu)
+ })
+
+ w.bot.Handle(&btnSave, func(c tb.Context) error {
+ return c.Send("Send link: /save <url>", menu)
+ })
+
+ w.bot.Handle(&btnViewed, func(c tb.Context) error {
+ return c.Send("Send id: /viewed <id>", menu)
+ })
+
+ w.bot.Handle(&btnRandom, func(c tb.Context) error {
+ ctx := context.Background()
+ link, err := w.api.RandomLink(ctx, "")
+ if err != nil {
+ logger.L().Error().Err(err).Msg("random link failed")
+ return c.Send("failed to get random link", menu)
+ }
+ if link.URL == "" {
+ return c.Send("no links found", menu)
+ }
+ msg := "random ✅\n" + link.URL + "\nID: " + link.ID
+ if link.Resource != "" {
+ msg += "\nResource: " + link.Resource
+ }
+ return c.Send(msg, menu)
+ })
+
+ w.bot.Handle(&btnRandomArticle, func(c tb.Context) error {
+ ctx := context.Background()
+ link, err := w.api.RandomLink(ctx, "article")
+ if err != nil {
+ logger.L().Error().Err(err).Msg("random article failed")
+ return c.Send("failed to get random article", menu)
+ }
+ if link.URL == "" {
+ return c.Send("no articles found", menu)
+ }
+ msg := "random ✅\n" + link.URL + "\nID: " + link.ID + "\nResource: article"
+ return c.Send(msg, menu)
+ })
+
+ w.bot.Handle(&btnRandomVideo, func(c tb.Context) error {
+ ctx := context.Background()
+ link, err := w.api.RandomLink(ctx, "video")
+ if err != nil {
+ logger.L().Error().Err(err).Msg("random video failed")
+ return c.Send("failed to get random video", menu)
+ }
+ if link.URL == "" {
+ return c.Send("no videos found", menu)
+ }
+ msg := "random ✅\n" + link.URL + "\nID: " + link.ID + "\nResource: video"
+ return c.Send(msg, menu)
+ })
+
+ w.bot.Handle(tb.OnText, func(c tb.Context) error {
+ text := strings.TrimSpace(c.Text())
+ if text == "" {
+ return nil
+ }
+ if strings.HasPrefix(text, "/") {
+ return c.Send("unknown command, try /save, /viewed, /random", menu)
+ }
+ return c.Send("commands: /save <url>, /viewed <id>, /random [resource]", menu)
+ })
+
+ w.bot.Handle(tb.OnPhoto, func(c tb.Context) error {
+ return c.Send("commands: /save <url>, /viewed <id>, /random [resource]", menu)
+ })
+
+ // reserved for future middleware
+}
+
+
+
package user
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "strings"
+ "time"
+)
+
+type Client struct {
+ baseURL string
+ http *http.Client
+}
+
+type User struct {
+ ID string `json:"id"`
+ TelegramID int64 `json:"telegram_id"`
+ Username string `json:"username,omitempty"`
+ FirstName string `json:"first_name,omitempty"`
+ LastName string `json:"last_name,omitempty"`
+ CreatedAt string `json:"created_at"`
+ UpdatedAt string `json:"updated_at"`
+}
+
+type CreateUserRequest struct {
+ TelegramID int64 `json:"telegram_id"`
+ Username string `json:"username,omitempty"`
+ FirstName string `json:"first_name,omitempty"`
+ LastName string `json:"last_name,omitempty"`
+}
+
+type ExistsResponse struct {
+ Exists bool `json:"exists"`
+}
+
+func NewClient(baseURL string, timeout time.Duration) *Client {
+ return &Client{
+ baseURL: strings.TrimRight(baseURL, "/"),
+ http: &http.Client{Timeout: timeout},
+ }
+}
+
+func (c *Client) GetOrCreateUser(ctx context.Context, telegramID int64, username, firstName, lastName string) (*User, error) {
+ reqData := CreateUserRequest{
+ TelegramID: telegramID,
+ Username: username,
+ FirstName: firstName,
+ LastName: lastName,
+ }
+
+ payload, err := json.Marshal(reqData)
+ if err != nil {
+ return nil, err
+ }
+
+ req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/api/v1/users", bytes.NewReader(payload))
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Set("Content-Type", "application/json")
+
+ resp, err := c.http.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode >= 300 {
+ return nil, fmt.Errorf("api status: %s", resp.Status)
+ }
+
+ var user User
+ if err := json.NewDecoder(resp.Body).Decode(&user); err != nil {
+ return nil, err
+ }
+
+ return &user, nil
+}
+
+func (c *Client) GetUserByTelegramID(ctx context.Context, telegramID int64) (*User, error) {
+ url := fmt.Sprintf("%s/api/v1/users/telegram/%d", c.baseURL, telegramID)
+
+ req, err := http.NewRequestWithContext(ctx, "GET", url, http.NoBody)
+ if err != nil {
+ return nil, err
+ }
+
+ resp, err := c.http.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode >= 300 {
+ return nil, fmt.Errorf("api status: %s", resp.Status)
+ }
+
+ var user User
+ if err := json.NewDecoder(resp.Body).Decode(&user); err != nil {
+ return nil, err
+ }
+
+ return &user, nil
+}
+
+func (c *Client) UserExists(ctx context.Context, telegramID int64) (bool, error) {
+ url := fmt.Sprintf("%s/api/v1/users/telegram/%d/exists", c.baseURL, telegramID)
+
+ req, err := http.NewRequestWithContext(ctx, "GET", url, http.NoBody)
+ if err != nil {
+ return false, err
+ }
+
+ resp, err := c.http.Do(req)
+ if err != nil {
+ return false, err
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode >= 300 {
+ return false, fmt.Errorf("api status: %s", resp.Status)
+ }
+
+ var result ExistsResponse
+ if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
+ return false, err
+ }
+
+ return result.Exists, nil
+}
+
+
+
package userservice
+
+import (
+ "time"
+
+ "github.com/google/uuid"
+ "gorm.io/gorm"
+)
+
+type UserModel struct {
+ ID uuid.UUID `gorm:"type:char(36);primary_key" json:"id"`
+ TelegramID int64 `gorm:"uniqueIndex;not null" json:"telegram_id"`
+ Username string `gorm:"type:varchar(255)" json:"username,omitempty"`
+ FirstName string `gorm:"type:varchar(255)" json:"first_name,omitempty"`
+ LastName string `gorm:"type:varchar(255)" json:"last_name,omitempty"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+}
+
+func (UserModel) TableName() string {
+ return "users"
+}
+
+func (u *UserModel) BeforeCreate(tx *gorm.DB) error {
+ if u.ID == uuid.Nil {
+ u.ID = uuid.New()
+ }
+ return nil
+}
+
+
+
package repository
+
+import (
+ "errors"
+
+ "github.com/google/uuid"
+ "gorm.io/gorm"
+
+ userservice "github.com/danilovid/linkkeeper/internal/user-service"
+)
+
+type userRepo struct {
+ db *gorm.DB
+}
+
+func NewUserRepo(db *gorm.DB) userservice.Repository {
+ return &userRepo{db: db}
+}
+
+func (r *userRepo) Create(user *userservice.UserModel) error {
+ return r.db.Create(user).Error
+}
+
+func (r *userRepo) GetByID(id uuid.UUID) (*userservice.UserModel, error) {
+ var user userservice.UserModel
+ err := r.db.Where("id = ?", id).First(&user).Error
+ if err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return nil, errors.New("user not found")
+ }
+ return nil, err
+ }
+ return &user, nil
+}
+
+func (r *userRepo) GetByTelegramID(telegramID int64) (*userservice.UserModel, error) {
+ var user userservice.UserModel
+ err := r.db.Where("telegram_id = ?", telegramID).First(&user).Error
+ if err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return nil, errors.New("user not found")
+ }
+ return nil, err
+ }
+ return &user, nil
+}
+
+func (r *userRepo) Update(user *userservice.UserModel) error {
+ return r.db.Save(user).Error
+}
+
+func (r *userRepo) Exists(telegramID int64) (bool, error) {
+ var count int64
+ err := r.db.Model(&userservice.UserModel{}).Where("telegram_id = ?", telegramID).Count(&count).Error
+ if err != nil {
+ return false, err
+ }
+ return count > 0, nil
+}
+
+
+
package http
+
+import (
+ "encoding/json"
+ "fmt"
+ "net/http"
+
+ "github.com/google/uuid"
+ "github.com/gorilla/mux"
+
+ userservice "github.com/danilovid/linkkeeper/internal/user-service"
+ "github.com/danilovid/linkkeeper/pkg/logger"
+)
+
+type Server struct {
+ uc userservice.Usecase
+}
+
+func NewServer(uc userservice.Usecase) *Server {
+ return &Server{uc: uc}
+}
+
+func (s *Server) Handler() http.Handler {
+ return s.routes()
+}
+
+type CreateUserRequest struct {
+ TelegramID int64 `json:"telegram_id"`
+ Username string `json:"username,omitempty"`
+ FirstName string `json:"first_name,omitempty"`
+ LastName string `json:"last_name,omitempty"`
+}
+
+type UserResponse struct {
+ ID string `json:"id"`
+ TelegramID int64 `json:"telegram_id"`
+ Username string `json:"username,omitempty"`
+ FirstName string `json:"first_name,omitempty"`
+ LastName string `json:"last_name,omitempty"`
+ CreatedAt string `json:"created_at"`
+ UpdatedAt string `json:"updated_at"`
+}
+
+type ExistsResponse struct {
+ Exists bool `json:"exists"`
+}
+
+func (s *Server) GetOrCreateUser(w http.ResponseWriter, r *http.Request) {
+ var req CreateUserRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ logger.L().Error().Err(err).Msg("failed to decode request")
+ http.Error(w, "invalid request body", http.StatusBadRequest)
+ return
+ }
+
+ if req.TelegramID == 0 {
+ http.Error(w, "telegram_id is required", http.StatusBadRequest)
+ return
+ }
+
+ user, err := s.uc.GetOrCreateUser(req.TelegramID, req.Username, req.FirstName, req.LastName)
+ if err != nil {
+ logger.L().Error().Err(err).Msg("failed to get or create user")
+ http.Error(w, "internal server error", http.StatusInternalServerError)
+ return
+ }
+
+ resp := UserResponse{
+ ID: user.ID.String(),
+ TelegramID: user.TelegramID,
+ Username: user.Username,
+ FirstName: user.FirstName,
+ LastName: user.LastName,
+ CreatedAt: user.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
+ UpdatedAt: user.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+ if err := json.NewEncoder(w).Encode(resp); err != nil {
+ logger.L().Error().Err(err).Msg("failed to encode response")
+ }
+}
+
+func (s *Server) GetUserByID(w http.ResponseWriter, r *http.Request) {
+ vars := mux.Vars(r)
+ idStr := vars["id"]
+
+ id, err := uuid.Parse(idStr)
+ if err != nil {
+ http.Error(w, "invalid user id", http.StatusBadRequest)
+ return
+ }
+
+ user, err := s.uc.GetUserByID(id)
+ if err != nil {
+ logger.L().Error().Err(err).Msg("failed to get user")
+ http.Error(w, "user not found", http.StatusNotFound)
+ return
+ }
+
+ resp := UserResponse{
+ ID: user.ID.String(),
+ TelegramID: user.TelegramID,
+ Username: user.Username,
+ FirstName: user.FirstName,
+ LastName: user.LastName,
+ CreatedAt: user.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
+ UpdatedAt: user.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ if err := json.NewEncoder(w).Encode(resp); err != nil {
+ logger.L().Error().Err(err).Msg("failed to encode response")
+ }
+}
+
+func (s *Server) GetUserByTelegramID(w http.ResponseWriter, r *http.Request) {
+ vars := mux.Vars(r)
+ telegramID := vars["telegram_id"]
+
+ var id int64
+ if _, err := fmt.Sscanf(telegramID, "%d", &id); err != nil {
+ http.Error(w, "invalid telegram_id", http.StatusBadRequest)
+ return
+ }
+
+ user, err := s.uc.GetUserByTelegramID(id)
+ if err != nil {
+ logger.L().Error().Err(err).Msg("failed to get user by telegram id")
+ http.Error(w, "user not found", http.StatusNotFound)
+ return
+ }
+
+ resp := UserResponse{
+ ID: user.ID.String(),
+ TelegramID: user.TelegramID,
+ Username: user.Username,
+ FirstName: user.FirstName,
+ LastName: user.LastName,
+ CreatedAt: user.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
+ UpdatedAt: user.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ if err := json.NewEncoder(w).Encode(resp); err != nil {
+ logger.L().Error().Err(err).Msg("failed to encode response")
+ }
+}
+
+func (s *Server) CheckUserExists(w http.ResponseWriter, r *http.Request) {
+ vars := mux.Vars(r)
+ telegramID := vars["telegram_id"]
+
+ var id int64
+ if _, err := fmt.Sscanf(telegramID, "%d", &id); err != nil {
+ http.Error(w, "invalid telegram_id", http.StatusBadRequest)
+ return
+ }
+
+ exists, err := s.uc.UserExists(id)
+ if err != nil {
+ logger.L().Error().Err(err).Msg("failed to check user existence")
+ http.Error(w, "internal server error", http.StatusInternalServerError)
+ return
+ }
+
+ resp := ExistsResponse{Exists: exists}
+ w.Header().Set("Content-Type", "application/json")
+ if err := json.NewEncoder(w).Encode(resp); err != nil {
+ logger.L().Error().Err(err).Msg("failed to encode response")
+ }
+}
+
+
+
package http
+
+import (
+ "net/http"
+
+ "github.com/gorilla/mux"
+ "github.com/justinas/alice"
+ "github.com/rs/cors"
+
+ "github.com/danilovid/linkkeeper/pkg/logger"
+)
+
+func (s *Server) routes() http.Handler {
+ r := mux.NewRouter()
+
+ middleware := alice.New(
+ logRequest,
+ cors.New(cors.Options{
+ AllowedOrigins: []string{"*"},
+ AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
+ AllowedHeaders: []string{"*"},
+ AllowCredentials: true,
+ }).Handler,
+ )
+
+ api := r.PathPrefix("/api/v1").Subrouter()
+
+ api.HandleFunc("/users", s.GetOrCreateUser).Methods("POST")
+ api.HandleFunc("/users/{id}", s.GetUserByID).Methods("GET")
+ api.HandleFunc("/users/telegram/{telegram_id}", s.GetUserByTelegramID).Methods("GET")
+ api.HandleFunc("/users/telegram/{telegram_id}/exists", s.CheckUserExists).Methods("GET")
+
+ r.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ if _, err := w.Write([]byte("OK")); err != nil {
+ logger.L().Error().Err(err).Msg("failed to write health response")
+ }
+ }).Methods("GET")
+
+ return middleware.Then(r)
+}
+
+func logRequest(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ logger.L().Info().
+ Str("method", r.Method).
+ Str("path", r.URL.Path).
+ Msg("request")
+ next.ServeHTTP(w, r)
+ })
+}
+
+
+
package usecase
+
+import (
+ "github.com/google/uuid"
+
+ userservice "github.com/danilovid/linkkeeper/internal/user-service"
+)
+
+type userUsecase struct {
+ repo userservice.Repository
+}
+
+func NewUserService(repo userservice.Repository) userservice.Usecase {
+ return &userUsecase{repo: repo}
+}
+
+func (u *userUsecase) CreateUser(telegramID int64, username, firstName, lastName string) (*userservice.UserModel, error) {
+ user := &userservice.UserModel{
+ TelegramID: telegramID,
+ Username: username,
+ FirstName: firstName,
+ LastName: lastName,
+ }
+
+ if err := u.repo.Create(user); err != nil {
+ return nil, err
+ }
+
+ return user, nil
+}
+
+func (u *userUsecase) GetUserByID(id uuid.UUID) (*userservice.UserModel, error) {
+ return u.repo.GetByID(id)
+}
+
+func (u *userUsecase) GetUserByTelegramID(telegramID int64) (*userservice.UserModel, error) {
+ return u.repo.GetByTelegramID(telegramID)
+}
+
+func (u *userUsecase) GetOrCreateUser(telegramID int64, username, firstName, lastName string) (*userservice.UserModel, error) {
+ user, err := u.repo.GetByTelegramID(telegramID)
+ if err == nil {
+ return user, nil
+ }
+
+ return u.CreateUser(telegramID, username, firstName, lastName)
+}
+
+func (u *userUsecase) UserExists(telegramID int64) (bool, error) {
+ return u.repo.Exists(telegramID)
+}
+
+
+
package config
+
+import (
+ "flag"
+ "os"
+)
+
+// Config represents system configuration.
+type Config struct {
+ Env string // runtime environment
+ HTTPAddr string // address "[host]:port" for HTTP server
+ PostgresDSN string // Postgres DSN
+}
+
+// New reads config from environment/flags and returns pointer to a new Config.
+func New() *Config {
+ c := &Config{}
+
+ flag.StringVar(&c.Env, "env", lookupEnvString("ENV", "dev"), "Set runtime environment.")
+ flag.StringVar(&c.HTTPAddr, "httpAddr", lookupEnvString("HTTP_ADDR", ":8080"), `Address in form of "[host]:port" that HTTP server should be listening on.`)
+ flag.StringVar(
+ &c.PostgresDSN,
+ "postgresDsn",
+ lookupEnvString("POSTGRES_DSN", "postgres://postgres:postgres@localhost:5432/linkkeeper?sslmode=disable"),
+ "PostgreSQL DSN.",
+ )
+
+ flag.Parse()
+
+ return c
+}
+
+func lookupEnvString(k, def string) string {
+ if v := os.Getenv(k); v != "" {
+ return v
+ }
+ return def
+}
+
+
+
package postgresql
+
+import (
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+
+ "gorm.io/driver/postgres"
+ "gorm.io/gorm"
+
+ "github.com/danilovid/linkkeeper/pkg/logger"
+)
+
+func New(dsn string, models ...any) *gorm.DB {
+ db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
+ if err != nil {
+ logger.L().Fatal().Err(err).Msg("open database")
+ }
+ if len(models) > 0 {
+ if err := db.AutoMigrate(models...); err != nil {
+ logger.L().Fatal().Err(err).Msg("auto migrate")
+ }
+ }
+ if err := applyMigrations(db, migrationsDir()); err != nil {
+ logger.L().Fatal().Err(err).Msg("apply migrations")
+ }
+ return db
+}
+
+func migrationsDir() string {
+ if v := os.Getenv("MIGRATIONS_DIR"); v != "" {
+ return v
+ }
+ return "migrations"
+}
+
+func applyMigrations(db *gorm.DB, dir string) error {
+ if err := db.Exec(`
+ CREATE TABLE IF NOT EXISTS schema_migrations (
+ version text PRIMARY KEY,
+ applied_at timestamptz NOT NULL DEFAULT now()
+ )
+ `).Error; err != nil {
+ return err
+ }
+
+ entries, err := os.ReadDir(dir)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return nil
+ }
+ return err
+ }
+
+ var files []string
+ for _, entry := range entries {
+ if entry.IsDir() {
+ continue
+ }
+ if strings.HasSuffix(entry.Name(), ".sql") {
+ files = append(files, entry.Name())
+ }
+ }
+ sort.Strings(files)
+
+ for _, name := range files {
+ var exists int
+ if err := db.Raw(`SELECT 1 FROM schema_migrations WHERE version = ?`, name).Scan(&exists).Error; err != nil {
+ return err
+ }
+ if exists == 1 {
+ continue
+ }
+
+ path := filepath.Join(dir, name)
+ content, err := os.ReadFile(path)
+ if err != nil {
+ return err
+ }
+ if err := db.Exec(string(content)).Error; err != nil {
+ return err
+ }
+ if err := db.Exec(`INSERT INTO schema_migrations (version) VALUES (?)`, name).Error; err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+
+
package httpclient
+
+import (
+ "crypto/tls"
+ "net/http"
+ "time"
+)
+
+const (
+ readTimeout = 5 * time.Second
+ writeTimeout = 5 * time.Second
+)
+
+func New(httpAddr string, handler http.Handler, tlsConfig *tls.Config) *http.Server {
+ return &http.Server{
+ ReadTimeout: readTimeout,
+ WriteTimeout: writeTimeout,
+ Addr: httpAddr,
+ Handler: handler,
+ TLSConfig: tlsConfig,
+ }
+}
+
+
+
package logger
+
+import (
+ "os"
+ "time"
+
+ "github.com/rs/zerolog"
+ "github.com/rs/zerolog/log"
+)
+
+func Init() {
+ zerolog.TimeFieldFormat = time.RFC3339Nano
+ writer := zerolog.ConsoleWriter{
+ Out: os.Stdout,
+ TimeFormat: time.RFC3339Nano,
+ }
+ log.Logger = log.Output(writer)
+}
+
+func L() *zerolog.Logger {
+ l := log.Logger
+ return &l
+}
+
+
+
+
+
+
diff --git a/coverage.out b/coverage.out
index c00a9c2..3fd1a39 100644
--- a/coverage.out
+++ b/coverage.out
@@ -1,141 +1,10 @@
mode: atomic
-github.com/danilovid/linkkeeper/internal/bot-service/api/client.go:24.63,29.2 1 0
-github.com/danilovid/linkkeeper/internal/bot-service/api/client.go:31.57,33.16 2 0
-github.com/danilovid/linkkeeper/internal/bot-service/api/client.go:33.16,35.3 1 0
-github.com/danilovid/linkkeeper/internal/bot-service/api/client.go:36.2,37.16 2 0
-github.com/danilovid/linkkeeper/internal/bot-service/api/client.go:37.16,39.3 1 0
-github.com/danilovid/linkkeeper/internal/bot-service/api/client.go:40.2,42.16 3 0
-github.com/danilovid/linkkeeper/internal/bot-service/api/client.go:42.16,44.3 1 0
-github.com/danilovid/linkkeeper/internal/bot-service/api/client.go:45.2,46.28 2 0
-github.com/danilovid/linkkeeper/internal/bot-service/api/client.go:46.28,48.3 1 0
-github.com/danilovid/linkkeeper/internal/bot-service/api/client.go:49.2,52.64 2 0
-github.com/danilovid/linkkeeper/internal/bot-service/api/client.go:52.64,54.3 1 0
-github.com/danilovid/linkkeeper/internal/bot-service/api/client.go:55.2,55.20 1 0
-github.com/danilovid/linkkeeper/internal/bot-service/api/client.go:58.46,60.16 2 0
-github.com/danilovid/linkkeeper/internal/bot-service/api/client.go:60.16,62.3 1 0
-github.com/danilovid/linkkeeper/internal/bot-service/api/client.go:63.2,64.16 2 0
-github.com/danilovid/linkkeeper/internal/bot-service/api/client.go:64.16,66.3 1 0
-github.com/danilovid/linkkeeper/internal/bot-service/api/client.go:67.2,68.28 2 0
-github.com/danilovid/linkkeeper/internal/bot-service/api/client.go:68.28,70.3 1 0
-github.com/danilovid/linkkeeper/internal/bot-service/api/client.go:71.2,71.12 1 0
-github.com/danilovid/linkkeeper/internal/bot-service/api/client.go:74.60,76.20 2 0
-github.com/danilovid/linkkeeper/internal/bot-service/api/client.go:76.20,78.3 1 0
-github.com/danilovid/linkkeeper/internal/bot-service/api/client.go:79.2,80.16 2 0
-github.com/danilovid/linkkeeper/internal/bot-service/api/client.go:80.16,82.3 1 0
-github.com/danilovid/linkkeeper/internal/bot-service/api/client.go:83.2,84.16 2 0
-github.com/danilovid/linkkeeper/internal/bot-service/api/client.go:84.16,86.3 1 0
-github.com/danilovid/linkkeeper/internal/bot-service/api/client.go:87.2,88.28 2 0
-github.com/danilovid/linkkeeper/internal/bot-service/api/client.go:88.28,90.3 1 0
-github.com/danilovid/linkkeeper/internal/bot-service/api/client.go:91.2,92.64 2 0
-github.com/danilovid/linkkeeper/internal/bot-service/api/client.go:92.64,94.3 1 0
-github.com/danilovid/linkkeeper/internal/bot-service/api/client.go:95.2,95.17 1 0
-github.com/danilovid/linkkeeper/internal/bot-service/bot/config.go:16.35,17.38 1 0
-github.com/danilovid/linkkeeper/internal/bot-service/bot/config.go:17.38,19.3 1 0
-github.com/danilovid/linkkeeper/internal/bot-service/bot/config.go:20.2,20.43 1 0
-github.com/danilovid/linkkeeper/internal/bot-service/bot/config.go:20.43,22.3 1 0
-github.com/danilovid/linkkeeper/internal/bot-service/bot/config.go:23.2,23.47 1 0
-github.com/danilovid/linkkeeper/internal/bot-service/bot/config.go:23.47,25.3 1 0
-github.com/danilovid/linkkeeper/internal/bot-service/bot/config.go:26.2,26.20 1 0
-github.com/danilovid/linkkeeper/internal/bot-service/bot/config.go:26.20,28.3 1 0
-github.com/danilovid/linkkeeper/internal/bot-service/bot/config.go:29.2,29.12 1 0
-github.com/danilovid/linkkeeper/internal/bot-service/bot/wrapper.go:30.51,31.42 1 0
-github.com/danilovid/linkkeeper/internal/bot-service/bot/wrapper.go:31.42,33.3 1 0
-github.com/danilovid/linkkeeper/internal/bot-service/bot/wrapper.go:35.2,41.16 3 0
-github.com/danilovid/linkkeeper/internal/bot-service/bot/wrapper.go:41.16,43.3 1 0
-github.com/danilovid/linkkeeper/internal/bot-service/bot/wrapper.go:45.2,55.15 5 0
-github.com/danilovid/linkkeeper/internal/bot-service/bot/wrapper.go:58.33,61.2 2 0
-github.com/danilovid/linkkeeper/internal/bot-service/bot/wrapper.go:63.29,70.50 2 0
-github.com/danilovid/linkkeeper/internal/bot-service/bot/wrapper.go:70.50,72.20 2 0
-github.com/danilovid/linkkeeper/internal/bot-service/bot/wrapper.go:72.20,79.18 2 0
-github.com/danilovid/linkkeeper/internal/bot-service/bot/wrapper.go:79.18,81.5 1 0
-github.com/danilovid/linkkeeper/internal/bot-service/bot/wrapper.go:81.10,83.5 1 0
-github.com/danilovid/linkkeeper/internal/bot-service/bot/wrapper.go:85.3,85.52 1 0
-github.com/danilovid/linkkeeper/internal/bot-service/bot/wrapper.go:88.2,88.49 1 0
-github.com/danilovid/linkkeeper/internal/bot-service/bot/wrapper.go:88.49,90.16 2 0
-github.com/danilovid/linkkeeper/internal/bot-service/bot/wrapper.go:90.16,92.4 1 0
-github.com/danilovid/linkkeeper/internal/bot-service/bot/wrapper.go:93.3,94.17 2 0
-github.com/danilovid/linkkeeper/internal/bot-service/bot/wrapper.go:94.17,97.4 2 0
-github.com/danilovid/linkkeeper/internal/bot-service/bot/wrapper.go:98.3,98.39 1 0
-github.com/danilovid/linkkeeper/internal/bot-service/bot/wrapper.go:101.2,101.51 1 0
-github.com/danilovid/linkkeeper/internal/bot-service/bot/wrapper.go:101.51,103.15 2 0
-github.com/danilovid/linkkeeper/internal/bot-service/bot/wrapper.go:103.15,105.4 1 0
-github.com/danilovid/linkkeeper/internal/bot-service/bot/wrapper.go:106.3,106.46 1 0
-github.com/danilovid/linkkeeper/internal/bot-service/bot/wrapper.go:106.46,109.4 2 0
-github.com/danilovid/linkkeeper/internal/bot-service/bot/wrapper.go:110.3,110.37 1 0
-github.com/danilovid/linkkeeper/internal/bot-service/bot/wrapper.go:113.2,113.51 1 0
-github.com/danilovid/linkkeeper/internal/bot-service/bot/wrapper.go:113.51,116.17 3 0
-github.com/danilovid/linkkeeper/internal/bot-service/bot/wrapper.go:116.17,119.4 2 0
-github.com/danilovid/linkkeeper/internal/bot-service/bot/wrapper.go:120.3,120.21 1 0
-github.com/danilovid/linkkeeper/internal/bot-service/bot/wrapper.go:120.21,122.4 1 0
-github.com/danilovid/linkkeeper/internal/bot-service/bot/wrapper.go:123.3,124.26 2 0
-github.com/danilovid/linkkeeper/internal/bot-service/bot/wrapper.go:124.26,126.4 1 0
-github.com/danilovid/linkkeeper/internal/bot-service/bot/wrapper.go:127.3,127.27 1 0
-github.com/danilovid/linkkeeper/internal/bot-service/bot/wrapper.go:130.2,130.50 1 0
-github.com/danilovid/linkkeeper/internal/bot-service/bot/wrapper.go:130.50,132.3 1 0
-github.com/danilovid/linkkeeper/internal/bot-service/bot/wrapper.go:134.2,134.52 1 0
-github.com/danilovid/linkkeeper/internal/bot-service/bot/wrapper.go:134.52,136.3 1 0
-github.com/danilovid/linkkeeper/internal/bot-service/bot/wrapper.go:138.2,138.52 1 0
-github.com/danilovid/linkkeeper/internal/bot-service/bot/wrapper.go:138.52,140.17 2 0
-github.com/danilovid/linkkeeper/internal/bot-service/bot/wrapper.go:140.17,143.4 2 0
-github.com/danilovid/linkkeeper/internal/bot-service/bot/wrapper.go:144.3,144.21 1 0
-github.com/danilovid/linkkeeper/internal/bot-service/bot/wrapper.go:144.21,146.4 1 0
-github.com/danilovid/linkkeeper/internal/bot-service/bot/wrapper.go:147.3,148.26 2 0
-github.com/danilovid/linkkeeper/internal/bot-service/bot/wrapper.go:148.26,150.4 1 0
-github.com/danilovid/linkkeeper/internal/bot-service/bot/wrapper.go:151.3,151.27 1 0
-github.com/danilovid/linkkeeper/internal/bot-service/bot/wrapper.go:154.2,154.59 1 0
-github.com/danilovid/linkkeeper/internal/bot-service/bot/wrapper.go:154.59,156.17 2 0
-github.com/danilovid/linkkeeper/internal/bot-service/bot/wrapper.go:156.17,159.4 2 0
-github.com/danilovid/linkkeeper/internal/bot-service/bot/wrapper.go:160.3,160.21 1 0
-github.com/danilovid/linkkeeper/internal/bot-service/bot/wrapper.go:160.21,162.4 1 0
-github.com/danilovid/linkkeeper/internal/bot-service/bot/wrapper.go:163.3,164.27 2 0
-github.com/danilovid/linkkeeper/internal/bot-service/bot/wrapper.go:167.2,167.57 1 0
-github.com/danilovid/linkkeeper/internal/bot-service/bot/wrapper.go:167.57,169.17 2 0
-github.com/danilovid/linkkeeper/internal/bot-service/bot/wrapper.go:169.17,172.4 2 0
-github.com/danilovid/linkkeeper/internal/bot-service/bot/wrapper.go:173.3,173.21 1 0
-github.com/danilovid/linkkeeper/internal/bot-service/bot/wrapper.go:173.21,175.4 1 0
-github.com/danilovid/linkkeeper/internal/bot-service/bot/wrapper.go:176.3,177.27 2 0
-github.com/danilovid/linkkeeper/internal/bot-service/bot/wrapper.go:180.2,180.51 1 0
-github.com/danilovid/linkkeeper/internal/bot-service/bot/wrapper.go:180.51,182.17 2 0
-github.com/danilovid/linkkeeper/internal/bot-service/bot/wrapper.go:182.17,184.4 1 0
-github.com/danilovid/linkkeeper/internal/bot-service/bot/wrapper.go:185.3,185.35 1 0
-github.com/danilovid/linkkeeper/internal/bot-service/bot/wrapper.go:185.35,187.4 1 0
-github.com/danilovid/linkkeeper/internal/bot-service/bot/wrapper.go:188.3,188.81 1 0
-github.com/danilovid/linkkeeper/internal/bot-service/bot/wrapper.go:191.2,191.52 1 0
-github.com/danilovid/linkkeeper/internal/bot-service/bot/wrapper.go:191.52,193.3 1 0
-github.com/danilovid/linkkeeper/internal/user-service/models.go:20.37,22.2 1 0
-github.com/danilovid/linkkeeper/internal/user-service/models.go:24.53,25.22 1 0
-github.com/danilovid/linkkeeper/internal/user-service/models.go:25.22,27.3 1 0
-github.com/danilovid/linkkeeper/internal/user-service/models.go:28.2,28.12 1 0
-github.com/danilovid/linkkeeper/internal/bot-service/user/client.go:38.63,43.2 1 0
-github.com/danilovid/linkkeeper/internal/bot-service/user/client.go:45.105,54.16 3 0
-github.com/danilovid/linkkeeper/internal/bot-service/user/client.go:54.16,56.3 1 0
-github.com/danilovid/linkkeeper/internal/bot-service/user/client.go:58.2,59.16 2 0
-github.com/danilovid/linkkeeper/internal/bot-service/user/client.go:59.16,61.3 1 0
-github.com/danilovid/linkkeeper/internal/bot-service/user/client.go:62.2,65.16 3 0
-github.com/danilovid/linkkeeper/internal/bot-service/user/client.go:65.16,67.3 1 0
-github.com/danilovid/linkkeeper/internal/bot-service/user/client.go:68.2,70.28 2 0
-github.com/danilovid/linkkeeper/internal/bot-service/user/client.go:70.28,72.3 1 0
-github.com/danilovid/linkkeeper/internal/bot-service/user/client.go:74.2,75.65 2 0
-github.com/danilovid/linkkeeper/internal/bot-service/user/client.go:75.65,77.3 1 0
-github.com/danilovid/linkkeeper/internal/bot-service/user/client.go:79.2,79.19 1 0
-github.com/danilovid/linkkeeper/internal/bot-service/user/client.go:82.71,86.16 3 0
-github.com/danilovid/linkkeeper/internal/bot-service/user/client.go:86.16,88.3 1 0
-github.com/danilovid/linkkeeper/internal/bot-service/user/client.go:90.2,91.16 2 0
-github.com/danilovid/linkkeeper/internal/bot-service/user/client.go:91.16,93.3 1 0
-github.com/danilovid/linkkeeper/internal/bot-service/user/client.go:94.2,96.28 2 0
-github.com/danilovid/linkkeeper/internal/bot-service/user/client.go:96.28,98.3 1 0
-github.com/danilovid/linkkeeper/internal/bot-service/user/client.go:100.2,101.65 2 0
-github.com/danilovid/linkkeeper/internal/bot-service/user/client.go:101.65,103.3 1 0
-github.com/danilovid/linkkeeper/internal/bot-service/user/client.go:105.2,105.19 1 0
-github.com/danilovid/linkkeeper/internal/bot-service/user/client.go:108.61,112.16 3 0
-github.com/danilovid/linkkeeper/internal/bot-service/user/client.go:112.16,114.3 1 0
-github.com/danilovid/linkkeeper/internal/bot-service/user/client.go:116.2,117.16 2 0
-github.com/danilovid/linkkeeper/internal/bot-service/user/client.go:117.16,119.3 1 0
-github.com/danilovid/linkkeeper/internal/bot-service/user/client.go:120.2,122.28 2 0
-github.com/danilovid/linkkeeper/internal/bot-service/user/client.go:122.28,124.3 1 0
-github.com/danilovid/linkkeeper/internal/bot-service/user/client.go:126.2,127.67 2 0
-github.com/danilovid/linkkeeper/internal/bot-service/user/client.go:127.67,129.3 1 0
-github.com/danilovid/linkkeeper/internal/bot-service/user/client.go:131.2,131.27 1 0
+github.com/danilovid/linkkeeper/cmd/api-service/main.go:21.13,33.12 8 0
+github.com/danilovid/linkkeeper/cmd/api-service/main.go:33.12,35.46 2 0
+github.com/danilovid/linkkeeper/cmd/api-service/main.go:35.46,37.4 1 0
+github.com/danilovid/linkkeeper/cmd/api-service/main.go:40.2,40.25 1 0
+github.com/danilovid/linkkeeper/cmd/api-service/main.go:40.25,44.3 3 0
+github.com/danilovid/linkkeeper/cmd/api-service/main.go:47.33,52.2 4 0
github.com/danilovid/linkkeeper/cmd/bot-service/main.go:14.13,25.16 4 0
github.com/danilovid/linkkeeper/cmd/bot-service/main.go:25.16,27.3 1 0
github.com/danilovid/linkkeeper/cmd/bot-service/main.go:29.2,30.34 2 0
@@ -145,75 +14,6 @@ github.com/danilovid/linkkeeper/cmd/bot-service/main.go:37.15,39.3 1 0
github.com/danilovid/linkkeeper/cmd/bot-service/main.go:40.2,41.32 2 0
github.com/danilovid/linkkeeper/cmd/bot-service/main.go:41.32,43.3 1 0
github.com/danilovid/linkkeeper/cmd/bot-service/main.go:44.2,44.45 1 0
-github.com/danilovid/linkkeeper/internal/api-service/transport/http/http.go:21.51,29.2 4 0
-github.com/danilovid/linkkeeper/internal/api-service/transport/http/http.go:31.41,33.2 1 0
-github.com/danilovid/linkkeeper/internal/api-service/transport/http/http.go:35.27,60.2 13 0
-github.com/danilovid/linkkeeper/internal/api-service/transport/http/http.go:62.52,63.71 1 0
-github.com/danilovid/linkkeeper/internal/api-service/transport/http/http.go:63.71,71.3 3 0
-github.com/danilovid/linkkeeper/internal/api-service/transport/http/routers.go:36.53,38.2 1 0
-github.com/danilovid/linkkeeper/internal/api-service/transport/http/routers.go:40.44,41.54 1 0
-github.com/danilovid/linkkeeper/internal/api-service/transport/http/routers.go:41.54,43.61 2 0
-github.com/danilovid/linkkeeper/internal/api-service/transport/http/routers.go:43.61,46.3 2 0
-github.com/danilovid/linkkeeper/internal/api-service/transport/http/routers.go:47.2,54.17 5 0
-github.com/danilovid/linkkeeper/internal/api-service/transport/http/routers.go:54.17,57.4 2 0
-github.com/danilovid/linkkeeper/internal/api-service/transport/http/routers.go:58.3,58.57 1 0
-github.com/danilovid/linkkeeper/internal/api-service/transport/http/routers.go:62.41,63.54 1 0
-github.com/danilovid/linkkeeper/internal/api-service/transport/http/routers.go:63.54,66.17 3 0
-github.com/danilovid/linkkeeper/internal/api-service/transport/http/routers.go:66.17,69.4 2 0
-github.com/danilovid/linkkeeper/internal/api-service/transport/http/routers.go:70.3,70.52 1 0
-github.com/danilovid/linkkeeper/internal/api-service/transport/http/routers.go:74.42,75.54 1 0
-github.com/danilovid/linkkeeper/internal/api-service/transport/http/routers.go:75.54,78.18 3 0
-github.com/danilovid/linkkeeper/internal/api-service/transport/http/routers.go:78.18,80.4 1 0
-github.com/danilovid/linkkeeper/internal/api-service/transport/http/routers.go:81.3,82.17 2 0
-github.com/danilovid/linkkeeper/internal/api-service/transport/http/routers.go:82.17,85.4 2 0
-github.com/danilovid/linkkeeper/internal/api-service/transport/http/routers.go:86.3,87.30 2 0
-github.com/danilovid/linkkeeper/internal/api-service/transport/http/routers.go:87.30,89.4 1 0
-github.com/danilovid/linkkeeper/internal/api-service/transport/http/routers.go:90.3,90.36 1 0
-github.com/danilovid/linkkeeper/internal/api-service/transport/http/routers.go:94.44,95.54 1 0
-github.com/danilovid/linkkeeper/internal/api-service/transport/http/routers.go:95.54,98.17 3 0
-github.com/danilovid/linkkeeper/internal/api-service/transport/http/routers.go:98.17,101.4 2 0
-github.com/danilovid/linkkeeper/internal/api-service/transport/http/routers.go:102.3,102.52 1 0
-github.com/danilovid/linkkeeper/internal/api-service/transport/http/routers.go:106.44,107.54 1 0
-github.com/danilovid/linkkeeper/internal/api-service/transport/http/routers.go:107.54,110.62 3 0
-github.com/danilovid/linkkeeper/internal/api-service/transport/http/routers.go:110.62,113.4 2 0
-github.com/danilovid/linkkeeper/internal/api-service/transport/http/routers.go:114.2,114.20 1 0
-github.com/danilovid/linkkeeper/internal/api-service/transport/http/routers.go:114.20,117.3 2 0
-github.com/danilovid/linkkeeper/internal/api-service/transport/http/routers.go:118.2,118.25 1 0
-github.com/danilovid/linkkeeper/internal/api-service/transport/http/routers.go:118.25,121.3 2 0
-github.com/danilovid/linkkeeper/internal/api-service/transport/http/routers.go:122.2,127.17 3 0
-github.com/danilovid/linkkeeper/internal/api-service/transport/http/routers.go:127.17,130.4 2 0
-github.com/danilovid/linkkeeper/internal/api-service/transport/http/routers.go:131.3,131.52 1 0
-github.com/danilovid/linkkeeper/internal/api-service/transport/http/routers.go:135.44,136.54 1 0
-github.com/danilovid/linkkeeper/internal/api-service/transport/http/routers.go:136.54,138.54 2 0
-github.com/danilovid/linkkeeper/internal/api-service/transport/http/routers.go:138.54,141.4 2 0
-github.com/danilovid/linkkeeper/internal/api-service/transport/http/routers.go:142.3,142.38 1 0
-github.com/danilovid/linkkeeper/internal/api-service/transport/http/routers.go:146.48,147.54 1 0
-github.com/danilovid/linkkeeper/internal/api-service/transport/http/routers.go:147.54,150.17 3 0
-github.com/danilovid/linkkeeper/internal/api-service/transport/http/routers.go:150.17,153.4 2 0
-github.com/danilovid/linkkeeper/internal/api-service/transport/http/routers.go:154.3,154.52 1 0
-github.com/danilovid/linkkeeper/internal/api-service/transport/http/routers.go:158.50,159.54 1 0
-github.com/danilovid/linkkeeper/internal/api-service/transport/http/routers.go:159.54,161.17 2 0
-github.com/danilovid/linkkeeper/internal/api-service/transport/http/routers.go:161.17,163.4 1 0
-github.com/danilovid/linkkeeper/internal/api-service/transport/http/routers.go:164.3,165.17 2 0
-github.com/danilovid/linkkeeper/internal/api-service/transport/http/routers.go:165.17,168.4 2 0
-github.com/danilovid/linkkeeper/internal/api-service/transport/http/routers.go:169.3,169.37 1 0
-github.com/danilovid/linkkeeper/internal/api-service/transport/http/routers.go:173.58,177.2 3 0
-github.com/danilovid/linkkeeper/internal/api-service/transport/http/routers.go:179.51,180.9 1 0
-github.com/danilovid/linkkeeper/internal/api-service/transport/http/routers.go:181.46,182.50 1 0
-github.com/danilovid/linkkeeper/internal/api-service/transport/http/routers.go:183.50,184.52 1 0
-github.com/danilovid/linkkeeper/internal/api-service/transport/http/routers.go:185.10,186.66 1 0
-github.com/danilovid/linkkeeper/internal/api-service/transport/http/routers.go:190.56,200.2 1 0
-github.com/danilovid/linkkeeper/internal/api-service/transport/http/routers.go:202.47,203.15 1 0
-github.com/danilovid/linkkeeper/internal/api-service/transport/http/routers.go:203.15,205.3 1 0
-github.com/danilovid/linkkeeper/internal/api-service/transport/http/routers.go:206.2,207.16 2 0
-github.com/danilovid/linkkeeper/internal/api-service/transport/http/routers.go:207.16,209.3 1 0
-github.com/danilovid/linkkeeper/internal/api-service/transport/http/routers.go:210.2,210.15 1 0
-github.com/danilovid/linkkeeper/cmd/api-service/main.go:21.13,33.12 8 0
-github.com/danilovid/linkkeeper/cmd/api-service/main.go:33.12,35.46 2 0
-github.com/danilovid/linkkeeper/cmd/api-service/main.go:35.46,37.4 1 0
-github.com/danilovid/linkkeeper/cmd/api-service/main.go:40.2,40.25 1 0
-github.com/danilovid/linkkeeper/cmd/api-service/main.go:40.25,44.3 3 0
-github.com/danilovid/linkkeeper/cmd/api-service/main.go:47.33,52.2 4 0
github.com/danilovid/linkkeeper/internal/api-service/repository/link.go:18.41,20.2 1 0
github.com/danilovid/linkkeeper/internal/api-service/repository/link.go:32.107,38.67 2 0
github.com/danilovid/linkkeeper/internal/api-service/repository/link.go:38.67,40.3 1 0
@@ -278,6 +78,212 @@ github.com/danilovid/linkkeeper/internal/api-service/repository/link.go:202.30,2
github.com/danilovid/linkkeeper/internal/api-service/repository/link.go:203.44,205.3 1 0
github.com/danilovid/linkkeeper/internal/api-service/repository/link.go:206.2,206.12 1 0
github.com/danilovid/linkkeeper/internal/api-service/repository/link.go:209.42,219.2 1 0
+github.com/danilovid/linkkeeper/cmd/user-service/main.go:23.13,35.12 8 0
+github.com/danilovid/linkkeeper/cmd/user-service/main.go:35.12,37.46 2 0
+github.com/danilovid/linkkeeper/cmd/user-service/main.go:37.46,39.4 1 0
+github.com/danilovid/linkkeeper/cmd/user-service/main.go:42.2,42.25 1 0
+github.com/danilovid/linkkeeper/cmd/user-service/main.go:42.25,46.3 3 0
+github.com/danilovid/linkkeeper/cmd/user-service/main.go:49.33,54.2 4 0
+github.com/danilovid/linkkeeper/internal/bot-service/user/client.go:39.63,44.2 1 0
+github.com/danilovid/linkkeeper/internal/bot-service/user/client.go:46.126,55.16 3 0
+github.com/danilovid/linkkeeper/internal/bot-service/user/client.go:55.16,57.3 1 0
+github.com/danilovid/linkkeeper/internal/bot-service/user/client.go:59.2,60.16 2 0
+github.com/danilovid/linkkeeper/internal/bot-service/user/client.go:60.16,62.3 1 0
+github.com/danilovid/linkkeeper/internal/bot-service/user/client.go:63.2,66.16 3 0
+github.com/danilovid/linkkeeper/internal/bot-service/user/client.go:66.16,68.3 1 0
+github.com/danilovid/linkkeeper/internal/bot-service/user/client.go:69.2,71.28 2 0
+github.com/danilovid/linkkeeper/internal/bot-service/user/client.go:71.28,73.3 1 0
+github.com/danilovid/linkkeeper/internal/bot-service/user/client.go:75.2,76.65 2 0
+github.com/danilovid/linkkeeper/internal/bot-service/user/client.go:76.65,78.3 1 0
+github.com/danilovid/linkkeeper/internal/bot-service/user/client.go:80.2,80.19 1 0
+github.com/danilovid/linkkeeper/internal/bot-service/user/client.go:83.92,87.16 3 0
+github.com/danilovid/linkkeeper/internal/bot-service/user/client.go:87.16,89.3 1 0
+github.com/danilovid/linkkeeper/internal/bot-service/user/client.go:91.2,92.16 2 0
+github.com/danilovid/linkkeeper/internal/bot-service/user/client.go:92.16,94.3 1 0
+github.com/danilovid/linkkeeper/internal/bot-service/user/client.go:95.2,97.28 2 0
+github.com/danilovid/linkkeeper/internal/bot-service/user/client.go:97.28,99.3 1 0
+github.com/danilovid/linkkeeper/internal/bot-service/user/client.go:101.2,102.65 2 0
+github.com/danilovid/linkkeeper/internal/bot-service/user/client.go:102.65,104.3 1 0
+github.com/danilovid/linkkeeper/internal/bot-service/user/client.go:106.2,106.19 1 0
+github.com/danilovid/linkkeeper/internal/bot-service/user/client.go:109.82,113.16 3 0
+github.com/danilovid/linkkeeper/internal/bot-service/user/client.go:113.16,115.3 1 0
+github.com/danilovid/linkkeeper/internal/bot-service/user/client.go:117.2,118.16 2 0
+github.com/danilovid/linkkeeper/internal/bot-service/user/client.go:118.16,120.3 1 0
+github.com/danilovid/linkkeeper/internal/bot-service/user/client.go:121.2,123.28 2 0
+github.com/danilovid/linkkeeper/internal/bot-service/user/client.go:123.28,125.3 1 0
+github.com/danilovid/linkkeeper/internal/bot-service/user/client.go:127.2,128.67 2 0
+github.com/danilovid/linkkeeper/internal/bot-service/user/client.go:128.67,130.3 1 0
+github.com/danilovid/linkkeeper/internal/bot-service/user/client.go:132.2,132.27 1 0
+github.com/danilovid/linkkeeper/internal/user-service/models.go:20.37,22.2 1 0
+github.com/danilovid/linkkeeper/internal/user-service/models.go:24.53,25.22 1 0
+github.com/danilovid/linkkeeper/internal/user-service/models.go:25.22,27.3 1 0
+github.com/danilovid/linkkeeper/internal/user-service/models.go:28.2,28.12 1 0
+github.com/danilovid/linkkeeper/internal/bot-service/api/client.go:25.63,30.2 1 0
+github.com/danilovid/linkkeeper/internal/bot-service/api/client.go:32.78,34.16 2 0
+github.com/danilovid/linkkeeper/internal/bot-service/api/client.go:34.16,36.3 1 0
+github.com/danilovid/linkkeeper/internal/bot-service/api/client.go:37.2,38.16 2 0
+github.com/danilovid/linkkeeper/internal/bot-service/api/client.go:38.16,40.3 1 0
+github.com/danilovid/linkkeeper/internal/bot-service/api/client.go:41.2,43.16 3 0
+github.com/danilovid/linkkeeper/internal/bot-service/api/client.go:43.16,45.3 1 0
+github.com/danilovid/linkkeeper/internal/bot-service/api/client.go:46.2,47.28 2 0
+github.com/danilovid/linkkeeper/internal/bot-service/api/client.go:47.28,49.3 1 0
+github.com/danilovid/linkkeeper/internal/bot-service/api/client.go:50.2,53.64 2 0
+github.com/danilovid/linkkeeper/internal/bot-service/api/client.go:53.64,55.3 1 0
+github.com/danilovid/linkkeeper/internal/bot-service/api/client.go:56.2,56.20 1 0
+github.com/danilovid/linkkeeper/internal/bot-service/api/client.go:59.67,61.16 2 0
+github.com/danilovid/linkkeeper/internal/bot-service/api/client.go:61.16,63.3 1 0
+github.com/danilovid/linkkeeper/internal/bot-service/api/client.go:64.2,65.16 2 0
+github.com/danilovid/linkkeeper/internal/bot-service/api/client.go:65.16,67.3 1 0
+github.com/danilovid/linkkeeper/internal/bot-service/api/client.go:68.2,69.28 2 0
+github.com/danilovid/linkkeeper/internal/bot-service/api/client.go:69.28,71.3 1 0
+github.com/danilovid/linkkeeper/internal/bot-service/api/client.go:72.2,72.12 1 0
+github.com/danilovid/linkkeeper/internal/bot-service/api/client.go:75.81,77.20 2 0
+github.com/danilovid/linkkeeper/internal/bot-service/api/client.go:77.20,79.3 1 0
+github.com/danilovid/linkkeeper/internal/bot-service/api/client.go:80.2,81.16 2 0
+github.com/danilovid/linkkeeper/internal/bot-service/api/client.go:81.16,83.3 1 0
+github.com/danilovid/linkkeeper/internal/bot-service/api/client.go:84.2,85.16 2 0
+github.com/danilovid/linkkeeper/internal/bot-service/api/client.go:85.16,87.3 1 0
+github.com/danilovid/linkkeeper/internal/bot-service/api/client.go:88.2,89.28 2 0
+github.com/danilovid/linkkeeper/internal/bot-service/api/client.go:89.28,91.3 1 0
+github.com/danilovid/linkkeeper/internal/bot-service/api/client.go:92.2,93.64 2 0
+github.com/danilovid/linkkeeper/internal/bot-service/api/client.go:93.64,95.3 1 0
+github.com/danilovid/linkkeeper/internal/bot-service/api/client.go:96.2,96.17 1 0
+github.com/danilovid/linkkeeper/internal/api-service/transport/http/http.go:21.51,29.2 4 0
+github.com/danilovid/linkkeeper/internal/api-service/transport/http/http.go:31.41,33.2 1 0
+github.com/danilovid/linkkeeper/internal/api-service/transport/http/http.go:35.27,60.2 13 0
+github.com/danilovid/linkkeeper/internal/api-service/transport/http/http.go:62.52,63.71 1 0
+github.com/danilovid/linkkeeper/internal/api-service/transport/http/http.go:63.71,71.3 3 0
+github.com/danilovid/linkkeeper/internal/api-service/transport/http/routers.go:36.53,38.2 1 0
+github.com/danilovid/linkkeeper/internal/api-service/transport/http/routers.go:40.44,41.54 1 0
+github.com/danilovid/linkkeeper/internal/api-service/transport/http/routers.go:41.54,43.62 2 0
+github.com/danilovid/linkkeeper/internal/api-service/transport/http/routers.go:43.62,46.4 2 0
+github.com/danilovid/linkkeeper/internal/api-service/transport/http/routers.go:47.3,54.17 5 0
+github.com/danilovid/linkkeeper/internal/api-service/transport/http/routers.go:54.17,57.4 2 0
+github.com/danilovid/linkkeeper/internal/api-service/transport/http/routers.go:58.3,58.57 1 0
+github.com/danilovid/linkkeeper/internal/api-service/transport/http/routers.go:62.41,63.54 1 0
+github.com/danilovid/linkkeeper/internal/api-service/transport/http/routers.go:63.54,66.17 3 0
+github.com/danilovid/linkkeeper/internal/api-service/transport/http/routers.go:66.17,69.4 2 0
+github.com/danilovid/linkkeeper/internal/api-service/transport/http/routers.go:70.3,70.52 1 0
+github.com/danilovid/linkkeeper/internal/api-service/transport/http/routers.go:74.42,75.54 1 0
+github.com/danilovid/linkkeeper/internal/api-service/transport/http/routers.go:75.54,78.18 3 0
+github.com/danilovid/linkkeeper/internal/api-service/transport/http/routers.go:78.18,80.4 1 0
+github.com/danilovid/linkkeeper/internal/api-service/transport/http/routers.go:81.3,82.17 2 0
+github.com/danilovid/linkkeeper/internal/api-service/transport/http/routers.go:82.17,85.4 2 0
+github.com/danilovid/linkkeeper/internal/api-service/transport/http/routers.go:86.3,87.30 2 0
+github.com/danilovid/linkkeeper/internal/api-service/transport/http/routers.go:87.30,89.4 1 0
+github.com/danilovid/linkkeeper/internal/api-service/transport/http/routers.go:90.3,90.36 1 0
+github.com/danilovid/linkkeeper/internal/api-service/transport/http/routers.go:94.44,95.54 1 0
+github.com/danilovid/linkkeeper/internal/api-service/transport/http/routers.go:95.54,98.17 3 0
+github.com/danilovid/linkkeeper/internal/api-service/transport/http/routers.go:98.17,101.4 2 0
+github.com/danilovid/linkkeeper/internal/api-service/transport/http/routers.go:102.3,102.52 1 0
+github.com/danilovid/linkkeeper/internal/api-service/transport/http/routers.go:106.44,107.54 1 0
+github.com/danilovid/linkkeeper/internal/api-service/transport/http/routers.go:107.54,110.62 3 0
+github.com/danilovid/linkkeeper/internal/api-service/transport/http/routers.go:110.62,113.4 2 0
+github.com/danilovid/linkkeeper/internal/api-service/transport/http/routers.go:114.3,114.21 1 0
+github.com/danilovid/linkkeeper/internal/api-service/transport/http/routers.go:114.21,117.4 2 0
+github.com/danilovid/linkkeeper/internal/api-service/transport/http/routers.go:118.3,118.26 1 0
+github.com/danilovid/linkkeeper/internal/api-service/transport/http/routers.go:118.26,121.4 2 0
+github.com/danilovid/linkkeeper/internal/api-service/transport/http/routers.go:122.3,127.17 3 0
+github.com/danilovid/linkkeeper/internal/api-service/transport/http/routers.go:127.17,130.4 2 0
+github.com/danilovid/linkkeeper/internal/api-service/transport/http/routers.go:131.3,131.52 1 0
+github.com/danilovid/linkkeeper/internal/api-service/transport/http/routers.go:135.44,136.54 1 0
+github.com/danilovid/linkkeeper/internal/api-service/transport/http/routers.go:136.54,138.54 2 0
+github.com/danilovid/linkkeeper/internal/api-service/transport/http/routers.go:138.54,141.4 2 0
+github.com/danilovid/linkkeeper/internal/api-service/transport/http/routers.go:142.3,142.38 1 0
+github.com/danilovid/linkkeeper/internal/api-service/transport/http/routers.go:146.48,147.54 1 0
+github.com/danilovid/linkkeeper/internal/api-service/transport/http/routers.go:147.54,150.17 3 0
+github.com/danilovid/linkkeeper/internal/api-service/transport/http/routers.go:150.17,153.4 2 0
+github.com/danilovid/linkkeeper/internal/api-service/transport/http/routers.go:154.3,154.52 1 0
+github.com/danilovid/linkkeeper/internal/api-service/transport/http/routers.go:158.50,159.54 1 0
+github.com/danilovid/linkkeeper/internal/api-service/transport/http/routers.go:159.54,161.17 2 0
+github.com/danilovid/linkkeeper/internal/api-service/transport/http/routers.go:161.17,163.4 1 0
+github.com/danilovid/linkkeeper/internal/api-service/transport/http/routers.go:164.3,165.17 2 0
+github.com/danilovid/linkkeeper/internal/api-service/transport/http/routers.go:165.17,168.4 2 0
+github.com/danilovid/linkkeeper/internal/api-service/transport/http/routers.go:169.3,169.37 1 0
+github.com/danilovid/linkkeeper/internal/api-service/transport/http/routers.go:173.58,177.2 3 0
+github.com/danilovid/linkkeeper/internal/api-service/transport/http/routers.go:179.51,180.9 1 0
+github.com/danilovid/linkkeeper/internal/api-service/transport/http/routers.go:181.46,182.50 1 0
+github.com/danilovid/linkkeeper/internal/api-service/transport/http/routers.go:183.50,184.52 1 0
+github.com/danilovid/linkkeeper/internal/api-service/transport/http/routers.go:185.10,186.66 1 0
+github.com/danilovid/linkkeeper/internal/api-service/transport/http/routers.go:190.56,200.2 1 0
+github.com/danilovid/linkkeeper/internal/api-service/transport/http/routers.go:202.47,203.15 1 0
+github.com/danilovid/linkkeeper/internal/api-service/transport/http/routers.go:203.15,205.3 1 0
+github.com/danilovid/linkkeeper/internal/api-service/transport/http/routers.go:206.2,207.16 2 0
+github.com/danilovid/linkkeeper/internal/api-service/transport/http/routers.go:207.16,209.3 1 0
+github.com/danilovid/linkkeeper/internal/api-service/transport/http/routers.go:210.2,210.15 1 0
+github.com/danilovid/linkkeeper/internal/bot-service/bot/config.go:16.35,17.38 1 0
+github.com/danilovid/linkkeeper/internal/bot-service/bot/config.go:17.38,19.3 1 0
+github.com/danilovid/linkkeeper/internal/bot-service/bot/config.go:20.2,20.43 1 0
+github.com/danilovid/linkkeeper/internal/bot-service/bot/config.go:20.43,22.3 1 0
+github.com/danilovid/linkkeeper/internal/bot-service/bot/config.go:23.2,23.47 1 0
+github.com/danilovid/linkkeeper/internal/bot-service/bot/config.go:23.47,25.3 1 0
+github.com/danilovid/linkkeeper/internal/bot-service/bot/config.go:26.2,26.20 1 0
+github.com/danilovid/linkkeeper/internal/bot-service/bot/config.go:26.20,28.3 1 0
+github.com/danilovid/linkkeeper/internal/bot-service/bot/config.go:29.2,29.12 1 0
+github.com/danilovid/linkkeeper/internal/bot-service/bot/wrapper.go:31.51,32.42 1 0
+github.com/danilovid/linkkeeper/internal/bot-service/bot/wrapper.go:32.42,34.3 1 0
+github.com/danilovid/linkkeeper/internal/bot-service/bot/wrapper.go:36.2,42.16 3 0
+github.com/danilovid/linkkeeper/internal/bot-service/bot/wrapper.go:42.16,44.3 1 0
+github.com/danilovid/linkkeeper/internal/bot-service/bot/wrapper.go:46.2,56.15 5 0
+github.com/danilovid/linkkeeper/internal/bot-service/bot/wrapper.go:59.33,62.2 2 0
+github.com/danilovid/linkkeeper/internal/bot-service/bot/wrapper.go:64.29,71.50 2 0
+github.com/danilovid/linkkeeper/internal/bot-service/bot/wrapper.go:71.50,73.20 2 0
+github.com/danilovid/linkkeeper/internal/bot-service/bot/wrapper.go:73.20,82.18 3 0
+github.com/danilovid/linkkeeper/internal/bot-service/bot/wrapper.go:82.18,84.5 1 0
+github.com/danilovid/linkkeeper/internal/bot-service/bot/wrapper.go:84.10,86.5 1 0
+github.com/danilovid/linkkeeper/internal/bot-service/bot/wrapper.go:88.3,88.52 1 0
+github.com/danilovid/linkkeeper/internal/bot-service/bot/wrapper.go:91.2,91.49 1 0
+github.com/danilovid/linkkeeper/internal/bot-service/bot/wrapper.go:91.49,93.16 2 0
+github.com/danilovid/linkkeeper/internal/bot-service/bot/wrapper.go:93.16,95.4 1 0
+github.com/danilovid/linkkeeper/internal/bot-service/bot/wrapper.go:96.3,98.17 3 0
+github.com/danilovid/linkkeeper/internal/bot-service/bot/wrapper.go:98.17,101.4 2 0
+github.com/danilovid/linkkeeper/internal/bot-service/bot/wrapper.go:102.3,102.39 1 0
+github.com/danilovid/linkkeeper/internal/bot-service/bot/wrapper.go:105.2,105.51 1 0
+github.com/danilovid/linkkeeper/internal/bot-service/bot/wrapper.go:105.51,107.15 2 0
+github.com/danilovid/linkkeeper/internal/bot-service/bot/wrapper.go:107.15,109.4 1 0
+github.com/danilovid/linkkeeper/internal/bot-service/bot/wrapper.go:110.3,111.51 2 0
+github.com/danilovid/linkkeeper/internal/bot-service/bot/wrapper.go:111.51,114.4 2 0
+github.com/danilovid/linkkeeper/internal/bot-service/bot/wrapper.go:115.3,115.37 1 0
+github.com/danilovid/linkkeeper/internal/bot-service/bot/wrapper.go:118.2,118.51 1 0
+github.com/danilovid/linkkeeper/internal/bot-service/bot/wrapper.go:118.51,122.17 4 0
+github.com/danilovid/linkkeeper/internal/bot-service/bot/wrapper.go:122.17,125.4 2 0
+github.com/danilovid/linkkeeper/internal/bot-service/bot/wrapper.go:126.3,126.21 1 0
+github.com/danilovid/linkkeeper/internal/bot-service/bot/wrapper.go:126.21,128.4 1 0
+github.com/danilovid/linkkeeper/internal/bot-service/bot/wrapper.go:129.3,130.26 2 0
+github.com/danilovid/linkkeeper/internal/bot-service/bot/wrapper.go:130.26,132.4 1 0
+github.com/danilovid/linkkeeper/internal/bot-service/bot/wrapper.go:133.3,133.27 1 0
+github.com/danilovid/linkkeeper/internal/bot-service/bot/wrapper.go:136.2,136.50 1 0
+github.com/danilovid/linkkeeper/internal/bot-service/bot/wrapper.go:136.50,138.3 1 0
+github.com/danilovid/linkkeeper/internal/bot-service/bot/wrapper.go:140.2,140.52 1 0
+github.com/danilovid/linkkeeper/internal/bot-service/bot/wrapper.go:140.52,142.3 1 0
+github.com/danilovid/linkkeeper/internal/bot-service/bot/wrapper.go:144.2,144.52 1 0
+github.com/danilovid/linkkeeper/internal/bot-service/bot/wrapper.go:144.52,147.17 3 0
+github.com/danilovid/linkkeeper/internal/bot-service/bot/wrapper.go:147.17,150.4 2 0
+github.com/danilovid/linkkeeper/internal/bot-service/bot/wrapper.go:151.3,151.21 1 0
+github.com/danilovid/linkkeeper/internal/bot-service/bot/wrapper.go:151.21,153.4 1 0
+github.com/danilovid/linkkeeper/internal/bot-service/bot/wrapper.go:154.3,155.26 2 0
+github.com/danilovid/linkkeeper/internal/bot-service/bot/wrapper.go:155.26,157.4 1 0
+github.com/danilovid/linkkeeper/internal/bot-service/bot/wrapper.go:158.3,158.27 1 0
+github.com/danilovid/linkkeeper/internal/bot-service/bot/wrapper.go:161.2,161.59 1 0
+github.com/danilovid/linkkeeper/internal/bot-service/bot/wrapper.go:161.59,164.17 3 0
+github.com/danilovid/linkkeeper/internal/bot-service/bot/wrapper.go:164.17,167.4 2 0
+github.com/danilovid/linkkeeper/internal/bot-service/bot/wrapper.go:168.3,168.21 1 0
+github.com/danilovid/linkkeeper/internal/bot-service/bot/wrapper.go:168.21,170.4 1 0
+github.com/danilovid/linkkeeper/internal/bot-service/bot/wrapper.go:171.3,172.27 2 0
+github.com/danilovid/linkkeeper/internal/bot-service/bot/wrapper.go:175.2,175.57 1 0
+github.com/danilovid/linkkeeper/internal/bot-service/bot/wrapper.go:175.57,178.17 3 0
+github.com/danilovid/linkkeeper/internal/bot-service/bot/wrapper.go:178.17,181.4 2 0
+github.com/danilovid/linkkeeper/internal/bot-service/bot/wrapper.go:182.3,182.21 1 0
+github.com/danilovid/linkkeeper/internal/bot-service/bot/wrapper.go:182.21,184.4 1 0
+github.com/danilovid/linkkeeper/internal/bot-service/bot/wrapper.go:185.3,186.27 2 0
+github.com/danilovid/linkkeeper/internal/bot-service/bot/wrapper.go:189.2,189.51 1 0
+github.com/danilovid/linkkeeper/internal/bot-service/bot/wrapper.go:189.51,191.17 2 0
+github.com/danilovid/linkkeeper/internal/bot-service/bot/wrapper.go:191.17,193.4 1 0
+github.com/danilovid/linkkeeper/internal/bot-service/bot/wrapper.go:194.3,194.35 1 0
+github.com/danilovid/linkkeeper/internal/bot-service/bot/wrapper.go:194.35,196.4 1 0
+github.com/danilovid/linkkeeper/internal/bot-service/bot/wrapper.go:197.3,197.81 1 0
+github.com/danilovid/linkkeeper/internal/bot-service/bot/wrapper.go:200.2,200.52 1 0
+github.com/danilovid/linkkeeper/internal/bot-service/bot/wrapper.go:200.52,202.3 1 0
github.com/danilovid/linkkeeper/internal/api-service/usecase/link.go:14.66,16.2 1 9
github.com/danilovid/linkkeeper/internal/api-service/usecase/link.go:18.110,19.46 1 3
github.com/danilovid/linkkeeper/internal/api-service/usecase/link.go:19.46,21.3 1 0
@@ -301,16 +307,13 @@ github.com/danilovid/linkkeeper/internal/api-service/usecase/link.go:67.47,69.3
github.com/danilovid/linkkeeper/internal/api-service/usecase/link.go:70.2,70.42 1 0
github.com/danilovid/linkkeeper/internal/api-service/usecase/link.go:70.42,72.3 1 0
github.com/danilovid/linkkeeper/internal/api-service/usecase/link.go:73.2,73.12 1 0
-github.com/danilovid/linkkeeper/cmd/user-service/main.go:23.13,35.12 8 0
-github.com/danilovid/linkkeeper/cmd/user-service/main.go:35.12,37.46 2 0
-github.com/danilovid/linkkeeper/cmd/user-service/main.go:37.46,39.4 1 0
-github.com/danilovid/linkkeeper/cmd/user-service/main.go:42.2,42.25 1 0
-github.com/danilovid/linkkeeper/cmd/user-service/main.go:42.25,46.3 3 0
-github.com/danilovid/linkkeeper/cmd/user-service/main.go:49.33,54.2 4 0
github.com/danilovid/linkkeeper/pkg/config/config.go:16.20,31.2 6 0
github.com/danilovid/linkkeeper/pkg/config/config.go:33.44,34.32 1 0
github.com/danilovid/linkkeeper/pkg/config/config.go:34.32,36.3 1 0
github.com/danilovid/linkkeeper/pkg/config/config.go:37.2,37.12 1 0
+github.com/danilovid/linkkeeper/pkg/httpclient/client.go:14.85,22.2 1 0
+github.com/danilovid/linkkeeper/pkg/logger/logger.go:11.13,18.2 3 0
+github.com/danilovid/linkkeeper/pkg/logger/logger.go:20.26,23.2 2 0
github.com/danilovid/linkkeeper/pkg/database/postgresql/postgresql.go:15.46,17.16 2 0
github.com/danilovid/linkkeeper/pkg/database/postgresql/postgresql.go:17.16,19.3 1 0
github.com/danilovid/linkkeeper/pkg/database/postgresql/postgresql.go:20.2,20.21 1 0
@@ -345,9 +348,6 @@ github.com/danilovid/linkkeeper/pkg/database/postgresql/postgresql.go:81.56,83.4
github.com/danilovid/linkkeeper/pkg/database/postgresql/postgresql.go:84.3,84.99 1 0
github.com/danilovid/linkkeeper/pkg/database/postgresql/postgresql.go:84.99,86.4 1 0
github.com/danilovid/linkkeeper/pkg/database/postgresql/postgresql.go:88.2,88.12 1 0
-github.com/danilovid/linkkeeper/pkg/logger/logger.go:11.13,18.2 3 0
-github.com/danilovid/linkkeeper/pkg/logger/logger.go:20.26,23.2 2 0
-github.com/danilovid/linkkeeper/pkg/httpclient/client.go:14.85,22.2 1 0
github.com/danilovid/linkkeeper/internal/user-service/repository/user.go:16.54,18.2 1 8
github.com/danilovid/linkkeeper/internal/user-service/repository/user.go:20.62,22.2 1 7
github.com/danilovid/linkkeeper/internal/user-service/repository/user.go:24.74,27.16 3 3
@@ -372,27 +372,32 @@ github.com/danilovid/linkkeeper/internal/user-service/transport/http/http.go:56.
github.com/danilovid/linkkeeper/internal/user-service/transport/http/http.go:56.25,59.3 2 1
github.com/danilovid/linkkeeper/internal/user-service/transport/http/http.go:61.2,62.16 2 1
github.com/danilovid/linkkeeper/internal/user-service/transport/http/http.go:62.16,66.3 3 0
-github.com/danilovid/linkkeeper/internal/user-service/transport/http/http.go:68.2,80.33 4 1
-github.com/danilovid/linkkeeper/internal/user-service/transport/http/http.go:83.70,88.16 4 3
-github.com/danilovid/linkkeeper/internal/user-service/transport/http/http.go:88.16,91.3 2 1
-github.com/danilovid/linkkeeper/internal/user-service/transport/http/http.go:93.2,94.16 2 2
-github.com/danilovid/linkkeeper/internal/user-service/transport/http/http.go:94.16,98.3 3 1
-github.com/danilovid/linkkeeper/internal/user-service/transport/http/http.go:100.2,111.33 3 1
-github.com/danilovid/linkkeeper/internal/user-service/transport/http/http.go:114.78,119.61 4 0
-github.com/danilovid/linkkeeper/internal/user-service/transport/http/http.go:119.61,122.3 2 0
-github.com/danilovid/linkkeeper/internal/user-service/transport/http/http.go:124.2,125.16 2 0
-github.com/danilovid/linkkeeper/internal/user-service/transport/http/http.go:125.16,129.3 3 0
-github.com/danilovid/linkkeeper/internal/user-service/transport/http/http.go:131.2,142.33 3 0
-github.com/danilovid/linkkeeper/internal/user-service/transport/http/http.go:145.74,150.61 4 2
-github.com/danilovid/linkkeeper/internal/user-service/transport/http/http.go:150.61,153.3 2 0
-github.com/danilovid/linkkeeper/internal/user-service/transport/http/http.go:155.2,156.16 2 2
-github.com/danilovid/linkkeeper/internal/user-service/transport/http/http.go:156.16,160.3 3 0
-github.com/danilovid/linkkeeper/internal/user-service/transport/http/http.go:162.2,164.33 3 2
+github.com/danilovid/linkkeeper/internal/user-service/transport/http/http.go:68.2,80.56 4 1
+github.com/danilovid/linkkeeper/internal/user-service/transport/http/http.go:80.56,82.3 1 0
+github.com/danilovid/linkkeeper/internal/user-service/transport/http/http.go:85.70,90.16 4 3
+github.com/danilovid/linkkeeper/internal/user-service/transport/http/http.go:90.16,93.3 2 1
+github.com/danilovid/linkkeeper/internal/user-service/transport/http/http.go:95.2,96.16 2 2
+github.com/danilovid/linkkeeper/internal/user-service/transport/http/http.go:96.16,100.3 3 1
+github.com/danilovid/linkkeeper/internal/user-service/transport/http/http.go:102.2,113.56 3 1
+github.com/danilovid/linkkeeper/internal/user-service/transport/http/http.go:113.56,115.3 1 0
+github.com/danilovid/linkkeeper/internal/user-service/transport/http/http.go:118.78,123.61 4 0
+github.com/danilovid/linkkeeper/internal/user-service/transport/http/http.go:123.61,126.3 2 0
+github.com/danilovid/linkkeeper/internal/user-service/transport/http/http.go:128.2,129.16 2 0
+github.com/danilovid/linkkeeper/internal/user-service/transport/http/http.go:129.16,133.3 3 0
+github.com/danilovid/linkkeeper/internal/user-service/transport/http/http.go:135.2,146.56 3 0
+github.com/danilovid/linkkeeper/internal/user-service/transport/http/http.go:146.56,148.3 1 0
+github.com/danilovid/linkkeeper/internal/user-service/transport/http/http.go:151.74,156.61 4 2
+github.com/danilovid/linkkeeper/internal/user-service/transport/http/http.go:156.61,159.3 2 0
+github.com/danilovid/linkkeeper/internal/user-service/transport/http/http.go:161.2,162.16 2 2
+github.com/danilovid/linkkeeper/internal/user-service/transport/http/http.go:162.16,166.3 3 0
+github.com/danilovid/linkkeeper/internal/user-service/transport/http/http.go:168.2,170.56 3 2
+github.com/danilovid/linkkeeper/internal/user-service/transport/http/http.go:170.56,172.3 1 0
github.com/danilovid/linkkeeper/internal/user-service/transport/http/routers.go:13.40,33.71 8 0
-github.com/danilovid/linkkeeper/internal/user-service/transport/http/routers.go:33.71,36.3 2 0
-github.com/danilovid/linkkeeper/internal/user-service/transport/http/routers.go:38.2,38.27 1 0
-github.com/danilovid/linkkeeper/internal/user-service/transport/http/routers.go:41.49,42.71 1 0
-github.com/danilovid/linkkeeper/internal/user-service/transport/http/routers.go:42.71,48.3 2 0
+github.com/danilovid/linkkeeper/internal/user-service/transport/http/routers.go:33.71,35.50 2 0
+github.com/danilovid/linkkeeper/internal/user-service/transport/http/routers.go:35.50,37.4 1 0
+github.com/danilovid/linkkeeper/internal/user-service/transport/http/routers.go:40.2,40.27 1 0
+github.com/danilovid/linkkeeper/internal/user-service/transport/http/routers.go:43.49,44.71 1 0
+github.com/danilovid/linkkeeper/internal/user-service/transport/http/routers.go:44.71,50.3 2 0
github.com/danilovid/linkkeeper/internal/user-service/usecase/user.go:13.70,15.2 1 8
github.com/danilovid/linkkeeper/internal/user-service/usecase/user.go:17.122,25.44 2 3
github.com/danilovid/linkkeeper/internal/user-service/usecase/user.go:25.44,27.3 1 1
diff --git a/docs/ARCHITECTURE_TESTING.md b/docs/ARCHITECTURE_TESTING.md
index 289ec78..abe0ff3 100644
--- a/docs/ARCHITECTURE_TESTING.md
+++ b/docs/ARCHITECTURE_TESTING.md
@@ -1,31 +1,31 @@
-# Тестирование и архитектура в GitHub Actions
+# Testing and Architecture in GitHub Actions
-## Краткий ответ: НЕТ, отдельная сборка с указанием архитектуры НЕ нужна
+## Quick Answer: NO, a separate build with architecture specification is NOT needed
-### Почему не нужно?
+### Why not?
-1. **`go test` автоматически компилирует для текущей платформы**
- - GitHub Actions runner (`ubuntu-latest`) работает на `linux/amd64`
- - `go test` автоматически компилирует тесты для `linux/amd64`
- - Тесты запускаются на той же архитектуре, что и runner
+1. **`go test` automatically compiles for the current platform**
+ - GitHub Actions runner (`ubuntu-latest`) runs on `linux/amd64`
+ - `go test` automatically compiles tests for `linux/amd64`
+ - Tests run on the same architecture as the runner
-2. **Текущая конфигурация правильная:**
+2. **Current configuration is correct:**
```yaml
- name: Run tests
run: |
go test -v -race -coverprofile=coverage.out -covermode=atomic ./...
```
-3. **Go автоматически определяет:**
- - Операционную систему (Linux)
- - Архитектуру (amd64)
- - Компилирует и запускает соответственно
+3. **Go automatically detects:**
+ - Operating system (Linux)
+ - Architecture (amd64)
+ - Compiles and runs accordingly
-## Когда МОЖЕТ понадобиться указать архитектуру?
+## When MIGHT you need to specify architecture?
-### 1. Кросс-платформенное тестирование
+### 1. Cross-platform testing
-Если нужно тестировать на разных платформах:
+If you need to test on different platforms:
```yaml
jobs:
@@ -43,7 +43,7 @@ jobs:
- run: go test ./...
```
-### 2. Тестирование на разных архитектурах (ARM, x86)
+### 2. Testing on different architectures (ARM, x86)
```yaml
jobs:
@@ -61,9 +61,9 @@ jobs:
GOARCH=${{ matrix.arch }} go test ./...
```
-### 3. Сборка бинарников для разных платформ
+### 3. Building binaries for different platforms
-Для сборки (не тестов) может понадобиться:
+For building (not testing) you might need:
```yaml
- name: Build for multiple platforms
@@ -73,7 +73,7 @@ jobs:
GOOS=windows GOARCH=amd64 go build -o bin/api-service-windows-amd64.exe ./cmd/api-service
```
-## Текущая конфигурация (оптимальная для большинства случаев)
+## Current configuration (optimal for most cases)
```yaml
- name: Set up Go
@@ -87,63 +87,63 @@ jobs:
go test -v -race -coverprofile=coverage.out -covermode=atomic ./...
```
-**Это работает потому что:**
+**This works because:**
- ✅ `ubuntu-latest` = Linux amd64
-- ✅ `go test` автоматически компилирует для Linux amd64
-- ✅ Тесты запускаются нативно (быстро)
-- ✅ Race detector работает корректно
-- ✅ Coverage собирается правильно
+- ✅ `go test` automatically compiles for Linux amd64
+- ✅ Tests run natively (fast)
+- ✅ Race detector works correctly
+- ✅ Coverage is collected properly
-## Сравнение подходов
+## Approach comparison
-### Текущий подход (рекомендуется)
+### Current approach (recommended)
```yaml
go test ./...
```
-- ✅ Просто и быстро
-- ✅ Автоматическая компиляция для текущей платформы
-- ✅ Нативная производительность
-- ✅ Подходит для 99% проектов
+- ✅ Simple and fast
+- ✅ Automatic compilation for current platform
+- ✅ Native performance
+- ✅ Suitable for 99% of projects
-### Явное указание архитектуры (избыточно)
+### Explicit architecture specification (redundant)
```yaml
GOOS=linux GOARCH=amd64 go test ./...
```
-- ⚠️ Избыточно (то же самое, что и без указания)
-- ⚠️ Может быть медленнее (если нужна кросс-компиляция)
-- ✅ Нужно только для кросс-платформенного тестирования
+- ⚠️ Redundant (same as without specification)
+- ⚠️ May be slower (if cross-compilation is needed)
+- ✅ Only needed for cross-platform testing
-## Когда добавить multi-arch тестирование?
+## When to add multi-arch testing?
-Добавьте только если:
-1. ✅ Проект должен работать на разных платформах (Windows, macOS, Linux)
-2. ✅ Есть платформо-специфичный код (syscalls, файловые пути)
-3. ✅ Нужно тестировать на ARM (например, для Docker на ARM)
-4. ✅ Требования проекта/компании
+Add only if:
+1. ✅ Project must work on different platforms (Windows, macOS, Linux)
+2. ✅ There is platform-specific code (syscalls, file paths)
+3. ✅ Need to test on ARM (e.g., for Docker on ARM)
+4. ✅ Project/company requirements
-## Рекомендации
+## Recommendations
-### Для вашего проекта (LinkKeeper)
+### For your project (LinkKeeper)
-**Текущая конфигурация идеальна:**
-- ✅ Тесты запускаются на Linux amd64 (стандарт для серверов)
-- ✅ Быстро и эффективно
-- ✅ Покрывает основную целевую платформу
-- ✅ Race detector работает корректно
+**Current configuration is ideal:**
+- ✅ Tests run on Linux amd64 (standard for servers)
+- ✅ Fast and efficient
+- ✅ Covers the main target platform
+- ✅ Race detector works correctly
-**Не нужно добавлять:**
-- ❌ Явное указание GOOS/GOARCH (избыточно)
-- ❌ Multi-arch тестирование (если не требуется)
-- ❌ Дополнительные сборки (только для тестов)
+**Don't need to add:**
+- ❌ Explicit GOOS/GOARCH specification (redundant)
+- ❌ Multi-arch testing (if not required)
+- ❌ Additional builds (only for tests)
-### Если понадобится расширить
+### If you need to expand
-Добавьте matrix strategy только если:
-- Нужно тестировать на Windows/macOS
-- Нужна поддержка ARM
-- Есть платформо-специфичный код
+Add matrix strategy only if:
+- Need to test on Windows/macOS
+- Need ARM support
+- There is platform-specific code
-## Пример расширенной конфигурации (если понадобится)
+## Extended configuration example (if needed)
```yaml
jobs:
@@ -161,7 +161,7 @@ jobs:
services:
postgres:
image: postgres:16
- # ... (только для Linux)
+ # ... (only for Linux)
if: matrix.os == 'ubuntu-latest'
steps:
- uses: actions/checkout@v4
@@ -170,18 +170,18 @@ jobs:
go-version: ${{ matrix.go-version }}
- name: Run tests
run: go test -v -race ./...
- # PostgreSQL только на Linux
+ # PostgreSQL only on Linux
if: matrix.os == 'ubuntu-latest'
```
-## Вывод
+## Conclusion
-**Для вашего проекта:**
-- ✅ Текущая конфигурация правильная
-- ✅ Не нужно указывать архитектуру явно
-- ✅ `go test` автоматически работает корректно
-- ✅ Все тесты запускаются нативно на Linux amd64
+**For your project:**
+- ✅ Current configuration is correct
+- ✅ No need to specify architecture explicitly
+- ✅ `go test` automatically works correctly
+- ✅ All tests run natively on Linux amd64
-**Добавляйте multi-arch только если:**
-- Требуется поддержка других платформ
-- Есть специфичные требования проекта
+**Add multi-arch only if:**
+- Support for other platforms is required
+- There are specific project requirements
diff --git a/docs/CI_CD.md b/docs/CI_CD.md
index 16583fe..599bca7 100644
--- a/docs/CI_CD.md
+++ b/docs/CI_CD.md
@@ -76,8 +76,6 @@ Install hooks:
```bash
task hooks:install
# or
-make install-hooks
-# or
pre-commit install
```
@@ -133,22 +131,6 @@ task test:unit
task test:integration
```
-### Using Makefile
-
-```bash
-# Run linter
-make lint
-
-# Run tests
-make test
-
-# Run tests with coverage
-make test-coverage
-
-# Format code
-make fmt
-```
-
## Environment Variables
### Required for CI/CD:
diff --git a/docs/GOFMT_FIX.md b/docs/GOFMT_FIX.md
index 50bae0e..bbce490 100644
--- a/docs/GOFMT_FIX.md
+++ b/docs/GOFMT_FIX.md
@@ -1,28 +1,28 @@
-# Исправление проверки gofmt в CI
+# Fixing gofmt Check in CI
-## Проблема
+## Problem
-CI падал с ошибкой:
+CI was failing with error:
```
Please run 'go fmt ./...'
vendor/github.com/davecgh/go-spew/spew/bypass.go
vendor/github.com/google/uuid/uuid.go
-... (много файлов из vendor/)
+... (many files from vendor/)
Error: Process completed with exit code 1.
```
-## Причина
+## Cause
-Проверка `gofmt` проверяла все файлы, включая директорию `vendor/`, которая содержит внешние зависимости. Эти файлы не должны проверяться, так как:
-1. Они не являются частью нашего кода
-2. Они могут быть отформатированы по-другому
-3. Мы не контролируем их форматирование
+The `gofmt` check was checking all files, including the `vendor/` directory, which contains external dependencies. These files should not be checked because:
+1. They are not part of our code
+2. They may be formatted differently
+3. We don't control their formatting
-## Решение
+## Solution
-Исключили директорию `vendor/` из проверки форматирования.
+Excluded the `vendor/` directory from formatting check.
-### Было:
+### Before:
```yaml
- name: Run go fmt
run: |
@@ -33,7 +33,7 @@ Error: Process completed with exit code 1.
fi
```
-### Стало:
+### After:
```yaml
- name: Run go fmt
run: |
@@ -45,17 +45,17 @@ Error: Process completed with exit code 1.
fi
```
-## Что изменилось
+## What changed
-1. ✅ Используется `find` для поиска `.go` файлов
-2. ✅ Исключается `vendor/` директория
-3. ✅ Исключается `.git/` директория
-4. ✅ Проверяются только файлы проекта
+1. ✅ Uses `find` to search for `.go` files
+2. ✅ Excludes `vendor/` directory
+3. ✅ Excludes `.git/` directory
+4. ✅ Checks only project files
-## Проверка
+## Verification
```bash
-# Локальная проверка (исключая vendor)
+# Local check (excluding vendor)
unformatted=$(gofmt -s -l $(find . -name '*.go' -not -path './vendor/*' -not -path './.git/*' 2>/dev/null))
if [ -n "$unformatted" ]; then
echo "Unformatted files found"
@@ -65,9 +65,9 @@ else
fi
```
-## Альтернативные подходы
+## Alternative approaches
-### Вариант 1: Использовать go fmt напрямую
+### Option 1: Use go fmt directly
```yaml
- name: Run go fmt
run: |
@@ -79,7 +79,7 @@ fi
fi
```
-### Вариант 2: Использовать gofmt с явным списком директорий
+### Option 2: Use gofmt with explicit directory list
```yaml
- name: Run go fmt
run: |
@@ -90,20 +90,20 @@ fi
done
```
-## Рекомендация
+## Recommendation
-Текущее решение оптимально:
-- ✅ Проверяет только файлы проекта
-- ✅ Игнорирует vendor и .git
-- ✅ Показывает список неотформатированных файлов
-- ✅ Работает быстро
+Current solution is optimal:
+- ✅ Checks only project files
+- ✅ Ignores vendor and .git
+- ✅ Shows list of unformatted files
+- ✅ Works fast
-## Файлы изменены
+## Files changed
-- `.github/workflows/ci.yml` - Обновлена проверка gofmt
+- `.github/workflows/ci.yml` - Updated gofmt check
-## Результат
+## Result
-- ✅ CI больше не падает на файлах из vendor/
-- ✅ Проверяются только файлы проекта
-- ✅ Более быстрая проверка (меньше файлов)
+- ✅ CI no longer fails on files from vendor/
+- ✅ Only project files are checked
+- ✅ Faster check (fewer files)
diff --git a/docs/README_TESTING_CI.md b/docs/README_TESTING_CI.md
index 8828288..79e8301 100644
--- a/docs/README_TESTING_CI.md
+++ b/docs/README_TESTING_CI.md
@@ -80,8 +80,7 @@ Located in `.pre-commit-config.yaml`
- `.golangci.yml` - Linter configuration
- `.pre-commit-config.yaml` - Pre-commit hooks
-- `Makefile` - Alternative task runner
-- `Taskfile.yml` - Updated with test commands
+- `Taskfile.yml` - Task runner with all project commands
### ✅ Documentation
@@ -127,8 +126,7 @@ LinkKeeper/
│ └── integration_test.go # ✨ Integration tests
├── .golangci.yml # ✨ Linter config
├── .pre-commit-config.yaml # ✨ Pre-commit hooks
-├── Makefile # ✨ Make commands
-├── Taskfile.yml # Updated with test tasks
+├── Taskfile.yml # ✨ Task runner with all commands
├── docs/
│ ├── TESTING.md # ✨ Testing guide
│ ├── CI_CD.md # ✨ CI/CD guide
@@ -143,9 +141,6 @@ LinkKeeper/
```bash
# Install pre-commit hooks (once)
task hooks:install
-
-# Or with make
-make install-hooks
```
### 2. While Developing
@@ -209,12 +204,6 @@ task test:integration # Integration tests only
task test:coverage # With HTML coverage report
task ci:local # Full CI simulation
-# Make
-make test # All tests
-make test-coverage # With coverage report
-make lint # Run linter
-make fmt # Format code
-
# Go directly
go test ./... # All tests
go test -v ./... # Verbose
@@ -227,7 +216,7 @@ go test -coverprofile=coverage.out ./... # Generate coverage
```bash
task lint # Run all linters
-make lint # Same with make
+task fmt # Format code
golangci-lint run # Direct command
go fmt ./... # Format code
go vet ./... # Static analysis
diff --git a/docs/TESTING.md b/docs/TESTING.md
index 1def6e8..19577c3 100644
--- a/docs/TESTING.md
+++ b/docs/TESTING.md
@@ -58,20 +58,20 @@ go test -v -coverprofile=coverage.out ./...
go tool cover -html=coverage.out
```
-### Using Makefile
+### Using Taskfile
```bash
# Run tests
-make test
+task test
# Run tests with coverage
-make test-coverage
+task test:coverage
# Run linters
-make lint
+task lint
# Format code
-make fmt
+task fmt
```
## Test Dependencies
@@ -187,8 +187,6 @@ Install pre-commit hooks to run checks before each commit:
```bash
task hooks:install
-# or
-make install-hooks
```
The hooks will:
diff --git a/frontend/App.tsx b/frontend/App.tsx
index 9ff241d..b1ef145 100644
--- a/frontend/App.tsx
+++ b/frontend/App.tsx
@@ -31,7 +31,7 @@ export default function App() {
component={ModernScreen}
options={{
title: 'LinkKeeper',
- headerShown: false, // Скрываем стандартный header, используем кастомный
+ headerShown: false, // Hide standard header, use custom one
}}
/>
diff --git a/frontend/DESIGN_IMPROVEMENTS.md b/frontend/DESIGN_IMPROVEMENTS.md
index 8e62ddc..b7aa721 100644
--- a/frontend/DESIGN_IMPROVEMENTS.md
+++ b/frontend/DESIGN_IMPROVEMENTS.md
@@ -1,76 +1,76 @@
-# Предложения по улучшению дизайна
-
-## Текущие проблемы:
-- ❌ Слишком большие кнопки (48px) - не подходят для десктопа
-- ❌ Большие формы с большими полями ввода
-- ❌ Вертикальный toolbar - занимает много места
-- ❌ Не адаптивный - одинаковый размер для всех экранов
-
-## Варианты улучшения:
-
-### Вариант 1: Адаптивный дизайн с breakpoints
-**Идея:** Разные размеры для десктопа (>768px) и мобильных (<768px)
-
-**Для десктопа:**
-- Кнопки: 32-36px высота (вместо 48px)
-- Поля ввода: 36-40px высота (вместо 52px)
-- Горизонтальный toolbar (поиск и кнопки в одну строку)
-- Компактный header (меньше padding)
-- Более мелкие шрифты (14px вместо 16px)
-- Плотнее расположение элементов
-
-**Для мобильных:**
-- Сохранить текущие размеры (48px кнопки, 52px поля)
-- Вертикальный layout
-
-### Вариант 2: Компактный десктопный стиль
-**Идея:** Стиль как в VS Code / Cursor - очень компактный
-
-**Особенности:**
-- Кнопки: 28-32px
-- Поля ввода: 32px
-- Компактные иконки
-- Минимальные отступы
-- Плотная сетка карточек (2-3 колонки на десктопе)
-
-### Вариант 3: Гибридный подход
-**Идея:** Компактный по умолчанию, но с возможностью увеличения для мобильных
-
-**Особенности:**
-- Базовые размеры компактные (32-36px)
-- Автоматическое увеличение на мобильных
-- Горизонтальный layout на десктопе
-- Вертикальный на мобильных
-
-## Рекомендация:
-**Вариант 1** - самый сбалансированный:
-- ✅ Комфортно на десктопе (компактно)
-- ✅ Удобно на мобильных (большие touch-таргеты)
-- ✅ Адаптивный и современный
-- ✅ Сохраняет стиль Cursor/GitHub
-
-## Что будет изменено:
+# Design Improvement Suggestions
+
+## Current issues:
+- ❌ Buttons too large (48px) - not suitable for desktop
+- ❌ Large forms with large input fields
+- ❌ Vertical toolbar - takes up too much space
+- ❌ Not responsive - same size for all screens
+
+## Improvement options:
+
+### Option 1: Responsive design with breakpoints
+**Idea:** Different sizes for desktop (>768px) and mobile (<768px)
+
+**For desktop:**
+- Buttons: 32-36px height (instead of 48px)
+- Input fields: 36-40px height (instead of 52px)
+- Horizontal toolbar (search and buttons in one row)
+- Compact header (less padding)
+- Smaller fonts (14px instead of 16px)
+- Tighter element spacing
+
+**For mobile:**
+- Keep current sizes (48px buttons, 52px fields)
+- Vertical layout
+
+### Option 2: Compact desktop style
+**Idea:** Style like VS Code / Cursor - very compact
+
+**Features:**
+- Buttons: 28-32px
+- Input fields: 32px
+- Compact icons
+- Minimal spacing
+- Dense card grid (2-3 columns on desktop)
+
+### Option 3: Hybrid approach
+**Idea:** Compact by default, but with ability to scale up for mobile
+
+**Features:**
+- Base sizes are compact (32-36px)
+- Automatic scaling on mobile
+- Horizontal layout on desktop
+- Vertical on mobile
+
+## Recommendation:
+**Option 1** - most balanced:
+- ✅ Comfortable on desktop (compact)
+- ✅ Convenient on mobile (large touch targets)
+- ✅ Responsive and modern
+- ✅ Maintains Cursor/GitHub style
+
+## What will be changed:
1. **Header:**
- - Десктоп: padding 12px, компактная статистика
- - Мобильные: padding 16-20px
+ - Desktop: 12px padding, compact statistics
+ - Mobile: 16-20px padding
-2. **Кнопки:**
- - Десктоп: 32-36px высота, padding 8-12px
- - Мобильные: 48px высота, padding 14px
+2. **Buttons:**
+ - Desktop: 32-36px height, 8-12px padding
+ - Mobile: 48px height, 14px padding
-3. **Формы:**
- - Десктоп: 36-40px высота полей, компактные кнопки
- - Мобильные: 52px высота, большие кнопки
+3. **Forms:**
+ - Desktop: 36-40px field height, compact buttons
+ - Mobile: 52px height, large buttons
4. **Toolbar:**
- - Десктоп: горизонтальный (поиск и кнопки в ряд)
- - Мобильные: вертикальный
+ - Desktop: horizontal (search and buttons in a row)
+ - Mobile: vertical
-5. **Карточки:**
- - Десктоп: меньше padding, компактнее метаданные
- - Мобильные: текущие размеры
+5. **Cards:**
+ - Desktop: less padding, more compact metadata
+ - Mobile: current sizes
-6. **Шрифты:**
- - Десктоп: 14px основной, 12px вторичный
- - Мобильные: 16px основной, 13px вторичный
+6. **Fonts:**
+ - Desktop: 14px primary, 12px secondary
+ - Mobile: 16px primary, 13px secondary
diff --git a/frontend/QUICKSTART.md b/frontend/QUICKSTART.md
index 4486c89..4e7dae1 100644
--- a/frontend/QUICKSTART.md
+++ b/frontend/QUICKSTART.md
@@ -1,54 +1,54 @@
-# Быстрый старт
+# Quick Start
-## 1. Установка зависимостей
+## 1. Install dependencies
```bash
cd frontend
npm install
```
-## 2. Запуск в браузере
+## 2. Run in browser
```bash
npm run dev
```
-Приложение откроется автоматически в браузере на `http://localhost:19006`
+The application will open automatically in the browser at `http://localhost:19006`
-## 3. Выбор варианта UI
+## 3. Select UI variant
-Откройте файл `App.tsx` и замените:
+Open the `App.tsx` file and replace:
```typescript
-// Текущий вариант (полный функционал)
+// Current variant (full functionality)
import HomeScreen from './src/screens/HomeScreen';
-// Или выберите один из вариантов:
+// Or choose one of the variants:
import Variant1_ClassicList from './src/screens/Variant1_ClassicList';
// import Variant2_CardGrid from './src/screens/Variant2_CardGrid';
// import Variant3_Dashboard from './src/screens/Variant3_Dashboard';
```
-И в компоненте:
+And in the component:
```typescript