feat: Add Smart Auto-Sorting and Auto-Tagging Rules (Inbox Triage Engine)
This commit is contained in:
1 parent
2f14f48ac1
commit
44d1dec42b
11 files changed
+1782
-20
No files matched your search
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in new issue
Block a user