f0b570284e
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
190 lines
5.7 KiB
TypeScript
190 lines
5.7 KiB
TypeScript
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);
|
||
}
|