Initial commit: Homelab API

This commit is contained in:
Cosmo
2026-02-06 11:19:55 +00:00
commit 5a40127edd
26 changed files with 2807 additions and 0 deletions

102
internal/repository/db.go Normal file
View File

@@ -0,0 +1,102 @@
package repository
import (
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq"
)
func NewDB(databaseURL string) (*sqlx.DB, error) {
db, err := sqlx.Connect("postgres", databaseURL)
if err != nil {
return nil, err
}
db.SetMaxOpenConns(25)
db.SetMaxIdleConns(5)
return db, nil
}
func RunMigrations(db *sqlx.DB) error {
migrations := []string{
`CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
username VARCHAR(100) NOT NULL,
password_hash VARCHAR(255) NOT NULL,
email_verified BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)`,
`CREATE TABLE IF NOT EXISTS habits (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
description TEXT DEFAULT '''',
color VARCHAR(20) DEFAULT '#6366f1',
icon VARCHAR(50) DEFAULT 'check',
frequency VARCHAR(20) DEFAULT 'daily',
target_days INTEGER[] DEFAULT '{1,2,3,4,5,6,0}',
target_count INTEGER DEFAULT 1,
is_archived BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)`,
`CREATE TABLE IF NOT EXISTS habit_logs (
id SERIAL PRIMARY KEY,
habit_id INTEGER REFERENCES habits(id) ON DELETE CASCADE,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
date DATE NOT NULL,
count INTEGER DEFAULT 1,
note TEXT DEFAULT '''',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(habit_id, date)
)`,
`CREATE TABLE IF NOT EXISTS email_tokens (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
token VARCHAR(255) UNIQUE NOT NULL,
type VARCHAR(20) NOT NULL,
expires_at TIMESTAMP NOT NULL,
used_at TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)`,
`CREATE TABLE IF NOT EXISTS tasks (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
title VARCHAR(255) NOT NULL,
description TEXT DEFAULT '''',
icon VARCHAR(10) DEFAULT '📋',
color VARCHAR(7) DEFAULT '#6B7280',
due_date DATE,
priority INTEGER DEFAULT 0,
completed_at TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)`,
`CREATE INDEX IF NOT EXISTS idx_habits_user_id ON habits(user_id)`,
`CREATE INDEX IF NOT EXISTS idx_habit_logs_habit_id ON habit_logs(habit_id)`,
`CREATE INDEX IF NOT EXISTS idx_habit_logs_date ON habit_logs(date)`,
`CREATE INDEX IF NOT EXISTS idx_habit_logs_user_date ON habit_logs(user_id, date)`,
`CREATE INDEX IF NOT EXISTS idx_email_tokens_token ON email_tokens(token)`,
`CREATE INDEX IF NOT EXISTS idx_email_tokens_user_id ON email_tokens(user_id)`,
`CREATE INDEX IF NOT EXISTS idx_tasks_user_id ON tasks(user_id)`,
`CREATE INDEX IF NOT EXISTS idx_tasks_due_date ON tasks(due_date)`,
`CREATE INDEX IF NOT EXISTS idx_tasks_completed ON tasks(user_id, completed_at)`,
// Migration: add email_verified column if not exists
`DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='users' AND column_name='email_verified') THEN
ALTER TABLE users ADD COLUMN email_verified BOOLEAN DEFAULT FALSE;
END IF;
END $$;`,
}
for _, migration := range migrations {
if _, err := db.Exec(migration); err != nil {
return err
}
}
return nil
}

View File

@@ -0,0 +1,113 @@
package repository
import (
"crypto/rand"
"database/sql"
"encoding/hex"
"errors"
"time"
"github.com/daniil/homelab-api/internal/model"
"github.com/jmoiron/sqlx"
)
var ErrTokenNotFound = errors.New("token not found")
var ErrTokenExpired = errors.New("token expired")
var ErrTokenUsed = errors.New("token already used")
type EmailTokenRepository struct {
db *sqlx.DB
}
func NewEmailTokenRepository(db *sqlx.DB) *EmailTokenRepository {
return &EmailTokenRepository{db: db}
}
func (r *EmailTokenRepository) Create(userID int64, tokenType string, expiry time.Duration) (*model.EmailToken, error) {
token, err := generateSecureToken(32)
if err != nil {
return nil, err
}
emailToken := &model.EmailToken{
UserID: userID,
Token: token,
Type: tokenType,
ExpiresAt: time.Now().Add(expiry),
}
query := `
INSERT INTO email_tokens (user_id, token, type, expires_at)
VALUES ($1, $2, $3, $4)
RETURNING id, created_at`
err = r.db.QueryRow(query, emailToken.UserID, emailToken.Token, emailToken.Type, emailToken.ExpiresAt).
Scan(&emailToken.ID, &emailToken.CreatedAt)
if err != nil {
return nil, err
}
return emailToken, nil
}
func (r *EmailTokenRepository) GetByToken(token string) (*model.EmailToken, error) {
var emailToken model.EmailToken
query := `SELECT id, user_id, token, type, expires_at, used_at, created_at FROM email_tokens WHERE token = $1`
if err := r.db.Get(&emailToken, query, token); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrTokenNotFound
}
return nil, err
}
return &emailToken, nil
}
func (r *EmailTokenRepository) Validate(token, tokenType string) (*model.EmailToken, error) {
emailToken, err := r.GetByToken(token)
if err != nil {
return nil, err
}
if emailToken.Type != tokenType {
return nil, ErrTokenNotFound
}
if emailToken.UsedAt != nil {
return nil, ErrTokenUsed
}
if time.Now().After(emailToken.ExpiresAt) {
return nil, ErrTokenExpired
}
return emailToken, nil
}
func (r *EmailTokenRepository) MarkUsed(id int64) error {
query := `UPDATE email_tokens SET used_at = CURRENT_TIMESTAMP WHERE id = $1`
_, err := r.db.Exec(query, id)
return err
}
func (r *EmailTokenRepository) DeleteExpired() error {
query := `DELETE FROM email_tokens WHERE expires_at < CURRENT_TIMESTAMP OR used_at IS NOT NULL`
_, err := r.db.Exec(query)
return err
}
func (r *EmailTokenRepository) DeleteByUserAndType(userID int64, tokenType string) error {
query := `DELETE FROM email_tokens WHERE user_id = $1 AND type = $2 AND used_at IS NULL`
_, err := r.db.Exec(query, userID, tokenType)
return err
}
func generateSecureToken(length int) (string, error) {
bytes := make([]byte, length)
if _, err := rand.Read(bytes); err != nil {
return "", err
}
return hex.EncodeToString(bytes), nil
}

View File

@@ -0,0 +1,271 @@
package repository
import (
"database/sql"
"errors"
"time"
"github.com/daniil/homelab-api/internal/model"
"github.com/jmoiron/sqlx"
"github.com/lib/pq"
)
var ErrHabitNotFound = errors.New("habit not found")
var ErrLogNotFound = errors.New("log not found")
type HabitRepository struct {
db *sqlx.DB
}
func NewHabitRepository(db *sqlx.DB) *HabitRepository {
return &HabitRepository{db: db}
}
func (r *HabitRepository) Create(habit *model.Habit) error {
query := `
INSERT INTO habits (user_id, name, description, color, icon, frequency, target_days, target_count)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id, created_at, updated_at`
targetDays := pq.Array(habit.TargetDays)
if len(habit.TargetDays) == 0 {
targetDays = pq.Array([]int{0, 1, 2, 3, 4, 5, 6})
}
return r.db.QueryRow(query,
habit.UserID,
habit.Name,
habit.Description,
habit.Color,
habit.Icon,
habit.Frequency,
targetDays,
habit.TargetCount,
).Scan(&habit.ID, &habit.CreatedAt, &habit.UpdatedAt)
}
func (r *HabitRepository) GetByID(id, userID int64) (*model.Habit, error) {
var habit model.Habit
var targetDays pq.Int64Array
query := `
SELECT id, user_id, name, description, color, icon, frequency, target_days, target_count, is_archived, created_at, updated_at
FROM habits WHERE id = $1 AND user_id = $2`
err := r.db.QueryRow(query, id, userID).Scan(
&habit.ID, &habit.UserID, &habit.Name, &habit.Description,
&habit.Color, &habit.Icon, &habit.Frequency, &targetDays,
&habit.TargetCount, &habit.IsArchived, &habit.CreatedAt, &habit.UpdatedAt,
)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrHabitNotFound
}
return nil, err
}
habit.TargetDays = make([]int, len(targetDays))
for i, v := range targetDays {
habit.TargetDays[i] = int(v)
}
return &habit, nil
}
func (r *HabitRepository) ListByUser(userID int64, includeArchived bool) ([]model.Habit, error) {
query := `
SELECT id, user_id, name, description, color, icon, frequency, target_days, target_count, is_archived, created_at, updated_at
FROM habits WHERE user_id = $1`
if !includeArchived {
query += " AND is_archived = FALSE"
}
query += " ORDER BY created_at DESC"
rows, err := r.db.Query(query, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var habits []model.Habit
for rows.Next() {
var habit model.Habit
var targetDays pq.Int64Array
if err := rows.Scan(
&habit.ID, &habit.UserID, &habit.Name, &habit.Description,
&habit.Color, &habit.Icon, &habit.Frequency, &targetDays,
&habit.TargetCount, &habit.IsArchived, &habit.CreatedAt, &habit.UpdatedAt,
); err != nil {
return nil, err
}
habit.TargetDays = make([]int, len(targetDays))
for i, v := range targetDays {
habit.TargetDays[i] = int(v)
}
habits = append(habits, habit)
}
return habits, nil
}
func (r *HabitRepository) Update(habit *model.Habit) error {
query := `
UPDATE habits
SET name = $2, description = $3, color = $4, icon = $5, frequency = $6,
target_days = $7, target_count = $8, is_archived = $9, updated_at = CURRENT_TIMESTAMP
WHERE id = $1 AND user_id = $10
RETURNING updated_at`
return r.db.QueryRow(query,
habit.ID,
habit.Name,
habit.Description,
habit.Color,
habit.Icon,
habit.Frequency,
pq.Array(habit.TargetDays),
habit.TargetCount,
habit.IsArchived,
habit.UserID,
).Scan(&habit.UpdatedAt)
}
func (r *HabitRepository) Delete(id, userID int64) error {
query := `DELETE FROM habits WHERE id = $1 AND user_id = $2`
result, err := r.db.Exec(query, id, userID)
if err != nil {
return err
}
rows, _ := result.RowsAffected()
if rows == 0 {
return ErrHabitNotFound
}
return nil
}
// Logs
func (r *HabitRepository) CreateLog(log *model.HabitLog) error {
query := `
INSERT INTO habit_logs (habit_id, user_id, date, count, note)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (habit_id, date)
DO UPDATE SET count = habit_logs.count + EXCLUDED.count, note = EXCLUDED.note
RETURNING id, created_at`
return r.db.QueryRow(query, log.HabitID, log.UserID, log.Date, log.Count, log.Note).
Scan(&log.ID, &log.CreatedAt)
}
func (r *HabitRepository) GetLogs(habitID, userID int64, from, to time.Time) ([]model.HabitLog, error) {
query := `
SELECT id, habit_id, user_id, date, count, note, created_at
FROM habit_logs
WHERE habit_id = $1 AND user_id = $2 AND date >= $3 AND date <= $4
ORDER BY date DESC`
var logs []model.HabitLog
if err := r.db.Select(&logs, query, habitID, userID, from, to); err != nil {
return nil, err
}
return logs, nil
}
func (r *HabitRepository) DeleteLog(logID, userID int64) error {
query := `DELETE FROM habit_logs WHERE id = $1 AND user_id = $2`
result, err := r.db.Exec(query, logID, userID)
if err != nil {
return err
}
rows, _ := result.RowsAffected()
if rows == 0 {
return ErrLogNotFound
}
return nil
}
func (r *HabitRepository) GetUserLogsForDate(userID int64, date time.Time) ([]model.HabitLog, error) {
query := `
SELECT id, habit_id, user_id, date, count, note, created_at
FROM habit_logs
WHERE user_id = $1 AND date = $2`
var logs []model.HabitLog
if err := r.db.Select(&logs, query, userID, date); err != nil {
return nil, err
}
return logs, nil
}
func (r *HabitRepository) GetStats(habitID, userID int64) (*model.HabitStats, error) {
stats := &model.HabitStats{HabitID: habitID}
// Total logs
r.db.Get(&stats.TotalLogs, `SELECT COUNT(*) FROM habit_logs WHERE habit_id = $1 AND user_id = $2`, habitID, userID)
// This week
weekStart := time.Now().AddDate(0, 0, -int(time.Now().Weekday()))
r.db.Get(&stats.ThisWeek, `SELECT COUNT(*) FROM habit_logs WHERE habit_id = $1 AND user_id = $2 AND date >= $3`, habitID, userID, weekStart)
// This month
monthStart := time.Date(time.Now().Year(), time.Now().Month(), 1, 0, 0, 0, 0, time.UTC)
r.db.Get(&stats.ThisMonth, `SELECT COUNT(*) FROM habit_logs WHERE habit_id = $1 AND user_id = $2 AND date >= $3`, habitID, userID, monthStart)
// Streaks calculation
stats.CurrentStreak, stats.LongestStreak = r.calculateStreaks(habitID, userID)
return stats, nil
}
func (r *HabitRepository) calculateStreaks(habitID, userID int64) (current, longest int) {
query := `SELECT date FROM habit_logs WHERE habit_id = $1 AND user_id = $2 ORDER BY date DESC`
var dates []time.Time
if err := r.db.Select(&dates, query, habitID, userID); err != nil || len(dates) == 0 {
return 0, 0
}
today := time.Now().Truncate(24 * time.Hour)
yesterday := today.AddDate(0, 0, -1)
// Current streak
if dates[0].Truncate(24*time.Hour).Equal(today) || dates[0].Truncate(24*time.Hour).Equal(yesterday) {
current = 1
for i := 1; i < len(dates); i++ {
expected := dates[i-1].AddDate(0, 0, -1).Truncate(24 * time.Hour)
if dates[i].Truncate(24 * time.Hour).Equal(expected) {
current++
} else {
break
}
}
}
// Longest streak
streak := 1
longest = 1
for i := 1; i < len(dates); i++ {
expected := dates[i-1].AddDate(0, 0, -1).Truncate(24 * time.Hour)
if dates[i].Truncate(24 * time.Hour).Equal(expected) {
streak++
if streak > longest {
longest = streak
}
} else {
streak = 1
}
}
return current, longest
}

198
internal/repository/task.go Normal file
View File

@@ -0,0 +1,198 @@
package repository
import (
"database/sql"
"errors"
"time"
"github.com/daniil/homelab-api/internal/model"
"github.com/jmoiron/sqlx"
)
var ErrTaskNotFound = errors.New("task not found")
type TaskRepository struct {
db *sqlx.DB
}
func NewTaskRepository(db *sqlx.DB) *TaskRepository {
return &TaskRepository{db: db}
}
func (r *TaskRepository) Create(task *model.Task) error {
query := `
INSERT INTO tasks (user_id, title, description, icon, color, due_date, priority)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id, created_at, updated_at`
return r.db.QueryRow(query,
task.UserID,
task.Title,
task.Description,
task.Icon,
task.Color,
task.DueDate,
task.Priority,
).Scan(&task.ID, &task.CreatedAt, &task.UpdatedAt)
}
func (r *TaskRepository) GetByID(id, userID int64) (*model.Task, error) {
var task model.Task
query := `
SELECT id, user_id, title, description, icon, color, due_date, priority, completed_at, created_at, updated_at
FROM tasks WHERE id = $1 AND user_id = $2`
err := r.db.QueryRow(query, id, userID).Scan(
&task.ID, &task.UserID, &task.Title, &task.Description,
&task.Icon, &task.Color, &task.DueDate, &task.Priority,
&task.CompletedAt, &task.CreatedAt, &task.UpdatedAt,
)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrTaskNotFound
}
return nil, err
}
task.ProcessForJSON()
return &task, nil
}
func (r *TaskRepository) ListByUser(userID int64, completed *bool) ([]model.Task, error) {
query := `
SELECT id, user_id, title, description, icon, color, due_date, priority, completed_at, created_at, updated_at
FROM tasks WHERE user_id = $1`
if completed != nil {
if *completed {
query += " AND completed_at IS NOT NULL"
} else {
query += " AND completed_at IS NULL"
}
}
query += " ORDER BY COALESCE(due_date, '9999-12-31'::date), priority DESC, created_at DESC"
rows, err := r.db.Query(query, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var tasks []model.Task
for rows.Next() {
var task model.Task
if err := rows.Scan(
&task.ID, &task.UserID, &task.Title, &task.Description,
&task.Icon, &task.Color, &task.DueDate, &task.Priority,
&task.CompletedAt, &task.CreatedAt, &task.UpdatedAt,
); err != nil {
return nil, err
}
task.ProcessForJSON()
tasks = append(tasks, task)
}
return tasks, nil
}
func (r *TaskRepository) GetTodayTasks(userID int64) ([]model.Task, error) {
today := time.Now().Format("2006-01-02")
query := `
SELECT id, user_id, title, description, icon, color, due_date, priority, completed_at, created_at, updated_at
FROM tasks
WHERE user_id = $1 AND completed_at IS NULL AND due_date <= $2
ORDER BY priority DESC, due_date, created_at`
rows, err := r.db.Query(query, userID, today)
if err != nil {
return nil, err
}
defer rows.Close()
var tasks []model.Task
for rows.Next() {
var task model.Task
if err := rows.Scan(
&task.ID, &task.UserID, &task.Title, &task.Description,
&task.Icon, &task.Color, &task.DueDate, &task.Priority,
&task.CompletedAt, &task.CreatedAt, &task.UpdatedAt,
); err != nil {
return nil, err
}
task.ProcessForJSON()
tasks = append(tasks, task)
}
return tasks, nil
}
func (r *TaskRepository) Update(task *model.Task) error {
query := `
UPDATE tasks
SET title = $2, description = $3, icon = $4, color = $5, due_date = $6, priority = $7, updated_at = CURRENT_TIMESTAMP
WHERE id = $1 AND user_id = $8
RETURNING updated_at`
return r.db.QueryRow(query,
task.ID,
task.Title,
task.Description,
task.Icon,
task.Color,
task.DueDate,
task.Priority,
task.UserID,
).Scan(&task.UpdatedAt)
}
func (r *TaskRepository) Delete(id, userID int64) error {
query := `DELETE FROM tasks WHERE id = $1 AND user_id = $2`
result, err := r.db.Exec(query, id, userID)
if err != nil {
return err
}
rows, _ := result.RowsAffected()
if rows == 0 {
return ErrTaskNotFound
}
return nil
}
func (r *TaskRepository) Complete(id, userID int64) error {
query := `UPDATE tasks SET completed_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP WHERE id = $1 AND user_id = $2 AND completed_at IS NULL`
result, err := r.db.Exec(query, id, userID)
if err != nil {
return err
}
rows, _ := result.RowsAffected()
if rows == 0 {
return ErrTaskNotFound
}
return nil
}
func (r *TaskRepository) Uncomplete(id, userID int64) error {
query := `UPDATE tasks SET completed_at = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = $1 AND user_id = $2 AND completed_at IS NOT NULL`
result, err := r.db.Exec(query, id, userID)
if err != nil {
return err
}
rows, _ := result.RowsAffected()
if rows == 0 {
return ErrTaskNotFound
}
return nil
}

View File

@@ -0,0 +1,94 @@
package repository
import (
"database/sql"
"errors"
"github.com/daniil/homelab-api/internal/model"
"github.com/jmoiron/sqlx"
)
var ErrUserNotFound = errors.New("user not found")
var ErrUserExists = errors.New("user already exists")
type UserRepository struct {
db *sqlx.DB
}
func NewUserRepository(db *sqlx.DB) *UserRepository {
return &UserRepository{db: db}
}
func (r *UserRepository) Create(user *model.User) error {
query := `
INSERT INTO users (email, username, password_hash, email_verified)
VALUES ($1, $2, $3, $4)
RETURNING id, created_at, updated_at`
err := r.db.QueryRow(query, user.Email, user.Username, user.PasswordHash, user.EmailVerified).
Scan(&user.ID, &user.CreatedAt, &user.UpdatedAt)
if err != nil {
if isUniqueViolation(err) {
return ErrUserExists
}
return err
}
return nil
}
func (r *UserRepository) GetByID(id int64) (*model.User, error) {
var user model.User
query := `SELECT id, email, username, password_hash, email_verified, created_at, updated_at FROM users WHERE id = $1`
if err := r.db.Get(&user, query, id); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrUserNotFound
}
return nil, err
}
return &user, nil
}
func (r *UserRepository) GetByEmail(email string) (*model.User, error) {
var user model.User
query := `SELECT id, email, username, password_hash, email_verified, created_at, updated_at FROM users WHERE email = $1`
if err := r.db.Get(&user, query, email); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrUserNotFound
}
return nil, err
}
return &user, nil
}
func (r *UserRepository) Update(user *model.User) error {
query := `
UPDATE users
SET username = $2, updated_at = CURRENT_TIMESTAMP
WHERE id = $1
RETURNING updated_at`
return r.db.QueryRow(query, user.ID, user.Username).Scan(&user.UpdatedAt)
}
func (r *UserRepository) UpdatePassword(id int64, passwordHash string) error {
query := `UPDATE users SET password_hash = $2, updated_at = CURRENT_TIMESTAMP WHERE id = $1`
_, err := r.db.Exec(query, id, passwordHash)
return err
}
func (r *UserRepository) SetEmailVerified(id int64) error {
query := `UPDATE users SET email_verified = TRUE, updated_at = CURRENT_TIMESTAMP WHERE id = $1`
_, err := r.db.Exec(query, id)
return err
}
func isUniqueViolation(err error) bool {
return err != nil && (err.Error() == "pq: duplicate key value violates unique constraint \"users_email_key\"" ||
err.Error() == "UNIQUE constraint failed: users.email")
}