Created Woodpecker CI/CD deployment
All checks were successful
ci/woodpecker/tag/api_build Pipeline was successful
ci/woodpecker/tag/api_uploadimage Pipeline was successful
ci/woodpecker/tag/web_uploadimage Pipeline was successful

- Created Dockerfile for packing up API and Web projects as Docker image
This commit is contained in:
2023-03-27 21:48:25 -05:00
parent baf38aa3cd
commit 9bbd12e4c3
145 changed files with 30955 additions and 18248 deletions

25
API/.dockerignore Normal file
View File

@ -0,0 +1,25 @@
**/.classpath
**/.dockerignore
**/.env
**/.git
**/.gitignore
**/.project
**/.settings
**/.toolstarget
**/.vs
**/.vscode
**/*.*proj.user
**/*.dbmdl
**/*.jfm
**/azds.yaml
**/bin
**/charts
**/docker-compose*
**/Dockerfile*
**/node_modules
**/npm-debug.log
**/obj
**/secrets.dev.yaml
**/values.dev.yaml
LICENSE
README.md

63
API/Readme.md Normal file
View File

@ -0,0 +1,63 @@
# YABA: Yet Another Bookmark App
## YABA.API - Developer Guide
### Running Migrations
When running migrations, .NET seems to be ignoring dependency injection settings. In order to get around this, be sure to add the connection string to the command like. For example, when adding the migration:
```
dotnet ef migrations add InitialMigration -p YABA.Data -s YABA.API --context YABABaseContext -- {CONNECTION_STRING_HERE}
```
When removing last migration:
```
dotnet ef migrations remove InitialMigration -p YABA.Data -s YABA.API --context YABABaseContext -- {CONNECTION_STRING_HERE}
```
When applying migrations:
```
dotnet ef database update -p YABA.Data -s YABA.API -c YABABaseContext -- { CONNECTION_STRING_HERE }
```
As per the documentation [on MSDN](https://learn.microsoft.com/en-ca/ef/core/cli/dbcontext-creation?tabs=dotnet-core-cli#from-application-services):
> The -- token directs dotnet ef to treat everything that follows as an argument and not try to parse them as options. Any extra arguments not used by dotnet ef are forwarded to the app.
### Managing secrets
Best practice dictates that sensitive values should never be committed to source control. To manage secrets locally, this project utilize the [secrets manager](https://learn.microsoft.com/en-us/aspnet/core/security/app-secrets?view=aspnetcore-7.0&tabs=windows).
To initialize a secrets manager:
```
dotnet user-secrets init --project YABA.API
```
To set (or override) the value of a secret using a provided key:
```
dotnet user-secrets set "Key" "Value" --project YABA.API
```
```
dotnet user-secrets set "Object:Property" "Value" --project YABA.API
```
To list all secets:
```
dotnet user-secrets list --project YABA.API
```
### Docker
To build a dockerized version of the API project:
```
docker build { API_PROJECT_ROOT_SOURCE } -f { API_PROJECT_DOCKERFILE_PATH } -t { imagename:tag }
```
In order to run a container using the image built above, keep the following things in mind:
- It might be necessary to map container port 80 to another
- In the absence of a linked user secrets, secrets will have to be passed in to the container as environment variables, prefixed with `ASPNETCORE_`
```
docker run -d -p { HOST_PC_PORT_HERE }:80 --env ASPNETCORE_Object__Property=Value --name { CONTAINER_NAME } {imagename:tag}
```
Environment variables that are explicitly listed in `YABA.API\Dockerfile` will have to be properly set for proper operation of the application

View File

@ -0,0 +1,146 @@
using AutoMapper;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.JsonPatch;
using Microsoft.AspNetCore.Mvc;
using System.Net;
using YABA.API.ViewModels.Bookmarks;
using YABA.API.ViewModels.Tags;
using YABA.Common.DTOs.Bookmarks;
using YABA.Service.Interfaces;
namespace YABA.API.Controllers
{
[ApiController]
[ApiVersion("1")]
[Authorize]
[Route("api/v{version:apiVersion}/[controller]")]
public class BookmarksController : ControllerBase
{
private readonly IMapper _mapper;
private readonly IBookmarkService _bookmarkService;
public BookmarksController(IMapper mapper, IBookmarkService bookmarkService)
{
_mapper = mapper;
_bookmarkService = bookmarkService;
}
[HttpPost]
[ProducesResponseType(typeof(BookmarkResponse), (int)HttpStatusCode.Created)]
[ProducesResponseType((int)HttpStatusCode.BadRequest)]
public async Task<IActionResult> Create([FromBody] CreateBookmarkRequestDTO request)
{
if (!ModelState.IsValid) return BadRequest(ModelState);
var result = await _bookmarkService.CreateBookmark(request);
if(result == null) return BadRequest();
return CreatedAtAction(nameof(Create), _mapper.Map<BookmarkResponse>(result));
}
[HttpPut("{id}")]
[ProducesResponseType(typeof(BookmarkResponse), (int)HttpStatusCode.OK)]
[ProducesResponseType((int)HttpStatusCode.NotFound)]
public async Task<IActionResult> UpdateBookmark(int id, [FromBody] UpdateBookmarkRequestDTO request)
{
var result = await _bookmarkService.UpdateBookmark(id, request);
if (result == null) return NotFound();
return Ok(_mapper.Map<BookmarkResponse>(result));
}
[HttpPatch("{id}")]
[ProducesResponseType(typeof(BookmarkResponse), (int)HttpStatusCode.OK)]
[ProducesResponseType((int)HttpStatusCode.NotFound)]
public async Task<IActionResult> PatchBookmark(int id, [FromBody] JsonPatchDocument<PatchBookmarkRequest> request)
{
if (request == null || !ModelState.IsValid) return BadRequest(ModelState);
var entryToEdit = await _bookmarkService.Get(id);
if(entryToEdit == null) return NotFound();
var entryToEditAsPatchRequest = _mapper.Map<PatchBookmarkRequest>(entryToEdit);
request.ApplyTo(entryToEditAsPatchRequest, ModelState);
var updateRequest = _mapper.Map<UpdateBookmarkRequestDTO>(entryToEditAsPatchRequest);
var result = await _bookmarkService.UpdateBookmark(id, updateRequest);
if (result == null) return NotFound();
return Ok();
}
[HttpGet]
[ProducesResponseType(typeof(IEnumerable<BookmarkResponse>), (int)HttpStatusCode.OK)]
public IActionResult GetAll(bool showHidden = false)
{
var result = _bookmarkService.GetAll(showHidden);
return Ok(_mapper.Map<IEnumerable<BookmarkResponse>>(result));
}
[HttpGet("{id}")]
[ProducesResponseType(typeof(BookmarkResponse), (int)HttpStatusCode.OK)]
[ProducesResponseType((int)HttpStatusCode.NotFound)]
public async Task<IActionResult> Get(int id)
{
var result = await _bookmarkService.Get(id);
if (result == null) return NotFound();
return Ok(_mapper.Map<BookmarkResponse>(result));
}
[HttpDelete("{id}")]
[ProducesResponseType((int)HttpStatusCode.NoContent)]
[ProducesResponseType((int)HttpStatusCode.NotFound)]
public async Task<IActionResult> Delete(int id)
{
var result = await _bookmarkService.DeleteBookmark(id);
if (!result.HasValue) return NotFound();
return NoContent();
}
[HttpDelete]
[ProducesResponseType((int)HttpStatusCode.NoContent)]
[ProducesResponseType((int)HttpStatusCode.NotFound)]
[ProducesResponseType((int)HttpStatusCode.BadRequest)]
public async Task<IActionResult> DeleteBookmarks([FromBody] DeleteBookmarksRequest request)
{
if (request.Ids == null || !request.Ids.Any()) return BadRequest();
var result = await _bookmarkService.DeleteBookmarks(request.Ids);
if(result == null) return NotFound();
return NoContent();
}
[HttpPost("Hide")]
[ProducesResponseType((int)HttpStatusCode.NoContent)]
[ProducesResponseType((int)HttpStatusCode.NotFound)]
[ProducesResponseType((int)HttpStatusCode.BadRequest)]
public async Task<IActionResult> HideBookmarks([FromBody] HideBookmarksRequest request)
{
if (request.Ids == null || !request.Ids.Any()) return BadRequest();
var result = await _bookmarkService.HideBookmarks(request.Ids);
if (result == null) return NotFound();
return NoContent();
}
[HttpGet("Tags")]
[ProducesResponseType((int)HttpStatusCode.OK)]
public IActionResult GetBookmarkTags(bool showHidden = false)
{
var result = _bookmarkService.GetAllBookmarkTags(showHidden);
return Ok(_mapper.Map<IEnumerable<TagResponse>>(result));
}
}
}

View File

@ -0,0 +1,40 @@
using AutoMapper;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using System.Net;
using YABA.API.Settings;
using YABA.API.ViewModels;
using YABA.Service.Interfaces;
namespace YABA.API.Controllers
{
[ApiController]
[ApiVersion("1")]
[Authorize, Route("api/v{version:apiVersion}/[controller]")]
public class MiscController : ControllerBase
{
private readonly IMapper _mapper;
private readonly IMiscService _miscService;
public MiscController(
IMapper mapper,
IMiscService miscService)
{
_mapper = mapper;
_miscService = miscService;
}
[HttpGet]
[DevOnly]
[Route("GetWebsiteMetaData")]
[ProducesResponseType(typeof(GetWebsiteMetaDataResponse), (int)HttpStatusCode.OK)]
[ProducesResponseType((int)HttpStatusCode.BadRequest)]
public IActionResult GetWebsiteMetaData(string url)
{
if (string.IsNullOrEmpty(url)) return BadRequest();
var response = _miscService.GetWebsiteMetaData(url);
return Ok(_mapper.Map<GetWebsiteMetaDataResponse>(response));
}
}
}

View File

@ -0,0 +1,110 @@
using AutoMapper;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using System.Net;
using YABA.API.ViewModels.Tags;
using YABA.Common.DTOs.Tags;
using YABA.Service.Interfaces;
namespace YABA.API.Controllers
{
[ApiController]
[ApiVersion("1")]
[Authorize]
[Route("api/v{version:apiVersion}/[controller]")]
public class TagsController : ControllerBase
{
private readonly IMapper _mapper;
private readonly ITagsService _tagsService;
public TagsController(IMapper mapper, ITagsService tagsService)
{
_mapper = mapper;
_tagsService = tagsService;
}
[HttpGet]
[ProducesResponseType(typeof(IEnumerable<TagResponse>), (int)HttpStatusCode.OK)]
[ProducesResponseType((int)HttpStatusCode.NotFound)]
public async Task<IActionResult> GetAll()
{
var result = await _tagsService.GetAll();
if (result == null) return NotFound();
return Ok(_mapper.Map<IEnumerable<TagResponse>>(result));
}
[HttpGet("{id}")]
[ProducesResponseType(typeof(TagResponse), (int)HttpStatusCode.OK)]
[ProducesResponseType((int)HttpStatusCode.NotFound)]
public async Task<IActionResult> Get(int id)
{
var result = await _tagsService.Get(id);
if (result == null) return NotFound();
return Ok(_mapper.Map<TagResponse>(result));
}
[HttpPost]
[ProducesResponseType(typeof(IEnumerable<TagResponse>), (int)HttpStatusCode.OK)]
[ProducesResponseType((int)HttpStatusCode.BadRequest)]
public async Task<IActionResult> CreateTag([FromBody]CreateTagDTO request)
{
if (!ModelState.IsValid) return BadRequest(ModelState);
var result = await _tagsService.CreateTag(request);
if (result == null) return BadRequest();
return Ok(_mapper.Map<TagResponse>(result));
}
[HttpPut("{id}")]
[ProducesResponseType(typeof(IEnumerable<TagResponse>), (int)HttpStatusCode.OK)]
[ProducesResponseType((int)HttpStatusCode.NotFound)]
public async Task<IActionResult> UpdateTag(int id, [FromBody]UpdateTagDTO request)
{
var result = await _tagsService.UpdateTag(id, request);
if (result == null) return NotFound();
return Ok(_mapper.Map<TagResponse>(result));
}
[HttpPatch("{id}")]
[ProducesResponseType(typeof(IEnumerable<TagResponse>), (int)HttpStatusCode.OK)]
[ProducesResponseType((int)HttpStatusCode.NotFound)]
public async Task<IActionResult> PatchTag(int id, [FromBody] UpdateTagDTO request) => await UpdateTag(id, request);
[HttpDelete]
[ProducesResponseType((int)HttpStatusCode.NoContent)]
[ProducesResponseType ((int)HttpStatusCode.NotFound)]
[ProducesResponseType((int)HttpStatusCode.BadRequest)]
public async Task<IActionResult> DeleteTags([FromBody] DeleteTagsRequest request)
{
if(request.Ids == null || !request.Ids.Any()) return BadRequest();
var result = await _tagsService.DeleteTags(request.Ids);
if (result == null) return NotFound();
return NoContent();
}
[HttpPost("Hide")]
[ProducesResponseType((int)HttpStatusCode.NoContent)]
[ProducesResponseType((int)HttpStatusCode.NotFound)]
[ProducesResponseType((int)HttpStatusCode.BadRequest)]
public async Task<IActionResult> HideTags([FromBody] HideTagsRequest request)
{
if (request.Ids == null || !request.Ids.Any()) return BadRequest();
var result = await _tagsService.HideTags(request.Ids);
if (result == null) return NotFound();
return NoContent();
}
}
}

View File

@ -0,0 +1,42 @@
using Microsoft.AspNetCore.Mvc;
using System.Net;
using YABA.API.Settings;
namespace YABA.API.Controllers
{
[ApiController]
[ApiVersion("1")]
[DevOnly]
[Route("api/v{version:apiVersion}/[controller]")]
public class TestController : ControllerBase
{
private readonly ILogger<TestController> _logger;
public TestController(ILogger<TestController> logger)
{
_logger = logger;
}
[HttpGet("TestLog")]
[ProducesResponseType(typeof(string), (int)HttpStatusCode.OK)]
[ProducesResponseType((int)HttpStatusCode.NotFound)]
[ProducesResponseType((int)HttpStatusCode.BadRequest)]
public IActionResult TestLog()
{
var testObject = new { id = 1, name = "Test Message" };
_logger.LogDebug("Testing debug: {@TestObject}", testObject);
return Ok(testObject);
}
[HttpGet("TestLogError")]
[ProducesResponseType(typeof(string), (int)HttpStatusCode.OK)]
[ProducesResponseType((int)HttpStatusCode.NotFound)]
[ProducesResponseType((int)HttpStatusCode.BadRequest)]
public IActionResult TestLogError()
{
var testObject = new { id = 1, name = "Test Message" };
throw new Exception();
return Ok(testObject);
}
}
}

View File

@ -0,0 +1,44 @@
using AutoMapper;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using System.Net;
using YABA.API.Extensions;
using YABA.API.ViewModels;
using YABA.Service.Interfaces;
namespace YABA.API.Controllers
{
[ApiController]
[ApiVersion("1")]
[Authorize]
[Route("api/v{version:apiVersion}/[controller]")]
public class UsersController : ControllerBase
{
private readonly IMapper _mapper;
private readonly IUserService _userService;
public UsersController(IMapper mapper, IUserService userService)
{
_mapper = mapper;
_userService = userService;
}
[HttpPost]
[ProducesResponseType(typeof(UserResponse), (int)HttpStatusCode.OK)]
[ProducesResponseType((int)HttpStatusCode.NotFound)]
[ProducesResponseType((int)HttpStatusCode.NoContent)]
public async Task<IActionResult> Register()
{
var authProviderId = this.GetAuthProviderId();
if (string.IsNullOrEmpty(authProviderId)) return NotFound();
var isRegistered = _userService.IsUserRegistered(authProviderId);
if (isRegistered) return NoContent();
var registedUser = await _userService.RegisterUser(authProviderId);
return Ok(_mapper.Map<UserResponse>(registedUser));
}
}
}

38
API/YABA.API/Dockerfile Normal file
View File

@ -0,0 +1,38 @@
#See https://aka.ms/customizecontainer to learn how to customize your debug container and how Visual Studio uses this Dockerfile to build your images for faster debugging.
FROM mcr.microsoft.com/dotnet/aspnet:6.0-alpine AS base
WORKDIR /app
EXPOSE 80
EXPOSE 443
# Set environment variables
ENV ASPNETCORE_Environment=Development
ENV ASPNETCORE_Authentication__Auth0__ClientId=
ENV ASPNETCORE_Authentication__Auth0__ClientSecret=
ENV ASPNETCORE_Authentication__Auth0__Domain=
ENV ASPNETCORE_Authentication__Auth0__Identifier=
ENV ASPNETCORE_ConnectionStrings__YABAReadOnlyDbConnectionString=
ENV ASPNETCORE_ConnectionStrings__YABAReadWriteDbConnectionString=
ENV WebClient__Url=
ENV Serilog__WriteTo__1__Args__Uri=
ENV Serilog__WriteTo__1__Args__Labels__1__Value=
FROM mcr.microsoft.com/dotnet/sdk:6.0-alpine AS build
WORKDIR /src
COPY ["YABA.API/YABA.API.csproj", "YABA.API/"]
COPY ["YABA.Common/YABA.Common.csproj", "YABA.Common/"]
COPY ["YABA.Data/YABA.Data.csproj", "YABA.Data/"]
COPY ["YABA.Models/YABA.Models.csproj", "YABA.Models/"]
COPY ["YABA.Service/YABA.Service.csproj", "YABA.Service/"]
RUN dotnet restore "YABA.API/YABA.API.csproj"
COPY . .
WORKDIR "/src/YABA.API"
RUN dotnet build "YABA.API.csproj" -c Release -o /app/build
FROM build AS publish
RUN dotnet publish "YABA.API.csproj" -c Release -o /app/publish /p:UseAppHost=false
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "YABA.API.dll"]

View File

@ -0,0 +1,28 @@
using Microsoft.AspNetCore.Mvc;
using YABA.Common.Extensions;
using YABA.Common.Lookups;
namespace YABA.API.Extensions
{
public static class ControllerExtensions
{
public static string GetAuthProviderId(this ControllerBase controller)
{
return controller.User.Identity.GetCustomClaim(ClaimsLookup.AuthProviderId);
}
public static int GetUserId(this ControllerBase controller)
{
var isValidUserId = int.TryParse(controller.User.Identity.GetCustomClaim(ClaimsLookup.UserId), out int userId);
return isValidUserId ? userId : 0;
}
public static string GetIpAddress(this ControllerBase controller)
{
if (controller.Request.Headers.ContainsKey("X-Forwarded-For"))
return controller.Request.Headers["X-Forwarded-For"];
return controller.HttpContext.Connection.RemoteIpAddress.MapToIPv4().ToString();
}
}
}

View File

@ -0,0 +1,41 @@
using System.Security.Claims;
using YABA.API.Extensions;
using YABA.Common.Extensions;
using YABA.Common.Lookups;
using YABA.Service.Interfaces;
namespace YABA.API.Middlewares
{
public class AddCustomClaimsMiddleware
{
private readonly RequestDelegate _next;
public AddCustomClaimsMiddleware(RequestDelegate next)
{
_next = next;
}
public async Task InvokeAsync(HttpContext httpContext, IUserService userService)
{
if (httpContext.User != null && httpContext.User.Identity.IsAuthenticated)
{
var userAuthProviderId = httpContext.User.Identity.GetAuthProviderId();
if (!string.IsNullOrEmpty(userAuthProviderId))
{
var userId = userService.GetUserId(userAuthProviderId);
if(userId <= 0)
{
var registedUser = await userService.RegisterUser(userAuthProviderId);
userId = registedUser.Id;
}
httpContext.User.Identities.FirstOrDefault().AddClaim(new Claim(ClaimsLookup.UserId.GetClaimName(), userId.ToString()));
}
}
await _next(httpContext);
}
}
}

View File

@ -0,0 +1,26 @@
using Serilog.Context;
using YABA.Common.Extensions;
namespace YABA.API.Middlewares
{
public class AddCustomLoggingPropertiesMiddleware
{
private readonly RequestDelegate _next;
public AddCustomLoggingPropertiesMiddleware(RequestDelegate next)
{
_next = next;
}
public async Task InvokeAsync(HttpContext httpContext)
{
if(httpContext.Request.Path.HasValue && httpContext.Request.Path.Value.Contains("/api"))
{
LogContext.PushProperty("UserId", httpContext.User.Identity.IsAuthenticated ? httpContext.User.Identity.GetUserId() : "Anonymous");
LogContext.PushProperty("RemoteIpAddress", httpContext.Connection.RemoteIpAddress.MapToIPv4());
}
await _next(httpContext);
}
}
}

127
API/YABA.API/Program.cs Normal file
View File

@ -0,0 +1,127 @@
using AutoMapper;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Versioning;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using Microsoft.OpenApi.Models;
using System.Security.Claims;
using YABA.API.Middlewares;
using YABA.API.Settings;
using YABA.API.Settings.Swashbuckle;
using YABA.Data.Configuration;
using YABA.Data.Context;
using YABA.Service.Configuration;
using Serilog;
using Microsoft.AspNetCore.HttpOverrides;
var builder = WebApplication.CreateBuilder(args);
builder.Configuration.AddEnvironmentVariables();
var configuration = builder.Configuration;
// Add services to the container.
var auth0Section = configuration.GetSection("Authentication").GetSection("Auth0");
var auth0Settings = auth0Section.Get<Auth0Settings>();
var domain = $"https://{auth0Settings.Domain}/";
builder.Services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
}).AddJwtBearer(options =>
{
options.Authority = domain;
options.Audience = auth0Settings.Identifier;
options.TokenValidationParameters = new TokenValidationParameters
{
NameClaimType = ClaimTypes.NameIdentifier
};
});
builder.Services.AddApiVersioning(setup =>
{
setup.DefaultApiVersion = new ApiVersion(1, 0);
setup.AssumeDefaultVersionWhenUnspecified = true;
setup.ReportApiVersions = true;
setup.ApiVersionReader = new UrlSegmentApiVersionReader();
});
// Add services to the container
builder.Services.AddHttpContextAccessor();
builder.Services.AddServiceProjectDependencyInjectionConfiguration(configuration);
builder.Services.AddDataProjectDependencyInjectionConfiguration(configuration);
builder.Services.AddControllers().AddNewtonsoftJson();
builder.Services.AddHealthChecks();
// Add AutoMapper profiles
var mapperConfiguration = new MapperConfiguration(mapperConfiguration =>
{
mapperConfiguration.AddProfile(new YABA.API.Settings.AutoMapperProfile());
mapperConfiguration.AddProfile(new YABA.Service.Configuration.AutoMapperProfile());
});
IMapper mapper = mapperConfiguration.CreateMapper();
builder.Services.AddSingleton(mapper);
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(
c =>
{
c.SwaggerDoc(
"v1",
new OpenApiInfo
{
Title = "YABA.API",
Version = "v1"
});
c.OperationFilter<RemoveVersionParameterFilter>();
c.DocumentFilter<ReplaceVersionWithExactValueInPathFilter>();
c.ResolveConflictingActions(apiDescription => apiDescription.First());
}
);
builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders =
ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
});
// Add Serilog
Log.Logger = new LoggerConfiguration().ReadFrom.Configuration(configuration).Enrich.FromLogContext().CreateLogger();
builder.Host.UseSerilog();
var app = builder.Build();
// Run database migrations
using (var scope = app.Services.CreateScope())
{
var yabaDbContext = scope.ServiceProvider.GetRequiredService<YABAReadWriteContext>();
yabaDbContext.Database.Migrate();
}
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseHttpsRedirection();
app.UseForwardedHeaders();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
// Add custom middlewares
app.UseMiddleware<AddCustomClaimsMiddleware>();
app.UseMiddleware<AddCustomLoggingPropertiesMiddleware>();
var webClientUrl = configuration.GetSection("WebClient").GetValue<string>("Url");
app.UseCors(x => x.WithOrigins(webClientUrl).AllowAnyMethod().AllowAnyHeader());
app.MapHealthChecks("/Pulse");
app.Run();

View File

@ -0,0 +1,38 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:7922",
"sslPort": 44310
}
},
"profiles": {
"YABA.API": {
"commandName": "Project",
"launchBrowser": true,
"launchUrl": "swagger",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"applicationUrl": "https://localhost:7032;http://localhost:5032",
"dotnetRunMessages": true
},
"IIS Express": {
"commandName": "IISExpress",
"launchBrowser": true,
"launchUrl": "swagger",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"Docker": {
"commandName": "Docker",
"launchBrowser": true,
"launchUrl": "{Scheme}://{ServiceHost}:{ServicePort}/swagger",
"publishAllPorts": true,
"useSSL": true
}
}
}

View File

@ -0,0 +1,10 @@
namespace YABA.API.Settings
{
public class Auth0Settings
{
public string Domain { get; set; }
public string ClientSecret { get; set; }
public string ClientId { get; set; }
public string Identifier { get; set; }
}
}

View File

@ -0,0 +1,26 @@
using AutoMapper;
using System.Net;
using YABA.API.ViewModels;
using YABA.API.ViewModels.Bookmarks;
using YABA.API.ViewModels.Tags;
using YABA.Common.DTOs;
using YABA.Common.DTOs.Bookmarks;
using YABA.Common.DTOs.Tags;
namespace YABA.API.Settings
{
public class AutoMapperProfile : Profile
{
public AutoMapperProfile()
{
CreateMap<UserDTO, UserResponse>();
CreateMap<BookmarkDTO, BookmarkResponse>()
.ForMember(dest => dest.Title, opt => opt.MapFrom(src => WebUtility.HtmlDecode(src.Title)))
.ForMember(dest => dest.Description, opt => opt.MapFrom(src => WebUtility.HtmlDecode(src.Description)));
CreateMap<WebsiteMetaDataDTO, GetWebsiteMetaDataResponse>();
CreateMap<BookmarkDTO, PatchBookmarkRequest>();
CreateMap<PatchBookmarkRequest, UpdateBookmarkRequestDTO>();
CreateMap<TagDTO, TagResponse>().ReverseMap();
}
}
}

View File

@ -0,0 +1,34 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
namespace YABA.API.Settings
{
// Source: https://stackoverflow.com/questions/56495475/asp-net-core-its-possible-to-configure-an-action-in-controller-only-in-developm
public class DevOnlyAttribute : Attribute, IFilterFactory
{
public IFilterMetadata CreateInstance(IServiceProvider serviceProvider)
{
return new DevOnlyAttributeImpl(serviceProvider.GetRequiredService<IWebHostEnvironment>());
}
public bool IsReusable => true;
private class DevOnlyAttributeImpl : Attribute, IAuthorizationFilter
{
public DevOnlyAttributeImpl(IWebHostEnvironment hostingEnv)
{
HostingEnv = hostingEnv;
}
private IWebHostEnvironment HostingEnv { get; }
public void OnAuthorization(AuthorizationFilterContext context)
{
if (!HostingEnv.IsDevelopment())
{
context.Result = new NotFoundResult();
}
}
}
}
}

View File

@ -0,0 +1,14 @@
using Microsoft.OpenApi.Models;
using Swashbuckle.AspNetCore.SwaggerGen;
namespace YABA.API.Settings.Swashbuckle
{
public class RemoveVersionParameterFilter : IOperationFilter
{
public void Apply(OpenApiOperation operation, OperationFilterContext context)
{
var versionParameter = operation.Parameters.Single(p => p.Name == "version");
operation.Parameters.Remove(versionParameter);
}
}
}

View File

@ -0,0 +1,18 @@
using Microsoft.OpenApi.Models;
using Swashbuckle.AspNetCore.SwaggerGen;
namespace YABA.API.Settings.Swashbuckle
{
public class ReplaceVersionWithExactValueInPathFilter : IDocumentFilter
{
public void Apply(OpenApiDocument swaggerDoc, DocumentFilterContext context)
{
var paths = new OpenApiPaths();
foreach (var path in swaggerDoc.Paths)
{
paths.Add(path.Key.Replace("v{version}", swaggerDoc.Info.Version), path.Value);
}
swaggerDoc.Paths = paths;
}
}
}

View File

@ -0,0 +1,17 @@
using YABA.API.ViewModels.Tags;
namespace YABA.API.ViewModels.Bookmarks
{
public class BookmarkResponse
{
public int Id { get; set; }
public DateTimeOffset CreatedOn { get; set; }
public DateTimeOffset LastModified { get; set; }
public string Title { get; set; }
public string? Description { get; set; }
public string? Note { get; set; }
public bool IsHidden { get; set; }
public string Url { get; set; }
public IEnumerable<TagResponse> Tags { get; set; }
}
}

View File

@ -0,0 +1,7 @@
namespace YABA.API.ViewModels.Bookmarks
{
public class DeleteBookmarksRequest
{
public IEnumerable<int> Ids { get; set; }
}
}

View File

@ -0,0 +1,7 @@
namespace YABA.API.ViewModels.Bookmarks
{
public class HideBookmarksRequest
{
public IEnumerable<int> Ids { get; set; }
}
}

View File

@ -0,0 +1,11 @@
namespace YABA.API.ViewModels.Bookmarks
{
public class PatchBookmarkRequest
{
public string? Title { get; set; }
public string? Description { get; set; }
public string? Note { get; set; }
public bool IsHidden { get; set; }
public string? Url { get; set; }
}
}

View File

@ -0,0 +1,7 @@
namespace YABA.API.ViewModels.Bookmarks
{
public class UpdateBookmarkTagRequest
{
public List<string> Tags { get; set; }
}
}

View File

@ -0,0 +1,8 @@
namespace YABA.API.ViewModels
{
public class GetWebsiteMetaDataResponse
{
public string? Title { get; set; }
public string? Description { get; set; }
}
}

View File

@ -0,0 +1,7 @@
namespace YABA.API.ViewModels.Tags
{
public class DeleteTagsRequest
{
public IEnumerable<int> Ids { get; set; }
}
}

View File

@ -0,0 +1,7 @@
namespace YABA.API.ViewModels.Tags
{
public class HideTagsRequest
{
public IEnumerable<int> Ids { get; set; }
}
}

View File

@ -0,0 +1,9 @@
namespace YABA.API.ViewModels.Tags
{
public class TagResponse
{
public int Id { get; set; }
public string Name { get; set; }
public bool IsHidden { get; set; }
}
}

View File

@ -0,0 +1,11 @@

namespace YABA.API.ViewModels
{
public class UserResponse
{
public int Id { get; set; }
public bool IsDeleted { get; set; }
public DateTimeOffset CreatedOn { get; set; }
public DateTimeOffset LastModified { get; set; }
}
}

View File

@ -0,0 +1,45 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UserSecretsId>726eb626-1514-45b8-8521-cd7353303edb</UserSecretsId>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AutoMapper" Version="12.0.1" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="6.0.13" />
<PackageReference Include="Microsoft.AspNetCore.HttpOverrides" Version="2.2.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="6.0.14" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Versioning" Version="5.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="6.0.14">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.15.1" />
<PackageReference Include="Serilog" Version="2.12.0" />
<PackageReference Include="Serilog.AspNetCore" Version="6.1.0" />
<PackageReference Include="Serilog.Enrichers.Environment" Version="2.2.0" />
<PackageReference Include="Serilog.Enrichers.Process" Version="2.0.2" />
<PackageReference Include="Serilog.Enrichers.Thread" Version="3.1.0" />
<PackageReference Include="Serilog.Settings.Configuration" Version="3.4.0" />
<PackageReference Include="Serilog.Sinks.File" Version="5.0.0" />
<PackageReference Include="Serilog.Sinks.Grafana.Loki" Version="8.1.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.2.3" />
<PackageReference Include="Swashbuckle.AspNetCore.Newtonsoft" Version="6.5.0" />
<PackageReference Include="Swashbuckle.AspNetCore.Swagger" Version="6.5.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\YABA.Common\YABA.Common.csproj" />
<ProjectReference Include="..\YABA.Data\YABA.Data.csproj" />
<ProjectReference Include="..\YABA.Service\YABA.Service.csproj" />
</ItemGroup>
<ItemGroup>
<Folder Include="Logs\" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View File

@ -0,0 +1,46 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft": "Warning",
"Microsoft.Hosting.Lifetime": "Information"
}
},
"Serilog": {
"MinimumLevel": "Information",
"Using": [
"Serilog.Sinks.Grafana.Loki"
],
"WriteTo": [
{
"Name": "File",
"Args": {
"path": "Logs/log.json",
"rollingInterval": "Day",
"formatter": "Serilog.Formatting.Json.JsonFormatter, Serilog"
}
},
{
"Name": "GrafanaLoki",
"Args": {
"uri": "https://loki.iwanaga.moe",
"labels": [
{
"key": "job",
"value": "YABA.API"
},
{
"key": "environment",
"value": "localhost"
}
],
"propertiesAsLabels": [ "job", "environment" ]
}
}
],
"Enrich": ["FromLogContext", "WithMachineName", "WithProcessId", "WithThreadId"]
},
"WebClient": {
"Url": "http://localhost:3000"
}
}

View File

@ -0,0 +1,14 @@
using System;
namespace YABA.Common.Attributes
{
public class ClaimNameAttribute : Attribute
{
public string Name { get; private set; }
public ClaimNameAttribute(string name)
{
this.Name = name;
}
}
}

View File

@ -0,0 +1,20 @@
using System;
using System.Collections.Generic;
using YABA.Common.DTOs.Tags;
using YABA.Common.Interfaces;
namespace YABA.Common.DTOs.Bookmarks
{
public class BookmarkDTO : IBookmark
{
public int Id { get; set; }
public DateTimeOffset CreatedOn { get; set; }
public DateTimeOffset LastModified { get; set; }
public string Title { get; set; }
public string? Description { get; set; }
public string? Note { get; set; }
public bool IsHidden { get; set; }
public string Url { get; set; }
public List<TagDTO>? Tags { get; set; } = new List<TagDTO>();
}
}

View File

@ -0,0 +1,18 @@
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using YABA.Common.Interfaces;
namespace YABA.Common.DTOs.Bookmarks
{
public class CreateBookmarkRequestDTO : IBookmark
{
public string? Title { get; set; }
public string? Description { get; set; }
public string? Note { get; set; }
public bool IsHidden { get; set; }
public IEnumerable<string>? Tags { get; set; }
[Required]
public string Url { get; set; }
}
}

View File

@ -0,0 +1,16 @@
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using YABA.Common.Interfaces;
namespace YABA.Common.DTOs.Bookmarks
{
public class UpdateBookmarkRequestDTO : IBookmark
{
public string? Title { get; set; }
public string? Description { get; set; }
public string? Note { get; set; }
public bool IsHidden { get; set; }
public string? Url { get; set; }
public IEnumerable<string>? Tags { get; set; }
}
}

View File

@ -0,0 +1,11 @@
using System.ComponentModel.DataAnnotations;
namespace YABA.Common.DTOs.Tags
{
public class CreateTagDTO
{
[Required]
public string Name { get; set; }
public bool IsHidden { get; set; }
}
}

View File

@ -0,0 +1,9 @@
namespace YABA.Common.DTOs.Tags
{
public class TagDTO
{
public int Id { get; set; }
public string Name { get; set; }
public bool IsHidden { get; set; }
}
}

View File

@ -0,0 +1,10 @@
using System.ComponentModel.DataAnnotations;
namespace YABA.Common.DTOs.Tags
{
public class UpdateTagDTO
{
public string? Name { get; set; }
public bool? IsHidden { get; set; }
}
}

View File

@ -0,0 +1,12 @@
using System;
namespace YABA.Common.DTOs
{
public class UserDTO
{
public int Id { get; set; }
public bool IsDeleted { get; set; }
public DateTimeOffset CreatedOn { get; set; }
public DateTimeOffset LastModified { get; set; }
}
}

View File

@ -0,0 +1,9 @@

namespace YABA.Common.DTOs
{
public class WebsiteMetaDataDTO
{
public string? Title { get; set; }
public string? Description { get; set; }
}
}

View File

@ -0,0 +1,24 @@
using System;
using System.Collections.Generic;
namespace YABA.Common.Extensions
{
public static class DictionaryExtensions
{
public static void AddRange<K, V>(this Dictionary<K, V> source, Dictionary<K, V> collection)
{
if (collection == null)
{
throw new ArgumentNullException("Collection is null");
}
foreach (var item in collection)
{
if (!source.ContainsKey(item.Key))
{
source.Add(item.Key, item.Value);
}
}
}
}
}

View File

@ -0,0 +1,40 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using YABA.Common.Attributes;
using YABA.Common.Lookups;
namespace YABA.Common.Extensions
{
public static class EnumExtensions
{
private static readonly IEnumerable<CrudResultLookup> SuccessfulCrudStatuses = new List<CrudResultLookup>() {
CrudResultLookup.CreateSucceeded,
CrudResultLookup.UpdateSucceeded,
CrudResultLookup.DeleteSucceeded,
CrudResultLookup.RetrieveSuccessful
};
public static TAttribute GetAttribute<TAttribute>(this Enum value) where TAttribute : Attribute
{
var enumType = value.GetType();
var name = Enum.GetName(enumType, value);
return enumType.GetField(name).GetCustomAttributes(false).OfType<TAttribute>().SingleOrDefault();
}
public static string GetDisplayName(this Enum enumValue)
{
return enumValue.GetAttribute<DisplayAttribute>().Name;
}
public static string GetClaimName(this ClaimsLookup claimLookup)
{
return claimLookup.GetAttribute<ClaimNameAttribute>().Name;
}
public static bool IsCrudResultSuccessful(this CrudResultLookup importStatusLookup) => SuccessfulCrudStatuses.Contains(importStatusLookup);
public static bool IsCrudResultFailure(this CrudResultLookup importStatusLookup) => !SuccessfulCrudStatuses.Contains(importStatusLookup);
}
}

View File

@ -0,0 +1,18 @@
using System.Security.Claims;
using System.Security.Principal;
using YABA.Common.Lookups;
namespace YABA.Common.Extensions
{
public static class UserIdentityExtensions
{
public static string GetUserId(this IIdentity identity) => GetCustomClaim(identity, ClaimsLookup.UserId);
public static string GetAuthProviderId(this IIdentity identity) => GetCustomClaim(identity, ClaimsLookup.AuthProviderId);
public static string GetCustomClaim(this IIdentity identity, ClaimsLookup claim)
{
var claimsIdentity = identity as ClaimsIdentity;
return claimsIdentity.FindFirst(claim.GetClaimName())?.Value.ToString();
}
}
}

View File

@ -0,0 +1,12 @@

namespace YABA.Common.Interfaces
{
public interface IBookmark
{
public string Title { get; set; }
public string? Description { get; set; }
public string? Note { get; set; }
public bool IsHidden { get; set; }
public string Url { get; set; }
}
}

View File

@ -0,0 +1,22 @@
using YABA.Common.Attributes;
namespace YABA.Common.Lookups
{
public enum ClaimsLookup
{
[ClaimNameAttribute("https://dev.iwanaga.moe/api/auth_provider_id")]
AuthProviderId = 1,
[ClaimNameAttribute("https://dev.iwanaga.moe/api/email_address")]
UserEmail = 2,
[ClaimNameAttribute("https://dev.iwanaga.moe/api/email_verified")]
IsEmailConfirmed = 3,
[ClaimNameAttribute("https://dev.iwanaga.moe/api/username")]
Username = 4,
[ClaimNameAttribute("https://dev.iwanaga.moe/api/id")]
UserId = 5
}
}

View File

@ -0,0 +1,34 @@
using System.ComponentModel.DataAnnotations;
namespace YABA.Common.Lookups
{
public enum CrudResultLookup
{
[Display(Name = "Insert failed")]
CreateFailed = 1,
[Display(Name = "Insert succeeded")]
CreateSucceeded = 2,
[Display(Name = "Insert failed. Entry already exists")]
CreateFailedEntryExists = 3,
[Display(Name = "Update failed")]
UpdateFailed = 4,
[Display(Name = "Update succeeded")]
UpdateSucceeded = 5,
[Display(Name = "Delete failed")]
DeleteFailed = 6,
[Display(Name = "Delete succeeded")]
DeleteSucceeded = 7,
[Display(Name = "Retrieve failed")]
RetrieveFailed = 8,
[Display(Name = "Retrieve successful")]
RetrieveSuccessful = 9
}
}

View File

@ -0,0 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="System.ComponentModel.Annotations" Version="5.0.0" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,41 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using YABA.Data.Context;
namespace YABA.Data.Configuration
{
public static class DependencyInjectionConfiguration
{
public static void AddDataProjectDependencyInjectionConfiguration(this IServiceCollection services, IConfiguration configuration)
{
services.AddScoped(x =>
{
var optionsBuilder = new DbContextOptionsBuilder<YABABaseContext>();
optionsBuilder.UseNpgsql(configuration.GetConnectionString("YABAReadOnlyDbConnectionString")).UseSnakeCaseNamingConvention();
return new YABAReadOnlyContext(optionsBuilder.Options);
});
services.AddScoped(x => {
var optionsBuilder = new DbContextOptionsBuilder<YABABaseContext>();
optionsBuilder.UseNpgsql(configuration.GetConnectionString("YABAReadWriteDbConnectionString")).UseSnakeCaseNamingConvention();
return new YABAReadWriteContext(optionsBuilder.Options);
});
services.AddDbContext<YABABaseContext>(options => options
.UseNpgsql(configuration.GetConnectionString("YABAReadWriteDbConnectionString"))
.UseSnakeCaseNamingConvention()
.UseQueryTrackingBehavior(QueryTrackingBehavior.TrackAll));
services.AddDbContext<YABAReadWriteContext>(options => options
.UseNpgsql(configuration.GetConnectionString("YABAReadWriteDbConnectionString"))
.UseSnakeCaseNamingConvention()
.UseQueryTrackingBehavior(QueryTrackingBehavior.TrackAll));
services.AddDbContext<YABAReadOnlyContext>(options => options
.UseNpgsql(configuration.GetConnectionString("YABAReadOnlyDbConnectionString"))
.UseSnakeCaseNamingConvention()
.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking));
}
}
}

View File

@ -0,0 +1,67 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Query;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Expressions;
using System.Text;
using YABA.Models;
using YABA.Models.Interfaces;
namespace YABA.Data.Context
{
public class YABABaseContext : DbContext
{
public YABABaseContext() : base() { }
public YABABaseContext(DbContextOptions<YABABaseContext> options) : base(options) { }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// Add lookup backed data here
// SAMPLE
// var lookupBackedData = Enum.GetValues(typeof(LookupEnum)).Cast<LookupEnum>();
// modelBuilder.Entity<LookupModel>().HasData(lookupBackedData.Select(x => new LookupModel(x)));
modelBuilder.Model.GetEntityTypes()
.Where(entityType => typeof(ISoftDeletable).IsAssignableFrom(entityType.ClrType))
.ToList()
.ForEach(entityType =>
{
modelBuilder.Entity(entityType.ClrType)
.HasQueryFilter(ConvertFilterExpression<ISoftDeletable>(e => !e.IsDeleted, entityType.ClrType));
});
modelBuilder.Entity<BookmarkTag>()
.HasKey(x => new { x.BookmarkId, x.TagId });
modelBuilder.Entity<User>()
.HasIndex(x => x.Auth0Id)
.IsUnique();
modelBuilder.Entity<Tag>()
.HasIndex(x => new { x.Name, x.UserId })
.IsUnique();
modelBuilder.Entity<Bookmark>()
.HasIndex(x => new { x.Url, x.UserId })
.IsUnique();
}
public virtual DbSet<Bookmark> Bookmarks { get; set; }
public virtual DbSet<Tag> Tags { get; set; }
public virtual DbSet<BookmarkTag> BookmarkTags { get; set; }
public virtual DbSet<User> Users { get; set; }
private static LambdaExpression ConvertFilterExpression<TInterface>(
Expression<Func<TInterface, bool>> filterExpression,
Type entityType)
{
// SOURCE: https://stackoverflow.com/questions/47673524/ef-core-soft-delete-with-shadow-properties-and-query-filters/48744644#48744644
var newParam = Expression.Parameter(entityType);
var newBody = ReplacingExpressionVisitor.Replace(filterExpression.Parameters.Single(), newParam, filterExpression.Body);
return Expression.Lambda(newBody, newParam);
}
}
}

View File

@ -0,0 +1,18 @@

using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
namespace YABA.Data.Context
{
public class YABABaseContextFactory : IDesignTimeDbContextFactory<YABABaseContext>
{
public YABABaseContext CreateDbContext(string[] args)
{
var optionsBuilder = new DbContextOptionsBuilder<YABABaseContext>();
optionsBuilder.UseNpgsql(args[0])
.UseSnakeCaseNamingConvention()
.UseQueryTrackingBehavior(QueryTrackingBehavior.TrackAll);
return new YABABaseContext(optionsBuilder.Options);
}
}
}

View File

@ -0,0 +1,13 @@
using Microsoft.EntityFrameworkCore;
namespace YABA.Data.Context
{
public class YABAReadOnlyContext : YABABaseContext
{
public YABAReadOnlyContext() : base() { }
public YABAReadOnlyContext(DbContextOptions<YABABaseContext> options) : base(options)
{
ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking;
}
}
}

View File

@ -0,0 +1,57 @@
using Microsoft.EntityFrameworkCore;
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using YABA.Models.Interfaces;
namespace YABA.Data.Context
{
public class YABAReadWriteContext : YABABaseContext
{
public YABAReadWriteContext() : base() { }
public YABAReadWriteContext(DbContextOptions<YABABaseContext> options) : base(options)
{
ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.TrackAll;
}
public override int SaveChanges()
{
var dateCreatedTrackableEntries = ChangeTracker
.Entries()
.Where(e => e.Entity is IDateCreatedTrackable && e.State == EntityState.Added);
foreach (var entry in dateCreatedTrackableEntries)
((IDateCreatedTrackable)entry.Entity).CreatedOn = DateTimeOffset.UtcNow;
var dateModifiedTrackableItems = ChangeTracker
.Entries()
.Where(e => e.Entity is IDateModifiedTrackable && (e.State == EntityState.Modified || e.State == EntityState.Added));
foreach (var entry in dateModifiedTrackableItems)
((IDateModifiedTrackable)entry.Entity).LastModified = DateTimeOffset.UtcNow;
return base.SaveChanges();
}
public override Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
var dateCreatedTrackableEntries = ChangeTracker
.Entries()
.Where(e => e.Entity is IDateCreatedTrackable && e.State == EntityState.Added);
foreach (var entry in dateCreatedTrackableEntries)
((IDateCreatedTrackable)entry.Entity).CreatedOn = DateTimeOffset.UtcNow;
var dateModifiedTrackableItems = ChangeTracker
.Entries()
.Where(e => e.Entity is IDateModifiedTrackable && (e.State == EntityState.Modified || e.State == EntityState.Added));
foreach (var entry in dateModifiedTrackableItems)
((IDateModifiedTrackable)entry.Entity).LastModified = DateTimeOffset.UtcNow;
return base.SaveChangesAsync(cancellationToken);
}
}
}

View File

@ -0,0 +1,35 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.ChangeTracking;
using System.Collections.Generic;
using System.Linq;
using YABA.Models.Interfaces;
namespace YABA.Data.Extensions
{
public static class GenericDbSetExtensions
{
public static void Upsert<T>(this DbSet<T> dbSet, T entity) where T : class, IIdentifiable
{
var entityInList = new List<T>() { entity };
dbSet.UpsertRange(entityInList);
}
public static void UpsertRange<T>(this DbSet<T> dbSet, IEnumerable<T> entities) where T : class, IIdentifiable
{
foreach (var entity in entities)
{
var entityExists = dbSet.Any(x => x.Id == entity.Id);
if (entityExists)
{
EntityEntry<T> attachedEntity = dbSet.Attach(entity);
attachedEntity.State = EntityState.Modified;
}
else
{
dbSet.Add(entity);
}
}
}
}
}

View File

@ -0,0 +1,23 @@
using Microsoft.EntityFrameworkCore;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using YABA.Models;
namespace YABA.Data.Extensions
{
public static class UsersDbSetExtensions
{
public static async Task<bool> UserExistsAsync(this DbSet<User> userDbSet, int userId)
{
return await userDbSet.AnyAsync(x => x.Id == userId);
}
public static bool UserExists(this DbSet<User> userDbSet, int userId)
{
return userDbSet.Any(x => x.Id == userId);
}
}
}

View File

@ -0,0 +1,221 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using YABA.Data.Context;
namespace YABA.Data.Migrations
{
[DbContext(typeof(YABABaseContext))]
[Migration("20230125055348_InitialMigration")]
partial class InitialMigration
{
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("Relational:MaxIdentifierLength", 63)
.HasAnnotation("ProductVersion", "5.0.17")
.HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn);
modelBuilder.Entity("YABA.Models.Bookmark", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id")
.HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn);
b.Property<DateTimeOffset>("CreatedOn")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_on");
b.Property<string>("Description")
.IsRequired()
.HasColumnType("text")
.HasColumnName("description");
b.Property<bool>("IsDeleted")
.HasColumnType("boolean")
.HasColumnName("is_deleted");
b.Property<bool>("IsHidden")
.HasColumnType("boolean")
.HasColumnName("is_hidden");
b.Property<DateTimeOffset>("LastModified")
.HasColumnType("timestamp with time zone")
.HasColumnName("last_modified");
b.Property<string>("Note")
.IsRequired()
.HasColumnType("text")
.HasColumnName("note");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("text")
.HasColumnName("title");
b.Property<int>("UserId")
.HasColumnType("integer")
.HasColumnName("user_id");
b.HasKey("Id")
.HasName("pk_bookmarks");
b.HasIndex("UserId")
.HasDatabaseName("ix_bookmarks_user_id");
b.ToTable("bookmarks");
});
modelBuilder.Entity("YABA.Models.BookmarkTag", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id")
.HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn);
b.Property<int>("BookmarkId")
.HasColumnType("integer")
.HasColumnName("bookmark_id");
b.Property<int>("TagId")
.HasColumnType("integer")
.HasColumnName("tag_id");
b.HasKey("Id")
.HasName("pk_bookmark_tags");
b.HasIndex("TagId")
.HasDatabaseName("ix_bookmark_tags_tag_id");
b.HasIndex("BookmarkId", "TagId")
.IsUnique()
.HasDatabaseName("ix_bookmark_tags_bookmark_id_tag_id");
b.ToTable("bookmark_tags");
});
modelBuilder.Entity("YABA.Models.Tag", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id")
.HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn);
b.Property<bool>("IsDeleted")
.HasColumnType("boolean")
.HasColumnName("is_deleted");
b.Property<bool>("IsHidden")
.HasColumnType("boolean")
.HasColumnName("is_hidden");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text")
.HasColumnName("name");
b.Property<int>("UserId")
.HasColumnType("integer")
.HasColumnName("user_id");
b.HasKey("Id")
.HasName("pk_tags");
b.HasIndex("UserId")
.HasDatabaseName("ix_tags_user_id");
b.ToTable("tags");
});
modelBuilder.Entity("YABA.Models.User", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id")
.HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn);
b.Property<string>("Auth0Id")
.IsRequired()
.HasColumnType("text")
.HasColumnName("auth0id");
b.Property<DateTimeOffset>("CreatedOn")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_on");
b.Property<bool>("IsDeleted")
.HasColumnType("boolean")
.HasColumnName("is_deleted");
b.Property<DateTimeOffset>("LastModified")
.HasColumnType("timestamp with time zone")
.HasColumnName("last_modified");
b.HasKey("Id")
.HasName("pk_users");
b.HasIndex("Auth0Id")
.IsUnique()
.HasDatabaseName("ix_users_auth0id");
b.ToTable("users");
});
modelBuilder.Entity("YABA.Models.Bookmark", b =>
{
b.HasOne("YABA.Models.User", "User")
.WithMany()
.HasForeignKey("UserId")
.HasConstraintName("fk_bookmarks_users_user_id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("YABA.Models.BookmarkTag", b =>
{
b.HasOne("YABA.Models.Bookmark", "Bookmark")
.WithMany()
.HasForeignKey("BookmarkId")
.HasConstraintName("fk_bookmark_tags_bookmarks_bookmark_id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("YABA.Models.Tag", "Tag")
.WithMany()
.HasForeignKey("TagId")
.HasConstraintName("fk_bookmark_tags_tags_tag_id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Bookmark");
b.Navigation("Tag");
});
modelBuilder.Entity("YABA.Models.Tag", b =>
{
b.HasOne("YABA.Models.User", "User")
.WithMany()
.HasForeignKey("UserId")
.HasConstraintName("fk_tags_users_user_id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
#pragma warning restore 612, 618
}
}
}

View File

@ -0,0 +1,144 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
namespace YABA.Data.Migrations
{
public partial class InitialMigration : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "users",
columns: table => new
{
id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
is_deleted = table.Column<bool>(type: "boolean", nullable: false),
created_on = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
last_modified = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
auth0id = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("pk_users", x => x.id);
});
migrationBuilder.CreateTable(
name: "bookmarks",
columns: table => new
{
id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
is_deleted = table.Column<bool>(type: "boolean", nullable: false),
created_on = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
last_modified = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
title = table.Column<string>(type: "text", nullable: false),
description = table.Column<string>(type: "text", nullable: false),
note = table.Column<string>(type: "text", nullable: false),
is_hidden = table.Column<bool>(type: "boolean", nullable: false),
user_id = table.Column<int>(type: "integer", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("pk_bookmarks", x => x.id);
table.ForeignKey(
name: "fk_bookmarks_users_user_id",
column: x => x.user_id,
principalTable: "users",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "tags",
columns: table => new
{
id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
is_deleted = table.Column<bool>(type: "boolean", nullable: false),
name = table.Column<string>(type: "text", nullable: false),
is_hidden = table.Column<bool>(type: "boolean", nullable: false),
user_id = table.Column<int>(type: "integer", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("pk_tags", x => x.id);
table.ForeignKey(
name: "fk_tags_users_user_id",
column: x => x.user_id,
principalTable: "users",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "bookmark_tags",
columns: table => new
{
id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
bookmark_id = table.Column<int>(type: "integer", nullable: false),
tag_id = table.Column<int>(type: "integer", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("pk_bookmark_tags", x => x.id);
table.ForeignKey(
name: "fk_bookmark_tags_bookmarks_bookmark_id",
column: x => x.bookmark_id,
principalTable: "bookmarks",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "fk_bookmark_tags_tags_tag_id",
column: x => x.tag_id,
principalTable: "tags",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "ix_bookmark_tags_bookmark_id_tag_id",
table: "bookmark_tags",
columns: new[] { "bookmark_id", "tag_id" },
unique: true);
migrationBuilder.CreateIndex(
name: "ix_bookmark_tags_tag_id",
table: "bookmark_tags",
column: "tag_id");
migrationBuilder.CreateIndex(
name: "ix_bookmarks_user_id",
table: "bookmarks",
column: "user_id");
migrationBuilder.CreateIndex(
name: "ix_tags_user_id",
table: "tags",
column: "user_id");
migrationBuilder.CreateIndex(
name: "ix_users_auth0id",
table: "users",
column: "auth0id",
unique: true);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "bookmark_tags");
migrationBuilder.DropTable(
name: "bookmarks");
migrationBuilder.DropTable(
name: "tags");
migrationBuilder.DropTable(
name: "users");
}
}
}

View File

@ -0,0 +1,226 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using YABA.Data.Context;
namespace YABA.Data.Migrations
{
[DbContext(typeof(YABABaseContext))]
[Migration("20230126050046_AddedUrlToBookmark")]
partial class AddedUrlToBookmark
{
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("Relational:MaxIdentifierLength", 63)
.HasAnnotation("ProductVersion", "5.0.17")
.HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn);
modelBuilder.Entity("YABA.Models.Bookmark", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id")
.HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn);
b.Property<DateTimeOffset>("CreatedOn")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_on");
b.Property<string>("Description")
.IsRequired()
.HasColumnType("text")
.HasColumnName("description");
b.Property<bool>("IsDeleted")
.HasColumnType("boolean")
.HasColumnName("is_deleted");
b.Property<bool>("IsHidden")
.HasColumnType("boolean")
.HasColumnName("is_hidden");
b.Property<DateTimeOffset>("LastModified")
.HasColumnType("timestamp with time zone")
.HasColumnName("last_modified");
b.Property<string>("Note")
.IsRequired()
.HasColumnType("text")
.HasColumnName("note");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("text")
.HasColumnName("title");
b.Property<string>("Url")
.IsRequired()
.HasColumnType("text")
.HasColumnName("url");
b.Property<int>("UserId")
.HasColumnType("integer")
.HasColumnName("user_id");
b.HasKey("Id")
.HasName("pk_bookmarks");
b.HasIndex("UserId")
.HasDatabaseName("ix_bookmarks_user_id");
b.ToTable("bookmarks");
});
modelBuilder.Entity("YABA.Models.BookmarkTag", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id")
.HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn);
b.Property<int>("BookmarkId")
.HasColumnType("integer")
.HasColumnName("bookmark_id");
b.Property<int>("TagId")
.HasColumnType("integer")
.HasColumnName("tag_id");
b.HasKey("Id")
.HasName("pk_bookmark_tags");
b.HasIndex("TagId")
.HasDatabaseName("ix_bookmark_tags_tag_id");
b.HasIndex("BookmarkId", "TagId")
.IsUnique()
.HasDatabaseName("ix_bookmark_tags_bookmark_id_tag_id");
b.ToTable("bookmark_tags");
});
modelBuilder.Entity("YABA.Models.Tag", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id")
.HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn);
b.Property<bool>("IsDeleted")
.HasColumnType("boolean")
.HasColumnName("is_deleted");
b.Property<bool>("IsHidden")
.HasColumnType("boolean")
.HasColumnName("is_hidden");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text")
.HasColumnName("name");
b.Property<int>("UserId")
.HasColumnType("integer")
.HasColumnName("user_id");
b.HasKey("Id")
.HasName("pk_tags");
b.HasIndex("UserId")
.HasDatabaseName("ix_tags_user_id");
b.ToTable("tags");
});
modelBuilder.Entity("YABA.Models.User", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id")
.HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn);
b.Property<string>("Auth0Id")
.IsRequired()
.HasColumnType("text")
.HasColumnName("auth0id");
b.Property<DateTimeOffset>("CreatedOn")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_on");
b.Property<bool>("IsDeleted")
.HasColumnType("boolean")
.HasColumnName("is_deleted");
b.Property<DateTimeOffset>("LastModified")
.HasColumnType("timestamp with time zone")
.HasColumnName("last_modified");
b.HasKey("Id")
.HasName("pk_users");
b.HasIndex("Auth0Id")
.IsUnique()
.HasDatabaseName("ix_users_auth0id");
b.ToTable("users");
});
modelBuilder.Entity("YABA.Models.Bookmark", b =>
{
b.HasOne("YABA.Models.User", "User")
.WithMany()
.HasForeignKey("UserId")
.HasConstraintName("fk_bookmarks_users_user_id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("YABA.Models.BookmarkTag", b =>
{
b.HasOne("YABA.Models.Bookmark", "Bookmark")
.WithMany()
.HasForeignKey("BookmarkId")
.HasConstraintName("fk_bookmark_tags_bookmarks_bookmark_id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("YABA.Models.Tag", "Tag")
.WithMany()
.HasForeignKey("TagId")
.HasConstraintName("fk_bookmark_tags_tags_tag_id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Bookmark");
b.Navigation("Tag");
});
modelBuilder.Entity("YABA.Models.Tag", b =>
{
b.HasOne("YABA.Models.User", "User")
.WithMany()
.HasForeignKey("UserId")
.HasConstraintName("fk_tags_users_user_id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
#pragma warning restore 612, 618
}
}
}

View File

@ -0,0 +1,24 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace YABA.Data.Migrations
{
public partial class AddedUrlToBookmark : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "url",
table: "bookmarks",
type: "text",
nullable: false,
defaultValue: "");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "url",
table: "bookmarks");
}
}
}

View File

@ -0,0 +1,212 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using YABA.Data.Context;
namespace YABA.Data.Migrations
{
[DbContext(typeof(YABABaseContext))]
[Migration("20230128064541_ModifiedBookmarkTagsPK_RemovedSoftDeleteFromTagsAndBookmarks")]
partial class ModifiedBookmarkTagsPK_RemovedSoftDeleteFromTagsAndBookmarks
{
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("Relational:MaxIdentifierLength", 63)
.HasAnnotation("ProductVersion", "5.0.17")
.HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn);
modelBuilder.Entity("YABA.Models.Bookmark", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id")
.HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn);
b.Property<DateTimeOffset>("CreatedOn")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_on");
b.Property<string>("Description")
.IsRequired()
.HasColumnType("text")
.HasColumnName("description");
b.Property<bool>("IsHidden")
.HasColumnType("boolean")
.HasColumnName("is_hidden");
b.Property<DateTimeOffset>("LastModified")
.HasColumnType("timestamp with time zone")
.HasColumnName("last_modified");
b.Property<string>("Note")
.IsRequired()
.HasColumnType("text")
.HasColumnName("note");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("text")
.HasColumnName("title");
b.Property<string>("Url")
.IsRequired()
.HasColumnType("text")
.HasColumnName("url");
b.Property<int>("UserId")
.HasColumnType("integer")
.HasColumnName("user_id");
b.HasKey("Id")
.HasName("pk_bookmarks");
b.HasIndex("UserId")
.HasDatabaseName("ix_bookmarks_user_id");
b.ToTable("bookmarks");
});
modelBuilder.Entity("YABA.Models.BookmarkTag", b =>
{
b.Property<int>("BookmarkId")
.HasColumnType("integer")
.HasColumnName("bookmark_id");
b.Property<int>("TagId")
.HasColumnType("integer")
.HasColumnName("tag_id");
b.HasKey("BookmarkId", "TagId")
.HasName("pk_bookmark_tags");
b.HasIndex("TagId")
.HasDatabaseName("ix_bookmark_tags_tag_id");
b.ToTable("bookmark_tags");
});
modelBuilder.Entity("YABA.Models.Tag", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id")
.HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn);
b.Property<bool>("IsHidden")
.HasColumnType("boolean")
.HasColumnName("is_hidden");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text")
.HasColumnName("name");
b.Property<int>("UserId")
.HasColumnType("integer")
.HasColumnName("user_id");
b.HasKey("Id")
.HasName("pk_tags");
b.HasIndex("Name")
.IsUnique()
.HasDatabaseName("ix_tags_name");
b.HasIndex("UserId")
.HasDatabaseName("ix_tags_user_id");
b.ToTable("tags");
});
modelBuilder.Entity("YABA.Models.User", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id")
.HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn);
b.Property<string>("Auth0Id")
.IsRequired()
.HasColumnType("text")
.HasColumnName("auth0id");
b.Property<DateTimeOffset>("CreatedOn")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_on");
b.Property<bool>("IsDeleted")
.HasColumnType("boolean")
.HasColumnName("is_deleted");
b.Property<DateTimeOffset>("LastModified")
.HasColumnType("timestamp with time zone")
.HasColumnName("last_modified");
b.HasKey("Id")
.HasName("pk_users");
b.HasIndex("Auth0Id")
.IsUnique()
.HasDatabaseName("ix_users_auth0id");
b.ToTable("users");
});
modelBuilder.Entity("YABA.Models.Bookmark", b =>
{
b.HasOne("YABA.Models.User", "User")
.WithMany()
.HasForeignKey("UserId")
.HasConstraintName("fk_bookmarks_users_user_id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("YABA.Models.BookmarkTag", b =>
{
b.HasOne("YABA.Models.Bookmark", "Bookmark")
.WithMany()
.HasForeignKey("BookmarkId")
.HasConstraintName("fk_bookmark_tags_bookmarks_bookmark_id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("YABA.Models.Tag", "Tag")
.WithMany()
.HasForeignKey("TagId")
.HasConstraintName("fk_bookmark_tags_tags_tag_id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Bookmark");
b.Navigation("Tag");
});
modelBuilder.Entity("YABA.Models.Tag", b =>
{
b.HasOne("YABA.Models.User", "User")
.WithMany()
.HasForeignKey("UserId")
.HasConstraintName("fk_tags_users_user_id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
#pragma warning restore 612, 618
}
}
}

View File

@ -0,0 +1,86 @@
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
namespace YABA.Data.Migrations
{
public partial class ModifiedBookmarkTagsPK_RemovedSoftDeleteFromTagsAndBookmarks : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropPrimaryKey(
name: "pk_bookmark_tags",
table: "bookmark_tags");
migrationBuilder.DropIndex(
name: "ix_bookmark_tags_bookmark_id_tag_id",
table: "bookmark_tags");
migrationBuilder.DropColumn(
name: "is_deleted",
table: "tags");
migrationBuilder.DropColumn(
name: "is_deleted",
table: "bookmarks");
migrationBuilder.DropColumn(
name: "id",
table: "bookmark_tags");
migrationBuilder.AddPrimaryKey(
name: "pk_bookmark_tags",
table: "bookmark_tags",
columns: new[] { "bookmark_id", "tag_id" });
migrationBuilder.CreateIndex(
name: "ix_tags_name",
table: "tags",
column: "name",
unique: true);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "ix_tags_name",
table: "tags");
migrationBuilder.DropPrimaryKey(
name: "pk_bookmark_tags",
table: "bookmark_tags");
migrationBuilder.AddColumn<bool>(
name: "is_deleted",
table: "tags",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "is_deleted",
table: "bookmarks",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<int>(
name: "id",
table: "bookmark_tags",
type: "integer",
nullable: false,
defaultValue: 0)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn);
migrationBuilder.AddPrimaryKey(
name: "pk_bookmark_tags",
table: "bookmark_tags",
column: "id");
migrationBuilder.CreateIndex(
name: "ix_bookmark_tags_bookmark_id_tag_id",
table: "bookmark_tags",
columns: new[] { "bookmark_id", "tag_id" },
unique: true);
}
}
}

View File

@ -0,0 +1,210 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using YABA.Data.Context;
namespace YABA.Data.Migrations
{
[DbContext(typeof(YABABaseContext))]
[Migration("20230128193757_ModifiedBookmarkTable_MakeNoteAndDescriptionOptional")]
partial class ModifiedBookmarkTable_MakeNoteAndDescriptionOptional
{
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("Relational:MaxIdentifierLength", 63)
.HasAnnotation("ProductVersion", "5.0.17")
.HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn);
modelBuilder.Entity("YABA.Models.Bookmark", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id")
.HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn);
b.Property<DateTimeOffset>("CreatedOn")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_on");
b.Property<string>("Description")
.HasColumnType("text")
.HasColumnName("description");
b.Property<bool>("IsHidden")
.HasColumnType("boolean")
.HasColumnName("is_hidden");
b.Property<DateTimeOffset>("LastModified")
.HasColumnType("timestamp with time zone")
.HasColumnName("last_modified");
b.Property<string>("Note")
.HasColumnType("text")
.HasColumnName("note");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("text")
.HasColumnName("title");
b.Property<string>("Url")
.IsRequired()
.HasColumnType("text")
.HasColumnName("url");
b.Property<int>("UserId")
.HasColumnType("integer")
.HasColumnName("user_id");
b.HasKey("Id")
.HasName("pk_bookmarks");
b.HasIndex("UserId")
.HasDatabaseName("ix_bookmarks_user_id");
b.ToTable("bookmarks");
});
modelBuilder.Entity("YABA.Models.BookmarkTag", b =>
{
b.Property<int>("BookmarkId")
.HasColumnType("integer")
.HasColumnName("bookmark_id");
b.Property<int>("TagId")
.HasColumnType("integer")
.HasColumnName("tag_id");
b.HasKey("BookmarkId", "TagId")
.HasName("pk_bookmark_tags");
b.HasIndex("TagId")
.HasDatabaseName("ix_bookmark_tags_tag_id");
b.ToTable("bookmark_tags");
});
modelBuilder.Entity("YABA.Models.Tag", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id")
.HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn);
b.Property<bool>("IsHidden")
.HasColumnType("boolean")
.HasColumnName("is_hidden");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text")
.HasColumnName("name");
b.Property<int>("UserId")
.HasColumnType("integer")
.HasColumnName("user_id");
b.HasKey("Id")
.HasName("pk_tags");
b.HasIndex("Name")
.IsUnique()
.HasDatabaseName("ix_tags_name");
b.HasIndex("UserId")
.HasDatabaseName("ix_tags_user_id");
b.ToTable("tags");
});
modelBuilder.Entity("YABA.Models.User", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id")
.HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn);
b.Property<string>("Auth0Id")
.IsRequired()
.HasColumnType("text")
.HasColumnName("auth0id");
b.Property<DateTimeOffset>("CreatedOn")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_on");
b.Property<bool>("IsDeleted")
.HasColumnType("boolean")
.HasColumnName("is_deleted");
b.Property<DateTimeOffset>("LastModified")
.HasColumnType("timestamp with time zone")
.HasColumnName("last_modified");
b.HasKey("Id")
.HasName("pk_users");
b.HasIndex("Auth0Id")
.IsUnique()
.HasDatabaseName("ix_users_auth0id");
b.ToTable("users");
});
modelBuilder.Entity("YABA.Models.Bookmark", b =>
{
b.HasOne("YABA.Models.User", "User")
.WithMany()
.HasForeignKey("UserId")
.HasConstraintName("fk_bookmarks_users_user_id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("YABA.Models.BookmarkTag", b =>
{
b.HasOne("YABA.Models.Bookmark", "Bookmark")
.WithMany()
.HasForeignKey("BookmarkId")
.HasConstraintName("fk_bookmark_tags_bookmarks_bookmark_id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("YABA.Models.Tag", "Tag")
.WithMany()
.HasForeignKey("TagId")
.HasConstraintName("fk_bookmark_tags_tags_tag_id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Bookmark");
b.Navigation("Tag");
});
modelBuilder.Entity("YABA.Models.Tag", b =>
{
b.HasOne("YABA.Models.User", "User")
.WithMany()
.HasForeignKey("UserId")
.HasConstraintName("fk_tags_users_user_id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
#pragma warning restore 612, 618
}
}
}

View File

@ -0,0 +1,49 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace YABA.Data.Migrations
{
public partial class ModifiedBookmarkTable_MakeNoteAndDescriptionOptional : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "note",
table: "bookmarks",
type: "text",
nullable: true,
oldClrType: typeof(string),
oldType: "text");
migrationBuilder.AlterColumn<string>(
name: "description",
table: "bookmarks",
type: "text",
nullable: true,
oldClrType: typeof(string),
oldType: "text");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "note",
table: "bookmarks",
type: "text",
nullable: false,
defaultValue: "",
oldClrType: typeof(string),
oldType: "text",
oldNullable: true);
migrationBuilder.AlterColumn<string>(
name: "description",
table: "bookmarks",
type: "text",
nullable: false,
defaultValue: "",
oldClrType: typeof(string),
oldType: "text",
oldNullable: true);
}
}
}

View File

@ -0,0 +1,214 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using YABA.Data.Context;
namespace YABA.Data.Migrations
{
[DbContext(typeof(YABABaseContext))]
[Migration("20230304061552_AddUniqueNameAndUserIdComboConstraintToTagsTable_AddUniqueUrlAndUserIdComboConstraintToBookmark")]
partial class AddUniqueNameAndUserIdComboConstraintToTagsTable_AddUniqueUrlAndUserIdComboConstraintToBookmark
{
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("Relational:MaxIdentifierLength", 63)
.HasAnnotation("ProductVersion", "5.0.17")
.HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn);
modelBuilder.Entity("YABA.Models.Bookmark", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id")
.HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn);
b.Property<DateTimeOffset>("CreatedOn")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_on");
b.Property<string>("Description")
.HasColumnType("text")
.HasColumnName("description");
b.Property<bool>("IsHidden")
.HasColumnType("boolean")
.HasColumnName("is_hidden");
b.Property<DateTimeOffset>("LastModified")
.HasColumnType("timestamp with time zone")
.HasColumnName("last_modified");
b.Property<string>("Note")
.HasColumnType("text")
.HasColumnName("note");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("text")
.HasColumnName("title");
b.Property<string>("Url")
.IsRequired()
.HasColumnType("text")
.HasColumnName("url");
b.Property<int>("UserId")
.HasColumnType("integer")
.HasColumnName("user_id");
b.HasKey("Id")
.HasName("pk_bookmarks");
b.HasIndex("UserId")
.HasDatabaseName("ix_bookmarks_user_id");
b.HasIndex("Url", "UserId")
.IsUnique()
.HasDatabaseName("ix_bookmarks_url_user_id");
b.ToTable("bookmarks");
});
modelBuilder.Entity("YABA.Models.BookmarkTag", b =>
{
b.Property<int>("BookmarkId")
.HasColumnType("integer")
.HasColumnName("bookmark_id");
b.Property<int>("TagId")
.HasColumnType("integer")
.HasColumnName("tag_id");
b.HasKey("BookmarkId", "TagId")
.HasName("pk_bookmark_tags");
b.HasIndex("TagId")
.HasDatabaseName("ix_bookmark_tags_tag_id");
b.ToTable("bookmark_tags");
});
modelBuilder.Entity("YABA.Models.Tag", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id")
.HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn);
b.Property<bool>("IsHidden")
.HasColumnType("boolean")
.HasColumnName("is_hidden");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text")
.HasColumnName("name");
b.Property<int>("UserId")
.HasColumnType("integer")
.HasColumnName("user_id");
b.HasKey("Id")
.HasName("pk_tags");
b.HasIndex("UserId")
.HasDatabaseName("ix_tags_user_id");
b.HasIndex("Name", "UserId")
.IsUnique()
.HasDatabaseName("ix_tags_name_user_id");
b.ToTable("tags");
});
modelBuilder.Entity("YABA.Models.User", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id")
.HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn);
b.Property<string>("Auth0Id")
.IsRequired()
.HasColumnType("text")
.HasColumnName("auth0id");
b.Property<DateTimeOffset>("CreatedOn")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_on");
b.Property<bool>("IsDeleted")
.HasColumnType("boolean")
.HasColumnName("is_deleted");
b.Property<DateTimeOffset>("LastModified")
.HasColumnType("timestamp with time zone")
.HasColumnName("last_modified");
b.HasKey("Id")
.HasName("pk_users");
b.HasIndex("Auth0Id")
.IsUnique()
.HasDatabaseName("ix_users_auth0id");
b.ToTable("users");
});
modelBuilder.Entity("YABA.Models.Bookmark", b =>
{
b.HasOne("YABA.Models.User", "User")
.WithMany()
.HasForeignKey("UserId")
.HasConstraintName("fk_bookmarks_users_user_id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("YABA.Models.BookmarkTag", b =>
{
b.HasOne("YABA.Models.Bookmark", "Bookmark")
.WithMany()
.HasForeignKey("BookmarkId")
.HasConstraintName("fk_bookmark_tags_bookmarks_bookmark_id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("YABA.Models.Tag", "Tag")
.WithMany()
.HasForeignKey("TagId")
.HasConstraintName("fk_bookmark_tags_tags_tag_id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Bookmark");
b.Navigation("Tag");
});
modelBuilder.Entity("YABA.Models.Tag", b =>
{
b.HasOne("YABA.Models.User", "User")
.WithMany()
.HasForeignKey("UserId")
.HasConstraintName("fk_tags_users_user_id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
#pragma warning restore 612, 618
}
}
}

View File

@ -0,0 +1,43 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace YABA.Data.Migrations
{
public partial class AddUniqueNameAndUserIdComboConstraintToTagsTable_AddUniqueUrlAndUserIdComboConstraintToBookmark : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "ix_tags_name",
table: "tags");
migrationBuilder.CreateIndex(
name: "ix_tags_name_user_id",
table: "tags",
columns: new[] { "name", "user_id" },
unique: true);
migrationBuilder.CreateIndex(
name: "ix_bookmarks_url_user_id",
table: "bookmarks",
columns: new[] { "url", "user_id" },
unique: true);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "ix_tags_name_user_id",
table: "tags");
migrationBuilder.DropIndex(
name: "ix_bookmarks_url_user_id",
table: "bookmarks");
migrationBuilder.CreateIndex(
name: "ix_tags_name",
table: "tags",
column: "name",
unique: true);
}
}
}

View File

@ -0,0 +1,212 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using YABA.Data.Context;
namespace YABA.Data.Migrations
{
[DbContext(typeof(YABABaseContext))]
partial class YABABaseContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("Relational:MaxIdentifierLength", 63)
.HasAnnotation("ProductVersion", "5.0.17")
.HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn);
modelBuilder.Entity("YABA.Models.Bookmark", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id")
.HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn);
b.Property<DateTimeOffset>("CreatedOn")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_on");
b.Property<string>("Description")
.HasColumnType("text")
.HasColumnName("description");
b.Property<bool>("IsHidden")
.HasColumnType("boolean")
.HasColumnName("is_hidden");
b.Property<DateTimeOffset>("LastModified")
.HasColumnType("timestamp with time zone")
.HasColumnName("last_modified");
b.Property<string>("Note")
.HasColumnType("text")
.HasColumnName("note");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("text")
.HasColumnName("title");
b.Property<string>("Url")
.IsRequired()
.HasColumnType("text")
.HasColumnName("url");
b.Property<int>("UserId")
.HasColumnType("integer")
.HasColumnName("user_id");
b.HasKey("Id")
.HasName("pk_bookmarks");
b.HasIndex("UserId")
.HasDatabaseName("ix_bookmarks_user_id");
b.HasIndex("Url", "UserId")
.IsUnique()
.HasDatabaseName("ix_bookmarks_url_user_id");
b.ToTable("bookmarks");
});
modelBuilder.Entity("YABA.Models.BookmarkTag", b =>
{
b.Property<int>("BookmarkId")
.HasColumnType("integer")
.HasColumnName("bookmark_id");
b.Property<int>("TagId")
.HasColumnType("integer")
.HasColumnName("tag_id");
b.HasKey("BookmarkId", "TagId")
.HasName("pk_bookmark_tags");
b.HasIndex("TagId")
.HasDatabaseName("ix_bookmark_tags_tag_id");
b.ToTable("bookmark_tags");
});
modelBuilder.Entity("YABA.Models.Tag", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id")
.HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn);
b.Property<bool>("IsHidden")
.HasColumnType("boolean")
.HasColumnName("is_hidden");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text")
.HasColumnName("name");
b.Property<int>("UserId")
.HasColumnType("integer")
.HasColumnName("user_id");
b.HasKey("Id")
.HasName("pk_tags");
b.HasIndex("UserId")
.HasDatabaseName("ix_tags_user_id");
b.HasIndex("Name", "UserId")
.IsUnique()
.HasDatabaseName("ix_tags_name_user_id");
b.ToTable("tags");
});
modelBuilder.Entity("YABA.Models.User", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id")
.HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn);
b.Property<string>("Auth0Id")
.IsRequired()
.HasColumnType("text")
.HasColumnName("auth0id");
b.Property<DateTimeOffset>("CreatedOn")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_on");
b.Property<bool>("IsDeleted")
.HasColumnType("boolean")
.HasColumnName("is_deleted");
b.Property<DateTimeOffset>("LastModified")
.HasColumnType("timestamp with time zone")
.HasColumnName("last_modified");
b.HasKey("Id")
.HasName("pk_users");
b.HasIndex("Auth0Id")
.IsUnique()
.HasDatabaseName("ix_users_auth0id");
b.ToTable("users");
});
modelBuilder.Entity("YABA.Models.Bookmark", b =>
{
b.HasOne("YABA.Models.User", "User")
.WithMany()
.HasForeignKey("UserId")
.HasConstraintName("fk_bookmarks_users_user_id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("YABA.Models.BookmarkTag", b =>
{
b.HasOne("YABA.Models.Bookmark", "Bookmark")
.WithMany()
.HasForeignKey("BookmarkId")
.HasConstraintName("fk_bookmark_tags_bookmarks_bookmark_id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("YABA.Models.Tag", "Tag")
.WithMany()
.HasForeignKey("TagId")
.HasConstraintName("fk_bookmark_tags_tags_tag_id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Bookmark");
b.Navigation("Tag");
});
modelBuilder.Entity("YABA.Models.Tag", b =>
{
b.HasOne("YABA.Models.User", "User")
.WithMany()
.HasForeignKey("UserId")
.HasConstraintName("fk_tags_users_user_id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
#pragma warning restore 612, 618
}
}
}

View File

@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="EFCore.NamingConventions" Version="6.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="6.0.14" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="6.0.14">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.Configuration" Version="7.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="7.0.0" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="6.0.8" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\YABA.Common\YABA.Common.csproj" />
<ProjectReference Include="..\YABA.Models\YABA.Models.csproj" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,28 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using YABA.Common.Interfaces;
using YABA.Models.Interfaces;
namespace YABA.Models
{
public class Bookmark : IIdentifiable, IDateCreatedTrackable, IDateModifiedTrackable, IBookmark
{
public int Id { get; set; }
public DateTimeOffset CreatedOn { get; set; }
public DateTimeOffset LastModified { get; set; }
public string Title { get; set; }
public string? Description { get; set; }
public string? Note { get; set; }
public bool IsHidden { get; set; }
public string Url { get; set; }
[Required]
[ForeignKey(nameof(User))]
public int UserId { get; set; }
public virtual User User { get; set; }
public ICollection<BookmarkTag> BookmarkTags { get; set; }
}
}

View File

@ -0,0 +1,18 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace YABA.Models
{
public class BookmarkTag
{
[Required]
[ForeignKey(nameof(Bookmark))]
public int BookmarkId { get; set; }
public virtual Bookmark Bookmark { get; set; }
[Required]
[ForeignKey(nameof(Tag))]
public int TagId { get; set; }
public virtual Tag Tag { get; set; }
}
}

View File

@ -0,0 +1,9 @@
using System;
namespace YABA.Models.Interfaces
{
public interface IDateCreatedTrackable
{
public DateTimeOffset CreatedOn { get; set; }
}
}

View File

@ -0,0 +1,9 @@
using System;
namespace YABA.Models.Interfaces
{
public interface IDateModifiedTrackable
{
public DateTimeOffset LastModified { get; set; }
}
}

View File

@ -0,0 +1,8 @@

namespace YABA.Models.Interfaces
{
public interface IIdentifiable
{
public int Id { get; set; }
}
}

View File

@ -0,0 +1,8 @@

namespace YABA.Models.Interfaces
{
public interface ISoftDeletable
{
public bool IsDeleted { get; set; }
}
}

21
API/YABA.Models/Tag.cs Normal file
View File

@ -0,0 +1,21 @@
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using YABA.Models.Interfaces;
namespace YABA.Models
{
public class Tag : IIdentifiable
{
public int Id { get; set; }
public string Name { get; set; }
public bool IsHidden { get; set; }
[Required]
[ForeignKey(nameof(User))]
public int UserId { get; set; }
public virtual User User { get; set; }
public virtual ICollection<BookmarkTag> TagBookmarks { get; set; }
}
}

17
API/YABA.Models/User.cs Normal file
View File

@ -0,0 +1,17 @@
using System;
using System.ComponentModel.DataAnnotations;
using YABA.Models.Interfaces;
namespace YABA.Models
{
public class User : IIdentifiable, ISoftDeletable, IDateCreatedTrackable, IDateModifiedTrackable
{
public int Id { get; set; }
public bool IsDeleted { get; set; }
public DateTimeOffset CreatedOn { get; set; }
public DateTimeOffset LastModified { get; set; }
[Required]
public string Auth0Id { get; set; }
}
}

View File

@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="System.ComponentModel.Annotations" Version="5.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\YABA.Common\YABA.Common.csproj" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,67 @@
using Microsoft.EntityFrameworkCore;
using Moq;
using System;
using System.Collections.Generic;
using System.Linq;
using Xunit;
using YABA.Data.Context;
using YABA.Models;
namespace YABA.Service.Tests
{
public partial class UserServiceTests
{
[Theory]
[ClassData(typeof(IsUserRegistedTestData))]
public void IsUserRegistedTests(IsUserRegisteredScenario scenario)
{
var usersDbSet = new Mock<DbSet<User>>();
usersDbSet.As<IQueryable<User>>().Setup(m => m.Provider).Returns(scenario.Users.Provider);
usersDbSet.As<IQueryable<User>>().Setup(m => m.Expression).Returns(scenario.Users.Expression);
usersDbSet.As<IQueryable<User>>().Setup(m => m.ElementType).Returns(scenario.Users.ElementType);
usersDbSet.As<IQueryable<User>>().Setup(m => m.GetEnumerator()).Returns(() => scenario.Users.GetEnumerator());
var mockContext = new Mock<YABAReadOnlyContext>();
mockContext.Setup(x => x.Users).Returns(usersDbSet.Object);
var userService = new UserService(mockContext.Object, null, null, null);
var actualIsUserRegistered = userService.IsUserRegistered(scenario.AuthProviderId);
Assert.Equal(scenario.ExpectedResult, actualIsUserRegistered);
}
}
public class IsUserRegistedTestData : TheoryData<IsUserRegisteredScenario>
{
public IsUserRegistedTestData()
{
Add(new IsUserRegisteredScenario
{
Users = new List<User>
{
new User { Id = 1, Auth0Id = "auth0|TestId1" },
}.AsQueryable(),
AuthProviderId = "auth0|TestId1",
ExpectedResult = true
});
// Not Found
Add(new IsUserRegisteredScenario
{
Users = new List<User>
{
new User { Id = 1, Auth0Id = "auth0|TestId1" },
}.AsQueryable(),
AuthProviderId = "auth0|TestId2",
ExpectedResult = false
});
}
}
public class IsUserRegisteredScenario
{
public IQueryable<User> Users { get; set; }
public string AuthProviderId { get; set; }
public bool ExpectedResult { get; set; }
public bool ActualResult { get; set; }
}
}

View File

@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.3.2" />
<PackageReference Include="Moq" Version="4.18.4" />
<PackageReference Include="xunit" Version="2.4.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.5">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" Version="3.1.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\YABA.Service\YABA.Service.csproj" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,280 @@
using AutoMapper;
using HtmlAgilityPack;
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using YABA.Common.DTOs.Bookmarks;
using YABA.Common.DTOs.Tags;
using YABA.Common.Extensions;
using YABA.Common.Interfaces;
using YABA.Data.Context;
using YABA.Data.Extensions;
using YABA.Models;
using YABA.Service.Interfaces;
namespace YABA.Service
{
public class BookmarkService : IBookmarkService
{
private readonly YABAReadOnlyContext _roContext;
private readonly YABAReadWriteContext _context;
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly IMapper _mapper;
public BookmarkService(
YABAReadOnlyContext roContext,
YABAReadWriteContext context,
IHttpContextAccessor httpContextAccessor,
IMapper mapper)
{
_roContext = roContext;
_context = context;
_httpContextAccessor = httpContextAccessor;
_mapper = mapper;
}
public IEnumerable<BookmarkDTO> GetAll(bool showHidden = false)
{
var currentUserId = GetCurrentUserId();
var userBookmarkDTOs = new List<BookmarkDTO>();
var showHiddenCondition = new Func<Bookmark, bool>(b => b.IsHidden || b.BookmarkTags.Where(x => x.Tag.UserId == currentUserId).Any(x => x.Tag.IsHidden));
var showNotHiddenCondition = new Func<Bookmark, bool>(b => !b.IsHidden && b.BookmarkTags.Where(x => x.Tag.UserId == currentUserId).All(bt => !bt.Tag.IsHidden));
var filteredBookmarks = _roContext.Bookmarks
.Include(b => b.BookmarkTags)
.ThenInclude(bt => bt.Tag)
.Where(b => b.UserId == currentUserId)
.Where(showHidden ? showHiddenCondition : showNotHiddenCondition)
.ToList();
foreach(var bookmark in filteredBookmarks)
{
var isBookmarkHidden = bookmark.IsHidden;
var bookmarkDTO = _mapper.Map<BookmarkDTO>(bookmark);
var bookmarkTags = bookmark.BookmarkTags.Select(x => x.Tag);
bookmarkDTO.Tags = _mapper.Map<List<TagDTO>>(bookmarkTags);
userBookmarkDTOs.Add(bookmarkDTO);
}
return userBookmarkDTOs;
}
public async Task<BookmarkDTO?> CreateBookmark(CreateBookmarkRequestDTO request)
{
var currentUserId = GetCurrentUserId();
if (!_roContext.Users.UserExists(currentUserId)
|| await _roContext.Bookmarks.AnyAsync(x => x.UserId == currentUserId && x.Url == request.Url)) return null;
var bookmark = _mapper.Map<Bookmark>(request);
UpdateBookmarkWithMetaData(bookmark);
bookmark.UserId = currentUserId;
var newEntity = await _context.Bookmarks.AddAsync(bookmark);
if (await _context.SaveChangesAsync() > 0)
{
var bookmarkDTO = _mapper.Map<BookmarkDTO>(newEntity.Entity);
if(request.Tags != null && request.Tags.Any())
bookmarkDTO.Tags.AddRange(await UpdateBookmarkTags(bookmarkDTO.Id, request.Tags));
return bookmarkDTO;
}
return null;
}
public async Task<BookmarkDTO?> UpdateBookmark(int id, UpdateBookmarkRequestDTO request)
{
var currentUserId = GetCurrentUserId();
var bookmark = _context.Bookmarks.FirstOrDefault(x => x.UserId == currentUserId && x.Id == id);
var tags = new List<TagDTO>();
if (request.Tags != null && request.Tags.Any())
tags = (await UpdateBookmarkTags(id, request.Tags)).ToList();
if (bookmark == null) return null;
bookmark.Title = !string.IsNullOrEmpty(request.Title) ? request.Title : bookmark.Title;
bookmark.Description = !string.IsNullOrEmpty(request.Description) ? request.Description : bookmark.Description;
bookmark.Note = !string.IsNullOrEmpty(request.Note) ? request.Note : bookmark.Note;
bookmark.IsHidden = request.IsHidden;
bookmark.Url = !string.IsNullOrEmpty(request.Url) ? request.Url : bookmark.Url;
UpdateBookmarkWithMetaData(bookmark);
await _context.SaveChangesAsync();
var bookmarkDTO = _mapper.Map<BookmarkDTO>(bookmark);
bookmarkDTO.Tags = tags;
return bookmarkDTO;
}
public async Task<IEnumerable<TagDTO>?> UpdateBookmarkTags(int id, IEnumerable<string> tags)
{
var currentUserId = GetCurrentUserId();
if (!_roContext.Bookmarks.Any(x => x.Id == id && x.UserId == currentUserId)
|| tags == null || !tags.Any()) return null;
// Add tags that are not yet in the database
var savedUserTags = _context.Tags.Where(x => x.UserId == currentUserId).ToList();
var tagsToSave = tags.Except(savedUserTags.Select(x => x.Name).ToHashSet()).Select(x => new Tag { Name = x, UserId = currentUserId }).ToList();
await _context.Tags.AddRangeAsync(tagsToSave);
await _context.SaveChangesAsync();
// Add newly added tags to the lookup
savedUserTags.AddRange(tagsToSave);
var existingBookmarkTags = _roContext.BookmarkTags.Include(x => x.Tag).Where(x => x.BookmarkId == id).ToList();
var existingBookmarkTagsLookup = existingBookmarkTags.ToDictionary(k => k.TagId, v => v.Tag.Name);
var bookmarkTagsToRemove = existingBookmarkTagsLookup
.Where(x => !tags.Contains(x.Value))
.Select(x => new BookmarkTag { BookmarkId = id, TagId = x.Key });
var savedUserTagsByName = savedUserTags.ToDictionary(k => k.Name, v => v.Id);
var bookmarkTagsToAdd = tags.Except(existingBookmarkTagsLookup.Values)
.Select(x => new BookmarkTag { BookmarkId = id, TagId = savedUserTagsByName[x] });
_context.BookmarkTags.RemoveRange(bookmarkTagsToRemove);
await _context.BookmarkTags.AddRangeAsync(bookmarkTagsToAdd);
if (await _context.SaveChangesAsync() >= 0)
{
var updatedBookmarkTags = _roContext.BookmarkTags
.Include(x => x.Tag)
.Where(x => x.BookmarkId == id)
.Select(x => x.Tag);
return _mapper.Map<IEnumerable<TagDTO>>(updatedBookmarkTags);
}
return null;
}
public async Task<BookmarkDTO?> Get(int id)
{
int.TryParse(_httpContextAccessor.HttpContext.User.Identity.GetUserId(), out int userId);
var bookmark = await _roContext.Bookmarks.FirstOrDefaultAsync(x => x.Id == id && x.UserId == userId);
if (bookmark == null) return null;
var bookmarkTags = _roContext.BookmarkTags
.Include(x => x.Tag)
.Where(x => x.BookmarkId == id)
.Select(x => x.Tag)
.ToList();
var bookmarkDTO = _mapper.Map<BookmarkDTO>(bookmark);
bookmarkDTO.Tags = _mapper.Map<List<TagDTO>>(bookmarkTags);
return bookmarkDTO;
}
public IEnumerable<TagDTO>? GetBookmarkTags(int id)
{
int.TryParse(_httpContextAccessor.HttpContext.User.Identity.GetUserId(), out int userId);
if (!_roContext.Bookmarks.Any(x => x.Id == id && x.UserId == userId)) return null;
var bookmarkTags = _roContext.BookmarkTags
.Include(x => x.Tag)
.Where(x => x.BookmarkId == id)
.Select(x => x.Tag)
.ToList();
return _mapper.Map<IEnumerable<TagDTO>>(bookmarkTags);
}
public async Task<int?> DeleteBookmark(int id)
{
var result = await DeleteBookmarks(new List<int> { id });
return result.FirstOrDefault();
}
public async Task<IEnumerable<int>?> DeleteBookmarks(IEnumerable<int> ids)
{
var currentUserId = GetCurrentUserId();
if (!await _roContext.Users.UserExistsAsync(currentUserId)) return null;
var entriesToDelete = _context.Bookmarks.Where(x => x.UserId == currentUserId && ids.Contains(x.Id)).ToList();
_context.Bookmarks.RemoveRange(entriesToDelete);
if (await _context.SaveChangesAsync() <= 0) return null;
return entriesToDelete.Select(x => x.Id);
}
public async Task<IEnumerable<int>?> HideBookmarks(IEnumerable<int> ids)
{
var currentUserId = GetCurrentUserId();
if (!await _roContext.Users.UserExistsAsync(currentUserId)) return null;
var entriesToHide = _context.Bookmarks.Where(x => x.UserId == currentUserId && ids.Contains(x.Id)).ToList();
entriesToHide.ForEach((x) => { x.IsHidden = !x.IsHidden; });
if(await _context.SaveChangesAsync() <= 0) return null;
return entriesToHide.Select(x => x.Id);
}
public IEnumerable<TagDTO> GetAllBookmarkTags(bool showHidden = false)
{
var currentUserId = GetCurrentUserId();
var activeUserTags = _roContext.BookmarkTags
.Include(x => x.Tag)
.Where(x => x.Tag.UserId == currentUserId && x.Tag.IsHidden == showHidden)
.ToList()
.GroupBy(x => x.Tag.Id)
.Select(g => g.First()?.Tag);
return _mapper.Map<IEnumerable<TagDTO>>(activeUserTags);
}
private int GetCurrentUserId()
{
int.TryParse(_httpContextAccessor.HttpContext.User.Identity.GetUserId(), out int userId);
return userId;
}
private void UpdateBookmarkWithMetaData(IBookmark bookmark)
{
var webClient = new WebClient();
webClient.Headers.Add("User-Agent", "APIClient");
var sourceData = webClient.DownloadString(bookmark.Url);
var title = Regex.Match(sourceData, @"\<title\b[^>]*\>\s*(?<Title>[\s\S]*?)\</title\>", RegexOptions.IgnoreCase).Groups["Title"].Value;
var description = string.Empty;
var getHtmlDoc = new HtmlWeb();
var document = getHtmlDoc.Load(bookmark.Url);
var metaTags = document.DocumentNode.SelectNodes("//meta");
if (metaTags != null)
{
foreach (var sitetag in metaTags)
{
if (sitetag.Attributes["name"] != null && sitetag.Attributes["content"] != null && sitetag.Attributes["name"].Value == "description")
{
description = sitetag.Attributes["content"].Value;
}
}
}
bookmark.Title = !string.IsNullOrEmpty(bookmark.Title) ? bookmark.Title : string.IsNullOrEmpty(title) ? bookmark.Url : title;
bookmark.Description = !string.IsNullOrEmpty(bookmark.Description) ? bookmark.Description : description;
}
}
}

View File

@ -0,0 +1,22 @@
using AutoMapper;
using YABA.Common.DTOs;
using YABA.Common.DTOs.Bookmarks;
using YABA.Common.DTOs.Tags;
using YABA.Models;
namespace YABA.Service.Configuration
{
public class AutoMapperProfile : Profile
{
public AutoMapperProfile()
{
CreateMap<User, UserDTO>();
CreateMap<BookmarkDTO, Bookmark>();
CreateMap<Bookmark, BookmarkDTO>();
CreateMap<CreateBookmarkRequestDTO, Bookmark>();
CreateMap<Tag, TagDTO>();
CreateMap<CreateTagDTO, Tag>()
.ForMember(dest => dest.Name, opt => opt.MapFrom(src => src.Name.ToLowerInvariant()));
}
}
}

View File

@ -0,0 +1,17 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using YABA.Service.Interfaces;
namespace YABA.Service.Configuration
{
public static class DependencyInjectionConfiguration
{
public static void AddServiceProjectDependencyInjectionConfiguration(this IServiceCollection services, IConfiguration configuration)
{
services.AddScoped<IUserService, UserService>();
services.AddScoped<IBookmarkService, BookmarkService>();
services.AddScoped<IMiscService, MiscService>();
services.AddScoped<ITagsService, TagsService>();
}
}
}

View File

@ -0,0 +1,22 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using YABA.Common.DTOs.Bookmarks;
using YABA.Common.DTOs.Tags;
namespace YABA.Service.Interfaces
{
public interface IBookmarkService
{
Task<BookmarkDTO?> CreateBookmark(CreateBookmarkRequestDTO request);
Task<BookmarkDTO?> UpdateBookmark(int id, UpdateBookmarkRequestDTO request);
Task<IEnumerable<TagDTO>?> UpdateBookmarkTags(int id, IEnumerable<string> tags);
IEnumerable<BookmarkDTO> GetAll(bool isHidden = false);
Task<BookmarkDTO?> Get(int id);
IEnumerable<TagDTO>? GetBookmarkTags(int id);
Task<int?> DeleteBookmark(int id);
Task<IEnumerable<int>?> DeleteBookmarks(IEnumerable<int> ids);
Task<IEnumerable<int>?> HideBookmarks(IEnumerable<int> ids);
IEnumerable<TagDTO> GetAllBookmarkTags(bool showHidden = false);
}
}

View File

@ -0,0 +1,9 @@
using YABA.Common.DTOs;
namespace YABA.Service.Interfaces
{
public interface IMiscService
{
public WebsiteMetaDataDTO GetWebsiteMetaData(string url);
}
}

View File

@ -0,0 +1,18 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using YABA.Common.DTOs.Tags;
namespace YABA.Service.Interfaces
{
public interface ITagsService
{
Task<IEnumerable<TagDTO>?> GetAll();
Task<TagDTO?> Get(int id);
Task<IEnumerable<TagDTO>?> UpsertTags(IEnumerable<TagDTO> tags);
Task<TagDTO?> UpsertTag(TagDTO tag);
Task<IEnumerable<int>?> DeleteTags(IEnumerable<int> ids);
Task<IEnumerable<int>?> HideTags(IEnumerable<int> ids);
Task<TagDTO?> CreateTag(CreateTagDTO request);
Task<TagDTO?> UpdateTag(int id, UpdateTagDTO request);
}
}

View File

@ -0,0 +1,12 @@
using System.Threading.Tasks;
using YABA.Common.DTOs;
namespace YABA.Service.Interfaces
{
public interface IUserService
{
bool IsUserRegistered(string authProviderId);
Task<UserDTO> RegisterUser(string authProviderId);
int GetUserId(string authProviderId);
}
}

View File

@ -0,0 +1,53 @@
using HtmlAgilityPack;
using System;
using System.Net;
using System.Text.RegularExpressions;
using YABA.Common.DTOs;
using YABA.Service.Interfaces;
namespace YABA.Service
{
public class MiscService : IMiscService
{
public MiscService() { }
public WebsiteMetaDataDTO GetWebsiteMetaData(string url)
{
try
{
var webClient = new WebClient();
webClient.Headers.Add("user-agent", "API-Application");
var sourceData = webClient.DownloadString(url);
var title = Regex.Match(sourceData, @"\<title\b[^>]*\>\s*(?<Title>[\s\S]*?)\</title\>", RegexOptions.IgnoreCase).Groups["Title"].Value;
var description = string.Empty;
var getHtmlDoc = new HtmlWeb();
var document = getHtmlDoc.Load(url);
var metaTags = document.DocumentNode.SelectNodes("//meta");
if (metaTags != null)
{
foreach (var sitetag in metaTags)
{
if (sitetag.Attributes["name"] != null && sitetag.Attributes["content"] != null && sitetag.Attributes["name"].Value == "description")
{
description = sitetag.Attributes["content"].Value;
}
}
}
return new WebsiteMetaDataDTO
{
Title = title,
Description = description
};
} catch(Exception)
{
return new WebsiteMetaDataDTO
{
Title = url,
Description = string.Empty
};
}
}
}
}

View File

@ -0,0 +1,133 @@
using AutoMapper;
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using YABA.Common.DTOs.Tags;
using YABA.Common.Extensions;
using YABA.Data.Context;
using YABA.Data.Extensions;
using YABA.Models;
using YABA.Service.Interfaces;
namespace YABA.Service
{
public class TagsService : ITagsService
{
private readonly YABAReadOnlyContext _roContext;
private readonly YABAReadWriteContext _rwContext;
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly IMapper _mapper;
public TagsService(
YABAReadOnlyContext roContext,
YABAReadWriteContext rwContext,
IHttpContextAccessor httpContextAccessor,
IMapper mapper)
{
_roContext = roContext;
_rwContext = rwContext;
_httpContextAccessor = httpContextAccessor;
_mapper = mapper;
}
public async Task<IEnumerable<TagDTO>?> GetAll()
{
var currentUserId = GetCurrentUserId();
if (!await _roContext.Users.UserExistsAsync(currentUserId)) return null;
var activeUserTags = _roContext.Tags
.Where(x => x.UserId == currentUserId)
.ToList();
return _mapper.Map<IEnumerable<TagDTO>>(activeUserTags);
}
public async Task<TagDTO?> Get(int id)
{
var currentUserId = GetCurrentUserId();
if (!await _roContext.Users.UserExistsAsync(currentUserId)) return null;
var bookmark = await _roContext.Tags.FirstOrDefaultAsync(x => x.Id == id && x.UserId == currentUserId);
return _mapper.Map<TagDTO>(bookmark);
}
public async Task<IEnumerable<TagDTO>?> UpsertTags(IEnumerable<TagDTO> tags)
{
var currentUserId = GetCurrentUserId();
if (!await _roContext.Users.UserExistsAsync(currentUserId)) return null;
var tagsToUpsert = tags.Select(x => new Tag { Id = x.Id, Name = x.Name.ToLower(), IsHidden = x.IsHidden, UserId = currentUserId});
_rwContext.Tags.UpsertRange(tagsToUpsert);
await _rwContext.SaveChangesAsync();
var newlyUpsertedTags = _roContext.Tags.Where(x => tagsToUpsert.Select(x => x.Name).Contains(x.Name)).ToList();
return _mapper.Map<IEnumerable<TagDTO>>(newlyUpsertedTags);
}
public async Task<IEnumerable<int>?> DeleteTags(IEnumerable<int> ids)
{
var currentUserId = GetCurrentUserId();
if (!await _roContext.Users.UserExistsAsync(currentUserId)) return null;
var entriesToDelete = _rwContext.Tags.Where(x => x.UserId == currentUserId && ids.Contains(x.Id)).ToList();
_rwContext.Tags.RemoveRange(entriesToDelete);
if (await _rwContext.SaveChangesAsync() <= 0) return null;
return entriesToDelete.Select(x => x.Id);
}
public async Task<IEnumerable<int>?> HideTags(IEnumerable<int> ids)
{
var currentUserId = GetCurrentUserId();
if (!await _roContext.Users.UserExistsAsync(currentUserId)) return null;
var entriesToHide = _rwContext.Tags.Where(x => x.UserId == currentUserId && ids.Contains(x.Id)).ToList();
entriesToHide.ForEach((x) => { x.IsHidden = !x.IsHidden; });
if (await _rwContext.SaveChangesAsync() <= 0) return null;
return entriesToHide.Select(x => x.Id);
}
public async Task<TagDTO?> UpsertTag(TagDTO tag) => (await UpsertTags(new List<TagDTO>() { tag }))?.FirstOrDefault();
public async Task<TagDTO?> CreateTag(CreateTagDTO request)
{
var currentUserId = GetCurrentUserId();
if(!(await _roContext.Users.UserExistsAsync(currentUserId))
|| await _roContext.Tags.AnyAsync(x => x.UserId == currentUserId && x.Name.ToLower() == request.Name.ToLower()))
return null;
var tag = _mapper.Map<Tag>(request);
tag.UserId = currentUserId;
var newEntity = await _rwContext.Tags.AddAsync(tag);
return await _rwContext.SaveChangesAsync() > 0 ? _mapper.Map<TagDTO>(newEntity.Entity) : null;
}
public async Task<TagDTO?> UpdateTag(int id, UpdateTagDTO request)
{
var currentUserId = GetCurrentUserId();
var tag = await _rwContext.Tags.FirstOrDefaultAsync(x => x.UserId == currentUserId && x.Id == id);
if (tag == null) return null;
tag.Name = !string.IsNullOrEmpty(request.Name) ? request.Name.ToLower() : tag.Name;
tag.IsHidden = request.IsHidden.HasValue ? request.IsHidden.Value : tag.IsHidden;
return await _rwContext.SaveChangesAsync() > 0 ? _mapper.Map<TagDTO>(tag) : null;
}
private int GetCurrentUserId()
{
int.TryParse(_httpContextAccessor.HttpContext.User.Identity.GetUserId(), out int userId);
return userId;
}
}
}

View File

@ -0,0 +1,79 @@
using AutoMapper;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Npgsql;
using System;
using System.Linq;
using System.Threading.Tasks;
using YABA.Common.DTOs;
using YABA.Data.Context;
using YABA.Models;
using YABA.Service.Interfaces;
namespace YABA.Service
{
public class UserService : IUserService
{
private readonly YABAReadOnlyContext _roContext;
private readonly YABAReadWriteContext _context;
private readonly IMapper _mapper;
private readonly ILogger<UserService> _logger;
public UserService (YABAReadOnlyContext roContext, YABAReadWriteContext context, IMapper mapper, ILogger<UserService> logger)
{
_roContext = roContext;
_context = context;
_mapper = mapper;
_logger = logger;
}
public bool IsUserRegistered(string authProviderId)
{
return _roContext.Users.Any(x => x.Auth0Id == authProviderId);
}
public async Task<UserDTO> RegisterUser(string authProviderId)
{
try
{
if (!IsUserRegistered(authProviderId))
{
var userToRegister = new User
{
Auth0Id = authProviderId
};
var registedUser = _context.Users.Add(userToRegister);
await _context.SaveChangesAsync();
}
}
catch (Exception ex)
{
if(ex.InnerException is PostgresException &&
((PostgresException)ex.InnerException).Code == "23505")
{
var postgresException = (PostgresException)ex.InnerException;
_logger.LogWarning("Swallowing constraint violation: {@ConstraintName} for {@AuthProviderId}", postgresException.ConstraintName, authProviderId);
}
else
{
throw ex;
}
}
return await Get(authProviderId);
}
public async Task<UserDTO> Get(string authProviderId)
{
var user = await _roContext.Users.FirstOrDefaultAsync(x => x.Auth0Id == authProviderId);
return _mapper.Map<UserDTO>(user);
}
public int GetUserId(string authProviderId)
{
var user = _roContext.Users.FirstOrDefault(x => x.Auth0Id == authProviderId);
return user != null ? user.Id : 0;
}
}
}

View File

@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AutoMapper" Version="12.0.1" />
<PackageReference Include="HtmlAgilityPack" Version="1.11.46" />
<PackageReference Include="Microsoft.AspNetCore.Http.Abstractions" Version="2.2.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="7.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="7.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\YABA.Common\YABA.Common.csproj" />
<ProjectReference Include="..\YABA.Data\YABA.Data.csproj" />
<ProjectReference Include="..\YABA.Models\YABA.Models.csproj" />
</ItemGroup>
</Project>

67
API/YABA.sln Normal file
View File

@ -0,0 +1,67 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.2.32505.173
MinimumVisualStudioVersion = 10.0.40219.1
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "YABA.API", "YABA.API\YABA.API.csproj", "{97FC2A4F-B663-43B0-987D-DC3EA7ADD66C}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "YABA.Models", "YABA.Models\YABA.Models.csproj", "{DDA30925-F844-426B-8B90-3E6E258BD407}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "YABA.Data", "YABA.Data\YABA.Data.csproj", "{461E9D5A-3C06-4CCB-A466-76BBB9BB7BF5}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "YABA.Service", "YABA.Service\YABA.Service.csproj", "{0098D0A8-0273-46F1-9FE7-B0409442251A}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "YABA.Common", "YABA.Common\YABA.Common.csproj", "{CA107B5D-4B8E-4515-8380-CB474C57F79C}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{17A95D6E-D7F8-40F0-A140-1556492495DB}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0CA49C56-9635-4BA7-A88A-DA16264E2ED2}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "YABA.Service.Tests", "YABA.Service.Tests\YABA.Service.Tests.csproj", "{00E8AC4F-B7B3-4DC5-8BA1-BB7DDC4A9071}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{97FC2A4F-B663-43B0-987D-DC3EA7ADD66C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{97FC2A4F-B663-43B0-987D-DC3EA7ADD66C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{97FC2A4F-B663-43B0-987D-DC3EA7ADD66C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{97FC2A4F-B663-43B0-987D-DC3EA7ADD66C}.Release|Any CPU.Build.0 = Release|Any CPU
{DDA30925-F844-426B-8B90-3E6E258BD407}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{DDA30925-F844-426B-8B90-3E6E258BD407}.Debug|Any CPU.Build.0 = Debug|Any CPU
{DDA30925-F844-426B-8B90-3E6E258BD407}.Release|Any CPU.ActiveCfg = Release|Any CPU
{DDA30925-F844-426B-8B90-3E6E258BD407}.Release|Any CPU.Build.0 = Release|Any CPU
{461E9D5A-3C06-4CCB-A466-76BBB9BB7BF5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{461E9D5A-3C06-4CCB-A466-76BBB9BB7BF5}.Debug|Any CPU.Build.0 = Debug|Any CPU
{461E9D5A-3C06-4CCB-A466-76BBB9BB7BF5}.Release|Any CPU.ActiveCfg = Release|Any CPU
{461E9D5A-3C06-4CCB-A466-76BBB9BB7BF5}.Release|Any CPU.Build.0 = Release|Any CPU
{0098D0A8-0273-46F1-9FE7-B0409442251A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{0098D0A8-0273-46F1-9FE7-B0409442251A}.Debug|Any CPU.Build.0 = Debug|Any CPU
{0098D0A8-0273-46F1-9FE7-B0409442251A}.Release|Any CPU.ActiveCfg = Release|Any CPU
{0098D0A8-0273-46F1-9FE7-B0409442251A}.Release|Any CPU.Build.0 = Release|Any CPU
{CA107B5D-4B8E-4515-8380-CB474C57F79C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{CA107B5D-4B8E-4515-8380-CB474C57F79C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{CA107B5D-4B8E-4515-8380-CB474C57F79C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{CA107B5D-4B8E-4515-8380-CB474C57F79C}.Release|Any CPU.Build.0 = Release|Any CPU
{00E8AC4F-B7B3-4DC5-8BA1-BB7DDC4A9071}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{00E8AC4F-B7B3-4DC5-8BA1-BB7DDC4A9071}.Debug|Any CPU.Build.0 = Debug|Any CPU
{00E8AC4F-B7B3-4DC5-8BA1-BB7DDC4A9071}.Release|Any CPU.ActiveCfg = Release|Any CPU
{00E8AC4F-B7B3-4DC5-8BA1-BB7DDC4A9071}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{97FC2A4F-B663-43B0-987D-DC3EA7ADD66C} = {17A95D6E-D7F8-40F0-A140-1556492495DB}
{DDA30925-F844-426B-8B90-3E6E258BD407} = {17A95D6E-D7F8-40F0-A140-1556492495DB}
{461E9D5A-3C06-4CCB-A466-76BBB9BB7BF5} = {17A95D6E-D7F8-40F0-A140-1556492495DB}
{0098D0A8-0273-46F1-9FE7-B0409442251A} = {17A95D6E-D7F8-40F0-A140-1556492495DB}
{CA107B5D-4B8E-4515-8380-CB474C57F79C} = {17A95D6E-D7F8-40F0-A140-1556492495DB}
{00E8AC4F-B7B3-4DC5-8BA1-BB7DDC4A9071} = {0CA49C56-9635-4BA7-A88A-DA16264E2ED2}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {8B677B7D-5CF6-460E-B4F6-7AF3BD655B12}
EndGlobalSection
EndGlobal