Files
butcem/src/features/stores/service.ts
T

190 lines
5.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}