diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d1b811b --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +*.sql diff --git a/Makefile b/Makefile index 0390f4c..6bbdd19 100644 --- a/Makefile +++ b/Makefile @@ -2,10 +2,9 @@ db: docker run --name nquest-db -e POSTGRES_DB=nquest -e POSTGRES_USER=nquest -e POSTGRES_PASSWORD=nquest -p 5432:5432 -d postgres -.PHONY: gen-api -gen-api: - oapi-codegen -generate server,spec -package api -o api/server.go api/openapi.yaml - oapi-codegen -generate types -package api -o api/types.go api/openapi.yaml +.PHONY: generate +generate: + go generate ./... .PHONY: build-front build-front: @@ -13,4 +12,4 @@ build-front: .PHONY: dev-front dev-front: - cd frontend & npm run dev \ No newline at end of file + cd frontend; npm run dev \ No newline at end of file diff --git a/api/doc.go b/api/doc.go new file mode 100644 index 0000000..07f36fe --- /dev/null +++ b/api/doc.go @@ -0,0 +1,4 @@ +package api + +//go:generate go run github.com/deepmap/oapi-codegen/v2/cmd/oapi-codegen -generate server,spec -package api -o ./server.go ./openapi.yaml +//go:generate go run github.com/deepmap/oapi-codegen/v2/cmd/oapi-codegen -generate types -package api -o ./types.go ./openapi.yaml diff --git a/api/mapper.go b/api/mapper.go index 146348f..b53ecb6 100644 --- a/api/mapper.go +++ b/api/mapper.go @@ -2,10 +2,28 @@ package api import "gitrepo.ru/neonxp/nquest/pkg/models" -var MapRole = map[models.Role]UserTeamRole{ +var MapTeamRole = map[models.Role]UserTeamRole{ models.Captain: Captain, models.Member: Member, } +var MapTeamRoleReverse = map[UserTeamRole]models.Role{ + Captain: models.Captain, + Member: models.Member, +} + +var MapUserRole = map[models.UserRole]UserRole{ + models.RoleNotVerified: NotVerified, + models.RoleUser: User, + models.RoleCreator: Creator, + models.RoleAdmin: Admin, +} + +var MapUserRoleReverse = map[UserRole]models.UserRole{ + NotVerified: models.RoleNotVerified, + User: models.RoleUser, + Creator: models.RoleCreator, + Admin: models.RoleAdmin, +} func MapUserTeam(team *models.TeamMember) *UserTeam { if team == nil || team.Team == nil { @@ -15,6 +33,6 @@ func MapUserTeam(team *models.TeamMember) *UserTeam { return &UserTeam{ Id: int(team.Team.ID), Name: team.Team.Name, - Role: MapRole[team.Role], + Role: MapTeamRole[team.Role], } } diff --git a/api/openapi.yaml b/api/openapi.yaml index 005f374..4a7dad0 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -1,9 +1,12 @@ openapi: "3.1.0" + info: version: 1.0.0 title: nQuest + servers: - url: /api + paths: # User routes /user: @@ -97,6 +100,17 @@ paths: schema: type: integer required: true + requestBody: + content: + 'application/json': + schema: + type: object + properties: + members: + type: array + items: + type: integer + required: [ members ] responses: 200: $ref: '#/components/responses/teamResponse' @@ -109,12 +123,46 @@ paths: schema: type: integer required: true + responses: + 200: + $ref: '#/components/responses/teamResponse' + 404: + $ref: '#/components/responses/errorResponse' + /teams/{teamID}/requests/{userID}: + post: + parameters: + - in: path + name: teamID + schema: + type: integer + required: true + - in: path + name: userID + schema: + type: integer + required: true requestBody: content: 'application/json': schema: - userID: - type: integer + type: object + properties: + approve: + type: boolean + required: [ approve ] + responses: + 200: + $ref: '#/components/responses/teamResponse' + 404: + $ref: '#/components/responses/errorResponse' + /teams/{teamID}/requests: + post: + parameters: + - in: path + name: teamID + schema: + type: integer + required: true responses: 200: $ref: '#/components/responses/teamResponse' @@ -147,6 +195,13 @@ components: enum: - member - captain + userRole: + type: string + enum: + - notVerified + - user + - creator + - admin teamMember: type: object properties: @@ -253,10 +308,13 @@ components: type: string team: $ref: "#/components/schemas/userTeam" + role: + $ref: "#/components/schemas/userRole" required: - id - username - email + - role errorResponse: description: '' content: diff --git a/api/server.go b/api/server.go index 9d7d281..1f84b91 100644 --- a/api/server.go +++ b/api/server.go @@ -42,6 +42,12 @@ type ServerInterface interface { // (POST /teams/{teamID}/members) PostTeamsTeamIDMembers(ctx echo.Context, teamID int) error + // (POST /teams/{teamID}/requests) + PostTeamsTeamIDRequests(ctx echo.Context, teamID int) error + + // (POST /teams/{teamID}/requests/{userID}) + PostTeamsTeamIDRequestsUserID(ctx echo.Context, teamID int, userID int) error + // (GET /user) GetUser(ctx echo.Context) error @@ -163,6 +169,50 @@ func (w *ServerInterfaceWrapper) PostTeamsTeamIDMembers(ctx echo.Context) error return err } +// PostTeamsTeamIDRequests converts echo context to params. +func (w *ServerInterfaceWrapper) PostTeamsTeamIDRequests(ctx echo.Context) error { + var err error + // ------------- Path parameter "teamID" ------------- + var teamID int + + err = runtime.BindStyledParameterWithLocation("simple", false, "teamID", runtime.ParamLocationPath, ctx.Param("teamID"), &teamID) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter teamID: %s", err)) + } + + ctx.Set(CookieAuthScopes, []string{}) + + // Invoke the callback with all the unmarshaled arguments + err = w.Handler.PostTeamsTeamIDRequests(ctx, teamID) + return err +} + +// PostTeamsTeamIDRequestsUserID converts echo context to params. +func (w *ServerInterfaceWrapper) PostTeamsTeamIDRequestsUserID(ctx echo.Context) error { + var err error + // ------------- Path parameter "teamID" ------------- + var teamID int + + err = runtime.BindStyledParameterWithLocation("simple", false, "teamID", runtime.ParamLocationPath, ctx.Param("teamID"), &teamID) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter teamID: %s", err)) + } + + // ------------- Path parameter "userID" ------------- + var userID int + + err = runtime.BindStyledParameterWithLocation("simple", false, "userID", runtime.ParamLocationPath, ctx.Param("userID"), &userID) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter userID: %s", err)) + } + + ctx.Set(CookieAuthScopes, []string{}) + + // Invoke the callback with all the unmarshaled arguments + err = w.Handler.PostTeamsTeamIDRequestsUserID(ctx, teamID, userID) + return err +} + // GetUser converts echo context to params. func (w *ServerInterfaceWrapper) GetUser(ctx echo.Context) error { var err error @@ -236,6 +286,8 @@ func RegisterHandlersWithBaseURL(router EchoRouter, si ServerInterface, baseURL router.GET(baseURL+"/teams/:teamID", wrapper.GetTeamsTeamID) router.DELETE(baseURL+"/teams/:teamID/members", wrapper.DeleteTeamsTeamIDMembers) router.POST(baseURL+"/teams/:teamID/members", wrapper.PostTeamsTeamIDMembers) + router.POST(baseURL+"/teams/:teamID/requests", wrapper.PostTeamsTeamIDRequests) + router.POST(baseURL+"/teams/:teamID/requests/:userID", wrapper.PostTeamsTeamIDRequestsUserID) router.GET(baseURL+"/user", wrapper.GetUser) router.POST(baseURL+"/user/login", wrapper.PostUserLogin) router.POST(baseURL+"/user/logout", wrapper.PostUserLogout) @@ -246,22 +298,24 @@ func RegisterHandlersWithBaseURL(router EchoRouter, si ServerInterface, baseURL // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "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", + "tlEaAAA=", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/api/types.go b/api/types.go index 6d7d3be..30ba941 100644 --- a/api/types.go +++ b/api/types.go @@ -7,6 +7,14 @@ const ( CookieAuthScopes = "cookieAuth.Scopes" ) +// Defines values for UserRole. +const ( + Admin UserRole = "admin" + Creator UserRole = "creator" + NotVerified UserRole = "notVerified" + User UserRole = "user" +) + // Defines values for UserTeamRole. const ( Captain UserTeamRole = "captain" @@ -44,6 +52,9 @@ type TeamView struct { Name string `json:"name"` } +// UserRole defines model for userRole. +type UserRole string + // UserTeam defines model for userTeam. type UserTeam struct { Id int `json:"id"` @@ -85,6 +96,7 @@ type TeamsListResponse = []TeamView type UserResponse struct { Email string `json:"email"` Id int `json:"id"` + Role UserRole `json:"role"` Team *UserTeam `json:"team,omitempty"` Username string `json:"username"` } @@ -108,8 +120,15 @@ type PostTeamsJSONBody struct { Name string `json:"name"` } -// DeleteTeamsTeamIDMembersJSONBody defines parameters for DeleteTeamsTeamIDMembers. -type DeleteTeamsTeamIDMembersJSONBody = interface{} +// PostTeamsTeamIDMembersJSONBody defines parameters for PostTeamsTeamIDMembers. +type PostTeamsTeamIDMembersJSONBody struct { + Members []int `json:"members"` +} + +// PostTeamsTeamIDRequestsUserIDJSONBody defines parameters for PostTeamsTeamIDRequestsUserID. +type PostTeamsTeamIDRequestsUserIDJSONBody struct { + Approve bool `json:"approve"` +} // PostUserLoginJSONBody defines parameters for PostUserLogin. type PostUserLoginJSONBody struct { @@ -128,8 +147,11 @@ type PostUserRegisterJSONBody struct { // PostTeamsJSONRequestBody defines body for PostTeams for application/json ContentType. type PostTeamsJSONRequestBody PostTeamsJSONBody -// DeleteTeamsTeamIDMembersJSONRequestBody defines body for DeleteTeamsTeamIDMembers for application/json ContentType. -type DeleteTeamsTeamIDMembersJSONRequestBody = DeleteTeamsTeamIDMembersJSONBody +// PostTeamsTeamIDMembersJSONRequestBody defines body for PostTeamsTeamIDMembers for application/json ContentType. +type PostTeamsTeamIDMembersJSONRequestBody PostTeamsTeamIDMembersJSONBody + +// PostTeamsTeamIDRequestsUserIDJSONRequestBody defines body for PostTeamsTeamIDRequestsUserID for application/json ContentType. +type PostTeamsTeamIDRequestsUserIDJSONRequestBody PostTeamsTeamIDRequestsUserIDJSONBody // PostUserLoginJSONRequestBody defines body for PostUserLogin for application/json ContentType. type PostUserLoginJSONRequestBody PostUserLoginJSONBody diff --git a/config.go b/config.go index 8794c6b..89b858b 100644 --- a/config.go +++ b/config.go @@ -2,9 +2,7 @@ package main import ( "fmt" - "log" - "github.com/joho/godotenv" "github.com/kelseyhightower/envconfig" ) @@ -32,10 +30,5 @@ func (c *Config) DSN() string { func GetConfig() (*Config, error) { c := new(Config) - err := godotenv.Load() - if err != nil { - log.Println("not .env") - } - return c, envconfig.Process("", c) } diff --git a/frontend/src/components/Layout.jsx b/frontend/src/components/Layout.jsx index 476c5ec..c9e0396 100644 --- a/frontend/src/components/Layout.jsx +++ b/frontend/src/components/Layout.jsx @@ -3,9 +3,12 @@ import { Button, Nav, Container, NavbarBrand, NavbarToggle, Navbar, NavbarCollap import { UserProvider } from "../store/user"; import { useEffect } from "react"; import { ajax } from "../utils/fetch"; +import { useRole } from "../utils/roles"; export default () => { const params = useLoaderData(); + const { hasRole } = useRole(); + useEffect(() => { setUser(params) }, [params]) @@ -28,28 +31,35 @@ export default () => { Игры - - Команды - + {hasRole("user") ? (<> + + Команды + + {hasRole("creator") ? ( + + Админка + + ) : null} + ) : null} - {user ? ( - <> - {user.username}  - {user.team ? ( - <>({user.team.name}) - ) : ( - <>(без команды) - )}  - - - ) : ( - - Вход - Регистрация - - )} - + {user ? ( + <> + {user.username}  + {user.team ? ( + <>({user.team.name}) + ) : ( + <>(без команды) + )}  + + + ) : ( + + Вход + Регистрация + + )} + diff --git a/frontend/src/pages/Team.jsx b/frontend/src/pages/Team.jsx index 14baa69..2f892aa 100644 --- a/frontend/src/pages/Team.jsx +++ b/frontend/src/pages/Team.jsx @@ -1,22 +1,32 @@ import { useLoaderData, useNavigate, useRouteLoaderData } from "react-router-dom"; -import { Button, Table } from "react-bootstrap"; +import { Alert, Button, ButtonGroup, Table } from "react-bootstrap"; import { UserProvider } from "../store/user"; import { ajax } from "../utils/fetch"; -import { useState } from "react"; +import { useEffect, useState } from "react"; const userRoles = { captain: "Капитан", member: "Участник" }; export default () => { - const team = useLoaderData(); + const teamFromRouter = useLoaderData(); + const [team, setTeam] = useState(teamFromRouter); + const [error, setError] = useState(null); + useEffect(() => { + setTeam(teamFromRouter); + }, [teamFromRouter]); const navigate = useNavigate(); - const {request, setRequest} = useState(false); + + const { user, setUser } = UserProvider.useContainer(); + + const [request, setRequest] = useState(false); + + useEffect(() => { + setRequest(!!team.requests.find(x => user && x.user.id == user.id)); + }, [user, team.requests]) if (!team) { return null; } - const { user } = UserProvider.useContainer(); - if (!user) { return null; } @@ -26,14 +36,38 @@ export default () => { const isCaptain = member && user.team.role == "captain"; const sendRequest = () => { + ajax(`/api/teams/${team.id}/requests`, { + method: "POST", + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + } + }).then(() => setRequest(true)).catch(setError); + }; + const updateMemebers = (members) => { ajax(`/api/teams/${team.id}/members`, { method: "POST", headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' }, - }).then(() => setRequest(true)); - }; + body: JSON.stringify({ members }), + }).then(team => setTeam(team)).catch(setError); + }; + const approveRequest = (userID, approve) => { + ajax(`/api/teams/${team.id}/requests/${userID}`, { + method: "POST", + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ approve }), + }).then(team => setTeam(team)).catch(setError); + } + const removeMember = (userID) => { + const members = team.members.map(x => x.user.id).filter(uid => uid != userID); + return updateMemebers(members) + } const leaveTeam = () => { ajax(`/api/teams/${team.id}/members`, { method: "DELETE", @@ -41,8 +75,13 @@ export default () => { 'Accept': 'application/json', 'Content-Type': 'application/json' }, - }).then(() => navigate("/teams")); - }; + }).then(team => setTeam(team)).then(() => { + setUser({ + ...user, + team: null, + }); + }).catch(setError); + }; const deleteTeam = () => { ajax(`/api/teams/${team.id}`, { method: "DELETE", @@ -50,7 +89,13 @@ export default () => { 'Accept': 'application/json', 'Content-Type': 'application/json' }, - }).then(() => navigate("/teams")); + }).then(() => { + setUser({ + ...user, + team: null, + }); + navigate("/teams"); + }).catch(setError); }; return (<> @@ -58,7 +103,8 @@ export default () => {

Создана: {team.createdAt}

{!member && !inOtherTeam && !request ? () : null} - {request ? (

Заявка в команду рассматривается

) : null} + {request ? (Заявка в команду отправлена) : null} + {error ? ({error}) : null} {member && !isCaptain ? () : null}

Участники

@@ -79,15 +125,51 @@ export default () => { {tm.createdAt} { - isCaptain && tm.user.id!=user.id - ? () - : null + isCaptain && tm.user.id != user.id + ? () + : null } ))} - {isCaptain && (team.members.length == 1)?:null} + {isCaptain + ? (<> +

Заявки

+ + + + + + + + + + {team.requests.map(tm => ( + + + + + + ))} + +
Имя пользователяДата заявки
{tm.user.username}{tm.createdAt} + + + + +
+ ) + : null} + {isCaptain && (team.members.length == 1) ? : null} ); } \ No newline at end of file diff --git a/frontend/src/pages/TeamNew.jsx b/frontend/src/pages/TeamNew.jsx index 673d14e..7498c5c 100644 --- a/frontend/src/pages/TeamNew.jsx +++ b/frontend/src/pages/TeamNew.jsx @@ -2,8 +2,10 @@ import { useState } from "react"; import { Form, Button, Row, Col } from "react-bootstrap"; import { useNavigate } from "react-router-dom"; import { ajax } from "../utils/fetch"; +import { UserProvider } from "../store/user"; export default () => { + const {user, setUser} = UserProvider.useContainer(); const [name, setName] = useState(""); const [error, setError] = useState(null); const navigate = useNavigate(); @@ -18,7 +20,14 @@ export default () => { }, body: JSON.stringify({ name }) }).then((team) => { - navigate(`/teams/${team.id}`) + setUser({ + ...user, + team: { + role: 1, + ...team, + } + }); + navigate(`/teams/${team.id}`); }).catch(e => setError(e.message)) } return (<> diff --git a/frontend/src/utils/roles.js b/frontend/src/utils/roles.js new file mode 100644 index 0000000..41ee59e --- /dev/null +++ b/frontend/src/utils/roles.js @@ -0,0 +1,24 @@ +import { UserProvider } from "../store/user" + +const roleHierarchy = { + "user": { + "user": true + }, + "creator": { + "user": true, + "creator": true, + }, + "admin": { + "user": true, + "creator": true, + "admin": true, + } +} + +export const useRole = () => { + const { user } = UserProvider.useContainer(); + + return { + hasRole: (role) => user && !!roleHierarchy[user.role][role] + } +} \ No newline at end of file diff --git a/go.mod b/go.mod index b0ae6de..5c41630 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,6 @@ go 1.21.3 require ( github.com/dimuska139/go-email-normalizer v1.2.1 github.com/getkin/kin-openapi v0.120.0 - github.com/joho/godotenv v1.5.1 github.com/kelseyhightower/envconfig v1.4.0 github.com/labstack/echo-contrib v0.15.0 github.com/labstack/echo/v4 v4.11.2 @@ -17,6 +16,7 @@ require ( require ( github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect + github.com/deepmap/oapi-codegen/v2 v2.0.0 // indirect github.com/go-openapi/jsonpointer v0.19.6 // indirect github.com/go-openapi/swag v0.22.4 // indirect github.com/google/uuid v1.3.1 // indirect @@ -29,6 +29,9 @@ require ( github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect github.com/perimeterx/marshmallow v1.1.5 // indirect github.com/rogpeppe/go-internal v1.9.0 // indirect + golang.org/x/mod v0.12.0 // indirect + golang.org/x/tools v0.12.0 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) @@ -56,7 +59,7 @@ require ( github.com/valyala/fasttemplate v1.2.2 // indirect github.com/wader/gormstore/v2 v2.0.3 golang.org/x/net v0.17.0 // indirect - golang.org/x/sys v0.13.0 // indirect + golang.org/x/sys v0.14.0 // indirect golang.org/x/text v0.13.0 // indirect golang.org/x/time v0.3.0 // indirect google.golang.org/protobuf v1.31.0 // indirect diff --git a/go.sum b/go.sum index 9b3142a..764f1cc 100644 --- a/go.sum +++ b/go.sum @@ -16,6 +16,8 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/deepmap/oapi-codegen/v2 v2.0.0 h1:3TS7w3r+XnjKFXcbFbc16pTWzfTy0OLPkCsutEHjWDA= +github.com/deepmap/oapi-codegen/v2 v2.0.0/go.mod h1:7zR+ZL3WzLeCkr2k8oWTxEa0v8y/F25ane0l6A5UjLA= github.com/dimuska139/go-email-normalizer v1.2.1 h1:pJNZnU7uS9MRoYqpoir05B+bCYXrS9sPGE4G1o9EDA8= github.com/dimuska139/go-email-normalizer v1.2.1/go.mod h1:fGPWcd/7PSz9aOHusKVYmDk+oKahH/fZTCQ7tTU7e0Y= github.com/getkin/kin-openapi v0.120.0 h1:MqJcNJFrMDFNc07iwE8iFC5eT2k/NPUFDIpNeiZv8Jg= @@ -104,8 +106,6 @@ github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkr github.com/jinzhu/now v1.1.4/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= -github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= -github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= @@ -235,6 +235,8 @@ golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/mod v0.12.0 h1:rmsUpXtvNzj340zd98LZ4KntptpfRHwpFOHG188oHXc= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -263,8 +265,8 @@ golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20211103235746-7861aae1554b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= -golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.14.0 h1:Vz7Qs629MkJkGyHxUlRHizWJRG2j8fbQKjELVSNhy7Q= +golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -285,6 +287,8 @@ golang.org/x/tools v0.0.0-20190823170909-c4a336ef6a2f/go.mod h1:b+2E5dAYhXwXZwtn golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200103221440-774c71fcf114/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.12.0 h1:YW6HUoUmYBpwSgyaGaZq1fHjrBjX1rlpZ54T6mu2kss= +golang.org/x/tools v0.12.0/go.mod h1:Sc0INKfu04TlqNoRA1hgpFZbhYXHPr4V5DzpSBTPqQM= golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -302,6 +306,8 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EV gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/inconshreveable/log15.v2 v2.0.0-20180818164646-67afb5ed74ec/go.mod h1:aPpfJ7XW+gOuirDoZ8gHhLh3kZ1B08FtV2bbmy7Jv3s= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go index 180d93b..c52f668 100644 --- a/main.go +++ b/main.go @@ -67,7 +67,7 @@ func main() { // --[ Services ]-- userService := service.NewUser(db) - teamService := service.NewTeam(db) + teamService := service.NewTeam(db, userService) gameService := service.NewGame(db) // --[ HTTP server ]-- @@ -146,6 +146,7 @@ func main() { Options: openapi3filter.Options{ AuthenticationFunc: authFunc, }, + SilenceServersWarning: true, }, ), ) diff --git a/pkg/controller/team.go b/pkg/controller/team.go index 4dc3423..b187163 100644 --- a/pkg/controller/team.go +++ b/pkg/controller/team.go @@ -45,29 +45,28 @@ func (t *Team) GetTeamsTeamID(ctx echo.Context, teamID int) error { return t.getTeamResponse(ctx, teamID) } -func (t *Team) DeleteTeamsTeamIDMembers(ctx echo.Context, teamID int) error { - return t.getTeamResponse(ctx, teamID) -} - func (t *Team) PostTeamsTeamIDMembers(ctx echo.Context, teamID int) error { - team, err := t.TeamService.GetByID(ctx.Request().Context(), uint(teamID)) - if err != nil { - return ctx.JSON(http.StatusNotFound, api.ErrorResponse{ - Code: http.StatusNotFound, + user := contextlib.GetUser(ctx) + if user.Team == nil || user.Team.TeamID != uint(teamID) || user.Team.Role != models.Captain { + return ctx.JSON(http.StatusForbidden, api.ErrorResponse{ + Code: http.StatusForbidden, + Message: "Вам нельзя менять состав команды", + }) + } + + req := &api.PostTeamsTeamIDMembersJSONRequestBody{} + if err := ctx.Bind(req); err != nil { + return ctx.JSON(http.StatusBadRequest, api.ErrorResponse{ + Code: http.StatusBadRequest, Message: err.Error(), }) } - user := contextlib.GetUser(ctx) - if user.Team != nil { - if err := t.TeamService.RemoveMember(ctx.Request().Context(), user.Team.Team, user); err != nil { - return err - } - user.Team = nil - } - - if err := t.TeamService.Request(ctx.Request().Context(), team, user); err != nil { - return err + if err := t.TeamService.UpdateMembers(ctx.Request().Context(), uint(teamID), req.Members); err != nil { + return ctx.JSON(http.StatusBadRequest, api.ErrorResponse{ + Code: http.StatusBadRequest, + Message: err.Error(), + }) } return t.getTeamResponse(ctx, teamID) @@ -124,7 +123,7 @@ func (t *Team) getTeamResponse(ctx echo.Context, teamID int) error { members := make([]api.TeamMember, 0, len(team.Members)) for _, tm := range team.Members { members = append(members, api.TeamMember{ - Role: api.MapRole[tm.Role], + Role: api.MapTeamRole[tm.Role], User: api.UserView{ Id: int(tm.User.ID), Username: tm.User.Username, @@ -151,3 +150,62 @@ func (t *Team) getTeamResponse(ctx echo.Context, teamID int) error { CreatedAt: team.CreatedAt.Format("02.01.06"), })) } + +// (POST /teams/{teamID}/requests) +func (t *Team) PostTeamsTeamIDRequests(ctx echo.Context, teamID int) error { + user := contextlib.GetUser(ctx) + if user.Team != nil { + return ctx.JSON(http.StatusForbidden, api.ErrorResponse{ + Code: http.StatusForbidden, + Message: "Вы уже в другой команде", + }) + } + if err := t.TeamService.Request(ctx.Request().Context(), uint(teamID), user); err != nil { + return ctx.JSON(http.StatusBadRequest, api.ErrorResponse{ + Code: http.StatusBadRequest, + Message: err.Error(), + }) + } + + return t.getTeamResponse(ctx, teamID) +} + +// (POST /teams/{teamID}/requests/{userID}) +func (t *Team) PostTeamsTeamIDRequestsUserID(ctx echo.Context, teamID int, userID int) error { + user := contextlib.GetUser(ctx) + if user.Team == nil || user.Team.TeamID != uint(teamID) || user.Team.Role != models.Captain { + return ctx.JSON(http.StatusForbidden, api.ErrorResponse{ + Code: http.StatusForbidden, + Message: "Вам нельзя менять состав команды", + }) + } + req := &api.PostTeamsTeamIDRequestsUserIDJSONRequestBody{} + if err := ctx.Bind(req); err != nil { + return ctx.JSON(http.StatusBadRequest, api.ErrorResponse{ + Code: http.StatusBadRequest, + Message: err.Error(), + }) + } + + if err := t.TeamService.ApproveRequest(ctx.Request().Context(), teamID, uint(userID), req.Approve); err != nil { + return ctx.JSON(http.StatusBadRequest, api.ErrorResponse{ + Code: http.StatusBadRequest, + Message: err.Error(), + }) + } + + return t.getTeamResponse(ctx, teamID) +} + +// (DELETE /teams/{teamID}/members) +func (t *Team) DeleteTeamsTeamIDMembers(ctx echo.Context, teamID int) error { + user := contextlib.GetUser(ctx) + if err := t.TeamService.DeleteMember(ctx.Request().Context(), teamID, user.ID); err != nil { + return ctx.JSON(http.StatusBadRequest, api.ErrorResponse{ + Code: http.StatusBadRequest, + Message: err.Error(), + }) + } + + return t.getTeamResponse(ctx, teamID) +} diff --git a/pkg/controller/user.go b/pkg/controller/user.go index 45a209a..38dee48 100644 --- a/pkg/controller/user.go +++ b/pkg/controller/user.go @@ -107,6 +107,7 @@ func (u *User) GetUser(c echo.Context) error { Username: user.Username, Email: user.Email, Team: api.MapUserTeam(user.Team), + Role: api.MapUserRole[user.Role], }) } diff --git a/pkg/service/team.go b/pkg/service/team.go index bcf1e35..d1747cc 100644 --- a/pkg/service/team.go +++ b/pkg/service/team.go @@ -4,6 +4,7 @@ import ( "context" "errors" "slices" + "time" "gitrepo.ru/neonxp/nquest/pkg/models" "gorm.io/gorm" @@ -15,16 +16,40 @@ var ( ) type Team struct { - DB *gorm.DB + DB *gorm.DB + User *User } // NewTeam returns new Team. -func NewTeam(db *gorm.DB) *Team { +func NewTeam(db *gorm.DB, user *User) *Team { return &Team{ - DB: db, + DB: db, + User: user, } } +func (ts *Team) GetByID(ctx context.Context, id uint) (*models.Team, error) { + t := new(models.Team) + + err := ts.DB. + WithContext(ctx). + Preload("Members"). + Preload("Members.User"). + Preload("Requests"). + Preload("Requests.User"). + First(t, id). + Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrTeamNotFound + } + + return nil, err + } + + return t, nil +} + func (ts *Team) List(ctx context.Context) ([]*models.Team, error) { teams := []*models.Team{} @@ -49,28 +74,6 @@ func (ts *Team) Create(ctx context.Context, name string, user *models.User) (*mo return t, db.Create(t).Error } -func (ts *Team) GetByID(ctx context.Context, id uint) (*models.Team, error) { - t := new(models.Team) - - err := ts.DB. - WithContext(ctx). - Preload("Members"). - Preload("Members.User"). - Preload("Requests"). - Preload("Requests.User"). - First(t, id). - Error - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, ErrTeamNotFound - } - - return nil, err - } - - return t, nil -} - func (ts *Team) Delete(ctx context.Context, id uint) error { if err := ts.DB.WithContext(ctx).Delete(&models.Team{}, id).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { @@ -83,7 +86,12 @@ func (ts *Team) Delete(ctx context.Context, id uint) error { return nil } -func (ts *Team) Request(ctx context.Context, team *models.Team, user *models.User) error { +func (ts *Team) Request(ctx context.Context, teamID uint, user *models.User) error { + team, err := ts.GetByID(ctx, teamID) + if err != nil { + return err + } + return ts.DB. WithContext(ctx). Clauses(clause.OnConflict{DoNothing: true}). @@ -94,37 +102,108 @@ func (ts *Team) Request(ctx context.Context, team *models.Team, user *models.Use Error } -func (ts *Team) ApproveMember(ctx context.Context, team *models.Team, user *models.User) error { - team.Requests = slices.DeleteFunc(team.Requests, func(tr *models.TeamRequest) bool { - return tr.UserID == user.ID - }) - - team.Members = append(team.Members, &models.TeamMember{ - Team: team, - User: user, - Role: models.Member, - }) - - db := ts.DB.WithContext(ctx) - if err := db.Delete(&models.TeamRequest{}, `user_id = ?`, user.ID).Error; err != nil { +func (ts *Team) UpdateMembers( + ctx context.Context, + teamID uint, + newMembers []int, +) error { + team, err := ts.GetByID(ctx, teamID) + if err != nil { return err } - return db.Save(team).Error + newMembersList := make([]*models.TeamMember, 0, len(newMembers)) + + for _, tm := range team.Members { + idx, ok := slices.BinarySearch(newMembers, int(tm.UserID)) + if ok { + newMembers = slices.Delete(newMembers, idx, idx) + newMembersList = append(newMembersList, tm) + continue + } + if err := ts.DB.WithContext(ctx).Delete(tm).Error; err != nil { + return err + } + } + + for _, userID := range newMembers { + _, found := slices.BinarySearchFunc(team.Members, userID, func(tm *models.TeamMember, i int) int { + return int(tm.UserID) - i + }) + if found { + continue + } + user, err := ts.User.GetUserByID(ctx, uint(userID)) + if err != nil { + return err + } + newMembersList = append(newMembersList, &models.TeamMember{ + TeamID: teamID, + Team: team, + UserID: user.ID, + User: user, + Role: models.Member, + CreatedAt: time.Now(), + }) + } + + team.Members = newMembersList + + return ts.DB. + WithContext(ctx). + Session(&gorm.Session{FullSaveAssociations: true}). + Updates(team). + Error } -func (ts *Team) DeclineMember(ctx context.Context, team *models.Team, user *models.User) error { - team.Requests = slices.DeleteFunc(team.Requests, func(tr *models.TeamRequest) bool { - return tr.UserID == user.ID - }) - db := ts.DB.WithContext(ctx) - if err := db.Delete(&models.TeamRequest{}, `user_id = ?`, user.ID).Error; err != nil { +func (ts *Team) ApproveRequest(ctx context.Context, teamID int, userID uint, approve bool) error { + team, err := ts.GetByID(ctx, uint(teamID)) + if err != nil { return err } - return db.Session(&gorm.Session{FullSaveAssociations: true}).Updates(team).Error + idx, found := slices.BinarySearchFunc(team.Requests, userID, func(tr *models.TeamRequest, i uint) int { + return int(tr.UserID - i) + }) + if !found { + return nil + } + request := team.Requests[idx] + team.Requests = slices.DeleteFunc(team.Requests, func(tr *models.TeamRequest) bool { + return tr.UserID == uint(userID) + }) + if err := ts.DB.WithContext(ctx).Delete(request).Error; err != nil { + return err + } + + if approve { + team.Members = append(team.Members, &models.TeamMember{ + Team: team, + TeamID: uint(teamID), + UserID: uint(userID), + Role: models.Member, + CreatedAt: time.Now(), + }) + + return ts.DB.WithContext(ctx).Save(team).Error + } + + return nil } -func (ts *Team) RemoveMember(ctx context.Context, team *models.Team, user *models.User) error { - return ts.DB.WithContext(ctx).Delete(&models.TeamMember{}, `user_id = ? and team_id = ?`, user.ID, team.ID).Error +func (ts *Team) DeleteMember(ctx context.Context, teamID int, userID uint) error { + team, err := ts.GetByID(ctx, uint(teamID)) + if err != nil { + return err + } + + idx, found := slices.BinarySearchFunc(team.Members, userID, func(tm *models.TeamMember, u uint) int { + return int(tm.UserID - u) + }) + if !found { + return nil + } + member := team.Members[idx] + + return ts.DB.WithContext(ctx).Delete(member).Error }