12 Commits

Author SHA1 Message Date
41c58b400f WIP: keep adding projects
Signed-off-by: Nikolai Rodionov <iam@allanger.xyz>
2026-05-26 16:47:00 +02:00
21a4c53e0f Implement list projects
Signed-off-by: Nikolai Rodionov <iam@allanger.xyz>
2026-05-22 11:23:04 +02:00
4e617c90ef Add basic health checks
Signed-off-by: Nikolai Rodionov <iam@allanger.xyz>
2026-05-22 09:39:25 +02:00
e132e143bd WIP: Fix the create project test
Signed-off-by: Nikolai Rodionov <iam@allanger.xyz>
2026-05-21 17:12:44 +02:00
a4ab4fa6c6 WIP: Fix the create project test
Signed-off-by: Nikolai Rodionov <iam@allanger.xyz>
2026-05-21 17:03:59 +02:00
891b9854b5 WIP: Add a migration for project membmership
Signed-off-by: Nikolai Rodionov <iam@allanger.xyz>
2026-05-21 16:48:12 +02:00
d1d4cba05b WIP: Adding list project
Signed-off-by: Nikolai Rodionov <iam@allanger.xyz>
2026-05-21 14:55:34 +02:00
9c558d601b WIP: Keep writing projectS
Signed-off-by: Nikolai Rodionov <iam@allanger.xyz>
2026-05-20 13:37:48 +02:00
2285bb10ae WIP: Adding projects
Some checks failed
ci/woodpecker/push/build Pipeline failed
Signed-off-by: Nikolai Rodionov <iam@allanger.xyz>
2026-05-19 14:04:26 +02:00
28235765cd Add tests for the transactions
All checks were successful
ci/woodpecker/push/build Pipeline was successful
Signed-off-by: Nikolai Rodionov <iam@allanger.xyz>
2026-05-19 11:32:34 +02:00
c8d3112fbd Restructure the projec and start adding projects
All checks were successful
ci/woodpecker/push/build Pipeline was successful
Signed-off-by: Nikolai Rodionov <iam@allanger.xyz>
2026-05-19 10:55:53 +02:00
25ebb4805e WIP: Start adding projects
Signed-off-by: Nikolai Rodionov <allanger@badhouseplants.net>
2026-05-18 22:15:28 +02:00
29 changed files with 1011 additions and 143 deletions

View File

@@ -61,6 +61,30 @@ tasks:
SOFTPLAYER_DB_CONNECTION_STRING: postgres://softplayer:qwertyu9@localhost:30432/softplayer?sslmode=disable
cmd: go run main.go migrate --migrations-path=file://migrations
force-migration:
desc: Force migrate to a desired version
vars:
SOFTPLAYER_DB_CONNECTION_STRING: postgres://softplayer:qwertyu9@localhost:30432/softplayer?sslmode=disable
cmd: "{{ .MIGRATE }} -path=./migrations -database={{ .SOFTPLAYER_DB_CONNECTION_STRING }} force {{ .CLI_ARGS }}"
deps:
- migrate
down-migrations:
desc: Roll back all migrations
vars:
SOFTPLAYER_DB_CONNECTION_STRING: postgres://softplayer:qwertyu9@localhost:30432/softplayer?sslmode=disable
cmd: "{{ .MIGRATE }} -path=./migrations -database={{ .SOFTPLAYER_DB_CONNECTION_STRING }} down -all"
deps:
- migrate
drop-migrations:
desc: Drop migrations
vars:
SOFTPLAYER_DB_CONNECTION_STRING: postgres://softplayer:qwertyu9@localhost:30432/softplayer?sslmode=disable
cmd: "{{ .MIGRATE }} -path=./migrations -database={{ .SOFTPLAYER_DB_CONNECTION_STRING }} drop"
deps:
- migrate
run-server-dev:
desc: Run the local dev server
deps:
@@ -113,6 +137,8 @@ tasks:
desc: Add a new database migration
silent: true
cmd: "{{.MIGRATE}} create -dir migrations -ext sql {{.CLI_ARGS}}"
deps:
- migrate
# Install required tools
localbin:
@@ -132,6 +158,7 @@ tasks:
TARGET: "{{.MIGRATE}}"
PACKAGE: github.com/golang-migrate/migrate/v4/cmd/migrate
VERSION: latest
TAGS: "postgres"
go-install-tool:
internal: true
@@ -150,7 +177,13 @@ tasks:
echo "Downloading $PACKAGE"
rm -f "$TARGET"
GOBIN="{{.LOCALBIN}}" go install "$PACKAGE"
TAGS="{{.TAGS}}"
if [ -n "$TAGS" ]; then
echo "Using build tags: $TAGS"
GOBIN="{{.LOCALBIN}}" go install -tags "$TAGS" "$PACKAGE"
else
GOBIN="{{.LOCALBIN}}" go install "$PACKAGE"
fi
mv "{{.LOCALBIN}}/$(basename "$TARGET")" "$VERSIONED"
ln -sf "$(realpath "$VERSIONED")" "$TARGET"

View File

@@ -2,15 +2,16 @@ package cmd
import (
"context"
"database/sql"
"fmt"
"net"
"strings"
"time"
v1 "gitea.badhouseplants.net/softplayer/softplayer-backend/api/v1"
"gitea.badhouseplants.net/softplayer/softplayer-backend/internal/controllers"
v1 "gitea.badhouseplants.net/softplayer/softplayer-backend/internal/api/v1"
"gitea.badhouseplants.net/softplayer/softplayer-backend/internal/helpers/logger"
"gitea.badhouseplants.net/softplayer/softplayer-backend/internal/helpers/postgres"
"gitea.badhouseplants.net/softplayer/softplayer-backend/internal/services"
accounts "gitea.badhouseplants.net/softplayer/softplayer-go-proto/pkg/accounts/v1"
test "gitea.badhouseplants.net/softplayer/softplayer-go-proto/pkg/test/v1"
tokens "gitea.badhouseplants.net/softplayer/softplayer-go-proto/pkg/tokens/v1"
@@ -21,6 +22,9 @@ import (
_ "github.com/jackc/pgx/v5/stdlib"
"github.com/redis/go-redis/v9"
"google.golang.org/grpc"
"google.golang.org/grpc/health"
healthgrpc "google.golang.org/grpc/health/grpc_health_v1"
healthpb "google.golang.org/grpc/health/grpc_health_v1"
"google.golang.org/grpc/reflection"
)
@@ -44,6 +48,8 @@ type Server struct {
// Dev and logging
Reflection bool `env:"SOFTPLAYER_REFLECTION" default:"false"`
DevMode bool `env:"SOFTPLAYER_DEV_MODE" default:"false"`
// HealthChecks
HealthCheckInterval time.Duration `env:"SOFTPLAYER_HEALTH_CHECK_INTERVAL" default:"5s"`
}
// Run the grpc backend server
@@ -68,7 +74,7 @@ func (cmd *Server) Run(ctx context.Context) error {
Addr: cmd.RedisHost,
})
authController := controllers.NewAuthController(
authController := services.NewAuthController(
[]byte(cmd.JWTSecret),
cmd.AccessTokenTTL,
cmd.RefrestTokenTTL,
@@ -97,12 +103,12 @@ func (cmd *Server) Run(ctx context.Context) error {
reflection.Register(grpcServer)
}
tokenCtrl := &controllers.TokenController{
tokenCtrl := &services.TokenController{
DB: db,
Redis: rdb,
}
accountCtrl := &controllers.AccountController{
accountCtrl := &services.AccountController{
HashCost: cmd.HashCost,
DB: db,
DevMode: cmd.DevMode,
@@ -120,6 +126,33 @@ func (cmd *Server) Run(ctx context.Context) error {
tokens.RegisterPublicTokensServiceServer(grpcServer, v1.NewPublicTokensServer(tokenCtrl, authController))
accounts.RegisterPublicAccountsServiceServer(grpcServer, v1.NewPublicAccountServer(accountCtrl, authController))
healthcheck := health.NewServer()
healthgrpc.RegisterHealthServer(grpcServer, healthcheck)
go func() {
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
for range ticker.C {
// Example checks
dbOK := checkDatabase(db)
redisOK := checkRedis(rdb)
status := healthpb.HealthCheckResponse_SERVING
if !dbOK || !redisOK {
status = healthpb.HealthCheckResponse_NOT_SERVING
}
healthcheck.SetServingStatus(
"",
status,
)
}
}()
info := grpcServer.GetServiceInfo()
tokenCtrl.SetGRPCInfo(info)
tokenCtrl.SetRules()
@@ -144,6 +177,10 @@ func selectorRequireAuth(ctx context.Context, callMeta interceptors.CallMeta) bo
return false
}
if serviceName == "Health" {
return false
}
if strings.Contains(serviceName, "ServerReflection") {
return false
}
@@ -154,3 +191,33 @@ func selectorRequireAuth(ctx context.Context, callMeta interceptors.CallMeta) bo
return true
}
func checkDatabase(db *sql.DB) bool {
ctx, cancel := context.WithTimeout(
context.Background(),
2*time.Second,
)
defer cancel()
// Fast connectivity check
if err := db.PingContext(ctx); err != nil {
return false
}
return true
}
func checkRedis(rdb *redis.Client) bool {
ctx, cancel := context.WithTimeout(
context.Background(),
2*time.Second,
)
defer cancel()
err := rdb.Ping(ctx).Err()
if err != nil {
return false
}
return true
}

2
go.mod
View File

@@ -43,7 +43,7 @@ require (
)
require (
gitea.badhouseplants.net/softplayer/softplayer-go-proto v0.0.0-20260517200845-22f1b32dfad9
gitea.badhouseplants.net/softplayer/softplayer-go-proto v0.0.0-20260518175130-4b27db42e21e
github.com/golang/protobuf v1.5.4
golang.org/x/net v0.51.0 // indirect
golang.org/x/sys v0.42.0 // indirect

4
go.sum
View File

@@ -2,8 +2,8 @@ cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMT
cloud.google.com/go v0.121.6 h1:waZiuajrI28iAf40cWgycWNgaXPO06dupuS+sgibK6c=
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
gitea.badhouseplants.net/softplayer/softplayer-go-proto v0.0.0-20260517200845-22f1b32dfad9 h1:RP73i+SOZYmc61F+gZjO/rvUlpPP0Za4MLJKAgS+1YI=
gitea.badhouseplants.net/softplayer/softplayer-go-proto v0.0.0-20260517200845-22f1b32dfad9/go.mod h1:EcQEZ3NN06b3UmKxiRnQnXDDjQ9kmJgoQQBAS+fpRQw=
gitea.badhouseplants.net/softplayer/softplayer-go-proto v0.0.0-20260518175130-4b27db42e21e h1:9pt3cvnJ3slg0lDjCwgVbvS/kI1JlKuNUFxdlYCGWF0=
gitea.badhouseplants.net/softplayer/softplayer-go-proto v0.0.0-20260518175130-4b27db42e21e/go.mod h1:EcQEZ3NN06b3UmKxiRnQnXDDjQ9kmJgoQQBAS+fpRQw=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=

View File

@@ -4,7 +4,7 @@ import (
"context"
"errors"
"gitea.badhouseplants.net/softplayer/softplayer-backend/internal/controllers"
"gitea.badhouseplants.net/softplayer/softplayer-backend/internal/services"
accounts "gitea.badhouseplants.net/softplayer/softplayer-go-proto/pkg/accounts/v1"
"github.com/golang/protobuf/ptypes/empty"
"google.golang.org/grpc"
@@ -15,8 +15,8 @@ import (
)
func NewAccountServer(
accountsCtrl *controllers.AccountController,
authorizationCtrl *controllers.AuthController,
accountsCtrl *services.AccountController,
authorizationCtrl *services.AuthController,
) *AccountsServer {
return &AccountsServer{
accountsCtrl: accountsCtrl,
@@ -26,23 +26,23 @@ func NewAccountServer(
type AccountsServer struct {
accounts.UnimplementedAccountsServiceServer
accountsCtrl *controllers.AccountController
authorizationCtrl *controllers.AuthController
accountsCtrl *services.AccountController
authorizationCtrl *services.AuthController
}
func (srv *AccountsServer) RefreshToken(ctx context.Context, in *empty.Empty) (*empty.Empty, error) {
claims, err := controllers.ClaimsFromContext(ctx)
claims, err := services.ClaimsFromContext(ctx)
if err != nil {
return nil, status.Error(codes.Aborted, "Context is invalid")
}
if claims.TokenType != controllers.TokenTypeRefresh {
if claims.TokenType != services.TokenTypeRefresh {
return nil, status.Error(codes.Unauthenticated, "Invalid token")
}
session, err := srv.authorizationCtrl.GetSession(ctx, claims.TokenID)
if err != nil {
if errors.Is(err, controllers.ErrSessionNotFound) {
if errors.Is(err, services.ErrSessionNotFound) {
return nil, status.Error(codes.Unauthenticated, "Session doesn't exists")
}
return nil, status.Error(codes.Internal, "Somethings is broken on our side")
@@ -52,24 +52,24 @@ func (srv *AccountsServer) RefreshToken(ctx context.Context, in *empty.Empty) (*
return nil, status.Error(codes.Unauthenticated, "Invalid session")
}
accessToken, _, err := srv.authorizationCtrl.GenerateToken(&controllers.JWTData{
accessToken, _, err := srv.authorizationCtrl.GenerateToken(&services.JWTData{
UserID: claims.UserID,
TokenType: controllers.TokenTypeAccess,
TokenAud: controllers.TokenAudWeb,
TokenType: services.TokenTypeAccess,
TokenAud: services.TokenAudWeb,
})
if err != nil {
return nil, status.Error(codes.Aborted, "Couldn't generate an access token")
}
refreshToken, tokenID, err := srv.authorizationCtrl.GenerateToken(&controllers.JWTData{
refreshToken, tokenID, err := srv.authorizationCtrl.GenerateToken(&services.JWTData{
UserID: claims.UserID,
TokenType: controllers.TokenTypeRefresh,
TokenAud: controllers.TokenAudWeb,
TokenType: services.TokenTypeRefresh,
TokenAud: services.TokenAudWeb,
})
if err != nil {
return nil, status.Error(codes.Aborted, "Couldn't generate an access token")
}
newSession := &controllers.Session{UserID: session.UserID}
newSession := &services.Session{UserID: session.UserID}
if err := srv.authorizationCtrl.SaveSession(ctx, tokenID, newSession); err != nil {
return nil, status.Error(codes.Aborted, "Couldn't store session")

View File

@@ -0,0 +1,38 @@
package v1
import (
"context"
projects "gitea.badhouseplants.net/softplayer/softplayer-go-proto/pkg/projects/v1"
"google.golang.org/grpc"
)
func NewProjectsServer() *ProjectsServer {
return &ProjectsServer{}
}
// var _ projects.ProjectsServiceServer = (*ProjectsServer)(nil)
type ProjectsServer struct {
projects.UnimplementedProjectsServiceServer
}
// CreateProject implements [v1.ProjectsServiceServer].
func (p *ProjectsServer) CreateProject(context.Context, *projects.CreateProjectRequest) (*projects.CreateProjectResponse, error) {
panic("unimplemented")
}
// GetProject implements [v1.ProjectsServiceServer].
func (p *ProjectsServer) GetProject(context.Context, *projects.GetProjectRequest) (*projects.GetProjectResponse, error) {
panic("unimplemented")
}
// ListProjects implements [v1.ProjectsServiceServer].
func (p *ProjectsServer) ListProjects(*projects.ListProjectsRequest, grpc.ServerStreamingServer[projects.ListProjectsResponse]) error {
panic("unimplemented")
}
// UpdateProject implements [v1.ProjectsServiceServer].
func (p *ProjectsServer) UpdateProject(context.Context, *projects.UpdateProjectRequest) (*projects.UpdateProjectResponse, error) {
panic("unimplemented")
}

View File

@@ -3,7 +3,7 @@ package v1
import (
"context"
"gitea.badhouseplants.net/softplayer/softplayer-backend/internal/controllers"
"gitea.badhouseplants.net/softplayer/softplayer-backend/internal/services"
accounts "gitea.badhouseplants.net/softplayer/softplayer-go-proto/pkg/accounts/v1"
"github.com/golang/protobuf/ptypes/empty"
"google.golang.org/grpc"
@@ -14,8 +14,8 @@ import (
)
func NewPublicAccountServer(
accountsCtrl *controllers.AccountController,
authorizationCtrl *controllers.AuthController,
accountsCtrl *services.AccountController,
authorizationCtrl *services.AuthController,
) *PublicAccountService {
return &PublicAccountService{
accountsCtrl: accountsCtrl,
@@ -25,8 +25,8 @@ func NewPublicAccountServer(
type PublicAccountService struct {
accounts.UnimplementedPublicAccountsServiceServer
accountsCtrl *controllers.AccountController
authorizationCtrl *controllers.AuthController
accountsCtrl *services.AccountController
authorizationCtrl *services.AuthController
}
func (a *PublicAccountService) SignIn(ctx context.Context, in *accounts.SignInRequest) (*empty.Empty, error) {
@@ -34,25 +34,25 @@ func (a *PublicAccountService) SignIn(ctx context.Context, in *accounts.SignInRe
if err != nil {
return nil, status.Error(codes.Aborted, "Couldn't create a user")
}
accessToken, _, err := a.authorizationCtrl.GenerateToken(&controllers.JWTData{
accessToken, _, err := a.authorizationCtrl.GenerateToken(&services.JWTData{
UserID: id,
TokenType: controllers.TokenTypeAccess,
TokenAud: controllers.TokenAudWeb,
TokenType: services.TokenTypeAccess,
TokenAud: services.TokenAudWeb,
})
if err != nil {
return nil, status.Error(codes.Aborted, "Couldn't generate an access token")
}
refreshToken, tokenID, err := a.authorizationCtrl.GenerateToken(&controllers.JWTData{
refreshToken, tokenID, err := a.authorizationCtrl.GenerateToken(&services.JWTData{
UserID: id,
TokenType: controllers.TokenTypeRefresh,
TokenAud: controllers.TokenAudWeb,
TokenType: services.TokenTypeRefresh,
TokenAud: services.TokenAudWeb,
})
if err != nil {
return nil, status.Error(codes.Aborted, "Couldn't generate an access token")
}
session := &controllers.Session{UserID: id}
session := &services.Session{UserID: id}
if err := a.authorizationCtrl.SaveSession(ctx, tokenID, session); err != nil {
return nil, status.Error(codes.Aborted, "Couldn't store session")
@@ -69,34 +69,36 @@ func (a *PublicAccountService) SignIn(ctx context.Context, in *accounts.SignInRe
// Create a new account in Softplayer
func (a *PublicAccountService) SignUp(ctx context.Context, in *accounts.SignUpRequest) (*empty.Empty, error) {
data := &controllers.AccountData{
data := &services.AccountData{
Password: in.GetPassword(),
Email: in.GetEmail(),
Name: in.PersonalData.GetName(),
Surname: in.PersonalData.GetSurname(),
}
id, err := a.accountsCtrl.Create(ctx, data)
if err != nil {
return nil, status.Error(codes.Aborted, "Couldn't create a user")
}
accessToken, _, err := a.authorizationCtrl.GenerateToken(&controllers.JWTData{
accessToken, _, err := a.authorizationCtrl.GenerateToken(&services.JWTData{
UserID: id,
TokenType: controllers.TokenTypeAccess,
TokenAud: controllers.TokenAudWeb,
TokenType: services.TokenTypeAccess,
TokenAud: services.TokenAudWeb,
})
if err != nil {
return nil, status.Error(codes.Aborted, "Couldn't generate an access token")
}
refreshToken, tokenID, err := a.authorizationCtrl.GenerateToken(&controllers.JWTData{
refreshToken, tokenID, err := a.authorizationCtrl.GenerateToken(&services.JWTData{
UserID: id,
TokenType: controllers.TokenTypeRefresh,
TokenAud: controllers.TokenAudWeb,
TokenType: services.TokenTypeRefresh,
TokenAud: services.TokenAudWeb,
})
if err != nil {
return nil, status.Error(codes.Aborted, "Couldn't generate an access token")
}
session := &controllers.Session{UserID: id}
session := &services.Session{UserID: id}
if err := a.authorizationCtrl.SaveSession(ctx, tokenID, session); err != nil {
return nil, status.Error(codes.Aborted, "Couldn't store session")

View File

@@ -4,7 +4,7 @@ import (
"context"
"errors"
"gitea.badhouseplants.net/softplayer/softplayer-backend/internal/controllers"
"gitea.badhouseplants.net/softplayer/softplayer-backend/internal/services"
tokens "gitea.badhouseplants.net/softplayer/softplayer-go-proto/pkg/tokens/v1"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
@@ -17,13 +17,13 @@ import (
type PublicTokensServer struct {
tokens.UnimplementedPublicTokensServiceServer
tokenCtrl *controllers.TokenController
authorizationCtrl *controllers.AuthController
tokenCtrl *services.TokenController
authorizationCtrl *services.AuthController
}
func NewPublicTokensServer(
tokenCtrl *controllers.TokenController,
authorizationCtrl *controllers.AuthController,
tokenCtrl *services.TokenController,
authorizationCtrl *services.AuthController,
) *PublicTokensServer {
return &PublicTokensServer{
tokenCtrl: tokenCtrl,
@@ -34,19 +34,19 @@ func NewPublicTokensServer(
func (srv *PublicTokensServer) AuthenticateWithToken(ctx context.Context, in *tokens.AuthenticateWithTokenRequest) (*emptypb.Empty, error) {
tokenAuthRes, err := srv.tokenCtrl.AuthenticateWithToken(ctx, in.TokenValue.Token)
if err != nil {
if errors.Is(err, controllers.ErrBadToken) {
if errors.Is(err, services.ErrBadToken) {
return nil, status.Error(codes.Unauthenticated, "Token is not valid")
}
if errors.Is(err, controllers.ErrServerError) {
if errors.Is(err, services.ErrServerError) {
return nil, status.Error(codes.Internal, "Something is broken on our side")
}
return nil, status.Error(codes.Aborted, "Couldn't authorize")
}
jwtData := &controllers.JWTData{
jwtData := &services.JWTData{
UserID: tokenAuthRes.UserID,
TokenType: controllers.TokenTypeAccess,
TokenAud: controllers.TokenAudToken,
TokenType: services.TokenTypeAccess,
TokenAud: services.TokenAudToken,
Scope: tokenAuthRes.Scope,
}
accessToken, _, err := srv.authorizationCtrl.GenerateToken(jwtData)

View File

@@ -4,7 +4,7 @@ import (
"context"
"errors"
"gitea.badhouseplants.net/softplayer/softplayer-backend/internal/controllers"
"gitea.badhouseplants.net/softplayer/softplayer-backend/internal/services"
tokens "gitea.badhouseplants.net/softplayer/softplayer-go-proto/pkg/tokens/v1"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
@@ -18,13 +18,13 @@ import (
// TokensServer implements the Token Service
type TokensServer struct {
tokens.UnimplementedTokensServiceServer
tokenCtrl *controllers.TokenController
authorizationCtrl *controllers.AuthController
tokenCtrl *services.TokenController
authorizationCtrl *services.AuthController
}
func NewTokensServer(
tokenCtrl *controllers.TokenController,
authorizationCtrl *controllers.AuthController,
tokenCtrl *services.TokenController,
authorizationCtrl *services.AuthController,
) *TokensServer {
return &TokensServer{
tokenCtrl: tokenCtrl,
@@ -34,7 +34,7 @@ func NewTokensServer(
// CreateToken implements [v1.TokensServiceServer].
func (srv *TokensServer) CreateToken(ctx context.Context, in *tokens.CreateTokenRequest) (*tokens.CreateTokenResponse, error) {
claims, err := controllers.ClaimsFromContext(ctx)
claims, err := services.ClaimsFromContext(ctx)
if err != nil {
return nil, status.Error(codes.Aborted, "Context is invalid")
}
@@ -50,7 +50,7 @@ func (srv *TokensServer) CreateToken(ctx context.Context, in *tokens.CreateToken
permissions[service] = methods.GetMethods()
}
tokenData := &controllers.TokenData{
tokenData := &services.TokenData{
Name: in.TokenMetadata.GetName(),
UserID: claims.UserID,
ExpiresAt: in.TokenMetadata.ExpiresAt.AsTime(),
@@ -59,7 +59,7 @@ func (srv *TokensServer) CreateToken(ctx context.Context, in *tokens.CreateToken
token, tokenID, err := srv.tokenCtrl.Create(ctx, tokenData)
if err != nil {
if errors.Is(err, controllers.ErrServerError) {
if errors.Is(err, services.ErrServerError) {
return nil, status.Error(codes.Internal, "Something is broken on our side")
}
return nil, status.Error(codes.Aborted, "Couldn't create a token")
@@ -75,7 +75,7 @@ func (srv *TokensServer) CreateToken(ctx context.Context, in *tokens.CreateToken
// ForceTokenExpiration implements [v1.TokensServiceServer].
func (srv *TokensServer) ForceTokenExpiration(ctx context.Context, in *tokens.ForceTokenExpirationRequest) (*emptypb.Empty, error) {
claims, err := controllers.ClaimsFromContext(ctx)
claims, err := services.ClaimsFromContext(ctx)
if err != nil {
return nil, status.Error(codes.Aborted, "Context is invalid")
}
@@ -84,14 +84,14 @@ func (srv *TokensServer) ForceTokenExpiration(ctx context.Context, in *tokens.Fo
}
if err := srv.tokenCtrl.VerifyTokenOwner(ctx, claims.UserID, in.TokenUuid.Uuid); err != nil {
if errors.Is(err, controllers.ErrServerError) {
if errors.Is(err, services.ErrServerError) {
return nil, status.Error(codes.Internal, "Something is broken on our side")
}
return nil, status.Error(codes.Aborted, "User is now allowed to manipulate this token")
}
if err := srv.tokenCtrl.ForceExpiration(ctx, in.TokenUuid.GetUuid()); err != nil {
if errors.Is(err, controllers.ErrServerError) {
if errors.Is(err, services.ErrServerError) {
return nil, status.Error(codes.Internal, "Something is broken on our side")
}
return nil, status.Error(codes.Aborted, "Couldn't create a token")
@@ -101,7 +101,7 @@ func (srv *TokensServer) ForceTokenExpiration(ctx context.Context, in *tokens.Fo
// GetToken implements [v1.TokensServiceServer].
func (srv *TokensServer) GetToken(ctx context.Context, in *tokens.GetTokenRequest) (*tokens.GetTokenResponse, error) {
claims, err := controllers.ClaimsFromContext(ctx)
claims, err := services.ClaimsFromContext(ctx)
if err != nil {
return nil, status.Error(codes.Aborted, "Context is invalid")
}
@@ -109,7 +109,7 @@ func (srv *TokensServer) GetToken(ctx context.Context, in *tokens.GetTokenReques
return nil, status.Error(codes.Aborted, "Context is invalid")
}
if err := srv.tokenCtrl.VerifyTokenOwner(ctx, claims.UserID, in.TokenUuid.Uuid); err != nil {
if errors.Is(err, controllers.ErrServerError) {
if errors.Is(err, services.ErrServerError) {
return nil, status.Error(codes.Internal, "Something is broken on our side")
}
return nil, status.Error(codes.Aborted, "User is now allowed to manipulate this token")
@@ -117,7 +117,7 @@ func (srv *TokensServer) GetToken(ctx context.Context, in *tokens.GetTokenReques
token, err := srv.tokenCtrl.Get(ctx, in.TokenUuid.Uuid, claims.UserID)
if err != nil {
if errors.Is(err, controllers.ErrServerError) {
if errors.Is(err, services.ErrServerError) {
return nil, status.Error(codes.Internal, "Something is broken on our side")
}
return nil, status.Error(codes.Aborted, "Couldn't list tokens")
@@ -141,7 +141,7 @@ func (srv *TokensServer) GetToken(ctx context.Context, in *tokens.GetTokenReques
// ListTokens implements [v1.TokensServiceServer].
func (srv *TokensServer) ListTokens(in *emptypb.Empty, stream grpc.ServerStreamingServer[tokens.ListTokensResponse]) error {
claims, err := controllers.ClaimsFromContext(stream.Context())
claims, err := services.ClaimsFromContext(stream.Context())
if err != nil {
return status.Error(codes.Aborted, "Context is invalid")
}
@@ -151,7 +151,7 @@ func (srv *TokensServer) ListTokens(in *emptypb.Empty, stream grpc.ServerStreami
tokensRes, err := srv.tokenCtrl.List(stream.Context(), claims.UserID)
if err != nil {
if errors.Is(err, controllers.ErrServerError) {
if errors.Is(err, services.ErrServerError) {
return status.Error(codes.Internal, "Something is broken on our side")
}
return status.Error(codes.Aborted, "Couldn't list tokens")
@@ -179,7 +179,7 @@ func (srv *TokensServer) ListTokens(in *emptypb.Empty, stream grpc.ServerStreami
// RegenerateToken implements [v1.TokensServiceServer].
func (srv *TokensServer) RegenerateToken(ctx context.Context, in *tokens.RegenerateTokenRequest) (*tokens.RegenerateTokenResponse, error) {
claims, err := controllers.ClaimsFromContext(ctx)
claims, err := services.ClaimsFromContext(ctx)
if err != nil {
return nil, status.Error(codes.Aborted, "Context is invalid")
}
@@ -187,7 +187,7 @@ func (srv *TokensServer) RegenerateToken(ctx context.Context, in *tokens.Regener
return nil, status.Error(codes.Aborted, "Context is invalid")
}
if err := srv.tokenCtrl.VerifyTokenOwner(ctx, claims.UserID, in.TokenUuid.Uuid); err != nil {
if errors.Is(err, controllers.ErrServerError) {
if errors.Is(err, services.ErrServerError) {
return nil, status.Error(codes.Internal, "Something is broken on our side")
}
return nil, status.Error(codes.Aborted, "User is now allowed to manipulate this token")
@@ -195,7 +195,7 @@ func (srv *TokensServer) RegenerateToken(ctx context.Context, in *tokens.Regener
tokenVal, err := srv.tokenCtrl.Regenerate(ctx, in.TokenUuid.GetUuid())
if err != nil {
if errors.Is(err, controllers.ErrServerError) {
if errors.Is(err, services.ErrServerError) {
return nil, status.Error(codes.Internal, "Something is broken on our side")
}
return nil, status.Error(codes.Aborted, "Couldn't list tokens")
@@ -209,7 +209,7 @@ func (srv *TokensServer) RegenerateToken(ctx context.Context, in *tokens.Regener
// UpdateToken implements [v1.TokensServiceServer].
func (srv *TokensServer) UpdateToken(ctx context.Context, in *tokens.UpdateTokenRequest) (*tokens.UpdateTokenResponse, error) {
claims, err := controllers.ClaimsFromContext(ctx)
claims, err := services.ClaimsFromContext(ctx)
if err != nil {
return nil, status.Error(codes.Aborted, "Context is invalid")
}
@@ -218,7 +218,7 @@ func (srv *TokensServer) UpdateToken(ctx context.Context, in *tokens.UpdateToken
}
if err := srv.tokenCtrl.VerifyTokenOwner(ctx, claims.UserID, in.TokenUuid.Uuid); err != nil {
if errors.Is(err, controllers.ErrServerError) {
if errors.Is(err, services.ErrServerError) {
return nil, status.Error(codes.Internal, "Something is broken on our side")
}
return nil, status.Error(codes.Aborted, "User is now allowed to manipulate this token")
@@ -231,12 +231,12 @@ func (srv *TokensServer) UpdateToken(ctx context.Context, in *tokens.UpdateToken
for service, methods := range in.TokenPermissions.Permissions {
permissions[service] = methods.GetMethods()
}
tokenData := &controllers.TokenData{
tokenData := &services.TokenData{
Name: in.TokenMetadata.Name,
Scopes: permissions,
}
if err := srv.tokenCtrl.Update(ctx, tokenData); err != nil {
if errors.Is(err, controllers.ErrServerError) {
if errors.Is(err, services.ErrServerError) {
return nil, status.Error(codes.Internal, "Something is broken on our side")
}
return nil, status.Error(codes.Aborted, "Couldn't list tokens")

View File

@@ -10,21 +10,24 @@ import (
)
var (
ErrAlreadyExists = errors.New("entry already exists")
ErrNotFound = errors.New("entry not found")
ErrAlreadyExists = errors.New("entry already exists")
ErrCheckNotPassed = errors.New("sql checks not passed")
ErrNotFound = errors.New("entry not found")
)
type AccountData struct {
UUID string
Email string
PasswordHash string
Name string
Surname string
}
// CreateAccount adds a new account to a database
func CreateAccount(ctx context.Context, db *sql.DB, account *AccountData) error {
query := "INSERT INTO accounts (uuid, email, password_hash) VALUES ($1, $2, $3)"
query := "INSERT INTO accounts (uuid, email, password_hash, name, surname) VALUES ($1, $2, $3, $4, $5)"
if _, err := db.ExecContext(ctx, query, account.UUID, account.Email, account.PasswordHash); err != nil {
if _, err := db.ExecContext(ctx, query, account.UUID, account.Email, account.PasswordHash, account.Name, account.Surname); err != nil {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
if pgErr.Code == pgerrcode.UniqueViolation {

View File

@@ -0,0 +1,169 @@
package repository
import (
"context"
"database/sql"
"errors"
"time"
"github.com/jackc/pgerrcode"
"github.com/jackc/pgx/v5/pgconn"
)
type ProjectData struct {
UUID string
Name string
Slug string
Description string
CreatedBy string
CreatedAt time.Time
ClosedAt sql.NullTime
Blocked bool
UpdatedAt time.Time
UpdatedBy string
}
// CreateProject adds a new projects to the database
func CreateProject(ctx context.Context, db *sql.DB, data *ProjectData) error {
tx, err := StartTransaction(ctx, db)
if err != nil {
return err
}
defer tx.Rollback()
queryProject := `
INSERT INTO projects
(uuid, name, slug, description, owner_user_id, billing_account_id, created_by, created_at, updated_by, updated_at)
VALUES
($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
`
if _, err := tx.ExecContext(ctx, queryProject,
data.UUID, data.Name, data.Slug, data.Description, data.CreatedBy,
data.CreatedBy, data.CreatedBy, data.CreatedAt, data.CreatedBy, data.CreatedAt); err != nil {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
switch pgErr.Code {
case pgerrcode.UniqueViolation:
return ErrAlreadyExists
case pgerrcode.CheckViolation:
return ErrCheckNotPassed
}
return err
}
return err
}
// When a project is created, we need to insert the default owner project membership
queryMembership := `
INSERT INTO project_membership
(project_uuid, user_uuid, role, status, invited_by, joined_at)
VALUES
($1, $2, $3, $4, $5, $6);
`
if _, err := tx.ExecContext(ctx, queryMembership,
data.UUID, data.CreatedBy, "owner", "active", data.CreatedBy, data.CreatedAt,
); err != nil {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
switch pgErr.Code {
case pgerrcode.UniqueViolation:
return ErrAlreadyExists
case pgerrcode.CheckViolation:
return ErrCheckNotPassed
}
return err
}
return err
}
if err := tx.Commit(); err != nil {
return err
}
return nil
}
// GetProjectByID returns a project from the database
func GetProjectByID(ctx context.Context, db *sql.DB, projectID string) (*ProjectData, error) {
data := &ProjectData{}
query := `
SELECT uuid, name, slug, description, owner_user_id, closed_at, created_at
FROM projects
WHERE uuid=$1`
if err := db.QueryRowContext(ctx, query, projectID).Scan(
&data.UUID,
&data.Name,
&data.Slug,
&data.Description,
&data.CreatedBy,
&data.ClosedAt,
&data.CreatedAt,
); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
return data, nil
}
// UpdateProject change editable project data
func UpdateProject(ctx context.Context, db *sql.DB, data *ProjectData) error {
query := `
UPDATE projects
SET
name = $2,
description = $3,
updated_at = $4,
updated_by = $5
WHERE uuid = $1;`
if _, err := db.Query(query, data.UUID, data.Name, data.Description, data.UpdatedAt, data.UpdatedBy); err != nil {
return err
}
return nil
}
// ListProjects get all projects that are available for the user from the database
func ListProjects(ctx context.Context, db *sql.DB, userID string) ([]*ProjectData, error) {
query := `
SELECT p.uuid, p.name
FROM projects p
JOIN project_membership pm ON pm.project_uuid = p.uuid
WHERE pm.user_uuid = $1`
rows, err := db.QueryContext(ctx, query, userID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
result := []*ProjectData{}
for rows.Next() {
pd := &ProjectData{}
err := rows.Scan(&pd.UUID, &pd.Name)
if err != nil {
return nil, err
}
result = append(result, pd)
}
return result, nil
}
// GetProjectOwner should return an owner if a project
func GetProjectOwner(ctx context.Context, db *sql.DB, projectID string) (userID string, err error) {
query := `
SELECT user_uuid
FROM project_membership
WHERE project_uuid = $1 AND role = 'owner'`
if err := db.QueryRowContext(ctx, query, projectID).Scan(
&userID,
); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return "", ErrNotFound
}
return "", err
}
return
}

View File

@@ -0,0 +1,246 @@
package repository_test
import (
"context"
"database/sql"
"fmt"
"testing"
"time"
"gitea.badhouseplants.net/softplayer/softplayer-backend/internal/repository"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
)
func newTestUniqueEmail(prefix string) string {
if prefix == "" {
prefix = "test"
}
return fmt.Sprintf(
"%s-%d-%s@example.com",
prefix,
time.Now().UnixMilli(),
uuid.NewString(),
)
}
func accountForProject(ctx context.Context) (string, error) {
account := &repository.AccountData{
UUID: uuid.NewString(),
Email: newTestUniqueEmail("projects"),
PasswordHash: "dummy",
Name: "John",
Surname: "Doe",
}
if err := repository.CreateAccount(ctx, newTestDBConnection(ctx), account); err != nil {
return "", err
}
return account.UUID, nil
}
func TestIntegrationProjectsCreate_Success(t *testing.T) {
createdBy, err := accountForProject(t.Context())
assert.NoError(t, err)
project := &repository.ProjectData{
UUID: uuid.NewString(),
Name: "test-1",
Slug: uuid.NewString(),
Description: "Test Project Number 1",
CreatedBy: createdBy,
CreatedAt: time.Now(),
ClosedAt: sql.NullTime{Time: time.Now()},
Blocked: false,
UpdatedAt: time.Now(),
UpdatedBy: createdBy,
}
assert.NoError(t, repository.CreateProject(t.Context(), newTestDBConnection(t.Context()), project))
}
func TestIntegrationProjectsCreate_CheckFailed(t *testing.T) {
createdBy, err := accountForProject(t.Context())
assert.NoError(t, err)
project := &repository.ProjectData{
UUID: uuid.NewString(),
Name: "test-2",
Slug: "test_2",
Description: "Test Project Number 1",
CreatedBy: createdBy,
CreatedAt: time.Now(),
ClosedAt: sql.NullTime{Time: time.Now()},
Blocked: false,
UpdatedAt: time.Now(),
UpdatedBy: createdBy,
}
assert.Error(t, repository.CreateProject(t.Context(), newTestDBConnection(t.Context()), project), repository.ErrCheckNotPassed)
}
func TestIntegrationProjectsCreate_AlreadyExistsFail(t *testing.T) {
createdBy, err := accountForProject(t.Context())
assert.NoError(t, err)
project := &repository.ProjectData{
UUID: uuid.NewString(),
Name: "test-3",
Slug: uuid.NewString(),
Description: "Test Project Number 1",
CreatedBy: createdBy,
CreatedAt: time.Now(),
ClosedAt: sql.NullTime{Time: time.Now()},
Blocked: false,
UpdatedAt: time.Now(),
UpdatedBy: createdBy,
}
assert.NoError(t, repository.CreateProject(t.Context(), newTestDBConnection(t.Context()), project))
assert.Error(t, repository.CreateProject(t.Context(), newTestDBConnection(t.Context()), project), repository.ErrAlreadyExists)
}
func TestIntegrationProjectsCreateAndGet_Success(t *testing.T) {
createdBy, err := accountForProject(t.Context())
assert.NoError(t, err)
project := &repository.ProjectData{
UUID: uuid.NewString(),
Name: "test-4",
Slug: uuid.NewString(),
Description: "Test Project Number 1",
CreatedBy: createdBy,
CreatedAt: time.Now(),
Blocked: false,
UpdatedAt: time.Now(),
UpdatedBy: createdBy,
}
assert.NoError(t, repository.CreateProject(t.Context(), newTestDBConnection(t.Context()), project))
data, err := repository.GetProjectByID(t.Context(), newTestDBConnection(t.Context()), project.UUID)
assert.NoError(t, err)
assert.Equal(t, project.Name, data.Name)
assert.Equal(t, project.Slug, data.Slug)
assert.Equal(t, project.Description, data.Description)
assert.Equal(t, project.CreatedBy, data.CreatedBy)
assert.Equal(t, project.ClosedAt.Time.Truncate(time.Second), data.ClosedAt.Time.Truncate(time.Second))
assert.Equal(t, project.CreatedAt.Truncate(time.Second), data.CreatedAt.Truncate(time.Second))
}
func TestIntegrationProjectsCreateAndGet_NotFound(t *testing.T) {
data, err := repository.GetProjectByID(t.Context(), newTestDBConnection(t.Context()), uuid.NewString())
assert.ErrorIs(t, err, repository.ErrNotFound)
assert.Nil(t, data)
}
func TestIntegrationProjectsCreateUpdateAndGet_Success(t *testing.T) {
createdBy, err := accountForProject(t.Context())
assert.NoError(t, err)
project := &repository.ProjectData{
UUID: uuid.NewString(),
Name: "test-5",
Slug: uuid.NewString(),
Description: "Test Project Number 1",
CreatedBy: createdBy,
CreatedAt: time.Now(),
Blocked: false,
UpdatedAt: time.Now(),
UpdatedBy: createdBy,
}
assert.NoError(t, repository.CreateProject(t.Context(), newTestDBConnection(t.Context()), project))
data, err := repository.GetProjectByID(t.Context(), newTestDBConnection(t.Context()), project.UUID)
assert.NoError(t, err)
assert.Equal(t, project.Name, data.Name)
assert.Equal(t, project.Slug, data.Slug)
assert.Equal(t, project.Description, data.Description)
assert.Equal(t, project.CreatedBy, data.CreatedBy)
assert.Equal(t, project.ClosedAt.Time.Truncate(time.Second), data.ClosedAt.Time.Truncate(time.Second))
assert.Equal(t, project.CreatedAt.Truncate(time.Second), data.CreatedAt.Truncate(time.Second))
project.UpdatedBy = uuid.NewString()
project.UpdatedAt = time.Now()
project.Description = "Updated description"
project.Name = "update name"
assert.NoError(t, repository.UpdateProject(t.Context(), newTestDBConnection(t.Context()), project))
data, err = repository.GetProjectByID(t.Context(), newTestDBConnection(t.Context()), project.UUID)
assert.NoError(t, err)
assert.Equal(t, project.Name, data.Name)
assert.Equal(t, project.Slug, data.Slug)
assert.Equal(t, project.Description, data.Description)
}
func TestIntegrationProjectsCreateAndGetOwner_Success(t *testing.T) {
createdBy, err := accountForProject(t.Context())
assert.NoError(t, err)
project := &repository.ProjectData{
UUID: uuid.NewString(),
Name: "test-6",
Slug: uuid.NewString(),
Description: "Test Project Number 1",
CreatedBy: createdBy,
CreatedAt: time.Now(),
ClosedAt: sql.NullTime{Time: time.Now()},
Blocked: false,
UpdatedAt: time.Now(),
UpdatedBy: createdBy,
}
assert.NoError(t, repository.CreateProject(t.Context(), newTestDBConnection(t.Context()), project))
userID, err := repository.GetProjectOwner(t.Context(), newTestDBConnection(t.Context()), project.UUID)
assert.NoError(t, err)
assert.Equal(t, project.CreatedBy, userID)
}
func TestIntegrationListProjectsWorkflow(t *testing.T) {
createdBy1, err := accountForProject(t.Context())
assert.NoError(t, err)
project1 := &repository.ProjectData{
UUID: uuid.NewString(),
Name: "List 1",
Slug: uuid.NewString(),
Description: "Test Project Number 1",
CreatedBy: createdBy1,
CreatedAt: time.Now(),
ClosedAt: sql.NullTime{Time: time.Now()},
Blocked: false,
UpdatedAt: time.Now(),
UpdatedBy: createdBy1,
}
assert.NoError(t, repository.CreateProject(t.Context(), newTestDBConnection(t.Context()), project1))
project2 := &repository.ProjectData{
UUID: uuid.NewString(),
Name: "List 2",
Slug: uuid.NewString(),
Description: "Test Project Number 1",
CreatedBy: createdBy1,
CreatedAt: time.Now(),
ClosedAt: sql.NullTime{Time: time.Now()},
Blocked: false,
UpdatedAt: time.Now(),
UpdatedBy: createdBy1,
}
assert.NoError(t, repository.CreateProject(t.Context(), newTestDBConnection(t.Context()), project2))
createdBy2, err := accountForProject(t.Context())
assert.NoError(t, err)
project3 := &repository.ProjectData{
UUID: uuid.NewString(),
Name: "List 3",
Slug: uuid.NewString(),
Description: "Test Project Number 1",
CreatedBy: createdBy2,
CreatedAt: time.Now(),
ClosedAt: sql.NullTime{Time: time.Now()},
Blocked: false,
UpdatedAt: time.Now(),
UpdatedBy: createdBy2,
}
assert.NoError(t, repository.CreateProject(t.Context(), newTestDBConnection(t.Context()), project3))
projects, err := repository.ListProjects(t.Context(), newTestDBConnection(t.Context()), createdBy1)
assert.NoError(t, err)
assert.Len(t, projects, 2)
projects, err = repository.ListProjects(t.Context(), newTestDBConnection(t.Context()), createdBy2)
assert.NoError(t, err)
assert.Len(t, projects, 1)
}

View File

@@ -0,0 +1,22 @@
package repository
import (
"context"
"database/sql"
)
func StartTransaction(ctx context.Context, db *sql.DB) (*sql.Tx, error) {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return nil, err
}
return tx, nil
}
func CommitTransaction(ctx context.Context, tx *sql.Tx) error {
return tx.Commit()
}
func RollBackTransaction(ctx context.Context, tx *sql.Tx) error {
return tx.Rollback()
}

View File

@@ -0,0 +1,95 @@
package repository_test
import (
"context"
"database/sql"
"os"
"testing"
"gitea.badhouseplants.net/softplayer/softplayer-backend/internal/helpers/postgres"
"gitea.badhouseplants.net/softplayer/softplayer-backend/internal/repository"
"github.com/stretchr/testify/assert"
)
func newTestDBConnection(ctx context.Context) *sql.DB {
connStr, ok := os.LookupEnv("SOFTPLAYER_DB_CONNECTION_STRING")
if !ok {
// Default connection string
connStr = "postgres://softplayer:qwertyu9@localhost:30432/softplayer?sslmode=disable"
}
db, err := postgres.Open(ctx, connStr)
if err != nil {
panic(err)
}
return db
}
func TestTransactionFlow_Success(t *testing.T) {
db := newTestDBConnection(t.Context())
tx, err := repository.StartTransaction(t.Context(), db)
assert.NoError(t, err)
query1 := "CREATE TABLE transaction_test_success (id SERIAL PRIMARY KEY, data VARCHAR (50) NOT NULL)"
_, err = db.Exec(query1)
assert.NoError(t, err)
data1 := "data1"
data2 := "data2"
query2 := "INSERT INTO transaction_test_success (data) VALUES ($1)"
_, err = tx.Exec(query2, data1)
assert.NoError(t, err)
_, err = tx.Exec(query2, data2)
assert.NoError(t, err)
assert.NoError(t, repository.CommitTransaction(t.Context(), tx))
exists := false
queryTest := `SELECT EXISTS(
SELECT 1 FROM transaction_test_success WHERE data = $1
)`
err = db.QueryRow(queryTest, data1).Scan(&exists)
assert.NoError(t, err)
assert.True(t, exists)
err = db.QueryRow(queryTest, data2).Scan(&exists)
assert.NoError(t, err)
assert.True(t, exists)
queryCleanup := "DROP TABLE transaction_test_success"
_, err = db.Exec(queryCleanup)
assert.NoError(t, err)
}
func TestTransactionFlow_Rollback(t *testing.T) {
db := newTestDBConnection(t.Context())
tx, err := repository.StartTransaction(t.Context(), db)
assert.NoError(t, err)
query1 := "CREATE TABLE transaction_test_rollback (id SERIAL PRIMARY KEY, data VARCHAR (50) NOT NULL)"
_, err = db.Exec(query1)
assert.NoError(t, err)
data1 := "data1"
data2 := "data2"
query2 := "INSERT INTO transaction_test_rollback (data) VALUES ($1)"
_, err = tx.Exec(query2, data1)
assert.NoError(t, err)
_, err = tx.Exec(query2, data2)
assert.NoError(t, err)
assert.NoError(t, repository.RollBackTransaction(t.Context(), tx))
exists := false
queryTest := `SELECT EXISTS(
SELECT 1 FROM transaction_test_rollback WHERE data = $1
)`
err = db.QueryRow(queryTest, data1).Scan(&exists)
assert.NoError(t, err)
assert.False(t, exists)
err = db.QueryRow(queryTest, data2).Scan(&exists)
assert.NoError(t, err)
assert.False(t, exists)
queryCleanup := "DROP TABLE transaction_test_rollback"
_, err = db.Exec(queryCleanup)
assert.NoError(t, err)
}

View File

@@ -1,4 +1,4 @@
package controllers
package services
import (
"context"
@@ -41,6 +41,8 @@ type AccountData struct {
Password string
Email string
UUID string
Name string
Surname string
}
// Create a new account
@@ -59,6 +61,8 @@ func (c *AccountController) Create(ctx context.Context, data *AccountData) (stri
UUID: data.UUID,
Email: data.Email,
PasswordHash: passwordHash,
Name: data.Name,
Surname: data.Surname,
}
if err := repository.CreateAccount(ctx, c.DB, queryData); err != nil {

View File

@@ -1,4 +1,4 @@
package controllers_test
package services_test
import (
"context"
@@ -8,8 +8,8 @@ import (
"testing"
"time"
"gitea.badhouseplants.net/softplayer/softplayer-backend/internal/controllers"
"gitea.badhouseplants.net/softplayer/softplayer-backend/internal/helpers/postgres"
"gitea.badhouseplants.net/softplayer/softplayer-backend/internal/services"
"github.com/google/uuid"
_ "github.com/jackc/pgx/v5/stdlib"
"github.com/redis/go-redis/v9"
@@ -40,8 +40,8 @@ func newTestRedisConnection() *redis.Client {
})
}
func newTestAccountController(ctx context.Context) *controllers.AccountController {
return &controllers.AccountController{
func newTestAccountController(ctx context.Context) *services.AccountController {
return &services.AccountController{
DB: newTestDBConnection(ctx),
Redis: newTestRedisConnection(),
DevMode: true,
@@ -67,9 +67,11 @@ func newTestUniqueEmail(prefix string) string {
func TestIntegrationAccountCreate_Success(t *testing.T) {
ctrl := newTestAccountController(t.Context())
accountData := &controllers.AccountData{
accountData := &services.AccountData{
Password: "qwertyu9",
Email: newTestUniqueEmail("accounts"),
Surname: "Doe",
Name: "John",
}
id, err := ctrl.Create(t.Context(), accountData)
assert.NoError(t, err)
@@ -79,9 +81,11 @@ func TestIntegrationAccountCreate_Success(t *testing.T) {
func TestIntegrationAccountCreate_ExistingAccountErr(t *testing.T) {
ctrl := newTestAccountController(t.Context())
email := newTestUniqueEmail("accounts")
accountData := &controllers.AccountData{
accountData := &services.AccountData{
Password: "qwertyu9",
Email: email,
Surname: "Doe",
Name: "John",
}
id, err := ctrl.Create(t.Context(), accountData)
assert.NoError(t, err)
@@ -90,13 +94,13 @@ func TestIntegrationAccountCreate_ExistingAccountErr(t *testing.T) {
id, err = ctrl.Create(t.Context(), accountData)
assert.Empty(t, id)
assert.Error(t, err)
assert.ErrorIs(t, err, controllers.ErrEmailUsed)
assert.ErrorIs(t, err, services.ErrEmailUsed)
}
func TestIntegrationAccountLogin_Success(t *testing.T) {
ctrl := newTestAccountController(t.Context())
email := newTestUniqueEmail("accounts")
accountData := &controllers.AccountData{
accountData := &services.AccountData{
Password: "qwertyu9",
Email: email,
}
@@ -113,7 +117,7 @@ func TestIntegrationAccountLogin_Success(t *testing.T) {
func TestIntegrationAccountLogin_WrongPassword(t *testing.T) {
ctrl := newTestAccountController(t.Context())
email := newTestUniqueEmail("accounts")
accountData := &controllers.AccountData{
accountData := &services.AccountData{
Password: "qwertyu9",
Email: email,
}
@@ -125,13 +129,13 @@ func TestIntegrationAccountLogin_WrongPassword(t *testing.T) {
id, err = ctrl.Login(t.Context(), accountData.Email, "Wrong Password")
assert.Empty(t, id)
assert.Error(t, err)
assert.ErrorIs(t, err, controllers.ErrWrongPassword)
assert.ErrorIs(t, err, services.ErrWrongPassword)
}
func TestIntegrationAccountLogin_WrongEmail(t *testing.T) {
ctrl := newTestAccountController(t.Context())
email := newTestUniqueEmail("accounts")
accountData := &controllers.AccountData{
accountData := &services.AccountData{
Password: "qwertyu9",
Email: email,
}
@@ -143,5 +147,5 @@ func TestIntegrationAccountLogin_WrongEmail(t *testing.T) {
id, err = ctrl.Login(t.Context(), "some@email.com", "Wrong Password")
assert.Empty(t, id)
assert.Error(t, err)
assert.ErrorIs(t, err, controllers.ErrUserNotFound)
assert.ErrorIs(t, err, services.ErrUserNotFound)
}

View File

@@ -1,4 +1,4 @@
package controllers
package services
import (
"context"

View File

@@ -1,10 +1,10 @@
package controllers_test
package services_test
import (
"testing"
"time"
"gitea.badhouseplants.net/softplayer/softplayer-backend/internal/controllers"
"gitea.badhouseplants.net/softplayer/softplayer-backend/internal/services"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
)
@@ -16,24 +16,24 @@ var (
)
func TestGenerateInvalidTokenType(t *testing.T) {
data := &controllers.JWTData{
data := &services.JWTData{
UserID: testUserID,
TokenType: "invalid_type",
}
authCtrl := controllers.NewAuthController([]byte("test"), testAccessTTL, testRefreshTTL, nil)
authCtrl := services.NewAuthController([]byte("test"), testAccessTTL, testRefreshTTL, nil)
token, _, err := authCtrl.GenerateToken(data)
assert.Equal(t, "", token)
assert.ErrorIs(t, controllers.ErrUnknownTokenType, err)
assert.ErrorIs(t, services.ErrUnknownTokenType, err)
}
func TestGenerateValidateAccessToken(t *testing.T) {
data := &controllers.JWTData{
data := &services.JWTData{
UserID: testUserID,
TokenType: controllers.TokenTypeAccess,
TokenType: services.TokenTypeAccess,
}
authCtrl := controllers.NewAuthController([]byte("test"), testAccessTTL, testRefreshTTL, nil)
authCtrl := services.NewAuthController([]byte("test"), testAccessTTL, testRefreshTTL, nil)
now := time.Now()
token, _, err := authCtrl.GenerateToken(data)
assert.NoError(t, err)
@@ -43,18 +43,18 @@ func TestGenerateValidateAccessToken(t *testing.T) {
assert.NoError(t, err)
assert.Equal(t, testUserID, claims.UserID)
assert.NotEmpty(t, claims.TokenID)
assert.Equal(t, controllers.TokenTypeAccess, claims.TokenType)
assert.Equal(t, services.TokenTypeAccess, claims.TokenType)
assert.Equal(t, now.Add(testAccessTTL).Unix(), claims.ExpiresAt.Unix())
assert.Equal(t, now.Unix(), claims.IssuedAt.Unix())
assert.Equal(t, now.Unix(), claims.NotBefore.Unix())
}
func TestGenerateValidateRefreshToken(t *testing.T) {
data := &controllers.JWTData{
data := &services.JWTData{
UserID: testUserID,
TokenType: controllers.TokenTypeRefresh,
TokenType: services.TokenTypeRefresh,
}
authCtrl := controllers.NewAuthController([]byte("test"), testAccessTTL, testRefreshTTL, nil)
authCtrl := services.NewAuthController([]byte("test"), testAccessTTL, testRefreshTTL, nil)
now := time.Now()
token, _, err := authCtrl.GenerateToken(data)
assert.NoError(t, err)
@@ -64,7 +64,7 @@ func TestGenerateValidateRefreshToken(t *testing.T) {
assert.NoError(t, err)
assert.Equal(t, testUserID, claims.UserID)
assert.NotEmpty(t, claims.TokenID)
assert.Equal(t, controllers.TokenTypeRefresh, claims.TokenType)
assert.Equal(t, services.TokenTypeRefresh, claims.TokenType)
assert.Equal(t, now.Add(testRefreshTTL).Unix(), claims.ExpiresAt.Unix())
assert.Equal(t, now.Unix(), claims.IssuedAt.Unix())
assert.Equal(t, now.Unix(), claims.NotBefore.Unix())

View File

@@ -0,0 +1,71 @@
package services
import (
"context"
"database/sql"
"errors"
"time"
"gitea.badhouseplants.net/softplayer/softplayer-backend/internal/helpers/logger"
"gitea.badhouseplants.net/softplayer/softplayer-backend/internal/repository"
"github.com/google/uuid"
)
type ProjectsController struct {
DB *sql.DB
}
type ProjectData struct {
UUID string
Name string
Slug string
Description string
}
var (
ErrProjectExists = errors.New("project exists")
ErrInvalidProject = errors.New("invalid project data")
)
// Create a new project
func (ctrl *ProjectsController) Create(ctx context.Context, data *ProjectData, userID string) (id string, err error) {
id = uuid.NewString()
log := logger.FromContext(ctx).WithValues("id", id)
log.V(2).Info("Creating a project")
queryData := &repository.ProjectData{
UUID: id,
Name: data.Name,
Slug: data.Slug,
Description: data.Description,
CreatedBy: userID,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
UpdatedBy: userID,
}
err = repository.CreateProject(ctx, ctrl.DB, queryData)
if err != nil {
if errors.Is(err, repository.ErrAlreadyExists) {
return "", ErrProjectExists
} else if errors.Is(err, repository.ErrCheckNotPassed) {
return "", ErrInvalidProject
}
log.Error(err, "Couldn't create a project")
return "", ErrServerError
}
return id, nil
}
// Update an existing project
func (ctrl *ProjectsController) Update(ctx context.Context, data *ProjectData) error {
return nil
}
// Get an existing project by ID
func (ctrl *ProjectsController) Get(ctx context.Context, projectID string) (data *ProjectData, err error) {
return nil, nil
}
// List projects available for a user
func (ctrl *ProjectsController) List(ctx context.Context) (data []*ProjectData, err error) {
return nil, nil
}

View File

@@ -0,0 +1,38 @@
package services_test
import (
"context"
"testing"
"gitea.badhouseplants.net/softplayer/softplayer-backend/internal/services"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
)
func newTestProjectsController(ctx context.Context) *services.ProjectsController {
return &services.ProjectsController{
DB: newTestDBConnection(ctx),
}
}
func TestIntegrationCreateProject_Success(t *testing.T) {
ctrlAccount := newTestAccountController(t.Context())
accountData := &services.AccountData{
Password: "qwertyu9",
Email: newTestUniqueEmail("projects"),
}
userID, err := ctrlAccount.Create(t.Context(), accountData)
assert.NoError(t, err)
ctrlProjects := newTestProjectsController(t.Context())
projectData := &services.ProjectData{
UUID: uuid.NewString(),
Name: "Test project",
Slug: uuid.NewString(),
Description: "Test project",
}
projectID, err := ctrlProjects.Create(t.Context(), projectData, userID)
assert.NoError(t, err)
assert.NotEmpty(t, projectID)
}

View File

@@ -1,6 +1,6 @@
// Package controllers for token management
// This a token controller, that implements the logic around tokens
package controllers
package services
import (
"context"

View File

@@ -1,4 +1,4 @@
package controllers_test
package services_test
import (
"context"
@@ -6,13 +6,13 @@ import (
"testing"
"time"
"gitea.badhouseplants.net/softplayer/softplayer-backend/internal/controllers"
"gitea.badhouseplants.net/softplayer/softplayer-backend/internal/services"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
)
func newTestTokensController(ctx context.Context) *controllers.TokenController {
return &controllers.TokenController{
func newTestTokensController(ctx context.Context) *services.TokenController {
return &services.TokenController{
DB: newTestDBConnection(ctx),
Redis: newTestRedisConnection(),
}
@@ -21,14 +21,14 @@ func newTestTokensController(ctx context.Context) *controllers.TokenController {
func TestIntegrationCreateToken_Success(t *testing.T) {
// Create a user for the token
ctrlAccount := newTestAccountController(t.Context())
accountData := &controllers.AccountData{
accountData := &services.AccountData{
Password: "qwertyu9",
Email: newTestUniqueEmail("accounts"),
}
id, err := ctrlAccount.Create(t.Context(), accountData)
assert.NoError(t, err)
tokenData := &controllers.TokenData{
tokenData := &services.TokenData{
Name: "Test Token",
UserID: id,
ExpiresAt: time.Now().Add(time.Second * 5),
@@ -45,7 +45,7 @@ func TestIntegrationCreateToken_Success(t *testing.T) {
}
func TestIntegrationCreateToken_UserNotExist(t *testing.T) {
tokenData := &controllers.TokenData{
tokenData := &services.TokenData{
Name: "Test Token",
UserID: uuid.NewString(),
ExpiresAt: time.Now().Add(time.Second * 5),
@@ -58,7 +58,7 @@ func TestIntegrationCreateToken_UserNotExist(t *testing.T) {
tokenVal, tokenID, err := ctrl.Create(t.Context(), tokenData)
assert.Error(t, err)
assert.ErrorIs(t, err, controllers.ErrUserNotFound)
assert.ErrorIs(t, err, services.ErrUserNotFound)
assert.Empty(t, tokenID)
assert.Empty(t, tokenVal)
}
@@ -66,7 +66,7 @@ func TestIntegrationCreateToken_UserNotExist(t *testing.T) {
func TestIntegrationGetToken_Success(t *testing.T) {
// Create a user for the token
ctrlAccount := newTestAccountController(t.Context())
accountData := &controllers.AccountData{
accountData := &services.AccountData{
Password: "qwertyu9",
Email: newTestUniqueEmail("accounts"),
}
@@ -75,7 +75,7 @@ func TestIntegrationGetToken_Success(t *testing.T) {
userID, err := ctrlAccount.Create(t.Context(), accountData)
assert.NoError(t, err)
tokenData := &controllers.TokenData{
tokenData := &services.TokenData{
Name: "Test Token",
UserID: userID,
ExpiresAt: time.Now().Add(time.Second * 5),
@@ -101,7 +101,7 @@ func TestIntegrationGetToken_NotExists(t *testing.T) {
ctrl := newTestTokensController(t.Context())
token, err := ctrl.Get(t.Context(), uuid.NewString(), uuid.NewString())
assert.Error(t, err)
assert.ErrorIs(t, err, controllers.ErrTokenNotFound)
assert.ErrorIs(t, err, services.ErrTokenNotFound)
assert.Empty(t, token)
}
@@ -109,14 +109,14 @@ func TestIntegrationGetToken_WrongRequest(t *testing.T) {
ctrl := newTestTokensController(t.Context())
token, err := ctrl.Get(t.Context(), "test", "test")
assert.Error(t, err)
assert.ErrorIs(t, err, controllers.ErrServerError)
assert.ErrorIs(t, err, services.ErrServerError)
assert.Empty(t, token)
}
func TestIntegrationVerifyTokenOwner_Success(t *testing.T) {
// Create a user for the token
ctrlAccount := newTestAccountController(t.Context())
accountData := &controllers.AccountData{
accountData := &services.AccountData{
Password: "qwertyu9",
Email: newTestUniqueEmail("accounts"),
}
@@ -124,7 +124,7 @@ func TestIntegrationVerifyTokenOwner_Success(t *testing.T) {
userID, err := ctrlAccount.Create(t.Context(), accountData)
assert.NoError(t, err)
tokenData := &controllers.TokenData{
tokenData := &services.TokenData{
Name: "Test Token",
UserID: userID,
ExpiresAt: time.Now().Add(time.Second * 5),
@@ -142,12 +142,12 @@ func TestIntegrationVerifyTokenOwner_Success(t *testing.T) {
func TestIntegrationVerifyTokenOwner_WrongOwner(t *testing.T) {
// Create a user for the token
ctrlAccount := newTestAccountController(t.Context())
accountData := &controllers.AccountData{
accountData := &services.AccountData{
Password: "qwertyu9",
Email: newTestUniqueEmail("accounts"),
}
secondAccountData := &controllers.AccountData{
secondAccountData := &services.AccountData{
Password: "qwertyu9",
Email: newTestUniqueEmail("accounts"),
}
@@ -157,7 +157,7 @@ func TestIntegrationVerifyTokenOwner_WrongOwner(t *testing.T) {
secondUserID, err := ctrlAccount.Create(t.Context(), secondAccountData)
assert.NoError(t, err)
tokenData := &controllers.TokenData{
tokenData := &services.TokenData{
Name: "Test Token",
UserID: userID,
ExpiresAt: time.Now().Add(time.Second * 5),
@@ -169,13 +169,13 @@ func TestIntegrationVerifyTokenOwner_WrongOwner(t *testing.T) {
ctrl := newTestTokensController(t.Context())
_, tokenID, err := ctrl.Create(t.Context(), tokenData)
assert.NoError(t, err)
assert.ErrorIs(t, ctrl.VerifyTokenOwner(t.Context(), secondUserID, tokenID), controllers.ErrUserTokenMismatch)
assert.ErrorIs(t, ctrl.VerifyTokenOwner(t.Context(), secondUserID, tokenID), services.ErrUserTokenMismatch)
}
func TestIntegrationForceExpiration_Success(t *testing.T) {
// Create a user for the token
ctrlAccount := newTestAccountController(t.Context())
accountData := &controllers.AccountData{
accountData := &services.AccountData{
Password: "qwertyu9",
Email: newTestUniqueEmail("accounts"),
}
@@ -183,7 +183,7 @@ func TestIntegrationForceExpiration_Success(t *testing.T) {
userID, err := ctrlAccount.Create(t.Context(), accountData)
assert.NoError(t, err)
tokenData := &controllers.TokenData{
tokenData := &services.TokenData{
Name: "Test Token",
UserID: userID,
ExpiresAt: time.Now().Add(time.Second * 5),
@@ -205,7 +205,7 @@ func TestIntegrationForceExpiration_Success(t *testing.T) {
func TestIntegrationRegenerateToken_Success(t *testing.T) {
// Create a user for the token
ctrlAccount := newTestAccountController(t.Context())
accountData := &controllers.AccountData{
accountData := &services.AccountData{
Password: "qwertyu9",
Email: newTestUniqueEmail("accounts"),
}
@@ -214,7 +214,7 @@ func TestIntegrationRegenerateToken_Success(t *testing.T) {
userID, err := ctrlAccount.Create(t.Context(), accountData)
assert.NoError(t, err)
tokenData := &controllers.TokenData{
tokenData := &services.TokenData{
Name: "Test Token",
UserID: userID,
ExpiresAt: time.Now().Add(time.Second * 5),
@@ -255,7 +255,7 @@ func TestIntegrationRegenerateToken_Success(t *testing.T) {
func TestIntegrationListTokens_Success(t *testing.T) {
// Create a user for the token
ctrlAccount := newTestAccountController(t.Context())
accountData := &controllers.AccountData{
accountData := &services.AccountData{
Password: "qwertyu9",
Email: newTestUniqueEmail("accounts"),
}
@@ -263,7 +263,7 @@ func TestIntegrationListTokens_Success(t *testing.T) {
userID, err := ctrlAccount.Create(t.Context(), accountData)
assert.NoError(t, err)
tokenDataOne := &controllers.TokenData{
tokenDataOne := &services.TokenData{
Name: "Test Token",
UserID: userID,
ExpiresAt: time.Now().Add(time.Second * 5),
@@ -271,7 +271,7 @@ func TestIntegrationListTokens_Success(t *testing.T) {
"Test": {"test", "test2"},
},
}
tokenDataTwo := &controllers.TokenData{
tokenDataTwo := &services.TokenData{
Name: "Test Token again",
UserID: userID,
ExpiresAt: time.Now().Add(time.Second * 5),
@@ -293,14 +293,14 @@ func TestIntegrationListTokens_Success(t *testing.T) {
func TestIntegrationAuthenticateWithToken_Success(t *testing.T) {
ctrlAccount := newTestAccountController(t.Context())
accountData := &controllers.AccountData{
accountData := &services.AccountData{
Password: "qwertyu9",
Email: newTestUniqueEmail("accounts"),
}
id, err := ctrlAccount.Create(t.Context(), accountData)
assert.NoError(t, err)
tokenData := &controllers.TokenData{
tokenData := &services.TokenData{
Name: "Test Token",
UserID: id,
ExpiresAt: time.Now().Add(time.Second * 5),
@@ -330,19 +330,19 @@ func TestIntegrationAuthenticateWithToken_UnknownToken(t *testing.T) {
auth, err := ctrl.AuthenticateWithToken(t.Context(), "dummy")
assert.Error(t, err)
assert.Nil(t, auth)
assert.ErrorIs(t, err, controllers.ErrTokenNotFound)
assert.ErrorIs(t, err, services.ErrTokenNotFound)
}
func TestIntegrationAuthenticateWithToken_Expired(t *testing.T) {
ctrlAccount := newTestAccountController(t.Context())
accountData := &controllers.AccountData{
accountData := &services.AccountData{
Password: "qwertyu9",
Email: newTestUniqueEmail("accounts"),
}
id, err := ctrlAccount.Create(t.Context(), accountData)
assert.NoError(t, err)
tokenData := &controllers.TokenData{
tokenData := &services.TokenData{
Name: "Test Token",
UserID: id,
ExpiresAt: time.Now().Add(time.Second * 5),
@@ -365,19 +365,19 @@ func TestIntegrationAuthenticateWithToken_Expired(t *testing.T) {
auth, err = ctrl.AuthenticateWithToken(t.Context(), tokenVal)
assert.Error(t, err)
assert.Nil(t, auth)
assert.ErrorIs(t, err, controllers.ErrBadToken)
assert.ErrorIs(t, err, services.ErrBadToken)
}
func TestIntegrationAuthenticateWithToken_Revoked(t *testing.T) {
ctrlAccount := newTestAccountController(t.Context())
accountData := &controllers.AccountData{
accountData := &services.AccountData{
Password: "qwertyu9",
Email: newTestUniqueEmail("accounts"),
}
id, err := ctrlAccount.Create(t.Context(), accountData)
assert.NoError(t, err)
tokenData := &controllers.TokenData{
tokenData := &services.TokenData{
Name: "Test Token",
UserID: id,
ExpiresAt: time.Now().Add(time.Second * 5),
@@ -399,5 +399,5 @@ func TestIntegrationAuthenticateWithToken_Revoked(t *testing.T) {
auth, err = ctrl.AuthenticateWithToken(t.Context(), tokenVal)
assert.Error(t, err)
assert.Nil(t, auth)
assert.ErrorIs(t, err, controllers.ErrBadToken)
assert.ErrorIs(t, err, services.ErrBadToken)
}

View File

@@ -0,0 +1,3 @@
ALTER TABLE accounts
DROP COLUMN name,
DROP COLUMN surname;

View File

@@ -0,0 +1,14 @@
-- Up migration (safe for existing data)
ALTER TABLE accounts
ADD COLUMN name TEXT,
ADD COLUMN surname TEXT;
UPDATE accounts
SET
name = 'John',
surname = 'Doe'
WHERE name IS NULL OR surname IS NULL;
ALTER TABLE accounts
ALTER COLUMN name SET NOT NULL,
ALTER COLUMN surname SET NOT NULL;

View File

@@ -0,0 +1 @@
DROP TABLE projects;

View File

@@ -0,0 +1,16 @@
CREATE TABLE projects (
uuid UUID PRIMARY KEY,
name VARCHAR(120) NOT NULL,
slug VARCHAR(120) NOT NULL UNIQUE
CHECK (
slug ~ '^[a-z0-9]+(?:-[a-z0-9]+)*$'
),
description TEXT,
owner_user_id UUID NOT NULL,
closed_at TIMESTAMPTZ NULL,
billing_account_id UUID NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_by UUID NOT NULL,
updated_by UUID NOT NULL
);

View File

@@ -0,0 +1,7 @@
DROP INDEX idx_project_membership_project;
DROP INDEX idx_project_membership_user;
DROP TABLE project_membership;
DROP TYPE membership_status CASCADE;
DROP TYPE project_role CASCADE;

View File

@@ -0,0 +1,35 @@
CREATE TYPE project_role AS ENUM (
'member',
'admin',
'owner'
);
CREATE TYPE membership_status AS ENUM (
'invited',
'active',
'suspended'
);
CREATE TABLE project_membership (
project_uuid UUID NOT NULL
REFERENCES projects(uuid)
ON DELETE CASCADE,
user_uuid UUID NOT NULL
REFERENCES accounts(uuid)
ON DELETE CASCADE,
role project_role NOT NULL,
status membership_status NOT NULL DEFAULT 'active',
invited_by UUID NULL
REFERENCES accounts(uuid),
joined_at TIMESTAMP NULL,
PRIMARY KEY (project_uuid, user_uuid)
);
CREATE INDEX idx_project_membership_user
ON project_membership(user_uuid);
CREATE INDEX idx_project_membership_project
ON project_membership(project_uuid);