Started working on service implementation api

This commit is contained in:
Baumgartner Marcel 2023-11-14 17:54:15 +01:00
parent a1cd6b5cd9
commit d55490dd51
26 changed files with 494 additions and 69 deletions

View file

@ -0,0 +1,13 @@
using System.ComponentModel;
using Moonlight.App.Database.Entities;
using Moonlight.App.Extensions.Attributes;
namespace Moonlight.App.Actions.Dummy;
public class DummyConfig
{
[Description("Some description")]
public string String { get; set; } = "";
public bool Boolean { get; set; }
public int Integer { get; set; }
}

View file

@ -0,0 +1,34 @@
using Microsoft.AspNetCore.Components;
using Moonlight.App.Actions.Dummy.Layouts;
using Moonlight.App.Database.Entities;
using Moonlight.App.Database.Entities.Store;
using Moonlight.App.Helpers;
using Moonlight.App.Models.Abstractions;
namespace Moonlight.App.Actions.Dummy;
public class DummyServiceImplementation : ServiceImplementation
{
public override ServiceActions Actions { get; } = new DummyActions();
public override Type ConfigType { get; } = typeof(DummyConfig);
public override RenderFragment GetAdminLayout()
{
return ComponentHelper.FromType(typeof(DummyAdmin));
}
public override RenderFragment GetUserLayout()
{
return ComponentHelper.FromType(typeof(DummyUser));
}
public override ServiceUiPage[] GetUserPages(Service service, User user)
{
return Array.Empty<ServiceUiPage>();
}
public override ServiceUiPage[] GetAdminPages(Service service, User user)
{
return Array.Empty<ServiceUiPage>();
}
}

View file

@ -0,0 +1,5 @@
<h3>DummyAdmin</h3>
@code {
}

View file

@ -0,0 +1,5 @@
<h3>DummyUser</h3>
@code {
}

View file

@ -0,0 +1,5 @@
<h3>DummyPage</h3>
@code {
}

View file

@ -4,9 +4,17 @@ namespace Moonlight.App.Helpers;
public static class ComponentHelper
{
public static RenderFragment FromType(Type type) => builder =>
public static RenderFragment FromType(Type type, Action<Dictionary<string, object>>? buildAttributes = null) => builder =>
{
builder.OpenComponent(0, type);
if (buildAttributes != null)
{
Dictionary<string, object> parameters = new();
buildAttributes.Invoke(parameters);
builder.AddMultipleAttributes(1, parameters);
}
builder.CloseComponent();
};
}

View file

@ -0,0 +1,18 @@
using Microsoft.AspNetCore.Components;
using Moonlight.App.Database.Entities;
using Moonlight.App.Database.Entities.Store;
namespace Moonlight.App.Models.Abstractions;
public abstract class ServiceImplementation
{
public abstract ServiceActions Actions { get; }
public abstract Type ConfigType { get; }
public abstract RenderFragment GetAdminLayout();
public abstract RenderFragment GetUserLayout();
// The service and user parameter can be used to only show certain pages to admins or other
public abstract ServiceUiPage[] GetUserPages(Service service, User user);
public abstract ServiceUiPage[] GetAdminPages(Service service, User user);
}

View file

@ -0,0 +1,11 @@
using Microsoft.AspNetCore.Components;
namespace Moonlight.App.Models.Abstractions;
public class ServiceUiPage
{
public string Name { get; set; }
public string Route { get; set; }
public string Icon { get; set; }
public ComponentBase Component { get; set; }
}

View file

@ -10,6 +10,7 @@ public enum Permission
AdminUsersEdit = 1003,
AdminTickets = 1004,
AdminCommunity = 1030,
AdminServices = 1050,
AdminStore = 1900,
AdminViewExceptions = 1999,
AdminRoot = 2000

View file

@ -1,4 +1,8 @@
namespace Moonlight.App.Plugins.Contexts;
using Moonlight.App.Database.Entities;
using Moonlight.App.Database.Entities.Store;
using Moonlight.App.Models.Abstractions;
namespace Moonlight.App.Plugins.Contexts;
public class PluginContext
{
@ -9,4 +13,5 @@ public class PluginContext
public WebApplication WebApplication { get; set; }
public List<Action> PreInitTasks = new();
public List<Action> PostInitTasks = new();
public Action<List<ServiceUiPage>, ServiceManageContext>? BuildServiceUiPages = null;
}

View file

@ -0,0 +1,11 @@
using Moonlight.App.Database.Entities;
using Moonlight.App.Database.Entities.Store;
namespace Moonlight.App.Plugins.Contexts;
public class ServiceManageContext
{
public Service Service { get; set; }
public User User { get; set; }
public Product Product { get; set; }
}

View file

@ -1,5 +1,8 @@
using System.Reflection;
using Moonlight.App.Database.Entities;
using Moonlight.App.Database.Entities.Store;
using Moonlight.App.Helpers;
using Moonlight.App.Models.Abstractions;
using Moonlight.App.Plugins;
using Moonlight.App.Plugins.Contexts;
@ -105,6 +108,20 @@ public class PluginService
}
}
public Task<ServiceUiPage[]> BuildServiceUiPages(ServiceUiPage[] pages, ServiceManageContext context)
{
var list = pages.ToList();
foreach (var plugin in Plugins)
{
// Only build if the plugin adds a page
if(plugin.Context.BuildServiceUiPages != null)
plugin.Context.BuildServiceUiPages.Invoke(list, context);
}
return Task.FromResult(list.ToArray());
}
private string[] FindFiles(string dir)
{
var result = new List<string>();

View file

@ -1,27 +1,25 @@
using Microsoft.EntityFrameworkCore;
using Moonlight.App.Database.Entities;
using Moonlight.App.Database.Entities.Store;
using Moonlight.App.Database.Enums;
using Moonlight.App.Exceptions;
using Moonlight.App.Models.Abstractions;
using Moonlight.App.Repositories;
namespace Moonlight.App.Services.ServiceManage;
public class ServiceAdminService
{
public readonly Dictionary<ServiceType, ServiceActions> Actions = new();
private readonly IServiceScopeFactory ServiceScopeFactory;
private readonly ServiceTypeService ServiceTypeService;
public ServiceAdminService(IServiceScopeFactory serviceScopeFactory)
public ServiceAdminService(IServiceScopeFactory serviceScopeFactory, ServiceTypeService serviceTypeService)
{
ServiceScopeFactory = serviceScopeFactory;
ServiceTypeService = serviceTypeService;
}
public async Task<Service> Create(User u, Product p, Action<Service>? modifyService = null)
{
if (!Actions.ContainsKey(p.Type))
throw new DisplayException($"The product type {p.Type} is not registered");
var impl = ServiceTypeService.Get(p);
// Load models in new scope
using var scope = ServiceScopeFactory.CreateScope();
@ -49,8 +47,7 @@ public class ServiceAdminService
var finishedService = serviceRepo.Add(service);
// Call the action for the logic behind the service type
var actions = Actions[product.Type];
await actions.Create(scope.ServiceProvider, finishedService);
await impl.Actions.Create(scope.ServiceProvider, finishedService);
return finishedService;
}
@ -63,17 +60,15 @@ public class ServiceAdminService
var service = serviceRepo
.Get()
.Include(x => x.Product)
.Include(x => x.Shares)
.FirstOrDefault(x => x.Id == s.Id);
if (service == null)
throw new DisplayException("Service does not exist anymore");
if (!Actions.ContainsKey(service.Product.Type))
throw new DisplayException($"The product type {service.Product.Type} is not registered");
var impl = ServiceTypeService.Get(service);
await Actions[service.Product.Type].Delete(scope.ServiceProvider, service);
await impl.Actions.Delete(scope.ServiceProvider, service);
foreach (var share in service.Shares.ToArray())
{
@ -82,10 +77,4 @@ public class ServiceAdminService
serviceRepo.Delete(service);
}
public Task RegisterAction(ServiceType type, ServiceActions actions) // Use this function to register service types
{
Actions.Add(type, actions);
return Task.CompletedTask;
}
}

View file

@ -0,0 +1,48 @@
using Microsoft.EntityFrameworkCore;
using Moonlight.App.Database.Entities;
using Moonlight.App.Database.Entities.Store;
using Moonlight.App.Models.Abstractions;
using Moonlight.App.Models.Enums;
using Moonlight.App.Repositories;
namespace Moonlight.App.Services.ServiceManage;
public class ServiceManageService
{
private readonly IServiceScopeFactory ServiceScopeFactory;
public ServiceManageService(IServiceScopeFactory serviceScopeFactory)
{
ServiceScopeFactory = serviceScopeFactory;
}
public Task<bool> CheckAccess(Service s, User user)
{
var permissionStorage = new PermissionStorage(user.Permissions);
// Is admin?
if(permissionStorage[Permission.AdminServices])
return Task.FromResult(true);
using var scope = ServiceScopeFactory.CreateScope();
var serviceRepo = scope.ServiceProvider.GetRequiredService<Repository<Service>>();
var service = serviceRepo
.Get()
.Include(x => x.Owner)
.Include(x => x.Shares)
.ThenInclude(x => x.User)
.First(x => x.Id == s.Id);
// Is owner?
if(service.Owner.Id == user.Id)
return Task.FromResult(true);
// Is shared user
if(service.Shares.Any(x => x.User.Id == user.Id))
return Task.FromResult(true);
// No match
return Task.FromResult(false);
}
}

View file

@ -13,6 +13,8 @@ public class ServiceService // This service is used for managing services and cr
private readonly Repository<User> UserRepository;
public ServiceAdminService Admin => ServiceProvider.GetRequiredService<ServiceAdminService>();
public ServiceTypeService Type => ServiceProvider.GetRequiredService<ServiceTypeService>();
public ServiceManageService Manage => ServiceProvider.GetRequiredService<ServiceManageService>();
public ServiceService(IServiceProvider serviceProvider, Repository<Service> serviceRepository, Repository<User> userRepository)
{

View file

@ -0,0 +1,55 @@
using Microsoft.EntityFrameworkCore;
using Moonlight.App.Database.Entities.Store;
using Moonlight.App.Database.Enums;
using Moonlight.App.Models.Abstractions;
using Moonlight.App.Repositories;
namespace Moonlight.App.Services.ServiceManage;
public class ServiceTypeService
{
private readonly Dictionary<ServiceType, ServiceImplementation> ServiceImplementations = new();
private readonly IServiceScopeFactory ServiceScopeFactory;
public ServiceTypeService(IServiceScopeFactory serviceScopeFactory)
{
ServiceScopeFactory = serviceScopeFactory;
}
public void Register<T>(ServiceType type) where T : ServiceImplementation
{
var impl = Activator.CreateInstance<T>() as ServiceImplementation;
if (impl == null)
throw new ArgumentException("The provided type is not an service implementation");
if (ServiceImplementations.ContainsKey(type))
throw new ArgumentException($"An implementation for {type} has already been registered");
ServiceImplementations.Add(type, impl);
}
public ServiceImplementation Get(Service s)
{
using var scope = ServiceScopeFactory.CreateScope();
var serviceRepo = scope.ServiceProvider.GetRequiredService<Repository<Service>>();
var service = serviceRepo
.Get()
.Include(x => x.Product)
.First(x => x.Id == s.Id);
return Get(service.Product);
}
public ServiceImplementation Get(Product p) => Get(p.Type);
public ServiceImplementation Get(ServiceType type)
{
if (!ServiceImplementations.ContainsKey(type))
throw new ArgumentException($"No service implementation found for {type}");
return ServiceImplementations[type];
}
}

View file

@ -2,6 +2,8 @@
using Moonlight.App.Database.Enums;
using Moonlight.App.Exceptions;
using Moonlight.App.Repositories;
using Moonlight.App.Services.ServiceManage;
using Newtonsoft.Json;
namespace Moonlight.App.Services.Store;
@ -9,11 +11,16 @@ public class StoreAdminService
{
private readonly Repository<Product> ProductRepository;
private readonly Repository<Category> CategoryRepository;
private readonly ServiceService ServiceService;
public StoreAdminService(Repository<Product> productRepository, Repository<Category> categoryRepository)
public StoreAdminService(
Repository<Product> productRepository,
Repository<Category> categoryRepository,
ServiceService serviceService)
{
ProductRepository = productRepository;
CategoryRepository = categoryRepository;
ServiceService = serviceService;
}
public Task<Category> AddCategory(string name, string description, string slug)
@ -31,8 +38,7 @@ public class StoreAdminService
return Task.FromResult(result);
}
public Task<Product> AddProduct(string name, string description, string slug, ServiceType type, string configJson,
Action<Product>? modifyProduct = null)
public Task<Product> AddProduct(string name, string description, string slug, ServiceType type, Action<Product>? modifyProduct = null)
{
if (ProductRepository.Get().Any(x => x.Slug == slug))
throw new DisplayException("A product with that slug does already exist");
@ -43,7 +49,7 @@ public class StoreAdminService
Description = description,
Slug = slug,
Type = type,
ConfigJson = configJson
ConfigJson = "{}"
};
if(modifyProduct != null)
@ -96,4 +102,36 @@ public class StoreAdminService
return Task.CompletedTask;
}
// Product config
public Type GetProductConfigType(ServiceType type)
{
try
{
var impl = ServiceService.Type.Get(type);
return impl.ConfigType;
}
catch (ArgumentException)
{
return typeof(object);
}
}
public object CreateNewProductConfig(ServiceType type)
{
var config = Activator.CreateInstance(GetProductConfigType(type))!;
return config;
}
public object GetProductConfig(Product product)
{
var impl = ServiceService.Type.Get(product.Type);
return JsonConvert.DeserializeObject(product.ConfigJson, impl.ConfigType) ??
CreateNewProductConfig(product.Type);
}
public void SaveProductConfig(Product product, object config)
{
product.ConfigJson = JsonConvert.SerializeObject(config);
ProductRepository.Update(product);
}
}

View file

@ -12,6 +12,7 @@ namespace Moonlight.App.Services.Users;
public class UserDeleteService
{
private readonly Repository<Service> ServiceRepository;
private readonly Repository<ServiceShare> ServiceShareRepository;
private readonly Repository<Post> PostRepository;
private readonly Repository<User> UserRepository;
private readonly Repository<Transaction> TransactionRepository;
@ -32,7 +33,8 @@ public class UserDeleteService
Repository<CouponUse> couponUseRepository,
Repository<Transaction> transactionRepository,
Repository<Ticket> ticketRepository,
Repository<TicketMessage> ticketMessageRepository)
Repository<TicketMessage> ticketMessageRepository,
Repository<ServiceShare> serviceShareRepository)
{
ServiceRepository = serviceRepository;
ServiceService = serviceService;
@ -44,6 +46,7 @@ public class UserDeleteService
TransactionRepository = transactionRepository;
TicketRepository = ticketRepository;
TicketMessageRepository = ticketMessageRepository;
ServiceShareRepository = serviceShareRepository;
}
public async Task Perform(User user)
@ -83,6 +86,17 @@ public class UserDeleteService
await ServiceService.Admin.Delete(service);
}
// Service shares
var shares = ServiceShareRepository
.Get()
.Where(x => x.User.Id == user.Id)
.ToArray();
foreach (var share in shares)
{
ServiceShareRepository.Delete(share);
}
// Transactions - Coupons - Gift codes
var userWithDetails = UserRepository
.Get()

View file

@ -80,6 +80,8 @@ builder.Services.AddSingleton<AutoMailSendService>();
// Services / ServiceManage
builder.Services.AddScoped<ServiceService>();
builder.Services.AddSingleton<ServiceAdminService>();
builder.Services.AddSingleton<ServiceTypeService>();
builder.Services.AddSingleton<ServiceManageService>();
// Services / Ticketing
builder.Services.AddScoped<TicketService>();
@ -121,8 +123,9 @@ app.MapControllers();
// Auto start background services
app.Services.GetRequiredService<AutoMailSendService>();
var serviceService = app.Services.GetRequiredService<ServiceAdminService>();
await serviceService.RegisterAction(ServiceType.Server, new DummyActions());
var serviceService = app.Services.GetRequiredService<ServiceTypeService>();
serviceService.Register<DummyServiceImplementation>(ServiceType.Server);
await pluginService.RunPrePost(app);

View file

@ -5,15 +5,16 @@
@foreach (var prop in typeof(TForm).GetProperties())
{
<div class="col-md-@(Columns) col-12">
<CascadingValue Name="Property" Value="prop">
<CascadingValue Name="Data" Value="(object)Model">
@{
var typeToCreate = typeof(AutoProperty<>).MakeGenericType(prop.PropertyType);
}
@{
var typeToCreate = typeof(AutoProperty<>).MakeGenericType(prop.PropertyType);
var rf = ComponentHelper.FromType(typeToCreate, parameters =>
{
parameters.Add("Data", Model);
parameters.Add("Property", prop);
});
}
@ComponentHelper.FromType(typeToCreate)
</CascadingValue>
</CascadingValue>
@rf
</div>
}

View file

@ -101,7 +101,7 @@
return prop.GetValue(x) as string ?? "N/A";
});
<SmartDropdown @bind-Value="Binder.Class" DisplayFunc="displayFunc" SearchProp="searchFunc" Items="Items" />
<SmartDropdown @bind-Value="Binder.Class" DisplayFunc="displayFunc" SearchProp="searchFunc" Items="Items"/>
}
else
{
@ -111,7 +111,7 @@
return prop.GetValue(x) as string ?? "N/A";
});
<SmartSelect @bind-Value="Binder.Class" DisplayField="displayFunc" Items="Items" CanBeNull="true" />
<SmartSelect @bind-Value="Binder.Class" DisplayField="displayFunc" Items="Items" CanBeNull="true"/>
}
}
}
@ -119,10 +119,10 @@
@code
{
[CascadingParameter(Name = "Data")]
[Parameter]
public object Data { get; set; }
[CascadingParameter(Name = "Property")]
[Parameter]
public PropertyInfo Property { get; set; }
private PropBinder<TProp> Binder;

View file

@ -0,0 +1,19 @@
@{
var typeToCreate = typeof(AutoForm<>).MakeGenericType(Model.GetType());
var rf = ComponentHelper.FromType(typeToCreate, parameter =>
{
parameter.Add("Model", Model);
parameter.Add("Columns", Columns);
});
}
@rf
@code
{
[Parameter]
public object Model { get; set; }
[Parameter]
public int Columns { get; set; } = 6;
}

View file

@ -109,14 +109,13 @@
</div>
<div class="mb-3">
<label class="form-label">Type</label>
<SmartEnumSelect @bind-Value="AddProductForm.Type"/>
</div>
<div class="mb-3">
<label class="form-label">Config</label>
<input @bind="AddProductForm.ConfigJson" class="form-control" type="text"/>
<SmartEnumSelect @bind-Value="AddProductServiceType"/>
</div>
</div>
</div>
<div class="row">
<DynamicTypedAutoForm Model="AddProductConfig" Columns="6"/>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
@ -172,14 +171,13 @@
</div>
<div class="mb-3">
<label class="form-label">Type</label>
<SmartEnumSelect @bind-Value="EditProductForm.Type"/>
</div>
<div class="mb-3">
<label class="form-label">Config</label>
<input @bind="EditProductForm.ConfigJson" class="form-control" type="text"/>
<SmartEnumSelect @bind-Value="EditProductServiceType"/>
</div>
</div>
</div>
<div class="row">
<DynamicTypedAutoForm Model="EditProductConfig" Columns="6"/>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
@ -231,6 +229,7 @@
EditCategoryForm = Mapper.Map<EditCategoryForm>(EditCategory);
await EditCategoryModal.Show();
}
private async Task EditCategorySubmit()
{
EditCategory = Mapper.Map(EditCategory, EditCategoryForm);
@ -250,17 +249,30 @@
private SmartModal AddProductModal;
private AddProductForm AddProductForm = new();
private Category[] Categories;
private object AddProductConfig = new();
private ServiceType AddProductServiceType
{
set
{
if (AddProductConfig.GetType() != StoreService.Admin.GetProductConfigType(value))
AddProductConfig = StoreService.Admin.CreateNewProductConfig(value);
AddProductForm.Type = value;
InvokeAsync(StateHasChanged);
}
get => AddProductForm.Type;
}
public Task AddProductShow => AddProductModal.Show();
private async Task AddProductSubmit()
{
await StoreService.Admin.AddProduct(
var product = await StoreService.Admin.AddProduct(
AddProductForm.Name,
AddProductForm.Description,
AddProductForm.Slug,
AddProductForm.Type,
AddProductForm.ConfigJson,
product =>
{
product.Category = AddProductForm.Category;
@ -271,6 +283,8 @@
}
);
StoreService.Admin.SaveProductConfig(product, AddProductConfig);
await ToastService.Success("Successfully added product");
await AddProductModal.Hide();
@ -285,10 +299,25 @@
private SmartModal EditProductModal;
private EditProductForm EditProductForm = new();
private Product EditProduct;
private object EditProductConfig = new();
private ServiceType EditProductServiceType
{
set
{
if (EditProductConfig.GetType() != StoreService.Admin.GetProductConfigType(value))
EditProductConfig = StoreService.Admin.CreateNewProductConfig(value);
EditProductForm.Type = value;
InvokeAsync(StateHasChanged);
}
get => EditProductForm.Type;
}
public async Task EditProductShow(Product product)
{
EditProduct = product;
EditProductConfig = StoreService.Admin.GetProductConfig(product);
EditProductForm = Mapper.Map<EditProductForm>(EditProduct);
await EditProductModal.Show();
@ -299,6 +328,7 @@
EditProduct = Mapper.Map(EditProduct, EditProductForm);
await StoreService.Admin.UpdateProduct(EditProduct);
StoreService.Admin.SaveProductConfig(EditProduct, EditProductConfig);
await ToastService.Success("Successfully updated product");
await EditProductModal.Hide();

View file

@ -83,15 +83,16 @@ else
@foreach (var prop in Properties)
{
<div class="col-md-6 col-12">
<CascadingValue Name="Property" Value="prop">
<CascadingValue Name="Data" Value="ModelToShow">
@{
var typeToCreate = typeof(AutoProperty<>).MakeGenericType(prop.PropertyType);
}
@{
var typeToCreate = typeof(AutoProperty<>).MakeGenericType(prop.PropertyType);
var rf = ComponentHelper.FromType(typeToCreate, parameters =>
{
parameters.Add("Data", ModelToShow);
parameters.Add("Property", prop);
});
}
@ComponentHelper.FromType(typeToCreate)
</CascadingValue>
</CascadingValue>
@rf
</div>
}
</LazyLoader>

View file

@ -0,0 +1,92 @@
@page "/service/{Id:int}/{Route?}"
@using Moonlight.App.Repositories
@using Moonlight.App.Database.Entities.Store
@using Moonlight.App.Services.ServiceManage
@using Microsoft.EntityFrameworkCore
@using Moonlight.App.Models.Abstractions
@using Moonlight.App.Services
@inject Repository<Service> ServiceRepository
@inject ServiceService ServiceService
@inject IdentityService IdentityService
@inject PluginService PluginService
<LazyLoader Load="Load" ShowAsCard="true">
@if (Service == null)
{
<NotFoundAlert />
}
else
{
<CascadingValue Name="Service" Value="Service">
<CascadingValue Name="Implementation" Value="Implementation">
<CascadingValue Name="Route" Value="Route">
<CascadingValue Name="Pages" Value="ServiceUiPages">
@Implementation.GetUserLayout()
</CascadingValue>
</CascadingValue>
</CascadingValue>
</CascadingValue>
}
</LazyLoader>
@code
{
[Parameter]
public int Id { get; set; }
[Parameter]
public string? Route { get; set; }
private Service? Service;
private ServiceImplementation Implementation;
private ServiceUiPage[] ServiceUiPages;
private async Task Load(LazyLoader lazyLoader)
{
await lazyLoader.SetText("Requesting service");
// Load service with relational data
Service = ServiceRepository
.Get()
.Include(x => x.Product)
.Include(x => x.Owner)
.FirstOrDefault(x => x.Id == Id);
if(Service == null)
return;
// Check permissions
if (!await ServiceService.Manage.CheckAccess(Service, IdentityService.CurrentUser))
Service = null;
if (Service == null)
return;
await lazyLoader.SetText("Loading implementation");
Implementation = ServiceService.Type.Get(Service.Product.Type);
await lazyLoader.SetText("Building ui");
// Build ui pages
List<ServiceUiPage> pagesWithoutPlugins = new();
// -- Add default here --
// Add implementation pages
pagesWithoutPlugins.AddRange(Implementation.GetUserPages(Service, IdentityService.CurrentUser));
// Modify pages through plugins
ServiceUiPages = await PluginService.BuildServiceUiPages(pagesWithoutPlugins.ToArray(), new()
{
Product = Service.Product,
Service = Service,
User = IdentityService.CurrentUser
});
// Done :D
}
}

View file

@ -43,7 +43,7 @@
</div>
</div>
<div class="card-footer p-3 text-center">
<button class="btn btn-primary">Manage</button>
<a href="/service/@(service.Id)" class="btn btn-primary">Manage</a>
</div>
</div>
</div>