Skip to content
Merged

Dev #2820

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,63 @@ public async Task<List<EmailLogAttachment>> GetAttachmentsAsync(Guid emailLogId)
return await _emailLogAttachmentRepository.GetByEmailLogIdAsync(emailLogId);
}

public async Task<int> CopyTemplateAttachmentsAsync(Guid templateId, Guid emailLogId, Guid? tenantId)
{
var templateAttachments = await _emailLogAttachmentRepository.GetByTemplateIdAsync(templateId);
var existingAttachments = await _emailLogAttachmentRepository.GetByEmailLogIdAsync(emailLogId);
// Dedup by (FileName, FileSize, ContentType) rather than S3ObjectKey: each copy gets its own
// S3 object (see below), so a re-run of this method for the same emailLogId/templateId would
// never see a matching key even though the attachment was already copied.
var alreadyCopied = existingAttachments
.Where(a => a.OriginTemplateId == templateId)
.Select(a => (a.FileName, a.FileSize, a.ContentType))
.ToHashSet();

var bucket = _configuration[S3BucketConfigKey];
var copiedAttachmentCount = 0;
foreach (var templateAttachment in templateAttachments)
{
var identity = (templateAttachment.FileName, templateAttachment.FileSize, templateAttachment.ContentType);
if (!alreadyCopied.Add(identity))
{
continue;
}

// Physically duplicate the S3 object under a new key instead of pointing at the
// template attachment's own key. EmailLogAttachmentAppService.DeleteAsync deletes the
// underlying S3 object whenever a template attachment (TemplateId.HasValue) is removed;
// sharing the key would silently break the attachment on every scheduled email that had
// already copied it.
var copiedS3Key = BuildUserAttachmentS3Key(
tenantId, emailLogId, Guid.NewGuid(), templateAttachment.FileName ?? templateAttachment.DisplayName ?? "attachment");
await _amazonS3Client.CopyObjectAsync(new CopyObjectRequest
{
SourceBucket = bucket,
SourceKey = templateAttachment.S3ObjectKey,
DestinationBucket = bucket,
DestinationKey = copiedS3Key
});

await _emailLogAttachmentRepository.InsertAsync(new EmailLogAttachment
{
EmailLogId = emailLogId,
TemplateId = null,
OriginTemplateId = templateId,
S3ObjectKey = copiedS3Key,
FileName = templateAttachment.FileName,
DisplayName = templateAttachment.DisplayName,
ContentType = templateAttachment.ContentType,
FileSize = templateAttachment.FileSize,
Time = DateTime.UtcNow,
UserId = Guid.Empty,
TenantId = tenantId
});
copiedAttachmentCount++;
}

return copiedAttachmentCount;
}

public async Task<long> GetTotalFileSizeAsync(Guid? emailLogId, Guid? templateId)
{
if(emailLogId != null)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,26 @@ private async Task<EmailLog> InitializeEmail(EmailInitParams p, string status)
emailLog.ScheduledNotificationId = eventData.ScheduledNotificationId.Value;
await emailLogsRepository.UpdateAsync(emailLog, autoSave: true);
}

if (eventData.ScheduledNotificationId.HasValue && eventData.TemplateId != Guid.Empty)
{
try
{
var copiedAttachmentCount = await emailAttachmentService.CopyTemplateAttachmentsAsync(
eventData.TemplateId, emailLog.Id, emailLog.TenantId);
_logger.LogInformation(
"Copied {AttachmentCount} template attachments for scheduled notification {ScheduledNotificationId}.",
copiedAttachmentCount, eventData.ScheduledNotificationId.Value);
}
catch (Exception ex)
{
_logger.LogError(ex,
"Failed to copy template attachments for scheduled notification {ScheduledNotificationId}. Email will be sent WITHOUT attachments.",
eventData.ScheduledNotificationId.Value);
// DO NOT THROW - matches InitializeEmailAndUploadAttachments: an attachment
// failure should not block the email from being created/sent.
}
}

await StampClassificationAsync(emailLog);
return emailLog;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,10 @@ body {
justify-content: center;
}

.split-container.split-active .left-pane {
flex: 0 0 48%;
}

/* ── Right pane ───────────────────────────────────────────────────────────── */
.right-pane {
flex-shrink: 0;
Expand All @@ -205,6 +209,19 @@ body {
height: calc(100vh - 230px);
}

.right-pane > .card {
min-height: 0;
}

.right-pane .editor-body {
overflow-x: hidden;
overflow-y: auto;
}

#email-attachments-section {
flex-shrink: 0;
}


/* ── Editor header ────────────────────────────────────────────────── */
.editor-header {
Expand All @@ -222,6 +239,15 @@ body {
padding: 2px;
}

#email-attachments-section .d-flex.justify-content-end.mt-2.mb-1 {
padding-bottom: 50px !important;
display: flex !important;
}

.right-pane .card-body.editor-body.overflow-auto {
padding-bottom: 20px !important;
}

/* ── Resizable textareas ──────────────────────────────────────────────────── */
.textarea {
resize: vertical;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ $(function () {
let emailAttachmentsTable = null;
let templatesDataTable = null;
let originalFormValues = {};
let attachmentChangesPending = false;

function init() {
$('#email-attachments-section').hide();
Expand Down Expand Up @@ -296,6 +297,7 @@ $(function () {

UiElements.deleteButton.show();
$('#email-attachments-section').show();
attachmentChangesPending = false;
initEmailAttachmentsTable(data.id);

// Recalculate table columns after initialization
Expand Down Expand Up @@ -328,6 +330,7 @@ $(function () {
$('#templateRecipientSelect').empty().val([]).trigger('change');
UiElements.deleteButton.hide();
$('#email-attachments-section').hide();
attachmentChangesPending = false;
// Don't load attachments for new templates - they have no ID yet
}

Expand Down Expand Up @@ -424,17 +427,64 @@ $(function () {
};

const isNewTemplate = !templateId || templateId.trim() === '';
const templateChangesPending = hasTemplateChanges(templateData) || attachmentChangesPending;

// Check template name uniqueness before saving
checkTemplateNameUnique(templateName.trim(), templateId, function (isUnique) {
if (!isUnique) {
markFieldError('templateName', 'Template name must be unique.');
return;
}
performSave(isNewTemplate, templateId, templateData, templateName, sendFrom, subject, bodyHTML);
confirmTemplateAttachmentImpact(templateId, isNewTemplate, templateChangesPending)
.then(function (confirmed) {
if (confirmed) {
performSave(isNewTemplate, templateId, templateData, templateName, sendFrom, subject, bodyHTML);
}
});
});
});

function hasTemplateChanges(templateData) {
const original = originalFormValues;
const fields = ['name', 'description', 'sendFrom', 'subject', 'bodyText', 'bodyHTML', 'recipientCategory', 'recipientIdentifier'];

return fields.some(field => String(templateData[field] ?? '') !== String(original[field] ?? ''));
}

function confirmTemplateAttachmentImpact(templateId, isNewTemplate, templateChangesPending) {
if (isNewTemplate || !templateChangesPending) {
return Promise.resolve(true);
}

return $.ajax({
url: `/api/form-notifications/template-notification-plans/${encodeURIComponent(templateId)}`,
type: 'GET',
dataType: 'json'
}).then(function (response) {
const planNames = response.notificationPlanNames || [];
if (planNames.length === 0) {
return true;
}

return Swal.fire({
icon: 'warning',
title: 'Template changes',
html: '<p><strong>Warning:</strong> This template is currently associated with ' + planNames.length + ' notification plan' + (planNames.length === 1 ? '' : 's') + '. Any changes made to this template may impact these notification plan' + (planNames.length === 1 ? '' : 's') + '.</p>',
showCancelButton: true,
confirmButtonText: 'OK',
cancelButtonText: 'Cancel',
customClass: {
confirmButton: 'btn btn-primary',
cancelButton: 'btn btn-secondary'
}
}).then(result => result.isConfirmed);
}).catch(function (e) {
console.warn('Failed to check template notification plans:', e);
abp.notify.error('Unable to verify whether this template is used by a notification plan. The template was not saved.');
return false;
});
}

function performSave(isNewTemplate, templateId, templateData, templateName, sendFrom, subject, bodyHTML) {
if (isNewTemplate) {
// Create new template
Expand Down Expand Up @@ -472,6 +522,7 @@ $(function () {
unity.notifications.templates.template
.updateTemplate(templateId, templateData)
.then(function () {
attachmentChangesPending = false;
abp.notify.success('Template updated successfully.');
// Update original values after successful save
originalFormValues = {
Expand All @@ -481,7 +532,9 @@ $(function () {
sendFrom: sendFrom,
subject: subject,
bodyText: '',
bodyHTML: bodyHTML
bodyHTML: bodyHTML,
recipientCategory: templateData.recipientCategory || '',
recipientIdentifier: templateData.recipientIdentifier || ''
};
PubSub.publish('reload_templates_table_no_close');
})
Expand Down Expand Up @@ -969,6 +1022,7 @@ $(function () {
$('#attachment-upload-progress').show();
},
success: function () {
attachmentChangesPending = true;
PubSub.publish('reload_email_attachments_table');
},
error: function (xhr) {
Expand Down Expand Up @@ -1047,6 +1101,10 @@ $(function () {
reloadEmailAttachmentsTable();
});

PubSub.subscribe('template_attachment_changed', () => {
attachmentChangesPending = true;
});

function reloadEmailAttachmentsTable() {
if (emailAttachmentsTable) {
emailAttachmentsTable.ajax.reload();
Expand Down Expand Up @@ -1162,14 +1220,24 @@ function generateEmailAttachmentButtonContent(attachmentId) {
* @param {string} attachmentId - Attachment ID to delete
*/
function deleteEmailAttachment(attachmentId) {
abp.message.confirm(
'Are you sure you want to delete this attachment?',
'Delete Attachment',
function (confirmed) {
if (confirmed) {
const templateId = $('#templateId').val();
const planImpactCheck = isConfigurationManagementTemplateEditor()
? checkScheduledPlanImpactForAttachmentDelete(templateId)
: Promise.resolve(true);

planImpactCheck.then(function (confirmed) {
if (!confirmed) return;

abp.message.confirm(
'Are you sure you want to delete this attachment?',
'Delete Attachment',
function (deleteConfirmed) {
if (!deleteConfirmed) return;

unity.notifications.emails.emailLogAttachment
.delete(attachmentId)
.then(function () {
PubSub.publish('template_attachment_changed');
abp.notify.success('Attachment deleted successfully.');
PubSub.publish('reload_email_attachments_table');
})
Expand All @@ -1178,8 +1246,45 @@ function deleteEmailAttachment(attachmentId) {
abp.notify.error('Failed to delete attachment.');
});
}
}
);
);
});
}

function isConfigurationManagementTemplateEditor() {
return window.location.pathname.toLowerCase() === '/configurationmanagement' &&
$('#nav-template').length > 0 &&
$('#TemplatesTable').length > 0 &&
$('#templateId').length > 0;
}

function checkScheduledPlanImpactForAttachmentDelete(templateId) {
if (!templateId) return Promise.resolve(true);

return $.ajax({
url: `/api/form-notifications/template-notification-plans/${encodeURIComponent(templateId)}`,
type: 'GET',
dataType: 'json'
}).then(function (response) {
const planNames = response.notificationPlanNames || [];
if (planNames.length === 0) return true;

return Swal.fire({
icon: 'warning',
title: 'Scheduled notification impact',
html: '<p><strong>Warning:</strong> This template is currently associated with ' + planNames.length + ' notification plan' + (planNames.length === 1 ? '' : 's') + '. Any changes made to this template may impact these notification plan' + (planNames.length === 1 ? '' : 's') + '.</p>',
showCancelButton: true,
confirmButtonText: 'OK',
cancelButtonText: 'Cancel',
customClass: {
confirmButton: 'btn btn-primary',
cancelButton: 'btn btn-secondary'
}
}).then(result => result.isConfirmed);
}).catch(function (e) {
console.warn('Failed to check template notification plans:', e);
abp.notify.error('Unable to verify whether this template is used by a scheduled notification plan. The attachment was not deleted.');
return false;
});
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -364,18 +364,47 @@ public async Task<ActionResult<object>> CanDeleteTemplate(Guid templateId)
var result = await _automatedNotificationAppService.GetListAsync(
new Notifications.GetNotificationsInput { MaxResultCount = 1000 });

var inUse = result.Items.Any(n => n.EmailTemplateId == templateId);
var associatedPlans = result.Items
.Where(n => n.EmailTemplateId == templateId)
.Select(n =>
string.IsNullOrWhiteSpace(n.TriggerDetail)
? $"{n.TriggerType} notification"
: $"{n.TriggerType} notification - {n.TriggerDetail}")
.Distinct()
.ToList();

var inUse = associatedPlans.Count > 0;

if (inUse)
{
return Ok(new
{
canDelete = false,
errorMessage = "This template cannot be deleted because it is assigned to one or more Scheduled Notifications. Please remove the template from all Scheduled Notifications before deleting."
errorMessage = "This template cannot be deleted because it is assigned to one or more Scheduled Notifications. Please remove the template from all Scheduled Notifications before deleting.",
notificationPlanNames = associatedPlans
});
}

return Ok(new { canDelete = true, errorMessage = (string?)null });
return Ok(new { canDelete = true, errorMessage = (string?)null, notificationPlanNames = Array.Empty<string>() });
}

[HttpGet("template-notification-plans/{templateId:guid}")]
public async Task<ActionResult<object>> GetTemplateNotificationPlans(Guid templateId)
{
var result = await _automatedNotificationAppService.GetListAsync(
new Notifications.GetNotificationsInput { MaxResultCount = 1000 });
var template = await _templateService.GetTemplateById(templateId);

var templateName = template?.Name ?? "Template";
var planNames = result.Items
.Where(n => n.IsActive && n.EmailTemplateId == templateId)
.Select(n => string.IsNullOrWhiteSpace(n.TriggerDetail)
? $"{templateName} - {n.TriggerType} notification"
: $"{templateName} - {n.TriggerType} notification - {n.TriggerDetail}")
.Distinct()
.ToList();

return Ok(new { notificationPlanNames = planNames });
}

[HttpPut("{formId}/{id:guid}")]
Expand Down
Loading
Loading