import { Injectable } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import { DatabaseService } from '../../../infrastructure/database/database.service';
import { DomainConflictError } from '../../../common/errors/domain-conflict.error';
import {
assertAttachModifierGroupInput,
assertAttachModifierInput,
assertModifierGroupInput,
assertModifierInput,
} from './menu-input-validation';
@Injectable()
export class ModifierService {
constructor(private readonly database: DatabaseService) {}
async createGroup(input: {
organizationId: string;
name: string;
minSelect: number;
maxSelect?: number | null;
}) {
assertModifierGroupInput(input);
const id = randomUUID();
if (input.maxSelect != null && input.maxSelect < input.minSelect) {
throw new DomainConflictError('modifier.invalid_selection_bounds');
}
await this.database.query(
`insert into modifier_groups(
id, organization_id, name, min_select, max_select
) values ($1,$2,$3,$4,$5)`,
[id, input.organizationId, input.name, input.minSelect, input.maxSelect ?? null],
);
return { id };
}
async createModifier(input: {
organizationId: string;
name: string;
recipeId?: string;
}) {
assertModifierInput(input);
if (input.recipeId) {
const recipe = await this.database.query<{ id: string }>(
`select id from recipes where id=$1 and organization_id=$2`,
[input.recipeId, input.organizationId],
);
if (!recipe[0]) throw new DomainConflictError('modifier.recipe_forbidden');
}
const id = randomUUID();
await this.database.query(
`insert into modifiers(id, organization_id, name, recipe_id)
values ($1,$2,$3,$4)`,
[id, input.organizationId, input.name, input.recipeId ?? null],
);
return { id };
}
async attachModifier(input: {
organizationId: string;
modifierGroupId: string;
modifierId: string;
priceDeltaMinor: number;
sortOrder: number;
}) {
assertAttachModifierInput(input);
const rows = await this.database.query<{ valid: boolean }>(
`select exists(
select 1
from modifier_groups mg
join modifiers m on m.id=$2
where mg.id=$1
and mg.organization_id=$3
and m.organization_id=$3
) as valid`,
[input.modifierGroupId, input.modifierId, input.organizationId],
);
if (!rows[0]?.valid) throw new DomainConflictError('modifier.cross_tenant_forbidden');
await this.database.query(
`insert into modifier_group_items(
modifier_group_id, modifier_id, price_delta_minor, sort_order
)
values ($1,$2,$3,$4)
on conflict (modifier_group_id, modifier_id)
do update set
price_delta_minor=excluded.price_delta_minor,
sort_order=excluded.sort_order`,
[
input.modifierGroupId,
input.modifierId,
input.priceDeltaMinor,
input.sortOrder,
],
);
return { attached: true };
}
async attachGroupToProduct(input: {
organizationId: string;
productId: string;
modifierGroupId: string;
sortOrder: number;
}) {
assertAttachModifierGroupInput(input);
const rows = await this.database.query<{ valid: boolean }>(
`select exists(
select 1
from products p
join modifier_groups mg on mg.id=$2
where p.id=$1
and p.organization_id=$3
and mg.organization_id=$3
) as valid`,
[input.productId, input.modifierGroupId, input.organizationId],
);
if (!rows[0]?.valid) throw new DomainConflictError('modifier.cross_tenant_forbidden');
await this.database.query(
`insert into product_modifier_groups(product_id, modifier_group_id, sort_order)
values ($1,$2,$3)
on conflict (product_id, modifier_group_id)
do update set sort_order=excluded.sort_order`,
[input.productId, input.modifierGroupId, input.sortOrder],
);
return { attached: true };
}
}
