first commit
This commit is contained in:
@@ -0,0 +1,220 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"secunda-test/internal/domain"
|
||||
"secunda-test/internal/service"
|
||||
)
|
||||
|
||||
type TaskRepository struct{ db *sql.DB }
|
||||
|
||||
func NewTaskRepository(db *sql.DB) *TaskRepository { return &TaskRepository{db: db} }
|
||||
|
||||
func (r *TaskRepository) Create(ctx context.Context, task domain.Task) (domain.Task, error) {
|
||||
res, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO tasks(team_id,title,description,status,assignee_id,created_by)
|
||||
VALUES(?,?,?,?,?,?)`,
|
||||
task.TeamID, task.Title, task.Description, valueOrDefault(string(task.Status), string(domain.StatusTodo)), task.AssigneeID, task.CreatedBy)
|
||||
if err != nil {
|
||||
return domain.Task{}, err
|
||||
}
|
||||
id, _ := res.LastInsertId()
|
||||
return r.Get(ctx, id)
|
||||
}
|
||||
|
||||
func (r *TaskRepository) Get(ctx context.Context, id int64) (domain.Task, error) {
|
||||
var t domain.Task
|
||||
err := r.db.QueryRowContext(ctx, `
|
||||
SELECT id,team_id,title,description,status,assignee_id,created_by,created_at,updated_at
|
||||
FROM tasks WHERE id=?`, id).
|
||||
Scan(&t.ID, &t.TeamID, &t.Title, &t.Description, &t.Status, &t.AssigneeID, &t.CreatedBy, &t.CreatedAt, &t.UpdatedAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return domain.Task{}, service.ErrNotFound
|
||||
}
|
||||
return t, err
|
||||
}
|
||||
|
||||
func (r *TaskRepository) Update(ctx context.Context, task domain.Task, changedBy int64) (domain.Task, error) {
|
||||
old, err := r.Get(ctx, task.ID)
|
||||
if err != nil {
|
||||
return domain.Task{}, err
|
||||
}
|
||||
tx, err := r.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return domain.Task{}, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
UPDATE tasks SET title=?,description=?,status=?,assignee_id=? WHERE id=?`,
|
||||
task.Title, task.Description, task.Status, task.AssigneeID, task.ID)
|
||||
if err != nil {
|
||||
return domain.Task{}, err
|
||||
}
|
||||
addHistory := func(field, oldValue, newValue string) error {
|
||||
if oldValue == newValue {
|
||||
return nil
|
||||
}
|
||||
_, err := tx.ExecContext(ctx, `INSERT INTO task_history(task_id,changed_by,field_name,old_value,new_value) VALUES(?,?,?,?,?)`,
|
||||
task.ID, changedBy, field, oldValue, newValue)
|
||||
return err
|
||||
}
|
||||
if err := addHistory("title", old.Title, task.Title); err != nil {
|
||||
return domain.Task{}, err
|
||||
}
|
||||
if err := addHistory("description", old.Description, task.Description); err != nil {
|
||||
return domain.Task{}, err
|
||||
}
|
||||
if err := addHistory("status", string(old.Status), string(task.Status)); err != nil {
|
||||
return domain.Task{}, err
|
||||
}
|
||||
if err := addHistory("assignee_id", ptrString(old.AssigneeID), ptrString(task.AssigneeID)); err != nil {
|
||||
return domain.Task{}, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return domain.Task{}, err
|
||||
}
|
||||
return r.Get(ctx, task.ID)
|
||||
}
|
||||
|
||||
func (r *TaskRepository) List(ctx context.Context, filter domain.TaskFilter) ([]domain.Task, error) {
|
||||
args := []any{filter.TeamID}
|
||||
where := []string{"team_id=?"}
|
||||
if filter.Status != "" {
|
||||
where = append(where, "status=?")
|
||||
args = append(args, filter.Status)
|
||||
}
|
||||
if filter.AssigneeID != nil {
|
||||
where = append(where, "assignee_id=?")
|
||||
args = append(args, *filter.AssigneeID)
|
||||
}
|
||||
limit, offset := filter.PageSize, (filter.Page-1)*filter.PageSize
|
||||
args = append(args, limit, offset)
|
||||
query := fmt.Sprintf(`
|
||||
SELECT id,team_id,title,description,status,assignee_id,created_by,created_at,updated_at
|
||||
FROM tasks WHERE %s ORDER BY created_at DESC LIMIT ? OFFSET ?`, strings.Join(where, " AND "))
|
||||
rows, err := r.db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []domain.Task
|
||||
for rows.Next() {
|
||||
var t domain.Task
|
||||
if err := rows.Scan(&t.ID, &t.TeamID, &t.Title, &t.Description, &t.Status, &t.AssigneeID, &t.CreatedBy, &t.CreatedAt, &t.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, t)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (r *TaskRepository) History(ctx context.Context, taskID int64) ([]domain.TaskHistory, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT id,task_id,changed_by,field_name,old_value,new_value,created_at
|
||||
FROM task_history WHERE task_id=? ORDER BY created_at DESC`, taskID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []domain.TaskHistory
|
||||
for rows.Next() {
|
||||
var h domain.TaskHistory
|
||||
if err := rows.Scan(&h.ID, &h.TaskID, &h.ChangedBy, &h.FieldName, &h.OldValue, &h.NewValue, &h.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, h)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (r *TaskRepository) TeamSummary(ctx context.Context) ([]domain.TeamSummary, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT t.id, t.name, COUNT(DISTINCT tm.user_id) AS members_count,
|
||||
COUNT(DISTINCT CASE WHEN ta.status='done' AND ta.updated_at >= NOW() - INTERVAL 7 DAY THEN ta.id END) AS done_last_7_days
|
||||
FROM teams t
|
||||
LEFT JOIN team_members tm ON tm.team_id=t.id
|
||||
LEFT JOIN tasks ta ON ta.team_id=t.id
|
||||
GROUP BY t.id, t.name
|
||||
ORDER BY t.name`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []domain.TeamSummary
|
||||
for rows.Next() {
|
||||
var s domain.TeamSummary
|
||||
if err := rows.Scan(&s.TeamID, &s.TeamName, &s.MembersCount, &s.DoneLast7Days); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (r *TaskRepository) TopCreators(ctx context.Context) ([]domain.TopCreator, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
WITH ranked AS (
|
||||
SELECT t.team_id, te.name AS team_name, t.created_by AS user_id, u.name AS user_name,
|
||||
COUNT(*) AS tasks_created,
|
||||
DENSE_RANK() OVER (PARTITION BY t.team_id ORDER BY COUNT(*) DESC) AS user_rank
|
||||
FROM tasks t
|
||||
JOIN teams te ON te.id=t.team_id
|
||||
JOIN users u ON u.id=t.created_by
|
||||
WHERE t.created_at >= NOW() - INTERVAL 1 MONTH
|
||||
GROUP BY t.team_id, te.name, t.created_by, u.name
|
||||
)
|
||||
SELECT team_id,team_name,user_id,user_name,tasks_created,user_rank
|
||||
FROM ranked WHERE user_rank <= 3 ORDER BY team_id,user_rank`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []domain.TopCreator
|
||||
for rows.Next() {
|
||||
var c domain.TopCreator
|
||||
if err := rows.Scan(&c.TeamID, &c.TeamName, &c.UserID, &c.UserName, &c.TasksCreated, &c.Rank); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (r *TaskRepository) InvalidAssignees(ctx context.Context) ([]domain.Task, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT ta.id,ta.team_id,ta.title,ta.description,ta.status,ta.assignee_id,ta.created_by,ta.created_at,ta.updated_at
|
||||
FROM tasks ta
|
||||
LEFT JOIN team_members tm ON tm.team_id=ta.team_id AND tm.user_id=ta.assignee_id
|
||||
WHERE ta.assignee_id IS NOT NULL AND tm.user_id IS NULL`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []domain.Task
|
||||
for rows.Next() {
|
||||
var t domain.Task
|
||||
if err := rows.Scan(&t.ID, &t.TeamID, &t.Title, &t.Description, &t.Status, &t.AssigneeID, &t.CreatedBy, &t.CreatedAt, &t.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, t)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func valueOrDefault(v, fallback string) string {
|
||||
if v == "" {
|
||||
return fallback
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func ptrString(v *int64) string {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprint(*v)
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
//go:build integration
|
||||
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
_ "github.com/go-sql-driver/mysql"
|
||||
"github.com/testcontainers/testcontainers-go"
|
||||
tcmysql "github.com/testcontainers/testcontainers-go/modules/mysql"
|
||||
"github.com/testcontainers/testcontainers-go/wait"
|
||||
)
|
||||
|
||||
func TestTaskRepositoryReportsWithMySQL(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
dsn := testDSN(ctx, t)
|
||||
db, err := sql.Open("mysql", dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
migrate(ctx, t, db)
|
||||
|
||||
repo := NewTaskRepository(db)
|
||||
if _, err := repo.TeamSummary(context.Background()); err != nil {
|
||||
t.Fatalf("team summary query failed: %v", err)
|
||||
}
|
||||
if _, err := repo.TopCreators(context.Background()); err != nil {
|
||||
t.Fatalf("top creators query failed: %v", err)
|
||||
}
|
||||
if _, err := repo.InvalidAssignees(context.Background()); err != nil {
|
||||
t.Fatalf("invalid assignees query failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func testDSN(ctx context.Context, t *testing.T) string {
|
||||
t.Helper()
|
||||
if dsn := os.Getenv("TEST_MYSQL_DSN"); dsn != "" {
|
||||
return dsn
|
||||
}
|
||||
container, err := tcmysql.Run(ctx,
|
||||
"mysql:8.4",
|
||||
tcmysql.WithDatabase("task_service_test"),
|
||||
tcmysql.WithUsername("app"),
|
||||
tcmysql.WithPassword("app"),
|
||||
testcontainers.WithWaitStrategy(wait.ForListeningPort("3306/tcp").WithStartupTimeout(2*time.Minute)),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("start mysql container: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = testcontainers.TerminateContainer(container)
|
||||
})
|
||||
dsn, err := container.ConnectionString(ctx, "parseTime=true", "multiStatements=true")
|
||||
if err != nil {
|
||||
t.Fatalf("mysql dsn: %v", err)
|
||||
}
|
||||
return dsn
|
||||
}
|
||||
|
||||
func migrate(ctx context.Context, t *testing.T, db *sql.DB) {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(filepath.Join("..", "..", "..", "migrations", "001_init.sql"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, stmt := range strings.Split(string(data), ";") {
|
||||
stmt = strings.TrimSpace(stmt)
|
||||
if stmt == "" {
|
||||
continue
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, stmt); err != nil {
|
||||
t.Fatalf("migration failed: %v\nsql: %s", err, stmt)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"secunda-test/internal/domain"
|
||||
)
|
||||
|
||||
type TeamRepository struct{ db *sql.DB }
|
||||
|
||||
func NewTeamRepository(db *sql.DB) *TeamRepository { return &TeamRepository{db: db} }
|
||||
|
||||
func (r *TeamRepository) Create(ctx context.Context, name string, createdBy int64) (domain.Team, error) {
|
||||
res, err := r.db.ExecContext(ctx, `INSERT INTO teams(name,created_by) VALUES(?,?)`, name, createdBy)
|
||||
if err != nil {
|
||||
return domain.Team{}, err
|
||||
}
|
||||
id, _ := res.LastInsertId()
|
||||
return domain.Team{ID: id, Name: name, CreatedBy: createdBy}, nil
|
||||
}
|
||||
|
||||
func (r *TeamRepository) AddMember(ctx context.Context, teamID, userID int64, role domain.Role) error {
|
||||
_, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO team_members(user_id,team_id,role) VALUES(?,?,?)
|
||||
ON DUPLICATE KEY UPDATE role=VALUES(role)`, userID, teamID, role)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *TeamRepository) ListByUser(ctx context.Context, userID int64) ([]domain.Team, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT t.id,t.name,t.created_by,tm.role,t.created_at
|
||||
FROM teams t
|
||||
JOIN team_members tm ON tm.team_id=t.id
|
||||
WHERE tm.user_id=?
|
||||
ORDER BY t.created_at DESC`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []domain.Team
|
||||
for rows.Next() {
|
||||
var t domain.Team
|
||||
if err := rows.Scan(&t.ID, &t.Name, &t.CreatedBy, &t.Role, &t.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, t)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (r *TeamRepository) MemberRole(ctx context.Context, teamID, userID int64) (domain.Role, bool, error) {
|
||||
var role domain.Role
|
||||
err := r.db.QueryRowContext(ctx, `SELECT role FROM team_members WHERE team_id=? AND user_id=?`, teamID, userID).Scan(&role)
|
||||
if err == sql.ErrNoRows {
|
||||
return "", false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
return role, true, nil
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"secunda-test/internal/domain"
|
||||
"secunda-test/internal/service"
|
||||
)
|
||||
|
||||
type UserRepository struct{ db *sql.DB }
|
||||
|
||||
func NewUserRepository(db *sql.DB) *UserRepository { return &UserRepository{db: db} }
|
||||
|
||||
func (r *UserRepository) Create(ctx context.Context, email, passwordHash, name string) (domain.User, error) {
|
||||
res, err := r.db.ExecContext(ctx, `INSERT INTO users(email,password_hash,name) VALUES(?,?,?)`, email, passwordHash, name)
|
||||
if err != nil {
|
||||
return domain.User{}, err
|
||||
}
|
||||
id, _ := res.LastInsertId()
|
||||
return r.FindByID(ctx, id)
|
||||
}
|
||||
|
||||
func (r *UserRepository) FindByEmail(ctx context.Context, email string) (domain.User, error) {
|
||||
return r.scan(r.db.QueryRowContext(ctx, `SELECT id,email,password_hash,name,created_at FROM users WHERE email=?`, email))
|
||||
}
|
||||
|
||||
func (r *UserRepository) FindByID(ctx context.Context, id int64) (domain.User, error) {
|
||||
return r.scan(r.db.QueryRowContext(ctx, `SELECT id,email,password_hash,name,created_at FROM users WHERE id=?`, id))
|
||||
}
|
||||
|
||||
func (r *UserRepository) scan(row *sql.Row) (domain.User, error) {
|
||||
var u domain.User
|
||||
err := row.Scan(&u.ID, &u.Email, &u.PasswordHash, &u.Name, &u.CreatedAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return domain.User{}, service.ErrNotFound
|
||||
}
|
||||
return u, err
|
||||
}
|
||||
Reference in New Issue
Block a user