feat: implement achievements

This commit is contained in:
Albert
2025-12-19 23:12:22 +04:00
parent d2d5bfbe34
commit ecff776c3b
11 changed files with 566 additions and 0 deletions
+2
View File
@@ -33,6 +33,8 @@ Temporary Items
!.vscode/*.code-snippets
!*.code-workspace
.idea/*
# Built Visual Studio Code Extensions
*.vsix
+33
View File
@@ -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")
}
@@ -0,0 +1,3 @@
DROP TABLE IF EXISTS user_achievements;
DROP TABLE IF EXISTS achievements;
@@ -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);
+77
View File
@@ -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)
}
@@ -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)
}
@@ -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
}
@@ -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)
}
+116
View File
@@ -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")
}
+78
View File
@@ -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
}
@@ -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
}