refactor(ux): consolidate BMAD skills, update design system, and clean up Prisma generated client

This commit is contained in:
Sepehr Ramezani
2026-04-19 19:21:27 +02:00
parent 5296c4da2c
commit 25529a24b8
2476 changed files with 127934 additions and 101962 deletions

View File

@@ -13,17 +13,25 @@ async function checkAdmin() {
return session
}
export async function testSMTP() {
export async function testEmail(provider: 'resend' | 'smtp' = 'smtp') {
const session = await checkAdmin()
const email = session.user?.email
if (!email) throw new Error("No admin email found")
const subject = provider === 'resend'
? "Memento Resend Test"
: "Memento SMTP Test"
const html = provider === 'resend'
? "<p>This is a test email from your Memento instance. <strong>Resend is working!</strong></p>"
: "<p>This is a test email from your Memento instance. <strong>SMTP is working!</strong></p>"
const result = await sendEmail({
to: email,
subject: "Memento SMTP Test",
html: "<p>This is a test email from your Memento instance. <strong>SMTP is working!</strong></p>"
})
subject,
html,
}, provider)
return result
}

View File

@@ -8,7 +8,6 @@
import { auth } from '@/auth'
import { prisma } from '@/lib/prisma'
import { revalidatePath } from 'next/cache'
import { executeAgent } from '@/lib/ai/services/agent-executor.service'
// --- CRUD ---
@@ -21,6 +20,10 @@ export async function createAgent(data: {
sourceNotebookId?: string
targetNotebookId?: string
frequency?: string
tools?: string[]
maxSteps?: number
notifyEmail?: boolean
includeImages?: boolean
}) {
const session = await auth()
if (!session?.user?.id) {
@@ -38,6 +41,10 @@ export async function createAgent(data: {
sourceNotebookId: data.sourceNotebookId || null,
targetNotebookId: data.targetNotebookId || null,
frequency: data.frequency || 'manual',
tools: data.tools ? JSON.stringify(data.tools) : '[]',
maxSteps: data.maxSteps || 10,
notifyEmail: data.notifyEmail || false,
includeImages: data.includeImages || false,
userId: session.user.id,
}
})
@@ -60,6 +67,10 @@ export async function updateAgent(id: string, data: {
targetNotebookId?: string | null
frequency?: string
isEnabled?: boolean
tools?: string[]
maxSteps?: number
notifyEmail?: boolean
includeImages?: boolean
}) {
const session = await auth()
if (!session?.user?.id) {
@@ -82,6 +93,10 @@ export async function updateAgent(id: string, data: {
if (data.targetNotebookId !== undefined) updateData.targetNotebookId = data.targetNotebookId
if (data.frequency !== undefined) updateData.frequency = data.frequency
if (data.isEnabled !== undefined) updateData.isEnabled = data.isEnabled
if (data.tools !== undefined) updateData.tools = JSON.stringify(data.tools)
if (data.maxSteps !== undefined) updateData.maxSteps = data.maxSteps
if (data.notifyEmail !== undefined) updateData.notifyEmail = data.notifyEmail
if (data.includeImages !== undefined) updateData.includeImages = data.includeImages
const agent = await prisma.agent.update({
where: { id },
@@ -155,6 +170,7 @@ export async function runAgent(id: string) {
}
try {
const { executeAgent } = await import('@/lib/ai/services/agent-executor.service')
const result = await executeAgent(id, session.user.id)
revalidatePath('/agents')
revalidatePath('/')
@@ -182,6 +198,16 @@ export async function getAgentActions(agentId: string) {
where: { agentId },
orderBy: { createdAt: 'desc' },
take: 20,
select: {
id: true,
status: true,
result: true,
log: true,
input: true,
toolLog: true,
tokensUsed: true,
createdAt: true,
}
})
return actions
} catch (error) {

View File

@@ -2,6 +2,7 @@
import prisma from '@/lib/prisma'
import { sendEmail } from '@/lib/mail'
import { getSystemConfig } from '@/lib/config'
import bcrypt from 'bcryptjs'
import { getEmailTemplate } from '@/lib/email-template'
@@ -42,11 +43,14 @@ export async function forgotPassword(email: string) {
"Reset Password"
);
const sysConfig = await getSystemConfig()
const emailProvider = (sysConfig.EMAIL_PROVIDER || 'auto') as 'resend' | 'smtp' | 'auto'
await sendEmail({
to: user.email,
subject: "Reset your Memento password",
html
});
}, emailProvider);
return { success: true };
} catch (error) {

View File

@@ -0,0 +1,111 @@
'use server'
import { auth } from '@/auth'
import { prisma } from '@/lib/prisma'
import { revalidatePath } from 'next/cache'
export async function saveCanvas(id: string | null, name: string, data: string) {
const session = await auth()
if (!session?.user?.id) throw new Error('Unauthorized')
if (id) {
const canvas = await prisma.canvas.update({
where: { id, userId: session.user.id },
data: { name, data }
})
revalidatePath('/lab')
return { success: true, canvas }
} else {
const canvas = await prisma.canvas.create({
data: {
name,
data,
userId: session.user.id
}
})
revalidatePath('/lab')
return { success: true, canvas }
}
}
export async function getCanvases() {
const session = await auth()
if (!session?.user?.id) return []
return prisma.canvas.findMany({
where: { userId: session.user.id },
orderBy: { createdAt: 'asc' }
})
}
export async function getCanvasDetails(id: string) {
const session = await auth()
if (!session?.user?.id) return null
return prisma.canvas.findUnique({
where: { id, userId: session.user.id }
})
}
export async function deleteCanvas(id: string) {
const session = await auth()
if (!session?.user?.id) throw new Error('Unauthorized')
await prisma.canvas.delete({
where: { id, userId: session.user.id }
})
revalidatePath('/lab')
return { success: true }
}
export async function renameCanvas(id: string, name: string) {
const session = await auth()
if (!session?.user?.id) throw new Error('Unauthorized')
await prisma.canvas.update({
where: { id, userId: session.user.id },
data: { name }
})
revalidatePath('/lab')
return { success: true }
}
export async function createCanvas(lang?: string) {
const session = await auth()
if (!session?.user?.id) throw new Error('Unauthorized')
const count = await prisma.canvas.count({
where: { userId: session.user.id }
})
const defaultNames: Record<string, string> = {
en: `Space ${count + 1}`,
fr: `Espace ${count + 1}`,
fa: `فضای ${count + 1}`,
es: `Espacio ${count + 1}`,
de: `Bereich ${count + 1}`,
it: `Spazio ${count + 1}`,
pt: `Espaço ${count + 1}`,
ru: `Пространство ${count + 1}`,
ja: `スペース ${count + 1}`,
ko: `공간 ${count + 1}`,
zh: `空间 ${count + 1}`,
ar: `مساحة ${count + 1}`,
hi: `स्थान ${count + 1}`,
nl: `Ruimte ${count + 1}`,
pl: `Przestrzeń ${count + 1}`,
}
const newCanvas = await prisma.canvas.create({
data: {
name: defaultNames[lang || 'en'] || defaultNames.en,
data: JSON.stringify({}),
userId: session.user.id
}
})
revalidatePath('/lab')
return newCanvas
}

View File

@@ -0,0 +1,74 @@
'use server'
import { chatService } from '@/lib/ai/services'
import { auth } from '@/auth'
import { revalidatePath } from 'next/cache'
import { prisma } from '@/lib/prisma'
/**
* Create a new empty conversation and return its id.
* Called before streaming so the client knows the conversationId upfront.
*/
export async function createConversation(title: string, notebookId?: string) {
const session = await auth()
if (!session?.user?.id) throw new Error('Unauthorized')
const conversation = await prisma.conversation.create({
data: {
userId: session.user.id,
notebookId: notebookId || null,
title: title.substring(0, 80) + (title.length > 80 ? '...' : ''),
},
})
revalidatePath('/chat')
return { id: conversation.id, title: conversation.title }
}
/**
* @deprecated Use the streaming API route /api/chat instead.
* Kept for backward compatibility with the debug route.
*/
export async function sendChatMessage(
message: string,
conversationId?: string,
notebookId?: string
) {
const session = await auth()
if (!session?.user?.id) throw new Error('Unauthorized')
try {
const result = await chatService.chat(message, conversationId, notebookId)
revalidatePath('/chat')
return { success: true, ...result }
} catch (error: any) {
console.error('[ChatAction] Error:', error)
return { success: false, error: error.message }
}
}
export async function getConversations() {
const session = await auth()
if (!session?.user?.id) return []
return chatService.listConversations(session.user.id)
}
export async function getConversationDetails(id: string) {
const session = await auth()
if (!session?.user?.id) return null
return chatService.getHistory(id)
}
export async function deleteConversation(id: string) {
const session = await auth()
if (!session?.user?.id) throw new Error('Unauthorized')
await prisma.conversation.delete({
where: { id, userId: session.user.id }
})
revalidatePath('/chat')
return { success: true }
}

View File

@@ -8,6 +8,7 @@ import { getAIProvider } from '@/lib/ai/factory'
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'
import { cleanupNoteImages, parseImageUrls, deleteImageFileSafely } from '@/lib/image-cleanup'
/**
* Champs sélectionnés pour les listes de notes (sans embedding pour économiser ~6KB/note).
@@ -20,6 +21,7 @@ const NOTE_LIST_SELECT = {
color: true,
isPinned: true,
isArchived: true,
trashedAt: true,
type: true,
dismissedFromRecent: true,
checkItems: true,
@@ -219,6 +221,7 @@ export async function getNotes(includeArchived = false) {
const notes = await prisma.note.findMany({
where: {
userId: session.user.id,
trashedAt: null,
...(includeArchived ? {} : { isArchived: false }),
},
select: NOTE_LIST_SELECT,
@@ -245,6 +248,7 @@ export async function getNotesWithReminders() {
const notes = await prisma.note.findMany({
where: {
userId: session.user.id,
trashedAt: null,
isArchived: false,
reminder: { not: null }
},
@@ -286,7 +290,8 @@ export async function getArchivedNotes() {
const notes = await prisma.note.findMany({
where: {
userId: session.user.id,
isArchived: true
isArchived: true,
trashedAt: null
},
select: NOTE_LIST_SELECT,
orderBy: { updatedAt: 'desc' }
@@ -321,6 +326,7 @@ export async function searchNotes(query: string, useSemantic: boolean = false, n
where: {
userId: session.user.id,
isArchived: false,
trashedAt: null,
OR: [
{ title: { contains: query } },
{ content: { contains: query } },
@@ -349,6 +355,7 @@ async function semanticSearch(query: string, userId: string, notebookId?: string
where: {
userId: userId,
isArchived: false,
trashedAt: null,
...(notebookId !== undefined ? { notebookId } : {})
},
include: { noteEmbedding: true }
@@ -650,17 +657,16 @@ export async function updateNote(id: string, data: {
}
}
// Delete a note
// Soft-delete a note (move to trash)
export async function deleteNote(id: string) {
const session = await auth();
if (!session?.user?.id) throw new Error('Unauthorized');
try {
await prisma.note.delete({ where: { id, userId: session.user.id } })
// Sync labels with empty array to trigger cleanup of any orphans
// The syncLabels function will scan all remaining notes and clean up unused labels
await syncLabels(session.user.id, [])
await prisma.note.update({
where: { id, userId: session.user.id },
data: { trashedAt: new Date() }
})
revalidatePath('/')
return { success: true }
@@ -670,6 +676,192 @@ export async function deleteNote(id: string) {
}
}
// Trash actions
export async function trashNote(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: { trashedAt: new Date() }
})
revalidatePath('/')
return { success: true }
} catch (error) {
console.error('Error trashing note:', error)
throw new Error('Failed to trash note')
}
}
export async function restoreNote(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: { trashedAt: null }
})
revalidatePath('/')
revalidatePath('/trash')
return { success: true }
} catch (error) {
console.error('Error restoring note:', error)
throw new Error('Failed to restore note')
}
}
export async function getTrashedNotes() {
const session = await auth();
if (!session?.user?.id) return [];
try {
const notes = await prisma.note.findMany({
where: {
userId: session.user.id,
trashedAt: { not: null }
},
select: NOTE_LIST_SELECT,
orderBy: { trashedAt: 'desc' }
})
return notes.map(parseNote)
} catch (error) {
console.error('Error fetching trashed notes:', error)
return []
}
}
export async function permanentDeleteNote(id: string) {
const session = await auth();
if (!session?.user?.id) throw new Error('Unauthorized');
try {
// Fetch images before deleting so we can clean up files
const note = await prisma.note.findUnique({
where: { id, userId: session.user.id },
select: { images: true }
})
const imageUrls = parseImageUrls(note?.images ?? null)
await prisma.note.delete({ where: { id, userId: session.user.id } })
// Clean up orphaned image files (safe: skips if referenced by other notes)
if (imageUrls.length > 0) {
await cleanupNoteImages(id, imageUrls)
}
await syncLabels(session.user.id, [])
revalidatePath('/trash')
revalidatePath('/')
return { success: true }
} catch (error) {
console.error('Error permanently deleting note:', error)
throw new Error('Failed to permanently delete note')
}
}
export async function emptyTrash() {
const session = await auth();
if (!session?.user?.id) throw new Error('Unauthorized');
try {
// Fetch trashed notes with images before deleting
const trashedNotes = await prisma.note.findMany({
where: {
userId: session.user.id,
trashedAt: { not: null }
},
select: { id: true, images: true }
})
await prisma.note.deleteMany({
where: {
userId: session.user.id,
trashedAt: { not: null }
}
})
// Clean up image files for all deleted notes
for (const note of trashedNotes) {
const imageUrls = parseImageUrls(note.images)
if (imageUrls.length > 0) {
await cleanupNoteImages(note.id, imageUrls)
}
}
await syncLabels(session.user.id, [])
revalidatePath('/trash')
revalidatePath('/')
return { success: true }
} catch (error) {
console.error('Error emptying trash:', error)
throw new Error('Failed to empty trash')
}
}
export async function removeImageFromNote(noteId: string, imageIndex: number) {
const session = await auth()
if (!session?.user?.id) throw new Error('Unauthorized')
try {
const note = await prisma.note.findUnique({
where: { id: noteId, userId: session.user.id },
select: { images: true },
})
if (!note) throw new Error('Note not found')
const imageUrls = parseImageUrls(note.images)
if (imageIndex < 0 || imageIndex >= imageUrls.length) throw new Error('Invalid image index')
const removedUrl = imageUrls[imageIndex]
const newImages = imageUrls.filter((_, i) => i !== imageIndex)
await prisma.note.update({
where: { id: noteId },
data: { images: newImages.length > 0 ? JSON.stringify(newImages) : null },
})
// Clean up file if no other note references it
await deleteImageFileSafely(removedUrl, noteId)
return { success: true }
} catch (error) {
console.error('Error removing image:', error)
throw new Error('Failed to remove image')
}
}
export async function cleanupOrphanedImages(imageUrls: string[], noteId: string) {
const session = await auth()
if (!session?.user?.id) return
try {
for (const url of imageUrls) {
await deleteImageFileSafely(url, noteId)
}
} catch {
// Silent — best-effort cleanup
}
}
export async function getTrashCount() {
const session = await auth();
if (!session?.user?.id) return 0;
try {
return await prisma.note.count({
where: {
userId: session.user.id,
trashedAt: { not: null }
}
})
} catch {
return 0
}
}
// Toggle functions
export async function togglePin(id: string, isPinned: boolean) { return updateNote(id, { isPinned }) }
export async function toggleArchive(id: string, isArchived: boolean) { return updateNote(id, { isArchived }) }
@@ -710,7 +902,7 @@ export async function reorderNotes(draggedId: string, targetId: string) {
const targetNote = await prisma.note.findUnique({ where: { id: targetId, userId: session.user.id } })
if (!draggedNote || !targetNote) throw new Error('Notes not found')
const allNotes = await prisma.note.findMany({
where: { userId: session.user.id, isPinned: draggedNote.isPinned, isArchived: false },
where: { userId: session.user.id, isPinned: draggedNote.isPinned, isArchived: false, trashedAt: null },
orderBy: { order: 'asc' }
})
const reorderedNotes = allNotes.filter((n: any) => n.id !== draggedId)
@@ -865,11 +1057,12 @@ export async function syncAllEmbeddings() {
const userId = session.user.id;
let updatedCount = 0;
try {
const notesToSync = await prisma.note.findMany({
where: {
userId,
const notesToSync = await prisma.note.findMany({
where: {
userId,
trashedAt: null,
noteEmbedding: { is: null }
}
}
})
const provider = getAIProvider(await getSystemConfig());
for (const note of notesToSync) {
@@ -905,6 +1098,7 @@ export async function getAllNotes(includeArchived = false) {
prisma.note.findMany({
where: {
userId,
trashedAt: null,
...(includeArchived ? {} : { isArchived: false }),
},
select: NOTE_LIST_SELECT,
@@ -923,6 +1117,7 @@ export async function getAllNotes(includeArchived = false) {
const sharedNotes = acceptedShares
.map(share => share.note)
.filter(note => includeArchived || !note.isArchived)
.map(note => ({ ...note, _isShared: true }))
return [...ownNotes.map(parseNote), ...sharedNotes.map(parseNote)]
} catch (error) {
@@ -944,6 +1139,7 @@ export async function getPinnedNotes(notebookId?: string) {
userId: userId,
isPinned: true,
isArchived: false,
trashedAt: null,
...(notebookId !== undefined ? { notebookId } : {})
},
orderBy: [
@@ -977,6 +1173,7 @@ export async function getRecentNotes(limit: number = 3) {
userId: userId,
contentUpdatedAt: { gte: sevenDaysAgo },
isArchived: false,
trashedAt: null,
dismissedFromRecent: false // Filter out dismissed notes
},
orderBy: { contentUpdatedAt: 'desc' },
@@ -1118,8 +1315,20 @@ export async function getNoteCollaborators(noteId: string) {
throw new Error('Note not found')
}
// Owner can always see collaborators
// Shared users can also see collaborators if they have accepted access
if (note.userId !== session.user.id) {
throw new Error('You do not have access to this note')
const share = await prisma.noteShare.findUnique({
where: {
noteId_userId: {
noteId,
userId: session.user.id
}
}
})
if (!share || share.status !== 'accepted') {
throw new Error('You do not have access to this note')
}
}
// Get all users who have been shared this note (any status)

View File

@@ -6,6 +6,7 @@ import { revalidatePath, updateTag } from 'next/cache'
export type UserSettingsData = {
theme?: 'light' | 'dark' | 'auto' | 'sepia' | 'midnight' | 'blue'
cardSizeMode?: 'variable' | 'uniform'
}
/**
@@ -48,11 +49,12 @@ const getCachedUserSettings = unstable_cache(
try {
const user = await prisma.user.findUnique({
where: { id: userId },
select: { theme: true }
select: { theme: true, cardSizeMode: true }
})
return {
theme: (user?.theme || 'light') as 'light' | 'dark' | 'auto' | 'sepia' | 'midnight' | 'blue'
theme: (user?.theme || 'light') as 'light' | 'dark' | 'auto' | 'sepia' | 'midnight' | 'blue',
cardSizeMode: (user?.cardSizeMode || 'variable') as 'variable' | 'uniform'
}
} catch (error) {
console.error('Error getting user settings:', error)
@@ -75,7 +77,8 @@ export async function getUserSettings(userId?: string) {
if (!id) {
return {
theme: 'light' as const
theme: 'light' as const,
cardSizeMode: 'variable' as const
}
}