feat: Add Smart Auto-Sorting and Auto-Tagging Rules (Inbox Triage Engine)

This commit is contained in:
chrisf committed 2026-10-22 17:38:00 -06:00
1 parent 2f14f48ac1
commit 44d1dec42b
11 files changed
+1782 -20

No files matched your search

+159
View File
@@ -0,0 +1,159 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma, getCurrentUserId } from '@/lib/prisma';
export async function GET() {
try {
const userId = await getCurrentUserId();
const rules = await prisma.autoSortRule.findMany({
where: { userId },
orderBy: { priority: 'asc' },
include: {
targetNotebook: {
select: { id: true, name: true },
},
},
});
return NextResponse.json({ rules });
} catch (err: unknown) {
console.error('GET /api/auto-sort error:', err);
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Failed to fetch rules' },
{ status: 500 }
);
}
}
export async function POST(req: NextRequest) {
try {
const userId = await getCurrentUserId();
const body = await req.json();
const name = (body.name || '').trim();
if (!name) {
return NextResponse.json({ error: 'Rule name is required' }, { status: 400 });
}
// Determine default priority if not provided
let priority = typeof body.priority === 'number' ? body.priority : 0;
if (priority === 0) {
const highest = await prisma.autoSortRule.findFirst({
where: { userId },
orderBy: { priority: 'desc' },
select: { priority: true },
});
priority = (highest?.priority ?? -1) + 1;
}
const rule = await prisma.autoSortRule.create({
data: {
userId,
name,
enabled: body.enabled !== false,
priority,
conditionField: body.conditionField || 'title',
conditionOperator: body.conditionOperator || 'contains',
conditionValue: (body.conditionValue || '').trim(),
targetNotebookId: body.targetNotebookId || null,
addTags: (body.addTags || '').trim(),
isPinned: typeof body.isPinned === 'boolean' ? body.isPinned : null,
isShortcut: typeof body.isShortcut === 'boolean' ? body.isShortcut : null,
stopOnMatch: body.stopOnMatch !== false,
},
include: {
targetNotebook: {
select: { id: true, name: true },
},
},
});
return NextResponse.json(rule, { status: 201 });
} catch (err: unknown) {
console.error('POST /api/auto-sort error:', err);
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Failed to create rule' },
{ status: 500 }
);
}
}
export async function PUT(req: NextRequest) {
try {
const userId = await getCurrentUserId();
const body = await req.json();
// Support bulk reordering
if (body.action === 'reorder' && Array.isArray(body.ruleIds)) {
await Promise.all(
body.ruleIds.map((id: string, index: number) =>
prisma.autoSortRule.updateMany({
where: { id, userId },
data: { priority: index },
})
)
);
return NextResponse.json({ success: true });
}
const { id } = body;
if (!id) {
return NextResponse.json({ error: 'Rule ID required' }, { status: 400 });
}
const updateData: Record<string, unknown> = {};
if (typeof body.name === 'string') updateData.name = body.name.trim();
if (typeof body.enabled === 'boolean') updateData.enabled = body.enabled;
if (typeof body.priority === 'number') updateData.priority = body.priority;
if (typeof body.conditionField === 'string') updateData.conditionField = body.conditionField;
if (typeof body.conditionOperator === 'string')
updateData.conditionOperator = body.conditionOperator;
if (typeof body.conditionValue === 'string') updateData.conditionValue = body.conditionValue.trim();
if (body.targetNotebookId !== undefined) {
updateData.targetNotebookId = body.targetNotebookId || null;
}
if (typeof body.addTags === 'string') updateData.addTags = body.addTags.trim();
if (body.isPinned !== undefined) updateData.isPinned = body.isPinned;
if (body.isShortcut !== undefined) updateData.isShortcut = body.isShortcut;
if (typeof body.stopOnMatch === 'boolean') updateData.stopOnMatch = body.stopOnMatch;
const updated = await prisma.autoSortRule.update({
where: { id },
data: updateData,
include: {
targetNotebook: {
select: { id: true, name: true },
},
},
});
return NextResponse.json(updated);
} catch (err: unknown) {
console.error('PUT /api/auto-sort error:', err);
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Failed to update rule' },
{ status: 500 }
);
}
}
export async function DELETE(req: NextRequest) {
try {
const userId = await getCurrentUserId();
const id = req.nextUrl.searchParams.get('id');
if (!id) {
return NextResponse.json({ error: 'Rule ID required' }, { status: 400 });
}
await prisma.autoSortRule.deleteMany({
where: { id, userId },
});
return NextResponse.json({ success: true });
} catch (err: unknown) {
console.error('DELETE /api/auto-sort error:', err);
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Failed to delete rule' },
{ status: 500 }
);
}
}
+34
View File
@@ -0,0 +1,34 @@
import { NextRequest, NextResponse } from 'next/server';
import { getCurrentUserId, ensureDefaultNotebook } from '@/lib/prisma';
import { runAutoSortOnNotebook } from '@/lib/autoSort';
export async function POST(req: NextRequest) {
try {
const userId = await getCurrentUserId();
const body = await req.json().catch(() => ({}));
let notebookId = body.notebookId;
if (!notebookId) {
const defaultNb = await ensureDefaultNotebook(userId);
notebookId = defaultNb.id;
}
const dryRun = body.dryRun === true;
const ruleIds = Array.isArray(body.ruleIds) ? body.ruleIds : undefined;
const result = await runAutoSortOnNotebook({
notebookId,
userId,
dryRun,
ruleIds,
});
return NextResponse.json(result);
} catch (err: unknown) {
console.error('POST /api/auto-sort/run error:', err);
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Failed to run auto-sort' },
{ status: 500 }
);
}
}
+10 -1
View File
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { marked } from 'marked';
import { JSDOM } from 'jsdom';
import { Readability } from '@mozilla/readability';
import { prisma, ensureDefaultNotebook } from '@/lib/prisma';
import { prisma, ensureDefaultNotebook, getCurrentUserId } from '@/lib/prisma';
import { verifyApiKeyFromRequest } from '@/lib/auth';
import {
clipUrlToNote,
@@ -16,6 +16,7 @@ import {
stripHtmlToText,
makeExcerpt,
} from '@/lib/storage';
import { applyAutoSortToNote } from '@/lib/autoSort';
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
@@ -29,6 +30,7 @@ export async function OPTIONS() {
export async function POST(req: NextRequest) {
try {
const userId = await getCurrentUserId();
const isValidKey = await verifyApiKeyFromRequest(req);
const isShareTarget = req.nextUrl.searchParams.get('source') === 'share-target';
@@ -165,6 +167,8 @@ export async function POST(req: NextRequest) {
});
}
await applyAutoSortToNote(note.id, userId).catch(() => {});
return NextResponse.json(
{ ok: true, noteId: note.id, title: note.title, mode: 'extension-article' },
{ status: 201, headers: corsHeaders }
@@ -283,6 +287,8 @@ export async function POST(req: NextRequest) {
});
}
await applyAutoSortToNote(note.id, userId).catch(() => {});
if (isShareTarget) {
return NextResponse.redirect(new URL(`/?noteId=${note.id}`, req.url));
}
@@ -302,6 +308,7 @@ export async function POST(req: NextRequest) {
export async function GET(req: NextRequest) {
try {
const userId = await getCurrentUserId();
const isValidKey = await verifyApiKeyFromRequest(req);
if (!isValidKey) {
return NextResponse.json(
@@ -361,6 +368,8 @@ export async function GET(req: NextRequest) {
noteId = created.id;
}
await applyAutoSortToNote(noteId, userId).catch(() => {});
if (sp.get('redirect') === '1') {
return NextResponse.redirect(new URL(`/?noteId=${noteId}`, req.url));
}
+27
View File
@@ -3,6 +3,7 @@ import { prisma, getCurrentUserId, ensureDefaultNotebook } from '@/lib/prisma';
import { buildNoteWhereClause } from '@/lib/search';
import { stripHtmlToText, makeExcerpt } from '@/lib/storage';
import { getOrCreateTagIds } from '@/lib/importers';
import { applyAutoSortToNote } from '@/lib/autoSort';
import { Prisma } from '@prisma/client';
export async function GET(req: NextRequest) {
@@ -161,6 +162,32 @@ export async function POST(req: NextRequest) {
},
});
// Automatically evaluate auto-sort rules if created in default inbox
if (!body.notebookId || body.notebookId === defaultNb.id) {
try {
const sortRes = await applyAutoSortToNote(note.id, userId);
if (sortRes.matched) {
const refreshed = await prisma.note.findUnique({
where: { id: note.id },
include: {
notebook: true,
tags: { include: { tag: true } },
attachments: true,
},
});
if (refreshed) {
return NextResponse.json({
...refreshed,
tags: refreshed.tags.map((nt) => nt.tag),
autoSortApplied: sortRes,
});
}
}
} catch (sortErr) {
console.error('Auto-sort on note creation failed (non-fatal):', sortErr);
}
}
return NextResponse.json({
...note,
tags: note.tags.map((nt) => nt.tag),