Add Image OCR search, Note Version History & Shared Notebooks
This commit is contained in:
1 parent
247b63691a
commit
969b6ef083
21 files changed
+2166
-100
No files matched your search
@@ -0,0 +1,47 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { runOcrOnAttachment } from '@/lib/ocr';
|
||||
|
||||
export async function GET(
|
||||
_req: NextRequest,
|
||||
context: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await context.params;
|
||||
const attachment = await prisma.attachment.findUnique({
|
||||
where: { id },
|
||||
select: { id: true, filename: true, mimeType: true, extractedText: true },
|
||||
});
|
||||
|
||||
if (!attachment) {
|
||||
return NextResponse.json({ error: 'Attachment not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
id: attachment.id,
|
||||
filename: attachment.filename,
|
||||
extractedText: attachment.extractedText,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : 'Failed to fetch OCR text' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
_req: NextRequest,
|
||||
context: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await context.params;
|
||||
const result = await runOcrOnAttachment(id);
|
||||
return NextResponse.json(result);
|
||||
} catch (err: unknown) {
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : 'OCR execution failed' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import fs from 'fs/promises';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { getFullAttachmentPath } from '@/lib/storage';
|
||||
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
context: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await context.params;
|
||||
const attachment = await prisma.attachment.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!attachment) {
|
||||
return NextResponse.json({ error: 'Attachment not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const fullPath = getFullAttachmentPath(attachment.storagePath);
|
||||
const fileBuf = await fs.readFile(fullPath);
|
||||
|
||||
const download = req.nextUrl.searchParams.get('download') === '1';
|
||||
const disposition = download ? 'attachment' : 'inline';
|
||||
const safeFilename = encodeURIComponent(attachment.filename);
|
||||
|
||||
return new NextResponse(fileBuf, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': attachment.mimeType || 'application/octet-stream',
|
||||
'Content-Length': String(fileBuf.length),
|
||||
'Content-Disposition': `${disposition}; filename*=UTF-8''${safeFilename}`,
|
||||
'Cache-Control': 'public, max-age=31536000, immutable',
|
||||
},
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : 'Failed to stream attachment' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
_req: NextRequest,
|
||||
context: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await context.params;
|
||||
await prisma.attachment.delete({ where: { id } });
|
||||
return NextResponse.json({ deleted: true });
|
||||
} catch (err: unknown) {
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : 'Failed to delete attachment' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { runOcrOnAttachment } from '@/lib/ocr';
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json().catch(() => ({}));
|
||||
const limit = Math.min(parseInt(String(body.limit || '10'), 10) || 10, 50);
|
||||
|
||||
// Find image or PDF attachments that do not have extractedText yet
|
||||
const pendingAttachments = await prisma.attachment.findMany({
|
||||
where: {
|
||||
extractedText: null,
|
||||
OR: [
|
||||
{ mimeType: { startsWith: 'image/' } },
|
||||
{ mimeType: 'application/pdf' },
|
||||
{ filename: { endsWith: '.pdf', mode: 'insensitive' } },
|
||||
{ filename: { endsWith: '.jpg', mode: 'insensitive' } },
|
||||
{ filename: { endsWith: '.jpeg', mode: 'insensitive' } },
|
||||
{ filename: { endsWith: '.png', mode: 'insensitive' } },
|
||||
{ filename: { endsWith: '.webp', mode: 'insensitive' } },
|
||||
],
|
||||
},
|
||||
take: limit,
|
||||
select: { id: true, filename: true },
|
||||
});
|
||||
|
||||
const results = [];
|
||||
for (const att of pendingAttachments) {
|
||||
const res = await runOcrOnAttachment(att.id);
|
||||
results.push({ id: att.id, filename: att.filename, success: res.success, hasText: Boolean(res.text) });
|
||||
}
|
||||
|
||||
const remainingCount = await prisma.attachment.count({
|
||||
where: {
|
||||
extractedText: null,
|
||||
OR: [
|
||||
{ mimeType: { startsWith: 'image/' } },
|
||||
{ mimeType: 'application/pdf' },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
processedCount: results.length,
|
||||
remainingCount,
|
||||
results,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : 'Batch OCR failed' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { saveAttachmentBuffer } from '@/lib/storage';
|
||||
import { enqueueAttachmentOcr } from '@/lib/ocr';
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const formData = await req.formData();
|
||||
const noteId = formData.get('noteId') as string | null;
|
||||
if (!noteId) {
|
||||
return NextResponse.json({ error: 'noteId is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const files = formData.getAll('files') as File[];
|
||||
const singleFile = formData.get('file') as File | null;
|
||||
if (singleFile && files.length === 0) {
|
||||
files.push(singleFile);
|
||||
}
|
||||
|
||||
const created = [];
|
||||
for (const file of files) {
|
||||
const arrayBuf = await file.arrayBuffer();
|
||||
const buffer = Buffer.from(arrayBuf);
|
||||
const mimeType = file.type || 'application/octet-stream';
|
||||
const filename = file.name || 'attachment';
|
||||
|
||||
const savedBlob = await saveAttachmentBuffer(buffer, filename, mimeType);
|
||||
const attachment = await prisma.attachment.create({
|
||||
data: {
|
||||
noteId,
|
||||
filename,
|
||||
mimeType,
|
||||
sizeBytes: savedBlob.sizeBytes,
|
||||
md5Hash: savedBlob.md5Hash,
|
||||
storagePath: savedBlob.storagePath,
|
||||
extractedText: savedBlob.extractedText,
|
||||
},
|
||||
});
|
||||
created.push(attachment);
|
||||
|
||||
if (!savedBlob.extractedText) {
|
||||
enqueueAttachmentOcr(attachment.id);
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.note.update({
|
||||
where: { id: noteId },
|
||||
data: { updatedAt: new Date() },
|
||||
});
|
||||
|
||||
return NextResponse.json({ attachments: created });
|
||||
} catch (err: unknown) {
|
||||
console.error('Upload attachment error:', err);
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : 'Failed to upload attachment' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { prisma, getCurrentUserId } from '@/lib/prisma';
|
||||
|
||||
export async function GET(
|
||||
_req: NextRequest,
|
||||
context: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const userId = await getCurrentUserId();
|
||||
const { id: notebookId } = await context.params;
|
||||
|
||||
// Verify notebook exists and user has access (either owner or shared)
|
||||
const notebook = await prisma.notebook.findUnique({
|
||||
where: { id: notebookId },
|
||||
include: {
|
||||
shares: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!notebook) {
|
||||
return NextResponse.json({ error: 'Notebook not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const isOwner = notebook.userId === userId;
|
||||
const isCollaborator = notebook.shares.some((s) => s.userId === userId);
|
||||
|
||||
if (!isOwner && !isCollaborator) {
|
||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
// Load collaborator user profiles
|
||||
const userIds = notebook.shares.map((s) => s.userId);
|
||||
const users = await prisma.user.findMany({
|
||||
where: { id: { in: userIds } },
|
||||
select: { id: true, name: true, email: true, avatarColor: true },
|
||||
});
|
||||
const userMap = new Map(users.map((u) => [u.id, u]));
|
||||
|
||||
const sharesWithUsers = notebook.shares.map((s) => ({
|
||||
...s,
|
||||
user: userMap.get(s.userId) || {
|
||||
id: s.userId,
|
||||
name: s.userId,
|
||||
email: `${s.userId}@everclone.local`,
|
||||
avatarColor: '#00a82d',
|
||||
},
|
||||
}));
|
||||
|
||||
return NextResponse.json({
|
||||
isOwner,
|
||||
notebook: {
|
||||
id: notebook.id,
|
||||
name: notebook.name,
|
||||
userId: notebook.userId,
|
||||
},
|
||||
shares: sharesWithUsers,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : 'Failed to fetch notebook shares' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
context: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const userId = await getCurrentUserId();
|
||||
const { id: notebookId } = await context.params;
|
||||
const body = await req.json();
|
||||
|
||||
const notebook = await prisma.notebook.findUnique({
|
||||
where: { id: notebookId },
|
||||
});
|
||||
|
||||
if (!notebook) {
|
||||
return NextResponse.json({ error: 'Notebook not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Only owner can share or modify shares
|
||||
if (notebook.userId !== userId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Only the notebook owner can manage shares' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
let targetUserId = body.userId?.trim();
|
||||
const email = body.email?.trim()?.toLowerCase();
|
||||
const role = body.role === 'read' ? 'read' : 'write';
|
||||
|
||||
if (!targetUserId && email) {
|
||||
let targetUser = await prisma.user.findUnique({ where: { email } });
|
||||
if (!targetUser) {
|
||||
// Create user placeholder for email invitation
|
||||
const username = email.split('@')[0];
|
||||
targetUser = await prisma.user.create({
|
||||
data: {
|
||||
email,
|
||||
name: username.charAt(0).toUpperCase() + username.slice(1),
|
||||
},
|
||||
});
|
||||
}
|
||||
targetUserId = targetUser.id;
|
||||
}
|
||||
|
||||
if (!targetUserId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Target user ID or email is required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (targetUserId === userId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Cannot share a notebook with yourself' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const share = await prisma.notebookShare.upsert({
|
||||
where: {
|
||||
notebookId_userId: {
|
||||
notebookId,
|
||||
userId: targetUserId,
|
||||
},
|
||||
},
|
||||
update: { role },
|
||||
create: {
|
||||
notebookId,
|
||||
userId: targetUserId,
|
||||
role,
|
||||
},
|
||||
});
|
||||
|
||||
const userProfile = await prisma.user.findUnique({
|
||||
where: { id: targetUserId },
|
||||
select: { id: true, name: true, email: true, avatarColor: true },
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
share: {
|
||||
...share,
|
||||
user: userProfile,
|
||||
},
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : 'Failed to save share' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
req: NextRequest,
|
||||
context: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const currentUserId = await getCurrentUserId();
|
||||
const { id: notebookId } = await context.params;
|
||||
const targetUserId = req.nextUrl.searchParams.get('userId');
|
||||
const shareId = req.nextUrl.searchParams.get('shareId');
|
||||
|
||||
const notebook = await prisma.notebook.findUnique({
|
||||
where: { id: notebookId },
|
||||
});
|
||||
|
||||
if (!notebook) {
|
||||
return NextResponse.json({ error: 'Notebook not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Owner can delete any share; collaborator can remove themselves
|
||||
const isOwner = notebook.userId === currentUserId;
|
||||
|
||||
if (!shareId && !targetUserId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'shareId or userId required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (shareId) {
|
||||
const share = await prisma.notebookShare.findUnique({ where: { id: shareId } });
|
||||
if (!share) {
|
||||
return NextResponse.json({ deleted: true });
|
||||
}
|
||||
if (!isOwner && share.userId !== currentUserId) {
|
||||
return NextResponse.json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
await prisma.notebookShare.delete({ where: { id: shareId } });
|
||||
return NextResponse.json({ deleted: true });
|
||||
}
|
||||
|
||||
if (targetUserId) {
|
||||
if (!isOwner && targetUserId !== currentUserId) {
|
||||
return NextResponse.json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
await prisma.notebookShare.deleteMany({
|
||||
where: { notebookId, userId: targetUserId },
|
||||
});
|
||||
return NextResponse.json({ deleted: true });
|
||||
}
|
||||
|
||||
return NextResponse.json({ deleted: true });
|
||||
} catch (err: unknown) {
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : 'Failed to remove share' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { stripHtmlToText, makeExcerpt } from '@/lib/storage';
|
||||
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
context: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await context.params;
|
||||
const historyId = req.nextUrl.searchParams.get('historyId');
|
||||
|
||||
if (historyId) {
|
||||
const snapshot = await prisma.noteHistory.findUnique({
|
||||
where: { id: historyId },
|
||||
});
|
||||
if (!snapshot) {
|
||||
return NextResponse.json({ error: 'Snapshot not found' }, { status: 404 });
|
||||
}
|
||||
return NextResponse.json(snapshot);
|
||||
}
|
||||
|
||||
const history = await prisma.noteHistory.findMany({
|
||||
where: { noteId: id },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: {
|
||||
id: true,
|
||||
noteId: true,
|
||||
title: true,
|
||||
excerpt: true,
|
||||
wordCount: true,
|
||||
createdBy: true,
|
||||
createdAt: true,
|
||||
},
|
||||
take: 50,
|
||||
});
|
||||
|
||||
return NextResponse.json({ history });
|
||||
} catch (err: unknown) {
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : 'Failed to load note history' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
context: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await context.params;
|
||||
const body = await req.json();
|
||||
|
||||
const note = await prisma.note.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!note) {
|
||||
return NextResponse.json({ error: 'Note not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Restore action
|
||||
if (body.action === 'restore') {
|
||||
const historyId = body.historyId;
|
||||
if (!historyId) {
|
||||
return NextResponse.json({ error: 'historyId required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const snapshot = await prisma.noteHistory.findUnique({
|
||||
where: { id: historyId },
|
||||
});
|
||||
|
||||
if (!snapshot) {
|
||||
return NextResponse.json({ error: 'Snapshot not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// 1. Take a snapshot of current state before restoring
|
||||
if (note.contentHtml) {
|
||||
await prisma.noteHistory.create({
|
||||
data: {
|
||||
noteId: id,
|
||||
title: note.title,
|
||||
contentHtml: note.contentHtml,
|
||||
contentText: note.contentText,
|
||||
excerpt: note.excerpt,
|
||||
wordCount: note.contentText.trim().split(/\s+/).filter(Boolean).length,
|
||||
createdBy: 'Before Restore',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Restore the note to the snapshot
|
||||
const restored = await prisma.note.update({
|
||||
where: { id },
|
||||
data: {
|
||||
title: snapshot.title,
|
||||
contentHtml: snapshot.contentHtml,
|
||||
contentText: snapshot.contentText,
|
||||
excerpt: snapshot.excerpt,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
include: {
|
||||
notebook: true,
|
||||
tags: { include: { tag: true } },
|
||||
attachments: { orderBy: { createdAt: 'asc' } },
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
restored: true,
|
||||
note: {
|
||||
...restored,
|
||||
tags: restored.tags.map((nt) => nt.tag),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Manual snapshot action
|
||||
const text = note.contentText || stripHtmlToText(note.contentHtml);
|
||||
const snapshot = await prisma.noteHistory.create({
|
||||
data: {
|
||||
noteId: id,
|
||||
title: note.title,
|
||||
contentHtml: note.contentHtml,
|
||||
contentText: text,
|
||||
excerpt: note.excerpt || makeExcerpt(text),
|
||||
wordCount: text.trim().split(/\s+/).filter(Boolean).length,
|
||||
createdBy: body.createdBy || 'Manual Snapshot',
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ snapshot });
|
||||
} catch (err: unknown) {
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : 'Note history operation failed' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { prisma, getCurrentUserId } from '@/lib/prisma';
|
||||
import { stripHtmlToText, makeExcerpt } from '@/lib/storage';
|
||||
import { getOrCreateTagIds } from '@/lib/importers';
|
||||
import { Prisma } from '@prisma/client';
|
||||
@@ -9,11 +9,16 @@ export async function GET(
|
||||
context: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const userId = await getCurrentUserId();
|
||||
const { id } = await context.params;
|
||||
const note = await prisma.note.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
notebook: true,
|
||||
notebook: {
|
||||
include: {
|
||||
shares: true,
|
||||
},
|
||||
},
|
||||
tags: { include: { tag: true } },
|
||||
attachments: { orderBy: { createdAt: 'asc' } },
|
||||
},
|
||||
@@ -23,8 +28,24 @@ export async function GET(
|
||||
return NextResponse.json({ error: 'Note not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const hasAccess =
|
||||
note.userId === userId ||
|
||||
note.notebook.userId === userId ||
|
||||
note.notebook.shares.some((s) => s.userId === userId);
|
||||
|
||||
if (!hasAccess) {
|
||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
const share = note.notebook.shares.find((s) => s.userId === userId);
|
||||
const canEdit =
|
||||
note.userId === userId ||
|
||||
note.notebook.userId === userId ||
|
||||
share?.role === 'write';
|
||||
|
||||
return NextResponse.json({
|
||||
...note,
|
||||
canEdit,
|
||||
tags: note.tags.map((nt) => nt.tag),
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
@@ -40,9 +61,32 @@ export async function PATCH(
|
||||
context: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const userId = await getCurrentUserId();
|
||||
const { id } = await context.params;
|
||||
const body = await req.json();
|
||||
|
||||
const existingNote = await prisma.note.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
notebook: {
|
||||
include: { shares: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!existingNote) {
|
||||
return NextResponse.json({ error: 'Note not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const canEdit =
|
||||
existingNote.userId === userId ||
|
||||
existingNote.notebook.userId === userId ||
|
||||
existingNote.notebook.shares.some((s) => s.userId === userId && s.role === 'write');
|
||||
|
||||
if (!canEdit) {
|
||||
return NextResponse.json({ error: 'Read-only access to this notebook' }, { status: 403 });
|
||||
}
|
||||
|
||||
const data: Prisma.NoteUncheckedUpdateInput = {};
|
||||
let shouldBumpUpdatedAt = false;
|
||||
|
||||
@@ -57,6 +101,40 @@ export async function PATCH(
|
||||
data.excerpt = makeExcerpt(text);
|
||||
shouldBumpUpdatedAt = true;
|
||||
}
|
||||
|
||||
// Automatic version history snapshotting (at most 1 snapshot per 5 minutes of editing)
|
||||
if (body.contentHtml !== undefined || body.title !== undefined) {
|
||||
try {
|
||||
const lastSnapshot = await prisma.noteHistory.findFirst({
|
||||
where: { noteId: id },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: { createdAt: true },
|
||||
});
|
||||
const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000);
|
||||
if (!lastSnapshot || lastSnapshot.createdAt < fiveMinutesAgo) {
|
||||
const currentNote = await prisma.note.findUnique({
|
||||
where: { id },
|
||||
select: { title: true, contentHtml: true, contentText: true, excerpt: true },
|
||||
});
|
||||
if (currentNote && currentNote.contentHtml) {
|
||||
const wordCount = currentNote.contentText.trim().split(/\s+/).filter(Boolean).length;
|
||||
await prisma.noteHistory.create({
|
||||
data: {
|
||||
noteId: id,
|
||||
title: currentNote.title,
|
||||
contentHtml: currentNote.contentHtml,
|
||||
contentText: currentNote.contentText,
|
||||
excerpt: currentNote.excerpt,
|
||||
wordCount,
|
||||
createdBy: 'Auto Snapshot',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (historyErr) {
|
||||
console.warn('Note history snapshot creation failed:', historyErr);
|
||||
}
|
||||
}
|
||||
if (body.notebookId !== undefined) {
|
||||
data.notebookId = body.notebookId;
|
||||
}
|
||||
@@ -138,9 +216,32 @@ export async function DELETE(
|
||||
context: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const userId = await getCurrentUserId();
|
||||
const { id } = await context.params;
|
||||
const permanent = req.nextUrl.searchParams.get('permanent') === 'true';
|
||||
|
||||
const existingNote = await prisma.note.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
notebook: {
|
||||
include: { shares: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!existingNote) {
|
||||
return NextResponse.json({ error: 'Note not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const canDelete =
|
||||
existingNote.userId === userId ||
|
||||
existingNote.notebook.userId === userId ||
|
||||
existingNote.notebook.shares.some((s) => s.userId === userId && s.role === 'write');
|
||||
|
||||
if (!canDelete) {
|
||||
return NextResponse.json({ error: 'Read-only access to this notebook' }, { status: 403 });
|
||||
}
|
||||
|
||||
if (permanent) {
|
||||
await prisma.note.delete({ where: { id } });
|
||||
return NextResponse.json({ deleted: true, permanent: true });
|
||||
|
||||
@@ -129,7 +129,21 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
|
||||
const defaultNb = await ensureDefaultNotebook(userId);
|
||||
const notebookId = body.notebookId || defaultNb.id;
|
||||
let notebookId = defaultNb.id;
|
||||
if (body.notebookId) {
|
||||
const allowedNb = await prisma.notebook.findFirst({
|
||||
where: {
|
||||
id: body.notebookId,
|
||||
OR: [
|
||||
{ userId },
|
||||
{ shares: { some: { userId, role: 'write' } } },
|
||||
],
|
||||
},
|
||||
});
|
||||
if (allowedNb) {
|
||||
notebookId = allowedNb.id;
|
||||
}
|
||||
}
|
||||
const title = body.title?.trim() || 'Untitled Note';
|
||||
const contentHtml = body.contentHtml || '<p></p>';
|
||||
const contentText = stripHtmlToText(contentHtml);
|
||||
|
||||
@@ -10,7 +10,8 @@ export async function GET() {
|
||||
const [
|
||||
users,
|
||||
stacks,
|
||||
notebooks,
|
||||
ownedNotebooks,
|
||||
sharedNotebooks,
|
||||
tags,
|
||||
savedSearches,
|
||||
shortcutNotes,
|
||||
@@ -28,8 +29,20 @@ export async function GET() {
|
||||
}),
|
||||
prisma.notebook.findMany({
|
||||
where: { userId },
|
||||
include: {
|
||||
shares: { select: { id: true, userId: true, role: true } },
|
||||
},
|
||||
orderBy: [{ isDefault: 'desc' }, { sortOrder: 'asc' }, { name: 'asc' }],
|
||||
}),
|
||||
prisma.notebook.findMany({
|
||||
where: {
|
||||
shares: { some: { userId } },
|
||||
},
|
||||
include: {
|
||||
shares: { where: { userId } },
|
||||
},
|
||||
orderBy: [{ name: 'asc' }],
|
||||
}),
|
||||
prisma.tag.findMany({
|
||||
where: { userId },
|
||||
orderBy: { name: 'asc' },
|
||||
@@ -43,12 +56,20 @@ export async function GET() {
|
||||
select: { id: true, title: true, notebookId: true, updatedAt: true },
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
}),
|
||||
prisma.note.count({ where: { userId, isTrashed: false } }),
|
||||
prisma.note.count({
|
||||
where: {
|
||||
isTrashed: false,
|
||||
OR: [{ userId }, { notebook: { shares: { some: { userId } } } }],
|
||||
},
|
||||
}),
|
||||
prisma.note.count({ where: { userId, isTrashed: true } }),
|
||||
// Fast single GROUP BY for notebook note counts
|
||||
// Fast single GROUP BY for notebook note counts (owned + shared)
|
||||
prisma.note.groupBy({
|
||||
by: ['notebookId'],
|
||||
where: { userId, isTrashed: false },
|
||||
where: {
|
||||
isTrashed: false,
|
||||
OR: [{ userId }, { notebook: { shares: { some: { userId } } } }],
|
||||
},
|
||||
_count: { _all: true },
|
||||
}),
|
||||
// Fast single raw GROUP BY query for tag note counts
|
||||
@@ -71,14 +92,48 @@ export async function GET() {
|
||||
tagCountMap.set(item.tagId, Number(item.count));
|
||||
}
|
||||
|
||||
// Format owned notebooks
|
||||
const formattedOwned = ownedNotebooks.map((nb) => ({
|
||||
id: nb.id,
|
||||
name: nb.name,
|
||||
userId: nb.userId,
|
||||
stackId: nb.stackId,
|
||||
isDefault: nb.isDefault,
|
||||
isShortcut: nb.isShortcut,
|
||||
sortOrder: nb.sortOrder,
|
||||
noteCount: notebookCountMap.get(nb.id) || 0,
|
||||
isOwner: true,
|
||||
isShared: nb.shares.length > 0,
|
||||
sharesCount: nb.shares.length,
|
||||
shareRole: 'owner' as const,
|
||||
}));
|
||||
|
||||
// Format shared notebooks
|
||||
const formattedShared = sharedNotebooks.map((nb) => {
|
||||
const myShare = nb.shares[0];
|
||||
return {
|
||||
id: nb.id,
|
||||
name: nb.name,
|
||||
userId: nb.userId,
|
||||
stackId: null, // Shared notebooks appear in their own section or unstacked
|
||||
isDefault: false,
|
||||
isShortcut: nb.isShortcut,
|
||||
sortOrder: nb.sortOrder,
|
||||
noteCount: notebookCountMap.get(nb.id) || 0,
|
||||
isOwner: false,
|
||||
isShared: true,
|
||||
sharesCount: 1,
|
||||
shareRole: (myShare?.role || 'read') as 'read' | 'write',
|
||||
};
|
||||
});
|
||||
|
||||
const combinedNotebooks = [...formattedOwned, ...formattedShared];
|
||||
|
||||
return NextResponse.json({
|
||||
currentUser,
|
||||
users,
|
||||
stacks,
|
||||
notebooks: notebooks.map((nb) => ({
|
||||
...nb,
|
||||
noteCount: notebookCountMap.get(nb.id) || 0,
|
||||
})),
|
||||
notebooks: combinedNotebooks,
|
||||
tags: tags.map((t) => ({
|
||||
...t,
|
||||
noteCount: tagCountMap.get(t.id) || 0,
|
||||
@@ -86,8 +141,8 @@ export async function GET() {
|
||||
savedSearches,
|
||||
shortcuts: {
|
||||
notes: shortcutNotes,
|
||||
notebooks: notebooks.filter((n) => n.isShortcut).map(nb => ({ ...nb, noteCount: notebookCountMap.get(nb.id) || 0 })),
|
||||
tags: tags.filter((t) => t.isShortcut).map(t => ({ ...t, noteCount: tagCountMap.get(t.id) || 0 })),
|
||||
notebooks: combinedNotebooks.filter((n) => n.isShortcut),
|
||||
tags: tags.filter((t) => t.isShortcut).map((t) => ({ ...t, noteCount: tagCountMap.get(t.id) || 0 })),
|
||||
savedSearches: savedSearches.filter((s) => s.isShortcut),
|
||||
},
|
||||
counts: {
|
||||
|
||||
Reference in new issue
Block a user