chore: clean up repo for public release

- Remove BMAD framework, IDE configs, dev screenshots, test files,
  internal docs, and backup files
- Rename keep-notes/ to memento-note/
- Update all references from keep-notes to memento-note
- Add Apache 2.0 license with Commons Clause (non-commercial restriction)
- Add clean .gitignore and .env.docker.example
This commit is contained in:
Sepehr Ramezani
2026-04-20 22:48:06 +02:00
parent 402e88b788
commit e4d4e23dc7
3981 changed files with 407 additions and 530622 deletions

View File

@@ -0,0 +1,73 @@
'use server'
import prisma from '@/lib/prisma'
import { auth } from '@/auth'
import { sendEmail } from '@/lib/mail'
import { revalidateTag } from 'next/cache'
async function checkAdmin() {
const session = await auth()
if (!session?.user?.id || (session.user as any).role !== 'ADMIN') {
throw new Error('Unauthorized: Admin access required')
}
return session
}
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,
html,
}, provider)
return result
}
export async function getSystemConfig() {
await checkAdmin()
// Reuse the cached version from lib/config
const { getSystemConfig: getCachedConfig } = await import('@/lib/config')
return getCachedConfig()
}
export async function updateSystemConfig(data: Record<string, string>) {
await checkAdmin()
try {
// Filter out empty values but keep 'false' as valid
const filteredData = Object.fromEntries(
Object.entries(data).filter(([key, value]) => value !== '' && value !== null && value !== undefined)
)
const operations = Object.entries(filteredData).map(([key, value]) =>
prisma.systemConfig.upsert({
where: { key },
update: { value },
create: { key, value }
})
)
await prisma.$transaction(operations)
// Invalidate cache after update
revalidateTag('system-config', '/settings')
return { success: true }
} catch (error) {
console.error('Failed to update settings:', error)
return { error: 'Failed to update settings' }
}
}

View File

@@ -0,0 +1,120 @@
'use server'
import { revalidatePath } from 'next/cache'
import prisma from '@/lib/prisma'
import { auth } from '@/auth'
import bcrypt from 'bcryptjs'
import { z } from 'zod'
// Schema pour la création d'utilisateur
const CreateUserSchema = z.object({
name: z.string().min(2, "Name must be at least 2 characters"),
email: z.string().email("Invalid email address"),
password: z.string().min(6, "Password must be at least 6 characters"),
role: z.enum(["USER", "ADMIN"]).default("USER"),
})
async function checkAdmin() {
const session = await auth()
if (!session?.user?.id || (session.user as any).role !== 'ADMIN') {
throw new Error('Unauthorized: Admin access required')
}
return session
}
export async function getUsers() {
await checkAdmin()
try {
const users = await prisma.user.findMany({
orderBy: { createdAt: 'desc' },
select: {
id: true,
name: true,
email: true,
role: true,
createdAt: true,
}
})
return users
} catch (error) {
console.error('Failed to fetch users:', error)
throw new Error('Failed to fetch users')
}
}
export async function createUser(formData: FormData) {
await checkAdmin()
const rawData = {
name: formData.get('name'),
email: formData.get('email'),
password: formData.get('password'),
role: formData.get('role'),
}
const validatedFields = CreateUserSchema.safeParse(rawData)
if (!validatedFields.success) {
return {
error: validatedFields.error.flatten().fieldErrors,
}
}
const { name, email, password, role } = validatedFields.data
const hashedPassword = await bcrypt.hash(password, 10)
try {
await prisma.user.create({
data: {
name,
email: email.toLowerCase(),
password: hashedPassword,
role,
},
})
revalidatePath('/admin')
return { success: true }
} catch (error: any) {
if (error.code === 'P2002') {
return { error: { email: ['Email already exists'] } }
}
return { error: { _form: ['Failed to create user'] } }
}
}
export async function deleteUser(userId: string) {
const session = await checkAdmin()
if (session.user?.id === userId) {
throw new Error("You cannot delete your own account")
}
try {
await prisma.user.delete({
where: { id: userId },
})
revalidatePath('/admin')
return { success: true }
} catch (error) {
throw new Error('Failed to delete user')
}
}
export async function updateUserRole(userId: string, newRole: string) {
const session = await checkAdmin()
if (session.user?.id === userId) {
throw new Error("You cannot change your own role")
}
try {
await prisma.user.update({
where: { id: userId },
data: { role: newRole },
})
revalidatePath('/admin')
return { success: true }
} catch (error) {
throw new Error('Failed to update role')
}
}

View File

@@ -0,0 +1,235 @@
'use server'
/**
* Agent Server Actions
* CRUD operations for agents and execution triggers.
*/
import { auth } from '@/auth'
import { prisma } from '@/lib/prisma'
import { revalidatePath } from 'next/cache'
// --- CRUD ---
export async function createAgent(data: {
name: string
description?: string
type: string
role: string
sourceUrls?: string[]
sourceNotebookId?: string
targetNotebookId?: string
frequency?: string
tools?: string[]
maxSteps?: number
notifyEmail?: boolean
includeImages?: boolean
}) {
const session = await auth()
if (!session?.user?.id) {
throw new Error('Non autorise')
}
try {
const agent = await prisma.agent.create({
data: {
name: data.name,
description: data.description,
type: data.type,
role: data.role,
sourceUrls: data.sourceUrls ? JSON.stringify(data.sourceUrls) : null,
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,
}
})
revalidatePath('/agents')
return { success: true, agent }
} catch (error) {
console.error('Error creating agent:', error)
throw new Error('Impossible de creer l\'agent')
}
}
export async function updateAgent(id: string, data: {
name?: string
description?: string
type?: string
role?: string
sourceUrls?: string[]
sourceNotebookId?: string | null
targetNotebookId?: string | null
frequency?: string
isEnabled?: boolean
tools?: string[]
maxSteps?: number
notifyEmail?: boolean
includeImages?: boolean
}) {
const session = await auth()
if (!session?.user?.id) {
throw new Error('Non autorise')
}
try {
const existing = await prisma.agent.findUnique({ where: { id } })
if (!existing || existing.userId !== session.user.id) {
throw new Error('Agent non trouve')
}
const updateData: Record<string, unknown> = {}
if (data.name !== undefined) updateData.name = data.name
if (data.description !== undefined) updateData.description = data.description
if (data.type !== undefined) updateData.type = data.type
if (data.role !== undefined) updateData.role = data.role
if (data.sourceUrls !== undefined) updateData.sourceUrls = JSON.stringify(data.sourceUrls)
if (data.sourceNotebookId !== undefined) updateData.sourceNotebookId = data.sourceNotebookId
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 },
data: updateData
})
revalidatePath('/agents')
return { success: true, agent }
} catch (error) {
console.error('Error updating agent:', error)
throw new Error('Impossible de mettre a jour l\'agent')
}
}
export async function deleteAgent(id: string) {
const session = await auth()
if (!session?.user?.id) {
throw new Error('Non autorise')
}
try {
const existing = await prisma.agent.findUnique({ where: { id } })
if (!existing || existing.userId !== session.user.id) {
throw new Error('Agent non trouve')
}
await prisma.agent.delete({ where: { id } })
revalidatePath('/agents')
return { success: true }
} catch (error) {
console.error('Error deleting agent:', error)
throw new Error('Impossible de supprimer l\'agent')
}
}
export async function getAgents() {
const session = await auth()
if (!session?.user?.id) {
throw new Error('Non autorise')
}
try {
const agents = await prisma.agent.findMany({
where: { userId: session.user.id },
include: {
_count: { select: { actions: true } },
actions: {
orderBy: { createdAt: 'desc' },
take: 1,
},
notebook: {
select: { id: true, name: true, icon: true }
}
},
orderBy: { createdAt: 'desc' }
})
return agents
} catch (error) {
console.error('Error fetching agents:', error)
throw new Error('Impossible de charger les agents')
}
}
// --- Execution ---
export async function runAgent(id: string) {
const session = await auth()
if (!session?.user?.id) {
throw new Error('Non autorise')
}
try {
const { executeAgent } = await import('@/lib/ai/services/agent-executor.service')
const result = await executeAgent(id, session.user.id)
revalidatePath('/agents')
revalidatePath('/')
return result
} catch (error) {
console.error('Error running agent:', error)
return {
success: false,
actionId: '',
error: error instanceof Error ? error.message : 'Erreur inconnue'
}
}
}
// --- History ---
export async function getAgentActions(agentId: string) {
const session = await auth()
if (!session?.user?.id) {
throw new Error('Non autorise')
}
try {
const actions = await prisma.agentAction.findMany({
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) {
console.error('Error fetching agent actions:', error)
throw new Error('Impossible de charger l\'historique')
}
}
export async function toggleAgent(id: string, isEnabled: boolean) {
const session = await auth()
if (!session?.user?.id) {
throw new Error('Non autorise')
}
try {
const agent = await prisma.agent.update({
where: { id },
data: { isEnabled }
})
return { success: true, agent }
} catch (error) {
console.error('Error toggling agent:', error)
throw new Error('Impossible de modifier l\'agent')
}
}

View File

@@ -0,0 +1,382 @@
'use server'
/**
* AI Server Actions Stub File
*
* This file provides a centralized location for all AI-related server action interfaces
* and serves as documentation for the AI server action architecture.
*
* IMPLEMENTATION STATUS:
* - Title Suggestions: ✅ Implemented (see app/actions/title-suggestions.ts)
* - Semantic Search: ✅ Implemented (see app/actions/semantic-search.ts)
* - Paragraph Reformulation: ✅ Implemented (see app/actions/paragraph-refactor.ts)
* - Memory Echo: ⏳ STUB - To be implemented in Epic 5 (Story 5-1)
* - Language Detection: ✅ Implemented (see app/actions/detect-language.ts)
* - AI Settings: ✅ Implemented (see app/actions/ai-settings.ts)
*
* NOTE: This file defines TypeScript interfaces and placeholder functions.
* Actual implementations are in separate action files (see references above).
*/
import { auth } from '@/auth'
import { prisma } from '@/lib/prisma'
import { revalidatePath } from 'next/cache'
// ============================================================================
// TYPESCRIPT INTERFACES
// ============================================================================
/**
* Title Suggestions Interfaces
* @see app/actions/title-suggestions.ts for implementation
*/
export interface GenerateTitlesRequest {
noteId: string
}
export interface GenerateTitlesResponse {
suggestions: Array<{
title: string
confidence: number
reasoning?: string
}>
noteId: string
}
/**
* Semantic Search Interfaces
* @see app/actions/semantic-search.ts for implementation
*/
export interface SearchResult {
noteId: string
title: string | null
content: string
similarity: number
matchType: 'exact' | 'related'
}
export interface SemanticSearchRequest {
query: string
options?: {
limit?: number
threshold?: number
notebookId?: string
}
}
export interface SemanticSearchResponse {
results: SearchResult[]
query: string
totalResults: number
}
/**
* Paragraph Reformulation Interfaces
* @see app/actions/paragraph-refactor.ts for implementation
*/
export type RefactorMode = 'clarify' | 'shorten' | 'improve'
export interface RefactorParagraphRequest {
noteId: string
selectedText: string
option: RefactorMode
}
export interface RefactorParagraphResponse {
originalText: string
refactoredText: string
}
/**
* Memory Echo Interfaces
* STUB - To be implemented in Epic 5 (Story 5-1)
*
* This feature will analyze all user notes with embeddings to find
* connections with cosine similarity > 0.75 and provide proactive insights.
*/
export interface GenerateMemoryEchoRequest {
// No params - uses current user session
}
export interface MemoryEchoInsight {
note1Id: string
note2Id: string
similarityScore: number
}
export interface GenerateMemoryEchoResponse {
success: boolean
insight: MemoryEchoInsight | null
}
/**
* Language Detection Interfaces
* @see app/actions/detect-language.ts for implementation
*/
export interface DetectLanguageRequest {
content: string
}
export interface DetectLanguageResponse {
language: string
confidence: number
method: 'tinyld' | 'ai'
}
/**
* AI Settings Interfaces
* @see app/actions/ai-settings.ts for implementation
*/
export interface AISettingsConfig {
titleSuggestions?: boolean
semanticSearch?: boolean
paragraphRefactor?: boolean
memoryEcho?: boolean
memoryEchoFrequency?: 'daily' | 'weekly' | 'custom'
aiProvider?: 'auto' | 'openai' | 'ollama'
preferredLanguage?: 'auto' | 'en' | 'fr' | 'es' | 'de' | 'fa' | 'it' | 'pt' | 'ru' | 'zh' | 'ja' | 'ko' | 'ar' | 'hi' | 'nl' | 'pl'
demoMode?: boolean
}
export interface UpdateAISettingsRequest {
settings: Partial<AISettingsConfig>
}
export interface UpdateAISettingsResponse {
success: boolean
}
// ============================================================================
// PLACEHOLDER FUNCTIONS
// ============================================================================
/**
* Generate Title Suggestions
*
* ALREADY IMPLEMENTED: See app/actions/title-suggestions.ts
*
* This function generates 3 AI-powered title suggestions for a note when it
* reaches 50+ words without a title.
*
* @see generateTitleSuggestions in app/actions/title-suggestions.ts
*/
export async function generateTitles(
request: GenerateTitlesRequest
): Promise<GenerateTitlesResponse> {
// TODO: Import and use implementation from title-suggestions.ts
// import { generateTitleSuggestions } from './title-suggestions'
// return generateTitleSuggestions(request.noteId)
throw new Error('Not implemented in stub: Use app/actions/title-suggestions.ts')
}
/**
* Semantic Search
*
* ALREADY IMPLEMENTED: See app/actions/semantic-search.ts
*
* This function performs hybrid semantic + keyword search across user notes.
*
* @see semanticSearch in app/actions/semantic-search.ts
*/
export async function semanticSearch(
request: SemanticSearchRequest
): Promise<SemanticSearchResponse> {
// TODO: Import and use implementation from semantic-search.ts
// import { semanticSearch } from './semantic-search'
// return semanticSearch(request.query, request.options)
throw new Error('Not implemented in stub: Use app/actions/semantic-search.ts')
}
/**
* Refactor Paragraph
*
* ALREADY IMPLEMENTED: See app/actions/paragraph-refactor.ts
*
* This function refactors a paragraph using AI with specific mode (clarify/shorten/improve).
*
* @see refactorParagraph in app/actions/paragraph-refactor.ts
*/
export async function refactorParagraph(
request: RefactorParagraphRequest
): Promise<RefactorParagraphResponse> {
// TODO: Import and use implementation from paragraph-refactor.ts
// import { refactorParagraph } from './paragraph-refactor'
// return refactorParagraph(request.selectedText, request.option)
throw new Error('Not implemented in stub: Use app/actions/paragraph-refactor.ts')
}
/**
* Generate Memory Echo Insights
*
* STUB: To be implemented in Epic 5 (Story 5-1)
*
* This will analyze all user notes with embeddings to find
* connections with cosine similarity > 0.75.
*
* Implementation Plan:
* - Fetch all user notes with embeddings
* - Calculate pairwise cosine similarities
* - Find top connection with similarity > 0.75
* - Store in MemoryEchoInsight table
* - Return insight or null if none found
*
* @see Epic 5 Story 5-1 in planning/epics.md
*/
export async function generateMemoryEcho(): Promise<GenerateMemoryEchoResponse> {
const session = await auth()
if (!session?.user?.id) {
throw new Error('Unauthorized')
}
// TODO: Implement Memory Echo background processing
// - Fetch all user notes with embeddings from prisma.note
// - Calculate pairwise cosine similarities using embedding vectors
// - Filter for similarity > 0.75
// - Select top insight
// - Store in prisma.memoryEchoInsight table (if it exists)
// - Return { success: true, insight: {...} }
throw new Error('Not implemented: See Epic 5 Story 5-1')
}
/**
* Detect Language
*
* ALREADY IMPLEMENTED: See app/actions/detect-language.ts
*
* This function detects the language of user content.
*
* @see getInitialLanguage in app/actions/detect-language.ts
*/
export async function detectLanguage(
request: DetectLanguageRequest
): Promise<DetectLanguageResponse> {
// TODO: Import and use implementation from detect-language.ts
// import { detectUserLanguage } from '@/lib/i18n/detect-user-language'
// const language = await detectUserLanguage()
// return { language, confidence: 0.95, method: 'tinyld' }
throw new Error('Not implemented in stub: Use app/actions/detect-language.ts')
}
/**
* Update AI Settings
*
* ALREADY IMPLEMENTED: See app/actions/ai-settings.ts
*
* This function updates user AI preferences.
*
* @see updateAISettings in app/actions/ai-settings.ts
*/
export async function updateAISettings(
request: UpdateAISettingsRequest
): Promise<UpdateAISettingsResponse> {
const session = await auth()
if (!session?.user?.id) {
throw new Error('Unauthorized')
}
// TODO: Import and use implementation from ai-settings.ts
// import { updateAISettings } from './ai-settings'
// return updateAISettings(request.settings)
throw new Error('Not implemented in stub: Use app/actions/ai-settings.ts')
}
/**
* Get AI Settings
*
* ALREADY IMPLEMENTED: See app/actions/ai-settings.ts
*
* This function retrieves user AI preferences.
*
* @see getAISettings in app/actions/ai-settings.ts
*/
export async function getAISettings(): Promise<AISettingsConfig> {
const session = await auth()
if (!session?.user?.id) {
throw new Error('Unauthorized')
}
// TODO: Import and use implementation from ai-settings.ts
// import { getAISettings } from './ai-settings'
// return getAISettings()
throw new Error('Not implemented in stub: Use app/actions/ai-settings.ts')
}
// ============================================================================
// UTILITY FUNCTIONS
// ============================================================================
/**
* Check if a specific AI feature is enabled for the user
*
* UTILITY: Helper function to check feature flags
*
* @param feature - The AI feature to check
* @returns Promise<boolean> - Whether the feature is enabled
*/
export async function isAIFeatureEnabled(
feature: keyof AISettingsConfig
): Promise<boolean> {
const session = await auth()
if (!session?.user?.id) {
return false
}
try {
const settings = await prisma.userAISettings.findUnique({
where: { userId: session.user.id }
})
if (!settings) {
// Default to enabled for new users
return true
}
switch (feature) {
case 'titleSuggestions':
return settings.titleSuggestions ?? true
case 'semanticSearch':
return settings.semanticSearch ?? true
case 'paragraphRefactor':
return settings.paragraphRefactor ?? true
case 'memoryEcho':
return settings.memoryEcho ?? true
default:
return true
}
} catch (error) {
console.error('Error checking AI feature enabled:', error)
return true
}
}
/**
* Get user's preferred AI provider
*
* UTILITY: Helper function to get provider preference
*
* @returns Promise<'auto' | 'openai' | 'ollama'> - The AI provider
*/
export async function getUserAIPreference(): Promise<'auto' | 'openai' | 'ollama'> {
const session = await auth()
if (!session?.user?.id) {
return 'auto'
}
try {
const settings = await prisma.userAISettings.findUnique({
where: { userId: session.user.id }
})
return (settings?.aiProvider || 'auto') as 'auto' | 'openai' | 'ollama'
} catch (error) {
console.error('Error getting user AI preference:', error)
return 'auto'
}
}

View File

@@ -0,0 +1,260 @@
'use server'
import { auth } from '@/auth'
import { prisma } from '@/lib/prisma'
import { revalidatePath, updateTag } from 'next/cache'
export type UserAISettingsData = {
titleSuggestions?: boolean
semanticSearch?: boolean
paragraphRefactor?: boolean
memoryEcho?: boolean
memoryEchoFrequency?: 'daily' | 'weekly' | 'custom'
aiProvider?: 'auto' | 'openai' | 'ollama'
preferredLanguage?: 'auto' | 'en' | 'fr' | 'es' | 'de' | 'fa' | 'it' | 'pt' | 'ru' | 'zh' | 'ja' | 'ko' | 'ar' | 'hi' | 'nl' | 'pl'
demoMode?: boolean
showRecentNotes?: boolean
notesViewMode?: 'masonry' | 'tabs' | 'list'
emailNotifications?: boolean
desktopNotifications?: boolean
anonymousAnalytics?: boolean
fontSize?: 'small' | 'medium' | 'large'
}
/** Only fields that exist on `UserAISettings` in Prisma (excludes e.g. `theme`, which lives on `User`). */
const USER_AI_SETTINGS_PRISMA_KEYS = [
'titleSuggestions',
'semanticSearch',
'paragraphRefactor',
'memoryEcho',
'memoryEchoFrequency',
'aiProvider',
'preferredLanguage',
'fontSize',
'demoMode',
'showRecentNotes',
'notesViewMode',
'emailNotifications',
'desktopNotifications',
'anonymousAnalytics',
] as const
type UserAISettingsPrismaKey = (typeof USER_AI_SETTINGS_PRISMA_KEYS)[number]
function pickUserAISettingsForDb(input: UserAISettingsData): Partial<Record<UserAISettingsPrismaKey, unknown>> {
const out: Partial<Record<UserAISettingsPrismaKey, unknown>> = {}
for (const key of USER_AI_SETTINGS_PRISMA_KEYS) {
const v = input[key]
if (v !== undefined) {
out[key] = v
}
}
if (out.notesViewMode === 'list') {
out.notesViewMode = 'tabs'
}
if (
out.notesViewMode != null &&
out.notesViewMode !== 'masonry' &&
out.notesViewMode !== 'tabs'
) {
delete out.notesViewMode
}
return out
}
/**
* Update AI settings for the current user
*/
export async function updateAISettings(settings: UserAISettingsData) {
const session = await auth()
if (!session?.user?.id) {
console.error('[updateAISettings] Unauthorized: No session or user ID')
throw new Error('Unauthorized')
}
try {
const data = pickUserAISettingsForDb(settings)
if (Object.keys(data).length === 0) {
return { success: true }
}
// Valeurs scalaires uniquement (pickUserAISettingsForDb) — cast pour éviter UpdateOperations vs create.
const payload = data as Record<string, string | boolean | undefined>
// Upsert settings (create if not exists, update if exists)
await prisma.userAISettings.upsert({
where: { userId: session.user.id },
create: {
userId: session.user.id,
...payload,
},
update: payload,
})
revalidatePath('/settings/ai', 'page')
revalidatePath('/settings/appearance', 'page')
revalidatePath('/', 'layout')
updateTag('ai-settings')
return { success: true }
} catch (error) {
console.error('Error updating AI settings:', error)
const raw = error instanceof Error ? error.message : String(error)
const isSchema =
/no such column|notesViewMode|Unknown column|does not exist/i.test(raw) ||
(typeof raw === 'string' && raw.includes('UserAISettings') && raw.includes('column'))
if (isSchema) {
throw new Error(
'Schéma base de données obsolète : colonne notesViewMode manquante. Dans le dossier memento-note, exécutez : npx prisma db push (ou appliquez les migrations Prisma).'
)
}
throw new Error('Failed to update AI settings')
}
}
/**
* Get AI settings for the current user (Cached)
*/
import { unstable_cache } from 'next/cache'
// Internal cached function to fetch settings from DB
const getCachedAISettings = unstable_cache(
async (userId: string) => {
try {
const settings = await prisma.userAISettings.findUnique({
where: { userId }
})
if (!settings) {
return {
titleSuggestions: true,
semanticSearch: true,
paragraphRefactor: true,
memoryEcho: true,
memoryEchoFrequency: 'daily' as const,
aiProvider: 'auto' as const,
preferredLanguage: 'auto' as const,
demoMode: false,
showRecentNotes: false,
notesViewMode: 'masonry' as const,
emailNotifications: false,
desktopNotifications: false,
anonymousAnalytics: false,
theme: 'light' as const,
fontSize: 'medium' as const
}
}
const raw = settings.notesViewMode
const viewMode =
raw === 'masonry'
? ('masonry' as const)
: raw === 'list' || raw === 'tabs'
? ('tabs' as const)
: ('masonry' as const)
return {
titleSuggestions: settings.titleSuggestions,
semanticSearch: settings.semanticSearch,
paragraphRefactor: settings.paragraphRefactor,
memoryEcho: settings.memoryEcho,
memoryEchoFrequency: (settings.memoryEchoFrequency || 'daily') as 'daily' | 'weekly' | 'custom',
aiProvider: (settings.aiProvider || 'auto') as 'auto' | 'openai' | 'ollama',
preferredLanguage: (settings.preferredLanguage || 'auto') as 'auto' | 'en' | 'fr' | 'es' | 'de' | 'fa' | 'it' | 'pt' | 'ru' | 'zh' | 'ja' | 'ko' | 'ar' | 'hi' | 'nl' | 'pl',
demoMode: settings.demoMode,
showRecentNotes: settings.showRecentNotes,
notesViewMode: viewMode,
emailNotifications: settings.emailNotifications,
desktopNotifications: settings.desktopNotifications,
anonymousAnalytics: settings.anonymousAnalytics,
// theme: 'light' as const, // REMOVED: Should not be handled here or hardcoded
fontSize: (settings.fontSize || 'medium') as 'small' | 'medium' | 'large'
}
} catch (error) {
console.error('Error getting AI settings:', error)
// Return defaults on error
return {
titleSuggestions: true,
semanticSearch: true,
paragraphRefactor: true,
memoryEcho: true,
memoryEchoFrequency: 'daily' as const,
aiProvider: 'auto' as const,
preferredLanguage: 'auto' as const,
demoMode: false,
showRecentNotes: false,
notesViewMode: 'masonry' as const,
emailNotifications: false,
desktopNotifications: false,
anonymousAnalytics: false,
theme: 'light' as const,
fontSize: 'medium' as const
}
}
},
['user-ai-settings'],
{ tags: ['ai-settings'] }
)
export async function getAISettings(userId?: string) {
let id = userId
if (!id) {
const session = await auth()
id = session?.user?.id
}
// Return defaults for non-logged-in users
if (!id) {
return {
titleSuggestions: true,
semanticSearch: true,
paragraphRefactor: true,
memoryEcho: true,
memoryEchoFrequency: 'daily' as const,
aiProvider: 'auto' as const,
preferredLanguage: 'auto' as const,
demoMode: false,
showRecentNotes: false,
notesViewMode: 'masonry' as const,
emailNotifications: false,
desktopNotifications: false,
anonymousAnalytics: false,
theme: 'light' as const,
fontSize: 'medium' as const
}
}
return getCachedAISettings(id)
}
/**
* Get user's preferred AI provider
*/
export async function getUserAIPreference(): Promise<'auto' | 'openai' | 'ollama'> {
const settings = await getAISettings()
return settings.aiProvider
}
/**
* Check if a specific AI feature is enabled for the user
*/
export async function isAIFeatureEnabled(feature: keyof UserAISettingsData): Promise<boolean> {
const settings = await getAISettings()
switch (feature) {
case 'titleSuggestions':
return settings.titleSuggestions
case 'semanticSearch':
return settings.semanticSearch
case 'paragraphRefactor':
return settings.paragraphRefactor
case 'memoryEcho':
return settings.memoryEcho
default:
return true
}
}

View File

@@ -0,0 +1,90 @@
'use server'
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'
// Helper simple pour générer un token sans dépendance externe lourde
function generateToken() {
const array = new Uint8Array(32);
globalThis.crypto.getRandomValues(array);
return Array.from(array, byte => byte.toString(16).padStart(2, '0')).join('');
}
export async function forgotPassword(email: string) {
if (!email) return { error: "Email is required" };
try {
const user = await prisma.user.findUnique({ where: { email: email.toLowerCase() } });
if (!user) {
// Pour des raisons de sécurité, on ne dit pas si l'email existe ou pas
return { success: true };
}
const token = generateToken();
const expiry = new Date(Date.now() + 3600000); // 1 hour
await prisma.user.update({
where: { id: user.id },
data: {
resetToken: token,
resetTokenExpiry: expiry
}
});
const resetLink = `${process.env.NEXTAUTH_URL || 'http://localhost:3000'}/reset-password?token=${token}`;
const html = getEmailTemplate(
"Reset your Password",
"<p>You requested a password reset for your Memento account.</p><p>Click the button below to set a new password. This link is valid for 1 hour.</p>",
resetLink,
"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) {
console.error('Forgot password error:', error);
return { error: "Failed to send reset email" };
}
}
export async function resetPassword(token: string, newPassword: string) {
if (!token || !newPassword) return { error: "Missing token or password" };
try {
const user = await prisma.user.findUnique({
where: { resetToken: token }
});
if (!user || !user.resetTokenExpiry || user.resetTokenExpiry < new Date()) {
return { error: "Invalid or expired token" };
}
const hashedPassword = await bcrypt.hash(newPassword, 10);
await prisma.user.update({
where: { id: user.id },
data: {
password: hashedPassword,
resetToken: null,
resetTokenExpiry: null
}
});
return { success: true };
} catch (error) {
console.error('Reset password error:', error);
return { error: "Failed to reset password" };
}
}

View File

@@ -0,0 +1,29 @@
'use server';
import { signIn } from '@/auth';
import { AuthError } from 'next-auth';
export async function authenticate(
prevState: string | undefined,
formData: FormData,
) {
try {
await signIn('credentials', {
email: formData.get('email'),
password: formData.get('password'),
redirectTo: '/',
});
} catch (error) {
if (error instanceof AuthError) {
console.error('AuthError details:', error.type, error.message);
switch (error.type) {
case 'CredentialsSignin':
return 'Invalid credentials.';
default:
return `Auth error: ${error.type}`;
}
}
// IMPORTANT: Next.js redirects throw a special error that must be rethrown
throw 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

@@ -0,0 +1,114 @@
'use server'
interface OpenAIModel {
id: string
object: string
created?: number
owned_by?: string
}
interface OpenAIModelsResponse {
object: string
data: OpenAIModel[]
}
async function fetchModelsFromEndpoint(
endpoint: string,
apiKey?: string
): Promise<{ success: boolean; models: string[]; error?: string }> {
try {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
}
if (apiKey) {
headers['Authorization'] = `Bearer ${apiKey}`
}
const response = await fetch(endpoint, {
method: 'GET',
headers,
signal: AbortSignal.timeout(8000),
})
if (!response.ok) {
throw new Error(`API returned ${response.status}: ${response.statusText}`)
}
const data = await response.json() as OpenAIModelsResponse
const modelIds = (data.data || [])
.map((m) => m.id)
.filter(Boolean)
.sort()
return { success: true, models: modelIds }
} catch (error: any) {
console.error('Failed to fetch provider models:', error)
return {
success: false,
models: [],
error: error.message || 'Failed to connect to provider',
}
}
}
/**
* Fetch all models from a custom OpenAI-compatible provider.
* Uses GET /v1/models (standard endpoint).
*/
export async function getCustomModels(
baseUrl: string,
apiKey?: string
): Promise<{ success: boolean; models: string[]; error?: string }> {
if (!baseUrl) {
return { success: false, models: [], error: 'Base URL is required' }
}
const cleanUrl = baseUrl.replace(/\/$/, '').replace(/\/v1$/, '')
return fetchModelsFromEndpoint(`${cleanUrl}/v1/models`, apiKey)
}
/**
* Fetch embedding-specific models from a custom provider.
* Tries GET /v1/embeddings/models first (OpenRouter-specific endpoint that returns
* only embedding models). Falls back to GET /v1/models filtered by common
* embedding model name patterns if the dedicated endpoint is unavailable.
*/
export async function getCustomEmbeddingModels(
baseUrl: string,
apiKey?: string
): Promise<{ success: boolean; models: string[]; error?: string }> {
if (!baseUrl) {
return { success: false, models: [], error: 'Base URL is required' }
}
const cleanUrl = baseUrl.replace(/\/$/, '').replace(/\/v1$/, '')
// Try the OpenRouter-specific embeddings models endpoint first
const embeddingsEndpoint = await fetchModelsFromEndpoint(
`${cleanUrl}/v1/embeddings/models`,
apiKey
)
if (embeddingsEndpoint.success && embeddingsEndpoint.models.length > 0) {
return embeddingsEndpoint
}
// Fallback: fetch all models and filter by common embedding name patterns
const allModels = await fetchModelsFromEndpoint(`${cleanUrl}/v1/models`, apiKey)
if (!allModels.success) {
return allModels
}
const embeddingKeywords = ['embed', 'embedding', 'ada', 'e5', 'bge', 'gte', 'minilm']
const filtered = allModels.models.filter((id) =>
embeddingKeywords.some((kw) => id.toLowerCase().includes(kw))
)
// If the filter finds nothing, return all models so the user can still pick
return {
success: true,
models: filtered.length > 0 ? filtered : allModels.models,
}
}

View File

@@ -0,0 +1,12 @@
'use server'
import { detectUserLanguage } from '@/lib/i18n/detect-user-language'
import { SupportedLanguage } from '@/lib/i18n/load-translations'
/**
* Server action to detect user's preferred language
* Called on app load to set initial language
*/
export async function getInitialLanguage(): Promise<SupportedLanguage> {
return await detectUserLanguage()
}

View File

@@ -0,0 +1,167 @@
'use server'
import { auth } from '@/auth'
import { prisma } from '@/lib/prisma'
import { revalidatePath } from 'next/cache'
import { createHash, randomBytes } from 'crypto'
const KEY_PREFIX = 'mcp_key_'
function hashKey(rawKey: string): string {
return createHash('sha256').update(rawKey).digest('hex')
}
export type McpKeyInfo = {
shortId: string
name: string
userId: string
userName: string
active: boolean
createdAt: string
lastUsedAt: string | null
}
/**
* List all MCP API keys for the current user.
*/
export async function listMcpKeys(): Promise<McpKeyInfo[]> {
const session = await auth()
if (!session?.user?.id) throw new Error('Unauthorized')
const allKeys = await prisma.systemConfig.findMany({
where: { key: { startsWith: KEY_PREFIX } },
})
const keys: McpKeyInfo[] = []
for (const entry of allKeys) {
try {
const info = JSON.parse(entry.value)
if (info.userId !== session.user.id) continue
keys.push({
shortId: info.shortId,
name: info.name,
userId: info.userId,
userName: info.userName,
active: info.active,
createdAt: info.createdAt,
lastUsedAt: info.lastUsedAt,
})
} catch {
// skip invalid JSON
}
}
return keys
}
/**
* Generate a new MCP API key for the current user.
* Returns the raw key (shown only once) and key info.
*/
export async function generateMcpKey(name: string): Promise<{ rawKey: string; info: { shortId: string; name: string } }> {
const session = await auth()
if (!session?.user?.id) throw new Error('Unauthorized')
const user = await prisma.user.findUnique({
where: { id: session.user.id },
select: { id: true, name: true, email: true },
})
if (!user) throw new Error('User not found')
const rawBytes = randomBytes(24)
const shortId = rawBytes.toString('hex').substring(0, 8)
const rawKey = `mcp_sk_${rawBytes.toString('hex')}`
const keyHash = hashKey(rawKey)
const keyInfo = {
shortId,
name: name || `Key for ${user.name}`,
userId: user.id,
userName: user.name,
userEmail: user.email,
keyHash,
createdAt: new Date().toISOString(),
lastUsedAt: null,
active: true,
}
await prisma.systemConfig.create({
data: {
key: `${KEY_PREFIX}${shortId}`,
value: JSON.stringify(keyInfo),
},
})
revalidatePath('/settings/mcp')
return {
rawKey,
info: { shortId, name: keyInfo.name },
}
}
/**
* Revoke (deactivate) an MCP API key. Only the owner can revoke.
*/
export async function revokeMcpKey(shortId: string): Promise<boolean> {
const session = await auth()
if (!session?.user?.id) throw new Error('Unauthorized')
const configKey = `${KEY_PREFIX}${shortId}`
const entry = await prisma.systemConfig.findUnique({ where: { key: configKey } })
if (!entry) throw new Error('Key not found')
const info = JSON.parse(entry.value)
if (info.userId !== session.user.id) throw new Error('Forbidden')
if (!info.active) return false
info.active = false
info.revokedAt = new Date().toISOString()
await prisma.systemConfig.update({
where: { key: configKey },
data: { value: JSON.stringify(info) },
})
revalidatePath('/settings/mcp')
return true
}
/**
* Permanently delete an MCP API key. Only the owner can delete.
*/
export async function deleteMcpKey(shortId: string): Promise<boolean> {
const session = await auth()
if (!session?.user?.id) throw new Error('Unauthorized')
const configKey = `${KEY_PREFIX}${shortId}`
const entry = await prisma.systemConfig.findUnique({ where: { key: configKey } })
if (!entry) throw new Error('Key not found')
const info = JSON.parse(entry.value)
if (info.userId !== session.user.id) throw new Error('Forbidden')
try {
await prisma.systemConfig.delete({ where: { key: configKey } })
revalidatePath('/settings/mcp')
return true
} catch {
return false
}
}
export type McpServerStatus = {
mode: 'stdio' | 'sse' | 'unknown'
url: string | null
}
/**
* Get MCP server status — mode and URL.
*/
export async function getMcpServerStatus(): Promise<McpServerStatus> {
// Check if SSE mode is configured via env
const mode = process.env.MCP_SERVER_MODE === 'sse' ? 'sse' : 'stdio'
const url = process.env.MCP_SERVER_URL || null
return { mode, url }
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,57 @@
'use server'
interface OllamaModel {
name: string
modified_at: string
size: number
digest: string
details: {
format: string
family: string
families: string[]
parameter_size: string
quantization_level: string
}
}
interface OllamaTagsResponse {
models: OllamaModel[]
}
export async function getOllamaModels(baseUrl: string): Promise<{ success: boolean; models: string[]; error?: string }> {
if (!baseUrl) {
return { success: false, models: [], error: 'Base URL is required' }
}
// Ensure URL doesn't end with slash
const cleanUrl = baseUrl.replace(/\/$/, '')
try {
const response = await fetch(`${cleanUrl}/api/tags`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
// Set a reasonable timeout
signal: AbortSignal.timeout(5000)
})
if (!response.ok) {
throw new Error(`Ollama API returned ${response.status}: ${response.statusText}`)
}
const data = await response.json() as OllamaTagsResponse
// Extract model names
const modelNames = data.models?.map(m => m.name) || []
return { success: true, models: modelNames }
} catch (error: any) {
console.error('Failed to fetch Ollama models:', error)
return {
success: false,
models: [],
error: error.message || 'Failed to connect to Ollama'
}
}
}

View File

@@ -0,0 +1,49 @@
'use server'
import { paragraphRefactorService, RefactorMode, RefactorResult } from '@/lib/ai/services/paragraph-refactor.service'
export interface RefactorResponse {
result: RefactorResult
}
/**
* Refactor a paragraph with a specific mode
*/
export async function refactorParagraph(
content: string,
mode: RefactorMode
): Promise<RefactorResponse> {
try {
const result = await paragraphRefactorService.refactor(content, mode)
return { result }
} catch (error) {
console.error('Error refactoring paragraph:', error)
throw error
}
}
/**
* Get all 3 refactor options at once
*/
export async function refactorParagraphAllModes(
content: string
): Promise<{ results: RefactorResult[] }> {
try {
const results = await paragraphRefactorService.refactorAllModes(content)
return { results }
} catch (error) {
console.error('Error refactoring paragraph in all modes:', error)
throw error
}
}
/**
* Validate word count before refactoring
*/
export async function validateRefactorWordCount(
content: string
): Promise<{ valid: boolean; error?: string }> {
return paragraphRefactorService.validateWordCount(content)
}

View File

@@ -0,0 +1,232 @@
'use server'
import { revalidatePath } from 'next/cache'
import prisma from '@/lib/prisma'
import { auth } from '@/auth'
import bcrypt from 'bcryptjs'
import { z } from 'zod'
const ProfileSchema = z.object({
name: z.string().min(2, "Name must be at least 2 characters"),
email: z.string().email().optional(), // Email change might require verification logic, keeping it simple for now or read-only
})
const PasswordSchema = z.object({
currentPassword: z.string().min(1, "Current password is required"),
newPassword: z.string().min(6, "New password must be at least 6 characters"),
confirmPassword: z.string().min(6, "Confirm password must be at least 6 characters"),
}).refine((data) => data.newPassword === data.confirmPassword, {
message: "Passwords don't match",
path: ["confirmPassword"],
})
export async function updateProfile(data: { name: string }) {
const session = await auth()
if (!session?.user?.id) throw new Error('Unauthorized')
const validated = ProfileSchema.safeParse(data)
if (!validated.success) {
return { error: validated.error.flatten().fieldErrors }
}
try {
await prisma.user.update({
where: { id: session.user.id },
data: { name: validated.data.name },
})
revalidatePath('/settings/profile')
return { success: true }
} catch (error) {
return { error: { _form: ['Failed to update profile'] } }
}
}
export async function changePassword(formData: FormData) {
const session = await auth()
if (!session?.user?.id) throw new Error('Unauthorized')
const rawData = {
currentPassword: formData.get('currentPassword'),
newPassword: formData.get('newPassword'),
confirmPassword: formData.get('confirmPassword'),
}
const validated = PasswordSchema.safeParse(rawData)
if (!validated.success) {
return { error: validated.error.flatten().fieldErrors }
}
const { currentPassword, newPassword } = validated.data
const user = await prisma.user.findUnique({
where: { id: session.user.id },
})
if (!user || !user.password) {
return { error: { _form: ['User not found'] } }
}
const passwordsMatch = await bcrypt.compare(currentPassword, user.password)
if (!passwordsMatch) {
return { error: { currentPassword: ['Incorrect current password'] } }
}
const hashedPassword = await bcrypt.hash(newPassword, 10)
try {
await prisma.user.update({
where: { id: session.user.id },
data: { password: hashedPassword },
})
return { success: true }
} catch (error) {
return { error: { _form: ['Failed to change password'] } }
}
}
export async function updateTheme(theme: string) {
const session = await auth()
if (!session?.user?.id) return { error: 'Unauthorized' }
try {
await prisma.user.update({
where: { id: session.user.id },
data: { theme },
})
revalidatePath('/')
revalidatePath('/settings/profile')
return { success: true }
} catch (error) {
return { error: 'Failed to update theme' }
}
}
export async function updateLanguage(language: string) {
const session = await auth()
if (!session?.user?.id) return { error: 'Unauthorized' }
try {
// Update or create UserAISettings with the preferred language
await prisma.userAISettings.upsert({
where: { userId: session.user.id },
create: {
userId: session.user.id,
preferredLanguage: language,
},
update: {
preferredLanguage: language,
},
})
// Note: The language will be applied on next page load
// The client component should handle updating localStorage and reloading
revalidatePath('/')
revalidatePath('/settings/profile')
return { success: true, language }
} catch (error) {
console.error('Failed to update language:', error)
return { error: 'Failed to update language' }
}
}
export async function updateFontSize(fontSize: string) {
const session = await auth()
if (!session?.user?.id) return { error: 'Unauthorized' }
try {
// Check if UserAISettings exists
const existing = await prisma.userAISettings.findUnique({
where: { userId: session.user.id }
})
let result
if (existing) {
// Update existing - only update fontSize field
result = await prisma.userAISettings.update({
where: { userId: session.user.id },
data: { fontSize: fontSize }
})
} else {
// Create new with all required fields
result = await prisma.userAISettings.create({
data: {
userId: session.user.id,
fontSize: fontSize,
// Set default values for required fields
titleSuggestions: true,
semanticSearch: true,
paragraphRefactor: true,
memoryEcho: true,
memoryEchoFrequency: 'daily',
aiProvider: 'auto',
preferredLanguage: 'auto',
showRecentNotes: false
}
})
}
revalidatePath('/')
revalidatePath('/settings/profile')
return { success: true, fontSize }
} catch (error) {
console.error('[updateFontSize] Failed to update font size:', error)
return { error: 'Failed to update font size' }
}
}
export async function updateShowRecentNotes(showRecentNotes: boolean) {
const session = await auth()
if (!session?.user?.id) return { error: 'Unauthorized' }
try {
// Use EXACT same pattern as updateFontSize which works
const existing = await prisma.userAISettings.findUnique({
where: { userId: session.user.id }
})
if (existing) {
// Try Prisma client first, fallback to raw SQL if field doesn't exist in client
try {
await prisma.userAISettings.update({
where: { userId: session.user.id },
data: { showRecentNotes: showRecentNotes } as any
})
} catch (prismaError: any) {
// If Prisma client doesn't know about showRecentNotes, use raw SQL
if (prismaError?.message?.includes('Unknown argument') || prismaError?.code === 'P2009') {
const value = showRecentNotes ? 1 : 0
await prisma.$executeRaw`
UPDATE UserAISettings
SET showRecentNotes = ${value}
WHERE userId = ${session.user.id}
`
} else {
throw prismaError
}
}
} else {
// Create new - same as updateFontSize
await prisma.userAISettings.create({
data: {
userId: session.user.id,
titleSuggestions: true,
semanticSearch: true,
paragraphRefactor: true,
memoryEcho: true,
memoryEchoFrequency: 'daily',
aiProvider: 'auto',
preferredLanguage: 'auto',
fontSize: 'medium',
showRecentNotes: showRecentNotes
} as any
})
}
revalidatePath('/')
revalidatePath('/settings/profile')
return { success: true, showRecentNotes }
} catch (error) {
console.error('[updateShowRecentNotes] Failed:', error)
return { error: 'Failed to update show recent notes setting' }
}
}

View File

@@ -0,0 +1,232 @@
'use server'
import { revalidatePath } from 'next/cache'
import prisma from '@/lib/prisma'
import { auth } from '@/auth'
import bcrypt from 'bcryptjs'
import { z } from 'zod'
const ProfileSchema = z.object({
name: z.string().min(2, "Name must be at least 2 characters"),
email: z.string().email().optional(), // Email change might require verification logic, keeping it simple for now or read-only
})
const PasswordSchema = z.object({
currentPassword: z.string().min(1, "Current password is required"),
newPassword: z.string().min(6, "New password must be at least 6 characters"),
confirmPassword: z.string().min(6, "Confirm password must be at least 6 characters"),
}).refine((data) => data.newPassword === data.confirmPassword, {
message: "Passwords don't match",
path: ["confirmPassword"],
})
export async function updateProfile(data: { name: string }) {
const session = await auth()
if (!session?.user?.id) throw new Error('Unauthorized')
const validated = ProfileSchema.safeParse(data)
if (!validated.success) {
return { error: validated.error.flatten().fieldErrors }
}
try {
await prisma.user.update({
where: { id: session.user.id },
data: { name: validated.data.name },
})
revalidatePath('/settings/profile')
return { success: true }
} catch (error) {
return { error: { _form: ['Failed to update profile'] } }
}
}
export async function changePassword(formData: FormData) {
const session = await auth()
if (!session?.user?.id) throw new Error('Unauthorized')
const rawData = {
currentPassword: formData.get('currentPassword'),
newPassword: formData.get('newPassword'),
confirmPassword: formData.get('confirmPassword'),
}
const validated = PasswordSchema.safeParse(rawData)
if (!validated.success) {
return { error: validated.error.flatten().fieldErrors }
}
const { currentPassword, newPassword } = validated.data
const user = await prisma.user.findUnique({
where: { id: session.user.id },
})
if (!user || !user.password) {
return { error: { _form: ['User not found'] } }
}
const passwordsMatch = await bcrypt.compare(currentPassword, user.password)
if (!passwordsMatch) {
return { error: { currentPassword: ['Incorrect current password'] } }
}
const hashedPassword = await bcrypt.hash(newPassword, 10)
try {
await prisma.user.update({
where: { id: session.user.id },
data: { password: hashedPassword },
})
return { success: true }
} catch (error) {
return { error: { _form: ['Failed to change password'] } }
}
}
export async function updateTheme(theme: string) {
const session = await auth()
if (!session?.user?.id) return { error: 'Unauthorized' }
try {
await prisma.user.update({
where: { id: session.user.id },
data: { theme },
})
revalidatePath('/')
revalidatePath('/settings/profile')
return { success: true }
} catch (error) {
return { error: 'Failed to update theme' }
}
}
export async function updateLanguage(language: string) {
const session = await auth()
if (!session?.user?.id) return { error: 'Unauthorized' }
try {
// Update or create UserAISettings with the preferred language
await prisma.userAISettings.upsert({
where: { userId: session.user.id },
create: {
userId: session.user.id,
preferredLanguage: language,
},
update: {
preferredLanguage: language,
},
})
// Note: The language will be applied on next page load
// The client component should handle updating localStorage and reloading
revalidatePath('/')
revalidatePath('/settings/profile')
return { success: true, language }
} catch (error) {
console.error('Failed to update language:', error)
return { error: 'Failed to update language' }
}
}
export async function updateFontSize(fontSize: string) {
const session = await auth()
if (!session?.user?.id) return { error: 'Unauthorized' }
try {
// Check if UserAISettings exists
const existing = await prisma.userAISettings.findUnique({
where: { userId: session.user.id }
})
let result
if (existing) {
// Update existing - only update fontSize field
result = await prisma.userAISettings.update({
where: { userId: session.user.id },
data: { fontSize: fontSize }
})
} else {
// Create new with all required fields
result = await prisma.userAISettings.create({
data: {
userId: session.user.id,
fontSize: fontSize,
// Set default values for required fields
titleSuggestions: true,
semanticSearch: true,
paragraphRefactor: true,
memoryEcho: true,
memoryEchoFrequency: 'daily',
aiProvider: 'auto',
preferredLanguage: 'auto',
showRecentNotes: false
}
})
}
revalidatePath('/')
revalidatePath('/settings/profile')
return { success: true, fontSize }
} catch (error) {
console.error('[updateFontSize] Failed to update font size:', error)
return { error: 'Failed to update font size' }
}
}
export async function updateShowRecentNotes(showRecentNotes: boolean) {
const session = await auth()
if (!session?.user?.id) return { error: 'Unauthorized' }
try {
// Use EXACT same pattern as updateFontSize which works
const existing = await prisma.userAISettings.findUnique({
where: { userId: session.user.id }
})
if (existing) {
// Try Prisma client first, fallback to raw SQL if field doesn't exist in client
try {
await prisma.userAISettings.update({
where: { userId: session.user.id },
data: { showRecentNotes: showRecentNotes } as any
})
} catch (prismaError: any) {
// If Prisma client doesn't know about showRecentNotes, use raw SQL
if (prismaError?.message?.includes('Unknown argument') || prismaError?.code === 'P2009') {
const value = showRecentNotes ? 1 : 0
await prisma.$executeRaw`
UPDATE UserAISettings
SET showRecentNotes = ${value}
WHERE userId = ${session.user.id}
`
} else {
throw prismaError
}
}
} else {
// Create new - same as updateFontSize
await prisma.userAISettings.create({
data: {
userId: session.user.id,
titleSuggestions: true,
semanticSearch: true,
paragraphRefactor: true,
memoryEcho: true,
memoryEchoFrequency: 'daily',
aiProvider: 'auto',
preferredLanguage: 'auto',
fontSize: 'medium',
showRecentNotes: showRecentNotes
} as any
})
}
revalidatePath('/')
revalidatePath('/settings/profile')
return { success: true, showRecentNotes }
} catch (error) {
console.error('[updateShowRecentNotes] Failed:', error)
return { error: 'Failed to update show recent notes setting' }
}
}

View File

@@ -0,0 +1,201 @@
'use server'
import { revalidatePath } from 'next/cache'
import prisma from '@/lib/prisma'
import { auth } from '@/auth'
import bcrypt from 'bcryptjs'
import { z } from 'zod'
const ProfileSchema = z.object({
name: z.string().min(2, "Name must be at least 2 characters"),
email: z.string().email().optional(), // Email change might require verification logic, keeping it simple for now or read-only
})
const PasswordSchema = z.object({
currentPassword: z.string().min(1, "Current password is required"),
newPassword: z.string().min(6, "New password must be at least 6 characters"),
confirmPassword: z.string().min(6, "Confirm password must be at least 6 characters"),
}).refine((data) => data.newPassword === data.confirmPassword, {
message: "Passwords don't match",
path: ["confirmPassword"],
})
export async function updateProfile(data: { name: string }) {
const session = await auth()
if (!session?.user?.id) throw new Error('Unauthorized')
const validated = ProfileSchema.safeParse(data)
if (!validated.success) {
return { error: validated.error.flatten().fieldErrors }
}
try {
await prisma.user.update({
where: { id: session.user.id },
data: { name: validated.data.name },
})
revalidatePath('/settings/profile')
return { success: true }
} catch (error) {
return { error: { _form: ['Failed to update profile'] } }
}
}
export async function changePassword(formData: FormData) {
const session = await auth()
if (!session?.user?.id) throw new Error('Unauthorized')
const rawData = {
currentPassword: formData.get('currentPassword'),
newPassword: formData.get('newPassword'),
confirmPassword: formData.get('confirmPassword'),
}
const validated = PasswordSchema.safeParse(rawData)
if (!validated.success) {
return { error: validated.error.flatten().fieldErrors }
}
const { currentPassword, newPassword } = validated.data
const user = await prisma.user.findUnique({
where: { id: session.user.id },
})
if (!user || !user.password) {
return { error: { _form: ['User not found'] } }
}
const passwordsMatch = await bcrypt.compare(currentPassword, user.password)
if (!passwordsMatch) {
return { error: { currentPassword: ['Incorrect current password'] } }
}
const hashedPassword = await bcrypt.hash(newPassword, 10)
try {
await prisma.user.update({
where: { id: session.user.id },
data: { password: hashedPassword },
})
return { success: true }
} catch (error) {
return { error: { _form: ['Failed to change password'] } }
}
}
export async function updateTheme(theme: string) {
const session = await auth()
if (!session?.user?.id) return { error: 'Unauthorized' }
try {
await prisma.user.update({
where: { id: session.user.id },
data: { theme },
})
revalidatePath('/')
revalidatePath('/settings/profile')
return { success: true }
} catch (error) {
return { error: 'Failed to update theme' }
}
}
export async function updateLanguage(language: string) {
const session = await auth()
if (!session?.user?.id) return { error: 'Unauthorized' }
try {
// Update or create UserAISettings with the preferred language
await prisma.userAISettings.upsert({
where: { userId: session.user.id },
create: {
userId: session.user.id,
preferredLanguage: language,
},
update: {
preferredLanguage: language,
},
})
// Note: The language will be applied on next page load
// The client component should handle updating localStorage and reloading
revalidatePath('/')
revalidatePath('/settings/profile')
return { success: true, language }
} catch (error) {
console.error('Failed to update language:', error)
return { error: 'Failed to update language' }
}
}
export async function updateFontSize(fontSize: string) {
const session = await auth()
if (!session?.user?.id) return { error: 'Unauthorized' }
try {
// Check if UserAISettings exists
const existing = await prisma.userAISettings.findUnique({
where: { userId: session.user.id }
})
let result
if (existing) {
// Update existing - only update fontSize field
result = await prisma.userAISettings.update({
where: { userId: session.user.id },
data: { fontSize: fontSize }
})
} else {
// Create new with all required fields
result = await prisma.userAISettings.create({
data: {
userId: session.user.id,
fontSize: fontSize,
// Set default values for required fields
titleSuggestions: true,
semanticSearch: true,
paragraphRefactor: true,
memoryEcho: true,
memoryEchoFrequency: 'daily',
aiProvider: 'auto',
preferredLanguage: 'auto',
showRecentNotes: false
}
})
}
revalidatePath('/')
revalidatePath('/settings/profile')
return { success: true, fontSize }
} catch (error) {
console.error('[updateFontSize] Failed to update font size:', error)
return { error: 'Failed to update font size' }
}
}
export async function updateShowRecentNotes(showRecentNotes: boolean) {
const session = await auth()
if (!session?.user?.id) return { error: 'Unauthorized' }
try {
await prisma.userAISettings.upsert({
where: { userId: session.user.id },
create: {
userId: session.user.id,
showRecentNotes,
// Defaults will be used for other fields
},
update: {
showRecentNotes,
},
})
revalidatePath('/')
revalidatePath('/settings/profile')
return { success: true, showRecentNotes }
} catch (error) {
console.error('[updateShowRecentNotes] Failed:', error)
return { error: 'Failed to update show recent notes setting' }
}
}

View File

@@ -0,0 +1,63 @@
'use server';
import bcrypt from 'bcryptjs';
import prisma from '@/lib/prisma';
import { z } from 'zod';
import { redirect } from 'next/navigation';
import { getSystemConfig } from '@/lib/config';
const RegisterSchema = z.object({
email: z.string().email(),
password: z.string().min(6),
name: z.string().min(2),
});
export async function register(prevState: string | undefined, formData: FormData) {
// Check if registration is allowed
const config = await getSystemConfig();
const allowRegister = config.ALLOW_REGISTRATION !== 'false' && process.env.ALLOW_REGISTRATION !== 'false';
if (!allowRegister) {
return 'Registration is currently disabled by the administrator.';
}
const validatedFields = RegisterSchema.safeParse({
email: formData.get('email'),
password: formData.get('password'),
name: formData.get('name'),
});
if (!validatedFields.success) {
return 'Invalid fields. Failed to register.';
}
const { email, password, name } = validatedFields.data;
try {
const existingUser = await prisma.user.findUnique({ where: { email: email.toLowerCase() } });
if (existingUser) {
return 'User already exists.';
}
const hashedPassword = await bcrypt.hash(password, 10);
await prisma.user.create({
data: {
email: email.toLowerCase(),
password: hashedPassword,
name,
},
});
// Attempt to sign in immediately after registration
// We cannot import signIn here directly if it causes circular deps or issues,
// but usually it works. If not, redirecting to login is fine.
// Let's stick to redirecting to login but with a clear success message?
// Or better: lowercase the email to fix the potential bug.
} catch (error) {
console.error('Registration Error:', error);
return 'Database Error: Failed to create user.';
}
redirect('/login');
}

View File

@@ -0,0 +1,59 @@
'use server'
import * as cheerio from 'cheerio';
export interface LinkMetadata {
url: string;
title?: string;
description?: string;
imageUrl?: string;
siteName?: string;
}
export async function fetchLinkMetadata(url: string): Promise<LinkMetadata | null> {
try {
// Add protocol if missing
let targetUrl = url;
if (!url.startsWith('http://') && !url.startsWith('https://')) {
targetUrl = 'https://' + url;
}
const response = await fetch(targetUrl, {
headers: {
// Use a real browser User-Agent to avoid 403 Forbidden from strict sites
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
'Accept-Language': 'en-US,en;q=0.5'
},
next: { revalidate: 3600 } // Cache for 1 hour
});
if (!response.ok) {
return null;
}
const html = await response.text();
const $ = cheerio.load(html);
const getMeta = (prop: string) =>
$(`meta[property="${prop}"]`).attr('content') ||
$(`meta[name="${prop}"]`).attr('content');
// Robust extraction with fallbacks
const title = getMeta('og:title') || $('title').text() || getMeta('twitter:title') || url;
const description = getMeta('og:description') || getMeta('description') || getMeta('twitter:description') || '';
const imageUrl = getMeta('og:image') || getMeta('twitter:image') || $('link[rel="image_src"]').attr('href');
const siteName = getMeta('og:site_name') || '';
return {
url: targetUrl,
title: title.substring(0, 100),
description: description.substring(0, 200),
imageUrl,
siteName
};
} catch (error) {
console.error(`[Scrape] Error fetching ${url}:`, error);
return null;
}
}

View File

@@ -0,0 +1,63 @@
'use server'
import { semanticSearchService, SearchResult } from '@/lib/ai/services/semantic-search.service'
export interface SemanticSearchResponse {
results: SearchResult[]
query: string
totalResults: number
}
/**
* Perform hybrid semantic + keyword search
* Supports contextual search within notebook (IA5)
*/
export async function semanticSearch(
query: string,
options?: {
limit?: number
threshold?: number
notebookId?: string // NEW: Filter by notebook for contextual search (IA5)
}
): Promise<SemanticSearchResponse> {
try {
const results = await semanticSearchService.search(query, {
limit: options?.limit || 20,
threshold: options?.threshold || 0.6,
notebookId: options?.notebookId // NEW: Pass notebook filter
})
return {
results,
query,
totalResults: results.length
}
} catch (error) {
console.error('Error in semantic search action:', error)
throw error
}
}
/**
* Index a note for semantic search (generate embedding)
*/
export async function indexNote(noteId: string): Promise<void> {
try {
await semanticSearchService.indexNote(noteId)
} catch (error) {
console.error('Error indexing note:', error)
throw error
}
}
/**
* Batch index notes (for initial setup)
*/
export async function batchIndexNotes(noteIds: string[]): Promise<void> {
try {
await semanticSearchService.indexBatchNotes(noteIds)
} catch (error) {
console.error('Error batch indexing notes:', error)
throw error
}
}

View File

@@ -0,0 +1,128 @@
'use server'
import { auth } from '@/auth'
import { titleSuggestionService } from '@/lib/ai/services/title-suggestion.service'
import { prisma } from '@/lib/prisma'
import { revalidatePath } from 'next/cache'
export interface GenerateTitlesResponse {
suggestions: Array<{
title: string
confidence: number
reasoning?: string
}>
noteId: string
}
/**
* Generate title suggestions for a note
* Triggered when note reaches 50+ words without a title
*/
export async function generateTitleSuggestions(noteId: string): Promise<GenerateTitlesResponse> {
const session = await auth()
if (!session?.user?.id) {
throw new Error('Unauthorized')
}
try {
// Fetch note content
const note = await prisma.note.findUnique({
where: { id: noteId },
select: { id: true, content: true, userId: true }
})
if (!note) {
throw new Error('Note not found')
}
if (note.userId !== session.user.id) {
throw new Error('Forbidden')
}
if (!note.content || note.content.trim().length === 0) {
throw new Error('Note content is empty')
}
// Generate suggestions
const suggestions = await titleSuggestionService.generateSuggestions(note.content)
return {
suggestions,
noteId
}
} catch (error) {
console.error('Error generating title suggestions:', error)
throw error
}
}
/**
* Apply selected title to note
*/
export async function applyTitleSuggestion(
noteId: string,
selectedTitle: string
): Promise<void> {
const session = await auth()
if (!session?.user?.id) {
throw new Error('Unauthorized')
}
try {
// Update note with selected title
await prisma.note.update({
where: {
id: noteId,
userId: session.user.id
},
data: {
title: selectedTitle,
autoGenerated: true,
lastAiAnalysis: new Date()
}
})
revalidatePath('/')
revalidatePath(`/note/${noteId}`)
} catch (error) {
console.error('Error applying title suggestion:', error)
throw error
}
}
/**
* Record user feedback on title suggestions
* (Phase 3 - for improving future suggestions)
*/
export async function recordTitleFeedback(
noteId: string,
selectedTitle: string,
allSuggestions: Array<{ title: string; confidence: number }>
): Promise<void> {
const session = await auth()
if (!session?.user?.id) {
throw new Error('Unauthorized')
}
try {
// Save to AiFeedback table for learning
await prisma.aiFeedback.create({
data: {
noteId,
userId: session.user.id,
feedbackType: 'thumbs_up', // User chose one of our suggestions
feature: 'title_suggestion',
originalContent: JSON.stringify(allSuggestions),
correctedContent: selectedTitle,
metadata: JSON.stringify({
timestamp: new Date().toISOString(),
provider: 'auto' // Will be dynamic based on user settings
})
}
})
} catch (error) {
console.error('Error recording title feedback:', error)
// Don't throw - feedback is optional
}
}

View File

@@ -0,0 +1,86 @@
'use server'
import { auth } from '@/auth'
import { prisma } from '@/lib/prisma'
import { revalidatePath, updateTag } from 'next/cache'
export type UserSettingsData = {
theme?: 'light' | 'dark' | 'auto' | 'sepia' | 'midnight' | 'blue'
cardSizeMode?: 'variable' | 'uniform'
}
/**
* Update user settings (theme, etc.)
*/
export async function updateUserSettings(settings: UserSettingsData) {
const session = await auth()
if (!session?.user?.id) {
console.error('[updateUserSettings] Unauthorized')
throw new Error('Unauthorized')
}
try {
const result = await prisma.user.update({
where: { id: session.user.id },
data: settings
})
revalidatePath('/', 'layout')
updateTag('user-settings')
return { success: true }
} catch (error) {
console.error('Error updating user settings:', error)
throw new Error('Failed to update user settings')
}
}
/**
* Get user settings for current user (Cached)
*/
import { unstable_cache } from 'next/cache'
// Internal cached function
const getCachedUserSettings = unstable_cache(
async (userId: string) => {
try {
const user = await prisma.user.findUnique({
where: { id: userId },
select: { theme: true, cardSizeMode: true }
})
return {
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)
return {
theme: 'light' as const
}
}
},
['user-settings'],
{ tags: ['user-settings'] }
)
export async function getUserSettings(userId?: string) {
let id = userId
if (!id) {
const session = await auth()
id = session?.user?.id
}
if (!id) {
return {
theme: 'light' as const,
cardSizeMode: 'variable' as const
}
}
return getCachedUserSettings(id)
}