Initial commit: Homelab API
This commit is contained in:
102
internal/repository/db.go
Normal file
102
internal/repository/db.go
Normal 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
|
||||
}
|
||||
113
internal/repository/email_token.go
Normal file
113
internal/repository/email_token.go
Normal 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
|
||||
}
|
||||
271
internal/repository/habit.go
Normal file
271
internal/repository/habit.go
Normal 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
198
internal/repository/task.go
Normal 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
|
||||
}
|
||||
94
internal/repository/user.go
Normal file
94
internal/repository/user.go
Normal 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")
|
||||
}
|
||||
Reference in New Issue
Block a user