diff --git a/server-go/docs/docs.go b/server-go/docs/docs.go index c942f572..fbee4df2 100644 --- a/server-go/docs/docs.go +++ b/server-go/docs/docs.go @@ -1455,6 +1455,139 @@ const docTemplate = `{ } } }, + "/clubs/filter": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "clubs" + ], + "summary": "Filter clubs", + "parameters": [ + { + "description": "Filter parameters", + "name": "filter", + "in": "body", + "schema": { + "$ref": "#/definitions/domain.FilterClub" + } + } + ], + "responses": { + "200": { + "description": "List of clubs", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/domain.Club" + } + } + }, + "400": { + "description": "Bad Request" + }, + "401": { + "description": "Unauthorized" + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/clubs/my": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "clubs" + ], + "summary": "Get my clubs", + "responses": { + "200": { + "description": "List of user's clubs", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/domain.Club" + } + } + }, + "400": { + "description": "Bad Request" + }, + "401": { + "description": "Unauthorized" + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/clubs/{club_id}/join": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "clubs" + ], + "summary": "Join club", + "parameters": [ + { + "type": "string", + "description": "Club ID", + "name": "club_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "Club data", + "schema": { + "$ref": "#/definitions/domain.Club" + } + }, + "400": { + "description": "Bad Request" + }, + "401": { + "description": "Unauthorized" + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, "/courts": { "get": { "security": [ @@ -2991,6 +3124,23 @@ const docTemplate = `{ } } }, + "domain.Club": { + "type": "object", + "properties": { + "createdAt": { + "type": "string" + }, + "description": { + "type": "string" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, "domain.Court": { "type": "object", "properties": { @@ -3073,6 +3223,7 @@ const docTemplate = `{ "domain.CreateTournament": { "type": "object", "required": [ + "clubId", "courtId", "maxUsers", "name", @@ -3083,6 +3234,9 @@ const docTemplate = `{ "tournamentType" ], "properties": { + "clubId": { + "type": "string" + }, "courtId": { "type": "string" }, @@ -3152,9 +3306,24 @@ const docTemplate = `{ } } }, + "domain.FilterClub": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, "domain.FilterTournament": { "type": "object", "properties": { + "filterByUserClubs": { + "description": "user ID to filter by user's clubs", + "type": "string" + }, "id": { "type": "string" }, @@ -3177,6 +3346,9 @@ const docTemplate = `{ "domain.FilterUser": { "type": "object", "properties": { + "filterByUserClubs": { + "type": "string" + }, "firstName": { "type": "string" }, @@ -3279,6 +3451,9 @@ const docTemplate = `{ "domain.PatchTournament": { "type": "object", "properties": { + "clubId": { + "type": "string" + }, "courtId": { "type": "string" }, @@ -3511,6 +3686,9 @@ const docTemplate = `{ "domain.Tournament": { "type": "object", "properties": { + "clubId": { + "type": "string" + }, "court": { "$ref": "#/definitions/domain.Court" }, diff --git a/server-go/docs/swagger.json b/server-go/docs/swagger.json index 1b40cddc..5fadfba3 100644 --- a/server-go/docs/swagger.json +++ b/server-go/docs/swagger.json @@ -1447,6 +1447,139 @@ } } }, + "/clubs/filter": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "clubs" + ], + "summary": "Filter clubs", + "parameters": [ + { + "description": "Filter parameters", + "name": "filter", + "in": "body", + "schema": { + "$ref": "#/definitions/domain.FilterClub" + } + } + ], + "responses": { + "200": { + "description": "List of clubs", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/domain.Club" + } + } + }, + "400": { + "description": "Bad Request" + }, + "401": { + "description": "Unauthorized" + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/clubs/my": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "clubs" + ], + "summary": "Get my clubs", + "responses": { + "200": { + "description": "List of user's clubs", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/domain.Club" + } + } + }, + "400": { + "description": "Bad Request" + }, + "401": { + "description": "Unauthorized" + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/clubs/{club_id}/join": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "clubs" + ], + "summary": "Join club", + "parameters": [ + { + "type": "string", + "description": "Club ID", + "name": "club_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "Club data", + "schema": { + "$ref": "#/definitions/domain.Club" + } + }, + "400": { + "description": "Bad Request" + }, + "401": { + "description": "Unauthorized" + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, "/courts": { "get": { "security": [ @@ -2983,6 +3116,23 @@ } } }, + "domain.Club": { + "type": "object", + "properties": { + "createdAt": { + "type": "string" + }, + "description": { + "type": "string" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, "domain.Court": { "type": "object", "properties": { @@ -3065,6 +3215,7 @@ "domain.CreateTournament": { "type": "object", "required": [ + "clubId", "courtId", "maxUsers", "name", @@ -3075,6 +3226,9 @@ "tournamentType" ], "properties": { + "clubId": { + "type": "string" + }, "courtId": { "type": "string" }, @@ -3144,9 +3298,24 @@ } } }, + "domain.FilterClub": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, "domain.FilterTournament": { "type": "object", "properties": { + "filterByUserClubs": { + "description": "user ID to filter by user's clubs", + "type": "string" + }, "id": { "type": "string" }, @@ -3169,6 +3338,9 @@ "domain.FilterUser": { "type": "object", "properties": { + "filterByUserClubs": { + "type": "string" + }, "firstName": { "type": "string" }, @@ -3271,6 +3443,9 @@ "domain.PatchTournament": { "type": "object", "properties": { + "clubId": { + "type": "string" + }, "courtId": { "type": "string" }, @@ -3503,6 +3678,9 @@ "domain.Tournament": { "type": "object", "properties": { + "clubId": { + "type": "string" + }, "court": { "$ref": "#/definitions/domain.Court" }, diff --git a/server-go/docs/swagger.yaml b/server-go/docs/swagger.yaml index 41a52648..15bc1a2d 100644 --- a/server-go/docs/swagger.yaml +++ b/server-go/docs/swagger.yaml @@ -103,6 +103,17 @@ definitions: username: type: string type: object + domain.Club: + properties: + createdAt: + type: string + description: + type: string + id: + type: string + name: + type: string + type: object domain.Court: properties: address: @@ -158,6 +169,8 @@ definitions: type: object domain.CreateTournament: properties: + clubId: + type: string courtId: type: string description: @@ -181,6 +194,7 @@ definitions: tournamentType: type: string required: + - clubId - courtId - maxUsers - name @@ -212,8 +226,18 @@ definitions: username: type: string type: object + domain.FilterClub: + properties: + id: + type: string + name: + type: string + type: object domain.FilterTournament: properties: + filterByUserClubs: + description: user ID to filter by user's clubs + type: string id: type: string name: @@ -229,6 +253,8 @@ definitions: type: object domain.FilterUser: properties: + filterByUserClubs: + type: string firstName: type: string id: @@ -295,6 +321,8 @@ definitions: type: object domain.PatchTournament: properties: + clubId: + type: string courtId: type: string description: @@ -453,6 +481,8 @@ definitions: type: object domain.Tournament: properties: + clubId: + type: string court: $ref: '#/definitions/domain.Court' description: @@ -1506,6 +1536,88 @@ paths: summary: YooKassa webhook for payment notifications tags: - webhook + /clubs/{club_id}/join: + post: + consumes: + - application/json + parameters: + - description: Club ID + in: path + name: club_id + required: true + type: string + produces: + - application/json + responses: + "200": + description: Club data + schema: + $ref: '#/definitions/domain.Club' + "400": + description: Bad Request + "401": + description: Unauthorized + "500": + description: Internal Server Error + security: + - ApiKeyAuth: [] + summary: Join club + tags: + - clubs + /clubs/filter: + post: + consumes: + - application/json + parameters: + - description: Filter parameters + in: body + name: filter + schema: + $ref: '#/definitions/domain.FilterClub' + produces: + - application/json + responses: + "200": + description: List of clubs + schema: + items: + $ref: '#/definitions/domain.Club' + type: array + "400": + description: Bad Request + "401": + description: Unauthorized + "500": + description: Internal Server Error + security: + - ApiKeyAuth: [] + summary: Filter clubs + tags: + - clubs + /clubs/my: + get: + consumes: + - application/json + produces: + - application/json + responses: + "200": + description: List of user's clubs + schema: + items: + $ref: '#/definitions/domain.Club' + type: array + "400": + description: Bad Request + "401": + description: Unauthorized + "500": + description: Internal Server Error + security: + - ApiKeyAuth: [] + summary: Get my clubs + tags: + - clubs /courts: get: consumes: diff --git a/server-go/migrations/05072025_create_clubs_and_clubs_users.down.sql b/server-go/migrations/05072025_create_clubs_and_clubs_users.down.sql new file mode 100644 index 00000000..8eb77575 --- /dev/null +++ b/server-go/migrations/05072025_create_clubs_and_clubs_users.down.sql @@ -0,0 +1,7 @@ +-- Drop indexes +DROP INDEX IF EXISTS idx_clubs_users_user_id; +DROP INDEX IF EXISTS idx_clubs_users_club_id; + +-- Drop tables (in reverse order due to foreign key constraints) +DROP TABLE IF EXISTS clubs_users; +DROP TABLE IF EXISTS clubs; \ No newline at end of file diff --git a/server-go/migrations/05072025_create_clubs_and_clubs_users.up.sql b/server-go/migrations/05072025_create_clubs_and_clubs_users.up.sql new file mode 100644 index 00000000..7c75430c --- /dev/null +++ b/server-go/migrations/05072025_create_clubs_and_clubs_users.up.sql @@ -0,0 +1,28 @@ +-- Create clubs table (communities) +CREATE TABLE clubs ( + id VARCHAR(255) PRIMARY KEY, + name VARCHAR(255) NOT NULL, + description TEXT, + created_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +-- Create clubs_users table (many-to-many relationship) +CREATE TABLE clubs_users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + club_id VARCHAR(255) NOT NULL REFERENCES clubs(id), + user_id UUID NOT NULL REFERENCES users(id), + joined_at TIMESTAMP NOT NULL DEFAULT NOW(), + UNIQUE(club_id, user_id) +); + +-- Create indexes for better performance +CREATE INDEX idx_clubs_users_club_id ON clubs_users(club_id); +CREATE INDEX idx_clubs_users_user_id ON clubs_users(user_id); + +-- Insert default global community +INSERT INTO clubs (id, name, description) VALUES + ('global', 'Глобальное сообщество', 'Основное сообщество для всех пользователей GoPadel'); + +-- Add all existing users to the global club +INSERT INTO clubs_users (club_id, user_id) +SELECT 'global', id FROM users; \ No newline at end of file diff --git a/server-go/migrations/06072025_add_club_to_tournaments.down.sql b/server-go/migrations/06072025_add_club_to_tournaments.down.sql new file mode 100644 index 00000000..ccd4b394 --- /dev/null +++ b/server-go/migrations/06072025_add_club_to_tournaments.down.sql @@ -0,0 +1,8 @@ +-- Убираем индекс +DROP INDEX IF EXISTS idx_tournaments_club_community_id; + +-- Убираем внешний ключ +ALTER TABLE tournaments DROP CONSTRAINT IF EXISTS fk_tournaments_club_community_id; + +-- Убираем колонку +ALTER TABLE tournaments DROP COLUMN IF EXISTS club_id; \ No newline at end of file diff --git a/server-go/migrations/06072025_add_club_to_tournaments.up.sql b/server-go/migrations/06072025_add_club_to_tournaments.up.sql new file mode 100644 index 00000000..22eee1ec --- /dev/null +++ b/server-go/migrations/06072025_add_club_to_tournaments.up.sql @@ -0,0 +1,11 @@ +-- Добавляем поле club_id для связи турниров с клубами-сообществами +ALTER TABLE tournaments ADD COLUMN club_id VARCHAR(255); + +-- Добавляем внешний ключ +ALTER TABLE tournaments ADD CONSTRAINT fk_tournaments_club_community_id FOREIGN KEY (club_id) REFERENCES clubs(id); + +-- Добавляем индекс для производительности +CREATE INDEX idx_tournaments_club_community_id ON tournaments(club_id); + +-- Устанавливаем всем существующим турнирам club_id = 'global' (глобальное сообщество) +UPDATE tournaments SET club_id = 'global' WHERE club_id IS NULL; \ No newline at end of file diff --git a/server-go/pkg/domain/club.go b/server-go/pkg/domain/club.go new file mode 100644 index 00000000..17700ee9 --- /dev/null +++ b/server-go/pkg/domain/club.go @@ -0,0 +1,57 @@ +package domain + +import "time" + +// Club представляет клуб-сообщество +type Club struct { + ID string `json:"id"` + Name string `json:"name"` + Description *string `json:"description,omitempty"` + CreatedAt time.Time `json:"createdAt"` +} + +// ClubUser представляет связь пользователя с клубом +type ClubUser struct { + ID string `json:"id"` + ClubID string `json:"clubId"` + UserID string `json:"userId"` + JoinedAt time.Time `json:"joinedAt"` +} + +// ClubWithUsers представляет клуб с информацией о пользователях +type ClubWithUsers struct { + Club + UserCount int `json:"userCount"` + Users []*User `json:"users,omitempty"` +} + +// UserWithClubs представляет пользователя с информацией о клубах +type UserWithClubs struct { + User + Clubs []*Club `json:"clubs,omitempty"` +} + +// CreateClub структура для создания клуба +type CreateClub struct { + ID string `json:"id" binding:"required"` + Name string `json:"name" binding:"required"` + Description *string `json:"description,omitempty"` +} + +// PatchClub структура для обновления клуба +type PatchClub struct { + Name *string `json:"name,omitempty"` + Description *string `json:"description,omitempty"` +} + +// JoinClub структура для вступления в клуб +type JoinClub struct { + ClubID string `json:"clubId" binding:"required"` + UserID string `json:"userId" binding:"required"` +} + +// FilterClub структура для фильтрации клубов +type FilterClub struct { + ID *string `json:"id,omitempty"` + Name *string `json:"name,omitempty"` +} \ No newline at end of file diff --git a/server-go/pkg/domain/tournament.go b/server-go/pkg/domain/tournament.go index 1e154b99..93efa174 100644 --- a/server-go/pkg/domain/tournament.go +++ b/server-go/pkg/domain/tournament.go @@ -13,6 +13,7 @@ type Tournament struct { MaxUsers int `json:"maxUsers"` Description string `json:"description"` Court Court `json:"court"` + ClubID string `json:"clubId"` TournamentType string `json:"tournamentType"` Organizator User `json:"organizator"` Participants []*Registration `json:"participants"` @@ -28,6 +29,7 @@ type CreateTournament struct { MaxUsers int `json:"maxUsers" binding:"required"` Description string `json:"description"` CourtID string `json:"courtId" binding:"required"` + ClubID string `json:"clubId" binding:"required"` TournamentType string `json:"tournamentType" binding:"required"` OrganizatorID string `json:"organizatorId" binding:"required"` } @@ -42,13 +44,15 @@ type PatchTournament struct { MaxUsers *int `json:"maxUsers,omitempty"` Description *string `json:"description,omitempty"` CourtID *string `json:"courtId,omitempty"` + ClubID *string `json:"clubId,omitempty"` TournamentType *string `json:"tournamentType,omitempty"` } type FilterTournament struct { - ID string `json:"id"` - Name string `json:"name"` - NotFull *bool `json:"notFull,omitempty"` // true if tournament is not full - NotEnded *bool `json:"notEnded,omitempty"` // default true - OrganizatorID *string `json:"organizatorId,omitempty"` + ID string `json:"id"` + Name string `json:"name"` + NotFull *bool `json:"notFull,omitempty"` // true if tournament is not full + NotEnded *bool `json:"notEnded,omitempty"` // default true + OrganizatorID *string `json:"organizatorId,omitempty"` + FilterByUserClubs *string `json:"filterByUserClubs,omitempty"` // user ID to filter by user's clubs } \ No newline at end of file diff --git a/server-go/pkg/domain/user.go b/server-go/pkg/domain/user.go index 0c9a533c..d9f7cefa 100644 --- a/server-go/pkg/domain/user.go +++ b/server-go/pkg/domain/user.go @@ -54,6 +54,7 @@ type FilterUser struct { TelegramUsername *string `json:"telegramUsername"` FirstName *string `json:"firstName"` LastName *string `json:"lastName"` + FilterByUserClubs *string `json:"filterByUserClubs,omitempty"` } type AdminPatchUser struct { diff --git a/server-go/pkg/gateways/rest/club/club.go b/server-go/pkg/gateways/rest/club/club.go new file mode 100644 index 00000000..b3b30c78 --- /dev/null +++ b/server-go/pkg/gateways/rest/club/club.go @@ -0,0 +1,16 @@ +package club + +import ( + "github.com/gin-gonic/gin" + "github.com/shampsdev/go-telegram-template/pkg/gateways/rest/middlewares" + "github.com/shampsdev/go-telegram-template/pkg/usecase" +) + +func Setup(r *gin.RouterGroup, cases usecase.Cases) { + g := r.Group("/clubs") + middlewares.SetupAuth(g, cases.User) + + g.POST("/filter", FilterClubs(cases.Club)) + g.GET("/my", GetMyClubs(cases.Club)) + g.POST("/:club_id/join", JoinClub(cases.Club)) +} \ No newline at end of file diff --git a/server-go/pkg/gateways/rest/club/filter_clubs.go b/server-go/pkg/gateways/rest/club/filter_clubs.go new file mode 100644 index 00000000..b922e7e6 --- /dev/null +++ b/server-go/pkg/gateways/rest/club/filter_clubs.go @@ -0,0 +1,44 @@ +package club + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/shampsdev/go-telegram-template/pkg/domain" + "github.com/shampsdev/go-telegram-template/pkg/gateways/rest/ginerr" + "github.com/shampsdev/go-telegram-template/pkg/gateways/rest/middlewares" + "github.com/shampsdev/go-telegram-template/pkg/usecase" +) + +// FilterClubs godoc +// @Summary Filter clubs +// @Tags clubs +// @Accept json +// @Produce json +// @Schemes http https +// @Param filter body domain.FilterClub false "Filter parameters" +// @Success 200 {array} domain.Club "List of clubs" +// @Failure 400 "Bad Request" +// @Failure 401 "Unauthorized" +// @Failure 500 "Internal Server Error" +// @Security ApiKeyAuth +// @Router /clubs/filter [post] +func FilterClubs(clubCase *usecase.Club) gin.HandlerFunc { + return func(c *gin.Context) { + user := middlewares.MustGetUser(c) + + var filter domain.FilterClub + if err := c.ShouldBindJSON(&filter); err != nil { + // Если нет параметров фильтрации, используем пустой фильтр + filter = domain.FilterClub{} + } + + ctx := usecase.NewContext(c, user) + clubs, err := clubCase.Filter(&ctx, &filter) + if ginerr.AbortIfErr(c, err, http.StatusBadRequest, "failed to filter clubs") { + return + } + + c.JSON(http.StatusOK, clubs) + } +} \ No newline at end of file diff --git a/server-go/pkg/gateways/rest/club/get_my_clubs.go b/server-go/pkg/gateways/rest/club/get_my_clubs.go new file mode 100644 index 00000000..ea55f2c3 --- /dev/null +++ b/server-go/pkg/gateways/rest/club/get_my_clubs.go @@ -0,0 +1,36 @@ +package club + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/shampsdev/go-telegram-template/pkg/gateways/rest/ginerr" + "github.com/shampsdev/go-telegram-template/pkg/gateways/rest/middlewares" + "github.com/shampsdev/go-telegram-template/pkg/usecase" +) + +// GetMyClubs godoc +// @Summary Get my clubs +// @Tags clubs +// @Accept json +// @Produce json +// @Schemes http https +// @Success 200 {array} domain.Club "List of user's clubs" +// @Failure 400 "Bad Request" +// @Failure 401 "Unauthorized" +// @Failure 500 "Internal Server Error" +// @Security ApiKeyAuth +// @Router /clubs/my [get] +func GetMyClubs(clubCase *usecase.Club) gin.HandlerFunc { + return func(c *gin.Context) { + user := middlewares.MustGetUser(c) + + ctx := usecase.NewContext(c, user) + clubs, err := clubCase.GetUserClubs(&ctx) + if ginerr.AbortIfErr(c, err, http.StatusBadRequest, "failed to get user clubs") { + return + } + + c.JSON(http.StatusOK, clubs) + } +} \ No newline at end of file diff --git a/server-go/pkg/gateways/rest/club/join_club.go b/server-go/pkg/gateways/rest/club/join_club.go new file mode 100644 index 00000000..bb4ec96b --- /dev/null +++ b/server-go/pkg/gateways/rest/club/join_club.go @@ -0,0 +1,43 @@ +package club + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/shampsdev/go-telegram-template/pkg/gateways/rest/ginerr" + "github.com/shampsdev/go-telegram-template/pkg/gateways/rest/middlewares" + "github.com/shampsdev/go-telegram-template/pkg/usecase" +) + +// JoinClub godoc +// @Summary Join club +// @Tags clubs +// @Accept json +// @Produce json +// @Schemes http https +// @Param club_id path string true "Club ID" +// @Success 200 {object} domain.Club "Club data" +// @Failure 400 "Bad Request" +// @Failure 401 "Unauthorized" +// @Failure 500 "Internal Server Error" +// @Security ApiKeyAuth +// @Router /clubs/{club_id}/join [post] +func JoinClub(clubCase *usecase.Club) gin.HandlerFunc { + return func(c *gin.Context) { + user := middlewares.MustGetUser(c) + clubID := c.Param("club_id") + + if clubID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "club_id is required"}) + return + } + + ctx := usecase.NewContext(c, user) + club, err := clubCase.JoinClubAndGet(&ctx, clubID) + if ginerr.AbortIfErr(c, err, http.StatusBadRequest, "failed to join club") { + return + } + + c.JSON(http.StatusOK, club) + } +} \ No newline at end of file diff --git a/server-go/pkg/gateways/rest/router.go b/server-go/pkg/gateways/rest/router.go index 31281f29..128d126c 100644 --- a/server-go/pkg/gateways/rest/router.go +++ b/server-go/pkg/gateways/rest/router.go @@ -12,6 +12,7 @@ import ( "github.com/shampsdev/go-telegram-template/pkg/gateways/rest/admin_loyalties" "github.com/shampsdev/go-telegram-template/pkg/gateways/rest/admin_registrations" "github.com/shampsdev/go-telegram-template/pkg/gateways/rest/admin_users" + "github.com/shampsdev/go-telegram-template/pkg/gateways/rest/club" "github.com/shampsdev/go-telegram-template/pkg/gateways/rest/courts" "github.com/shampsdev/go-telegram-template/pkg/gateways/rest/image" "github.com/shampsdev/go-telegram-template/pkg/gateways/rest/loyalty" @@ -36,6 +37,7 @@ func setupRouter(ctx context.Context, r *gin.Engine, useCases usecase.Cases, cfg user.Setup(v1, useCases) courts.Setup(v1, useCases) + club.Setup(v1, useCases) image.Setup(v1, useCases) tournament.Setup(v1, useCases) loyalty.Setup(v1, useCases) diff --git a/server-go/pkg/gateways/rest/tournament/filter.go b/server-go/pkg/gateways/rest/tournament/filter.go index de821ce9..f48fa0de 100644 --- a/server-go/pkg/gateways/rest/tournament/filter.go +++ b/server-go/pkg/gateways/rest/tournament/filter.go @@ -33,7 +33,8 @@ func Filter(tournamentCase *usecase.Tournament) gin.HandlerFunc { return } - tournaments, err := tournamentCase.Filter(usecase.NewContext(c, user), &filterTournament) + ctx := usecase.NewContext(c, user) + tournaments, err := tournamentCase.FilterForUser(&ctx, &filterTournament) if err != nil { ginerr.AbortIfErr(c, err, http.StatusInternalServerError, "failed to filter tournaments") return diff --git a/server-go/pkg/gateways/rest/user/filter_users.go b/server-go/pkg/gateways/rest/user/filter_users.go index f9661880..60b2f077 100644 --- a/server-go/pkg/gateways/rest/user/filter_users.go +++ b/server-go/pkg/gateways/rest/user/filter_users.go @@ -33,7 +33,8 @@ func FilterUsers(userCase *usecase.User) gin.HandlerFunc { return } - users, err := userCase.Filter(usecase.NewContext(c, user), &filterUser) + ctx := usecase.NewContext(c, user) + users, err := userCase.FilterForUser(&ctx, &filterUser) if err != nil { ginerr.AbortIfErr(c, err, http.StatusInternalServerError, "failed to filter users") return diff --git a/server-go/pkg/repo/pg/club.go b/server-go/pkg/repo/pg/club.go new file mode 100644 index 00000000..1d39a106 --- /dev/null +++ b/server-go/pkg/repo/pg/club.go @@ -0,0 +1,153 @@ +package pg + +import ( + "context" + "errors" + "fmt" + + sq "github.com/Masterminds/squirrel" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/shampsdev/go-telegram-template/pkg/domain" +) + +type ClubRepo struct { + db *pgxpool.Pool + psql sq.StatementBuilderType +} + +func NewClubRepo(db *pgxpool.Pool) *ClubRepo { + return &ClubRepo{ + db: db, + psql: sq.StatementBuilder.PlaceholderFormat(sq.Dollar), + } +} + +func (r *ClubRepo) Create(ctx context.Context, club *domain.CreateClub) error { + s := r.psql.Insert(`"clubs"`). + Columns("id", "name", "description"). + Values(club.ID, club.Name, club.Description) + + sql, args, err := s.ToSql() + if err != nil { + return fmt.Errorf("failed to build SQL: %w", err) + } + + _, err = r.db.Exec(ctx, sql, args...) + if err != nil { + return fmt.Errorf("failed to create club: %w", err) + } + + return nil +} + +func (r *ClubRepo) Filter(ctx context.Context, filter *domain.FilterClub) ([]*domain.Club, error) { + s := r.psql.Select("id", "name", "description", "created_at").From(`"clubs"`) + + if filter.ID != nil { + s = s.Where(sq.Eq{"id": *filter.ID}) + } + + if filter.Name != nil { + s = s.Where(sq.ILike{"name": fmt.Sprintf("%%%s%%", *filter.Name)}) + } + + sql, args, err := s.ToSql() + if err != nil { + return nil, fmt.Errorf("failed to build SQL: %w", err) + } + + rows, err := r.db.Query(ctx, sql, args...) + if errors.Is(err, pgx.ErrNoRows) { + return []*domain.Club{}, nil + } + if err != nil { + return nil, fmt.Errorf("failed to execute SQL: %w", err) + } + defer rows.Close() + + clubs := []*domain.Club{} + for rows.Next() { + var club domain.Club + + err := rows.Scan( + &club.ID, + &club.Name, + &club.Description, + &club.CreatedAt, + ) + if err != nil { + return nil, fmt.Errorf("failed to scan row: %w", err) + } + + clubs = append(clubs, &club) + } + + if err = rows.Err(); err != nil { + return nil, fmt.Errorf("error iterating rows: %w", err) + } + + return clubs, nil +} + +func (r *ClubRepo) JoinClub(ctx context.Context, clubID, userID string) error { + s := r.psql.Insert(`"clubs_users"`). + Columns("club_id", "user_id"). + Values(clubID, userID) + + sql, args, err := s.ToSql() + if err != nil { + return fmt.Errorf("failed to build SQL: %w", err) + } + + _, err = r.db.Exec(ctx, sql, args...) + if err != nil { + return fmt.Errorf("failed to join club: %w", err) + } + + return nil +} + +func (r *ClubRepo) GetUserClubs(ctx context.Context, userID string) ([]*domain.Club, error) { + s := r.psql.Select("c.id", "c.name", "c.description", "c.created_at"). + From(`"clubs" c`). + Join(`"clubs_users" cu ON c.id = cu.club_id`). + Where(sq.Eq{"cu.user_id": userID}) + + sql, args, err := s.ToSql() + if err != nil { + return nil, fmt.Errorf("failed to build SQL: %w", err) + } + + rows, err := r.db.Query(ctx, sql, args...) + if errors.Is(err, pgx.ErrNoRows) { + return []*domain.Club{}, nil + } + if err != nil { + return nil, fmt.Errorf("failed to execute SQL: %w", err) + } + defer rows.Close() + + clubs := []*domain.Club{} + for rows.Next() { + var club domain.Club + + err := rows.Scan( + &club.ID, + &club.Name, + &club.Description, + &club.CreatedAt, + ) + if err != nil { + return nil, fmt.Errorf("failed to scan row: %w", err) + } + + clubs = append(clubs, &club) + } + + if err = rows.Err(); err != nil { + return nil, fmt.Errorf("error iterating rows: %w", err) + } + + return clubs, nil +} \ No newline at end of file diff --git a/server-go/pkg/repo/pg/tournament.go b/server-go/pkg/repo/pg/tournament.go index 2dd769b1..df090477 100644 --- a/server-go/pkg/repo/pg/tournament.go +++ b/server-go/pkg/repo/pg/tournament.go @@ -27,7 +27,7 @@ func NewTournamentRepo(db *pgxpool.Pool) *TournamentRepo { func (r *TournamentRepo) Filter(ctx context.Context, filter *domain.FilterTournament) ([]*domain.Tournament, error) { s := r.psql.Select( `"t"."id"`, `"t"."name"`, `"t"."start_time"`, `"t"."end_time"`, `"t"."price"`, - `"t"."rank_min"`, `"t"."rank_max"`, `"t"."max_users"`, `"t"."description"`, `"t"."tournament_type"`, + `"t"."rank_min"`, `"t"."rank_max"`, `"t"."max_users"`, `"t"."description"`, `"t"."club_id"`, `"t"."tournament_type"`, `"c"."id"`, `"c"."name"`, `"c"."address"`, `"u"."id"`, `"u"."telegram_id"`, `"u"."telegram_username"`, `"u"."first_name"`, `"u"."last_name"`, `"u"."avatar"`, `"u"."bio"`, `"u"."rank"`, `"u"."city"`, `"u"."birth_date"`, `"u"."playing_position"`, `"u"."padel_profiles"`, `"u"."is_registered"`, @@ -52,13 +52,19 @@ func (r *TournamentRepo) Filter(ctx context.Context, filter *domain.FilterTourna } if filter.NotEnded == nil || *filter.NotEnded { - s = s.Where(`("t"."end_time" IS NOT NULL AND "t"."end_time" > NOW()) OR ("t"."end_time" IS NULL AND "t"."start_time" + INTERVAL '1 hour' > NOW())`) + s = s.Where(`(("t"."end_time" IS NOT NULL AND "t"."end_time" > NOW()) OR ("t"."end_time" IS NULL AND "t"."start_time" + INTERVAL '1 hour' > NOW()))`) } if filter.NotFull != nil && *filter.NotFull { s = s.Having(`COUNT(CASE WHEN "r"."status" IN ('PENDING', 'ACTIVE') THEN 1 END) < "t"."max_users"`) } + // Фильтрация по клубам пользователя - показываем турниры из клубов, в которых состоит пользователь + if filter.FilterByUserClubs != nil { + s = s.Join(`"clubs_users" AS cu_filter ON "t"."club_id" = "cu_filter"."club_id"`). + Where(sq.Eq{`"cu_filter"."user_id"`: *filter.FilterByUserClubs}) + } + s = s.OrderBy(`"t"."start_time" ASC`) sql, args, err := s.ToSql() @@ -66,6 +72,8 @@ func (r *TournamentRepo) Filter(ctx context.Context, filter *domain.FilterTourna return nil, fmt.Errorf("failed to build SQL: %w", err) } + + rows, err := r.db.Query(ctx, sql, args...) if errors.Is(err, pgx.ErrNoRows) { return []*domain.Tournament{}, nil @@ -105,6 +113,7 @@ func (r *TournamentRepo) Filter(ctx context.Context, filter *domain.FilterTourna &tournament.RankMax, &tournament.MaxUsers, &description, + &tournament.ClubID, &tournament.TournamentType, &courtID, &courtName, @@ -186,7 +195,7 @@ func (r *TournamentRepo) Filter(ctx context.Context, filter *domain.FilterTourna func (r *TournamentRepo) GetTournamentsByUserID(ctx context.Context, userID string) ([]*domain.Tournament, error) { s := r.psql.Select( `"t"."id"`, `"t"."name"`, `"t"."start_time"`, `"t"."end_time"`, `"t"."price"`, - `"t"."rank_min"`, `"t"."rank_max"`, `"t"."max_users"`, `"t"."description"`, `"t"."tournament_type"`, + `"t"."rank_min"`, `"t"."rank_max"`, `"t"."max_users"`, `"t"."description"`, `"t"."club_id"`, `"t"."tournament_type"`, `"c"."id"`, `"c"."name"`, `"c"."address"`, `"org"."id"`, `"org"."telegram_id"`, `"org"."telegram_username"`, `"org"."first_name"`, `"org"."last_name"`, `"org"."avatar"`, `"org"."bio"`, `"org"."rank"`, `"org"."city"`, `"org"."birth_date"`, `"org"."playing_position"`, `"org"."padel_profiles"`, `"org"."is_registered"`, @@ -243,6 +252,7 @@ func (r *TournamentRepo) GetTournamentsByUserID(ctx context.Context, userID stri &tournament.RankMax, &tournament.MaxUsers, &description, + &tournament.ClubID, &tournament.TournamentType, &courtID, &courtName, @@ -323,10 +333,10 @@ func (r *TournamentRepo) GetTournamentsByUserID(ctx context.Context, userID stri func (r *TournamentRepo) Create(ctx context.Context, tournament *domain.CreateTournament) (string, error) { s := r.psql.Insert(`"tournaments"`). Columns("name", "start_time", "end_time", "price", "rank_min", "rank_max", - "max_users", "description", "court_id", "tournament_type", "organizator_id"). + "max_users", "description", "court_id", "club_id", "tournament_type", "organizator_id"). Values(tournament.Name, tournament.StartTime, tournament.EndTime, tournament.Price, tournament.RankMin, tournament.RankMax, tournament.MaxUsers, tournament.Description, - tournament.CourtID, tournament.TournamentType, tournament.OrganizatorID). + tournament.CourtID, tournament.ClubID, tournament.TournamentType, tournament.OrganizatorID). Suffix("RETURNING id") sql, args, err := s.ToSql() @@ -369,6 +379,9 @@ func (r *TournamentRepo) Patch(ctx context.Context, id string, tournament *domai if tournament.CourtID != nil { s = s.Set("court_id", *tournament.CourtID) } + if tournament.ClubID != nil { + s = s.Set("club_id", *tournament.ClubID) + } if tournament.TournamentType != nil { s = s.Set("tournament_type", *tournament.TournamentType) } diff --git a/server-go/pkg/repo/pg/user.go b/server-go/pkg/repo/pg/user.go index 1c74c391..a8bc80fe 100644 --- a/server-go/pkg/repo/pg/user.go +++ b/server-go/pkg/repo/pg/user.go @@ -42,9 +42,9 @@ func (r *UserRepo) Create(ctx context.Context, user *domain.CreateUser) (string, func (r *UserRepo) Filter(ctx context.Context, filter *domain.FilterUser) ([]*domain.User, error) { s := r.psql.Select( - `"u"."id"`, `"u"."telegram_id"`, `"u"."telegram_username"`, `"u"."first_name"`, `"u"."last_name"`, `"u"."avatar"`, + `DISTINCT "u"."id"`, `"u"."telegram_id"`, `"u"."telegram_username"`, `"u"."first_name"`, `"u"."last_name"`, `"u"."avatar"`, `"u"."bio"`, `"u"."rank"`, `"u"."city"`, `"u"."birth_date"`, `"u"."playing_position"`, `"u"."padel_profiles"`, - `"u"."is_registered"`, `"l"."id"`, `"l"."name"`, `"l"."discount"`, `"l"."description"`, `"l"."requirements"`, + `"u"."is_registered"`, `"l"."id"`, `"l"."name"`, `"l"."discount"`, `"l"."description"`, ).Join(`"loyalties" AS l ON "u"."loyalty_id" = "l"."id"`).From(`"users" AS u`) if filter.ID != nil { @@ -67,6 +67,14 @@ func (r *UserRepo) Filter(ctx context.Context, filter *domain.FilterUser) ([]*do s = s.Where(sq.ILike{`"u"."last_name"`: "%" + *filter.LastName + "%"}) } + // Фильтрация по общим клубам + if filter.FilterByUserClubs != nil { + s = s.Join(`"clubs_users" AS cu ON "u"."id" = "cu"."user_id"`). + Join(`"clubs_users" AS cu_filter ON "cu"."club_id" = "cu_filter"."club_id"`). + Where(sq.Eq{`"cu_filter"."user_id"`: *filter.FilterByUserClubs}). + Where(sq.NotEq{`"u"."id"`: *filter.FilterByUserClubs}) // исключаем самого пользователя + } + sql, args, err := s.ToSql() if err != nil { return nil, fmt.Errorf("failed to build SQL: %w", err) @@ -93,7 +101,6 @@ func (r *UserRepo) Filter(ctx context.Context, filter *domain.FilterUser) ([]*do var loyaltyName pgtype.Text var loyaltyDiscount pgtype.Int4 var loyaltyDescription pgtype.Text - var loyaltyRequirements pgtype.Text err := rows.Scan( &user.ID, @@ -113,7 +120,6 @@ func (r *UserRepo) Filter(ctx context.Context, filter *domain.FilterUser) ([]*do &loyaltyName, &loyaltyDiscount, &loyaltyDescription, - &loyaltyRequirements, ) if err != nil { return nil, fmt.Errorf("failed to scan row: %w", err) @@ -153,7 +159,7 @@ func (r *UserRepo) Filter(ctx context.Context, filter *domain.FilterUser) ([]*do Name: loyaltyName.String, Discount: int(loyaltyDiscount.Int32), Description: loyaltyDescription.String, - Requirements: loyaltyRequirements.String, + Requirements: "", // Оставляем пустым чтобы избежать проблем с JSON типом } } diff --git a/server-go/pkg/repo/repo.go b/server-go/pkg/repo/repo.go index 702e4097..ae1509e8 100644 --- a/server-go/pkg/repo/repo.go +++ b/server-go/pkg/repo/repo.go @@ -35,6 +35,13 @@ type Court interface { Delete(ctx context.Context, id string) error } +type Club interface { + Create(ctx context.Context, club *domain.CreateClub) error + Filter(ctx context.Context, filter *domain.FilterClub) ([]*domain.Club, error) + JoinClub(ctx context.Context, clubID, userID string) error + GetUserClubs(ctx context.Context, userID string) ([]*domain.Club, error) +} + type Loyalty interface { Create(ctx context.Context, loyalty *domain.CreateLoyalty) (string, error) Patch(ctx context.Context, id int, loyalty *domain.PatchLoyalty) error diff --git a/server-go/pkg/usecase/club.go b/server-go/pkg/usecase/club.go new file mode 100644 index 00000000..9c4a01a2 --- /dev/null +++ b/server-go/pkg/usecase/club.go @@ -0,0 +1,83 @@ +package usecase + +import ( + "context" + "fmt" + + "github.com/shampsdev/go-telegram-template/pkg/domain" + "github.com/shampsdev/go-telegram-template/pkg/repo" +) + +type Club struct { + ctx context.Context + repo repo.Club +} + +func NewClub(ctx context.Context, repo repo.Club) *Club { + return &Club{ + ctx: ctx, + repo: repo, + } +} + +func (uc *Club) Create(ctx *Context, club *domain.CreateClub) error { + // Проверяем, что пользователь авторизован + if ctx.User == nil { + return fmt.Errorf("user not authenticated") + } + + return uc.repo.Create(ctx, club) +} + +func (uc *Club) Filter(ctx *Context, filter *domain.FilterClub) ([]*domain.Club, error) { + // Проверяем, что пользователь авторизован + if ctx.User == nil { + return nil, fmt.Errorf("user not authenticated") + } + + return uc.repo.Filter(ctx, filter) +} + +func (uc *Club) JoinClub(ctx *Context, clubID string) error { + // Проверяем, что пользователь авторизован + if ctx.User == nil { + return fmt.Errorf("user not authenticated") + } + + return uc.repo.JoinClub(ctx, clubID, ctx.User.ID) +} + +func (uc *Club) JoinClubAndGet(ctx *Context, clubID string) (*domain.Club, error) { + // Проверяем, что пользователь авторизован + if ctx.User == nil { + return nil, fmt.Errorf("user not authenticated") + } + + // Вступаем в клуб + err := uc.repo.JoinClub(ctx, clubID, ctx.User.ID) + if err != nil { + return nil, err + } + + // Получаем данные клуба + filter := &domain.FilterClub{ID: &clubID} + clubs, err := uc.repo.Filter(ctx, filter) + if err != nil { + return nil, err + } + + if len(clubs) == 0 { + return nil, fmt.Errorf("club not found") + } + + return clubs[0], nil +} + +func (uc *Club) GetUserClubs(ctx *Context) ([]*domain.Club, error) { + // Проверяем, что пользователь авторизован + if ctx.User == nil { + return nil, fmt.Errorf("user not authenticated") + } + + return uc.repo.GetUserClubs(ctx, ctx.User.ID) +} \ No newline at end of file diff --git a/server-go/pkg/usecase/tournament.go b/server-go/pkg/usecase/tournament.go index 934a1afe..d86ea00e 100644 --- a/server-go/pkg/usecase/tournament.go +++ b/server-go/pkg/usecase/tournament.go @@ -81,6 +81,15 @@ func (t *Tournament) Filter(ctx context.Context, filter *domain.FilterTournament return tournaments, nil } +func (t *Tournament) FilterForUser(ctx *Context, filter *domain.FilterTournament) ([]*domain.Tournament, error) { + // Автоматически добавляем фильтрацию по клубам пользователя + if ctx.User != nil && filter.FilterByUserClubs == nil { + filter.FilterByUserClubs = &ctx.User.ID + } + + return t.Filter(ctx.Context, filter) +} + func (t *Tournament) GetTournamentByID(ctx context.Context, tournamentID string) (*domain.Tournament, error) { filter := &domain.FilterTournament{ ID: tournamentID, diff --git a/server-go/pkg/usecase/usecase.go b/server-go/pkg/usecase/usecase.go index 72a9d1c7..640a74d3 100644 --- a/server-go/pkg/usecase/usecase.go +++ b/server-go/pkg/usecase/usecase.go @@ -14,6 +14,7 @@ type Cases struct { AdminUser *AdminUser Image *Image Court *Court + Club *Club Tournament *Tournament Loyalty *Loyalty Registration *Registration @@ -25,6 +26,7 @@ func Setup(ctx context.Context, cfg *config.Config, db *pgxpool.Pool) Cases { userRepo := pg.NewUserRepo(db) adminUserRepo := pg.NewAdminUserRepo(db) courtRepo := pg.NewCourtRepo(db) + clubRepo := pg.NewClubRepo(db) tournamentRepo := pg.NewTournamentRepo(db) loyaltyRepo := pg.NewLoyaltyRepo(db) registrationRepo := pg.NewRegistrationRepo(db) @@ -40,6 +42,7 @@ func Setup(ctx context.Context, cfg *config.Config, db *pgxpool.Pool) Cases { adminUserCase := NewAdminUser(ctx, adminUserRepo, cfg) imageCase := NewImage(ctx, storage) courtCase := NewCourt(ctx, courtRepo) + clubCase := NewClub(ctx, clubRepo) tournamentCase := NewTournament(ctx, tournamentRepo, registrationRepo) loyaltyCase := NewLoyalty(ctx, loyaltyRepo) registrationCase := NewRegistration(ctx, registrationRepo, tournamentRepo, paymentRepo) @@ -51,6 +54,7 @@ func Setup(ctx context.Context, cfg *config.Config, db *pgxpool.Pool) Cases { AdminUser: adminUserCase, Image: imageCase, Court: courtCase, + Club: clubCase, Tournament: tournamentCase, Loyalty: loyaltyCase, Registration: registrationCase, diff --git a/server-go/pkg/usecase/user.go b/server-go/pkg/usecase/user.go index f43f798d..608f0b68 100644 --- a/server-go/pkg/usecase/user.go +++ b/server-go/pkg/usecase/user.go @@ -78,6 +78,15 @@ func (u *User) Filter(ctx Context, filter *domain.FilterUser) ([]*domain.User, e return u.userRepo.Filter(ctx, filter) } +func (u *User) FilterForUser(ctx *Context, filter *domain.FilterUser) ([]*domain.User, error) { + // Автоматически добавляем фильтрацию по клубам пользователя + if ctx.User != nil && filter.FilterByUserClubs == nil { + filter.FilterByUserClubs = &ctx.User.ID + } + + return u.userRepo.Filter(ctx.Context, filter) +} + func (u *User) AdminFilter(ctx context.Context, filter *domain.FilterUser) ([]*domain.User, error) { return u.userRepo.Filter(ctx, filter) }