using AutoMapper; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using System; using System.Collections.Generic; using System.Globalization; using System.Linq; using System.Threading.Tasks; using Timeline.Entities; using Timeline.Models.Http; using Timeline.Models.Validation; using static Timeline.Resources.Services.TimelineService; namespace Timeline.Services { public enum TimelineUserRelationshipType { Own = 0b1, Join = 0b10, Default = Own | Join } public class TimelineUserRelationship { public TimelineUserRelationship(TimelineUserRelationshipType type, long userId) { Type = type; UserId = userId; } public TimelineUserRelationshipType Type { get; set; } public long UserId { get; set; } } /// /// This define the common interface of both personal timeline /// and normal timeline. /// /// /// The "name" parameter in method means name of timeline in /// while username of the owner /// of the personal timeline in . /// public interface IBaseTimelineService { /// /// Get the timeline info. /// /// Username or the timeline name. See remarks of . /// The timeline info. /// Thrown when is null. /// Thrown when is illegal. It is not a valid timeline name (for normal timeline service) or a valid username (for personal timeline service). /// /// Thrown when timeline does not exist. /// For normal timeline, it means the name does not exist. /// For personal timeline, it means the user of that username does not exist /// and the inner exception should be a . /// Task GetTimeline(string name); /// /// Set the properties of a timeline. /// /// Username or the timeline name. See remarks of . /// The new properties. Null member means not to change. /// The timeline info. /// Thrown when is null. /// Thrown when is illegal. It is not a valid timeline name (for normal timeline service) or a valid username (for personal timeline service). /// /// Thrown when timeline does not exist. /// For normal timeline, it means the name does not exist. /// For personal timeline, it means the user of that username does not exist /// and the inner exception should be a . /// Task ChangeProperty(string name, TimelinePatchRequest newProperties); /// /// Get all the posts in the timeline. /// /// Username or the timeline name. See remarks of . /// A list of all posts. /// Thrown when is null. /// Thrown when is illegal. It is not a valid timeline name (for normal timeline service) or a valid username (for personal timeline service). /// /// Thrown when timeline does not exist. /// For normal timeline, it means the name does not exist. /// For personal timeline, it means the user of that username does not exist /// and the inner exception should be a . /// Task> GetPosts(string name); /// /// Create a new post in timeline. /// /// Username or the timeline name. See remarks of . /// The author's id. /// The content. /// The time of the post. If null, then use current time. /// The info of the created post. /// Thrown when or is null. /// Thrown when is illegal. It is not a valid timeline name (for normal timeline service) or a valid username (for personal timeline service). /// /// Thrown when timeline does not exist. /// For normal timeline, it means the name does not exist. /// For personal timeline, it means the user of that username does not exist /// and the inner exception should be a . /// /// Thrown if user with does not exist. Task CreatePost(string name, long authorId, string content, DateTime? time); /// /// Delete a post /// /// Username or the timeline name. See remarks of . /// The id of the post to delete. /// Thrown when or is null. /// Thrown when is illegal. It is not a valid timeline name (for normal timeline service) or a valid username (for personal timeline service). /// /// Thrown when timeline does not exist. /// For normal timeline, it means the name does not exist. /// For personal timeline, it means the user of that username does not exist /// and the inner exception should be a . /// /// /// Thrown when the post with given id does not exist or is deleted already. /// /// /// First use /// to check the permission. /// Task DeletePost(string name, long id); /// /// Remove members to a timeline. /// /// Username or the timeline name. See remarks of . /// A list of usernames of members to add. May be null. /// A list of usernames of members to remove. May be null. /// Thrown when is null. /// Thrown when is illegal. It is not a valid timeline name (for normal timeline service) or a valid username (for personal timeline service). /// Thrown when names in or is not a valid username. /// /// Thrown when timeline does not exist. /// For normal timeline, it means the name does not exist. /// For personal timeline, it means the user of that username does not exist /// and the inner exception should be a . /// /// /// Thrown when one of the user to change does not exist. /// /// /// Operating on a username that is of bad format or does not exist always throws. /// Add a user that already is a member has no effects. /// Remove a user that is not a member also has not effects. /// Add and remove an identical user results in no effects. /// More than one same usernames are regarded as one. /// Task ChangeMember(string name, IList? add, IList? remove); /// /// Check whether a user can manage(change timeline info, member, ...) a timeline. /// /// /// /// True if the user can manage the timeline, otherwise false. /// Thrown when is null. /// Thrown when is illegal. It is not a valid timeline name (for normal timeline service) or a valid username (for personal timeline service). /// /// Thrown when timeline does not exist. /// For normal timeline, it means the name does not exist. /// For personal timeline, it means the user of that username does not exist /// and the inner exception should be a . /// /// /// This method does not check whether visitor is administrator. /// Return false if user with user id does not exist. /// Task HasManagePermission(string name, long userId); /// /// Verify whether a visitor has the permission to read a timeline. /// /// Username or the timeline name. See remarks of . /// The id of the user to check on. Null means visitor without account. /// True if can read, false if can't read. /// Thrown when is null. /// Thrown when is illegal. It is not a valid timeline name (for normal timeline service) or a valid username (for personal timeline service). /// /// Thrown when timeline does not exist. /// For normal timeline, it means the name does not exist. /// For personal timeline, it means the user of that username does not exist /// and the inner exception should be a . /// /// /// This method does not check whether visitor is administrator. /// Return false if user with visitor id does not exist. /// Task HasReadPermission(string name, long? visitorId); /// /// Verify whether a user has the permission to modify a post. /// /// Username or the timeline name. See remarks of . /// The id of the user to check on. /// True if can modify, false if can't modify. /// Thrown when is null. /// Thrown when is illegal. It is not a valid timeline name (for normal timeline service) or a valid username (for personal timeline service). /// /// Thrown when timeline does not exist. /// For normal timeline, it means the name does not exist. /// For personal timeline, it means the user of that username does not exist /// and the inner exception should be a . /// /// /// Thrown when the post with given id does not exist or is deleted already. /// /// /// This method does not check whether the user is administrator. /// It only checks whether he is the author of the post or the owner of the timeline. /// Return false when user with modifier id does not exist. /// Task HasPostModifyPermission(string name, long id, long modifierId); /// /// Verify whether a user is member of a timeline. /// /// Username or the timeline name. See remarks of . /// The id of user to check on. /// True if it is a member, false if not. /// Thrown when is null. /// Thrown when is illegal. It is not a valid timeline name (for normal timeline service) or a valid username (for personal timeline service). /// /// Thrown when timeline does not exist. /// For normal timeline, it means the name does not exist. /// For personal timeline, it means the user of that username does not exist /// and the inner exception should be a . /// /// /// Timeline owner is also considered as a member. /// Return false when user with user id does not exist. /// Task IsMemberOf(string name, long userId); } /// /// Service for normal timeline. /// public interface ITimelineService : IBaseTimelineService { /// /// Get all timelines including personal timelines. /// /// Filter timelines related (own or is a member) to specific user. /// Filter timelines with given visibility. If null or empty, all visibilities are returned. Duplicate value are ignored. /// The list of timelines. /// /// If user with related user id does not exist, empty list will be returned. /// Task> GetTimelines(TimelineUserRelationship? relate = null, List? visibility = null); /// /// Create a timeline. /// /// The name of the timeline. /// The id of owner of the timeline. /// The info of the new timeline. /// Thrown when is null. /// Thrown when timeline name is invalid. /// Thrown when the timeline already exists. /// Thrown when the owner user does not exist. Task CreateTimeline(string name, long owner); } public interface IPersonalTimelineService : IBaseTimelineService { } public abstract class BaseTimelineService : IBaseTimelineService { protected BaseTimelineService(ILoggerFactory loggerFactory, DatabaseContext database, IUserService userService, IMapper mapper, IClock clock) { Clock = clock; Database = database; UserService = userService; Mapper = mapper; } protected IClock Clock { get; } protected UsernameValidator UsernameValidator { get; } = new UsernameValidator(); protected DatabaseContext Database { get; } protected IUserService UserService { get; } protected IMapper Mapper { get; } protected TimelineEntity CreateNewEntity(string? name, long owner) { return new TimelineEntity { Name = name, OwnerId = owner, Visibility = TimelineVisibility.Register, CreateTime = Clock.GetCurrentTime() }; } /// /// Find the timeline id by the name. /// For details, see remarks. /// /// The username or the timeline name. See remarks. /// The id of the timeline entity. /// Thrown when is null. /// Thrown when is illegal. It is not a valid timeline name (for normal timeline service) or a valid username (for personal timeline service). /// /// Thrown when timeline does not exist. /// For normal timeline, it means the name does not exist. /// For personal timeline, it means the user of that username does not exist /// and the inner exception should be a . /// /// /// This is the common but different part for both types of timeline service. /// For class that implements , this method should /// find the timeline entity id by the given as the username of the owner. /// For class that implements , this method should /// find the timeline entity id by the given as the timeline name. /// This method should be called by many other method that follows the contract. /// protected abstract Task FindTimelineId(string name); public async Task GetTimeline(string name) { if (name == null) throw new ArgumentNullException(nameof(name)); var timelineId = await FindTimelineId(name); var timelineEntity = await Database.Timelines.Where(t => t.Id == timelineId).SingleAsync(); var timelineMemberEntities = await Database.TimelineMembers.Where(m => m.TimelineId == timelineId).Select(m => new { m.UserId }).ToListAsync(); var owner = Mapper.Map(await UserService.GetUserById(timelineEntity.OwnerId)); var members = new List(); foreach (var memberEntity in timelineMemberEntities) { members.Add(Mapper.Map(await UserService.GetUserById(memberEntity.UserId))); } return new TimelineInfo { Name = timelineEntity.Name, Description = timelineEntity.Description ?? "", Owner = owner, Visibility = timelineEntity.Visibility, Members = members }; } public async Task> GetPosts(string name) { if (name == null) throw new ArgumentNullException(nameof(name)); var timelineId = await FindTimelineId(name); var postEntities = await Database.TimelinePosts.OrderBy(p => p.Time).Where(p => p.TimelineId == timelineId && p.Content != null).ToListAsync(); var posts = new List(); foreach (var entity in postEntities) { if (entity.Content != null) // otherwise it is deleted { var author = Mapper.Map(await UserService.GetUserById(entity.AuthorId)); posts.Add(new TimelinePostInfo { Id = entity.Id, Content = entity.Content, Author = author, Time = entity.Time, LastUpdated = entity.LastUpdated }); } } return posts; } public async Task CreatePost(string name, long authorId, string content, DateTime? time) { if (name == null) throw new ArgumentNullException(nameof(name)); if (content == null) throw new ArgumentNullException(nameof(content)); var timelineId = await FindTimelineId(name); var author = Mapper.Map(await UserService.GetUserById(authorId)); var currentTime = Clock.GetCurrentTime(); var finalTime = time ?? currentTime; var postEntity = new TimelinePostEntity { Content = content, AuthorId = authorId, TimelineId = timelineId, Time = finalTime, LastUpdated = currentTime }; Database.TimelinePosts.Add(postEntity); await Database.SaveChangesAsync(); return new TimelinePostInfo { Id = postEntity.Id, Content = content, Author = author, Time = finalTime, LastUpdated = currentTime }; } public async Task DeletePost(string name, long id) { if (name == null) throw new ArgumentNullException(nameof(name)); // Currently we don't use the result. But we need to check the timeline. var _ = await FindTimelineId(name); var post = await Database.TimelinePosts.Where(p => p.Id == id).SingleOrDefaultAsync(); if (post == null) throw new TimelinePostNotExistException(id); post.Content = null; post.LastUpdated = Clock.GetCurrentTime(); await Database.SaveChangesAsync(); } public async Task ChangeProperty(string name, TimelinePatchRequest newProperties) { if (name == null) throw new ArgumentNullException(nameof(name)); if (newProperties == null) throw new ArgumentNullException(nameof(newProperties)); var timelineId = await FindTimelineId(name); var timelineEntity = await Database.Timelines.Where(t => t.Id == timelineId).SingleAsync(); if (newProperties.Description != null) { timelineEntity.Description = newProperties.Description; } if (newProperties.Visibility.HasValue) { timelineEntity.Visibility = newProperties.Visibility.Value; } await Database.SaveChangesAsync(); } public async Task ChangeMember(string name, IList? add, IList? remove) { if (name == null) throw new ArgumentNullException(nameof(name)); List? RemoveDuplicateAndCheckFormat(IList? list, string paramName) { if (list != null) { List result = new List(); var count = list.Count; for (var index = 0; index < count; index++) { var username = list[index]; if (result.Contains(username)) { continue; } var (validationResult, message) = UsernameValidator.Validate(username); if (!validationResult) throw new ArgumentException(string.Format(CultureInfo.CurrentCulture, ExceptionChangeMemberUsernameBadFormat, index), nameof(paramName)); result.Add(username); } return result; } else { return null; } } var simplifiedAdd = RemoveDuplicateAndCheckFormat(add, nameof(add)); var simplifiedRemove = RemoveDuplicateAndCheckFormat(remove, nameof(remove)); // remove those both in add and remove if (simplifiedAdd != null && simplifiedRemove != null) { var usersToClean = simplifiedRemove.Where(u => simplifiedAdd.Contains(u)).ToList(); foreach (var u in usersToClean) { simplifiedAdd.Remove(u); simplifiedRemove.Remove(u); } } var timelineId = await FindTimelineId(name); async Task?> CheckExistenceAndGetId(List? list) { if (list == null) return null; List result = new List(); foreach (var username in list) { result.Add(await UserService.GetUserIdByUsername(username)); } return result; } var userIdsAdd = await CheckExistenceAndGetId(simplifiedAdd); var userIdsRemove = await CheckExistenceAndGetId(simplifiedRemove); if (userIdsAdd != null) { var membersToAdd = userIdsAdd.Select(id => new TimelineMemberEntity { UserId = id, TimelineId = timelineId }).ToList(); Database.TimelineMembers.AddRange(membersToAdd); } if (userIdsRemove != null) { var membersToRemove = await Database.TimelineMembers.Where(m => m.TimelineId == timelineId && userIdsRemove.Contains(m.UserId)).ToListAsync(); Database.TimelineMembers.RemoveRange(membersToRemove); } await Database.SaveChangesAsync(); } public async Task HasManagePermission(string name, long userId) { if (name == null) throw new ArgumentNullException(nameof(name)); var timelineId = await FindTimelineId(name); var timelineEntity = await Database.Timelines.Where(t => t.Id == timelineId).Select(t => new { t.OwnerId }).SingleAsync(); return userId == timelineEntity.OwnerId; } public async Task HasReadPermission(string name, long? visitorId) { if (name == null) throw new ArgumentNullException(nameof(name)); var timelineId = await FindTimelineId(name); var timelineEntity = await Database.Timelines.Where(t => t.Id == timelineId).Select(t => new { t.Visibility }).SingleAsync(); if (timelineEntity.Visibility == TimelineVisibility.Public) return true; if (timelineEntity.Visibility == TimelineVisibility.Register && visitorId != null) return true; if (visitorId == null) { return false; } else { var memberEntity = await Database.TimelineMembers.Where(m => m.UserId == visitorId && m.TimelineId == timelineId).SingleOrDefaultAsync(); return memberEntity != null; } } public async Task HasPostModifyPermission(string name, long id, long modifierId) { if (name == null) throw new ArgumentNullException(nameof(name)); var timelineId = await FindTimelineId(name); var timelineEntity = await Database.Timelines.Where(t => t.Id == timelineId).Select(t => new { t.OwnerId }).SingleAsync(); var postEntity = await Database.TimelinePosts.Where(p => p.Id == id).Select(p => new { p.AuthorId }).SingleOrDefaultAsync(); if (postEntity == null) throw new TimelinePostNotExistException(id); return timelineEntity.OwnerId == modifierId || postEntity.AuthorId == modifierId; } public async Task IsMemberOf(string name, long userId) { if (name == null) throw new ArgumentNullException(nameof(name)); var timelineId = await FindTimelineId(name); var timelineEntity = await Database.Timelines.Where(t => t.Id == timelineId).Select(t => new { t.OwnerId }).SingleAsync(); if (userId == timelineEntity.OwnerId) return true; return await Database.TimelineMembers.AnyAsync(m => m.TimelineId == timelineId && m.UserId == userId); } } public class TimelineService : BaseTimelineService, ITimelineService { private readonly TimelineNameValidator _timelineNameValidator = new TimelineNameValidator(); private void ValidateTimelineName(string name, string paramName) { if (!_timelineNameValidator.Validate(name, out var message)) { throw new ArgumentException(string.Format(CultureInfo.CurrentCulture, ExceptionTimelineNameBadFormat, message), paramName); } } public TimelineService(ILoggerFactory loggerFactory, DatabaseContext database, IUserService userService, IMapper mapper, IClock clock) : base(loggerFactory, database, userService, mapper, clock) { } protected override async Task FindTimelineId(string name) { if (name == null) throw new ArgumentNullException(nameof(name)); ValidateTimelineName(name, nameof(name)); var timelineEntity = await Database.Timelines.Where(t => t.Name == name).Select(t => new { t.Id }).SingleOrDefaultAsync(); if (timelineEntity == null) { throw new TimelineNotExistException(name); } else { return timelineEntity.Id; } } public async Task> GetTimelines(TimelineUserRelationship? relate = null, List? visibility = null) { List entities; IQueryable ApplyTimelineVisibilityFilter(IQueryable query) { if (visibility != null && visibility.Count != 0) { return query.Where(t => visibility.Contains(t.Visibility)); } return query; } bool allVisibilities = visibility == null || visibility.Count == 0; if (relate == null) { entities = await ApplyTimelineVisibilityFilter(Database.Timelines).Include(t => t.Members).ToListAsync(); } else { entities = new List(); if ((relate.Type & TimelineUserRelationshipType.Own) != 0) { entities.AddRange(await ApplyTimelineVisibilityFilter(Database.Timelines.Where(t => t.OwnerId == relate.UserId)).Include(t => t.Members).ToListAsync()); } if ((relate.Type & TimelineUserRelationshipType.Join) != 0) { entities.AddRange(await ApplyTimelineVisibilityFilter(Database.TimelineMembers.Where(m => m.UserId == relate.UserId).Include(m => m.Timeline).ThenInclude(t => t.Members).Select(m => m.Timeline)).ToListAsync()); } } var result = new List(); foreach (var entity in entities) { var timeline = new TimelineInfo { Name = entity.Name, Description = entity.Description ?? "", Owner = Mapper.Map(await UserService.GetUserById(entity.OwnerId)), Visibility = entity.Visibility, Members = new List() }; foreach (var m in entity.Members) { timeline.Members.Add(Mapper.Map(await UserService.GetUserById(m.UserId))); } result.Add(timeline); } return result; } public async Task CreateTimeline(string name, long owner) { if (name == null) throw new ArgumentNullException(nameof(name)); ValidateTimelineName(name, nameof(name)); var user = await UserService.GetUserById(owner); var conflict = await Database.Timelines.AnyAsync(t => t.Name == name); if (conflict) throw new ConflictException(ExceptionTimelineNameConflict); var newEntity = CreateNewEntity(name, owner); Database.Timelines.Add(newEntity); await Database.SaveChangesAsync(); return new TimelineInfo { Name = name, Description = "", Owner = Mapper.Map(user), Visibility = newEntity.Visibility, Members = new List() }; } } public class PersonalTimelineService : BaseTimelineService, IPersonalTimelineService { public PersonalTimelineService(ILoggerFactory loggerFactory, DatabaseContext database, IUserService userService, IMapper mapper, IClock clock) : base(loggerFactory, database, userService, mapper, clock) { } protected override async Task FindTimelineId(string name) { if (name == null) throw new ArgumentNullException(nameof(name)); long userId; try { userId = await UserService.GetUserIdByUsername(name); } catch (ArgumentException e) { throw new ArgumentException(ExceptionFindTimelineUsernameBadFormat, nameof(name), e); } catch (UserNotExistException e) { throw new TimelineNotExistException(name, e); } var timelineEntity = await Database.Timelines.Where(t => t.OwnerId == userId && t.Name == null).Select(t => new { t.Id }).SingleOrDefaultAsync(); if (timelineEntity != null) { return timelineEntity.Id; } else { var newTimelineEntity = CreateNewEntity(null, userId); Database.Timelines.Add(newTimelineEntity); await Database.SaveChangesAsync(); return newTimelineEntity.Id; } } } }