feat(attachments): depolama soyutlaması, içerikten tür tespiti, sharp/HEIC dönüşümü ve yetkili dosya servisi

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-05 07:55:04 +03:00
parent 99fc9dfea6
commit 2e1f4d7929
21 changed files with 1246 additions and 16 deletions
@@ -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<ReturnType<typeof setupSeededDb>>;
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<TransactionForm>): Promise<TransactionForm> {
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("<script>alert(1)</script>"),
});
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();
});
});
+326
View File
@@ -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<ActionResult<AttachmentInfo>> {
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<string, unknown>;
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<ActionResult<number>> {
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<AttachmentInfo[]> {
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<AttachmentFile | null> {
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 };
}