Поддержка ролей пользователя

This commit is contained in:
Александр Кирюхин 2023-11-12 23:22:58 +03:00
parent 8f6748d2ca
commit d0fce2b2fe
18 changed files with 570 additions and 148 deletions

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
*.sql

View file

@ -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
cd frontend; npm run dev

4
api/doc.go Normal file
View file

@ -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

View file

@ -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],
}
}

View file

@ -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: 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:

View file

@ -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

View file

@ -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

View file

@ -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)
}

View file

@ -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,9 +31,16 @@ export default () => {
<Nav.Item>
<Nav.Link as={Link} className="nav-link" to="/">Игры</Nav.Link>
</Nav.Item>
{hasRole("user") ? (<>
<Nav.Item>
<Nav.Link as={Link} className="nav-link" to="/teams">Команды</Nav.Link>
</Nav.Item>
{hasRole("creator") ? (
<Nav.Item>
<Nav.Link as={Link} className="nav-link" to="/admin">Админка</Nav.Link>
</Nav.Item>
) : null}
</>) : null}
</Nav>
<Navbar.Text>
{user ? (

View file

@ -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,7 +75,12 @@ 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}`, {
@ -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 () => {
<p>Создана: {team.createdAt}</p>
{!member && !inOtherTeam && !request ? (<Button onClick={sendRequest}>Отправить заявку в команду</Button>) : null}
{request ? (<p>Заявка в команду рассматривается</p>) : null}
{request ? (<Alert variant="success">Заявка в команду отправлена</Alert>) : null}
{error ? (<Alert variant="danger">{error}</Alert>) : null}
{member && !isCaptain ? (<Button onClick={leaveTeam}>Выйти из команды</Button>) : null}
<h2>Участники</h2>
@ -80,7 +126,7 @@ export default () => {
<td>
{
isCaptain && tm.user.id != user.id
? (<Button variant="outline-danger">Выгнать</Button>)
? (<Button variant="outline-danger" onClick={() => removeMember(tm.user.id)}>Выгнать</Button>)
: null
}
</td>
@ -88,6 +134,42 @@ export default () => {
))}
</tbody>
</Table>
{isCaptain
? (<>
<h2>Заявки</h2>
<Table>
<thead>
<tr>
<th>Имя пользователя</th>
<th>Дата заявки</th>
<th></th>
</tr>
</thead>
<tbody>
{team.requests.map(tm => (
<tr key={tm.user.id}>
<td>{tm.user.username}</td>
<td>{tm.createdAt}</td>
<td>
<ButtonGroup>
<Button
variant="outline-success"
onClick={() => approveRequest(tm.user.id, true)}>
Принять
</Button>
<Button
variant="outline-danger"
onClick={() => approveRequest(tm.user.id, false)}>
Отказать
</Button>
</ButtonGroup>
</td>
</tr>
))}
</tbody>
</Table>
</>)
: null}
{isCaptain && (team.members.length == 1) ? <Button variant="outline-danger" onClick={deleteTeam}>Удалить команду</Button> : null}
</>);
}

View file

@ -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 (<>

View file

@ -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]
}
}

7
go.mod
View file

@ -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

14
go.sum
View file

@ -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=

View file

@ -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,
},
),
)

View file

@ -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 {
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: "Вам нельзя менять состав команды",
})
}
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,
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)
}

View file

@ -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],
})
}

View file

@ -4,6 +4,7 @@ import (
"context"
"errors"
"slices"
"time"
"gitrepo.ru/neonxp/nquest/pkg/models"
"gorm.io/gorm"
@ -16,15 +17,39 @@ var (
type Team struct {
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,
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
})
func (ts *Team) UpdateMembers(
ctx context.Context,
teamID uint,
newMembers []int,
) error {
team, err := ts.GetByID(ctx, teamID)
if err != nil {
return err
}
team.Members = append(team.Members, &models.TeamMember{
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(),
})
}
db := ts.DB.WithContext(ctx)
if err := db.Delete(&models.TeamRequest{}, `user_id = ?`, user.ID).Error; err != nil {
team.Members = newMembersList
return ts.DB.
WithContext(ctx).
Session(&gorm.Session{FullSaveAssociations: true}).
Updates(team).
Error
}
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.Save(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
}
func (ts *Team) DeclineMember(ctx context.Context, team *models.Team, user *models.User) error {
request := team.Requests[idx]
team.Requests = slices.DeleteFunc(team.Requests, func(tr *models.TeamRequest) bool {
return tr.UserID == user.ID
return tr.UserID == uint(userID)
})
db := ts.DB.WithContext(ctx)
if err := db.Delete(&models.TeamRequest{}, `user_id = ?`, user.ID).Error; err != nil {
if err := ts.DB.WithContext(ctx).Delete(request).Error; err != nil {
return err
}
return db.Session(&gorm.Session{FullSaveAssociations: true}).Updates(team).Error
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
}
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
return nil
}
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
}