Compare commits
19 Commits
13f21d4d3f
...
production
| Author | SHA1 | Date | |
|---|---|---|---|
| 8225a3818c | |||
| 827864b19c | |||
| b59541b6f7 | |||
| 40e78b4ccd | |||
| ea629fcbf6 | |||
| c96df8748f | |||
| c439fdd6f9 | |||
| 27c45974f4 | |||
| 549da39473 | |||
| 096297d1d1 | |||
| 9fa55cc6b5 | |||
| d1f85d78f4 | |||
| 17223ced06 | |||
| 01ae67248a | |||
| 443b62eb8e | |||
| 4abc2ae6e5 | |||
| 204b4f2007 | |||
| a05643daf5 | |||
| e991215f5a |
+14
@@ -0,0 +1,14 @@
|
|||||||
|
curl --location 'https://arvancloudai.ir/gateway/models/DeepSeek-V4-Flash/GShkYZ-j5g8GPs2hizngJUlGKcqhDw14rUWb0wu-eLcYjfqUVlvxAhbcBdjg5iH_FSs0uHY0ypJj94AIKmr-gotsFuKm2FqCGbNXJcBi7-2lTu3Ep_cQp_eCSvmo9y4GWUU8j26WQN7pvm-MgH-L9aH-wEdU-qsyqgSslgaPdPigDJz29XNbVmBX5uw3fMpyrJi_I8myhEAoQ9IwpPblDvyHJHgaMAHFX5NZ77PWfyrxCUWkCnlsLZwUUnQ3YEjujmNm77mDqQQ/v1/chat/completions' \
|
||||||
|
-H 'Authorization: apikey 4fe6d150-7951-58f5-b813-2e4bd7e74734' \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{
|
||||||
|
"model":"DeepSeek-V4-Flash-f5unr",
|
||||||
|
"messages":[
|
||||||
|
{
|
||||||
|
"role":"user",
|
||||||
|
"content":"چرا آسمان آبی است؟"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"max_tokens":1000,
|
||||||
|
"temperature":0.8
|
||||||
|
}'
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"folders": [
|
||||||
|
{
|
||||||
|
"path": ".",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "../dmenu-admin",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"settings": {},
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,81 @@
|
|||||||
|
import { Migration } from '@mikro-orm/migrations';
|
||||||
|
|
||||||
|
export class Migration20260714170013_changeOrderStatus extends Migration {
|
||||||
|
|
||||||
|
public async up(): Promise<void> {
|
||||||
|
|
||||||
|
// remove old constraint
|
||||||
|
this.addSql(`
|
||||||
|
ALTER TABLE orders
|
||||||
|
DROP CONSTRAINT IF EXISTS orders_status_check;
|
||||||
|
`);
|
||||||
|
|
||||||
|
// migrate data
|
||||||
|
this.addSql(`
|
||||||
|
UPDATE orders
|
||||||
|
SET status='new'
|
||||||
|
WHERE status='pendingPayment';
|
||||||
|
`);
|
||||||
|
|
||||||
|
this.addSql(`
|
||||||
|
UPDATE orders
|
||||||
|
SET status='completed'
|
||||||
|
WHERE status='paid';
|
||||||
|
`);
|
||||||
|
|
||||||
|
// create new constraint
|
||||||
|
this.addSql(`
|
||||||
|
ALTER TABLE orders
|
||||||
|
ADD CONSTRAINT orders_status_check
|
||||||
|
CHECK (
|
||||||
|
status IN (
|
||||||
|
'new',
|
||||||
|
'preparing',
|
||||||
|
'deliveredToWaiter',
|
||||||
|
'deliveredToReceptionist',
|
||||||
|
'shipped',
|
||||||
|
'canceled',
|
||||||
|
'completed'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(): Promise<void> {
|
||||||
|
|
||||||
|
this.addSql(`
|
||||||
|
ALTER TABLE "orders"
|
||||||
|
DROP CONSTRAINT IF EXISTS "orders_status_check";
|
||||||
|
`);
|
||||||
|
|
||||||
|
this.addSql(`
|
||||||
|
UPDATE "orders"
|
||||||
|
SET "status" = 'pendingPayment'
|
||||||
|
WHERE "status" = 'new';
|
||||||
|
`);
|
||||||
|
|
||||||
|
this.addSql(`
|
||||||
|
UPDATE "orders"
|
||||||
|
SET "status" = 'paid'
|
||||||
|
WHERE "status" = 'completed';
|
||||||
|
`);
|
||||||
|
|
||||||
|
this.addSql(`
|
||||||
|
ALTER TABLE "orders"
|
||||||
|
ADD CONSTRAINT "orders_status_check"
|
||||||
|
CHECK (
|
||||||
|
"status" IN (
|
||||||
|
'pendingPayment',
|
||||||
|
'preparing',
|
||||||
|
'deliveredToWaiter',
|
||||||
|
'deliveredToReceptionist',
|
||||||
|
'shipped',
|
||||||
|
'paid',
|
||||||
|
'canceled',
|
||||||
|
'completed'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { Migration } from '@mikro-orm/migrations';
|
||||||
|
|
||||||
|
export class Migration20260715083002_task extends Migration {
|
||||||
|
|
||||||
|
override async up(): Promise<void> {
|
||||||
|
this.addSql(`alter table "reviews" drop constraint "reviews_order_id_foreign";`);
|
||||||
|
|
||||||
|
this.addSql(`drop index "reviews_order_id_index";`);
|
||||||
|
this.addSql(`alter table "reviews" drop constraint "reviews_order_id_food_id_user_id_unique";`);
|
||||||
|
|
||||||
|
this.addSql(`alter table "reviews" drop column "order_id";`);
|
||||||
|
|
||||||
|
this.addSql(`alter table "reviews" add column "is_buyer" boolean not null default false;`);
|
||||||
|
this.addSql(`alter table "reviews" add constraint "reviews_food_id_user_id_unique" unique ("food_id", "user_id");`);
|
||||||
|
}
|
||||||
|
|
||||||
|
override async down(): Promise<void> {
|
||||||
|
this.addSql(`alter table "reviews" drop constraint "reviews_food_id_user_id_unique";`);
|
||||||
|
|
||||||
|
this.addSql(`alter table "reviews" add column "order_id" char(26) not null;`);
|
||||||
|
this.addSql(`alter table "reviews" add constraint "reviews_order_id_foreign" foreign key ("order_id") references "orders" ("id") on update cascade;`);
|
||||||
|
this.addSql(`create index "reviews_order_id_index" on "reviews" ("order_id");`);
|
||||||
|
this.addSql(`alter table "reviews" add constraint "reviews_order_id_food_id_user_id_unique" unique ("order_id", "food_id", "user_id");`);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { Migration } from '@mikro-orm/migrations';
|
||||||
|
|
||||||
|
export class Migration20260715120000_dropReviewsOrderId extends Migration {
|
||||||
|
|
||||||
|
override async up(): Promise<void> {
|
||||||
|
this.addSql(`alter table "reviews" drop column if exists "order_id";`);
|
||||||
|
}
|
||||||
|
|
||||||
|
override async down(): Promise<void> {
|
||||||
|
this.addSql(`alter table "reviews" add column "order_id" char(26) not null;`);
|
||||||
|
this.addSql(`alter table "reviews" add constraint "reviews_order_id_foreign" foreign key ("order_id") references "orders" ("id") on update cascade;`);
|
||||||
|
this.addSql(`create index "reviews_order_id_index" on "reviews" ("order_id");`);
|
||||||
|
this.addSql(`alter table "reviews" drop constraint if exists "reviews_food_id_user_id_unique";`);
|
||||||
|
this.addSql(`alter table "reviews" add constraint "reviews_order_id_food_id_user_id_unique" unique ("order_id", "food_id", "user_id");`);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { Migration } from '@mikro-orm/migrations';
|
||||||
|
|
||||||
|
export class Migration20260716120000_addReviewsParentId extends Migration {
|
||||||
|
|
||||||
|
override async up(): Promise<void> {
|
||||||
|
this.addSql(`alter table "reviews" add column "parent_id" char(26) null;`);
|
||||||
|
this.addSql(`alter table "reviews" add constraint "reviews_parent_id_foreign" foreign key ("parent_id") references "reviews" ("id") on update cascade on delete set null;`);
|
||||||
|
this.addSql(`create index "reviews_parent_id_index" on "reviews" ("parent_id");`);
|
||||||
|
}
|
||||||
|
|
||||||
|
override async down(): Promise<void> {
|
||||||
|
this.addSql(`alter table "reviews" drop constraint "reviews_parent_id_foreign";`);
|
||||||
|
this.addSql(`drop index "reviews_parent_id_index";`);
|
||||||
|
this.addSql(`alter table "reviews" drop column "parent_id";`);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { Migration } from '@mikro-orm/migrations';
|
||||||
|
|
||||||
|
export class Migration20260716123000_nullableReviewUserForReplies extends Migration {
|
||||||
|
|
||||||
|
override async up(): Promise<void> {
|
||||||
|
this.addSql(`alter table "reviews" alter column "user_id" drop not null;`);
|
||||||
|
}
|
||||||
|
|
||||||
|
override async down(): Promise<void> {
|
||||||
|
this.addSql(`alter table "reviews" alter column "user_id" set not null;`);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { Migration } from '@mikro-orm/migrations';
|
||||||
|
|
||||||
|
export class Migration20260717100000_addReviewsAdminId extends Migration {
|
||||||
|
|
||||||
|
override async up(): Promise<void> {
|
||||||
|
this.addSql(`alter table "reviews" add column "admin_id" char(26) null;`);
|
||||||
|
this.addSql(`alter table "reviews" add constraint "reviews_admin_id_foreign" foreign key ("admin_id") references "admins" ("id") on update cascade on delete set null;`);
|
||||||
|
this.addSql(`create index "reviews_admin_id_index" on "reviews" ("admin_id");`);
|
||||||
|
}
|
||||||
|
|
||||||
|
override async down(): Promise<void> {
|
||||||
|
this.addSql(`alter table "reviews" drop constraint "reviews_admin_id_foreign";`);
|
||||||
|
this.addSql(`drop index "reviews_admin_id_index";`);
|
||||||
|
this.addSql(`alter table "reviews" drop column "admin_id";`);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import { Migration } from '@mikro-orm/migrations';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds paid_amount, balance, refunded_amount on orders.
|
||||||
|
* Values are maintained by PostgreSQL functions + triggers from payments / order.total.
|
||||||
|
*
|
||||||
|
* Definitions:
|
||||||
|
* - paid_amount = SUM(payments.amount) WHERE status = 'paid' AND deleted_at IS NULL
|
||||||
|
* - refunded_amount = SUM(payments.amount) WHERE status = 'refunded' AND deleted_at IS NULL
|
||||||
|
* - balance = total - paid_amount (remaining amount due)
|
||||||
|
*/
|
||||||
|
export class Migration20260718093000_addOrderPaymentAmounts extends Migration {
|
||||||
|
override async up(): Promise<void> {
|
||||||
|
this.addSql(`
|
||||||
|
alter table "orders"
|
||||||
|
add column "paid_amount" numeric(10,0) not null default 0,
|
||||||
|
add column "balance" numeric(10,0) not null default 0,
|
||||||
|
add column "refunded_amount" numeric(10,0) not null default 0;
|
||||||
|
`);
|
||||||
|
|
||||||
|
this.addSql(`
|
||||||
|
create or replace function recalculate_order_payment_amounts(p_order_id char(26))
|
||||||
|
returns void
|
||||||
|
language plpgsql
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
v_paid numeric(10,0);
|
||||||
|
v_refunded numeric(10,0);
|
||||||
|
begin
|
||||||
|
if p_order_id is null then
|
||||||
|
return;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
select
|
||||||
|
coalesce(sum(p.amount) filter (where p.status = 'paid'), 0),
|
||||||
|
coalesce(sum(p.amount) filter (where p.status = 'refunded'), 0)
|
||||||
|
into v_paid, v_refunded
|
||||||
|
from payments p
|
||||||
|
where p.order_id = p_order_id
|
||||||
|
and p.deleted_at is null;
|
||||||
|
|
||||||
|
update orders o
|
||||||
|
set
|
||||||
|
paid_amount = v_paid,
|
||||||
|
refunded_amount = v_refunded,
|
||||||
|
balance = o.total - v_paid
|
||||||
|
where o.id = p_order_id;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
`);
|
||||||
|
|
||||||
|
this.addSql(`
|
||||||
|
create or replace function trg_payments_recalculate_order_payment_amounts()
|
||||||
|
returns trigger
|
||||||
|
language plpgsql
|
||||||
|
as $$
|
||||||
|
begin
|
||||||
|
if tg_op = 'DELETE' then
|
||||||
|
perform recalculate_order_payment_amounts(old.order_id);
|
||||||
|
return old;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
perform recalculate_order_payment_amounts(new.order_id);
|
||||||
|
|
||||||
|
if tg_op = 'UPDATE' and old.order_id is distinct from new.order_id then
|
||||||
|
perform recalculate_order_payment_amounts(old.order_id);
|
||||||
|
end if;
|
||||||
|
|
||||||
|
return new;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
`);
|
||||||
|
|
||||||
|
this.addSql(`
|
||||||
|
create trigger payments_recalculate_order_payment_amounts
|
||||||
|
after insert or update of amount, status, order_id, deleted_at or delete
|
||||||
|
on payments
|
||||||
|
for each row
|
||||||
|
execute procedure trg_payments_recalculate_order_payment_amounts();
|
||||||
|
`);
|
||||||
|
|
||||||
|
this.addSql(`
|
||||||
|
create or replace function trg_orders_sync_balance()
|
||||||
|
returns trigger
|
||||||
|
language plpgsql
|
||||||
|
as $$
|
||||||
|
begin
|
||||||
|
new.balance := coalesce(new.total, 0) - coalesce(new.paid_amount, 0);
|
||||||
|
return new;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
`);
|
||||||
|
|
||||||
|
this.addSql(`
|
||||||
|
create trigger orders_sync_balance
|
||||||
|
before insert or update of total, paid_amount
|
||||||
|
on orders
|
||||||
|
for each row
|
||||||
|
execute procedure trg_orders_sync_balance();
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Backfill existing rows: set balance from total, then recalc from payments
|
||||||
|
this.addSql(`
|
||||||
|
update orders
|
||||||
|
set balance = total - paid_amount;
|
||||||
|
`);
|
||||||
|
|
||||||
|
this.addSql(`
|
||||||
|
do $$
|
||||||
|
declare
|
||||||
|
r record;
|
||||||
|
begin
|
||||||
|
for r in
|
||||||
|
select distinct order_id
|
||||||
|
from payments
|
||||||
|
where deleted_at is null
|
||||||
|
and status in ('paid', 'refunded')
|
||||||
|
loop
|
||||||
|
perform recalculate_order_payment_amounts(r.order_id);
|
||||||
|
end loop;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
override async down(): Promise<void> {
|
||||||
|
this.addSql(`drop trigger if exists orders_sync_balance on orders;`);
|
||||||
|
this.addSql(`drop trigger if exists payments_recalculate_order_payment_amounts on payments;`);
|
||||||
|
this.addSql(`drop function if exists trg_orders_sync_balance();`);
|
||||||
|
this.addSql(`drop function if exists trg_payments_recalculate_order_payment_amounts();`);
|
||||||
|
this.addSql(`drop function if exists recalculate_order_payment_amounts(char(26));`);
|
||||||
|
this.addSql(`
|
||||||
|
alter table "orders"
|
||||||
|
drop column "paid_amount",
|
||||||
|
drop column "balance",
|
||||||
|
drop column "refunded_amount";
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
import { Migration } from '@mikro-orm/migrations';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DB-maintained order money fields.
|
||||||
|
*
|
||||||
|
* Inputs (set by app): coupon_discount, tax, delivery_fee, packing_fee
|
||||||
|
*
|
||||||
|
* Derived:
|
||||||
|
* - order_items.total_price = (unit_price - discount) * quantity
|
||||||
|
* - sub_total = SUM(unit_price * quantity) from non-deleted items
|
||||||
|
* - items_discount = SUM(discount * quantity) from non-deleted items
|
||||||
|
* - total_items = SUM(quantity) from non-deleted items
|
||||||
|
* - total_discount = coupon_discount + items_discount
|
||||||
|
* - total = GREATEST(0, sub_total - total_discount) + tax + delivery_fee + packing_fee
|
||||||
|
* - balance = total - paid_amount (also synced by payment-amount triggers)
|
||||||
|
*
|
||||||
|
* Also syncs pending payment.amount when order.total changes.
|
||||||
|
*/
|
||||||
|
export class Migration20260718110000_addOrderTotalsCalculation extends Migration {
|
||||||
|
override async up(): Promise<void> {
|
||||||
|
// --- order_items.total_price ---
|
||||||
|
this.addSql(`
|
||||||
|
create or replace function trg_order_items_sync_total_price()
|
||||||
|
returns trigger
|
||||||
|
language plpgsql
|
||||||
|
as $$
|
||||||
|
begin
|
||||||
|
new.total_price := (coalesce(new.unit_price, 0) - coalesce(new.discount, 0)) * coalesce(new.quantity, 0);
|
||||||
|
return new;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
`);
|
||||||
|
|
||||||
|
this.addSql(`
|
||||||
|
create trigger order_items_sync_total_price
|
||||||
|
before insert or update of unit_price, discount, quantity
|
||||||
|
on order_items
|
||||||
|
for each row
|
||||||
|
execute procedure trg_order_items_sync_total_price();
|
||||||
|
`);
|
||||||
|
|
||||||
|
// --- recalculate order aggregates from items + fee inputs ---
|
||||||
|
this.addSql(`
|
||||||
|
create or replace function recalculate_order_totals(p_order_id char(26))
|
||||||
|
returns void
|
||||||
|
language plpgsql
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
v_sub_total numeric(10,0);
|
||||||
|
v_items_discount numeric(10,0);
|
||||||
|
v_total_items int;
|
||||||
|
v_total numeric(10,0);
|
||||||
|
begin
|
||||||
|
if p_order_id is null then
|
||||||
|
return;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
select
|
||||||
|
coalesce(sum(oi.unit_price * oi.quantity), 0),
|
||||||
|
coalesce(sum(oi.discount * oi.quantity), 0),
|
||||||
|
coalesce(sum(oi.quantity), 0)::int
|
||||||
|
into v_sub_total, v_items_discount, v_total_items
|
||||||
|
from order_items oi
|
||||||
|
where oi.order_id = p_order_id
|
||||||
|
and oi.deleted_at is null;
|
||||||
|
|
||||||
|
update orders o
|
||||||
|
set
|
||||||
|
sub_total = v_sub_total,
|
||||||
|
items_discount = v_items_discount,
|
||||||
|
total_items = v_total_items,
|
||||||
|
total_discount = coalesce(o.coupon_discount, 0) + v_items_discount,
|
||||||
|
total = greatest(
|
||||||
|
0,
|
||||||
|
v_sub_total - (coalesce(o.coupon_discount, 0) + v_items_discount)
|
||||||
|
) + coalesce(o.tax, 0) + coalesce(o.delivery_fee, 0) + coalesce(o.packing_fee, 0),
|
||||||
|
balance = (
|
||||||
|
greatest(
|
||||||
|
0,
|
||||||
|
v_sub_total - (coalesce(o.coupon_discount, 0) + v_items_discount)
|
||||||
|
) + coalesce(o.tax, 0) + coalesce(o.delivery_fee, 0) + coalesce(o.packing_fee, 0)
|
||||||
|
) - coalesce(o.paid_amount, 0)
|
||||||
|
where o.id = p_order_id
|
||||||
|
returning o.total into v_total;
|
||||||
|
|
||||||
|
-- keep pending payments in sync with the new order total
|
||||||
|
update payments p
|
||||||
|
set amount = v_total
|
||||||
|
where p.order_id = p_order_id
|
||||||
|
and p.status = 'pending'
|
||||||
|
and p.deleted_at is null
|
||||||
|
and p.amount is distinct from v_total;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
`);
|
||||||
|
|
||||||
|
this.addSql(`
|
||||||
|
create or replace function trg_order_items_recalculate_order_totals()
|
||||||
|
returns trigger
|
||||||
|
language plpgsql
|
||||||
|
as $$
|
||||||
|
begin
|
||||||
|
if tg_op = 'DELETE' then
|
||||||
|
perform recalculate_order_totals(old.order_id);
|
||||||
|
return old;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
perform recalculate_order_totals(new.order_id);
|
||||||
|
|
||||||
|
if tg_op = 'UPDATE' and old.order_id is distinct from new.order_id then
|
||||||
|
perform recalculate_order_totals(old.order_id);
|
||||||
|
end if;
|
||||||
|
|
||||||
|
return new;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
`);
|
||||||
|
|
||||||
|
this.addSql(`
|
||||||
|
create trigger order_items_recalculate_order_totals
|
||||||
|
after insert or update of unit_price, discount, quantity, order_id, deleted_at or delete
|
||||||
|
on order_items
|
||||||
|
for each row
|
||||||
|
execute procedure trg_order_items_recalculate_order_totals();
|
||||||
|
`);
|
||||||
|
|
||||||
|
// When fee / coupon / tax inputs change, recompute total (items unchanged)
|
||||||
|
this.addSql(`
|
||||||
|
create or replace function trg_orders_recalculate_totals_from_inputs()
|
||||||
|
returns trigger
|
||||||
|
language plpgsql
|
||||||
|
as $$
|
||||||
|
begin
|
||||||
|
if tg_op = 'UPDATE' and (
|
||||||
|
old.coupon_discount is not distinct from new.coupon_discount
|
||||||
|
and old.tax is not distinct from new.tax
|
||||||
|
and old.delivery_fee is not distinct from new.delivery_fee
|
||||||
|
and old.packing_fee is not distinct from new.packing_fee
|
||||||
|
) then
|
||||||
|
return new;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
new.total_discount := coalesce(new.coupon_discount, 0) + coalesce(new.items_discount, 0);
|
||||||
|
new.total := greatest(
|
||||||
|
0,
|
||||||
|
coalesce(new.sub_total, 0) - new.total_discount
|
||||||
|
) + coalesce(new.tax, 0) + coalesce(new.delivery_fee, 0) + coalesce(new.packing_fee, 0);
|
||||||
|
new.balance := new.total - coalesce(new.paid_amount, 0);
|
||||||
|
|
||||||
|
return new;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
`);
|
||||||
|
|
||||||
|
this.addSql(`
|
||||||
|
create trigger orders_recalculate_totals_from_inputs
|
||||||
|
before insert or update of coupon_discount, tax, delivery_fee, packing_fee
|
||||||
|
on orders
|
||||||
|
for each row
|
||||||
|
execute procedure trg_orders_recalculate_totals_from_inputs();
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Sync pending payment amount when total changes (e.g. fee update)
|
||||||
|
this.addSql(`
|
||||||
|
create or replace function trg_orders_sync_pending_payment_amount()
|
||||||
|
returns trigger
|
||||||
|
language plpgsql
|
||||||
|
as $$
|
||||||
|
begin
|
||||||
|
if tg_op = 'UPDATE' and old.total is not distinct from new.total then
|
||||||
|
return new;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
update payments p
|
||||||
|
set amount = new.total
|
||||||
|
where p.order_id = new.id
|
||||||
|
and p.status = 'pending'
|
||||||
|
and p.deleted_at is null
|
||||||
|
and p.amount is distinct from new.total;
|
||||||
|
|
||||||
|
return new;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Must be AFTER UPDATE (not UPDATE OF total): BEFORE triggers may change NEW.total
|
||||||
|
// when only fee columns are in the client UPDATE, and UPDATE OF would not fire then.
|
||||||
|
this.addSql(`
|
||||||
|
create trigger orders_sync_pending_payment_amount
|
||||||
|
after update
|
||||||
|
on orders
|
||||||
|
for each row
|
||||||
|
when (old.total is distinct from new.total)
|
||||||
|
execute procedure trg_orders_sync_pending_payment_amount();
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Backfill item total_price and order aggregates
|
||||||
|
this.addSql(`
|
||||||
|
update order_items
|
||||||
|
set total_price = (coalesce(unit_price, 0) - coalesce(discount, 0)) * coalesce(quantity, 0)
|
||||||
|
where total_price is distinct from (coalesce(unit_price, 0) - coalesce(discount, 0)) * coalesce(quantity, 0);
|
||||||
|
`);
|
||||||
|
|
||||||
|
this.addSql(`
|
||||||
|
do $$
|
||||||
|
declare
|
||||||
|
r record;
|
||||||
|
begin
|
||||||
|
for r in select id from orders where deleted_at is null
|
||||||
|
loop
|
||||||
|
perform recalculate_order_totals(r.id);
|
||||||
|
end loop;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
override async down(): Promise<void> {
|
||||||
|
this.addSql(`drop trigger if exists orders_sync_pending_payment_amount on orders;`);
|
||||||
|
this.addSql(`drop trigger if exists orders_recalculate_totals_from_inputs on orders;`);
|
||||||
|
this.addSql(`drop trigger if exists order_items_recalculate_order_totals on order_items;`);
|
||||||
|
this.addSql(`drop trigger if exists order_items_sync_total_price on order_items;`);
|
||||||
|
this.addSql(`drop function if exists trg_orders_sync_pending_payment_amount();`);
|
||||||
|
this.addSql(`drop function if exists trg_orders_recalculate_totals_from_inputs();`);
|
||||||
|
this.addSql(`drop function if exists trg_order_items_recalculate_order_totals();`);
|
||||||
|
this.addSql(`drop function if exists trg_order_items_sync_total_price();`);
|
||||||
|
this.addSql(`drop function if exists recalculate_order_totals(char(26));`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { Migration } from '@mikro-orm/migrations';
|
||||||
|
|
||||||
|
export class Migration20260723090000_addAiChats extends Migration {
|
||||||
|
override async up(): Promise<void> {
|
||||||
|
this.addSql(`
|
||||||
|
create table "ai_chats" (
|
||||||
|
"id" char(26) not null,
|
||||||
|
"created_at" timestamptz not null default now(),
|
||||||
|
"updated_at" timestamptz not null default now(),
|
||||||
|
"deleted_at" timestamptz null,
|
||||||
|
"restaurant_id" char(26) not null,
|
||||||
|
"user_id" char(26) null,
|
||||||
|
"question" text not null,
|
||||||
|
"consumed_tokens" int not null,
|
||||||
|
"cost" numeric(10,0) not null,
|
||||||
|
constraint "ai_chats_pkey" primary key ("id")
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
this.addSql(`create index "ai_chats_created_at_index" on "ai_chats" ("created_at");`);
|
||||||
|
this.addSql(`create index "ai_chats_deleted_at_index" on "ai_chats" ("deleted_at");`);
|
||||||
|
this.addSql(`create index "ai_chats_restaurant_id_index" on "ai_chats" ("restaurant_id");`);
|
||||||
|
this.addSql(`
|
||||||
|
alter table "ai_chats"
|
||||||
|
add constraint "ai_chats_restaurant_id_foreign"
|
||||||
|
foreign key ("restaurant_id") references "restaurants" ("id") on update cascade;
|
||||||
|
`);
|
||||||
|
this.addSql(`
|
||||||
|
alter table "ai_chats"
|
||||||
|
add constraint "ai_chats_user_id_foreign"
|
||||||
|
foreign key ("user_id") references "users" ("id") on update cascade on delete set null;
|
||||||
|
`);
|
||||||
|
|
||||||
|
this.addSql(`
|
||||||
|
alter table "restaurant_credit_transactions"
|
||||||
|
drop constraint if exists "restaurant_credit_transactions_reason_check";
|
||||||
|
`);
|
||||||
|
this.addSql(`
|
||||||
|
alter table "restaurant_credit_transactions"
|
||||||
|
add constraint "restaurant_credit_transactions_reason_check"
|
||||||
|
check ("reason" in ('sms_send', 'deposit', 'adjustment', 'ai_chat'));
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
override async down(): Promise<void> {
|
||||||
|
this.addSql(`
|
||||||
|
alter table "restaurant_credit_transactions"
|
||||||
|
drop constraint if exists "restaurant_credit_transactions_reason_check";
|
||||||
|
`);
|
||||||
|
this.addSql(`
|
||||||
|
alter table "restaurant_credit_transactions"
|
||||||
|
add constraint "restaurant_credit_transactions_reason_check"
|
||||||
|
check ("reason" in ('sms_send', 'deposit', 'adjustment'));
|
||||||
|
`);
|
||||||
|
|
||||||
|
this.addSql(`alter table "ai_chats" drop constraint if exists "ai_chats_user_id_foreign";`);
|
||||||
|
this.addSql(`alter table "ai_chats" drop constraint if exists "ai_chats_restaurant_id_foreign";`);
|
||||||
|
this.addSql(`drop table if exists "ai_chats" cascade;`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { Migration } from '@mikro-orm/migrations';
|
||||||
|
|
||||||
|
export class Migration20260726120000_addEnableAiChatToRestaurants extends Migration {
|
||||||
|
override async up(): Promise<void> {
|
||||||
|
this.addSql(
|
||||||
|
`alter table "restaurants" add column "enable_ai_chat" boolean not null default false;`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
override async down(): Promise<void> {
|
||||||
|
this.addSql(`alter table "restaurants" drop column "enable_ai_chat";`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@ import { ContactModule } from './modules/contact/contact.module';
|
|||||||
import { InventoryModule } from './modules/inventory/inventory.module';
|
import { InventoryModule } from './modules/inventory/inventory.module';
|
||||||
import { IconsModule } from './modules/icons/icons.module';
|
import { IconsModule } from './modules/icons/icons.module';
|
||||||
import { SliderModule } from './modules/slider/slider.module';
|
import { SliderModule } from './modules/slider/slider.module';
|
||||||
|
import { AiModule } from './modules/ai/ai.module';
|
||||||
import { CacheModule } from '@nestjs/cache-manager';
|
import { CacheModule } from '@nestjs/cache-manager';
|
||||||
import { cacheConfig } from './config/cache.config';
|
import { cacheConfig } from './config/cache.config';
|
||||||
|
|
||||||
@@ -61,6 +62,7 @@ import { cacheConfig } from './config/cache.config';
|
|||||||
InventoryModule,
|
InventoryModule,
|
||||||
IconsModule,
|
IconsModule,
|
||||||
SliderModule,
|
SliderModule,
|
||||||
|
AiModule,
|
||||||
],
|
],
|
||||||
controllers: [],
|
controllers: [],
|
||||||
providers: [],
|
providers: [],
|
||||||
|
|||||||
@@ -600,6 +600,9 @@ export const enum SettingMessageEnum {
|
|||||||
|
|
||||||
export const enum AiMessage {
|
export const enum AiMessage {
|
||||||
NO_RESPONSE_FROM_AI_MODEL = 'خطا در دریافت پاسخ از مدل AI',
|
NO_RESPONSE_FROM_AI_MODEL = 'خطا در دریافت پاسخ از مدل AI',
|
||||||
|
LLM_SERVICE_ERROR = 'سرویس هوش مصنوعی موقتاً در دسترس نیست',
|
||||||
|
INSUFFICIENT_CREDIT = 'اعتبار کیف پول رستوران برای استفاده از هوش مصنوعی کافی نیست',
|
||||||
|
AI_CHAT_DISABLED = 'چت هوش مصنوعی برای این رستوران فعال نیست',
|
||||||
MESSAGE_NOT_FOUND_OR_ACCESS_DENIED = 'ایمیل یافت نشد یا دسترسی آن را ندارید',
|
MESSAGE_NOT_FOUND_OR_ACCESS_DENIED = 'ایمیل یافت نشد یا دسترسی آن را ندارید',
|
||||||
EMAIL_CONTENT_IS_EMPTY_OR_COULD_NOT_BE_EXTRACTED = 'محتوای ایمیل خالی است یا قابل استخراج نیست',
|
EMAIL_CONTENT_IS_EMPTY_OR_COULD_NOT_BE_EXTRACTED = 'محتوای ایمیل خالی است یا قابل استخراج نیست',
|
||||||
DESCRIPTION_IS_REQUIRED = 'شرح و هدف قالب الزامی است',
|
DESCRIPTION_IS_REQUIRED = 'شرح و هدف قالب الزامی است',
|
||||||
@@ -660,6 +663,11 @@ export const enum ReviewMessage {
|
|||||||
COMMENT_CREATED = 'نظر با موفقیت ثبت شد',
|
COMMENT_CREATED = 'نظر با موفقیت ثبت شد',
|
||||||
COMMENT_UPDATED = 'نظر با موفقیت بهروزرسانی شد',
|
COMMENT_UPDATED = 'نظر با موفقیت بهروزرسانی شد',
|
||||||
COMMENT_DELETED = 'نظر با موفقیت حذف شد',
|
COMMENT_DELETED = 'نظر با موفقیت حذف شد',
|
||||||
|
CANNOT_REPLY_TO_REPLY = 'امکان پاسخ به پاسخ وجود ندارد',
|
||||||
|
REPLY_CREATED = 'پاسخ با موفقیت ثبت شد',
|
||||||
|
REPLY_UPDATED = 'پاسخ با موفقیت بهروزرسانی شد',
|
||||||
|
REPLY_DELETED = 'پاسخ با موفقیت حذف شد',
|
||||||
|
REPLY_NOT_FOUND = 'پاسخی برای این نظر یافت نشد',
|
||||||
}
|
}
|
||||||
|
|
||||||
export const enum IconMessage {
|
export const enum IconMessage {
|
||||||
@@ -699,6 +707,18 @@ export const enum OrderMessage {
|
|||||||
DISCOUNT_AMOUNT_EXCEEDS_ORDER_TOTAL = 'مبلغ تخفیف بیشتر از مبلغ سفارش است',
|
DISCOUNT_AMOUNT_EXCEEDS_ORDER_TOTAL = 'مبلغ تخفیف بیشتر از مبلغ سفارش است',
|
||||||
CANNOT_UPDATE_CANCELED_OR_COMPLETED_ORDER = 'امکان ویرایش سفارش لغو شده یا تکمیل شده وجود ندارد',
|
CANNOT_UPDATE_CANCELED_OR_COMPLETED_ORDER = 'امکان ویرایش سفارش لغو شده یا تکمیل شده وجود ندارد',
|
||||||
NO_FIELDS_TO_UPDATE = 'حداقل یک فیلد برای بروزرسانی الزامی است',
|
NO_FIELDS_TO_UPDATE = 'حداقل یک فیلد برای بروزرسانی الزامی است',
|
||||||
|
ORDER_ITEM_NOT_FOUND = 'آیتم سفارش یافت نشد',
|
||||||
|
CANNOT_REMOVE_LAST_ORDER_ITEM = 'سفارش باید حداقل یک آیتم داشته باشد',
|
||||||
|
REFUND_NOT_ALLOWED = 'امکان بازگشت وجه برای این سفارش وجود ندارد',
|
||||||
|
REFUND_AMOUNT_EXCEEDS_PAID = 'مبلغ بازگشت وجه بیشتر از مبلغ پرداختشده است',
|
||||||
|
REFUND_AMOUNT_EXCEEDS_OVERPAYMENT = 'مبلغ بازگشت وجه بیشتر از مانده منفی سفارش است',
|
||||||
|
NO_PAID_PAYMENTS_TO_REFUND = 'پرداختی برای بازگشت وجه یافت نشد',
|
||||||
|
ADD_PAYMENT_NOT_ALLOWED = 'امکان افزودن پرداخت برای این سفارش وجود ندارد',
|
||||||
|
ADD_PAYMENT_NO_BALANCE = 'ماندهای برای پرداخت وجود ندارد',
|
||||||
|
PLEASE_COME_SMS_QUEUED = 'پیامک دعوت به مراجعه در صف ارسال قرار گرفت',
|
||||||
|
PLEASE_COME_SMS_PATTERN_MISSING = 'الگوی پیامک دعوت به مراجعه تنظیم نشده است',
|
||||||
|
PLEASE_COME_USER_MISSING = 'این سفارش کاربری ندارد',
|
||||||
|
PLEASE_COME_PHONE_MISSING = 'شماره تلفن کاربر یافت نشد',
|
||||||
}
|
}
|
||||||
|
|
||||||
export const enum CartMessage {
|
export const enum CartMessage {
|
||||||
@@ -752,6 +772,7 @@ export const enum PaymentMessage {
|
|||||||
CARD_OWNER_REQUIRED = 'نام صاحب کارت برای پرداخت کارت به کارت الزامی است',
|
CARD_OWNER_REQUIRED = 'نام صاحب کارت برای پرداخت کارت به کارت الزامی است',
|
||||||
PAYMENT_ALREADY_PAID = 'پرداخت قبلا انجام شده است',
|
PAYMENT_ALREADY_PAID = 'پرداخت قبلا انجام شده است',
|
||||||
EXISTING_PENDING_PAYMENT_MISMATCH = 'پرداخت در انتظار موجود با روش پرداخت سفارش مطابقت ندارد',
|
EXISTING_PENDING_PAYMENT_MISMATCH = 'پرداخت در انتظار موجود با روش پرداخت سفارش مطابقت ندارد',
|
||||||
|
REFUND_WALLET_NOT_FOUND = 'کیف پول کاربر برای بازگشت وجه یافت نشد',
|
||||||
ZARINPAL_INVALID_RESPONSE = 'پاسخ نامعتبر از API زرینپال',
|
ZARINPAL_INVALID_RESPONSE = 'پاسخ نامعتبر از API زرینپال',
|
||||||
ZARINPAL_PAYMENT_REQUEST_ERROR = 'خطا در درخواست پرداخت زرینپال',
|
ZARINPAL_PAYMENT_REQUEST_ERROR = 'خطا در درخواست پرداخت زرینپال',
|
||||||
ZARINPAL_VERIFY_ERROR = 'خطا در تایید پرداخت زرینپال',
|
ZARINPAL_VERIFY_ERROR = 'خطا در تایید پرداخت زرینپال',
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { MikroOrmModule } from '@mikro-orm/nestjs';
|
||||||
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
|
import { AiController } from './controllers/ai.controller';
|
||||||
|
import { AiService } from './providers/ai.service';
|
||||||
|
import { AiChatRepository } from './repositories/ai-chat.repository';
|
||||||
|
import { AiChat } from './entities/ai-chat.entity';
|
||||||
|
import { RestaurantsModule } from '../restaurants/restaurants.module';
|
||||||
|
import { AiCrone } from './crone/ai.crone';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [MikroOrmModule.forFeature([AiChat]), JwtModule, RestaurantsModule],
|
||||||
|
controllers: [AiController],
|
||||||
|
providers: [AiService, AiChatRepository, AiCrone],
|
||||||
|
exports: [AiChatRepository],
|
||||||
|
})
|
||||||
|
export class AiModule {}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { Body, Controller, Get, Post, Query, UseGuards } from '@nestjs/common';
|
||||||
|
import { ApiBearerAuth, ApiBody, ApiHeader, ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger';
|
||||||
|
import { AiService } from '../providers/ai.service';
|
||||||
|
import { ChatDto } from '../dto/chat.dto';
|
||||||
|
import { FindAiChatsDto } from '../dto/find-ai-chats.dto';
|
||||||
|
import { GenerateFoodDescriptionDto } from '../dto/generate-food-description.dto';
|
||||||
|
import { GenerateFoodContentDto } from '../dto/generate-food-content.dto';
|
||||||
|
import { OptionalAuthGuard } from '../../auth/guards/optinalAuth.guard';
|
||||||
|
import { AdminAuthGuard } from '../../auth/guards/adminAuth.guard';
|
||||||
|
import { RestId } from 'src/common/decorators/rest-id.decorator';
|
||||||
|
import { UserId } from 'src/common/decorators/user-id.decorator';
|
||||||
|
import { RestSlug } from 'src/common/decorators/rest-slug.decorator';
|
||||||
|
import { Permissions } from 'src/common/decorators/permissions.decorator';
|
||||||
|
import { Permission } from 'src/common/enums/permission.enum';
|
||||||
|
import { API_HEADER_SLUG } from 'src/common/constants';
|
||||||
|
|
||||||
|
@ApiTags('ai')
|
||||||
|
@Controller()
|
||||||
|
export class AiController {
|
||||||
|
constructor(private readonly aiService: AiService) {}
|
||||||
|
|
||||||
|
@UseGuards(OptionalAuthGuard)
|
||||||
|
@Post('public/ai/chat')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Ask the restaurant AI assistant about the menu',
|
||||||
|
description: 'Returns an answer plus any offered foods populated from AI-selected food IDs',
|
||||||
|
})
|
||||||
|
@ApiHeader(API_HEADER_SLUG)
|
||||||
|
@ApiHeader({
|
||||||
|
name: 'Authorization',
|
||||||
|
required: false,
|
||||||
|
description: 'Bearer token (optional authentication)',
|
||||||
|
})
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiBody({ type: ChatDto })
|
||||||
|
async chat(
|
||||||
|
@Body() dto: ChatDto,
|
||||||
|
@RestSlug() slug: string,
|
||||||
|
@RestId() restId?: string,
|
||||||
|
@UserId() userId?: string,
|
||||||
|
) {
|
||||||
|
return this.aiService.chat(dto, {
|
||||||
|
restId: restId || undefined,
|
||||||
|
userId: userId || undefined,
|
||||||
|
slug,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@UseGuards(AdminAuthGuard)
|
||||||
|
@Permissions(Permission.UPDATE_RESTAURANT)
|
||||||
|
@Get('admin/ai/chats')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiOperation({ summary: 'Get paginated list of AI chats for the restaurant' })
|
||||||
|
@ApiQuery({ name: 'page', required: false, type: Number })
|
||||||
|
@ApiQuery({ name: 'limit', required: false, type: Number })
|
||||||
|
@ApiQuery({ name: 'search', required: false, type: String })
|
||||||
|
@ApiQuery({ name: 'orderBy', required: false, type: String })
|
||||||
|
@ApiQuery({ name: 'order', required: false, enum: ['asc', 'desc'] })
|
||||||
|
async findAllPaginated(@RestId() restId: string, @Query() dto: FindAiChatsDto) {
|
||||||
|
return this.aiService.findAllPaginated(restId, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@UseGuards(AdminAuthGuard)
|
||||||
|
@Permissions(Permission.MANAGE_FOODS)
|
||||||
|
@Post('admin/ai/food-description')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiOperation({ summary: 'Generate a two-line food description from a food name' })
|
||||||
|
@ApiBody({ type: GenerateFoodDescriptionDto })
|
||||||
|
async generateFoodDescription(@Body() dto: GenerateFoodDescriptionDto, @RestId() restId: string) {
|
||||||
|
return this.aiService.generateFoodDescription(dto, restId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@UseGuards(AdminAuthGuard)
|
||||||
|
@Permissions(Permission.MANAGE_FOODS)
|
||||||
|
@Post('admin/ai/food-content')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiOperation({ summary: 'Generate food ingredients/contents from a food name' })
|
||||||
|
@ApiBody({ type: GenerateFoodContentDto })
|
||||||
|
async generateFoodContent(@Body() dto: GenerateFoodContentDto, @RestId() restId: string) {
|
||||||
|
return this.aiService.generateFoodContent(dto, restId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { Cron } from '@nestjs/schedule';
|
||||||
|
import { CreateRequestContext } from '@mikro-orm/core';
|
||||||
|
import { EntityManager } from '@mikro-orm/postgresql';
|
||||||
|
import { AiChat } from '../entities/ai-chat.entity';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AiCrone {
|
||||||
|
private readonly logger = new Logger(AiCrone.name);
|
||||||
|
|
||||||
|
constructor(private readonly em: EntityManager) {}
|
||||||
|
|
||||||
|
// run every day at 03:00 (3:00 AM)
|
||||||
|
@Cron('0 3 * * *', {
|
||||||
|
name: 'deleteOldAiChats',
|
||||||
|
timeZone: 'UTC',
|
||||||
|
})
|
||||||
|
@CreateRequestContext()
|
||||||
|
async handleCron() {
|
||||||
|
try {
|
||||||
|
this.logger.debug('Starting daily AI chat cleanup (removing questions older than 1 month)');
|
||||||
|
|
||||||
|
const oneMonthAgo = new Date();
|
||||||
|
oneMonthAgo.setMonth(oneMonthAgo.getMonth() - 1);
|
||||||
|
|
||||||
|
const oldAiChats = await this.em.find(AiChat, {
|
||||||
|
createdAt: { $lt: oneMonthAgo },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!oldAiChats || oldAiChats.length === 0) {
|
||||||
|
this.logger.debug('No old AI chats found to delete');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(`Found ${oldAiChats.length} AI chats older than 1 month to delete`);
|
||||||
|
|
||||||
|
await this.em.removeAndFlush(oldAiChats);
|
||||||
|
|
||||||
|
this.logger.log(`Successfully deleted ${oldAiChats.length} old AI chats`);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(`AiCrone failed: ${err?.message}`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { IsNotEmpty, IsString, MaxLength } from 'class-validator';
|
||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class ChatDto {
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(2000)
|
||||||
|
@ApiProperty({
|
||||||
|
example: 'کدام غذا برای گیاهخواران مناسب است؟',
|
||||||
|
description: 'User question for the restaurant AI assistant',
|
||||||
|
})
|
||||||
|
question!: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { IsOptional, IsNumber, IsString, IsIn, Min } from 'class-validator';
|
||||||
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
|
||||||
|
const sortOrderOptions = ['asc', 'desc'] as const;
|
||||||
|
type SortOrder = (typeof sortOrderOptions)[number];
|
||||||
|
|
||||||
|
export class FindAiChatsDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(1)
|
||||||
|
@Type(() => Number)
|
||||||
|
@ApiPropertyOptional({ example: 1, description: 'Page number', minimum: 1, default: 1 })
|
||||||
|
page?: number = 1;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(1)
|
||||||
|
@Type(() => Number)
|
||||||
|
@ApiPropertyOptional({ example: 10, description: 'Items per page', minimum: 1, default: 10 })
|
||||||
|
limit?: number = 10;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@ApiPropertyOptional({ example: 'گیاهخوار', description: 'Search in question text' })
|
||||||
|
search?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@ApiPropertyOptional({ example: 'createdAt', description: 'Field to sort by', default: 'createdAt' })
|
||||||
|
orderBy?: string = 'createdAt';
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(sortOrderOptions)
|
||||||
|
@ApiPropertyOptional({ example: 'desc', enum: sortOrderOptions, default: 'desc' })
|
||||||
|
order?: SortOrder = 'desc';
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { IsNotEmpty, IsString, MaxLength } from 'class-validator';
|
||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class GenerateFoodContentDto {
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(200)
|
||||||
|
@ApiProperty({
|
||||||
|
example: 'چلو کباب کوبیده',
|
||||||
|
description: 'Food name to generate ingredients/contents for',
|
||||||
|
})
|
||||||
|
foodName!: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { IsNotEmpty, IsString, MaxLength } from 'class-validator';
|
||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class GenerateFoodDescriptionDto {
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(200)
|
||||||
|
@ApiProperty({
|
||||||
|
example: 'چلو کباب کوبیده',
|
||||||
|
description: 'Food name to generate a short description for',
|
||||||
|
})
|
||||||
|
foodName!: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { Entity, Index, ManyToOne, Property } from '@mikro-orm/core';
|
||||||
|
import { BaseEntity } from '../../../common/entities/base.entity';
|
||||||
|
import { Restaurant } from '../../restaurants/entities/restaurant.entity';
|
||||||
|
import { User } from '../../users/entities/user.entity';
|
||||||
|
|
||||||
|
@Entity({ tableName: 'ai_chats' })
|
||||||
|
@Index({ properties: ['restaurant'] })
|
||||||
|
export class AiChat extends BaseEntity {
|
||||||
|
@ManyToOne(() => Restaurant)
|
||||||
|
restaurant!: Restaurant;
|
||||||
|
|
||||||
|
@ManyToOne(() => User, { nullable: true })
|
||||||
|
user?: User | null;
|
||||||
|
|
||||||
|
@Property({ type: 'text' })
|
||||||
|
question!: string;
|
||||||
|
|
||||||
|
@Property({ type: 'int' })
|
||||||
|
consumedTokens!: number;
|
||||||
|
|
||||||
|
@Property({ type: 'decimal', precision: 10, scale: 0 })
|
||||||
|
cost!: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
export interface AiChatCompletionUsage {
|
||||||
|
prompt_tokens: number;
|
||||||
|
completion_tokens: number;
|
||||||
|
total_tokens: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AiChatCompletionChoice {
|
||||||
|
finish_reason?: string | null;
|
||||||
|
message?: {
|
||||||
|
role?: string;
|
||||||
|
content?: string | null;
|
||||||
|
reasoning_content?: string | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AiChatCompletionResponse {
|
||||||
|
choices?: AiChatCompletionChoice[];
|
||||||
|
usage?: AiChatCompletionUsage;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AiChatOfferedFood {
|
||||||
|
id: string;
|
||||||
|
title?: string;
|
||||||
|
desc?: string;
|
||||||
|
content?: string[];
|
||||||
|
price?: number;
|
||||||
|
images?: string[];
|
||||||
|
discount: number;
|
||||||
|
isSpecialOffer: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AiChatResult {
|
||||||
|
answer: string;
|
||||||
|
foods: AiChatOfferedFood[];
|
||||||
|
consumedTokens: number;
|
||||||
|
cost: number;
|
||||||
|
promptTokens: number;
|
||||||
|
completionTokens: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,395 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
NotFoundException,
|
||||||
|
ServiceUnavailableException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { HttpService } from '@nestjs/axios';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { EntityManager } from '@mikro-orm/postgresql';
|
||||||
|
import { AxiosError } from 'axios';
|
||||||
|
import { catchError, firstValueFrom, throwError } from 'rxjs';
|
||||||
|
import { ChatDto } from '../dto/chat.dto';
|
||||||
|
import { FindAiChatsDto } from '../dto/find-ai-chats.dto';
|
||||||
|
import { GenerateFoodDescriptionDto } from '../dto/generate-food-description.dto';
|
||||||
|
import { GenerateFoodContentDto } from '../dto/generate-food-content.dto';
|
||||||
|
import { AiChat } from '../entities/ai-chat.entity';
|
||||||
|
import { AiChatRepository } from '../repositories/ai-chat.repository';
|
||||||
|
import {
|
||||||
|
AiChatCompletionResponse,
|
||||||
|
AiChatOfferedFood,
|
||||||
|
AiChatResult,
|
||||||
|
} from '../interface/ai-chat.interface';
|
||||||
|
import { Restaurant } from '../../restaurants/entities/restaurant.entity';
|
||||||
|
import { User } from '../../users/entities/user.entity';
|
||||||
|
import { Food } from '../../foods/entities/food.entity';
|
||||||
|
import { RestaurantWalletService } from '../../restaurants/providers/restaurant-wallet.service';
|
||||||
|
import {
|
||||||
|
RestaurantCreditTransactionReason,
|
||||||
|
RestaurantCreditTransactionType,
|
||||||
|
} from '../../restaurants/interface/restaurant-credit-transaction.interface';
|
||||||
|
import { AiMessage, RestMessage } from 'src/common/enums/message.enum';
|
||||||
|
import { PaginatedResult } from 'src/common/interfaces/pagination.interface';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AiService {
|
||||||
|
private readonly logger = new Logger(AiService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly em: EntityManager,
|
||||||
|
private readonly httpService: HttpService,
|
||||||
|
private readonly configService: ConfigService,
|
||||||
|
private readonly restaurantWalletService: RestaurantWalletService,
|
||||||
|
private readonly aiChatRepository: AiChatRepository,
|
||||||
|
) { }
|
||||||
|
|
||||||
|
findAllPaginated(restId: string, dto: FindAiChatsDto): Promise<PaginatedResult<AiChat>> {
|
||||||
|
return this.aiChatRepository.findAllPaginated(restId, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
async chat(dto: ChatDto, params: { restId?: string; userId?: string; slug?: string }): Promise<AiChatResult> {
|
||||||
|
const restaurant = await this.resolveRestaurant(params.restId, params.slug);
|
||||||
|
|
||||||
|
if (!restaurant.enableAiChat) {
|
||||||
|
throw new BadRequestException(AiMessage.AI_CHAT_DISABLED);
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = params.userId ? await this.em.findOne(User, { id: params.userId }) : null;
|
||||||
|
|
||||||
|
await this.ensureHasCredit(restaurant.id);
|
||||||
|
|
||||||
|
const foods = await this.em.find(
|
||||||
|
Food,
|
||||||
|
{ restaurant: restaurant.id, isActive: true },
|
||||||
|
{
|
||||||
|
fields: ['id', 'title', 'desc', 'content', 'price', 'images', 'discount', 'isSpecialOffer'],
|
||||||
|
orderBy: { order: 'asc' },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const menuContext = this.buildMenuContext(foods);
|
||||||
|
const aiResponse = await this.callAiApi({
|
||||||
|
systemPrompt: [
|
||||||
|
'تو دستیار هوشمند منوی رستوران هستی.',
|
||||||
|
'فقط بر اساس منوی زیر به سوالات کاربر پاسخ بده.',
|
||||||
|
'یک توضیح کوتاه دو خطی هم اگه لازم بود اضافه کن',
|
||||||
|
'اگر اطلاعات کافی در منو نیست، صادقانه بگو.',
|
||||||
|
'وقتی غذایی پیشنهاد میدهی، فقط از شناسههای (id) موجود در منو استفاده کن.',
|
||||||
|
'پاسخ را فقط و فقط به صورت JSON معتبر برگردان، بدون متن اضافه یا markdown:',
|
||||||
|
'{"answer":"پاسخ کوتاه فارسی","foodIds":["id1","id2"]}',
|
||||||
|
'اگر غذایی پیشنهاد نمیکنی، foodIds را آرایه خالی بگذار.',
|
||||||
|
'هیچ وقت id غذا را داخل متن قرار نده.',
|
||||||
|
'',
|
||||||
|
'منوی رستوران:',
|
||||||
|
menuContext,
|
||||||
|
].join('\n'),
|
||||||
|
userPrompt: dto.question,
|
||||||
|
});
|
||||||
|
|
||||||
|
const parsed = this.parseChatAiContent(aiResponse);
|
||||||
|
const offeredFoods = this.populateOfferedFoods(parsed.foodIds, foods);
|
||||||
|
|
||||||
|
return this.persistAiUsage({
|
||||||
|
restaurant,
|
||||||
|
user,
|
||||||
|
question: dto.question,
|
||||||
|
aiResponse,
|
||||||
|
answerOverride: parsed.answer,
|
||||||
|
foods: offeredFoods,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async generateFoodDescription(dto: GenerateFoodDescriptionDto, restId: string): Promise<AiChatResult> {
|
||||||
|
const restaurant = await this.resolveRestaurant(restId);
|
||||||
|
await this.ensureHasCredit(restaurant.id);
|
||||||
|
|
||||||
|
const aiResponse = await this.callAiApi({
|
||||||
|
systemPrompt: [
|
||||||
|
'تو نویسنده منوی رستوران هستی.',
|
||||||
|
'برای نام غذای دادهشده، دقیقاً یک توضیح کوتاه دو خطی به فارسی بنویس.',
|
||||||
|
'فقط همان دو خط را برگردان؛ بدون عنوان، بدون بولت و بدون توضیح اضافه.',
|
||||||
|
'لحن باید جذاب، مختصر و مناسب منوی دیجیتال باشد.',
|
||||||
|
].join('\n'),
|
||||||
|
userPrompt: `نام غذا: ${dto.foodName}`,
|
||||||
|
maxTokens: 20000,
|
||||||
|
temperature: 0.7,
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.persistAiUsage({
|
||||||
|
restaurant,
|
||||||
|
question: `تولید توضیح برای غذا: ${dto.foodName}`,
|
||||||
|
aiResponse,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async generateFoodContent(dto: GenerateFoodContentDto, restId: string): Promise<AiChatResult> {
|
||||||
|
const restaurant = await this.resolveRestaurant(restId);
|
||||||
|
await this.ensureHasCredit(restaurant.id);
|
||||||
|
|
||||||
|
const aiResponse = await this.callAiApi({
|
||||||
|
systemPrompt: [
|
||||||
|
'تو نویسنده منوی رستوران هستی.',
|
||||||
|
'برای نام غذای دادهشده، محتویات و مواد تشکیلدهنده را به فارسی بنویس.',
|
||||||
|
'فقط لیست مواد را با ویرگول فارسی (،) جدا کن؛ بدون عنوان، بدون بولت و بدون توضیح اضافه.',
|
||||||
|
'مواد باید رایج، مختصر و مناسب منوی دیجیتال باشد.',
|
||||||
|
].join('\n'),
|
||||||
|
userPrompt: `نام غذا: ${dto.foodName}`,
|
||||||
|
maxTokens: 20000,
|
||||||
|
temperature: 0.7,
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.persistAiUsage({
|
||||||
|
restaurant,
|
||||||
|
question: `تولید محتویات برای غذا: ${dto.foodName}`,
|
||||||
|
aiResponse,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureHasCredit(restaurantId: string): Promise<void> {
|
||||||
|
const balance = await this.restaurantWalletService.getCurrentBalance(restaurantId);
|
||||||
|
if (balance <= 0) {
|
||||||
|
throw new BadRequestException(AiMessage.INSUFFICIENT_CREDIT);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async persistAiUsage(params: {
|
||||||
|
restaurant: Restaurant;
|
||||||
|
user?: User | null;
|
||||||
|
question: string;
|
||||||
|
aiResponse: AiChatCompletionResponse;
|
||||||
|
answerOverride?: string;
|
||||||
|
foods?: AiChatOfferedFood[];
|
||||||
|
}): Promise<AiChatResult> {
|
||||||
|
const { restaurant, user = null, question, aiResponse, answerOverride, foods = [] } = params;
|
||||||
|
|
||||||
|
const promptTokens = Number(aiResponse.usage?.prompt_tokens ?? 0);
|
||||||
|
const completionTokens = Number(aiResponse.usage?.completion_tokens ?? 0);
|
||||||
|
const consumedTokens = Number(aiResponse.usage?.total_tokens ?? promptTokens + completionTokens);
|
||||||
|
const choice = aiResponse.choices?.[0];
|
||||||
|
const answer = (answerOverride ?? choice?.message?.content)?.trim();
|
||||||
|
|
||||||
|
if (!answer) {
|
||||||
|
this.logger.error(
|
||||||
|
`Empty AI content. finish_reason=${choice?.finish_reason ?? 'unknown'} ` +
|
||||||
|
`reasoning_tokens=${choice?.message?.reasoning_content?.length ?? 0} ` +
|
||||||
|
`usage=${JSON.stringify(aiResponse.usage ?? null)}`,
|
||||||
|
);
|
||||||
|
throw new ServiceUnavailableException(AiMessage.NO_RESPONSE_FROM_AI_MODEL);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prices in .env are per 1,000,000 tokens
|
||||||
|
const promptTokenPricePerMillion = Number(this.configService.getOrThrow<string>('AI_PROMPT_TOKEN_PRICE_PER_MILLION'));
|
||||||
|
const completionTokenPricePerMillion = Number(
|
||||||
|
this.configService.getOrThrow<string>('AI_COMPLETION_TOKEN_PRICE_PER_MILLION'),
|
||||||
|
);
|
||||||
|
const cost = Math.ceil(
|
||||||
|
(promptTokens * promptTokenPricePerMillion + completionTokens * completionTokenPricePerMillion) /
|
||||||
|
1_000_000,
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.em.transactional(async (em) => {
|
||||||
|
const aiChat = em.create(AiChat, {
|
||||||
|
restaurant,
|
||||||
|
user,
|
||||||
|
question,
|
||||||
|
consumedTokens,
|
||||||
|
cost,
|
||||||
|
});
|
||||||
|
await em.persistAndFlush(aiChat);
|
||||||
|
|
||||||
|
if (cost > 0) {
|
||||||
|
await this.restaurantWalletService.createTransaction({
|
||||||
|
restaurant,
|
||||||
|
amount: cost,
|
||||||
|
type: RestaurantCreditTransactionType.DEBIT,
|
||||||
|
reason: RestaurantCreditTransactionReason.AI_CHAT,
|
||||||
|
insufficientBalanceMessage: AiMessage.INSUFFICIENT_CREDIT,
|
||||||
|
em,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
answer,
|
||||||
|
foods,
|
||||||
|
consumedTokens,
|
||||||
|
cost,
|
||||||
|
promptTokens,
|
||||||
|
completionTokens,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveRestaurant(restId?: string, slug?: string): Promise<Restaurant> {
|
||||||
|
if (!restId && !slug) {
|
||||||
|
throw new BadRequestException('شناسه یا اسلاگ رستوران الزامی است');
|
||||||
|
}
|
||||||
|
|
||||||
|
const restaurant = await this.em.findOne(Restaurant, {
|
||||||
|
...(restId ? { id: restId } : {}),
|
||||||
|
...(slug ? { slug } : {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!restaurant) {
|
||||||
|
throw new NotFoundException(RestMessage.NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
return restaurant;
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildMenuContext(
|
||||||
|
foods: Array<Pick<Food, 'id' | 'title' >>,
|
||||||
|
): string {
|
||||||
|
if (!foods.length) {
|
||||||
|
return 'منوی رستوران در حال حاضر خالی است.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return foods
|
||||||
|
.map((food, index) => {
|
||||||
|
return [
|
||||||
|
`${index + 1}. id: ${food.id}`,
|
||||||
|
`نام: ${food.title ?? 'بدون نام'}`
|
||||||
|
].join('\n');
|
||||||
|
})
|
||||||
|
.join('\n\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseChatAiContent(aiResponse: AiChatCompletionResponse): {
|
||||||
|
answer: string;
|
||||||
|
foodIds: string[];
|
||||||
|
} {
|
||||||
|
const choice = aiResponse.choices?.[0];
|
||||||
|
const raw = choice?.message?.content?.trim();
|
||||||
|
|
||||||
|
if (!raw) {
|
||||||
|
this.logger.error(
|
||||||
|
`Empty AI content. finish_reason=${choice?.finish_reason ?? 'unknown'} ` +
|
||||||
|
`reasoning_tokens=${choice?.message?.reasoning_content?.length ?? 0} ` +
|
||||||
|
`usage=${JSON.stringify(aiResponse.usage ?? null)}`,
|
||||||
|
);
|
||||||
|
throw new ServiceUnavailableException(AiMessage.NO_RESPONSE_FROM_AI_MODEL);
|
||||||
|
}
|
||||||
|
|
||||||
|
const jsonPayload = this.extractJsonObject(raw);
|
||||||
|
if (!jsonPayload) {
|
||||||
|
return { answer: raw, foodIds: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(jsonPayload) as { answer?: unknown; foodIds?: unknown };
|
||||||
|
const answer = typeof parsed.answer === 'string' ? parsed.answer.trim() : raw;
|
||||||
|
const foodIds = Array.isArray(parsed.foodIds)
|
||||||
|
? parsed.foodIds.filter((id): id is string => typeof id === 'string' && id.length > 0)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return { answer: answer || raw, foodIds };
|
||||||
|
} catch {
|
||||||
|
this.logger.warn(`Failed to parse AI chat JSON response: ${raw.slice(0, 200)}`);
|
||||||
|
return { answer: raw, foodIds: [] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractJsonObject(raw: string): string | null {
|
||||||
|
const fenced = raw.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
||||||
|
if (fenced?.[1]) {
|
||||||
|
return fenced[1].trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = raw.indexOf('{');
|
||||||
|
const end = raw.lastIndexOf('}');
|
||||||
|
if (start === -1 || end === -1 || end <= start) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return raw.slice(start, end + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private populateOfferedFoods(
|
||||||
|
foodIds: string[],
|
||||||
|
menuFoods: Array<
|
||||||
|
Pick<Food, 'id' | 'title' | 'desc' | 'content' | 'price' | 'images' | 'discount' | 'isSpecialOffer'>
|
||||||
|
>,
|
||||||
|
): AiChatOfferedFood[] {
|
||||||
|
if (!foodIds.length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const foodMap = new Map(menuFoods.map((food) => [food.id, food]));
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
return foodIds.reduce<AiChatOfferedFood[]>((acc, foodId) => {
|
||||||
|
if (seen.has(foodId)) {
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
seen.add(foodId);
|
||||||
|
|
||||||
|
const food = foodMap.get(foodId);
|
||||||
|
if (!food) {
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
|
||||||
|
acc.push({
|
||||||
|
id: food.id,
|
||||||
|
title: food.title,
|
||||||
|
desc: food.desc,
|
||||||
|
content: food.content,
|
||||||
|
price: food.price,
|
||||||
|
images: food.images,
|
||||||
|
discount: food.discount,
|
||||||
|
isSpecialOffer: food.isSpecialOffer,
|
||||||
|
});
|
||||||
|
return acc;
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async callAiApi(params: {
|
||||||
|
systemPrompt: string;
|
||||||
|
userPrompt: string;
|
||||||
|
maxTokens?: number;
|
||||||
|
temperature?: number;
|
||||||
|
}): Promise<AiChatCompletionResponse> {
|
||||||
|
const apiUrl = this.configService.getOrThrow<string>('AI_API_URL');
|
||||||
|
const apiKey = this.configService.getOrThrow<string>('AI_API_KEY');
|
||||||
|
const model = this.configService.getOrThrow<string>('AI_MODEL');
|
||||||
|
const maxTokens = params.maxTokens ?? Number(this.configService.get('AI_MAX_TOKENS') ?? 1000);
|
||||||
|
const temperature = params.temperature ?? Number(this.configService.get('AI_TEMPERATURE') ?? 0.8);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { data } = await firstValueFrom(
|
||||||
|
this.httpService
|
||||||
|
.post<AiChatCompletionResponse>(
|
||||||
|
apiUrl,
|
||||||
|
{
|
||||||
|
model,
|
||||||
|
messages: [
|
||||||
|
{ role: 'system', content: params.systemPrompt },
|
||||||
|
{ role: 'user', content: params.userPrompt },
|
||||||
|
],
|
||||||
|
max_tokens: maxTokens,
|
||||||
|
temperature,
|
||||||
|
// DeepSeek V4 thinking is on by default; CoT can exhaust max_tokens and leave content empty.
|
||||||
|
thinking: { type: 'disabled' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `apikey ${apiKey}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
timeout: 60000,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.pipe(
|
||||||
|
catchError((err: AxiosError) => {
|
||||||
|
this.logger.error(`AI API request failed: ${err.message}`, err.stack);
|
||||||
|
return throwError(() => err);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return data;
|
||||||
|
} catch (error: unknown) {
|
||||||
|
this.logger.error(`AI API error: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||||
|
throw new ServiceUnavailableException(AiMessage.LLM_SERVICE_ERROR);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { EntityManager, EntityRepository } from '@mikro-orm/postgresql';
|
||||||
|
import { FilterQuery } from '@mikro-orm/core';
|
||||||
|
import { AiChat } from '../entities/ai-chat.entity';
|
||||||
|
import { FindAiChatsDto } from '../dto/find-ai-chats.dto';
|
||||||
|
import { PaginatedResult } from 'src/common/interfaces/pagination.interface';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AiChatRepository extends EntityRepository<AiChat> {
|
||||||
|
constructor(readonly em: EntityManager) {
|
||||||
|
super(em, AiChat);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findAllPaginated(restaurantId: string, dto: FindAiChatsDto): Promise<PaginatedResult<AiChat>> {
|
||||||
|
const { page = 1, limit = 10, search, orderBy = 'createdAt', order = 'desc' } = dto;
|
||||||
|
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
const where: FilterQuery<AiChat> = {
|
||||||
|
restaurant: { id: restaurantId },
|
||||||
|
};
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
where.question = { $ilike: `%${search}%` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const [data, total] = await this.findAndCount(where, {
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
orderBy: { [orderBy]: order.toLowerCase() as 'asc' | 'desc' },
|
||||||
|
populate: ['user'],
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
meta: {
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
totalPages: Math.ceil(total / limit),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -48,6 +48,6 @@ import { WalletTransaction } from '../users/entities/wallet-transaction.entity';
|
|||||||
CartCalculationService,
|
CartCalculationService,
|
||||||
CartItemService,
|
CartItemService,
|
||||||
],
|
],
|
||||||
exports: [CartService],
|
exports: [CartService, CartValidationService],
|
||||||
})
|
})
|
||||||
export class CartModule { }
|
export class CartModule { }
|
||||||
|
|||||||
@@ -109,33 +109,51 @@ export class CartValidationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Assert address is inside restaurant service area
|
* Whether the restaurant has a usable GeoJSON polygon service area.
|
||||||
|
* Empty / incomplete polygons are treated as "not set".
|
||||||
|
*/
|
||||||
|
hasConfiguredServiceArea(
|
||||||
|
serviceArea?: { type: 'Polygon'; coordinates: number[][][] } | null,
|
||||||
|
): boolean {
|
||||||
|
if (!serviceArea?.coordinates || !Array.isArray(serviceArea.coordinates)) return false;
|
||||||
|
|
||||||
|
const ring = serviceArea.coordinates[0];
|
||||||
|
if (!Array.isArray(ring) || ring.length < 3) return false;
|
||||||
|
|
||||||
|
return ring.every(
|
||||||
|
coord =>
|
||||||
|
Array.isArray(coord) &&
|
||||||
|
coord.length >= 2 &&
|
||||||
|
Number.isFinite(Number(coord[0])) &&
|
||||||
|
Number.isFinite(Number(coord[1])),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assert address is inside restaurant service area.
|
||||||
|
* Skips entirely when the restaurant has not configured a service area.
|
||||||
*/
|
*/
|
||||||
async assertAddressInsideServiceArea(
|
async assertAddressInsideServiceArea(
|
||||||
restaurantId: string,
|
restaurantId: string,
|
||||||
latitude?: number | null,
|
latitude?: number | null,
|
||||||
longitude?: number | null,
|
longitude?: number | null,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
const restaurant = await this.getRestaurantOrFail(restaurantId);
|
||||||
|
|
||||||
|
// If no service area is defined, assume service is available everywhere
|
||||||
|
if (!this.hasConfiguredServiceArea(restaurant.serviceArea)) return;
|
||||||
|
|
||||||
if (latitude === undefined || latitude === null || longitude === undefined || longitude === null) {
|
if (latitude === undefined || latitude === null || longitude === undefined || longitude === null) {
|
||||||
throw new BadRequestException(CartMessage.ADDRESS_NO_COORDINATES);
|
throw new BadRequestException(CartMessage.ADDRESS_NO_COORDINATES);
|
||||||
}
|
}
|
||||||
|
|
||||||
const restaurant = await this.getRestaurantOrFail(restaurantId);
|
|
||||||
|
|
||||||
const serviceArea = restaurant.serviceArea;
|
|
||||||
// If no service area is defined, assume service is available everywhere
|
|
||||||
if (!serviceArea || !serviceArea.coordinates || !Array.isArray(serviceArea.coordinates)) return;
|
|
||||||
|
|
||||||
// GeoJSON coordinates: [ [ [lng, lat], ... ] ] -> take first ring
|
// GeoJSON coordinates: [ [ [lng, lat], ... ] ] -> take first ring
|
||||||
const rings = serviceArea.coordinates;
|
const ring = restaurant.serviceArea!.coordinates[0];
|
||||||
if (!rings || rings.length === 0 || !Array.isArray(rings[0])) return;
|
|
||||||
|
|
||||||
const ring = rings[0];
|
|
||||||
const point: [number, number] = [Number(longitude), Number(latitude)];
|
const point: [number, number] = [Number(longitude), Number(latitude)];
|
||||||
|
|
||||||
// Ensure polygon has the correct tuple typing ([lng, lat] pairs)
|
// Ensure polygon has the correct tuple typing ([lng, lat] pairs)
|
||||||
const polygon: [number, number][] = ring.map(
|
const polygon: [number, number][] = ring.map(
|
||||||
coord => [Number(coord[0] ?? 0), Number(coord[1] ?? 0)] as [number, number],
|
coord => [Number(coord[0]), Number(coord[1])] as [number, number],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!GeographicUtils.isPointInPolygon(point, polygon)) {
|
if (!GeographicUtils.isPointInPolygon(point, polygon)) {
|
||||||
|
|||||||
@@ -177,6 +177,19 @@ export class NotificationsController {
|
|||||||
return { smsCount };
|
return { smsCount };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@UseGuards(AdminAuthGuard)
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Post('admin/notifications/test-notis')
|
||||||
|
@ApiOperation({ summary: 'Send a test in-app notification (socket) to the current admin' })
|
||||||
|
async createTestNotis(@RestId() restaurantId: string, @AdminId() adminId: string) {
|
||||||
|
const notification = await this.notificationService.sendTestInAppNotification(adminId, restaurantId);
|
||||||
|
return {
|
||||||
|
message: 'Test in-app notification queued',
|
||||||
|
notification,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// super admin endpoints
|
// super admin endpoints
|
||||||
|
|
||||||
@UseGuards(SuperAdminAuthGuard)
|
@UseGuards(SuperAdminAuthGuard)
|
||||||
|
|||||||
@@ -37,10 +37,19 @@ export class NotificationsGateway implements OnGatewayConnection, OnGatewayDisco
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
async handleConnection(client: Socket) {
|
async handleConnection(client: Socket) {
|
||||||
|
this.logger.log(
|
||||||
|
`[WS] connection attempt socketId=${client.id} transport=${client.conn.transport.name} ` +
|
||||||
|
`hasAuthToken=${Boolean(client.handshake.auth?.token || client.handshake.auth?.authorization)} ` +
|
||||||
|
`hasQueryToken=${Boolean(client.handshake.query?.token || client.handshake.query?.authorization)} ` +
|
||||||
|
`hasHeaderAuth=${Boolean(client.handshake.headers.authorization)}`,
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.authenticateConnection(client);
|
await this.authenticateConnection(client);
|
||||||
const authenticatedClient = client as AuthenticatedSocket;
|
const authenticatedClient = client as AuthenticatedSocket;
|
||||||
this.logger.log(`Admin connected: ${authenticatedClient.adminId}`);
|
this.logger.log(
|
||||||
|
`[WS] connected socketId=${client.id} adminId=${authenticatedClient.adminId} restId=${authenticatedClient.restId}`,
|
||||||
|
);
|
||||||
this.handleJoinRoom(authenticatedClient);
|
this.handleJoinRoom(authenticatedClient);
|
||||||
|
|
||||||
// Start ping interval for this client
|
// Start ping interval for this client
|
||||||
@@ -51,7 +60,7 @@ export class NotificationsGateway implements OnGatewayConnection, OnGatewayDisco
|
|||||||
// this.pingIntervals.set(client.id, intervalId);
|
// this.pingIntervals.set(client.id, intervalId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`Connection authentication failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
`[WS] connection auth failed socketId=${client.id}: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||||
);
|
);
|
||||||
void client.emit('error', { message: 'Authentication failed' });
|
void client.emit('error', { message: 'Authentication failed' });
|
||||||
client.disconnect();
|
client.disconnect();
|
||||||
@@ -59,8 +68,11 @@ export class NotificationsGateway implements OnGatewayConnection, OnGatewayDisco
|
|||||||
}
|
}
|
||||||
|
|
||||||
handleDisconnect(client: Socket) {
|
handleDisconnect(client: Socket) {
|
||||||
this.logger.log(`Client disconnected: ${client.id}`);
|
const authenticatedClient = client as AuthenticatedSocket;
|
||||||
this.handleLeaveRoom(client);
|
this.logger.log(
|
||||||
|
`[WS] disconnect socketId=${client.id} adminId=${authenticatedClient.adminId ?? 'n/a'} restId=${authenticatedClient.restId ?? 'n/a'}`,
|
||||||
|
);
|
||||||
|
this.handleLeaveRoom(authenticatedClient);
|
||||||
|
|
||||||
// Clear ping interval for this client
|
// Clear ping interval for this client
|
||||||
// const intervalId = this.pingIntervals.get(client.id);
|
// const intervalId = this.pingIntervals.get(client.id);
|
||||||
@@ -72,13 +84,41 @@ export class NotificationsGateway implements OnGatewayConnection, OnGatewayDisco
|
|||||||
|
|
||||||
sendInAppNotification(repipient: { adminId: string; restaurantId: string }, payload: IInAppNotificationPayload) {
|
sendInAppNotification(repipient: { adminId: string; restaurantId: string }, payload: IInAppNotificationPayload) {
|
||||||
const room = this.getRoom(repipient.adminId, repipient.restaurantId);
|
const room = this.getRoom(repipient.adminId, repipient.restaurantId);
|
||||||
|
const adapter = this.server.sockets?.adapter ?? this.server.adapter;
|
||||||
|
const socketsMap = this.server.sockets?.sockets ?? this.server.sockets;
|
||||||
|
const socketsInRoom = adapter?.rooms?.get(room);
|
||||||
|
const roomSize = socketsInRoom?.size ?? 0;
|
||||||
|
const connectedSocketIds =
|
||||||
|
socketsMap instanceof Map ? [...socketsMap.keys()] : [...(Object.keys(socketsMap ?? {}) as string[])];
|
||||||
|
const allRooms = adapter?.rooms ? [...adapter.rooms.keys()] : [];
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`[WS] emit start room=${room} adminId=${repipient.adminId} restaurantId=${repipient.restaurantId} ` +
|
||||||
|
`notificationId=${payload.notificationId} subject=${payload.subject} roomSize=${roomSize} ` +
|
||||||
|
`namespaceConnected=${connectedSocketIds.length}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (roomSize === 0) {
|
||||||
|
this.logger.warn(
|
||||||
|
`[WS] emit to EMPTY room=${room} — no client joined this room. ` +
|
||||||
|
`connectedSockets=[${connectedSocketIds.join(', ') || 'none'}] ` +
|
||||||
|
`allRooms=[${allRooms.join(', ') || 'none'}]`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
this.logger.log(`[WS] room members socketIds=[${[...(socketsInRoom ?? [])].join(', ')}]`);
|
||||||
|
}
|
||||||
|
|
||||||
this.logger.log(`Sending in app notification to admin: ${repipient.adminId}`);
|
|
||||||
this.server.to(room).emit('notifications', payload, async () => {
|
this.server.to(room).emit('notifications', payload, async () => {
|
||||||
|
this.logger.log(
|
||||||
|
`[WS] ACK received room=${room} notificationId=${payload.notificationId} (client acknowledged)`,
|
||||||
|
);
|
||||||
// 3. ACK (delivered)
|
// 3. ACK (delivered)
|
||||||
// await this.notificationService.markAsDelivered(payload.notificationId);
|
// await this.notificationService.markAsDelivered(payload.notificationId);
|
||||||
});
|
});
|
||||||
this.logger.log(`In app notification sent to admin: ${repipient.adminId}`);
|
|
||||||
|
this.logger.log(
|
||||||
|
`[WS] emit queued event=notifications room=${room} roomSize=${roomSize} notificationId=${payload.notificationId}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private getRoom(adminId: string, restaurantId: string) {
|
private getRoom(adminId: string, restaurantId: string) {
|
||||||
@@ -86,16 +126,30 @@ export class NotificationsGateway implements OnGatewayConnection, OnGatewayDisco
|
|||||||
}
|
}
|
||||||
|
|
||||||
handleLeaveRoom(client: AuthenticatedSocket) {
|
handleLeaveRoom(client: AuthenticatedSocket) {
|
||||||
const room = this.getRoom(client.adminId!, client.restId!);
|
if (!client.adminId || !client.restId) {
|
||||||
|
this.logger.warn(`[WS] leave skipped socketId=${client.id} — missing adminId/restId`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const room = this.getRoom(client.adminId, client.restId);
|
||||||
void client.leave(room);
|
void client.leave(room);
|
||||||
this.logger.log(`Admin ${client.adminId} (Client ${client.id}) left room: ${room}`);
|
this.logger.log(`[WS] left room=${room} adminId=${client.adminId} socketId=${client.id}`);
|
||||||
void client.emit('left', { room, message: 'Successfully Admin left room' });
|
void client.emit('left', { room, message: 'Successfully Admin left room' });
|
||||||
}
|
}
|
||||||
|
|
||||||
handleJoinRoom(client: AuthenticatedSocket) {
|
handleJoinRoom(client: AuthenticatedSocket) {
|
||||||
const room = this.getRoom(client.adminId!, client.restId!);
|
if (!client.adminId || !client.restId) {
|
||||||
|
this.logger.warn(`[WS] join skipped socketId=${client.id} — missing adminId/restId`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const room = this.getRoom(client.adminId, client.restId);
|
||||||
void client.join(room);
|
void client.join(room);
|
||||||
this.logger.log(`Admin ${client.adminId} (Client ${client.id}) joined room: ${room}`);
|
const adapter = this.server.sockets?.adapter ?? this.server.adapter;
|
||||||
|
const roomSize = adapter?.rooms?.get(room)?.size ?? 0;
|
||||||
|
this.logger.log(
|
||||||
|
`[WS] joined room=${room} adminId=${client.adminId} restId=${client.restId} socketId=${client.id} roomSize=${roomSize}`,
|
||||||
|
);
|
||||||
void client.emit('joined', { room, message: 'Successfully Admin joined room' });
|
void client.emit('joined', { room, message: 'Successfully Admin joined room' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,7 @@ import { Processor, WorkerHost, OnWorkerEvent } from '@nestjs/bullmq';
|
|||||||
import { Logger } from '@nestjs/common';
|
import { Logger } from '@nestjs/common';
|
||||||
import { Job } from 'bullmq';
|
import { Job } from 'bullmq';
|
||||||
import { CreateRequestContext } from '@mikro-orm/core';
|
import { CreateRequestContext } from '@mikro-orm/core';
|
||||||
import { InjectRepository } from '@mikro-orm/nestjs';
|
|
||||||
import { EntityManager } from '@mikro-orm/postgresql';
|
import { EntityManager } from '@mikro-orm/postgresql';
|
||||||
import { Notification } from '../entities/notification.entity';
|
|
||||||
import { InAppNotificationQueueJob } from '../interfaces/jobs-queue.interface';
|
import { InAppNotificationQueueJob } from '../interfaces/jobs-queue.interface';
|
||||||
import { NotificationQueueNameEnum } from '../constants/queue';
|
import { NotificationQueueNameEnum } from '../constants/queue';
|
||||||
import { NotificationsGateway } from '../notifications.gateway';
|
import { NotificationsGateway } from '../notifications.gateway';
|
||||||
@@ -14,7 +12,6 @@ export class InAppProcessor extends WorkerHost {
|
|||||||
private readonly logger = new Logger(InAppProcessor.name);
|
private readonly logger = new Logger(InAppProcessor.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(Notification)
|
|
||||||
private readonly em: EntityManager,
|
private readonly em: EntityManager,
|
||||||
private readonly notificationsGateway: NotificationsGateway,
|
private readonly notificationsGateway: NotificationsGateway,
|
||||||
) {
|
) {
|
||||||
@@ -25,10 +22,18 @@ export class InAppProcessor extends WorkerHost {
|
|||||||
async process(job: Job<InAppNotificationQueueJob>): Promise<void> {
|
async process(job: Job<InAppNotificationQueueJob>): Promise<void> {
|
||||||
const { recipient, subject, body, notificationId } = job.data;
|
const { recipient, subject, body, notificationId } = job.data;
|
||||||
|
|
||||||
this.logger.log(`Processing InApp notification - Recipient: ${JSON.stringify(recipient)}, subject: ${subject}`);
|
this.logger.log(
|
||||||
|
`[InAppProcessor] processing jobId=${job.id} attempt=${job.attemptsMade + 1} ` +
|
||||||
|
`recipient=${JSON.stringify(recipient)} subject=${subject} notificationId=${notificationId}`,
|
||||||
|
);
|
||||||
|
|
||||||
if (!recipient.adminId) {
|
if (!recipient?.adminId) {
|
||||||
this.logger.warn(`Skipping in-app notification job ${job.id}: missing adminId`);
|
this.logger.warn(`[InAppProcessor] skipping job ${job.id}: missing adminId`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!recipient.restaurantId) {
|
||||||
|
this.logger.warn(`[InAppProcessor] skipping job ${job.id}: missing restaurantId`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,20 +43,30 @@ export class InAppProcessor extends WorkerHost {
|
|||||||
{ subject, body, notificationId },
|
{ subject, body, notificationId },
|
||||||
);
|
);
|
||||||
|
|
||||||
this.logger.log(`InApp notification sent successfully to ${recipient.adminId}`);
|
this.logger.log(
|
||||||
|
`[InAppProcessor] gateway emit done for admin=${recipient.adminId} restaurant=${recipient.restaurantId}`,
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(`Error processing InApp notification job ${job.id}:`, error);
|
this.logger.error(`[InAppProcessor] error processing job ${job.id}:`, error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OnWorkerEvent('active')
|
||||||
|
onActive(job: Job) {
|
||||||
|
this.logger.log(`[InAppProcessor] job ${job.id} became active`);
|
||||||
|
}
|
||||||
|
|
||||||
@OnWorkerEvent('completed')
|
@OnWorkerEvent('completed')
|
||||||
onCompleted(job: Job) {
|
onCompleted(job: Job) {
|
||||||
this.logger.log(`Notification job ${job.id} completed`);
|
this.logger.log(`[InAppProcessor] job ${job.id} completed`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@OnWorkerEvent('failed')
|
@OnWorkerEvent('failed')
|
||||||
onFailed(job: Job, error: Error) {
|
onFailed(job: Job, error: Error) {
|
||||||
this.logger.error(`Notification job ${job.id} failed:`, error);
|
this.logger.error(
|
||||||
|
`[InAppProcessor] job ${job.id} failed after ${job.attemptsMade} attempt(s): ${error.message}`,
|
||||||
|
error.stack,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -119,6 +119,12 @@ export class NotificationQueueService {
|
|||||||
}
|
}
|
||||||
async addBulkInAppNotifications(jobs: InAppNotificationQueueJob[]): Promise<void> {
|
async addBulkInAppNotifications(jobs: InAppNotificationQueueJob[]): Promise<void> {
|
||||||
try {
|
try {
|
||||||
|
this.logger.log(
|
||||||
|
`[addBulkInAppNotifications] queueing ${jobs.length} job(s): ${jobs
|
||||||
|
.map(j => `admin=${j.recipient.adminId} rest=${j.recipient.restaurantId} notif=${j.notificationId}`)
|
||||||
|
.join(' | ')}`,
|
||||||
|
);
|
||||||
|
|
||||||
const queueJobs = jobs.map(job => ({
|
const queueJobs = jobs.map(job => ({
|
||||||
name: 'in-app-notification',
|
name: 'in-app-notification',
|
||||||
data: job,
|
data: job,
|
||||||
@@ -132,10 +138,12 @@ export class NotificationQueueService {
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
await this.inAppQueue.addBulk(queueJobs);
|
const added = await this.inAppQueue.addBulk(queueJobs);
|
||||||
this.logger.log(`Added ${jobs.length} InApp notification jobs to queue`);
|
this.logger.log(
|
||||||
|
`[addBulkInAppNotifications] added ${added.length} job(s) to queue "${NotificationQueueNameEnum.IN_APP}": ids=${added.map(j => j.id).join(', ')}`,
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(`Failed to add bulk notifications to queue:`, error);
|
this.logger.error(`[addBulkInAppNotifications] failed to add jobs to queue:`, error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,11 @@ export class NotificationService {
|
|||||||
async sendNotification(params: NotifRequest): Promise<Notification[]> {
|
async sendNotification(params: NotifRequest): Promise<Notification[]> {
|
||||||
const { recipients, message, metadata, restaurantId } = params;
|
const { recipients, message, metadata, restaurantId } = params;
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`[sendNotification] start restaurant=${restaurantId} title=${message.title} recipients=${recipients.length} ` +
|
||||||
|
`admins=${recipients.filter(r => 'adminId' in r).length} users=${recipients.filter(r => 'userId' in r).length}`,
|
||||||
|
);
|
||||||
|
|
||||||
// create Database notifications
|
// create Database notifications
|
||||||
const notifications = await this.createAdminBulkNotifications(
|
const notifications = await this.createAdminBulkNotifications(
|
||||||
recipients.map(recipient => ({
|
recipients.map(recipient => ({
|
||||||
@@ -35,18 +40,35 @@ export class NotificationService {
|
|||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
this.logger.log(`[sendNotification] created ${notifications.length} DB notification(s)`);
|
||||||
|
|
||||||
// get admin prefrences
|
// get admin prefrences
|
||||||
const preference = await this.preferenceService.findByRestaurantAndType(restaurantId, message.title);
|
const preference = await this.preferenceService.findByRestaurantAndType(restaurantId, message.title);
|
||||||
|
|
||||||
if (preference?.channels?.length === 0) {
|
this.logger.log(
|
||||||
|
`[sendNotification] preference=${preference ? `id=${preference.id} channels=${JSON.stringify(preference.channels)}` : 'NOT FOUND'}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!preference) {
|
||||||
|
this.logger.warn(
|
||||||
|
`[sendNotification] skipping channels — no preference for restaurant ${restaurantId}, title ${message.title}`,
|
||||||
|
);
|
||||||
|
return notifications;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preference.channels?.length === 0) {
|
||||||
this.logger.warn(`Notification type is NONE for restaurant ${restaurantId}, title ${message.title}`);
|
this.logger.warn(`Notification type is NONE for restaurant ${restaurantId}, title ${message.title}`);
|
||||||
return notifications;
|
return notifications;
|
||||||
}
|
}
|
||||||
|
|
||||||
// send in app notification (admins only — user records must not emit to admin socket rooms)
|
// send in app notification (admins only — user records must not emit to admin socket rooms)
|
||||||
if (preference?.channels?.includes(NotifChannelEnum.IN_APP)) {
|
if (preference.channels?.includes(NotifChannelEnum.IN_APP)) {
|
||||||
|
this.logger.log(`[sendNotification] IN_APP channel enabled, building jobs`);
|
||||||
const inAppJobs = recipients.flatMap((recipient, index) => {
|
const inAppJobs = recipients.flatMap((recipient, index) => {
|
||||||
if (!('adminId' in recipient) || !recipient.adminId) {
|
if (!('adminId' in recipient) || !recipient.adminId) {
|
||||||
|
this.logger.debug(
|
||||||
|
`[sendNotification] skipping recipient index=${index} (not admin): ${JSON.stringify(recipient)}`,
|
||||||
|
);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,16 +83,23 @@ export class NotificationService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (inAppJobs.length > 0) {
|
if (inAppJobs.length > 0) {
|
||||||
|
this.logger.log(
|
||||||
|
`[sendNotification] queueing ${inAppJobs.length} in-app job(s): ${inAppJobs.map(j => j.recipient.adminId).join(', ')}`,
|
||||||
|
);
|
||||||
await this.queueService.addBulkInAppNotifications(inAppJobs);
|
await this.queueService.addBulkInAppNotifications(inAppJobs);
|
||||||
} else {
|
} else {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`No admin recipients for in-app notification: restaurant ${restaurantId}, title ${message.title}`,
|
`No admin recipients for in-app notification: restaurant ${restaurantId}, title ${message.title}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
this.logger.warn(
|
||||||
|
`[sendNotification] IN_APP channel NOT enabled for restaurant ${restaurantId}, title ${message.title}, channels=${JSON.stringify(preference.channels)}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// add sms notifications to queue
|
// add sms notifications to queue
|
||||||
if (preference?.channels?.includes(NotifChannelEnum.SMS)) {
|
if (preference.channels?.includes(NotifChannelEnum.SMS)) {
|
||||||
await this.queueService.addBulkSmsNotifications(
|
await this.queueService.addBulkSmsNotifications(
|
||||||
recipients.map(recipient => ({
|
recipients.map(recipient => ({
|
||||||
recipient,
|
recipient,
|
||||||
@@ -293,4 +322,40 @@ export class NotificationService {
|
|||||||
async getSmsCountByRestaurantId(restaurantId: string): Promise<number> {
|
async getSmsCountByRestaurantId(restaurantId: string): Promise<number> {
|
||||||
return this.smsLogRepository.getSmsCountByRestaurantId(restaurantId);
|
return this.smsLogRepository.getSmsCountByRestaurantId(restaurantId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async sendTestInAppNotification(adminId: string, restaurantId: string): Promise<Notification> {
|
||||||
|
const title = NotifTitleEnum.ORDER_CREATED;
|
||||||
|
const content = 'Test in-app notification';
|
||||||
|
|
||||||
|
this.logger.log(`[sendTestInAppNotification] start adminId=${adminId} restaurantId=${restaurantId}`);
|
||||||
|
|
||||||
|
const [notification] = await this.createAdminBulkNotifications([
|
||||||
|
{
|
||||||
|
restaurantId,
|
||||||
|
title,
|
||||||
|
content,
|
||||||
|
adminId,
|
||||||
|
userId: null,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`[sendTestInAppNotification] created notification id=${notification.id}, queueing in-app job`,
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.queueService.addBulkInAppNotifications([
|
||||||
|
{
|
||||||
|
recipient: { adminId, restaurantId },
|
||||||
|
subject: title,
|
||||||
|
body: content,
|
||||||
|
notificationId: notification.id,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`[sendTestInAppNotification] queued successfully admin=${adminId} restaurant=${restaurantId} notificationId=${notification.id}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return notification;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Controller, Get, Post, Param, UseGuards, Patch, Query, Body } from '@nestjs/common';
|
import { Controller, Get, Post, Param, UseGuards, Patch, Query, Body, Delete } from '@nestjs/common';
|
||||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiHeader, ApiBody } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiHeader, ApiBody } from '@nestjs/swagger';
|
||||||
import { OrdersService } from '../providers/orders.service';
|
import { OrdersService } from '../providers/orders.service';
|
||||||
import { AuthGuard } from '../../auth/guards/auth.guard';
|
import { AuthGuard } from '../../auth/guards/auth.guard';
|
||||||
@@ -13,8 +13,13 @@ import { Permissions } from 'src/common/decorators/permissions.decorator';
|
|||||||
import { Permission } from 'src/common/enums/permission.enum';
|
import { Permission } from 'src/common/enums/permission.enum';
|
||||||
import { AdminCreateOrderDto } from '../dto/admin-create-order.dto';
|
import { AdminCreateOrderDto } from '../dto/admin-create-order.dto';
|
||||||
import { AdminUpdateOrderFeesDto } from '../dto/admin-update-order-fees.dto';
|
import { AdminUpdateOrderFeesDto } from '../dto/admin-update-order-fees.dto';
|
||||||
|
import { AdminAddOrderItemDto } from '../dto/admin-add-order-item.dto';
|
||||||
|
import { AdminUpdateOrderItemDto } from '../dto/admin-update-order-item.dto';
|
||||||
import { FoodOrderReportDto } from '../dto/food-order-report.dto';
|
import { FoodOrderReportDto } from '../dto/food-order-report.dto';
|
||||||
import { DailyOrderReportDto } from '../dto/daily-order-report.dto';
|
import { DailyOrderReportDto } from '../dto/daily-order-report.dto';
|
||||||
|
import { AdminRefundOrderDto } from '../dto/admin-refund-order.dto';
|
||||||
|
import { AdminAddPaymentDto } from '../dto/admin-add-payment.dto';
|
||||||
|
import { OrderMessage } from 'src/common/enums/message.enum';
|
||||||
|
|
||||||
@ApiTags('orders')
|
@ApiTags('orders')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@@ -94,6 +99,102 @@ export class OrdersController {
|
|||||||
return this.ordersService.findOne(orderId, restId);
|
return this.ordersService.findOne(orderId, restId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@UseGuards(AdminAuthGuard)
|
||||||
|
@Permissions(Permission.MANAGE_ORDERS)
|
||||||
|
@Patch('admin/orders/:orderId/fees')
|
||||||
|
@ApiOperation({ summary: 'Update order delivery fee, packing fee, and preparation time' })
|
||||||
|
@ApiParam({ name: 'orderId', description: 'Order ID' })
|
||||||
|
@ApiBody({ type: AdminUpdateOrderFeesDto })
|
||||||
|
updateOrderFees(
|
||||||
|
@Param('orderId') orderId: string,
|
||||||
|
@Body() dto: AdminUpdateOrderFeesDto,
|
||||||
|
@RestId() restId: string,
|
||||||
|
) {
|
||||||
|
return this.ordersService.updateOrderFees(orderId, restId, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@UseGuards(AdminAuthGuard)
|
||||||
|
@Permissions(Permission.MANAGE_ORDERS)
|
||||||
|
@Post('admin/orders/:orderId/items')
|
||||||
|
@ApiOperation({ summary: 'Add an item to an existing order' })
|
||||||
|
@ApiParam({ name: 'orderId', description: 'Order ID' })
|
||||||
|
@ApiBody({ type: AdminAddOrderItemDto })
|
||||||
|
addOrderItem(
|
||||||
|
@Param('orderId') orderId: string,
|
||||||
|
@Body() dto: AdminAddOrderItemDto,
|
||||||
|
@RestId() restId: string,
|
||||||
|
) {
|
||||||
|
return this.ordersService.addOrderItem(orderId, restId, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@UseGuards(AdminAuthGuard)
|
||||||
|
@Permissions(Permission.MANAGE_ORDERS)
|
||||||
|
@Patch('admin/orders/:orderId/items/:itemId')
|
||||||
|
@ApiOperation({ summary: 'Update quantity of an order item' })
|
||||||
|
@ApiParam({ name: 'orderId', description: 'Order ID' })
|
||||||
|
@ApiParam({ name: 'itemId', description: 'Order item ID' })
|
||||||
|
@ApiBody({ type: AdminUpdateOrderItemDto })
|
||||||
|
updateOrderItem(
|
||||||
|
@Param('orderId') orderId: string,
|
||||||
|
@Param('itemId') itemId: string,
|
||||||
|
@Body() dto: AdminUpdateOrderItemDto,
|
||||||
|
@RestId() restId: string,
|
||||||
|
) {
|
||||||
|
return this.ordersService.updateOrderItemQuantity(orderId, restId, itemId, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@UseGuards(AdminAuthGuard)
|
||||||
|
@Permissions(Permission.MANAGE_ORDERS)
|
||||||
|
@Delete('admin/orders/:orderId/items/:itemId')
|
||||||
|
@ApiOperation({ summary: 'Remove an item from an order' })
|
||||||
|
@ApiParam({ name: 'orderId', description: 'Order ID' })
|
||||||
|
@ApiParam({ name: 'itemId', description: 'Order item ID' })
|
||||||
|
removeOrderItem(
|
||||||
|
@Param('orderId') orderId: string,
|
||||||
|
@Param('itemId') itemId: string,
|
||||||
|
@RestId() restId: string,
|
||||||
|
) {
|
||||||
|
return this.ordersService.removeOrderItem(orderId, restId, itemId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@UseGuards(AdminAuthGuard)
|
||||||
|
@Permissions(Permission.MANAGE_ORDERS)
|
||||||
|
@Post('admin/orders/:orderId/payments')
|
||||||
|
@ApiOperation({ summary: 'Add a cash payment to an existing order' })
|
||||||
|
@ApiParam({ name: 'orderId', description: 'Order ID' })
|
||||||
|
@ApiBody({ type: AdminAddPaymentDto })
|
||||||
|
addPayment(
|
||||||
|
@Param('orderId') orderId: string,
|
||||||
|
@Body() dto: AdminAddPaymentDto,
|
||||||
|
@RestId() restId: string,
|
||||||
|
) {
|
||||||
|
return this.ordersService.addPayment(orderId, restId, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@UseGuards(AdminAuthGuard)
|
||||||
|
@Permissions(Permission.MANAGE_ORDERS)
|
||||||
|
@Post('admin/orders/:orderId/refund')
|
||||||
|
@ApiOperation({ summary: 'Refund order payment(s) by amount' })
|
||||||
|
@ApiParam({ name: 'orderId', description: 'Order ID' })
|
||||||
|
@ApiBody({ type: AdminRefundOrderDto })
|
||||||
|
refundOrder(
|
||||||
|
@Param('orderId') orderId: string,
|
||||||
|
@Body() dto: AdminRefundOrderDto,
|
||||||
|
@RestId() restId: string,
|
||||||
|
) {
|
||||||
|
return this.ordersService.refundOrder(orderId, restId, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@UseGuards(AdminAuthGuard)
|
||||||
|
@Permissions(Permission.MANAGE_ORDERS)
|
||||||
|
@Post('admin/orders/:orderId/please-come')
|
||||||
|
@ApiOperation({ summary: 'Send please-come SMS to the order user' })
|
||||||
|
@ApiParam({ name: 'orderId', description: 'Order ID' })
|
||||||
|
async sendPleaseComeSms(@Param('orderId') orderId: string, @RestId() restId: string) {
|
||||||
|
await this.ordersService.sendPleaseComeSms(orderId, restId);
|
||||||
|
return { message: OrderMessage.PLEASE_COME_SMS_QUEUED };
|
||||||
|
}
|
||||||
|
|
||||||
@UseGuards(AdminAuthGuard)
|
@UseGuards(AdminAuthGuard)
|
||||||
@Permissions(Permission.MANAGE_ORDERS)
|
@Permissions(Permission.MANAGE_ORDERS)
|
||||||
@Patch('admin/orders/:orderId/:status')
|
@Patch('admin/orders/:orderId/:status')
|
||||||
@@ -114,20 +215,6 @@ export class OrdersController {
|
|||||||
return this.ordersService.changeOrderStatus(orderId, restId, status, 'admin', dto?.desc);
|
return this.ordersService.changeOrderStatus(orderId, restId, status, 'admin', dto?.desc);
|
||||||
}
|
}
|
||||||
|
|
||||||
@UseGuards(AdminAuthGuard)
|
|
||||||
@Permissions(Permission.MANAGE_ORDERS)
|
|
||||||
@Patch('admin/orders/:orderId/fees')
|
|
||||||
@ApiOperation({ summary: 'Update order delivery fee, packing fee, and preparation time' })
|
|
||||||
@ApiParam({ name: 'orderId', description: 'Order ID' })
|
|
||||||
@ApiBody({ type: AdminUpdateOrderFeesDto })
|
|
||||||
updateOrderFees(
|
|
||||||
@Param('orderId') orderId: string,
|
|
||||||
@Body() dto: AdminUpdateOrderFeesDto,
|
|
||||||
@RestId() restId: string,
|
|
||||||
) {
|
|
||||||
return this.ordersService.updateOrderFees(orderId, restId, dto);
|
|
||||||
}
|
|
||||||
|
|
||||||
@UseGuards(AdminAuthGuard)
|
@UseGuards(AdminAuthGuard)
|
||||||
@Permissions(Permission.VIEW_REPORTS)
|
@Permissions(Permission.VIEW_REPORTS)
|
||||||
@ApiOperation({ summary: 'Get Stats for report page' })
|
@ApiOperation({ summary: 'Get Stats for report page' })
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { IsNotEmpty, IsNumber, IsString, Min } from 'class-validator';
|
||||||
|
|
||||||
|
export class AdminAddOrderItemDto {
|
||||||
|
@ApiProperty({ description: 'Food ID' })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
foodId: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Quantity', minimum: 1, default: 1 })
|
||||||
|
@IsNumber()
|
||||||
|
@Min(1)
|
||||||
|
quantity: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import { IsNumber, IsOptional, IsString, Min } from 'class-validator';
|
||||||
|
|
||||||
|
export class AdminAddPaymentDto {
|
||||||
|
@ApiProperty({ description: 'Payment amount', minimum: 1 })
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber()
|
||||||
|
@Min(1)
|
||||||
|
amount!: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Payment description' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import { IsNumber, IsOptional, IsString, Min } from 'class-validator';
|
||||||
|
|
||||||
|
export class AdminRefundOrderDto {
|
||||||
|
@ApiProperty({ description: 'Refund amount', minimum: 1 })
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber()
|
||||||
|
@Min(1)
|
||||||
|
amount!: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Refund description / reason' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { IsNumber, Min } from 'class-validator';
|
||||||
|
|
||||||
|
export class AdminUpdateOrderItemDto {
|
||||||
|
@ApiProperty({ description: 'New quantity', minimum: 1 })
|
||||||
|
@IsNumber()
|
||||||
|
@Min(1)
|
||||||
|
quantity: number;
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ export class OrderItem extends BaseEntity {
|
|||||||
@Property({ type: 'decimal', precision: 10, scale: 0, default: 0 })
|
@Property({ type: 'decimal', precision: 10, scale: 0, default: 0 })
|
||||||
discount: number = 0;
|
discount: number = 0;
|
||||||
|
|
||||||
@Property({ type: 'decimal', precision: 10, scale: 0 })
|
/** (unitPrice - discount) * quantity. Maintained by DB triggers — do not set in app code. */
|
||||||
totalPrice!: number;
|
@Property({ type: 'decimal', precision: 10, scale: 0, default: 0 })
|
||||||
|
totalPrice: number = 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,14 +70,17 @@ export class Order extends BaseEntity {
|
|||||||
@Property({ type: 'jsonb', nullable: true })
|
@Property({ type: 'jsonb', nullable: true })
|
||||||
couponDetail?: OrderCouponDetail | null;
|
couponDetail?: OrderCouponDetail | null;
|
||||||
|
|
||||||
|
/** Sum of item discounts. Maintained by DB triggers — do not set in app code. */
|
||||||
@Property({ type: 'decimal', precision: 10, scale: 0, default: 0 })
|
@Property({ type: 'decimal', precision: 10, scale: 0, default: 0 })
|
||||||
itemsDiscount: number = 0;
|
itemsDiscount: number = 0;
|
||||||
|
|
||||||
|
/** couponDiscount + itemsDiscount. Maintained by DB triggers — do not set in app code. */
|
||||||
@Property({ type: 'decimal', precision: 10, scale: 0, default: 0 })
|
@Property({ type: 'decimal', precision: 10, scale: 0, default: 0 })
|
||||||
totalDiscount: number = 0;
|
totalDiscount: number = 0;
|
||||||
|
|
||||||
@Property({ type: 'decimal', precision: 10, scale: 0 })
|
/** Sum of unitPrice * quantity. Maintained by DB triggers — do not set in app code. */
|
||||||
subTotal!: number;
|
@Property({ type: 'decimal', precision: 10, scale: 0, default: 0 })
|
||||||
|
subTotal: number = 0;
|
||||||
|
|
||||||
@Property({ type: 'decimal', precision: 10, scale: 0, default: 0 })
|
@Property({ type: 'decimal', precision: 10, scale: 0, default: 0 })
|
||||||
tax: number = 0;
|
tax: number = 0;
|
||||||
@@ -91,9 +94,23 @@ export class Order extends BaseEntity {
|
|||||||
@Property({ type: 'int', nullable: true })
|
@Property({ type: 'int', nullable: true })
|
||||||
prepareTime?: number | null;
|
prepareTime?: number | null;
|
||||||
|
|
||||||
@Property({ type: 'decimal', precision: 10, scale: 0 })
|
/** GREATEST(0, subTotal - totalDiscount) + tax + deliveryFee + packingFee. Maintained by DB triggers. */
|
||||||
total!: number;
|
@Property({ type: 'decimal', precision: 10, scale: 0, default: 0 })
|
||||||
|
total: number = 0;
|
||||||
|
|
||||||
|
/** Sum of payments with status `paid`. Maintained by DB triggers — do not set in app code. */
|
||||||
|
@Property({ type: 'decimal', precision: 10, scale: 0, default: 0 })
|
||||||
|
paidAmount: number = 0;
|
||||||
|
|
||||||
|
/** Remaining amount due (`total - paidAmount`). Maintained by DB triggers — do not set in app code. */
|
||||||
|
@Property({ type: 'decimal', precision: 10, scale: 0, default: 0 })
|
||||||
|
balance: number = 0;
|
||||||
|
|
||||||
|
/** Sum of payments with status `refunded`. Maintained by DB triggers — do not set in app code. */
|
||||||
|
@Property({ type: 'decimal', precision: 10, scale: 0, default: 0 })
|
||||||
|
refundedAmount: number = 0;
|
||||||
|
|
||||||
|
/** Sum of item quantities. Maintained by DB triggers — do not set in app code. */
|
||||||
@Property({ type: 'int', default: 0 })
|
@Property({ type: 'int', default: 0 })
|
||||||
totalItems: number = 0;
|
totalItems: number = 0;
|
||||||
|
|
||||||
|
|||||||
@@ -17,8 +17,7 @@ export interface OrderCarAddress {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export enum OrderStatus {
|
export enum OrderStatus {
|
||||||
PENDING_PAYMENT = 'pendingPayment',
|
NEW = 'new',
|
||||||
PAID = 'paid',
|
|
||||||
PREPARING = 'preparing',
|
PREPARING = 'preparing',
|
||||||
DELIVERED_TO_WAITER = 'deliveredToWaiter',
|
DELIVERED_TO_WAITER = 'deliveredToWaiter',
|
||||||
DELIVERED_TO_RECEPTIONIST = 'deliveredToReceptionist',
|
DELIVERED_TO_RECEPTIONIST = 'deliveredToReceptionist',
|
||||||
|
|||||||
@@ -35,8 +35,7 @@ export class OrderListeners {
|
|||||||
|
|
||||||
private getStatusFarsi(status: OrderStatus): string {
|
private getStatusFarsi(status: OrderStatus): string {
|
||||||
const statusMap: Record<OrderStatus, string> = {
|
const statusMap: Record<OrderStatus, string> = {
|
||||||
[OrderStatus.PENDING_PAYMENT]: 'در انتظار پرداخت',
|
[OrderStatus.NEW]: 'جدید',
|
||||||
[OrderStatus.PAID]: 'پرداخت شده',
|
|
||||||
[OrderStatus.PREPARING]: 'در حال آمادهسازی',
|
[OrderStatus.PREPARING]: 'در حال آمادهسازی',
|
||||||
[OrderStatus.DELIVERED_TO_RECEPTIONIST]: 'تحویل به پذیرش',
|
[OrderStatus.DELIVERED_TO_RECEPTIONIST]: 'تحویل به پذیرش',
|
||||||
[OrderStatus.DELIVERED_TO_WAITER]: 'تحویل به گارسون',
|
[OrderStatus.DELIVERED_TO_WAITER]: 'تحویل به گارسون',
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Injectable, NotFoundException, BadRequestException, Logger } from '@nestjs/common';
|
import { Injectable, NotFoundException, BadRequestException, Logger } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { EntityManager } from '@mikro-orm/postgresql';
|
import { EntityManager } from '@mikro-orm/postgresql';
|
||||||
import { Order } from '../entities/order.entity';
|
import { Order } from '../entities/order.entity';
|
||||||
import { OrderItem } from '../entities/order-item.entity';
|
import { OrderItem } from '../entities/order-item.entity';
|
||||||
@@ -8,6 +9,7 @@ import { UserRestaurant } from '../../users/entities/user-restuarant.entity';
|
|||||||
import { Restaurant } from '../../restaurants/entities/restaurant.entity';
|
import { Restaurant } from '../../restaurants/entities/restaurant.entity';
|
||||||
import { Food } from '../../foods/entities/food.entity';
|
import { Food } from '../../foods/entities/food.entity';
|
||||||
import { CartService } from '../../cart/providers/cart.service';
|
import { CartService } from '../../cart/providers/cart.service';
|
||||||
|
import { CartValidationService } from '../../cart/providers/cart-validation.service';
|
||||||
import { OrderStatus, OrderUserAddress, OrderCarAddress } from '../interface/order.interface';
|
import { OrderStatus, OrderUserAddress, OrderCarAddress } from '../interface/order.interface';
|
||||||
import { PaymentMethodEnum, PaymentStatusEnum } from '../../payments/interface/payment';
|
import { PaymentMethodEnum, PaymentStatusEnum } from '../../payments/interface/payment';
|
||||||
import { Cart } from '../../cart/interfaces/cart.interface';
|
import { Cart } from '../../cart/interfaces/cart.interface';
|
||||||
@@ -24,12 +26,19 @@ import { BulkReserveFoodDto } from 'src/modules/inventory/dto/bulk-reserve-food.
|
|||||||
import { StatusTransitionRef } from '../interface/order.interface';
|
import { StatusTransitionRef } from '../interface/order.interface';
|
||||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||||
import { OrderCreatedEvent, OrderStatusChangedEvent } from '../events/order.events';
|
import { OrderCreatedEvent, OrderStatusChangedEvent } from '../events/order.events';
|
||||||
import { OrderMessage } from 'src/common/enums/message.enum';
|
|
||||||
import { AdminCreateOrderDto } from '../dto/admin-create-order.dto';
|
import { AdminCreateOrderDto } from '../dto/admin-create-order.dto';
|
||||||
import { AdminUpdateOrderFeesDto } from '../dto/admin-update-order-fees.dto';
|
import { AdminUpdateOrderFeesDto } from '../dto/admin-update-order-fees.dto';
|
||||||
|
import { AdminAddOrderItemDto } from '../dto/admin-add-order-item.dto';
|
||||||
|
import { AdminUpdateOrderItemDto } from '../dto/admin-update-order-item.dto';
|
||||||
|
import { AdminRefundOrderDto } from '../dto/admin-refund-order.dto';
|
||||||
|
import { AdminAddPaymentDto } from '../dto/admin-add-payment.dto';
|
||||||
import { FoodOrderReportDto, FoodOrderReportSortBy } from '../dto/food-order-report.dto';
|
import { FoodOrderReportDto, FoodOrderReportSortBy } from '../dto/food-order-report.dto';
|
||||||
import { DailyOrderReportDto, DailyOrderReportSortBy } from '../dto/daily-order-report.dto';
|
import { DailyOrderReportDto, DailyOrderReportSortBy } from '../dto/daily-order-report.dto';
|
||||||
import { CashShiftsService } from './cash-shifts.service';
|
import { CashShiftsService } from './cash-shifts.service';
|
||||||
|
import { WalletTransaction } from 'src/modules/users/entities/wallet-transaction.entity';
|
||||||
|
import { WalletTransactionReason, WalletTransactionType } from 'src/modules/users/interface/wallet';
|
||||||
|
import { OrderMessage, PaymentMessage } from 'src/common/enums/message.enum';
|
||||||
|
import { SmsQueueService } from 'src/modules/notifications/services/sms-queue.service';
|
||||||
type OrderItemData = { food: Food; quantity: number; unitPrice: number; discount: number };
|
type OrderItemData = { food: Food; quantity: number; unitPrice: number; discount: number };
|
||||||
|
|
||||||
type ValidatedCartForOrder = {
|
type ValidatedCartForOrder = {
|
||||||
@@ -47,8 +56,7 @@ export class OrdersService {
|
|||||||
private readonly logger = new Logger(OrdersService.name);
|
private readonly logger = new Logger(OrdersService.name);
|
||||||
|
|
||||||
private static readonly STATUS_TRANSITIONS: Record<OrderStatus, readonly OrderStatus[]> = {
|
private static readonly STATUS_TRANSITIONS: Record<OrderStatus, readonly OrderStatus[]> = {
|
||||||
[OrderStatus.PENDING_PAYMENT]: [OrderStatus.PAID, OrderStatus.CANCELED, OrderStatus.PREPARING],
|
[OrderStatus.NEW]: [ OrderStatus.CANCELED, OrderStatus.PREPARING],
|
||||||
[OrderStatus.PAID]: [OrderStatus.PREPARING, OrderStatus.CANCELED],
|
|
||||||
[OrderStatus.PREPARING]: [OrderStatus.DELIVERED_TO_RECEPTIONIST, OrderStatus.DELIVERED_TO_WAITER, OrderStatus.SHIPPED, OrderStatus.CANCELED],
|
[OrderStatus.PREPARING]: [OrderStatus.DELIVERED_TO_RECEPTIONIST, OrderStatus.DELIVERED_TO_WAITER, OrderStatus.SHIPPED, OrderStatus.CANCELED],
|
||||||
[OrderStatus.DELIVERED_TO_WAITER]: [OrderStatus.COMPLETED, OrderStatus.CANCELED],
|
[OrderStatus.DELIVERED_TO_WAITER]: [OrderStatus.COMPLETED, OrderStatus.CANCELED],
|
||||||
[OrderStatus.DELIVERED_TO_RECEPTIONIST]: [OrderStatus.COMPLETED, OrderStatus.CANCELED],
|
[OrderStatus.DELIVERED_TO_RECEPTIONIST]: [OrderStatus.COMPLETED, OrderStatus.CANCELED],
|
||||||
@@ -60,11 +68,14 @@ export class OrdersService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly em: EntityManager,
|
private readonly em: EntityManager,
|
||||||
private readonly cartService: CartService,
|
private readonly cartService: CartService,
|
||||||
|
private readonly cartValidationService: CartValidationService,
|
||||||
private readonly orderRepository: OrderRepository,
|
private readonly orderRepository: OrderRepository,
|
||||||
private readonly paymentsService: PaymentsService,
|
private readonly paymentsService: PaymentsService,
|
||||||
private readonly inventoryService: InventoryService,
|
private readonly inventoryService: InventoryService,
|
||||||
private readonly eventEmitter: EventEmitter2,
|
private readonly eventEmitter: EventEmitter2,
|
||||||
private readonly cashShiftsService: CashShiftsService,
|
private readonly cashShiftsService: CashShiftsService,
|
||||||
|
private readonly smsQueueService: SmsQueueService,
|
||||||
|
private readonly configService: ConfigService,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
async checkout(userId: string, restaurantId: string) {
|
async checkout(userId: string, restaurantId: string) {
|
||||||
@@ -81,19 +92,22 @@ export class OrdersService {
|
|||||||
paymentMethod: validated.paymentMethod,
|
paymentMethod: validated.paymentMethod,
|
||||||
couponDiscount: cart.couponDiscount || 0,
|
couponDiscount: cart.couponDiscount || 0,
|
||||||
couponDetail: cart.coupon,
|
couponDetail: cart.coupon,
|
||||||
itemsDiscount: cart.itemsDiscount || 0,
|
|
||||||
totalDiscount: cart.totalDiscount || 0,
|
|
||||||
subTotal: cart.subTotal || 0,
|
|
||||||
tax: cart.tax || 0,
|
tax: cart.tax || 0,
|
||||||
deliveryFee: cart.deliveryFee || 0,
|
deliveryFee: cart.deliveryFee || 0,
|
||||||
packingFee: 0,
|
packingFee: 0,
|
||||||
total: cart.total || 0,
|
|
||||||
totalItems: cart.totalItems || 0,
|
|
||||||
description: cart.description,
|
description: cart.description,
|
||||||
printInvoice: cart.printInvoice ?? false,
|
printInvoice: cart.printInvoice ?? false,
|
||||||
tableNumber: cart.tableNumber,
|
tableNumber: cart.tableNumber,
|
||||||
status: OrderStatus.PENDING_PAYMENT,
|
status: OrderStatus.NEW,
|
||||||
history: [{ status: OrderStatus.PENDING_PAYMENT, changedAt: new Date() }],
|
history: [{ status: OrderStatus.NEW, changedAt: new Date() }],
|
||||||
|
subTotal: 0,
|
||||||
|
total: 0,
|
||||||
|
totalItems: 0,
|
||||||
|
totalDiscount: 0,
|
||||||
|
itemsDiscount: 0,
|
||||||
|
paidAmount: 0,
|
||||||
|
balance: 0,
|
||||||
|
refundedAmount: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
em.persist(order);
|
em.persist(order);
|
||||||
@@ -103,20 +117,22 @@ export class OrdersService {
|
|||||||
|
|
||||||
this.assertFoodHasSufficientStock(food, quantity);
|
this.assertFoodHasSufficientStock(food, quantity);
|
||||||
|
|
||||||
const totalPrice = (unitPrice - discount) * quantity;
|
|
||||||
|
|
||||||
const orderItem = em.create(OrderItem, {
|
const orderItem = em.create(OrderItem, {
|
||||||
order,
|
order,
|
||||||
food,
|
food,
|
||||||
quantity,
|
quantity,
|
||||||
unitPrice,
|
unitPrice,
|
||||||
discount,
|
discount,
|
||||||
totalPrice,
|
totalPrice: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
em.persist(orderItem);
|
em.persist(orderItem);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Flush so DB triggers compute subTotal / total / totalItems from items
|
||||||
|
await em.flush();
|
||||||
|
await em.refresh(order);
|
||||||
|
|
||||||
const payment = em.create(Payment, {
|
const payment = em.create(Payment, {
|
||||||
order,
|
order,
|
||||||
amount: order.total,
|
amount: order.total,
|
||||||
@@ -170,20 +186,26 @@ export class OrdersService {
|
|||||||
? await this.resolveUserAddressForOrder(user!, dto.addressId!)
|
? await this.resolveUserAddressForOrder(user!, dto.addressId!)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
if (userAddress) {
|
||||||
|
await this.cartValidationService.assertAddressInsideServiceArea(
|
||||||
|
restaurantId,
|
||||||
|
userAddress.latitude,
|
||||||
|
userAddress.longitude,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const orderItemsData = await this.buildOrderItemsDataFromDto(dto.items, restaurantId);
|
const orderItemsData = await this.buildOrderItemsDataFromDto(dto.items, restaurantId);
|
||||||
|
|
||||||
const subTotal = orderItemsData.reduce((sum, item) => sum + item.unitPrice * item.quantity, 0);
|
// Validate admin discount against item totals (not persisted — DB recomputes order money fields)
|
||||||
|
const itemsSubTotal = orderItemsData.reduce((sum, item) => sum + item.unitPrice * item.quantity, 0);
|
||||||
const itemsDiscount = orderItemsData.reduce((sum, item) => sum + item.discount * item.quantity, 0);
|
const itemsDiscount = orderItemsData.reduce((sum, item) => sum + item.discount * item.quantity, 0);
|
||||||
const deliveryFee = delivery.deliveryFeeType === DeliveryFeeTypeEnum.FIXED ? Number(delivery.deliveryFee) : 0;
|
const deliveryFee = delivery.deliveryFeeType === DeliveryFeeTypeEnum.FIXED ? Number(delivery.deliveryFee) : 0;
|
||||||
const packingFee = dto.packingFee ?? 0;
|
const packingFee = dto.packingFee ?? 0;
|
||||||
const adminDiscount = dto.discountAmount ?? 0;
|
const adminDiscount = dto.discountAmount ?? 0;
|
||||||
const maxDiscount = subTotal - itemsDiscount;
|
const maxDiscount = itemsSubTotal - itemsDiscount;
|
||||||
if (adminDiscount > maxDiscount) {
|
if (adminDiscount > maxDiscount) {
|
||||||
throw new BadRequestException(OrderMessage.DISCOUNT_AMOUNT_EXCEEDS_ORDER_TOTAL);
|
throw new BadRequestException(OrderMessage.DISCOUNT_AMOUNT_EXCEEDS_ORDER_TOTAL);
|
||||||
}
|
}
|
||||||
const totalDiscount = itemsDiscount + adminDiscount;
|
|
||||||
const total = subTotal - totalDiscount + deliveryFee + packingFee;
|
|
||||||
const totalItems = orderItemsData.reduce((sum, item) => sum + item.quantity, 0);
|
|
||||||
|
|
||||||
const order = await this.em.transactional(async em => {
|
const order = await this.em.transactional(async em => {
|
||||||
const order = em.create(Order, {
|
const order = em.create(Order, {
|
||||||
@@ -195,19 +217,22 @@ export class OrdersService {
|
|||||||
paymentMethod,
|
paymentMethod,
|
||||||
couponDiscount: adminDiscount,
|
couponDiscount: adminDiscount,
|
||||||
couponDetail: null,
|
couponDetail: null,
|
||||||
itemsDiscount,
|
|
||||||
totalDiscount,
|
|
||||||
subTotal,
|
|
||||||
tax: 0,
|
tax: 0,
|
||||||
deliveryFee,
|
deliveryFee,
|
||||||
packingFee,
|
packingFee,
|
||||||
total,
|
|
||||||
totalItems,
|
|
||||||
description: dto.description,
|
description: dto.description,
|
||||||
printInvoice: false,
|
printInvoice: false,
|
||||||
tableNumber: dto.tableNumber,
|
tableNumber: dto.tableNumber,
|
||||||
status: OrderStatus.PENDING_PAYMENT,
|
status: OrderStatus.COMPLETED,
|
||||||
history: [{ status: OrderStatus.PENDING_PAYMENT, changedAt: new Date() }],
|
history: [{ status: OrderStatus.NEW, changedAt: new Date() }],
|
||||||
|
subTotal: 0,
|
||||||
|
total: 0,
|
||||||
|
totalItems: 0,
|
||||||
|
totalDiscount: 0,
|
||||||
|
itemsDiscount: 0,
|
||||||
|
paidAmount: 0,
|
||||||
|
balance: 0,
|
||||||
|
refundedAmount: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
em.persist(order);
|
em.persist(order);
|
||||||
@@ -215,15 +240,17 @@ export class OrdersService {
|
|||||||
for (const itemData of orderItemsData) {
|
for (const itemData of orderItemsData) {
|
||||||
const { food, quantity, unitPrice, discount } = itemData;
|
const { food, quantity, unitPrice, discount } = itemData;
|
||||||
this.assertFoodHasSufficientStock(food, quantity);
|
this.assertFoodHasSufficientStock(food, quantity);
|
||||||
const totalPrice = (unitPrice - discount) * quantity;
|
const orderItem = em.create(OrderItem, { order, food, quantity, unitPrice, discount, totalPrice: 0 });
|
||||||
const orderItem = em.create(OrderItem, { order, food, quantity, unitPrice, discount, totalPrice });
|
|
||||||
em.persist(orderItem);
|
em.persist(orderItem);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await em.flush();
|
||||||
|
await em.refresh(order);
|
||||||
|
|
||||||
const payment = em.create(Payment, {
|
const payment = em.create(Payment, {
|
||||||
order,
|
order,
|
||||||
amount: order.total,
|
amount: order.total,
|
||||||
status: PaymentStatusEnum.Pending,
|
status: PaymentStatusEnum.Paid,
|
||||||
method: order.paymentMethod.method,
|
method: order.paymentMethod.method,
|
||||||
gateway: order.paymentMethod.gateway ?? null,
|
gateway: order.paymentMethod.gateway ?? null,
|
||||||
});
|
});
|
||||||
@@ -271,20 +298,136 @@ export class OrdersService {
|
|||||||
order.prepareTime = dto.prepareTime;
|
order.prepareTime = dto.prepareTime;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (feesChanged) {
|
// total / balance / pending payment.amount are recalculated by DB triggers
|
||||||
order.total = order.subTotal - order.totalDiscount + order.deliveryFee + order.packingFee + order.tax;
|
await this.em.persistAndFlush(order);
|
||||||
|
|
||||||
const pendingPayment = await this.em.findOne(Payment, {
|
if (feesChanged) {
|
||||||
order: { id: orderId },
|
await this.em.refresh(order);
|
||||||
status: PaymentStatusEnum.Pending,
|
}
|
||||||
|
|
||||||
|
return order;
|
||||||
|
}
|
||||||
|
|
||||||
|
async addOrderItem(orderId: string, restId: string, dto: AdminAddOrderItemDto): Promise<Order> {
|
||||||
|
const order = await this.getOrderWithItemsOrFail(orderId, restId);
|
||||||
|
this.assertOrderEditable(order);
|
||||||
|
|
||||||
|
const existingItem = order.items.getItems().find(item => item.food.id === dto.foodId);
|
||||||
|
|
||||||
|
if (existingItem) {
|
||||||
|
const newQuantity = existingItem.quantity + dto.quantity;
|
||||||
|
return this.updateOrderItemQuantity(orderId, restId, existingItem.id, { quantity: newQuantity });
|
||||||
|
}
|
||||||
|
|
||||||
|
const [itemData] = await this.buildOrderItemsDataFromDto([{ foodId: dto.foodId, quantity: dto.quantity }], restId);
|
||||||
|
|
||||||
|
await this.em.transactional(async em => {
|
||||||
|
const orderItem = em.create(OrderItem, {
|
||||||
|
order,
|
||||||
|
food: itemData.food,
|
||||||
|
quantity: itemData.quantity,
|
||||||
|
unitPrice: itemData.unitPrice,
|
||||||
|
discount: itemData.discount,
|
||||||
|
totalPrice: 0,
|
||||||
|
});
|
||||||
|
em.persist(orderItem);
|
||||||
|
|
||||||
|
await this.inventoryService.deductFromInventory(em, {
|
||||||
|
items: [{ foodId: itemData.food.id, quantity: itemData.quantity }],
|
||||||
|
});
|
||||||
|
await em.flush();
|
||||||
});
|
});
|
||||||
|
|
||||||
if (pendingPayment) {
|
return this.findOne(orderId, restId);
|
||||||
pendingPayment.amount = order.total;
|
}
|
||||||
|
|
||||||
|
async updateOrderItemQuantity(
|
||||||
|
orderId: string,
|
||||||
|
restId: string,
|
||||||
|
itemId: string,
|
||||||
|
dto: AdminUpdateOrderItemDto,
|
||||||
|
): Promise<Order> {
|
||||||
|
const order = await this.getOrderWithItemsOrFail(orderId, restId);
|
||||||
|
this.assertOrderEditable(order);
|
||||||
|
|
||||||
|
const orderItem = order.items.getItems().find(item => item.id === itemId);
|
||||||
|
if (!orderItem) {
|
||||||
|
throw new NotFoundException(OrderMessage.ORDER_ITEM_NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (orderItem.quantity === dto.quantity) {
|
||||||
|
return this.findOne(orderId, restId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const delta = dto.quantity - orderItem.quantity;
|
||||||
|
const foodId = orderItem.food.id;
|
||||||
|
|
||||||
|
if (delta > 0) {
|
||||||
|
const food = await this.em.findOne(Food, { id: foodId }, { populate: ['inventory'] });
|
||||||
|
if (!food) throw new NotFoundException(OrderMessage.FOOD_NOT_FOUND);
|
||||||
|
this.assertFoodHasSufficientStock(food, delta);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.em.transactional(async em => {
|
||||||
|
orderItem.quantity = dto.quantity;
|
||||||
|
em.persist(orderItem);
|
||||||
|
|
||||||
|
if (delta > 0) {
|
||||||
|
await this.inventoryService.deductFromInventory(em, {
|
||||||
|
items: [{ foodId, quantity: delta }],
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await this.inventoryService.restoreToInventory(em, {
|
||||||
|
items: [{ foodId, quantity: Math.abs(delta) }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await em.flush();
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.findOne(orderId, restId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeOrderItem(orderId: string, restId: string, itemId: string): Promise<Order> {
|
||||||
|
const order = await this.getOrderWithItemsOrFail(orderId, restId);
|
||||||
|
this.assertOrderEditable(order);
|
||||||
|
|
||||||
|
const items = order.items.getItems();
|
||||||
|
if (items.length <= 1) {
|
||||||
|
throw new BadRequestException(OrderMessage.CANNOT_REMOVE_LAST_ORDER_ITEM);
|
||||||
|
}
|
||||||
|
|
||||||
|
const orderItem = items.find(item => item.id === itemId);
|
||||||
|
if (!orderItem) {
|
||||||
|
throw new NotFoundException(OrderMessage.ORDER_ITEM_NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
const foodId = orderItem.food.id;
|
||||||
|
const quantity = orderItem.quantity;
|
||||||
|
|
||||||
|
await this.em.transactional(async em => {
|
||||||
|
await this.inventoryService.restoreToInventory(em, {
|
||||||
|
items: [{ foodId, quantity }],
|
||||||
|
});
|
||||||
|
em.remove(orderItem);
|
||||||
|
await em.flush();
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.findOne(orderId, restId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertOrderEditable(order: Order) {
|
||||||
|
if ([OrderStatus.CANCELED, OrderStatus.COMPLETED].includes(order.status)) {
|
||||||
|
throw new BadRequestException(OrderMessage.CANNOT_UPDATE_CANCELED_OR_COMPLETED_ORDER);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.em.persistAndFlush(order);
|
private async getOrderWithItemsOrFail(orderId: string, restId: string): Promise<Order> {
|
||||||
|
const order = await this.em.findOne(
|
||||||
|
Order,
|
||||||
|
{ id: orderId, restaurant: { id: restId } },
|
||||||
|
{ populate: ['items', 'items.food', 'items.food.inventory'] },
|
||||||
|
);
|
||||||
|
if (!order) throw new NotFoundException(OrderMessage.NOT_FOUND);
|
||||||
return order;
|
return order;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -305,6 +448,14 @@ export class OrdersService {
|
|||||||
this.assertMeetsMinOrderForDelivery(cart, delivery);
|
this.assertMeetsMinOrderForDelivery(cart, delivery);
|
||||||
this.assertDeliveryMethodRequirements(cart, delivery);
|
this.assertDeliveryMethodRequirements(cart, delivery);
|
||||||
|
|
||||||
|
if (delivery.method === DeliveryMethodEnum.DeliveryCourier && cart.userAddress) {
|
||||||
|
await this.cartValidationService.assertAddressInsideServiceArea(
|
||||||
|
restaurantId,
|
||||||
|
cart.userAddress.latitude,
|
||||||
|
cart.userAddress.longitude,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const paymentMethod = await this.getPaymentMethodOrFail(cart.paymentMethodId!, restaurantId);
|
const paymentMethod = await this.getPaymentMethodOrFail(cart.paymentMethodId!, restaurantId);
|
||||||
this.assertPaymentMethodEnabled(paymentMethod);
|
this.assertPaymentMethodEnabled(paymentMethod);
|
||||||
this.assertCreditCardPaymentDetails(paymentMethod, cart.paymentDesc, cart.attachments);
|
this.assertCreditCardPaymentDetails(paymentMethod, cart.paymentDesc, cart.attachments);
|
||||||
@@ -369,6 +520,7 @@ export class OrdersService {
|
|||||||
'carAddress',
|
'carAddress',
|
||||||
'paymentMethod',
|
'paymentMethod',
|
||||||
'cashShift',
|
'cashShift',
|
||||||
|
'cashShift.admin',
|
||||||
'payments',
|
'payments',
|
||||||
'items',
|
'items',
|
||||||
'items.food',
|
'items.food',
|
||||||
@@ -415,6 +567,176 @@ export class OrdersService {
|
|||||||
);
|
);
|
||||||
return order;
|
return order;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async addPayment(orderId: string, restId: string, dto: AdminAddPaymentDto): Promise<Order> {
|
||||||
|
return this.em.transactional(async em => {
|
||||||
|
const order = await em.findOne(
|
||||||
|
Order,
|
||||||
|
{ id: orderId, restaurant: { id: restId } },
|
||||||
|
{ populate: ['payments', 'user', 'paymentMethod', 'deliveryMethod', 'items', 'items.food'] },
|
||||||
|
);
|
||||||
|
if (!order) {
|
||||||
|
throw new NotFoundException(OrderMessage.NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (order.status === OrderStatus.CANCELED) {
|
||||||
|
throw new BadRequestException(OrderMessage.ADD_PAYMENT_NOT_ALLOWED);
|
||||||
|
}
|
||||||
|
|
||||||
|
const balance = Number(order.balance ?? 0);
|
||||||
|
if (balance <= 0) {
|
||||||
|
throw new BadRequestException(OrderMessage.ADD_PAYMENT_NO_BALANCE);
|
||||||
|
}
|
||||||
|
|
||||||
|
const amount = Number(dto.amount);
|
||||||
|
if (!amount || amount <= 0) {
|
||||||
|
throw new BadRequestException(PaymentMessage.AMOUNT_MUST_BE_GREATER_THAN_ZERO);
|
||||||
|
}
|
||||||
|
|
||||||
|
const payment = em.create(Payment, {
|
||||||
|
order,
|
||||||
|
amount,
|
||||||
|
method: PaymentMethodEnum.Cash,
|
||||||
|
gateway: null,
|
||||||
|
status: PaymentStatusEnum.Paid,
|
||||||
|
paidAt: new Date(),
|
||||||
|
description: dto.description?.trim() || null,
|
||||||
|
});
|
||||||
|
em.persist(payment);
|
||||||
|
|
||||||
|
await em.flush();
|
||||||
|
|
||||||
|
return em.findOneOrFail(
|
||||||
|
Order,
|
||||||
|
{ id: orderId },
|
||||||
|
{ populate: ['payments', 'user', 'paymentMethod', 'deliveryMethod', 'items', 'items.food'] },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async refundOrder(orderId: string, restId: string, dto: AdminRefundOrderDto): Promise<Order> {
|
||||||
|
return this.em.transactional(async em => {
|
||||||
|
const order = await em.findOne(
|
||||||
|
Order,
|
||||||
|
{ id: orderId, restaurant: { id: restId } },
|
||||||
|
{ populate: ['payments', 'user', 'restaurant', 'paymentMethod'] },
|
||||||
|
);
|
||||||
|
if (!order) {
|
||||||
|
throw new NotFoundException(OrderMessage.NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
const paidAmount = Number(order.paidAmount ?? 0);
|
||||||
|
const balance = Number(order.balance ?? 0);
|
||||||
|
const isCanceled = order.status === OrderStatus.CANCELED;
|
||||||
|
const canRefund = balance < 0 || (isCanceled && paidAmount > 0);
|
||||||
|
|
||||||
|
if (!canRefund) {
|
||||||
|
throw new BadRequestException(OrderMessage.REFUND_NOT_ALLOWED);
|
||||||
|
}
|
||||||
|
|
||||||
|
const amount = Number(dto.amount);
|
||||||
|
if (!amount || amount <= 0) {
|
||||||
|
throw new BadRequestException(PaymentMessage.AMOUNT_MUST_BE_GREATER_THAN_ZERO);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (amount > paidAmount) {
|
||||||
|
throw new BadRequestException(OrderMessage.REFUND_AMOUNT_EXCEEDS_PAID);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isCanceled && balance < 0 && amount > Math.abs(balance)) {
|
||||||
|
throw new BadRequestException(OrderMessage.REFUND_AMOUNT_EXCEEDS_OVERPAYMENT);
|
||||||
|
}
|
||||||
|
|
||||||
|
const paidPayments = order.payments
|
||||||
|
.getItems()
|
||||||
|
.filter(p => p.status === PaymentStatusEnum.Paid && Number(p.amount) > 0)
|
||||||
|
.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
|
||||||
|
|
||||||
|
if (paidPayments.length === 0) {
|
||||||
|
throw new BadRequestException(OrderMessage.NO_PAID_PAYMENTS_TO_REFUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
let remaining = amount;
|
||||||
|
let walletRefundAmount = 0;
|
||||||
|
|
||||||
|
for (const payment of paidPayments) {
|
||||||
|
if (remaining <= 0) break;
|
||||||
|
|
||||||
|
const paymentAmount = Number(payment.amount);
|
||||||
|
const refundFromPayment = Math.min(paymentAmount, remaining);
|
||||||
|
|
||||||
|
if (refundFromPayment === paymentAmount) {
|
||||||
|
payment.status = PaymentStatusEnum.Refunded;
|
||||||
|
if (dto.description) {
|
||||||
|
payment.description = dto.description;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
payment.amount = paymentAmount - refundFromPayment;
|
||||||
|
const refundPayment = em.create(Payment, {
|
||||||
|
order,
|
||||||
|
amount: refundFromPayment,
|
||||||
|
method: payment.method,
|
||||||
|
gateway: payment.gateway ?? null,
|
||||||
|
status: PaymentStatusEnum.Refunded,
|
||||||
|
description: dto.description ?? null,
|
||||||
|
referenceId: payment.referenceId ?? null,
|
||||||
|
transactionId: payment.transactionId ?? null,
|
||||||
|
});
|
||||||
|
em.persist(refundPayment);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payment.method === PaymentMethodEnum.Wallet) {
|
||||||
|
walletRefundAmount += refundFromPayment;
|
||||||
|
}
|
||||||
|
|
||||||
|
remaining -= refundFromPayment;
|
||||||
|
em.persist(payment);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (remaining > 0) {
|
||||||
|
throw new BadRequestException(OrderMessage.NO_PAID_PAYMENTS_TO_REFUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (walletRefundAmount > 0) {
|
||||||
|
if (!order.user) {
|
||||||
|
throw new NotFoundException(OrderMessage.USER_NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
const latestWalletTx = await em.findOne(
|
||||||
|
WalletTransaction,
|
||||||
|
{
|
||||||
|
user: { id: order.user.id },
|
||||||
|
restaurant: { id: order.restaurant.id },
|
||||||
|
},
|
||||||
|
{ orderBy: { createdAt: 'DESC' } },
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!latestWalletTx) {
|
||||||
|
throw new NotFoundException(PaymentMessage.REFUND_WALLET_NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newBalance = Number(latestWalletTx.balance ?? 0) + walletRefundAmount;
|
||||||
|
const creditTx = em.create(WalletTransaction, {
|
||||||
|
user: order.user,
|
||||||
|
restaurant: order.restaurant,
|
||||||
|
amount: walletRefundAmount,
|
||||||
|
type: WalletTransactionType.CREDIT,
|
||||||
|
reason: WalletTransactionReason.ORDER_REFUND,
|
||||||
|
balance: newBalance,
|
||||||
|
});
|
||||||
|
em.persist(creditTx);
|
||||||
|
}
|
||||||
|
|
||||||
|
await em.flush();
|
||||||
|
|
||||||
|
return em.findOneOrFail(
|
||||||
|
Order,
|
||||||
|
{ id: orderId },
|
||||||
|
{ populate: ['payments', 'user', 'paymentMethod', 'deliveryMethod', 'items', 'items.food'] },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/* Helpers */
|
/* Helpers */
|
||||||
private assertStatusTransitionAllowed(order: Order, to: OrderStatus, ref: StatusTransitionRef) {
|
private assertStatusTransitionAllowed(order: Order, to: OrderStatus, ref: StatusTransitionRef) {
|
||||||
const paymentMethod = order.paymentMethod?.method;
|
const paymentMethod = order.paymentMethod?.method;
|
||||||
@@ -431,8 +753,8 @@ export class OrdersService {
|
|||||||
if (!OrdersService.STATUS_TRANSITIONS[from]?.includes(to)) return false;
|
if (!OrdersService.STATUS_TRANSITIONS[from]?.includes(to)) return false;
|
||||||
|
|
||||||
if (to === OrderStatus.CANCELED) {
|
if (to === OrderStatus.CANCELED) {
|
||||||
// only allow orders with status of PENDING_PAYMENT and PAID are allowed to be canceled by user
|
// only allow orders with status of NEW are allowed to be canceled by user
|
||||||
if (ref === 'user' && ![OrderStatus.PENDING_PAYMENT, OrderStatus.PAID].includes(from)) {
|
if (ref === 'user' && ![OrderStatus.NEW].includes(from)) {
|
||||||
return false;
|
return false;
|
||||||
} else if (ref === 'admin') {
|
} else if (ref === 'admin') {
|
||||||
return true;
|
return true;
|
||||||
@@ -441,7 +763,7 @@ export class OrdersService {
|
|||||||
// only allow orders with status of PENDING_PAYMENT and payment
|
// only allow orders with status of PENDING_PAYMENT and payment
|
||||||
// method of cash are allowed to move to CONFIRMED directly
|
// method of cash are allowed to move to CONFIRMED directly
|
||||||
if (
|
if (
|
||||||
from == OrderStatus.PENDING_PAYMENT &&
|
from == OrderStatus.NEW &&
|
||||||
to == OrderStatus.PREPARING &&
|
to == OrderStatus.PREPARING &&
|
||||||
paymentMethod !== PaymentMethodEnum.Cash &&
|
paymentMethod !== PaymentMethodEnum.Cash &&
|
||||||
paymentMethod !== PaymentMethodEnum.CreditCard
|
paymentMethod !== PaymentMethodEnum.CreditCard
|
||||||
@@ -475,10 +797,6 @@ export class OrdersService {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (paymentMethod === PaymentMethodEnum.Online) {
|
|
||||||
if (to === OrderStatus.PREPARING && from !== OrderStatus.PAID) return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -486,12 +804,45 @@ export class OrdersService {
|
|||||||
const order = await this.em.findOne(
|
const order = await this.em.findOne(
|
||||||
Order,
|
Order,
|
||||||
{ id: orderId, restaurant: { id: restId } },
|
{ id: orderId, restaurant: { id: restId } },
|
||||||
{ populate: ['paymentMethod', 'deliveryMethod', 'user'] },
|
{ populate: ['paymentMethod', 'deliveryMethod', 'user', 'restaurant'] },
|
||||||
);
|
);
|
||||||
if (!order) throw new NotFoundException(OrderMessage.NOT_FOUND);
|
if (!order) throw new NotFoundException(OrderMessage.NOT_FOUND);
|
||||||
return order;
|
return order;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async sendPleaseComeSms(orderId: string, restId: string): Promise<void> {
|
||||||
|
const order = await this.getOrderOrFail(orderId, restId);
|
||||||
|
const user = order.user;
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new BadRequestException(OrderMessage.PLEASE_COME_USER_MISSING);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user.phone) {
|
||||||
|
throw new BadRequestException(OrderMessage.PLEASE_COME_PHONE_MISSING);
|
||||||
|
}
|
||||||
|
|
||||||
|
const templateId = this.configService.get<string>('SMS_PATTERN_PLEASE_COME');
|
||||||
|
if (!templateId) {
|
||||||
|
throw new BadRequestException(OrderMessage.PLEASE_COME_SMS_PATTERN_MISSING);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Queueing please-come SMS for order ${orderId} to user ${user.id} (${user.phone})`,
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.smsQueueService.enqueue({
|
||||||
|
phone: user.phone,
|
||||||
|
templateId,
|
||||||
|
restaurantId: restId,
|
||||||
|
quantity: 1,
|
||||||
|
params: {
|
||||||
|
name: user.firstName ?? '',
|
||||||
|
rest: order.restaurant?.name ?? '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private assertCartHasItems(cart: Cart) {
|
private assertCartHasItems(cart: Cart) {
|
||||||
if (!cart.items || cart.items.length === 0) {
|
if (!cart.items || cart.items.length === 0) {
|
||||||
throw new BadRequestException(OrderMessage.CART_EMPTY);
|
throw new BadRequestException(OrderMessage.CART_EMPTY);
|
||||||
@@ -709,7 +1060,6 @@ export class OrdersService {
|
|||||||
restaurant: { id: restId },
|
restaurant: { id: restId },
|
||||||
status: {
|
status: {
|
||||||
$in: [
|
$in: [
|
||||||
OrderStatus.PAID,
|
|
||||||
OrderStatus.PREPARING,
|
OrderStatus.PREPARING,
|
||||||
OrderStatus.DELIVERED_TO_WAITER,
|
OrderStatus.DELIVERED_TO_WAITER,
|
||||||
OrderStatus.DELIVERED_TO_RECEPTIONIST,
|
OrderStatus.DELIVERED_TO_RECEPTIONIST,
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ export class OrderRepository extends EntityRepository<Order> {
|
|||||||
where.$or = searchConditions;
|
where.$or = searchConditions;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter: Exclude orders with payment method Online and status pending_payment
|
// Filter: Exclude online orders that have no paid payment
|
||||||
if (excludeOnlinePendingPayment) {
|
if (excludeOnlinePendingPayment) {
|
||||||
const existingConditions = where.$and || [];
|
const existingConditions = where.$and || [];
|
||||||
where.$and = [
|
where.$and = [
|
||||||
@@ -113,7 +113,7 @@ export class OrderRepository extends EntityRepository<Order> {
|
|||||||
{
|
{
|
||||||
$or: [
|
$or: [
|
||||||
{ paymentMethod: { method: { $ne: PaymentMethodEnum.Online } } },
|
{ paymentMethod: { method: { $ne: PaymentMethodEnum.Online } } },
|
||||||
{ status: { $ne: OrderStatus.PENDING_PAYMENT } },
|
{ payments: { status: PaymentStatusEnum.Paid } },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -124,7 +124,7 @@ export class OrderRepository extends EntityRepository<Order> {
|
|||||||
limit,
|
limit,
|
||||||
offset,
|
offset,
|
||||||
orderBy: { [orderBy]: order.toLowerCase() as 'asc' | 'desc' },
|
orderBy: { [orderBy]: order.toLowerCase() as 'asc' | 'desc' },
|
||||||
populate: ['user', 'restaurant', 'deliveryMethod', 'paymentMethod', 'cashShift', 'payments'] as never,
|
populate: ['user', 'restaurant', 'deliveryMethod', 'paymentMethod', 'cashShift', 'cashShift.admin', 'payments'] as never,
|
||||||
});
|
});
|
||||||
|
|
||||||
const totalPages = Math.ceil(total / limit);
|
const totalPages = Math.ceil(total / limit);
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export class PaymentsService {
|
|||||||
const ctx = await this.loadAndValidateOrder(orderId);
|
const ctx = await this.loadAndValidateOrder(orderId);
|
||||||
|
|
||||||
// Idempotency: avoid creating/charging again for already-paid orders
|
// Idempotency: avoid creating/charging again for already-paid orders
|
||||||
if (ctx.order.status === OrderStatus.PAID) {
|
if (ctx.order.payments.find(p => p.status === PaymentStatusEnum.Paid)) {
|
||||||
return { paymentUrl: null };
|
return { paymentUrl: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,7 +112,7 @@ export class PaymentsService {
|
|||||||
const order = await em.findOne(Order, { id: ctx.order.id }, { populate: ['user', 'restaurant'] });
|
const order = await em.findOne(Order, { id: ctx.order.id }, { populate: ['user', 'restaurant'] });
|
||||||
if (!order) throw new NotFoundException(OrderMessage.NOT_FOUND);
|
if (!order) throw new NotFoundException(OrderMessage.NOT_FOUND);
|
||||||
if (!order.user) throw new NotFoundException(OrderMessage.USER_NOT_FOUND);
|
if (!order.user) throw new NotFoundException(OrderMessage.USER_NOT_FOUND);
|
||||||
if (order.status === OrderStatus.PAID) {
|
if (order.payments.find(p => p.status === PaymentStatusEnum.Paid)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,7 +152,6 @@ export class PaymentsService {
|
|||||||
|
|
||||||
payment.status = PaymentStatusEnum.Paid;
|
payment.status = PaymentStatusEnum.Paid;
|
||||||
payment.paidAt = new Date();
|
payment.paidAt = new Date();
|
||||||
order.status = OrderStatus.PAID;
|
|
||||||
|
|
||||||
em.persist([ payment, order, newWalletTransaction]);
|
em.persist([ payment, order, newWalletTransaction]);
|
||||||
await em.flush();
|
await em.flush();
|
||||||
@@ -232,9 +231,6 @@ export class PaymentsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.markPaid(payment, result.referenceId);
|
this.markPaid(payment, result.referenceId);
|
||||||
if (payment.order.status === OrderStatus.PENDING_PAYMENT) {
|
|
||||||
payment.order.status = OrderStatus.PAID;
|
|
||||||
}
|
|
||||||
|
|
||||||
await em.flush();
|
await em.flush();
|
||||||
return payment;
|
return payment;
|
||||||
@@ -275,9 +271,7 @@ export class PaymentsService {
|
|||||||
if (payment.status === PaymentStatusEnum.Paid) {
|
if (payment.status === PaymentStatusEnum.Paid) {
|
||||||
throw new BadRequestException(PaymentMessage.PAYMENT_ALREADY_PAID);
|
throw new BadRequestException(PaymentMessage.PAYMENT_ALREADY_PAID);
|
||||||
}
|
}
|
||||||
if (payment.order.status === OrderStatus.PENDING_PAYMENT) {
|
|
||||||
payment.order.status = OrderStatus.PAID;
|
|
||||||
}
|
|
||||||
payment.status = PaymentStatusEnum.Paid;
|
payment.status = PaymentStatusEnum.Paid;
|
||||||
payment.paidAt = new Date();
|
payment.paidAt = new Date();
|
||||||
em.persist(payment);
|
em.persist(payment);
|
||||||
|
|||||||
@@ -52,4 +52,9 @@ export class CreateRestaurantDto {
|
|||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
isActive?: boolean;
|
isActive?: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: false, description: 'فعال بودن چت هوش مصنوعی', default: false })
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
enableAiChat?: boolean;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,4 +51,9 @@ export class SetupRestaurantDto {
|
|||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
isActive?: boolean;
|
isActive?: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: false, description: 'فعال بودن چت هوش مصنوعی', default: false })
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
enableAiChat?: boolean;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,9 @@ export class Restaurant extends BaseEntity {
|
|||||||
@Property({ default: true })
|
@Property({ default: true })
|
||||||
isActive: boolean = true;
|
isActive: boolean = true;
|
||||||
|
|
||||||
|
@Property({ default: false })
|
||||||
|
enableAiChat: boolean = false;
|
||||||
|
|
||||||
@Property({ nullable: true })
|
@Property({ nullable: true })
|
||||||
establishedYear?: number;
|
establishedYear?: number;
|
||||||
|
|
||||||
|
|||||||
@@ -7,4 +7,5 @@ export enum RestaurantCreditTransactionReason {
|
|||||||
SMS_SEND = 'sms_send',
|
SMS_SEND = 'sms_send',
|
||||||
DEPOSIT = 'deposit',
|
DEPOSIT = 'deposit',
|
||||||
ADJUSTMENT = 'adjustment',
|
ADJUSTMENT = 'adjustment',
|
||||||
|
AI_CHAT = 'ai_chat',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ export class RestaurantsService {
|
|||||||
establishedYear: dto.establishedYear,
|
establishedYear: dto.establishedYear,
|
||||||
phones: dto.phone ? [dto.phone] : undefined,
|
phones: dto.phone ? [dto.phone] : undefined,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
|
enableAiChat: dto.enableAiChat ?? false,
|
||||||
plan: dto.plan,
|
plan: dto.plan,
|
||||||
domain: `https://dmenu.danakcorp.com/${slug}`,
|
domain: `https://dmenu.danakcorp.com/${slug}`,
|
||||||
subscriptionId: dto.subscriptionId,
|
subscriptionId: dto.subscriptionId,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { CreateReviewDto } from '../dto/create-review.dto';
|
|||||||
import { UpdateReviewDto } from '../dto/update-review.dto';
|
import { UpdateReviewDto } from '../dto/update-review.dto';
|
||||||
import { FindReviewsDto, FindRestuarantReviewsDto } from '../dto/find-reviews.dto';
|
import { FindReviewsDto, FindRestuarantReviewsDto } from '../dto/find-reviews.dto';
|
||||||
import { ChangeStatusDto } from '../dto/change-status.dto';
|
import { ChangeStatusDto } from '../dto/change-status.dto';
|
||||||
|
import { ReplyReviewDto } from '../dto/reply-review.dto';
|
||||||
import {
|
import {
|
||||||
ApiTags,
|
ApiTags,
|
||||||
ApiOperation,
|
ApiOperation,
|
||||||
@@ -18,6 +19,7 @@ import {
|
|||||||
import { AuthGuard } from 'src/modules/auth/guards/auth.guard';
|
import { AuthGuard } from 'src/modules/auth/guards/auth.guard';
|
||||||
import { AdminAuthGuard } from 'src/modules/auth/guards/adminAuth.guard';
|
import { AdminAuthGuard } from 'src/modules/auth/guards/adminAuth.guard';
|
||||||
import { UserId } from 'src/common/decorators/user-id.decorator';
|
import { UserId } from 'src/common/decorators/user-id.decorator';
|
||||||
|
import { AdminId } from 'src/common/decorators/admin-id.decorator';
|
||||||
import { RestId } from 'src/common/decorators/rest-id.decorator';
|
import { RestId } from 'src/common/decorators/rest-id.decorator';
|
||||||
import { ReviewStatus } from '../enums/review-status.enum';
|
import { ReviewStatus } from '../enums/review-status.enum';
|
||||||
import { Permissions } from 'src/common/decorators/permissions.decorator';
|
import { Permissions } from 'src/common/decorators/permissions.decorator';
|
||||||
@@ -114,7 +116,7 @@ export class ReviewController {
|
|||||||
@ApiQuery({ name: 'orderBy', required: false, type: String })
|
@ApiQuery({ name: 'orderBy', required: false, type: String })
|
||||||
@ApiQuery({ name: 'order', required: false, enum: ['asc', 'desc'] })
|
@ApiQuery({ name: 'order', required: false, enum: ['asc', 'desc'] })
|
||||||
findAllAdmin(@Query() dto: FindReviewsDto, @RestId() restId: string) {
|
findAllAdmin(@Query() dto: FindReviewsDto, @RestId() restId: string) {
|
||||||
return this.reviewService.findAll({ ...dto, restId: restId });
|
return this.reviewService.findAll({ ...dto, topLevel: true, restId: restId });
|
||||||
}
|
}
|
||||||
|
|
||||||
@UseGuards(AdminAuthGuard)
|
@UseGuards(AdminAuthGuard)
|
||||||
@@ -135,7 +137,7 @@ export class ReviewController {
|
|||||||
@ApiOperation({ summary: 'review detail' })
|
@ApiOperation({ summary: 'review detail' })
|
||||||
@ApiParam({ name: 'id', required: true })
|
@ApiParam({ name: 'id', required: true })
|
||||||
reviewDetail(@Param('id') reviewId: string, @RestId() restaurantId: string) {
|
reviewDetail(@Param('id') reviewId: string, @RestId() restaurantId: string) {
|
||||||
return this.reviewService.findById(reviewId, restaurantId);
|
return this.reviewService.findWithReplies(reviewId, restaurantId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@UseGuards(AdminAuthGuard)
|
@UseGuards(AdminAuthGuard)
|
||||||
@@ -153,6 +155,33 @@ export class ReviewController {
|
|||||||
return this.reviewService.changeStatus(reviewId, changeStatusDto.status, restaurantId);
|
return this.reviewService.changeStatus(reviewId, changeStatusDto.status, restaurantId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@UseGuards(AdminAuthGuard)
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Permissions(Permission.MANAGE_REVIEWS)
|
||||||
|
@Post('admin/reviews/:id/reply')
|
||||||
|
@ApiOperation({ summary: 'Create or update restaurant reply to a review' })
|
||||||
|
@ApiParam({ name: 'id', required: true, description: 'Parent review ID' })
|
||||||
|
@ApiBody({ type: ReplyReviewDto })
|
||||||
|
@ApiOkResponse({ description: 'The created or updated reply' })
|
||||||
|
reply(
|
||||||
|
@Param('id') reviewId: string,
|
||||||
|
@Body() replyReviewDto: ReplyReviewDto,
|
||||||
|
@RestId() restaurantId: string,
|
||||||
|
@AdminId() adminId: string,
|
||||||
|
) {
|
||||||
|
return this.reviewService.reply(reviewId, restaurantId, adminId, replyReviewDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@UseGuards(AdminAuthGuard)
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Permissions(Permission.MANAGE_REVIEWS)
|
||||||
|
@Delete('admin/reviews/:id/reply')
|
||||||
|
@ApiOperation({ summary: 'Delete restaurant reply to a review' })
|
||||||
|
@ApiParam({ name: 'id', required: true, description: 'Parent review ID' })
|
||||||
|
removeReply(@Param('id') reviewId: string, @RestId() restaurantId: string) {
|
||||||
|
return this.reviewService.removeReply(reviewId, restaurantId);
|
||||||
|
}
|
||||||
|
|
||||||
@UseGuards(AdminAuthGuard)
|
@UseGuards(AdminAuthGuard)
|
||||||
@Permissions(Permission.MANAGE_REVIEWS)
|
@Permissions(Permission.MANAGE_REVIEWS)
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
|
|||||||
@@ -9,11 +9,6 @@ export class CreateReviewDto {
|
|||||||
@ApiProperty({ description: 'Food ID' })
|
@ApiProperty({ description: 'Food ID' })
|
||||||
foodId: string;
|
foodId: string;
|
||||||
|
|
||||||
@IsNotEmpty()
|
|
||||||
@IsString()
|
|
||||||
@ApiProperty({ description: 'Order ID' })
|
|
||||||
orderId: string;
|
|
||||||
|
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
@Min(1)
|
@Min(1)
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { IsNotEmpty, IsString, MaxLength } from 'class-validator';
|
||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class ReplyReviewDto {
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(2000)
|
||||||
|
@ApiProperty({ description: 'Restaurant reply comment', example: 'از بازخورد شما سپاسگزاریم' })
|
||||||
|
comment: string;
|
||||||
|
}
|
||||||
@@ -2,24 +2,25 @@ import { Entity, Index, ManyToOne, Property, Unique, Enum } from '@mikro-orm/cor
|
|||||||
import { BaseEntity } from '../../../common/entities/base.entity';
|
import { BaseEntity } from '../../../common/entities/base.entity';
|
||||||
import { Food } from '../../foods/entities/food.entity';
|
import { Food } from '../../foods/entities/food.entity';
|
||||||
import { User } from '../../users/entities/user.entity';
|
import { User } from '../../users/entities/user.entity';
|
||||||
import { Order } from 'src/modules/orders/entities/order.entity';
|
import { Admin } from '../../admin/entities/admin.entity';
|
||||||
import { PositivePoint, NegativePoint } from '../enums/review-point.enum';
|
import { PositivePoint, NegativePoint } from '../enums/review-point.enum';
|
||||||
import { ReviewStatus } from '../enums/review-status.enum';
|
import { ReviewStatus } from '../enums/review-status.enum';
|
||||||
|
|
||||||
@Entity({ tableName: 'reviews' })
|
@Entity({ tableName: 'reviews' })
|
||||||
@Unique({ properties: ['order', 'food', 'user'] })
|
@Unique({ properties: ['food', 'user'] })
|
||||||
@Index({ properties: ['food', 'status'] })
|
@Index({ properties: ['food', 'status'] })
|
||||||
@Index({ properties: ['user'] })
|
@Index({ properties: ['user'] })
|
||||||
@Index({ properties: ['order'] })
|
@Index({ properties: ['parent'] })
|
||||||
|
@Index({ properties: ['admin'] })
|
||||||
export class Review extends BaseEntity {
|
export class Review extends BaseEntity {
|
||||||
@ManyToOne(() => Order)
|
@Property({ type: 'boolean' })
|
||||||
order: Order;
|
isBuyer: boolean = false
|
||||||
|
|
||||||
@ManyToOne(() => Food)
|
@ManyToOne(() => Food)
|
||||||
food: Food;
|
food: Food;
|
||||||
|
|
||||||
@ManyToOne(() => User)
|
@ManyToOne(() => User, { nullable: true })
|
||||||
user: User;
|
user?: User;
|
||||||
|
|
||||||
@Property({ type: 'text', nullable: true })
|
@Property({ type: 'text', nullable: true })
|
||||||
comment?: string;
|
comment?: string;
|
||||||
@@ -36,4 +37,10 @@ export class Review extends BaseEntity {
|
|||||||
@Enum(() => ReviewStatus)
|
@Enum(() => ReviewStatus)
|
||||||
@Property({ type: 'string', default: ReviewStatus.PENDING })
|
@Property({ type: 'string', default: ReviewStatus.PENDING })
|
||||||
status: ReviewStatus = ReviewStatus.PENDING;
|
status: ReviewStatus = ReviewStatus.PENDING;
|
||||||
|
|
||||||
|
@ManyToOne(() => Review, { nullable: true })
|
||||||
|
parent?: Review;
|
||||||
|
|
||||||
|
@ManyToOne(() => Admin, { nullable: true })
|
||||||
|
admin?: Admin;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ export class ReviewCreatedEvent {
|
|||||||
public readonly userId: string,
|
public readonly userId: string,
|
||||||
public readonly foodId: string,
|
public readonly foodId: string,
|
||||||
public readonly foodName: string,
|
public readonly foodName: string,
|
||||||
public readonly orderId: string,
|
|
||||||
public readonly orderNumber: string | number,
|
|
||||||
public readonly rating: number,
|
public readonly rating: number,
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,13 +46,12 @@ export class ReviewListeners {
|
|||||||
restaurantId: event.restaurantId,
|
restaurantId: event.restaurantId,
|
||||||
message: {
|
message: {
|
||||||
title: NotifTitleEnum.REVIEW_CREATED,
|
title: NotifTitleEnum.REVIEW_CREATED,
|
||||||
content: `نظر جدید برای غذا "${event.foodName}" با امتیاز ${event.rating} از 5 برای سفارش شماره ${event.orderNumber} ثبت شد`,
|
content: `نظر جدید برای غذا "${event.foodName}" با امتیاز ${event.rating} از 5 برای سفارش شماره ثبت شد`,
|
||||||
sms: {
|
sms: {
|
||||||
templateId: this.reviewCreatedSmsTemplateId,
|
templateId: this.reviewCreatedSmsTemplateId,
|
||||||
parameters: {
|
parameters: {
|
||||||
foodName: event.foodName,
|
foodName: event.foodName,
|
||||||
rating: event.rating.toString(),
|
rating: event.rating.toString(),
|
||||||
orderNumber: String(event.orderNumber),
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
pushNotif: {
|
pushNotif: {
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ export class FoodRatingCronService {
|
|||||||
food: { id: food.id },
|
food: { id: food.id },
|
||||||
status: ReviewStatus.APPROVED,
|
status: ReviewStatus.APPROVED,
|
||||||
deletedAt: null,
|
deletedAt: null,
|
||||||
|
parent: null,
|
||||||
},
|
},
|
||||||
{ fields: ['rating'] },
|
{ fields: ['rating'] },
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,21 +1,24 @@
|
|||||||
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||||
import { CreateReviewDto } from '../dto/create-review.dto';
|
import { CreateReviewDto } from '../dto/create-review.dto';
|
||||||
import { UpdateReviewDto } from '../dto/update-review.dto';
|
import { UpdateReviewDto } from '../dto/update-review.dto';
|
||||||
|
import { ReplyReviewDto } from '../dto/reply-review.dto';
|
||||||
import { FindRestuarantReviewsDto, FindReviewsDto } from '../dto/find-reviews.dto';
|
import { FindRestuarantReviewsDto, FindReviewsDto } from '../dto/find-reviews.dto';
|
||||||
import { ReviewRepository } from '../repositories/review.repository';
|
import { ReviewRepository } from '../repositories/review.repository';
|
||||||
import { FoodRepository } from '../../foods/repositories/food.repository';
|
import { FoodRepository } from '../../foods/repositories/food.repository';
|
||||||
import { UserRepository } from '../../users/repositories/user.repository';
|
import { UserRepository } from '../../users/repositories/user.repository';
|
||||||
import { EntityManager } from '@mikro-orm/postgresql';
|
import { EntityManager } from '@mikro-orm/postgresql';
|
||||||
import { RequiredEntityData } from '@mikro-orm/core';
|
import { RequiredEntityData, wrap } from '@mikro-orm/core';
|
||||||
import { Review } from '../entities/review.entity';
|
import { Review } from '../entities/review.entity';
|
||||||
import { FoodMessage, UserMessage, ReviewMessage } from 'src/common/enums/message.enum';
|
import { FoodMessage, UserMessage, ReviewMessage } from 'src/common/enums/message.enum';
|
||||||
import { Order } from '../../orders/entities/order.entity';
|
import { Order } from '../../orders/entities/order.entity';
|
||||||
import { OrderItem } from '../../orders/entities/order-item.entity';
|
|
||||||
import { Restaurant } from 'src/modules/restaurants/entities/restaurant.entity';
|
import { Restaurant } from 'src/modules/restaurants/entities/restaurant.entity';
|
||||||
import { ReviewStatus } from '../enums/review-status.enum';
|
import { ReviewStatus } from '../enums/review-status.enum';
|
||||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||||
import { ReviewCreatedEvent } from '../events/review.events';
|
import { ReviewCreatedEvent } from '../events/review.events';
|
||||||
import { sanitizeText } from '../../utils/sanitize.util';
|
import { sanitizeText } from '../../utils/sanitize.util';
|
||||||
|
import { OrderStatus } from 'src/modules/orders/interface/order.interface';
|
||||||
|
import { Admin } from '../../admin/entities/admin.entity';
|
||||||
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ReviewService {
|
export class ReviewService {
|
||||||
@@ -28,9 +31,9 @@ export class ReviewService {
|
|||||||
) { }
|
) { }
|
||||||
|
|
||||||
async create(userId: string, createReviewDto: CreateReviewDto): Promise<Review> {
|
async create(userId: string, createReviewDto: CreateReviewDto): Promise<Review> {
|
||||||
const { foodId, orderId, rating, comment, positivePoints, negativePoints } = createReviewDto;
|
const { foodId, rating, comment, positivePoints, negativePoints } = createReviewDto;
|
||||||
|
|
||||||
const food = await this.foodRepository.findOne({ id: foodId });
|
const food = await this.foodRepository.findOne({ id: foodId }, { populate: ['restaurant'] });
|
||||||
if (!food) {
|
if (!food) {
|
||||||
throw new NotFoundException(FoodMessage.NOT_FOUND);
|
throw new NotFoundException(FoodMessage.NOT_FOUND);
|
||||||
}
|
}
|
||||||
@@ -40,34 +43,14 @@ export class ReviewService {
|
|||||||
throw new NotFoundException(UserMessage.USER_NOT_FOUND);
|
throw new NotFoundException(UserMessage.USER_NOT_FOUND);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find order and verify it belongs to the user, populate restaurant to get restaurantId
|
const isBuyer = await this.em.count(Order, {
|
||||||
const order = await this.em.findOne(Order, { id: orderId, user: { id: userId } }, { populate: ['restaurant'] });
|
status: OrderStatus.COMPLETED,
|
||||||
if (!order) {
|
items: { food: { id: foodId } },
|
||||||
throw new NotFoundException('Order not found or does not belong to the current user');
|
user: { id: userId }
|
||||||
}
|
}) > 0;
|
||||||
|
|
||||||
// Verify the food is in the order
|
|
||||||
const orderItem = await this.em.findOne(OrderItem, {
|
|
||||||
order: { id: orderId },
|
|
||||||
food: { id: foodId },
|
|
||||||
});
|
|
||||||
if (!orderItem) {
|
|
||||||
throw new BadRequestException('Food is not in the specified order');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if user already commented on this food for this order
|
|
||||||
const existingReview = await this.reviewRepository.findOne({
|
|
||||||
order: { id: orderId },
|
|
||||||
food: { id: foodId },
|
|
||||||
user: { id: userId },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (existingReview) {
|
|
||||||
throw new BadRequestException(ReviewMessage.ALREADY_COMMENTED);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data: RequiredEntityData<Review> = {
|
const data: RequiredEntityData<Review> = {
|
||||||
order,
|
isBuyer: true,
|
||||||
food,
|
food,
|
||||||
user,
|
user,
|
||||||
rating,
|
rating,
|
||||||
@@ -92,12 +75,10 @@ export class ReviewService {
|
|||||||
ReviewCreatedEvent.name,
|
ReviewCreatedEvent.name,
|
||||||
new ReviewCreatedEvent(
|
new ReviewCreatedEvent(
|
||||||
review.id,
|
review.id,
|
||||||
order.restaurant.id,
|
food.restaurant.id,
|
||||||
userId,
|
userId,
|
||||||
foodId,
|
foodId,
|
||||||
food.title || 'Unknown Food',
|
food.title || '',
|
||||||
orderId,
|
|
||||||
order.orderNumber || '',
|
|
||||||
rating,
|
rating,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -105,7 +86,7 @@ export class ReviewService {
|
|||||||
return review;
|
return review;
|
||||||
}
|
}
|
||||||
|
|
||||||
async findAll(dto: FindReviewsDto) {
|
async findAll(dto: FindReviewsDto & { topLevel?: boolean }) {
|
||||||
return this.reviewRepository.findAllPaginated(dto);
|
return this.reviewRepository.findAllPaginated(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,25 +99,102 @@ export class ReviewService {
|
|||||||
return this.reviewRepository.findAllPaginated({ ...restDto, restId: restaurant.id });
|
return this.reviewRepository.findAllPaginated({ ...restDto, restId: restaurant.id });
|
||||||
}
|
}
|
||||||
|
|
||||||
async findById(id: string, restaurantId: string): Promise<Review> {
|
async findWithReplies(reviewId: string, restaurantId: string): Promise<Review[]> {
|
||||||
const review = await this.reviewRepository.findOne(
|
const reviews = await this.reviewRepository.find(
|
||||||
{ id, order: { restaurant: { id: restaurantId } } },
|
{
|
||||||
{ populate: ['food', 'user', 'order'] },
|
$and: [
|
||||||
|
{
|
||||||
|
$or: [
|
||||||
|
{ id: reviewId },
|
||||||
|
{ parent: { id: reviewId } }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{ food: { restaurant: { id: restaurantId } } },
|
||||||
|
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{ populate: ['food', 'user', 'admin'] },
|
||||||
);
|
);
|
||||||
if (!review) {
|
if (!reviews) {
|
||||||
throw new NotFoundException(ReviewMessage.NOT_FOUND);
|
throw new NotFoundException(ReviewMessage.NOT_FOUND);
|
||||||
}
|
}
|
||||||
return review;
|
|
||||||
|
return reviews;
|
||||||
|
}
|
||||||
|
|
||||||
|
async reply(reviewId: string, restaurantId: string, adminId: string, dto: ReplyReviewDto): Promise<Review> {
|
||||||
|
const parent = await this.reviewRepository.findOne(
|
||||||
|
{ id: reviewId, food: { restaurant: { id: restaurantId } }, parent: null },
|
||||||
|
{ populate: ['food'] },
|
||||||
|
);
|
||||||
|
if (!parent) {
|
||||||
|
throw new NotFoundException(ReviewMessage.NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
const comment = sanitizeText(dto.comment);
|
||||||
|
if (!comment) {
|
||||||
|
throw new BadRequestException('متن پاسخ الزامی است');
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingReply = await this.reviewRepository.findOne({
|
||||||
|
parent: reviewId,
|
||||||
|
deletedAt: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const admin = this.em.getReference(Admin, adminId);
|
||||||
|
|
||||||
|
if (existingReply) {
|
||||||
|
existingReply.comment = comment;
|
||||||
|
existingReply.status = ReviewStatus.APPROVED;
|
||||||
|
existingReply.admin = admin;
|
||||||
|
await this.em.persistAndFlush(existingReply);
|
||||||
|
return existingReply;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reply = this.reviewRepository.create({
|
||||||
|
isBuyer: false,
|
||||||
|
food: parent.food,
|
||||||
|
comment,
|
||||||
|
rating: 0,
|
||||||
|
status: ReviewStatus.APPROVED,
|
||||||
|
parent: parent.id,
|
||||||
|
admin,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.em.persistAndFlush(reply);
|
||||||
|
return reply;
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeReply(reviewId: string, restaurantId: string): Promise<void> {
|
||||||
|
const parent = await this.reviewRepository.findOne({
|
||||||
|
id: reviewId,
|
||||||
|
food: { restaurant: { id: restaurantId } },
|
||||||
|
parent: null,
|
||||||
|
});
|
||||||
|
if (!parent) {
|
||||||
|
throw new NotFoundException(ReviewMessage.NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
const reply = await this.reviewRepository.findOne({
|
||||||
|
parent: reviewId,
|
||||||
|
deletedAt: null,
|
||||||
|
});
|
||||||
|
if (!reply) {
|
||||||
|
throw new NotFoundException(ReviewMessage.REPLY_NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
reply.deletedAt = new Date();
|
||||||
|
await this.em.persistAndFlush(reply);
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(id: string, userId: string, dto: UpdateReviewDto, isAdmin: boolean = false): Promise<Review> {
|
async update(id: string, userId: string, dto: UpdateReviewDto, isAdmin: boolean = false): Promise<Review> {
|
||||||
const review = await this.reviewRepository.findOne({ id }, { populate: ['food', 'user', 'order'] });
|
const review = await this.reviewRepository.findOne({ id }, { populate: ['food', 'user',] });
|
||||||
if (!review) {
|
if (!review) {
|
||||||
throw new NotFoundException(ReviewMessage.NOT_FOUND);
|
throw new NotFoundException(ReviewMessage.NOT_FOUND);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only allow user to update their own reviews (unless admin)
|
// Only allow user to update their own reviews (unless admin)
|
||||||
if (!isAdmin && review.user.id !== userId) {
|
if (!isAdmin && review.user?.id !== userId) {
|
||||||
throw new BadRequestException(ReviewMessage.CAN_ONLY_UPDATE_OWN);
|
throw new BadRequestException(ReviewMessage.CAN_ONLY_UPDATE_OWN);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,7 +223,7 @@ export class ReviewService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async changeStatus(reviewId: string, status: ReviewStatus, restaurantId: string): Promise<Review> {
|
async changeStatus(reviewId: string, status: ReviewStatus, restaurantId: string): Promise<Review> {
|
||||||
const review = await this.reviewRepository.findOne({ id: reviewId, order: { restaurant: { id: restaurantId } } });
|
const review = await this.reviewRepository.findOne({ id: reviewId, food: { restaurant: { id: restaurantId } } });
|
||||||
if (!review) {
|
if (!review) {
|
||||||
throw new NotFoundException(ReviewMessage.NOT_FOUND);
|
throw new NotFoundException(ReviewMessage.NOT_FOUND);
|
||||||
}
|
}
|
||||||
@@ -175,27 +233,30 @@ export class ReviewService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async remove(id: string, userId: string, isAdmin: boolean = false) {
|
async remove(id: string, userId: string, isAdmin: boolean = false) {
|
||||||
const review = await this.reviewRepository.findOne({ id }, { populate: ['food', 'user', 'order'] });
|
const review = await this.reviewRepository.findOne({ id }, { populate: ['food', 'user'] });
|
||||||
if (!review) {
|
if (!review) {
|
||||||
throw new NotFoundException(ReviewMessage.NOT_FOUND);
|
throw new NotFoundException(ReviewMessage.NOT_FOUND);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only allow user to delete their own reviews (unless admin)
|
// Only allow user to delete their own reviews (unless admin)
|
||||||
if (!isAdmin && review.user.id !== userId) {
|
if (!isAdmin && review.user?.id !== userId) {
|
||||||
throw new BadRequestException(ReviewMessage.CAN_ONLY_DELETE_OWN);
|
throw new BadRequestException(ReviewMessage.CAN_ONLY_DELETE_OWN);
|
||||||
}
|
}
|
||||||
|
|
||||||
const foodId = review.food.id;
|
const foodId = review.food.id;
|
||||||
|
const isTopLevel = !review.parent;
|
||||||
review.deletedAt = new Date();
|
review.deletedAt = new Date();
|
||||||
await this.em.persistAndFlush(review);
|
await this.em.persistAndFlush(review);
|
||||||
|
|
||||||
// Update food rating average
|
// Update food rating average only for top-level reviews
|
||||||
|
if (isTopLevel) {
|
||||||
await this.updateFoodRating(foodId);
|
await this.updateFoodRating(foodId);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async updateFoodRating(foodId: string): Promise<void> {
|
private async updateFoodRating(foodId: string): Promise<void> {
|
||||||
const reviews = await this.reviewRepository.find(
|
const reviews = await this.reviewRepository.find(
|
||||||
{ food: { id: foodId }, status: ReviewStatus.APPROVED, deletedAt: null },
|
{ food: { id: foodId }, status: ReviewStatus.APPROVED, deletedAt: null, parent: null },
|
||||||
{ fields: ['rating'] },
|
{ fields: ['rating'] },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ type FindReviewsOpts = {
|
|||||||
orderBy?: string;
|
orderBy?: string;
|
||||||
restId?: string;
|
restId?: string;
|
||||||
order?: 'asc' | 'desc';
|
order?: 'asc' | 'desc';
|
||||||
|
parentId?: string;
|
||||||
|
topLevel?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -27,11 +29,20 @@ export class ReviewRepository extends EntityRepository<Review> {
|
|||||||
* Supports: foodId, userId, status, ordering.
|
* Supports: foodId, userId, status, ordering.
|
||||||
*/
|
*/
|
||||||
async findAllPaginated(opts: FindReviewsOpts = {}): Promise<PaginatedResult<Review>> {
|
async findAllPaginated(opts: FindReviewsOpts = {}): Promise<PaginatedResult<Review>> {
|
||||||
const { page = 1, limit = 10, foodId, restId, userId, status, orderBy = 'createdAt', order = 'desc' } = opts;
|
const { page = 1, limit = 10, parentId, topLevel,
|
||||||
|
foodId, restId, userId, status, orderBy = 'createdAt', order = 'desc' } = opts;
|
||||||
|
|
||||||
const offset = (page - 1) * limit;
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
// Only list top-level reviews; replies are nested under their parent
|
||||||
const where: FilterQuery<Review> = {};
|
const where: FilterQuery<Review> = {};
|
||||||
|
if (topLevel) {
|
||||||
|
where.parent = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parentId) {
|
||||||
|
where.parent = { id: parentId };
|
||||||
|
}
|
||||||
|
|
||||||
if (foodId) {
|
if (foodId) {
|
||||||
where.food = { id: foodId };
|
where.food = { id: foodId };
|
||||||
@@ -53,7 +64,7 @@ export class ReviewRepository extends EntityRepository<Review> {
|
|||||||
limit,
|
limit,
|
||||||
offset,
|
offset,
|
||||||
orderBy: { [orderBy]: order.toLowerCase() as 'asc' | 'desc' },
|
orderBy: { [orderBy]: order.toLowerCase() as 'asc' | 'desc' },
|
||||||
populate: ['food', 'user'],
|
populate: ['user', 'admin', 'parent', 'parent.user'],
|
||||||
});
|
});
|
||||||
|
|
||||||
const totalPages = Math.ceil(total / limit);
|
const totalPages = Math.ceil(total / limit);
|
||||||
|
|||||||
@@ -33,15 +33,11 @@ export class RolesController {
|
|||||||
@Get('admin/roles/permissions')
|
@Get('admin/roles/permissions')
|
||||||
@UseGuards(AdminAuthGuard)
|
@UseGuards(AdminAuthGuard)
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@ApiOperation({ summary: 'Get all permissions that the admin has' })
|
@ApiOperation({ summary: 'Get all permissions ' })
|
||||||
async findAllPermissions(@AdminId() adminId: string, @RestId() restId: string) {
|
async findAllPermissions() {
|
||||||
const adminPermissionNames = await this.permissionService.getAdminFullPermissions(adminId, restId);
|
return this.permissionService.getPermissions();
|
||||||
|
|
||||||
return adminPermissionNames;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@Post('admin/roles')
|
@Post('admin/roles')
|
||||||
@UseGuards(AdminAuthGuard)
|
@UseGuards(AdminAuthGuard)
|
||||||
@Permissions(Permission.MANAGE_ROLES)
|
@Permissions(Permission.MANAGE_ROLES)
|
||||||
|
|||||||
@@ -41,6 +41,10 @@ export class PermissionsService {
|
|||||||
return permissionNames;
|
return permissionNames;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getPermissions() {
|
||||||
|
return this.permissionRepository.findAll()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Refresh admin permissions in Redis (used after login or role changes)
|
* Refresh admin permissions in Redis (used after login or role changes)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -215,6 +215,17 @@ export class UsersController {
|
|||||||
return this.userService.adminUpdateUser(userId, restId, dto);
|
return this.userService.adminUpdateUser(userId, restId, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@UseGuards(AdminAuthGuard)
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Permissions(Permission.MANAGE_USERS)
|
||||||
|
@ApiHeader(API_HEADER_SLUG)
|
||||||
|
@ApiOperation({ summary: 'Get user groups for a customer (admin)' })
|
||||||
|
@ApiParam({ name: 'userId', description: 'User ID' })
|
||||||
|
@Get('admin/users/:userId/groups')
|
||||||
|
async adminGetUserGroups(@Param('userId') userId: string, @RestId() restId: string) {
|
||||||
|
return this.userService.getUserGroups(userId, restId);
|
||||||
|
}
|
||||||
|
|
||||||
@UseGuards(AdminAuthGuard)
|
@UseGuards(AdminAuthGuard)
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@Permissions(Permission.MANAGE_USERS)
|
@Permissions(Permission.MANAGE_USERS)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { EntityManager } from '@mikro-orm/postgresql';
|
|||||||
import { OrderStatus } from '../../orders/interface/order.interface';
|
import { OrderStatus } from '../../orders/interface/order.interface';
|
||||||
|
|
||||||
const COUNTED_ORDER_STATUSES = [
|
const COUNTED_ORDER_STATUSES = [
|
||||||
OrderStatus.PAID,
|
OrderStatus.NEW,
|
||||||
OrderStatus.PREPARING,
|
OrderStatus.PREPARING,
|
||||||
OrderStatus.DELIVERED_TO_WAITER,
|
OrderStatus.DELIVERED_TO_WAITER,
|
||||||
OrderStatus.DELIVERED_TO_RECEPTIONIST,
|
OrderStatus.DELIVERED_TO_RECEPTIONIST,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { IsBoolean, IsMobilePhone, IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
import { IsArray, IsBoolean, IsMobilePhone, IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
||||||
|
|
||||||
export class CreateUserDto {
|
export class CreateUserDto {
|
||||||
@ApiProperty({ description: "User's phone number", example: '09121234567' })
|
@ApiProperty({ description: "User's phone number", example: '09121234567' })
|
||||||
@@ -37,4 +37,15 @@ export class CreateUserDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
avatarUrl?: string;
|
avatarUrl?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'User group IDs to assign the customer to',
|
||||||
|
type: [String],
|
||||||
|
example: ['01HXABCDEFGHIJKLMNOPQRSTUV'],
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
@IsNotEmpty({ each: true })
|
||||||
|
groupIds?: string[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { IsString, IsOptional, IsBoolean } from 'class-validator';
|
import { IsString, IsOptional, IsBoolean, IsArray, IsNotEmpty } from 'class-validator';
|
||||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
|
||||||
export class UpdateUserDto {
|
export class UpdateUserDto {
|
||||||
@@ -32,4 +32,15 @@ export class UpdateUserDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
avatarUrl?: string;
|
avatarUrl?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'User group IDs to assign the customer to (replaces current groups)',
|
||||||
|
type: [String],
|
||||||
|
example: ['01HXABCDEFGHIJKLMNOPQRSTUV'],
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
@IsNotEmpty({ each: true })
|
||||||
|
groupIds?: string[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -138,4 +138,42 @@ export class UserGroupService {
|
|||||||
group.count = Math.max(0, group.count - 1);
|
group.count = Math.max(0, group.count - 1);
|
||||||
return group;
|
return group;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async assignUserToGroups(restId: string, userId: string, groupIds: string[]): Promise<void> {
|
||||||
|
const uniqueGroupIds = [...new Set(groupIds)];
|
||||||
|
for (const groupId of uniqueGroupIds) {
|
||||||
|
await this.addUsers(restId, groupId, { userIds: [userId] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getGroupsForUser(restId: string, userId: string): Promise<UserGroup[]> {
|
||||||
|
const links = await this.userGroupUserRepository.find(
|
||||||
|
{
|
||||||
|
user: { id: userId },
|
||||||
|
userGroup: { restaurant: { id: restId }, deletedAt: null },
|
||||||
|
},
|
||||||
|
{ populate: ['userGroup'], orderBy: { createdAt: 'asc' } },
|
||||||
|
);
|
||||||
|
|
||||||
|
return links.map(link => link.userGroup);
|
||||||
|
}
|
||||||
|
|
||||||
|
async syncUserGroups(restId: string, userId: string, groupIds: string[]): Promise<void> {
|
||||||
|
const targetGroupIds = [...new Set(groupIds)];
|
||||||
|
const currentGroups = await this.getGroupsForUser(restId, userId);
|
||||||
|
const currentGroupIds = new Set(currentGroups.map(group => group.id));
|
||||||
|
const targetGroupIdsSet = new Set(targetGroupIds);
|
||||||
|
|
||||||
|
for (const group of currentGroups) {
|
||||||
|
if (!targetGroupIdsSet.has(group.id)) {
|
||||||
|
await this.removeUser(restId, group.id, userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const groupId of targetGroupIds) {
|
||||||
|
if (!currentGroupIds.has(groupId)) {
|
||||||
|
await this.addUsers(restId, groupId, { userIds: [userId] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { UserRestaurant } from '../entities/user-restuarant.entity';
|
|||||||
import { ImportUsersResult } from '../interfaces/import-users.interface';
|
import { ImportUsersResult } from '../interfaces/import-users.interface';
|
||||||
import { assertExcelMimeType, parseUsersExcel } from '../utils/import-users-excel.util';
|
import { assertExcelMimeType, parseUsersExcel } from '../utils/import-users-excel.util';
|
||||||
import { AuthMessage, UserImportMessage } from 'src/common/enums/message.enum';
|
import { AuthMessage, UserImportMessage } from 'src/common/enums/message.enum';
|
||||||
|
import { UserGroupService } from './user-group.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class UserService {
|
export class UserService {
|
||||||
@@ -35,6 +36,7 @@ export class UserService {
|
|||||||
private readonly walletService: WalletService,
|
private readonly walletService: WalletService,
|
||||||
private readonly em: EntityManager,
|
private readonly em: EntityManager,
|
||||||
private readonly userRestaurantRepository: UserRestaurantRepository,
|
private readonly userRestaurantRepository: UserRestaurantRepository,
|
||||||
|
private readonly userGroupService: UserGroupService,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
async findOrCreateByPhone(phone: string): Promise<User> {
|
async findOrCreateByPhone(phone: string): Promise<User> {
|
||||||
@@ -189,6 +191,10 @@ export class UserService {
|
|||||||
this.em.persist(userRestaurant);
|
this.em.persist(userRestaurant);
|
||||||
await this.em.flush();
|
await this.em.flush();
|
||||||
|
|
||||||
|
if (dto.groupIds?.length) {
|
||||||
|
await this.userGroupService.assignUserToGroups(restId, user.id, dto.groupIds);
|
||||||
|
}
|
||||||
|
|
||||||
await this.em.populate(userRestaurant, ['user']);
|
await this.em.populate(userRestaurant, ['user']);
|
||||||
return userRestaurant;
|
return userRestaurant;
|
||||||
}
|
}
|
||||||
@@ -202,7 +208,26 @@ export class UserService {
|
|||||||
throw new NotFoundException(`User with ID ${userId} not found.`);
|
throw new NotFoundException(`User with ID ${userId} not found.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.updateUser(userId, restId, dto);
|
const { groupIds, ...userDto } = dto;
|
||||||
|
const user = await this.updateUser(userId, restId, userDto);
|
||||||
|
|
||||||
|
if (groupIds !== undefined) {
|
||||||
|
await this.userGroupService.syncUserGroups(restId, userId, groupIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUserGroups(userId: string, restId: string) {
|
||||||
|
const userRestaurant = await this.userRestaurantRepository.findOne({
|
||||||
|
user: { id: userId },
|
||||||
|
restaurant: { id: restId },
|
||||||
|
});
|
||||||
|
if (!userRestaurant) {
|
||||||
|
throw new NotFoundException(`User with ID ${userId} not found.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.userGroupService.getGroupsForUser(restId, userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateUser(userId: string, restId: string, dto: UpdateUserDto): Promise<User> {
|
async updateUser(userId: string, restId: string, dto: UpdateUserDto): Promise<User> {
|
||||||
@@ -211,15 +236,16 @@ export class UserService {
|
|||||||
throw new NotFoundException(`User with ID ${userId} not found.`);
|
throw new NotFoundException(`User with ID ${userId} not found.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { groupIds: _groupIds, ...userFields } = dto;
|
||||||
|
|
||||||
// Normalize date strings into Date objects if provided
|
// Normalize date strings into Date objects if provided
|
||||||
const assignData: Partial<User> = { ...dto } as Partial<User>;
|
const assignData: Partial<User> = { ...userFields } as Partial<User>;
|
||||||
if (dto.birthDate) {
|
if (userFields.birthDate) {
|
||||||
assignData.birthDate = new Date(dto.birthDate);
|
assignData.birthDate = new Date(userFields.birthDate);
|
||||||
}
|
}
|
||||||
if (dto.marriageDate) {
|
if (userFields.marriageDate) {
|
||||||
assignData.marriageDate = new Date(dto.marriageDate);
|
assignData.marriageDate = new Date(userFields.marriageDate);
|
||||||
}
|
}
|
||||||
// get restuarant
|
|
||||||
this.em.assign(user, assignData);
|
this.em.assign(user, assignData);
|
||||||
await this.em.flush();
|
await this.em.flush();
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export class RestaurantsSeeder {
|
|||||||
subscriptionStartDate: new Date('2026-01-01'),
|
subscriptionStartDate: new Date('2026-01-01'),
|
||||||
subscriptionEndDate: new Date('2026-01-07'),
|
subscriptionEndDate: new Date('2026-01-07'),
|
||||||
isActive: true,
|
isActive: true,
|
||||||
|
enableAiChat: false,
|
||||||
});
|
});
|
||||||
em.persist(restaurant);
|
em.persist(restaurant);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user