diff --git a/summercms/src/model/User.scala b/summercms/src/model/User.scala new file mode 100644 index 0000000..7524268 --- /dev/null +++ b/summercms/src/model/User.scala @@ -0,0 +1,26 @@ +package model + +import java.time.Instant + +/** User domain model + * + * Represents a registered user in the system. Maps to the summer_users table. + * + * @param id + * Auto-generated primary key + * @param email + * Unique email address, used for login + * @param passwordHash + * Bcrypt-hashed password (never store plain text) + * @param createdAt + * Timestamp when user was created + * @param updatedAt + * Timestamp of last update + */ +case class User( + id: Long, + email: String, + passwordHash: String, + createdAt: Instant, + updatedAt: Instant +) diff --git a/summercms/src/repository/RepositoryError.scala b/summercms/src/repository/RepositoryError.scala new file mode 100644 index 0000000..992a341 --- /dev/null +++ b/summercms/src/repository/RepositoryError.scala @@ -0,0 +1,39 @@ +package repository + +/** Typed error ADT for repository operations + * + * Provides compile-time checked errors for all data access operations. Each + * error type includes context for proper error handling and user-friendly + * messages. + * + * Usage: + * {{{ + * def findUser(id: Long): IO[RepositoryError, User] = ??? + * + * // Handle specific error types + * result.catchSome { + * case RepositoryError.NotFound(entity, id) => + * ZIO.fail(HttpError.NotFound(s"$entity $id not found")) + * } + * }}} + */ +sealed trait RepositoryError + +object RepositoryError: + + /** Entity with given ID was not found */ + case class NotFound(entity: String, id: Long) extends RepositoryError + + /** Entity with given field value was not found */ + case class NotFoundByField(entity: String, field: String, value: String) + extends RepositoryError + + /** Operation would violate uniqueness constraint */ + case class Conflict(entity: String, message: String) extends RepositoryError + + /** Input validation failed */ + case class ValidationError(errors: List[String]) extends RepositoryError + + /** Database-level error occurred */ + case class DatabaseError(message: String, cause: Option[Throwable] = None) + extends RepositoryError diff --git a/summercms/src/repository/UserRepository.scala b/summercms/src/repository/UserRepository.scala new file mode 100644 index 0000000..accf37d --- /dev/null +++ b/summercms/src/repository/UserRepository.scala @@ -0,0 +1,172 @@ +package repository + +import io.getquill.* +import io.getquill.jdbczio.Quill +import model.User +import zio.* + +import java.time.Instant + +/** User repository trait defining CRUD operations + * + * All methods return typed IO with RepositoryError for proper error handling. + * Implementations use Quill for compile-time SQL validation. + * + * Usage: + * {{{ + * ZIO.serviceWithZIO[UserRepository](_.findById(1L)) + * .provide(UserRepository.live, QuillContext.quillLive) + * }}} + */ +trait UserRepository: + /** Find user by ID + * + * @param id + * User ID + * @return + * Some(user) if found, None if not found + */ + def findById(id: Long): IO[RepositoryError, Option[User]] + + /** Find user by email address + * + * @param email + * Email to search for (case-sensitive) + * @return + * Some(user) if found, None if not found + */ + def findByEmail(email: String): IO[RepositoryError, Option[User]] + + /** Create new user + * + * @param email + * Unique email address + * @param passwordHash + * Pre-hashed password (use bcrypt) + * @return + * Created user with generated ID + * @throws RepositoryError.Conflict + * if email already exists + */ + def create(email: String, passwordHash: String): IO[RepositoryError, User] + + /** Update existing user + * + * @param user + * User with updated fields (ID must exist) + * @return + * Updated user + * @throws RepositoryError.NotFound + * if user doesn't exist + */ + def update(user: User): IO[RepositoryError, User] + + /** Delete user by ID + * + * @param id + * User ID to delete + * @return + * Unit on success + * @throws RepositoryError.NotFound + * if user doesn't exist + */ + def delete(id: Long): IO[RepositoryError, Unit] + +object UserRepository: + /** Live implementation layer requiring Quill.Postgres + * + * Provides UserRepository backed by PostgreSQL via Quill. + */ + val live: ZLayer[Quill.Postgres[SnakeCase], Nothing, UserRepository] = + ZLayer.fromFunction(UserRepositoryLive(_)) + + // Accessor methods for ZIO service pattern + def findById(id: Long): ZIO[UserRepository, RepositoryError, Option[User]] = + ZIO.serviceWithZIO(_.findById(id)) + + def findByEmail( + email: String + ): ZIO[UserRepository, RepositoryError, Option[User]] = + ZIO.serviceWithZIO(_.findByEmail(email)) + + def create( + email: String, + passwordHash: String + ): ZIO[UserRepository, RepositoryError, User] = + ZIO.serviceWithZIO(_.create(email, passwordHash)) + + def update(user: User): ZIO[UserRepository, RepositoryError, User] = + ZIO.serviceWithZIO(_.update(user)) + + def delete(id: Long): ZIO[UserRepository, RepositoryError, Unit] = + ZIO.serviceWithZIO(_.delete(id)) + +/** Live UserRepository implementation using Quill + * + * Provides compile-time validated SQL queries against PostgreSQL. Handles SQL + * exceptions and converts them to typed RepositoryError values. + * + * Table mapping: User -> summer_users (snake_case columns) + * + * @param quill + * Quill PostgreSQL context with SnakeCase naming + */ +class UserRepositoryLive(quill: Quill.Postgres[SnakeCase]) + extends UserRepository: + import quill.* + + /** Table schema mapping to summer_users table */ + inline def users = quote(querySchema[User]("summer_users")) + + override def findById(id: Long): IO[RepositoryError, Option[User]] = + run(users.filter(_.id == lift(id))) + .map(_.headOption) + .refineOrDie { case e: java.sql.SQLException => + RepositoryError.DatabaseError(e.getMessage, Some(e)) + } + + override def findByEmail(email: String): IO[RepositoryError, Option[User]] = + run(users.filter(_.email == lift(email))) + .map(_.headOption) + .refineOrDie { case e: java.sql.SQLException => + RepositoryError.DatabaseError(e.getMessage, Some(e)) + } + + override def create( + email: String, + passwordHash: String + ): IO[RepositoryError, User] = + val now = Instant.now() + val newUser = User(0, email, passwordHash, now, now) + run(users.insertValue(lift(newUser)).returningGenerated(_.id)) + .map(generatedId => newUser.copy(id = generatedId)) + .refineOrDie { + case e: java.sql.SQLException if e.getSQLState == "23505" => + RepositoryError.Conflict("User", s"Email $email already exists") + case e: java.sql.SQLException => + RepositoryError.DatabaseError(e.getMessage, Some(e)) + } + + override def update(user: User): IO[RepositoryError, User] = + val updatedUser = user.copy(updatedAt = Instant.now()) + run( + users + .filter(_.id == lift(user.id)) + .updateValue(lift(updatedUser)) + ).refineOrDie { case e: java.sql.SQLException => + RepositoryError.DatabaseError(e.getMessage, Some(e)) + }.flatMap { rowsUpdated => + if rowsUpdated == 0 then + ZIO.fail(RepositoryError.NotFound("User", user.id)) + else ZIO.succeed(updatedUser) + } + + override def delete(id: Long): IO[RepositoryError, Unit] = + run(users.filter(_.id == lift(id)).delete) + .refineOrDie { case e: java.sql.SQLException => + RepositoryError.DatabaseError(e.getMessage, Some(e)) + } + .flatMap { rowsDeleted => + if rowsDeleted == 0 then ZIO.fail(RepositoryError.NotFound("User", id)) + else ZIO.unit + }