first commit

This commit is contained in:
2026-06-22 14:31:01 +05:00
commit 109e01a656
35 changed files with 2120 additions and 0 deletions
+220
View File
@@ -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)
}
}
}
+62
View File
@@ -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
}
+39
View File
@@ -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
}