Compare commits

..

19 Commits

Author SHA1 Message Date
morteza 8225a3818c ai modification
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-28 16:43:23 +03:30
morteza 827864b19c new prompt
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-26 16:55:56 +03:30
morteza b59541b6f7 not to small answer
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-26 16:40:32 +03:30
morteza 40e78b4ccd add enable chat
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-26 12:27:38 +03:30
morteza ea629fcbf6 dmenu notif
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-26 11:49:18 +03:30
morteza c96df8748f update up
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-26 11:07:55 +03:30
morteza c439fdd6f9 ai question list
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-23 11:41:09 +03:30
morteza 27c45974f4 ai module 2026-07-23 10:52:29 +03:30
morteza 549da39473 add group to user
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-22 11:52:10 +03:30
morteza 096297d1d1 fix notif
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-19 19:46:49 +03:30
morteza 9fa55cc6b5 perms
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-19 11:33:05 +03:30
morteza d1f85d78f4 add payment
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-19 10:18:33 +03:30
morteza 17223ced06 edit order
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-18 20:56:29 +03:30
morteza 01ae67248a review
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-17 10:53:42 +03:30
morteza 443b62eb8e review 2026-07-17 10:18:39 +03:30
morteza 4abc2ae6e5 fix review table migration
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-15 12:39:53 +03:30
morteza 204b4f2007 update review entity
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-15 12:07:58 +03:30
morteza a05643daf5 order status when admin create new order for user
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-14 21:21:16 +03:30
morteza e991215f5a update order status 2026-07-14 21:04:07 +03:30
68 changed files with 3221 additions and 479 deletions
+14
View File
@@ -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
}'
+11
View File
@@ -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";`);
}
}
+2
View File
@@ -25,6 +25,7 @@ import { ContactModule } from './modules/contact/contact.module';
import { InventoryModule } from './modules/inventory/inventory.module';
import { IconsModule } from './modules/icons/icons.module';
import { SliderModule } from './modules/slider/slider.module';
import { AiModule } from './modules/ai/ai.module';
import { CacheModule } from '@nestjs/cache-manager';
import { cacheConfig } from './config/cache.config';
@@ -61,6 +62,7 @@ import { cacheConfig } from './config/cache.config';
InventoryModule,
IconsModule,
SliderModule,
AiModule,
],
controllers: [],
providers: [],
+21
View File
@@ -600,6 +600,9 @@ export const enum SettingMessageEnum {
export const enum AiMessage {
NO_RESPONSE_FROM_AI_MODEL = 'خطا در دریافت پاسخ از مدل AI',
LLM_SERVICE_ERROR = 'سرویس هوش مصنوعی موقتاً در دسترس نیست',
INSUFFICIENT_CREDIT = 'اعتبار کیف پول رستوران برای استفاده از هوش مصنوعی کافی نیست',
AI_CHAT_DISABLED = 'چت هوش مصنوعی برای این رستوران فعال نیست',
MESSAGE_NOT_FOUND_OR_ACCESS_DENIED = 'ایمیل یافت نشد یا دسترسی آن را ندارید',
EMAIL_CONTENT_IS_EMPTY_OR_COULD_NOT_BE_EXTRACTED = 'محتوای ایمیل خالی است یا قابل استخراج نیست',
DESCRIPTION_IS_REQUIRED = 'شرح و هدف قالب الزامی است',
@@ -660,6 +663,11 @@ export const enum ReviewMessage {
COMMENT_CREATED = 'نظر با موفقیت ثبت شد',
COMMENT_UPDATED = 'نظر با موفقیت به‌روزرسانی شد',
COMMENT_DELETED = 'نظر با موفقیت حذف شد',
CANNOT_REPLY_TO_REPLY = 'امکان پاسخ به پاسخ وجود ندارد',
REPLY_CREATED = 'پاسخ با موفقیت ثبت شد',
REPLY_UPDATED = 'پاسخ با موفقیت به‌روزرسانی شد',
REPLY_DELETED = 'پاسخ با موفقیت حذف شد',
REPLY_NOT_FOUND = 'پاسخی برای این نظر یافت نشد',
}
export const enum IconMessage {
@@ -699,6 +707,18 @@ export const enum OrderMessage {
DISCOUNT_AMOUNT_EXCEEDS_ORDER_TOTAL = 'مبلغ تخفیف بیشتر از مبلغ سفارش است',
CANNOT_UPDATE_CANCELED_OR_COMPLETED_ORDER = 'امکان ویرایش سفارش لغو شده یا تکمیل شده وجود ندارد',
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 {
@@ -752,6 +772,7 @@ export const enum PaymentMessage {
CARD_OWNER_REQUIRED = 'نام صاحب کارت برای پرداخت کارت به کارت الزامی است',
PAYMENT_ALREADY_PAID = 'پرداخت قبلا انجام شده است',
EXISTING_PENDING_PAYMENT_MISMATCH = 'پرداخت در انتظار موجود با روش پرداخت سفارش مطابقت ندارد',
REFUND_WALLET_NOT_FOUND = 'کیف پول کاربر برای بازگشت وجه یافت نشد',
ZARINPAL_INVALID_RESPONSE = 'پاسخ نامعتبر از API زرین‌پال',
ZARINPAL_PAYMENT_REQUEST_ERROR = 'خطا در درخواست پرداخت زرین‌پال',
ZARINPAL_VERIFY_ERROR = 'خطا در تایید پرداخت زرین‌پال',
+17
View File
@@ -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);
}
}
+44
View File
@@ -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);
}
}
}
+13
View File
@@ -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;
}
+37
View File
@@ -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;
}
+23
View File
@@ -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;
}
+395
View File
@@ -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),
},
};
}
}
+1 -1
View File
@@ -48,6 +48,6 @@ import { WalletTransaction } from '../users/entities/wallet-transaction.entity';
CartCalculationService,
CartItemService,
],
exports: [CartService],
exports: [CartService, CartValidationService],
})
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(
restaurantId: string,
latitude?: number | null,
longitude?: number | null,
): 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) {
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
const rings = serviceArea.coordinates;
if (!rings || rings.length === 0 || !Array.isArray(rings[0])) return;
const ring = rings[0];
const ring = restaurant.serviceArea!.coordinates[0];
const point: [number, number] = [Number(longitude), Number(latitude)];
// Ensure polygon has the correct tuple typing ([lng, lat] pairs)
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)) {
@@ -177,6 +177,19 @@ export class NotificationsController {
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
@UseGuards(SuperAdminAuthGuard)
@@ -37,10 +37,19 @@ export class NotificationsGateway implements OnGatewayConnection, OnGatewayDisco
) {}
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 {
await this.authenticateConnection(client);
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);
// Start ping interval for this client
@@ -51,7 +60,7 @@ export class NotificationsGateway implements OnGatewayConnection, OnGatewayDisco
// this.pingIntervals.set(client.id, intervalId);
} catch (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' });
client.disconnect();
@@ -59,8 +68,11 @@ export class NotificationsGateway implements OnGatewayConnection, OnGatewayDisco
}
handleDisconnect(client: Socket) {
this.logger.log(`Client disconnected: ${client.id}`);
this.handleLeaveRoom(client);
const authenticatedClient = client as AuthenticatedSocket;
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
// 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) {
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.logger.log(
`[WS] ACK received room=${room} notificationId=${payload.notificationId} (client acknowledged)`,
);
// 3. ACK (delivered)
// 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) {
@@ -86,16 +126,30 @@ export class NotificationsGateway implements OnGatewayConnection, OnGatewayDisco
}
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);
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' });
}
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);
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' });
}
@@ -2,9 +2,7 @@ import { Processor, WorkerHost, OnWorkerEvent } from '@nestjs/bullmq';
import { Logger } from '@nestjs/common';
import { Job } from 'bullmq';
import { CreateRequestContext } from '@mikro-orm/core';
import { InjectRepository } from '@mikro-orm/nestjs';
import { EntityManager } from '@mikro-orm/postgresql';
import { Notification } from '../entities/notification.entity';
import { InAppNotificationQueueJob } from '../interfaces/jobs-queue.interface';
import { NotificationQueueNameEnum } from '../constants/queue';
import { NotificationsGateway } from '../notifications.gateway';
@@ -14,7 +12,6 @@ export class InAppProcessor extends WorkerHost {
private readonly logger = new Logger(InAppProcessor.name);
constructor(
@InjectRepository(Notification)
private readonly em: EntityManager,
private readonly notificationsGateway: NotificationsGateway,
) {
@@ -25,10 +22,18 @@ export class InAppProcessor extends WorkerHost {
async process(job: Job<InAppNotificationQueueJob>): Promise<void> {
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) {
this.logger.warn(`Skipping in-app notification job ${job.id}: missing adminId`);
if (!recipient?.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;
}
@@ -38,20 +43,30 @@ export class InAppProcessor extends WorkerHost {
{ 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) {
this.logger.error(`Error processing InApp notification job ${job.id}:`, error);
this.logger.error(`[InAppProcessor] error processing job ${job.id}:`, error);
throw error;
}
}
@OnWorkerEvent('active')
onActive(job: Job) {
this.logger.log(`[InAppProcessor] job ${job.id} became active`);
}
@OnWorkerEvent('completed')
onCompleted(job: Job) {
this.logger.log(`Notification job ${job.id} completed`);
this.logger.log(`[InAppProcessor] job ${job.id} completed`);
}
@OnWorkerEvent('failed')
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> {
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 => ({
name: 'in-app-notification',
data: job,
@@ -132,10 +138,12 @@ export class NotificationQueueService {
},
}));
await this.inAppQueue.addBulk(queueJobs);
this.logger.log(`Added ${jobs.length} InApp notification jobs to queue`);
const added = await this.inAppQueue.addBulk(queueJobs);
this.logger.log(
`[addBulkInAppNotifications] added ${added.length} job(s) to queue "${NotificationQueueNameEnum.IN_APP}": ids=${added.map(j => j.id).join(', ')}`,
);
} 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;
}
}
@@ -24,6 +24,11 @@ export class NotificationService {
async sendNotification(params: NotifRequest): Promise<Notification[]> {
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
const notifications = await this.createAdminBulkNotifications(
recipients.map(recipient => ({
@@ -35,18 +40,35 @@ export class NotificationService {
})),
);
this.logger.log(`[sendNotification] created ${notifications.length} DB notification(s)`);
// get admin prefrences
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}`);
return notifications;
}
// 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) => {
if (!('adminId' in recipient) || !recipient.adminId) {
this.logger.debug(
`[sendNotification] skipping recipient index=${index} (not admin): ${JSON.stringify(recipient)}`,
);
return [];
}
@@ -61,16 +83,23 @@ export class NotificationService {
});
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);
} else {
this.logger.warn(
`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
if (preference?.channels?.includes(NotifChannelEnum.SMS)) {
if (preference.channels?.includes(NotifChannelEnum.SMS)) {
await this.queueService.addBulkSmsNotifications(
recipients.map(recipient => ({
recipient,
@@ -293,4 +322,40 @@ export class NotificationService {
async getSmsCountByRestaurantId(restaurantId: string): Promise<number> {
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 { OrdersService } from '../providers/orders.service';
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 { AdminCreateOrderDto } from '../dto/admin-create-order.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 { 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')
@ApiBearerAuth()
@@ -94,6 +99,102 @@ export class OrdersController {
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)
@Permissions(Permission.MANAGE_ORDERS)
@Patch('admin/orders/:orderId/:status')
@@ -114,20 +215,6 @@ export class OrdersController {
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)
@Permissions(Permission.VIEW_REPORTS)
@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 })
discount: number = 0;
@Property({ type: 'decimal', precision: 10, scale: 0 })
totalPrice!: number;
/** (unitPrice - discount) * quantity. Maintained by DB triggers — do not set in app code. */
@Property({ type: 'decimal', precision: 10, scale: 0, default: 0 })
totalPrice: number = 0;
}
+21 -4
View File
@@ -70,14 +70,17 @@ export class Order extends BaseEntity {
@Property({ type: 'jsonb', nullable: true })
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 })
itemsDiscount: number = 0;
/** couponDiscount + itemsDiscount. Maintained by DB triggers — do not set in app code. */
@Property({ type: 'decimal', precision: 10, scale: 0, default: 0 })
totalDiscount: number = 0;
@Property({ type: 'decimal', precision: 10, scale: 0 })
subTotal!: number;
/** Sum of unitPrice * quantity. Maintained by DB triggers — do not set in app code. */
@Property({ type: 'decimal', precision: 10, scale: 0, default: 0 })
subTotal: number = 0;
@Property({ type: 'decimal', precision: 10, scale: 0, default: 0 })
tax: number = 0;
@@ -91,9 +94,23 @@ export class Order extends BaseEntity {
@Property({ type: 'int', nullable: true })
prepareTime?: number | null;
@Property({ type: 'decimal', precision: 10, scale: 0 })
total!: number;
/** GREATEST(0, subTotal - totalDiscount) + tax + deliveryFee + packingFee. Maintained by DB triggers. */
@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 })
totalItems: number = 0;
@@ -17,8 +17,7 @@ export interface OrderCarAddress {
}
export enum OrderStatus {
PENDING_PAYMENT = 'pendingPayment',
PAID = 'paid',
NEW = 'new',
PREPARING = 'preparing',
DELIVERED_TO_WAITER = 'deliveredToWaiter',
DELIVERED_TO_RECEPTIONIST = 'deliveredToReceptionist',
@@ -35,8 +35,7 @@ export class OrderListeners {
private getStatusFarsi(status: OrderStatus): string {
const statusMap: Record<OrderStatus, string> = {
[OrderStatus.PENDING_PAYMENT]: ر انتظار پرداخت',
[OrderStatus.PAID]: 'پرداخت شده',
[OrderStatus.NEW]: 'جدید',
[OrderStatus.PREPARING]: 'در حال آماده‌سازی',
[OrderStatus.DELIVERED_TO_RECEPTIONIST]: 'تحویل به پذیرش',
[OrderStatus.DELIVERED_TO_WAITER]: 'تحویل به گارسون',
+398 -48
View File
@@ -1,4 +1,5 @@
import { Injectable, NotFoundException, BadRequestException, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { EntityManager } from '@mikro-orm/postgresql';
import { Order } from '../entities/order.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 { Food } from '../../foods/entities/food.entity';
import { CartService } from '../../cart/providers/cart.service';
import { CartValidationService } from '../../cart/providers/cart-validation.service';
import { OrderStatus, OrderUserAddress, OrderCarAddress } from '../interface/order.interface';
import { PaymentMethodEnum, PaymentStatusEnum } from '../../payments/interface/payment';
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 { EventEmitter2 } from '@nestjs/event-emitter';
import { OrderCreatedEvent, OrderStatusChangedEvent } from '../events/order.events';
import { OrderMessage } from 'src/common/enums/message.enum';
import { AdminCreateOrderDto } from '../dto/admin-create-order.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 { DailyOrderReportDto, DailyOrderReportSortBy } from '../dto/daily-order-report.dto';
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 ValidatedCartForOrder = {
@@ -47,8 +56,7 @@ export class OrdersService {
private readonly logger = new Logger(OrdersService.name);
private static readonly STATUS_TRANSITIONS: Record<OrderStatus, readonly OrderStatus[]> = {
[OrderStatus.PENDING_PAYMENT]: [OrderStatus.PAID, OrderStatus.CANCELED, OrderStatus.PREPARING],
[OrderStatus.PAID]: [OrderStatus.PREPARING, OrderStatus.CANCELED],
[OrderStatus.NEW]: [ OrderStatus.CANCELED, OrderStatus.PREPARING],
[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_RECEPTIONIST]: [OrderStatus.COMPLETED, OrderStatus.CANCELED],
@@ -60,11 +68,14 @@ export class OrdersService {
constructor(
private readonly em: EntityManager,
private readonly cartService: CartService,
private readonly cartValidationService: CartValidationService,
private readonly orderRepository: OrderRepository,
private readonly paymentsService: PaymentsService,
private readonly inventoryService: InventoryService,
private readonly eventEmitter: EventEmitter2,
private readonly cashShiftsService: CashShiftsService,
private readonly smsQueueService: SmsQueueService,
private readonly configService: ConfigService,
) { }
async checkout(userId: string, restaurantId: string) {
@@ -81,19 +92,22 @@ export class OrdersService {
paymentMethod: validated.paymentMethod,
couponDiscount: cart.couponDiscount || 0,
couponDetail: cart.coupon,
itemsDiscount: cart.itemsDiscount || 0,
totalDiscount: cart.totalDiscount || 0,
subTotal: cart.subTotal || 0,
tax: cart.tax || 0,
deliveryFee: cart.deliveryFee || 0,
packingFee: 0,
total: cart.total || 0,
totalItems: cart.totalItems || 0,
description: cart.description,
printInvoice: cart.printInvoice ?? false,
tableNumber: cart.tableNumber,
status: OrderStatus.PENDING_PAYMENT,
history: [{ status: OrderStatus.PENDING_PAYMENT, changedAt: new Date() }],
status: OrderStatus.NEW,
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);
@@ -103,20 +117,22 @@ export class OrdersService {
this.assertFoodHasSufficientStock(food, quantity);
const totalPrice = (unitPrice - discount) * quantity;
const orderItem = em.create(OrderItem, {
order,
food,
quantity,
unitPrice,
discount,
totalPrice,
totalPrice: 0,
});
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, {
order,
amount: order.total,
@@ -170,20 +186,26 @@ export class OrdersService {
? await this.resolveUserAddressForOrder(user!, dto.addressId!)
: null;
if (userAddress) {
await this.cartValidationService.assertAddressInsideServiceArea(
restaurantId,
userAddress.latitude,
userAddress.longitude,
);
}
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 deliveryFee = delivery.deliveryFeeType === DeliveryFeeTypeEnum.FIXED ? Number(delivery.deliveryFee) : 0;
const packingFee = dto.packingFee ?? 0;
const adminDiscount = dto.discountAmount ?? 0;
const maxDiscount = subTotal - itemsDiscount;
const maxDiscount = itemsSubTotal - itemsDiscount;
if (adminDiscount > maxDiscount) {
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 = em.create(Order, {
@@ -195,19 +217,22 @@ export class OrdersService {
paymentMethod,
couponDiscount: adminDiscount,
couponDetail: null,
itemsDiscount,
totalDiscount,
subTotal,
tax: 0,
deliveryFee,
packingFee,
total,
totalItems,
description: dto.description,
printInvoice: false,
tableNumber: dto.tableNumber,
status: OrderStatus.PENDING_PAYMENT,
history: [{ status: OrderStatus.PENDING_PAYMENT, changedAt: new Date() }],
status: OrderStatus.COMPLETED,
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);
@@ -215,15 +240,17 @@ export class OrdersService {
for (const itemData of orderItemsData) {
const { food, quantity, unitPrice, discount } = itemData;
this.assertFoodHasSufficientStock(food, quantity);
const totalPrice = (unitPrice - discount) * quantity;
const orderItem = em.create(OrderItem, { order, food, quantity, unitPrice, discount, totalPrice });
const orderItem = em.create(OrderItem, { order, food, quantity, unitPrice, discount, totalPrice: 0 });
em.persist(orderItem);
}
await em.flush();
await em.refresh(order);
const payment = em.create(Payment, {
order,
amount: order.total,
status: PaymentStatusEnum.Pending,
status: PaymentStatusEnum.Paid,
method: order.paymentMethod.method,
gateway: order.paymentMethod.gateway ?? null,
});
@@ -271,20 +298,136 @@ export class OrdersService {
order.prepareTime = dto.prepareTime;
}
// total / balance / pending payment.amount are recalculated by DB triggers
await this.em.persistAndFlush(order);
if (feesChanged) {
order.total = order.subTotal - order.totalDiscount + order.deliveryFee + order.packingFee + order.tax;
const pendingPayment = await this.em.findOne(Payment, {
order: { id: orderId },
status: PaymentStatusEnum.Pending,
});
if (pendingPayment) {
pendingPayment.amount = order.total;
}
await this.em.refresh(order);
}
await this.em.persistAndFlush(order);
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();
});
return this.findOne(orderId, restId);
}
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);
}
}
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;
}
@@ -305,6 +448,14 @@ export class OrdersService {
this.assertMeetsMinOrderForDelivery(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);
this.assertPaymentMethodEnabled(paymentMethod);
this.assertCreditCardPaymentDetails(paymentMethod, cart.paymentDesc, cart.attachments);
@@ -369,6 +520,7 @@ export class OrdersService {
'carAddress',
'paymentMethod',
'cashShift',
'cashShift.admin',
'payments',
'items',
'items.food',
@@ -415,6 +567,176 @@ export class OrdersService {
);
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 */
private assertStatusTransitionAllowed(order: Order, to: OrderStatus, ref: StatusTransitionRef) {
const paymentMethod = order.paymentMethod?.method;
@@ -431,8 +753,8 @@ export class OrdersService {
if (!OrdersService.STATUS_TRANSITIONS[from]?.includes(to)) return false;
if (to === OrderStatus.CANCELED) {
// only allow orders with status of PENDING_PAYMENT and PAID are allowed to be canceled by user
if (ref === 'user' && ![OrderStatus.PENDING_PAYMENT, OrderStatus.PAID].includes(from)) {
// only allow orders with status of NEW are allowed to be canceled by user
if (ref === 'user' && ![OrderStatus.NEW].includes(from)) {
return false;
} else if (ref === 'admin') {
return true;
@@ -441,7 +763,7 @@ export class OrdersService {
// only allow orders with status of PENDING_PAYMENT and payment
// method of cash are allowed to move to CONFIRMED directly
if (
from == OrderStatus.PENDING_PAYMENT &&
from == OrderStatus.NEW &&
to == OrderStatus.PREPARING &&
paymentMethod !== PaymentMethodEnum.Cash &&
paymentMethod !== PaymentMethodEnum.CreditCard
@@ -475,10 +797,6 @@ export class OrdersService {
return false;
}
if (paymentMethod === PaymentMethodEnum.Online) {
if (to === OrderStatus.PREPARING && from !== OrderStatus.PAID) return false;
}
return true;
}
@@ -486,12 +804,45 @@ export class OrdersService {
const order = await this.em.findOne(
Order,
{ id: orderId, restaurant: { id: restId } },
{ populate: ['paymentMethod', 'deliveryMethod', 'user'] },
{ populate: ['paymentMethod', 'deliveryMethod', 'user', 'restaurant'] },
);
if (!order) throw new NotFoundException(OrderMessage.NOT_FOUND);
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) {
if (!cart.items || cart.items.length === 0) {
throw new BadRequestException(OrderMessage.CART_EMPTY);
@@ -709,7 +1060,6 @@ export class OrdersService {
restaurant: { id: restId },
status: {
$in: [
OrderStatus.PAID,
OrderStatus.PREPARING,
OrderStatus.DELIVERED_TO_WAITER,
OrderStatus.DELIVERED_TO_RECEPTIONIST,
@@ -105,7 +105,7 @@ export class OrderRepository extends EntityRepository<Order> {
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) {
const existingConditions = where.$and || [];
where.$and = [
@@ -113,7 +113,7 @@ export class OrderRepository extends EntityRepository<Order> {
{
$or: [
{ 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,
offset,
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);
@@ -28,7 +28,7 @@ export class PaymentsService {
const ctx = await this.loadAndValidateOrder(orderId);
// 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 };
}
@@ -112,7 +112,7 @@ export class PaymentsService {
const order = await em.findOne(Order, { id: ctx.order.id }, { populate: ['user', 'restaurant'] });
if (!order) throw new NotFoundException(OrderMessage.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;
}
@@ -152,7 +152,6 @@ export class PaymentsService {
payment.status = PaymentStatusEnum.Paid;
payment.paidAt = new Date();
order.status = OrderStatus.PAID;
em.persist([ payment, order, newWalletTransaction]);
await em.flush();
@@ -232,9 +231,6 @@ export class PaymentsService {
}
this.markPaid(payment, result.referenceId);
if (payment.order.status === OrderStatus.PENDING_PAYMENT) {
payment.order.status = OrderStatus.PAID;
}
await em.flush();
return payment;
@@ -275,9 +271,7 @@ export class PaymentsService {
if (payment.status === PaymentStatusEnum.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.paidAt = new Date();
em.persist(payment);
@@ -52,4 +52,9 @@ export class CreateRestaurantDto {
@IsBoolean()
isActive?: boolean;
@ApiPropertyOptional({ example: false, description: 'فعال بودن چت هوش مصنوعی', default: false })
@IsOptional()
@IsBoolean()
enableAiChat?: boolean;
}
@@ -51,4 +51,9 @@ export class SetupRestaurantDto {
@IsBoolean()
isActive?: boolean;
@ApiPropertyOptional({ example: false, description: 'فعال بودن چت هوش مصنوعی', default: false })
@IsOptional()
@IsBoolean()
enableAiChat?: boolean;
}
@@ -40,6 +40,9 @@ export class Restaurant extends BaseEntity {
@Property({ default: true })
isActive: boolean = true;
@Property({ default: false })
enableAiChat: boolean = false;
@Property({ nullable: true })
establishedYear?: number;
@@ -7,4 +7,5 @@ export enum RestaurantCreditTransactionReason {
SMS_SEND = 'sms_send',
DEPOSIT = 'deposit',
ADJUSTMENT = 'adjustment',
AI_CHAT = 'ai_chat',
}
@@ -73,6 +73,7 @@ export class RestaurantsService {
establishedYear: dto.establishedYear,
phones: dto.phone ? [dto.phone] : undefined,
isActive: true,
enableAiChat: dto.enableAiChat ?? false,
plan: dto.plan,
domain: `https://dmenu.danakcorp.com/${slug}`,
subscriptionId: dto.subscriptionId,
@@ -4,6 +4,7 @@ import { CreateReviewDto } from '../dto/create-review.dto';
import { UpdateReviewDto } from '../dto/update-review.dto';
import { FindReviewsDto, FindRestuarantReviewsDto } from '../dto/find-reviews.dto';
import { ChangeStatusDto } from '../dto/change-status.dto';
import { ReplyReviewDto } from '../dto/reply-review.dto';
import {
ApiTags,
ApiOperation,
@@ -18,6 +19,7 @@ import {
import { AuthGuard } from 'src/modules/auth/guards/auth.guard';
import { AdminAuthGuard } from 'src/modules/auth/guards/adminAuth.guard';
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 { ReviewStatus } from '../enums/review-status.enum';
import { Permissions } from 'src/common/decorators/permissions.decorator';
@@ -114,7 +116,7 @@ export class ReviewController {
@ApiQuery({ name: 'orderBy', required: false, type: String })
@ApiQuery({ name: 'order', required: false, enum: ['asc', 'desc'] })
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)
@@ -135,7 +137,7 @@ export class ReviewController {
@ApiOperation({ summary: 'review detail' })
@ApiParam({ name: 'id', required: true })
reviewDetail(@Param('id') reviewId: string, @RestId() restaurantId: string) {
return this.reviewService.findById(reviewId, restaurantId);
return this.reviewService.findWithReplies(reviewId, restaurantId);
}
@UseGuards(AdminAuthGuard)
@@ -153,6 +155,33 @@ export class ReviewController {
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)
@Permissions(Permission.MANAGE_REVIEWS)
@ApiBearerAuth()
@@ -9,11 +9,6 @@ export class CreateReviewDto {
@ApiProperty({ description: 'Food ID' })
foodId: string;
@IsNotEmpty()
@IsString()
@ApiProperty({ description: 'Order ID' })
orderId: string;
@IsNotEmpty()
@IsNumber()
@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;
}
+14 -7
View File
@@ -2,24 +2,25 @@ import { Entity, Index, ManyToOne, Property, Unique, Enum } from '@mikro-orm/cor
import { BaseEntity } from '../../../common/entities/base.entity';
import { Food } from '../../foods/entities/food.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 { ReviewStatus } from '../enums/review-status.enum';
@Entity({ tableName: 'reviews' })
@Unique({ properties: ['order', 'food', 'user'] })
@Unique({ properties: ['food', 'user'] })
@Index({ properties: ['food', 'status'] })
@Index({ properties: ['user'] })
@Index({ properties: ['order'] })
@Index({ properties: ['parent'] })
@Index({ properties: ['admin'] })
export class Review extends BaseEntity {
@ManyToOne(() => Order)
order: Order;
@Property({ type: 'boolean' })
isBuyer: boolean = false
@ManyToOne(() => Food)
food: Food;
@ManyToOne(() => User)
user: User;
@ManyToOne(() => User, { nullable: true })
user?: User;
@Property({ type: 'text', nullable: true })
comment?: string;
@@ -36,4 +37,10 @@ export class Review extends BaseEntity {
@Enum(() => ReviewStatus)
@Property({ type: 'string', default: 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 foodId: string,
public readonly foodName: string,
public readonly orderId: string,
public readonly orderNumber: string | number,
public readonly rating: number,
) {}
}
@@ -46,13 +46,12 @@ export class ReviewListeners {
restaurantId: event.restaurantId,
message: {
title: NotifTitleEnum.REVIEW_CREATED,
content: `نظر جدید برای غذا "${event.foodName}" با امتیاز ${event.rating} از 5 برای سفارش شماره ${event.orderNumber} ثبت شد`,
content: `نظر جدید برای غذا "${event.foodName}" با امتیاز ${event.rating} از 5 برای سفارش شماره ثبت شد`,
sms: {
templateId: this.reviewCreatedSmsTemplateId,
parameters: {
foodName: event.foodName,
rating: event.rating.toString(),
orderNumber: String(event.orderNumber),
},
},
pushNotif: {
@@ -46,6 +46,7 @@ export class FoodRatingCronService {
food: { id: food.id },
status: ReviewStatus.APPROVED,
deletedAt: null,
parent: null,
},
{ fields: ['rating'] },
);
+110 -49
View File
@@ -1,21 +1,24 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { CreateReviewDto } from '../dto/create-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 { ReviewRepository } from '../repositories/review.repository';
import { FoodRepository } from '../../foods/repositories/food.repository';
import { UserRepository } from '../../users/repositories/user.repository';
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 { FoodMessage, UserMessage, ReviewMessage } from 'src/common/enums/message.enum';
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 { ReviewStatus } from '../enums/review-status.enum';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { ReviewCreatedEvent } from '../events/review.events';
import { sanitizeText } from '../../utils/sanitize.util';
import { OrderStatus } from 'src/modules/orders/interface/order.interface';
import { Admin } from '../../admin/entities/admin.entity';
@Injectable()
export class ReviewService {
@@ -28,9 +31,9 @@ export class ReviewService {
) { }
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) {
throw new NotFoundException(FoodMessage.NOT_FOUND);
}
@@ -40,34 +43,14 @@ export class ReviewService {
throw new NotFoundException(UserMessage.USER_NOT_FOUND);
}
// Find order and verify it belongs to the user, populate restaurant to get restaurantId
const order = await this.em.findOne(Order, { id: orderId, user: { id: userId } }, { populate: ['restaurant'] });
if (!order) {
throw new NotFoundException('Order not found or does not belong to the current user');
}
// 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 isBuyer = await this.em.count(Order, {
status: OrderStatus.COMPLETED,
items: { food: { id: foodId } },
user: { id: userId }
}) > 0;
const data: RequiredEntityData<Review> = {
order,
isBuyer: true,
food,
user,
rating,
@@ -92,12 +75,10 @@ export class ReviewService {
ReviewCreatedEvent.name,
new ReviewCreatedEvent(
review.id,
order.restaurant.id,
food.restaurant.id,
userId,
foodId,
food.title || 'Unknown Food',
orderId,
order.orderNumber || '',
food.title || '',
rating,
),
);
@@ -105,7 +86,7 @@ export class ReviewService {
return review;
}
async findAll(dto: FindReviewsDto) {
async findAll(dto: FindReviewsDto & { topLevel?: boolean }) {
return this.reviewRepository.findAllPaginated(dto);
}
@@ -118,25 +99,102 @@ export class ReviewService {
return this.reviewRepository.findAllPaginated({ ...restDto, restId: restaurant.id });
}
async findById(id: string, restaurantId: string): Promise<Review> {
const review = await this.reviewRepository.findOne(
{ id, order: { restaurant: { id: restaurantId } } },
{ populate: ['food', 'user', 'order'] },
async findWithReplies(reviewId: string, restaurantId: string): Promise<Review[]> {
const reviews = await this.reviewRepository.find(
{
$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);
}
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> {
const review = await this.reviewRepository.findOne({ id }, { populate: ['food', 'user', 'order'] });
const review = await this.reviewRepository.findOne({ id }, { populate: ['food', 'user',] });
if (!review) {
throw new NotFoundException(ReviewMessage.NOT_FOUND);
}
// 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);
}
@@ -165,7 +223,7 @@ export class ReviewService {
}
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) {
throw new NotFoundException(ReviewMessage.NOT_FOUND);
}
@@ -175,27 +233,30 @@ export class ReviewService {
}
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) {
throw new NotFoundException(ReviewMessage.NOT_FOUND);
}
// 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);
}
const foodId = review.food.id;
const isTopLevel = !review.parent;
review.deletedAt = new Date();
await this.em.persistAndFlush(review);
// Update food rating average
await this.updateFoodRating(foodId);
// Update food rating average only for top-level reviews
if (isTopLevel) {
await this.updateFoodRating(foodId);
}
}
private async updateFoodRating(foodId: string): Promise<void> {
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'] },
);
@@ -14,6 +14,8 @@ type FindReviewsOpts = {
orderBy?: string;
restId?: string;
order?: 'asc' | 'desc';
parentId?: string;
topLevel?: boolean;
};
@Injectable()
@@ -27,11 +29,20 @@ export class ReviewRepository extends EntityRepository<Review> {
* Supports: foodId, userId, status, ordering.
*/
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;
// Only list top-level reviews; replies are nested under their parent
const where: FilterQuery<Review> = {};
if (topLevel) {
where.parent = null;
}
if (parentId) {
where.parent = { id: parentId };
}
if (foodId) {
where.food = { id: foodId };
@@ -53,7 +64,7 @@ export class ReviewRepository extends EntityRepository<Review> {
limit,
offset,
orderBy: { [orderBy]: order.toLowerCase() as 'asc' | 'desc' },
populate: ['food', 'user'],
populate: ['user', 'admin', 'parent', 'parent.user'],
});
const totalPages = Math.ceil(total / limit);
@@ -33,15 +33,11 @@ export class RolesController {
@Get('admin/roles/permissions')
@UseGuards(AdminAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Get all permissions that the admin has' })
async findAllPermissions(@AdminId() adminId: string, @RestId() restId: string) {
const adminPermissionNames = await this.permissionService.getAdminFullPermissions(adminId, restId);
return adminPermissionNames;
@ApiOperation({ summary: 'Get all permissions ' })
async findAllPermissions() {
return this.permissionService.getPermissions();
}
@Post('admin/roles')
@UseGuards(AdminAuthGuard)
@Permissions(Permission.MANAGE_ROLES)
@@ -41,6 +41,10 @@ export class PermissionsService {
return permissionNames;
}
async getPermissions() {
return this.permissionRepository.findAll()
}
/**
* 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);
}
@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)
@ApiBearerAuth()
@Permissions(Permission.MANAGE_USERS)
@@ -5,7 +5,7 @@ import { EntityManager } from '@mikro-orm/postgresql';
import { OrderStatus } from '../../orders/interface/order.interface';
const COUNTED_ORDER_STATUSES = [
OrderStatus.PAID,
OrderStatus.NEW,
OrderStatus.PREPARING,
OrderStatus.DELIVERED_TO_WAITER,
OrderStatus.DELIVERED_TO_RECEPTIONIST,
+12 -1
View File
@@ -1,5 +1,5 @@
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 {
@ApiProperty({ description: "User's phone number", example: '09121234567' })
@@ -37,4 +37,15 @@ export class CreateUserDto {
@IsOptional()
@IsString()
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[];
}
+12 -1
View File
@@ -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';
export class UpdateUserDto {
@@ -32,4 +32,15 @@ export class UpdateUserDto {
@IsOptional()
@IsString()
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);
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] });
}
}
}
}
+33 -7
View File
@@ -25,6 +25,7 @@ import { UserRestaurant } from '../entities/user-restuarant.entity';
import { ImportUsersResult } from '../interfaces/import-users.interface';
import { assertExcelMimeType, parseUsersExcel } from '../utils/import-users-excel.util';
import { AuthMessage, UserImportMessage } from 'src/common/enums/message.enum';
import { UserGroupService } from './user-group.service';
@Injectable()
export class UserService {
@@ -35,6 +36,7 @@ export class UserService {
private readonly walletService: WalletService,
private readonly em: EntityManager,
private readonly userRestaurantRepository: UserRestaurantRepository,
private readonly userGroupService: UserGroupService,
) { }
async findOrCreateByPhone(phone: string): Promise<User> {
@@ -189,6 +191,10 @@ export class UserService {
this.em.persist(userRestaurant);
await this.em.flush();
if (dto.groupIds?.length) {
await this.userGroupService.assignUserToGroups(restId, user.id, dto.groupIds);
}
await this.em.populate(userRestaurant, ['user']);
return userRestaurant;
}
@@ -202,7 +208,26 @@ export class UserService {
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> {
@@ -211,15 +236,16 @@ export class UserService {
throw new NotFoundException(`User with ID ${userId} not found.`);
}
const { groupIds: _groupIds, ...userFields } = dto;
// Normalize date strings into Date objects if provided
const assignData: Partial<User> = { ...dto } as Partial<User>;
if (dto.birthDate) {
assignData.birthDate = new Date(dto.birthDate);
const assignData: Partial<User> = { ...userFields } as Partial<User>;
if (userFields.birthDate) {
assignData.birthDate = new Date(userFields.birthDate);
}
if (dto.marriageDate) {
assignData.marriageDate = new Date(dto.marriageDate);
if (userFields.marriageDate) {
assignData.marriageDate = new Date(userFields.marriageDate);
}
// get restuarant
this.em.assign(user, assignData);
await this.em.flush();
+1
View File
@@ -14,6 +14,7 @@ export class RestaurantsSeeder {
subscriptionStartDate: new Date('2026-01-01'),
subscriptionEndDate: new Date('2026-01-07'),
isActive: true,
enableAiChat: false,
});
em.persist(restaurant);
}