From 25ebb4805e5ad0c94d7da2d1100ddb9452e1fab4 Mon Sep 17 00:00:00 2001 From: Nikolai Rodionov Date: Mon, 18 May 2026 22:15:28 +0200 Subject: [PATCH 01/12] WIP: Start adding projects Signed-off-by: Nikolai Rodionov --- api/v1/projects.go | 38 ++++++++++++++++++ api/v1/public_accounts.go | 2 + go.mod | 2 +- go.sum | 4 +- internal/controllers/accounts.go | 4 ++ internal/controllers/accounts_test.go | 4 ++ internal/controllers/projects.go | 27 +++++++++++++ internal/repository/accounts.go | 6 ++- internal/repository/projects.go | 40 +++++++++++++++++++ ...0260518174322_users_personal_data.down.sql | 3 ++ .../20260518174322_users_personal_data.up.sql | 14 +++++++ .../20260518193048_projects_init.down.sql | 1 + .../20260518193048_projects_init.up.sql | 16 ++++++++ ...518193057_project_membership_init.down.sql | 0 ...60518193057_project_membership_init.up.sql | 0 15 files changed, 156 insertions(+), 5 deletions(-) create mode 100644 api/v1/projects.go create mode 100644 internal/controllers/projects.go create mode 100644 internal/repository/projects.go create mode 100644 migrations/20260518174322_users_personal_data.down.sql create mode 100644 migrations/20260518174322_users_personal_data.up.sql create mode 100644 migrations/20260518193048_projects_init.down.sql create mode 100644 migrations/20260518193048_projects_init.up.sql create mode 100644 migrations/20260518193057_project_membership_init.down.sql create mode 100644 migrations/20260518193057_project_membership_init.up.sql diff --git a/api/v1/projects.go b/api/v1/projects.go new file mode 100644 index 0000000..58c6a3d --- /dev/null +++ b/api/v1/projects.go @@ -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") +} diff --git a/api/v1/public_accounts.go b/api/v1/public_accounts.go index 8ec563e..cc12f93 100644 --- a/api/v1/public_accounts.go +++ b/api/v1/public_accounts.go @@ -72,6 +72,8 @@ func (a *PublicAccountService) SignUp(ctx context.Context, in *accounts.SignUpRe data := &controllers.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 { diff --git a/go.mod b/go.mod index d9ad547..a66d838 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 6e15f29..c5f69bb 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/controllers/accounts.go b/internal/controllers/accounts.go index d6ad0fb..acb5010 100644 --- a/internal/controllers/accounts.go +++ b/internal/controllers/accounts.go @@ -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 { diff --git a/internal/controllers/accounts_test.go b/internal/controllers/accounts_test.go index d87a2b1..22e3f58 100644 --- a/internal/controllers/accounts_test.go +++ b/internal/controllers/accounts_test.go @@ -70,6 +70,8 @@ func TestIntegrationAccountCreate_Success(t *testing.T) { accountData := &controllers.AccountData{ Password: "qwertyu9", Email: newTestUniqueEmail("accounts"), + Surname: "Doe", + Name: "John", } id, err := ctrl.Create(t.Context(), accountData) assert.NoError(t, err) @@ -82,6 +84,8 @@ func TestIntegrationAccountCreate_ExistingAccountErr(t *testing.T) { accountData := &controllers.AccountData{ Password: "qwertyu9", Email: email, + Surname: "Doe", + Name: "John", } id, err := ctrl.Create(t.Context(), accountData) assert.NoError(t, err) diff --git a/internal/controllers/projects.go b/internal/controllers/projects.go new file mode 100644 index 0000000..95fce54 --- /dev/null +++ b/internal/controllers/projects.go @@ -0,0 +1,27 @@ +package controllers + +import "context" + +type ProjectsController struct{} + +type ProjectData struct{} + +// Create a new project +func (ctrl *ProjectsController) Create(ctx context.Context, data *ProjectData) (id string, err error) { + return "", 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 +} diff --git a/internal/repository/accounts.go b/internal/repository/accounts.go index e396e5b..b966e7c 100644 --- a/internal/repository/accounts.go +++ b/internal/repository/accounts.go @@ -18,13 +18,15 @@ 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 { diff --git a/internal/repository/projects.go b/internal/repository/projects.go new file mode 100644 index 0000000..85bac95 --- /dev/null +++ b/internal/repository/projects.go @@ -0,0 +1,40 @@ +package repository + +import ( + "context" + "database/sql" + "time" +) + +type ProjectData struct { + UUID string + Name string + Slug string + Description string + OwnerID string + CreatedAt string + ArchivedAt time.Time + 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 { + return nil +} + +// GetProjectByID returns a project from the database +func GetProjectByID(ctx context.Context, db *sql.DB, projectID string) (data *ProjectData, err error) { + return nil, nil +} + +// UpdateProject change editable project data +func UpdateProject(ctx context.Context, db *sql.DB, data *ProjectData) error { + return nil +} + +// ListProjects get all projects that are available for the user from the database +func ListProjects(ctx context.Context, db *sql.DB) ([]*ProjectData, error) { + return nil, nil +} diff --git a/migrations/20260518174322_users_personal_data.down.sql b/migrations/20260518174322_users_personal_data.down.sql new file mode 100644 index 0000000..5e63184 --- /dev/null +++ b/migrations/20260518174322_users_personal_data.down.sql @@ -0,0 +1,3 @@ +ALTER TABLE accounts +DROP COLUMN name, +DROP COLUMN surname; diff --git a/migrations/20260518174322_users_personal_data.up.sql b/migrations/20260518174322_users_personal_data.up.sql new file mode 100644 index 0000000..c6c8ef6 --- /dev/null +++ b/migrations/20260518174322_users_personal_data.up.sql @@ -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; diff --git a/migrations/20260518193048_projects_init.down.sql b/migrations/20260518193048_projects_init.down.sql new file mode 100644 index 0000000..2e30072 --- /dev/null +++ b/migrations/20260518193048_projects_init.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXIST prohects; diff --git a/migrations/20260518193048_projects_init.up.sql b/migrations/20260518193048_projects_init.up.sql new file mode 100644 index 0000000..60d8bf0 --- /dev/null +++ b/migrations/20260518193048_projects_init.up.sql @@ -0,0 +1,16 @@ +CREATE TABLE projects ( + id UUID PRIMARY KEY, + name VARCHAR(120) NOT NULL, + slug VARCHAR(120) NOT NULL UNIQUE, + description TEXT, + owner_user_id UUID NOT NULL, + archived_at TIMESTAMP NULL, + closed_at TIMESTAMP NULL, + billing_account_id UUID NULL, + max_clusters INTEGER DEFAULT 10, + max_nodes INTEGER DEFAULT 100, + created_at TIMESTAMP NOT NULL DEFAULT now(), + updated_at TIMESTAMP NOT NULL DEFAULT now(), + created_by UUID NOT NULL, + updated_by UUID NOT NULL, +); diff --git a/migrations/20260518193057_project_membership_init.down.sql b/migrations/20260518193057_project_membership_init.down.sql new file mode 100644 index 0000000..e69de29 diff --git a/migrations/20260518193057_project_membership_init.up.sql b/migrations/20260518193057_project_membership_init.up.sql new file mode 100644 index 0000000..e69de29 -- 2.49.1 From c8d3112fbd175b1994f88d1a8eb287e559bb47a2 Mon Sep 17 00:00:00 2001 From: Nikolai Rodionov Date: Tue, 19 May 2026 10:55:53 +0200 Subject: [PATCH 02/12] Restructure the projec and start adding projects Signed-off-by: Nikolai Rodionov --- cmd/server.go | 10 +-- {api => internal/api}/v1/accounts.go | 30 ++++---- {api => internal/api}/v1/projects.go | 0 {api => internal/api}/v1/public_accounts.go | 40 +++++------ {api => internal/api}/v1/public_tokens.go | 20 +++--- {api => internal/api}/v1/test.go | 0 {api => internal/api}/v1/tokens.go | 46 ++++++------- internal/repository/projects.go | 6 +- internal/repository/projects_test.go | 26 +++++++ internal/repository/transaction.go | 22 ++++++ .../{controllers => services}/accounts.go | 2 +- .../accounts_test.go | 24 +++---- .../authorization.go | 2 +- .../authorization_test.go | 26 +++---- .../{controllers => services}/projects.go | 12 +++- internal/{controllers => services}/tokens.go | 2 +- .../{controllers => services}/tokens_test.go | 68 +++++++++---------- 17 files changed, 196 insertions(+), 140 deletions(-) rename {api => internal/api}/v1/accounts.go (74%) rename {api => internal/api}/v1/projects.go (100%) rename {api => internal/api}/v1/public_accounts.go (76%) rename {api => internal/api}/v1/public_tokens.go (79%) rename {api => internal/api}/v1/test.go (100%) rename {api => internal/api}/v1/tokens.go (88%) create mode 100644 internal/repository/projects_test.go create mode 100644 internal/repository/transaction.go rename internal/{controllers => services}/accounts.go (99%) rename internal/{controllers => services}/accounts_test.go (86%) rename internal/{controllers => services}/authorization.go (99%) rename internal/{controllers => services}/authorization_test.go (68%) rename internal/{controllers => services}/projects.go (72%) rename internal/{controllers => services}/tokens.go (99%) rename internal/{controllers => services}/tokens_test.go (88%) diff --git a/cmd/server.go b/cmd/server.go index afda1e7..193d840 100644 --- a/cmd/server.go +++ b/cmd/server.go @@ -7,10 +7,10 @@ import ( "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" @@ -68,7 +68,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 +97,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, diff --git a/api/v1/accounts.go b/internal/api/v1/accounts.go similarity index 74% rename from api/v1/accounts.go rename to internal/api/v1/accounts.go index 0d7a603..27aaa1b 100644 --- a/api/v1/accounts.go +++ b/internal/api/v1/accounts.go @@ -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") diff --git a/api/v1/projects.go b/internal/api/v1/projects.go similarity index 100% rename from api/v1/projects.go rename to internal/api/v1/projects.go diff --git a/api/v1/public_accounts.go b/internal/api/v1/public_accounts.go similarity index 76% rename from api/v1/public_accounts.go rename to internal/api/v1/public_accounts.go index cc12f93..4726ba2 100644 --- a/api/v1/public_accounts.go +++ b/internal/api/v1/public_accounts.go @@ -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,7 +69,7 @@ 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(), @@ -80,25 +80,25 @@ func (a *PublicAccountService) SignUp(ctx context.Context, in *accounts.SignUpRe 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") diff --git a/api/v1/public_tokens.go b/internal/api/v1/public_tokens.go similarity index 79% rename from api/v1/public_tokens.go rename to internal/api/v1/public_tokens.go index 30e777f..da267cd 100644 --- a/api/v1/public_tokens.go +++ b/internal/api/v1/public_tokens.go @@ -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) diff --git a/api/v1/test.go b/internal/api/v1/test.go similarity index 100% rename from api/v1/test.go rename to internal/api/v1/test.go diff --git a/api/v1/tokens.go b/internal/api/v1/tokens.go similarity index 88% rename from api/v1/tokens.go rename to internal/api/v1/tokens.go index 812abd9..7a75dfd 100644 --- a/api/v1/tokens.go +++ b/internal/api/v1/tokens.go @@ -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") diff --git a/internal/repository/projects.go b/internal/repository/projects.go index 85bac95..35563fd 100644 --- a/internal/repository/projects.go +++ b/internal/repository/projects.go @@ -11,9 +11,9 @@ type ProjectData struct { Name string Slug string Description string - OwnerID string - CreatedAt string - ArchivedAt time.Time + CreatedBy string + CreatedAt time.Time + ArchivedAt sql.NullTime Blocked bool UpdatedAt time.Time UpdatedBy string diff --git a/internal/repository/projects_test.go b/internal/repository/projects_test.go new file mode 100644 index 0000000..534f39f --- /dev/null +++ b/internal/repository/projects_test.go @@ -0,0 +1,26 @@ +package repository_test + +import ( + "database/sql" + "testing" + "time" + + "gitea.badhouseplants.net/softplayer/softplayer-backend/internal/repository" + "github.com/google/uuid" +) + +func TestProjectsRepository_Success(t *testing.T) { + createdBy := uuid.NewString() + _ = &repository.ProjectData{ + UUID: uuid.NewString(), + Name: "test-1", + Slug: "test_1", + Description: "Test Project Number 1", + CreatedBy: createdBy, + CreatedAt: time.Now(), + ArchivedAt: sql.NullTime{Time: time.Now()}, + Blocked: false, + UpdatedAt: time.Now(), + UpdatedBy: createdBy, + } +} diff --git a/internal/repository/transaction.go b/internal/repository/transaction.go new file mode 100644 index 0000000..8e5e3d4 --- /dev/null +++ b/internal/repository/transaction.go @@ -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() +} diff --git a/internal/controllers/accounts.go b/internal/services/accounts.go similarity index 99% rename from internal/controllers/accounts.go rename to internal/services/accounts.go index acb5010..fc814c2 100644 --- a/internal/controllers/accounts.go +++ b/internal/services/accounts.go @@ -1,4 +1,4 @@ -package controllers +package services import ( "context" diff --git a/internal/controllers/accounts_test.go b/internal/services/accounts_test.go similarity index 86% rename from internal/controllers/accounts_test.go rename to internal/services/accounts_test.go index 22e3f58..f9764c2 100644 --- a/internal/controllers/accounts_test.go +++ b/internal/services/accounts_test.go @@ -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,7 +67,7 @@ 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", @@ -81,7 +81,7 @@ 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", @@ -94,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, } @@ -117,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, } @@ -129,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, } @@ -147,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) } diff --git a/internal/controllers/authorization.go b/internal/services/authorization.go similarity index 99% rename from internal/controllers/authorization.go rename to internal/services/authorization.go index 31da861..b0f94d0 100644 --- a/internal/controllers/authorization.go +++ b/internal/services/authorization.go @@ -1,4 +1,4 @@ -package controllers +package services import ( "context" diff --git a/internal/controllers/authorization_test.go b/internal/services/authorization_test.go similarity index 68% rename from internal/controllers/authorization_test.go rename to internal/services/authorization_test.go index 567014a..4673c0f 100644 --- a/internal/controllers/authorization_test.go +++ b/internal/services/authorization_test.go @@ -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()) diff --git a/internal/controllers/projects.go b/internal/services/projects.go similarity index 72% rename from internal/controllers/projects.go rename to internal/services/projects.go index 95fce54..9d56803 100644 --- a/internal/controllers/projects.go +++ b/internal/services/projects.go @@ -1,13 +1,21 @@ -package controllers +package services -import "context" +import ( + "context" + + "gitea.badhouseplants.net/softplayer/softplayer-backend/internal/helpers/logger" +) type ProjectsController struct{} type ProjectData struct{} // Create a new project +// It should create a project and set an owner to it func (ctrl *ProjectsController) Create(ctx context.Context, data *ProjectData) (id string, err error) { + log := logger.FromContext(ctx) + log.V(2).Info("Creating a project") + return "", nil } diff --git a/internal/controllers/tokens.go b/internal/services/tokens.go similarity index 99% rename from internal/controllers/tokens.go rename to internal/services/tokens.go index 85a675a..a34b94f 100644 --- a/internal/controllers/tokens.go +++ b/internal/services/tokens.go @@ -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" diff --git a/internal/controllers/tokens_test.go b/internal/services/tokens_test.go similarity index 88% rename from internal/controllers/tokens_test.go rename to internal/services/tokens_test.go index 6a6d5a7..0a5eaae 100644 --- a/internal/controllers/tokens_test.go +++ b/internal/services/tokens_test.go @@ -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) } -- 2.49.1 From 28235765cdca5ea05c227ad49205e7e643512bd6 Mon Sep 17 00:00:00 2001 From: Nikolai Rodionov Date: Tue, 19 May 2026 11:32:34 +0200 Subject: [PATCH 03/12] Add tests for the transactions Signed-off-by: Nikolai Rodionov --- internal/repository/transaction_test.go | 95 +++++++++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 internal/repository/transaction_test.go diff --git a/internal/repository/transaction_test.go b/internal/repository/transaction_test.go new file mode 100644 index 0000000..7116d41 --- /dev/null +++ b/internal/repository/transaction_test.go @@ -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) +} -- 2.49.1 From 2285bb10aea4f8ea037bc150dc1ecaf3134cbff4 Mon Sep 17 00:00:00 2001 From: Nikolai Rodionov Date: Tue, 19 May 2026 14:04:26 +0200 Subject: [PATCH 04/12] WIP: Adding projects Signed-off-by: Nikolai Rodionov --- internal/repository/projects.go | 4 ++++ migrations/20260518193048_projects_init.up.sql | 7 ++++--- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/internal/repository/projects.go b/internal/repository/projects.go index 35563fd..4de7726 100644 --- a/internal/repository/projects.go +++ b/internal/repository/projects.go @@ -21,6 +21,10 @@ type ProjectData struct { // CreateProject adds a new projects to the database func CreateProject(ctx context.Context, db *sql.DB, data *ProjectData) error { + query := ` + INSERT INTO projects + (uuid, name, slug, ) + ` return nil } diff --git a/migrations/20260518193048_projects_init.up.sql b/migrations/20260518193048_projects_init.up.sql index 60d8bf0..3a670b1 100644 --- a/migrations/20260518193048_projects_init.up.sql +++ b/migrations/20260518193048_projects_init.up.sql @@ -1,14 +1,15 @@ CREATE TABLE projects ( id UUID PRIMARY KEY, name VARCHAR(120) NOT NULL, - slug VARCHAR(120) NOT NULL UNIQUE, + slug VARCHAR(120) NOT NULL UNIQUE + CHECK ( + slug ~ '^[a-z0-9]+(?:-[a-z0-9]+)*$' + ), description TEXT, owner_user_id UUID NOT NULL, archived_at TIMESTAMP NULL, closed_at TIMESTAMP NULL, billing_account_id UUID NULL, - max_clusters INTEGER DEFAULT 10, - max_nodes INTEGER DEFAULT 100, created_at TIMESTAMP NOT NULL DEFAULT now(), updated_at TIMESTAMP NOT NULL DEFAULT now(), created_by UUID NOT NULL, -- 2.49.1 From 9c558d601bd31fe2e0b15ec16b69ec4d8f85558b Mon Sep 17 00:00:00 2001 From: Nikolai Rodionov Date: Wed, 20 May 2026 13:37:48 +0200 Subject: [PATCH 05/12] WIP: Keep writing projectS Signed-off-by: Nikolai Rodionov --- Taskfile.yml | 27 +++- internal/repository/accounts.go | 5 +- internal/repository/projects.go | 61 +++++++++- internal/repository/projects_test.go | 115 +++++++++++++++++- .../20260518193048_projects_init.up.sql | 11 +- 5 files changed, 202 insertions(+), 17 deletions(-) diff --git a/Taskfile.yml b/Taskfile.yml index 25dd942..9ad24e3 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -61,6 +61,22 @@ 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 + + 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 +129,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 +150,7 @@ tasks: TARGET: "{{.MIGRATE}}" PACKAGE: github.com/golang-migrate/migrate/v4/cmd/migrate VERSION: latest + TAGS: "postgres" go-install-tool: internal: true @@ -150,7 +169,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" diff --git a/internal/repository/accounts.go b/internal/repository/accounts.go index b966e7c..77e85e5 100644 --- a/internal/repository/accounts.go +++ b/internal/repository/accounts.go @@ -10,8 +10,9 @@ 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 { diff --git a/internal/repository/projects.go b/internal/repository/projects.go index 4de7726..4022b90 100644 --- a/internal/repository/projects.go +++ b/internal/repository/projects.go @@ -3,7 +3,11 @@ package repository import ( "context" "database/sql" + "errors" "time" + + "github.com/jackc/pgerrcode" + "github.com/jackc/pgx/v5/pgconn" ) type ProjectData struct { @@ -13,7 +17,7 @@ type ProjectData struct { Description string CreatedBy string CreatedAt time.Time - ArchivedAt sql.NullTime + ClosedAt sql.NullTime Blocked bool UpdatedAt time.Time UpdatedBy string @@ -23,18 +27,67 @@ type ProjectData struct { func CreateProject(ctx context.Context, db *sql.DB, data *ProjectData) error { query := ` INSERT INTO projects - (uuid, name, slug, ) + (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 := db.ExecContext(ctx, query, + 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 + } return nil } // GetProjectByID returns a project from the database -func GetProjectByID(ctx context.Context, db *sql.DB, projectID string) (data *ProjectData, err error) { - return nil, nil +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 } diff --git a/internal/repository/projects_test.go b/internal/repository/projects_test.go index 534f39f..7c5b4eb 100644 --- a/internal/repository/projects_test.go +++ b/internal/repository/projects_test.go @@ -7,20 +7,127 @@ import ( "gitea.badhouseplants.net/softplayer/softplayer-backend/internal/repository" "github.com/google/uuid" + "github.com/stretchr/testify/assert" ) -func TestProjectsRepository_Success(t *testing.T) { +func TestIntegrationProjectsCreate_Success(t *testing.T) { createdBy := uuid.NewString() - _ = &repository.ProjectData{ + project := &repository.ProjectData{ UUID: uuid.NewString(), Name: "test-1", - Slug: "test_1", + Slug: uuid.NewString(), Description: "Test Project Number 1", CreatedBy: createdBy, CreatedAt: time.Now(), - ArchivedAt: sql.NullTime{Time: 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 := uuid.NewString() + 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 := uuid.NewString() + 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 := uuid.NewString() + 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 := uuid.NewString() + 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) } diff --git a/migrations/20260518193048_projects_init.up.sql b/migrations/20260518193048_projects_init.up.sql index 3a670b1..49d7783 100644 --- a/migrations/20260518193048_projects_init.up.sql +++ b/migrations/20260518193048_projects_init.up.sql @@ -1,5 +1,5 @@ CREATE TABLE projects ( - id UUID PRIMARY KEY, + uuid UUID PRIMARY KEY, name VARCHAR(120) NOT NULL, slug VARCHAR(120) NOT NULL UNIQUE CHECK ( @@ -7,11 +7,10 @@ CREATE TABLE projects ( ), description TEXT, owner_user_id UUID NOT NULL, - archived_at TIMESTAMP NULL, - closed_at TIMESTAMP NULL, + closed_at TIMESTAMPTZ NULL, billing_account_id UUID NULL, - created_at TIMESTAMP NOT NULL DEFAULT now(), - updated_at TIMESTAMP NOT NULL DEFAULT now(), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), created_by UUID NOT NULL, - updated_by UUID NOT NULL, + updated_by UUID NOT NULL ); -- 2.49.1 From d1d4cba05bac361327ebae143829686a7c643577 Mon Sep 17 00:00:00 2001 From: Nikolai Rodionov Date: Thu, 21 May 2026 14:55:34 +0200 Subject: [PATCH 06/12] WIP: Adding list project Signed-off-by: Nikolai Rodionov --- internal/repository/projects.go | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/internal/repository/projects.go b/internal/repository/projects.go index 4022b90..648ff94 100644 --- a/internal/repository/projects.go +++ b/internal/repository/projects.go @@ -25,6 +25,11 @@ type ProjectData struct { // 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() query := ` INSERT INTO projects (uuid, name, slug, description, owner_user_id, billing_account_id, created_by, created_at, updated_by, updated_at) @@ -32,7 +37,7 @@ func CreateProject(ctx context.Context, db *sql.DB, data *ProjectData) error { ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) ` - if _, err := db.ExecContext(ctx, query, + if _, err := tx.ExecContext(ctx, query, 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 @@ -93,5 +98,10 @@ func UpdateProject(ctx context.Context, db *sql.DB, data *ProjectData) error { // ListProjects get all projects that are available for the user from the database func ListProjects(ctx context.Context, db *sql.DB) ([]*ProjectData, error) { + query := ` + SELECT p.uuid, p.name + FROM projects p + JOIN project_membership pm ON pm.project_id = p.id + WHERE pm.user_id = ?` return nil, nil } -- 2.49.1 From 891b9854b5f0a34201cff30c2fcfc030ead73f28 Mon Sep 17 00:00:00 2001 From: Nikolai Rodionov Date: Thu, 21 May 2026 16:48:12 +0200 Subject: [PATCH 07/12] WIP: Add a migration for project membmership Signed-off-by: Nikolai Rodionov --- internal/repository/projects.go | 2 ++ ...518193057_project_membership_init.down.sql | 7 ++++ ...60518193057_project_membership_init.up.sql | 35 +++++++++++++++++++ 3 files changed, 44 insertions(+) diff --git a/internal/repository/projects.go b/internal/repository/projects.go index 648ff94..faa455e 100644 --- a/internal/repository/projects.go +++ b/internal/repository/projects.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "errors" + "fmt" "time" "github.com/jackc/pgerrcode" @@ -103,5 +104,6 @@ func ListProjects(ctx context.Context, db *sql.DB) ([]*ProjectData, error) { FROM projects p JOIN project_membership pm ON pm.project_id = p.id WHERE pm.user_id = ?` + fmt.Println(query) return nil, nil } diff --git a/migrations/20260518193057_project_membership_init.down.sql b/migrations/20260518193057_project_membership_init.down.sql index e69de29..76e1132 100644 --- a/migrations/20260518193057_project_membership_init.down.sql +++ b/migrations/20260518193057_project_membership_init.down.sql @@ -0,0 +1,7 @@ +DROP INDEX IF EXISTS idx_project_memberships_project; +DROP INDEX IF EXISTS idx_project_memberships_user; + +DROP TABLE IF EXISTS project_memberships; + +DROP TYPE IF EXISTS membership_status; +DROP TYPE IF EXISTS project_role; diff --git a/migrations/20260518193057_project_membership_init.up.sql b/migrations/20260518193057_project_membership_init.up.sql index e69de29..57b56d9 100644 --- a/migrations/20260518193057_project_membership_init.up.sql +++ b/migrations/20260518193057_project_membership_init.up.sql @@ -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_memberships ( + 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_memberships_user +ON project_memberships(user_uuid); + +CREATE INDEX idx_project_memberships_project +ON project_memberships(project_uuid); -- 2.49.1 From a4ab4fa6c675d5680579f592d84b9d4b6716b831 Mon Sep 17 00:00:00 2001 From: Nikolai Rodionov Date: Thu, 21 May 2026 17:03:59 +0200 Subject: [PATCH 08/12] WIP: Fix the create project test Signed-off-by: Nikolai Rodionov --- internal/repository/projects.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/internal/repository/projects.go b/internal/repository/projects.go index faa455e..720322f 100644 --- a/internal/repository/projects.go +++ b/internal/repository/projects.go @@ -53,6 +53,10 @@ func CreateProject(ctx context.Context, db *sql.DB, data *ProjectData) error { } return err } + + if err := tx.Commit(); err != nil { + return err + } return nil } @@ -78,6 +82,7 @@ func GetProjectByID(ctx context.Context, db *sql.DB, projectID string) (*Project } return nil, err } + fmt.Println(data) return data, nil } -- 2.49.1 From e132e143bd50f77e3024eb8d47c3711ac12bbc64 Mon Sep 17 00:00:00 2001 From: Nikolai Rodionov Date: Thu, 21 May 2026 17:12:44 +0200 Subject: [PATCH 09/12] WIP: Fix the create project test Signed-off-by: Nikolai Rodionov --- internal/repository/projects.go | 26 +++++++++++++++++-- ...518193057_project_membership_init.down.sql | 6 ++--- ...60518193057_project_membership_init.up.sql | 10 +++---- 3 files changed, 32 insertions(+), 10 deletions(-) diff --git a/internal/repository/projects.go b/internal/repository/projects.go index 720322f..d86c816 100644 --- a/internal/repository/projects.go +++ b/internal/repository/projects.go @@ -31,14 +31,14 @@ func CreateProject(ctx context.Context, db *sql.DB, data *ProjectData) error { return err } defer tx.Rollback() - query := ` + 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, query, + 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 @@ -54,6 +54,28 @@ func CreateProject(ctx context.Context, db *sql.DB, data *ProjectData) error { return err } + 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 } diff --git a/migrations/20260518193057_project_membership_init.down.sql b/migrations/20260518193057_project_membership_init.down.sql index 76e1132..cd338a6 100644 --- a/migrations/20260518193057_project_membership_init.down.sql +++ b/migrations/20260518193057_project_membership_init.down.sql @@ -1,7 +1,7 @@ -DROP INDEX IF EXISTS idx_project_memberships_project; -DROP INDEX IF EXISTS idx_project_memberships_user; +DROP INDEX IF EXISTS idx_project_membership_project; +DROP INDEX IF EXISTS idx_project_membership_user; -DROP TABLE IF EXISTS project_memberships; +DROP TABLE IF EXISTS project_membership; DROP TYPE IF EXISTS membership_status; DROP TYPE IF EXISTS project_role; diff --git a/migrations/20260518193057_project_membership_init.up.sql b/migrations/20260518193057_project_membership_init.up.sql index 57b56d9..e56fa39 100644 --- a/migrations/20260518193057_project_membership_init.up.sql +++ b/migrations/20260518193057_project_membership_init.up.sql @@ -10,7 +10,7 @@ CREATE TYPE membership_status AS ENUM ( 'suspended' ); -CREATE TABLE project_memberships ( +CREATE TABLE project_membership ( project_uuid UUID NOT NULL REFERENCES projects(uuid) ON DELETE CASCADE, @@ -28,8 +28,8 @@ CREATE TABLE project_memberships ( PRIMARY KEY (project_uuid, user_uuid) ); -CREATE INDEX idx_project_memberships_user -ON project_memberships(user_uuid); +CREATE INDEX idx_project_membership_user +ON project_membership(user_uuid); -CREATE INDEX idx_project_memberships_project -ON project_memberships(project_uuid); +CREATE INDEX idx_project_membership_project +ON project_membership(project_uuid); -- 2.49.1 From 4e617c90ef63b5bdd79bf120e43fa07159c3a913 Mon Sep 17 00:00:00 2001 From: Nikolai Rodionov Date: Fri, 22 May 2026 09:39:25 +0200 Subject: [PATCH 10/12] Add basic health checks Signed-off-by: Nikolai Rodionov --- cmd/server.go | 67 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/cmd/server.go b/cmd/server.go index 193d840..b3239e1 100644 --- a/cmd/server.go +++ b/cmd/server.go @@ -2,6 +2,7 @@ package cmd import ( "context" + "database/sql" "fmt" "net" "strings" @@ -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 @@ -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.49.1 From 21a4c53e0f2c84b52169c997264cfa6edd837928 Mon Sep 17 00:00:00 2001 From: Nikolai Rodionov Date: Fri, 22 May 2026 11:23:04 +0200 Subject: [PATCH 11/12] Implement list projects Signed-off-by: Nikolai Rodionov --- Taskfile.yml | 8 ++ internal/repository/projects.go | 47 ++++++- internal/repository/projects_test.go | 123 +++++++++++++++++- .../20260518193048_projects_init.down.sql | 2 +- ...518193057_project_membership_init.down.sql | 10 +- 5 files changed, 172 insertions(+), 18 deletions(-) diff --git a/Taskfile.yml b/Taskfile.yml index 9ad24e3..ab65e2c 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -69,6 +69,14 @@ tasks: 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: diff --git a/internal/repository/projects.go b/internal/repository/projects.go index d86c816..fa8530e 100644 --- a/internal/repository/projects.go +++ b/internal/repository/projects.go @@ -4,7 +4,6 @@ import ( "context" "database/sql" "errors" - "fmt" "time" "github.com/jackc/pgerrcode" @@ -54,6 +53,7 @@ func CreateProject(ctx context.Context, db *sql.DB, data *ProjectData) error { 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) @@ -104,7 +104,6 @@ func GetProjectByID(ctx context.Context, db *sql.DB, projectID string) (*Project } return nil, err } - fmt.Println(data) return data, nil } @@ -125,12 +124,46 @@ func UpdateProject(ctx context.Context, db *sql.DB, data *ProjectData) error { } // ListProjects get all projects that are available for the user from the database -func ListProjects(ctx context.Context, db *sql.DB) ([]*ProjectData, error) { +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_id = p.id - WHERE pm.user_id = ?` - fmt.Println(query) - return nil, nil + 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 } diff --git a/internal/repository/projects_test.go b/internal/repository/projects_test.go index 7c5b4eb..738a88a 100644 --- a/internal/repository/projects_test.go +++ b/internal/repository/projects_test.go @@ -1,7 +1,9 @@ package repository_test import ( + "context" "database/sql" + "fmt" "testing" "time" @@ -10,8 +12,35 @@ import ( "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 := uuid.NewString() + createdBy, err := accountForProject(t.Context()) + assert.NoError(t, err) project := &repository.ProjectData{ UUID: uuid.NewString(), Name: "test-1", @@ -29,7 +58,8 @@ func TestIntegrationProjectsCreate_Success(t *testing.T) { } func TestIntegrationProjectsCreate_CheckFailed(t *testing.T) { - createdBy := uuid.NewString() + createdBy, err := accountForProject(t.Context()) + assert.NoError(t, err) project := &repository.ProjectData{ UUID: uuid.NewString(), Name: "test-2", @@ -46,7 +76,8 @@ func TestIntegrationProjectsCreate_CheckFailed(t *testing.T) { } func TestIntegrationProjectsCreate_AlreadyExistsFail(t *testing.T) { - createdBy := uuid.NewString() + createdBy, err := accountForProject(t.Context()) + assert.NoError(t, err) project := &repository.ProjectData{ UUID: uuid.NewString(), Name: "test-3", @@ -65,7 +96,8 @@ func TestIntegrationProjectsCreate_AlreadyExistsFail(t *testing.T) { } func TestIntegrationProjectsCreateAndGet_Success(t *testing.T) { - createdBy := uuid.NewString() + createdBy, err := accountForProject(t.Context()) + assert.NoError(t, err) project := &repository.ProjectData{ UUID: uuid.NewString(), Name: "test-4", @@ -96,7 +128,8 @@ func TestIntegrationProjectsCreateAndGet_NotFound(t *testing.T) { } func TestIntegrationProjectsCreateUpdateAndGet_Success(t *testing.T) { - createdBy := uuid.NewString() + createdBy, err := accountForProject(t.Context()) + assert.NoError(t, err) project := &repository.ProjectData{ UUID: uuid.NewString(), Name: "test-5", @@ -131,3 +164,83 @@ func TestIntegrationProjectsCreateUpdateAndGet_Success(t *testing.T) { 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) +} diff --git a/migrations/20260518193048_projects_init.down.sql b/migrations/20260518193048_projects_init.down.sql index 2e30072..48a1f84 100644 --- a/migrations/20260518193048_projects_init.down.sql +++ b/migrations/20260518193048_projects_init.down.sql @@ -1 +1 @@ -DROP TABLE IF EXIST prohects; +DROP TABLE projects; diff --git a/migrations/20260518193057_project_membership_init.down.sql b/migrations/20260518193057_project_membership_init.down.sql index cd338a6..989fcb8 100644 --- a/migrations/20260518193057_project_membership_init.down.sql +++ b/migrations/20260518193057_project_membership_init.down.sql @@ -1,7 +1,7 @@ -DROP INDEX IF EXISTS idx_project_membership_project; -DROP INDEX IF EXISTS idx_project_membership_user; +DROP INDEX idx_project_membership_project; +DROP INDEX idx_project_membership_user; -DROP TABLE IF EXISTS project_membership; +DROP TABLE project_membership; -DROP TYPE IF EXISTS membership_status; -DROP TYPE IF EXISTS project_role; +DROP TYPE membership_status CASCADE; +DROP TYPE project_role CASCADE; -- 2.49.1 From 41c58b400f043f88f725b9c068a70ea3cd331144 Mon Sep 17 00:00:00 2001 From: Nikolai Rodionov Date: Tue, 26 May 2026 16:47:00 +0200 Subject: [PATCH 12/12] WIP: keep adding projects Signed-off-by: Nikolai Rodionov --- internal/services/projects.go | 50 +++++++++++++++++++++++++----- internal/services/projects_test.go | 38 +++++++++++++++++++++++ 2 files changed, 81 insertions(+), 7 deletions(-) create mode 100644 internal/services/projects_test.go diff --git a/internal/services/projects.go b/internal/services/projects.go index 9d56803..48eca48 100644 --- a/internal/services/projects.go +++ b/internal/services/projects.go @@ -2,21 +2,57 @@ 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{} +type ProjectsController struct { + DB *sql.DB +} -type ProjectData struct{} +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 -// It should create a project and set an owner to it -func (ctrl *ProjectsController) Create(ctx context.Context, data *ProjectData) (id string, err error) { - log := logger.FromContext(ctx) +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") - - return "", nil + 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 diff --git a/internal/services/projects_test.go b/internal/services/projects_test.go new file mode 100644 index 0000000..78b39d9 --- /dev/null +++ b/internal/services/projects_test.go @@ -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) +} -- 2.49.1