- Add admin PWA push notifications for order management
- Integrate TeleBot customer messaging service
- Add push notification endpoints and VAPID key support
- Implement order status notifications throughout workflow
- Add notification UI components in admin panel
- Create TeleBotMessagingService for customer updates
- Add WebPush configuration to appsettings
- Fix compilation issues (BotStatus, BotContacts DbSet)
- Add comprehensive testing documentation
Features:
- Real-time admin notifications for new orders and status changes
- Customer order progress updates via TeleBot
- Graceful failure handling for notification services
- Test endpoints for notification system validation
🤖 Generated with Claude Code
Co-Authored-By: Claude <noreply@anthropic.com>
387 lines
15 KiB
C#
387 lines
15 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using System.Threading.Tasks;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.Logging;
|
|
using LittleShop.Data;
|
|
using LittleShop.Models;
|
|
using LittleShop.DTOs;
|
|
using LittleShop.Enums;
|
|
|
|
namespace LittleShop.Services;
|
|
|
|
public interface IMessageDeliveryService
|
|
{
|
|
Task<bool> QueueRecoveryMessageAsync(long telegramUserId, string message);
|
|
}
|
|
|
|
public interface IBotContactService
|
|
{
|
|
Task<BotContact> RecordContactAsync(Guid botId, TelegramUserDto user);
|
|
Task<IEnumerable<BotContact>> GetBotContactsAsync(Guid botId, bool activeOnly = true);
|
|
Task<IEnumerable<BotContact>> GetOrphanedContactsAsync(Guid failedBotId);
|
|
Task<bool> MigrateContactsAsync(Guid fromBotId, Guid toBotId);
|
|
Task<BotContactRecoveryDto> PrepareContactRecoveryAsync(Guid failedBotId);
|
|
Task<bool> NotifyContactOfBotChangeAsync(long telegramUserId, string newBotUsername);
|
|
Task<ContactBackupDto> CreateContactBackupAsync(Guid botId);
|
|
Task<bool> RestoreContactsFromBackupAsync(Guid toBotId, ContactBackupDto backup);
|
|
}
|
|
|
|
public class BotContactService : IBotContactService
|
|
{
|
|
private readonly LittleShopContext _context;
|
|
private readonly ILogger<BotContactService> _logger;
|
|
private readonly IMessageDeliveryService _messageService;
|
|
|
|
public BotContactService(
|
|
LittleShopContext context,
|
|
ILogger<BotContactService> logger,
|
|
IMessageDeliveryService messageService)
|
|
{
|
|
_context = context;
|
|
_logger = logger;
|
|
_messageService = messageService;
|
|
}
|
|
|
|
public async Task<BotContact> RecordContactAsync(Guid botId, TelegramUserDto user)
|
|
{
|
|
var existingContact = await _context.BotContacts
|
|
.FirstOrDefaultAsync(c => c.BotId == botId && c.TelegramUserId == user.Id);
|
|
|
|
if (existingContact != null)
|
|
{
|
|
// Update existing contact
|
|
existingContact.TelegramUsername = user.Username ?? string.Empty;
|
|
existingContact.DisplayName = $"{user.FirstName} {user.LastName}".Trim();
|
|
existingContact.FirstName = user.FirstName ?? string.Empty;
|
|
existingContact.LastName = user.LastName ?? string.Empty;
|
|
existingContact.LastContactDate = DateTime.UtcNow;
|
|
existingContact.TotalInteractions++;
|
|
existingContact.UpdatedAt = DateTime.UtcNow;
|
|
|
|
_context.BotContacts.Update(existingContact);
|
|
}
|
|
else
|
|
{
|
|
// Create new contact record
|
|
existingContact = new BotContact
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
BotId = botId,
|
|
TelegramUserId = user.Id,
|
|
TelegramUsername = user.Username ?? string.Empty,
|
|
DisplayName = $"{user.FirstName} {user.LastName}".Trim(),
|
|
FirstName = user.FirstName ?? string.Empty,
|
|
LastName = user.LastName ?? string.Empty,
|
|
FirstContactDate = DateTime.UtcNow,
|
|
LastContactDate = DateTime.UtcNow,
|
|
TotalInteractions = 1,
|
|
LastKnownLanguage = user.LanguageCode ?? "en",
|
|
Status = ContactStatus.Active,
|
|
CreatedAt = DateTime.UtcNow,
|
|
UpdatedAt = DateTime.UtcNow,
|
|
IsActive = true
|
|
};
|
|
|
|
await _context.BotContacts.AddAsync(existingContact);
|
|
}
|
|
|
|
await _context.SaveChangesAsync();
|
|
_logger.LogInformation("Recorded contact {UserId} for bot {BotId}", user.Id, botId);
|
|
|
|
return existingContact;
|
|
}
|
|
|
|
public async Task<IEnumerable<BotContact>> GetBotContactsAsync(Guid botId, bool activeOnly = true)
|
|
{
|
|
var query = _context.BotContacts.Where(c => c.BotId == botId);
|
|
|
|
if (activeOnly)
|
|
query = query.Where(c => c.IsActive && c.Status == ContactStatus.Active);
|
|
|
|
return await query
|
|
.OrderByDescending(c => c.LastContactDate)
|
|
.ToListAsync();
|
|
}
|
|
|
|
public async Task<IEnumerable<BotContact>> GetOrphanedContactsAsync(Guid failedBotId)
|
|
{
|
|
// Get contacts from failed bot that haven't been recovered yet
|
|
return await _context.BotContacts
|
|
.Where(c => c.BotId == failedBotId &&
|
|
c.Status == ContactStatus.Active &&
|
|
!c.IsRecovered)
|
|
.OrderByDescending(c => c.TotalInteractions) // Prioritize most active users
|
|
.ToListAsync();
|
|
}
|
|
|
|
public async Task<bool> MigrateContactsAsync(Guid fromBotId, Guid toBotId)
|
|
{
|
|
try
|
|
{
|
|
var contactsToMigrate = await GetOrphanedContactsAsync(fromBotId);
|
|
var toBot = await _context.Bots.FindAsync(toBotId);
|
|
|
|
if (toBot == null)
|
|
{
|
|
_logger.LogError("Target bot {BotId} not found", toBotId);
|
|
return false;
|
|
}
|
|
|
|
foreach (var contact in contactsToMigrate)
|
|
{
|
|
// Create new contact record for new bot
|
|
var newContact = new BotContact
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
BotId = toBotId,
|
|
TelegramUserId = contact.TelegramUserId,
|
|
TelegramUsername = contact.TelegramUsername,
|
|
DisplayName = contact.DisplayName,
|
|
FirstName = contact.FirstName,
|
|
LastName = contact.LastName,
|
|
FirstContactDate = DateTime.UtcNow,
|
|
LastContactDate = DateTime.UtcNow,
|
|
TotalInteractions = 0, // Reset for new bot
|
|
LastKnownLanguage = contact.LastKnownLanguage,
|
|
Status = ContactStatus.Recovered,
|
|
CustomerId = contact.CustomerId,
|
|
IsRecovered = true,
|
|
RecoveredFromBotId = fromBotId,
|
|
RecoveredAt = DateTime.UtcNow,
|
|
CreatedAt = DateTime.UtcNow,
|
|
UpdatedAt = DateTime.UtcNow,
|
|
IsActive = true,
|
|
Preferences = contact.Preferences,
|
|
Notes = $"Migrated from bot {fromBotId} on {DateTime.UtcNow:yyyy-MM-dd}"
|
|
};
|
|
|
|
await _context.BotContacts.AddAsync(newContact);
|
|
|
|
// Mark original contact as migrated
|
|
contact.Status = ContactStatus.Migrated;
|
|
contact.IsRecovered = true;
|
|
contact.UpdatedAt = DateTime.UtcNow;
|
|
_context.BotContacts.Update(contact);
|
|
|
|
_logger.LogInformation("Migrated contact {UserId} from bot {FromBot} to {ToBot}",
|
|
contact.TelegramUserId, fromBotId, toBotId);
|
|
}
|
|
|
|
await _context.SaveChangesAsync();
|
|
return true;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Failed to migrate contacts from {FromBot} to {ToBot}", fromBotId, toBotId);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
public async Task<BotContactRecoveryDto> PrepareContactRecoveryAsync(Guid failedBotId)
|
|
{
|
|
var orphanedContacts = await GetOrphanedContactsAsync(failedBotId);
|
|
var failedBot = await _context.Bots.FindAsync(failedBotId);
|
|
var availableBots = await _context.Bots
|
|
.Where(b => b.Id != failedBotId && b.Status == BotStatus.Active)
|
|
.ToListAsync();
|
|
|
|
return new BotContactRecoveryDto
|
|
{
|
|
FailedBotId = failedBotId,
|
|
FailedBotName = failedBot?.Name ?? "Unknown",
|
|
OrphanedContactCount = orphanedContacts.Count(),
|
|
HighValueContacts = orphanedContacts
|
|
.Where(c => c.TotalInteractions > 10 || c.CustomerId != null)
|
|
.Count(),
|
|
AvailableRecoveryBots = availableBots.Select(b => new BotInfoDto
|
|
{
|
|
Id = b.Id,
|
|
Name = b.Name,
|
|
Status = b.Status.ToString(),
|
|
CurrentContactCount = _context.BotContacts.Count(c => c.BotId == b.Id && c.IsActive)
|
|
}).ToList(),
|
|
ContactDetails = orphanedContacts.Select(c => new ContactSummaryDto
|
|
{
|
|
TelegramUserId = c.TelegramUserId,
|
|
Username = c.TelegramUsername,
|
|
DisplayName = c.DisplayName,
|
|
TotalInteractions = c.TotalInteractions,
|
|
LastContactDate = c.LastContactDate,
|
|
IsCustomer = c.CustomerId != null
|
|
}).ToList()
|
|
};
|
|
}
|
|
|
|
public async Task<bool> NotifyContactOfBotChangeAsync(long telegramUserId, string newBotUsername)
|
|
{
|
|
try
|
|
{
|
|
// This would integrate with the message delivery service
|
|
var message = $"Hello! Your previous bot is temporarily unavailable. " +
|
|
$"Please continue your conversation with our new bot: @{newBotUsername} " +
|
|
$"All your order history and preferences have been preserved.";
|
|
|
|
// Queue message for delivery when user contacts new bot
|
|
await _messageService.QueueRecoveryMessageAsync(telegramUserId, message);
|
|
|
|
return true;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Failed to notify user {UserId} of bot change", telegramUserId);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
public async Task<ContactBackupDto> CreateContactBackupAsync(Guid botId)
|
|
{
|
|
var contacts = await GetBotContactsAsync(botId, activeOnly: false);
|
|
var bot = await _context.Bots.FindAsync(botId);
|
|
|
|
return new ContactBackupDto
|
|
{
|
|
BackupId = Guid.NewGuid(),
|
|
BotId = botId,
|
|
BotName = bot?.Name ?? "Unknown",
|
|
BackupDate = DateTime.UtcNow,
|
|
ContactCount = contacts.Count(),
|
|
Contacts = contacts.Select(c => new ContactExportDto
|
|
{
|
|
TelegramUserId = c.TelegramUserId,
|
|
TelegramUsername = c.TelegramUsername,
|
|
DisplayName = c.DisplayName,
|
|
FirstName = c.FirstName,
|
|
LastName = c.LastName,
|
|
FirstContactDate = c.FirstContactDate,
|
|
LastContactDate = c.LastContactDate,
|
|
TotalInteractions = c.TotalInteractions,
|
|
LastKnownLanguage = c.LastKnownLanguage,
|
|
Status = c.Status.ToString(),
|
|
CustomerId = c.CustomerId,
|
|
Preferences = c.Preferences,
|
|
Notes = c.Notes
|
|
}).ToList()
|
|
};
|
|
}
|
|
|
|
public async Task<bool> RestoreContactsFromBackupAsync(Guid toBotId, ContactBackupDto backup)
|
|
{
|
|
try
|
|
{
|
|
foreach (var contactData in backup.Contacts)
|
|
{
|
|
// Check if contact already exists for this bot
|
|
var existingContact = await _context.BotContacts
|
|
.FirstOrDefaultAsync(c => c.BotId == toBotId &&
|
|
c.TelegramUserId == contactData.TelegramUserId);
|
|
|
|
if (existingContact == null)
|
|
{
|
|
var newContact = new BotContact
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
BotId = toBotId,
|
|
TelegramUserId = contactData.TelegramUserId,
|
|
TelegramUsername = contactData.TelegramUsername,
|
|
DisplayName = contactData.DisplayName,
|
|
FirstName = contactData.FirstName,
|
|
LastName = contactData.LastName,
|
|
FirstContactDate = contactData.FirstContactDate,
|
|
LastContactDate = contactData.LastContactDate,
|
|
TotalInteractions = contactData.TotalInteractions,
|
|
LastKnownLanguage = contactData.LastKnownLanguage,
|
|
Status = Enum.Parse<ContactStatus>(contactData.Status),
|
|
CustomerId = contactData.CustomerId,
|
|
IsRecovered = true,
|
|
RecoveredFromBotId = backup.BotId,
|
|
RecoveredAt = DateTime.UtcNow,
|
|
CreatedAt = DateTime.UtcNow,
|
|
UpdatedAt = DateTime.UtcNow,
|
|
IsActive = true,
|
|
Preferences = contactData.Preferences,
|
|
Notes = $"Restored from backup {backup.BackupId} on {DateTime.UtcNow:yyyy-MM-dd}"
|
|
};
|
|
|
|
await _context.BotContacts.AddAsync(newContact);
|
|
}
|
|
}
|
|
|
|
await _context.SaveChangesAsync();
|
|
_logger.LogInformation("Restored {Count} contacts from backup to bot {BotId}",
|
|
backup.Contacts.Count, toBotId);
|
|
return true;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Failed to restore contacts from backup");
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
// DTOs for contact management
|
|
public class TelegramUserDto
|
|
{
|
|
public long Id { get; set; }
|
|
public string? Username { get; set; }
|
|
public string? FirstName { get; set; }
|
|
public string? LastName { get; set; }
|
|
public string? LanguageCode { get; set; }
|
|
}
|
|
|
|
public class BotContactRecoveryDto
|
|
{
|
|
public Guid FailedBotId { get; set; }
|
|
public string FailedBotName { get; set; } = string.Empty;
|
|
public int OrphanedContactCount { get; set; }
|
|
public int HighValueContacts { get; set; }
|
|
public List<BotInfoDto> AvailableRecoveryBots { get; set; } = new();
|
|
public List<ContactSummaryDto> ContactDetails { get; set; } = new();
|
|
}
|
|
|
|
public class BotInfoDto
|
|
{
|
|
public Guid Id { get; set; }
|
|
public string Name { get; set; } = string.Empty;
|
|
public string Status { get; set; } = string.Empty;
|
|
public int CurrentContactCount { get; set; }
|
|
}
|
|
|
|
public class ContactSummaryDto
|
|
{
|
|
public long TelegramUserId { get; set; }
|
|
public string Username { get; set; } = string.Empty;
|
|
public string DisplayName { get; set; } = string.Empty;
|
|
public int TotalInteractions { get; set; }
|
|
public DateTime LastContactDate { get; set; }
|
|
public bool IsCustomer { get; set; }
|
|
}
|
|
|
|
public class ContactBackupDto
|
|
{
|
|
public Guid BackupId { get; set; }
|
|
public Guid BotId { get; set; }
|
|
public string BotName { get; set; } = string.Empty;
|
|
public DateTime BackupDate { get; set; }
|
|
public int ContactCount { get; set; }
|
|
public List<ContactExportDto> Contacts { get; set; } = new();
|
|
}
|
|
|
|
public class ContactExportDto
|
|
{
|
|
public long TelegramUserId { get; set; }
|
|
public string TelegramUsername { get; set; } = string.Empty;
|
|
public string DisplayName { get; set; } = string.Empty;
|
|
public string FirstName { get; set; } = string.Empty;
|
|
public string LastName { get; set; } = string.Empty;
|
|
public DateTime FirstContactDate { get; set; }
|
|
public DateTime LastContactDate { get; set; }
|
|
public int TotalInteractions { get; set; }
|
|
public string LastKnownLanguage { get; set; } = string.Empty;
|
|
public string Status { get; set; } = string.Empty;
|
|
public Guid? CustomerId { get; set; }
|
|
public string? Preferences { get; set; }
|
|
public string? Notes { get; set; }
|
|
} |