From 2e1f4d7929438e6def6d53a845988c1bafd051eb Mon Sep 17 00:00:00 2001 From: suphicakir Date: Thu, 5 Nov 2026 07:55:04 +0300 Subject: [PATCH 1/4] =?UTF-8?q?feat(attachments):=20depolama=20soyutlamas?= =?UTF-8?q?=C4=B1,=20i=C3=A7erikten=20t=C3=BCr=20tespiti,=20sharp/HEIC=20d?= =?UTF-8?q?=C3=B6n=C3=BC=C5=9F=C3=BCm=C3=BC=20ve=20yetkili=20dosya=20servi?= =?UTF-8?q?si?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- README.md | 23 +- next.config.ts | 25 +- package-lock.json | 55 +++- package.json | 7 +- scripts/cleanup.ts | 18 + src/app/api/files/[id]/route.ts | 49 +++ src/app/api/files/route.ts | 53 +++ src/features/attachments/service.int.test.ts | 326 +++++++++++++++++++ src/features/attachments/service.ts | 326 +++++++++++++++++++ src/lib/storage/.gitkeep | 0 src/lib/storage/index.ts | 18 + src/lib/storage/local.test.ts | 35 ++ src/lib/storage/local.ts | 37 +++ src/lib/storage/types.ts | 18 + src/lib/uploads/process.ts | 74 +++++ src/models/attachment.ts | 51 +++ src/proxy.ts | 4 +- src/shared/attachments.test.ts | 56 ++++ src/shared/attachments.ts | 78 +++++ src/types/heic-decode.d.ts | 5 + vitest.integration.config.mts | 4 + 21 files changed, 1246 insertions(+), 16 deletions(-) create mode 100644 scripts/cleanup.ts create mode 100644 src/app/api/files/[id]/route.ts create mode 100644 src/app/api/files/route.ts create mode 100644 src/features/attachments/service.int.test.ts create mode 100644 src/features/attachments/service.ts delete mode 100644 src/lib/storage/.gitkeep create mode 100644 src/lib/storage/index.ts create mode 100644 src/lib/storage/local.test.ts create mode 100644 src/lib/storage/local.ts create mode 100644 src/lib/storage/types.ts create mode 100644 src/lib/uploads/process.ts create mode 100644 src/models/attachment.ts create mode 100644 src/shared/attachments.test.ts create mode 100644 src/shared/attachments.ts create mode 100644 src/types/heic-decode.d.ts diff --git a/README.md b/README.md index 35a9630..6859b22 100644 --- a/README.md +++ b/README.md @@ -52,16 +52,19 @@ Giriş kullanıcı adı **veya** e-posta ile yapılır. Kayıt kapalıdır; kull ## Scriptler -| Komut | Açıklama | -| ----------------------------- | -------------------------------- | -| `npm run dev` | Geliştirme sunucusu | -| `npm run build` / `start` | Üretim build'i / sunucusu | -| `npm run lint` | ESLint | -| `npm run typecheck` | `tsc --noEmit` | -| `npm run test` | Vitest birim testleri | -| `npm run format` | Prettier | -| `npm run db:up` / `db:down` | Lokal MongoDB'yi başlat / durdur | -| `npm run seed` / `seed:reset` | Örnek veri (Faz 4) | +| Komut | Açıklama | +| ----------------------------- | --------------------------------- | +| `npm run dev` | Geliştirme sunucusu | +| `npm run build` / `start` | Üretim build'i / sunucusu | +| `npm run lint` | ESLint | +| `npm run typecheck` | `tsc --noEmit` | +| `npm run test` | Vitest birim testleri | +| `npm run format` | Prettier | +| `npm run db:up` / `db:down` | Lokal MongoDB'yi başlat / durdur | +| `npm run seed` / `seed:reset` | Örnek veri (Faz 4) | +| `npm run test:integration` | Entegrasyon testleri (MongoDB) | +| `npm run migrate` | Bekleyen migration'lar | +| `npm run cleanup` | Eski/bağlanmamış ek dosyaları sil | ## Haneler ve yetki diff --git a/next.config.ts b/next.config.ts index 8e8b26e..a367e4f 100644 --- a/next.config.ts +++ b/next.config.ts @@ -42,10 +42,24 @@ const securityHeaders = [ [{ key: "Strict-Transport-Security", value: "max-age=31536000" }]), ]; +/** + * /api/files: kullanıcı dosyaları (WebP görsel, PDF). Sayfa CSP'si yerine daha sıkı bir politika: + * betik yok; PDF'in tarayıcı görüntüleyicisinde açılabilmesi için object-src 'self'. + */ +const fileCsp = [ + "default-src 'none'", + "img-src 'self'", + "style-src 'unsafe-inline'", + "object-src 'self'", + "frame-ancestors 'self'", +].join("; "); + const nextConfig: NextConfig = { // Docker imajı için yalnızca gerekli dosyaları .next/standalone altına kopyala. output: "standalone", poweredByHeader: false, + // HEIC çözücü (libheif WASM) bundle'a girmesin; Node require ile yüklensin. + serverExternalPackages: ["heic-decode", "libheif-js"], experimental: { agentFeedback: true, }, @@ -60,7 +74,16 @@ const nextConfig: NextConfig = { }, }, async headers() { - return [{ source: "/:path*", headers: securityHeaders }]; + return [ + { source: "/:path((?!api/files/).*)", headers: securityHeaders }, + { + source: "/api/files/:path*", + headers: [ + ...securityHeaders.filter((h) => h.key !== "Content-Security-Policy"), + { key: "Content-Security-Policy", value: fileCsp }, + ], + }, + ]; }, }; diff --git a/package-lock.json b/package-lock.json index f62a378..7d959e9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,18 +1,20 @@ { "name": "butcem", - "version": "0.1.0", + "version": "0.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "butcem", - "version": "0.1.0", + "version": "0.6.0", "dependencies": { "@hookform/resolvers": "^7.0.2", "@tanstack/react-table": "^9.3.1", "better-auth": "^1.7.17", "class-variance-authority": "^0.7.1", "cn": "^0.5.2", + "embla-carousel-react": "^8.6.0", + "heic-decode": "^2.1.0", "lucide-react": "^1.68.0", "mongodb": "~7.7", "mongoose": "^9.11.4", @@ -6861,6 +6863,34 @@ "integrity": "sha512-LnPnRZ0esKB9NjsSIaC+mffW+5dAtBui6kbK5f9Qykblj4Q33cn6yIhuCcTVjwpvL3YJPqqQ4IlsNyriGNpyFQ==", "license": "ISC" }, + "node_modules/embla-carousel": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz", + "integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==", + "license": "MIT" + }, + "node_modules/embla-carousel-react": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/embla-carousel-react/-/embla-carousel-react-8.6.0.tgz", + "integrity": "sha512-0/PjqU7geVmo6F734pmPqpyHqiM99olvyecY7zdweCw+6tKEXnrE90pBiBbMMU8s5tICemzpQ3hi5EpxzGW+JA==", + "license": "MIT", + "dependencies": { + "embla-carousel": "8.6.0", + "embla-carousel-reactive-utils": "8.6.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.1 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, + "node_modules/embla-carousel-reactive-utils": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/embla-carousel-reactive-utils/-/embla-carousel-reactive-utils-8.6.0.tgz", + "integrity": "sha512-fMVUDUEx0/uIEDM0Mz3dHznDhfX+znCCDCeIophYb1QGVM7YThSWX+wz11zlYwWFOr74b4QLGg0hrGPJeG2s4A==", + "license": "MIT", + "peerDependencies": { + "embla-carousel": "8.6.0" + } + }, "node_modules/emoji-regex": { "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", @@ -8205,6 +8235,18 @@ "node": ">= 0.4" } }, + "node_modules/heic-decode": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/heic-decode/-/heic-decode-2.1.0.tgz", + "integrity": "sha512-0fB3O3WMk38+PScbHLVp66jcNhsZ/ErtQ6u2lMYu/YxXgbBtl+oKOhGQHa4RpvE68k8IzbWkABzHnyAIjR758A==", + "license": "ISC", + "dependencies": { + "libheif-js": "^1.19.8" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/hermes-estree": { "version": "0.25.1", "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", @@ -9076,6 +9118,15 @@ "node": ">= 0.8.0" } }, + "node_modules/libheif-js": { + "version": "1.23.5", + "resolved": "https://registry.npmjs.org/libheif-js/-/libheif-js-1.23.5.tgz", + "integrity": "sha512-umXZPthnWZtF3iG/5mQG+AZ2V6i0UeYpo3z2CkggTEgL8dKmIIwJT6TDuAMuabXa1yTW8uJ1ZAgR+CxNTklYYw==", + "license": "LGPL-3.0", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/lightningcss": { "version": "1.33.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", diff --git a/package.json b/package.json index 489c51e..419a6af 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "butcem", - "version": "0.5.0", + "version": "0.6.0", "private": true, "description": "BÜTÇEM — aile bütçe, cari, birikim, ev stoğu ve akıllı alışveriş uygulaması", "engines": { @@ -22,7 +22,8 @@ "seed:reset": "tsx --conditions=react-server --env-file=.env.local scripts/seed.ts --reset", "icons": "tsx scripts/generate-icons.ts", "test:integration": "vitest run --config vitest.integration.config.mts", - "migrate": "tsx --conditions=react-server --env-file=.env.local scripts/migrate.ts" + "migrate": "tsx --conditions=react-server --env-file=.env.local scripts/migrate.ts", + "cleanup": "tsx --conditions=react-server --env-file=.env.local scripts/cleanup.ts" }, "dependencies": { "@hookform/resolvers": "^7.0.2", @@ -30,6 +31,8 @@ "better-auth": "^1.7.17", "class-variance-authority": "^0.7.1", "cn": "^0.5.2", + "embla-carousel-react": "^8.6.0", + "heic-decode": "^2.1.0", "lucide-react": "^1.68.0", "mongodb": "~7.7", "mongoose": "^9.11.4", diff --git a/scripts/cleanup.ts b/scripts/cleanup.ts new file mode 100644 index 0000000..7223877 --- /dev/null +++ b/scripts/cleanup.ts @@ -0,0 +1,18 @@ +/** + * Ek dosya temizliği: npm run cleanup (canlıda günlük zamanlanmış görev; bkz. Faz 15). + * 24 saatten eski bağlanmamış ekler ve 30 günden önce silinmiş kayıtların ekleri fiziksel olarak silinir. + */ +import mongoose from "mongoose"; +import { purgeAttachments } from "@/features/attachments/service"; +import { getMongoClient } from "@/lib/db/mongo"; + +purgeAttachments() + .then(({ removed }) => console.log(`✓ Temizlenen ek dosya: ${removed}`)) + .catch((err: unknown) => { + console.error(err instanceof Error ? err.message : err); + process.exitCode = 1; + }) + .finally(async () => { + await mongoose.disconnect(); + await getMongoClient().close(); + }); diff --git a/src/app/api/files/[id]/route.ts b/src/app/api/files/[id]/route.ts new file mode 100644 index 0000000..b0f307d --- /dev/null +++ b/src/app/api/files/[id]/route.ts @@ -0,0 +1,49 @@ +import type { NextRequest } from "next/server"; +import { readAttachmentForUser } from "@/features/attachments/service"; +import { getCurrentUser } from "@/lib/dal"; + +/** + * GET /api/files/:id[?v=thumb][&indir=1] — oturum ve hane yetkisi kontrol edilerek ek dosyayı + * servis eder. Erişim yoksa (başka hane, gizli hesap, silinmiş kayıt) 404: varlık sızdırılmaz. + */ +export async function GET(request: NextRequest, ctx: RouteContext<"/api/files/[id]">) { + const user = await getCurrentUser(); + if (!user) return new Response(null, { status: 401 }); + const { id } = await ctx.params; + const params = request.nextUrl.searchParams; + const file = await readAttachmentForUser( + user.id, + id, + params.get("v") === "thumb" ? "thumb" : "full", + ); + if (!file) return new Response(null, { status: 404 }); + + const disposition = params.get("indir") === "1" ? "attachment" : "inline"; + const fallbackName = file.mimeType === "application/pdf" ? "belge.pdf" : "gorsel.webp"; + const name = toServedName(file.name || fallbackName, file.mimeType); + return new Response(new Uint8Array(file.bytes), { + headers: { + "Content-Type": file.mimeType, + "Content-Length": String(file.bytes.length), + "Content-Disposition": `${disposition}; filename="${asciiName(name)}"; filename*=UTF-8''${encodeURIComponent(name)}`, + // Yalnızca kullanıcının tarayıcısında; paylaşılan önbelleklere girmez. + "Cache-Control": "private, max-age=3600", + "X-Content-Type-Options": "nosniff", + }, + }); +} + +/** Görseller WebP'ye çevrildiği için indirilen adın uzantısı içerikle uyumlu olsun. */ +function toServedName(name: string, mimeType: string): string { + const ext = mimeType === "application/pdf" ? ".pdf" : ".webp"; + return name.replace(/\.[a-z0-9]{1,5}$/i, "") + ext; +} + +function asciiName(name: string): string { + return ( + name + .normalize("NFKD") + .replace(/[^\x20-\x7e]/g, "") + .replace(/["\\]/g, "") || "dosya" + ); +} diff --git a/src/app/api/files/route.ts b/src/app/api/files/route.ts new file mode 100644 index 0000000..449e6dd --- /dev/null +++ b/src/app/api/files/route.ts @@ -0,0 +1,53 @@ +import { uploadAttachment } from "@/features/attachments/service"; +import { tr } from "@/i18n/tr"; +import { fail } from "@/lib/action-result"; +import { getHouseholdActionContext } from "@/lib/dal"; +import { env } from "@/lib/env"; + +/** Multipart başlıkları için tolerans. */ +const MULTIPART_OVERHEAD = 64 * 1024; + +/** + * POST /api/files — tek dosya yükler (`file` alanı), aktif haneye bekleyen ek olarak kaydeder. + * Kayıt (ör. işlem) kaydedilirken ek kimlikleri gönderilir ve ek kayda bağlanır. + */ +export async function POST(request: Request) { + if (!isSameOrigin(request)) return Response.json(fail(tr.common.unauthorized), { status: 403 }); + const ctx = await getHouseholdActionContext("member"); + if (!ctx) return Response.json(fail(tr.common.unauthorized), { status: 401 }); + + const maxMb = env().MAX_UPLOAD_MB; + const length = Number(request.headers.get("content-length") ?? 0); + if (length > maxMb * 1024 * 1024 + MULTIPART_OVERHEAD) { + return Response.json(fail(tr.attachments.errors.tooLarge(maxMb)), { status: 413 }); + } + + let file: FormDataEntryValue | null; + try { + file = (await request.formData()).get("file"); + } catch { + return Response.json(fail(tr.attachments.errors.invalid), { status: 400 }); + } + if (!(file instanceof File)) { + return Response.json(fail(tr.attachments.errors.empty), { status: 400 }); + } + + const bytes = Buffer.from(await file.arrayBuffer()); + const result = await uploadAttachment(ctx.user.id, ctx.household.id, { name: file.name, bytes }); + return Response.json(result, { status: result.ok ? 201 : 400 }); +} + +/** + * CSRF savunması: tarayıcı isteğinde Origin varsa uygulamanın kendi host'u olmalı. + * (Oturum cookie'si SameSite=Lax; bu ek bir katman.) + */ +function isSameOrigin(request: Request): boolean { + const origin = request.headers.get("origin"); + if (!origin) return true; + const host = request.headers.get("x-forwarded-host") ?? request.headers.get("host"); + try { + return new URL(origin).host === host; + } catch { + return false; + } +} diff --git a/src/features/attachments/service.int.test.ts b/src/features/attachments/service.int.test.ts new file mode 100644 index 0000000..464ffa9 --- /dev/null +++ b/src/features/attachments/service.int.test.ts @@ -0,0 +1,326 @@ +import { readFile, rm } from "node:fs/promises"; +import sharp from "sharp"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { + getTransaction, + createTransaction, + updateTransaction, + setTransactionDeleted, +} from "@/features/transactions/service"; +import { env } from "@/lib/env"; +import { getStorage } from "@/lib/storage"; +import { AccountModel } from "@/models/account"; +import { AttachmentModel } from "@/models/attachment"; +import { CategoryModel } from "@/models/category"; +import { TransactionModel } from "@/models/transaction"; +import type { TransactionForm } from "@/shared/schemas/transaction"; +import { setupSeededDb, teardownDb } from "@/test/seeded-db"; +import { + purgeAttachments, + readAttachmentForUser, + uploadAttachment, + type AttachmentInfo, +} from "./service"; + +let ids: Awaited>; +let cakir = ""; +let annemler = ""; +let ayse = ""; +let emre = ""; +let zeynep = ""; +let fatma = ""; + +beforeAll(async () => { + await rm(env().UPLOAD_DIR, { recursive: true, force: true }); + ids = await setupSeededDb(); + cakir = ids.household("Çakır Ailesi"); + annemler = ids.household("Annemler"); + ayse = ids.user("ayse"); + emre = ids.user("emre"); + zeynep = ids.user("zeynep"); + fatma = ids.user("fatma"); +}); +afterAll(async () => { + await teardownDb(); + await rm(env().UPLOAD_DIR, { recursive: true, force: true }); +}); + +const acc = (name: string, householdId = cakir) => + AccountModel.findOne({ householdId, name }) + .lean() + .then((a) => String(a!._id)); +const cat = (key: string) => + CategoryModel.findOne({ householdId: cakir, defaultKey: key }) + .lean() + .then((c) => String(c!._id)); + +async function form(over: Partial): Promise { + return { + type: "expense", + amount: 100_00, + date: "2026-06-15", + accountId: await acc("Cüzdan"), + toAccountId: null, + categoryId: await cat("market"), + splits: [], + payee: "", + description: "", + tags: [], + attachmentIds: [], + ...over, + }; +} + +const png = (w = 40, h = 30) => + sharp({ create: { width: w, height: h, channels: 3, background: "#3366cc" } }) + .png() + .toBuffer(); +const pdf = Buffer.from("%PDF-1.4\n1 0 obj<<>>endobj\ntrailer<<>>\n%%EOF\n", "latin1"); + +async function upload(userId: string, bytes: Buffer, name = "fis.png", householdId = cakir) { + const r = await uploadAttachment(userId, householdId, { name, bytes }); + if (!r.ok) throw new Error(r.error); + return r.data; +} + +describe("yükleme ve dönüştürme", () => { + it("JPEG: EXIF/GPS silinir, yön uygulanır, 2000 px'e küçültülür, WebP + küçük resim", async () => { + const jpeg = await sharp({ + create: { width: 3000, height: 1200, channels: 3, background: "#cc3333" }, + }) + .jpeg() + .withMetadata({ orientation: 6 }) + .withExif({ IFD0: { Copyright: "gizli", Artist: "Ayşe" } }) + .toBuffer(); + expect((await sharp(jpeg).metadata()).exif).toBeDefined(); + + const info = await upload(ayse, jpeg, "../../etc/fiş.JPG"); + expect(info.kind).toBe("image"); + expect(info.name).toBe("fiş.JPG"); + // Yön 6 (90°): 3000x1200 → 1200x3000 → en fazla 2000: 800x2000 + expect([info.width, info.height]).toEqual([800, 2000]); + + const doc = await AttachmentModel.findById(info.id).lean(); + expect(doc!.mimeType).toBe("image/webp"); + expect(doc!.ownerId).toBeNull(); + const stored = await getStorage().get(doc!.storageKey); + const meta = await sharp(stored!).metadata(); + expect(meta.format).toBe("webp"); + expect(meta.exif).toBeUndefined(); + expect(meta.orientation).toBeUndefined(); + const thumb = await sharp((await getStorage().get(doc!.thumbKey!))!).metadata(); + expect(Math.max(thumb.width!, thumb.height!)).toBe(320); + }); + + it("PNG ve PDF kabul edilir; uzantıya değil içeriğe bakılır", async () => { + expect((await upload(ayse, await png(), "resim.pdf")).kind).toBe("image"); + const p = await upload(ayse, pdf, "bordro.jpg"); + expect(p.kind).toBe("pdf"); + expect(p.thumbUrl).toBeNull(); + }); + + it("desteklenmeyen, bozuk ve boş dosyalar reddedilir", async () => { + const text = await uploadAttachment(ayse, cakir, { + name: "fis.jpg", + bytes: Buffer.from(""), + }); + expect(text.ok).toBe(false); + const broken = await uploadAttachment(ayse, cakir, { + name: "x.jpg", + bytes: Buffer.from([0xff, 0xd8, 0xff, 0xe0, 1, 2, 3, 4, 5, 6]), + }); + expect(broken).toMatchObject({ ok: false }); + const fakePdf = await uploadAttachment(ayse, cakir, { + name: "x.pdf", + bytes: Buffer.from("%PDF-1.4 yarım"), + }); + expect(fakePdf.ok).toBe(false); + expect((await uploadAttachment(ayse, cakir, { name: "x", bytes: Buffer.alloc(0) })).ok).toBe( + false, + ); + }); + + it("boyut sınırı uygulanır", async () => { + const big = Buffer.alloc(env().MAX_UPLOAD_MB * 1024 * 1024 + 1); + big.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + expect((await uploadAttachment(ayse, cakir, { name: "b.png", bytes: big })).ok).toBe(false); + }); + + it("izleyici ve hane dışı kullanıcı yükleyemez", async () => { + expect((await uploadAttachment(zeynep, cakir, { name: "a.png", bytes: await png() })).ok).toBe( + false, + ); + expect((await uploadAttachment(fatma, cakir, { name: "a.png", bytes: await png() })).ok).toBe( + false, + ); + }); + + it.skipIf(!process.env.HEIC_SAMPLE)("HEIC (iPhone) WebP'ye dönüştürülür", async () => { + const heic = await readFile(process.env.HEIC_SAMPLE!); + const info = await upload(ayse, heic, "IMG_0001.HEIC"); + expect(info.kind).toBe("image"); + const doc = await AttachmentModel.findById(info.id).lean(); + expect(doc!.sourceType).toBe("heic"); + expect((await sharp((await getStorage().get(doc!.storageKey))!).metadata()).format).toBe( + "webp", + ); + }); +}); + +describe("işleme bağlama", () => { + let a1: AttachmentInfo; + let a2: AttachmentInfo; + let a3: AttachmentInfo; + let txId = ""; + + it("gider işlemine görseller bağlanır, sıra korunur", async () => { + a1 = await upload(ayse, await png(10, 10)); + a2 = await upload(ayse, await png(20, 10)); + a3 = await upload(ayse, await png(30, 10)); + const r = await createTransaction( + ayse, + cakir, + await form({ attachmentIds: [a2.id, a1.id, a3.id] }), + ); + expect(r.ok).toBe(true); + txId = r.ok ? r.data.id : ""; + const detail = await getTransaction(ayse, cakir, txId); + expect(detail!.attachmentCount).toBe(3); + expect(detail!.attachments.map((a) => a.id)).toEqual([a2.id, a1.id, a3.id]); + }); + + it("gider işlemine PDF eklenemez; gelirde eklenir; hata olursa işlem oluşmaz", async () => { + const p = await upload(ayse, pdf, "bordro.pdf"); + const before = await TransactionModel.countDocuments({ householdId: cakir }); + const bad = await createTransaction(ayse, cakir, await form({ attachmentIds: [p.id] })); + expect(bad).toMatchObject({ ok: false }); + expect(await TransactionModel.countDocuments({ householdId: cakir })).toBe(before); + expect((await AttachmentModel.findById(p.id).lean())!.ownerId).toBeNull(); + + const good = await createTransaction( + ayse, + cakir, + await form({ + type: "income", + categoryId: await cat("maas"), + accountId: await acc("Ziraat Maaş"), + attachmentIds: [p.id], + }), + ); + expect(good.ok).toBe(true); + }); + + it("başkasının bekleyen eki, başka hanenin eki veya başka işlemin eki bağlanamaz", async () => { + const emres = await upload(emre, await png()); + expect( + (await createTransaction(ayse, cakir, await form({ attachmentIds: [emres.id] }))).ok, + ).toBe(false); + const other = await upload(fatma, await png(), "x.png", annemler); + expect( + (await createTransaction(ayse, cakir, await form({ attachmentIds: [other.id] }))).ok, + ).toBe(false); + // a1 zaten txId'ye bağlı: başka işleme taşınamaz. + expect((await createTransaction(ayse, cakir, await form({ attachmentIds: [a1.id] }))).ok).toBe( + false, + ); + }); + + it("kayıt başına en fazla 10 ek", async () => { + const many = []; + for (let i = 0; i < 11; i++) many.push((await upload(emre, await png(5 + i, 5))).id); + expect((await createTransaction(emre, cakir, await form({ attachmentIds: many }))).ok).toBe( + false, + ); + expect( + (await createTransaction(emre, cakir, await form({ attachmentIds: many.slice(0, 10) }))).ok, + ).toBe(true); + }); + + it("düzenlemede çıkarılan ek kayıttan düşer, yeni ek eklenir (başka üye de düzenleyebilir)", async () => { + const a4 = await upload(emre, await png(44, 10)); + const detail = await getTransaction(emre, cakir, txId); + const r = await updateTransaction( + emre, + cakir, + txId, + await form({ + accountId: detail!.account.id, + categoryId: detail!.categoryId, + attachmentIds: [a3.id, a4.id], + }), + ); + expect(r.ok).toBe(true); + const after = await getTransaction(ayse, cakir, txId); + expect(after!.attachments.map((a) => a.id)).toEqual([a3.id, a4.id]); + expect(after!.attachmentCount).toBe(2); + expect((await AttachmentModel.findById(a1.id).lean())!.deletedAt).toBeInstanceOf(Date); + expect(await readAttachmentForUser(ayse, a1.id, "full")).toBeNull(); + }); + + it("dosya erişimi: hane üyeleri (izleyici dahil) okur; hane dışı ve silinmiş işlem okuyamaz", async () => { + const file = await readAttachmentForUser(zeynep, a3.id, "thumb"); + expect(file?.mimeType).toBe("image/webp"); + expect(await readAttachmentForUser(fatma, a3.id, "full")).toBeNull(); + expect(await readAttachmentForUser(ayse, "not-an-id", "full")).toBeNull(); + + await setTransactionDeleted(ayse, cakir, txId, true); + expect(await readAttachmentForUser(ayse, a3.id, "full")).toBeNull(); + await setTransactionDeleted(ayse, cakir, txId, false); + expect(await readAttachmentForUser(ayse, a3.id, "full")).not.toBeNull(); + }); + + it("bekleyen ek yalnızca yükleyene görünür", async () => { + const pending = await upload(ayse, await png()); + expect(await readAttachmentForUser(ayse, pending.id, "full")).not.toBeNull(); + expect(await readAttachmentForUser(emre, pending.id, "full")).toBeNull(); + }); + + it("gizli hesaptaki işlemin eki hesap sahibi dışındakilere kapalı", async () => { + const img = await upload(ayse, await png()); + const r = await createTransaction( + ayse, + cakir, + await form({ accountId: await acc("Ayşe — Pluxee"), attachmentIds: [img.id] }), + ); + expect(r.ok).toBe(true); + expect(await readAttachmentForUser(ayse, img.id, "full")).not.toBeNull(); + expect(await readAttachmentForUser(emre, img.id, "full")).toBeNull(); + }); +}); + +describe("temizlik", () => { + it("eski bekleyen ekler, çıkarılmış ekler ve 30 günden önce silinmiş işlemlerin ekleri silinir", async () => { + const pending = await upload(ayse, await png()); + const old = new Date(Date.now() - 25 * 60 * 60 * 1000); + await AttachmentModel.collection.updateOne( + { _id: (await AttachmentModel.findById(pending.id))!._id }, + { $set: { createdAt: old } }, + ); + + const kept = await upload(ayse, await png()); + const r = await createTransaction(ayse, cakir, await form({ attachmentIds: [kept.id] })); + const tx = r.ok ? r.data.id : ""; + await setTransactionDeleted(ayse, cakir, tx, true); + const keptDoc = (await AttachmentModel.findById(kept.id).lean())!; + + // Silme 31 gün önce yapılmış gibi. + const now = new Date(Date.now() + 31 * 24 * 60 * 60 * 1000); + const pendingDoc = (await AttachmentModel.findById(pending.id).lean())!; + const { removed } = await purgeAttachments(now); + expect(removed).toBeGreaterThan(0); + expect(await AttachmentModel.findById(pending.id)).toBeNull(); + expect(await getStorage().get(pendingDoc.storageKey)).toBeNull(); + expect(await AttachmentModel.findById(kept.id)).toBeNull(); + expect(await getStorage().get(keptDoc.storageKey)).toBeNull(); + expect((await TransactionModel.findById(tx).lean())!.attachmentCount).toBe(0); + }); + + it("taze bekleyen ekler ve bağlı ekler korunur", async () => { + const fresh = await upload(ayse, await png()); + const linked = await upload(ayse, await png()); + await createTransaction(ayse, cakir, await form({ attachmentIds: [linked.id] })); + await purgeAttachments(); + expect(await AttachmentModel.findById(fresh.id)).not.toBeNull(); + expect(await AttachmentModel.findById(linked.id)).not.toBeNull(); + }); +}); diff --git a/src/features/attachments/service.ts b/src/features/attachments/service.ts new file mode 100644 index 0000000..bd8925d --- /dev/null +++ b/src/features/attachments/service.ts @@ -0,0 +1,326 @@ +import "server-only"; +import { randomBytes } from "node:crypto"; +import { Types, type ClientSession } from "mongoose"; +import { resolveMembership } from "@/features/households/service"; +import { tr } from "@/i18n/tr"; +import { fail, ok, type ActionResult } from "@/lib/action-result"; +import { connectMongoose } from "@/lib/db/mongoose"; +import { env } from "@/lib/env"; +import { toObjectId } from "@/lib/object-id"; +import { getStorage } from "@/lib/storage"; +import { isPlausiblePdf, processImage, UnreadableImageError } from "@/lib/uploads/process"; +import { AccountModel } from "@/models/account"; +import { AttachmentModel, type AttachmentOwnerType } from "@/models/attachment"; +import { TransactionModel } from "@/models/transaction"; +import { + detectFileType, + MAX_ATTACHMENTS_PER_RECORD, + type AttachmentKind, +} from "@/shared/attachments"; + +const t = tr.attachments; + +/** Bir kullanıcının bir hanede aynı anda bekletebileceği (kayda bağlanmamış) ek sayısı. */ +export const MAX_PENDING_PER_USER = 30; +/** Bağlanmamış eklerin ömrü. */ +export const PENDING_TTL_MS = 24 * 60 * 60 * 1000; +/** Silinen kayıt/ek dosyalarının fiziksel olarak silinmeden önce bekleme süresi (geri alma payı). */ +export const SOFT_DELETE_RETENTION_MS = 30 * 24 * 60 * 60 * 1000; + +export type AttachmentInfo = { + id: string; + kind: AttachmentKind; + name: string; + size: number; + width: number | null; + height: number | null; + url: string; + thumbUrl: string | null; +}; + +type LeanAttachment = { + _id: Types.ObjectId; + kind: AttachmentKind; + originalName?: string | null; + size: number; + width?: number | null; + height?: number | null; + thumbKey?: string | null; +}; + +export function toAttachmentInfo(a: LeanAttachment): AttachmentInfo { + const id = String(a._id); + return { + id, + kind: a.kind, + name: a.originalName || (a.kind === "pdf" ? "belge.pdf" : "gorsel.webp"), + size: a.size, + width: a.width ?? null, + height: a.height ?? null, + url: `/api/files/${id}`, + thumbUrl: a.thumbKey ? `/api/files/${id}?v=thumb` : null, + }; +} + +/** Kullanıcının verdiği dosya adını güvenli hale getirir (yol, kontrol karakteri, uzunluk). */ +export function sanitizeFileName(name: string): string { + const base = name.split(/[\\/]/).pop() ?? ""; + return base + .replace(/[\u0000-\u001f\u007f"<>]/g, "") + .trim() + .slice(0, 120); +} + +function newKey(): string { + const id = randomBytes(16).toString("hex"); + return `${id.slice(0, 2)}/${id}`; +} + +// --- Yükleme --- + +export async function uploadAttachment( + userId: string, + householdId: string, + file: { name: string; bytes: Buffer }, +): Promise> { + const m = await resolveMembership(userId, householdId, "member"); + if (!m) return fail(tr.common.unauthorized); + const maxBytes = env().MAX_UPLOAD_MB * 1024 * 1024; + if (file.bytes.length === 0) return fail(t.errors.empty); + if (file.bytes.length > maxBytes) return fail(t.errors.tooLarge(env().MAX_UPLOAD_MB)); + + const type = detectFileType(file.bytes); + if (!type) return fail(t.errors.unsupported); + + const uploader = new Types.ObjectId(userId); + const pending = await AttachmentModel.countDocuments({ + householdId: m.householdId, + ownerId: null, + uploadedBy: uploader, + deletedAt: null, + }); + if (pending >= MAX_PENDING_PER_USER) return fail(t.errors.tooManyPending); + + const key = newKey(); + const storage = getStorage(); + let doc: Record; + if (type === "pdf") { + if (!isPlausiblePdf(file.bytes)) return fail(t.errors.unreadable); + await storage.put(`${key}.pdf`, file.bytes); + doc = { + kind: "pdf", + mimeType: "application/pdf", + size: file.bytes.length, + storageKey: `${key}.pdf`, + thumbKey: null, + }; + } else { + let processed; + try { + processed = await processImage(file.bytes, type); + } catch (error) { + if (error instanceof UnreadableImageError) return fail(t.errors.unreadable); + throw error; + } + await storage.put(`${key}.webp`, processed.main); + await storage.put(`${key}.thumb.webp`, processed.thumb); + doc = { + kind: "image", + mimeType: "image/webp", + size: processed.main.length, + width: processed.width, + height: processed.height, + storageKey: `${key}.webp`, + thumbKey: `${key}.thumb.webp`, + }; + } + + const created = await AttachmentModel.create({ + ...doc, + householdId: m.householdId, + sourceType: type, + originalName: sanitizeFileName(file.name), + originalSize: file.bytes.length, + uploadedBy: uploader, + }); + return ok(toAttachmentInfo(created.toObject() as LeanAttachment)); +} + +// --- Kayda bağlama --- + +/** + * Kaydın ek listesini `ids` ile eşitler (sıra korunur). Yeni kimlikler, bu kullanıcının bu hanede + * yüklediği bekleyen ekler olmalı; listede olmayan mevcut ekler kayıttan çıkarılır (`deletedAt`). + * Kayıttaki güncel ek sayısını döner. Transaction içinde çağrılmalı (session). + */ +export async function syncOwnerAttachments(opts: { + householdId: Types.ObjectId; + actorId: string; + ownerType: AttachmentOwnerType; + ownerId: Types.ObjectId; + ids: string[]; + allowedKinds: readonly AttachmentKind[]; + session?: ClientSession; +}): Promise> { + const { householdId, ownerType, ownerId, session } = opts; + const unique = [...new Set(opts.ids)]; + if (unique.length > MAX_ATTACHMENTS_PER_RECORD) + return fail(t.errors.tooMany(MAX_ATTACHMENTS_PER_RECORD)); + const objectIds = unique.map(toObjectId); + if (objectIds.some((id) => !id)) return fail(t.errors.invalid); + + const current = await AttachmentModel.find( + { householdId, ownerType, ownerId, deletedAt: null }, + { _id: 1 }, + { session }, + ).lean(); + const currentIds = new Set(current.map((a) => String(a._id))); + const newIds = objectIds.filter((id) => !currentIds.has(String(id))) as Types.ObjectId[]; + + const candidates = await AttachmentModel.find( + { _id: { $in: objectIds }, householdId, deletedAt: null }, + { kind: 1, ownerId: 1, uploadedBy: 1 }, + { session }, + ).lean(); + const byId = new Map(candidates.map((a) => [String(a._id), a])); + for (const id of newIds) { + const a = byId.get(String(id)); + // Yalnızca kendi yüklediği, henüz hiçbir kayda bağlanmamış ekler bağlanabilir. + if (!a || a.ownerId || String(a.uploadedBy) !== opts.actorId) return fail(t.errors.invalid); + } + for (const id of objectIds) { + const a = byId.get(String(id)); + if (a && !opts.allowedKinds.includes(a.kind)) return fail(t.errors.kindNotAllowed); + } + + const removed = current.filter((a) => !objectIds.some((id) => id!.equals(a._id))); + if (removed.length) { + await AttachmentModel.updateMany( + { _id: { $in: removed.map((a) => a._id) }, householdId }, + { $set: { deletedAt: new Date() } }, + { session }, + ); + } + if (objectIds.length) { + await AttachmentModel.bulkWrite( + objectIds.map((id, order) => ({ + updateOne: { + filter: { _id: id!, householdId }, + update: { $set: { ownerType, ownerId, order } }, + }, + })), + { session }, + ); + } + return ok(objectIds.length); +} + +export async function listOwnerAttachments( + householdId: Types.ObjectId, + ownerType: AttachmentOwnerType, + ownerId: Types.ObjectId, +): Promise { + const rows = await AttachmentModel.find({ householdId, ownerType, ownerId, deletedAt: null }) + .sort({ order: 1, createdAt: 1 }) + .lean(); + return rows.map((a) => toAttachmentInfo(a as unknown as LeanAttachment)); +} + +// --- Servis etme --- + +export type AttachmentFile = { + bytes: Buffer; + mimeType: string; + name: string; +}; + +/** + * Yetki kontrollü dosya okuma: kullanıcı ekin hanesinin üyesi olmalı; bekleyen ek yalnızca + * yükleyene; işleme bağlı ek, işlem kullanıcıya görünürse (silinmemiş ve başkasının gizli + * hesabına dokunmuyorsa). Erişim yoksa null (çağıran 404 döner; varlık sızdırılmaz). + */ +export async function readAttachmentForUser( + userId: string, + attachmentId: string, + variant: "full" | "thumb", +): Promise { + const id = toObjectId(attachmentId); + if (!id) return null; + await connectMongoose(); + const a = await AttachmentModel.findOne({ _id: id, deletedAt: null }).lean(); + if (!a) return null; + const m = await resolveMembership(userId, String(a.householdId), "viewer"); + if (!m) return null; + + if (!a.ownerId) { + if (String(a.uploadedBy) !== userId) return null; + } else if (a.ownerType === "transaction") { + const tx = await TransactionModel.findOne( + { _id: a.ownerId, householdId: a.householdId, deletedAt: null }, + { accountId: 1, toAccountId: 1 }, + ).lean(); + if (!tx) return null; + const hidden = await AccountModel.exists({ + _id: { $in: [tx.accountId, tx.toAccountId].filter(Boolean) }, + householdId: a.householdId, + isPrivate: true, + ownerUserId: { $ne: new Types.ObjectId(userId) }, + }); + if (hidden) return null; + } else { + return null; + } + + const key = variant === "thumb" && a.thumbKey ? a.thumbKey : a.storageKey; + const bytes = await getStorage().get(key); + if (!bytes) return null; + return { + bytes, + mimeType: variant === "thumb" && a.thumbKey ? "image/webp" : a.mimeType, + name: a.originalName ?? "", + }; +} + +// --- Temizlik --- + +/** + * Fiziksel temizlik (Bölüm 7.4): 24 saatten eski bekleyen ekler, 30 günden önce çıkarılmış + * ekler ve 30 günden önce silinmiş işlemlerin ekleri. Dosyalar silinir, kayıtlar kaldırılır. + */ +export async function purgeAttachments(now = new Date()): Promise<{ removed: number }> { + await connectMongoose(); + const retentionCutoff = new Date(now.getTime() - SOFT_DELETE_RETENTION_MS); + const pendingCutoff = new Date(now.getTime() - PENDING_TTL_MS); + + const deletedTx = await TransactionModel.find( + { deletedAt: { $ne: null, $lt: retentionCutoff }, attachmentCount: { $gt: 0 } }, + { _id: 1 }, + ).lean(); + + const victims = await AttachmentModel.find( + { + $or: [ + { ownerId: null, createdAt: { $lt: pendingCutoff } }, + { deletedAt: { $ne: null, $lt: retentionCutoff } }, + ...(deletedTx.length + ? [{ ownerType: "transaction" as const, ownerId: { $in: deletedTx.map((d) => d._id) } }] + : []), + ], + }, + { storageKey: 1, thumbKey: 1 }, + ).lean(); + + const storage = getStorage(); + for (const v of victims) { + await storage.delete(v.storageKey); + if (v.thumbKey) await storage.delete(v.thumbKey); + await AttachmentModel.deleteOne({ _id: v._id }); + } + if (deletedTx.length) { + await TransactionModel.updateMany( + { _id: { $in: deletedTx.map((d) => d._id) } }, + { $set: { attachmentCount: 0 } }, + ); + } + return { removed: victims.length }; +} diff --git a/src/lib/storage/.gitkeep b/src/lib/storage/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/lib/storage/index.ts b/src/lib/storage/index.ts new file mode 100644 index 0000000..1dff069 --- /dev/null +++ b/src/lib/storage/index.ts @@ -0,0 +1,18 @@ +import "server-only"; +import { env } from "@/lib/env"; +import { createLocalDriver } from "./local"; +import type { StorageDriver } from "./types"; + +export type { StorageDriver } from "./types"; +export { isValidStorageKey } from "./types"; + +/** + * Depolama soyutlaması (Bölüm 7.4): `put`, `get`, `delete`. İlk sürücü yerel disk (`UPLOAD_DIR`, + * canlıda Docker volume). İleride S3 uyumlu (MinIO) sürücü aynı arayüzle eklenebilir. + */ +const g = globalThis as unknown as { __butcemStorage?: StorageDriver }; + +export function getStorage(): StorageDriver { + g.__butcemStorage ??= createLocalDriver(env().UPLOAD_DIR); + return g.__butcemStorage; +} diff --git a/src/lib/storage/local.test.ts b/src/lib/storage/local.test.ts new file mode 100644 index 0000000..250336d --- /dev/null +++ b/src/lib/storage/local.test.ts @@ -0,0 +1,35 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterAll, describe, expect, it } from "vitest"; +import { createLocalDriver } from "./local"; +import { isValidStorageKey } from "./types"; + +const dirs: string[] = []; +afterAll(() => Promise.all(dirs.map((d) => rm(d, { recursive: true, force: true })))); + +describe("yerel depolama", () => { + it("anahtar biçimi: yol geçişi ve beklenmeyen uzantılar reddedilir", () => { + expect(isValidStorageKey("ab/0123456789abcdef0123.webp")).toBe(true); + expect(isValidStorageKey("ab/0123456789abcdef0123.thumb.webp")).toBe(true); + expect(isValidStorageKey("ab/0123456789abcdef0123.pdf")).toBe(true); + expect(isValidStorageKey("../0123456789abcdef0123.webp")).toBe(false); + expect(isValidStorageKey("ab/../../etc/passwd")).toBe(false); + expect(isValidStorageKey("/ab/0123456789abcdef0123.webp")).toBe(false); + expect(isValidStorageKey("ab/0123456789abcdef0123.html")).toBe(false); + }); + + it("put/get/delete", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "butcem-storage-")); + dirs.push(dir); + const s = createLocalDriver(dir); + const key = "cd/cdef0123456789abcdef.pdf"; + expect(await s.get(key)).toBeNull(); + await s.put(key, new TextEncoder().encode("merhaba")); + expect((await s.get(key))!.toString()).toBe("merhaba"); + await s.delete(key); + await s.delete(key); + expect(await s.get(key)).toBeNull(); + await expect(s.put("../x.pdf", new Uint8Array())).rejects.toThrow(); + }); +}); diff --git a/src/lib/storage/local.ts b/src/lib/storage/local.ts new file mode 100644 index 0000000..937f289 --- /dev/null +++ b/src/lib/storage/local.ts @@ -0,0 +1,37 @@ +import { randomBytes } from "node:crypto"; +import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { isValidStorageKey, type StorageDriver } from "./types"; + +/** Yerel disk sürücüsü. Yazma atomiktir (geçici dosya + rename); yarım dosya servis edilmez. */ +export function createLocalDriver(baseDir: string): StorageDriver { + const root = path.resolve(baseDir); + + function resolve(key: string): string { + if (!isValidStorageKey(key)) throw new Error(`Geçersiz depolama anahtarı: ${key}`); + const full = path.resolve(root, key); + if (!full.startsWith(root + path.sep)) throw new Error("Depolama kökü dışına çıkılamaz."); + return full; + } + + return { + async put(key, data) { + const full = resolve(key); + await mkdir(path.dirname(full), { recursive: true, mode: 0o750 }); + const tmp = `${full}.${randomBytes(6).toString("hex")}.tmp`; + await writeFile(tmp, data, { mode: 0o640 }); + await rename(tmp, full); + }, + async get(key) { + try { + return await readFile(resolve(key)); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; + throw error; + } + }, + async delete(key) { + await rm(resolve(key), { force: true }); + }, + }; +} diff --git a/src/lib/storage/types.ts b/src/lib/storage/types.ts new file mode 100644 index 0000000..2c4dc71 --- /dev/null +++ b/src/lib/storage/types.ts @@ -0,0 +1,18 @@ +export interface StorageDriver { + /** Dosyayı yazar (varsa üzerine yazar). */ + put(key: string, data: Uint8Array): Promise; + /** Dosyayı okur; yoksa null. */ + get(key: string): Promise; + /** Dosyayı siler; yoksa sessizce geçer. */ + delete(key: string): Promise; +} + +/** + * Anahtar biçimi: "ab/[.thumb].webp|pdf". Yalnızca küçük harf, rakam, tire, nokta + * ve tek bir alt klasör; ".." veya mutlak yol olamaz (dizin dışına çıkma engellenir). + */ +const KEY_PATTERN = /^[a-z0-9]{2}\/[a-z0-9-]{16,64}(\.thumb)?\.(webp|pdf)$/; + +export function isValidStorageKey(key: string): boolean { + return KEY_PATTERN.test(key); +} diff --git a/src/lib/uploads/process.ts b/src/lib/uploads/process.ts new file mode 100644 index 0000000..467d11f --- /dev/null +++ b/src/lib/uploads/process.ts @@ -0,0 +1,74 @@ +import "server-only"; +import sharp from "sharp"; +import type { DetectedType } from "@/shared/attachments"; + +/** Ana görsel uzun kenar sınırı (fiş okunabilir kalsın, dosya küçülsün). */ +export const MAX_IMAGE_EDGE = 2000; +export const THUMB_EDGE = 320; +/** Dekompresyon bombasına karşı piksel sınırı (~50 MP). */ +const MAX_INPUT_PIXELS = 50_000_000; + +export type ProcessedImage = { + main: Buffer; + thumb: Buffer; + width: number; + height: number; +}; + +export class UnreadableImageError extends Error {} + +/** + * Görseli normalize eder: EXIF yönüne göre döndürür, en fazla 2000 px'e küçültür ve WebP'ye + * çevirir. sharp çıktıya meta veri yazmaz (withMetadata kullanılmadığı için EXIF/GPS/ICC dahil + * tüm meta veri düşer). HEIC (iPhone, HEVC) sharp'ın hazır derlemesinde çözülemediği için + * libheif (WASM) ile çözülür. + */ +export async function processImage(bytes: Buffer, type: DetectedType): Promise { + try { + const source = type === "heic" ? await decodeHeic(bytes) : sharpFrom(bytes).rotate(); + const { data, info } = await source + .resize({ + width: MAX_IMAGE_EDGE, + height: MAX_IMAGE_EDGE, + fit: "inside", + withoutEnlargement: true, + }) + .webp({ quality: 82 }) + .toBuffer({ resolveWithObject: true }); + const thumb = await sharp(data) + .resize({ width: THUMB_EDGE, height: THUMB_EDGE, fit: "inside", withoutEnlargement: true }) + .webp({ quality: 70 }) + .toBuffer(); + return { main: data, thumb, width: info.width, height: info.height }; + } catch (error) { + throw new UnreadableImageError(error instanceof Error ? error.message : String(error)); + } +} + +function sharpFrom(bytes: Buffer) { + return sharp(bytes, { failOn: "error", limitInputPixels: MAX_INPUT_PIXELS }); +} + +async function decodeHeic(bytes: Buffer) { + // Bazı .heic dosyaları aslında AV1 (AVIF) içerir; sharp bunları doğrudan çözebilir. + try { + const meta = await sharpFrom(bytes).metadata(); + if (meta.compression === "av1") return sharpFrom(bytes).rotate(); + } catch { + // libheif ile denenecek. + } + const { default: decode } = await import("heic-decode"); + const image = await decode({ buffer: bytes }); + if (image.width * image.height > MAX_INPUT_PIXELS) throw new Error("Görsel çok büyük."); + // libheif yönlendirmeyi (irot/imir) çözerken uygular; ham RGBA piksellerden devam edilir. + return sharp(Buffer.from(image.data.buffer, image.data.byteOffset, image.data.byteLength), { + raw: { width: image.width, height: image.height, channels: 4 }, + }); +} + +/** PDF'ler değiştirilmeden saklanır; yalnızca yapı kontrolü (başlık + dosya sonu işareti). */ +export function isPlausiblePdf(bytes: Buffer): boolean { + if (bytes.subarray(0, 5).toString("latin1") !== "%PDF-") return false; + const tail = bytes.subarray(Math.max(0, bytes.length - 2048)).toString("latin1"); + return tail.includes("%%EOF"); +} diff --git a/src/models/attachment.ts b/src/models/attachment.ts new file mode 100644 index 0000000..abc9199 --- /dev/null +++ b/src/models/attachment.ts @@ -0,0 +1,51 @@ +import { model, models, Schema, type InferSchemaType, type Model } from "mongoose"; +import { ATTACHMENT_KINDS } from "@/shared/attachments"; + +/** Ek dosyanın bağlanabileceği kayıt türleri (ileride: alışveriş fişi, cari hareket…). */ +export const ATTACHMENT_OWNER_TYPES = ["transaction"] as const; +export type AttachmentOwnerType = (typeof ATTACHMENT_OWNER_TYPES)[number]; + +/** + * Ek dosya meta verisi (Bölüm 7.4). Dosyanın kendisi depolama sürücüsünde, rastgele anahtarla. + * - Yüklenince `ownerId: null` (bekleyen); kayıt kaydedilince sahibine bağlanır. + * - Bekleyen ek yalnızca yükleyene görünür ve 24 saat içinde bağlanmazsa temizlenir. + * - Kayıttan çıkarılan ek `deletedAt` alır; dosya saklama süresi sonunda silinir. + */ +const attachmentSchema = new Schema( + { + householdId: { type: Schema.Types.ObjectId, required: true }, + ownerType: { type: String, enum: ATTACHMENT_OWNER_TYPES, default: null }, + ownerId: { type: Schema.Types.ObjectId, default: null }, + kind: { type: String, enum: ATTACHMENT_KINDS, required: true }, + /** Saklanan içeriğin türü (görseller her zaman WebP'ye çevrilir). */ + mimeType: { type: String, enum: ["image/webp", "application/pdf"], required: true }, + /** Kaynak tür (jpeg, png, webp, heic, pdf) — bilgi amaçlı. */ + sourceType: { type: String, required: true }, + originalName: { type: String, default: "", maxlength: 120 }, + size: { type: Number, required: true }, + originalSize: { type: Number, required: true }, + width: { type: Number, default: null }, + height: { type: Number, default: null }, + storageKey: { type: String, required: true }, + thumbKey: { type: String, default: null }, + uploadedBy: { type: Schema.Types.ObjectId, required: true }, + /** Kayıt içindeki sıra (carousel sırası). */ + order: { type: Number, default: 0 }, + deletedAt: { type: Date, default: null }, + }, + { timestamps: true, collection: "attachments" }, +); + +attachmentSchema.index({ householdId: 1, ownerType: 1, ownerId: 1, deletedAt: 1, order: 1 }); +attachmentSchema.index({ ownerId: 1, createdAt: 1 }); +attachmentSchema.index( + { deletedAt: 1 }, + { partialFilterExpression: { deletedAt: { $type: "date" } } }, +); +attachmentSchema.index({ storageKey: 1 }, { unique: true }); + +export type Attachment = InferSchemaType; + +export const AttachmentModel: Model = + (models.Attachment as Model | undefined) ?? + model("Attachment", attachmentSchema); diff --git a/src/proxy.ts b/src/proxy.ts index a292ad9..ec0c66b 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -16,7 +16,9 @@ export function proxy(request: NextRequest) { export const config = { // Herkese açık yollar hariç: giriş, auth ve health API'leri, statik dosyalar, ikonlar, manifest. + // /api/files da hariç: route oturumu kendisi kontrol eder (401) ve proxy büyük yükleme + // gövdelerini tamponlamasın (proxyClientMaxBodySize). matcher: [ - "/((?!login|api/auth|api/health|_next/static|_next/image|brand/|icons/|favicon.ico|icon.png|apple-icon.png|manifest.webmanifest|robots.txt).*)", + "/((?!login|api/auth|api/health|api/files|_next/static|_next/image|brand/|icons/|favicon.ico|icon.png|apple-icon.png|manifest.webmanifest|robots.txt).*)", ], }; diff --git a/src/shared/attachments.test.ts b/src/shared/attachments.test.ts new file mode 100644 index 0000000..f655c3d --- /dev/null +++ b/src/shared/attachments.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; +import { + acceptAttribute, + allowedAttachmentKinds, + detectFileType, + formatFileSize, + kindOf, +} from "./attachments"; + +const bytes = (...parts: (number[] | string)[]) => + new Uint8Array( + parts.flatMap((p) => (typeof p === "string" ? [...p].map((c) => c.charCodeAt(0)) : p)), + ); + +describe("detectFileType", () => { + it("imzalardan türü bulur", () => { + expect(detectFileType(bytes([0xff, 0xd8, 0xff, 0xe0]))).toBe("jpeg"); + expect(detectFileType(bytes([0x89], "PNG", [0x0d, 0x0a, 0x1a, 0x0a]))).toBe("png"); + expect(detectFileType(bytes("RIFF", [0, 0, 0, 0], "WEBPVP8 "))).toBe("webp"); + expect(detectFileType(bytes("%PDF-1.7"))).toBe("pdf"); + }); + + it("HEIC: ana veya uyumlu markadan", () => { + expect(detectFileType(bytes([0, 0, 0, 24], "ftypheic", [0, 0, 0, 0], "mif1heic"))).toBe("heic"); + expect(detectFileType(bytes([0, 0, 0, 28], "ftypmif1", [0, 0, 0, 0], "mif1heichevc"))).toBe( + "heic", + ); + // MP4 videosu HEIC değildir. + expect(detectFileType(bytes([0, 0, 0, 24], "ftypisom", [0, 0, 2, 0], "isomiso2"))).toBeNull(); + }); + + it("uzantı/metin/kısa içerik reddedilir", () => { + expect(detectFileType(bytes(" { + it("gider yalnızca görsel; gelir ve transfer görsel + PDF", () => { + expect(allowedAttachmentKinds("expense")).toEqual(["image"]); + expect(allowedAttachmentKinds("income")).toEqual(["image", "pdf"]); + expect(allowedAttachmentKinds("transfer")).toEqual(["image", "pdf"]); + expect(acceptAttribute(["image"])).not.toContain("pdf"); + expect(acceptAttribute(["image", "pdf"])).toContain("application/pdf"); + expect(kindOf("heic")).toBe("image"); + expect(kindOf("pdf")).toBe("pdf"); + }); + + it("boyut biçimi", () => { + expect(formatFileSize(900)).toBe("900 B"); + expect(formatFileSize(2048)).toBe("2 KB"); + expect(formatFileSize(1.5 * 1024 * 1024)).toBe("1,5 MB"); + }); +}); diff --git a/src/shared/attachments.ts b/src/shared/attachments.ts new file mode 100644 index 0000000..dd86736 --- /dev/null +++ b/src/shared/attachments.ts @@ -0,0 +1,78 @@ +import type { TransactionType } from "./transactions"; + +/** Ek dosya kuralları (Bölüm 6.3, 7.4). Sunucu ve istemci aynı sabitleri kullanır. */ + +export const MAX_ATTACHMENTS_PER_RECORD = 10; +export const ATTACHMENT_KINDS = ["image", "pdf"] as const; +export type AttachmentKind = (typeof ATTACHMENT_KINDS)[number]; + +/** Dosya içeriğinden tespit edilen kaynak türler (uzantıya güvenilmez). */ +export type DetectedType = "jpeg" | "png" | "webp" | "heic" | "pdf"; + +export function kindOf(type: DetectedType): AttachmentKind { + return type === "pdf" ? "pdf" : "image"; +} + +/** + * İşlem türüne göre izinli ek türleri: gider yalnızca görsel (fiş/fatura fotoğrafı); + * gelir görsel veya PDF (ör. bordro); transfer görsel veya PDF (dekont). + */ +export function allowedAttachmentKinds(type: TransactionType): readonly AttachmentKind[] { + return type === "expense" ? ["image"] : ["image", "pdf"]; +} + +/** `
+ {children} +
+ + ); +} + +function CarouselContent({ + className, + containerClassName, + ...props +}: React.ComponentProps<"div"> & { containerClassName?: string }) { + const { carouselRef, orientation } = useCarousel(); + + return ( +
+
+
+ ); +} + +function CarouselItem({ className, ...props }: React.ComponentProps<"div">) { + const { orientation } = useCarousel(); + + return ( +
+ ); +} + +function CarouselPrevious({ + className, + variant = "outline", + size = "icon-sm", + ...props +}: React.ComponentProps) { + const { orientation, scrollPrev, canScrollPrev } = useCarousel(); + + return ( + + ); +} + +function CarouselNext({ + className, + variant = "outline", + size = "icon-sm", + ...props +}: React.ComponentProps) { + const { orientation, scrollNext, canScrollNext } = useCarousel(); + + return ( + + ); +} + +export { + type CarouselApi, + Carousel, + CarouselContent, + CarouselItem, + CarouselPrevious, + CarouselNext, + useCarousel, +}; diff --git a/src/features/accounts/components/accounts-view.tsx b/src/features/accounts/components/accounts-view.tsx index c8a994a..c78d641 100644 --- a/src/features/accounts/components/accounts-view.tsx +++ b/src/features/accounts/components/accounts-view.tsx @@ -110,12 +110,14 @@ export function AccountsView({
  • - - {a.name} - + {/* Gizli hesabın ekstresi yalnızca sahibine açılır. */} + {a.balanceMinor === null ? ( + {a.name} + ) : ( + + {a.name} + + )}
    {a.type === "meal_card" && a.mealCardProvider ? ( {t.providers[a.mealCardProvider]} diff --git a/src/features/attachments/components/attachment-picker.tsx b/src/features/attachments/components/attachment-picker.tsx new file mode 100644 index 0000000..c3b0f7d --- /dev/null +++ b/src/features/attachments/components/attachment-picker.tsx @@ -0,0 +1,168 @@ +"use client"; + +import { Loader2, Paperclip, X } from "lucide-react"; +import { useRef, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { tr } from "@/i18n/tr"; +import type { ActionResult } from "@/lib/action-result"; +import { cn } from "@/lib/utils"; +import { + acceptAttribute, + MAX_ATTACHMENTS_PER_RECORD, + type AttachmentKind, +} from "@/shared/attachments"; +import type { AttachmentInfo } from "../service"; +import { AttachmentThumb } from "./attachment-strip"; +import { AttachmentViewer } from "./attachment-viewer"; + +const t = tr.attachments; + +/** Tek dosyayı /api/files'a yükler. */ +async function uploadFile(file: File): Promise> { + const body = new FormData(); + body.append("file", file); + try { + const res = await fetch("/api/files", { method: "POST", body }); + return (await res.json()) as ActionResult; + } catch { + return { ok: false, error: t.errors.upload }; + } +} + +/** + * Formdaki küçük "Ek ekle" alanı (Bölüm 6.3): seçilen dosyalar hemen yüklenir (bekleyen ek), + * küçük önizleme ve sayaç gösterilir; kayıt kaydedilince ekler kayda bağlanır. + */ +export function AttachmentPicker({ + value, + onChange, + allowedKinds, + onBusyChange, +}: { + value: AttachmentInfo[]; + onChange: (items: AttachmentInfo[]) => void; + allowedKinds: readonly AttachmentKind[]; + onBusyChange?: (busy: boolean) => void; +}) { + const inputRef = useRef(null); + const [uploading, setUploading] = useState(0); + const [errors, setErrors] = useState([]); + const [viewer, setViewer] = useState(null); + const invalid = value.filter((a) => !allowedKinds.includes(a.kind)); + const room = MAX_ATTACHMENTS_PER_RECORD - value.length; + + async function onFiles(files: FileList | null) { + if (!files?.length) return; + const list = [...files]; + const accepted = list.slice(0, Math.max(0, room)); + const nextErrors: string[] = []; + if (list.length > accepted.length) + nextErrors.push(t.errors.tooMany(MAX_ATTACHMENTS_PER_RECORD)); + setUploading(accepted.length); + onBusyChange?.(true); + let items = value; + for (const file of accepted) { + const result = await uploadFile(file); + if (result.ok) { + items = [...items, result.data]; + onChange(items); + } else { + nextErrors.push(`${file.name}: ${result.error}`); + } + setUploading((n) => n - 1); + } + setErrors(nextErrors); + onBusyChange?.(false); + if (inputRef.current) inputRef.current.value = ""; + } + + function remove(id: string) { + onChange(value.filter((a) => a.id !== id)); + } + + return ( +
    +
    + + + {value.length + ? t.count(value.length) + : allowedKinds.includes("pdf") + ? t.addHint.imagePdf + : t.addHint.image} + + onFiles(e.target.files)} + /> +
    + + {value.length || uploading ? ( +
      + {value.map((item, i) => ( +
    • + setViewer(i)} + /> + +
    • + ))} + {Array.from({ length: uploading }, (_, i) => ( +
    • + +
    • + ))} +
    + ) : null} + + {invalid.length ? ( +

    + {t.errors.kindNotAllowed} +

    + ) : null} + {errors.map((e) => ( +

    + {e} +

    + ))} + + !open && setViewer(null)} + /> +
    + ); +} diff --git a/src/features/attachments/components/attachment-strip.tsx b/src/features/attachments/components/attachment-strip.tsx new file mode 100644 index 0000000..d24849c --- /dev/null +++ b/src/features/attachments/components/attachment-strip.tsx @@ -0,0 +1,75 @@ +"use client"; + +import { FileText, Paperclip } from "lucide-react"; +import { useState } from "react"; +import { tr } from "@/i18n/tr"; +import type { AttachmentInfo } from "../service"; +import { AttachmentViewer } from "./attachment-viewer"; + +const t = tr.attachments; +const VISIBLE = 4; + +/** Ayrıntıda küçük önizleme şeridi (en fazla 4 + "+N"); tıklayınca görüntüleyici açılır. */ +export function AttachmentStrip({ items }: { items: AttachmentInfo[] }) { + const [viewer, setViewer] = useState(null); + if (!items.length) return null; + const shown = items.slice(0, VISIBLE); + const rest = items.length - shown.length; + + return ( +
    + + {t.count(items.length)} +
      + {shown.map((item, i) => ( +
    • + setViewer(i)} /> + {rest > 0 && i === shown.length - 1 ? ( + + ) : null} +
    • + ))} +
    + !open && setViewer(null)} + /> +
    + ); +} + +export function AttachmentThumb({ + item, + onClick, + className = "size-12", +}: { + item: AttachmentInfo; + onClick?: () => void; + className?: string; +}) { + return ( + + ); +} diff --git a/src/features/attachments/components/attachment-viewer.tsx b/src/features/attachments/components/attachment-viewer.tsx new file mode 100644 index 0000000..341eb79 --- /dev/null +++ b/src/features/attachments/components/attachment-viewer.tsx @@ -0,0 +1,170 @@ +"use client"; + +import { Download, ExternalLink, FileText, Maximize, Minimize } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { + Carousel, + CarouselContent, + CarouselItem, + CarouselNext, + CarouselPrevious, + type CarouselApi, +} from "@/components/ui/carousel"; +import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@/components/ui/dialog"; +import { tr } from "@/i18n/tr"; +import { formatFileSize } from "@/shared/attachments"; +import type { AttachmentInfo } from "../service"; + +const t = tr.attachments; + +/** + * Ek dosya görüntüleyici (Bölüm 6.3): carousel (oklar, kaydırma, klavye okları), tam ekran ve + * "2 / 5" göstergesi. PDF'ler ikon + "Aç" düğmesiyle gösterilir (tarayıcının PDF görüntüleyicisi). + */ +export function AttachmentViewer({ + items, + startIndex, + open, + onOpenChange, +}: { + items: AttachmentInfo[]; + startIndex: number; + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const [api, setApi] = useState(); + const [index, setIndex] = useState(startIndex); + const [fullscreen, setFullscreen] = useState(false); + const containerRef = useRef(null); + + useEffect(() => { + if (!api) return; + const update = () => setIndex(api.selectedScrollSnap()); + api.on("select", update); + api.on("reInit", update); + return () => { + api.off("select", update); + api.off("reInit", update); + }; + }, [api]); + + useEffect(() => { + const onChange = () => setFullscreen(document.fullscreenElement === containerRef.current); + document.addEventListener("fullscreenchange", onChange); + return () => document.removeEventListener("fullscreenchange", onChange); + }, []); + + function handleOpenChange(next: boolean) { + if (!next && document.fullscreenElement) void document.exitFullscreen(); + if (next) setIndex(startIndex); + onOpenChange(next); + } + + async function toggleFullscreen() { + if (document.fullscreenElement) await document.exitFullscreen(); + else await containerRef.current?.requestFullscreen().catch(() => {}); + } + + /** Odak carousel dışındayken de (ör. kapat düğmesi) ok tuşları çalışsın. */ + function onKeyDown(event: React.KeyboardEvent) { + if (event.defaultPrevented) return; + if (event.key === "ArrowLeft") { + event.preventDefault(); + api?.scrollPrev(); + } else if (event.key === "ArrowRight") { + event.preventDefault(); + api?.scrollNext(); + } + } + + const current = items[Math.min(index, items.length - 1)]; + + return ( + + +
    +
    + + {current?.name ?? t.viewerTitle} + + {t.viewerTitle} + + {t.position(index + 1, items.length)} + + {current ? ( + + ) : null} + +
    + + + {items.map((item) => ( + + {item.kind === "image" ? ( + // eslint-disable-next-line @next/next/no-img-element -- yetkili özel dosya; optimizasyon hattına girmez + {item.name} + ) : ( +
    + +
    +

    {item.name}

    +

    + {t.pdf} · {formatFileSize(item.size)} +

    +
    + +
    + )} +
    + ))} +
    + {items.length > 1 ? ( + <> + + + + ) : null} +
    +
    +
    +
    + ); +} diff --git a/src/features/transactions/components/statement-view.tsx b/src/features/transactions/components/statement-view.tsx new file mode 100644 index 0000000..9627412 --- /dev/null +++ b/src/features/transactions/components/statement-view.tsx @@ -0,0 +1,387 @@ +"use client"; + +import { + ArrowLeft, + CheckCheck, + ChevronLeft, + ChevronRight, + List, + Loader2, + Scale, +} from "lucide-react"; +import Link from "next/link"; +import { useState } from "react"; +import { toast } from "sonner"; +import { MoneyInput } from "@/components/money-input"; +import { PageHeader } from "@/components/page-header"; +import { ResponsiveDialog } from "@/components/responsive-dialog"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"; +import { Input } from "@/components/ui/input"; +import { tr } from "@/i18n/tr"; +import { cn } from "@/lib/utils"; +import { todayIstanbul } from "@/shared/dates"; +import { formatDate, formatMoney } from "@/shared/format"; +import { previewReconciliationAction, reconcileAccountAction } from "../actions"; +import type { AccountStatement, ReconcilePreview, StatementRow } from "../statement"; +import { RowIcon, rowSubtitle, rowTitle, useTransactionDetail } from "./transaction-parts"; + +const t = tr.statement; +const r = t.reconcile; +const monthFmt = new Intl.DateTimeFormat("tr-TR", { + month: "long", + year: "numeric", + timeZone: "Europe/Istanbul", +}); +const monthLabel = (ym: string) => monthFmt.format(new Date(`${ym}-15T12:00:00+03:00`)); +const dateLabel = (ymd: string) => formatDate(new Date(`${ymd}T12:00:00+03:00`)); + +function SignedMoney({ minor, className }: { minor: number; className?: string }) { + return ( + 0 && "text-income", + minor < 0 && "text-expense", + className, + )} + > + {minor > 0 ? "+" : minor < 0 ? "−" : ""} + {formatMoney(Math.abs(minor))} + + ); +} + +export function StatementView({ + statement: s, + canWrite, +}: { + statement: AccountStatement; + canWrite: boolean; +}) { + const { openDetail, element: detailElement } = useTransactionDetail(canWrite); + const [reconcileOpen, setReconcileOpen] = useState(false); + const base = `/hesaplar/${s.account.id}`; + + return ( +
    + + + + {canWrite && s.canReconcile ? ( + + ) : null} + + } + /> + +
    + +

    {monthLabel(s.month)}

    + {s.nextMonth ? ( + + ) : ( + + )} +
    + +
    + {( + [ + [t.opening, s.openingMinor, false], + [t.inflow, s.inflowMinor, true], + [t.outflow, -s.outflowMinor, true], + [t.closing, s.closingMinor, false], + ] as const + ).map(([label, value, colored]) => ( + + + {label} + + {colored ? ( + + ) : ( + {formatMoney(value)} + )} + + + + ))} +
    + +

    + + {s.lastReconciliation + ? t.lastReconciliation( + dateLabel(s.lastReconciliation.date), + formatMoney(s.lastReconciliation.statementBalanceMinor), + ) + : t.noReconciliation} +

    + + {s.rows.length === 0 ? ( + + + {t.empty} + + + ) : ( +
      + {s.rows.map((row) => ( +
    • + openDetail(row.item.id)} /> +
    • + ))} +
    + )} + {s.truncated ?

    {t.truncated}

    : null} + + {detailElement} + +
    + ); +} + +function StatementRowView({ row, onOpen }: { row: StatementRow; onOpen: () => void }) { + const content = ( + <> + + + + {rowTitle(row.item)} + {row.reconciled ? ( + + ) : null} + + + {dateLabel(row.item.date)} · {rowSubtitle(row.item)} + + + + + + {formatMoney(row.balanceMinor)} + + + + ); + const cls = "flex w-full items-center gap-3 p-3 text-left"; + // Gizli karşı hesaba dokunan işlemin ayrıntısı açılmaz. + return row.restricted ? ( +
    {content}
    + ) : ( + + ); +} + +function ReconcileDialog({ + open, + onOpenChange, + accountId, + isLiability, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + accountId: string; + isLiability: boolean; +}) { + const [date, setDate] = useState(todayIstanbul); + const [balance, setBalance] = useState(""); + const [preview, setPreview] = useState(null); + const [accept, setAccept] = useState(false); + const [errors, setErrors] = useState>({}); + const [pending, setPending] = useState(false); + + function reset(next: boolean) { + if (!next) { + setPreview(null); + setAccept(false); + setErrors({}); + setBalance(""); + setDate(todayIstanbul()); + } + onOpenChange(next); + } + + const input = () => ({ + statementDate: date, + statementBalance: balance, + acceptAdjustment: accept, + }); + + async function check() { + setPending(true); + const res = await previewReconciliationAction(accountId, input()); + setPending(false); + if (!res.ok) { + setErrors({ ...res.fieldErrors, root: res.fieldErrors ? undefined : res.error }); + return; + } + setErrors({}); + setAccept(false); + setPreview(res.data); + } + + async function confirm() { + setPending(true); + const res = await reconcileAccountAction(accountId, input()); + setPending(false); + if (!res.ok) { + setErrors({ root: res.error }); + return; + } + toast.success( + res.data.adjustmentId + ? r.doneWithAdjustment(res.data.markedCount) + : r.done(res.data.markedCount), + ); + reset(false); + } + + const diff = preview?.differenceMinor ?? 0; + const display = (minor: number) => formatMoney(isLiability ? -minor : minor); + + return ( + + + {preview ? ( + + ) : ( + + )} + + } + > + + + {r.date} + { + setDate(e.target.value); + setPreview(null); + }} + /> + {errors.statementDate ? {errors.statementDate} : null} + + + {isLiability ? r.debt : r.balance} + { + setBalance(v); + setPreview(null); + }} + /> + {isLiability ? r.debtHint : r.balanceHint} + {errors.statementBalance ? {errors.statementBalance} : null} + + + {preview ? ( +
    +
    +
    +
    {r.statementBalance}
    +
    {display(preview.statementBalanceMinor)}
    +
    +
    +
    {r.appBalance}
    +
    {display(preview.appBalanceMinor)}
    +
    +
    +
    {r.difference}
    +
    + +
    +
    +
    + {diff === 0 ? ( +

    {r.matches}

    + ) : ( + <> +

    {r.mismatch}

    + + + )} + + {r.unreconciled(preview.unreconciledCount)} + +
    + ) : null} + {errors.root ? {errors.root} : null} +
    +
    + ); +} diff --git a/src/features/transactions/components/transaction-form.tsx b/src/features/transactions/components/transaction-form.tsx index be97413..15849c1 100644 --- a/src/features/transactions/components/transaction-form.tsx +++ b/src/features/transactions/components/transaction-form.tsx @@ -27,6 +27,9 @@ import { cn } from "@/lib/utils"; import { todayIstanbul } from "@/shared/dates"; import { formatMoney } from "@/shared/format"; import { formatMoneyInput, parseMoney } from "@/shared/money"; +import { allowedAttachmentKinds } from "@/shared/attachments"; +import { AttachmentPicker } from "@/features/attachments/components/attachment-picker"; +import type { AttachmentInfo } from "@/features/attachments/service"; import { transactionFormSchema, type TransactionForm } from "@/shared/schemas/transaction"; import { MAX_SPLITS, TRANSACTION_TYPES, type TransactionType } from "@/shared/transactions"; import { @@ -57,6 +60,8 @@ type FormValues = { payee: string; description: string; tags: string; + /** Önizleme bilgisiyle ekler; sunucuya yalnızca kimlikleri gider. */ + attachments: AttachmentInfo[]; }; function toValues(mode: TransactionFormMode, options: TransactionFormOptions | null): FormValues { @@ -72,6 +77,7 @@ function toValues(mode: TransactionFormMode, options: TransactionFormOptions | n payee: "", description: "", tags: "", + attachments: [], }; } const x = mode.transaction; @@ -90,6 +96,8 @@ function toValues(mode: TransactionFormMode, options: TransactionFormOptions | n payee: x.payeeName, description: x.description, tags: x.tags.join(", "), + // Kopyada ekler taşınmaz (fiş o işleme aittir). + attachments: mode.kind === "edit" ? x.attachments : [], }; } @@ -298,6 +306,7 @@ export function TransactionFormDialog({ const [loadError, setLoadError] = useState(null); const [advancedOpen, setAdvancedOpen] = useState(false); const [suggested, setSuggested] = useState(false); + const [uploadBusy, setUploadBusy] = useState(false); const form = useForm({ // FormValues, şemanın giriş şekliyle birebir (tutarlar string; zod kuruşa çevirir). resolver: zodResolver(transactionFormSchema) as unknown as Resolver< @@ -332,6 +341,7 @@ export function TransactionFormDialog({ const amount = useWatch({ control: form.control, name: "amount" }); const accountId = useWatch({ control: form.control, name: "accountId" }); const splitValues = useWatch({ control: form.control, name: "splits" }); + const attachmentCount = useWatch({ control: form.control, name: "attachments" })?.length ?? 0; const splitMode = splits.fields.length > 0; // Düzenlemede arşivli/kapalı hesap veya kategori seçenek listesinde yoksa ekle (kaybolmasın). @@ -431,9 +441,14 @@ export function TransactionFormDialog({ async function onSubmit() { if (!mode) return; - const values = form.getValues(); + if (uploadBusy) { + form.setError("root", { message: tr.attachments.waitForUploads }); + return; + } + const { attachments, ...values } = form.getValues(); const payload = { ...values, + attachmentIds: attachments.map((a) => a.id), toAccountId: values.type === "transfer" ? values.toAccountId : "", categoryId: values.type === "transfer" || values.splits.length ? "" : values.categoryId, splits: values.type === "transfer" ? [] : values.splits, @@ -479,7 +494,7 @@ export function TransactionFormDialog({ + ( + + {tr.attachments.title} + + + )} + /> + {text} + + ); +} + +export function RowIcon({ item }: { item: TransactionListItem }) { + if (item.type === "transfer") { + return ( + + + + ); + } + if (item.isAdjustment) { + return ( + + + + ); + } + if (item.splitCount) { + return ( + + + + ); + } + return item.category ? ( + + ) : ( + + ); +} + +export function rowTitle(item: TransactionListItem) { + if (item.isAdjustment) return t.adjustment; + if (item.payeeName) return item.payeeName; + if (item.description) return item.description; + if (item.type === "transfer") return t.types.transfer; + return item.category?.name ?? t.types[item.type]; +} + +export function rowSubtitle(item: TransactionListItem) { + if (item.type === "transfer") + return t.list.transfer(item.account.name, item.toAccount?.name ?? "—"); + const what = item.isAdjustment + ? item.description + : item.splitCount + ? t.list.split(item.splitCount) + : (item.category?.path ?? "—"); + return `${what} · ${item.account.name}`; +} + +function DetailDialog({ + detail, + loading, + canWrite, + onOpenChange, + onEdit, + onCopy, + onDelete, +}: { + detail: TransactionDetail | null; + loading: boolean; + canWrite: boolean; + onOpenChange: (o: boolean) => void; + onEdit: (d: TransactionDetail) => void; + onCopy: (d: TransactionDetail) => void; + onDelete: (d: TransactionDetail) => void; +}) { + const d = t.detail; + const row = (label: string, value: React.ReactNode) => ( +
    +
    {label}
    +
    {value}
    +
    + ); + return ( + + + {/* Mutabakat düzeltmesi düzenlenemez/kopyalanamaz; yalnızca silinebilir. */} + {detail.isAdjustment ? null : ( + <> + + + + )} + + ) : null + } + > + {!detail ? ( +
    + + +
    + ) : ( +
    +
    + +
    +

    {rowTitle(detail)}

    +

    {dayLabel(detail.date)}

    +
    + +
    +
    + {row(t.filters.type, {t.types[detail.type]})} + {detail.status !== "posted" ? row(t.filters.status, t.statuses[detail.status]) : null} + {row( + detail.type === "transfer" ? t.fields.fromAccount : t.fields.account, + detail.account.name, + )} + {detail.toAccount ? row(t.fields.toAccount, detail.toAccount.name) : null} + {detail.category ? row(t.fields.category, detail.category.path) : null} + {detail.isAdjustment ? row(t.fields.category, t.adjustment) : null} + {detail.payeeName ? row(t.fields.payee, detail.payeeName) : null} + {detail.description ? row(t.fields.description, detail.description) : null} + {detail.tags.length + ? row( + t.fields.tags, + + {detail.tags.map((tag) => ( + + #{tag} + + ))} + , + ) + : null} + {detail.attachments.length + ? row( + tr.attachments.title, + + + , + ) + : null} + {detail.reconciledAccountIds.length + ? row( + tr.statement.reconcile.title, + + + {t.reconciled} + , + ) + : null} + {row(d.createdBy, `${detail.createdBy.name} · ${formatDateTime(detail.createdAt)}`)} + {new Date(detail.updatedAt).getTime() - new Date(detail.createdAt).getTime() > 1000 + ? row(d.updatedBy, `${detail.updatedBy.name} · ${formatDateTime(detail.updatedAt)}`) + : null} +
    + {detail.splits.length ? ( +
    +

    + {d.lines} +

    +
      + {detail.splits.map((s, i) => ( +
    • + + + {s.path} + {s.note ? ( + + {s.note} + + ) : null} + + {formatMoney(s.amountMinor)} +
    • + ))} +
    +
    + ) : null} +
    + )} +
    + ); +} + +/** + * Ayrıntı penceresi + silme onayı + geri al bildirimi. Liste ve ekstre aynı davranışı kullanır; + * düzenle/kopyala uygulama genelindeki işlem formunu açar. + */ +export function useTransactionDetail(canWrite: boolean) { + const { openTransactionForm } = useQuickAdd(); + const [detail, setDetail] = useState(null); + const [loading, setLoading] = useState(false); + const [deleting, setDeleting] = useState(null); + const [, startTransition] = useTransition(); + + async function openDetail(id: string) { + setLoading(true); + setDetail(null); + const res = await getTransactionAction(id); + setLoading(false); + if (res.ok) setDetail(res.data); + else toast.error(res.error); + } + + function close() { + setDetail(null); + setLoading(false); + } + + function remove(d: TransactionDetail) { + setDeleting(null); + close(); + startTransition(async () => { + const res = await setTransactionDeletedAction(d.id, true); + if (!res.ok) return void toast.error(res.error); + toast.success(t.toast.deleted, { + duration: 10_000, + action: { + label: t.actions.restore, + onClick: async () => { + const r = await setTransactionDeletedAction(d.id, false); + if (r.ok) toast.success(t.toast.restored); + else toast.error(r.error); + }, + }, + }); + }); + } + + const element = ( + <> + !o && close()} + onEdit={(d) => { + close(); + openTransactionForm({ kind: "edit", transaction: d }); + }} + onCopy={(d) => { + close(); + openTransactionForm({ kind: "copy", transaction: d }); + }} + onDelete={setDeleting} + /> + !o && setDeleting(null)}> + + + {t.deleteTitle} + {t.deleteDescription} + + + {tr.common.cancel} + + + + + + ); + return { openDetail, element }; +} diff --git a/src/features/transactions/components/transactions-view.tsx b/src/features/transactions/components/transactions-view.tsx index 453fc78..4983c64 100644 --- a/src/features/transactions/components/transactions-view.tsx +++ b/src/features/transactions/components/transactions-view.tsx @@ -1,36 +1,12 @@ "use client"; -import { - ArrowLeftRight, - ChevronLeft, - ChevronRight, - Copy, - Download, - Filter, - Paperclip, - Pencil, - Plus, - Split, - Trash2, - X, -} from "lucide-react"; +import { ChevronLeft, ChevronRight, Download, Filter, Paperclip, Plus, X } from "lucide-react"; import Link from "next/link"; import { usePathname, useRouter } from "next/navigation"; -import { useState, useTransition } from "react"; -import { toast } from "sonner"; -import { IconBadge } from "@/components/appearance/swatch"; +import { useState } from "react"; import { useQuickAdd } from "@/components/layout/quick-add"; import { MoneyInput } from "@/components/money-input"; import { ResponsiveDialog } from "@/components/responsive-dialog"; -import { - AlertDialog, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "@/components/ui/alert-dialog"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; @@ -43,103 +19,24 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { Skeleton } from "@/components/ui/skeleton"; import { tr } from "@/i18n/tr"; import { cn } from "@/lib/utils"; -import { formatDate, formatDateTime, formatMoney } from "@/shared/format"; +import { formatMoney } from "@/shared/format"; import type { TransactionFilters } from "@/shared/schemas/transaction"; +import { TRANSACTION_STATUSES, TRANSACTION_TYPES } from "@/shared/transactions"; +import type { TransactionFormOptions, TransactionListItem, TransactionPage } from "../service"; import { - TRANSACTION_STATUSES, - TRANSACTION_TYPES, - type TransactionType, -} from "@/shared/transactions"; -import { getTransactionAction, setTransactionDeletedAction } from "../actions"; -import type { - TransactionDetail, - TransactionFormOptions, - TransactionListItem, - TransactionPage, -} from "../service"; + Amount, + dayLabel, + RowIcon, + rowSubtitle, + rowTitle, + signed, + useTransactionDetail, +} from "./transaction-parts"; const t = tr.transactions; const ALL = "__all__"; -const weekday = new Intl.DateTimeFormat("tr-TR", { weekday: "long", timeZone: "Europe/Istanbul" }); - -/** "2026-11-05" → "05.11.2026 · Perşembe" */ -function dayLabel(ymd: string) { - const d = new Date(`${ymd}T12:00:00+03:00`); - return `${formatDate(d)} · ${weekday.format(d)}`; -} - -function signed(type: TransactionType, amountMinor: number) { - return type === "income" ? amountMinor : type === "expense" ? -amountMinor : 0; -} - -function Amount({ - type, - amountMinor, - className, -}: { - type: TransactionType; - amountMinor: number; - className?: string; -}) { - const text = - type === "income" - ? `+${formatMoney(amountMinor)}` - : type === "expense" - ? `−${formatMoney(amountMinor)}` - : formatMoney(amountMinor); - return ( - - {text} - - ); -} - -function RowIcon({ item }: { item: TransactionListItem }) { - if (item.type === "transfer") { - return ( - - - - ); - } - if (item.splitCount) { - return ( - - - - ); - } - return item.category ? ( - - ) : ( - - ); -} - -function rowTitle(item: TransactionListItem) { - if (item.payeeName) return item.payeeName; - if (item.description) return item.description; - if (item.type === "transfer") return t.types.transfer; - return item.category?.name ?? t.types[item.type]; -} - -function rowSubtitle(item: TransactionListItem) { - if (item.type === "transfer") - return t.list.transfer(item.account.name, item.toAccount?.name ?? "—"); - const what = item.splitCount ? t.list.split(item.splitCount) : (item.category?.path ?? "—"); - return `${what} · ${item.account.name}`; -} // --- Filtreler --- @@ -318,126 +215,6 @@ function FiltersDialog({ // --- Ayrıntı --- -function DetailDialog({ - detail, - loading, - canWrite, - onOpenChange, - onEdit, - onCopy, - onDelete, -}: { - detail: TransactionDetail | null; - loading: boolean; - canWrite: boolean; - onOpenChange: (o: boolean) => void; - onEdit: (d: TransactionDetail) => void; - onCopy: (d: TransactionDetail) => void; - onDelete: (d: TransactionDetail) => void; -}) { - const d = t.detail; - const row = (label: string, value: React.ReactNode) => ( -
    -
    {label}
    -
    {value}
    -
    - ); - return ( - - - - - - ) : null - } - > - {!detail ? ( -
    - - -
    - ) : ( -
    -
    - -
    -

    {rowTitle(detail)}

    -

    {dayLabel(detail.date)}

    -
    - -
    -
    - {row(t.filters.type, {t.types[detail.type]})} - {detail.status !== "posted" ? row(t.filters.status, t.statuses[detail.status]) : null} - {row( - detail.type === "transfer" ? t.fields.fromAccount : t.fields.account, - detail.account.name, - )} - {detail.toAccount ? row(t.fields.toAccount, detail.toAccount.name) : null} - {detail.category ? row(t.fields.category, detail.category.path) : null} - {detail.payeeName ? row(t.fields.payee, detail.payeeName) : null} - {detail.description ? row(t.fields.description, detail.description) : null} - {detail.tags.length - ? row( - t.fields.tags, - - {detail.tags.map((tag) => ( - - #{tag} - - ))} - , - ) - : null} - {row(d.createdBy, `${detail.createdBy.name} · ${formatDateTime(detail.createdAt)}`)} - {new Date(detail.updatedAt).getTime() - new Date(detail.createdAt).getTime() > 1000 - ? row(d.updatedBy, `${detail.updatedBy.name} · ${formatDateTime(detail.updatedAt)}`) - : null} -
    - {detail.splits.length ? ( -
    -

    - {d.lines} -

    -
      - {detail.splits.map((s, i) => ( -
    • - - - {s.path} - {s.note ? ( - - {s.note} - - ) : null} - - {formatMoney(s.amountMinor)} -
    • - ))} -
    -
    - ) : null} -
    - )} -
    - ); -} - // --- Ana görünüm --- export function TransactionsView({ @@ -454,12 +231,9 @@ export function TransactionsView({ canWrite: boolean; }) { const pathname = usePathname(); - const { openQuickAdd, openTransactionForm } = useQuickAdd(); + const { openQuickAdd } = useQuickAdd(); const [filtersOpen, setFiltersOpen] = useState(false); - const [detail, setDetail] = useState(null); - const [detailLoading, setDetailLoading] = useState(false); - const [deleting, setDeleting] = useState(null); - const [, startTransition] = useTransition(); + const { openDetail, element: detailElement } = useTransactionDetail(canWrite); const active = countActive(filters); const pageHref = (page: number) => { @@ -474,40 +248,6 @@ export function TransactionsView({ return `/islemler/disa-aktar${params.size ? `?${params}` : ""}`; }; - async function openDetail(id: string) { - setDetailLoading(true); - setDetail(null); - const res = await getTransactionAction(id); - setDetailLoading(false); - if (res.ok) setDetail(res.data); - else toast.error(res.error); - } - - function closeDetail() { - setDetail(null); - setDetailLoading(false); - } - - function remove(d: TransactionDetail) { - setDeleting(null); - closeDetail(); - startTransition(async () => { - const res = await setTransactionDeletedAction(d.id, true); - if (!res.ok) return void toast.error(res.error); - toast.success(t.toast.deleted, { - duration: 10_000, - action: { - label: t.actions.restore, - onClick: async () => { - const r = await setTransactionDeletedAction(d.id, false); - if (r.ok) toast.success(t.toast.restored); - else toast.error(r.error); - }, - }, - }); - }); - } - // Sayfadaki işlemleri güne göre grupla (liste zaten tarihe göre sıralı). const groups: { date: string; items: TransactionListItem[] }[] = []; for (const item of data.items) { @@ -679,35 +419,7 @@ export function TransactionsView({ raw={raw} options={options} /> - !o && closeDetail()} - onEdit={(d) => { - closeDetail(); - openTransactionForm({ kind: "edit", transaction: d }); - }} - onCopy={(d) => { - closeDetail(); - openTransactionForm({ kind: "copy", transaction: d }); - }} - onDelete={setDeleting} - /> - !o && setDeleting(null)}> - - - {t.deleteTitle} - {t.deleteDescription} - - - {tr.common.cancel} - - - - + {detailElement}
    ); } diff --git a/src/i18n/tr.ts b/src/i18n/tr.ts index d172a56..2178555 100644 --- a/src/i18n/tr.ts +++ b/src/i18n/tr.ts @@ -256,7 +256,7 @@ export const tr = { description: "Açıklama", tags: "Etiketler", tagsHint: "Virgülle ayırın (ör. tatil, indirim)", - advanced: "Gelişmiş: etiket, bölme", + advanced: "Gelişmiş: ek, etiket, bölme", split: "Bölünmüş işlem", splitHint: "Tutarı birden çok kategoriye bölün; kalemlerin toplamı tutara eşit olmalı.", addSplit: "Kalem ekle", @@ -344,10 +344,96 @@ export const tr = { ], filename: (date: string) => `butcem-islemler-${date}.csv`, }, + adjustment: "Mutabakat düzeltmesi", + adjustmentReadOnly: + "Mutabakat düzeltmesi düzenlenemez; gerekirse silip mutabakatı yeniden yapın.", + reconciled: "Mutabakatı yapıldı", readOnly: "İzleyici rolündesiniz; işlem ekleyemezsiniz.", noAccounts: "İşlem girmek için önce bir hesap gerekli.", noAccountsLink: "Hesaplara git", }, + attachments: { + title: "Ekler", + add: "Ek ekle", + addHint: { image: "Fiş veya fatura fotoğrafı", imagePdf: "Görsel veya PDF (ör. bordro)" }, + count: (n: number) => `${n} ek`, + uploading: "Yükleniyor…", + remove: "Eki kaldır", + open: "Aç", + download: "İndir", + fullscreen: "Tam ekran", + exitFullscreen: "Tam ekrandan çık", + previous: "Önceki", + next: "Sonraki", + position: (i: number, n: number) => `${i} / ${n}`, + viewerTitle: "Ek dosyalar", + pdf: "PDF belgesi", + moreCount: (n: number) => `+${n}`, + waitForUploads: "Yüklemeler bitmeden kaydedilemez.", + errors: { + empty: "Dosya seçilmedi veya dosya boş.", + tooLarge: (mb: number) => `Dosya çok büyük (en fazla ${mb} MB).`, + tooMany: (n: number) => `Bir kayda en fazla ${n} dosya eklenebilir.`, + tooManyPending: "Kaydedilmemiş çok fazla ek var; önce formu kaydedin.", + unsupported: "Desteklenmeyen dosya türü. JPEG, PNG, WebP, HEIC veya PDF yükleyin.", + unreadable: "Dosya okunamadı veya bozuk.", + kindNotAllowed: "Gider işlemlerine yalnızca görsel eklenebilir (PDF gelir ve transferlerde).", + invalid: "Ek dosya bulunamadı veya bu kayda eklenemez.", + upload: "Dosya yüklenemedi.", + }, + }, + statement: { + title: "Hesap ekstresi", + back: "Hesaplar", + allTransactions: "Tüm işlemler", + prevMonth: "Önceki ay", + nextMonth: "Sonraki ay", + opening: "Ay başı bakiye", + inflow: "Giriş", + outflow: "Çıkış", + closing: "Ay sonu bakiye", + current: "Güncel bakiye", + balanceAfter: "Bakiye", + empty: "Bu ayda işlem yok.", + truncated: "Çok fazla işlem var; yalnızca ilk 2.000 işlem gösteriliyor.", + lastReconciliation: (date: string, balance: string) => + `Son mutabakat: ${date} · ekstre bakiyesi ${balance}`, + noReconciliation: "Henüz mutabakat yapılmadı.", + reconciledHint: "Mutabakatı yapılmış işlem", + adjustmentDescription: (statementBalance: string) => + `Ekstre bakiyesi ${statementBalance} ile fark`, + reconcile: { + open: "Mutabakat yap", + title: "Mutabakat", + description: + "Banka veya kart ekstrenizdeki tarihi ve bakiyeyi girin; uygulamadaki bakiye ile karşılaştırılır.", + date: "Ekstre tarihi", + balance: "Ekstredeki bakiye (TL)", + debt: "Ekstredeki borç (TL)", + balanceHint: "Eksi bakiye için başına - koyun (ör. KMH kullanımı).", + debtHint: "Karttaki toplam borcu pozitif girin.", + check: "Karşılaştır", + statementBalance: "Ekstre", + appBalance: "Uygulama", + difference: "Fark", + matches: "Bakiyeler tutuyor.", + mismatch: "Bakiyeler tutmuyor. Eksik veya hatalı işlem olabilir.", + adjustmentOffer: (amount: string, kind: string) => + `Farkı kapatmak için ${amount} tutarında bir “${kind}” düzeltme işlemi oluşturulsun.`, + acceptAdjustment: "Düzeltme işlemi oluştur", + unreconciled: (n: number) => `${n} işlem işaretlenecek.`, + confirm: "Mutabakatı tamamla", + confirmWithAdjustment: "Düzelt ve tamamla", + done: (n: number) => `Mutabakat tamamlandı; ${n} işlem işaretlendi.`, + doneWithAdjustment: (n: number) => `Düzeltme işlemi oluşturuldu ve ${n} işlem işaretlendi.`, + }, + errors: { + date: "Geçerli ve bugünden ileri olmayan bir tarih girin.", + archived: "Arşivlenmiş hesapta mutabakat yapılamaz.", + needsAdjustment: + "Bakiyeler tutmuyor; düzeltme işlemini onaylayın veya işlemleri kontrol edin.", + }, + }, categories: { title: "Kategoriler", description: "Gelir ve gider kategorileri. Alt kategoriler raporlarda üst kategoriye toplanır.", From 43053898b474ffa635476ef98bb188d96d1b0938 Mon Sep 17 00:00:00 2001 From: suphicakir Date: Thu, 5 Nov 2026 07:55:04 +0300 Subject: [PATCH 4/4] chore(release): 0.6.0 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5559216..d151ce3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,34 @@ Biçim [Keep a Changelog](https://keepachangelog.com/tr-TR/1.1.0/) esas alınır ## [Yayınlanmamış] -Faz 6 (alt fazlar halinde; `v0.6.0` Faz 6c sonunda). +## [0.6.0] - 2026-11-05 + +Faz 6: hesaplar, kategoriler, işlemler, ek dosyalar ve mutabakat (6a, 6b, 6c alt fazları). + +### Eklendi — Faz 6c: ek dosyalar, carousel, ekstre ve mutabakat + +- Depolama soyutlaması `lib/storage/` (`put`, `get`, `delete`); yerel disk sürücüsü (`UPLOAD_DIR`), atomik yazma, rastgele anahtarlar, yol geçişine karşı anahtar doğrulaması. +- Ek dosya yükleme `POST /api/files`: tür dosya içeriğinden (imza) tespit edilir — JPEG, PNG, WebP, HEIC, PDF; uzantıya güvenilmez. Dosya başına `MAX_UPLOAD_MB` (10 MB), kayıt başına 10 dosya. Origin kontrolü. +- Görseller sharp ile EXIF yönüne göre döndürülür, en fazla 2000 px'e küçültülür, WebP'ye çevrilir; EXIF/GPS dahil tüm meta veri silinir; 320 px küçük resim üretilir. iPhone HEIC dosyaları libheif (WASM, `heic-decode`) ile çözülür. PDF'ler yapı kontrolünden sonra olduğu gibi saklanır. +- Yetkili servis `GET /api/files/:id` (`?v=thumb`, `&indir=1`): oturum + hane üyeliği; bekleyen ek yalnızca yükleyene; silinmiş işlemin veya başkasının gizli hesabındaki işlemin eki görünmez (404). Dosyalara özel sıkı CSP, `nosniff`, `private` önbellek. +- `attachments` koleksiyonu (`ownerType`, `ownerId`, tür, boyut, yükleyen, sıra). Ekler işlemle aynı MongoDB transaction'ında bağlanır; hata olursa işlem de oluşmaz. Gider: yalnızca görsel; gelir ve transfer: görsel veya PDF. +- İşlem formunda küçük "Ek ekle" alanı (çoklu seçim, yükleme göstergesi, küçük önizleme, kaldırma, sayaç); yüklemeler bitmeden kaydedilemez. Kopyada ekler taşınmaz. +- Görüntüleyici: shadcn Carousel (Embla) — oklar, kaydırma, klavye okları, tam ekran, "2 / 5" göstergesi, indirme; PDF'ler ikon + "Aç" düğmesiyle. Ayrıntıda en fazla 4 küçük resim + "+N". +- Temizlik `npm run cleanup`: 24 saatten eski bağlanmamış ekler, 30 günden önce kayıttan çıkarılmış ekler ve 30 günden önce silinmiş işlemlerin ekleri (dosyalarıyla) silinir. +- Hesap ekstresi `/hesaplar/:id?ay=YYYY-MM`: ay başı/ay sonu bakiyesi, giriş/çıkış, yürüyen bakiye, mutabakat işaretleri; işlem ayrıntısı ekstreden açılır. Başkasının gizli hesabına transfer ekstrede görünür ama karşı hesap adı gizlenir. +- Mutabakat: ekstre tarihi ve bakiyesi girilir, uygulama bakiyesiyle karşılaştırılır (kredi kartında borç pozitif girilir). Fark varsa onayla "Mutabakat düzeltmesi" işlemi oluşturulur; o tarihe kadarki işlemler hesap bazında işaretlenir; `reconciliations` kaydı ve denetim kaydı (`account.reconcile`) tek transaction'da. +- `004` migration'ı: `attachments` ve `reconciliations` index'leri; işlemlerde `reconciledAccountIds` ve `isAdjustment`. +- Testler: dosya türü tespiti, depolama, ekstre yardımcıları (birim); yükleme/dönüştürme (EXIF silme, yön, boyut, HEIC), bağlama kuralları, erişim izolasyonu, temizlik, ekstre tutarlılığı, mutabakat (entegrasyon). + +### Değişti — Faz 6c + +- Hesap adı artık hesap ekstresini açar (gizli hesaplarda bağlantı yok). +- Bakiyeyi etkileyen alanı (tutar, tarih, tür, hesap) değiştirilen işlemin mutabakat işareti kalkar. Mutabakat düzeltmesi düzenlenemez, yalnızca silinebilir. +- İşlemlerdeki kullanılmayan `reconciledAt` alanı, hesap bazında `reconciledAccountIds` ile değiştirildi. + +### Düzeltildi + +- `seed:reset` işlemleri ve satıcıları silmiyordu; artık işlemler, satıcılar, ekler (diskteki dosyalarıyla) ve mutabakatlar da temizlenir. ### Eklendi — Faz 6b: işlemler