Add IsPinned and IsDeleted properties to Note entity with corresponding database configurations and migrations
This commit enhances the Note entity by introducing two new properties: IsPinned and IsDeleted, allowing for better management of note visibility and status. The NoteConfiguration class has been updated to include indexes for these properties, improving query performance. Additionally, new migrations have been created to reflect these changes in the database schema. The UI components have been updated to support pinning and restoring notes, enhancing user interaction and functionality within the note management system.
This commit is contained in:
@@ -5,15 +5,23 @@ namespace WebApp.Services;
|
||||
public interface INotesService
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets all notes.
|
||||
/// Gets all notes, optionally including deleted notes.
|
||||
/// </summary>
|
||||
Task<IEnumerable<Note>> GetNotesAsync();
|
||||
/// <param name="includeDeleted">If true, includes soft-deleted notes. Defaults to false.</param>
|
||||
Task<IEnumerable<Note>> GetNotesAsync(bool includeDeleted = false);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a single note by ID.
|
||||
/// </summary>
|
||||
Task<Note?> GetNoteAsync(int id);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a page-specific note by page identifier.
|
||||
/// </summary>
|
||||
/// <param name="pageIdentifier">The page identifier (e.g., "Teams", "Registration")</param>
|
||||
/// <returns>The note with title "@{pageIdentifier}" or null if not found</returns>
|
||||
Task<Note?> GetPageNoteAsync(string pageIdentifier);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all history entries for a note.
|
||||
/// </summary>
|
||||
@@ -33,4 +41,24 @@ public interface INotesService
|
||||
/// Deletes a note and creates a deletion history entry.
|
||||
/// </summary>
|
||||
Task DeleteNoteAsync(int id);
|
||||
|
||||
/// <summary>
|
||||
/// Gets up to 3 pinned notes, excluding page notes and deleted notes, ordered by most recently updated.
|
||||
/// </summary>
|
||||
Task<IEnumerable<Note>> GetPinnedNotesAsync();
|
||||
|
||||
/// <summary>
|
||||
/// Toggles the pin status of a note, enforcing the 3-note limit.
|
||||
/// </summary>
|
||||
Task TogglePinNoteAsync(int noteId);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all soft-deleted notes.
|
||||
/// </summary>
|
||||
Task<IEnumerable<Note>> GetDeletedNotesAsync();
|
||||
|
||||
/// <summary>
|
||||
/// Restores a soft-deleted note.
|
||||
/// </summary>
|
||||
Task RestoreNoteAsync(int noteId);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,22 @@ public static class MarkdownHelper
|
||||
.UseAdvancedExtensions()
|
||||
.Build();
|
||||
|
||||
// Compiled regex patterns for stripping markdown
|
||||
private static readonly Regex MarkdownLinkRegex =
|
||||
new(@"\[([^\]]+)\]\([^\)]+\)", RegexOptions.Compiled);
|
||||
private static readonly Regex BoldRegex =
|
||||
new(@"\*\*([^\*]+)\*\*", RegexOptions.Compiled);
|
||||
private static readonly Regex ItalicRegex =
|
||||
new(@"\*([^\*]+)\*", RegexOptions.Compiled);
|
||||
private static readonly Regex InlineCodeRegex =
|
||||
new(@"`([^`]+)`", RegexOptions.Compiled);
|
||||
private static readonly Regex HeaderRegex =
|
||||
new(@"#+\s+", RegexOptions.Compiled);
|
||||
private static readonly Regex NewlineRegex =
|
||||
new(@"\n+", RegexOptions.Compiled);
|
||||
private static readonly Regex HtmlTagRegex =
|
||||
new(@"<[^>]+>", RegexOptions.Compiled);
|
||||
|
||||
/// <summary>
|
||||
/// Converts markdown text to HTML.
|
||||
/// </summary>
|
||||
@@ -45,4 +61,35 @@ public static class MarkdownHelper
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Strips markdown formatting from content and returns plain text for preview.
|
||||
/// </summary>
|
||||
/// <param name="content">The markdown content to strip.</param>
|
||||
/// <param name="maxLength">Maximum length of the preview. If 0 or negative, no truncation is performed.</param>
|
||||
/// <returns>Plain text preview with markdown formatting removed.</returns>
|
||||
public static string StripMarkdownPreview(string? content, int maxLength = 0)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(content))
|
||||
return string.Empty;
|
||||
|
||||
// Strip markdown formatting for preview using compiled regex
|
||||
var plainText = MarkdownLinkRegex.Replace(content, "$1"); // Replace markdown links with link text
|
||||
plainText = BoldRegex.Replace(plainText, "$1"); // Remove bold
|
||||
plainText = ItalicRegex.Replace(plainText, "$1"); // Remove italic
|
||||
plainText = InlineCodeRegex.Replace(plainText, "$1"); // Remove inline code
|
||||
plainText = HeaderRegex.Replace(plainText, ""); // Remove headers
|
||||
plainText = NewlineRegex.Replace(plainText, " "); // Replace newlines with spaces
|
||||
plainText = HtmlTagRegex.Replace(plainText, ""); // Remove HTML tags
|
||||
|
||||
plainText = plainText.Trim();
|
||||
|
||||
// Truncate if maxLength is specified and content exceeds it
|
||||
if (maxLength > 0 && plainText.Length > maxLength)
|
||||
{
|
||||
return plainText.Substring(0, maxLength - 3) + "...";
|
||||
}
|
||||
|
||||
return plainText;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,11 +30,20 @@ public class NotesService : INotesService
|
||||
return user.FindFirstValue(ClaimTypes.Email) ?? user.Identity?.Name;
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<Note>> GetNotesAsync()
|
||||
public async Task<IEnumerable<Note>> GetNotesAsync(bool includeDeleted = false)
|
||||
{
|
||||
return await _context.Notes
|
||||
var query = _context.Notes
|
||||
.AsNoTracking()
|
||||
.OrderByDescending(n => n.UpdatedAt)
|
||||
.AsQueryable();
|
||||
|
||||
if (!includeDeleted)
|
||||
{
|
||||
query = query.Where(n => !n.IsDeleted);
|
||||
}
|
||||
|
||||
return await query
|
||||
.OrderBy(n => n.Title.StartsWith("@") ? 1 : 0) // Non-page notes first (0), page notes last (1)
|
||||
.ThenByDescending(n => n.UpdatedAt) // Within each group, order by most recently updated
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
@@ -45,6 +54,15 @@ public class NotesService : INotesService
|
||||
.FirstOrDefaultAsync(n => n.Id == id);
|
||||
}
|
||||
|
||||
public async Task<Note?> GetPageNoteAsync(string pageIdentifier)
|
||||
{
|
||||
var pageNoteTitle = $"@{pageIdentifier}";
|
||||
return await _context.Notes
|
||||
.AsNoTracking()
|
||||
.Where(n => n.Title == pageNoteTitle && !n.IsDeleted)
|
||||
.FirstOrDefaultAsync();
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<NoteHistory>> GetNoteHistoryAsync(int noteId)
|
||||
{
|
||||
return await _context.NoteHistories
|
||||
@@ -147,16 +165,134 @@ public class NotesService : INotesService
|
||||
Content = note.Content,
|
||||
ModifiedBy = userEmail,
|
||||
ModifiedAt = now,
|
||||
ChangeType = "Deleted"
|
||||
ChangeType = "Soft Deleted"
|
||||
};
|
||||
|
||||
_context.NoteHistories.Add(history);
|
||||
|
||||
// Delete the note (cascade will handle history, but we want to keep history)
|
||||
_context.Notes.Remove(note);
|
||||
// Soft delete - set IsDeleted flag instead of removing
|
||||
note.IsDeleted = true;
|
||||
note.UpdatedAt = now;
|
||||
note.LastModifiedBy = userEmail;
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Note deleted: {NoteId} by {User}", id, userEmail);
|
||||
_logger.LogInformation("Note soft deleted: {NoteId} by {User}", id, userEmail);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<Note>> GetPinnedNotesAsync()
|
||||
{
|
||||
return await _context.Notes
|
||||
.AsNoTracking()
|
||||
.Where(n => n.IsPinned && !n.Title.StartsWith("@") && !n.IsDeleted)
|
||||
.OrderByDescending(n => n.UpdatedAt)
|
||||
.Take(3)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task TogglePinNoteAsync(int noteId)
|
||||
{
|
||||
var note = await _context.Notes
|
||||
.FirstOrDefaultAsync(n => n.Id == noteId);
|
||||
|
||||
if (note == null)
|
||||
{
|
||||
throw new InvalidOperationException($"Note with ID {noteId} not found.");
|
||||
}
|
||||
|
||||
// Prevent pinning page notes
|
||||
if (note.Title.StartsWith("@"))
|
||||
{
|
||||
throw new InvalidOperationException("Page notes cannot be pinned.");
|
||||
}
|
||||
|
||||
var userEmail = GetCurrentUserEmail();
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
// If pinning and already 3 pinned notes exist (excluding this note if it's already pinned)
|
||||
if (!note.IsPinned)
|
||||
{
|
||||
var pinnedCount = await _context.Notes
|
||||
.CountAsync(n => n.IsPinned && !n.Title.StartsWith("@") && !n.IsDeleted);
|
||||
|
||||
if (pinnedCount >= 3)
|
||||
{
|
||||
// Unpin the oldest pinned note
|
||||
var oldestPinned = await _context.Notes
|
||||
.Where(n => n.IsPinned && !n.Title.StartsWith("@") && !n.IsDeleted)
|
||||
.OrderBy(n => n.UpdatedAt)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
if (oldestPinned != null)
|
||||
{
|
||||
oldestPinned.IsPinned = false;
|
||||
oldestPinned.UpdatedAt = now;
|
||||
oldestPinned.LastModifiedBy = userEmail;
|
||||
_logger.LogInformation("Auto-unpinned note {NoteId} (oldest) when pinning note {NewNoteId} by {User}",
|
||||
oldestPinned.Id, noteId, userEmail);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle the pin status
|
||||
note.IsPinned = !note.IsPinned;
|
||||
note.UpdatedAt = now;
|
||||
note.LastModifiedBy = userEmail;
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Note {NoteId} {Action} by {User}", noteId,
|
||||
note.IsPinned ? "pinned" : "unpinned", userEmail);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<Note>> GetDeletedNotesAsync()
|
||||
{
|
||||
return await _context.Notes
|
||||
.AsNoTracking()
|
||||
.Where(n => n.IsDeleted)
|
||||
.OrderBy(n => n.Title.StartsWith("@") ? 1 : 0) // Non-page notes first (0), page notes last (1)
|
||||
.ThenByDescending(n => n.UpdatedAt) // Within each group, order by most recently updated
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task RestoreNoteAsync(int noteId)
|
||||
{
|
||||
var note = await _context.Notes
|
||||
.FirstOrDefaultAsync(n => n.Id == noteId);
|
||||
|
||||
if (note == null)
|
||||
{
|
||||
throw new InvalidOperationException($"Note with ID {noteId} not found.");
|
||||
}
|
||||
|
||||
if (!note.IsDeleted)
|
||||
{
|
||||
throw new InvalidOperationException($"Note with ID {noteId} is not deleted.");
|
||||
}
|
||||
|
||||
var userEmail = GetCurrentUserEmail();
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
// Create restore history entry
|
||||
var history = new NoteHistory
|
||||
{
|
||||
NoteId = note.Id,
|
||||
Title = note.Title,
|
||||
Content = note.Content,
|
||||
ModifiedBy = userEmail,
|
||||
ModifiedAt = now,
|
||||
ChangeType = "Restored"
|
||||
};
|
||||
|
||||
_context.NoteHistories.Add(history);
|
||||
|
||||
// Restore the note
|
||||
note.IsDeleted = false;
|
||||
note.UpdatedAt = now;
|
||||
note.LastModifiedBy = userEmail;
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Note restored: {NoteId} by {User}", noteId, userEmail);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user