feat: add reminders page, BMad skills upgrade, MCP server refactor

- Add reminders page with navigation support
- Upgrade BMad builder module to skills-based architecture
- Refactor MCP server: extract tools and auth into separate modules
- Add connections cache, custom AI provider support
- Update prisma schema and generated client
- Various UI/UX improvements and i18n updates
- Add service worker for PWA support

Made-with: Cursor
This commit is contained in:
Sepehr Ramezani
2026-04-13 21:02:53 +02:00
parent 18ed116e0d
commit fa7e166f3e
3099 changed files with 397228 additions and 14584 deletions

View File

@@ -5,43 +5,26 @@ import prisma from '@/lib/prisma'
import { Note, CheckItem } from '@/lib/types'
import { auth } from '@/auth'
import { getAIProvider } from '@/lib/ai/factory'
import { cosineSimilarity, validateEmbedding, calculateRRFK, detectQueryType, getSearchWeights } from '@/lib/utils'
import { parseNote as parseNoteUtil, cosineSimilarity, validateEmbedding, calculateRRFK, detectQueryType, getSearchWeights } from '@/lib/utils'
import { getSystemConfig, getConfigNumber, getConfigBoolean, SEARCH_DEFAULTS } from '@/lib/config'
import { contextualAutoTagService } from '@/lib/ai/services/contextual-auto-tag.service'
// Helper function to parse JSON strings from database
// Wrapper for parseNote that validates embeddings
function parseNote(dbNote: any): Note {
// Parse embedding
const embedding = dbNote.embedding ? JSON.parse(dbNote.embedding) : null
const note = parseNoteUtil(dbNote)
// Validate embedding if present
if (embedding && Array.isArray(embedding)) {
const validation = validateEmbedding(embedding)
if (note.embedding && Array.isArray(note.embedding)) {
const validation = validateEmbedding(note.embedding)
if (!validation.valid) {
// Don't include invalid embedding in the returned note
return {
...dbNote,
checkItems: dbNote.checkItems ? JSON.parse(dbNote.checkItems) : null,
labels: dbNote.labels ? JSON.parse(dbNote.labels) : null,
images: dbNote.images ? JSON.parse(dbNote.images) : null,
links: dbNote.links ? JSON.parse(dbNote.links) : null,
embedding: null, // Exclude invalid embedding
sharedWith: dbNote.sharedWith ? JSON.parse(dbNote.sharedWith) : [],
size: dbNote.size || 'small',
...note,
embedding: null
}
}
}
return {
...dbNote,
checkItems: dbNote.checkItems ? JSON.parse(dbNote.checkItems) : null,
labels: dbNote.labels ? JSON.parse(dbNote.labels) : null,
images: dbNote.images ? JSON.parse(dbNote.images) : null,
links: dbNote.links ? JSON.parse(dbNote.links) : null,
embedding,
sharedWith: dbNote.sharedWith ? JSON.parse(dbNote.sharedWith) : [],
size: dbNote.size || 'small',
}
return note
}
// Helper to get hash color for labels (copied from utils)
@@ -170,6 +153,46 @@ export async function getNotes(includeArchived = false) {
}
}
// Get notes with reminders (upcoming, overdue, done)
export async function getNotesWithReminders() {
const session = await auth();
if (!session?.user?.id) return [];
try {
const notes = await prisma.note.findMany({
where: {
userId: session.user.id,
isArchived: false,
reminder: { not: null }
},
orderBy: { reminder: 'asc' }
})
return notes.map(parseNote)
} catch (error) {
console.error('Error fetching notes with reminders:', error)
return []
}
}
// Mark a reminder as done / undone
export async function toggleReminderDone(noteId: string, done: boolean) {
const session = await auth();
if (!session?.user?.id) return { error: 'Unauthorized' }
try {
await prisma.note.update({
where: { id: noteId, userId: session.user.id },
data: { isReminderDone: done }
})
revalidatePath('/reminders')
return { success: true }
} catch (error) {
console.error('Error toggling reminder done:', error)
return { error: 'Failed to update reminder' }
}
}
// Get archived notes only
export async function getArchivedNotes() {
const session = await auth();
@@ -329,50 +352,7 @@ export async function createNote(data: {
if (!session?.user?.id) throw new Error('Unauthorized');
try {
let embeddingString: string | null = null;
try {
const provider = getAIProvider(await getSystemConfig());
const embedding = await provider.getEmbeddings(data.content);
if (embedding) embeddingString = JSON.stringify(embedding);
} catch (e) {
console.error('Embedding generation failed:', e);
}
// AUTO-LABELING: If no labels provided and auto-labeling is enabled, suggest labels
let labelsToUse = data.labels || null;
if ((!labelsToUse || labelsToUse.length === 0) && data.notebookId) {
try {
const autoLabelingEnabled = await getConfigBoolean('AUTO_LABELING_ENABLED', true);
const autoLabelingConfidence = await getConfigNumber('AUTO_LABELING_CONFIDENCE_THRESHOLD', 70);
if (autoLabelingEnabled) {
const suggestions = await contextualAutoTagService.suggestLabels(
data.content,
data.notebookId,
session.user.id
);
// Apply suggestions with confidence >= threshold
const appliedLabels = suggestions
.filter(s => s.confidence >= autoLabelingConfidence)
.map(s => s.label);
if (appliedLabels.length > 0) {
labelsToUse = appliedLabels;
} else {
}
} else {
}
} catch (error) {
console.error('[AUTO-LABELING] Failed to suggest labels:', error);
// Continue without auto-labeling on error
}
}
// Save note to DB immediately (fast!) — AI operations run in background after
const note = await prisma.note.create({
data: {
userId: session.user.id,
@@ -381,27 +361,83 @@ export async function createNote(data: {
color: data.color || 'default',
type: data.type || 'text',
checkItems: data.checkItems ? JSON.stringify(data.checkItems) : null,
labels: labelsToUse ? JSON.stringify(labelsToUse) : null,
labels: data.labels && data.labels.length > 0 ? JSON.stringify(data.labels) : null,
images: data.images ? JSON.stringify(data.images) : null,
links: data.links ? JSON.stringify(data.links) : null,
isArchived: data.isArchived || false,
reminder: data.reminder || null,
isMarkdown: data.isMarkdown || false,
size: data.size || 'small',
embedding: embeddingString,
embedding: null, // Generated in background
sharedWith: data.sharedWith && data.sharedWith.length > 0 ? JSON.stringify(data.sharedWith) : null,
autoGenerated: data.autoGenerated || null,
notebookId: data.notebookId || null, // Assign note to notebook if provided
notebookId: data.notebookId || null,
}
})
// Sync labels to ensure Label records exist
if (labelsToUse && labelsToUse.length > 0) {
await syncLabels(session.user.id, labelsToUse)
// Sync user-provided labels immediately
if (data.labels && data.labels.length > 0) {
await syncLabels(session.user.id, data.labels)
}
// Revalidate main page (handles both inbox and notebook views via query params)
revalidatePath('/')
// Fire-and-forget: run AI operations in background without blocking the response
const userId = session.user.id
const noteId = note.id
const content = data.content
const notebookId = data.notebookId
const hasUserLabels = data.labels && data.labels.length > 0
// Use setImmediate-like pattern to not block the response
;(async () => {
try {
// Background task 1: Generate embedding
const provider = getAIProvider(await getSystemConfig())
const embedding = await provider.getEmbeddings(content)
if (embedding) {
await prisma.note.update({
where: { id: noteId },
data: { embedding: JSON.stringify(embedding) }
})
}
} catch (e) {
console.error('[BG] Embedding generation failed:', e)
}
// Background task 2: Auto-labeling (only if no user labels and has notebook)
if (!hasUserLabels && notebookId) {
try {
const autoLabelingEnabled = await getConfigBoolean('AUTO_LABELING_ENABLED', true)
const autoLabelingConfidence = await getConfigNumber('AUTO_LABELING_CONFIDENCE_THRESHOLD', 70)
if (autoLabelingEnabled) {
const suggestions = await contextualAutoTagService.suggestLabels(
content,
notebookId,
userId
)
const appliedLabels = suggestions
.filter(s => s.confidence >= autoLabelingConfidence)
.map(s => s.label)
if (appliedLabels.length > 0) {
await prisma.note.update({
where: { id: noteId },
data: { labels: JSON.stringify(appliedLabels) }
})
await syncLabels(userId, appliedLabels)
revalidatePath('/')
}
}
} catch (error) {
console.error('[BG] Auto-labeling failed:', error)
}
}
})()
return parseNote(note)
} catch (error) {
console.error('Error creating note:', error)
@@ -433,23 +469,42 @@ export async function updateNote(id: string, data: {
try {
const oldNote = await prisma.note.findUnique({
where: { id, userId: session.user.id },
select: { labels: true, notebookId: true }
select: { labels: true, notebookId: true, reminder: true }
})
const oldLabels: string[] = oldNote?.labels ? JSON.parse(oldNote.labels) : []
const oldNotebookId = oldNote?.notebookId
const updateData: any = { ...data }
if (data.content !== undefined) {
try {
const provider = getAIProvider(await getSystemConfig());
const embedding = await provider.getEmbeddings(data.content);
updateData.embedding = embedding ? JSON.stringify(embedding) : null;
} catch (e) {
console.error('Embedding regeneration failed:', e);
// Reset isReminderDone only when reminder date actually changes (not on every save)
if ('reminder' in data && data.reminder !== null) {
const newTime = new Date(data.reminder as Date).getTime()
const oldTime = oldNote?.reminder ? new Date(oldNote.reminder).getTime() : null
if (newTime !== oldTime) {
updateData.isReminderDone = false
}
}
// Generate embedding in background — don't block the update
if (data.content !== undefined) {
const noteId = id
const content = data.content
;(async () => {
try {
const provider = getAIProvider(await getSystemConfig());
const embedding = await provider.getEmbeddings(content);
if (embedding) {
await prisma.note.update({
where: { id: noteId },
data: { embedding: JSON.stringify(embedding) }
})
}
} catch (e) {
console.error('[BG] Embedding regeneration failed:', e);
}
})()
}
if ('checkItems' in data) updateData.checkItems = data.checkItems ? JSON.stringify(data.checkItems) : null
if ('labels' in data) updateData.labels = data.labels ? JSON.stringify(data.labels) : null
if ('images' in data) updateData.images = data.images ? JSON.stringify(data.images) : null
@@ -766,13 +821,25 @@ export async function getAllNotes(includeArchived = false) {
}
})
// Filter out archived shared notes if needed
const sharedNotes = acceptedShares
.map(share => share.note)
.filter(note => includeArchived || !note.isArchived)
const allNotes = [...ownNotes.map(parseNote), ...sharedNotes.map(parseNote)]
// Derive pinned and recent notes
const pinned = allNotes.filter((note: Note) => note.isPinned)
const sevenDaysAgo = new Date()
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7)
sevenDaysAgo.setHours(0, 0, 0, 0)
const recent = allNotes
.filter((note: Note) => {
return !note.isArchived && !note.dismissedFromRecent && note.contentUpdatedAt >= sevenDaysAgo
})
.sort((a, b) => new Date(b.contentUpdatedAt).getTime() - new Date(a.createdAt).getTime())
.slice(0, 3)
return allNotes
} catch (error) {
console.error('Error fetching notes:', error)
@@ -808,6 +875,7 @@ export async function getPinnedNotes(notebookId?: string) {
}
}
// Get recent notes (notes modified in the last 7 days)
// Get recent notes (notes modified in the last 7 days)
export async function getRecentNotes(limit: number = 3) {
const session = await auth();
@@ -824,7 +892,8 @@ export async function getRecentNotes(limit: number = 3) {
where: {
userId: userId,
contentUpdatedAt: { gte: sevenDaysAgo },
isArchived: false
isArchived: false,
dismissedFromRecent: false // Filter out dismissed notes
},
orderBy: { contentUpdatedAt: 'desc' },
take: limit
@@ -837,6 +906,25 @@ export async function getRecentNotes(limit: number = 3) {
}
}
// Dismiss a note from Recent section
export async function dismissFromRecent(id: string) {
const session = await auth();
if (!session?.user?.id) throw new Error('Unauthorized');
try {
await prisma.note.update({
where: { id, userId: session.user.id },
data: { dismissedFromRecent: true }
})
// revalidatePath('/') // Removed to prevent immediate refill of the list
return { success: true }
} catch (error) {
console.error('Error dismissing note from recent:', error)
throw new Error('Failed to dismiss note')
}
}
export async function getNoteById(noteId: string) {
const session = await auth();
if (!session?.user?.id) return null;