diff --git a/.gitignore b/.gitignore index 974acf7..99eed3c 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,8 @@ Temporary Items !.vscode/*.code-snippets !*.code-workspace +.idea/* + # Built Visual Studio Code Extensions *.vsix diff --git a/cmd/achievements/main.go b/cmd/achievements/main.go new file mode 100644 index 0000000..500ce53 --- /dev/null +++ b/cmd/achievements/main.go @@ -0,0 +1,33 @@ +package main + +import ( + "log" + "os" + "os/signal" + "syscall" + + "datarush/internal/achievements/config" + "datarush/internal/achievements/server" +) + +func main() { + cfg, err := config.Load() + if err != nil { + log.Fatalf("failed to load config: %v", err) + } + + srv := server.New(cfg) + + if err := srv.Start(); err != nil { + log.Fatalf("failed to start server: %v", err) + } + + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit + + log.Println("shutting down achievements server...") + srv.Stop() + log.Println("achievements server stopped") +} + diff --git a/cmd/migrate/migrations/000002_create_achievements_tables.down.sql b/cmd/migrate/migrations/000002_create_achievements_tables.down.sql new file mode 100644 index 0000000..0b7c486 --- /dev/null +++ b/cmd/migrate/migrations/000002_create_achievements_tables.down.sql @@ -0,0 +1,3 @@ +DROP TABLE IF EXISTS user_achievements; +DROP TABLE IF EXISTS achievements; + diff --git a/cmd/migrate/migrations/000002_create_achievements_tables.up.sql b/cmd/migrate/migrations/000002_create_achievements_tables.up.sql new file mode 100644 index 0000000..aafd4a0 --- /dev/null +++ b/cmd/migrate/migrations/000002_create_achievements_tables.up.sql @@ -0,0 +1,20 @@ +CREATE TABLE IF NOT EXISTS achievements ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(255) NOT NULL, + description TEXT NOT NULL, + icon_url VARCHAR(512), + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS user_achievements ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + achievement_id UUID NOT NULL REFERENCES achievements(id) ON DELETE CASCADE, + received_at TIMESTAMP NOT NULL DEFAULT NOW(), + UNIQUE(user_id, achievement_id) +); + +CREATE INDEX IF NOT EXISTS idx_user_achievements_user_id ON user_achievements(user_id); +CREATE INDEX IF NOT EXISTS idx_user_achievements_achievement_id ON user_achievements(achievement_id); + diff --git a/internal/achievements/config/config.go b/internal/achievements/config/config.go new file mode 100644 index 0000000..a9e6bf4 --- /dev/null +++ b/internal/achievements/config/config.go @@ -0,0 +1,77 @@ +package config + +import ( + "fmt" + "log" + "net" + "os" + "strconv" + + "github.com/joho/godotenv" +) + +type Config struct { + GRPCPort int + GRPCEnableReflection bool + HTTPPort int + LogLevel string + DBHost string + DBPort int + DBUser string + DBPassword string + DBName string + AuthSvcAddr string +} + +func Load() (*Config, error) { + _ = godotenv.Load() + + return &Config{ + GRPCPort: mustGetInt("ACHIEVEMENTS_GRPC_PORT", 50057), + GRPCEnableReflection: mustGetBool("ACHIEVEMENTS_GRPC_ENABLE_REFLECTION", false), + HTTPPort: mustGetInt("ACHIEVEMENTS_HTTP_PORT", 8087), + LogLevel: getEnv("LOG_LEVEL", "info"), + DBHost: getEnv("POSTGRES_HOST", "localhost"), + DBPort: mustGetInt("POSTGRES_PORT", 5432), + DBUser: getEnv("POSTGRES_USERNAME", "postgres"), + DBPassword: getEnv("POSTGRES_PASSWORD", "postgres"), + DBName: getEnv("POSTGRES_DATABASE", "postgres"), + AuthSvcAddr: getEnv("AUTH_SVC_ADDR", "localhost:50051"), + }, nil +} + +func getEnv(key, def string) string { + if val := os.Getenv(key); val != "" { + return val + } + return def +} + +func mustGetInt(key string, def int) int { + val := getEnv(key, strconv.Itoa(def)) + n, err := strconv.Atoi(val) + if err != nil { + log.Fatalf("invalid int for %s: %v", key, err) + } + return n +} + +func mustGetBool(key string, def bool) bool { + val := getEnv(key, strconv.FormatBool(def)) + b, err := strconv.ParseBool(val) + if err != nil { + log.Fatalf("invalid bool for %s: %v", key, err) + } + return b +} + +func (c Config) BuildPostgresConnStr() string { + return fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=disable", + c.DBHost, c.DBPort, c.DBUser, c.DBPassword, c.DBName) +} + +func (c Config) BuildPostgresDSN() string { + return fmt.Sprintf("postgresql://%s:%s@%s/%s?sslmode=disable", + c.DBUser, c.DBPassword, net.JoinHostPort(c.DBHost, strconv.Itoa(c.DBPort)), c.DBName) +} + diff --git a/internal/achievements/handler/grpc/handler.go b/internal/achievements/handler/grpc/handler.go new file mode 100644 index 0000000..dde5464 --- /dev/null +++ b/internal/achievements/handler/grpc/handler.go @@ -0,0 +1,51 @@ +package grpc + +import ( + "context" + + pb "datarush/pkg/api/achievements" + + "google.golang.org/protobuf/types/known/emptypb" +) + +type AchievementService interface { + CreateAchievement(ctx context.Context, req *pb.Achievement) (*pb.Achievement, error) + GetAchievement(ctx context.Context, req *pb.GetAchievementRequest) (*pb.Achievement, error) + EditAchievement(ctx context.Context, req *pb.Achievement) (*pb.Achievement, error) + DeleteAchievement(ctx context.Context, req *pb.DeleteAchievementRequest) (*emptypb.Empty, error) + ListAchievements(ctx context.Context, req *emptypb.Empty) (*pb.ListAchievementsResponse, error) + GetUserAchievements(ctx context.Context, req *pb.GetUserAchievementsRequest) (*pb.GetUserAchievementsResponse, error) +} + +type AchievementHandler struct { + pb.UnimplementedAchievementsServiceServer + service AchievementService +} + +func NewAchievementHandler(service AchievementService) *AchievementHandler { + return &AchievementHandler{service: service} +} + +func (h *AchievementHandler) CreateAchievement(ctx context.Context, req *pb.Achievement) (*pb.Achievement, error) { + return h.service.CreateAchievement(ctx, req) +} + +func (h *AchievementHandler) GetAchievement(ctx context.Context, req *pb.GetAchievementRequest) (*pb.Achievement, error) { + return h.service.GetAchievement(ctx, req) +} + +func (h *AchievementHandler) EditAchievement(ctx context.Context, req *pb.Achievement) (*pb.Achievement, error) { + return h.service.EditAchievement(ctx, req) +} + +func (h *AchievementHandler) DeleteAchievement(ctx context.Context, req *pb.DeleteAchievementRequest) (*emptypb.Empty, error) { + return h.service.DeleteAchievement(ctx, req) +} + +func (h *AchievementHandler) ListAchievements(ctx context.Context, req *emptypb.Empty) (*pb.ListAchievementsResponse, error) { + return h.service.ListAchievements(ctx, req) +} + +func (h *AchievementHandler) GetUserAchievements(ctx context.Context, req *pb.GetUserAchievementsRequest) (*pb.GetUserAchievementsResponse, error) { + return h.service.GetUserAchievements(ctx, req) +} diff --git a/internal/achievements/repository/postgres/repository.go b/internal/achievements/repository/postgres/repository.go new file mode 100644 index 0000000..bfb9dcf --- /dev/null +++ b/internal/achievements/repository/postgres/repository.go @@ -0,0 +1,145 @@ +package postgres + +import ( + "context" + "database/sql" + "time" + + "github.com/jmoiron/sqlx" + "google.golang.org/protobuf/types/known/timestamppb" + + pb "datarush/pkg/api/achievements" +) + +type AchievementRepository struct { + db *sqlx.DB +} + +func NewAchievementRepository(db *sqlx.DB) *AchievementRepository { + return &AchievementRepository{db: db} +} + +func (r *AchievementRepository) CreateAchievement(ctx context.Context, achievement *pb.Achievement) (*pb.Achievement, error) { + query := `INSERT INTO achievements (name, description, icon_url) + VALUES ($1, $2, $3) RETURNING id` + + var createdAchievement pb.Achievement + err := r.db.QueryRowContext(ctx, query, achievement.Name, achievement.Description, achievement.IconUrl). + Scan(&createdAchievement.Id) + if err != nil { + return nil, err + } + + createdAchievement.Name = achievement.Name + createdAchievement.Description = achievement.Description + createdAchievement.IconUrl = achievement.IconUrl + + return &createdAchievement, nil +} + +func (r *AchievementRepository) GetAchievement(ctx context.Context, id string) (*pb.Achievement, error) { + query := `SELECT id, name, description, icon_url + FROM achievements WHERE id = $1` + + var achievement pb.Achievement + err := r.db.QueryRowContext(ctx, query, id). + Scan(&achievement.Id, &achievement.Name, &achievement.Description, &achievement.IconUrl) + if err != nil { + if err == sql.ErrNoRows { + return nil, sql.ErrNoRows + } + return nil, err + } + + return &achievement, nil +} + +func (r *AchievementRepository) EditAchievement(ctx context.Context, achievement *pb.Achievement) (*pb.Achievement, error) { + query := `UPDATE achievements SET name = $1, description = $2, icon_url = $3, updated_at = NOW() + WHERE id = $4` + + _, err := r.db.ExecContext(ctx, query, achievement.Name, achievement.Description, achievement.IconUrl, achievement.Id) + if err != nil { + return nil, err + } + + return achievement, nil +} + +func (r *AchievementRepository) DeleteAchievement(ctx context.Context, id string) error { + _, err := r.db.ExecContext(ctx, "DELETE FROM achievements WHERE id = $1", id) + return err +} + +func (r *AchievementRepository) ListAchievements(ctx context.Context) ([]*pb.Achievement, error) { + query := `SELECT id, name, description, icon_url FROM achievements ORDER BY created_at DESC` + + rows, err := r.db.QueryContext(ctx, query) + if err != nil { + return nil, err + } + defer rows.Close() + + var achievements []*pb.Achievement + for rows.Next() { + var achievement pb.Achievement + + err := rows.Scan(&achievement.Id, &achievement.Name, &achievement.Description, &achievement.IconUrl) + if err != nil { + return nil, err + } + + achievements = append(achievements, &achievement) + } + + if err = rows.Err(); err != nil { + return nil, err + } + + return achievements, nil +} + +func (r *AchievementRepository) GetUserAchievements(ctx context.Context, userID string) ([]*pb.AchievementUser, error) { + query := `SELECT a.id, a.name, a.description, a.icon_url, ua.received_at + FROM user_achievements ua + INNER JOIN achievements a ON ua.achievement_id = a.id + WHERE ua.user_id = $1 + ORDER BY ua.received_at DESC` + + rows, err := r.db.QueryContext(ctx, query, userID) + if err != nil { + return nil, err + } + defer rows.Close() + + var userAchievements []*pb.AchievementUser + for rows.Next() { + var achievement pb.Achievement + var receivedAt time.Time + + err := rows.Scan( + &achievement.Id, + &achievement.Name, + &achievement.Description, + &achievement.IconUrl, + &receivedAt, + ) + if err != nil { + return nil, err + } + + userAchievement := &pb.AchievementUser{ + Achievement: &achievement, + ReceivedAt: timestamppb.New(receivedAt), + } + + userAchievements = append(userAchievements, userAchievement) + } + + if err = rows.Err(); err != nil { + return nil, err + } + + return userAchievements, nil +} + diff --git a/internal/achievements/repository/repository.go b/internal/achievements/repository/repository.go new file mode 100644 index 0000000..41780fe --- /dev/null +++ b/internal/achievements/repository/repository.go @@ -0,0 +1,16 @@ +package repository + +import ( + "context" + + pb "datarush/pkg/api/achievements" +) + +type AchievementRepository interface { + CreateAchievement(ctx context.Context, achievement *pb.Achievement) (*pb.Achievement, error) + GetAchievement(ctx context.Context, id string) (*pb.Achievement, error) + EditAchievement(ctx context.Context, achievement *pb.Achievement) (*pb.Achievement, error) + DeleteAchievement(ctx context.Context, id string) error + ListAchievements(ctx context.Context) ([]*pb.Achievement, error) + GetUserAchievements(ctx context.Context, userID string) ([]*pb.AchievementUser, error) +} diff --git a/internal/achievements/server/server.go b/internal/achievements/server/server.go new file mode 100644 index 0000000..d90a284 --- /dev/null +++ b/internal/achievements/server/server.go @@ -0,0 +1,116 @@ +package server + +import ( + "fmt" + "log" + "net" + "time" + + "datarush/internal/achievements/config" + grpcHandlers "datarush/internal/achievements/handler/grpc" + achievementPostgresRepo "datarush/internal/achievements/repository/postgres" + "datarush/internal/achievements/service" + pb "datarush/pkg/api/achievements" + authpb "datarush/pkg/api/auth" + "datarush/pkg/interceptor" + + "github.com/jmoiron/sqlx" + _ "github.com/lib/pq" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/reflection" +) + +const ( + httpReadTimeout = 10 * time.Second + httpWriteTimeout = 10 * time.Second + httpIdleTimeout = 60 * time.Second +) + +type Server struct { + grpcServer *grpc.Server + config *config.Config + db *sqlx.DB + authConn *grpc.ClientConn +} + +func New(cfg *config.Config) *Server { + return &Server{ + config: cfg, + } +} + +func (s *Server) Start() error { + db, err := sqlx.Connect("postgres", s.config.BuildPostgresConnStr()) + if err != nil { + return fmt.Errorf("failed to connect to postgres: %w", err) + } + s.db = db + + authConn, err := grpc.Dial(s.config.AuthSvcAddr, grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + return fmt.Errorf("failed to connect to auth service: %w", err) + } + s.authConn = authConn + + if err := s.registerGRPCServices(); err != nil { + return fmt.Errorf("failed to register gRPC services: %w", err) + } + + go func() { + lis, err := net.Listen("tcp", fmt.Sprintf(":%d", s.config.GRPCPort)) + if err != nil { + log.Fatalf("failed to listen on grpc port: %v", err) + } + + log.Printf("starting gRPC server on port %d", s.config.GRPCPort) + if err := s.grpcServer.Serve(lis); err != nil { + log.Fatalf("failed to serve gRPC: %v", err) + } + }() + + return nil +} + +func (s *Server) registerGRPCServices() error { + authClient := authpb.NewAuthServiceClient(s.authConn) + authInterceptor := interceptor.NewAuthInterceptor(authClient) + + s.grpcServer = grpc.NewServer( + grpc.UnaryInterceptor(authInterceptor.Unary()), + ) + achievementRepo := achievementPostgresRepo.NewAchievementRepository(s.db) + + achievementService := service.NewAchievementService(achievementRepo) + + achievementHandler := grpcHandlers.NewAchievementHandler(achievementService) + pb.RegisterAchievementsServiceServer(s.grpcServer, achievementHandler) + + if s.config.GRPCEnableReflection { + reflection.Register(s.grpcServer) + } + + return nil +} + +func (s *Server) Stop() { + log.Println("shutting down achievements server...") + + if s.grpcServer != nil { + s.grpcServer.GracefulStop() + } + + if s.db != nil { + if err := s.db.Close(); err != nil { + log.Printf("failed to close database: %v", err) + } + } + + if s.authConn != nil { + if err := s.authConn.Close(); err != nil { + log.Printf("failed to close auth connection: %v", err) + } + } + + log.Println("achievements server stopped") +} diff --git a/internal/achievements/service/service.go b/internal/achievements/service/service.go new file mode 100644 index 0000000..35910b3 --- /dev/null +++ b/internal/achievements/service/service.go @@ -0,0 +1,78 @@ +package service + +import ( + "context" + "database/sql" + "errors" + + pb "datarush/pkg/api/achievements" + "google.golang.org/protobuf/types/known/emptypb" +) + +type AchievementRepository interface { + CreateAchievement(ctx context.Context, achievement *pb.Achievement) (*pb.Achievement, error) + GetAchievement(ctx context.Context, id string) (*pb.Achievement, error) + EditAchievement(ctx context.Context, achievement *pb.Achievement) (*pb.Achievement, error) + DeleteAchievement(ctx context.Context, id string) error + ListAchievements(ctx context.Context) ([]*pb.Achievement, error) + GetUserAchievements(ctx context.Context, userID string) ([]*pb.AchievementUser, error) +} + +type AchievementService struct { + repo AchievementRepository +} + +func NewAchievementService(repo AchievementRepository) *AchievementService { + return &AchievementService{repo: repo} +} + +func (s *AchievementService) CreateAchievement(ctx context.Context, req *pb.Achievement) (*pb.Achievement, error) { + return s.repo.CreateAchievement(ctx, req) +} + +func (s *AchievementService) GetAchievement(ctx context.Context, req *pb.GetAchievementRequest) (*pb.Achievement, error) { + achievement, err := s.repo.GetAchievement(ctx, req.Id) + if err != nil { + if err == sql.ErrNoRows { + return nil, errors.New("achievement not found") + } + return nil, err + } + return achievement, nil +} + +func (s *AchievementService) EditAchievement(ctx context.Context, req *pb.Achievement) (*pb.Achievement, error) { + _, err := s.repo.GetAchievement(ctx, req.Id) + if err != nil { + if err == sql.ErrNoRows { + return nil, errors.New("achievement not found") + } + return nil, err + } + + return s.repo.EditAchievement(ctx, req) +} + +func (s *AchievementService) DeleteAchievement(ctx context.Context, req *pb.DeleteAchievementRequest) (*emptypb.Empty, error) { + err := s.repo.DeleteAchievement(ctx, req.AchievementId) + if err != nil { + return nil, err + } + return &emptypb.Empty{}, nil +} + +func (s *AchievementService) ListAchievements(ctx context.Context, req *emptypb.Empty) (*pb.ListAchievementsResponse, error) { + achievements, err := s.repo.ListAchievements(ctx) + if err != nil { + return nil, err + } + return &pb.ListAchievementsResponse{Achievements: achievements}, nil +} + +func (s *AchievementService) GetUserAchievements(ctx context.Context, req *pb.GetUserAchievementsRequest) (*pb.GetUserAchievementsResponse, error) { + userAchievements, err := s.repo.GetUserAchievements(ctx, req.UserId) + if err != nil { + return nil, err + } + return &pb.GetUserAchievementsResponse{UserAchievements: userAchievements}, nil +} diff --git a/internal/gw/grpc_client/achievements_client.go b/internal/gw/grpc_client/achievements_client.go index 4a4148d..f33bc51 100644 --- a/internal/gw/grpc_client/achievements_client.go +++ b/internal/gw/grpc_client/achievements_client.go @@ -47,3 +47,28 @@ func (c *AchievementsClient) GetUserAchievements(ctx context.Context, userID str } return resp.UserAchievements, nil } + +func (c *AchievementsClient) CreateAchievement(ctx context.Context, achievement *pb.Achievement) (*pb.Achievement, error) { + resp, err := c.client.CreateAchievement(ctx, achievement) + if err != nil { + return nil, fmt.Errorf("create achievement failed: %w", err) + } + return resp, nil +} + +func (c *AchievementsClient) EditAchievement(ctx context.Context, achievement *pb.Achievement) (*pb.Achievement, error) { + resp, err := c.client.EditAchievement(ctx, achievement) + if err != nil { + return nil, fmt.Errorf("edit achievement failed: %w", err) + } + return resp, nil +} + +func (c *AchievementsClient) DeleteAchievement(ctx context.Context, achievementID string) error { + req := &pb.DeleteAchievementRequest{AchievementId: achievementID} + _, err := c.client.DeleteAchievement(ctx, req) + if err != nil { + return fmt.Errorf("delete achievement failed: %w", err) + } + return nil +} \ No newline at end of file