diff --git a/model/user.go b/model/user.go new file mode 100644 index 0000000..cbbb9bc --- /dev/null +++ b/model/user.go @@ -0,0 +1,13 @@ +package model + +import ( + "time" +) + +type User struct { + Username string `json:"username"` // Must be unique + Password []byte `json:"password"` + + DateCreated time.Time `json:"date_created"` + DateUpdated time.Time `json:"date_updated"` +} diff --git a/repository/users.go b/repository/users.go new file mode 100644 index 0000000..a6a8ba9 --- /dev/null +++ b/repository/users.go @@ -0,0 +1,166 @@ +package repository + +import ( + "context" + "database/sql" + "encoding/base64" + "errors" + "log/slog" + "time" + + "forge.capytal.company/capytalcode/project-comicverse/model" + "forge.capytal.company/loreddev/x/tinyssert" +) + +type UserRepository struct { + db *sql.DB + + ctx context.Context + log *slog.Logger + assert tinyssert.Assertions +} + +func NewUserRepository( + db *sql.DB, + ctx context.Context, + logger *slog.Logger, + assert tinyssert.Assertions, +) (*UserRepository, error) { + assert.NotNil(db) + assert.NotNil(logger) + + _, err := db.Exec(`CREATE TABLE IF NOT EXISTS users ( + username TEXT NOT NULL PRIMARY KEY, + password_hash TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + )`) + if err != nil { + return nil, err + } + + return &UserRepository{ + db: db, + log: logger, + assert: assert, + }, nil +} + +func (r *UserRepository) Create(u model.User) (model.User, error) { + tx, err := r.db.BeginTx(r.ctx, nil) + if err != nil { + return model.User{}, err + } + + q := ` + INSERT INTO users (username, password_hash, created_at, updated_at) + VALUES (:username, :password_hash, :created_at, :updated_at) + ` + + log := r.log.With(slog.String("username", u.Username), slog.String("query", q)) + log.DebugContext(r.ctx, "Inserting new user") + + t := time.Now() + + passwd := base64.URLEncoding.EncodeToString(u.Password) + + _, err = tx.ExecContext(r.ctx, q, + sql.Named("username", u.Username), + sql.Named("password_hash", passwd), + sql.Named("created_at", t.Format(repositoryDateFormat)), + sql.Named("updated_at", t.Format(repositoryDateFormat))) + if err != nil { + log.ErrorContext(r.ctx, "Failed to create user", slog.String("error", err.Error())) + return model.User{}, nil + } + + if err := tx.Commit(); err != nil { + log.ErrorContext(r.ctx, "Failed to commit transaction", slog.String("error", err.Error())) + return model.User{}, err + } + + return u, nil +} + +func (r *UserRepository) GetByUsername(username string) (model.User, error) { + tx, err := r.db.BeginTx(r.ctx, nil) + if err != nil { + return model.User{}, err + } + + q := ` + SELECT FROM users (username, password_hash, created_at, updated_at) + WHERE username = :username + ` + + log := r.log.With(slog.String("username", username), slog.String("query", q)) + log.DebugContext(r.ctx, "Querying user") + + row := tx.QueryRowContext(r.ctx, q, sql.Named("username", username)) + + var password_hash, dateCreated, dateUpdated string + if err = row.Scan(&username, &password_hash, &dateCreated, &dateUpdated); err != nil { + return model.User{}, err + } + + if err := tx.Commit(); err != nil { + log.ErrorContext(r.ctx, "Failed to commit transaction", slog.String("error", err.Error())) + return model.User{}, err + } + + passwd, err := base64.URLEncoding.DecodeString(password_hash) + if err != nil { + return model.User{}, err + } + + c, err := time.Parse(repositoryDateFormat, dateCreated) + if err != nil { + return model.User{}, errors.Join(ErrInvalidData, err) + } + + u, err := time.Parse(repositoryDateFormat, dateUpdated) + if err != nil { + return model.User{}, errors.Join(ErrInvalidData, err) + } + + return model.User{ + Username: username, + Password: passwd, + DateCreated: c, + DateUpdated: u, + }, nil +} + +func (r *UserRepository) Delete(u model.User) error { + tx, err := r.db.BeginTx(r.ctx, nil) + if err != nil { + return err + } + + q := ` + DELETE FROM users WHERE username = :username + ` + + log := r.log.With(slog.String("username", u.Username), slog.String("query", q)) + log.DebugContext(r.ctx, "Deleting user") + + _, err = tx.ExecContext(r.ctx, q, sql.Named("username", u.Username)) + if err != nil { + log.ErrorContext(r.ctx, "Failed to delete user", slog.String("error", err.Error())) + return err + } + + if err := tx.Commit(); err != nil { + log.ErrorContext(r.ctx, "Failed to commit transaction", slog.String("error", err.Error())) + return err + } + + return nil +} + +var ( + ErrNotFound = sql.ErrNoRows + ErrInvalidData = errors.New("model was saved with invalid data") + + repositoryDateFormat = time.RFC3339 +)