diff --git a/memory-bank/progress.md b/memory-bank/progress.md index ab078c3..ec53146 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -31,11 +31,11 @@ - [x] Şifreli mesaj düzenleme - [x] Dosya adı şifreleme -### Faz 5: Check-in & Cron 🔲 -- [ ] Check-in API endpoint -- [ ] node-cron scheduler setup -- [ ] Deadline kontrolü -- [ ] Status güncellemeleri +### Faz 5: Check-in & Cron ✅ +- [x] Check-in API endpoint +- [x] Interval unit (dakika/saat/gün/ay) +- [x] Deadline kontrolü +- [x] Status güncellemeleri ### Faz 6: Email Gönderimi ✅ - [x] Nodemailer setup @@ -81,6 +81,7 @@ - ✅ Faz 2: Mesaj CRUD - ✅ Faz 3: Dosya Yükleme (R2) - ✅ Faz 4: Client-side Encryption +- ✅ Faz 5: Check-in & Cron - ✅ Faz 6: Email Gönderimi ## Bilinen Sorunlar diff --git a/package.json b/package.json index f69f471..271a4d5 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,11 @@ "dev": "next dev", "build": "next build", "start": "next start", - "lint": "eslint" + "lint": "eslint", + "db:generate": "prisma generate", + "db:push": "prisma db push", + "db:migrate": "prisma migrate dev", + "db:studio": "prisma studio" }, "dependencies": { "@auth/prisma-adapter": "^2.11.1", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index c51f057..b3a5b20 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -45,19 +45,20 @@ model User { } model Message { - id String @id @default(cuid()) - userId String - title String - content String? @db.Text - status String @default("DRAFT") - isEncrypted Boolean @default(false) - lastPing DateTime @default(now()) - checkInterval Int @default(24) - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - recipients Recipient[] - attachments Attachment[] - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(cuid()) + userId String + title String + content String? @db.Text + status String @default("DRAFT") + isEncrypted Boolean @default(false) + lastPing DateTime @default(now()) + checkInterval Int @default(24) + intervalUnit String @default("HOURS") + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + recipients Recipient[] + attachments Attachment[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt } model Recipient { diff --git a/src/app/api/cron/check-deadlines/route.ts b/src/app/api/cron/check-deadlines/route.ts index 55e7efa..692f64d 100644 --- a/src/app/api/cron/check-deadlines/route.ts +++ b/src/app/api/cron/check-deadlines/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from 'next/server'; import { prisma } from '@/lib/prisma'; import { sendEmail, emailTemplates } from '@/lib/email'; +import { intervalToMs, IntervalUnit } from '@/lib/interval'; export async function GET(request: Request) { const authHeader = request.headers.get('authorization'); @@ -9,14 +10,9 @@ export async function GET(request: Request) { } const now = new Date(); - - const expiredMessages = await prisma.message.findMany({ - where: { - status: 'ACTIVE', - lastPing: { - lt: new Date(now.getTime() - 1000 * 60 * 60 * 24), // 24 saat geçmiş - }, - }, + + const activeMessages = await prisma.message.findMany({ + where: { status: 'ACTIVE' }, include: { user: true, recipients: true, @@ -25,8 +21,9 @@ export async function GET(request: Request) { const results = []; - for (const message of expiredMessages) { - const deadlineTime = new Date(message.lastPing.getTime() + message.checkInterval * 60 * 60 * 1000); + for (const message of activeMessages) { + const intervalMs = intervalToMs(message.checkInterval, message.intervalUnit as IntervalUnit); + const deadlineTime = new Date(message.lastPing.getTime() + intervalMs); if (now > deadlineTime) { for (const recipient of message.recipients) { @@ -55,5 +52,5 @@ export async function GET(request: Request) { } } - return NextResponse.json({ checked: expiredMessages.length, results }); -} + return NextResponse.json({ checked: activeMessages.length, results }); +} \ No newline at end of file diff --git a/src/app/api/cron/send-reminders/route.ts b/src/app/api/cron/send-reminders/route.ts index 9bb200e..6878ecc 100644 --- a/src/app/api/cron/send-reminders/route.ts +++ b/src/app/api/cron/send-reminders/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from 'next/server'; import { prisma } from '@/lib/prisma'; import { sendEmail, emailTemplates } from '@/lib/email'; +import { intervalToMs, IntervalUnit } from '@/lib/interval'; export async function GET(request: Request) { const authHeader = request.headers.get('authorization'); @@ -18,7 +19,8 @@ export async function GET(request: Request) { }); for (const message of activeMessages) { - const deadlineTime = new Date(message.lastPing.getTime() + message.checkInterval * 60 * 60 * 1000); + const intervalMs = intervalToMs(message.checkInterval, message.intervalUnit as IntervalUnit); + const deadlineTime = new Date(message.lastPing.getTime() + intervalMs); const hoursLeft = (deadlineTime.getTime() - now.getTime()) / (1000 * 60 * 60); for (const reminderHour of reminderHours) { diff --git a/src/app/api/decrypt/[messageId]/attachments/[attachmentId]/route.ts b/src/app/api/decrypt/[messageId]/attachments/[attachmentId]/route.ts new file mode 100644 index 0000000..309fbea --- /dev/null +++ b/src/app/api/decrypt/[messageId]/attachments/[attachmentId]/route.ts @@ -0,0 +1,29 @@ +import { NextResponse } from "next/server"; +import { getDownloadUrl } from "@/lib/r2"; +import { prisma } from "@/lib/prisma"; + +// Public endpoint for recipients to download attachments +export async function GET( + req: Request, + { params }: { params: Promise<{ messageId: string; attachmentId: string }> } +) { + const { messageId, attachmentId } = await params; + + try { + const attachment = await prisma.attachment.findUnique({ + where: { id: attachmentId }, + include: { message: true }, + }); + + // Verify attachment belongs to the specified message + if (!attachment || attachment.messageId !== messageId) { + return new NextResponse("Not found", { status: 404 }); + } + + const downloadUrl = await getDownloadUrl(attachment.fileUrl); + return NextResponse.json({ downloadUrl }); + } catch (error) { + console.error("DECRYPT_ATTACHMENT_DOWNLOAD_ERROR", error); + return new NextResponse("Internal Server Error", { status: 500 }); + } +} diff --git a/src/app/api/decrypt/[messageId]/route.ts b/src/app/api/decrypt/[messageId]/route.ts new file mode 100644 index 0000000..9bcc7fc --- /dev/null +++ b/src/app/api/decrypt/[messageId]/route.ts @@ -0,0 +1,27 @@ +import { NextResponse } from 'next/server'; +import { prisma } from '@/lib/prisma'; + +export async function GET( + req: Request, + { params }: { params: Promise<{ messageId: string }> } +) { + const { messageId } = await params; + + const message = await prisma.message.findUnique({ + where: { id: messageId }, + select: { + id: true, + title: true, + content: true, + isEncrypted: true, + user: { select: { name: true } }, + attachments: { select: { id: true, fileName: true, fileUrl: true } }, + }, + }); + + if (!message) { + return NextResponse.json({ error: 'Mesaj bulunamadı' }, { status: 404 }); + } + + return NextResponse.json(message); +} diff --git a/src/app/api/messages/[messageId]/check-in/route.ts b/src/app/api/messages/[messageId]/check-in/route.ts new file mode 100644 index 0000000..f410b95 --- /dev/null +++ b/src/app/api/messages/[messageId]/check-in/route.ts @@ -0,0 +1,31 @@ +import { NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth/options'; +import { prisma } from '@/lib/prisma'; + +export async function POST( + req: Request, + { params }: { params: Promise<{ messageId: string }> } +) { + const session = await getServerSession(authOptions); + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const { messageId } = await params; + + const message = await prisma.message.findUnique({ + where: { id: messageId, userId: session.user.id }, + }); + + if (!message) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + } + + const updated = await prisma.message.update({ + where: { id: messageId }, + data: { lastPing: new Date() }, + }); + + return NextResponse.json({ success: true, lastPing: updated.lastPing }); +} diff --git a/src/app/api/messages/[messageId]/route.ts b/src/app/api/messages/[messageId]/route.ts index a2347aa..50f5464 100644 --- a/src/app/api/messages/[messageId]/route.ts +++ b/src/app/api/messages/[messageId]/route.ts @@ -16,7 +16,7 @@ export async function PATCH( try { const body = await req.json(); - const { title, content, recipients, checkInterval, isEncrypted } = body; + const { title, content, recipients, checkInterval, intervalUnit, isEncrypted } = body; const existingMessage = await prisma.message.findUnique({ where: { id: messageId, userId: session.user.id }, @@ -40,6 +40,7 @@ export async function PATCH( title, content, checkInterval, + intervalUnit: intervalUnit || existingMessage.intervalUnit, isEncrypted: isEncrypted || existingMessage.isEncrypted, recipients: { create: recipients.map((email: string) => ({ email })), diff --git a/src/app/api/messages/[messageId]/status/route.ts b/src/app/api/messages/[messageId]/status/route.ts new file mode 100644 index 0000000..23d6afe --- /dev/null +++ b/src/app/api/messages/[messageId]/status/route.ts @@ -0,0 +1,36 @@ +import { NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth/options'; +import { prisma } from '@/lib/prisma'; + +export async function POST( + req: Request, + { params }: { params: Promise<{ messageId: string }> } +) { + const session = await getServerSession(authOptions); + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const { messageId } = await params; + const { status } = await req.json(); + + if (!['DRAFT', 'ACTIVE'].includes(status)) { + return NextResponse.json({ error: 'Invalid status' }, { status: 400 }); + } + + const message = await prisma.message.findUnique({ + where: { id: messageId, userId: session.user.id }, + }); + + if (!message) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + } + + const updated = await prisma.message.update({ + where: { id: messageId }, + data: { status, lastPing: new Date() }, + }); + + return NextResponse.json({ success: true, status: updated.status }); +} diff --git a/src/app/api/messages/route.ts b/src/app/api/messages/route.ts index f90896d..d438c7b 100644 --- a/src/app/api/messages/route.ts +++ b/src/app/api/messages/route.ts @@ -12,7 +12,7 @@ export async function POST(req: Request) { try { const body = await req.json(); - const { title, content, recipients, checkInterval, isEncrypted } = body; + const { title, content, recipients, checkInterval, intervalUnit, isEncrypted } = body; const message = await prisma.message.create({ data: { @@ -20,6 +20,7 @@ export async function POST(req: Request) { title, content, checkInterval, + intervalUnit: intervalUnit || 'HOURS', isEncrypted: isEncrypted || false, recipients: { create: recipients.map((email: string) => ({ email })), diff --git a/src/app/dashboard/messages/[messageId]/page.tsx b/src/app/dashboard/messages/[messageId]/page.tsx index e987a00..7b91c22 100644 --- a/src/app/dashboard/messages/[messageId]/page.tsx +++ b/src/app/dashboard/messages/[messageId]/page.tsx @@ -36,6 +36,7 @@ export default async function EditMessagePage({ title: message.title, content: message.content || "", checkInterval: message.checkInterval, + intervalUnit: message.intervalUnit as "MINUTES" | "HOURS" | "DAYS" | "MONTHS", recipients: message.recipients.map((r) => r.email), attachments: message.attachments.map((a) => ({ id: a.id, fileName: a.fileName })), isEncrypted: message.isEncrypted, diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index 6afe9b9..2cdeefe 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -6,6 +6,8 @@ import { getServerSession } from "next-auth"; import { authOptions } from "@/lib/auth/options"; import { prisma } from "@/lib/prisma"; import { redirect } from "next/navigation"; +import { formatInterval, type IntervalUnit } from "@/lib/interval"; +import { CheckInButton, ToggleStatusButton } from "@/components/check-in-button"; export default async function DashboardPage() { const session = await getServerSession(authOptions); @@ -47,12 +49,16 @@ export default async function DashboardPage() { {messages.map((msg) => ( - {msg.title} - - - + {msg.title} +
+ + + + + +

@@ -62,7 +68,7 @@ export default async function DashboardPage() { Alıcılar: {msg.recipients.length}

- Kontrol: {msg.checkInterval} saat başı + Kontrol: {formatInterval(msg.checkInterval, msg.intervalUnit as IntervalUnit)}

diff --git a/src/app/decrypt/[messageId]/page.tsx b/src/app/decrypt/[messageId]/page.tsx new file mode 100644 index 0000000..5b268b3 --- /dev/null +++ b/src/app/decrypt/[messageId]/page.tsx @@ -0,0 +1,166 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'; +import { Lock } from 'lucide-react'; +import { decrypt, decryptFile } from '@/lib/crypto'; +import { use } from 'react'; + +interface MessageData { + id: string; + title: string; + content: string; + isEncrypted: boolean; + user: { name: string }; + attachments: { id: string; fileName: string }[]; +} + +export default function DecryptPage({ params }: { params: Promise<{ messageId: string }> }) { + const { messageId } = use(params); + const [message, setMessage] = useState(null); + const [password, setPassword] = useState(''); + const [decryptedContent, setDecryptedContent] = useState(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); + const [downloading, setDownloading] = useState(null); + + useEffect(() => { + fetch(`/api/decrypt/${messageId}`) + .then(res => res.json()) + .then(data => { + if (data.error) setError(data.error); + else setMessage(data); + }) + .catch(() => setError('Mesaj yüklenemedi')) + .finally(() => setLoading(false)); + }, [messageId]); + + const handleDecrypt = async () => { + if (!message || !password) return; + try { + const content = await decrypt(message.content, password); + setDecryptedContent(content); + setError(null); + } catch { + setError('Şifre hatalı'); + } + }; + + const handleDownloadAttachment = async (attachmentId: string, fileName: string) => { + if (!password) return; + + setDownloading(attachmentId); + try { + // Use the public decrypt attachments endpoint + const res = await fetch(`/api/decrypt/${messageId}/attachments/${attachmentId}`); + if (!res.ok) throw new Error('Failed to get download URL'); + + const { downloadUrl } = await res.json(); + + if (message?.isEncrypted && fileName.endsWith('.enc')) { + // Download and decrypt the file + const fileRes = await fetch(downloadUrl); + const encryptedBlob = await fileRes.blob(); + const decryptedData = await decryptFile(encryptedBlob, password); + + // Decrypt the filename + let originalName = 'decrypted_file'; + try { + const encryptedName = fileName.slice(0, -4); + originalName = await decrypt(encryptedName, password); + } catch { } + + // Trigger download + const blob = new Blob([decryptedData]); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = originalName; + a.click(); + URL.revokeObjectURL(url); + } else { + // Non-encrypted file, just open + window.open(downloadUrl, '_blank'); + } + } catch (error) { + console.error('Download error:', error); + setError('Dosya indirilemedi'); + } finally { + setDownloading(null); + } + }; + + if (loading) return
Yükleniyor...
; + if (error && !message) return
{error}
; + if (!message) return null; + + const canDownloadAttachments = !message.isEncrypted || decryptedContent !== null; + + return ( +
+ + + {message.title} +

Gönderen: {message.user.name}

+
+ + {message.isEncrypted && !decryptedContent ? ( +
+
+ + Bu mesaj şifrelidir +
+ setPassword(e.target.value)} + /> + {error &&

{error}

} + +
+ ) : ( +
+

{decryptedContent || message.content}

+
+ )} + + {message.attachments.length > 0 && ( +
+

Ekler

+ {canDownloadAttachments ? ( +
    + {message.attachments.map((att) => ( +
  • + + {att.fileName.endsWith('.enc') ? '🔒 Şifreli dosya' : att.fileName} + + +
  • + ))} +
+ ) : ( +

+ Şifreli ekleri indirmek için önce mesajı çözün. +

+ )} +
+ )} +
+
+
+ ); +} + diff --git a/src/components/check-in-button.tsx b/src/components/check-in-button.tsx new file mode 100644 index 0000000..2ede117 --- /dev/null +++ b/src/components/check-in-button.tsx @@ -0,0 +1,70 @@ +'use client'; + +import { Button } from '@/components/ui/button'; +import { CheckCircle, Power } from 'lucide-react'; +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; + +export function CheckInButton({ messageId }: { messageId: string }) { + const [loading, setLoading] = useState(false); + const [success, setSuccess] = useState(false); + + const handleCheckIn = async () => { + setLoading(true); + try { + const res = await fetch(`/api/messages/${messageId}/check-in`, { method: 'POST' }); + if (res.ok) { + setSuccess(true); + setTimeout(() => setSuccess(false), 2000); + } + } finally { + setLoading(false); + } + }; + + return ( + + ); +} + +export function ToggleStatusButton({ messageId, currentStatus }: { messageId: string; currentStatus: string }) { + const [loading, setLoading] = useState(false); + const router = useRouter(); + const isActive = currentStatus === 'ACTIVE'; + + const handleToggle = async () => { + setLoading(true); + try { + await fetch(`/api/messages/${messageId}/status`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ status: isActive ? 'DRAFT' : 'ACTIVE' }), + }); + router.refresh(); + } finally { + setLoading(false); + } + }; + + return ( + + ); +} \ No newline at end of file diff --git a/src/components/messages/message-form.tsx b/src/components/messages/message-form.tsx index 9e8804b..fc569e7 100644 --- a/src/components/messages/message-form.tsx +++ b/src/components/messages/message-form.tsx @@ -10,6 +10,7 @@ import { Plus, Trash2, Lock, Unlock } from "lucide-react"; import { useRouter } from "next/navigation"; import { FileUpload, AttachmentList } from "@/components/attachments/file-upload"; import { encrypt, decrypt, encryptFile } from "@/lib/crypto"; +import { IntervalUnit } from "@/lib/interval"; interface Attachment { id: string; @@ -23,6 +24,7 @@ interface MessageFormProps { content: string; recipients: string[]; checkInterval: number; + intervalUnit?: IntervalUnit; attachments?: Attachment[]; isEncrypted?: boolean; }; @@ -33,6 +35,7 @@ export default function MessageForm({ initialData }: MessageFormProps) { const [content, setContent] = useState(initialData?.content || ""); const [recipients, setRecipients] = useState(initialData?.recipients || [""]); const [checkInterval, setCheckInterval] = useState(initialData?.checkInterval || 24); + const [intervalUnit, setIntervalUnit] = useState(initialData?.intervalUnit || "HOURS"); const [attachments, setAttachments] = useState(initialData?.attachments || []); const [pendingFiles, setPendingFiles] = useState([]); const [isEncrypted, setIsEncrypted] = useState(initialData?.isEncrypted || false); @@ -116,6 +119,7 @@ export default function MessageForm({ initialData }: MessageFormProps) { content: finalContent, recipients, checkInterval, + intervalUnit, isEncrypted }), }); @@ -263,15 +267,28 @@ export default function MessageForm({ initialData }: MessageFormProps) {
- - setCheckInterval(parseInt(e.target.value))} - min={1} - required - /> + +
+ setCheckInterval(parseInt(e.target.value))} + min={1} + required + className="w-24" + /> + +
{!initialData?.isEncrypted && (
diff --git a/src/lib/interval.ts b/src/lib/interval.ts new file mode 100644 index 0000000..f449467 --- /dev/null +++ b/src/lib/interval.ts @@ -0,0 +1,20 @@ +export type IntervalUnit = 'MINUTES' | 'HOURS' | 'DAYS' | 'MONTHS'; + +export function intervalToMs(interval: number, unit: IntervalUnit): number { + switch (unit) { + case 'MINUTES': return interval * 60 * 1000; + case 'HOURS': return interval * 60 * 60 * 1000; + case 'DAYS': return interval * 24 * 60 * 60 * 1000; + case 'MONTHS': return interval * 30 * 24 * 60 * 60 * 1000; + } +} + +export function formatInterval(interval: number, unit: IntervalUnit): string { + const labels: Record = { + MINUTES: 'dakika', + HOURS: 'saat', + DAYS: 'gün', + MONTHS: 'ay', + }; + return `${interval} ${labels[unit]}`; +}