feat: fatura PDF, hizmet/yazılım atama dosya ekleri

- /print/invoices/[id] sayfası: A4 fatura yazdırma/PDF (AutoPrint + PrintActionBar)
- Fatura detayı header'ına PDF butonu eklendi (Yazdır yerine)
- Appwrite Storage: entity-attachments bucket (20MB, şifreli)
- Appwrite Tables: attachments collection (tenantId, entityType, entityId, fileId, name, size, mimeType)
- attachment-actions.ts: fetchAttachmentsAction, uploadAttachmentAction, deleteAttachmentAction
- AttachmentsPanel bileşeni: dosya yükleme/listeleme/silme, edit modunda görünür
- Hizmet ve yazılım atama form sheet'lerine AttachmentsPanel entegrasyonu
- /api/files/[attachmentId]: güvenli proxy indirme (tenant doğrulama + admin key ile Appwrite'a istek)
This commit is contained in:
kovakmedya
2026-05-07 20:22:17 +03:00
parent a0aec13b8c
commit 1299cd10ce
9 changed files with 695 additions and 4 deletions
@@ -2,7 +2,7 @@
import { useState, useTransition } from "react"; import { useState, useTransition } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Loader2, Pencil, Printer, Trash2 } from "lucide-react"; import { FileDown, Loader2, Pencil, Trash2 } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -45,9 +45,13 @@ export function InvoiceHeaderActions({ invoice, customers }: Props) {
return ( return (
<> <>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" onClick={() => window.print()}> <Button
<Printer className="size-3.5" /> variant="outline"
Yazdır size="sm"
onClick={() => window.open(`/print/invoices/${invoice.id}`, "_blank")}
>
<FileDown className="size-3.5" />
PDF
</Button> </Button>
<Button variant="outline" size="sm" onClick={() => setEditOpen(true)}> <Button variant="outline" size="sm" onClick={() => setEditOpen(true)}>
<Pencil className="size-3.5" /> <Pencil className="size-3.5" />
@@ -37,6 +37,7 @@ import {
updateServiceAction, updateServiceAction,
} from "@/lib/appwrite/service-actions"; } from "@/lib/appwrite/service-actions";
import { initialServiceState } from "@/lib/appwrite/service-types"; import { initialServiceState } from "@/lib/appwrite/service-types";
import { AttachmentsPanel } from "@/components/attachments-panel";
import type { CustomerOption, MemberOption, ServiceRow } from "./types"; import type { CustomerOption, MemberOption, ServiceRow } from "./types";
const PRESET_SERVICES = [ const PRESET_SERVICES = [
@@ -262,6 +263,11 @@ export function ServiceFormSheet({
</div> </div>
</div> </div>
{/* Ekler */}
{isEdit && service && (
<AttachmentsPanel entityType="service" entityId={service.id} />
)}
{/* Sorumlu personel */} {/* Sorumlu personel */}
{members.length > 0 && ( {members.length > 0 && (
<div className="grid gap-2"> <div className="grid gap-2">
@@ -28,6 +28,7 @@ import {
updateAssignmentAction, updateAssignmentAction,
} from "@/lib/appwrite/software-actions"; } from "@/lib/appwrite/software-actions";
import { initialSoftwareState } from "@/lib/appwrite/software-types"; import { initialSoftwareState } from "@/lib/appwrite/software-types";
import { AttachmentsPanel } from "@/components/attachments-panel";
import type { AssignmentRow, CustomerOption, SoftwareOption } from "./types"; import type { AssignmentRow, CustomerOption, SoftwareOption } from "./types";
type Props = { type Props = {
@@ -202,6 +203,14 @@ export function AssignmentFormSheet({
placeholder="Lisans bilgileri, özel koşullar" placeholder="Lisans bilgileri, özel koşullar"
/> />
</div> </div>
{/* Ekler */}
{isEdit && assignment && (
<AttachmentsPanel
entityType="customer_software"
entityId={assignment.id}
/>
)}
</div> </div>
<SheetFooter className="border-t bg-muted/30 px-6 pt-4"> <SheetFooter className="border-t bg-muted/30 px-6 pt-4">
+71
View File
@@ -0,0 +1,71 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { BUCKETS, DATABASE_ID, TABLES, type Attachment } from "@/lib/appwrite/schema";
import { createAdminClient } from "@/lib/appwrite/server";
import { requireTenant } from "@/lib/appwrite/tenant-guard";
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ attachmentId: string }> },
) {
const { attachmentId } = await params;
let ctx;
try {
ctx = await requireTenant();
} catch {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { tablesDB } = createAdminClient();
let attachment: Attachment;
try {
attachment = (await tablesDB.getRow(
DATABASE_ID,
TABLES.attachments,
attachmentId,
)) as unknown as Attachment;
} catch {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
if (attachment.tenantId !== ctx.tenantId) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const endpoint = (process.env.NEXT_PUBLIC_APPWRITE_ENDPOINT ?? "").replace(/\/$/, "");
const projectId = process.env.NEXT_PUBLIC_APPWRITE_PROJECT_ID ?? "";
const apiKey = process.env.APPWRITE_API_KEY ?? "";
const fileUrl = `${endpoint}/storage/buckets/${BUCKETS.entityAttachments}/files/${attachment.fileId}/download`;
let upstream: Response;
try {
upstream = await fetch(fileUrl, {
headers: {
"X-Appwrite-Project": projectId,
"X-Appwrite-Key": apiKey,
},
});
} catch {
return NextResponse.json({ error: "Storage unavailable" }, { status: 502 });
}
if (!upstream.ok) {
return NextResponse.json({ error: "File not found" }, { status: 404 });
}
const contentType =
upstream.headers.get("Content-Type") || "application/octet-stream";
const encodedName = encodeURIComponent(attachment.name);
return new NextResponse(upstream.body, {
headers: {
"Content-Type": contentType,
"Content-Disposition": `attachment; filename*=UTF-8''${encodedName}`,
"Cache-Control": "private, no-store",
},
});
}
@@ -0,0 +1,58 @@
"use client";
import { useEffect } from "react";
export function AutoPrint() {
useEffect(() => {
const t = setTimeout(() => window.print(), 600);
return () => clearTimeout(t);
}, []);
return null;
}
export function PrintActionBar() {
return (
<div
className="no-print"
style={{
display: "flex",
justifyContent: "flex-end",
gap: "0.75rem",
marginBottom: "1.5rem",
}}
>
<button
type="button"
onClick={() => window.close()}
style={{
cursor: "pointer",
padding: "0.5rem 1.25rem",
borderRadius: "6px",
fontSize: "0.85rem",
fontWeight: 500,
border: "1px solid #d1d5db",
background: "#f9fafb",
color: "#374151",
}}
>
Kapat
</button>
<button
type="button"
onClick={() => window.print()}
style={{
cursor: "pointer",
padding: "0.5rem 1.25rem",
borderRadius: "6px",
fontSize: "0.85rem",
fontWeight: 500,
border: "1px solid #111827",
background: "#111827",
color: "#fff",
}}
>
Yazdır / PDF
</button>
</div>
);
}
+218
View File
@@ -0,0 +1,218 @@
import { notFound, redirect } from "next/navigation";
import { listCustomers } from "@/lib/appwrite/customer-queries";
import { getInvoice, listInvoiceItems } from "@/lib/appwrite/invoice-queries";
import { getLogoUrl } from "@/lib/appwrite/storage";
import { requireTenant } from "@/lib/appwrite/tenant-guard";
import { formatDate, formatTRY } from "@/lib/format";
import { AutoPrint, PrintActionBar } from "./auto-print";
const STATUS_LABEL: Record<string, string> = {
draft: "Taslak",
sent: "Gönderildi",
paid: "Ödendi",
overdue: "Gecikmiş",
cancelled: "İptal",
};
export default async function InvoicePrintPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
let ctx;
try {
ctx = await requireTenant();
} catch {
redirect("/onboarding");
}
const invoice = await getInvoice(ctx.tenantId, id);
if (!invoice) notFound();
const [items, customers] = await Promise.all([
listInvoiceItems(ctx.tenantId, id),
listCustomers(ctx.tenantId),
]);
const customer = customers.find((c) => c.$id === invoice.customerId);
const settings = ctx.settings;
const logoUrl = getLogoUrl(settings?.logo);
const statusLabel = STATUS_LABEL[invoice.status ?? "draft"] ?? "Taslak";
return (
<>
<AutoPrint />
{/* eslint-disable-next-line react/no-unknown-property */}
<style>{`
@media print {
@page { margin: 1.5cm; size: A4 portrait; }
.no-print { display: none !important; }
body { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
background: #ffffff;
color: #111827;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
font-size: 13px;
line-height: 1.6;
}
.page { max-width: 860px; margin: 0 auto; padding: 2.5rem 3rem; }
.header { display: flex; justify-content: space-between; align-items: flex-start; gap: 2rem; margin-bottom: 2rem; }
.company-block { flex: 1; }
.company-logo { max-height: 56px; max-width: 160px; object-fit: contain; margin-bottom: 0.75rem; }
.company-name { font-size: 1.1rem; font-weight: 700; color: #111827; }
.company-meta { font-size: 0.8rem; color: #6b7280; margin-top: 0.25rem; line-height: 1.5; }
.invoice-title-block { text-align: right; }
.invoice-title { font-size: 2rem; font-weight: 800; letter-spacing: -0.02em; color: #111827; }
.invoice-number { font-size: 1rem; font-weight: 600; font-family: "SF Mono", "Fira Code", monospace; color: #374151; margin-top: 0.25rem; }
.invoice-meta { font-size: 0.8rem; color: #6b7280; margin-top: 0.5rem; display: grid; row-gap: 0.15rem; }
.invoice-meta span { display: block; }
.status-badge { display: inline-block; margin-top: 0.4rem; padding: 0.2rem 0.65rem; border-radius: 9999px; font-size: 0.7rem; font-weight: 600; letter-spacing: 0.03em; text-transform: uppercase; background: #f3f4f6; color: #374151; border: 1px solid #e5e7eb; }
.divider { border: none; border-top: 1.5px solid #e5e7eb; margin: 1.5rem 0; }
.bill-section { display: grid; grid-template-columns: 1fr 1fr; gap: 2rem; margin-bottom: 1.75rem; }
.bill-block-label { font-size: 0.7rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: #9ca3af; margin-bottom: 0.35rem; }
.bill-block-value { font-size: 0.85rem; color: #111827; line-height: 1.55; }
.bill-block-value strong { font-weight: 700; font-size: 0.95rem; }
table { width: 100%; border-collapse: collapse; margin-bottom: 1.5rem; }
thead tr { border-bottom: 2px solid #111827; }
thead th { padding: 0.4rem 0.5rem; font-size: 0.7rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; color: #374151; text-align: left; }
thead th.num { text-align: right; }
tbody tr { border-bottom: 1px solid #f3f4f6; }
tbody tr:last-child { border-bottom: 1px solid #e5e7eb; }
tbody td { padding: 0.55rem 0.5rem; font-size: 0.85rem; color: #374151; vertical-align: top; }
tbody td.num { text-align: right; font-variant-numeric: tabular-nums; }
.totals { display: flex; justify-content: flex-end; }
.totals-table { width: 280px; }
.totals-table tr td { padding: 0.3rem 0.5rem; font-size: 0.85rem; }
.totals-table tr td:first-child { color: #6b7280; }
.totals-table tr td:last-child { text-align: right; font-variant-numeric: tabular-nums; }
.totals-total { border-top: 2px solid #111827; }
.totals-total td { padding-top: 0.5rem !important; font-size: 1rem; font-weight: 700; color: #111827 !important; }
.notes-section { margin-top: 1.75rem; }
.notes-label { font-size: 0.7rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: #9ca3af; margin-bottom: 0.4rem; }
.notes-text { font-size: 0.85rem; color: #374151; white-space: pre-line; }
.print-btn-bar { display: flex; justify-content: flex-end; gap: 0.75rem; margin-bottom: 1.5rem; }
.print-btn { cursor: pointer; padding: 0.5rem 1.25rem; border-radius: 6px; font-size: 0.85rem; font-weight: 500; border: 1px solid #d1d5db; background: #f9fafb; color: #374151; }
.print-btn:hover { background: #f3f4f6; }
`}</style>
<div className="page">
{/* Print / close bar */}
<PrintActionBar />
{/* Header */}
<div className="header">
<div className="company-block">
{logoUrl && (
// eslint-disable-next-line @next/next/no-img-element
<img src={logoUrl} alt="Logo" className="company-logo" />
)}
<div className="company-name">{settings?.companyName ?? "Şirketiniz"}</div>
<div className="company-meta">
{settings?.companyTaxId && <span>VKN: {settings.companyTaxId}</span>}
{settings?.companyAddress && <span>{settings.companyAddress}</span>}
{settings?.companyPhone && <span>{settings.companyPhone}</span>}
{settings?.companyEmail && <span>{settings.companyEmail}</span>}
</div>
</div>
<div className="invoice-title-block">
<div className="invoice-title">FATURA</div>
<div className="invoice-number">{invoice.number}</div>
<div className="invoice-meta">
<span>Düzenleme: {formatDate(invoice.issueDate)}</span>
<span>Vade: {formatDate(invoice.dueDate)}</span>
</div>
<span className="status-badge">{statusLabel}</span>
</div>
</div>
<hr className="divider" />
{/* Bill to */}
<div className="bill-section">
<div>
<div className="bill-block-label">Fatura Kesilecek</div>
<div className="bill-block-value">
<strong>{customer?.name ?? "—"}</strong>
{customer?.address && <><br />{customer.address}</>}
{customer?.taxId && (
<>
<br />
VKN: {customer.taxId}
{customer.taxOffice ? ` / ${customer.taxOffice}` : ""}
</>
)}
{customer?.email && <><br />{customer.email}</>}
{customer?.phone && <><br />{customer.phone}</>}
</div>
</div>
<div>
<div className="bill-block-label">Ödeme Bilgisi</div>
<div className="bill-block-value">
<span>Vade tarihi: {formatDate(invoice.dueDate)}</span>
</div>
</div>
</div>
{/* Items */}
<table>
<thead>
<tr>
<th style={{ width: "40%" }}>Açıklama</th>
<th className="num" style={{ width: "10%" }}>Miktar</th>
<th className="num" style={{ width: "18%" }}>Birim Fiyat</th>
<th className="num" style={{ width: "10%" }}>KDV %</th>
<th className="num" style={{ width: "22%" }}>Tutar</th>
</tr>
</thead>
<tbody>
{items.map((item, i) => (
<tr key={item.$id ?? i}>
<td>{item.description}</td>
<td className="num">{item.quantity}</td>
<td className="num">{formatTRY(item.unitPrice)}</td>
<td className="num">{item.vatRate ?? 0}%</td>
<td className="num">{formatTRY(item.lineTotal)}</td>
</tr>
))}
</tbody>
</table>
{/* Totals */}
<div className="totals">
<table className="totals-table">
<tbody>
<tr>
<td>Ara toplam</td>
<td>{formatTRY(invoice.subtotal ?? 0)}</td>
</tr>
<tr>
<td>KDV</td>
<td>{formatTRY(invoice.vatTotal ?? 0)}</td>
</tr>
<tr className="totals-total">
<td>Genel toplam</td>
<td>{formatTRY(invoice.total ?? 0)}</td>
</tr>
</tbody>
</table>
</div>
{invoice.notes && (
<div className="notes-section">
<hr className="divider" />
<div className="notes-label">Notlar</div>
<div className="notes-text">{invoice.notes}</div>
</div>
)}
</div>
</>
);
}
+140
View File
@@ -0,0 +1,140 @@
"use client";
import { useCallback, useActionState, useEffect, useRef, useState, useTransition } from "react";
import { Loader2, Paperclip, Trash2, Upload } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import {
deleteAttachmentAction,
fetchAttachmentsAction,
uploadAttachmentAction,
type AttachmentItem,
} from "@/lib/appwrite/attachment-actions";
type Props = {
entityType: "service" | "customer_software";
entityId: string;
};
const initialUploadState = { ok: false } as { ok: boolean; error?: string };
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
export function AttachmentsPanel({ entityType, entityId }: Props) {
const [attachments, setAttachments] = useState<AttachmentItem[]>([]);
const [loading, setLoading] = useState(true);
const [uploadState, uploadAction, isUploading] = useActionState(
uploadAttachmentAction,
initialUploadState,
);
const [, startDelete] = useTransition();
const fileRef = useRef<HTMLInputElement>(null);
const load = useCallback(async () => {
setLoading(true);
const data = await fetchAttachmentsAction(entityType, entityId);
setAttachments(data);
setLoading(false);
}, [entityType, entityId]);
useEffect(() => {
load();
}, [load]);
useEffect(() => {
if (uploadState.ok) {
toast.success("Dosya yüklendi.");
if (fileRef.current) fileRef.current.value = "";
load();
} else if (uploadState.error) {
toast.error(uploadState.error);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [uploadState]);
const handleDelete = (a: AttachmentItem) => {
startDelete(async () => {
const fd = new FormData();
fd.set("attachmentId", a.id);
const result = await deleteAttachmentAction(fd);
if (result.ok) {
toast.success("Dosya silindi.");
setAttachments((prev) => prev.filter((x) => x.id !== a.id));
} else {
toast.error(result.error ?? "Silme başarısız.");
}
});
};
return (
<div className="grid gap-2">
<Label>Ekler</Label>
<form action={uploadAction}>
<input type="hidden" name="entityType" value={entityType} />
<input type="hidden" name="entityId" value={entityId} />
<div className="flex items-center gap-2">
<input
ref={fileRef}
type="file"
name="file"
className="flex-1 text-sm file:mr-2 file:cursor-pointer file:rounded file:border-0 file:bg-muted file:px-3 file:py-1.5 file:text-xs file:font-medium"
/>
<Button type="submit" variant="outline" size="sm" disabled={isUploading}>
{isUploading ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Upload className="size-4" />
)}
Yükle
</Button>
</div>
</form>
{loading ? (
<div className="text-muted-foreground flex items-center gap-2 py-1 text-xs">
<Loader2 className="size-3 animate-spin" />
Yükleniyor...
</div>
) : attachments.length === 0 ? (
<p className="text-muted-foreground py-1 text-xs">Henüz ek yok.</p>
) : (
<div className="space-y-1">
{attachments.map((a) => (
<div
key={a.id}
className="flex items-center gap-2 rounded border px-2.5 py-1.5"
>
<Paperclip className="text-muted-foreground size-3.5 shrink-0" />
<a
href={`/api/files/${a.id}`}
download={a.name}
className="min-w-0 flex-1 truncate text-xs hover:underline"
title={a.name}
>
{a.name}
</a>
<span className="text-muted-foreground shrink-0 text-xs">
{formatBytes(a.size)}
</span>
<button
type="button"
onClick={() => handleDelete(a)}
className="text-muted-foreground hover:text-destructive ml-1 shrink-0"
title="Sil"
>
<Trash2 className="size-3.5" />
</button>
</div>
))}
</div>
)}
</div>
);
}
+172
View File
@@ -0,0 +1,172 @@
"use server";
import { ID, Permission, Query, Role } from "node-appwrite";
import { InputFile } from "node-appwrite/file";
import { BUCKETS, DATABASE_ID, TABLES, type Attachment } from "./schema";
import { createAdminClient } from "./server";
import { requireTenant } from "./tenant-guard";
export type AttachmentItem = {
id: string;
fileId: string;
name: string;
size: number;
mimeType: string;
createdAt: string;
};
type AttachmentActionState = { ok: boolean; error?: string };
const MAX_BYTES = 20 * 1024 * 1024;
function teamFilePermissions(tenantId: string) {
return [
Permission.read(Role.team(tenantId)),
Permission.update(Role.team(tenantId, "owner")),
Permission.update(Role.team(tenantId, "admin")),
Permission.delete(Role.team(tenantId, "owner")),
Permission.delete(Role.team(tenantId, "admin")),
Permission.delete(Role.team(tenantId, "member")),
];
}
export async function fetchAttachmentsAction(
entityType: string,
entityId: string,
): Promise<AttachmentItem[]> {
let ctx;
try {
ctx = await requireTenant();
} catch {
return [];
}
try {
const { tablesDB } = createAdminClient();
const result = await tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.attachments,
queries: [
Query.equal("tenantId", ctx.tenantId),
Query.equal("entityId", entityId),
Query.orderAsc("$createdAt"),
Query.limit(100),
],
});
return (result.rows as unknown as Attachment[]).map((r) => ({
id: r.$id,
fileId: r.fileId,
name: r.name,
size: r.size,
mimeType: r.mimeType ?? "",
createdAt: r.$createdAt,
}));
} catch {
return [];
}
}
export async function uploadAttachmentAction(
_prev: AttachmentActionState,
formData: FormData,
): Promise<AttachmentActionState> {
let ctx;
try {
ctx = await requireTenant();
} catch {
return { ok: false, error: "Yetkiniz yok." };
}
const entityType = String(formData.get("entityType") ?? "").trim();
const entityId = String(formData.get("entityId") ?? "").trim();
const file = formData.get("file");
if (!entityType || !entityId) return { ok: false, error: "Geçersiz istek." };
if (!(file instanceof File) || file.size === 0) return { ok: false, error: "Dosya seçin." };
if (file.size > MAX_BYTES) return { ok: false, error: "Dosya 20MB'dan büyük olamaz." };
const { storage, tablesDB } = createAdminClient();
let createdFileId: string | null = null;
try {
const buffer = Buffer.from(await file.arrayBuffer());
const inputFile = InputFile.fromBuffer(buffer, file.name);
const created = await storage.createFile({
bucketId: BUCKETS.entityAttachments,
fileId: ID.unique(),
file: inputFile,
permissions: teamFilePermissions(ctx.tenantId),
});
createdFileId = created.$id;
await tablesDB.createRow(
DATABASE_ID,
TABLES.attachments,
ID.unique(),
{
tenantId: ctx.tenantId,
createdBy: ctx.user.id,
entityType,
entityId,
fileId: createdFileId,
name: file.name,
size: file.size,
mimeType: file.type || null,
},
teamFilePermissions(ctx.tenantId),
);
} catch (e) {
if (createdFileId) {
try {
await storage.deleteFile({ bucketId: BUCKETS.entityAttachments, fileId: createdFileId });
} catch { /* best-effort cleanup */ }
}
return { ok: false, error: e instanceof Error ? e.message : "Yükleme başarısız." };
}
return { ok: true };
}
export async function deleteAttachmentAction(
formData: FormData,
): Promise<AttachmentActionState> {
const attachmentId = String(formData.get("attachmentId") ?? "");
if (!attachmentId) return { ok: false, error: "ID eksik." };
let ctx;
try {
ctx = await requireTenant();
} catch {
return { ok: false, error: "Yetkiniz yok." };
}
const { storage, tablesDB } = createAdminClient();
try {
const existing = (await tablesDB.getRow(
DATABASE_ID,
TABLES.attachments,
attachmentId,
)) as unknown as Attachment;
if (existing.tenantId !== ctx.tenantId) {
return { ok: false, error: "Erişim engellendi." };
}
await tablesDB.deleteRow(DATABASE_ID, TABLES.attachments, attachmentId);
try {
await storage.deleteFile({
bucketId: BUCKETS.entityAttachments,
fileId: existing.fileId,
});
} catch { /* best-effort — DB row already deleted */ }
} catch (e) {
return { ok: false, error: e instanceof Error ? e.message : "Silme başarısız." };
}
return { ok: true };
}
+13
View File
@@ -2,6 +2,7 @@ export const DATABASE_ID = "isletmem";
export const BUCKETS = { export const BUCKETS = {
tenantLogos: "tenant-logos", tenantLogos: "tenant-logos",
entityAttachments: "entity-attachments",
} as const; } as const;
export const TABLES = { export const TABLES = {
@@ -27,6 +28,7 @@ export const TABLES = {
leads: "leads", leads: "leads",
leadActivities: "lead_activities", leadActivities: "lead_activities",
passwordResets: "password_resets", passwordResets: "password_resets",
attachments: "attachments",
} as const; } as const;
export type TableId = (typeof TABLES)[keyof typeof TABLES]; export type TableId = (typeof TABLES)[keyof typeof TABLES];
@@ -361,3 +363,14 @@ export interface InviteLink extends Row {
acceptedAt?: string; acceptedAt?: string;
acceptedBy?: string; acceptedBy?: string;
} }
export interface Attachment extends Row {
tenantId: string;
createdBy: string;
entityType: string;
entityId: string;
fileId: string;
name: string;
size: number;
mimeType?: string;
}