feat(shopping): mağazalar, zahmet skoru ve ürünler (marka/paket, dönüşüm, görseller)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -11,6 +11,7 @@ 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 { ProductModel } from "@/models/product";
|
||||
import { TransactionModel } from "@/models/transaction";
|
||||
import {
|
||||
detectFileType,
|
||||
@@ -267,6 +268,9 @@ export async function readAttachmentForUser(
|
||||
ownerUserId: { $ne: new Types.ObjectId(userId) },
|
||||
});
|
||||
if (hidden) return null;
|
||||
} else if (a.ownerType === "product") {
|
||||
// Ürün görselleri hane ortaktır (arşivli ürün dahil).
|
||||
if (!(await ProductModel.exists({ _id: a.ownerId, householdId: a.householdId }))) return null;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { fail, type ActionResult } from "@/lib/action-result";
|
||||
import { getHouseholdActionContext } from "@/lib/dal";
|
||||
import { productFormSchema } from "@/shared/schemas/product";
|
||||
import { createProduct, setProductArchived, updateProduct } from "./service";
|
||||
|
||||
const idSchema = z.string().regex(/^[a-f0-9]{24}$/i);
|
||||
|
||||
function fieldErrors(error: z.ZodError) {
|
||||
const out: Record<string, string> = {};
|
||||
for (const issue of error.issues) {
|
||||
const key = issue.path.join(".");
|
||||
if (key && !out[key]) out[key] = issue.message;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function run<T, R>(
|
||||
schema: z.ZodType<T>,
|
||||
input: unknown,
|
||||
fn: (userId: string, householdId: string, data: T) => Promise<ActionResult<R>>,
|
||||
): Promise<ActionResult<R>> {
|
||||
const ctx = await getHouseholdActionContext("member");
|
||||
if (!ctx) return fail(tr.common.unauthorized);
|
||||
const parsed = schema.safeParse(input);
|
||||
if (!parsed.success) return fail(tr.adminUsers.errors.checkFields, fieldErrors(parsed.error));
|
||||
const result = await fn(ctx.user.id, ctx.household.id, parsed.data);
|
||||
if (result.ok) revalidatePath("/", "layout");
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Yeni (id boş) veya mevcut ürün. */
|
||||
export async function saveProductAction(productId: unknown, input: unknown) {
|
||||
if (productId) {
|
||||
const id = idSchema.safeParse(productId);
|
||||
if (!id.success) return fail(tr.common.notFound);
|
||||
return run(productFormSchema, input, (u, h, d) => updateProduct(u, h, id.data, d));
|
||||
}
|
||||
return run(productFormSchema, input, createProduct);
|
||||
}
|
||||
|
||||
export async function setProductArchivedAction(productId: unknown, archived: unknown) {
|
||||
const id = idSchema.safeParse(productId);
|
||||
if (!id.success) return fail(tr.common.notFound);
|
||||
return run(z.boolean(), archived, (u, h, a) => setProductArchived(u, h, id.data, a));
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import { rm } from "node:fs/promises";
|
||||
import sharp from "sharp";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { readAttachmentForUser, uploadAttachment } from "@/features/attachments/service";
|
||||
import { env } from "@/lib/env";
|
||||
import { AttachmentModel } from "@/models/attachment";
|
||||
import { ProductTypeModel } from "@/models/product-type";
|
||||
import { productFormSchema, type ProductFormInput } from "@/shared/schemas/product";
|
||||
import { setupSeededDb, teardownDb } from "@/test/seeded-db";
|
||||
import {
|
||||
createProduct,
|
||||
listProducts,
|
||||
listProductTypeOptions,
|
||||
setProductArchived,
|
||||
updateProduct,
|
||||
} from "./service";
|
||||
|
||||
let ids: Awaited<ReturnType<typeof setupSeededDb>>;
|
||||
let cakir = "";
|
||||
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");
|
||||
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 typeId = (ref: string) =>
|
||||
ProductTypeModel.findOne({ seedRef: ref })
|
||||
.lean()
|
||||
.then((x) => String(x!._id));
|
||||
|
||||
const png = () =>
|
||||
sharp({ create: { width: 40, height: 30, channels: 3, background: "#cc6633" } })
|
||||
.png()
|
||||
.toBuffer();
|
||||
|
||||
async function form(over: Partial<ProductFormInput> = {}) {
|
||||
return productFormSchema.parse({
|
||||
productTypeId: await typeId("toz-deterjan"),
|
||||
brand: "Persil",
|
||||
name: "Matik",
|
||||
barcode: "",
|
||||
packageQuantity: "4,5",
|
||||
packageUnit: "kg",
|
||||
typeQuantity: "30",
|
||||
note: "",
|
||||
imageIds: [],
|
||||
...over,
|
||||
});
|
||||
}
|
||||
|
||||
describe("ürünler (marka/paket)", () => {
|
||||
it("seed: dönüşümler ve paket açıklaması; izolasyon", async () => {
|
||||
const all = (await listProducts(ayse, cakir))!;
|
||||
expect(all.products).toHaveLength(22);
|
||||
const ariel = all.products.find((p) => p.brand === "Ariel")!;
|
||||
expect(ariel).toMatchObject({
|
||||
productTypeName: "Toz Deterjan",
|
||||
typeUnit: "yıkama",
|
||||
inTypeUnit: 33_000,
|
||||
packageLabel: "5 kg · 33 yıkama",
|
||||
conversionMissing: false,
|
||||
});
|
||||
const icim = all.products.find((p) => p.brand === "İçim")!;
|
||||
expect(icim).toMatchObject({ typeQuantity: null, inTypeUnit: 500 });
|
||||
const peynir = (await listProducts(ayse, cakir, {
|
||||
productTypeId: await typeId("beyaz-peynir"),
|
||||
}))!;
|
||||
expect(peynir.products.map((p) => p.packageLabel)).toEqual(["500 g · 0,5 kg"]);
|
||||
expect(await listProducts(fatma, cakir)).toBeNull();
|
||||
expect((await listProductTypeOptions(ayse, cakir))!.length).toBe(19);
|
||||
});
|
||||
|
||||
it("dönüşüm zorunlu; kendiliğinden çevrilebilende yok sayılır; barkod tek", async () => {
|
||||
const missing = await createProduct(ayse, cakir, await form({ typeQuantity: "" }));
|
||||
expect(missing).toMatchObject({ ok: false, fieldErrors: { typeQuantity: expect.any(String) } });
|
||||
|
||||
const ok = await createProduct(ayse, cakir, await form({ barcode: "8690 0000 0099 9" }));
|
||||
expect(ok.ok).toBe(true);
|
||||
const sut = await createProduct(
|
||||
ayse,
|
||||
cakir,
|
||||
await form({
|
||||
productTypeId: await typeId("sut"),
|
||||
brand: "Torku",
|
||||
packageQuantity: "1000",
|
||||
packageUnit: "ml",
|
||||
typeQuantity: "7",
|
||||
}),
|
||||
);
|
||||
expect(sut.ok).toBe(true);
|
||||
const list = (await listProducts(ayse, cakir))!.products;
|
||||
expect(list.find((p) => p.brand === "Persil")).toMatchObject({
|
||||
barcode: "8690000000999",
|
||||
inTypeUnit: 30_000,
|
||||
});
|
||||
expect(list.find((p) => p.brand === "Torku")).toMatchObject({
|
||||
typeQuantity: null,
|
||||
inTypeUnit: 1_000,
|
||||
});
|
||||
|
||||
const dup = await createProduct(
|
||||
ayse,
|
||||
cakir,
|
||||
await form({ brand: "Başka", barcode: "8690000000999" }),
|
||||
);
|
||||
expect(dup).toMatchObject({ ok: false, fieldErrors: { barcode: expect.any(String) } });
|
||||
expect(productFormSchema.safeParse({ ...(await form()), barcode: "12a" }).success).toBe(false);
|
||||
expect((await createProduct(zeynep, cakir, await form({ brand: "İzleyici" }))).ok).toBe(false);
|
||||
});
|
||||
|
||||
it("görseller: bağlanır, sıralanır, çıkarılır; okuma hane üyeleriyle sınırlı", async () => {
|
||||
const a1 = await uploadAttachment(ayse, cakir, { name: "on.png", bytes: await png() });
|
||||
const a2 = await uploadAttachment(ayse, cakir, { name: "arka.png", bytes: await png() });
|
||||
const id1 = a1.ok ? a1.data.id : "";
|
||||
const id2 = a2.ok ? a2.data.id : "";
|
||||
const r = await createProduct(
|
||||
ayse,
|
||||
cakir,
|
||||
await form({ brand: "Peros", imageIds: [id2, id1] }),
|
||||
);
|
||||
expect(r.ok).toBe(true);
|
||||
const pid = r.ok ? r.data.id : "";
|
||||
let p = (await listProducts(ayse, cakir))!.products.find((x) => x.id === pid)!;
|
||||
expect(p.images.map((i) => i.id)).toEqual([id2, id1]);
|
||||
|
||||
// Aynı hanenin başka üyesi görür; başka hane görmez.
|
||||
expect(await readAttachmentForUser(emre, id1, "thumb")).not.toBeNull();
|
||||
expect(await readAttachmentForUser(fatma, id1, "full")).toBeNull();
|
||||
|
||||
// Başkasının yüklediği bekleyen görsel bağlanamaz.
|
||||
const foreign = await uploadAttachment(emre, cakir, { name: "x.png", bytes: await png() });
|
||||
const bad = await updateProduct(
|
||||
ayse,
|
||||
cakir,
|
||||
pid,
|
||||
await form({ brand: "Peros", imageIds: [id1, foreign.ok ? foreign.data.id : ""] }),
|
||||
);
|
||||
expect(bad.ok).toBe(false);
|
||||
p = (await listProducts(ayse, cakir))!.products.find((x) => x.id === pid)!;
|
||||
expect(p.images).toHaveLength(2);
|
||||
|
||||
expect(
|
||||
(await updateProduct(ayse, cakir, pid, await form({ brand: "Peros", imageIds: [id1] }))).ok,
|
||||
).toBe(true);
|
||||
expect(await AttachmentModel.exists({ _id: id2, deletedAt: { $ne: null } })).toBeTruthy();
|
||||
p = (await listProducts(ayse, cakir))!.products.find((x) => x.id === pid)!;
|
||||
expect(p.images.map((i) => i.id)).toEqual([id1]);
|
||||
});
|
||||
|
||||
it("arşiv: listeden çıkar, düzenlenemez, barkod çakışırsa geri alınamaz", async () => {
|
||||
const r = await createProduct(ayse, cakir, await form({ brand: "Arşiv", barcode: "12345678" }));
|
||||
const id = r.ok ? r.data.id : "";
|
||||
expect((await setProductArchived(ayse, cakir, id, true)).ok).toBe(true);
|
||||
expect((await listProducts(ayse, cakir))!.products.some((p) => p.id === id)).toBe(false);
|
||||
expect(
|
||||
(await listProducts(ayse, cakir, { includeArchived: true }))!.products.find(
|
||||
(p) => p.id === id,
|
||||
)!.archived,
|
||||
).toBe(true);
|
||||
expect((await updateProduct(ayse, cakir, id, await form({ brand: "Arşiv" }))).ok).toBe(false);
|
||||
expect(
|
||||
(await createProduct(ayse, cakir, await form({ brand: "Yeni", barcode: "12345678" }))).ok,
|
||||
).toBe(true);
|
||||
expect((await setProductArchived(ayse, cakir, id, false)).ok).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,358 @@
|
||||
import "server-only";
|
||||
import { Types } from "mongoose";
|
||||
import {
|
||||
syncOwnerAttachments,
|
||||
toAttachmentInfo,
|
||||
type AttachmentInfo,
|
||||
} from "@/features/attachments/service";
|
||||
import { hasHouseholdRole } from "@/features/households/roles";
|
||||
import { resolveMembership, type Membership } from "@/features/households/service";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { fail, ok, type ActionResult } from "@/lib/action-result";
|
||||
import { writeAudit } from "@/lib/audit";
|
||||
import { connectMongoose } from "@/lib/db/mongoose";
|
||||
import { toObjectId } from "@/lib/object-id";
|
||||
import { AttachmentModel } from "@/models/attachment";
|
||||
import { ProductModel } from "@/models/product";
|
||||
import { ProductTypeModel } from "@/models/product-type";
|
||||
import { autoConvertible, describePackage, packageInTypeUnit } from "@/shared/products";
|
||||
import type { ProductForm } from "@/shared/schemas/product";
|
||||
import { compareTr, foldTr } from "@/shared/search";
|
||||
|
||||
/**
|
||||
* Ürünler (marka/varyant, Bölüm 6.11). Hane bazlı, hane içinde ortak. Okuma: viewer; yazma:
|
||||
* member. Paket birimi ürün tipinin birimine kendiliğinden çevrilemiyorsa dönüşüm zorunludur.
|
||||
* Görseller ek dosya altyapısıyla (ownerType "product", yalnızca görsel).
|
||||
*/
|
||||
|
||||
const t = tr.products;
|
||||
|
||||
type ProductLean = {
|
||||
_id: Types.ObjectId;
|
||||
productTypeId: Types.ObjectId;
|
||||
brand: string;
|
||||
name: string;
|
||||
barcode: string | null;
|
||||
packageQuantity: number;
|
||||
packageUnit: string;
|
||||
typeQuantity: number | null;
|
||||
note: string;
|
||||
imageCount: number;
|
||||
archivedAt: Date | null;
|
||||
};
|
||||
|
||||
type TypeLean = { _id: Types.ObjectId; name: string; unit: string; archivedAt: Date | null };
|
||||
|
||||
export type ProductView = {
|
||||
id: string;
|
||||
productTypeId: string;
|
||||
productTypeName: string;
|
||||
typeUnit: string;
|
||||
brand: string;
|
||||
name: string;
|
||||
/** "Ariel Matik Dağ Esintisi". */
|
||||
label: string;
|
||||
barcode: string | null;
|
||||
packageQuantity: number;
|
||||
packageUnit: string;
|
||||
/** Elle girilen dönüşüm (kendiliğinden çevrilebiliyorsa null). */
|
||||
typeQuantity: number | null;
|
||||
/** Bir paketin ürün tipi birimindeki karşılığı (çevrilemiyorsa null). */
|
||||
inTypeUnit: number | null;
|
||||
/** Ürün tipinin birimi sonradan değişti ve dönüşüm yok: fiyat karşılaştırmasına katılamaz. */
|
||||
conversionMissing: boolean;
|
||||
/** "5 kg · 50 yıkama". */
|
||||
packageLabel: string;
|
||||
note: string;
|
||||
archived: boolean;
|
||||
images: AttachmentInfo[];
|
||||
};
|
||||
|
||||
export type ProductTypeOption = { id: string; name: string; unit: string };
|
||||
|
||||
export const productLabel = (p: { brand: string; name: string }) =>
|
||||
p.name ? `${p.brand} ${p.name}` : p.brand;
|
||||
|
||||
function toView(p: ProductLean, type: TypeLean | undefined, images: AttachmentInfo[]): ProductView {
|
||||
const typeUnit = type?.unit ?? "";
|
||||
const conv = {
|
||||
packageQuantity: p.packageQuantity,
|
||||
packageUnit: p.packageUnit,
|
||||
typeUnit,
|
||||
typeQuantity: p.typeQuantity,
|
||||
};
|
||||
const inTypeUnit = packageInTypeUnit(conv);
|
||||
return {
|
||||
id: String(p._id),
|
||||
productTypeId: String(p.productTypeId),
|
||||
productTypeName: type?.name ?? "",
|
||||
typeUnit,
|
||||
brand: p.brand,
|
||||
name: p.name,
|
||||
label: productLabel(p),
|
||||
barcode: p.barcode,
|
||||
packageQuantity: p.packageQuantity,
|
||||
packageUnit: p.packageUnit,
|
||||
typeQuantity: p.typeQuantity,
|
||||
inTypeUnit,
|
||||
conversionMissing: inTypeUnit === null,
|
||||
packageLabel: describePackage(conv),
|
||||
note: p.note,
|
||||
archived: Boolean(p.archivedAt),
|
||||
images,
|
||||
};
|
||||
}
|
||||
|
||||
async function loadImages(householdId: Types.ObjectId, productIds: Types.ObjectId[]) {
|
||||
const rows = await AttachmentModel.find({
|
||||
householdId,
|
||||
ownerType: "product",
|
||||
ownerId: { $in: productIds },
|
||||
deletedAt: null,
|
||||
})
|
||||
.sort({ order: 1, createdAt: 1 })
|
||||
.lean();
|
||||
const by = new Map<string, AttachmentInfo[]>();
|
||||
for (const a of rows) {
|
||||
const key = String(a.ownerId);
|
||||
by.set(key, [
|
||||
...(by.get(key) ?? []),
|
||||
toAttachmentInfo(a as Parameters<typeof toAttachmentInfo>[0]),
|
||||
]);
|
||||
}
|
||||
return by;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ürün listesi: ürün tipine göre (verilirse) veya tüm katalog. Sıra: ürün tipi adı, marka,
|
||||
* tanım. Arşivliler `includeArchived` ile.
|
||||
*/
|
||||
export async function listProducts(
|
||||
userId: string,
|
||||
householdId: string,
|
||||
opts: { productTypeId?: string; includeArchived?: boolean } = {},
|
||||
): Promise<{ products: ProductView[]; canWrite: boolean } | null> {
|
||||
const m = await resolveMembership(userId, householdId, "viewer");
|
||||
if (!m) return null;
|
||||
const typeId = opts.productTypeId ? toObjectId(opts.productTypeId) : null;
|
||||
if (opts.productTypeId && !typeId) return null;
|
||||
const rows = await ProductModel.find({
|
||||
householdId: m.householdId,
|
||||
...(typeId ? { productTypeId: typeId } : {}),
|
||||
...(opts.includeArchived ? {} : { archivedAt: null }),
|
||||
}).lean<ProductLean[]>();
|
||||
const types = await ProductTypeModel.find({
|
||||
householdId: m.householdId,
|
||||
_id: {
|
||||
$in: [...new Set(rows.map((r) => String(r.productTypeId)))].map(
|
||||
(id) => new Types.ObjectId(id),
|
||||
),
|
||||
},
|
||||
})
|
||||
.select({ name: 1, unit: 1, archivedAt: 1 })
|
||||
.lean<TypeLean[]>();
|
||||
const typeById = new Map(types.map((x) => [String(x._id), x]));
|
||||
const images = await loadImages(
|
||||
m.householdId,
|
||||
rows.filter((r) => r.imageCount > 0).map((r) => r._id),
|
||||
);
|
||||
const products = rows
|
||||
.map((r) => toView(r, typeById.get(String(r.productTypeId)), images.get(String(r._id)) ?? []))
|
||||
.sort(
|
||||
(a, b) =>
|
||||
compareTr(a.productTypeName, b.productTypeName) ||
|
||||
compareTr(a.brand, b.brand) ||
|
||||
compareTr(a.name, b.name),
|
||||
);
|
||||
return { products, canWrite: hasHouseholdRole(m.role, "member") };
|
||||
}
|
||||
|
||||
/** Ürün formu için aktif ürün tipleri (ada göre). */
|
||||
export async function listProductTypeOptions(
|
||||
userId: string,
|
||||
householdId: string,
|
||||
): Promise<ProductTypeOption[] | null> {
|
||||
const m = await resolveMembership(userId, householdId, "viewer");
|
||||
if (!m) return null;
|
||||
const rows = await ProductTypeModel.find({ householdId: m.householdId, archivedAt: null })
|
||||
.select({ name: 1, unit: 1 })
|
||||
.lean<TypeLean[]>();
|
||||
return rows
|
||||
.map((r) => ({ id: String(r._id), name: r.name, unit: r.unit }))
|
||||
.sort((a, b) => compareTr(a.name, b.name));
|
||||
}
|
||||
|
||||
/** Ürün tipi, dönüşüm ve barkod denetimi; geçerliyse kaydedilecek dönüşümü döner. */
|
||||
async function checkInput(
|
||||
m: Membership,
|
||||
input: ProductForm,
|
||||
exceptId?: Types.ObjectId,
|
||||
): Promise<{ error: ActionResult<never> } | { error: null; typeQuantity: number | null }> {
|
||||
const type = await ProductTypeModel.findOne({
|
||||
_id: new Types.ObjectId(input.productTypeId),
|
||||
householdId: m.householdId,
|
||||
}).lean<TypeLean>();
|
||||
if (!type || type.archivedAt)
|
||||
return { error: fail(t.validation.productType, { productTypeId: t.validation.productType }) };
|
||||
const auto = autoConvertible(input.packageUnit, type.unit);
|
||||
if (!auto && !input.typeQuantity) {
|
||||
const msg = t.validation.conversionRequired(input.packageUnit, type.unit);
|
||||
return { error: fail(msg, { typeQuantity: msg }) };
|
||||
}
|
||||
if (input.barcode) {
|
||||
const dup = await ProductModel.exists({
|
||||
householdId: m.householdId,
|
||||
barcode: input.barcode,
|
||||
archivedAt: null,
|
||||
...(exceptId ? { _id: { $ne: exceptId } } : {}),
|
||||
});
|
||||
if (dup)
|
||||
return { error: fail(t.validation.barcodeTaken, { barcode: t.validation.barcodeTaken }) };
|
||||
}
|
||||
return { error: null, typeQuantity: auto ? null : input.typeQuantity };
|
||||
}
|
||||
|
||||
/** Ek senkronizasyonu başarısızsa transaction'ı geri almak için fırlatılır. */
|
||||
class ImageError extends Error {
|
||||
constructor(readonly result: ActionResult<never>) {
|
||||
super(result.ok ? "" : result.error);
|
||||
}
|
||||
}
|
||||
|
||||
async function save(
|
||||
m: Membership,
|
||||
actorId: string,
|
||||
input: ProductForm,
|
||||
typeQuantity: number | null,
|
||||
existing: Types.ObjectId | null,
|
||||
): Promise<ActionResult<{ id: string }>> {
|
||||
const actor = new Types.ObjectId(actorId);
|
||||
const fields = {
|
||||
productTypeId: new Types.ObjectId(input.productTypeId),
|
||||
brand: input.brand,
|
||||
name: input.name,
|
||||
searchKey: foldTr(`${input.brand} ${input.name}`),
|
||||
barcode: input.barcode,
|
||||
packageQuantity: input.packageQuantity,
|
||||
packageUnit: input.packageUnit,
|
||||
typeQuantity,
|
||||
note: input.note,
|
||||
updatedBy: actor,
|
||||
};
|
||||
const mongoose = await connectMongoose();
|
||||
let id = existing;
|
||||
try {
|
||||
await mongoose.connection.transaction(async (session) => {
|
||||
if (existing) {
|
||||
await ProductModel.updateOne(
|
||||
{ _id: existing, householdId: m.householdId },
|
||||
{ $set: fields },
|
||||
{ session },
|
||||
);
|
||||
} else {
|
||||
const [doc] = await ProductModel.create(
|
||||
[{ householdId: m.householdId, ...fields, createdBy: actor }],
|
||||
{ session },
|
||||
);
|
||||
id = doc!._id;
|
||||
}
|
||||
const synced = await syncOwnerAttachments({
|
||||
householdId: m.householdId,
|
||||
actorId,
|
||||
ownerType: "product",
|
||||
ownerId: id!,
|
||||
ids: input.imageIds,
|
||||
allowedKinds: ["image"],
|
||||
session,
|
||||
});
|
||||
if (!synced.ok) throw new ImageError(synced);
|
||||
await ProductModel.updateOne(
|
||||
{ _id: id!, householdId: m.householdId },
|
||||
{ $set: { imageCount: synced.data } },
|
||||
{ session },
|
||||
);
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ImageError) return error.result;
|
||||
throw error;
|
||||
}
|
||||
return ok({ id: String(id) });
|
||||
}
|
||||
|
||||
export async function createProduct(
|
||||
actorId: string,
|
||||
householdId: string,
|
||||
input: ProductForm,
|
||||
): Promise<ActionResult<{ id: string }>> {
|
||||
const m = await resolveMembership(actorId, householdId, "member");
|
||||
if (!m) return fail(tr.common.unauthorized);
|
||||
const c = await checkInput(m, input);
|
||||
if (c.error) return c.error;
|
||||
return save(m, actorId, input, c.typeQuantity, null);
|
||||
}
|
||||
|
||||
export async function updateProduct(
|
||||
actorId: string,
|
||||
householdId: string,
|
||||
productId: string,
|
||||
input: ProductForm,
|
||||
): Promise<ActionResult<{ id: string }>> {
|
||||
const m = await resolveMembership(actorId, householdId, "member");
|
||||
if (!m) return fail(tr.common.unauthorized);
|
||||
const id = toObjectId(productId);
|
||||
const current = id
|
||||
? await ProductModel.findOne({ _id: id, householdId: m.householdId }).lean<ProductLean>()
|
||||
: null;
|
||||
if (!current) return fail(tr.common.notFound);
|
||||
if (current.archivedAt) return fail(t.validation.archived);
|
||||
const c = await checkInput(m, input, current._id);
|
||||
if (c.error) return c.error;
|
||||
return save(m, actorId, input, c.typeQuantity, current._id);
|
||||
}
|
||||
|
||||
export async function setProductArchived(
|
||||
actorId: string,
|
||||
householdId: string,
|
||||
productId: string,
|
||||
archived: boolean,
|
||||
): Promise<ActionResult> {
|
||||
const m = await resolveMembership(actorId, householdId, "member");
|
||||
if (!m) return fail(tr.common.unauthorized);
|
||||
const id = toObjectId(productId);
|
||||
const current = id
|
||||
? await ProductModel.findOne({ _id: id, householdId: m.householdId }).lean<ProductLean>()
|
||||
: null;
|
||||
if (!current) return fail(tr.common.notFound);
|
||||
if (!archived) {
|
||||
const type = await ProductTypeModel.exists({
|
||||
_id: current.productTypeId,
|
||||
householdId: m.householdId,
|
||||
archivedAt: null,
|
||||
});
|
||||
if (!type) return fail(t.validation.productType);
|
||||
if (
|
||||
current.barcode &&
|
||||
(await ProductModel.exists({
|
||||
householdId: m.householdId,
|
||||
barcode: current.barcode,
|
||||
archivedAt: null,
|
||||
_id: { $ne: current._id },
|
||||
}))
|
||||
)
|
||||
return fail(t.validation.barcodeTaken);
|
||||
}
|
||||
const actor = new Types.ObjectId(actorId);
|
||||
await ProductModel.updateOne(
|
||||
{ _id: current._id, householdId: m.householdId },
|
||||
{ $set: { archivedAt: archived ? new Date() : null, updatedBy: actor } },
|
||||
);
|
||||
await writeAudit({
|
||||
action: archived ? "product.archive" : "product.unarchive",
|
||||
actorId: actor,
|
||||
householdId: m.householdId,
|
||||
targetType: "product",
|
||||
targetId: productId,
|
||||
diff: { label: productLabel(current) },
|
||||
});
|
||||
return ok(undefined);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { fail, type ActionResult } from "@/lib/action-result";
|
||||
import { getHouseholdActionContext } from "@/lib/dal";
|
||||
import { storeFormSchema } from "@/shared/schemas/store";
|
||||
import { createStore, setStoreArchived, updateStore } from "./service";
|
||||
|
||||
const idSchema = z.string().regex(/^[a-f0-9]{24}$/i);
|
||||
|
||||
function fieldErrors(error: z.ZodError) {
|
||||
const out: Record<string, string> = {};
|
||||
for (const issue of error.issues) {
|
||||
const key = issue.path.join(".");
|
||||
if (key && !out[key]) out[key] = issue.message;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function run<T, R>(
|
||||
schema: z.ZodType<T>,
|
||||
input: unknown,
|
||||
fn: (userId: string, householdId: string, data: T) => Promise<ActionResult<R>>,
|
||||
): Promise<ActionResult<R>> {
|
||||
const ctx = await getHouseholdActionContext("member");
|
||||
if (!ctx) return fail(tr.common.unauthorized);
|
||||
const parsed = schema.safeParse(input);
|
||||
if (!parsed.success) return fail(tr.adminUsers.errors.checkFields, fieldErrors(parsed.error));
|
||||
const result = await fn(ctx.user.id, ctx.household.id, parsed.data);
|
||||
if (result.ok) revalidatePath("/", "layout");
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Yeni (id boş) veya mevcut mağaza. */
|
||||
export async function saveStoreAction(storeId: unknown, input: unknown) {
|
||||
if (storeId) {
|
||||
const id = idSchema.safeParse(storeId);
|
||||
if (!id.success) return fail(tr.common.notFound);
|
||||
return run(storeFormSchema, input, (u, h, d) => updateStore(u, h, id.data, d));
|
||||
}
|
||||
return run(storeFormSchema, input, createStore);
|
||||
}
|
||||
|
||||
export async function setStoreArchivedAction(storeId: unknown, archived: unknown) {
|
||||
const id = idSchema.safeParse(storeId);
|
||||
if (!id.success) return fail(tr.common.notFound);
|
||||
return run(z.boolean(), archived, (u, h, a) => setStoreArchived(u, h, id.data, a));
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { AuditLogModel } from "@/models/audit-log";
|
||||
import { storeFormSchema, type StoreFormInput } from "@/shared/schemas/store";
|
||||
import { setupSeededDb, teardownDb } from "@/test/seeded-db";
|
||||
import { createStore, listStores, setStoreArchived, updateStore } from "./service";
|
||||
|
||||
let ids: Awaited<ReturnType<typeof setupSeededDb>>;
|
||||
let cakir = "";
|
||||
let ayse = "";
|
||||
let zeynep = "";
|
||||
let fatma = "";
|
||||
|
||||
beforeAll(async () => {
|
||||
ids = await setupSeededDb();
|
||||
cakir = ids.household("Çakır Ailesi");
|
||||
ayse = ids.user("ayse");
|
||||
zeynep = ids.user("zeynep");
|
||||
fatma = ids.user("fatma");
|
||||
});
|
||||
afterAll(teardownDb);
|
||||
|
||||
const form = (over: Partial<StoreFormInput> = {}) =>
|
||||
storeFormSchema.parse({
|
||||
name: "Macrocenter",
|
||||
kind: "market",
|
||||
branch: "Caddebostan",
|
||||
distanceKm: "4,5",
|
||||
carryDifficulty: "3",
|
||||
deliveryFee: "",
|
||||
freeDeliveryMin: "",
|
||||
mealCardAccepted: false,
|
||||
mealCardProviders: [],
|
||||
note: "",
|
||||
...over,
|
||||
});
|
||||
|
||||
describe("mağazalar", () => {
|
||||
it("seed: zahmet skoru hesaplanır; izolasyon", async () => {
|
||||
const s = (await listStores(ayse, cakir))!;
|
||||
expect(s.stores).toHaveLength(8);
|
||||
const carrefour = s.stores.find((x) => x.name === "CarrefourSA")!;
|
||||
// 3,8 km → 9,5; taşıma 4 → 37,5 → 47.
|
||||
expect(carrefour).toMatchObject({ effort: 47, effortLevel: "medium", mealCardAccepted: true });
|
||||
const getir = s.stores.find((x) => x.kind === "online")!;
|
||||
expect(getir).toMatchObject({ distanceM: null, effort: 0, deliveryFeeMinor: 39_90 });
|
||||
expect(s.stores.find((x) => x.name === "Metro Toptancı")!.effortLevel).toBe("high");
|
||||
expect(await listStores(fatma, cakir)).toBeNull();
|
||||
expect((await listStores(zeynep, cakir))!.canWrite).toBe(false);
|
||||
});
|
||||
|
||||
it("şema: online mağazada uzaklık yok; yemek kartı seçilince sağlayıcı zorunlu", () => {
|
||||
expect(form({ kind: "online", deliveryFee: "29,90", distanceKm: "3" })).toMatchObject({
|
||||
distanceM: null,
|
||||
deliveryFeeMinor: 29_90,
|
||||
});
|
||||
expect(form({ deliveryFee: "29,90" })).toMatchObject({
|
||||
distanceM: 4_500,
|
||||
deliveryFeeMinor: null,
|
||||
});
|
||||
expect(
|
||||
storeFormSchema.safeParse({ ...form(), mealCardAccepted: true, mealCardProviders: [] })
|
||||
.success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
storeFormSchema.safeParse({
|
||||
name: "X",
|
||||
kind: "market",
|
||||
branch: "",
|
||||
distanceKm: "abc",
|
||||
carryDifficulty: "6",
|
||||
deliveryFee: "",
|
||||
freeDeliveryMin: "",
|
||||
mealCardAccepted: false,
|
||||
mealCardProviders: [],
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("oluştur, aynı ad + şube reddi, düzenle, arşiv denetimi", async () => {
|
||||
const r = await createStore(ayse, cakir, form());
|
||||
expect(r.ok).toBe(true);
|
||||
const id = r.ok ? r.data.id : "";
|
||||
expect((await createStore(ayse, cakir, form({ name: "macrocenter" }))).ok).toBe(false);
|
||||
// Farklı şube serbest.
|
||||
expect((await createStore(ayse, cakir, form({ branch: "Ataşehir" }))).ok).toBe(true);
|
||||
expect(
|
||||
(
|
||||
await updateStore(
|
||||
ayse,
|
||||
cakir,
|
||||
id,
|
||||
form({ mealCardAccepted: true, mealCardProviders: ["pluxee", "pluxee"] }),
|
||||
)
|
||||
).ok,
|
||||
).toBe(true);
|
||||
let s = (await listStores(ayse, cakir))!.stores.find((x) => x.id === id)!;
|
||||
expect(s.mealCardProviders).toEqual(["pluxee"]);
|
||||
expect((await createStore(zeynep, cakir, form({ name: "İzleyici" }))).ok).toBe(false);
|
||||
|
||||
expect((await setStoreArchived(ayse, cakir, id, true)).ok).toBe(true);
|
||||
expect(await AuditLogModel.exists({ action: "store.archive", targetId: id })).toBeTruthy();
|
||||
expect((await updateStore(ayse, cakir, id, form())).ok).toBe(false);
|
||||
// Arşivliyken aynı ad + şube yeniden eklenebilir; arşivden çıkarma çakışırsa reddedilir.
|
||||
expect((await createStore(ayse, cakir, form())).ok).toBe(true);
|
||||
expect((await setStoreArchived(ayse, cakir, id, false)).ok).toBe(false);
|
||||
s = (await listStores(ayse, cakir))!.stores.find((x) => x.id === id)!;
|
||||
expect(s.archived).toBe(true);
|
||||
// Başka hanenin kimliğiyle işlem yok.
|
||||
expect((await setStoreArchived(fatma, cakir, id, true)).ok).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,189 @@
|
||||
import "server-only";
|
||||
import { Types } from "mongoose";
|
||||
import { hasHouseholdRole } from "@/features/households/roles";
|
||||
import { resolveMembership } from "@/features/households/service";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { fail, ok, type ActionResult } from "@/lib/action-result";
|
||||
import { writeAudit } from "@/lib/audit";
|
||||
import { toObjectId } from "@/lib/object-id";
|
||||
import { StoreModel } from "@/models/store";
|
||||
import type { MealCardProvider } from "@/shared/accounts";
|
||||
import type { StoreForm } from "@/shared/schemas/store";
|
||||
import { foldTr } from "@/shared/search";
|
||||
import {
|
||||
effortLevel,
|
||||
effortScore,
|
||||
sortStores,
|
||||
type EffortLevel,
|
||||
type StoreKind,
|
||||
} from "@/shared/stores";
|
||||
|
||||
/**
|
||||
* Mağazalar (Bölüm 6.11). Hane bazlı, hane içinde ortak. Okuma: viewer; yazma: member.
|
||||
* Zahmet skoru okuma sırasında hesaplanır (saf motor: shared/stores).
|
||||
*/
|
||||
|
||||
const t = tr.stores;
|
||||
|
||||
type StoreLean = {
|
||||
_id: Types.ObjectId;
|
||||
name: string;
|
||||
kind: StoreKind;
|
||||
branch: string;
|
||||
distanceM: number | null;
|
||||
carryDifficulty: number;
|
||||
deliveryFeeMinor: number | null;
|
||||
freeDeliveryMinMinor: number | null;
|
||||
mealCardAccepted: boolean;
|
||||
mealCardProviders: MealCardProvider[];
|
||||
note: string;
|
||||
archivedAt: Date | null;
|
||||
};
|
||||
|
||||
export type StoreView = {
|
||||
id: string;
|
||||
name: string;
|
||||
kind: StoreKind;
|
||||
branch: string;
|
||||
/** "Migros · Kadıköy". */
|
||||
label: string;
|
||||
distanceM: number | null;
|
||||
carryDifficulty: number;
|
||||
deliveryFeeMinor: number | null;
|
||||
freeDeliveryMinMinor: number | null;
|
||||
mealCardAccepted: boolean;
|
||||
mealCardProviders: MealCardProvider[];
|
||||
note: string;
|
||||
archived: boolean;
|
||||
effort: number;
|
||||
effortLevel: EffortLevel;
|
||||
};
|
||||
|
||||
export const storeLabel = (s: { name: string; branch: string }) =>
|
||||
s.branch ? `${s.name} · ${s.branch}` : s.name;
|
||||
|
||||
function toView(s: StoreLean): StoreView {
|
||||
const effort = effortScore(s);
|
||||
return {
|
||||
id: String(s._id),
|
||||
name: s.name,
|
||||
kind: s.kind,
|
||||
branch: s.branch,
|
||||
label: storeLabel(s),
|
||||
distanceM: s.distanceM,
|
||||
carryDifficulty: s.carryDifficulty,
|
||||
deliveryFeeMinor: s.deliveryFeeMinor,
|
||||
freeDeliveryMinMinor: s.freeDeliveryMinMinor,
|
||||
mealCardAccepted: s.mealCardAccepted,
|
||||
mealCardProviders: s.mealCardProviders,
|
||||
note: s.note,
|
||||
archived: Boolean(s.archivedAt),
|
||||
effort,
|
||||
effortLevel: effortLevel(effort),
|
||||
};
|
||||
}
|
||||
|
||||
export async function listStores(
|
||||
userId: string,
|
||||
householdId: string,
|
||||
): Promise<{ stores: StoreView[]; canWrite: boolean } | null> {
|
||||
const m = await resolveMembership(userId, householdId, "viewer");
|
||||
if (!m) return null;
|
||||
const rows = await StoreModel.find({ householdId: m.householdId }).lean<StoreLean[]>();
|
||||
return {
|
||||
stores: sortStores(rows.map(toView), "name"),
|
||||
canWrite: hasHouseholdRole(m.role, "member"),
|
||||
};
|
||||
}
|
||||
|
||||
/** Aynı ad + şube (Türkçe katlanmış) aktif mağazalarda tektir. */
|
||||
async function isDuplicate(
|
||||
householdId: Types.ObjectId,
|
||||
input: Pick<StoreForm, "name" | "branch">,
|
||||
except?: Types.ObjectId,
|
||||
) {
|
||||
const rows = await StoreModel.find({
|
||||
householdId,
|
||||
nameKey: foldTr(input.name),
|
||||
archivedAt: null,
|
||||
...(except ? { _id: { $ne: except } } : {}),
|
||||
})
|
||||
.select({ branch: 1 })
|
||||
.lean<{ branch: string }[]>();
|
||||
return rows.some((r) => foldTr(r.branch) === foldTr(input.branch));
|
||||
}
|
||||
|
||||
const fields = (input: StoreForm) => ({ ...input, nameKey: foldTr(input.name) });
|
||||
|
||||
export async function createStore(
|
||||
actorId: string,
|
||||
householdId: string,
|
||||
input: StoreForm,
|
||||
): Promise<ActionResult<{ id: string }>> {
|
||||
const m = await resolveMembership(actorId, householdId, "member");
|
||||
if (!m) return fail(tr.common.unauthorized);
|
||||
if (await isDuplicate(m.householdId, input))
|
||||
return fail(t.validation.duplicate, { name: t.validation.duplicate });
|
||||
const actor = new Types.ObjectId(actorId);
|
||||
const doc = await StoreModel.create({
|
||||
householdId: m.householdId,
|
||||
...fields(input),
|
||||
createdBy: actor,
|
||||
updatedBy: actor,
|
||||
});
|
||||
return ok({ id: String(doc._id) });
|
||||
}
|
||||
|
||||
export async function updateStore(
|
||||
actorId: string,
|
||||
householdId: string,
|
||||
storeId: string,
|
||||
input: StoreForm,
|
||||
): Promise<ActionResult> {
|
||||
const m = await resolveMembership(actorId, householdId, "member");
|
||||
if (!m) return fail(tr.common.unauthorized);
|
||||
const id = toObjectId(storeId);
|
||||
const current = id
|
||||
? await StoreModel.findOne({ _id: id, householdId: m.householdId }).lean()
|
||||
: null;
|
||||
if (!current) return fail(tr.common.notFound);
|
||||
if (current.archivedAt) return fail(t.validation.archived);
|
||||
if (await isDuplicate(m.householdId, input, current._id))
|
||||
return fail(t.validation.duplicate, { name: t.validation.duplicate });
|
||||
await StoreModel.updateOne(
|
||||
{ _id: current._id, householdId: m.householdId },
|
||||
{ $set: { ...fields(input), updatedBy: new Types.ObjectId(actorId) } },
|
||||
);
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
export async function setStoreArchived(
|
||||
actorId: string,
|
||||
householdId: string,
|
||||
storeId: string,
|
||||
archived: boolean,
|
||||
): Promise<ActionResult> {
|
||||
const m = await resolveMembership(actorId, householdId, "member");
|
||||
if (!m) return fail(tr.common.unauthorized);
|
||||
const id = toObjectId(storeId);
|
||||
const current = id
|
||||
? await StoreModel.findOne({ _id: id, householdId: m.householdId }).lean<StoreLean>()
|
||||
: null;
|
||||
if (!current) return fail(tr.common.notFound);
|
||||
if (!archived && (await isDuplicate(m.householdId, current, current._id)))
|
||||
return fail(t.validation.duplicate);
|
||||
const actor = new Types.ObjectId(actorId);
|
||||
await StoreModel.updateOne(
|
||||
{ _id: current._id, householdId: m.householdId },
|
||||
{ $set: { archivedAt: archived ? new Date() : null, updatedBy: actor } },
|
||||
);
|
||||
await writeAudit({
|
||||
action: archived ? "store.archive" : "store.unarchive",
|
||||
actorId: actor,
|
||||
householdId: m.householdId,
|
||||
targetType: "store",
|
||||
targetId: storeId,
|
||||
diff: { name: storeLabel(current) },
|
||||
});
|
||||
return ok(undefined);
|
||||
}
|
||||
Reference in New Issue
Block a user