Add unsubscribe list management functionality

This commit introduces comprehensive functionality for managing unsubscribe lists within the application. Key changes include:

- Creation of new DTOs, services, repositories, and controllers for unsubscribe list operations.
- Updates to `MailingsController.cs` and `TestEmailListsController.cs` to include necessary using directives.
- Registration of `IUnsubscribeListService` and `IUnsubscribeListRepository` in `Program.cs`.
- Implementation of the `UnsubscribeListsController` with API endpoints for CRUD operations.
- Introduction of the `UnsubscribeList` entity and its mapping to database columns.
- Modifications to existing classes, including `Mailing` and `Target`, to reference unsubscribe lists.
- Frontend updates to TypeScript interfaces and components for displaying and selecting unsubscribe lists.

These enhancements provide a more robust email management system.
This commit is contained in:
David Headrick 2025-08-24 08:04:53 -05:00
parent 0208536f67
commit 5a6c57bade
40 changed files with 690 additions and 59 deletions

View File

@ -2,6 +2,7 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.Data.SqlClient;
using Surge365.Core.Controllers;
using Surge365.MassEmailReact.Application.DTOs;
using Surge365.MassEmailReact.Application.Interfaces;
using Surge365.MassEmailReact.Domain.Entities;
using Surge365.MassEmailReact.Domain.Enums;

View File

@ -1,6 +1,7 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Surge365.Core.Controllers;
using Surge365.MassEmailReact.Application.DTOs;
using Surge365.MassEmailReact.Application.Interfaces;
using Surge365.MassEmailReact.Domain.Entities;

View File

@ -0,0 +1,93 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Surge365.MassEmailReact.Application.DTOs;
using Surge365.MassEmailReact.Application.Interfaces;
using System;
using System.Threading.Tasks;
namespace Surge365.MassEmailReact.API.Controllers
{
[ApiController]
[Route("api/[controller]")]
[Authorize]
public class UnsubscribeListsController : ControllerBase
{
private readonly IUnsubscribeListService _unsubscribeListService;
public UnsubscribeListsController(IUnsubscribeListService unsubscribeListService)
{
_unsubscribeListService = unsubscribeListService;
}
[HttpGet("GetByCode/{unsubscribeListCode}")]
public async Task<IActionResult> GetByCode(string unsubscribeListCode)
{
try
{
var unsubscribeList = await _unsubscribeListService.GetByCodeAsync(unsubscribeListCode);
if (unsubscribeList == null)
return NotFound();
return Ok(unsubscribeList);
}
catch (Exception ex)
{
return StatusCode(500, $"An error occurred: {ex.Message}");
}
}
[HttpGet("GetAll")]
public async Task<IActionResult> GetAll([FromQuery] bool activeOnly = true)
{
try
{
var unsubscribeLists = await _unsubscribeListService.GetAllAsync(activeOnly);
return Ok(unsubscribeLists);
}
catch (Exception ex)
{
return StatusCode(500, $"An error occurred: {ex.Message}");
}
}
[HttpPost("Create")]
public async Task<IActionResult> Create([FromBody] UnsubscribeListUpdateDto unsubscribeListDto)
{
try
{
if (!ModelState.IsValid)
return BadRequest(ModelState);
var success = await _unsubscribeListService.CreateAsync(unsubscribeListDto);
if (success)
return Ok(new { success = true });
else
return BadRequest("Failed to create unsubscribe list");
}
catch (Exception ex)
{
return StatusCode(500, $"An error occurred: {ex.Message}");
}
}
[HttpPut("Update")]
public async Task<IActionResult> Update([FromBody] UnsubscribeListUpdateDto unsubscribeListDto)
{
try
{
if (!ModelState.IsValid)
return BadRequest(ModelState);
var success = await _unsubscribeListService.UpdateAsync(unsubscribeListDto);
if (success)
return Ok(new { success = true });
else
return BadRequest("Failed to update unsubscribe list");
}
catch (Exception ex)
{
return StatusCode(500, $"An error occurred: {ex.Message}");
}
}
}
}

View File

@ -69,6 +69,8 @@ try
builder.Services.AddScoped<IBouncedEmailRepository, BouncedEmailRepository>();
builder.Services.AddScoped<IUnsubscribeUrlService, UnsubscribeUrlService>();
builder.Services.AddScoped<IUnsubscribeUrlRepository, UnsubscribeUrlRepository>();
builder.Services.AddScoped<IUnsubscribeListService, UnsubscribeListService>();
builder.Services.AddScoped<IUnsubscribeListRepository, UnsubscribeListRepository>();
builder.Services.AddScoped<ITemplateService, TemplateService>();
builder.Services.AddScoped<ITemplateRepository, TemplateRepository>();
builder.Services.AddScoped<IEmailDomainService, EmailDomainService>();

View File

@ -1,4 +1,4 @@
namespace Surge365.MassEmailReact.Domain.Entities
namespace Surge365.MassEmailReact.Application.DTOs
{
public class MailingTemplateUpdateDto
{

View File

@ -1,6 +1,6 @@
using System;
namespace Surge365.MassEmailReact.Domain.Entities
namespace Surge365.MassEmailReact.Application.DTOs
{
public class MailingUpdateDto
{
@ -15,6 +15,7 @@ namespace Surge365.MassEmailReact.Domain.Entities
public Guid? SessionActivityId { get; set; }
public string? RecurringTypeCode { get; set; }
public DateTime? RecurringStartDate { get; set; }
public string? UnsubscribeListCode { get; set; }
public MailingTemplateUpdateDto Template { get; set; } = new MailingTemplateUpdateDto();
}
}
}

View File

@ -4,7 +4,7 @@ using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace Surge365.MassEmailReact.Domain.Entities
namespace Surge365.MassEmailReact.Application.DTOs
{
public class TargetColumnUpdateDto
{

View File

@ -4,7 +4,7 @@ using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace Surge365.MassEmailReact.Domain.Entities
namespace Surge365.MassEmailReact.Application.DTOs
{
public class TargetUpdateDto
{
@ -16,6 +16,7 @@ namespace Surge365.MassEmailReact.Domain.Entities
public string FilterQuery { get; set; } = "";
public bool AllowWriteBack { get; set; } = false;
public bool IsActive { get; set; } = true;
public string? DefaultUnsubscribeListCode { get; set; }
public List<TargetColumnUpdateDto> Columns { get; set; } = new List<TargetColumnUpdateDto>();
}
}

View File

@ -1,4 +1,6 @@
namespace Surge365.MassEmailReact.Domain.Entities
using System.Collections.Generic;
namespace Surge365.MassEmailReact.Application.DTOs
{
public class TestEmailListUpdateDto
{

View File

@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
namespace Surge365.MassEmailReact.Domain.Entities
namespace Surge365.MassEmailReact.Application.DTOs
{
public class TestMailingDto
{

View File

@ -4,7 +4,7 @@ using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace Surge365.MassEmailReact.Domain.Entities
namespace Surge365.MassEmailReact.Application.DTOs
{
public class TestTargetDto
{

View File

@ -0,0 +1,13 @@
using System;
namespace Surge365.MassEmailReact.Application.DTOs
{
public class UnsubscribeListUpdateDto
{
public string UnsubscribeListCode { get; set; } = "";
public string FriendlyName { get; set; } = "";
public string? FriendlyDescription { get; set; }
public bool IsActive { get; set; } = true;
public short DisplayOrder { get; set; } = 0;
}
}

View File

@ -1,4 +1,5 @@
using Surge365.MassEmailReact.Domain.Entities;
using Surge365.MassEmailReact.Application.DTOs;
using Surge365.MassEmailReact.Domain.Entities;
using System.Collections.Generic;
using System.Threading.Tasks;

View File

@ -1,4 +1,5 @@
using Surge365.MassEmailReact.Domain.Entities;
using Surge365.MassEmailReact.Application.DTOs;
using Surge365.MassEmailReact.Domain.Entities;
using System.Collections.Generic;
using System.Threading.Tasks;

View File

@ -0,0 +1,14 @@
using Surge365.MassEmailReact.Domain.Entities;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace Surge365.MassEmailReact.Application.Interfaces
{
public interface IUnsubscribeListRepository
{
Task<UnsubscribeList?> GetByCodeAsync(string unsubscribeListCode);
Task<List<UnsubscribeList>> GetAllAsync(bool activeOnly = true);
Task<bool> CreateAsync(UnsubscribeList unsubscribeList);
Task<bool> UpdateAsync(UnsubscribeList unsubscribeList);
}
}

View File

@ -0,0 +1,15 @@
using Surge365.MassEmailReact.Application.DTOs;
using Surge365.MassEmailReact.Domain.Entities;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace Surge365.MassEmailReact.Application.Interfaces
{
public interface IUnsubscribeListService
{
Task<UnsubscribeList?> GetByCodeAsync(string unsubscribeListCode);
Task<List<UnsubscribeList>> GetAllAsync(bool activeOnly = true);
Task<bool> CreateAsync(UnsubscribeListUpdateDto unsubscribeListDto);
Task<bool> UpdateAsync(UnsubscribeListUpdateDto unsubscribeListDto);
}
}

View File

@ -16,13 +16,15 @@ namespace Surge365.MassEmailReact.Domain.Entities
public DateTime UpdateDate { get; set; } = DateTime.Now;
public string? RecurringTypeCode { get; set; }
public DateTime? RecurringStartDate { get; set; }
public string? UnsubscribeListCode { get; set; }
public MailingTemplate Template { get; set; } = new MailingTemplate();
public Mailing() { }
private Mailing(int id, string name, string description, int templateId, int targetId,
string statusCode, DateTime? scheduleDate, DateTime? sentDate, DateTime createDate,
DateTime updateDate, string? recurringTypeCode, DateTime? recurringStartDate)
DateTime updateDate, string? recurringTypeCode, DateTime? recurringStartDate,
string? unsubscribeListCode)
{
Id = id;
Name = name;
@ -36,14 +38,17 @@ namespace Surge365.MassEmailReact.Domain.Entities
UpdateDate = updateDate;
RecurringTypeCode = recurringTypeCode;
RecurringStartDate = recurringStartDate;
UnsubscribeListCode = unsubscribeListCode;
}
public static Mailing Create(int id, string name, string description, int templateId, int targetId,
string statusCode, DateTime? scheduleDate, DateTime? sentDate, DateTime createDate,
DateTime updateDate, string? recurringTypeCode, DateTime? recurringStartDate)
DateTime updateDate, string? recurringTypeCode, DateTime? recurringStartDate,
string? unsubscribeListCode)
{
return new Mailing(id, name, description, templateId, targetId, statusCode, scheduleDate,
sentDate, createDate, updateDate, recurringTypeCode, recurringStartDate);
sentDate, createDate, updateDate, recurringTypeCode, recurringStartDate,
unsubscribeListCode);
}
}
}

View File

@ -16,10 +16,11 @@ namespace Surge365.MassEmailReact.Domain.Entities
public string FilterQuery { get; set; } = "";
public bool AllowWriteBack { get; set; }
public bool IsActive { get; set; }
public string? DefaultUnsubscribeListCode { get; set; }
public List<TargetColumn> Columns { get; set; } = new List<TargetColumn>();
public Target() { }
private Target(int id, int serverId, string name, string databaseName, string viewName, string filterQuery, bool allowWriteBack, bool isActive)
private Target(int id, int serverId, string name, string databaseName, string viewName, string filterQuery, bool allowWriteBack, bool isActive, string? defaultUnsubscribeListCode)
{
Id = id;
ServerId = ServerId;
@ -29,10 +30,11 @@ namespace Surge365.MassEmailReact.Domain.Entities
FilterQuery = filterQuery;
AllowWriteBack = allowWriteBack;
IsActive = isActive;
DefaultUnsubscribeListCode = defaultUnsubscribeListCode;
}
public static Target Create(int id, int serverId, string name, string databaseName, string viewName, string filterQuery, bool allowWriteBack, bool isActive)
public static Target Create(int id, int serverId, string name, string databaseName, string viewName, string filterQuery, bool allowWriteBack, bool isActive, string? defaultUnsubscribeListCode)
{
return new Target(id, serverId, name, databaseName, viewName, filterQuery, allowWriteBack, isActive);
return new Target(id, serverId, name, databaseName, viewName, filterQuery, allowWriteBack, isActive, defaultUnsubscribeListCode);
}
}
}

View File

@ -0,0 +1,36 @@
using System;
namespace Surge365.MassEmailReact.Domain.Entities
{
public class UnsubscribeList
{
public string UnsubscribeListCode { get; private set; } = "";
public string FriendlyName { get; set; } = "";
public string? FriendlyDescription { get; set; }
public bool IsActive { get; set; } = true;
public short DisplayOrder { get; set; } = 0;
public DateTime CreateDate { get; set; } = DateTime.Now;
public DateTime UpdateDate { get; set; } = DateTime.Now;
public UnsubscribeList() { }
private UnsubscribeList(string unsubscribeListCode, string friendlyName, string? friendlyDescription,
bool isActive, short displayOrder, DateTime createDate, DateTime updateDate)
{
UnsubscribeListCode = unsubscribeListCode;
FriendlyName = friendlyName;
FriendlyDescription = friendlyDescription;
IsActive = isActive;
DisplayOrder = displayOrder;
CreateDate = createDate;
UpdateDate = updateDate;
}
public static UnsubscribeList Create(string unsubscribeListCode, string friendlyName, string? friendlyDescription,
bool isActive, short displayOrder, DateTime createDate, DateTime updateDate)
{
return new UnsubscribeList(unsubscribeListCode, friendlyName, friendlyDescription, isActive,
displayOrder, createDate, updateDate);
}
}
}

View File

@ -12,6 +12,7 @@ namespace Surge365.MassEmailReact.Infrastructure.EntityMaps
QueryMapper.AddMap(new TestEmailListMap());
QueryMapper.AddMap(new BouncedEmailMap());
QueryMapper .AddMap(new UnsubscribeUrlMap());
QueryMapper.AddMap(new UnsubscribeListMap());
QueryMapper.AddMap(new TemplateMap());
QueryMapper.AddMap(new EmailDomainMap());
QueryMapper .AddMap(new MailingMap());

View File

@ -19,6 +19,7 @@ namespace Surge365.MassEmailReact.Infrastructure.EntityMaps
Map(m => m.UpdateDate).ToColumn("update_date");
Map(m => m.RecurringTypeCode).ToColumn("blast_recurring_type_code");
Map(m => m.RecurringStartDate).ToColumn("recurring_start_date");
Map(m => m.UnsubscribeListCode).ToColumn("unsubscribe_list_code");
}
}
}

View File

@ -20,6 +20,7 @@ namespace Surge365.MassEmailReact.Infrastructure.EntityMaps
Map(t => t.FilterQuery).ToColumn("filter_query");
Map(t => t.AllowWriteBack).ToColumn("allow_write_back");
Map(t => t.IsActive).ToColumn("is_active");
Map(t => t.DefaultUnsubscribeListCode).ToColumn("default_unsubscribe_list_code");
}
}
}

View File

@ -0,0 +1,19 @@
using Surge365.Core.Mapping;
using Surge365.MassEmailReact.Domain.Entities;
namespace Surge365.MassEmailReact.Infrastructure.EntityMaps
{
public class UnsubscribeListMap : EntityMap<UnsubscribeList>
{
public UnsubscribeListMap()
{
Map(u => u.UnsubscribeListCode).ToColumn("unsubscribe_list_code");
Map(u => u.FriendlyName).ToColumn("friendly_name");
Map(u => u.FriendlyDescription).ToColumn("friendly_description");
Map(u => u.IsActive).ToColumn("is_active");
Map(u => u.DisplayOrder).ToColumn("display_order");
Map(u => u.CreateDate).ToColumn("create_date");
Map(u => u.UpdateDate).ToColumn("update_date");
}
}
}

View File

@ -259,6 +259,7 @@ namespace Surge365.MassEmailReact.Infrastructure.Repositories
new SqlParameter("@sent_date", mailing.SentDate ?? (object)DBNull.Value),
new SqlParameter("@blast_recurring_type_code", mailing.RecurringTypeCode ?? (object)DBNull.Value),
new SqlParameter("@recurring_start_date", mailing.RecurringStartDate ?? (object)DBNull.Value),
new SqlParameter("@unsubscribe_list_code", mailing.UnsubscribeListCode ?? (object)DBNull.Value),
new SqlParameter("@template_json", JsonSerializer.Serialize(mailing.Template, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower })),
pmSuccess
};
@ -294,6 +295,7 @@ namespace Surge365.MassEmailReact.Infrastructure.Repositories
new SqlParameter("@sent_date", mailing.SentDate ?? (object)DBNull.Value),
new SqlParameter("@blast_recurring_type_code", mailing.RecurringTypeCode ?? (object)DBNull.Value),
new SqlParameter("@recurring_start_date", mailing.RecurringStartDate ?? (object)DBNull.Value),
new SqlParameter("@unsubscribe_list_code", mailing.UnsubscribeListCode ?? (object)DBNull.Value),
new SqlParameter("@template_json", JsonSerializer.Serialize(mailing.Template, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower })),
pmSuccess
};

View File

@ -118,6 +118,7 @@ namespace Surge365.MassEmailReact.Infrastructure.Repositories
new SqlParameter("@filter_query", target.FilterQuery),
new SqlParameter("@allow_write_back", target.AllowWriteBack),
new SqlParameter("@is_active", target.IsActive),
new SqlParameter("@default_unsubscribe_list_code", target.DefaultUnsubscribeListCode ?? (object)DBNull.Value),
new SqlParameter("@column_json", target.Columns != null ? JsonSerializer.Serialize(target.Columns) : (object)DBNull.Value),
pmSuccess
};
@ -152,6 +153,7 @@ namespace Surge365.MassEmailReact.Infrastructure.Repositories
new SqlParameter("@filter_query", target.FilterQuery),
new SqlParameter("@allow_write_back", target.AllowWriteBack),
new SqlParameter("@is_active", target.IsActive),
new SqlParameter("@default_unsubscribe_list_code", target.DefaultUnsubscribeListCode ?? (object)DBNull.Value),
new SqlParameter("@column_json", target.Columns != null ? JsonSerializer.Serialize(target.Columns) : (object)DBNull.Value),
pmSuccess
};

View File

@ -0,0 +1,122 @@
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Configuration;
using Surge365.Core.Interfaces;
using Surge365.Core.Mapping;
using Surge365.Core.Services;
using Surge365.MassEmailReact.Application.Interfaces;
using Surge365.MassEmailReact.Domain.Entities;
using System;
using System.Collections.Generic;
using System.Data;
using System.Linq;
using System.Threading.Tasks;
namespace Surge365.MassEmailReact.Infrastructure.Repositories
{
public class UnsubscribeListRepository : IUnsubscribeListRepository
{
private IConfiguration _config;
private DataAccessFactory _dataAccessFactory;
private IQueryMapper _queryMapper;
public DataAccess GetDataAccess(string connectionStringName = "MassEmail") => _dataAccessFactory.Get(connectionStringName) ?? throw new ArgumentNullException(nameof(_dataAccessFactory), $"DataAccess context for '{connectionStringName}' not found.");
public UnsubscribeListRepository(IConfiguration config, DataAccessFactory dataAccessFactory, IQueryMapper queryMapper)
{
_config = config;
_dataAccessFactory = dataAccessFactory ?? throw new ArgumentNullException(nameof(dataAccessFactory), "DataAccessFactory cannot be null.");
_queryMapper = queryMapper;
#if DEBUG
if (!_queryMapper.EntityMaps.ContainsKey(typeof(UnsubscribeList)))
{
throw new InvalidOperationException("UnsubscribeList query mapping is missing. Make sure ConfigureCustomMaps() is called inside program.cs (program startup).");
}
#endif
}
public async Task<UnsubscribeList?> GetByCodeAsync(string unsubscribeListCode)
{
ArgumentNullException.ThrowIfNull(_config);
var parameters = new List<SqlParameter>
{
new SqlParameter("@unsubscribe_list_code", unsubscribeListCode)
};
DataAccess dataAccess = GetDataAccess();
var dataSet = await dataAccess.CallRetrievalProcedureAsync(parameters, "mem_get_unsubscribe_list_by_code");
var results = await _queryMapper.MapAsync<UnsubscribeList>(dataSet);
return results.FirstOrDefault();
}
public async Task<List<UnsubscribeList>> GetAllAsync(bool activeOnly = true)
{
var parameters = new List<SqlParameter>
{
new SqlParameter("@active_only", activeOnly)
};
DataAccess dataAccess = GetDataAccess();
var dataSet = await dataAccess.CallRetrievalProcedureAsync(parameters, "mem_get_unsubscribe_list_all");
var results = await _queryMapper.MapAsync<UnsubscribeList>(dataSet);
return results.ToList();
}
public async Task<bool> CreateAsync(UnsubscribeList unsubscribeList)
{
ArgumentNullException.ThrowIfNull(unsubscribeList);
if (string.IsNullOrWhiteSpace(unsubscribeList.UnsubscribeListCode))
throw new Exception("UnsubscribeListCode cannot be null or empty");
SqlParameter pmSuccess = new SqlParameter("@success", SqlDbType.Bit)
{
Direction = ParameterDirection.Output
};
List<SqlParameter> parameters = new List<SqlParameter>
{
new SqlParameter("@unsubscribe_list_code", unsubscribeList.UnsubscribeListCode),
new SqlParameter("@friendly_name", unsubscribeList.FriendlyName),
new SqlParameter("@friendly_description", unsubscribeList.FriendlyDescription ?? (object)DBNull.Value),
new SqlParameter("@is_active", unsubscribeList.IsActive),
new SqlParameter("@display_order", unsubscribeList.DisplayOrder),
pmSuccess
};
DataAccess dataAccess = GetDataAccess();
await dataAccess.CallActionProcedureAsync(parameters, "mem_save_unsubscribe_list");
bool success = pmSuccess.Value != null && (bool)pmSuccess.Value;
return success;
}
public async Task<bool> UpdateAsync(UnsubscribeList unsubscribeList)
{
ArgumentNullException.ThrowIfNull(unsubscribeList);
if (string.IsNullOrWhiteSpace(unsubscribeList.UnsubscribeListCode))
throw new Exception("UnsubscribeListCode cannot be null or empty");
SqlParameter pmSuccess = new SqlParameter("@success", SqlDbType.Bit)
{
Direction = ParameterDirection.Output
};
List<SqlParameter> parameters = new List<SqlParameter>
{
new SqlParameter("@unsubscribe_list_code", unsubscribeList.UnsubscribeListCode),
new SqlParameter("@friendly_name", unsubscribeList.FriendlyName),
new SqlParameter("@friendly_description", unsubscribeList.FriendlyDescription ?? (object)DBNull.Value),
new SqlParameter("@is_active", unsubscribeList.IsActive),
new SqlParameter("@display_order", unsubscribeList.DisplayOrder),
pmSuccess
};
DataAccess dataAccess = GetDataAccess();
await dataAccess.CallActionProcedureAsync(parameters, "mem_save_unsubscribe_list");
bool success = pmSuccess.Value != null && (bool)pmSuccess.Value;
return success;
}
}
}

View File

@ -111,6 +111,7 @@ namespace Surge365.MassEmailReact.Infrastructure.Services
SentDate = mailingDto.SentDate,
RecurringTypeCode = mailingDto.RecurringTypeCode,
RecurringStartDate = mailingDto.RecurringStartDate,
UnsubscribeListCode = mailingDto.UnsubscribeListCode,
Template = new MailingTemplate
{
DomainId = mailingDto.Template.DomainId,
@ -139,6 +140,7 @@ namespace Surge365.MassEmailReact.Infrastructure.Services
mailing.SentDate = mailingDto.SentDate;
mailing.RecurringTypeCode = mailingDto.RecurringTypeCode;
mailing.RecurringStartDate = mailingDto.RecurringStartDate;
mailing.UnsubscribeListCode = mailingDto.UnsubscribeListCode;
mailing.Template = new MailingTemplate
{
DomainId = mailingDto.Template.DomainId,

View File

@ -9,8 +9,8 @@ using System.IdentityModel.Tokens.Jwt;
using Microsoft.IdentityModel.Tokens;
using Microsoft.Extensions.Configuration;
using Surge365.MassEmailReact.Domain.Entities;
using System.Security.Cryptography;
using Surge365.MassEmailReact.Application.DTOs;
using System.Security.Cryptography;
using System.Text.RegularExpressions;
using Microsoft.Data.SqlClient;
@ -53,6 +53,7 @@ namespace Surge365.MassEmailReact.Infrastructure.Services
target.FilterQuery = targetDto.FilterQuery;
target.AllowWriteBack = targetDto.AllowWriteBack;
target.IsActive = targetDto.IsActive;
target.DefaultUnsubscribeListCode = targetDto.DefaultUnsubscribeListCode;
target.Columns = new List<TargetColumn>();
foreach (var columnDto in targetDto.Columns)
@ -77,6 +78,7 @@ namespace Surge365.MassEmailReact.Infrastructure.Services
target.FilterQuery = targetDto.FilterQuery;
target.AllowWriteBack = targetDto.AllowWriteBack;
target.IsActive = targetDto.IsActive;
target.DefaultUnsubscribeListCode = targetDto.DefaultUnsubscribeListCode;
target.Columns = new List<TargetColumn>();
foreach (var columnDto in targetDto.Columns)

View File

@ -1,4 +1,5 @@
using Surge365.MassEmailReact.Application.Interfaces;
using Surge365.MassEmailReact.Application.DTOs;
using Surge365.MassEmailReact.Application.Interfaces;
using Surge365.MassEmailReact.Domain.Entities;
using System;
using System.Collections.Generic;

View File

@ -0,0 +1,66 @@
using Surge365.MassEmailReact.Application.Interfaces;
using Surge365.MassEmailReact.Application.DTOs;
using Surge365.MassEmailReact.Domain.Entities;
using Microsoft.Extensions.Configuration;
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace Surge365.MassEmailReact.Infrastructure.Services
{
public class UnsubscribeListService : IUnsubscribeListService
{
private readonly IUnsubscribeListRepository _unsubscribeListRepository;
private readonly IConfiguration _config;
public UnsubscribeListService(IUnsubscribeListRepository unsubscribeListRepository, IConfiguration config)
{
_unsubscribeListRepository = unsubscribeListRepository;
_config = config;
}
public async Task<UnsubscribeList?> GetByCodeAsync(string unsubscribeListCode)
{
return await _unsubscribeListRepository.GetByCodeAsync(unsubscribeListCode);
}
public async Task<List<UnsubscribeList>> GetAllAsync(bool activeOnly = true)
{
return await _unsubscribeListRepository.GetAllAsync(activeOnly);
}
public async Task<bool> CreateAsync(UnsubscribeListUpdateDto unsubscribeListDto)
{
ArgumentNullException.ThrowIfNull(unsubscribeListDto, nameof(unsubscribeListDto));
if (string.IsNullOrWhiteSpace(unsubscribeListDto.UnsubscribeListCode))
throw new Exception("UnsubscribeListCode cannot be null or empty");
var unsubscribeList = new UnsubscribeList
{
FriendlyName = unsubscribeListDto.FriendlyName,
FriendlyDescription = unsubscribeListDto.FriendlyDescription,
IsActive = unsubscribeListDto.IsActive,
DisplayOrder = unsubscribeListDto.DisplayOrder
};
return await _unsubscribeListRepository.CreateAsync(unsubscribeList);
}
public async Task<bool> UpdateAsync(UnsubscribeListUpdateDto unsubscribeListDto)
{
ArgumentNullException.ThrowIfNull(unsubscribeListDto, nameof(unsubscribeListDto));
if (string.IsNullOrWhiteSpace(unsubscribeListDto.UnsubscribeListCode))
throw new Exception("UnsubscribeListCode cannot be null or empty");
var unsubscribeList = await _unsubscribeListRepository.GetByCodeAsync(unsubscribeListDto.UnsubscribeListCode);
if (unsubscribeList == null) return false;
unsubscribeList.FriendlyName = unsubscribeListDto.FriendlyName;
unsubscribeList.FriendlyDescription = unsubscribeListDto.FriendlyDescription;
unsubscribeList.IsActive = unsubscribeListDto.IsActive;
unsubscribeList.DisplayOrder = unsubscribeListDto.DisplayOrder;
return await _unsubscribeListRepository.UpdateAsync(unsubscribeList);
}
}
}

View File

@ -19,6 +19,7 @@
<None Remove="src\components\layouts\Layout_backup.tsx" />
</ItemGroup>
<ItemGroup>
<Folder Include="Properties\" />
<Folder Include="src\hooks\" />
</ItemGroup>
</Project>

View File

@ -23,6 +23,7 @@ import Template from "@/types/template";
import Mailing from "@/types/mailing";
import TargetSample from "@/types/targetSample";
import Target from "@/types/target";
import UnsubscribeList from "@/types/unsubscribeList";
//import MailingTemplate from "@/types/mailingTemplate";
//import MailingTarget from "@/types/mailingTarget";
import EmailList from "@/components/forms/EmailList";
@ -82,6 +83,7 @@ const MailingEdit = ({ open, mailing, onClose, onSave }: MailingEditProps) => {
const [currentTarget, setCurrentTarget] = useState<Target | null>(null);
const [targetSample, setTargetSample] = useState<TargetSample | null>(null);
const [targetSampleLoading, setTargetSampleLoading] = useState(false);
const [availableUnsubscribeLists, setAvailableUnsubscribeLists] = useState<UnsubscribeList[]>([]);
const defaultMailing: Mailing = {
id: 0,
@ -95,6 +97,7 @@ const MailingEdit = ({ open, mailing, onClose, onSave }: MailingEditProps) => {
sessionActivityId: null,
recurringTypeCode: null,
recurringStartDate: null,
unsubscribeListCode: null,
template: {
id: 0,
mailingId: 0,
@ -208,6 +211,7 @@ const MailingEdit = ({ open, mailing, onClose, onSave }: MailingEditProps) => {
})
: schema.nullable();
}),
unsubscribeListCode: yup.string().nullable().optional(),
template: yup.object().shape({
id: yup.number().nullable().default(0),
mailingId: yup.number().default(0),
@ -254,6 +258,39 @@ const MailingEdit = ({ open, mailing, onClose, onSave }: MailingEditProps) => {
const [loading, setLoading] = useState(false);
// Function to fetch unsubscribe list by code if not in active list
const fetchUnsubscribeListByCode = async (code: string): Promise<UnsubscribeList | null> => {
try {
const response = await customFetch(`/api/unsubscribeLists/GetByCode/${code}`);
if (!response.ok) return null;
return await response.json();
} catch (error) {
console.error("Error fetching unsubscribe list by code:", error);
return null;
}
};
// Function to update available unsubscribe lists based on target selection
const updateAvailableUnsubscribeLists = async (selectedTarget: Target | null) => {
let lists = [...setupData.unsubscribeLists];
if (selectedTarget?.defaultUnsubscribeListCode) {
// Check if the target's default unsubscribe list is already in the active list
const isDefaultInActiveList = lists.some(
list => list.unsubscribeListCode === selectedTarget.defaultUnsubscribeListCode
);
// If not in active list, fetch it and add it
if (!isDefaultInActiveList) {
const defaultList = await fetchUnsubscribeListByCode(selectedTarget.defaultUnsubscribeListCode);
if (defaultList) {
lists = [...lists, defaultList];
}
}
}
setAvailableUnsubscribeLists(lists);
};
useEffect(() => {
const initializeMailingEdit = async () => {
@ -270,6 +307,10 @@ const MailingEdit = ({ open, mailing, onClose, onSave }: MailingEditProps) => {
setRecurring(false);
setScheduleForLater(false);
reset(mailing || defaultMailing, { keepDefaultValues: true });
// Initialize available unsubscribe lists
setAvailableUnsubscribeLists(setupData.unsubscribeLists);
if (setupData.testEmailLists.length > 0) {
setTestEmailListId(setupData.testEmailLists[0].id);
setEmails(setupData.testEmailLists[0].emails);
@ -286,6 +327,13 @@ const MailingEdit = ({ open, mailing, onClose, onSave }: MailingEditProps) => {
if (mailing?.targetId) {
const target = setupData.targets.find(t => t.id === mailing.targetId) || null;
setCurrentTarget(target);
// Update available unsubscribe lists and set default if needed
await updateAvailableUnsubscribeLists(target);
// If this is a new mailing or no unsubscribe list is set, default to target's default
if (target && (!mailing?.unsubscribeListCode || mailing.id === 0) && target.defaultUnsubscribeListCode) {
setValue("unsubscribeListCode", target.defaultUnsubscribeListCode);
}
} else {
setCurrentTarget(null);
}
@ -293,7 +341,7 @@ const MailingEdit = ({ open, mailing, onClose, onSave }: MailingEditProps) => {
};
initializeMailingEdit();
}, [open, mailing, reset, setupData.testEmailLists, setupData.targets, setupData.templates]);
}, [open, mailing, reset, setupData.testEmailLists, setupData.targets, setupData.templates, setupData.unsubscribeLists]);
const handleSave = async (formData: Mailing) => {
const apiUrl = isNew ? "/api/mailings" : `/api/mailings/${formData.id}`;
@ -565,33 +613,43 @@ const MailingEdit = ({ open, mailing, onClose, onSave }: MailingEditProps) => {
/>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Controller
name="targetId"
control={control}
render={({ field }) => (
<Autocomplete
{...field}
options={filteredTargets}
getOptionLabel={(option) => option.name}
value={filteredTargets.find(t => t.id === field.value) || null}
onChange={(_, newValue) => {
field.onChange(newValue ? newValue.id : null);
trigger("targetId");
setCurrentTarget(newValue);
}}
renderInput={(params) => (
<TextField
{...params}
label="Target"
fullWidth
margin="dense"
error={!!errors.targetId}
helperText={errors.targetId?.message}
/>
)}
sx={{ flexGrow: 1 }}
/>
)}
<Controller
name="targetId"
control={control}
render={({ field }) => (
<Autocomplete
{...field}
options={filteredTargets}
getOptionLabel={(option) => option.name}
value={filteredTargets.find(t => t.id === field.value) || null}
onChange={async (_, newValue) => {
field.onChange(newValue ? newValue.id : null);
trigger("targetId");
setCurrentTarget(newValue);
// Update available unsubscribe lists when target changes
await updateAvailableUnsubscribeLists(newValue);
// Set default unsubscribe list from target if available
if (newValue?.defaultUnsubscribeListCode) {
setValue("unsubscribeListCode", newValue.defaultUnsubscribeListCode);
} else {
setValue("unsubscribeListCode", null);
}
}}
renderInput={(params) => (
<TextField
{...params}
label="Target"
fullWidth
margin="dense"
error={!!errors.targetId}
helperText={errors.targetId?.message}
/>
)}
sx={{ flexGrow: 1 }}
/>
)}
/>
{currentTarget && (
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 0 }}>
@ -611,6 +669,32 @@ const MailingEdit = ({ open, mailing, onClose, onSave }: MailingEditProps) => {
</Box>
)}
</Box>
<Controller
name="unsubscribeListCode"
control={control}
render={({ field }) => (
<Autocomplete
{...field}
options={availableUnsubscribeLists}
getOptionLabel={(option) => option.friendlyName}
value={availableUnsubscribeLists.find((list) => list.unsubscribeListCode === field.value) || null}
onChange={(_, newValue) => {
field.onChange(newValue ? newValue.unsubscribeListCode : null);
trigger("unsubscribeListCode");
}}
renderInput={(params) => (
<TextField
{...params}
label="Unsubscribe List"
fullWidth
margin="dense"
error={!!errors.unsubscribeListCode}
helperText={errors.unsubscribeListCode?.message || "Optional - Select an unsubscribe list for this mailing"}
/>
)}
/>
)}
/>
<Autocomplete
options={setupData.testEmailLists}
getOptionLabel={(option) => option.name}
@ -724,24 +808,24 @@ const MailingEdit = ({ open, mailing, onClose, onSave }: MailingEditProps) => {
</Box>
</>)}
{templateViewerOpen && (
<TemplateViewer
open={templateViewerOpen}
template={currentTemplate!}
onClose={() => { setTemplateViewerOpen(false) }}
/>
)}
{templateViewerOpen && (
<TemplateViewer
open={templateViewerOpen}
template={currentTemplate!}
onClose={() => { setTemplateViewerOpen(false) }}
/>
)}
{TargetSampleModalOpen && currentTarget && (
<TargetSampleModal
open={TargetSampleModalOpen}
target={currentTarget}
targetSample={targetSample}
onClose={() => { setTargetSampleModalOpen(false) }}
/>
)}
onClose={() => { setTargetSampleModalOpen(false) }}
/>
)}
</DialogContent>
<DialogActions>
<Button onClick={() => { onClose( 'cancelled'); }} disabled={loading}>Cancel</Button>
<Button onClick={() => { onClose('cancelled'); }} disabled={loading}>Cancel</Button>
<Button onClick={handleSubmit(handleSave)} color="primary" disabled={loading}>
{loading ? "Saving..." : "Save"}
</Button>

View File

@ -51,6 +51,7 @@ const schema = yup.object().shape({
filterQuery: yup.string().nullable(),
allowWriteBack: yup.boolean().default(false),
isActive: yup.boolean().default(true),
defaultUnsubscribeListCode: yup.string().nullable().optional(),
columns: yup
.array().of(
yup.object().shape({
@ -84,6 +85,7 @@ const defaultTarget: Target = {
filterQuery: "",
allowWriteBack: false,
isActive: true,
defaultUnsubscribeListCode: null,
columns: [],
};
@ -315,6 +317,32 @@ const TargetEdit = ({ open, target, onClose, onSave }: TargetEditProps) => {
error={!!errors.filterQuery}
helperText={errors.filterQuery?.message}
/>
<Controller
name="defaultUnsubscribeListCode"
control={control}
render={({ field }) => (
<Autocomplete
{...field}
options={setupData.unsubscribeLists}
getOptionLabel={(option) => option.friendlyName}
value={setupData.unsubscribeLists.find((list) => list.unsubscribeListCode === field.value) || null}
onChange={(_, newValue) => {
field.onChange(newValue ? newValue.unsubscribeListCode : null);
trigger("defaultUnsubscribeListCode");
}}
renderInput={(params) => (
<TextField
{...params}
label="Default Unsubscribe List"
fullWidth
margin="dense"
error={!!errors.defaultUnsubscribeListCode}
helperText={errors.defaultUnsubscribeListCode?.message || "Optional - Select a default unsubscribe list for this target"}
/>
)}
/>
)}
/>
<FormControlLabel
control={<Switch {...register("allowWriteBack")} />}
label="Allow Write Back"

View File

@ -53,6 +53,18 @@ function NewMailings() {
minWidth: 160,
valueGetter: (_: number, row: Mailing) => setupData.templates.find(t => t.id === row.templateId)?.subject || 'Unknown',
},
{
field: "unsubscribeListCode",
headerName: "Unsubscribe List",
flex: 1,
minWidth: 160,
renderCell: (params: GridRenderCellParams<Mailing>) => {
const unsubscribeList = setupData.unsubscribeLists.find(
list => list.unsubscribeListCode === params.row.unsubscribeListCode
);
return unsubscribeList ? unsubscribeList.friendlyName : (params.row.unsubscribeListCode || "None");
}
},
];
const reloadMailings = async () => {
@ -153,6 +165,13 @@ function NewMailings() {
<Typography variant="body2">ID: {row.id}</Typography>
<Typography variant="body2">Description: {row.description}</Typography>
<Typography variant="body2">Subject: {setupData.templates.find(t => t.id === row.templateId)?.subject || 'Unknown'}</Typography>
<Typography variant="body2">
Unsubscribe List: {
setupData.unsubscribeLists.find(
list => list.unsubscribeListCode === row.unsubscribeListCode
)?.friendlyName || row.unsubscribeListCode || "None"
}
</Typography>
</CardContent>
<IconButton onClick={(e) => { e.stopPropagation(); handleEdit(row); }}>
<EditIcon />

View File

@ -38,6 +38,18 @@ function Targets() {
{ field: "databaseName", headerName: "Database", flex: 1, minWidth: 100 },
{ field: "viewName", headerName: "View", flex: 1, minWidth: 300 },
{ field: "filterQuery", headerName: "Filter Query", flex: 1, minWidth: 100 },
{
field: "defaultUnsubscribeListCode",
headerName: "Default Unsubscribe List",
flex: 1,
minWidth: 180,
renderCell: (params: GridRenderCellParams<Target>) => {
const unsubscribeList = setupData.unsubscribeLists.find(
list => list.unsubscribeListCode === params.row.defaultUnsubscribeListCode
);
return unsubscribeList ? unsubscribeList.friendlyName : (params.row.defaultUnsubscribeListCode || "None");
}
},
{ field: "allowWriteBack", headerName: "Write Back", width: 150 },
{ field: "isActive", headerName: "Active", width: 115 },
];
@ -102,6 +114,13 @@ function Targets() {
<Typography variant="body2">Database: {row.databaseName}</Typography>
<Typography variant="body2">View: {row.viewName}</Typography>
<Typography variant="body2">Filter: {row.filterQuery}</Typography>
<Typography variant="body2">
Default Unsubscribe List: {
setupData.unsubscribeLists.find(
list => list.unsubscribeListCode === row.defaultUnsubscribeListCode
)?.friendlyName || row.defaultUnsubscribeListCode || "None"
}
</Typography>
<Typography variant="body2">Writeback: {row.allowWriteBack ? "Yes" : "No"}</Typography>
<Typography variant="body2">Active: {row.isActive ? "Yes" : "No"}</Typography>
</CardContent>

View File

@ -4,6 +4,7 @@ import Server from "@/types/server";
import TestEmailList from '@/types/testEmailList';
import BouncedEmail from '@/types/bouncedEmail';
import UnsubscribeUrl from '@/types/unsubscribeUrl';
import UnsubscribeList from '@/types/unsubscribeList';
import Template from '@/types/template';
import EmailDomain from '@/types/emailDomain';
import { useCustomFetchNoNavigate } from "@/utils/customFetch";
@ -36,6 +37,11 @@ export type SetupData = {
setUnsubscribeUrls: (updatedUnsubscribeUrl: UnsubscribeUrl) => void;
unsubscribeUrlsLoading: boolean;
unsubscribeLists: UnsubscribeList[];
reloadUnsubscribeLists: () => void;
setUnsubscribeLists: (updatedUnsubscribeList: UnsubscribeList) => void;
unsubscribeListsLoading: boolean;
templates: Template[];
reloadTemplates: () => void;
setTemplates: (updatedTemplate: Template) => void;
@ -71,6 +77,9 @@ export const SetupDataProvider = ({ children }: { children: React.ReactNode }) =
const [unsubscribeUrls, setUnsubscribeUrls] = useState<UnsubscribeUrl[]>([]);
const [unsubscribeUrlsLoading, setUnsubscribeUrlsLoading] = useState<boolean>(false);
const [unsubscribeLists, setUnsubscribeLists] = useState<UnsubscribeList[]>([]);
const [unsubscribeListsLoading, setUnsubscribeListsLoading] = useState<boolean>(false);
const [templates, setTemplates] = useState<Template[]>([]);
const [templatesLoading, setTemplatesLoading] = useState<boolean>(false);
@ -109,6 +118,10 @@ export const SetupDataProvider = ({ children }: { children: React.ReactNode }) =
let unsubscribeUrlsData: UnsubscribeUrl[] | null = null;
let loadUnsubscribeUrls = true;
setUnsubscribeListsLoading(true);
let unsubscribeListsData: UnsubscribeList[] | null = null;
let loadUnsubscribeLists = true;
setTemplatesLoading(true);
let templatesData: Template[] | null = null;
let loadTemplates = true;
@ -144,6 +157,11 @@ export const SetupDataProvider = ({ children }: { children: React.ReactNode }) =
setUnsubscribeUrls(parsedData.unsubscribeUrls);
setUnsubscribeUrlsLoading(false);
}
if (parsedData.unsubscribeLists) {
loadUnsubscribeLists = false;
setUnsubscribeLists(parsedData.unsubscribeLists);
setUnsubscribeListsLoading(false);
}
if (parsedData.templates) {
loadTemplates = false;
setTemplates(parsedData.templates);
@ -220,6 +238,19 @@ export const SetupDataProvider = ({ children }: { children: React.ReactNode }) =
}
}
if (loadUnsubscribeLists) {
const unsubscribeListsResponse = await customFetch("/api/unsubscribeLists/GetAll?activeOnly=true");
unsubscribeListsData = await unsubscribeListsResponse.json();
if (unsubscribeListsData) {
setUnsubscribeLists(unsubscribeListsData);
setUnsubscribeListsLoading(false);
}
else {
console.error("Failed to fetch unsubscribeLists");
setUnsubscribeListsLoading(false);
}
}
if (loadTemplates) {
const templatesResponse = await customFetch("/api/templates/GetAll?activeOnly=false");
templatesData = await templatesResponse.json();
@ -247,13 +278,24 @@ export const SetupDataProvider = ({ children }: { children: React.ReactNode }) =
}
setDataLoading(false);
sessionStorage.setItem("setupData", JSON.stringify({ targets: targetsData, servers: serversData, testEmailLists: testEmailListsData, bouncedEmails: bouncedEmailsData, unsubscribeUrls: unsubscribeUrlsData, templates: templatesData, emailDomains: emailDomainsData }));
sessionStorage.setItem("setupData", JSON.stringify({
targets: targetsData,
servers: serversData,
testEmailLists: testEmailListsData,
bouncedEmails: bouncedEmailsData,
unsubscribeUrls: unsubscribeUrlsData,
unsubscribeLists: unsubscribeListsData,
templates: templatesData,
emailDomains: emailDomainsData
}));
} catch (error) {
setDataLoading(false);
setTargetsLoading(false);
setServersLoading(false);
setTestEmailListsLoading(false);
setBouncedEmailsLoading(false);
setUnsubscribeUrlsLoading(false);
setUnsubscribeListsLoading(false);
setTemplatesLoading(false);
setEmailDomainsLoading(false);
console.error("Failed to fetch setup data:", error);
@ -322,6 +364,17 @@ export const SetupDataProvider = ({ children }: { children: React.ReactNode }) =
sessionStorage.setItem("setupData", JSON.stringify({ unsubscribeUrls, targets, testEmailLists, bouncedEmails }));
};
const updateUnsubscribeListCache = (updatedUnsubscribeList: UnsubscribeList) => {
setUnsubscribeLists((prevUnsubscribeLists) => {
const unsubscribeListExists = prevUnsubscribeLists.some((unsubscribeList) => unsubscribeList.unsubscribeListCode === updatedUnsubscribeList.unsubscribeListCode);
return unsubscribeListExists
? prevUnsubscribeLists.map((unsubscribeList) => (unsubscribeList.unsubscribeListCode === updatedUnsubscribeList.unsubscribeListCode ? updatedUnsubscribeList : unsubscribeList))
: [...prevUnsubscribeLists, updatedUnsubscribeList]; // Push new unsubscribeList if not found
});
sessionStorage.setItem("setupData", JSON.stringify({ unsubscribeLists, targets, testEmailLists, bouncedEmails }));
};
const updateTemplateCache = (updatedTemplate: Template) => {
setTemplates((prevTemplates) => {
const templateExists = prevTemplates.some((template) => template.id === updatedTemplate.id);
@ -376,6 +429,11 @@ export const SetupDataProvider = ({ children }: { children: React.ReactNode }) =
setUnsubscribeUrls: updateUnsubscribeUrlCache,
unsubscribeUrlsLoading,
unsubscribeLists,
reloadUnsubscribeLists: reloadSetupData,
setUnsubscribeLists: updateUnsubscribeListCache,
unsubscribeListsLoading,
templates,
reloadTemplates: reloadSetupData,
setTemplates: updateTemplateCache,

View File

@ -12,6 +12,7 @@ export interface Mailing {
sessionActivityId: string | null;
recurringTypeCode: string | null;
recurringStartDate: string | null;
unsubscribeListCode: string | null;
template: MailingTemplate | null;
target: MailingTarget | null;
}

View File

@ -8,6 +8,7 @@ export interface Target {
filterQuery: string;
allowWriteBack: boolean;
isActive: boolean;
defaultUnsubscribeListCode: string | null;
columns: TargetColumn[];
}

View File

@ -0,0 +1,11 @@
export interface UnsubscribeList {
unsubscribeListCode: string;
friendlyName: string;
friendlyDescription: string | null;
isActive: boolean;
displayOrder: number;
createDate: string;
updateDate: string;
}
export default UnsubscribeList;

View File

@ -56,6 +56,7 @@ export default defineConfig({
https: {
key: fs.readFileSync(keyFilePath),
cert: fs.readFileSync(certFilePath),
}
},
open: 'chrome' // This will try to open Chrome specifically
}
})