From 922021d088d9ca9844c4ac55d5599f0618730ee3 Mon Sep 17 00:00:00 2001 From: lumijiez <59575049+lumijiez@users.noreply.github.com> Date: Wed, 11 Jun 2025 02:12:49 +0300 Subject: [PATCH] Redo controllers --- .../Exceptions/DataUpdateException.cs | 7 ++ src/Imprink.Application/Users/Dtos/UserDto.cs | 3 +- .../Users/SetUserFullNameHandler.cs | 48 +++++++++++++ .../Users/SetUserPhoneHandler.cs | 43 +++++++++++- .../Users/SyncUserHandler.cs | 30 +++++--- src/Imprink.Domain/Entities/Users/User.cs | 4 +- .../Repositories/Users/IUserRepository.cs | 6 +- .../Configuration/Users/UserConfiguration.cs | 5 +- ...> 20250610225629_InitialSetup.Designer.cs} | 13 ++-- ...etup.cs => 20250610225629_InitialSetup.cs} | 4 +- .../ApplicationDbContextModelSnapshot.cs | 11 ++- .../Repositories/Users/UserRepository.cs | 43 +++++++++--- .../Controllers/Users/UserController.cs | 32 --------- .../Controllers/Users/UserRoleController.cs | 34 --------- .../Controllers/Users/UsersController.cs | 70 +++++++++++++++++++ 15 files changed, 245 insertions(+), 108 deletions(-) create mode 100644 src/Imprink.Application/Exceptions/DataUpdateException.cs create mode 100644 src/Imprink.Application/Users/SetUserFullNameHandler.cs rename src/Imprink.Infrastructure/Migrations/{20250609202250_InitialSetup.Designer.cs => 20250610225629_InitialSetup.Designer.cs} (99%) rename src/Imprink.Infrastructure/Migrations/{20250609202250_InitialSetup.cs => 20250610225629_InitialSetup.cs} (99%) delete mode 100644 src/Imprink.WebApi/Controllers/Users/UserController.cs delete mode 100644 src/Imprink.WebApi/Controllers/Users/UserRoleController.cs create mode 100644 src/Imprink.WebApi/Controllers/Users/UsersController.cs diff --git a/src/Imprink.Application/Exceptions/DataUpdateException.cs b/src/Imprink.Application/Exceptions/DataUpdateException.cs new file mode 100644 index 0000000..7b2f535 --- /dev/null +++ b/src/Imprink.Application/Exceptions/DataUpdateException.cs @@ -0,0 +1,7 @@ +namespace Imprink.Application.Exceptions; + +public class DataUpdateException : BaseApplicationException +{ + public DataUpdateException(string message) : base(message) { } + public DataUpdateException(string message, Exception innerException) : base(message, innerException) { } +} \ No newline at end of file diff --git a/src/Imprink.Application/Users/Dtos/UserDto.cs b/src/Imprink.Application/Users/Dtos/UserDto.cs index 256629e..9eb46f6 100644 --- a/src/Imprink.Application/Users/Dtos/UserDto.cs +++ b/src/Imprink.Application/Users/Dtos/UserDto.cs @@ -10,7 +10,8 @@ public class UserDto public required string Nickname { get; set; } public required string Email { get; set; } public bool EmailVerified { get; set; } - public string? FullName { get; set; } + public string? FirstName { get; set; } + public string? LastName { get; set; } public string? PhoneNumber { get; set; } public required bool IsActive { get; set; } diff --git a/src/Imprink.Application/Users/SetUserFullNameHandler.cs b/src/Imprink.Application/Users/SetUserFullNameHandler.cs new file mode 100644 index 0000000..68efb8a --- /dev/null +++ b/src/Imprink.Application/Users/SetUserFullNameHandler.cs @@ -0,0 +1,48 @@ +using Imprink.Application.Exceptions; +using Imprink.Application.Service; +using Imprink.Application.Users.Dtos; +using MediatR; + +namespace Imprink.Application.Users; + +public record SetUserFullNameCommand(string FirstName, string LastName) : IRequest; + +public class SetUserFullNameHandler(IUnitOfWork uw, ICurrentUserService userService) : IRequestHandler +{ + public async Task Handle(SetUserFullNameCommand request, CancellationToken cancellationToken) + { + await uw.BeginTransactionAsync(cancellationToken); + + try + { + var currentUser = userService.GetCurrentUserId(); + if (currentUser == null) + throw new NotFoundException("User token could not be accessed."); + + var user = await uw.UserRepository.SetUserFullNameAsync(currentUser, request.FirstName, request.LastName, cancellationToken); + if (user == null) + throw new DataUpdateException("User name could not be updated."); + + await uw.SaveAsync(cancellationToken); + await uw.CommitTransactionAsync(cancellationToken); + + return new UserDto + { + Id = user.Id, + Name = user.Name, + Nickname = user.Nickname, + Email = user.Email, + EmailVerified = user.EmailVerified, + FirstName = user.FirstName, + LastName = user.LastName, + PhoneNumber = user.PhoneNumber, + IsActive = user.IsActive + }; + } + catch + { + await uw.RollbackTransactionAsync(cancellationToken); + throw; + } + } +} \ No newline at end of file diff --git a/src/Imprink.Application/Users/SetUserPhoneHandler.cs b/src/Imprink.Application/Users/SetUserPhoneHandler.cs index 5b2cfe3..47c064b 100644 --- a/src/Imprink.Application/Users/SetUserPhoneHandler.cs +++ b/src/Imprink.Application/Users/SetUserPhoneHandler.cs @@ -1,11 +1,48 @@ +using Imprink.Application.Exceptions; +using Imprink.Application.Service; using Imprink.Application.Users.Dtos; using MediatR; namespace Imprink.Application.Users; -public record SetUserPhoneCommand(string Sub, Guid RoleId) : IRequest; +public record SetUserPhoneCommand(string PhoneNumber) : IRequest; -public class SetUserPhoneHandler +public class SetUserPhoneHandler(IUnitOfWork uw, ICurrentUserService userService) : IRequestHandler { - + public async Task Handle(SetUserPhoneCommand request, CancellationToken cancellationToken) + { + await uw.BeginTransactionAsync(cancellationToken); + + try + { + var currentUser = userService.GetCurrentUserId(); + if (currentUser == null) + throw new NotFoundException("User token could not be accessed."); + + var user = await uw.UserRepository.SetUserPhoneAsync(currentUser, request.PhoneNumber, cancellationToken); + if (user == null) + throw new DataUpdateException("User phone could not be updated."); + + await uw.SaveAsync(cancellationToken); + await uw.CommitTransactionAsync(cancellationToken); + + return new UserDto + { + Id = user.Id, + Name = user.Name, + Nickname = user.Nickname, + Email = user.Email, + EmailVerified = user.EmailVerified, + FirstName = user.FirstName, + LastName = user.LastName, + PhoneNumber = user.PhoneNumber, + IsActive = user.IsActive + }; + } + catch + { + await uw.RollbackTransactionAsync(cancellationToken); + throw; + } + } } \ No newline at end of file diff --git a/src/Imprink.Application/Users/SyncUserHandler.cs b/src/Imprink.Application/Users/SyncUserHandler.cs index aadbf43..edcf817 100644 --- a/src/Imprink.Application/Users/SyncUserHandler.cs +++ b/src/Imprink.Application/Users/SyncUserHandler.cs @@ -1,26 +1,38 @@ +using Imprink.Application.Users.Dtos; using Imprink.Domain.Models; using MediatR; namespace Imprink.Application.Users; -public record SyncUserCommand(Auth0User User) : IRequest; +public record SyncUserCommand(Auth0User User) : IRequest; -public class SyncUserHandler(IUnitOfWork uw): IRequestHandler +public class SyncUserHandler(IUnitOfWork uw): IRequestHandler { - public async Task Handle(SyncUserCommand request, CancellationToken cancellationToken) + public async Task Handle(SyncUserCommand request, CancellationToken cancellationToken) { await uw.BeginTransactionAsync(cancellationToken); try { - if (!await uw.UserRepository.UpdateOrCreateUserAsync(request.User, cancellationToken)) - { - await uw.RollbackTransactionAsync(cancellationToken); - } - + var user = await uw.UserRepository.UpdateOrCreateUserAsync(request.User, cancellationToken); + + if (user == null) throw new Exception("User exists but could not be updated"); + await uw.SaveAsync(cancellationToken); await uw.CommitTransactionAsync(cancellationToken); - return true; + + return new UserDto + { + Id = user.Id, + Name = user.Name, + Nickname = user.Nickname, + Email = user.Email, + EmailVerified = user.EmailVerified, + FirstName = user.FirstName, + LastName = user.LastName, + PhoneNumber = user.PhoneNumber, + IsActive = user.IsActive + }; } catch { diff --git a/src/Imprink.Domain/Entities/Users/User.cs b/src/Imprink.Domain/Entities/Users/User.cs index d1a1a4d..f6d7713 100644 --- a/src/Imprink.Domain/Entities/Users/User.cs +++ b/src/Imprink.Domain/Entities/Users/User.cs @@ -9,7 +9,9 @@ public class User public required string Nickname { get; set; } public required string Email { get; set; } public bool EmailVerified { get; set; } - public string? FullName { get; set; } + + public string? FirstName { get; set; } = null!; + public string? LastName { get; set; } = null!; public string? PhoneNumber { get; set; } public required bool IsActive { get; set; } diff --git a/src/Imprink.Domain/Repositories/Users/IUserRepository.cs b/src/Imprink.Domain/Repositories/Users/IUserRepository.cs index 5809d58..ad16875 100644 --- a/src/Imprink.Domain/Repositories/Users/IUserRepository.cs +++ b/src/Imprink.Domain/Repositories/Users/IUserRepository.cs @@ -1,16 +1,18 @@ using Imprink.Domain.Entities.Users; using Imprink.Domain.Models; -namespace Imprink.Domain.Repositories; +namespace Imprink.Domain.Repositories.Users; public interface IUserRepository { - Task UpdateOrCreateUserAsync(Auth0User user, CancellationToken cancellationToken = default); + Task UpdateOrCreateUserAsync(Auth0User user, CancellationToken cancellationToken = default); Task GetUserByIdAsync(string userId, CancellationToken cancellationToken = default); Task GetUserByEmailAsync(string email, CancellationToken cancellationToken = default); Task> GetAllUsersAsync(CancellationToken cancellationToken = default); Task> GetActiveUsersAsync(CancellationToken cancellationToken = default); Task UserExistsAsync(string userId, CancellationToken cancellationToken = default); Task> GetUsersByRoleAsync(Guid roleId, CancellationToken cancellationToken = default); + Task SetUserPhoneAsync(string userId, string phoneNumber, CancellationToken cancellationToken = default); + Task SetUserFullNameAsync(string userId, string firstName, string lastName, CancellationToken cancellationToken = default); Task GetUserWithAllRelatedDataAsync(string userId, CancellationToken cancellationToken = default); } \ No newline at end of file diff --git a/src/Imprink.Infrastructure/Configuration/Users/UserConfiguration.cs b/src/Imprink.Infrastructure/Configuration/Users/UserConfiguration.cs index 8003def..4667aca 100644 --- a/src/Imprink.Infrastructure/Configuration/Users/UserConfiguration.cs +++ b/src/Imprink.Infrastructure/Configuration/Users/UserConfiguration.cs @@ -28,7 +28,10 @@ public class UserConfiguration : IEntityTypeConfiguration .IsRequired() .HasMaxLength(100); - builder.Property(u => u.FullName) + builder.Property(u => u.FirstName) + .HasMaxLength(100); + + builder.Property(u => u.LastName) .HasMaxLength(100); builder.Property(u => u.PhoneNumber) diff --git a/src/Imprink.Infrastructure/Migrations/20250609202250_InitialSetup.Designer.cs b/src/Imprink.Infrastructure/Migrations/20250610225629_InitialSetup.Designer.cs similarity index 99% rename from src/Imprink.Infrastructure/Migrations/20250609202250_InitialSetup.Designer.cs rename to src/Imprink.Infrastructure/Migrations/20250610225629_InitialSetup.Designer.cs index 8d719f1..4dfe290 100644 --- a/src/Imprink.Infrastructure/Migrations/20250609202250_InitialSetup.Designer.cs +++ b/src/Imprink.Infrastructure/Migrations/20250610225629_InitialSetup.Designer.cs @@ -12,7 +12,7 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion; namespace Imprink.Infrastructure.Migrations { [DbContext(typeof(ApplicationDbContext))] - [Migration("20250609202250_InitialSetup")] + [Migration("20250610225629_InitialSetup")] partial class InitialSetup { /// @@ -743,11 +743,6 @@ namespace Imprink.Infrastructure.Migrations b.ToTable("Roles"); b.HasData( - new - { - Id = new Guid("11111111-1111-1111-1111-111111111111"), - RoleName = "User" - }, new { Id = new Guid("22222222-2222-2222-2222-222222222222"), @@ -775,7 +770,7 @@ namespace Imprink.Infrastructure.Migrations .HasMaxLength(100) .HasColumnType("bit"); - b.Property("FullName") + b.Property("FirstName") .HasMaxLength(100) .HasColumnType("nvarchar(100)"); @@ -784,6 +779,10 @@ namespace Imprink.Infrastructure.Migrations .HasColumnType("bit") .HasDefaultValue(true); + b.Property("LastName") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + b.Property("Name") .IsRequired() .HasMaxLength(100) diff --git a/src/Imprink.Infrastructure/Migrations/20250609202250_InitialSetup.cs b/src/Imprink.Infrastructure/Migrations/20250610225629_InitialSetup.cs similarity index 99% rename from src/Imprink.Infrastructure/Migrations/20250609202250_InitialSetup.cs rename to src/Imprink.Infrastructure/Migrations/20250610225629_InitialSetup.cs index f666300..30c801b 100644 --- a/src/Imprink.Infrastructure/Migrations/20250609202250_InitialSetup.cs +++ b/src/Imprink.Infrastructure/Migrations/20250610225629_InitialSetup.cs @@ -85,7 +85,8 @@ namespace Imprink.Infrastructure.Migrations Nickname = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false), Email = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: false), EmailVerified = table.Column(type: "bit", maxLength: 100, nullable: false), - FullName = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: true), + FirstName = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: true), + LastName = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: true), PhoneNumber = table.Column(type: "nvarchar(20)", maxLength: 20, nullable: true), IsActive = table.Column(type: "bit", nullable: false, defaultValue: true) }, @@ -339,7 +340,6 @@ namespace Imprink.Infrastructure.Migrations columns: new[] { "Id", "RoleName" }, values: new object[,] { - { new Guid("11111111-1111-1111-1111-111111111111"), "User" }, { new Guid("22222222-2222-2222-2222-222222222222"), "Merchant" }, { new Guid("33333333-3333-3333-3333-333333333333"), "Admin" } }); diff --git a/src/Imprink.Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs b/src/Imprink.Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs index ee202dc..1195e8b 100644 --- a/src/Imprink.Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/src/Imprink.Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs @@ -740,11 +740,6 @@ namespace Imprink.Infrastructure.Migrations b.ToTable("Roles"); b.HasData( - new - { - Id = new Guid("11111111-1111-1111-1111-111111111111"), - RoleName = "User" - }, new { Id = new Guid("22222222-2222-2222-2222-222222222222"), @@ -772,7 +767,7 @@ namespace Imprink.Infrastructure.Migrations .HasMaxLength(100) .HasColumnType("bit"); - b.Property("FullName") + b.Property("FirstName") .HasMaxLength(100) .HasColumnType("nvarchar(100)"); @@ -781,6 +776,10 @@ namespace Imprink.Infrastructure.Migrations .HasColumnType("bit") .HasDefaultValue(true); + b.Property("LastName") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + b.Property("Name") .IsRequired() .HasMaxLength(100) diff --git a/src/Imprink.Infrastructure/Repositories/Users/UserRepository.cs b/src/Imprink.Infrastructure/Repositories/Users/UserRepository.cs index 93b4d9b..93194dd 100644 --- a/src/Imprink.Infrastructure/Repositories/Users/UserRepository.cs +++ b/src/Imprink.Infrastructure/Repositories/Users/UserRepository.cs @@ -1,6 +1,7 @@ using Imprink.Domain.Entities.Users; using Imprink.Domain.Models; using Imprink.Domain.Repositories; +using Imprink.Domain.Repositories.Users; using Imprink.Infrastructure.Database; using Microsoft.EntityFrameworkCore; @@ -8,7 +9,7 @@ namespace Imprink.Infrastructure.Repositories.Users; public class UserRepository(ApplicationDbContext context) : IUserRepository { - public async Task UpdateOrCreateUserAsync(Auth0User user, CancellationToken cancellationToken = default) + public async Task UpdateOrCreateUserAsync(Auth0User user, CancellationToken cancellationToken = default) { var userToUpdate = await context.Users .Where(u => u.Id.Equals(user.Sub)) @@ -27,16 +28,15 @@ public class UserRepository(ApplicationDbContext context) : IUserRepository }; context.Users.Add(newUser); + return newUser; } - else - { - userToUpdate.Email = user.Email; - userToUpdate.Name = user.Name; - userToUpdate.Nickname = user.Nickname; - userToUpdate.EmailVerified = user.EmailVerified; - } - - return true; + + userToUpdate.Email = user.Email; + userToUpdate.Name = user.Name; + userToUpdate.Nickname = user.Nickname; + userToUpdate.EmailVerified = user.EmailVerified; + + return userToUpdate; } public async Task GetUserByIdAsync(string userId, CancellationToken cancellationToken = default) @@ -92,4 +92,27 @@ public class UserRepository(ApplicationDbContext context) : IUserRepository .Include(u => u.Orders) .FirstOrDefaultAsync(u => u.Id == userId, cancellationToken); } + + public async Task SetUserPhoneAsync(string userId, string phoneNumber, CancellationToken cancellationToken = default) + { + var user = await context.Users + .FirstOrDefaultAsync(u => u.Id == userId, cancellationToken); + + if (user == null) return null; + + user.PhoneNumber = phoneNumber; + return user; + } + + public async Task SetUserFullNameAsync(string userId, string firstName, string lastName, CancellationToken cancellationToken = default) + { + var user = await context.Users + .FirstOrDefaultAsync(u => u.Id == userId, cancellationToken); + + if (user == null) return null; + + user.FirstName = firstName; + user.LastName = lastName; + return user; + } } \ No newline at end of file diff --git a/src/Imprink.WebApi/Controllers/Users/UserController.cs b/src/Imprink.WebApi/Controllers/Users/UserController.cs deleted file mode 100644 index d4cba8f..0000000 --- a/src/Imprink.WebApi/Controllers/Users/UserController.cs +++ /dev/null @@ -1,32 +0,0 @@ -using System.Security.Claims; -using Imprink.Application.Users; -using Imprink.Domain.Models; -using MediatR; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; - -namespace Imprink.WebApi.Controllers.Users; - -[ApiController] -[Route("/api/users")] -public class UserController(IMediator mediator) : ControllerBase -{ - [Authorize] - [HttpPost("sync")] - public async Task Sync() - { - var claims = User.Claims as Claim[] ?? User.Claims.ToArray(); - - var auth0User = new Auth0User - { - Sub = claims.FirstOrDefault(c => c.Type == ClaimTypes.NameIdentifier)?.Value ?? string.Empty, - Name = claims.FirstOrDefault(c => c.Type == "name")?.Value ?? string.Empty, - Nickname = claims.FirstOrDefault(c => c.Type == "nickname")?.Value ?? string.Empty, - Email = claims.FirstOrDefault(c => c.Type == ClaimTypes.Email)?.Value ?? string.Empty, - EmailVerified = bool.TryParse(claims.FirstOrDefault(c => c.Type == "email_verified")?.Value, out var emailVerified) && emailVerified - }; - - await mediator.Send(new SyncUserCommand(auth0User)); - return Ok("User Synced."); - } -} \ No newline at end of file diff --git a/src/Imprink.WebApi/Controllers/Users/UserRoleController.cs b/src/Imprink.WebApi/Controllers/Users/UserRoleController.cs deleted file mode 100644 index d6092b0..0000000 --- a/src/Imprink.WebApi/Controllers/Users/UserRoleController.cs +++ /dev/null @@ -1,34 +0,0 @@ -using System.Security.Claims; -using Imprink.Application.Users; -using MediatR; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; - -namespace Imprink.WebApi.Controllers.Users; - -[ApiController] -[Route("/api/users/roles")] -public class UserRoleController(IMediator mediator) : ControllerBase -{ - [Authorize] - [HttpGet("me")] - public async Task GetMyRoles() - { - var sub = User.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? string.Empty; - return Ok(await mediator.Send(new GetUserRolesCommand(sub))); - } - - [Authorize(Roles = "Admin")] - [HttpPost("set")] - public async Task SetUserRole(SetUserRoleCommand command) - { - return Ok(await mediator.Send(command)); - } - - [Authorize(Roles = "Admin")] - [HttpPost("unset")] - public async Task UnsetUserRole(DeleteUserRoleCommand command) - { - return Ok(await mediator.Send(command)); - } -} \ No newline at end of file diff --git a/src/Imprink.WebApi/Controllers/Users/UsersController.cs b/src/Imprink.WebApi/Controllers/Users/UsersController.cs new file mode 100644 index 0000000..606f313 --- /dev/null +++ b/src/Imprink.WebApi/Controllers/Users/UsersController.cs @@ -0,0 +1,70 @@ +using System.Security.Claims; +using Imprink.Application.Users; +using Imprink.Domain.Models; +using MediatR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Imprink.WebApi.Controllers.Users; + +[ApiController] +[Route("/api/users")] +public class UsersController(IMediator mediator) : ControllerBase +{ + [Authorize] + [HttpPost("me/sync")] + public async Task SyncMyProfile() + { + var claims = User.Claims as Claim[] ?? User.Claims.ToArray(); + + var auth0User = new Auth0User + { + Sub = claims.FirstOrDefault(c => c.Type == ClaimTypes.NameIdentifier)?.Value ?? string.Empty, + Name = claims.FirstOrDefault(c => c.Type == "name")?.Value ?? string.Empty, + Nickname = claims.FirstOrDefault(c => c.Type == "nickname")?.Value ?? string.Empty, + Email = claims.FirstOrDefault(c => c.Type == ClaimTypes.Email)?.Value ?? string.Empty, + EmailVerified = bool.TryParse(claims.FirstOrDefault(c => c.Type == "email_verified")?.Value, out var emailVerified) && emailVerified + }; + + await mediator.Send(new SyncUserCommand(auth0User)); + return Ok("User profile synchronized."); + } + + [Authorize] + [HttpGet("me/roles")] + public async Task GetMyRoles() + { + var sub = User.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? string.Empty; + return Ok(await mediator.Send(new GetUserRolesCommand(sub))); + } + + [Authorize] + [HttpPut("me/phone")] + public async Task UpdateMyPhone([FromBody] SetUserPhoneCommand command) + { + return Ok(await mediator.Send(command)); + } + + [Authorize] + [HttpPut("me/fullname")] + public async Task UpdateMyFullName([FromBody] SetUserFullNameCommand command) + { + return Ok(await mediator.Send(command)); + } + + [Authorize(Roles = "Admin")] + [HttpPut("{userId}/roles/{roleId:guid}")] + public async Task AddUserRole(string userId, Guid roleId) + { + var command = new SetUserRoleCommand(userId, roleId); + return Ok(await mediator.Send(command)); + } + + [Authorize(Roles = "Admin")] + [HttpDelete("{userId}/roles/{roleId:guid}")] + public async Task RemoveUserRole(string userId, Guid roleId) + { + var command = new DeleteUserRoleCommand(userId, roleId); + return Ok(await mediator.Send(command)); + } +} \ No newline at end of file