feat: Implement message check-in and status management, introduce flexible interval units, and add decryption routes for messages and attachments.

This commit is contained in:
Yusuf İpek
2025-12-28 14:15:16 +03:00
parent 1c91a1fe9d
commit 89968db712
17 changed files with 460 additions and 50 deletions
+6 -5
View File
@@ -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
+5 -1
View File
@@ -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",
+14 -13
View File
@@ -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 {
+7 -10
View File
@@ -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');
@@ -10,13 +11,8 @@ 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 });
}
+3 -1
View File
@@ -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) {
@@ -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 });
}
}
+27
View File
@@ -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);
}
@@ -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 });
}
+2 -1
View File
@@ -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 })),
@@ -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 });
}
+2 -1
View File
@@ -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 })),
@@ -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,
+13 -7
View File
@@ -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) => (
<Card key={msg.id} className="relative group">
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="truncate pr-8">{msg.title}</CardTitle>
<Link href={`/dashboard/messages/${msg.id}`}>
<Button variant="ghost" size="icon" className="h-8 w-8">
<Edit2 className="h-4 w-4" />
</Button>
</Link>
<CardTitle className="truncate pr-20">{msg.title}</CardTitle>
<div className="flex gap-1">
<ToggleStatusButton messageId={msg.id} currentStatus={msg.status} />
<CheckInButton messageId={msg.id} />
<Link href={`/dashboard/messages/${msg.id}`}>
<Button variant="ghost" size="icon" className="h-8 w-8">
<Edit2 className="h-4 w-4" />
</Button>
</Link>
</div>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground mb-2">
@@ -62,7 +68,7 @@ export default async function DashboardPage() {
Alıcılar: {msg.recipients.length}
</p>
<p className="text-sm text-muted-foreground">
Kontrol: {msg.checkInterval} saat başı
Kontrol: {formatInterval(msg.checkInterval, msg.intervalUnit as IntervalUnit)}
</p>
</CardContent>
</Card>
+166
View File
@@ -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<MessageData | null>(null);
const [password, setPassword] = useState('');
const [decryptedContent, setDecryptedContent] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [downloading, setDownloading] = useState<string | null>(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 <div className="container mx-auto py-10 text-center">Yükleniyor...</div>;
if (error && !message) return <div className="container mx-auto py-10 text-center text-red-500">{error}</div>;
if (!message) return null;
const canDownloadAttachments = !message.isEncrypted || decryptedContent !== null;
return (
<div className="container mx-auto py-10 max-w-2xl">
<Card>
<CardHeader>
<CardTitle>{message.title}</CardTitle>
<p className="text-sm text-muted-foreground">Gönderen: {message.user.name}</p>
</CardHeader>
<CardContent className="space-y-4">
{message.isEncrypted && !decryptedContent ? (
<div className="space-y-4">
<div className="flex items-center gap-2 text-muted-foreground">
<Lock className="h-4 w-4" />
<span>Bu mesaj şifrelidir</span>
</div>
<Input
type="password"
placeholder="Şifre"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
{error && <p className="text-red-500 text-sm">{error}</p>}
<Button onClick={handleDecrypt} disabled={!password}>
Şifreyi Çöz
</Button>
</div>
) : (
<div className="prose dark:prose-invert">
<p className="whitespace-pre-wrap">{decryptedContent || message.content}</p>
</div>
)}
{message.attachments.length > 0 && (
<div className="mt-6">
<h3 className="font-semibold mb-2">Ekler</h3>
{canDownloadAttachments ? (
<ul className="space-y-2">
{message.attachments.map((att) => (
<li key={att.id} className="flex items-center gap-2 text-sm">
<span className="flex-1 truncate">
{att.fileName.endsWith('.enc') ? '🔒 Şifreli dosya' : att.fileName}
</span>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => handleDownloadAttachment(att.id, att.fileName)}
disabled={downloading === att.id}
>
{downloading === att.id ? '...' : 'İndir'}
</Button>
</li>
))}
</ul>
) : (
<p className="text-sm text-muted-foreground">
Şifreli ekleri indirmek için önce mesajı çözün.
</p>
)}
</div>
)}
</CardContent>
</Card>
</div>
);
}
+70
View File
@@ -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 (
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={handleCheckIn}
disabled={loading}
title="Check-in"
>
<CheckCircle className={`h-4 w-4 ${success ? 'text-green-500' : ''}`} />
</Button>
);
}
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 (
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={handleToggle}
disabled={loading}
title={isActive ? 'Devre dışı bırak' : 'Aktifleştir'}
>
<Power className={`h-4 w-4 ${isActive ? 'text-green-500' : 'text-muted-foreground'}`} />
</Button>
);
}
+26 -9
View File
@@ -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<IntervalUnit>(initialData?.intervalUnit || "HOURS");
const [attachments, setAttachments] = useState<Attachment[]>(initialData?.attachments || []);
const [pendingFiles, setPendingFiles] = useState<File[]>([]);
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) {
</Button>
</div>
<div className="space-y-2">
<Label htmlFor="interval">Kontrol Aralığı (Saat)</Label>
<Input
id="interval"
type="number"
value={checkInterval}
onChange={(e) => setCheckInterval(parseInt(e.target.value))}
min={1}
required
/>
<Label htmlFor="interval">Kontrol Aralığı</Label>
<div className="flex gap-2">
<Input
id="interval"
type="number"
value={checkInterval}
onChange={(e) => setCheckInterval(parseInt(e.target.value))}
min={1}
required
className="w-24"
/>
<select
value={intervalUnit}
onChange={(e) => setIntervalUnit(e.target.value as IntervalUnit)}
className="flex h-10 rounded-md border border-input bg-background px-3 py-2 text-sm"
>
<option value="MINUTES">Dakika</option>
<option value="HOURS">Saat</option>
<option value="DAYS">Gün</option>
<option value="MONTHS">Ay</option>
</select>
</div>
</div>
{!initialData?.isEncrypted && (
<div className="space-y-2">
+20
View File
@@ -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<IntervalUnit, string> = {
MINUTES: 'dakika',
HOURS: 'saat',
DAYS: 'gün',
MONTHS: 'ay',
};
return `${interval} ${labels[unit]}`;
}