aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--BackEnd/Timeline.ErrorCodes/ErrorCodes.cs5
-rw-r--r--BackEnd/Timeline.Tests/IntegratedTests/BookmarkTimelineTest.cs87
-rw-r--r--BackEnd/Timeline.Tests/IntegratedTests/HighlightTimelineTest.cs1
-rw-r--r--BackEnd/Timeline.Tests/IntegratedTests/HttpClientTestExtensions.cs5
-rw-r--r--BackEnd/Timeline.Tests/Services/BookmarkTimelineServiceTest.cs89
-rw-r--r--BackEnd/Timeline.Tests/Services/HighlightTimelineServiceTest.cs4
-rw-r--r--BackEnd/Timeline/Controllers/BookmarkTimelineController.cs114
-rw-r--r--BackEnd/Timeline/Controllers/HighlightTimelineController.cs8
-rw-r--r--BackEnd/Timeline/Entities/BookmarkTimelineEntity.cs28
-rw-r--r--BackEnd/Timeline/Entities/DatabaseContext.cs1
-rw-r--r--BackEnd/Timeline/Migrations/20201219120929_AddBookmarkTimeline.Designer.cs498
-rw-r--r--BackEnd/Timeline/Migrations/20201219120929_AddBookmarkTimeline.cs53
-rw-r--r--BackEnd/Timeline/Migrations/DatabaseContextModelSnapshot.cs47
-rw-r--r--BackEnd/Timeline/Models/Http/BookmarkTimeline.cs23
-rw-r--r--BackEnd/Timeline/Models/Http/HighlightTimeline.cs6
-rw-r--r--BackEnd/Timeline/Services/BookmarkTimelineService.cs193
-rw-r--r--BackEnd/Timeline/Startup.cs1
17 files changed, 1162 insertions, 1 deletions
diff --git a/BackEnd/Timeline.ErrorCodes/ErrorCodes.cs b/BackEnd/Timeline.ErrorCodes/ErrorCodes.cs
index a8519216..c65bf26e 100644
--- a/BackEnd/Timeline.ErrorCodes/ErrorCodes.cs
+++ b/BackEnd/Timeline.ErrorCodes/ErrorCodes.cs
@@ -68,6 +68,11 @@
{
public const int NonHighlight = 1_105_01_01;
}
+
+ public static class BookmarkTimelineController
+ {
+ public const int NonBookmark = 1_106_01_01;
+ }
}
}
diff --git a/BackEnd/Timeline.Tests/IntegratedTests/BookmarkTimelineTest.cs b/BackEnd/Timeline.Tests/IntegratedTests/BookmarkTimelineTest.cs
new file mode 100644
index 00000000..e6ae178f
--- /dev/null
+++ b/BackEnd/Timeline.Tests/IntegratedTests/BookmarkTimelineTest.cs
@@ -0,0 +1,87 @@
+using FluentAssertions;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using Timeline.Models.Http;
+using Xunit;
+
+namespace Timeline.Tests.IntegratedTests
+{
+ public class BookmarkTimelineTest : IntegratedTestBase
+ {
+ [Fact]
+ public async Task AuthTest()
+ {
+ using var client = await CreateDefaultClient();
+
+ await client.TestPutAssertUnauthorizedAsync("bookmarks/@user1");
+ await client.TestDeleteAssertUnauthorizedAsync("bookmarks/@user1");
+ await client.TestPostAssertUnauthorizedAsync("bookmarkop/move", new HttpBookmarkTimelineMoveRequest { Timeline = "aaa", NewPosition = 1 });
+ }
+
+ [Fact]
+ public async Task InvalidModel()
+ {
+ using var client = await CreateClientAsUser();
+
+ await client.TestPutAssertInvalidModelAsync("bookmarks/!!!");
+ await client.TestDeleteAssertInvalidModelAsync("bookmarks/!!!");
+ await client.TestPostAssertInvalidModelAsync("bookmarkop/move", new HttpBookmarkTimelineMoveRequest { Timeline = null!, NewPosition = 1 });
+ await client.TestPostAssertInvalidModelAsync("bookmarkop/move", new HttpBookmarkTimelineMoveRequest { Timeline = "!!!", NewPosition = 1 });
+ await client.TestPostAssertInvalidModelAsync("bookmarkop/move", new HttpBookmarkTimelineMoveRequest { Timeline = "aaa", NewPosition = null });
+ }
+
+ [Fact]
+ public async Task ShouldWork()
+ {
+ using var client = await CreateClientAsUser();
+ await client.TestPostAsync("timelines", new TimelineCreateRequest { Name = "t1" });
+
+
+ {
+ var h = await client.TestGetAsync<List<HttpTimeline>>("bookmarks");
+ h.Should().BeEmpty();
+ }
+
+ await client.TestPutAsync("bookmarks/@user1");
+
+ {
+ var h = await client.TestGetAsync<List<HttpTimeline>>("bookmarks");
+ h.Should().HaveCount(1);
+ h[0].Name.Should().Be("@user1");
+ }
+
+ await client.TestPutAsync("bookmarks/t1");
+
+ {
+ var h = await client.TestGetAsync<List<HttpTimeline>>("bookmarks");
+ h.Should().HaveCount(2);
+ h[0].Name.Should().Be("@user1");
+ h[1].Name.Should().Be("t1");
+ }
+
+ await client.TestPostAsync("bookmarkop/move", new HttpHighlightTimelineMoveRequest { Timeline = "@user1", NewPosition = 2 });
+
+ {
+ var h = await client.TestGetAsync<List<HttpTimeline>>("bookmarks");
+ h.Should().HaveCount(2);
+ h[0].Name.Should().Be("t1");
+ h[1].Name.Should().Be("@user1");
+ }
+
+ await client.TestDeleteAsync("bookmarks/@user1");
+
+ {
+ var h = await client.TestGetAsync<List<HttpTimeline>>("bookmarks");
+ h.Should().HaveCount(1);
+ h[0].Name.Should().Be("t1");
+ }
+
+ await client.TestDeleteAsync("bookmarks/t1");
+
+ {
+ var h = await client.TestGetAsync<List<HttpTimeline>>("bookmarks");
+ h.Should().BeEmpty();
+ }
+ }
+ }
+}
diff --git a/BackEnd/Timeline.Tests/IntegratedTests/HighlightTimelineTest.cs b/BackEnd/Timeline.Tests/IntegratedTests/HighlightTimelineTest.cs
index d4b4d55d..63f40a1e 100644
--- a/BackEnd/Timeline.Tests/IntegratedTests/HighlightTimelineTest.cs
+++ b/BackEnd/Timeline.Tests/IntegratedTests/HighlightTimelineTest.cs
@@ -28,6 +28,7 @@ namespace Timeline.Tests.IntegratedTests
await client.TestPutAssertInvalidModelAsync("highlights/!!!");
await client.TestDeleteAssertInvalidModelAsync("highlights/!!!");
await client.TestPostAssertInvalidModelAsync("highlightop/move", new HttpHighlightTimelineMoveRequest { Timeline = null!, NewPosition = 1 });
+ await client.TestPostAssertInvalidModelAsync("highlightop/move", new HttpHighlightTimelineMoveRequest { Timeline = "!!!", NewPosition = 1 });
await client.TestPostAssertInvalidModelAsync("highlightop/move", new HttpHighlightTimelineMoveRequest { Timeline = "aaa", NewPosition = null });
}
diff --git a/BackEnd/Timeline.Tests/IntegratedTests/HttpClientTestExtensions.cs b/BackEnd/Timeline.Tests/IntegratedTests/HttpClientTestExtensions.cs
index ec517362..b219f092 100644
--- a/BackEnd/Timeline.Tests/IntegratedTests/HttpClientTestExtensions.cs
+++ b/BackEnd/Timeline.Tests/IntegratedTests/HttpClientTestExtensions.cs
@@ -192,6 +192,11 @@ namespace Timeline.Tests.IntegratedTests
await client.TestJsonSendAssertUnauthorizedAsync(HttpMethod.Patch, url, jsonBody, errorCode, headerSetup);
}
+ public static async Task TestPutAssertUnauthorizedAsync(this HttpClient client, string url, object? jsonBody = null, int? errorCode = null, HeaderSetup? headerSetup = null)
+ {
+ await client.TestJsonSendAssertUnauthorizedAsync(HttpMethod.Put, url, jsonBody, errorCode, headerSetup);
+ }
+
public static async Task TestDeleteAssertUnauthorizedAsync(this HttpClient client, string url, object? jsonBody = null, int? errorCode = null, HeaderSetup? headerSetup = null)
{
await client.TestJsonSendAssertUnauthorizedAsync(HttpMethod.Delete, url, jsonBody, errorCode, headerSetup);
diff --git a/BackEnd/Timeline.Tests/Services/BookmarkTimelineServiceTest.cs b/BackEnd/Timeline.Tests/Services/BookmarkTimelineServiceTest.cs
new file mode 100644
index 00000000..1b8bff63
--- /dev/null
+++ b/BackEnd/Timeline.Tests/Services/BookmarkTimelineServiceTest.cs
@@ -0,0 +1,89 @@
+using FluentAssertions;
+using Microsoft.Extensions.Logging.Abstractions;
+using System.Threading.Tasks;
+using Timeline.Services;
+using Timeline.Tests.Helpers;
+using Xunit;
+
+namespace Timeline.Tests.Services
+{
+ public class BookmarkTimelineServiceTest : DatabaseBasedTest
+ {
+ private BookmarkTimelineService _service = default!;
+ private UserService _userService = default!;
+ private TimelineService _timelineService = default!;
+
+ protected override void OnDatabaseCreated()
+ {
+ var clock = new TestClock();
+ _userService = new UserService(NullLogger<UserService>.Instance, Database, new PasswordService(), new UserPermissionService(Database), clock);
+ _timelineService = new TimelineService(Database, _userService, clock);
+ _service = new BookmarkTimelineService(Database, _userService, _timelineService);
+ }
+
+ [Fact]
+ public async Task Should_Work()
+ {
+ var userId = await _userService.GetUserIdByUsername("user");
+
+ {
+ var b = await _service.GetBookmarks(userId);
+ b.Should().BeEmpty();
+ }
+
+ await _timelineService.CreateTimeline("tl", userId);
+ await _service.AddBookmark(userId, "tl");
+
+ {
+ var b = await _service.GetBookmarks(userId);
+ b.Should().HaveCount(1).And.BeEquivalentTo(await _timelineService.GetTimeline("tl"));
+ }
+ }
+
+ [Fact]
+ public async Task NewOne_Should_BeAtLast()
+ {
+ var userId = await _userService.GetUserIdByUsername("user");
+ await _timelineService.CreateTimeline("t1", userId);
+ await _service.AddBookmark(userId, "t1");
+
+ await _timelineService.CreateTimeline("t2", userId);
+ await _service.AddBookmark(userId, "t2");
+
+ var b = await _service.GetBookmarks(userId);
+
+ b.Should().HaveCount(2);
+ b[0].Name.Should().Be("t1");
+ b[1].Name.Should().Be("t2");
+ }
+
+ [Fact]
+ public async Task Multiple_Should_Work()
+ {
+ var userId = await _userService.GetUserIdByUsername("user");
+
+ // make timeline id not same as entity id.
+ await _timelineService.CreateTimeline("t0", userId);
+
+ await _timelineService.CreateTimeline("t1", userId);
+ await _service.AddBookmark(userId, "t1");
+
+ await _timelineService.CreateTimeline("t2", userId);
+ await _service.AddBookmark(userId, "t2");
+
+ await _timelineService.CreateTimeline("t3", userId);
+ await _service.AddBookmark(userId, "t3");
+
+ await _service.MoveBookmark(userId, "t3", 2);
+ (await _service.GetBookmarks(userId))[1].Name.Should().Be("t3");
+
+ await _service.MoveBookmark(userId, "t1", 3);
+ (await _service.GetBookmarks(userId))[2].Name.Should().Be("t1");
+
+ await _service.RemoveBookmark(userId, "t2");
+ await _service.RemoveBookmark(userId, "t1");
+ await _service.RemoveBookmark(userId, "t3");
+ (await _service.GetBookmarks(userId)).Should().BeEmpty();
+ }
+ }
+}
diff --git a/BackEnd/Timeline.Tests/Services/HighlightTimelineServiceTest.cs b/BackEnd/Timeline.Tests/Services/HighlightTimelineServiceTest.cs
index dca070c6..f48404a9 100644
--- a/BackEnd/Timeline.Tests/Services/HighlightTimelineServiceTest.cs
+++ b/BackEnd/Timeline.Tests/Services/HighlightTimelineServiceTest.cs
@@ -68,6 +68,10 @@ namespace Timeline.Tests.Services
public async Task Multiple_Should_Work()
{
var userId = await _userService.GetUserIdByUsername("user");
+
+ // make timeline id not same as entity id.
+ await _timelineService.CreateTimeline("t0", userId);
+
await _timelineService.CreateTimeline("t1", userId);
await _service.AddHighlightTimeline("t1", userId);
diff --git a/BackEnd/Timeline/Controllers/BookmarkTimelineController.cs b/BackEnd/Timeline/Controllers/BookmarkTimelineController.cs
new file mode 100644
index 00000000..9dff95f3
--- /dev/null
+++ b/BackEnd/Timeline/Controllers/BookmarkTimelineController.cs
@@ -0,0 +1,114 @@
+using AutoMapper;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using Timeline.Models.Http;
+using Timeline.Models.Validation;
+using Timeline.Services;
+using Timeline.Services.Exceptions;
+
+namespace Timeline.Controllers
+{
+ /// <summary>
+ /// Api related to timeline bookmarks.
+ /// </summary>
+ [ApiController]
+ [ProducesErrorResponseType(typeof(CommonResponse))]
+ public class BookmarkTimelineController : Controller
+ {
+ private readonly IBookmarkTimelineService _service;
+
+ private readonly IMapper _mapper;
+
+ public BookmarkTimelineController(IBookmarkTimelineService service, IMapper mapper)
+ {
+ _service = service;
+ _mapper = mapper;
+ }
+
+ /// <summary>
+ /// Get bookmark list in order.
+ /// </summary>
+ /// <returns>Bookmarks.</returns>
+ [HttpGet("bookmarks")]
+ [Authorize]
+ [ProducesResponseType(200)]
+ [ProducesResponseType(401)]
+ public async Task<ActionResult<List<HttpTimeline>>> List()
+ {
+ var bookmarks = await _service.GetBookmarks(this.GetUserId());
+ return Ok(_mapper.Map<List<HttpTimeline>>(bookmarks));
+ }
+
+ /// <summary>
+ /// Add a bookmark.
+ /// </summary>
+ /// <param name="timeline">Timeline name.</param>
+ [HttpPut("bookmarks/{timeline}")]
+ [Authorize]
+ [ProducesResponseType(200)]
+ [ProducesResponseType(400)]
+ [ProducesResponseType(401)]
+ public async Task<ActionResult> Put([GeneralTimelineName] string timeline)
+ {
+ try
+ {
+ await _service.AddBookmark(this.GetUserId(), timeline);
+ return Ok();
+ }
+ catch (TimelineNotExistException)
+ {
+ return BadRequest(ErrorResponse.TimelineController.NotExist());
+ }
+ }
+
+ /// <summary>
+ /// Remove a bookmark.
+ /// </summary>
+ /// <param name="timeline">Timeline name.</param>
+ [HttpDelete("bookmarks/{timeline}")]
+ [Authorize]
+ [ProducesResponseType(200)]
+ [ProducesResponseType(400)]
+ [ProducesResponseType(401)]
+ public async Task<ActionResult> Delete([GeneralTimelineName] string timeline)
+ {
+ try
+ {
+ await _service.RemoveBookmark(this.GetUserId(), timeline);
+ return Ok();
+ }
+ catch (TimelineNotExistException)
+ {
+ return BadRequest(ErrorResponse.TimelineController.NotExist());
+ }
+ }
+
+ /// <summary>
+ /// Move a bookmark to new posisition.
+ /// </summary>
+ /// <param name="request">Request body.</param>
+ [HttpPost("bookmarkop/move")]
+ [Authorize]
+ [ProducesResponseType(200)]
+ [ProducesResponseType(400)]
+ [ProducesResponseType(401)]
+ public async Task<ActionResult> Move([FromBody] HttpBookmarkTimelineMoveRequest request)
+ {
+ try
+ {
+ await _service.MoveBookmark(this.GetUserId(), request.Timeline, request.NewPosition!.Value);
+ return Ok();
+ }
+ catch (TimelineNotExistException)
+ {
+ return BadRequest(ErrorResponse.TimelineController.NotExist());
+ }
+ catch (InvalidBookmarkException)
+ {
+ return BadRequest(new CommonResponse(ErrorCodes.BookmarkTimelineController.NonBookmark, "You can't move a non-bookmark timeline."));
+ }
+ }
+ }
+}
diff --git a/BackEnd/Timeline/Controllers/HighlightTimelineController.cs b/BackEnd/Timeline/Controllers/HighlightTimelineController.cs
index 0b6e1665..519d6161 100644
--- a/BackEnd/Timeline/Controllers/HighlightTimelineController.cs
+++ b/BackEnd/Timeline/Controllers/HighlightTimelineController.cs
@@ -46,6 +46,8 @@ namespace Timeline.Controllers
[PermissionAuthorize(UserPermission.HighlightTimelineManagement)]
[ProducesResponseType(200)]
[ProducesResponseType(400)]
+ [ProducesResponseType(401)]
+ [ProducesResponseType(403)]
public async Task<ActionResult> Put([GeneralTimelineName] string timeline)
{
try
@@ -67,6 +69,8 @@ namespace Timeline.Controllers
[PermissionAuthorize(UserPermission.HighlightTimelineManagement)]
[ProducesResponseType(200)]
[ProducesResponseType(400)]
+ [ProducesResponseType(401)]
+ [ProducesResponseType(403)]
public async Task<ActionResult> Delete([GeneralTimelineName] string timeline)
{
try
@@ -81,12 +85,14 @@ namespace Timeline.Controllers
}
/// <summary>
- /// Move a highlight position.
+ /// Move a highlight to new position.
/// </summary>
[HttpPost("highlightop/move")]
[PermissionAuthorize(UserPermission.HighlightTimelineManagement)]
[ProducesResponseType(200)]
[ProducesResponseType(400)]
+ [ProducesResponseType(401)]
+ [ProducesResponseType(403)]
public async Task<ActionResult> Move([FromBody] HttpHighlightTimelineMoveRequest body)
{
try
diff --git a/BackEnd/Timeline/Entities/BookmarkTimelineEntity.cs b/BackEnd/Timeline/Entities/BookmarkTimelineEntity.cs
new file mode 100644
index 00000000..99c0bc9b
--- /dev/null
+++ b/BackEnd/Timeline/Entities/BookmarkTimelineEntity.cs
@@ -0,0 +1,28 @@
+using System.ComponentModel.DataAnnotations;
+using System.ComponentModel.DataAnnotations.Schema;
+
+namespace Timeline.Entities
+{
+ [Table("bookmark_timelines")]
+ public class BookmarkTimelineEntity
+ {
+ [Key, Column("id"), DatabaseGenerated(DatabaseGeneratedOption.Identity)]
+ public long Id { get; set; }
+
+ [Column("timeline")]
+ public long TimelineId { get; set; }
+
+ [ForeignKey(nameof(TimelineId))]
+ public TimelineEntity Timeline { get; set; } = default!;
+
+ [Column("user")]
+ public long UserId { get; set; }
+
+ [ForeignKey(nameof(UserId))]
+ public UserEntity User { get; set; } = default!;
+
+ // I don't use order any more since keyword name conflict.
+ [Column("rank")]
+ public long Rank { get; set; }
+ }
+}
diff --git a/BackEnd/Timeline/Entities/DatabaseContext.cs b/BackEnd/Timeline/Entities/DatabaseContext.cs
index 4205c2cf..513cdc95 100644
--- a/BackEnd/Timeline/Entities/DatabaseContext.cs
+++ b/BackEnd/Timeline/Entities/DatabaseContext.cs
@@ -30,6 +30,7 @@ namespace Timeline.Entities
public DbSet<TimelinePostEntity> TimelinePosts { get; set; } = default!;
public DbSet<TimelineMemberEntity> TimelineMembers { get; set; } = default!;
public DbSet<HighlightTimelineEntity> HighlightTimelines { get; set; } = default!;
+ public DbSet<BookmarkTimelineEntity> BookmarkTimelines { get; set; } = default!;
public DbSet<JwtTokenEntity> JwtToken { get; set; } = default!;
public DbSet<DataEntity> Data { get; set; } = default!;
diff --git a/BackEnd/Timeline/Migrations/20201219120929_AddBookmarkTimeline.Designer.cs b/BackEnd/Timeline/Migrations/20201219120929_AddBookmarkTimeline.Designer.cs
new file mode 100644
index 00000000..a68decb8
--- /dev/null
+++ b/BackEnd/Timeline/Migrations/20201219120929_AddBookmarkTimeline.Designer.cs
@@ -0,0 +1,498 @@
+// <auto-generated />
+using System;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Timeline.Entities;
+
+namespace Timeline.Migrations
+{
+ [DbContext(typeof(DatabaseContext))]
+ [Migration("20201219120929_AddBookmarkTimeline")]
+ partial class AddBookmarkTimeline
+ {
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "5.0.0");
+
+ modelBuilder.Entity("Timeline.Entities.BookmarkTimelineEntity", b =>
+ {
+ b.Property<long>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER")
+ .HasColumnName("id");
+
+ b.Property<long>("Rank")
+ .HasColumnType("INTEGER")
+ .HasColumnName("rank");
+
+ b.Property<long>("TimelineId")
+ .HasColumnType("INTEGER")
+ .HasColumnName("timeline");
+
+ b.Property<long>("UserId")
+ .HasColumnType("INTEGER")
+ .HasColumnName("user");
+
+ b.HasKey("Id");
+
+ b.HasIndex("TimelineId");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("bookmark_timelines");
+ });
+
+ modelBuilder.Entity("Timeline.Entities.DataEntity", b =>
+ {
+ b.Property<long>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER")
+ .HasColumnName("id");
+
+ b.Property<byte[]>("Data")
+ .IsRequired()
+ .HasColumnType("BLOB")
+ .HasColumnName("data");
+
+ b.Property<int>("Ref")
+ .HasColumnType("INTEGER")
+ .HasColumnName("ref");
+
+ b.Property<string>("Tag")
+ .IsRequired()
+ .HasColumnType("TEXT")
+ .HasColumnName("tag");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Tag")
+ .IsUnique();
+
+ b.ToTable("data");
+ });
+
+ modelBuilder.Entity("Timeline.Entities.HighlightTimelineEntity", b =>
+ {
+ b.Property<long>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER")
+ .HasColumnName("id");
+
+ b.Property<DateTime>("AddTime")
+ .HasColumnType("TEXT")
+ .HasColumnName("add_time");
+
+ b.Property<long?>("OperatorId")
+ .HasColumnType("INTEGER")
+ .HasColumnName("operator_id");
+
+ b.Property<long>("Order")
+ .HasColumnType("INTEGER")
+ .HasColumnName("order");
+
+ b.Property<long>("TimelineId")
+ .HasColumnType("INTEGER")
+ .HasColumnName("timeline_id");
+
+ b.HasKey("Id");
+
+ b.HasIndex("OperatorId");
+
+ b.HasIndex("TimelineId");
+
+ b.ToTable("highlight_timelines");
+ });
+
+ modelBuilder.Entity("Timeline.Entities.JwtTokenEntity", b =>
+ {
+ b.Property<long>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER")
+ .HasColumnName("id");
+
+ b.Property<byte[]>("Key")
+ .IsRequired()
+ .HasColumnType("BLOB")
+ .HasColumnName("key");
+
+ b.HasKey("Id");
+
+ b.ToTable("jwt_token");
+ });
+
+ modelBuilder.Entity("Timeline.Entities.TimelineEntity", b =>
+ {
+ b.Property<long>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER")
+ .HasColumnName("id");
+
+ b.Property<DateTime>("CreateTime")
+ .HasColumnType("TEXT")
+ .HasColumnName("create_time");
+
+ b.Property<long>("CurrentPostLocalId")
+ .HasColumnType("INTEGER")
+ .HasColumnName("current_post_local_id");
+
+ b.Property<string>("Description")
+ .HasColumnType("TEXT")
+ .HasColumnName("description");
+
+ b.Property<DateTime>("LastModified")
+ .HasColumnType("TEXT")
+ .HasColumnName("last_modified");
+
+ b.Property<string>("Name")
+ .HasColumnType("TEXT")
+ .HasColumnName("name");
+
+ b.Property<DateTime>("NameLastModified")
+ .HasColumnType("TEXT")
+ .HasColumnName("name_last_modified");
+
+ b.Property<long>("OwnerId")
+ .HasColumnType("INTEGER")
+ .HasColumnName("owner");
+
+ b.Property<string>("Title")
+ .HasColumnType("TEXT")
+ .HasColumnName("title");
+
+ b.Property<string>("UniqueId")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT")
+ .HasColumnName("unique_id")
+ .HasDefaultValueSql("lower(hex(randomblob(16)))");
+
+ b.Property<int>("Visibility")
+ .HasColumnType("INTEGER")
+ .HasColumnName("visibility");
+
+ b.HasKey("Id");
+
+ b.HasIndex("OwnerId");
+
+ b.ToTable("timelines");
+ });
+
+ modelBuilder.Entity("Timeline.Entities.TimelineMemberEntity", b =>
+ {
+ b.Property<long>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER")
+ .HasColumnName("id");
+
+ b.Property<long>("TimelineId")
+ .HasColumnType("INTEGER")
+ .HasColumnName("timeline");
+
+ b.Property<long>("UserId")
+ .HasColumnType("INTEGER")
+ .HasColumnName("user");
+
+ b.HasKey("Id");
+
+ b.HasIndex("TimelineId");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("timeline_members");
+ });
+
+ modelBuilder.Entity("Timeline.Entities.TimelinePostEntity", b =>
+ {
+ b.Property<long>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER")
+ .HasColumnName("id");
+
+ b.Property<long?>("AuthorId")
+ .HasColumnType("INTEGER")
+ .HasColumnName("author");
+
+ b.Property<string>("Content")
+ .HasColumnType("TEXT")
+ .HasColumnName("content");
+
+ b.Property<string>("ContentType")
+ .IsRequired()
+ .HasColumnType("TEXT")
+ .HasColumnName("content_type");
+
+ b.Property<string>("ExtraContent")
+ .HasColumnType("TEXT")
+ .HasColumnName("extra_content");
+
+ b.Property<DateTime>("LastUpdated")
+ .HasColumnType("TEXT")
+ .HasColumnName("last_updated");
+
+ b.Property<long>("LocalId")
+ .HasColumnType("INTEGER")
+ .HasColumnName("local_id");
+
+ b.Property<DateTime>("Time")
+ .HasColumnType("TEXT")
+ .HasColumnName("time");
+
+ b.Property<long>("TimelineId")
+ .HasColumnType("INTEGER")
+ .HasColumnName("timeline");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AuthorId");
+
+ b.HasIndex("TimelineId");
+
+ b.ToTable("timeline_posts");
+ });
+
+ modelBuilder.Entity("Timeline.Entities.UserAvatarEntity", b =>
+ {
+ b.Property<long>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER")
+ .HasColumnName("id");
+
+ b.Property<string>("DataTag")
+ .HasColumnType("TEXT")
+ .HasColumnName("data_tag");
+
+ b.Property<DateTime>("LastModified")
+ .HasColumnType("TEXT")
+ .HasColumnName("last_modified");
+
+ b.Property<string>("Type")
+ .HasColumnType("TEXT")
+ .HasColumnName("type");
+
+ b.Property<long>("UserId")
+ .HasColumnType("INTEGER")
+ .HasColumnName("user");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId")
+ .IsUnique();
+
+ b.ToTable("user_avatars");
+ });
+
+ modelBuilder.Entity("Timeline.Entities.UserEntity", b =>
+ {
+ b.Property<long>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER")
+ .HasColumnName("id");
+
+ b.Property<DateTime>("CreateTime")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT")
+ .HasColumnName("create_time")
+ .HasDefaultValueSql("datetime('now', 'utc')");
+
+ b.Property<DateTime>("LastModified")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT")
+ .HasColumnName("last_modified")
+ .HasDefaultValueSql("datetime('now', 'utc')");
+
+ b.Property<string>("Nickname")
+ .HasColumnType("TEXT")
+ .HasColumnName("nickname");
+
+ b.Property<string>("Password")
+ .IsRequired()
+ .HasColumnType("TEXT")
+ .HasColumnName("password");
+
+ b.Property<string>("UniqueId")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT")
+ .HasColumnName("unique_id")
+ .HasDefaultValueSql("lower(hex(randomblob(16)))");
+
+ b.Property<string>("Username")
+ .IsRequired()
+ .HasColumnType("TEXT")
+ .HasColumnName("username");
+
+ b.Property<DateTime>("UsernameChangeTime")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT")
+ .HasColumnName("username_change_time")
+ .HasDefaultValueSql("datetime('now', 'utc')");
+
+ b.Property<long>("Version")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER")
+ .HasDefaultValue(0L)
+ .HasColumnName("version");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Username")
+ .IsUnique();
+
+ b.ToTable("users");
+ });
+
+ modelBuilder.Entity("Timeline.Entities.UserPermissionEntity", b =>
+ {
+ b.Property<long>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER")
+ .HasColumnName("id");
+
+ b.Property<string>("Permission")
+ .IsRequired()
+ .HasColumnType("TEXT")
+ .HasColumnName("permission");
+
+ b.Property<long>("UserId")
+ .HasColumnType("INTEGER")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("user_permission");
+ });
+
+ modelBuilder.Entity("Timeline.Entities.BookmarkTimelineEntity", b =>
+ {
+ b.HasOne("Timeline.Entities.TimelineEntity", "Timeline")
+ .WithMany()
+ .HasForeignKey("TimelineId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("Timeline.Entities.UserEntity", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Timeline");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("Timeline.Entities.HighlightTimelineEntity", b =>
+ {
+ b.HasOne("Timeline.Entities.UserEntity", "Operator")
+ .WithMany()
+ .HasForeignKey("OperatorId");
+
+ b.HasOne("Timeline.Entities.TimelineEntity", "Timeline")
+ .WithMany()
+ .HasForeignKey("TimelineId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Operator");
+
+ b.Navigation("Timeline");
+ });
+
+ modelBuilder.Entity("Timeline.Entities.TimelineEntity", b =>
+ {
+ b.HasOne("Timeline.Entities.UserEntity", "Owner")
+ .WithMany("Timelines")
+ .HasForeignKey("OwnerId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Owner");
+ });
+
+ modelBuilder.Entity("Timeline.Entities.TimelineMemberEntity", b =>
+ {
+ b.HasOne("Timeline.Entities.TimelineEntity", "Timeline")
+ .WithMany("Members")
+ .HasForeignKey("TimelineId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("Timeline.Entities.UserEntity", "User")
+ .WithMany("TimelinesJoined")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Timeline");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("Timeline.Entities.TimelinePostEntity", b =>
+ {
+ b.HasOne("Timeline.Entities.UserEntity", "Author")
+ .WithMany("TimelinePosts")
+ .HasForeignKey("AuthorId");
+
+ b.HasOne("Timeline.Entities.TimelineEntity", "Timeline")
+ .WithMany("Posts")
+ .HasForeignKey("TimelineId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Author");
+
+ b.Navigation("Timeline");
+ });
+
+ modelBuilder.Entity("Timeline.Entities.UserAvatarEntity", b =>
+ {
+ b.HasOne("Timeline.Entities.UserEntity", "User")
+ .WithOne("Avatar")
+ .HasForeignKey("Timeline.Entities.UserAvatarEntity", "UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("Timeline.Entities.UserPermissionEntity", b =>
+ {
+ b.HasOne("Timeline.Entities.UserEntity", "User")
+ .WithMany("Permissions")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("Timeline.Entities.TimelineEntity", b =>
+ {
+ b.Navigation("Members");
+
+ b.Navigation("Posts");
+ });
+
+ modelBuilder.Entity("Timeline.Entities.UserEntity", b =>
+ {
+ b.Navigation("Avatar");
+
+ b.Navigation("Permissions");
+
+ b.Navigation("TimelinePosts");
+
+ b.Navigation("Timelines");
+
+ b.Navigation("TimelinesJoined");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/BackEnd/Timeline/Migrations/20201219120929_AddBookmarkTimeline.cs b/BackEnd/Timeline/Migrations/20201219120929_AddBookmarkTimeline.cs
new file mode 100644
index 00000000..571d0419
--- /dev/null
+++ b/BackEnd/Timeline/Migrations/20201219120929_AddBookmarkTimeline.cs
@@ -0,0 +1,53 @@
+using Microsoft.EntityFrameworkCore.Migrations;
+
+namespace Timeline.Migrations
+{
+ public partial class AddBookmarkTimeline : Migration
+ {
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.CreateTable(
+ name: "bookmark_timelines",
+ columns: table => new
+ {
+ id = table.Column<long>(type: "INTEGER", nullable: false)
+ .Annotation("Sqlite:Autoincrement", true),
+ timeline = table.Column<long>(type: "INTEGER", nullable: false),
+ user = table.Column<long>(type: "INTEGER", nullable: false),
+ rank = table.Column<long>(type: "INTEGER", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_bookmark_timelines", x => x.id);
+ table.ForeignKey(
+ name: "FK_bookmark_timelines_timelines_timeline",
+ column: x => x.timeline,
+ principalTable: "timelines",
+ principalColumn: "id",
+ onDelete: ReferentialAction.Cascade);
+ table.ForeignKey(
+ name: "FK_bookmark_timelines_users_user",
+ column: x => x.user,
+ principalTable: "users",
+ principalColumn: "id",
+ onDelete: ReferentialAction.Cascade);
+ });
+
+ migrationBuilder.CreateIndex(
+ name: "IX_bookmark_timelines_timeline",
+ table: "bookmark_timelines",
+ column: "timeline");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_bookmark_timelines_user",
+ table: "bookmark_timelines",
+ column: "user");
+ }
+
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "bookmark_timelines");
+ }
+ }
+}
diff --git a/BackEnd/Timeline/Migrations/DatabaseContextModelSnapshot.cs b/BackEnd/Timeline/Migrations/DatabaseContextModelSnapshot.cs
index ea3378dc..6b547a55 100644
--- a/BackEnd/Timeline/Migrations/DatabaseContextModelSnapshot.cs
+++ b/BackEnd/Timeline/Migrations/DatabaseContextModelSnapshot.cs
@@ -16,6 +16,34 @@ namespace Timeline.Migrations
modelBuilder
.HasAnnotation("ProductVersion", "5.0.0");
+ modelBuilder.Entity("Timeline.Entities.BookmarkTimelineEntity", b =>
+ {
+ b.Property<long>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER")
+ .HasColumnName("id");
+
+ b.Property<long>("Rank")
+ .HasColumnType("INTEGER")
+ .HasColumnName("rank");
+
+ b.Property<long>("TimelineId")
+ .HasColumnType("INTEGER")
+ .HasColumnName("timeline");
+
+ b.Property<long>("UserId")
+ .HasColumnType("INTEGER")
+ .HasColumnName("user");
+
+ b.HasKey("Id");
+
+ b.HasIndex("TimelineId");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("bookmark_timelines");
+ });
+
modelBuilder.Entity("Timeline.Entities.DataEntity", b =>
{
b.Property<long>("Id")
@@ -338,6 +366,25 @@ namespace Timeline.Migrations
b.ToTable("user_permission");
});
+ modelBuilder.Entity("Timeline.Entities.BookmarkTimelineEntity", b =>
+ {
+ b.HasOne("Timeline.Entities.TimelineEntity", "Timeline")
+ .WithMany()
+ .HasForeignKey("TimelineId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("Timeline.Entities.UserEntity", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Timeline");
+
+ b.Navigation("User");
+ });
+
modelBuilder.Entity("Timeline.Entities.HighlightTimelineEntity", b =>
{
b.HasOne("Timeline.Entities.UserEntity", "Operator")
diff --git a/BackEnd/Timeline/Models/Http/BookmarkTimeline.cs b/BackEnd/Timeline/Models/Http/BookmarkTimeline.cs
new file mode 100644
index 00000000..14be1112
--- /dev/null
+++ b/BackEnd/Timeline/Models/Http/BookmarkTimeline.cs
@@ -0,0 +1,23 @@
+using System.ComponentModel.DataAnnotations;
+using Timeline.Models.Validation;
+
+namespace Timeline.Models.Http
+{
+ /// <summary>
+ /// Move bookmark timeline request body model.
+ /// </summary>
+ public class HttpBookmarkTimelineMoveRequest
+ {
+ /// <summary>
+ /// Timeline name.
+ /// </summary>
+ [GeneralTimelineName]
+ public string Timeline { get; set; } = default!;
+
+ /// <summary>
+ /// New position, starting at 1.
+ /// </summary>
+ [Required]
+ public long? NewPosition { get; set; }
+ }
+}
diff --git a/BackEnd/Timeline/Models/Http/HighlightTimeline.cs b/BackEnd/Timeline/Models/Http/HighlightTimeline.cs
index e5aed068..5af0e528 100644
--- a/BackEnd/Timeline/Models/Http/HighlightTimeline.cs
+++ b/BackEnd/Timeline/Models/Http/HighlightTimeline.cs
@@ -8,9 +8,15 @@ namespace Timeline.Models.Http
/// </summary>
public class HttpHighlightTimelineMoveRequest
{
+ /// <summary>
+ /// Timeline name.
+ /// </summary>
[GeneralTimelineName]
public string Timeline { get; set; } = default!;
+ /// <summary>
+ /// New position, starting at 1.
+ /// </summary>
[Required]
public long? NewPosition { get; set; }
}
diff --git a/BackEnd/Timeline/Services/BookmarkTimelineService.cs b/BackEnd/Timeline/Services/BookmarkTimelineService.cs
new file mode 100644
index 00000000..09438193
--- /dev/null
+++ b/BackEnd/Timeline/Services/BookmarkTimelineService.cs
@@ -0,0 +1,193 @@
+using Microsoft.EntityFrameworkCore;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using Timeline.Entities;
+using Timeline.Models;
+using Timeline.Services.Exceptions;
+
+namespace Timeline.Services
+{
+
+ [Serializable]
+ public class InvalidBookmarkException : Exception
+ {
+ public InvalidBookmarkException() { }
+ public InvalidBookmarkException(string message) : base(message) { }
+ public InvalidBookmarkException(string message, Exception inner) : base(message, inner) { }
+ protected InvalidBookmarkException(
+ System.Runtime.Serialization.SerializationInfo info,
+ System.Runtime.Serialization.StreamingContext context) : base(info, context) { }
+ }
+
+ /// <summary>
+ /// Service interface that manages timeline bookmarks.
+ /// </summary>
+ public interface IBookmarkTimelineService
+ {
+ /// <summary>
+ /// Get bookmarks of a user.
+ /// </summary>
+ /// <param name="userId">User id of bookmark owner.</param>
+ /// <returns>Bookmarks in order.</returns>
+ /// <exception cref="UserNotExistException">Thrown when user does not exist.</exception>
+ Task<List<TimelineInfo>> GetBookmarks(long userId);
+
+ /// <summary>
+ /// Add a bookmark to tail to a user.
+ /// </summary>
+ /// <param name="userId">User id of bookmark owner.</param>
+ /// <param name="timelineName">Timeline name.</param>
+ /// <exception cref="ArgumentNullException">Thrown when <paramref name="timelineName"/> is null.</exception>
+ /// <exception cref="ArgumentException">Thrown when <paramref name="timelineName"/> is not a valid name.</exception>
+ /// <exception cref="UserNotExistException">Thrown when user does not exist.</exception>
+ /// <exception cref="TimelineNotExistException">Thrown when timeline does not exist.</exception>
+ Task AddBookmark(long userId, string timelineName);
+
+ /// <summary>
+ /// Remove a bookmark from a user.
+ /// </summary>
+ /// <param name="userId">User id of bookmark owner.</param>
+ /// <param name="timelineName">Timeline name.</param>
+ /// <returns>True if deletion is performed. False if bookmark does not exist.</returns>
+ /// <exception cref="ArgumentNullException">Thrown when <paramref name="timelineName"/> is null.</exception>
+ /// <exception cref="ArgumentException">Thrown when <paramref name="timelineName"/> is not a valid name.</exception>
+ /// <exception cref="UserNotExistException">Thrown when user does not exist.</exception>
+ /// <exception cref="TimelineNotExistException">Thrown when timeline does not exist.</exception>
+ Task<bool> RemoveBookmark(long userId, string timelineName);
+
+ /// <summary>
+ /// Move bookmark to a new position.
+ /// </summary>
+ /// <param name="userId">User id of bookmark owner.</param>
+ /// <param name="timelineName">Timeline name.</param>
+ /// <param name="newPosition">New position. Starts at 1.</param>
+ /// <exception cref="ArgumentNullException">Thrown when <paramref name="timelineName"/> is null.</exception>
+ /// <exception cref="ArgumentException">Thrown when <paramref name="timelineName"/> is not a valid name.</exception>
+ /// <exception cref="UserNotExistException">Thrown when user does not exist.</exception>
+ /// <exception cref="TimelineNotExistException">Thrown when timeline does not exist.</exception>
+ /// <exception cref="InvalidBookmarkException">Thrown when the timeline is not a bookmark.</exception>
+ Task MoveBookmark(long userId, string timelineName, long newPosition);
+ }
+
+ public class BookmarkTimelineService : IBookmarkTimelineService
+ {
+ private readonly DatabaseContext _database;
+ private readonly IBasicUserService _userService;
+ private readonly ITimelineService _timelineService;
+
+ public BookmarkTimelineService(DatabaseContext database, IBasicUserService userService, ITimelineService timelineService)
+ {
+ _database = database;
+ _userService = userService;
+ _timelineService = timelineService;
+ }
+
+ public async Task AddBookmark(long userId, string timelineName)
+ {
+ if (timelineName is null)
+ throw new ArgumentNullException(nameof(timelineName));
+
+ if (!await _userService.CheckUserExistence(userId))
+ throw new UserNotExistException(userId);
+
+ var timelineId = await _timelineService.GetTimelineIdByName(timelineName);
+
+ _database.BookmarkTimelines.Add(new BookmarkTimelineEntity
+ {
+ TimelineId = timelineId,
+ UserId = userId,
+ Rank = (await _database.BookmarkTimelines.CountAsync(t => t.UserId == userId)) + 1
+ });
+
+ await _database.SaveChangesAsync();
+ }
+
+ public async Task<List<TimelineInfo>> GetBookmarks(long userId)
+ {
+ if (!await _userService.CheckUserExistence(userId))
+ throw new UserNotExistException(userId);
+
+ var entities = await _database.BookmarkTimelines.Where(t => t.UserId == userId).OrderBy(t => t.Rank).Select(t => new { t.TimelineId }).ToListAsync();
+
+ List<TimelineInfo> result = new();
+
+ foreach (var entity in entities)
+ {
+ result.Add(await _timelineService.GetTimelineById(entity.TimelineId));
+ }
+
+ return result;
+ }
+
+ public async Task MoveBookmark(long userId, string timelineName, long newPosition)
+ {
+ if (timelineName == null)
+ throw new ArgumentNullException(nameof(timelineName));
+
+ var timelineId = await _timelineService.GetTimelineIdByName(timelineName);
+
+ var entity = await _database.BookmarkTimelines.SingleOrDefaultAsync(t => t.TimelineId == timelineId && t.UserId == userId);
+
+ if (entity == null) throw new InvalidBookmarkException("You can't move a non-bookmark timeline.");
+
+ var oldPosition = entity.Rank;
+
+ if (newPosition < 1)
+ {
+ newPosition = 1;
+ }
+ else
+ {
+ var totalCount = await _database.BookmarkTimelines.CountAsync(t => t.UserId == userId);
+ if (newPosition > totalCount) newPosition = totalCount;
+ }
+
+ if (oldPosition == newPosition) return;
+
+ await using var transaction = await _database.Database.BeginTransactionAsync();
+
+ if (newPosition > oldPosition)
+ {
+ await _database.Database.ExecuteSqlRawAsync("UPDATE `bookmark_timelines` SET `rank` = `rank` - 1 WHERE `rank` BETWEEN {0} AND {1} AND `user` = {2}", oldPosition + 1, newPosition, userId);
+ await _database.Database.ExecuteSqlRawAsync("UPDATE `bookmark_timelines` SET `rank` = {0} WHERE `id` = {1}", newPosition, entity.Id);
+ }
+ else
+ {
+ await _database.Database.ExecuteSqlRawAsync("UPDATE `bookmark_timelines` SET `rank` = `rank` + 1 WHERE `rank` BETWEEN {0} AND {1} AND `user` = {2}", newPosition, oldPosition - 1, userId);
+ await _database.Database.ExecuteSqlRawAsync("UPDATE `bookmark_timelines` SET `rank` = {0} WHERE `id` = {1}", newPosition, entity.Id);
+ }
+
+ await transaction.CommitAsync();
+ }
+
+ public async Task<bool> RemoveBookmark(long userId, string timelineName)
+ {
+ if (timelineName is null)
+ throw new ArgumentNullException(nameof(timelineName));
+
+ if (!await _userService.CheckUserExistence(userId))
+ throw new UserNotExistException(userId);
+
+ var timelineId = await _timelineService.GetTimelineIdByName(timelineName);
+
+ var entity = await _database.BookmarkTimelines.SingleOrDefaultAsync(t => t.UserId == userId && t.TimelineId == timelineId);
+
+ if (entity == null) return false;
+
+ await using var transaction = await _database.Database.BeginTransactionAsync();
+
+ var rank = entity.Rank;
+
+ _database.BookmarkTimelines.Remove(entity);
+ await _database.SaveChangesAsync();
+
+ await _database.Database.ExecuteSqlRawAsync("UPDATE `bookmark_timelines` SET `rank` = `rank` - 1 WHERE `rank` > {0}", rank);
+
+ await transaction.CommitAsync();
+
+ return true;
+ }
+ }
+}
diff --git a/BackEnd/Timeline/Startup.cs b/BackEnd/Timeline/Startup.cs
index d20fc54b..66c708ac 100644
--- a/BackEnd/Timeline/Startup.cs
+++ b/BackEnd/Timeline/Startup.cs
@@ -102,6 +102,7 @@ namespace Timeline
services.AddScoped<ITimelinePostService, TimelinePostService>();
services.AddScoped<IHighlightTimelineService, HighlightTimelineService>();
+ services.AddScoped<IBookmarkTimelineService, BookmarkTimelineService>();
services.AddDbContext<DatabaseContext>((services, options) =>
{