perf: optimize MCP server (O(1) auth, compact JSON, trashedAt fix) + memento-note performance (lazy loading, server-side filtering, XSS fixes, dead code removal, security hardening)
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 1m35s
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 1m35s
MCP Server: - Fix validateApiKey: O(1) direct lookup by shortId instead of loading all keys - Add trashedAt:null filter to ALL note queries (trashed notes leaked in results) - Compact JSON output (~40% smaller responses) - Bounded session cache (Map with MAX_SESSIONS=500) to prevent memory leaks - PostgreSQL connection pooling (connection_limit=10) - Rewrite all 22 tool descriptions in clear English - Fix /sse fallback to proper 307 redirect memento-note Performance: - loading=lazy on all note images - Split notebooksRefreshKey from global refreshKey (note CRUD no longer re-fetches notebooks) - Remove searchKey from trash count deps (no re-fetch on every keystroke) - Server-side notebookId filter in getAllNotes() (biggest win) - Skip collaborator fetch for non-shared notes (eliminates N+1 API calls) - next/dynamic for MarkdownContent + 4 modals (code-split remark/rehype/KaTeX) - Memoize DOMPurify sanitize with useMemo Security: - XSS: DOMPurify sanitize in note-card and note-history-modal - Auth anti-enumeration: uniform errors in auth.ts - CRON_SECRET mandatory on cron endpoints - Rate limiting on login (5 attempts/min per email) - Centralized API auth helpers (requireAuth/requireAdmin) - randomize-labels changed GET→POST - Removed debug endpoints (/api/debug/config, /api/debug/test-chat) Cleanup: - Removed dead code: .backup-keep, settings-backup, fix-*.js, debug-theme, fix-labels route - Removed sensitive console.error in auth.ts - Ollama fetchWithTimeout (30s/60s AbortController) - i18n: full Arabic translation, Farsi missing keys - Masonry drag-and-drop fix (localOrderMap, cross-section block) - Sidebar notebook tooltip on truncation
This commit is contained in:
@@ -1,13 +1,8 @@
|
||||
/**
|
||||
* Memento MCP Server - Optimized Tool Definitions & Handlers
|
||||
* Memento MCP Server - Tool Definitions & Handlers
|
||||
*
|
||||
* Performance optimizations:
|
||||
* - O(1) API key lookup with caching
|
||||
* - Batch operations for imports
|
||||
* - Parallel promise execution
|
||||
* - HTTP timeout wrapper
|
||||
* - N+1 query fixes
|
||||
* - Connection pooling
|
||||
* Fast, minimal overhead. All queries filter trashed notes.
|
||||
* Compact JSON output. Direct DB lookups.
|
||||
*/
|
||||
|
||||
import {
|
||||
@@ -18,12 +13,12 @@ import {
|
||||
} from '@modelcontextprotocol/sdk/types.js';
|
||||
import { requestContext } from './request-context.js';
|
||||
|
||||
// ─── Configuration ─────────────────────────────────────────────────────────
|
||||
|
||||
const DEFAULT_SEARCH_LIMIT = 50;
|
||||
const DEFAULT_NOTES_LIMIT = 100;
|
||||
const MAX_NOTES_LIMIT = 500;
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||
const NOTE_COLORS = 'default, red, orange, yellow, green, teal, blue, purple, pink, gray';
|
||||
const LABEL_COLORS = 'red, orange, yellow, green, teal, blue, purple, pink, gray';
|
||||
|
||||
export function parseNote(dbNote) {
|
||||
if (!dbNote) return null;
|
||||
@@ -66,65 +61,60 @@ export function parseNoteLightweight(dbNote) {
|
||||
|
||||
function textResult(data) {
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }],
|
||||
content: [{ type: 'text', text: JSON.stringify(data) }],
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Tool Schemas ───────────────────────────────────────────────────────────
|
||||
|
||||
const NOTE_COLORS = ['default', 'red', 'orange', 'yellow', 'green', 'teal', 'blue', 'purple', 'pink', 'gray'];
|
||||
const LABEL_COLORS = ['red', 'orange', 'yellow', 'green', 'teal', 'blue', 'purple', 'pink', 'gray'];
|
||||
// ─── Tool Definitions ──────────────────────────────────────────────────────
|
||||
|
||||
const toolDefinitions = [
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// NOTE TOOLS
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// ═══ NOTES ═══
|
||||
{
|
||||
name: 'create_note',
|
||||
description: 'Create a new note. Supports text and checklist types, colors, labels, images, links, reminders, markdown, and notebook assignment.',
|
||||
description: 'Create a new note. Set content (required), optional title, color, labels, notebook assignment, reminder, or checklist items.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
title: { type: 'string', description: 'Note title (optional)' },
|
||||
content: { type: 'string', description: 'Note content (required)' },
|
||||
color: { type: 'string', description: `Note color: ${NOTE_COLORS.join(', ')}`, default: 'default' },
|
||||
title: { type: 'string', description: 'Note title' },
|
||||
content: { type: 'string', description: 'Note body text (required)' },
|
||||
color: { type: 'string', description: `Color: ${NOTE_COLORS}`, default: 'default' },
|
||||
type: { type: 'string', enum: ['text', 'checklist'], description: 'Note type', default: 'text' },
|
||||
checkItems: {
|
||||
type: 'array',
|
||||
description: 'Checklist items (when type is checklist)',
|
||||
description: 'Checklist items (when type=list)',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: { id: { type: 'string' }, text: { type: 'string' }, checked: { type: 'boolean' } },
|
||||
required: ['id', 'text', 'checked'],
|
||||
},
|
||||
},
|
||||
labels: { type: 'array', description: 'Note labels/tags', items: { type: 'string' } },
|
||||
isPinned: { type: 'boolean', description: 'Pin the note', default: false },
|
||||
labels: { type: 'array', description: 'Tags/labels', items: { type: 'string' } },
|
||||
isPinned: { type: 'boolean', description: 'Pin to top', default: false },
|
||||
isArchived: { type: 'boolean', description: 'Create as archived', default: false },
|
||||
images: { type: 'array', description: 'Image URLs or base64 strings', items: { type: 'string' } },
|
||||
links: { type: 'array', description: 'URLs attached to the note', items: { type: 'string' } },
|
||||
images: { type: 'array', description: 'Image URLs', items: { type: 'string' } },
|
||||
links: { type: 'array', description: 'Attached URLs', items: { type: 'string' } },
|
||||
reminder: { type: 'string', description: 'Reminder datetime (ISO 8601)' },
|
||||
isReminderDone: { type: 'boolean', default: false },
|
||||
reminderRecurrence: { type: 'string', description: 'Recurrence: daily, weekly, monthly, yearly' },
|
||||
reminderLocation: { type: 'string', description: 'Location-based reminder' },
|
||||
isMarkdown: { type: 'boolean', description: 'Enable markdown rendering', default: false },
|
||||
reminderRecurrence: { type: 'string', description: 'daily, weekly, monthly, yearly' },
|
||||
reminderLocation: { type: 'string', description: 'Location string' },
|
||||
isMarkdown: { type: 'boolean', description: 'Render as markdown', default: false },
|
||||
size: { type: 'string', enum: ['small', 'medium', 'large'], default: 'small' },
|
||||
notebookId: { type: 'string', description: 'Notebook to assign the note to' },
|
||||
notebookId: { type: 'string', description: 'Assign to notebook' },
|
||||
},
|
||||
required: ['content'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'get_notes',
|
||||
description: 'Get notes with optional filters. Returns lightweight format by default (truncated content, no images). Use fullDetails=true for complete data.',
|
||||
description: 'List notes. Returns lightweight format by default (truncated content, no images). Use fullDetails=true for full payloads.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
includeArchived: { type: 'boolean', description: 'Include archived notes', default: false },
|
||||
search: { type: 'string', description: 'Filter by keyword in title/content' },
|
||||
notebookId: { type: 'string', description: 'Filter by notebook ID. Use "inbox" for notes without a notebook' },
|
||||
fullDetails: { type: 'boolean', description: 'Return full details including images (large payload)', default: false },
|
||||
limit: { type: 'number', description: `Max notes to return (default ${DEFAULT_NOTES_LIMIT})`, default: DEFAULT_NOTES_LIMIT },
|
||||
search: { type: 'string', description: 'Keyword filter on title/content' },
|
||||
notebookId: { type: 'string', description: 'Filter by notebook. Use "inbox" for unfiled notes.' },
|
||||
fullDetails: { type: 'boolean', description: 'Full payload including images', default: false },
|
||||
limit: { type: 'number', description: `Max results (default ${DEFAULT_NOTES_LIMIT})`, default: DEFAULT_NOTES_LIMIT },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -139,14 +129,14 @@ const toolDefinitions = [
|
||||
},
|
||||
{
|
||||
name: 'update_note',
|
||||
description: 'Update an existing note. Only include fields you want to change.',
|
||||
description: 'Update a note. Only fields you include will be changed.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Note ID' },
|
||||
title: { type: 'string' },
|
||||
content: { type: 'string' },
|
||||
color: { type: 'string', description: `One of: ${NOTE_COLORS.join(', ')}` },
|
||||
color: { type: 'string', description: `One of: ${NOTE_COLORS}` },
|
||||
type: { type: 'string', enum: ['text', 'checklist'] },
|
||||
checkItems: {
|
||||
type: 'array',
|
||||
@@ -173,7 +163,7 @@ const toolDefinitions = [
|
||||
},
|
||||
{
|
||||
name: 'delete_note',
|
||||
description: 'Delete a note by ID.',
|
||||
description: 'Permanently delete a note.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: { id: { type: 'string', description: 'Note ID' } },
|
||||
@@ -187,7 +177,7 @@ const toolDefinitions = [
|
||||
type: 'object',
|
||||
properties: {
|
||||
query: { type: 'string', description: 'Search query' },
|
||||
notebookId: { type: 'string', description: 'Limit search to a notebook' },
|
||||
notebookId: { type: 'string', description: 'Limit to a notebook' },
|
||||
includeArchived: { type: 'boolean', default: false },
|
||||
},
|
||||
required: ['query'],
|
||||
@@ -195,12 +185,12 @@ const toolDefinitions = [
|
||||
},
|
||||
{
|
||||
name: 'move_note',
|
||||
description: 'Move a note to a different notebook. Pass null for notebookId to move to Inbox.',
|
||||
description: 'Move a note to a notebook. Set notebookId to null to move to Inbox.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Note ID' },
|
||||
notebookId: { type: 'string', description: 'Target notebook ID, or null/empty for Inbox' },
|
||||
notebookId: { type: 'string', description: 'Target notebook ID, or null for Inbox' },
|
||||
},
|
||||
required: ['id'],
|
||||
},
|
||||
@@ -225,18 +215,18 @@ const toolDefinitions = [
|
||||
},
|
||||
{
|
||||
name: 'export_notes',
|
||||
description: 'Export all notes, labels, and notebooks as a JSON object.',
|
||||
description: 'Export all notes, notebooks, and labels as JSON.',
|
||||
inputSchema: { type: 'object', properties: {} },
|
||||
},
|
||||
{
|
||||
name: 'import_notes',
|
||||
description: 'Import notes from a previously exported JSON object. Skips duplicates by name.',
|
||||
description: 'Import notes, notebooks, and labels from a previous export. Duplicates are skipped.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
data: {
|
||||
type: 'object',
|
||||
description: 'The exported JSON data (from export_notes)',
|
||||
description: 'Export JSON (from export_notes)',
|
||||
properties: {
|
||||
version: { type: 'string' },
|
||||
data: {
|
||||
@@ -254,31 +244,29 @@ const toolDefinitions = [
|
||||
},
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// NOTEBOOK TOOLS
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// ═══ NOTEBOOKS ═══
|
||||
{
|
||||
name: 'create_notebook',
|
||||
description: 'Create a new notebook.',
|
||||
description: 'Create a notebook with a name, icon, and color.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string', description: 'Notebook name' },
|
||||
icon: { type: 'string', description: 'Notebook icon (emoji)', default: '📁' },
|
||||
color: { type: 'string', description: 'Hex color code', default: '#3B82F6' },
|
||||
order: { type: 'number', description: 'Sort position (auto-assigned if omitted)' },
|
||||
icon: { type: 'string', description: 'Icon (emoji)', default: '📁' },
|
||||
color: { type: 'string', description: 'Hex color', default: '#3B82F6' },
|
||||
order: { type: 'number', description: 'Sort position (auto if omitted)' },
|
||||
},
|
||||
required: ['name'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'get_notebooks',
|
||||
description: 'Get all notebooks with label and note counts.',
|
||||
description: 'List all notebooks with label and note counts.',
|
||||
inputSchema: { type: 'object', properties: {} },
|
||||
},
|
||||
{
|
||||
name: 'get_notebook',
|
||||
description: 'Get a notebook by ID, including its notes.',
|
||||
description: 'Get a notebook by ID including its notes.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: { id: { type: 'string', description: 'Notebook ID' } },
|
||||
@@ -287,7 +275,7 @@ const toolDefinitions = [
|
||||
},
|
||||
{
|
||||
name: 'update_notebook',
|
||||
description: 'Update a notebook\'s name, icon, color, or order.',
|
||||
description: 'Update a notebook name, icon, color, or order.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -302,7 +290,7 @@ const toolDefinitions = [
|
||||
},
|
||||
{
|
||||
name: 'delete_notebook',
|
||||
description: 'Delete a notebook. Notes inside will be moved to Inbox.',
|
||||
description: 'Delete a notebook. Notes inside are moved to Inbox.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: { id: { type: 'string', description: 'Notebook ID' } },
|
||||
@@ -311,13 +299,13 @@ const toolDefinitions = [
|
||||
},
|
||||
{
|
||||
name: 'reorder_notebooks',
|
||||
description: 'Reorder notebooks. Pass an ordered array of notebook IDs.',
|
||||
description: 'Set the order of all notebooks by passing their IDs in sequence.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
notebookIds: {
|
||||
type: 'array',
|
||||
description: 'Notebook IDs in the desired order',
|
||||
description: 'Notebook IDs in desired order',
|
||||
items: { type: 'string' },
|
||||
},
|
||||
},
|
||||
@@ -325,35 +313,33 @@ const toolDefinitions = [
|
||||
},
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// LABEL TOOLS
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// ═══ LABELS ═══
|
||||
{
|
||||
name: 'create_label',
|
||||
description: 'Create a label in a notebook.',
|
||||
description: 'Create a label inside a notebook.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string', description: 'Label name' },
|
||||
color: { type: 'string', description: `Label color: ${LABEL_COLORS.join(', ')}` },
|
||||
notebookId: { type: 'string', description: 'Notebook to attach the label to' },
|
||||
color: { type: 'string', description: `Color: ${LABEL_COLORS}` },
|
||||
notebookId: { type: 'string', description: 'Parent notebook ID' },
|
||||
},
|
||||
required: ['name', 'notebookId'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'get_labels',
|
||||
description: 'Get all labels, optionally filtered by notebook.',
|
||||
description: 'List labels, optionally filtered by notebook.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
notebookId: { type: 'string', description: 'Filter labels by notebook ID' },
|
||||
notebookId: { type: 'string', description: 'Filter by notebook' },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'update_label',
|
||||
description: 'Update a label\'s name or color.',
|
||||
description: 'Update a label name or color.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -366,7 +352,7 @@ const toolDefinitions = [
|
||||
},
|
||||
{
|
||||
name: 'delete_label',
|
||||
description: 'Delete a label by ID.',
|
||||
description: 'Delete a label.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: { id: { type: 'string', description: 'Label ID' } },
|
||||
@@ -374,33 +360,23 @@ const toolDefinitions = [
|
||||
},
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// REMINDER TOOLS
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// ═══ REMINDERS ═══
|
||||
{
|
||||
name: 'get_due_reminders',
|
||||
description: 'Get all notes with due reminders that haven\'t been processed yet. Designed for cron/automation use.',
|
||||
description: 'Get notes with due reminders. Designed for cron/automation.',
|
||||
inputSchema: { type: 'object', properties: {} },
|
||||
},
|
||||
];
|
||||
|
||||
// ─── Tool Handlers ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Register all tools and handlers on an MCP Server instance.
|
||||
*
|
||||
* @param {import('@modelcontextprotocol/sdk/server/index.js').Server} server
|
||||
* @param {import('@prisma/client').PrismaClient} prisma
|
||||
*/
|
||||
export function registerTools(server, prisma) {
|
||||
|
||||
// Resolve userId per-request from AsyncLocalStorage (set by auth middleware)
|
||||
const getResolvedUserId = () => {
|
||||
const store = requestContext.getStore();
|
||||
return store?.userId || null;
|
||||
};
|
||||
|
||||
// Fallback: auto-detect first user when no auth context
|
||||
let fallbackUserId = null;
|
||||
let fallbackPromise = null;
|
||||
|
||||
@@ -418,22 +394,24 @@ export function registerTools(server, prisma) {
|
||||
return fallbackPromise;
|
||||
};
|
||||
|
||||
// ── List Tools ────────────────────────────────────────────────────────────
|
||||
const noteWhere = (resolvedUserId, extra = {}) => ({
|
||||
trashedAt: null,
|
||||
...(resolvedUserId ? { userId: resolvedUserId } : {}),
|
||||
...extra,
|
||||
});
|
||||
|
||||
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||
return { tools: toolDefinitions };
|
||||
});
|
||||
|
||||
// ── Call Tools ────────────────────────────────────────────────────────────
|
||||
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
const { name, arguments: args } = request.params;
|
||||
const resolvedUserId = getResolvedUserId();
|
||||
const uid = getResolvedUserId();
|
||||
|
||||
try {
|
||||
switch (name) {
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// NOTES
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// ═══ NOTES ═══
|
||||
case 'create_note': {
|
||||
const data = {
|
||||
title: args.title || null,
|
||||
@@ -453,31 +431,29 @@ export function registerTools(server, prisma) {
|
||||
isMarkdown: args.isMarkdown || false,
|
||||
size: args.size || 'small',
|
||||
notebookId: args.notebookId || null,
|
||||
userId: uid || await ensureUserId(),
|
||||
};
|
||||
if (resolvedUserId) data.userId = resolvedUserId;
|
||||
else data.userId = await ensureUserId();
|
||||
|
||||
const note = await prisma.note.create({ data });
|
||||
return textResult(parseNote(note));
|
||||
}
|
||||
|
||||
case 'get_notes': {
|
||||
const where = {};
|
||||
if (resolvedUserId) where.userId = resolvedUserId;
|
||||
if (!args?.includeArchived) where.isArchived = false;
|
||||
const extra = {};
|
||||
if (!args?.includeArchived) extra.isArchived = false;
|
||||
if (args?.search) {
|
||||
where.OR = [
|
||||
extra.OR = [
|
||||
{ title: { contains: args.search } },
|
||||
{ content: { contains: args.search } },
|
||||
];
|
||||
}
|
||||
if (args?.notebookId) {
|
||||
where.notebookId = args.notebookId === 'inbox' ? null : args.notebookId;
|
||||
extra.notebookId = args.notebookId === 'inbox' ? null : args.notebookId;
|
||||
}
|
||||
|
||||
const limit = Math.min(args?.limit || DEFAULT_NOTES_LIMIT, 500); // Max 500
|
||||
const limit = Math.min(args?.limit || DEFAULT_NOTES_LIMIT, MAX_NOTES_LIMIT);
|
||||
const notes = await prisma.note.findMany({
|
||||
where,
|
||||
where: noteWhere(uid, extra),
|
||||
orderBy: [{ isPinned: 'desc' }, { order: 'asc' }, { updatedAt: 'desc' }],
|
||||
take: limit,
|
||||
});
|
||||
@@ -487,50 +463,50 @@ export function registerTools(server, prisma) {
|
||||
}
|
||||
|
||||
case 'get_note': {
|
||||
const where = { id: args.id };
|
||||
if (resolvedUserId) where.userId = resolvedUserId;
|
||||
const note = await prisma.note.findUnique({ where });
|
||||
const note = await prisma.note.findUnique({
|
||||
where: { id: args.id, ...(uid ? { userId: uid } : {}), trashedAt: null },
|
||||
});
|
||||
if (!note) throw new McpError(ErrorCode.InvalidRequest, 'Note not found');
|
||||
return textResult(parseNote(note));
|
||||
}
|
||||
|
||||
case 'update_note': {
|
||||
const updateData = {};
|
||||
const fields = ['title', 'color', 'type', 'isPinned', 'isArchived', 'isMarkdown', 'size', 'notebookId', 'isReminderDone', 'reminderRecurrence', 'reminderLocation'];
|
||||
for (const f of fields) {
|
||||
if (f in args) updateData[f] = args[f];
|
||||
const d = {};
|
||||
const simpleFields = ['title', 'color', 'type', 'isPinned', 'isArchived', 'isMarkdown', 'size', 'notebookId', 'isReminderDone', 'reminderRecurrence', 'reminderLocation'];
|
||||
for (const f of simpleFields) {
|
||||
if (f in args) d[f] = args[f];
|
||||
}
|
||||
if ('content' in args) updateData.content = args.content;
|
||||
if ('checkItems' in args) updateData.checkItems = args.checkItems ?? null;
|
||||
if ('labels' in args) updateData.labels = args.labels ?? null;
|
||||
if ('images' in args) updateData.images = args.images ?? null;
|
||||
if ('links' in args) updateData.links = args.links ?? null;
|
||||
if ('reminder' in args) updateData.reminder = args.reminder ? new Date(args.reminder) : null;
|
||||
updateData.updatedAt = new Date();
|
||||
if ('content' in args) d.content = args.content;
|
||||
if ('checkItems' in args) d.checkItems = args.checkItems ?? null;
|
||||
if ('labels' in args) d.labels = args.labels ?? null;
|
||||
if ('images' in args) d.images = args.images ?? null;
|
||||
if ('links' in args) d.links = args.links ?? null;
|
||||
if ('reminder' in args) d.reminder = args.reminder ? new Date(args.reminder) : null;
|
||||
d.updatedAt = new Date();
|
||||
|
||||
const where = { id: args.id };
|
||||
if (resolvedUserId) where.userId = resolvedUserId;
|
||||
const note = await prisma.note.update({ where, data: updateData });
|
||||
const note = await prisma.note.update({
|
||||
where: { id: args.id, ...(uid ? { userId: uid } : {}), trashedAt: null },
|
||||
data: d,
|
||||
});
|
||||
return textResult(parseNote(note));
|
||||
}
|
||||
|
||||
case 'delete_note': {
|
||||
const where = { id: args.id };
|
||||
if (resolvedUserId) where.userId = resolvedUserId;
|
||||
await prisma.note.delete({ where });
|
||||
return textResult({ success: true, message: 'Note deleted' });
|
||||
await prisma.note.delete({
|
||||
where: { id: args.id, ...(uid ? { userId: uid } : {}), trashedAt: null },
|
||||
});
|
||||
return textResult({ success: true, deleted: args.id });
|
||||
}
|
||||
|
||||
case 'search_notes': {
|
||||
const where = {
|
||||
const where = noteWhere(uid, {
|
||||
isArchived: args.includeArchived || false,
|
||||
OR: [
|
||||
{ title: { contains: args.query } },
|
||||
{ content: { contains: args.query } },
|
||||
],
|
||||
};
|
||||
if (resolvedUserId) where.userId = resolvedUserId;
|
||||
if (args.notebookId) where.notebookId = args.notebookId;
|
||||
...(args.notebookId ? { notebookId: args.notebookId } : {}),
|
||||
});
|
||||
|
||||
const notes = await prisma.note.findMany({
|
||||
where,
|
||||
@@ -541,79 +517,61 @@ export function registerTools(server, prisma) {
|
||||
}
|
||||
|
||||
case 'move_note': {
|
||||
const noteWhere = { id: args.id };
|
||||
if (resolvedUserId) noteWhere.userId = resolvedUserId;
|
||||
const targetId = args.notebookId || null;
|
||||
|
||||
const targetNotebookId = args.notebookId || null;
|
||||
|
||||
// Optimized: Parallel execution
|
||||
const [note, notebook] = await Promise.all([
|
||||
prisma.note.update({
|
||||
where: noteWhere,
|
||||
data: { notebookId: targetNotebookId, updatedAt: new Date() },
|
||||
where: { id: args.id, ...(uid ? { userId: uid } : {}), trashedAt: null },
|
||||
data: { notebookId: targetId, updatedAt: new Date() },
|
||||
}),
|
||||
targetNotebookId
|
||||
? prisma.notebook.findUnique({ where: { id: targetNotebookId }, select: { name: true } })
|
||||
targetId
|
||||
? prisma.notebook.findUnique({ where: { id: targetId }, select: { name: true } })
|
||||
: Promise.resolve(null),
|
||||
]);
|
||||
|
||||
const notebookName = notebook?.name || 'Inbox';
|
||||
|
||||
return textResult({
|
||||
success: true,
|
||||
data: { id: note.id, notebookId: note.notebookId, notebook: { name: notebookName } },
|
||||
message: `Note moved to ${notebookName}`,
|
||||
id: note.id,
|
||||
notebookId: note.notebookId,
|
||||
notebookName: notebook?.name || 'Inbox',
|
||||
});
|
||||
}
|
||||
|
||||
case 'toggle_pin': {
|
||||
const where = { id: args.id };
|
||||
if (resolvedUserId) where.userId = resolvedUserId;
|
||||
const note = await prisma.note.findUnique({ where });
|
||||
const note = await prisma.note.findUnique({
|
||||
where: { id: args.id, ...(uid ? { userId: uid } : {}), trashedAt: null },
|
||||
});
|
||||
if (!note) throw new McpError(ErrorCode.InvalidRequest, 'Note not found');
|
||||
const updated = await prisma.note.update({
|
||||
where,
|
||||
where: { id: args.id },
|
||||
data: { isPinned: !note.isPinned },
|
||||
});
|
||||
return textResult(parseNote(updated));
|
||||
}
|
||||
|
||||
case 'toggle_archive': {
|
||||
const where = { id: args.id };
|
||||
if (resolvedUserId) where.userId = resolvedUserId;
|
||||
const note = await prisma.note.findUnique({ where });
|
||||
const note = await prisma.note.findUnique({
|
||||
where: { id: args.id, ...(uid ? { userId: uid } : {}), trashedAt: null },
|
||||
});
|
||||
if (!note) throw new McpError(ErrorCode.InvalidRequest, 'Note not found');
|
||||
const updated = await prisma.note.update({
|
||||
where,
|
||||
where: { id: args.id },
|
||||
data: { isArchived: !note.isArchived },
|
||||
});
|
||||
return textResult(parseNote(updated));
|
||||
}
|
||||
|
||||
case 'export_notes': {
|
||||
const noteWhere = {};
|
||||
const nbWhere = {};
|
||||
if (resolvedUserId) { noteWhere.userId = resolvedUserId; nbWhere.userId = resolvedUserId; }
|
||||
const nbWhere = uid ? { userId: uid } : {};
|
||||
|
||||
// Optimized: Parallel queries
|
||||
const [notes, notebooks, labels] = await Promise.all([
|
||||
prisma.note.findMany({
|
||||
where: noteWhere,
|
||||
prisma.note.findMany({
|
||||
where: noteWhere(uid),
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
content: true,
|
||||
color: true,
|
||||
type: true,
|
||||
isPinned: true,
|
||||
isArchived: true,
|
||||
isMarkdown: true,
|
||||
size: true,
|
||||
labels: true,
|
||||
notebookId: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
id: true, title: true, content: true, color: true, type: true,
|
||||
isPinned: true, isArchived: true, isMarkdown: true, size: true,
|
||||
labels: true, notebookId: true, createdAt: true, updatedAt: true,
|
||||
},
|
||||
}),
|
||||
prisma.notebook.findMany({
|
||||
@@ -626,9 +584,8 @@ export function registerTools(server, prisma) {
|
||||
}),
|
||||
]);
|
||||
|
||||
// Filter labels by userId in memory (faster than multiple queries)
|
||||
const filteredLabels = resolvedUserId
|
||||
? labels.filter(l => l.notebook?.userId === resolvedUserId)
|
||||
const filteredLabels = uid
|
||||
? labels.filter(l => l.notebook?.userId === uid)
|
||||
: labels;
|
||||
|
||||
return textResult({
|
||||
@@ -636,32 +593,16 @@ export function registerTools(server, prisma) {
|
||||
exportDate: new Date().toISOString(),
|
||||
data: {
|
||||
notes: notes.map(n => ({
|
||||
id: n.id,
|
||||
title: n.title,
|
||||
content: n.content,
|
||||
color: n.color,
|
||||
type: n.type,
|
||||
isPinned: n.isPinned,
|
||||
isArchived: n.isArchived,
|
||||
isMarkdown: n.isMarkdown,
|
||||
size: n.size,
|
||||
labels: Array.isArray(n.labels) ? n.labels : [],
|
||||
notebookId: n.notebookId,
|
||||
createdAt: n.createdAt,
|
||||
updatedAt: n.updatedAt,
|
||||
id: n.id, title: n.title, content: n.content, color: n.color, type: n.type,
|
||||
isPinned: n.isPinned, isArchived: n.isArchived, isMarkdown: n.isMarkdown,
|
||||
size: n.size, labels: Array.isArray(n.labels) ? n.labels : [],
|
||||
notebookId: n.notebookId, createdAt: n.createdAt, updatedAt: n.updatedAt,
|
||||
})),
|
||||
notebooks: notebooks.map(nb => ({
|
||||
id: nb.id,
|
||||
name: nb.name,
|
||||
icon: nb.icon,
|
||||
color: nb.color,
|
||||
noteCount: nb._count.notes,
|
||||
id: nb.id, name: nb.name, icon: nb.icon, color: nb.color, noteCount: nb._count.notes,
|
||||
})),
|
||||
labels: filteredLabels.map(l => ({
|
||||
id: l.id,
|
||||
name: l.name,
|
||||
color: l.color,
|
||||
notebookId: l.notebookId,
|
||||
id: l.id, name: l.name, color: l.color, notebookId: l.notebookId,
|
||||
})),
|
||||
},
|
||||
});
|
||||
@@ -671,60 +612,51 @@ export function registerTools(server, prisma) {
|
||||
const importData = args.data;
|
||||
let importedNotes = 0, importedLabels = 0, importedNotebooks = 0;
|
||||
|
||||
// OPTIMIZED: Batch operations with Promise.all for notebooks
|
||||
if (importData.data?.notebooks?.length > 0) {
|
||||
const existingNotebooks = await prisma.notebook.findMany({
|
||||
where: resolvedUserId ? { userId: resolvedUserId } : {},
|
||||
const existing = await prisma.notebook.findMany({
|
||||
where: uid ? { userId: uid } : {},
|
||||
select: { name: true },
|
||||
});
|
||||
const existingNames = new Set(existingNotebooks.map(nb => nb.name));
|
||||
const existingNames = new Set(existing.map(nb => nb.name));
|
||||
|
||||
const notebooksToCreate = importData.data.notebooks
|
||||
const toCreate = importData.data.notebooks
|
||||
.filter(nb => !existingNames.has(nb.name))
|
||||
.map(nb => prisma.notebook.create({
|
||||
data: {
|
||||
name: nb.name,
|
||||
icon: nb.icon || '📁',
|
||||
color: nb.color || '#3B82F6',
|
||||
...(resolvedUserId ? { userId: resolvedUserId } : {}),
|
||||
},
|
||||
.map(nb => ({
|
||||
name: nb.name,
|
||||
icon: nb.icon || '📁',
|
||||
color: nb.color || '#3B82F6',
|
||||
...(uid ? { userId: uid } : {}),
|
||||
}));
|
||||
|
||||
await Promise.all(notebooksToCreate);
|
||||
importedNotebooks = notebooksToCreate.length;
|
||||
if (toCreate.length > 0) {
|
||||
await prisma.notebook.createMany({ data: toCreate, skipDuplicates: true });
|
||||
importedNotebooks = toCreate.length;
|
||||
}
|
||||
}
|
||||
|
||||
// OPTIMIZED: Batch labels
|
||||
if (importData.data?.labels?.length > 0) {
|
||||
const notebooks = await prisma.notebook.findMany({
|
||||
where: resolvedUserId ? { userId: resolvedUserId } : {},
|
||||
where: uid ? { userId: uid } : {},
|
||||
select: { id: true },
|
||||
});
|
||||
const notebookIds = new Set(notebooks.map(nb => nb.id));
|
||||
const nbIds = new Set(notebooks.map(nb => nb.id));
|
||||
|
||||
const existingLabels = await prisma.label.findMany({
|
||||
where: { notebookId: { in: Array.from(notebookIds) } },
|
||||
const existing = await prisma.label.findMany({
|
||||
where: { notebookId: { in: Array.from(nbIds) } },
|
||||
select: { name: true, notebookId: true },
|
||||
});
|
||||
const existingLabelKeys = new Set(existingLabels.map(l => `${l.notebookId}:${l.name}`));
|
||||
const existingKeys = new Set(existing.map(l => `${l.notebookId}:${l.name}`));
|
||||
|
||||
const labelsToCreate = [];
|
||||
for (const label of importData.data.labels) {
|
||||
if (label.notebookId && notebookIds.has(label.notebookId)) {
|
||||
const key = `${label.notebookId}:${label.name}`;
|
||||
if (!existingLabelKeys.has(key)) {
|
||||
labelsToCreate.push(prisma.label.create({
|
||||
data: { name: label.name, color: label.color, notebookId: label.notebookId },
|
||||
}));
|
||||
}
|
||||
}
|
||||
const toCreate = importData.data.labels
|
||||
.filter(l => l.notebookId && nbIds.has(l.notebookId) && !existingKeys.has(`${l.notebookId}:${l.name}`))
|
||||
.map(l => ({ name: l.name, color: l.color, notebookId: l.notebookId }));
|
||||
|
||||
if (toCreate.length > 0) {
|
||||
await prisma.label.createMany({ data: toCreate, skipDuplicates: true });
|
||||
importedLabels = toCreate.length;
|
||||
}
|
||||
|
||||
await Promise.all(labelsToCreate);
|
||||
importedLabels = labelsToCreate.length;
|
||||
}
|
||||
|
||||
// OPTIMIZED: Batch notes with createMany if available, else Promise.all
|
||||
if (importData.data?.notes?.length > 0) {
|
||||
const notesData = importData.data.notes.map(note => ({
|
||||
title: note.title,
|
||||
@@ -737,22 +669,14 @@ export function registerTools(server, prisma) {
|
||||
size: note.size || 'small',
|
||||
labels: note.labels ?? null,
|
||||
notebookId: note.notebookId || null,
|
||||
...(resolvedUserId ? { userId: resolvedUserId } : {}),
|
||||
...(uid ? { userId: uid } : {}),
|
||||
}));
|
||||
|
||||
// Try createMany first (faster), fall back to Promise.all
|
||||
try {
|
||||
const result = await prisma.note.createMany({
|
||||
data: notesData,
|
||||
skipDuplicates: true,
|
||||
});
|
||||
const result = await prisma.note.createMany({ data: notesData, skipDuplicates: true });
|
||||
importedNotes = result.count;
|
||||
} catch {
|
||||
// Fallback to individual creates
|
||||
const creates = notesData.map(data =>
|
||||
prisma.note.create({ data }).catch(() => null)
|
||||
);
|
||||
const results = await Promise.all(creates);
|
||||
const results = await Promise.all(notesData.map(d => prisma.note.create({ data: d }).catch(() => null)));
|
||||
importedNotes = results.filter(r => r !== null).length;
|
||||
}
|
||||
}
|
||||
@@ -763,27 +687,23 @@ export function registerTools(server, prisma) {
|
||||
});
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// NOTEBOOKS
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// ═══ NOTEBOOKS ═══
|
||||
case 'create_notebook': {
|
||||
const highestOrder = await prisma.notebook.findFirst({
|
||||
where: resolvedUserId ? { userId: resolvedUserId } : {},
|
||||
const highest = await prisma.notebook.findFirst({
|
||||
where: uid ? { userId: uid } : {},
|
||||
orderBy: { order: 'desc' },
|
||||
select: { order: true },
|
||||
});
|
||||
const nextOrder = args.order !== undefined ? args.order : (highestOrder?.order ?? -1) + 1;
|
||||
|
||||
const data = {
|
||||
name: args.name.trim(),
|
||||
icon: args.icon || '📁',
|
||||
color: args.color || '#3B82F6',
|
||||
order: nextOrder,
|
||||
};
|
||||
data.userId = resolvedUserId || await ensureUserId();
|
||||
const nextOrder = args.order !== undefined ? args.order : (highest?.order ?? -1) + 1;
|
||||
|
||||
const notebook = await prisma.notebook.create({
|
||||
data,
|
||||
data: {
|
||||
name: args.name.trim(),
|
||||
icon: args.icon || '📁',
|
||||
color: args.color || '#3B82F6',
|
||||
order: nextOrder,
|
||||
userId: uid || await ensureUserId(),
|
||||
},
|
||||
include: { labels: true, _count: { select: { notes: true } } },
|
||||
});
|
||||
|
||||
@@ -791,9 +711,7 @@ export function registerTools(server, prisma) {
|
||||
}
|
||||
|
||||
case 'get_notebooks': {
|
||||
const where = {};
|
||||
if (resolvedUserId) where.userId = resolvedUserId;
|
||||
|
||||
const where = uid ? { userId: uid } : {};
|
||||
const notebooks = await prisma.notebook.findMany({
|
||||
where,
|
||||
include: {
|
||||
@@ -807,12 +725,10 @@ export function registerTools(server, prisma) {
|
||||
}
|
||||
|
||||
case 'get_notebook': {
|
||||
const where = { id: args.id };
|
||||
if (resolvedUserId) where.userId = resolvedUserId;
|
||||
|
||||
const where = { id: args.id, ...(uid ? { userId: uid } : {}) };
|
||||
const notebook = await prisma.notebook.findUnique({
|
||||
where,
|
||||
include: { labels: true, notes: true, _count: { select: { notes: true } } },
|
||||
include: { labels: true, notes: { where: { trashedAt: null } }, _count: { select: { notes: true } } },
|
||||
});
|
||||
if (!notebook) throw new McpError(ErrorCode.InvalidRequest, 'Notebook not found');
|
||||
|
||||
@@ -824,18 +740,16 @@ export function registerTools(server, prisma) {
|
||||
}
|
||||
|
||||
case 'update_notebook': {
|
||||
const updateData = {};
|
||||
if ('name' in args) updateData.name = args.name.trim();
|
||||
if ('icon' in args) updateData.icon = args.icon;
|
||||
if ('color' in args) updateData.color = args.color;
|
||||
if ('order' in args) updateData.order = args.order;
|
||||
|
||||
const where = { id: args.id };
|
||||
if (resolvedUserId) where.userId = resolvedUserId;
|
||||
const d = {};
|
||||
if ('name' in args) d.name = args.name.trim();
|
||||
if ('icon' in args) d.icon = args.icon;
|
||||
if ('color' in args) d.color = args.color;
|
||||
if ('order' in args) d.order = args.order;
|
||||
|
||||
const where = { id: args.id, ...(uid ? { userId: uid } : {}) };
|
||||
const notebook = await prisma.notebook.update({
|
||||
where,
|
||||
data: updateData,
|
||||
data: d,
|
||||
include: { labels: true, _count: { select: { notes: true } } },
|
||||
});
|
||||
|
||||
@@ -843,38 +757,29 @@ export function registerTools(server, prisma) {
|
||||
}
|
||||
|
||||
case 'delete_notebook': {
|
||||
const where = { id: args.id };
|
||||
if (resolvedUserId) where.userId = resolvedUserId;
|
||||
|
||||
// Move notes to inbox before deleting
|
||||
await prisma.$transaction([
|
||||
prisma.note.updateMany({
|
||||
where: { notebookId: args.id, ...(resolvedUserId ? { userId: resolvedUserId } : {}) },
|
||||
where: { notebookId: args.id, ...(uid ? { userId: uid } : {}) },
|
||||
data: { notebookId: null },
|
||||
}),
|
||||
prisma.notebook.delete({ where }),
|
||||
prisma.notebook.delete({
|
||||
where: { id: args.id, ...(uid ? { userId: uid } : {}) },
|
||||
}),
|
||||
]);
|
||||
|
||||
|
||||
return textResult({ success: true, message: 'Notebook deleted, notes moved to Inbox' });
|
||||
}
|
||||
|
||||
case 'reorder_notebooks': {
|
||||
const ids = args.notebookIds;
|
||||
|
||||
// Optimized: Verify ownership in one query
|
||||
const where = { id: { in: ids } };
|
||||
if (resolvedUserId) where.userId = resolvedUserId;
|
||||
|
||||
const existingNotebooks = await prisma.notebook.findMany({
|
||||
where,
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
const existingIds = new Set(existingNotebooks.map(nb => nb.id));
|
||||
const missingIds = ids.filter(id => !existingIds.has(id));
|
||||
|
||||
if (missingIds.length > 0) {
|
||||
throw new McpError(ErrorCode.InvalidRequest, `Notebooks not found: ${missingIds.join(', ')}`);
|
||||
const where = { id: { in: ids }, ...(uid ? { userId: uid } : {}) };
|
||||
|
||||
const existing = await prisma.notebook.findMany({ where, select: { id: true } });
|
||||
const existingIds = new Set(existing.map(nb => nb.id));
|
||||
const missing = ids.filter(id => !existingIds.has(id));
|
||||
|
||||
if (missing.length > 0) {
|
||||
throw new McpError(ErrorCode.InvalidRequest, `Notebooks not found: ${missing.join(', ')}`);
|
||||
}
|
||||
|
||||
await prisma.$transaction(
|
||||
@@ -882,12 +787,10 @@ export function registerTools(server, prisma) {
|
||||
prisma.notebook.update({ where: { id }, data: { order: index } })
|
||||
)
|
||||
);
|
||||
return textResult({ success: true, message: 'Notebooks reordered' });
|
||||
return textResult({ success: true });
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// LABELS - OPTIMIZED to fix N+1 query
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// ═══ LABELS ═══
|
||||
case 'create_label': {
|
||||
const existing = await prisma.label.findFirst({
|
||||
where: { name: args.name.trim(), notebookId: args.notebookId },
|
||||
@@ -908,49 +811,37 @@ export function registerTools(server, prisma) {
|
||||
const where = {};
|
||||
if (args?.notebookId) where.notebookId = args.notebookId;
|
||||
|
||||
// OPTIMIZED: Single query with include, then filter in memory
|
||||
const labels = await prisma.label.findMany({
|
||||
where,
|
||||
include: { notebook: { select: { id: true, name: true, userId: true } } },
|
||||
orderBy: { name: 'asc' },
|
||||
});
|
||||
|
||||
// Filter by userId in memory (much faster than N+1 queries)
|
||||
let filteredLabels = labels;
|
||||
if (resolvedUserId) {
|
||||
filteredLabels = labels.filter(l => l.notebook?.userId === resolvedUserId);
|
||||
}
|
||||
|
||||
return textResult(filteredLabels);
|
||||
const filtered = uid ? labels.filter(l => l.notebook?.userId === uid) : labels;
|
||||
return textResult(filtered);
|
||||
}
|
||||
|
||||
case 'update_label': {
|
||||
const updateData = {};
|
||||
if ('name' in args) updateData.name = args.name.trim();
|
||||
if ('color' in args) updateData.color = args.color;
|
||||
const d = {};
|
||||
if ('name' in args) d.name = args.name.trim();
|
||||
if ('color' in args) d.color = args.color;
|
||||
|
||||
const label = await prisma.label.update({
|
||||
where: { id: args.id },
|
||||
data: updateData,
|
||||
});
|
||||
const label = await prisma.label.update({ where: { id: args.id }, data: d });
|
||||
return textResult(label);
|
||||
}
|
||||
|
||||
case 'delete_label': {
|
||||
await prisma.label.delete({ where: { id: args.id } });
|
||||
return textResult({ success: true, message: 'Label deleted' });
|
||||
return textResult({ success: true });
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// REMINDERS
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// ═══ REMINDERS ═══
|
||||
case 'get_due_reminders': {
|
||||
const where = {
|
||||
const where = noteWhere(uid, {
|
||||
reminder: { not: null, lte: new Date() },
|
||||
isReminderDone: false,
|
||||
isArchived: false,
|
||||
};
|
||||
if (resolvedUserId) where.userId = resolvedUserId;
|
||||
});
|
||||
|
||||
const reminders = await prisma.note.findMany({
|
||||
where,
|
||||
@@ -958,7 +849,6 @@ export function registerTools(server, prisma) {
|
||||
orderBy: { reminder: 'asc' },
|
||||
});
|
||||
|
||||
// Mark them as done
|
||||
if (reminders.length > 0) {
|
||||
await prisma.note.updateMany({
|
||||
where: { id: { in: reminders.map(r => r.id) } },
|
||||
@@ -966,7 +856,7 @@ export function registerTools(server, prisma) {
|
||||
});
|
||||
}
|
||||
|
||||
return textResult({ success: true, count: reminders.length, reminders });
|
||||
return textResult({ count: reminders.length, reminders });
|
||||
}
|
||||
|
||||
default:
|
||||
@@ -974,7 +864,7 @@ export function registerTools(server, prisma) {
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof McpError) throw error;
|
||||
throw new McpError(ErrorCode.InternalError, `Tool execution failed: ${error.message}`);
|
||||
throw new McpError(ErrorCode.InternalError, `Tool error: ${error.message}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user