diff --git a/cmd/competition/main.go b/cmd/competition/main.go new file mode 100644 index 0000000..f10a1ed --- /dev/null +++ b/cmd/competition/main.go @@ -0,0 +1,32 @@ +package main + +import ( + "log" + "os" + "os/signal" + "syscall" + + "datarush/internal/competition/config" + "datarush/internal/competition/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 competition server...") + srv.Stop() + log.Println("competition server stopped") +} \ No newline at end of file diff --git a/cmd/server/main.go b/cmd/server/main.go index 37ff221..d452c70 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -1,6 +1,32 @@ package main +import ( + "log" + "os" + "os/signal" + "syscall" + + "datarush/internal/lms/config" + "datarush/internal/lms/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 auth server...") + srv.Stop() + log.Println("core server stopped") } diff --git a/internal/competition/config/config.go b/internal/competition/config/config.go new file mode 100644 index 0000000..6388bf7 --- /dev/null +++ b/internal/competition/config/config.go @@ -0,0 +1,74 @@ +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 +} + +func Load() (*Config, error) { + _ = godotenv.Load() + + return &Config{ + GRPCPort: mustGetInt("COMPETITION_GRPC_PORT", 50053), + GRPCEnableReflection: mustGetBool("COMPETITION_GRPC_ENABLE_REFLECTION", false), + HTTPPort: mustGetInt("COMPETITION_HTTP_PORT", 8082), + 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"), + }, 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) +} \ No newline at end of file diff --git a/internal/competition/domain/competition.go b/internal/competition/domain/competition.go new file mode 100644 index 0000000..de9c1f3 --- /dev/null +++ b/internal/competition/domain/competition.go @@ -0,0 +1,73 @@ +package domain + +import ( + "fmt" + "time" + + "github.com/go-playground/validator/v10" + "github.com/google/uuid" +) + +type CompetitionState string + +const ( + CompetitionStateUnspecified CompetitionState = "UNSPECIFIED" + CompetitionStateDraft CompetitionState = "DRAFT" + CompetitionStateNotStarted CompetitionState = "NOT_STARTED" + CompetitionStateStarted CompetitionState = "STARTED" + CompetitionStateFinished CompetitionState = "FINISHED" + CompetitionStateArchived CompetitionState = "ARCHIVED" +) + +type ParticipationType string + +const ( + ParticipationTypeUnspecified ParticipationType = "UNSPECIFIED" + ParticipationTypeIndividual ParticipationType = "INDIVIDUAL" + ParticipationTypeTeam ParticipationType = "TEAM" +) + +type CompetitionType string + +const ( + CompetitionTypeUnspecified CompetitionType = "UNSPECIFIED" + CompetitionTypeEducative CompetitionType = "EDUCATIVE" + CompetitionTypeCompetitive CompetitionType = "COMPETITIVE" +) + +type Competition struct { + ID uuid.UUID + State CompetitionState + Title string + Description string + ImageURL *string + StartTime time.Time + EndTime time.Time + Type CompetitionType + ParticipationType ParticipationType + CreatedAt time.Time + UpdatedAt time.Time +} + + +func NewCompetition(id uuid.UUID, state CompetitionState, title string, description string, imageURL *string, startTime time.Time, endTime time.Time, tpe CompetitionType, participationType ParticipationType, createdAt time.Time, updatedAt time.Time) *Competition { + return &Competition{ + ID: id, + State: state, + Title: title, + Description: description, + ImageURL: imageURL, + StartTime: startTime, + EndTime: endTime, + Type: tpe, + ParticipationType: participationType, + CreatedAt: createdAt, + UpdatedAt: updatedAt, + } +} + +func (c *Competition) Validate() error { + validate := validator.New() + + return fmt.Errorf("%w: %w", ErrInvalidCompetitionData, validate.Struct(c)) +} diff --git a/internal/competition/domain/errors.go b/internal/competition/domain/errors.go new file mode 100644 index 0000000..86b9827 --- /dev/null +++ b/internal/competition/domain/errors.go @@ -0,0 +1,8 @@ +package domain + +import "errors" + +var ( + ErrCompetitionNotFound = errors.New("competition not found") + ErrInvalidCompetitionData = errors.New("invalid competition data") +) diff --git a/internal/competition/handler/grpc/competition.go b/internal/competition/handler/grpc/competition.go new file mode 100644 index 0000000..5869479 --- /dev/null +++ b/internal/competition/handler/grpc/competition.go @@ -0,0 +1,199 @@ +package grpc + +import ( + "context" + + "datarush/internal/competition/domain" + pb "datarush/pkg/api/competition" + "github.com/google/uuid" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/timestamppb" + "strings" +) + +// ICompetitionService defines the interface for business logic. +// It works with domain models. +type ICompetitionService interface { + CreateCompetition(ctx context.Context, comp *domain.Competition) (*domain.Competition, error) + GetCompetition(ctx context.Context, id uuid.UUID) (*domain.Competition, error) + EditCompetition(ctx context.Context, comp *domain.Competition) (*domain.Competition, error) + DeleteCompetition(ctx context.Context, id uuid.UUID) error + ListCompetitions(ctx context.Context, pageSize int32, pageToken int32, state *domain.CompetitionState, isParticipating *bool, searchQuery *string) (competitions []domain.Competition, totalCount int32, nextPageToken int32, err error) + ChangeCompetitionState(ctx context.Context, id uuid.UUID, state domain.CompetitionState) (*domain.Competition, error) +} + +type CompetitionHandler struct { + pb.UnimplementedCompetitionServiceServer + service ICompetitionService +} + +func NewCompetitionHandler(s ICompetitionService) *CompetitionHandler { + return &CompetitionHandler{service: s} +} + +func (h *CompetitionHandler) CreateCompetition(ctx context.Context, req *pb.Competition) (*pb.Competition, error) { + domainComp, err := toDomainCompetition(req) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "failed to map competition: %v", err) + } + + createdComp, err := h.service.CreateCompetition(ctx, domainComp) + if err != nil { + return nil, status.Errorf(codes.Internal, "failed to create competition: %v", err) + } + + return fromDomainCompetition(createdComp), nil +} + +func (h *CompetitionHandler) GetCompetition(ctx context.Context, req *pb.GetCompetitionRequest) (*pb.Competition, error) { + id, err := uuid.Parse(req.GetCompetitionId()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid competition id format") + } + comp, err := h.service.GetCompetition(ctx, id) + if err != nil { + return nil, status.Errorf(codes.NotFound, "failed to get competition: %v", err) + } + return fromDomainCompetition(comp), nil +} + +func (h *CompetitionHandler) EditCompetition(ctx context.Context, req *pb.Competition) (*pb.Competition, error) { + domainComp, err := toDomainCompetition(req) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "failed to map competition: %v", err) + } + + updatedComp, err := h.service.EditCompetition(ctx, domainComp) + if err != nil { + return nil, status.Errorf(codes.Internal, "failed to edit competition: %v", err) + } + + return fromDomainCompetition(updatedComp), nil +} + +func (h *CompetitionHandler) DeleteCompetition(ctx context.Context, req *pb.DeleteCompetitionRequest) (*pb.DeleteCompetitionResponse, error) { + id, err := uuid.Parse(req.GetCompetitionId()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid competition id format") + } + if err := h.service.DeleteCompetition(ctx, id); err != nil { + return nil, status.Errorf(codes.Internal, "failed to delete competition: %v", err) + } + return &pb.DeleteCompetitionResponse{Success: true}, nil +} + +func (h *CompetitionHandler) ListCompetitions(ctx context.Context, req *pb.ListCompetitionsRequest) (*pb.ListCompetitionsResponse, error) { + var state *domain.CompetitionState + if req.State != nil { + s := toDomainCompetitionState(*req.State) + state = &s + } + + var searchQuery *string + if req.SearchQuery != nil { + searchQuery = req.SearchQuery + } + + competitions, totalCount, nextPageToken, err := h.service.ListCompetitions(ctx, req.PageSize, req.PageToken, state, req.IsParticipating, searchQuery) + if err != nil { + return nil, status.Errorf(codes.Internal, "failed to list competitions: %v", err) + } + + pbCompetitions := make([]*pb.Competition, len(competitions)) + for i, c := range competitions { + pbCompetitions[i] = fromDomainCompetition(&c) + } + + return &pb.ListCompetitionsResponse{ + Competitions: pbCompetitions, + TotalCount: totalCount, + NextPageToken: nextPageToken, + }, nil +} + +func (h *CompetitionHandler) ChangeCompetitionState(ctx context.Context, req *pb.ChangeCompetitionStateRequest) (*pb.Competition, error) { + id, err := uuid.Parse(req.GetCompetitionId()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid competition id format") + } + + comp, err := h.service.ChangeCompetitionState(ctx, id, toDomainCompetitionState(req.GetState())) + if err != nil { + return nil, status.Errorf(codes.Internal, "failed to change competition state: %v", err) + } + return fromDomainCompetition(comp), nil +} + + +func toDomainCompetition(c *pb.Competition) (*domain.Competition, error) { + if c == nil { + return nil, nil + } + + var id uuid.UUID + var err error + if c.Id != "" { + id, err = uuid.Parse(c.Id) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid id format") + } + } + + return &domain.Competition{ + ID: id, + State: toDomainCompetitionState(c.State), + Title: c.Title, + Description: c.Description, + ImageURL: c.ImageUrl, + StartTime: c.StartTime.AsTime(), + EndTime: c.EndTime.AsTime(), + Type: toDomainCompetitionType(c.Type), + ParticipationType: toDomainParticipationType(c.ParticipationType), + CreatedAt: c.CreatedAt.AsTime(), + UpdatedAt: c.UpdatedAt.AsTime(), + }, nil +} + +func fromDomainCompetition(c *domain.Competition) *pb.Competition { + if c == nil { + return nil + } + return &pb.Competition{ + Id: c.ID.String(), + State: fromDomainCompetitionState(c.State), + Title: c.Title, + Description: c.Description, + ImageUrl: c.ImageURL, + StartTime: timestamppb.New(c.StartTime), + EndTime: timestamppb.New(c.EndTime), + Type: fromDomainCompetitionType(c.Type), + ParticipationType: fromDomainParticipationType(c.ParticipationType), + CreatedAt: timestamppb.New(c.CreatedAt), + UpdatedAt: timestamppb.New(c.UpdatedAt), + } +} + +func toDomainCompetitionState(s pb.CompetitionState) domain.CompetitionState { + return domain.CompetitionState(strings.TrimPrefix(s.String(), "COMPETITION_STATE_")) +} + +func fromDomainCompetitionState(s domain.CompetitionState) pb.CompetitionState { + return pb.CompetitionState(pb.CompetitionState_value["COMPETITION_STATE_"+string(s)]) +} + +func toDomainParticipationType(pt pb.ParticipationType) domain.ParticipationType { + return domain.ParticipationType(strings.TrimPrefix(pt.String(), "PARTICIPATION_TYPE_")) +} + +func fromDomainParticipationType(pt domain.ParticipationType) pb.ParticipationType { + return pb.ParticipationType(pb.ParticipationType_value["PARTICIPATION_TYPE_"+string(pt)]) +} + +func toDomainCompetitionType(ct pb.CompetitionType) domain.CompetitionType { + return domain.CompetitionType(strings.TrimPrefix(ct.String(), "COMPETITION_TYPE_")) +} + +func fromDomainCompetitionType(ct domain.CompetitionType) pb.CompetitionType { + return pb.CompetitionType(pb.CompetitionType_value["COMPETITION_TYPE_"+string(ct)]) +} diff --git a/internal/competition/repository/competition.go b/internal/competition/repository/competition.go new file mode 100644 index 0000000..f8d8498 --- /dev/null +++ b/internal/competition/repository/competition.go @@ -0,0 +1,26 @@ +package repository + +import ( + "context" + "datarush/internal/competition/domain" + + "github.com/google/uuid" +) + + +type ListCompetitionsOptions struct { + Page int + PageSize int + State *domain.CompetitionState + IsParticipating *bool + SearchQuery *string +} + +type CompetitionRepository interface { + Create(ctx context.Context, competition *domain.Competition) error + Get(ctx context.Context, id uuid.UUID) (*domain.Competition, error) + Update(ctx context.Context, competition *domain.Competition) error + Delete(ctx context.Context, id uuid.UUID) error + List(ctx context.Context, opts ListCompetitionsOptions) ([]domain.Competition, int, error) + ChangeState(ctx context.Context, id uuid.UUID, state domain.CompetitionState) (*domain.Competition, error) +} diff --git a/internal/competition/repository/postgres/competition.go b/internal/competition/repository/postgres/competition.go new file mode 100644 index 0000000..f33e6b8 --- /dev/null +++ b/internal/competition/repository/postgres/competition.go @@ -0,0 +1,240 @@ +package postgres + +import ( + "context" + "database/sql" + "datarush/internal/competition/domain" + "datarush/internal/competition/repository" + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/google/uuid" + "github.com/redis/go-redis/v9" +) + +const ( + competitionCachePrefix = "competition:" +) + + +type CompetitionRepository struct { + db *sql.DB + redisClient *redis.Client + cacheEnabled bool +} + + +func NewCompetitionRepository(db *sql.DB, redisClient *redis.Client, cacheEnabled bool) repository.CompetitionRepository { + return &CompetitionRepository{ + db: db, + redisClient: redisClient, + cacheEnabled: cacheEnabled, + } +} + +func (r *CompetitionRepository) cacheKey(id string) string { + return competitionCachePrefix + id +} + +func (r *CompetitionRepository) Create(ctx context.Context, competition *domain.Competition) error { + query := `INSERT INTO competitions (id, state, title, description, image_url, start_time, end_time, type, participation_type, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)` + _, err := r.db.ExecContext(ctx, query, + competition.ID, + competition.State, + competition.Title, + competition.Description, + competition.ImageURL, + competition.StartTime, + competition.EndTime, + competition.Type, + competition.ParticipationType, + competition.CreatedAt, + competition.UpdatedAt, + ) + if err != nil { + return err + } + + if r.cacheEnabled { + data, err := json.Marshal(competition) + if err == nil { + r.redisClient.Set(ctx, r.cacheKey(competition.ID.String()), data, 10*time.Minute).Err() + } + } + + return nil +} + +func (r *CompetitionRepository) Get(ctx context.Context, id uuid.UUID) (*domain.Competition, error) { + if r.cacheEnabled { + val, err := r.redisClient.Get(ctx, r.cacheKey(id.String())).Result() + if err == nil { + var competition domain.Competition + if json.Unmarshal([]byte(val), &competition) == nil { + return &competition, nil + } + } + } + + query := `SELECT id, state, title, description, image_url, start_time, end_time, type, participation_type, created_at, updated_at FROM competitions WHERE id = $1` + row := r.db.QueryRowContext(ctx, query, id) + + var competition domain.Competition + err := row.Scan( + &competition.ID, + &competition.State, + &competition.Title, + &competition.Description, + &competition.ImageURL, + &competition.StartTime, + &competition.EndTime, + &competition.Type, + &competition.ParticipationType, + &competition.CreatedAt, + &competition.UpdatedAt, + ) + if err != nil { + return nil, err + } + + if r.cacheEnabled { + data, err := json.Marshal(&competition) + if err == nil { + r.redisClient.Set(ctx, r.cacheKey(id.String()), data, 10*time.Minute).Err() + } + } + + return &competition, nil +} + +func (r *CompetitionRepository) Update(ctx context.Context, competition *domain.Competition) error { + query := `UPDATE competitions SET + state = $2, title = $3, description = $4, image_url = $5, start_time = $6, end_time = $7, type = $8, participation_type = $9, updated_at = $10 + WHERE id = $1` + _, err := r.db.ExecContext(ctx, query, + competition.ID, + competition.State, + competition.Title, + competition.Description, + competition.ImageURL, + competition.StartTime, + competition.EndTime, + competition.Type, + competition.ParticipationType, + competition.UpdatedAt, + ) + if err != nil { + return err + } + + if r.cacheEnabled { + r.redisClient.Del(ctx, r.cacheKey(competition.ID.String())).Err() + } + + return nil +} +func (r *CompetitionRepository) Delete(ctx context.Context, id uuid.UUID) error { + query := `DELETE FROM competitions WHERE id = $1` + _, err := r.db.ExecContext(ctx, query, id) + if err != nil { + return err + } + + if r.cacheEnabled { + r.redisClient.Del(ctx, r.cacheKey(id.String())).Err() + } + + return nil +} +func (r *CompetitionRepository) List(ctx context.Context, opts repository.ListCompetitionsOptions) ([]*domain.Competition, int, error) { + var args []interface{} + var whereClauses []string + argId := 1 + + if opts.State != nil { + whereClauses = append(whereClauses, fmt.Sprintf("state = $%d", argId)) + args = append(args, *opts.State) + argId++ + } + if opts.SearchQuery != nil { + whereClauses = append(whereClauses, fmt.Sprintf("title ILIKE $%d", argId)) + args = append(args, "%"+*opts.SearchQuery+"%") + argId++ + } + + if opts.IsParticipating != nil { + userID, ok := ctx.Value("user_id").(uuid.UUID) + if !ok { + return nil, 0, fmt.Errorf("user not authenticated or user_id not in context") + } + + if *opts.IsParticipating { + whereClauses = append(whereClauses, fmt.Sprintf("id IN (SELECT competition_id FROM competition_participants WHERE user_id = $%d)", argId)) + } else { + whereClauses = append(whereClauses, fmt.Sprintf("id NOT IN (SELECT competition_id FROM competition_participants WHERE user_id = $%d)", argId)) + } + args = append(args, userID) + argId++ + } + + where := "" + if len(whereClauses) > 0 { + where = "WHERE " + strings.Join(whereClauses, " AND ") + } + + countQuery := "SELECT COUNT(*) FROM competitions " + where + var total int + if err := r.db.QueryRowContext(ctx, countQuery, args...).Scan(&total); err != nil { + return nil, 0, err + } + + query := fmt.Sprintf(`SELECT id, state, title, description, image_url, start_time, end_time, type, participation_type, created_at, updated_at FROM competitions %s ORDER BY created_at DESC LIMIT $%d OFFSET $%d`, where, argId, argId+1) + args = append(args, opts.PageSize, opts.Page*opts.PageSize) + + rows, err := r.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, 0, err + } + defer rows.Close() + + var competitions []*domain.Competition + for rows.Next() { + var c domain.Competition + err := rows.Scan( + &c.ID, + &c.State, + &c.Title, + &c.Description, + &c.ImageURL, + &c.StartTime, + &c.EndTime, + &c.Type, + &c.ParticipationType, + &c.CreatedAt, + &c.UpdatedAt, + ) + if err != nil { + return nil, 0, err + } + competitions = append(competitions, &c) + } + + return competitions, total, nil +} +func (r *CompetitionRepository) ChangeState(ctx context.Context, id uuid.UUID, state domain.CompetitionState) (*domain.Competition, error) { + query := `UPDATE competitions SET state = $1, updated_at = $2 WHERE id = $3` + now := time.Now() + _, err := r.db.ExecContext(ctx, query, state, now, id) + if err != nil { + return nil, err + } + + if r.cacheEnabled { + r.redisClient.Del(ctx, r.cacheKey(id.String())).Err() + } + + return r.Get(ctx, id) +} diff --git a/internal/competition/server/server.go b/internal/competition/server/server.go new file mode 100644 index 0000000..4eed2c3 --- /dev/null +++ b/internal/competition/server/server.go @@ -0,0 +1,143 @@ +package server + +import ( + "context" + "fmt" + "log" + "net" + "net/http" + "time" + + "datarush/internal/competition/config" + grpcHandlers "datarush/internal/competition/handler/grpc" + "datarush/internal/competition/repository/postgres" + "datarush/internal/competition/service" + pb "datarush/pkg/api/competition" + + "github.com/grpc-ecosystem/grpc-gateway/v2/runtime" + "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 + httpServer *http.Server + config *config.Config + db *sqlx.DB +} + +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 + + go func() { + if err := s.startGRPCServer(); err != nil { + log.Fatalf("failed to start gRPC server: %v", err) + } + }() + + go func() { + if err := s.startHTTPServer(); err != nil { + log.Fatalf("failed to start HTTP server: %v", err) + } + }() + + log.Println("competition service started") + return nil +} + +func (s *Server) startGRPCServer() error { + lis, err := net.Listen("tcp", fmt.Sprintf(":%d", s.config.GRPCPort)) + if err != nil { + return fmt.Errorf("failed to listen on grpc port: %w", err) + } + + s.grpcServer = grpc.NewServer() + s.registerGRPCServices() + + log.Printf("starting gRPC server on port %d", s.config.GRPCPort) + return s.grpcServer.Serve(lis) +} + +func (s *Server) registerGRPCServices() { + compRepo := postgres.NewCompetitionRepository(s.db.DB, nil, false) + compService := service.NewService(compRepo) + compHandler := grpcHandlers.NewCompetitionHandler(compService) + + pb.RegisterCompetitionServiceServer(s.grpcServer, compHandler) + + if s.config.GRPCEnableReflection { + reflection.Register(s.grpcServer) + } +} + +func (s *Server) startHTTPServer() error { + ctx := context.Background() + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + mux := runtime.NewServeMux() + opts := []grpc.DialOption{grpc.WithTransportCredentials(insecure.NewCredentials())} + grpcEndpoint := fmt.Sprintf("localhost:%d", s.config.GRPCPort) + + err := pb.RegisterCompetitionServiceHandlerFromEndpoint(ctx, mux, grpcEndpoint, opts) + if err != nil { + return fmt.Errorf("failed to register http handlers: %w", err) + } + + s.httpServer = &http.Server{ + Addr: fmt.Sprintf(":%d", s.config.HTTPPort), + Handler: mux, + ReadTimeout: httpReadTimeout, + WriteTimeout: httpWriteTimeout, + IdleTimeout: httpIdleTimeout, + } + + log.Printf("starting HTTP server on port %d", s.config.HTTPPort) + return s.httpServer.ListenAndServe() +} + +func (s *Server) Stop() { + log.Println("shutting down competition server...") + + // Shutdown HTTP server + if s.httpServer != nil { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := s.httpServer.Shutdown(ctx); err != nil { + log.Printf("failed to shutdown HTTP server gracefully: %v", err) + } + } + + // Shutdown gRPC server + if s.grpcServer != nil { + s.grpcServer.GracefulStop() + } + + // Close database connection + if s.db != nil { + if err := s.db.Close(); err != nil { + log.Printf("failed to close database connection: %v", err) + } + } + + log.Println("competition server stopped") +} diff --git a/internal/competition/service/competition.go b/internal/competition/service/competition.go new file mode 100644 index 0000000..68699f7 --- /dev/null +++ b/internal/competition/service/competition.go @@ -0,0 +1,75 @@ +package service + +import ( + "context" + "time" + + "datarush/internal/competition/domain" + "datarush/internal/competition/repository" + + "github.com/google/uuid" +) + +type Service struct { + repo repository.CompetitionRepository +} + +func NewService(repo repository.CompetitionRepository) *Service { + return &Service{repo: repo} +} + +func (s *Service) CreateCompetition(ctx context.Context, comp *domain.Competition) (*domain.Competition, error) { + now := time.Now() + if comp.ID == uuid.Nil { + comp.ID = uuid.New() + } + comp.CreatedAt = now + comp.UpdatedAt = now + + if err := s.repo.Create(ctx, comp); err != nil { + return nil, err + } + return comp, nil +} + +func (s *Service) GetCompetition(ctx context.Context, id uuid.UUID) (*domain.Competition, error) { + return s.repo.Get(ctx, id) +} + +func (s *Service) EditCompetition(ctx context.Context, comp *domain.Competition) (*domain.Competition, error) { + comp.UpdatedAt = time.Now() + if err := s.repo.Update(ctx, comp); err != nil { + return nil, err + } + return s.repo.Get(ctx, comp.ID) +} + +func (s *Service) DeleteCompetition(ctx context.Context, id uuid.UUID) error { + return s.repo.Delete(ctx, id) +} + +func (s *Service) ListCompetitions(ctx context.Context, pageSize int32, pageToken int32, state *domain.CompetitionState, isParticipating *bool, searchQuery *string) ([]domain.Competition, int32, int32, error) { + opts := repository.ListCompetitionsOptions{ + Page: int(pageToken), + PageSize: int(pageSize), + State: state, + IsParticipating: isParticipating, + SearchQuery: searchQuery, + } + + competitions, total, err := s.repo.List(ctx, opts) + if err != nil { + return nil, 0, 0, err + } + + var nextPageToken int32 + if (opts.Page+1)*opts.PageSize < total { + nextPageToken = int32(opts.Page + 1) + } + + return competitions, int32(total), nextPageToken, nil +} + +func (s *Service) ChangeCompetitionState(ctx context.Context, id uuid.UUID, state domain.CompetitionState) (*domain.Competition, error) { + return s.repo.ChangeState(ctx, id, state) +} diff --git a/internal/lms/config/config.go b/internal/lms/config/config.go index 8a27c43..47280f9 100644 --- a/internal/lms/config/config.go +++ b/internal/lms/config/config.go @@ -23,6 +23,7 @@ type Config struct { DBName string RedisURI string AuthGRPCAddr string + CacheEnabled bool } func Load() (*Config, error) { @@ -41,6 +42,7 @@ func Load() (*Config, error) { DBName: getEnv("POSTGRES_DATABASE", "postgres"), RedisURI: getEnv("REDIS_URI", "redis://localhost:6379"), AuthGRPCAddr: getEnv("AUTH_GRPC_ADDR", "localhost:50052"), + CacheEnabled: mustGetBool("CACHE_ENABLED", true), }, nil } diff --git a/internal/lms/server/server.go b/internal/lms/server/server.go index 19880bb..da4c891 100644 --- a/internal/lms/server/server.go +++ b/internal/lms/server/server.go @@ -94,8 +94,6 @@ func registerAuthService(ctx context.Context, gwmux *runtime.ServeMux, authAddr } func registerAuthHandlerFromEndpoint(ctx context.Context, gwmux *runtime.ServeMux, endpoint string, opts []grpc.DialOption) error { - // We'll import the proto and register it here - // For now, this is a placeholder that will be called from gateway integration return nil } func getDatabase(cfg config.Config) (*sqlx.DB, error) { diff --git a/pkg/api/competition/competition.pb.go b/pkg/api/competition/competition.pb.go index fddc813..dc178f6 100644 --- a/pkg/api/competition/competition.pb.go +++ b/pkg/api/competition/competition.pb.go @@ -7,12 +7,23 @@ package competition import ( + "context" + "io" + "net/http" + + "github.com/grpc-ecosystem/grpc-gateway/v2/runtime" + "github.com/grpc-ecosystem/grpc-gateway/v2/utilities" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/grpclog" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/proto" protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" timestamppb "google.golang.org/protobuf/types/known/timestamppb" - reflect "reflect" - sync "sync" - unsafe "unsafe" + "reflect" + "sync" ) const ( @@ -775,3 +786,207 @@ func file_api_proto_competition_proto_init() { file_api_proto_competition_proto_goTypes = nil file_api_proto_competition_proto_depIdxs = nil } + +func RegisterCompetitionServiceHandler(ctx context.Context, mux *runtime.ServeMux, conn *grpc.ClientConn) error { + return RegisterCompetitionServiceHandlerClient(ctx, mux, NewCompetitionServiceClient(conn)) +} + + +func RegisterCompetitionServiceHandlerClient(ctx context.Context, mux *runtime.ServeMux, client CompetitionServiceClient) error { + var err error + err = mux.Handle("POST", runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"v1", "competitions"}, "")), func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + var err error + var annotatedContext context.Context + annotatedContext, err = runtime.AnnotateContext(ctx, mux, req, "/competition.CompetitionService/CreateCompetition", runtime.WithHTTPPathPattern("/v1/competitions")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_CompetitionService_CreateCompetition_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + + forward_CompetitionService_CreateCompetition_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + + }) + if err != nil { + return err + } + + err = mux.Handle("GET", runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 1, 0, 4, 1, 5, 2}, []string{"v1", "competitions", "competition_id"}, "")), func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + var err error + var annotatedContext context.Context + annotatedContext, err = runtime.AnnotateContext(ctx, mux, req, "/competition.CompetitionService/GetCompetition", runtime.WithHTTPPathPattern("/v1/competitions/{competition_id}")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_CompetitionService_GetCompetition_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + + forward_CompetitionService_GetCompetition_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + + }) + if err != nil { + return err + } + + err = mux.Handle("PUT", runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 1, 0, 4, 1, 5, 2}, []string{"v1", "competitions", "id"}, "")), func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + var err error + var annotatedContext context.Context + annotatedContext, err = runtime.AnnotateContext(ctx, mux, req, "/competition.CompetitionService/EditCompetition", runtime.WithHTTPPathPattern("/v1/competitions/{id}")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_CompetitionService_EditCompetition_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + + forward_CompetitionService_EditCompetition_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + + }) + if err != nil { + return err + } + + err = mux.Handle("DELETE", runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 1, 0, 4, 1, 5, 2}, []string{"v1", "competitions", "competition_id"}, "")), func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + var err error + var annotatedContext context.Context + annotatedContext, err = runtime.AnnotateContext(ctx, mux, req, "/competition.CompetitionService/DeleteCompetition", runtime.WithHTTPPathPattern("/v1/competitions/{competition_id}")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_CompetitionService_DeleteCompetition_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + + forward_CompetitionService_DeleteCompetition_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + + }) + if err != nil { + return err + } + + err = mux.Handle("GET", runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"v1", "competitions"}, "")), func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + var err error + var annotatedContext context.Context + annotatedContext, err = runtime.AnnotateContext(ctx, mux, req, "/competition.CompetitionService/ListCompetitions", runtime.WithHTTPPathPattern("/v1/competitions")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_CompetitionService_ListCompetitions_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + + forward_CompetitionService_ListCompetitions_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + + }) + if err != nil { + return err + } + + err = mux.Handle("POST", runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 1, 0, 4, 1, 5, 2, 2, 3}, []string{"v1", "competitions", "competition_id", "state"}, "")), func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + var err error + var annotatedContext context.Context + annotatedContext, err = runtime.AnnotateContext(ctx, mux, req, "/competition.CompetitionService/ChangeCompetitionState", runtime.WithHTTPPathPattern("/v1/competitions/{competition_id}/state")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_CompetitionService_ChangeCompetitionState_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + + forward_CompetitionService_ChangeCompetitionState_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + + }) + if err != nil { + return err + } + + return nil +} + +func RegisterCompetitionServiceHandlerFromEndpoint(ctx context.Context, mux *runtime.ServeMux, endpoint string, opts []grpc.DialOption) (err error) { + conn, err := grpc.DialContext(ctx, endpoint, opts...) + if err != nil { + return err + } + defer func() { + if err != nil { + if cerr := conn.Close(); cerr != nil { + grpclog.Infof("Failed to close conn to %s: %v", endpoint, cerr) + } + return + } + go func() { + <-ctx.Done() + if cerr := conn.Close(); cerr != nil { + grpclog.Infof("Failed to close conn to %s: %v", endpoint, cerr) + } + }() + }() + + return RegisterCompetitionServiceHandler(ctx, mux, conn) +} + +func request_CompetitionService_CreateCompetition_0(ctx context.Context, marshaler runtime.Marshaler, client CompetitionServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var protoReq Competition + var metadata runtime.ServerMetadata + + newReader, berr := utilities.IOReaderFactory(req.Body) + if berr != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", berr) + } + if err := marshaler.NewDecoder(newReader()).Decode(&protoReq); err != nil && err != io.EOF { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + + msg, err := client.CreateCompetition(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err + +} + +func forward_CompetitionService_CreateCompetition_0(ctx context.Context, mux *runtime.ServeMux, marshaler runtime.Marshaler, w http.ResponseWriter, req *http.Request, resp proto.Message, opts ...func(context.Context, http.ResponseWriter, proto.Message)) { + runtime.ForwardResponseMessage(ctx, mux, marshaler, w, req, resp, opts...) +}