first commit
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
type AuthService struct {
|
||||
users UserRepository
|
||||
jwtSecret []byte
|
||||
jwtTTL time.Duration
|
||||
}
|
||||
|
||||
func NewAuthService(users UserRepository, jwtSecret string, jwtTTL time.Duration) *AuthService {
|
||||
return &AuthService{users: users, jwtSecret: []byte(jwtSecret), jwtTTL: jwtTTL}
|
||||
}
|
||||
|
||||
func (s *AuthService) Register(ctx context.Context, email, password, name string) (int64, error) {
|
||||
if email == "" || password == "" || name == "" {
|
||||
return 0, ErrBadRequest
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
u, err := s.users.Create(ctx, email, string(hash), name)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return u.ID, nil
|
||||
}
|
||||
|
||||
func (s *AuthService) Login(ctx context.Context, email, password string) (string, error) {
|
||||
u, err := s.users.FindByEmail(ctx, email)
|
||||
if err != nil {
|
||||
return "", ErrUnauthorized
|
||||
}
|
||||
if bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)) != nil {
|
||||
return "", ErrUnauthorized
|
||||
}
|
||||
claims := jwt.MapClaims{
|
||||
"sub": u.ID,
|
||||
"exp": time.Now().Add(s.jwtTTL).Unix(),
|
||||
}
|
||||
return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(s.jwtSecret)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"secunda-test/internal/domain"
|
||||
)
|
||||
|
||||
type UserRepository interface {
|
||||
Create(ctx context.Context, email, passwordHash, name string) (domain.User, error)
|
||||
FindByEmail(ctx context.Context, email string) (domain.User, error)
|
||||
FindByID(ctx context.Context, id int64) (domain.User, error)
|
||||
}
|
||||
|
||||
type TeamRepository interface {
|
||||
Create(ctx context.Context, name string, createdBy int64) (domain.Team, error)
|
||||
AddMember(ctx context.Context, teamID, userID int64, role domain.Role) error
|
||||
ListByUser(ctx context.Context, userID int64) ([]domain.Team, error)
|
||||
MemberRole(ctx context.Context, teamID, userID int64) (domain.Role, bool, error)
|
||||
}
|
||||
|
||||
type TaskRepository interface {
|
||||
Create(ctx context.Context, task domain.Task) (domain.Task, error)
|
||||
Get(ctx context.Context, id int64) (domain.Task, error)
|
||||
Update(ctx context.Context, task domain.Task, changedBy int64) (domain.Task, error)
|
||||
List(ctx context.Context, filter domain.TaskFilter) ([]domain.Task, error)
|
||||
History(ctx context.Context, taskID int64) ([]domain.TaskHistory, error)
|
||||
TeamSummary(ctx context.Context) ([]domain.TeamSummary, error)
|
||||
TopCreators(ctx context.Context) ([]domain.TopCreator, error)
|
||||
InvalidAssignees(ctx context.Context) ([]domain.Task, error)
|
||||
}
|
||||
|
||||
type TaskCache interface {
|
||||
GetTasks(ctx context.Context, filter domain.TaskFilter) ([]domain.Task, bool, error)
|
||||
SetTasks(ctx context.Context, filter domain.TaskFilter, tasks []domain.Task) error
|
||||
DeleteTeam(ctx context.Context, teamID int64) error
|
||||
}
|
||||
|
||||
type EmailSender interface {
|
||||
SendInvite(ctx context.Context, email, teamName string) error
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package service
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
ErrUnauthorized = errors.New("unauthorized")
|
||||
ErrForbidden = errors.New("forbidden")
|
||||
ErrNotFound = errors.New("not found")
|
||||
ErrBadRequest = errors.New("bad request")
|
||||
ErrConflict = errors.New("conflict")
|
||||
)
|
||||
@@ -0,0 +1,127 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"secunda-test/internal/domain"
|
||||
)
|
||||
|
||||
type TaskService struct {
|
||||
tasks TaskRepository
|
||||
teams TeamRepository
|
||||
cache TaskCache
|
||||
}
|
||||
|
||||
func NewTaskService(tasks TaskRepository, teams TeamRepository, cache TaskCache) *TaskService {
|
||||
return &TaskService{tasks: tasks, teams: teams, cache: cache}
|
||||
}
|
||||
|
||||
func (s *TaskService) Create(ctx context.Context, userID int64, task domain.Task) (domain.Task, error) {
|
||||
if task.TeamID == 0 || task.Title == "" {
|
||||
return domain.Task{}, ErrBadRequest
|
||||
}
|
||||
if _, ok, err := s.teams.MemberRole(ctx, task.TeamID, userID); err != nil {
|
||||
return domain.Task{}, err
|
||||
} else if !ok {
|
||||
return domain.Task{}, ErrForbidden
|
||||
}
|
||||
if task.AssigneeID != nil {
|
||||
if _, ok, err := s.teams.MemberRole(ctx, task.TeamID, *task.AssigneeID); err != nil {
|
||||
return domain.Task{}, err
|
||||
} else if !ok {
|
||||
return domain.Task{}, ErrBadRequest
|
||||
}
|
||||
}
|
||||
task.CreatedBy = userID
|
||||
created, err := s.tasks.Create(ctx, task)
|
||||
if err == nil {
|
||||
_ = s.cache.DeleteTeam(ctx, task.TeamID)
|
||||
}
|
||||
return created, err
|
||||
}
|
||||
|
||||
func (s *TaskService) List(ctx context.Context, userID int64, filter domain.TaskFilter) ([]domain.Task, error) {
|
||||
if filter.TeamID == 0 {
|
||||
return nil, ErrBadRequest
|
||||
}
|
||||
if _, ok, err := s.teams.MemberRole(ctx, filter.TeamID, userID); err != nil {
|
||||
return nil, err
|
||||
} else if !ok {
|
||||
return nil, ErrForbidden
|
||||
}
|
||||
if filter.Page < 1 {
|
||||
filter.Page = 1
|
||||
}
|
||||
if filter.PageSize <= 0 || filter.PageSize > 100 {
|
||||
filter.PageSize = 20
|
||||
}
|
||||
if cached, ok, err := s.cache.GetTasks(ctx, filter); err == nil && ok {
|
||||
return cached, nil
|
||||
}
|
||||
tasks, err := s.tasks.List(ctx, filter)
|
||||
if err == nil {
|
||||
_ = s.cache.SetTasks(ctx, filter, tasks)
|
||||
}
|
||||
return tasks, err
|
||||
}
|
||||
|
||||
func (s *TaskService) Update(ctx context.Context, userID, taskID int64, patch domain.Task) (domain.Task, error) {
|
||||
current, err := s.tasks.Get(ctx, taskID)
|
||||
if err != nil {
|
||||
return domain.Task{}, err
|
||||
}
|
||||
role, ok, err := s.teams.MemberRole(ctx, current.TeamID, userID)
|
||||
if err != nil {
|
||||
return domain.Task{}, err
|
||||
}
|
||||
if !ok || (role == domain.RoleMember && current.CreatedBy != userID && (current.AssigneeID == nil || *current.AssigneeID != userID)) {
|
||||
return domain.Task{}, ErrForbidden
|
||||
}
|
||||
if patch.Title != "" {
|
||||
current.Title = patch.Title
|
||||
}
|
||||
if patch.Description != "" {
|
||||
current.Description = patch.Description
|
||||
}
|
||||
if patch.Status != "" {
|
||||
current.Status = patch.Status
|
||||
}
|
||||
if patch.AssigneeID != nil {
|
||||
if _, ok, err := s.teams.MemberRole(ctx, current.TeamID, *patch.AssigneeID); err != nil {
|
||||
return domain.Task{}, err
|
||||
} else if !ok {
|
||||
return domain.Task{}, ErrBadRequest
|
||||
}
|
||||
current.AssigneeID = patch.AssigneeID
|
||||
}
|
||||
updated, err := s.tasks.Update(ctx, current, userID)
|
||||
if err == nil {
|
||||
_ = s.cache.DeleteTeam(ctx, current.TeamID)
|
||||
}
|
||||
return updated, err
|
||||
}
|
||||
|
||||
func (s *TaskService) History(ctx context.Context, userID, taskID int64) ([]domain.TaskHistory, error) {
|
||||
task, err := s.tasks.Get(ctx, taskID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, ok, err := s.teams.MemberRole(ctx, task.TeamID, userID); err != nil {
|
||||
return nil, err
|
||||
} else if !ok {
|
||||
return nil, ErrForbidden
|
||||
}
|
||||
return s.tasks.History(ctx, taskID)
|
||||
}
|
||||
|
||||
func (s *TaskService) TeamSummary(ctx context.Context) ([]domain.TeamSummary, error) {
|
||||
return s.tasks.TeamSummary(ctx)
|
||||
}
|
||||
|
||||
func (s *TaskService) TopCreators(ctx context.Context) ([]domain.TopCreator, error) {
|
||||
return s.tasks.TopCreators(ctx)
|
||||
}
|
||||
|
||||
func (s *TaskService) InvalidAssignees(ctx context.Context) ([]domain.Task, error) {
|
||||
return s.tasks.InvalidAssignees(ctx)
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"secunda-test/internal/domain"
|
||||
)
|
||||
|
||||
func TestTaskServiceCreateRequiresTeamMembership(t *testing.T) {
|
||||
svc := NewTaskService(&fakeTaskRepo{}, &fakeTeamRepo{members: map[int64]map[int64]domain.Role{}}, noopCache{})
|
||||
_, err := svc.Create(context.Background(), 10, domain.Task{TeamID: 1, Title: "task"})
|
||||
if !errors.Is(err, ErrForbidden) {
|
||||
t.Fatalf("expected forbidden, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskServiceRejectsAssigneeOutsideTeam(t *testing.T) {
|
||||
teams := &fakeTeamRepo{members: map[int64]map[int64]domain.Role{1: {10: domain.RoleMember}}}
|
||||
svc := NewTaskService(&fakeTaskRepo{}, teams, noopCache{})
|
||||
assigneeID := int64(20)
|
||||
_, err := svc.Create(context.Background(), 10, domain.Task{TeamID: 1, Title: "task", AssigneeID: &assigneeID})
|
||||
if !errors.Is(err, ErrBadRequest) {
|
||||
t.Fatalf("expected bad request, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskServiceMemberCanUpdateAssignedTask(t *testing.T) {
|
||||
assigneeID := int64(10)
|
||||
repo := &fakeTaskRepo{task: domain.Task{ID: 7, TeamID: 1, Title: "old", Status: domain.StatusTodo, AssigneeID: &assigneeID, CreatedBy: 99}}
|
||||
teams := &fakeTeamRepo{members: map[int64]map[int64]domain.Role{1: {10: domain.RoleMember}}}
|
||||
svc := NewTaskService(repo, teams, noopCache{})
|
||||
updated, err := svc.Update(context.Background(), 10, 7, domain.Task{Title: "new", Status: domain.StatusDone})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if updated.Title != "new" || updated.Status != domain.StatusDone {
|
||||
t.Fatalf("unexpected task: %+v", updated)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeTaskRepo struct {
|
||||
task domain.Task
|
||||
}
|
||||
|
||||
func (r *fakeTaskRepo) Create(ctx context.Context, task domain.Task) (domain.Task, error) {
|
||||
task.ID = 1
|
||||
r.task = task
|
||||
return task, nil
|
||||
}
|
||||
|
||||
func (r *fakeTaskRepo) Get(ctx context.Context, id int64) (domain.Task, error) {
|
||||
if r.task.ID == 0 {
|
||||
return domain.Task{}, ErrNotFound
|
||||
}
|
||||
return r.task, nil
|
||||
}
|
||||
|
||||
func (r *fakeTaskRepo) Update(ctx context.Context, task domain.Task, changedBy int64) (domain.Task, error) {
|
||||
r.task = task
|
||||
return task, nil
|
||||
}
|
||||
|
||||
func (r *fakeTaskRepo) List(ctx context.Context, filter domain.TaskFilter) ([]domain.Task, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (r *fakeTaskRepo) History(ctx context.Context, taskID int64) ([]domain.TaskHistory, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (r *fakeTaskRepo) TeamSummary(ctx context.Context) ([]domain.TeamSummary, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (r *fakeTaskRepo) TopCreators(ctx context.Context) ([]domain.TopCreator, error) { return nil, nil }
|
||||
func (r *fakeTaskRepo) InvalidAssignees(ctx context.Context) ([]domain.Task, error) { return nil, nil }
|
||||
|
||||
type fakeTeamRepo struct {
|
||||
members map[int64]map[int64]domain.Role
|
||||
}
|
||||
|
||||
func (r *fakeTeamRepo) Create(ctx context.Context, name string, createdBy int64) (domain.Team, error) {
|
||||
return domain.Team{ID: 1, Name: name, CreatedBy: createdBy}, nil
|
||||
}
|
||||
|
||||
func (r *fakeTeamRepo) AddMember(ctx context.Context, teamID, userID int64, role domain.Role) error {
|
||||
if r.members == nil {
|
||||
r.members = map[int64]map[int64]domain.Role{}
|
||||
}
|
||||
if r.members[teamID] == nil {
|
||||
r.members[teamID] = map[int64]domain.Role{}
|
||||
}
|
||||
r.members[teamID][userID] = role
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *fakeTeamRepo) ListByUser(ctx context.Context, userID int64) ([]domain.Team, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (r *fakeTeamRepo) MemberRole(ctx context.Context, teamID, userID int64) (domain.Role, bool, error) {
|
||||
role, ok := r.members[teamID][userID]
|
||||
return role, ok, nil
|
||||
}
|
||||
|
||||
type noopCache struct{}
|
||||
|
||||
func (noopCache) GetTasks(ctx context.Context, filter domain.TaskFilter) ([]domain.Task, bool, error) {
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
func (noopCache) SetTasks(ctx context.Context, filter domain.TaskFilter, tasks []domain.Task) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (noopCache) DeleteTeam(ctx context.Context, teamID int64) error { return nil }
|
||||
@@ -0,0 +1,54 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"secunda-test/internal/domain"
|
||||
)
|
||||
|
||||
type TeamService struct {
|
||||
teams TeamRepository
|
||||
users UserRepository
|
||||
email EmailSender
|
||||
}
|
||||
|
||||
func NewTeamService(teams TeamRepository, users UserRepository, email EmailSender) *TeamService {
|
||||
return &TeamService{teams: teams, users: users, email: email}
|
||||
}
|
||||
|
||||
func (s *TeamService) Create(ctx context.Context, userID int64, name string) (domain.Team, error) {
|
||||
if name == "" {
|
||||
return domain.Team{}, ErrBadRequest
|
||||
}
|
||||
t, err := s.teams.Create(ctx, name, userID)
|
||||
if err != nil {
|
||||
return domain.Team{}, err
|
||||
}
|
||||
if err := s.teams.AddMember(ctx, t.ID, userID, domain.RoleOwner); err != nil {
|
||||
return domain.Team{}, err
|
||||
}
|
||||
t.Role = domain.RoleOwner
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func (s *TeamService) List(ctx context.Context, userID int64) ([]domain.Team, error) {
|
||||
return s.teams.ListByUser(ctx, userID)
|
||||
}
|
||||
|
||||
func (s *TeamService) Invite(ctx context.Context, actorID, teamID int64, email string) error {
|
||||
role, ok, err := s.teams.MemberRole(ctx, teamID, actorID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok || (role != domain.RoleOwner && role != domain.RoleAdmin) {
|
||||
return ErrForbidden
|
||||
}
|
||||
u, err := s.users.FindByEmail(ctx, email)
|
||||
if err != nil {
|
||||
return ErrNotFound
|
||||
}
|
||||
if err := s.teams.AddMember(ctx, teamID, u.ID, domain.RoleMember); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.email.SendInvite(ctx, email, "")
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"secunda-test/internal/domain"
|
||||
)
|
||||
|
||||
func TestTeamServiceInviteRequiresOwnerOrAdmin(t *testing.T) {
|
||||
teams := &fakeTeamRepo{members: map[int64]map[int64]domain.Role{1: {10: domain.RoleMember}}}
|
||||
users := &fakeUserRepo{user: domain.User{ID: 20, Email: "new@example.com"}}
|
||||
svc := NewTeamService(teams, users, fakeEmail{})
|
||||
err := svc.Invite(context.Background(), 10, 1, "new@example.com")
|
||||
if !errors.Is(err, ErrForbidden) {
|
||||
t.Fatalf("expected forbidden, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTeamServiceInviteAddsMember(t *testing.T) {
|
||||
teams := &fakeTeamRepo{members: map[int64]map[int64]domain.Role{1: {10: domain.RoleAdmin}}}
|
||||
users := &fakeUserRepo{user: domain.User{ID: 20, Email: "new@example.com"}}
|
||||
svc := NewTeamService(teams, users, fakeEmail{})
|
||||
if err := svc.Invite(context.Background(), 10, 1, "new@example.com"); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if role, ok := teams.members[1][20]; !ok || role != domain.RoleMember {
|
||||
t.Fatalf("expected invited member, got role=%q ok=%v", role, ok)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeUserRepo struct {
|
||||
user domain.User
|
||||
}
|
||||
|
||||
func (r *fakeUserRepo) Create(ctx context.Context, email, passwordHash, name string) (domain.User, error) {
|
||||
return domain.User{ID: 1, Email: email, PasswordHash: passwordHash, Name: name}, nil
|
||||
}
|
||||
|
||||
func (r *fakeUserRepo) FindByEmail(ctx context.Context, email string) (domain.User, error) {
|
||||
if r.user.Email == email {
|
||||
return r.user, nil
|
||||
}
|
||||
return domain.User{}, ErrNotFound
|
||||
}
|
||||
|
||||
func (r *fakeUserRepo) FindByID(ctx context.Context, id int64) (domain.User, error) {
|
||||
if r.user.ID == id {
|
||||
return r.user, nil
|
||||
}
|
||||
return domain.User{}, ErrNotFound
|
||||
}
|
||||
|
||||
type fakeEmail struct{}
|
||||
|
||||
func (fakeEmail) SendInvite(ctx context.Context, email, teamName string) error { return nil }
|
||||
Reference in New Issue
Block a user