diff --git a/LibraryApp.Domain/BookCountReport.cs b/LibraryApp.Domain/BookCountReport.cs new file mode 100644 index 0000000..328e112 --- /dev/null +++ b/LibraryApp.Domain/BookCountReport.cs @@ -0,0 +1,30 @@ +using LibraryApp.Domain.Entities; + +namespace LibraryApp.Domain; + +public class BookCountReport +{ + public ShelfId ShelfId { get; } + public DateTime CountedAt { get; } + public IReadOnlyList MissingInstances { get; } + public int TotalExpected { get; } + public int TotalFound { get; } + + public BookCountReport( + ShelfId shelfId, + DateTime countedAt, + IReadOnlyList allInstancesOnShelf, + IReadOnlyList scannedBarcodes) + { + ShelfId = shelfId; + CountedAt = countedAt; + TotalExpected = allInstancesOnShelf.Count; + + var scannedSet = scannedBarcodes.Select(b => b.Barcode).ToHashSet(); + MissingInstances = allInstancesOnShelf + .Where(i => !scannedSet.Contains(i.Barcode.Barcode)) + .ToList(); + + TotalFound = TotalExpected - MissingInstances.Count; + } +} diff --git a/LibraryApp.Domain/Class1.cs b/LibraryApp.Domain/Class1.cs deleted file mode 100644 index 3b7e276..0000000 --- a/LibraryApp.Domain/Class1.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace LibraryApp.Domain; - -public class Class1 -{ - -} \ No newline at end of file diff --git a/LibraryApp.Domain/DbId.cs b/LibraryApp.Domain/DbId.cs new file mode 100644 index 0000000..d31c320 --- /dev/null +++ b/LibraryApp.Domain/DbId.cs @@ -0,0 +1,13 @@ +namespace LibraryApp.Domain; + +public record DbId +{ + public string Id { get; init; } + public bool BlankId => string.IsNullOrWhiteSpace(Id); + public DbId(string id) + { + if(id is null) + throw new ArgumentNullException(nameof(id)); + Id = id; + } +} diff --git a/LibraryApp.Domain/Entities/Book.cs b/LibraryApp.Domain/Entities/Book.cs new file mode 100644 index 0000000..6a54e4c --- /dev/null +++ b/LibraryApp.Domain/Entities/Book.cs @@ -0,0 +1,65 @@ +namespace LibraryApp.Domain.Entities; + +public class Book +{ + public BookId Id { get; private set; } + public BookTitle Title { get; private set; } + public BookAuthor Author { get; private set; } + + public Book(BookTitle title, BookAuthor author) + : this(new BookId(string.Empty), title, author) + { } + + public Book(BookId id, BookTitle title, BookAuthor author) + { + Id = id; + Title = title; + Author = author; + } + + public Book UpdateTitle(string newTitle) + { + Title = new (newTitle); + return this; + } + public Book UpdateAuthor(string newAuthor) + { + Author = new (newAuthor); + return this; + } +} + +public record BookId : DbId +{ + public BookId(string id) : base(id) {} +} + +public record BookTitle +{ + public string Title { get; init; } + + public BookTitle(string title) + { + if(string.IsNullOrWhiteSpace(title)) + { + throw new ArgumentException("Title cannot be empty."); + } + Title = title; + } +} + +public record BookAuthor +{ + public string Author { get; init; } + + public BookAuthor(string author) + { + if(string.IsNullOrWhiteSpace(author)) + { + throw new ArgumentException("Author cannot be empty."); + } + Author = author; + } +} + + diff --git a/LibraryApp.Domain/Entities/BookInstance.cs b/LibraryApp.Domain/Entities/BookInstance.cs new file mode 100644 index 0000000..799a747 --- /dev/null +++ b/LibraryApp.Domain/Entities/BookInstance.cs @@ -0,0 +1,99 @@ +namespace LibraryApp.Domain.Entities; + +public enum BookInstanceStatus +{ + OnShelf, + Lent, + PutAside, + Lost +} + +public class BookInstance +{ + public BookInstanceId Id { get; private set; } + public BookId BookId { get; private set; } + public ShelfId ShelfId { get; private set; } + public BookInstanceBarcode Barcode { get; private set; } + public BookInstanceStatus Status { get; private set; } + + public BookInstance(BookId bookId, ShelfId shelfId, BookInstanceBarcode barcode) + : this(new BookInstanceId(string.Empty), bookId, shelfId, barcode, BookInstanceStatus.OnShelf) + { } + + public BookInstance(BookInstanceId id, BookId bookId, ShelfId shelfId, BookInstanceBarcode barcode, BookInstanceStatus status) + { + Id = id; + BookId = bookId; + ShelfId = shelfId; + Barcode = barcode; + Status = status; + } + + public BookInstance MarkAsLent() + { + if (Status != BookInstanceStatus.OnShelf) + throw new InvalidOperationException($"Cannot lend a book that is not on the shelf. Current status: {Status}."); + Status = BookInstanceStatus.Lent; + return this; + } + + public BookInstance ReturnToShelf() + { + if (Status != BookInstanceStatus.Lent) + throw new InvalidOperationException($"Cannot return a book that is not lent out. Current status: {Status}."); + Status = BookInstanceStatus.OnShelf; + return this; + } + + public BookInstance MarkAsPutAside() + { + if (Status != BookInstanceStatus.OnShelf) + throw new InvalidOperationException($"Cannot put aside a book that is not on the shelf. Current status: {Status}."); + Status = BookInstanceStatus.PutAside; + return this; + } + + public BookInstance PlaceOnShelf(ShelfId shelfId) + { + if (Status != BookInstanceStatus.PutAside) + throw new InvalidOperationException($"Cannot place on shelf a book that is not put aside. Current status: {Status}."); + ShelfId = shelfId; + Status = BookInstanceStatus.OnShelf; + return this; + } + + public BookInstance MarkAsLost() + { + if (Status != BookInstanceStatus.OnShelf) + throw new InvalidOperationException($"Cannot mark as lost a book that is not on the shelf. Current status: {Status}."); + Status = BookInstanceStatus.Lost; + return this; + } + + public BookInstance UnmarkAsLost() + { + if (Status != BookInstanceStatus.Lost) + throw new InvalidOperationException($"Cannot unmark as lost a book that is not marked as lost. Current status: {Status}."); + Status = BookInstanceStatus.OnShelf; + return this; + } +} + +public record BookInstanceId : DbId +{ + public BookInstanceId(string id) : base(id) {} +} + +public record BookInstanceBarcode +{ + public string Barcode { get; init; } + + public BookInstanceBarcode(string barcode) + { + if(string.IsNullOrWhiteSpace(barcode)) + { + throw new ArgumentException("Barcode cannot be empty."); + } + Barcode = barcode; + } +} diff --git a/LibraryApp.Domain/Entities/LendRecord.cs b/LibraryApp.Domain/Entities/LendRecord.cs new file mode 100644 index 0000000..ddbd8dc --- /dev/null +++ b/LibraryApp.Domain/Entities/LendRecord.cs @@ -0,0 +1,72 @@ +namespace LibraryApp.Domain.Entities; + +public class LendRecord +{ + public LendRecordId Id { get; private set; } + public BookInstanceId BookInstanceId { get; private set; } + public MemberId MemberId { get; private set; } + public LendCode Code { get; private set; } + public DateTime LendDate { get; private set; } + public DateTime? ReturnDate { get; private set; } + + public LendRecord(BookInstanceId bookInstanceId, MemberId memberId, LendCode code) + : this(new LendRecordId(string.Empty), bookInstanceId, memberId, code, DateTime.UtcNow, null) + { } + + public LendRecord(LendRecordId id, BookInstanceId bookInstanceId, MemberId memberId, LendCode code, DateTime lendDate, DateTime? returnDate) + { + Id = id; + BookInstanceId = bookInstanceId; + MemberId = memberId; + Code = code; + LendDate = lendDate; + ReturnDate = returnDate; + } + + public LendRecord MarkAsReturned() + { + if(ReturnDate != null) + throw new InvalidOperationException("This lend record is already marked as returned."); + ReturnDate = DateTime.UtcNow; + return this; + } + + public LendRecord UpdateLendDate(DateTime newLendDate) + { + if(ReturnDate != null) + throw new InvalidOperationException("Cannot update lend date for a record that is already marked as returned."); + LendDate = newLendDate; + return this; + } + + public LendRecord UnmarkAsReturned() + { + if(ReturnDate == null) + throw new InvalidOperationException("This lend record is not marked as returned."); + ReturnDate = null; + return this; + } +} + +public record LendRecordId : DbId +{ + public LendRecordId(string id) : base(id) {} +} + +/// +/// A short, human-friendly code written on the physical lend slip alongside the member's name. +/// Must be unique per lend record and no longer than 8 characters. +/// +public record LendCode +{ + public string Code { get; init; } + + public LendCode(string code) + { + if (string.IsNullOrWhiteSpace(code)) + throw new ArgumentException("Lend code cannot be empty."); + if (code.Length > 8) + throw new ArgumentException("Lend code cannot exceed 8 characters."); + Code = code.ToUpperInvariant(); + } +} \ No newline at end of file diff --git a/LibraryApp.Domain/Entities/Member.cs b/LibraryApp.Domain/Entities/Member.cs new file mode 100644 index 0000000..6ce55b0 --- /dev/null +++ b/LibraryApp.Domain/Entities/Member.cs @@ -0,0 +1,119 @@ +namespace LibraryApp.Domain.Entities; + +public class Member +{ + public MemberId Id { get; private set; } + public MemberName Name { get; private set; } + public List Emails { get; private set; } = new(); + public List Phones { get; private set; } = new(); + public int PrimaryEmailIndex { get; private set; } = 0; + public int PrimaryPhoneIndex { get; private set; } = 0; + + public Member(MemberName name, MemberEmail email, MemberPhone phone) + : this(new MemberId(string.Empty), name, email, phone) + { } + public Member(MemberId id, MemberName name, MemberEmail email, MemberPhone phone) + { + + Id = id; + Name = name; + Emails.Add(email); + Phones.Add(phone); + } + + public Member UpdateName(string newName) + { + Name = new (newName); + return this; + } + public Member AddEmail(string email) + { + Emails.Add(new MemberEmail(email)); + return this; + } + public Member AddPhone(string phone) + { + Phones.Add(new MemberPhone(phone)); + return this; + } + public Member SetPrimaryEmail(MemberEmail email) + { + var index = Emails.FindIndex(e => e.Email == email.Email); + if(index == -1) + throw new ArgumentException("Email not found in member's email list.", nameof(email)); + PrimaryEmailIndex = index; + return this; + } + public Member SetPrimaryPhone(MemberPhone phone) + { + var index = Phones.FindIndex(p => p.Phone == phone.Phone); + if(index == -1) + throw new ArgumentException("Phone not found in member's phone list.", nameof(phone)); + PrimaryPhoneIndex = index; + return this; + } +} + +public record MemberId : DbId +{ + public MemberId(string id) : base(id) {} +} + +public record MemberEmail +{ + public string Email { get; init; } + + public MemberEmail(string email) + { + if(string.IsNullOrWhiteSpace(email)) + throw new ArgumentException("Email cannot be empty."); + + if(!email.Contains("@")) + throw new ArgumentException("Email must contain '@'."); + + if(!email.Contains(".")) + throw new ArgumentException("Email must contain '.'."); + + if(email.StartsWith("@") || email.EndsWith("@")) + throw new ArgumentException("Email cannot start or end with '@'."); + + var atCount = email.Count(c => c == '@'); + if(atCount > 1) + throw new ArgumentException("Email cannot contain more than one '@'."); + + var dotCount = email.Count(c => c == '.'); + if(dotCount > 1) + throw new ArgumentException("Email cannot contain more than one '.'."); + + Email = email; + } +} + +public record MemberName +{ + public string Name { get; init; } + + public MemberName(string name) + { + if(string.IsNullOrWhiteSpace(name)) + { + throw new ArgumentException("Member name cannot be empty."); + } + Name = name; + } +} + +public record MemberPhone +{ + private const string VALID_PHONE_PATTERN = @"^\+?[1-9]\d{1,14}$"; + public string Phone { get; init; } + + public MemberPhone(string phone) + { + if(string.IsNullOrWhiteSpace(phone)) + throw new ArgumentException("Phone number cannot be empty."); + if(!System.Text.RegularExpressions.Regex.IsMatch(phone, VALID_PHONE_PATTERN)) + throw new ArgumentException("Phone number is not in a valid format."); + Phone = phone; + } +} diff --git a/LibraryApp.Domain/Entities/Shelf.cs b/LibraryApp.Domain/Entities/Shelf.cs new file mode 100644 index 0000000..42518ad --- /dev/null +++ b/LibraryApp.Domain/Entities/Shelf.cs @@ -0,0 +1,44 @@ +namespace LibraryApp.Domain.Entities; + +public class Shelf +{ + public ShelfId Id { get; private set; } + public ShelfName Name { get; private set; } + public DateTime? LastCountedAt { get; private set; } + + public Shelf(ShelfName name) + : this(new ShelfId(string.Empty), name, null) + { } + + public Shelf(ShelfId id, ShelfName name, DateTime? lastCountedAt) + { + Id = id; + Name = name; + LastCountedAt = lastCountedAt; + } + + public Shelf RecordCount() + { + LastCountedAt = DateTime.UtcNow; + return this; + } +} + +public record ShelfId : DbId +{ + public ShelfId(string id) : base(id) {} +} + +public record ShelfName +{ + public string Name { get; init; } + + public ShelfName(string name) + { + if(string.IsNullOrWhiteSpace(name)) + { + throw new ArgumentException("Shelf name cannot be empty."); + } + Name = name; + } +} diff --git a/LibraryApp.Domain/Error.cs b/LibraryApp.Domain/Error.cs new file mode 100644 index 0000000..5a01d10 --- /dev/null +++ b/LibraryApp.Domain/Error.cs @@ -0,0 +1,14 @@ +namespace LibraryApp.Domain; + +public record Error +{ + public string Message { get; init; } + + public Error(string message) + { + Message = message; + } + + public static implicit operator Error(string message) => new Error(message); + public static implicit operator string(Error error) => error.Message; +} \ No newline at end of file diff --git a/LibraryApp.Domain/Repositories/IBooks.cs b/LibraryApp.Domain/Repositories/IBooks.cs new file mode 100644 index 0000000..f1203e6 --- /dev/null +++ b/LibraryApp.Domain/Repositories/IBooks.cs @@ -0,0 +1,56 @@ +using LibraryApp.Domain.Entities; + +namespace LibraryApp.Domain.Repositories; + +public interface IBookRepository +{ + public Task> GetAsync(BookId id); + public Task> UpdateAsync(Book book); + public Task>> GetAllAsync(int pageNumber, int pageSize); + public Task> AddAsync(Book book); + public Task DeleteAsync(BookId id); +} + +public interface IBookInstanceRepository +{ + public Task> GetByIdAsync(BookInstanceId id); + public Task> GetByBarcodeAsync(BookInstanceBarcode barcode); + public Task>> GetAllByShelfIdAsync(ShelfId shelfId); + public Task>> GetAllByStatusAsync(BookInstanceStatus status); + public Task> UpdateAsync(BookInstance bookInstance); + public Task>> GetAllAsync(int pageNumber, int pageSize); + public Task> AddAsync(BookInstance bookInstance); + public Task DeleteAsync(BookInstanceId id); +} + +public interface IShelfRepository +{ + public Task> GetAsync(ShelfId id); + public Task> GetByBookInstanceBarcodeAsync(BookInstanceBarcode barcode); + public Task> GetNextForCountAsync(); + public Task> UpdateAsync(Shelf shelf); + public Task>> GetAllAsync(int pageNumber, int pageSize); + public Task> AddAsync(Shelf shelf); + public Task DeleteAsync(ShelfId id); +} + +public interface IMemberRepository +{ + public Task> GetAsync(MemberId id); + public Task> UpdateAsync(Member member); + public Task>> GetAllAsync(int pageNumber, int pageSize); + public Task> AddAsync(Member member); + public Task DeleteAsync(MemberId id); +} + +public interface ILendRecordRepository +{ + public Task> GetAsync(LendRecordId id); + public Task>> GetAllByMemberIdAsync(MemberId memberId); + public Task> GetActiveByBookInstanceIdAsync(BookInstanceId bookInstanceId); + public Task ExistsWithCodeAsync(LendCode code); + public Task> UpdateAsync(LendRecord lendRecord); + public Task>> GetAllAsync(int pageNumber, int pageSize); + public Task> AddAsync(LendRecord lendRecord); + public Task DeleteAsync(LendRecordId id); +} \ No newline at end of file diff --git a/LibraryApp.Domain/Result.cs b/LibraryApp.Domain/Result.cs new file mode 100644 index 0000000..0ddbead --- /dev/null +++ b/LibraryApp.Domain/Result.cs @@ -0,0 +1,23 @@ +namespace LibraryApp.Domain; + +public record Result +{ + public required bool IsSuccess { get; init; } + public Error? ErrorMessage { get; init; } + + private Result() { } + + public static Result Success() => new Result { IsSuccess = true }; + public static Result Failure(string errorMessage) => new Result { IsSuccess = false, ErrorMessage = errorMessage }; +} +public record Result +{ + public required bool IsSuccess { get; init; } + public T? Value { get; init; } + public Error? ErrorMessage { get; init; } + + private Result() { } + + public static Result Success(T value) => new Result { IsSuccess = true, Value = value }; + public static Result Failure(string errorMessage) => new Result { IsSuccess = false, ErrorMessage = errorMessage }; +} \ No newline at end of file diff --git a/LibraryApp.Domain/Services/BookCountService.cs b/LibraryApp.Domain/Services/BookCountService.cs new file mode 100644 index 0000000..fb1cdf0 --- /dev/null +++ b/LibraryApp.Domain/Services/BookCountService.cs @@ -0,0 +1,31 @@ +using LibraryApp.Domain; +using LibraryApp.Domain.Entities; + +namespace LibraryApp.Domain.Services; + +public class BookCountService +{ + private readonly IBookCountReportPrinter _printer; + + public BookCountService(IBookCountReportPrinter printer) + { + _printer = printer; + } + + public async Task> PerformCountAsync( + Shelf shelf, + IReadOnlyList scannedBarcodes, + IReadOnlyList allInstancesOnShelf) + { + shelf.RecordCount(); + + var report = new BookCountReport(shelf.Id, DateTime.UtcNow, allInstancesOnShelf, scannedBarcodes); + + foreach (var instance in report.MissingInstances) + instance.MarkAsLost(); + + await _printer.PrintAsync(report); + + return Result.Success(report); + } +} diff --git a/LibraryApp.Domain/Services/IBookCountReportPrinter.cs b/LibraryApp.Domain/Services/IBookCountReportPrinter.cs new file mode 100644 index 0000000..98a030f --- /dev/null +++ b/LibraryApp.Domain/Services/IBookCountReportPrinter.cs @@ -0,0 +1,6 @@ +namespace LibraryApp.Domain.Services; + +public interface IBookCountReportPrinter +{ + Task PrintAsync(BookCountReport report); +} diff --git a/LibraryApp.Domain/Services/LendingService.cs b/LibraryApp.Domain/Services/LendingService.cs new file mode 100644 index 0000000..44769cb --- /dev/null +++ b/LibraryApp.Domain/Services/LendingService.cs @@ -0,0 +1,49 @@ +using LibraryApp.Domain.Entities; +using LibraryApp.Domain.Repositories; + +namespace LibraryApp.Domain.Services; + +public class LendingService +{ + private readonly ILendRecordRepository _lendRecords; + + public LendingService(ILendRecordRepository lendRecords) + { + _lendRecords = lendRecords; + } + + public async Task> LendBookAsync(BookInstance bookInstance, Member member) + { + if (bookInstance.Status != BookInstanceStatus.OnShelf) + return Result.Failure($"Book is not available for lending. Current status: {bookInstance.Status}."); + + var code = await GenerateUniqueLendCodeAsync(); + bookInstance.MarkAsLent(); + var record = new LendRecord(bookInstance.Id, member.Id, code); + return Result.Success(record); + } + + public Task ReturnBookAsync(BookInstance bookInstance, LendRecord lendRecord) + { + if (bookInstance.Status != BookInstanceStatus.Lent) + return Task.FromResult(Result.Failure($"Book is not currently lent out. Current status: {bookInstance.Status}.")); + + bookInstance.ReturnToShelf(); + lendRecord.MarkAsReturned(); + return Task.FromResult(Result.Success()); + } + + private async Task GenerateUniqueLendCodeAsync() + { + const string chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + var random = Random.Shared; + + while (true) + { + var code = new string(Enumerable.Range(0, 6).Select(_ => chars[random.Next(chars.Length)]).ToArray()); + var lendCode = new LendCode(code); + if (!await _lendRecords.ExistsWithCodeAsync(lendCode)) + return lendCode; + } + } +}