Compare commits
31
Commits
b7ca306097
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb0644e6c1 | ||
|
|
3558c441b9 | ||
|
|
a6c2d4764e | ||
|
|
d237d00f65 | ||
|
|
6f2ce12c0f | ||
|
|
a9e29b79fd | ||
|
|
e0cd2327eb | ||
|
|
63ae9b22b9 | ||
|
|
cffc828328 | ||
|
|
41d3f8f4eb | ||
|
|
7f6fa3672d | ||
|
|
0d9e866a57 | ||
|
|
0a330b8749 | ||
|
|
88ef4e82a1 | ||
|
|
1a49576752 | ||
|
|
4a9e10e614 | ||
|
|
2a01873c95 | ||
|
|
503b70a690 | ||
|
|
f8b865b0aa | ||
|
|
a1e73d1c9e | ||
|
|
00d0284454 | ||
|
|
ac04e51c8b | ||
|
|
94e9a8ed1c | ||
|
|
c6b7629c77 | ||
|
|
57800634ca | ||
|
|
d37b920c01 | ||
|
|
94cb7eaa33 | ||
|
|
50b716f040 | ||
|
|
5e7bdc0cf2 | ||
|
|
19c0ea25e1 | ||
|
|
95d851f9dd |
@@ -0,0 +1,6 @@
|
|||||||
|
-- Used / second-hand flag on store listings (product-level).
|
||||||
|
ALTER TABLE store_items
|
||||||
|
ADD COLUMN IF NOT EXISTS is_used BOOLEAN NOT NULL DEFAULT FALSE;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_store_items_business_is_used
|
||||||
|
ON store_items (business_id, is_used);
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- Free-text list field type for category technical forms (duplicatable values).
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TYPE technical_field_type ADD VALUE 'multi_value';
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN NULL;
|
||||||
|
END $$;
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
-- Meshkee CMS — invoice / template payment methods (نحوه ی پرداخت)
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- template payment methods (duplicatable free-text)
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
CREATE TABLE invoice_template_payment_methods (
|
||||||
|
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
|
template_id BIGINT NOT NULL,
|
||||||
|
text TEXT NOT NULL,
|
||||||
|
sort_order INT NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
|
||||||
|
CONSTRAINT invoice_template_payment_methods_template_id_fkey
|
||||||
|
FOREIGN KEY (template_id) REFERENCES invoice_templates (id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT invoice_template_payment_methods_text_nonempty
|
||||||
|
CHECK (char_length(trim(text)) > 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_invoice_template_payment_methods_template_id
|
||||||
|
ON invoice_template_payment_methods (template_id, sort_order);
|
||||||
|
|
||||||
|
CREATE TRIGGER invoice_template_payment_methods_set_updated_at
|
||||||
|
BEFORE UPDATE ON invoice_template_payment_methods
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION set_updated_at();
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- invoice payment methods (duplicatable free-text)
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
CREATE TABLE invoice_payment_methods (
|
||||||
|
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
|
invoice_id BIGINT NOT NULL,
|
||||||
|
text TEXT NOT NULL,
|
||||||
|
sort_order INT NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
|
||||||
|
CONSTRAINT invoice_payment_methods_invoice_id_fkey
|
||||||
|
FOREIGN KEY (invoice_id) REFERENCES invoices (id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT invoice_payment_methods_text_nonempty
|
||||||
|
CHECK (char_length(trim(text)) > 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_invoice_payment_methods_invoice_id
|
||||||
|
ON invoice_payment_methods (invoice_id, sort_order);
|
||||||
|
|
||||||
|
CREATE TRIGGER invoice_payment_methods_set_updated_at
|
||||||
|
BEFORE UPDATE ON invoice_payment_methods
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION set_updated_at();
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
-- Reusable bank account templates (platform or business), parallel to invoice_item_templates.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS invoice_account_templates (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
owner_scope invoice_owner_scope NOT NULL,
|
||||||
|
business_id BIGINT NULL REFERENCES businesses (id) ON DELETE CASCADE ON UPDATE NO ACTION,
|
||||||
|
bank_name VARCHAR(255) NOT NULL,
|
||||||
|
account_holder_name VARCHAR(255) NULL,
|
||||||
|
card_number VARCHAR(64) NULL,
|
||||||
|
iban VARCHAR(64) NULL,
|
||||||
|
sort_order INT NOT NULL DEFAULT 0,
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_invoice_account_templates_owner_scope
|
||||||
|
ON invoice_account_templates (owner_scope, sort_order);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_invoice_account_templates_business_id
|
||||||
|
ON invoice_account_templates (business_id);
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS invoice_account_templates_set_updated_at ON invoice_account_templates;
|
||||||
|
CREATE TRIGGER invoice_account_templates_set_updated_at
|
||||||
|
BEFORE UPDATE ON invoice_account_templates
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION set_updated_at();
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
-- Store loan / installment methods (اقساط و تسهیلات)
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS store_loan_methods (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
business_id BIGINT NOT NULL REFERENCES businesses (id) ON DELETE CASCADE ON UPDATE NO ACTION,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
name_en VARCHAR(255) NOT NULL,
|
||||||
|
description TEXT NULL,
|
||||||
|
image_media_id BIGINT NULL REFERENCES media (id) ON UPDATE NO ACTION,
|
||||||
|
image_aspect_ratio VARCHAR(10) NOT NULL DEFAULT '1:1',
|
||||||
|
min_shopping_amount NUMERIC(12, 2) NOT NULL,
|
||||||
|
min_amount NUMERIC(12, 2) NOT NULL,
|
||||||
|
max_amount NUMERIC(12, 2) NOT NULL,
|
||||||
|
interest NUMERIC(8, 4) NOT NULL,
|
||||||
|
return_months_min INT NOT NULL,
|
||||||
|
return_months_max INT NOT NULL,
|
||||||
|
return_months_step INT NOT NULL DEFAULT 3,
|
||||||
|
sort_order INT NOT NULL DEFAULT 0,
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_store_loan_methods_business_id
|
||||||
|
ON store_loan_methods (business_id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_store_loan_methods_image_media_id
|
||||||
|
ON store_loan_methods (image_media_id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_store_loan_methods_business_sort_order
|
||||||
|
ON store_loan_methods (business_id, sort_order);
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS store_loan_methods_set_updated_at ON store_loan_methods;
|
||||||
|
CREATE TRIGGER store_loan_methods_set_updated_at
|
||||||
|
BEFORE UPDATE ON store_loan_methods
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION set_updated_at();
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
-- Product catalog PDF (optional document on products) + media_type.document
|
||||||
|
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TYPE media_type ADD VALUE IF NOT EXISTS 'document';
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN NULL;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
ALTER TABLE products
|
||||||
|
ADD COLUMN IF NOT EXISTS catalog_media_id BIGINT NULL;
|
||||||
|
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE products
|
||||||
|
ADD CONSTRAINT products_catalog_media_id_fkey
|
||||||
|
FOREIGN KEY (catalog_media_id) REFERENCES media (id) ON UPDATE NO ACTION;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN NULL;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_products_catalog_media_id
|
||||||
|
ON products (catalog_media_id);
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
-- Shipping cost groups (هزینه ارسال) + per-city overrides + category assignment
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS shipping_cost_groups (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
business_id BIGINT NOT NULL REFERENCES businesses (id) ON DELETE CASCADE ON UPDATE NO ACTION,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
name_en VARCHAR(255) NOT NULL,
|
||||||
|
description TEXT NULL,
|
||||||
|
methods TEXT[] NOT NULL DEFAULT '{}',
|
||||||
|
default_amount NUMERIC(12, 2) NOT NULL DEFAULT 0,
|
||||||
|
sort_order INT NOT NULL DEFAULT 0,
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_shipping_cost_groups_business_id
|
||||||
|
ON shipping_cost_groups (business_id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_shipping_cost_groups_business_sort_order
|
||||||
|
ON shipping_cost_groups (business_id, sort_order);
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS shipping_cost_groups_set_updated_at ON shipping_cost_groups;
|
||||||
|
CREATE TRIGGER shipping_cost_groups_set_updated_at
|
||||||
|
BEFORE UPDATE ON shipping_cost_groups
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION set_updated_at();
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS shipping_cost_group_city_rates (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
shipping_cost_group_id BIGINT NOT NULL REFERENCES shipping_cost_groups (id) ON DELETE CASCADE ON UPDATE NO ACTION,
|
||||||
|
city_id BIGINT NOT NULL REFERENCES cities (id) ON DELETE CASCADE ON UPDATE NO ACTION,
|
||||||
|
amount NUMERIC(12, 2) NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
CONSTRAINT shipping_cost_group_city_rates_unique UNIQUE (shipping_cost_group_id, city_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_shipping_cost_group_city_rates_group_id
|
||||||
|
ON shipping_cost_group_city_rates (shipping_cost_group_id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_shipping_cost_group_city_rates_city_id
|
||||||
|
ON shipping_cost_group_city_rates (city_id);
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS shipping_cost_group_city_rates_set_updated_at ON shipping_cost_group_city_rates;
|
||||||
|
CREATE TRIGGER shipping_cost_group_city_rates_set_updated_at
|
||||||
|
BEFORE UPDATE ON shipping_cost_group_city_rates
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION set_updated_at();
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS shipping_cost_group_categories (
|
||||||
|
shipping_cost_group_id BIGINT NOT NULL REFERENCES shipping_cost_groups (id) ON DELETE CASCADE ON UPDATE NO ACTION,
|
||||||
|
category_id BIGINT NOT NULL REFERENCES categories (id) ON DELETE CASCADE ON UPDATE NO ACTION,
|
||||||
|
PRIMARY KEY (shipping_cost_group_id, category_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_shipping_cost_group_categories_category_id
|
||||||
|
ON shipping_cost_group_categories (category_id);
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
-- Default shipping cost group per business (fallback for unassigned categories)
|
||||||
|
|
||||||
|
ALTER TABLE shipping_cost_groups
|
||||||
|
ADD COLUMN IF NOT EXISTS is_default BOOLEAN NOT NULL DEFAULT FALSE;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_shipping_cost_groups_business_default
|
||||||
|
ON shipping_cost_groups (business_id)
|
||||||
|
WHERE is_default = TRUE;
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-- Freight-collect (پس کرایه): shipping is free at checkout but labeled for the customer
|
||||||
|
|
||||||
|
ALTER TABLE shipping_cost_groups
|
||||||
|
ADD COLUMN IF NOT EXISTS pas_kraye BOOLEAN NOT NULL DEFAULT FALSE;
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
-- Roll call / daily work log for business admins (customer dashboard)
|
||||||
|
|
||||||
|
CREATE TABLE roll_calls (
|
||||||
|
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
|
business_id BIGINT NOT NULL,
|
||||||
|
user_id BIGINT NOT NULL,
|
||||||
|
work_date DATE NOT NULL,
|
||||||
|
start_time VARCHAR(5) NOT NULL,
|
||||||
|
end_time VARCHAR(5) NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
|
||||||
|
CONSTRAINT roll_calls_business_id_fkey
|
||||||
|
FOREIGN KEY (business_id) REFERENCES businesses (id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT roll_calls_user_id_fkey
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT roll_calls_business_user_date_unique
|
||||||
|
UNIQUE (business_id, user_id, work_date),
|
||||||
|
CONSTRAINT roll_calls_start_time_format
|
||||||
|
CHECK (start_time ~ '^\d{2}:\d{2}$'),
|
||||||
|
CONSTRAINT roll_calls_end_time_format
|
||||||
|
CHECK (end_time ~ '^\d{2}:\d{2}$')
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_roll_calls_business_id ON roll_calls (business_id);
|
||||||
|
CREATE INDEX idx_roll_calls_user_id ON roll_calls (user_id);
|
||||||
|
CREATE INDEX idx_roll_calls_business_user_date
|
||||||
|
ON roll_calls (business_id, user_id, work_date DESC);
|
||||||
|
|
||||||
|
CREATE TRIGGER roll_calls_set_updated_at
|
||||||
|
BEFORE UPDATE ON roll_calls
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION set_updated_at();
|
||||||
|
|
||||||
|
CREATE TABLE roll_call_tasks (
|
||||||
|
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
|
roll_call_id BIGINT NOT NULL,
|
||||||
|
title VARCHAR(500) NOT NULL,
|
||||||
|
sort_order INT NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
|
||||||
|
CONSTRAINT roll_call_tasks_roll_call_id_fkey
|
||||||
|
FOREIGN KEY (roll_call_id) REFERENCES roll_calls (id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT roll_call_tasks_title_nonempty
|
||||||
|
CHECK (char_length(trim(title)) > 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_roll_call_tasks_roll_call_id ON roll_call_tasks (roll_call_id);
|
||||||
|
|
||||||
|
INSERT INTO permissions (name, slug, group_name, description) VALUES
|
||||||
|
('View roll calls', 'roll_calls.read', 'roll_calls', 'View roll call / work day logs'),
|
||||||
|
('Create roll calls', 'roll_calls.create', 'roll_calls', 'Create roll call / work day logs'),
|
||||||
|
('Update roll calls', 'roll_calls.update', 'roll_calls', 'Update roll call / work day logs'),
|
||||||
|
('Delete roll calls', 'roll_calls.delete', 'roll_calls', 'Delete roll call / work day logs')
|
||||||
|
ON CONFLICT (slug) DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO role_permissions (role_id, permission_id)
|
||||||
|
SELECT r.id, p.id
|
||||||
|
FROM roles r
|
||||||
|
JOIN permissions p ON p.slug LIKE 'roll_calls.%'
|
||||||
|
WHERE r.slug IN ('business_owner', 'owner', 'admin')
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
-- Roll call is for every staff role, including editor and viewer.
|
||||||
|
|
||||||
|
INSERT INTO role_permissions (role_id, permission_id)
|
||||||
|
SELECT r.id, p.id
|
||||||
|
FROM roles r
|
||||||
|
JOIN permissions p ON p.slug LIKE 'roll_calls.%'
|
||||||
|
WHERE r.slug IN ('editor', 'viewer', 'business_staff')
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
+43
-1
@@ -214,6 +214,8 @@ StoreItem 1──* StoreItemVariant (purchasable SKUs: price, stock, variation c
|
|||||||
StoreItemVariant 1──* StoreItemVariantSelection → CategoryVariationOption
|
StoreItemVariant 1──* StoreItemVariantSelection → CategoryVariationOption
|
||||||
Product 1──* ProductTechnicalFieldValue → CategoryTechnicalFormField
|
Product 1──* ProductTechnicalFieldValue → CategoryTechnicalFormField
|
||||||
|
|
||||||
|
Business 1──* StoreLoanMethod (installments / facilities; module `store_installments`)
|
||||||
|
|
||||||
UserProduct *── Category (via CategoryAssignment, entityType user_product → product categories)
|
UserProduct *── Category (via CategoryAssignment, entityType user_product → product categories)
|
||||||
UserProduct 1──* UserProductTechnicalFieldValue → CategoryTechnicalFormField
|
UserProduct 1──* UserProductTechnicalFieldValue → CategoryTechnicalFormField
|
||||||
UserProduct → City (country, city, optional district)
|
UserProduct → City (country, city, optional district)
|
||||||
@@ -284,6 +286,9 @@ All routes are prefixed with `/api/v1`.
|
|||||||
| GET | `/tenants/:host/portfolios/by-id/:portfolioId` | Public portfolio by id |
|
| GET | `/tenants/:host/portfolios/by-id/:portfolioId` | Public portfolio by id |
|
||||||
| POST | `/businesses/:id/website/sitemap/sync` | Import manifest from `GET https://{domain}/meshkee/sitemap-config.json` |
|
| POST | `/businesses/:id/website/sitemap/sync` | Import manifest from `GET https://{domain}/meshkee/sitemap-config.json` |
|
||||||
| GET | `/tenants/:host/store-specials` | Active store specials (`source` is `product` or `store_item`) |
|
| GET | `/tenants/:host/store-specials` | Active store specials (`source` is `product` or `store_item`) |
|
||||||
|
| GET | `/tenants/:host/store-loan-methods` | Active installment/credit methods (`enabled` + `items[]`; needs `store` + `store_installments`) |
|
||||||
|
| GET | `/tenants/:host/store-loan-methods/:methodId` | One active method |
|
||||||
|
| POST | `/tenants/:host/store-loan-methods/calculate` | Installment plan — body requires `methodId` (pick method first if multiple) |
|
||||||
| GET | `/tenants/:host/website/category-groups` | Homepage category rows |
|
| GET | `/tenants/:host/website/category-groups` | Homepage category rows |
|
||||||
| GET | `/tenants/:host/website/brand-groups` | Homepage brand rows |
|
| GET | `/tenants/:host/website/brand-groups` | Homepage brand rows |
|
||||||
| GET | `/tenants/:host/website/sliders` | Homepage sliders with slides |
|
| GET | `/tenants/:host/website/sliders` | Homepage sliders with slides |
|
||||||
@@ -518,6 +523,42 @@ Stored in `businesses.settings.website.specialProductsSource` (`product` | `stor
|
|||||||
- Dashboard: `PATCH /businesses/:id/settings` with `{ website: { specialProductsSource } }`.
|
- Dashboard: `PATCH /businesses/:id/settings` with `{ website: { specialProductsSource } }`.
|
||||||
- Public: `GET /tenants/:host` includes `specialProductsSource`; `GET /tenants/:host/store-specials` includes top-level `source`.
|
- Public: `GET /tenants/:host` includes `specialProductsSource`; `GET /tenants/:host/store-specials` includes top-level `source`.
|
||||||
|
|
||||||
|
### Store loan methods (installments & facilities)
|
||||||
|
|
||||||
|
Requires modules `store` + `store_installments` (opt-in). Dashboard: Store → اقساط و تسهیلات.
|
||||||
|
|
||||||
|
| Field | Notes |
|
||||||
|
|-------|-------|
|
||||||
|
| `nameFa` / `nameEn` | Display names |
|
||||||
|
| `minShoppingAmount` | Minimum purchase total to use this method |
|
||||||
|
| `minAmount` / `maxAmount` | Allowed credit range |
|
||||||
|
| `interest` | Flat annual %; fee = credit × (interest/100) × (months/12) |
|
||||||
|
| `returnMonthsMin` / `Max` / `Step` | Term options (step ∈ 2,3,4,6,12) |
|
||||||
|
| `imageMediaId` / `imageAspectRatio` | Optional image (`1:1` or `3:2`) |
|
||||||
|
|
||||||
|
**Dashboard (auth, `products.read` / `products.update`):**
|
||||||
|
- `GET/POST /businesses/:businessId/store-loan-methods`
|
||||||
|
- `GET/PATCH/DELETE /businesses/:businessId/store-loan-methods/:methodId`
|
||||||
|
|
||||||
|
**Public website:**
|
||||||
|
- `GET /tenants/:host/store-loan-methods` → `{ enabled, items[] }` (`enabled: false` + empty when module off)
|
||||||
|
- `GET /tenants/:host/store-loan-methods/:methodId` → `{ method }` (active only)
|
||||||
|
- `POST /tenants/:host/store-loan-methods/calculate` → `{ method, plan }` — **always requires `methodId`**
|
||||||
|
|
||||||
|
Calculator UX when a site has multiple methods: list first → if `items.length > 1`, ask the shopper which method → then collect purchase total / credit / months → `POST .../calculate`. If exactly one method, auto-select it. Never calculate without a chosen `methodId`.
|
||||||
|
|
||||||
|
```json
|
||||||
|
POST /tenants/:host/store-loan-methods/calculate
|
||||||
|
{
|
||||||
|
"methodId": "1",
|
||||||
|
"totalValue": 5000000,
|
||||||
|
"creditAmount": 2000000,
|
||||||
|
"months": 12
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`plan` includes `cashPayment`, `interestFee`, `totalRepay`, and monthly `installments[]` (`index`, `dueDate`, `amount`). Public items also include `returnMonthOptions[]`.
|
||||||
|
|
||||||
**Example — batch create variants for a product:**
|
**Example — batch create variants for a product:**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -659,7 +700,7 @@ See `.env.example` for the full list. Key groups:
|
|||||||
- Multi-tenant auth (register, login, passwordless OTP login, reset password via SMS, profile)
|
- Multi-tenant auth (register, login, passwordless OTP login, reset password via SMS, profile)
|
||||||
- Super admin: users, businesses, domains, system business categories
|
- Super admin: users, businesses, domains, system business categories
|
||||||
- Super admin add/update domain upserts tenant DNS via ArvanCloud or Cloudflare (`@` ANAME/CNAME, `www`/`business`/`customer`/`api` CNAMEs; Cloudflare DNS-only)
|
- Super admin add/update domain upserts tenant DNS via ArvanCloud or Cloudflare (`@` ANAME/CNAME, `www`/`business`/`customer`/`api` CNAMEs; Cloudflare DNS-only)
|
||||||
- Super admin Websites ⋯ **Email DNS** upserts Stalwart mail records (mail A `185.214.101.41`, MX `mail.meshkee.com`, SPF, two DKIM TXT, autoconfig/autodiscover) on Arvan or Cloudflare; removes extra MX, `dkim._domainkey`, and `_dmarc`
|
- Super admin Websites ⋯ **Email DNS** upserts Stalwart mail records (mail A `185.214.101.41`, MX `mail.meshkee.com`, SPF `v=spf1 mx -all`, RSA DKIM required, Ed25519 DKIM optional, autoconfig/autodiscover) on Arvan or Cloudflare; removes extra MX, `dkim._domainkey`, and `_dmarc`
|
||||||
- Super admin: selective migrate-from-old + purge-data (portfolio categories + portfolios; oversized images resized to max 1280×1280; purge removes portfolios + images)
|
- Super admin: selective migrate-from-old + purge-data (portfolio categories + portfolios; oversized images resized to max 1280×1280; purge removes portfolios + images)
|
||||||
- Business team management
|
- Business team management
|
||||||
- Media upload (S3 + Sharp)
|
- Media upload (S3 + Sharp)
|
||||||
@@ -667,6 +708,7 @@ See `.env.example` for the full list. Key groups:
|
|||||||
- Products CRUD
|
- Products CRUD
|
||||||
- Product variation values (which options a product offers)
|
- Product variation values (which options a product offers)
|
||||||
- Store items / product variants (price, stock, SKU)
|
- Store items / product variants (price, stock, SKU)
|
||||||
|
- Store loan methods / installments (`store_installments`): dashboard CRUD + public list/get/calculate
|
||||||
- Torob Product API v3 (`POST /tenants/:host/torob_api/v3/products`) for store-module websites; nginx proxies `/torob_api/v3/products` on the shop apex
|
- Torob Product API v3 (`POST /tenants/:host/torob_api/v3/products`) for store-module websites; nginx proxies `/torob_api/v3/products` on the shop apex
|
||||||
- Shopping cart + checkout + orders (customer + admin)
|
- Shopping cart + checkout + orders (customer + admin)
|
||||||
- Online e-payment (Mellat + ZarinPal; stubs for SEP / Snapp Pay / DigiPay)
|
- Online e-payment (Mellat + ZarinPal; stubs for SEP / Snapp Pay / DigiPay)
|
||||||
|
|||||||
@@ -331,6 +331,24 @@ These are **required on every crawlable page** (home, listing, product/blog/port
|
|||||||
**Open Graph (recommended)**
|
**Open Graph (recommended)**
|
||||||
- Set `og:title`, `og:description`, and `og:image` on important pages (home + detail pages) from CMS media when available.
|
- Set `og:title`, `og:description`, and `og:image` on important pages (home + detail pages) from CMS media when available.
|
||||||
|
|
||||||
|
### Site-wide meta tags + trust badges (eNamad / Samandehi)
|
||||||
|
|
||||||
|
Business admins configure this under **Website → Tags & Badges**.
|
||||||
|
|
||||||
|
1. `GET /tenants/{domain}/website/business-info`
|
||||||
|
2. For each item in `metaTags` (array of `{ html }`), emit the full HTML once in the root layout `<head>`.
|
||||||
|
|
||||||
|
```html
|
||||||
|
{html}
|
||||||
|
```
|
||||||
|
|
||||||
|
Example `html` value: `<meta name="google-site-verification" content="…">` (also supports `property`, `http-equiv`, etc.). Parse attributes into a `<meta />` element, or inject the sanitized string into `<head>`. Do **not** wrap it again as `name`/`content` — `html` is already the whole tag. The dashboard “Name” field is an admin label only and is **not** in this public payload.
|
||||||
|
|
||||||
|
3. For each item in `trustBadges` (array of `{ kind, embedHtml }`), render `embedHtml` in the site footer (e.g. `dangerouslySetInnerHTML`). These are HTML widgets only (not meta). Scripts are stripped by the API — still treat the HTML as CMS-controlled content.
|
||||||
|
|
||||||
|
- When arrays are empty, omit tags / footer badges.
|
||||||
|
- Do **not** hardcode eNamad/Samandehi HTML in the website repo when these fields are present.
|
||||||
|
|
||||||
### Site-wide Schema.org JSON-LD (Organization / LocalBusiness)
|
### Site-wide Schema.org JSON-LD (Organization / LocalBusiness)
|
||||||
|
|
||||||
Business admins configure this under **Website → Settings → Structured data**. The API builds a ready `jsonLd` object for you.
|
Business admins configure this under **Website → Settings → Structured data**. The API builds a ready `jsonLd` object for you.
|
||||||
@@ -398,6 +416,7 @@ Website rules:
|
|||||||
4. Public URL is included via CMS sitemap and/or `sitemap-config.json` static pages
|
4. Public URL is included via CMS sitemap and/or `sitemap-config.json` static pages
|
||||||
5. Site-wide Organization/LocalBusiness JSON-LD from `business-info.schema.jsonLd` when enabled
|
5. Site-wide Organization/LocalBusiness JSON-LD from `business-info.schema.jsonLd` when enabled
|
||||||
6. Product/blog detail pages emit `product.schema.jsonLd` / `blog.schema.jsonLd` when present
|
6. Product/blog detail pages emit `product.schema.jsonLd` / `blog.schema.jsonLd` when present
|
||||||
|
7. Site-wide `metaTags` + footer `trustBadges` from business-info when present
|
||||||
|
|
||||||
If OpenAPI and this brief conflict, **OpenAPI wins**.
|
If OpenAPI and this brief conflict, **OpenAPI wins**.
|
||||||
|
|
||||||
|
|||||||
@@ -567,6 +567,38 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": ["enabled", "type", "sameAs", "jsonLd"]
|
"required": ["enabled", "type", "sameAs", "jsonLd"]
|
||||||
|
},
|
||||||
|
"metaTags": {
|
||||||
|
"type": "array",
|
||||||
|
"description": "Custom <meta> HTML snippets for the site <head>. Each item is { html } with a full <meta …> tag. Configured under Website → Tags & Badges (name there is an admin label only).",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"html": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Full sanitized <meta …> HTML tag to inject in <head>."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["html"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"trustBadges": {
|
||||||
|
"type": "array",
|
||||||
|
"description": "Enabled eNamad / Samandehi footer widgets (sanitized HTML). Empty when none are shown.",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"kind": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["enamad", "samandehi"]
|
||||||
|
},
|
||||||
|
"embedHtml": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Trusted HTML snippet (scripts stripped server-side)."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["kind", "embedHtml"]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+178
-5
@@ -51,6 +51,7 @@ model User {
|
|||||||
userProducts UserProduct[]
|
userProducts UserProduct[]
|
||||||
userRoles UserRole[]
|
userRoles UserRole[]
|
||||||
smsTemplatesVerified SmsTemplate[] @relation("SmsTemplateVerifier")
|
smsTemplatesVerified SmsTemplate[] @relation("SmsTemplateVerifier")
|
||||||
|
rollCalls RollCall[]
|
||||||
|
|
||||||
@@index([cellNumber], map: "idx_users_cell_number")
|
@@index([cellNumber], map: "idx_users_cell_number")
|
||||||
@@map("users")
|
@@map("users")
|
||||||
@@ -143,6 +144,7 @@ model Business {
|
|||||||
expertReviews ExpertReview[]
|
expertReviews ExpertReview[]
|
||||||
favorites Favorite[]
|
favorites Favorite[]
|
||||||
invoiceItemTemplates InvoiceItemTemplate[]
|
invoiceItemTemplates InvoiceItemTemplate[]
|
||||||
|
invoiceAccountTemplates InvoiceAccountTemplate[]
|
||||||
invoiceTemplates InvoiceTemplate[]
|
invoiceTemplates InvoiceTemplate[]
|
||||||
invoices Invoice[] @relation("InvoiceBusiness")
|
invoices Invoice[] @relation("InvoiceBusiness")
|
||||||
invoicesIssued Invoice[] @relation("InvoiceIssuerBusiness")
|
invoicesIssued Invoice[] @relation("InvoiceIssuerBusiness")
|
||||||
@@ -159,6 +161,8 @@ model Business {
|
|||||||
shoppingCards ShoppingCard[]
|
shoppingCards ShoppingCard[]
|
||||||
storeItemVariants StoreItemVariant[]
|
storeItemVariants StoreItemVariant[]
|
||||||
storeItems StoreItem[]
|
storeItems StoreItem[]
|
||||||
|
storeLoanMethods StoreLoanMethod[]
|
||||||
|
shippingCostGroups ShippingCostGroup[]
|
||||||
storeSpecials StoreSpecial[]
|
storeSpecials StoreSpecial[]
|
||||||
transactions Transaction[]
|
transactions Transaction[]
|
||||||
userProductTechnicalFieldValues UserProductTechnicalFieldValue[]
|
userProductTechnicalFieldValues UserProductTechnicalFieldValue[]
|
||||||
@@ -169,6 +173,7 @@ model Business {
|
|||||||
website_sliders website_sliders[]
|
website_sliders website_sliders[]
|
||||||
websitePageViews WebsitePageView[]
|
websitePageViews WebsitePageView[]
|
||||||
websitePageViewCounters WebsitePageViewCounter[]
|
websitePageViewCounters WebsitePageViewCounter[]
|
||||||
|
rollCalls RollCall[]
|
||||||
|
|
||||||
@@map("businesses")
|
@@map("businesses")
|
||||||
}
|
}
|
||||||
@@ -378,6 +383,7 @@ model Media {
|
|||||||
oldId BigInt? @map("old_id")
|
oldId BigInt? @map("old_id")
|
||||||
blogs blogs[]
|
blogs blogs[]
|
||||||
brandImages Brand[]
|
brandImages Brand[]
|
||||||
|
storeLoanMethodImages StoreLoanMethod[]
|
||||||
faviconBusinesses Business[] @relation("BusinessFavicon")
|
faviconBusinesses Business[] @relation("BusinessFavicon")
|
||||||
logoBusinesses Business[] @relation("BusinessLogo")
|
logoBusinesses Business[] @relation("BusinessLogo")
|
||||||
logoDarkBusinesses Business[] @relation("BusinessLogoDark")
|
logoDarkBusinesses Business[] @relation("BusinessLogoDark")
|
||||||
@@ -390,6 +396,7 @@ model Media {
|
|||||||
instructions instructions[]
|
instructions instructions[]
|
||||||
workshops workshops[]
|
workshops workshops[]
|
||||||
featuredProducts Product[] @relation("ProductFeaturedMedia")
|
featuredProducts Product[] @relation("ProductFeaturedMedia")
|
||||||
|
catalogProducts Product[] @relation("ProductCatalogMedia")
|
||||||
featuredUserProducts UserProduct[] @relation("UserProductFeaturedMedia")
|
featuredUserProducts UserProduct[] @relation("UserProductFeaturedMedia")
|
||||||
website_image_slot_items website_image_slot_items[]
|
website_image_slot_items website_image_slot_items[]
|
||||||
website_slider_slides website_slider_slides[]
|
website_slider_slides website_slider_slides[]
|
||||||
@@ -439,6 +446,7 @@ model Category {
|
|||||||
assignments CategoryAssignment[]
|
assignments CategoryAssignment[]
|
||||||
technicalForm CategoryTechnicalForm?
|
technicalForm CategoryTechnicalForm?
|
||||||
variations CategoryVariation[]
|
variations CategoryVariation[]
|
||||||
|
shippingCostGroupCategories ShippingCostGroupCategory[]
|
||||||
website_category_group_items website_category_group_items[]
|
website_category_group_items website_category_group_items[]
|
||||||
|
|
||||||
@@unique([businessId, entityType, slug], map: "categories_business_entity_slug_unique")
|
@@unique([businessId, entityType, slug], map: "categories_business_entity_slug_unique")
|
||||||
@@ -521,6 +529,7 @@ model Product {
|
|||||||
stockQuantity Int? @map("stock_quantity")
|
stockQuantity Int? @map("stock_quantity")
|
||||||
status ContentStatus @default(draft)
|
status ContentStatus @default(draft)
|
||||||
featuredMediaId BigInt? @map("featured_media_id")
|
featuredMediaId BigInt? @map("featured_media_id")
|
||||||
|
catalogMediaId BigInt? @map("catalog_media_id")
|
||||||
sortOrder Int @default(0) @map("sort_order")
|
sortOrder Int @default(0) @map("sort_order")
|
||||||
publishedAt DateTime? @map("published_at") @db.Timestamptz(6)
|
publishedAt DateTime? @map("published_at") @db.Timestamptz(6)
|
||||||
metadata Json @default("{}")
|
metadata Json @default("{}")
|
||||||
@@ -538,6 +547,7 @@ model Product {
|
|||||||
brand Brand? @relation(fields: [brandId], references: [id], onUpdate: NoAction)
|
brand Brand? @relation(fields: [brandId], references: [id], onUpdate: NoAction)
|
||||||
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||||
featuredMedia Media? @relation("ProductFeaturedMedia", fields: [featuredMediaId], references: [id], onUpdate: NoAction)
|
featuredMedia Media? @relation("ProductFeaturedMedia", fields: [featuredMediaId], references: [id], onUpdate: NoAction)
|
||||||
|
catalogMedia Media? @relation("ProductCatalogMedia", fields: [catalogMediaId], references: [id], onUpdate: NoAction)
|
||||||
shoppingCardItems ShoppingCardItem[]
|
shoppingCardItems ShoppingCardItem[]
|
||||||
storeItem StoreItem[]
|
storeItem StoreItem[]
|
||||||
|
|
||||||
@@ -546,6 +556,7 @@ model Product {
|
|||||||
@@index([brandId], map: "idx_products_brand_id")
|
@@index([brandId], map: "idx_products_brand_id")
|
||||||
@@index([businessId, status], map: "idx_products_business_status")
|
@@index([businessId, status], map: "idx_products_business_status")
|
||||||
@@index([businessId, publishedAt(sort: Desc)], map: "idx_products_business_published")
|
@@index([businessId, publishedAt(sort: Desc)], map: "idx_products_business_published")
|
||||||
|
@@index([catalogMediaId], map: "idx_products_catalog_media_id")
|
||||||
@@map("products")
|
@@map("products")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -936,6 +947,7 @@ model City {
|
|||||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||||
parent City? @relation("CityTree", fields: [parentId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
parent City? @relation("CityTree", fields: [parentId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||||
children City[] @relation("CityTree")
|
children City[] @relation("CityTree")
|
||||||
|
shippingCostCityRates ShippingCostGroupCityRate[]
|
||||||
userProductsAsCity UserProduct[] @relation("UserProductCity")
|
userProductsAsCity UserProduct[] @relation("UserProductCity")
|
||||||
userProductsAsCountry UserProduct[] @relation("UserProductCountry")
|
userProductsAsCountry UserProduct[] @relation("UserProductCountry")
|
||||||
userProductsAsDistrict UserProduct[] @relation("UserProductDistrict")
|
userProductsAsDistrict UserProduct[] @relation("UserProductDistrict")
|
||||||
@@ -1141,6 +1153,7 @@ model StoreItem {
|
|||||||
businessId BigInt @map("business_id")
|
businessId BigInt @map("business_id")
|
||||||
productId BigInt @map("product_id")
|
productId BigInt @map("product_id")
|
||||||
isActive Boolean @default(true) @map("is_active")
|
isActive Boolean @default(true) @map("is_active")
|
||||||
|
isUsed Boolean @default(false) @map("is_used")
|
||||||
sortOrder Int @default(0) @map("sort_order")
|
sortOrder Int @default(0) @map("sort_order")
|
||||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||||
@@ -1152,6 +1165,7 @@ model StoreItem {
|
|||||||
@@unique([businessId, productId], map: "store_items_business_product_unique")
|
@@unique([businessId, productId], map: "store_items_business_product_unique")
|
||||||
@@index([businessId], map: "idx_store_items_business_id")
|
@@index([businessId], map: "idx_store_items_business_id")
|
||||||
@@index([productId], map: "idx_store_items_product_id")
|
@@index([productId], map: "idx_store_items_product_id")
|
||||||
|
@@index([businessId, isUsed], map: "idx_store_items_business_is_used")
|
||||||
@@map("store_items")
|
@@map("store_items")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1217,6 +1231,84 @@ model StoreSpecial {
|
|||||||
@@map("store_specials")
|
@@map("store_specials")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model StoreLoanMethod {
|
||||||
|
id BigInt @id @default(autoincrement())
|
||||||
|
businessId BigInt @map("business_id")
|
||||||
|
nameFa String @map("name") @db.VarChar(255)
|
||||||
|
nameEn String @map("name_en") @db.VarChar(255)
|
||||||
|
description String?
|
||||||
|
imageMediaId BigInt? @map("image_media_id")
|
||||||
|
imageAspectRatio String @default("1:1") @map("image_aspect_ratio") @db.VarChar(10)
|
||||||
|
minShoppingAmount Decimal @map("min_shopping_amount") @db.Decimal(12, 2)
|
||||||
|
minAmount Decimal @map("min_amount") @db.Decimal(12, 2)
|
||||||
|
maxAmount Decimal @map("max_amount") @db.Decimal(12, 2)
|
||||||
|
interest Decimal @db.Decimal(8, 4)
|
||||||
|
returnMonthsMin Int @map("return_months_min")
|
||||||
|
returnMonthsMax Int @map("return_months_max")
|
||||||
|
returnMonthsStep Int @default(3) @map("return_months_step")
|
||||||
|
sortOrder Int @default(0) @map("sort_order")
|
||||||
|
isActive Boolean @default(true) @map("is_active")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||||
|
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||||
|
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||||
|
imageMedia Media? @relation(fields: [imageMediaId], references: [id], onUpdate: NoAction)
|
||||||
|
|
||||||
|
@@index([businessId], map: "idx_store_loan_methods_business_id")
|
||||||
|
@@index([imageMediaId], map: "idx_store_loan_methods_image_media_id")
|
||||||
|
@@index([businessId, sortOrder], map: "idx_store_loan_methods_business_sort_order")
|
||||||
|
@@map("store_loan_methods")
|
||||||
|
}
|
||||||
|
|
||||||
|
model ShippingCostGroup {
|
||||||
|
id BigInt @id @default(autoincrement())
|
||||||
|
businessId BigInt @map("business_id")
|
||||||
|
nameFa String @map("name") @db.VarChar(255)
|
||||||
|
nameEn String @map("name_en") @db.VarChar(255)
|
||||||
|
description String?
|
||||||
|
methods String[] @default([])
|
||||||
|
defaultAmount Decimal @default(0) @map("default_amount") @db.Decimal(12, 2)
|
||||||
|
sortOrder Int @default(0) @map("sort_order")
|
||||||
|
isActive Boolean @default(true) @map("is_active")
|
||||||
|
isDefault Boolean @default(false) @map("is_default")
|
||||||
|
pasKraye Boolean @default(false) @map("pas_kraye")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||||
|
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||||
|
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||||
|
cityRates ShippingCostGroupCityRate[]
|
||||||
|
categories ShippingCostGroupCategory[]
|
||||||
|
|
||||||
|
@@index([businessId], map: "idx_shipping_cost_groups_business_id")
|
||||||
|
@@index([businessId, sortOrder], map: "idx_shipping_cost_groups_business_sort_order")
|
||||||
|
@@map("shipping_cost_groups")
|
||||||
|
}
|
||||||
|
|
||||||
|
model ShippingCostGroupCityRate {
|
||||||
|
id BigInt @id @default(autoincrement())
|
||||||
|
shippingCostGroupId BigInt @map("shipping_cost_group_id")
|
||||||
|
cityId BigInt @map("city_id")
|
||||||
|
amount Decimal @db.Decimal(12, 2)
|
||||||
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||||
|
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||||
|
shippingCostGroup ShippingCostGroup @relation(fields: [shippingCostGroupId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||||
|
city City @relation(fields: [cityId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||||
|
|
||||||
|
@@unique([shippingCostGroupId, cityId], map: "shipping_cost_group_city_rates_unique")
|
||||||
|
@@index([shippingCostGroupId], map: "idx_shipping_cost_group_city_rates_group_id")
|
||||||
|
@@index([cityId], map: "idx_shipping_cost_group_city_rates_city_id")
|
||||||
|
@@map("shipping_cost_group_city_rates")
|
||||||
|
}
|
||||||
|
|
||||||
|
model ShippingCostGroupCategory {
|
||||||
|
shippingCostGroupId BigInt @map("shipping_cost_group_id")
|
||||||
|
categoryId BigInt @map("category_id")
|
||||||
|
shippingCostGroup ShippingCostGroup @relation(fields: [shippingCostGroupId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||||
|
category Category @relation(fields: [categoryId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||||
|
|
||||||
|
@@id([shippingCostGroupId, categoryId])
|
||||||
|
@@index([categoryId], map: "idx_shipping_cost_group_categories_category_id")
|
||||||
|
@@map("shipping_cost_group_categories")
|
||||||
|
}
|
||||||
|
|
||||||
model website_brand_group_items {
|
model website_brand_group_items {
|
||||||
id BigInt @id @default(autoincrement())
|
id BigInt @id @default(autoincrement())
|
||||||
group_id BigInt
|
group_id BigInt
|
||||||
@@ -1368,6 +1460,25 @@ model InvoiceItemTemplate {
|
|||||||
@@map("invoice_item_templates")
|
@@map("invoice_item_templates")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model InvoiceAccountTemplate {
|
||||||
|
id BigInt @id @default(autoincrement())
|
||||||
|
ownerScope InvoiceOwnerScope @map("owner_scope")
|
||||||
|
businessId BigInt? @map("business_id")
|
||||||
|
bankName String @map("bank_name") @db.VarChar(255)
|
||||||
|
accountHolderName String? @map("account_holder_name") @db.VarChar(255)
|
||||||
|
cardNumber String? @map("card_number") @db.VarChar(64)
|
||||||
|
iban String? @db.VarChar(64)
|
||||||
|
sortOrder Int @default(0) @map("sort_order")
|
||||||
|
isActive Boolean @default(true) @map("is_active")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||||
|
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||||
|
business Business? @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||||
|
|
||||||
|
@@index([ownerScope, sortOrder], map: "idx_invoice_account_templates_owner_scope")
|
||||||
|
@@index([businessId], map: "idx_invoice_account_templates_business_id")
|
||||||
|
@@map("invoice_account_templates")
|
||||||
|
}
|
||||||
|
|
||||||
model InvoiceTemplate {
|
model InvoiceTemplate {
|
||||||
id BigInt @id @default(autoincrement())
|
id BigInt @id @default(autoincrement())
|
||||||
ownerScope InvoiceOwnerScope @map("owner_scope")
|
ownerScope InvoiceOwnerScope @map("owner_scope")
|
||||||
@@ -1378,11 +1489,12 @@ model InvoiceTemplate {
|
|||||||
isActive Boolean @default(true) @map("is_active")
|
isActive Boolean @default(true) @map("is_active")
|
||||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||||
accounts InvoiceTemplateAccount[]
|
accounts InvoiceTemplateAccount[]
|
||||||
items InvoiceTemplateItem[]
|
items InvoiceTemplateItem[]
|
||||||
keyPoints InvoiceTemplateKeyPoint[]
|
keyPoints InvoiceTemplateKeyPoint[]
|
||||||
business Business? @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
paymentMethods InvoiceTemplatePaymentMethod[]
|
||||||
invoices Invoice[]
|
business Business? @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||||
|
invoices Invoice[]
|
||||||
|
|
||||||
@@index([ownerScope, sortOrder], map: "idx_invoice_templates_owner_scope")
|
@@index([ownerScope, sortOrder], map: "idx_invoice_templates_owner_scope")
|
||||||
@@map("invoice_templates")
|
@@map("invoice_templates")
|
||||||
@@ -1421,6 +1533,19 @@ model InvoiceTemplateKeyPoint {
|
|||||||
@@map("invoice_template_key_points")
|
@@map("invoice_template_key_points")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model InvoiceTemplatePaymentMethod {
|
||||||
|
id BigInt @id @default(autoincrement())
|
||||||
|
templateId BigInt @map("template_id")
|
||||||
|
text String
|
||||||
|
sortOrder Int @default(0) @map("sort_order")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||||
|
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||||
|
template InvoiceTemplate @relation(fields: [templateId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||||
|
|
||||||
|
@@index([templateId, sortOrder], map: "idx_invoice_template_payment_methods_template_id")
|
||||||
|
@@map("invoice_template_payment_methods")
|
||||||
|
}
|
||||||
|
|
||||||
model InvoiceTemplateAccount {
|
model InvoiceTemplateAccount {
|
||||||
id BigInt @id @default(autoincrement())
|
id BigInt @id @default(autoincrement())
|
||||||
templateId BigInt @map("template_id")
|
templateId BigInt @map("template_id")
|
||||||
@@ -1456,6 +1581,7 @@ model Invoice {
|
|||||||
accounts InvoiceAccount[]
|
accounts InvoiceAccount[]
|
||||||
items InvoiceItem[]
|
items InvoiceItem[]
|
||||||
keyPoints InvoiceKeyPoint[]
|
keyPoints InvoiceKeyPoint[]
|
||||||
|
paymentMethods InvoicePaymentMethod[]
|
||||||
business Business @relation("InvoiceBusiness", fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
business Business @relation("InvoiceBusiness", fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||||
invoiceTemplate InvoiceTemplate? @relation(fields: [invoiceTemplateId], references: [id], onUpdate: NoAction)
|
invoiceTemplate InvoiceTemplate? @relation(fields: [invoiceTemplateId], references: [id], onUpdate: NoAction)
|
||||||
issuer User? @relation("InvoiceIssuer", fields: [issuedBy], references: [id], onUpdate: NoAction)
|
issuer User? @relation("InvoiceIssuer", fields: [issuedBy], references: [id], onUpdate: NoAction)
|
||||||
@@ -1502,6 +1628,19 @@ model InvoiceKeyPoint {
|
|||||||
@@map("invoice_key_points")
|
@@map("invoice_key_points")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model InvoicePaymentMethod {
|
||||||
|
id BigInt @id @default(autoincrement())
|
||||||
|
invoiceId BigInt @map("invoice_id")
|
||||||
|
text String
|
||||||
|
sortOrder Int @default(0) @map("sort_order")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||||
|
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||||
|
invoice Invoice @relation(fields: [invoiceId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||||
|
|
||||||
|
@@index([invoiceId, sortOrder], map: "idx_invoice_payment_methods_invoice_id")
|
||||||
|
@@map("invoice_payment_methods")
|
||||||
|
}
|
||||||
|
|
||||||
model InvoiceAccount {
|
model InvoiceAccount {
|
||||||
id BigInt @id @default(autoincrement())
|
id BigInt @id @default(autoincrement())
|
||||||
invoiceId BigInt @map("invoice_id")
|
invoiceId BigInt @map("invoice_id")
|
||||||
@@ -1603,6 +1742,7 @@ model UserProduct {
|
|||||||
enum MediaType {
|
enum MediaType {
|
||||||
image
|
image
|
||||||
video
|
video
|
||||||
|
document
|
||||||
|
|
||||||
@@map("media_type")
|
@@map("media_type")
|
||||||
}
|
}
|
||||||
@@ -1658,6 +1798,7 @@ enum TechnicalFieldType {
|
|||||||
textarea
|
textarea
|
||||||
select
|
select
|
||||||
multi_select
|
multi_select
|
||||||
|
multi_value
|
||||||
|
|
||||||
@@map("technical_field_type")
|
@@map("technical_field_type")
|
||||||
}
|
}
|
||||||
@@ -1823,3 +1964,35 @@ enum TranslationStatus {
|
|||||||
|
|
||||||
@@map("translation_status")
|
@@map("translation_status")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model RollCall {
|
||||||
|
id BigInt @id @default(autoincrement())
|
||||||
|
businessId BigInt @map("business_id")
|
||||||
|
userId BigInt @map("user_id")
|
||||||
|
workDate DateTime @map("work_date") @db.Date
|
||||||
|
startTime String @map("start_time") @db.VarChar(5)
|
||||||
|
endTime String @map("end_time") @db.VarChar(5)
|
||||||
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||||
|
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||||
|
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||||
|
tasks RollCallTask[]
|
||||||
|
|
||||||
|
@@unique([businessId, userId, workDate], map: "roll_calls_business_user_date_unique")
|
||||||
|
@@index([businessId], map: "idx_roll_calls_business_id")
|
||||||
|
@@index([userId], map: "idx_roll_calls_user_id")
|
||||||
|
@@index([businessId, userId, workDate(sort: Desc)], map: "idx_roll_calls_business_user_date")
|
||||||
|
@@map("roll_calls")
|
||||||
|
}
|
||||||
|
|
||||||
|
model RollCallTask {
|
||||||
|
id BigInt @id @default(autoincrement())
|
||||||
|
rollCallId BigInt @map("roll_call_id")
|
||||||
|
title String @db.VarChar(500)
|
||||||
|
sortOrder Int @default(0) @map("sort_order")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||||
|
rollCall RollCall @relation(fields: [rollCallId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||||
|
|
||||||
|
@@index([rollCallId], map: "idx_roll_call_tasks_roll_call_id")
|
||||||
|
@@map("roll_call_tasks")
|
||||||
|
}
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ import { PaymentsModule } from './payments/payments.module';
|
|||||||
import { SitemapModule } from './sitemap/sitemap.module';
|
import { SitemapModule } from './sitemap/sitemap.module';
|
||||||
import { SmsTemplatesModule } from './sms-templates/sms-templates.module';
|
import { SmsTemplatesModule } from './sms-templates/sms-templates.module';
|
||||||
import { TorobModule } from './torob/torob.module';
|
import { TorobModule } from './torob/torob.module';
|
||||||
|
import { RollCallsModule } from './roll-calls/roll-calls.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -98,6 +99,7 @@ import { TorobModule } from './torob/torob.module';
|
|||||||
SitemapModule,
|
SitemapModule,
|
||||||
SmsTemplatesModule,
|
SmsTemplatesModule,
|
||||||
TorobModule,
|
TorobModule,
|
||||||
|
RollCallsModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ type ArvanRecordType = 'aname' | 'cname' | 'a' | 'mx' | 'txt';
|
|||||||
type DesiredRecord = {
|
type DesiredRecord = {
|
||||||
type: ArvanRecordType;
|
type: ArvanRecordType;
|
||||||
name: string;
|
name: string;
|
||||||
value: Record<string, unknown>;
|
/** A records use an array of `{ ip }`; other types use a single object. */
|
||||||
|
value: Record<string, unknown> | Array<Record<string, unknown>>;
|
||||||
};
|
};
|
||||||
|
|
||||||
type DnsMutationResult = {
|
type DnsMutationResult = {
|
||||||
@@ -34,7 +35,7 @@ type ArvanDnsRecord = {
|
|||||||
name: string;
|
name: string;
|
||||||
ttl?: number;
|
ttl?: number;
|
||||||
cloud?: boolean;
|
cloud?: boolean;
|
||||||
value?: Record<string, unknown> | null;
|
value?: Record<string, unknown> | Array<Record<string, unknown>> | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -110,7 +111,8 @@ export class ArvanDnsService {
|
|||||||
await this.upsertTyped(host, auth, existing, {
|
await this.upsertTyped(host, auth, existing, {
|
||||||
type: 'a',
|
type: 'a',
|
||||||
name: 'mail',
|
name: 'mail',
|
||||||
value: { ip: MAIL_IPV4 },
|
// Arvan CDN 4.0 requires A `value` as an array of IP objects.
|
||||||
|
value: [{ ip: MAIL_IPV4 }],
|
||||||
}, created, updated, skipped);
|
}, created, updated, skipped);
|
||||||
|
|
||||||
await this.replaceExtraMx(host, auth, existing, removed);
|
await this.replaceExtraMx(host, auth, existing, removed);
|
||||||
@@ -447,7 +449,7 @@ export class ArvanDnsService {
|
|||||||
const mx = existing.filter((item) => item.type === 'mx');
|
const mx = existing.filter((item) => item.type === 'mx');
|
||||||
const keep = mx.find((item) => {
|
const keep = mx.find((item) => {
|
||||||
const name = this.relativeName(item.name, host);
|
const name = this.relativeName(item.name, host);
|
||||||
return name === '@' && this.fqdn(this.mxHost(item.value ?? {})) === MAIL_HOSTNAME;
|
return name === '@' && this.fqdn(this.mxHost(this.recordValueObject(item.value))) === MAIL_HOSTNAME;
|
||||||
});
|
});
|
||||||
for (const item of mx) {
|
for (const item of mx) {
|
||||||
if (keep && item.id === keep.id) continue;
|
if (keep && item.id === keep.id) continue;
|
||||||
@@ -462,7 +464,7 @@ export class ArvanDnsService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
const name = this.relativeName(item.name, host);
|
const name = this.relativeName(item.name, host);
|
||||||
removed.push(`MX ${name} ${this.mxHost(item.value ?? {}) || item.id}`);
|
removed.push(`MX ${name} ${this.mxHost(this.recordValueObject(item.value)) || item.id}`);
|
||||||
const index = existing.indexOf(item);
|
const index = existing.indexOf(item);
|
||||||
if (index >= 0) existing.splice(index, 1);
|
if (index >= 0) existing.splice(index, 1);
|
||||||
}
|
}
|
||||||
@@ -498,27 +500,51 @@ export class ArvanDnsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private sameTarget(desired: DesiredRecord, existing: ArvanDnsRecord): boolean {
|
private sameTarget(desired: DesiredRecord, existing: ArvanDnsRecord): boolean {
|
||||||
const current = existing.value ?? {};
|
const current = this.recordValueObject(existing.value);
|
||||||
|
const desiredObj = this.recordValueObject(desired.value);
|
||||||
if (desired.type === 'aname') {
|
if (desired.type === 'aname') {
|
||||||
return this.fqdn(String(current.location ?? '')) === this.fqdn(String(desired.value.location ?? ''));
|
return this.fqdn(String(current.location ?? '')) === this.fqdn(String(desiredObj.location ?? ''));
|
||||||
}
|
}
|
||||||
if (desired.type === 'a') {
|
if (desired.type === 'a') {
|
||||||
return String(current.ip ?? '') === String(desired.value.ip ?? '');
|
return this.aIps(existing.value).join(',') === this.aIps(desired.value).join(',');
|
||||||
}
|
}
|
||||||
if (desired.type === 'mx') {
|
if (desired.type === 'mx') {
|
||||||
return (
|
return (
|
||||||
this.fqdn(this.mxHost(current)) === this.fqdn(String(desired.value.host ?? '')) &&
|
this.fqdn(this.mxHost(current)) === this.fqdn(String(desiredObj.host ?? '')) &&
|
||||||
Number(current.priority ?? 0) === Number(desired.value.priority ?? 0)
|
Number(current.priority ?? 0) === Number(desiredObj.priority ?? 0)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (desired.type === 'txt') {
|
if (desired.type === 'txt') {
|
||||||
return this.txtValue(existing).replace(/\s+/g, '') === String(desired.value.text ?? '').replace(/\s+/g, '');
|
return this.txtValue(existing).replace(/\s+/g, '') === String(desiredObj.text ?? '').replace(/\s+/g, '');
|
||||||
}
|
}
|
||||||
return this.fqdn(String(current.host ?? '')) === this.fqdn(String(desired.value.host ?? ''));
|
return this.fqdn(String(current.host ?? '')) === this.fqdn(String(desiredObj.host ?? ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Arvan A records return `value` as `[{ ip }]`; normalize to IP list for compare/send. */
|
||||||
|
private aIps(value: DesiredRecord['value'] | ArvanDnsRecord['value']): string[] {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value
|
||||||
|
.map((item) => String(item?.ip ?? '').trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
if (value && typeof value === 'object' && 'ip' in value) {
|
||||||
|
const ip = String(value.ip ?? '').trim();
|
||||||
|
return ip ? [ip] : [];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private recordValueObject(
|
||||||
|
value: DesiredRecord['value'] | ArvanDnsRecord['value'],
|
||||||
|
): Record<string, unknown> {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return (value[0] as Record<string, unknown> | undefined) ?? {};
|
||||||
|
}
|
||||||
|
return value ?? {};
|
||||||
}
|
}
|
||||||
|
|
||||||
private txtValue(record: ArvanDnsRecord): string {
|
private txtValue(record: ArvanDnsRecord): string {
|
||||||
const value = record.value ?? {};
|
const value = this.recordValueObject(record.value);
|
||||||
if (typeof value.text === 'string') return value.text;
|
if (typeof value.text === 'string') return value.text;
|
||||||
if (value.txt && typeof value.txt === 'object' && 'text' in value.txt) {
|
if (value.txt && typeof value.txt === 'object' && 'text' in value.txt) {
|
||||||
return String((value.txt as { text?: unknown }).text ?? '');
|
return String((value.txt as { text?: unknown }).text ?? '');
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ import { RefreshTokenDto } from './dto/refresh-token.dto';
|
|||||||
import { RegisterDto } from './dto/register.dto';
|
import { RegisterDto } from './dto/register.dto';
|
||||||
import { ResetPasswordDto } from './dto/reset-password.dto';
|
import { ResetPasswordDto } from './dto/reset-password.dto';
|
||||||
import { SendOtpDto } from './dto/send-otp.dto';
|
import { SendOtpDto } from './dto/send-otp.dto';
|
||||||
|
import { SignupCompleteDto } from './dto/signup-complete.dto';
|
||||||
|
import { SignupStartDto } from './dto/signup-start.dto';
|
||||||
|
import { SignupVerifyOtpDto } from './dto/signup-verify-otp.dto';
|
||||||
import { UpdateProfileDto } from './dto/update-profile.dto';
|
import { UpdateProfileDto } from './dto/update-profile.dto';
|
||||||
import { UpsertUserAddressDto } from './dto/upsert-user-address.dto';
|
import { UpsertUserAddressDto } from './dto/upsert-user-address.dto';
|
||||||
import { VerifyOtpDto } from './dto/verify-otp.dto';
|
import { VerifyOtpDto } from './dto/verify-otp.dto';
|
||||||
@@ -37,6 +40,21 @@ export class AuthController {
|
|||||||
return this.authService.register(dto);
|
return this.authService.register(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('signup/start')
|
||||||
|
startSignup(@Body() dto: SignupStartDto) {
|
||||||
|
return this.authService.startSignup(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('signup/verify-otp')
|
||||||
|
verifySignupOtp(@Body() dto: SignupVerifyOtpDto) {
|
||||||
|
return this.authService.verifySignupOtp(dto.cellNumber, dto.code);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('signup/complete')
|
||||||
|
completeSignup(@Body() dto: SignupCompleteDto) {
|
||||||
|
return this.authService.completeSignup(dto);
|
||||||
|
}
|
||||||
|
|
||||||
@Post('login')
|
@Post('login')
|
||||||
login(@Body() dto: LoginDto) {
|
login(@Body() dto: LoginDto) {
|
||||||
return this.authService.login(dto);
|
return this.authService.login(dto);
|
||||||
|
|||||||
+338
-22
@@ -24,6 +24,8 @@ import {
|
|||||||
import { ChangePasswordDto } from './dto/change-password.dto';
|
import { ChangePasswordDto } from './dto/change-password.dto';
|
||||||
import { LoginDto } from './dto/login.dto';
|
import { LoginDto } from './dto/login.dto';
|
||||||
import { RegisterDto } from './dto/register.dto';
|
import { RegisterDto } from './dto/register.dto';
|
||||||
|
import { SignupCompleteDto } from './dto/signup-complete.dto';
|
||||||
|
import { SignupStartDto } from './dto/signup-start.dto';
|
||||||
import { UpdateProfileDto } from './dto/update-profile.dto';
|
import { UpdateProfileDto } from './dto/update-profile.dto';
|
||||||
import { PermissionsService } from './permissions.service';
|
import { PermissionsService } from './permissions.service';
|
||||||
import { parseUserProfile } from './profile.util';
|
import { parseUserProfile } from './profile.util';
|
||||||
@@ -33,6 +35,8 @@ import { SmsBillingService } from './sms-billing.service';
|
|||||||
const OTP_TTL_SECONDS = 300;
|
const OTP_TTL_SECONDS = 300;
|
||||||
/** Pending cross-site password change (longer than OTP so resend still works). */
|
/** Pending cross-site password change (longer than OTP so resend still works). */
|
||||||
const PENDING_PASSWORD_TTL_SECONDS = 600;
|
const PENDING_PASSWORD_TTL_SECONDS = 600;
|
||||||
|
/** Pending signup + post-OTP registration token. */
|
||||||
|
const PENDING_SIGNUP_TTL_SECONDS = 600;
|
||||||
const HANDOFF_TTL_SECONDS = 60;
|
const HANDOFF_TTL_SECONDS = 60;
|
||||||
const HANDOFF_RATE_LIMIT = 10;
|
const HANDOFF_RATE_LIMIT = 10;
|
||||||
const HANDOFF_RATE_WINDOW_SECONDS = 60;
|
const HANDOFF_RATE_WINDOW_SECONDS = 60;
|
||||||
@@ -212,6 +216,271 @@ export class AuthService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signup step 1: name + number. Stores pending signup and sends OTP
|
||||||
|
* (OTP is allowed before the user row exists).
|
||||||
|
*/
|
||||||
|
async startSignup(dto: SignupStartDto) {
|
||||||
|
const business = await this.tenant.resolveBusinessByDomain(dto.domain);
|
||||||
|
const existingUser = await this.prisma.user.findUnique({
|
||||||
|
where: { cellNumber: dto.cellNumber },
|
||||||
|
include: {
|
||||||
|
businessCustomers: {
|
||||||
|
include: { business: { select: { id: true, name: true, nameFa: true } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const alreadyOnThisSite = existingUser?.businessCustomers.some(
|
||||||
|
(item) => item.businessId === business.id,
|
||||||
|
);
|
||||||
|
if (alreadyOnThisSite) {
|
||||||
|
throw new ConflictException(
|
||||||
|
'This cell number is already registered on this website',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.redis.setPendingSignup(
|
||||||
|
dto.cellNumber,
|
||||||
|
{
|
||||||
|
firstName: dto.firstName.trim(),
|
||||||
|
lastName: dto.lastName.trim(),
|
||||||
|
businessId: business.id.toString(),
|
||||||
|
},
|
||||||
|
PENDING_SIGNUP_TTL_SECONDS,
|
||||||
|
);
|
||||||
|
|
||||||
|
const smsEnabled = this.sms.isEnabled();
|
||||||
|
if (!smsEnabled) {
|
||||||
|
const sites = this.otherSiteNames(existingUser, business.id);
|
||||||
|
const registrationToken = await this.issueSignupVerifiedToken({
|
||||||
|
cellNumber: dto.cellNumber,
|
||||||
|
firstName: dto.firstName.trim(),
|
||||||
|
lastName: dto.lastName.trim(),
|
||||||
|
businessId: business.id.toString(),
|
||||||
|
existingUserId: existingUser ? existingUser.id.toString() : null,
|
||||||
|
});
|
||||||
|
await this.redis.deletePendingSignup(dto.cellNumber);
|
||||||
|
|
||||||
|
return {
|
||||||
|
enabled: false,
|
||||||
|
requiresOtp: false,
|
||||||
|
existingAccount: Boolean(existingUser),
|
||||||
|
sites,
|
||||||
|
registrationToken,
|
||||||
|
message:
|
||||||
|
'SMS verification is currently disabled. Continue to finish registration.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.dispatchOtpSms(dto.cellNumber, business);
|
||||||
|
|
||||||
|
return {
|
||||||
|
enabled: true,
|
||||||
|
requiresOtp: true,
|
||||||
|
message: 'Verification code sent',
|
||||||
|
expiresInSeconds: OTP_TTL_SECONDS,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signup step 2: verify OTP, then report whether the phone already has a
|
||||||
|
* Meshkee account. Returns a one-time registrationToken for step 3.
|
||||||
|
*/
|
||||||
|
async verifySignupOtp(cellNumber: string, code: string) {
|
||||||
|
const pending = await this.redis.getPendingSignup(cellNumber);
|
||||||
|
if (!pending) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Signup session expired. Please start registration again.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.consumeOtpCode(cellNumber, code);
|
||||||
|
|
||||||
|
const businessId = BigInt(pending.businessId);
|
||||||
|
const existingUser = await this.prisma.user.findUnique({
|
||||||
|
where: { cellNumber },
|
||||||
|
include: {
|
||||||
|
businessCustomers: {
|
||||||
|
include: { business: { select: { id: true, name: true, nameFa: true } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const alreadyOnThisSite = existingUser?.businessCustomers.some(
|
||||||
|
(item) => item.businessId === businessId,
|
||||||
|
);
|
||||||
|
if (alreadyOnThisSite) {
|
||||||
|
await this.redis.deletePendingSignup(cellNumber);
|
||||||
|
throw new ConflictException(
|
||||||
|
'This cell number is already registered on this website',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sites = this.otherSiteNames(existingUser, businessId);
|
||||||
|
const registrationToken = await this.issueSignupVerifiedToken({
|
||||||
|
cellNumber,
|
||||||
|
firstName: pending.firstName,
|
||||||
|
lastName: pending.lastName,
|
||||||
|
businessId: pending.businessId,
|
||||||
|
existingUserId: existingUser ? existingUser.id.toString() : null,
|
||||||
|
});
|
||||||
|
await this.redis.deletePendingSignup(cellNumber);
|
||||||
|
|
||||||
|
return {
|
||||||
|
verified: true,
|
||||||
|
existingAccount: Boolean(existingUser),
|
||||||
|
sites,
|
||||||
|
registrationToken,
|
||||||
|
message: existingUser
|
||||||
|
? 'Phone verified. You already have a Meshkee account — your password stays the same.'
|
||||||
|
: 'Phone verified. Choose a password to finish creating your account.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signup step 3: create a new account (password required) or link an
|
||||||
|
* existing Meshkee account (password unchanged).
|
||||||
|
*/
|
||||||
|
async completeSignup(dto: SignupCompleteDto) {
|
||||||
|
const verified = await this.redis.consumeSignupVerifiedToken(
|
||||||
|
dto.registrationToken,
|
||||||
|
);
|
||||||
|
if (!verified) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Registration session expired. Please start registration again.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const businessId = BigInt(verified.businessId);
|
||||||
|
const business = await this.prisma.business.findUnique({
|
||||||
|
where: { id: businessId },
|
||||||
|
select: { id: true, name: true, slug: true },
|
||||||
|
});
|
||||||
|
if (!business) {
|
||||||
|
throw new BadRequestException('Business not found for this registration.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const customerRole = await this.prisma.role.findUnique({
|
||||||
|
where: { slug: 'customer' },
|
||||||
|
});
|
||||||
|
if (!customerRole) {
|
||||||
|
throw new Error('Customer role is missing. Run database migrations first.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const linkedExisting = Boolean(verified.existingUserId);
|
||||||
|
if (!linkedExisting) {
|
||||||
|
const password = dto.password?.trim();
|
||||||
|
if (!password || password.length < 8) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Password is required (at least 8 characters) for new accounts.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = await this.prisma.$transaction(async (tx) => {
|
||||||
|
let userId: bigint;
|
||||||
|
|
||||||
|
if (verified.existingUserId) {
|
||||||
|
userId = BigInt(verified.existingUserId);
|
||||||
|
const existing = await tx.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
include: {
|
||||||
|
businessCustomers: { select: { businessId: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!existing) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Existing account not found. Please start registration again.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
existing.businessCustomers.some((item) => item.businessId === businessId)
|
||||||
|
) {
|
||||||
|
throw new ConflictException(
|
||||||
|
'This cell number is already registered on this website',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await tx.user.update({
|
||||||
|
where: { id: userId },
|
||||||
|
data: {
|
||||||
|
isActive: true,
|
||||||
|
cellVerifiedAt: existing.cellVerifiedAt ?? new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const collision = await tx.user.findUnique({
|
||||||
|
where: { cellNumber: verified.cellNumber },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (collision) {
|
||||||
|
throw new ConflictException(
|
||||||
|
'This cell number is already registered. Please start registration again.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordHash = await bcrypt.hash(dto.password!.trim(), 10);
|
||||||
|
const created = await tx.user.create({
|
||||||
|
data: {
|
||||||
|
cellNumber: verified.cellNumber,
|
||||||
|
passwordHash,
|
||||||
|
firstName: verified.firstName,
|
||||||
|
lastName: verified.lastName,
|
||||||
|
cellVerifiedAt: new Date(),
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
userId = created.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
await tx.businessCustomer.create({
|
||||||
|
data: {
|
||||||
|
businessId,
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasCustomerRole = await tx.userRole.findUnique({
|
||||||
|
where: {
|
||||||
|
userId_roleId: {
|
||||||
|
userId,
|
||||||
|
roleId: customerRole.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!hasCustomerRole) {
|
||||||
|
await tx.userRole.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
roleId: customerRole.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return userId;
|
||||||
|
});
|
||||||
|
|
||||||
|
const authUser = await this.getAuthUser(userId);
|
||||||
|
const tokens = await this.issueTokens(authUser);
|
||||||
|
|
||||||
|
return {
|
||||||
|
message: linkedExisting
|
||||||
|
? 'Joined this website with your existing Meshkee account.'
|
||||||
|
: 'Registration successful.',
|
||||||
|
existingAccount: linkedExisting,
|
||||||
|
passwordUpdated: false,
|
||||||
|
user: this.serializeUser(authUser),
|
||||||
|
registeredBusiness: {
|
||||||
|
id: business.id,
|
||||||
|
name: business.name,
|
||||||
|
slug: business.slug,
|
||||||
|
},
|
||||||
|
...tokens,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async login(dto: LoginDto) {
|
async login(dto: LoginDto) {
|
||||||
const user = await this.prisma.user.findUnique({
|
const user = await this.prisma.user.findUnique({
|
||||||
where: { cellNumber: dto.cellNumber },
|
where: { cellNumber: dto.cellNumber },
|
||||||
@@ -430,26 +699,7 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const business = await this.tenant.resolveBusinessByDomain(host);
|
const business = await this.tenant.resolveBusinessByDomain(host);
|
||||||
const businessNameFa =
|
await this.dispatchOtpSms(cellNumber, business);
|
||||||
business.nameFa?.trim() || business.name?.trim() || undefined;
|
|
||||||
const code = this.generateOtpCode();
|
|
||||||
const brand = businessNameFa?.trim();
|
|
||||||
const message = brand
|
|
||||||
? `کد تایید شما: ${code}\n${brand}`
|
|
||||||
: `کد تایید شما: ${code}`;
|
|
||||||
|
|
||||||
const charged = await this.smsBilling.charge(business.id, message, 1, 'auth');
|
|
||||||
await this.redis.setOtp(cellNumber, code, OTP_TTL_SECONDS);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await this.sms.sendMessage(cellNumber, message);
|
|
||||||
} catch (err) {
|
|
||||||
await this.smsBilling.refund(business.id, charged, 1, 'auth');
|
|
||||||
if (err instanceof HttpException) {
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
throw new ServiceUnavailableException('Unable to send SMS');
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@@ -545,8 +795,14 @@ export class AuthService {
|
|||||||
throw new UnauthorizedException('Cell number is not registered');
|
throw new UnauthorizedException('Cell number is not registered');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.consumeOtpCode(cellNumber, code);
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Validates and consumes the OTP code without requiring a user row. */
|
||||||
|
private async consumeOtpCode(cellNumber: string, code: string) {
|
||||||
if (!this.sms.isEnabled()) {
|
if (!this.sms.isEnabled()) {
|
||||||
return user;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const storedCode = await this.redis.getOtp(cellNumber);
|
const storedCode = await this.redis.getOtp(cellNumber);
|
||||||
@@ -555,7 +811,67 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await this.redis.deleteOtp(cellNumber);
|
await this.redis.deleteOtp(cellNumber);
|
||||||
return user;
|
}
|
||||||
|
|
||||||
|
private async dispatchOtpSms(
|
||||||
|
cellNumber: string,
|
||||||
|
business: { id: bigint; name: string | null; nameFa: string | null },
|
||||||
|
) {
|
||||||
|
const businessNameFa =
|
||||||
|
business.nameFa?.trim() || business.name?.trim() || undefined;
|
||||||
|
const code = this.generateOtpCode();
|
||||||
|
const brand = businessNameFa?.trim();
|
||||||
|
const message = brand
|
||||||
|
? `کد تایید شما: ${code}\n${brand}`
|
||||||
|
: `کد تایید شما: ${code}`;
|
||||||
|
|
||||||
|
const charged = await this.smsBilling.charge(business.id, message, 1, 'auth');
|
||||||
|
await this.redis.setOtp(cellNumber, code, OTP_TTL_SECONDS);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.sms.sendMessage(cellNumber, message);
|
||||||
|
} catch (err) {
|
||||||
|
await this.smsBilling.refund(business.id, charged, 1, 'auth');
|
||||||
|
if (err instanceof HttpException) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
throw new ServiceUnavailableException('Unable to send SMS');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private otherSiteNames(
|
||||||
|
existingUser:
|
||||||
|
| {
|
||||||
|
businessCustomers: {
|
||||||
|
businessId: bigint;
|
||||||
|
business: { name: string; nameFa: string | null };
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
| null
|
||||||
|
| undefined,
|
||||||
|
excludeBusinessId: bigint,
|
||||||
|
): string[] {
|
||||||
|
if (!existingUser) return [];
|
||||||
|
return existingUser.businessCustomers
|
||||||
|
.filter((item) => item.businessId !== excludeBusinessId)
|
||||||
|
.map((item) => item.business.nameFa?.trim() || item.business.name)
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async issueSignupVerifiedToken(payload: {
|
||||||
|
cellNumber: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
businessId: string;
|
||||||
|
existingUserId: string | null;
|
||||||
|
}): Promise<string> {
|
||||||
|
const registrationToken = randomBytes(32).toString('hex');
|
||||||
|
await this.redis.setSignupVerifiedToken(
|
||||||
|
registrationToken,
|
||||||
|
payload,
|
||||||
|
PENDING_SIGNUP_TTL_SECONDS,
|
||||||
|
);
|
||||||
|
return registrationToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async ensureCustomerMembershipForDomain(
|
private async ensureCustomerMembershipForDomain(
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { IsOptional, IsString, MinLength } from 'class-validator';
|
||||||
|
|
||||||
|
export class SignupCompleteDto {
|
||||||
|
/** One-time token from signup/verify-otp (or signup/start when SMS is off). */
|
||||||
|
@IsString()
|
||||||
|
@MinLength(16)
|
||||||
|
registrationToken!: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Required when creating a brand-new Meshkee account.
|
||||||
|
* Ignored when linking an existing account (password stays unchanged).
|
||||||
|
*/
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(8, { message: 'password must be at least 8 characters' })
|
||||||
|
password?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { IsString, Matches, MaxLength, MinLength } from 'class-validator';
|
||||||
|
|
||||||
|
export class SignupStartDto {
|
||||||
|
@IsString()
|
||||||
|
@Matches(/^\+[1-9]\d{6,14}$/, {
|
||||||
|
message: 'cellNumber must be in E.164 format (e.g. +989121234567)',
|
||||||
|
})
|
||||||
|
cellNumber!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(2)
|
||||||
|
firstName!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(2)
|
||||||
|
lastName!: string;
|
||||||
|
|
||||||
|
/** Domain of the business website (e.g. shop-a.local). */
|
||||||
|
@IsString()
|
||||||
|
@MinLength(3)
|
||||||
|
@MaxLength(253)
|
||||||
|
domain!: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { IsString, Length, Matches } from 'class-validator';
|
||||||
|
|
||||||
|
export class SignupVerifyOtpDto {
|
||||||
|
@IsString()
|
||||||
|
@Matches(/^\+[1-9]\d{6,14}$/, {
|
||||||
|
message: 'cellNumber must be in E.164 format (e.g. +989121234567)',
|
||||||
|
})
|
||||||
|
cellNumber!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@Length(6, 6)
|
||||||
|
@Matches(/^\d{6}$/, { message: 'code must be a 6-digit number' })
|
||||||
|
code!: string;
|
||||||
|
}
|
||||||
@@ -501,6 +501,18 @@ export function toGamaMsisdn(raw: string): string | null {
|
|||||||
return msisdn;
|
return msisdn;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Iranian mobile only (local 09xxxxxxxxx → `989xxxxxxxxx`).
|
||||||
|
* Rejects landlines, foreign numbers, and invalid formats.
|
||||||
|
*/
|
||||||
|
export function toIranianMobileMsisdn(raw: string): string | null {
|
||||||
|
const msisdn = toGamaMsisdn(raw);
|
||||||
|
if (!msisdn || !/^989\d{9}$/.test(msisdn)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return msisdn;
|
||||||
|
}
|
||||||
|
|
||||||
export function maskMsisdn(msisdn: string): string {
|
export function maskMsisdn(msisdn: string): string {
|
||||||
if (msisdn.length < 6) return '***';
|
if (msisdn.length < 6) return '***';
|
||||||
return `${msisdn.slice(0, 4)}****${msisdn.slice(-3)}`;
|
return `${msisdn.slice(0, 4)}****${msisdn.slice(-3)}`;
|
||||||
|
|||||||
@@ -130,6 +130,8 @@ export class BusinessAdminService {
|
|||||||
const nameLike = query.name ? `%${query.name.trim()}%` : null;
|
const nameLike = query.name ? `%${query.name.trim()}%` : null;
|
||||||
const domainLike = query.domain ? `%${query.domain.trim()}%` : null;
|
const domainLike = query.domain ? `%${query.domain.trim()}%` : null;
|
||||||
const categoryLike = query.category ? `%${query.category.trim()}%` : null;
|
const categoryLike = query.category ? `%${query.category.trim()}%` : null;
|
||||||
|
const sortByRenewal = query.sort === 'renewal';
|
||||||
|
const lowSmsThreshold = 100;
|
||||||
|
|
||||||
const where = Prisma.sql`
|
const where = Prisma.sql`
|
||||||
WHERE 1=1
|
WHERE 1=1
|
||||||
@@ -151,7 +153,40 @@ export class BusinessAdminService {
|
|||||||
` : Prisma.empty}
|
` : Prisma.empty}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const [items, totalRow] = await Promise.all([
|
// Matches super-admin nextAnnualRenewal(): future contract date as-is,
|
||||||
|
// otherwise this calendar year's month/day (overdue if already past).
|
||||||
|
const nextRenewalDateSql = Prisma.sql`
|
||||||
|
CASE
|
||||||
|
WHEN COALESCE(b.annual_renewal_at, b.created_at) IS NULL THEN NULL
|
||||||
|
WHEN COALESCE(b.annual_renewal_at, b.created_at)::date > CURRENT_DATE
|
||||||
|
THEN COALESCE(b.annual_renewal_at, b.created_at)::date
|
||||||
|
ELSE make_date(
|
||||||
|
EXTRACT(YEAR FROM CURRENT_DATE)::int,
|
||||||
|
EXTRACT(MONTH FROM COALESCE(b.annual_renewal_at, b.created_at))::int,
|
||||||
|
LEAST(
|
||||||
|
EXTRACT(DAY FROM COALESCE(b.annual_renewal_at, b.created_at))::int,
|
||||||
|
EXTRACT(
|
||||||
|
DAY FROM (
|
||||||
|
DATE_TRUNC(
|
||||||
|
'month',
|
||||||
|
make_date(
|
||||||
|
EXTRACT(YEAR FROM CURRENT_DATE)::int,
|
||||||
|
EXTRACT(MONTH FROM COALESCE(b.annual_renewal_at, b.created_at))::int,
|
||||||
|
1
|
||||||
|
)
|
||||||
|
) + INTERVAL '1 month - 1 day'
|
||||||
|
)
|
||||||
|
)::int
|
||||||
|
)
|
||||||
|
)
|
||||||
|
END
|
||||||
|
`;
|
||||||
|
|
||||||
|
const orderBy = sortByRenewal
|
||||||
|
? Prisma.sql`ORDER BY ${nextRenewalDateSql} ASC NULLS LAST, b.created_at DESC`
|
||||||
|
: Prisma.sql`ORDER BY b.created_at DESC`;
|
||||||
|
|
||||||
|
const [items, totalRow, lowSmsRows] = await Promise.all([
|
||||||
this.prisma.$queryRaw<BusinessRow[]>(Prisma.sql`
|
this.prisma.$queryRaw<BusinessRow[]>(Prisma.sql`
|
||||||
SELECT
|
SELECT
|
||||||
b.id AS "id",
|
b.id AS "id",
|
||||||
@@ -205,7 +240,7 @@ export class BusinessAdminService {
|
|||||||
LIMIT 1
|
LIMIT 1
|
||||||
) own ON TRUE
|
) own ON TRUE
|
||||||
${where}
|
${where}
|
||||||
ORDER BY b.created_at DESC
|
${orderBy}
|
||||||
LIMIT ${pageSize} OFFSET ${skip}
|
LIMIT ${pageSize} OFFSET ${skip}
|
||||||
`),
|
`),
|
||||||
this.prisma.$queryRaw<{ total: number }[]>(Prisma.sql`
|
this.prisma.$queryRaw<{ total: number }[]>(Prisma.sql`
|
||||||
@@ -213,6 +248,30 @@ export class BusinessAdminService {
|
|||||||
FROM businesses b
|
FROM businesses b
|
||||||
${where}
|
${where}
|
||||||
`),
|
`),
|
||||||
|
this.prisma.$queryRaw<
|
||||||
|
{
|
||||||
|
id: bigint;
|
||||||
|
name: string;
|
||||||
|
domain: string | null;
|
||||||
|
smsBalance: number;
|
||||||
|
}[]
|
||||||
|
>(Prisma.sql`
|
||||||
|
SELECT
|
||||||
|
b.id AS "id",
|
||||||
|
b.name AS "name",
|
||||||
|
dom.host AS "domain",
|
||||||
|
b.sms_balance AS "smsBalance"
|
||||||
|
FROM businesses b
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT d.host
|
||||||
|
FROM domains d
|
||||||
|
WHERE d.business_id = b.id
|
||||||
|
ORDER BY d.is_primary DESC, d.created_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
) dom ON TRUE
|
||||||
|
WHERE b.sms_balance < ${lowSmsThreshold}
|
||||||
|
ORDER BY b.sms_balance ASC, b.name ASC
|
||||||
|
`),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -260,6 +319,13 @@ export class BusinessAdminService {
|
|||||||
total: totalRow[0]?.total ?? 0,
|
total: totalRow[0]?.total ?? 0,
|
||||||
page,
|
page,
|
||||||
pageSize,
|
pageSize,
|
||||||
|
lowSmsBusinesses: lowSmsRows.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
domain: row.domain,
|
||||||
|
smsBalance: Number(row.smsBalance ?? 0),
|
||||||
|
})),
|
||||||
|
lowSmsThreshold,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import {
|
import {
|
||||||
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
|
Delete,
|
||||||
Get,
|
Get,
|
||||||
|
Param,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
Query,
|
Query,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
@@ -9,6 +14,10 @@ import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
|||||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||||
import { AuthUser } from '../auth/auth.types';
|
import { AuthUser } from '../auth/auth.types';
|
||||||
import { BusinessCategoriesService } from './business-categories.service';
|
import { BusinessCategoriesService } from './business-categories.service';
|
||||||
|
import {
|
||||||
|
CreateBusinessCategoryDto,
|
||||||
|
UpdateBusinessCategoryDto,
|
||||||
|
} from './dto/business-category.dto';
|
||||||
|
|
||||||
class ListBusinessCategoriesDto {
|
class ListBusinessCategoriesDto {
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@@ -29,4 +38,32 @@ export class BusinessCategoriesController {
|
|||||||
) {
|
) {
|
||||||
return this.service.list(user, query.q);
|
return this.service.list(user, query.q);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
create(
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
@Body() dto: CreateBusinessCategoryDto,
|
||||||
|
) {
|
||||||
|
return this.service.create(user, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':categoryId')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
update(
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
@Param('categoryId') categoryId: string,
|
||||||
|
@Body() dto: UpdateBusinessCategoryDto,
|
||||||
|
) {
|
||||||
|
return this.service.update(user, categoryId, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':categoryId')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
remove(
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
@Param('categoryId') categoryId: string,
|
||||||
|
) {
|
||||||
|
return this.service.remove(user, categoryId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,27 @@
|
|||||||
import { Injectable, ForbiddenException } from '@nestjs/common';
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ForbiddenException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { PermissionsService } from '../auth/permissions.service';
|
import { PermissionsService } from '../auth/permissions.service';
|
||||||
import { AuthUser } from '../auth/auth.types';
|
import { AuthUser } from '../auth/auth.types';
|
||||||
|
import {
|
||||||
|
CreateBusinessCategoryDto,
|
||||||
|
UpdateBusinessCategoryDto,
|
||||||
|
} from './dto/business-category.dto';
|
||||||
|
|
||||||
|
function slugify(value: string): string {
|
||||||
|
return (
|
||||||
|
value
|
||||||
|
.toLowerCase()
|
||||||
|
.trim()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '') || 'category'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BusinessCategoriesService {
|
export class BusinessCategoriesService {
|
||||||
@@ -12,15 +31,7 @@ export class BusinessCategoriesService {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
async list(actor: AuthUser, q?: string) {
|
async list(actor: AuthUser, q?: string) {
|
||||||
const isSuperAdmin = await this.permissions.isSuperAdmin(actor.id);
|
await this.assertCanRead(actor);
|
||||||
const canRead =
|
|
||||||
isSuperAdmin ||
|
|
||||||
actor.roles.includes('business_owner') ||
|
|
||||||
actor.roles.includes('business_staff');
|
|
||||||
|
|
||||||
if (!canRead) {
|
|
||||||
throw new ForbiddenException('Access denied');
|
|
||||||
}
|
|
||||||
|
|
||||||
const term = q?.trim();
|
const term = q?.trim();
|
||||||
if (term) {
|
if (term) {
|
||||||
@@ -88,4 +99,211 @@ export class BusinessCategoriesService {
|
|||||||
|
|
||||||
return { items: categories };
|
return { items: categories };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async create(actor: AuthUser, dto: CreateBusinessCategoryDto) {
|
||||||
|
await this.assertCanManage(actor);
|
||||||
|
|
||||||
|
const parentId = await this.resolveParentId(dto.parentId);
|
||||||
|
const slug = await this.ensureUniqueSlug(dto.slug ?? slugify(dto.name));
|
||||||
|
|
||||||
|
const created = await this.prisma.businessCategory.create({
|
||||||
|
data: {
|
||||||
|
parentId,
|
||||||
|
name: dto.name.trim(),
|
||||||
|
nameFa: dto.nameFa?.trim() || null,
|
||||||
|
slug,
|
||||||
|
description: dto.description?.trim() || null,
|
||||||
|
icon: dto.icon?.trim() || null,
|
||||||
|
sortOrder: dto.sortOrder ?? 0,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
parentId: true,
|
||||||
|
name: true,
|
||||||
|
nameFa: true,
|
||||||
|
slug: true,
|
||||||
|
description: true,
|
||||||
|
icon: true,
|
||||||
|
sortOrder: true,
|
||||||
|
tags: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { item: created };
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(
|
||||||
|
actor: AuthUser,
|
||||||
|
categoryIdRaw: string,
|
||||||
|
dto: UpdateBusinessCategoryDto,
|
||||||
|
) {
|
||||||
|
await this.assertCanManage(actor);
|
||||||
|
const categoryId = this.parseId(categoryIdRaw);
|
||||||
|
|
||||||
|
const existing = await this.prisma.businessCategory.findFirst({
|
||||||
|
where: { id: categoryId, isActive: true },
|
||||||
|
});
|
||||||
|
if (!existing) {
|
||||||
|
throw new NotFoundException('Category not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
let parentId: bigint | null | undefined = undefined;
|
||||||
|
if (dto.parentId !== undefined) {
|
||||||
|
if (dto.parentId === null || dto.parentId === '') {
|
||||||
|
parentId = null;
|
||||||
|
} else {
|
||||||
|
parentId = await this.resolveParentId(dto.parentId);
|
||||||
|
if (parentId === categoryId) {
|
||||||
|
throw new BadRequestException('Category cannot be its own parent');
|
||||||
|
}
|
||||||
|
const descendantIds = await this.collectDescendantIds(categoryId);
|
||||||
|
if (parentId && descendantIds.includes(parentId)) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Cannot move category under its own descendant',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let slug = existing.slug;
|
||||||
|
if (dto.slug) {
|
||||||
|
slug = await this.ensureUniqueSlug(dto.slug, categoryId);
|
||||||
|
} else if (dto.name && dto.name !== existing.name) {
|
||||||
|
slug = await this.ensureUniqueSlug(slugify(dto.name), categoryId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await this.prisma.businessCategory.update({
|
||||||
|
where: { id: categoryId },
|
||||||
|
data: {
|
||||||
|
...(dto.name !== undefined ? { name: dto.name.trim() } : {}),
|
||||||
|
...(dto.nameFa !== undefined
|
||||||
|
? { nameFa: dto.nameFa?.trim() || null }
|
||||||
|
: {}),
|
||||||
|
...(dto.description !== undefined
|
||||||
|
? { description: dto.description?.trim() || null }
|
||||||
|
: {}),
|
||||||
|
...(dto.icon !== undefined ? { icon: dto.icon?.trim() || null } : {}),
|
||||||
|
...(dto.sortOrder !== undefined ? { sortOrder: dto.sortOrder } : {}),
|
||||||
|
...(parentId !== undefined ? { parentId } : {}),
|
||||||
|
slug,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
parentId: true,
|
||||||
|
name: true,
|
||||||
|
nameFa: true,
|
||||||
|
slug: true,
|
||||||
|
description: true,
|
||||||
|
icon: true,
|
||||||
|
sortOrder: true,
|
||||||
|
tags: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { item: updated };
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(actor: AuthUser, categoryIdRaw: string) {
|
||||||
|
await this.assertCanManage(actor);
|
||||||
|
const categoryId = this.parseId(categoryIdRaw);
|
||||||
|
|
||||||
|
const existing = await this.prisma.businessCategory.findFirst({
|
||||||
|
where: { id: categoryId, isActive: true },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!existing) {
|
||||||
|
throw new NotFoundException('Category not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const descendantIds = await this.collectDescendantIds(categoryId);
|
||||||
|
|
||||||
|
await this.prisma.businessCategory.updateMany({
|
||||||
|
where: { id: { in: descendantIds } },
|
||||||
|
data: { isActive: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
message: 'Category deleted successfully',
|
||||||
|
deletedIds: descendantIds.map((id) => id.toString()),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertCanRead(actor: AuthUser) {
|
||||||
|
const isSuperAdmin = await this.permissions.isSuperAdmin(actor.id);
|
||||||
|
const canRead =
|
||||||
|
isSuperAdmin ||
|
||||||
|
actor.roles.includes('business_owner') ||
|
||||||
|
actor.roles.includes('business_staff');
|
||||||
|
|
||||||
|
if (!canRead) {
|
||||||
|
throw new ForbiddenException('Access denied');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertCanManage(actor: AuthUser) {
|
||||||
|
const isSuperAdmin = await this.permissions.isSuperAdmin(actor.id);
|
||||||
|
if (!isSuperAdmin) {
|
||||||
|
throw new ForbiddenException('Only super admins can manage categories');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseId(value: string) {
|
||||||
|
if (!/^\d+$/.test(value)) {
|
||||||
|
throw new BadRequestException('Invalid category id');
|
||||||
|
}
|
||||||
|
return BigInt(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveParentId(parentIdRaw?: string | null) {
|
||||||
|
if (parentIdRaw == null || parentIdRaw === '') return null;
|
||||||
|
const parentId = this.parseId(parentIdRaw);
|
||||||
|
const parent = await this.prisma.businessCategory.findFirst({
|
||||||
|
where: { id: parentId, isActive: true },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!parent) {
|
||||||
|
throw new BadRequestException('Parent category not found');
|
||||||
|
}
|
||||||
|
return parent.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureUniqueSlug(base: string, excludeId?: bigint) {
|
||||||
|
let candidate = slugify(base);
|
||||||
|
let suffix = 2;
|
||||||
|
while (true) {
|
||||||
|
const existing = await this.prisma.businessCategory.findFirst({
|
||||||
|
where: {
|
||||||
|
slug: candidate,
|
||||||
|
...(excludeId ? { id: { not: excludeId } } : {}),
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!existing) return candidate;
|
||||||
|
candidate = `${slugify(base)}-${suffix}`;
|
||||||
|
suffix += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async collectDescendantIds(rootId: bigint): Promise<bigint[]> {
|
||||||
|
const all = await this.prisma.businessCategory.findMany({
|
||||||
|
where: { isActive: true },
|
||||||
|
select: { id: true, parentId: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result: bigint[] = [rootId];
|
||||||
|
const queue = [rootId];
|
||||||
|
|
||||||
|
while (queue.length > 0) {
|
||||||
|
const current = queue.shift()!;
|
||||||
|
for (const child of all) {
|
||||||
|
if (child.parentId === current && !result.includes(child.id)) {
|
||||||
|
result.push(child.id);
|
||||||
|
queue.push(child.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import {
|
||||||
|
IsInt,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
Matches,
|
||||||
|
MaxLength,
|
||||||
|
Min,
|
||||||
|
MinLength,
|
||||||
|
ValidateIf,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateBusinessCategoryDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(2)
|
||||||
|
@MaxLength(255)
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(255)
|
||||||
|
nameFa?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@ValidateIf((_, value) => value !== null && value !== '')
|
||||||
|
@IsString()
|
||||||
|
parentId?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@Matches(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, {
|
||||||
|
message: 'slug must be lowercase letters, numbers, and hyphens',
|
||||||
|
})
|
||||||
|
slug?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
sortOrder?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
icon?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateBusinessCategoryDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(2)
|
||||||
|
@MaxLength(255)
|
||||||
|
name?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(255)
|
||||||
|
nameFa?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@ValidateIf((_, value) => value !== null && value !== '')
|
||||||
|
@IsString()
|
||||||
|
parentId?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
description?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@Matches(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, {
|
||||||
|
message: 'slug must be lowercase letters, numbers, and hyphens',
|
||||||
|
})
|
||||||
|
slug?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
sortOrder?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
icon?: string | null;
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Type } from 'class-transformer';
|
import { Type } from 'class-transformer';
|
||||||
import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
|
import { IsIn, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
|
||||||
|
|
||||||
export class ListBusinessesDto {
|
export class ListBusinessesDto {
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@@ -26,5 +26,10 @@ export class ListBusinessesDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
category?: string;
|
category?: string;
|
||||||
|
|
||||||
|
/** `created` (default) or `renewal` (nearest next/overdue renewal first). */
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(['created', 'renewal'])
|
||||||
|
sort?: 'created' | 'renewal';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1379,7 +1379,7 @@ export class LegacyMigrateService {
|
|||||||
.filter((m) => Number(m.size) > 0)
|
.filter((m) => Number(m.size) > 0)
|
||||||
.slice(0, 12)
|
.slice(0, 12)
|
||||||
.map((m) => ({ id: m.id, fileName: m.file_name }));
|
.map((m) => ({ id: m.id, fileName: m.file_name }));
|
||||||
const storagePrefix = await this.legacySourceS3.resolveBusinessPrefix(
|
const storagePrefix = await this.tryResolveStoragePrefix(
|
||||||
oldBizId,
|
oldBizId,
|
||||||
oldSlug,
|
oldSlug,
|
||||||
probeMedia,
|
probeMedia,
|
||||||
@@ -1417,7 +1417,7 @@ export class LegacyMigrateService {
|
|||||||
|
|
||||||
for (const media of articleMedia) {
|
for (const media of articleMedia) {
|
||||||
const size = Number(media.size) || 0;
|
const size = Number(media.size) || 0;
|
||||||
if (size <= 0) {
|
if (size <= 0 || !storagePrefix) {
|
||||||
imagesFailed += 1;
|
imagesFailed += 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -1611,7 +1611,7 @@ export class LegacyMigrateService {
|
|||||||
.filter((m) => Number(m.size) > 0)
|
.filter((m) => Number(m.size) > 0)
|
||||||
.slice(0, 12)
|
.slice(0, 12)
|
||||||
.map((m) => ({ id: m.id, fileName: m.file_name }));
|
.map((m) => ({ id: m.id, fileName: m.file_name }));
|
||||||
const storagePrefix = await this.legacySourceS3.resolveBusinessPrefix(
|
const storagePrefix = await this.tryResolveStoragePrefix(
|
||||||
oldBizId,
|
oldBizId,
|
||||||
oldSlug,
|
oldSlug,
|
||||||
probeMedia,
|
probeMedia,
|
||||||
@@ -1648,7 +1648,7 @@ export class LegacyMigrateService {
|
|||||||
|
|
||||||
for (const media of itemMedia) {
|
for (const media of itemMedia) {
|
||||||
const size = Number(media.size) || 0;
|
const size = Number(media.size) || 0;
|
||||||
if (size <= 0) {
|
if (size <= 0 || !storagePrefix) {
|
||||||
imagesFailed += 1;
|
imagesFailed += 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -1978,6 +1978,27 @@ export class LegacyMigrateService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async tryResolveStoragePrefix(
|
||||||
|
oldBizId: number,
|
||||||
|
oldSlug: string | null,
|
||||||
|
probeMedia: Array<{ id: number; fileName: string }>,
|
||||||
|
): Promise<string | null> {
|
||||||
|
if (!probeMedia.length) return null;
|
||||||
|
try {
|
||||||
|
return await this.legacySourceS3.resolveBusinessPrefix(
|
||||||
|
oldBizId,
|
||||||
|
oldSlug,
|
||||||
|
probeMedia,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
console.warn(
|
||||||
|
`Legacy migrate: skipping image copy for business ${oldBizId}: ${message}`,
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async copyLegacyMedia(input: {
|
private async copyLegacyMedia(input: {
|
||||||
businessId: bigint;
|
businessId: bigint;
|
||||||
oldMedia: OldMediaRow;
|
oldMedia: OldMediaRow;
|
||||||
|
|||||||
@@ -494,7 +494,7 @@ export class BusinessProfileService {
|
|||||||
province: item.province.trim(),
|
province: item.province.trim(),
|
||||||
city: item.city.trim(),
|
city: item.city.trim(),
|
||||||
address: item.address.trim(),
|
address: item.address.trim(),
|
||||||
postalCode: item.postalCode.trim(),
|
postalCode: item.postalCode?.trim() || null,
|
||||||
landline: item.landline?.trim() || null,
|
landline: item.landline?.trim() || null,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -549,7 +549,7 @@ export class BusinessProfileService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Recommended banner aspect 18:4.5 (4:1). Soft-check when dimensions exist. */
|
/** Recommended banner aspect 18:6 (3:1). Soft-check when dimensions exist. */
|
||||||
private async assertBannerMedia(businessId: bigint, mediaId: bigint) {
|
private async assertBannerMedia(businessId: bigint, mediaId: bigint) {
|
||||||
const media = await this.prisma.media.findFirst({
|
const media = await this.prisma.media.findFirst({
|
||||||
where: { id: mediaId, businessId },
|
where: { id: mediaId, businessId },
|
||||||
@@ -562,10 +562,10 @@ export class BusinessProfileService {
|
|||||||
|
|
||||||
if (media.width && media.height && media.height > 0) {
|
if (media.width && media.height && media.height > 0) {
|
||||||
const ratio = media.width / media.height;
|
const ratio = media.width / media.height;
|
||||||
const target = 18 / 4.5; // 4
|
const target = 18 / 6; // 3
|
||||||
if (Math.abs(ratio - target) / target > 0.12) {
|
if (Math.abs(ratio - target) / target > 0.12) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
'Banner aspect ratio should be about 18:4.5 (4:1)',
|
'Banner aspect ratio should be about 18:6 (3:1)',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export type BusinessProfile = {
|
|||||||
logoDarkUrl: string | null;
|
logoDarkUrl: string | null;
|
||||||
faviconMediaId: string | null;
|
faviconMediaId: string | null;
|
||||||
faviconUrl: string | null;
|
faviconUrl: string | null;
|
||||||
/** Recommended aspect ratio 18:4.5 (4:1). */
|
/** Recommended aspect ratio 18:6 (3:1). */
|
||||||
bannerMediaId: string | null;
|
bannerMediaId: string | null;
|
||||||
bannerUrl: string | null;
|
bannerUrl: string | null;
|
||||||
categoryIds: string[];
|
categoryIds: string[];
|
||||||
|
|||||||
@@ -71,9 +71,10 @@ class BusinessAddressDto {
|
|||||||
@MinLength(1)
|
@MinLength(1)
|
||||||
address!: string;
|
address!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@MinLength(1)
|
@MaxLength(20)
|
||||||
postalCode!: string;
|
postalCode?: string | null;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -136,7 +137,7 @@ export class UpdateBusinessProfileDto {
|
|||||||
@IsInt()
|
@IsInt()
|
||||||
faviconMediaId?: number | null;
|
faviconMediaId?: number | null;
|
||||||
|
|
||||||
/** Marketplace/storefront banner; recommended aspect 18:4.5 (4:1). */
|
/** Marketplace/storefront banner; recommended aspect 18:6 (3:1). */
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Type(() => Number)
|
@Type(() => Number)
|
||||||
@IsInt()
|
@IsInt()
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
toPrismaJson,
|
toPrismaJson,
|
||||||
} from './business-settings.util';
|
} from './business-settings.util';
|
||||||
import { normalizeWebsiteSchemaSettings } from './website-schema.util';
|
import { normalizeWebsiteSchemaSettings } from './website-schema.util';
|
||||||
|
import { normalizeWebsiteTagsAndBadges } from './website-tags-badges.util';
|
||||||
import { UpdateBusinessSettingsDto } from './dto/update-business-settings.dto';
|
import { UpdateBusinessSettingsDto } from './dto/update-business-settings.dto';
|
||||||
import { normalizeBusinessPrimaryColorId } from './business-primary-colors';
|
import { normalizeBusinessPrimaryColorId } from './business-primary-colors';
|
||||||
|
|
||||||
@@ -170,6 +171,21 @@ export class BusinessSettingsService {
|
|||||||
}).store.orderProcessSteps
|
}).store.orderProcessSteps
|
||||||
: current.store.orderProcessSteps,
|
: current.store.orderProcessSteps,
|
||||||
ePayment: ePaymentPatch ?? current.store.ePayment,
|
ePayment: ePaymentPatch ?? current.store.ePayment,
|
||||||
|
staticShippingEnabled:
|
||||||
|
dto.store.staticShippingEnabled ??
|
||||||
|
current.store.staticShippingEnabled,
|
||||||
|
staticShippingAmount:
|
||||||
|
dto.store.staticShippingAmount ??
|
||||||
|
current.store.staticShippingAmount,
|
||||||
|
staticShippingProvinceExceptions:
|
||||||
|
dto.store.staticShippingProvinceExceptions !== undefined
|
||||||
|
? normalizeBusinessSettings({
|
||||||
|
store: {
|
||||||
|
staticShippingProvinceExceptions:
|
||||||
|
dto.store.staticShippingProvinceExceptions,
|
||||||
|
},
|
||||||
|
}).store.staticShippingProvinceExceptions
|
||||||
|
: current.store.staticShippingProvinceExceptions,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,6 +218,31 @@ export class BusinessSettingsService {
|
|||||||
: current.website.schema.sameAs,
|
: current.website.schema.sameAs,
|
||||||
})
|
})
|
||||||
: current.website.schema,
|
: current.website.schema,
|
||||||
|
tagsAndBadges:
|
||||||
|
dto.website.tagsAndBadges !== undefined
|
||||||
|
? normalizeWebsiteTagsAndBadges({
|
||||||
|
...current.website.tagsAndBadges,
|
||||||
|
...dto.website.tagsAndBadges,
|
||||||
|
metaTags:
|
||||||
|
dto.website.tagsAndBadges.metaTags !== undefined
|
||||||
|
? dto.website.tagsAndBadges.metaTags
|
||||||
|
: current.website.tagsAndBadges.metaTags,
|
||||||
|
enamad:
|
||||||
|
dto.website.tagsAndBadges.enamad !== undefined
|
||||||
|
? {
|
||||||
|
...current.website.tagsAndBadges.enamad,
|
||||||
|
...dto.website.tagsAndBadges.enamad,
|
||||||
|
}
|
||||||
|
: current.website.tagsAndBadges.enamad,
|
||||||
|
samandehi:
|
||||||
|
dto.website.tagsAndBadges.samandehi !== undefined
|
||||||
|
? {
|
||||||
|
...current.website.tagsAndBadges.samandehi,
|
||||||
|
...dto.website.tagsAndBadges.samandehi,
|
||||||
|
}
|
||||||
|
: current.website.tagsAndBadges.samandehi,
|
||||||
|
})
|
||||||
|
: current.website.tagsAndBadges,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -92,6 +92,12 @@ export type EPaymentSettings = {
|
|||||||
gateways: PaymentGatewaysSettings;
|
gateways: PaymentGatewaysSettings;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Per-province override for the static flat shipping fee. */
|
||||||
|
export type StaticShippingProvinceException = {
|
||||||
|
provinceId: string;
|
||||||
|
amount: number;
|
||||||
|
};
|
||||||
|
|
||||||
/** Per-business store / sales settings. */
|
/** Per-business store / sales settings. */
|
||||||
export type StoreSettings = {
|
export type StoreSettings = {
|
||||||
onlineSellEnabled: boolean;
|
onlineSellEnabled: boolean;
|
||||||
@@ -99,6 +105,14 @@ export type StoreSettings = {
|
|||||||
torobEnabled: boolean;
|
torobEnabled: boolean;
|
||||||
orderProcessSteps: OrderProcessStep[];
|
orderProcessSteps: OrderProcessStep[];
|
||||||
ePayment: EPaymentSettings;
|
ePayment: EPaymentSettings;
|
||||||
|
/**
|
||||||
|
* When true, every order uses `staticShippingAmount` and shipping cost groups are ignored.
|
||||||
|
*/
|
||||||
|
staticShippingEnabled: boolean;
|
||||||
|
/** Flat shipping fee in IRT applied to all orders when static shipping is enabled. */
|
||||||
|
staticShippingAmount: number;
|
||||||
|
/** Province-specific overrides of the static flat fee. */
|
||||||
|
staticShippingProvinceExceptions: StaticShippingProvinceException[];
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Where website special-product carousels read from. */
|
/** Where website special-product carousels read from. */
|
||||||
@@ -113,6 +127,40 @@ export type WebsiteSettings = {
|
|||||||
sitemapConfig?: WebsiteSitemapConfig | null;
|
sitemapConfig?: WebsiteSitemapConfig | null;
|
||||||
/** Site-wide Schema.org Organization / LocalBusiness settings (Phase A). */
|
/** Site-wide Schema.org Organization / LocalBusiness settings (Phase A). */
|
||||||
schema: WebsiteSchemaSettings;
|
schema: WebsiteSchemaSettings;
|
||||||
|
/** Custom <meta> tags + eNamad / Samandehi trust badges. */
|
||||||
|
tagsAndBadges: WebsiteTagsAndBadgesSettings;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom head meta tag.
|
||||||
|
* `name` = admin label only; `content` = full `<meta …>` HTML to inject.
|
||||||
|
*/
|
||||||
|
export type WebsiteMetaTag = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
content: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Fixed trust integrations (eNamad / Samandehi) — footer HTML only. */
|
||||||
|
export type WebsiteTrustBadgeSettings = {
|
||||||
|
/** Footer widget HTML; empty = not shown. */
|
||||||
|
embedHtml: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WebsiteTagsAndBadgesSettings = {
|
||||||
|
metaTags: WebsiteMetaTag[];
|
||||||
|
enamad: WebsiteTrustBadgeSettings;
|
||||||
|
samandehi: WebsiteTrustBadgeSettings;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEFAULT_WEBSITE_TRUST_BADGE: WebsiteTrustBadgeSettings = {
|
||||||
|
embedHtml: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEFAULT_WEBSITE_TAGS_AND_BADGES: WebsiteTagsAndBadgesSettings = {
|
||||||
|
metaTags: [],
|
||||||
|
enamad: { ...DEFAULT_WEBSITE_TRUST_BADGE },
|
||||||
|
samandehi: { ...DEFAULT_WEBSITE_TRUST_BADGE },
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Schema.org @type for the site-wide entity JSON-LD. */
|
/** Schema.org @type for the site-wide entity JSON-LD. */
|
||||||
@@ -188,6 +236,7 @@ export type WebsiteSitemapConfig = {
|
|||||||
export const BUSINESS_DASHBOARD_MODULE_IDS = [
|
export const BUSINESS_DASHBOARD_MODULE_IDS = [
|
||||||
'products',
|
'products',
|
||||||
'store',
|
'store',
|
||||||
|
'store_installments',
|
||||||
'portfolio',
|
'portfolio',
|
||||||
'blog',
|
'blog',
|
||||||
'warehouse',
|
'warehouse',
|
||||||
@@ -196,6 +245,7 @@ export const BUSINESS_DASHBOARD_MODULE_IDS = [
|
|||||||
'workshops',
|
'workshops',
|
||||||
'finance',
|
'finance',
|
||||||
'multilanguage_data',
|
'multilanguage_data',
|
||||||
|
'roll_call',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
/** Optional customer-dashboard modules. Always-on: home, profile, addresses, orders, favorites. */
|
/** Optional customer-dashboard modules. Always-on: home, profile, addresses, orders, favorites. */
|
||||||
@@ -300,7 +350,11 @@ export const DEFAULT_NEW_BUSINESS_MODULES: BusinessModuleId[] = [
|
|||||||
*/
|
*/
|
||||||
export const DEFAULT_ENABLED_BUSINESS_MODULES: BusinessModuleId[] =
|
export const DEFAULT_ENABLED_BUSINESS_MODULES: BusinessModuleId[] =
|
||||||
BUSINESS_DASHBOARD_MODULE_IDS.filter(
|
BUSINESS_DASHBOARD_MODULE_IDS.filter(
|
||||||
(id) => id !== 'workshops' && id !== 'multilanguage_data',
|
(id) =>
|
||||||
|
id !== 'workshops' &&
|
||||||
|
id !== 'multilanguage_data' &&
|
||||||
|
id !== 'store_installments' &&
|
||||||
|
id !== 'roll_call',
|
||||||
);
|
);
|
||||||
|
|
||||||
export const DEFAULT_HOME_CHARTS: [HomeChartId, HomeChartId] = [
|
export const DEFAULT_HOME_CHARTS: [HomeChartId, HomeChartId] = [
|
||||||
@@ -352,6 +406,9 @@ export const DEFAULT_BUSINESS_SETTINGS: BusinessSettings = {
|
|||||||
torobEnabled: false,
|
torobEnabled: false,
|
||||||
orderProcessSteps: DEFAULT_ORDER_PROCESS_STEPS,
|
orderProcessSteps: DEFAULT_ORDER_PROCESS_STEPS,
|
||||||
ePayment: { ...DEFAULT_EPAYMENT_SETTINGS },
|
ePayment: { ...DEFAULT_EPAYMENT_SETTINGS },
|
||||||
|
staticShippingEnabled: true,
|
||||||
|
staticShippingAmount: 0,
|
||||||
|
staticShippingProvinceExceptions: [],
|
||||||
},
|
},
|
||||||
modules: {
|
modules: {
|
||||||
enabled: DEFAULT_ENABLED_BUSINESS_MODULES,
|
enabled: DEFAULT_ENABLED_BUSINESS_MODULES,
|
||||||
@@ -360,6 +417,11 @@ export const DEFAULT_BUSINESS_SETTINGS: BusinessSettings = {
|
|||||||
website: {
|
website: {
|
||||||
specialProductsSource: 'store_item',
|
specialProductsSource: 'store_item',
|
||||||
schema: { ...DEFAULT_WEBSITE_SCHEMA_SETTINGS },
|
schema: { ...DEFAULT_WEBSITE_SCHEMA_SETTINGS },
|
||||||
|
tagsAndBadges: {
|
||||||
|
metaTags: [],
|
||||||
|
enamad: { ...DEFAULT_WEBSITE_TRUST_BADGE },
|
||||||
|
samandehi: { ...DEFAULT_WEBSITE_TRUST_BADGE },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
sms: {
|
sms: {
|
||||||
senderNumber: null,
|
senderNumber: null,
|
||||||
|
|||||||
@@ -34,12 +34,14 @@ import {
|
|||||||
WebsiteSitemapConfig,
|
WebsiteSitemapConfig,
|
||||||
ZarinpalGatewaySettings,
|
ZarinpalGatewaySettings,
|
||||||
DEFAULT_WEBSITE_SCHEMA_SETTINGS,
|
DEFAULT_WEBSITE_SCHEMA_SETTINGS,
|
||||||
|
DEFAULT_WEBSITE_TAGS_AND_BADGES,
|
||||||
} from './business-settings.types';
|
} from './business-settings.types';
|
||||||
import {
|
import {
|
||||||
defaultOrderStepColor,
|
defaultOrderStepColor,
|
||||||
normalizeOrderStepColor,
|
normalizeOrderStepColor,
|
||||||
} from './order-step-colors';
|
} from './order-step-colors';
|
||||||
import { normalizeWebsiteSchemaSettings } from './website-schema.util';
|
import { normalizeWebsiteSchemaSettings } from './website-schema.util';
|
||||||
|
import { normalizeWebsiteTagsAndBadges } from './website-tags-badges.util';
|
||||||
|
|
||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||||
@@ -119,6 +121,44 @@ function readBoolean(value: unknown, fallback: boolean) {
|
|||||||
return typeof value === 'boolean' ? value : fallback;
|
return typeof value === 'boolean' ? value : fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readNonNegativeNumber(value: unknown, fallback: number) {
|
||||||
|
if (typeof value === 'number' && Number.isFinite(value) && value >= 0) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (typeof value === 'string' && value.trim() !== '') {
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (Number.isFinite(parsed) && parsed >= 0) return parsed;
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readStaticShippingProvinceExceptions(
|
||||||
|
value: unknown,
|
||||||
|
): BusinessSettings['store']['staticShippingProvinceExceptions'] {
|
||||||
|
if (!Array.isArray(value)) return [];
|
||||||
|
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const items: BusinessSettings['store']['staticShippingProvinceExceptions'] =
|
||||||
|
[];
|
||||||
|
|
||||||
|
for (const entry of value) {
|
||||||
|
if (!isRecord(entry)) continue;
|
||||||
|
const provinceId =
|
||||||
|
typeof entry.provinceId === 'string'
|
||||||
|
? entry.provinceId.trim()
|
||||||
|
: typeof entry.provinceId === 'number'
|
||||||
|
? String(entry.provinceId)
|
||||||
|
: '';
|
||||||
|
if (!provinceId || seen.has(provinceId)) continue;
|
||||||
|
const amount = readNonNegativeNumber(entry.amount, Number.NaN);
|
||||||
|
if (!Number.isFinite(amount)) continue;
|
||||||
|
seen.add(provinceId);
|
||||||
|
items.push({ provinceId, amount });
|
||||||
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
function readString(value: unknown, fallback = '') {
|
function readString(value: unknown, fallback = '') {
|
||||||
return typeof value === 'string' ? value.trim() : fallback;
|
return typeof value === 'string' ? value.trim() : fallback;
|
||||||
}
|
}
|
||||||
@@ -332,7 +372,17 @@ export function listPublicPaymentGateways(settings: EPaymentSettings) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const PAYMENT_PROCESS_STEP_IDS = ['awaiting-payment', 'payment-successful'] as const;
|
/** System-managed payment steps — set by gateway callbacks, not admin UI. */
|
||||||
|
export const PAYMENT_PROCESS_STEP_IDS = [
|
||||||
|
'awaiting-payment',
|
||||||
|
'payment-successful',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type PaymentProcessStepId = (typeof PAYMENT_PROCESS_STEP_IDS)[number];
|
||||||
|
|
||||||
|
export function isSystemPaymentProcessStep(stepId: string): boolean {
|
||||||
|
return (PAYMENT_PROCESS_STEP_IDS as readonly string[]).includes(stepId);
|
||||||
|
}
|
||||||
|
|
||||||
/** Prepend default payment steps when an older store settings JSON omits them. */
|
/** Prepend default payment steps when an older store settings JSON omits them. */
|
||||||
function ensurePaymentProcessSteps(steps: OrderProcessStep[]): OrderProcessStep[] {
|
function ensurePaymentProcessSteps(steps: OrderProcessStep[]): OrderProcessStep[] {
|
||||||
@@ -431,6 +481,17 @@ export function normalizeBusinessSettings(raw: unknown): BusinessSettings {
|
|||||||
),
|
),
|
||||||
orderProcessSteps: readOrderProcessSteps(store.orderProcessSteps),
|
orderProcessSteps: readOrderProcessSteps(store.orderProcessSteps),
|
||||||
ePayment: normalizeEPaymentSettings(store.ePayment),
|
ePayment: normalizeEPaymentSettings(store.ePayment),
|
||||||
|
staticShippingEnabled: readBoolean(
|
||||||
|
store.staticShippingEnabled,
|
||||||
|
DEFAULT_BUSINESS_SETTINGS.store.staticShippingEnabled,
|
||||||
|
),
|
||||||
|
staticShippingAmount: readNonNegativeNumber(
|
||||||
|
store.staticShippingAmount,
|
||||||
|
DEFAULT_BUSINESS_SETTINGS.store.staticShippingAmount,
|
||||||
|
),
|
||||||
|
staticShippingProvinceExceptions: readStaticShippingProvinceExceptions(
|
||||||
|
store.staticShippingProvinceExceptions,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
modules: {
|
modules: {
|
||||||
// Missing `modules` → all enabled (legacy). Explicit `{ enabled: [] }` stays empty.
|
// Missing `modules` → all enabled (legacy). Explicit `{ enabled: [] }` stays empty.
|
||||||
@@ -446,6 +507,11 @@ export function normalizeBusinessSettings(raw: unknown): BusinessSettings {
|
|||||||
schema: normalizeWebsiteSchemaSettings(
|
schema: normalizeWebsiteSchemaSettings(
|
||||||
isRecord(website) ? website.schema : DEFAULT_WEBSITE_SCHEMA_SETTINGS,
|
isRecord(website) ? website.schema : DEFAULT_WEBSITE_SCHEMA_SETTINGS,
|
||||||
),
|
),
|
||||||
|
tagsAndBadges: normalizeWebsiteTagsAndBadges(
|
||||||
|
isRecord(website)
|
||||||
|
? website.tagsAndBadges
|
||||||
|
: DEFAULT_WEBSITE_TAGS_AND_BADGES,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
sms: {
|
sms: {
|
||||||
senderNumber: normalizeSmsSenderNumber(sms.senderNumber),
|
senderNumber: normalizeSmsSenderNumber(sms.senderNumber),
|
||||||
@@ -487,6 +553,14 @@ export function mergeBusinessSettings(
|
|||||||
current.store.ePayment,
|
current.store.ePayment,
|
||||||
patch.store?.ePayment,
|
patch.store?.ePayment,
|
||||||
),
|
),
|
||||||
|
staticShippingEnabled:
|
||||||
|
patch.store?.staticShippingEnabled ??
|
||||||
|
current.store.staticShippingEnabled,
|
||||||
|
staticShippingAmount:
|
||||||
|
patch.store?.staticShippingAmount ?? current.store.staticShippingAmount,
|
||||||
|
staticShippingProvinceExceptions:
|
||||||
|
patch.store?.staticShippingProvinceExceptions ??
|
||||||
|
current.store.staticShippingProvinceExceptions,
|
||||||
},
|
},
|
||||||
modules: {
|
modules: {
|
||||||
enabled: patch.modules?.enabled ?? current.modules.enabled,
|
enabled: patch.modules?.enabled ?? current.modules.enabled,
|
||||||
@@ -504,6 +578,10 @@ export function mergeBusinessSettings(
|
|||||||
patch.website?.schema !== undefined
|
patch.website?.schema !== undefined
|
||||||
? patch.website.schema
|
? patch.website.schema
|
||||||
: current.website.schema,
|
: current.website.schema,
|
||||||
|
tagsAndBadges:
|
||||||
|
patch.website?.tagsAndBadges !== undefined
|
||||||
|
? patch.website.tagsAndBadges
|
||||||
|
: current.website.tagsAndBadges,
|
||||||
},
|
},
|
||||||
sms: {
|
sms: {
|
||||||
senderNumber:
|
senderNumber:
|
||||||
|
|||||||
@@ -5,8 +5,11 @@ import {
|
|||||||
IsArray,
|
IsArray,
|
||||||
IsBoolean,
|
IsBoolean,
|
||||||
IsIn,
|
IsIn,
|
||||||
|
IsNumber,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
|
MaxLength,
|
||||||
|
Min,
|
||||||
MinLength,
|
MinLength,
|
||||||
ValidateIf,
|
ValidateIf,
|
||||||
ValidateNested,
|
ValidateNested,
|
||||||
@@ -163,6 +166,17 @@ class EPaymentSettingsDto {
|
|||||||
gateways?: PaymentGatewaysSettingsDto;
|
gateways?: PaymentGatewaysSettingsDto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class StaticShippingProvinceExceptionDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
provinceId!: string;
|
||||||
|
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber({ maxDecimalPlaces: 2 })
|
||||||
|
@Min(0)
|
||||||
|
amount!: number;
|
||||||
|
}
|
||||||
|
|
||||||
class StoreSettingsDto {
|
class StoreSettingsDto {
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
@@ -182,6 +196,22 @@ class StoreSettingsDto {
|
|||||||
@ValidateNested()
|
@ValidateNested()
|
||||||
@Type(() => EPaymentSettingsDto)
|
@Type(() => EPaymentSettingsDto)
|
||||||
ePayment?: EPaymentSettingsDto;
|
ePayment?: EPaymentSettingsDto;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
staticShippingEnabled?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber({ maxDecimalPlaces: 2 })
|
||||||
|
@Min(0)
|
||||||
|
staticShippingAmount?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => StaticShippingProvinceExceptionDto)
|
||||||
|
staticShippingProvinceExceptions?: StaticShippingProvinceExceptionDto[];
|
||||||
}
|
}
|
||||||
|
|
||||||
class ModulesSettingsDto {
|
class ModulesSettingsDto {
|
||||||
@@ -233,6 +263,51 @@ class WebsiteSchemaSettingsDto {
|
|||||||
sameAs?: string[];
|
sameAs?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class WebsiteMetaTagDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(64)
|
||||||
|
id?: string;
|
||||||
|
|
||||||
|
/** Admin label only (not necessarily the HTML name= attribute). */
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(80)
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
/** Full `<meta …>` HTML snippet. */
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(4000)
|
||||||
|
content!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
class WebsiteTrustBadgeDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(10000)
|
||||||
|
embedHtml?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
class WebsiteTagsAndBadgesDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@ArrayMaxSize(40)
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => WebsiteMetaTagDto)
|
||||||
|
metaTags?: WebsiteMetaTagDto[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@ValidateNested()
|
||||||
|
@Type(() => WebsiteTrustBadgeDto)
|
||||||
|
enamad?: WebsiteTrustBadgeDto;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@ValidateNested()
|
||||||
|
@Type(() => WebsiteTrustBadgeDto)
|
||||||
|
samandehi?: WebsiteTrustBadgeDto;
|
||||||
|
}
|
||||||
|
|
||||||
class WebsiteSettingsDto {
|
class WebsiteSettingsDto {
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -243,6 +318,11 @@ class WebsiteSettingsDto {
|
|||||||
@ValidateNested()
|
@ValidateNested()
|
||||||
@Type(() => WebsiteSchemaSettingsDto)
|
@Type(() => WebsiteSchemaSettingsDto)
|
||||||
schema?: WebsiteSchemaSettingsDto;
|
schema?: WebsiteSchemaSettingsDto;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@ValidateNested()
|
||||||
|
@Type(() => WebsiteTagsAndBadgesDto)
|
||||||
|
tagsAndBadges?: WebsiteTagsAndBadgesDto;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class UpdateBusinessSettingsDto {
|
export class UpdateBusinessSettingsDto {
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
import {
|
||||||
|
DEFAULT_WEBSITE_TRUST_BADGE,
|
||||||
|
type WebsiteMetaTag,
|
||||||
|
type WebsiteTagsAndBadgesSettings,
|
||||||
|
type WebsiteTrustBadgeSettings,
|
||||||
|
} from './business-settings.types';
|
||||||
|
|
||||||
|
const MAX_META_TAGS = 40;
|
||||||
|
const MAX_LABEL_LEN = 80;
|
||||||
|
const MAX_META_HTML_LEN = 4000;
|
||||||
|
const MAX_EMBED_LEN = 10000;
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readString(value: unknown, maxLen: number): string {
|
||||||
|
if (typeof value !== 'string') return '';
|
||||||
|
return value.trim().slice(0, maxLen);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Strip scripts / handlers from pasted trust-seal HTML. */
|
||||||
|
export function sanitizeTrustEmbedHtml(raw: string): string {
|
||||||
|
let html = raw.slice(0, MAX_EMBED_LEN);
|
||||||
|
html = html.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, '');
|
||||||
|
html = html.replace(/<script\b[^>]*\/?>/gi, '');
|
||||||
|
html = html.replace(/\son\w+\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, '');
|
||||||
|
html = html.replace(/javascript\s*:/gi, '');
|
||||||
|
return html.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep a single <meta …> tag. Reject anything else.
|
||||||
|
* Legacy plain content + attr-name is wrapped into a meta tag.
|
||||||
|
*/
|
||||||
|
export function sanitizeMetaTagHtml(
|
||||||
|
rawHtml: string,
|
||||||
|
legacyAttrName?: string,
|
||||||
|
): string | null {
|
||||||
|
let html = rawHtml.trim().slice(0, MAX_META_HTML_LEN);
|
||||||
|
if (!html) return null;
|
||||||
|
|
||||||
|
html = html.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, '');
|
||||||
|
html = html.replace(/<script\b[^>]*\/?>/gi, '');
|
||||||
|
html = html.replace(/\son\w+\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, '');
|
||||||
|
html = html.replace(/javascript\s*:/gi, '');
|
||||||
|
html = html.trim();
|
||||||
|
|
||||||
|
if (!/^<meta\b/i.test(html)) {
|
||||||
|
// Legacy: name was HTML meta name, content was only the content value.
|
||||||
|
const attr = (legacyAttrName ?? '').trim();
|
||||||
|
if (!attr || !/^[a-zA-Z0-9_.:-]{1,80}$/.test(attr) || /[<>]/.test(html)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const escaped = html.replace(/&/g, '&').replace(/"/g, '"');
|
||||||
|
return `<meta name="${attr}" content="${escaped}">`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = html.match(/<meta\b[^>]*>/i);
|
||||||
|
if (!match) return null;
|
||||||
|
return match[0].trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTrustBadge(raw: unknown): WebsiteTrustBadgeSettings {
|
||||||
|
const source = isRecord(raw) ? raw : {};
|
||||||
|
return {
|
||||||
|
embedHtml: sanitizeTrustEmbedHtml(
|
||||||
|
typeof source.embedHtml === 'string'
|
||||||
|
? source.embedHtml
|
||||||
|
: DEFAULT_WEBSITE_TRUST_BADGE.embedHtml,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeMetaTag(raw: unknown): WebsiteMetaTag | null {
|
||||||
|
if (!isRecord(raw)) return null;
|
||||||
|
if (raw.enabled === false) return null;
|
||||||
|
|
||||||
|
const label = readString(raw.name, MAX_LABEL_LEN);
|
||||||
|
const rawContent =
|
||||||
|
typeof raw.content === 'string' ? raw.content.trim() : '';
|
||||||
|
if (!label || !rawContent) return null;
|
||||||
|
|
||||||
|
// If content is already a full <meta>, label is display-only.
|
||||||
|
// If not, treat legacy attr-name + content-value (use label as attr name only when it looks like one).
|
||||||
|
const looksLikeHtml = /^<meta\b/i.test(rawContent);
|
||||||
|
const html = sanitizeMetaTagHtml(
|
||||||
|
rawContent,
|
||||||
|
looksLikeHtml ? undefined : label,
|
||||||
|
);
|
||||||
|
if (!html) return null;
|
||||||
|
|
||||||
|
const id =
|
||||||
|
typeof raw.id === 'string' && raw.id.trim().length > 0
|
||||||
|
? raw.id.trim().slice(0, 64)
|
||||||
|
: randomUUID();
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: label,
|
||||||
|
content: html,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeWebsiteTagsAndBadges(
|
||||||
|
raw: unknown,
|
||||||
|
): WebsiteTagsAndBadgesSettings {
|
||||||
|
const source = isRecord(raw) ? raw : {};
|
||||||
|
const metaTags: WebsiteMetaTag[] = [];
|
||||||
|
if (Array.isArray(source.metaTags)) {
|
||||||
|
for (const item of source.metaTags) {
|
||||||
|
const tag = normalizeMetaTag(item);
|
||||||
|
if (!tag) continue;
|
||||||
|
metaTags.push(tag);
|
||||||
|
if (metaTags.length >= MAX_META_TAGS) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
metaTags,
|
||||||
|
enamad: normalizeTrustBadge(source.enamad),
|
||||||
|
samandehi: normalizeTrustBadge(source.samandehi),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Full <meta> HTML snippets for storefront <head>. */
|
||||||
|
export type PublicWebsiteMetaTag = { html: string };
|
||||||
|
|
||||||
|
export type PublicWebsiteTrustBadge = {
|
||||||
|
kind: 'enamad' | 'samandehi';
|
||||||
|
embedHtml: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function toPublicWebsiteMetaTags(
|
||||||
|
settings: WebsiteTagsAndBadgesSettings,
|
||||||
|
): PublicWebsiteMetaTag[] {
|
||||||
|
const out: PublicWebsiteMetaTag[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
for (const tag of settings.metaTags) {
|
||||||
|
const html = tag.content?.trim();
|
||||||
|
if (!html) continue;
|
||||||
|
const key = html.toLowerCase();
|
||||||
|
if (seen.has(key)) continue;
|
||||||
|
seen.add(key);
|
||||||
|
out.push({ html });
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toPublicWebsiteTrustBadges(
|
||||||
|
settings: WebsiteTagsAndBadgesSettings,
|
||||||
|
): PublicWebsiteTrustBadge[] {
|
||||||
|
const out: PublicWebsiteTrustBadge[] = [];
|
||||||
|
for (const kind of ['enamad', 'samandehi'] as const) {
|
||||||
|
const embedHtml = settings[kind].embedHtml;
|
||||||
|
if (!embedHtml) continue;
|
||||||
|
out.push({ kind, embedHtml });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function emptyWebsiteTagsAndBadges(): WebsiteTagsAndBadgesSettings {
|
||||||
|
return {
|
||||||
|
metaTags: [],
|
||||||
|
enamad: { ...DEFAULT_WEBSITE_TRUST_BADGE },
|
||||||
|
samandehi: { ...DEFAULT_WEBSITE_TRUST_BADGE },
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -3,11 +3,18 @@ import { AuthModule } from '../auth/auth.module';
|
|||||||
import { BusinessSettingsModule } from '../business-settings/business-settings.module';
|
import { BusinessSettingsModule } from '../business-settings/business-settings.module';
|
||||||
import { OrdersModule } from '../orders/orders.module';
|
import { OrdersModule } from '../orders/orders.module';
|
||||||
import { PaymentsModule } from '../payments/payments.module';
|
import { PaymentsModule } from '../payments/payments.module';
|
||||||
|
import { StoreModule } from '../store/store.module';
|
||||||
import { CartController } from './cart.controller';
|
import { CartController } from './cart.controller';
|
||||||
import { CartService } from './cart.service';
|
import { CartService } from './cart.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [AuthModule, OrdersModule, PaymentsModule, BusinessSettingsModule],
|
imports: [
|
||||||
|
AuthModule,
|
||||||
|
OrdersModule,
|
||||||
|
PaymentsModule,
|
||||||
|
BusinessSettingsModule,
|
||||||
|
StoreModule,
|
||||||
|
],
|
||||||
controllers: [CartController],
|
controllers: [CartController],
|
||||||
providers: [CartService],
|
providers: [CartService],
|
||||||
})
|
})
|
||||||
|
|||||||
+114
-10
@@ -20,6 +20,7 @@ import { TransactionType } from '@prisma/client';
|
|||||||
import { BusinessSettingsService } from '../business-settings/business-settings.service';
|
import { BusinessSettingsService } from '../business-settings/business-settings.service';
|
||||||
import type { PaymentGatewayId } from '../business-settings/business-settings.types';
|
import type { PaymentGatewayId } from '../business-settings/business-settings.types';
|
||||||
import { listPublicPaymentGateways } from '../business-settings/business-settings.util';
|
import { listPublicPaymentGateways } from '../business-settings/business-settings.util';
|
||||||
|
import { ShippingCostGroupsService } from '../store/shipping-cost-groups.service';
|
||||||
|
|
||||||
const cartVariantInclude = {
|
const cartVariantInclude = {
|
||||||
storeItem: {
|
storeItem: {
|
||||||
@@ -59,6 +60,7 @@ export class CartService {
|
|||||||
private readonly orders: OrdersService,
|
private readonly orders: OrdersService,
|
||||||
private readonly payments: PaymentsService,
|
private readonly payments: PaymentsService,
|
||||||
private readonly businessSettings: BusinessSettingsService,
|
private readonly businessSettings: BusinessSettingsService,
|
||||||
|
private readonly shippingCostGroups: ShippingCostGroupsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async getCart(businessIdRaw: string, actor: AuthUser) {
|
async getCart(businessIdRaw: string, actor: AuthUser) {
|
||||||
@@ -66,7 +68,7 @@ export class CartService {
|
|||||||
await this.assertCustomerAccess(businessId, actor);
|
await this.assertCustomerAccess(businessId, actor);
|
||||||
|
|
||||||
const cart = await this.getOrCreateCart(businessId, actor.id);
|
const cart = await this.getOrCreateCart(businessId, actor.id);
|
||||||
return { cart: this.serializeCart(cart) };
|
return { cart: await this.serializeCart(cart) };
|
||||||
}
|
}
|
||||||
|
|
||||||
async addItem(businessIdRaw: string, dto: AddCartItemDto, actor: AuthUser) {
|
async addItem(businessIdRaw: string, dto: AddCartItemDto, actor: AuthUser) {
|
||||||
@@ -97,7 +99,7 @@ export class CartService {
|
|||||||
const refreshed = await this.loadCart(cart.id);
|
const refreshed = await this.loadCart(cart.id);
|
||||||
return {
|
return {
|
||||||
message: 'Cart item quantity updated',
|
message: 'Cart item quantity updated',
|
||||||
cart: this.serializeCart(refreshed),
|
cart: await this.serializeCart(refreshed),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,7 +116,7 @@ export class CartService {
|
|||||||
const refreshed = await this.loadCart(cart.id);
|
const refreshed = await this.loadCart(cart.id);
|
||||||
return {
|
return {
|
||||||
message: 'Item added to cart',
|
message: 'Item added to cart',
|
||||||
cart: this.serializeCart(refreshed),
|
cart: await this.serializeCart(refreshed),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,7 +147,7 @@ export class CartService {
|
|||||||
const refreshed = await this.loadCart(cart.id);
|
const refreshed = await this.loadCart(cart.id);
|
||||||
return {
|
return {
|
||||||
message: 'Cart item updated',
|
message: 'Cart item updated',
|
||||||
cart: this.serializeCart(refreshed),
|
cart: await this.serializeCart(refreshed),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,7 +168,7 @@ export class CartService {
|
|||||||
const refreshed = await this.loadCart(cart.id);
|
const refreshed = await this.loadCart(cart.id);
|
||||||
return {
|
return {
|
||||||
message: 'Cart item removed',
|
message: 'Cart item removed',
|
||||||
cart: this.serializeCart(refreshed),
|
cart: await this.serializeCart(refreshed),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,7 +182,7 @@ export class CartService {
|
|||||||
const refreshed = await this.loadCart(cart.id);
|
const refreshed = await this.loadCart(cart.id);
|
||||||
return {
|
return {
|
||||||
message: 'Cart cleared',
|
message: 'Cart cleared',
|
||||||
cart: this.serializeCart(refreshed),
|
cart: await this.serializeCart(refreshed),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,6 +206,71 @@ export class CartService {
|
|||||||
dto.shippingAddress,
|
dto.shippingAddress,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const deliveryMode =
|
||||||
|
dto.deliveryMode === 'pickup' || dto.deliveryMode === 'delivery'
|
||||||
|
? dto.deliveryMode
|
||||||
|
: dto.addressId
|
||||||
|
? 'delivery'
|
||||||
|
: 'pickup';
|
||||||
|
|
||||||
|
const selectedMethodsByGroupId: Record<string, string> = {};
|
||||||
|
for (const group of dto.shippingGroups ?? []) {
|
||||||
|
if (group.methodId?.trim()) {
|
||||||
|
selectedMethodsByGroupId[group.groupId] = group.methodId.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const productIds = [
|
||||||
|
...new Set(
|
||||||
|
cart.items.map((item) => item.storeItemVariant.storeItem.product.id),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
const assignments =
|
||||||
|
productIds.length === 0
|
||||||
|
? []
|
||||||
|
: await this.prisma.categoryAssignment.findMany({
|
||||||
|
where: {
|
||||||
|
businessId,
|
||||||
|
entityType: 'product',
|
||||||
|
entityId: { in: productIds },
|
||||||
|
},
|
||||||
|
select: { entityId: true, categoryId: true },
|
||||||
|
});
|
||||||
|
const categoryIdsByProduct = new Map<string, string[]>();
|
||||||
|
for (const row of assignments) {
|
||||||
|
const key = row.entityId.toString();
|
||||||
|
const list = categoryIdsByProduct.get(key) ?? [];
|
||||||
|
list.push(row.categoryId.toString());
|
||||||
|
categoryIdsByProduct.set(key, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
const shippingQuote = await this.shippingCostGroups.quoteForCheckout({
|
||||||
|
businessId,
|
||||||
|
deliveryMode,
|
||||||
|
provinceName:
|
||||||
|
deliveryMode === 'delivery' ? String(shippingAddress.province ?? '') : null,
|
||||||
|
cartItems: cart.items.map((item) => {
|
||||||
|
const product = item.storeItemVariant.storeItem.product;
|
||||||
|
const content = this.asRecord(product.content);
|
||||||
|
const nameFa = (content.nameFa as string | null | undefined)?.trim();
|
||||||
|
return {
|
||||||
|
productName: nameFa || product.title,
|
||||||
|
categoryIds:
|
||||||
|
categoryIdsByProduct.get(product.id.toString()) ?? [],
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
selectedMethodsByGroupId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const enrichedShippingAddress = {
|
||||||
|
...shippingAddress,
|
||||||
|
deliveryMode,
|
||||||
|
shipping: {
|
||||||
|
total: shippingQuote.total,
|
||||||
|
groups: shippingQuote.groups,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
const isEPayment = dto.payment.type === TransactionType.e_payment_gate;
|
const isEPayment = dto.payment.type === TransactionType.e_payment_gate;
|
||||||
let gatewayType: PaymentGatewayId | undefined;
|
let gatewayType: PaymentGatewayId | undefined;
|
||||||
|
|
||||||
@@ -234,7 +301,8 @@ export class CartService {
|
|||||||
createdBy: actor.id,
|
createdBy: actor.id,
|
||||||
source: 'website',
|
source: 'website',
|
||||||
cartItems: cart.items,
|
cartItems: cart.items,
|
||||||
shippingAddress,
|
shippingAddress: enrichedShippingAddress,
|
||||||
|
shippingTotal: shippingQuote.total,
|
||||||
addressId: dto.addressId ? BigInt(dto.addressId) : null,
|
addressId: dto.addressId ? BigInt(dto.addressId) : null,
|
||||||
customerNotes: dto.customerNotes?.trim() || null,
|
customerNotes: dto.customerNotes?.trim() || null,
|
||||||
adminNotes: null,
|
adminNotes: null,
|
||||||
@@ -393,8 +461,40 @@ export class CartService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private serializeCart(cart: CartWithItems) {
|
private async serializeCart(cart: CartWithItems) {
|
||||||
const items = cart.items.map((item) => this.serializeCartItem(item));
|
const productIds = [
|
||||||
|
...new Set(
|
||||||
|
cart.items.map((item) => item.storeItemVariant.storeItem.product.id),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
const assignments =
|
||||||
|
productIds.length === 0
|
||||||
|
? []
|
||||||
|
: await this.prisma.categoryAssignment.findMany({
|
||||||
|
where: {
|
||||||
|
businessId: cart.businessId,
|
||||||
|
entityType: 'product',
|
||||||
|
entityId: { in: productIds },
|
||||||
|
},
|
||||||
|
select: { entityId: true, categoryId: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const categoryIdsByProduct = new Map<string, string[]>();
|
||||||
|
for (const row of assignments) {
|
||||||
|
const key = row.entityId.toString();
|
||||||
|
const list = categoryIdsByProduct.get(key) ?? [];
|
||||||
|
list.push(row.categoryId.toString());
|
||||||
|
categoryIdsByProduct.set(key, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = cart.items.map((item) => {
|
||||||
|
const productId = item.storeItemVariant.storeItem.product.id.toString();
|
||||||
|
return this.serializeCartItem(
|
||||||
|
item,
|
||||||
|
categoryIdsByProduct.get(productId) ?? [],
|
||||||
|
);
|
||||||
|
});
|
||||||
const subtotal = items.reduce((sum, item) => sum + item.lineTotal, 0);
|
const subtotal = items.reduce((sum, item) => sum + item.lineTotal, 0);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -407,7 +507,10 @@ export class CartService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private serializeCartItem(item: CartWithItems['items'][number]) {
|
private serializeCartItem(
|
||||||
|
item: CartWithItems['items'][number],
|
||||||
|
categoryIds: string[],
|
||||||
|
) {
|
||||||
const variant = item.storeItemVariant;
|
const variant = item.storeItemVariant;
|
||||||
const product = variant.storeItem.product;
|
const product = variant.storeItem.product;
|
||||||
const content = this.asRecord(product.content);
|
const content = this.asRecord(product.content);
|
||||||
@@ -431,6 +534,7 @@ export class CartService {
|
|||||||
productTitle: product.title,
|
productTitle: product.title,
|
||||||
productNameFa: (content.nameFa as string | null | undefined) ?? '',
|
productNameFa: (content.nameFa as string | null | undefined) ?? '',
|
||||||
productImage: product.featuredMedia?.publicUrl ?? null,
|
productImage: product.featuredMedia?.publicUrl ?? null,
|
||||||
|
categoryIds,
|
||||||
sku: variant.sku,
|
sku: variant.sku,
|
||||||
selections,
|
selections,
|
||||||
label: selections.map((entry) => entry.value).join(' · ') || product.title,
|
label: selections.map((entry) => entry.value).join(' · ') || product.title,
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import {
|
import {
|
||||||
|
IsArray,
|
||||||
|
IsIn,
|
||||||
IsInt,
|
IsInt,
|
||||||
|
IsNumber,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
IsUrl,
|
IsUrl,
|
||||||
@@ -53,6 +56,35 @@ export class ShippingAddressDto {
|
|||||||
landline?: string;
|
landline?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class CheckoutShippingGroupDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
groupId!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
title?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
methodId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
methodLabel?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber({ maxDecimalPlaces: 2 })
|
||||||
|
@Min(0)
|
||||||
|
amount?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
itemNames?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export class CheckoutCartDto {
|
export class CheckoutCartDto {
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -64,6 +96,16 @@ export class CheckoutCartDto {
|
|||||||
@Type(() => ShippingAddressDto)
|
@Type(() => ShippingAddressDto)
|
||||||
shippingAddress?: ShippingAddressDto;
|
shippingAddress?: ShippingAddressDto;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(['delivery', 'pickup'])
|
||||||
|
deliveryMode?: 'delivery' | 'pickup';
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => CheckoutShippingGroupDto)
|
||||||
|
shippingGroups?: CheckoutShippingGroupDto[];
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
customerNotes?: string;
|
customerNotes?: string;
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ Rules:
|
|||||||
- Use 2-6 top-level categories when appropriate.
|
- Use 2-6 top-level categories when appropriate.
|
||||||
- Add subcategories only where they help shoppers browse (max depth ${MAX_DEPTH}).
|
- Add subcategories only where they help shoppers browse (max depth ${MAX_DEPTH}).
|
||||||
- Every node needs nameEn and nameFa (Farsi in Persian script).
|
- Every node needs nameEn and nameFa (Farsi in Persian script).
|
||||||
- Descriptions are optional, max 120 characters, English only.
|
- Descriptions are optional, max 120 characters. Match the user prompt language (Persian if the prompt is Persian, otherwise English).
|
||||||
- Do not duplicate category names at the same level.
|
- Do not duplicate category names at the same level.
|
||||||
- Total nodes must not exceed ${MAX_TOTAL}.`;
|
- Total nodes must not exceed ${MAX_TOTAL}.`;
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
requestAiJsonCompletion,
|
requestAiJsonCompletion,
|
||||||
resolveAiProvider,
|
resolveAiProvider,
|
||||||
} from '../common/ai-provider.util';
|
} from '../common/ai-provider.util';
|
||||||
|
import { loadReferencePagesFromText } from '../common/ai-reference-pages.util';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { CategoryTechnicalFormService } from './category-technical-form.service';
|
import { CategoryTechnicalFormService } from './category-technical-form.service';
|
||||||
import {
|
import {
|
||||||
@@ -26,7 +27,22 @@ type AiTechnicalFormDraft = {
|
|||||||
}[];
|
}[];
|
||||||
};
|
};
|
||||||
|
|
||||||
const ALLOWED_TYPES = new Set(['text', 'textarea', 'select', 'multi_select']);
|
const ALLOWED_TYPES = new Set([
|
||||||
|
'text',
|
||||||
|
'textarea',
|
||||||
|
'select',
|
||||||
|
'multi_select',
|
||||||
|
'multi_value',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const OTHER_FEATURES_LABEL = 'سایر ویژگیها';
|
||||||
|
const OTHER_FEATURES_LABELS = new Set([
|
||||||
|
'سایر ویژگیها',
|
||||||
|
'سایر ویژگی ها',
|
||||||
|
'other features',
|
||||||
|
'other feature',
|
||||||
|
'additional features',
|
||||||
|
]);
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CategoryTechnicalFormAiService {
|
export class CategoryTechnicalFormAiService {
|
||||||
@@ -86,30 +102,47 @@ export class CategoryTechnicalFormAiService {
|
|||||||
existingFieldCount: number;
|
existingFieldCount: number;
|
||||||
}): Promise<TechnicalFormFieldInputDto[]> {
|
}): Promise<TechnicalFormFieldInputDto[]> {
|
||||||
const provider = resolveAiProvider(this.config);
|
const provider = resolveAiProvider(this.config);
|
||||||
|
const referencePages = await loadReferencePagesFromText(input.hint ?? '');
|
||||||
|
const usableReferences = referencePages.filter((page) => page.text);
|
||||||
|
|
||||||
const systemPrompt = `You design technical specification forms for e-commerce product categories on an Iranian marketplace.
|
if (referencePages.length > 0 && usableReferences.length === 0) {
|
||||||
Return ONLY valid JSON with this shape:
|
throw new BadRequestException(
|
||||||
|
'Could not load the product page URL from the prompt. Check the link and try again.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const systemPrompt = `تو طراح فرم مشخصات فنی برای دستهبندیهای فروشگاهی در یک مارکتپلیس ایرانی هستی.
|
||||||
|
فقط JSON معتبر با این شکل برگردان:
|
||||||
{
|
{
|
||||||
"fields": [
|
"fields": [
|
||||||
{
|
{
|
||||||
"label": "Field label in English",
|
"label": "برچسب فیلد",
|
||||||
"type": "text | textarea | select | multi_select",
|
"type": "text | textarea | select | multi_select | multi_value",
|
||||||
"isRequired": true,
|
"isRequired": true,
|
||||||
"options": ["Option A", "Option B"]
|
"options": ["گزینه ۱", "گزینه ۲"]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
Rules:
|
قوانین:
|
||||||
- Generate 5-12 practical fields that buyers need for products in the given category.
|
- بین ۵ تا ۱۱ فیلد کاربردی تولید کن که خریداران برای محصولات این دسته نیاز دارند (سیستم همیشه «سایر ویژگیها» را اضافه میکند).
|
||||||
- Use a mix of text, textarea, select, and multi_select where appropriate.
|
- ترجیحاً از type=text استفاده کن تا فروشنده بتواند هر مقدار دلخواهی را آزادانه وارد کند.
|
||||||
- select and multi_select fields must include 3-8 realistic options.
|
- از select و multi_select حتیالمقدور استفاده نکن. فقط وقتی مجموعه گزینهها واقعاً ثابت، کوتاه و استاندارد است (مثلاً بله/خیر یا حداکثر چند گزینه قطعی) مجازند؛ برای ظرفیت، توان، ابعاد، مدل، جنس، رنگ آزاد و مشابه آنها همیشه text بگذار.
|
||||||
- text fields are for short values (dimensions, weight, model year).
|
- اگر بین text و select مردد هستی، حتماً text انتخاب کن.
|
||||||
- textarea fields are for longer specs (features, care instructions).
|
- فیلدهای select و multi_select (در صورت استفاده) باید ۳ تا ۸ گزینه واقعی داشته باشند.
|
||||||
- Mark important buyer-facing specs as required.
|
- multi_value یک لیست تکراری از مقادیر متنی آزاد است (بدون گزینه) برای مقادیر کوتاه چندتایی.
|
||||||
- Labels must be unique and human-readable.
|
- text برای مقادیر کوتاه (ابعاد، وزن، سال مدل، ظرفیت، توان).
|
||||||
- Do not include price, stock, SKU, or warranty period fields.
|
- textarea برای مشخصات طولانیتر (ویژگیها، دستورالعمل نگهداری).
|
||||||
- Prefer metric units common in Iran when relevant.`;
|
- اگر برای این دسته مناسب است، فیلدهای «ابعاد» و «وزن» را بهصورت text اضافه کن (واحد متریک رایج ایران را در برچسب بیاور، مثلاً سانتیمتر / کیلوگرم). اگر برای دسته بیمعناست، اضافه نکن.
|
||||||
|
- مشخصات مهم خریدار را الزامی علامت بزن.
|
||||||
|
- برچسبها باید یکتا و خوانا باشند.
|
||||||
|
- همیشه برچسبها و گزینهها را به فارسی بنویس.
|
||||||
|
- فیلدهای قیمت، موجودی، SKU یا مدت گارانتی نساز.
|
||||||
|
- در صورت مرتبط بودن از واحدهای متریک رایج در ایران استفاده کن.
|
||||||
|
- اگر referencePages داده شده، آنها منبع حقیقت هستند: فیلدهایی بساز که با مشخصات واقعی همان صفحات همخوان باشد.
|
||||||
|
- مشخصات اصلی نزدیک عنوان محصول را ترجیح بده؛ بلوکهای محصول مرتبط/آپسل را نادیده بگیر.
|
||||||
|
- وقتی صفحه مرجع هست از حافظه فیلد اختراع نکن — همان چیزی را بازتاب بده که صفحه واقعاً دارد.
|
||||||
|
- یک فیلد «سایر ویژگیها» خودت نساز؛ سیستم آن را بهصورت multi_value اضافه میکند.`;
|
||||||
|
|
||||||
const userPrompt = JSON.stringify({
|
const userPrompt = JSON.stringify({
|
||||||
category: input.categoryName,
|
category: input.categoryName,
|
||||||
@@ -119,11 +152,23 @@ Rules:
|
|||||||
input.existingFieldCount > 0
|
input.existingFieldCount > 0
|
||||||
? 'Category already has a form; suggest a fresh complete replacement set.'
|
? 'Category already has a form; suggest a fresh complete replacement set.'
|
||||||
: 'Category has no form yet.',
|
: 'Category has no form yet.',
|
||||||
|
referencePages: usableReferences.map((page) => ({
|
||||||
|
url: page.url,
|
||||||
|
text: page.text,
|
||||||
|
})),
|
||||||
|
referenceFetchErrors: referencePages
|
||||||
|
.filter((page) => !page.text)
|
||||||
|
.map((page) => ({ url: page.url, error: page.error ?? 'unknown' })),
|
||||||
});
|
});
|
||||||
|
|
||||||
let content: string;
|
let content: string;
|
||||||
try {
|
try {
|
||||||
content = await requestAiJsonCompletion(provider, systemPrompt, userPrompt);
|
content = await requestAiJsonCompletion(
|
||||||
|
provider,
|
||||||
|
systemPrompt,
|
||||||
|
userPrompt,
|
||||||
|
usableReferences.length > 0 ? 0.2 : 0.4,
|
||||||
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : 'AI request failed';
|
const message = err instanceof Error ? err.message : 'AI request failed';
|
||||||
throw new BadRequestException(message);
|
throw new BadRequestException(message);
|
||||||
@@ -148,7 +193,8 @@ Rules:
|
|||||||
const label = String(raw.label ?? '').trim();
|
const label = String(raw.label ?? '').trim();
|
||||||
if (!label) continue;
|
if (!label) continue;
|
||||||
|
|
||||||
const normalizedLabel = label.toLowerCase();
|
const normalizedLabel = label.toLowerCase().replace(/\s+/g, ' ').trim();
|
||||||
|
if (OTHER_FEATURES_LABELS.has(normalizedLabel)) continue;
|
||||||
if (labels.has(normalizedLabel)) continue;
|
if (labels.has(normalizedLabel)) continue;
|
||||||
labels.add(normalizedLabel);
|
labels.add(normalizedLabel);
|
||||||
|
|
||||||
@@ -172,9 +218,15 @@ Rules:
|
|||||||
}
|
}
|
||||||
|
|
||||||
fields.push(field);
|
fields.push(field);
|
||||||
if (fields.length >= 12) break;
|
if (fields.length >= 11) break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fields.push({
|
||||||
|
label: OTHER_FEATURES_LABEL,
|
||||||
|
type: 'multi_value',
|
||||||
|
isRequired: false,
|
||||||
|
});
|
||||||
|
|
||||||
if (!fields.length) {
|
if (!fields.length) {
|
||||||
throw new BadRequestException('AI did not return any valid technical form fields');
|
throw new BadRequestException('AI did not return any valid technical form fields');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,6 +54,31 @@ function uniqueSlug(base: string, used: Set<string>, fallbackIndex: number): str
|
|||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeHex(value: string | null | undefined): string | null {
|
||||||
|
if (value == null) return null;
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!isHexColor(trimmed)) return null;
|
||||||
|
return trimmed.toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map value labels → provided hex (later entries win for duplicate labels). */
|
||||||
|
function buildColorHexByLabel(
|
||||||
|
values: string[],
|
||||||
|
colorHexes: (string | null)[] | undefined,
|
||||||
|
): Map<string, string> {
|
||||||
|
const map = new Map<string, string>();
|
||||||
|
if (!colorHexes?.length) return map;
|
||||||
|
|
||||||
|
values.forEach((raw, index) => {
|
||||||
|
const label = raw.trim();
|
||||||
|
if (!label) return;
|
||||||
|
const hex = normalizeHex(colorHexes[index] ?? null);
|
||||||
|
if (hex) map.set(label, hex);
|
||||||
|
});
|
||||||
|
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
type ExistingVariation = {
|
type ExistingVariation = {
|
||||||
id: bigint;
|
id: bigint;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -126,6 +151,10 @@ export class CategoryVariationsService {
|
|||||||
const uniqueValues = [
|
const uniqueValues = [
|
||||||
...new Set(variation.values.map((v) => v.trim()).filter(Boolean)),
|
...new Set(variation.values.map((v) => v.trim()).filter(Boolean)),
|
||||||
];
|
];
|
||||||
|
const hexByLabel = buildColorHexByLabel(
|
||||||
|
variation.values,
|
||||||
|
variation.colorHexes,
|
||||||
|
);
|
||||||
|
|
||||||
if (current) {
|
if (current) {
|
||||||
matchedExistingIds.add(current.id);
|
matchedExistingIds.add(current.id);
|
||||||
@@ -137,7 +166,7 @@ export class CategoryVariationsService {
|
|||||||
sortOrder: index,
|
sortOrder: index,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await this.syncOptions(tx, current, variation.type, uniqueValues);
|
await this.syncOptions(tx, current, variation.type, uniqueValues, hexByLabel);
|
||||||
} else {
|
} else {
|
||||||
const created = await tx.categoryVariation.create({
|
const created = await tx.categoryVariation.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -148,7 +177,13 @@ export class CategoryVariationsService {
|
|||||||
sortOrder: index,
|
sortOrder: index,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await this.createOptions(tx, created.id, variation.type, uniqueValues);
|
await this.createOptions(
|
||||||
|
tx,
|
||||||
|
created.id,
|
||||||
|
variation.type,
|
||||||
|
uniqueValues,
|
||||||
|
hexByLabel,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,6 +238,7 @@ export class CategoryVariationsService {
|
|||||||
current: ExistingVariation,
|
current: ExistingVariation,
|
||||||
type: CategoryVariationInputDto['type'],
|
type: CategoryVariationInputDto['type'],
|
||||||
labels: string[],
|
labels: string[],
|
||||||
|
hexByLabel: Map<string, string> = new Map(),
|
||||||
) {
|
) {
|
||||||
const usedSlugs = new Set<string>();
|
const usedSlugs = new Set<string>();
|
||||||
const matchedOptionIds = new Set<bigint>();
|
const matchedOptionIds = new Set<bigint>();
|
||||||
@@ -215,7 +251,8 @@ export class CategoryVariationsService {
|
|||||||
|
|
||||||
const colorHex =
|
const colorHex =
|
||||||
type === 'color'
|
type === 'color'
|
||||||
? resolveColorHex(label) ??
|
? hexByLabel.get(label) ??
|
||||||
|
resolveColorHex(label) ??
|
||||||
existingOption?.colorHex ??
|
existingOption?.colorHex ??
|
||||||
(existingOption && isHexColor(existingOption.value)
|
(existingOption && isHexColor(existingOption.value)
|
||||||
? existingOption.value.trim()
|
? existingOption.value.trim()
|
||||||
@@ -270,6 +307,7 @@ export class CategoryVariationsService {
|
|||||||
variationId: bigint,
|
variationId: bigint,
|
||||||
type: CategoryVariationInputDto['type'],
|
type: CategoryVariationInputDto['type'],
|
||||||
labels: string[],
|
labels: string[],
|
||||||
|
hexByLabel: Map<string, string> = new Map(),
|
||||||
) {
|
) {
|
||||||
const usedSlugs = new Set<string>();
|
const usedSlugs = new Set<string>();
|
||||||
await tx.categoryVariationOption.createMany({
|
await tx.categoryVariationOption.createMany({
|
||||||
@@ -277,7 +315,10 @@ export class CategoryVariationsService {
|
|||||||
variationId,
|
variationId,
|
||||||
label,
|
label,
|
||||||
value: uniqueSlug(slugifyValue(label), usedSlugs, optionIndex + 1),
|
value: uniqueSlug(slugifyValue(label), usedSlugs, optionIndex + 1),
|
||||||
colorHex: type === 'color' ? resolveColorHex(label) : null,
|
colorHex:
|
||||||
|
type === 'color'
|
||||||
|
? hexByLabel.get(label) ?? resolveColorHex(label)
|
||||||
|
: null,
|
||||||
sortOrder: optionIndex,
|
sortOrder: optionIndex,
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ export class TechnicalFormFieldInputDto {
|
|||||||
label!: string;
|
label!: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsIn(['text', 'textarea', 'select', 'multi_select'])
|
@IsIn(['text', 'textarea', 'select', 'multi_select', 'multi_value'])
|
||||||
type!: 'text' | 'textarea' | 'select' | 'multi_select';
|
type!: 'text' | 'textarea' | 'select' | 'multi_select' | 'multi_value';
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
ArrayMinSize,
|
ArrayMinSize,
|
||||||
IsArray,
|
IsArray,
|
||||||
IsIn,
|
IsIn,
|
||||||
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
MinLength,
|
MinLength,
|
||||||
ValidateNested,
|
ValidateNested,
|
||||||
@@ -21,6 +22,11 @@ export class CategoryVariationInputDto {
|
|||||||
@ArrayMinSize(1)
|
@ArrayMinSize(1)
|
||||||
@IsString({ each: true })
|
@IsString({ each: true })
|
||||||
values!: string[];
|
values!: string[];
|
||||||
|
|
||||||
|
/** Optional parallel list of hex colors for `color` variations (same order as values). */
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
colorHexes?: (string | null)[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ReplaceCategoryVariationsDto {
|
export class ReplaceCategoryVariationsDto {
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
const URL_RE = /https?:\/\/[^\s<>"'`)\]]+/gi;
|
||||||
|
|
||||||
|
const MAX_URLS = 3;
|
||||||
|
const MAX_BYTES = 1_500_000;
|
||||||
|
const MAX_TEXT_CHARS = 12_000;
|
||||||
|
const FETCH_TIMEOUT_MS = 12_000;
|
||||||
|
|
||||||
|
export function extractHttpUrls(text: string): string[] {
|
||||||
|
const matches = text.match(URL_RE) ?? [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const urls: string[] = [];
|
||||||
|
|
||||||
|
for (const raw of matches) {
|
||||||
|
const cleaned = raw.replace(/[.,;:!?]+$/g, '');
|
||||||
|
if (!cleaned || seen.has(cleaned)) continue;
|
||||||
|
try {
|
||||||
|
const parsed = new URL(cleaned);
|
||||||
|
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') continue;
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seen.add(cleaned);
|
||||||
|
urls.push(cleaned);
|
||||||
|
if (urls.length >= MAX_URLS) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return urls;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripHtmlToText(html: string): string {
|
||||||
|
const withoutNoise = html
|
||||||
|
.replace(/<script[\s\S]*?<\/script>/gi, ' ')
|
||||||
|
.replace(/<style[\s\S]*?<\/style>/gi, ' ')
|
||||||
|
.replace(/<noscript[\s\S]*?<\/noscript>/gi, ' ')
|
||||||
|
.replace(/<!--[\s\S]*?-->/g, ' ')
|
||||||
|
.replace(/<\/(p|div|li|tr|h[1-6]|br|table|section|article)>/gi, '\n')
|
||||||
|
.replace(/<(br|hr)\s*\/?>/gi, '\n')
|
||||||
|
.replace(/<[^>]+>/g, ' ')
|
||||||
|
.replace(/ /gi, ' ')
|
||||||
|
.replace(/&/gi, '&')
|
||||||
|
.replace(/</gi, '<')
|
||||||
|
.replace(/>/gi, '>')
|
||||||
|
.replace(/&#(\d+);/g, (_, code) => {
|
||||||
|
const n = Number(code);
|
||||||
|
return Number.isFinite(n) ? String.fromCharCode(n) : ' ';
|
||||||
|
})
|
||||||
|
.replace(/\s+\n/g, '\n')
|
||||||
|
.replace(/\n{3,}/g, '\n\n')
|
||||||
|
.replace(/[ \t]{2,}/g, ' ')
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
return withoutNoise.slice(0, MAX_TEXT_CHARS);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchUrlAsPlainText(url: string): Promise<string> {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
redirect: 'follow',
|
||||||
|
signal: controller.signal,
|
||||||
|
headers: {
|
||||||
|
Accept: 'text/html,application/xhtml+xml;q=0.9,*/*;q=0.8',
|
||||||
|
'User-Agent':
|
||||||
|
'Mozilla/5.0 (compatible; MeshkeeBot/1.0; +https://meshkee.com)',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = response.headers.get('content-type') ?? '';
|
||||||
|
if (
|
||||||
|
contentType &&
|
||||||
|
!/text\/html|application\/xhtml\+xml|text\/plain/i.test(contentType)
|
||||||
|
) {
|
||||||
|
throw new Error(`Unsupported content type: ${contentType}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = Buffer.from(await response.arrayBuffer());
|
||||||
|
const truncated = buffer.subarray(0, MAX_BYTES).toString('utf8');
|
||||||
|
const text = stripHtmlToText(truncated);
|
||||||
|
if (!text || text.length < 40) {
|
||||||
|
throw new Error('Page content was empty');
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ReferencePage = {
|
||||||
|
url: string;
|
||||||
|
text: string | null;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract http(s) URLs from a prompt and fetch their readable text for AI grounding.
|
||||||
|
*/
|
||||||
|
export async function loadReferencePagesFromText(
|
||||||
|
text: string,
|
||||||
|
): Promise<ReferencePage[]> {
|
||||||
|
const urls = extractHttpUrls(text);
|
||||||
|
if (urls.length === 0) return [];
|
||||||
|
|
||||||
|
const pages = await Promise.all(
|
||||||
|
urls.map(async (url) => {
|
||||||
|
try {
|
||||||
|
const pageText = await fetchUrlAsPlainText(url);
|
||||||
|
return { url, text: pageText } satisfies ReferencePage;
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Fetch failed';
|
||||||
|
return { url, text: null, error: message } satisfies ReferencePage;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return pages;
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
/** Encode free-text list values for storage in text_value columns. */
|
||||||
|
export function encodeMultiValueText(values: string[]): string {
|
||||||
|
const cleaned = values.map((item) => String(item).trim()).filter(Boolean);
|
||||||
|
return JSON.stringify(cleaned);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Decode free-text list values from text_value storage. */
|
||||||
|
export function decodeMultiValueText(raw: string | null | undefined): string[] {
|
||||||
|
if (raw == null) return [];
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
if (!trimmed) return [];
|
||||||
|
|
||||||
|
if (trimmed.startsWith('[')) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(trimmed) as unknown;
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
return parsed.map((item) => String(item).trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// fall through
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [trimmed];
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
/**
|
||||||
|
* Heuristics to reject obvious bot / gibberish contact submissions
|
||||||
|
* before they are stored or trigger SMS.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Single Latin token with chaotic mixed case (e.g. nuKnGmIVvgMoplagJYbJHIf). */
|
||||||
|
export function looksLikeRandomToken(value: string): boolean {
|
||||||
|
const s = value.trim();
|
||||||
|
if (s.length < 12) return false;
|
||||||
|
if (!/^[A-Za-z]+$/.test(s)) return false;
|
||||||
|
|
||||||
|
// Normal Title Case word: "Something"
|
||||||
|
if (/^[A-Z][a-z]+$/.test(s)) return false;
|
||||||
|
// ALL CAPS short-ish brand-like tokens are less common at this length
|
||||||
|
if (/^[A-Z]+$/.test(s) && s.length < 16) return false;
|
||||||
|
// all lowercase long word without spaces is still suspicious when very long
|
||||||
|
if (/^[a-z]+$/.test(s)) return s.length >= 18;
|
||||||
|
|
||||||
|
let caseSwitches = 0;
|
||||||
|
for (let i = 1; i < s.length; i++) {
|
||||||
|
const prevUpper = s[i - 1]! >= 'A' && s[i - 1]! <= 'Z';
|
||||||
|
const curUpper = s[i]! >= 'A' && s[i]! <= 'Z';
|
||||||
|
if (prevUpper !== curUpper) caseSwitches += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Random camelNoise tends to flip case often
|
||||||
|
if (caseSwitches >= 5) return true;
|
||||||
|
if (caseSwitches >= 3 && caseSwitches / (s.length - 1) >= 0.28) return true;
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when the submission looks like spam and should be rejected. */
|
||||||
|
export function isLikelySpamContactSubmission(input: {
|
||||||
|
title: string;
|
||||||
|
name: string;
|
||||||
|
text: string;
|
||||||
|
}): boolean {
|
||||||
|
const title = input.title.trim();
|
||||||
|
const name = input.name.trim();
|
||||||
|
const text = input.text.trim();
|
||||||
|
|
||||||
|
if (looksLikeRandomToken(name)) return true;
|
||||||
|
|
||||||
|
const gibberishFields = [title, name, text].filter((v) =>
|
||||||
|
looksLikeRandomToken(v),
|
||||||
|
).length;
|
||||||
|
if (gibberishFields >= 2) return true;
|
||||||
|
|
||||||
|
// Entire payload is one unbroken Latin blob with no spaces / Persian
|
||||||
|
const combined = `${title} ${name} ${text}`;
|
||||||
|
const hasPersian = /[\u0600-\u06FF]/.test(combined);
|
||||||
|
const hasSpace = /\s/.test(name) || /\s/.test(text);
|
||||||
|
if (
|
||||||
|
!hasPersian &&
|
||||||
|
!hasSpace &&
|
||||||
|
/^[A-Za-z0-9]+$/.test(name) &&
|
||||||
|
name.length >= 14 &&
|
||||||
|
text.length >= 14 &&
|
||||||
|
/^[A-Za-z0-9]+$/.test(text)
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
@@ -1,9 +1,19 @@
|
|||||||
import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ForbiddenException,
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import { AuthUser } from '../auth/auth.types';
|
import { AuthUser } from '../auth/auth.types';
|
||||||
import { PermissionsService } from '../auth/permissions.service';
|
import { PermissionsService } from '../auth/permissions.service';
|
||||||
|
import { SmsBillingService } from '../auth/sms-billing.service';
|
||||||
|
import type { SmsSendType } from '../auth/sms-billing';
|
||||||
|
import { SmsService, toIranianMobileMsisdn } from '../auth/sms.service';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { TenantService } from '../tenant/tenant.service';
|
import { TenantService } from '../tenant/tenant.service';
|
||||||
|
import { isLikelySpamContactSubmission } from './contact-submission-spam';
|
||||||
import { CreateContactSubmissionDto } from './dto/create-contact-submission.dto';
|
import { CreateContactSubmissionDto } from './dto/create-contact-submission.dto';
|
||||||
import { ListContactSubmissionsDto } from './dto/list-contact-submissions.dto';
|
import { ListContactSubmissionsDto } from './dto/list-contact-submissions.dto';
|
||||||
|
|
||||||
@@ -19,32 +29,164 @@ type ContactSubmissionRow = {
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ContactSubmissionsService {
|
export class ContactSubmissionsService {
|
||||||
|
private readonly logger = new Logger(ContactSubmissionsService.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly permissions: PermissionsService,
|
private readonly permissions: PermissionsService,
|
||||||
private readonly tenant: TenantService,
|
private readonly tenant: TenantService,
|
||||||
|
private readonly sms: SmsService,
|
||||||
|
private readonly smsBilling: SmsBillingService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async createPublic(host: string, dto: CreateContactSubmissionDto) {
|
async createPublic(host: string, dto: CreateContactSubmissionDto) {
|
||||||
const business = await this.tenant.resolveBusinessByDomain(host);
|
const business = await this.tenant.resolveBusinessByDomain(host);
|
||||||
|
|
||||||
|
const title = dto.title.trim();
|
||||||
|
const name = dto.name.trim();
|
||||||
|
const text = dto.text.trim();
|
||||||
|
const email = dto.email?.trim() || null;
|
||||||
|
const cellNumber = dto.cellNumber?.trim() || null;
|
||||||
|
|
||||||
|
if (isLikelySpamContactSubmission({ title, name, text })) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Contact form rejected as spam for business ${business.id}: name="${name}"`,
|
||||||
|
);
|
||||||
|
throw new BadRequestException('Invalid submission');
|
||||||
|
}
|
||||||
|
|
||||||
const created = await this.prisma.contactSubmission.create({
|
const created = await this.prisma.contactSubmission.create({
|
||||||
data: {
|
data: {
|
||||||
businessId: business.id,
|
businessId: business.id,
|
||||||
title: dto.title.trim(),
|
title,
|
||||||
name: dto.name.trim(),
|
name,
|
||||||
email: dto.email?.trim() || null,
|
email,
|
||||||
cellNumber: dto.cellNumber?.trim() || null,
|
cellNumber,
|
||||||
text: dto.text.trim(),
|
text,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const businessLabel =
|
||||||
|
business.nameFa?.trim() || business.name?.trim() || undefined;
|
||||||
|
await this.notifyContactSubmissionSms({
|
||||||
|
businessId: business.id,
|
||||||
|
businessLabel,
|
||||||
|
contactName: created.name,
|
||||||
|
contacterCellNumber: created.cellNumber,
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
submission: this.serialize(created),
|
submission: this.serialize(created),
|
||||||
message: 'Contact form submitted successfully',
|
message: 'Contact form submitted successfully',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Best-effort SMS via Meshkee service (main) line:
|
||||||
|
* 1) business owner — new contact named in the message
|
||||||
|
* 2) contacter — confirmation, only for a valid Iranian mobile
|
||||||
|
* Never fails the contact form submission.
|
||||||
|
*/
|
||||||
|
private async notifyContactSubmissionSms(input: {
|
||||||
|
businessId: bigint;
|
||||||
|
businessLabel?: string;
|
||||||
|
contactName: string;
|
||||||
|
contacterCellNumber: string | null;
|
||||||
|
}): Promise<void> {
|
||||||
|
if (!this.sms.isEnabled()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = input.contactName.trim() || 'یک بازدیدکننده';
|
||||||
|
|
||||||
|
await this.sendBilledSms({
|
||||||
|
businessId: input.businessId,
|
||||||
|
cellNumber: await this.resolveOwnerCellNumber(input.businessId),
|
||||||
|
message: `پیام جدید از فرم تماس با ما: ${name}`,
|
||||||
|
type: 'admin',
|
||||||
|
label: 'owner',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!toIranianMobileMsisdn(input.contacterCellNumber ?? '')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const brand = input.businessLabel?.trim();
|
||||||
|
const confirmation = brand
|
||||||
|
? `${name} عزیز، پیام شما به ${brand} دریافت شد.`
|
||||||
|
: `${name} عزیز، پیام شما دریافت شد.`;
|
||||||
|
|
||||||
|
await this.sendBilledSms({
|
||||||
|
businessId: input.businessId,
|
||||||
|
cellNumber: input.contacterCellNumber,
|
||||||
|
message: confirmation,
|
||||||
|
type: 'auto_customer',
|
||||||
|
label: 'contacter',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveOwnerCellNumber(
|
||||||
|
businessId: bigint,
|
||||||
|
): Promise<string | null> {
|
||||||
|
const owner = await this.prisma.businessUser.findFirst({
|
||||||
|
where: { businessId, isOwner: true },
|
||||||
|
orderBy: { userId: 'asc' },
|
||||||
|
select: { user: { select: { cellNumber: true } } },
|
||||||
|
});
|
||||||
|
return owner?.user.cellNumber?.trim() || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async sendBilledSms(input: {
|
||||||
|
businessId: bigint;
|
||||||
|
cellNumber: string | null | undefined;
|
||||||
|
message: string;
|
||||||
|
type: SmsSendType;
|
||||||
|
label: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
const cellNumber = input.cellNumber?.trim();
|
||||||
|
if (!cellNumber || !toIranianMobileMsisdn(cellNumber)) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Contact form ${input.label} SMS skipped for business ${input.businessId}: not a valid Iranian mobile`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let charged = 0;
|
||||||
|
try {
|
||||||
|
charged = await this.smsBilling.charge(
|
||||||
|
input.businessId,
|
||||||
|
input.message,
|
||||||
|
1,
|
||||||
|
input.type,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
const reason = err instanceof Error ? err.message : String(err);
|
||||||
|
this.logger.warn(
|
||||||
|
`Contact form ${input.label} SMS not charged for business ${input.businessId}: ${reason}`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.sms.sendMessage(cellNumber, input.message, {
|
||||||
|
source: this.sms.getServiceSource(),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if (charged > 0) {
|
||||||
|
await this.smsBilling.refund(
|
||||||
|
input.businessId,
|
||||||
|
charged,
|
||||||
|
1,
|
||||||
|
input.type,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const reason = err instanceof Error ? err.message : String(err);
|
||||||
|
this.logger.warn(
|
||||||
|
`Contact form ${input.label} SMS failed for business ${input.businessId}: ${reason}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async list(
|
async list(
|
||||||
businessIdRaw: string,
|
businessIdRaw: string,
|
||||||
query: ListContactSubmissionsDto,
|
query: ListContactSubmissionsDto,
|
||||||
|
|||||||
@@ -939,22 +939,25 @@ export class DomainAdminService {
|
|||||||
let ed25519;
|
let ed25519;
|
||||||
let rsa;
|
let rsa;
|
||||||
try {
|
try {
|
||||||
ed25519 = parseDkimPaste(dto.ed25519Key, 'ed25519');
|
if (dto.ed25519Key?.trim()) {
|
||||||
rsa = parseDkimPaste(dto.rsaKey, 'rsa');
|
ed25519 = parseDkimPaste(dto.ed25519Key, 'ed25519', dto.ed25519Selector);
|
||||||
|
}
|
||||||
|
rsa = parseDkimPaste(dto.rsaKey, 'rsa', dto.rsaSelector);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw new BadRequestException(err instanceof Error ? err.message : 'Invalid DKIM key');
|
throw new BadRequestException(err instanceof Error ? err.message : 'Invalid DKIM key');
|
||||||
}
|
}
|
||||||
if (ed25519.selector === rsa.selector) {
|
if (ed25519 && ed25519.selector === rsa.selector) {
|
||||||
throw new BadRequestException('Ed25519 and RSA DKIM selectors must be different.');
|
throw new BadRequestException('Ed25519 and RSA DKIM selectors must be different.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const keys = ed25519 ? [ed25519, rsa] : [rsa];
|
||||||
const provider = dto.dnsProvider === 'cloudflare' ? 'cloudflare' : 'arvan';
|
const provider = dto.dnsProvider === 'cloudflare' ? 'cloudflare' : 'arvan';
|
||||||
const host = domain.host.trim().toLowerCase();
|
const host = domain.host.trim().toLowerCase();
|
||||||
try {
|
try {
|
||||||
const result =
|
const result =
|
||||||
provider === 'cloudflare'
|
provider === 'cloudflare'
|
||||||
? await this.cloudflareDns.ensureMailRecords(host, [ed25519, rsa])
|
? await this.cloudflareDns.ensureMailRecords(host, keys)
|
||||||
: await this.arvanDns.ensureMailRecords(host, [ed25519, rsa]);
|
: await this.arvanDns.ensureMailRecords(host, keys);
|
||||||
|
|
||||||
const parts = [
|
const parts = [
|
||||||
result.created.length ? `created ${result.created.join(', ')}` : '',
|
result.created.length ? `created ${result.created.join(', ')}` : '',
|
||||||
|
|||||||
@@ -1,17 +1,31 @@
|
|||||||
import { IsIn, IsString, MaxLength, MinLength } from 'class-validator';
|
import { IsIn, IsOptional, IsString, MaxLength, MinLength, ValidateIf } from 'class-validator';
|
||||||
import { PARKED_DNS_PROVIDERS } from '../parked-host.util';
|
import { PARKED_DNS_PROVIDERS } from '../parked-host.util';
|
||||||
|
|
||||||
export class ApplyMailDnsDto {
|
export class ApplyMailDnsDto {
|
||||||
@IsIn(PARKED_DNS_PROVIDERS)
|
@IsIn(PARKED_DNS_PROVIDERS)
|
||||||
dnsProvider!: (typeof PARKED_DNS_PROVIDERS)[number];
|
dnsProvider!: (typeof PARKED_DNS_PROVIDERS)[number];
|
||||||
|
|
||||||
|
/** Optional — omit or leave empty when only publishing RSA from Stalwart. */
|
||||||
|
@ValidateIf((_, value) => value != null && String(value).trim() !== '')
|
||||||
@IsString()
|
@IsString()
|
||||||
@MinLength(20)
|
@MinLength(20)
|
||||||
@MaxLength(8000)
|
@MaxLength(8000)
|
||||||
ed25519Key!: string;
|
ed25519Key?: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@MinLength(20)
|
@MinLength(20)
|
||||||
@MaxLength(8000)
|
@MaxLength(8000)
|
||||||
rsaKey!: string;
|
rsaKey!: string;
|
||||||
|
|
||||||
|
/** Required when ed25519Key is a value-only `v=DKIM1; …` paste (no `…._domainkey`). */
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(253)
|
||||||
|
ed25519Selector?: string;
|
||||||
|
|
||||||
|
/** Required when rsaKey is a value-only `v=DKIM1; …` paste (no `…._domainkey`). */
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(253)
|
||||||
|
rsaSelector?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,6 +57,12 @@ export class InvoiceKeyPointInputDto {
|
|||||||
text!: string;
|
text!: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class InvoicePaymentMethodInputDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
text!: string;
|
||||||
|
}
|
||||||
|
|
||||||
export class InvoiceAccountInputDto {
|
export class InvoiceAccountInputDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@MinLength(1)
|
@MinLength(1)
|
||||||
@@ -114,6 +120,12 @@ export class CreateInvoiceDto {
|
|||||||
@Type(() => InvoiceKeyPointInputDto)
|
@Type(() => InvoiceKeyPointInputDto)
|
||||||
keyPoints?: InvoiceKeyPointInputDto[];
|
keyPoints?: InvoiceKeyPointInputDto[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => InvoicePaymentMethodInputDto)
|
||||||
|
paymentMethods?: InvoicePaymentMethodInputDto[];
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsArray()
|
@IsArray()
|
||||||
@ValidateNested({ each: true })
|
@ValidateNested({ each: true })
|
||||||
@@ -134,7 +146,7 @@ export class UpdateInvoiceStatusDto {
|
|||||||
notes?: string;
|
notes?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Full content replace for an existing invoice (items / key points / accounts). */
|
/** Full content replace for an existing invoice (items / key points / payment methods / accounts). */
|
||||||
export class UpdateInvoiceDto {
|
export class UpdateInvoiceDto {
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -169,6 +181,12 @@ export class UpdateInvoiceDto {
|
|||||||
@Type(() => InvoiceKeyPointInputDto)
|
@Type(() => InvoiceKeyPointInputDto)
|
||||||
keyPoints?: InvoiceKeyPointInputDto[];
|
keyPoints?: InvoiceKeyPointInputDto[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => InvoicePaymentMethodInputDto)
|
||||||
|
paymentMethods?: InvoicePaymentMethodInputDto[];
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsArray()
|
@IsArray()
|
||||||
@ValidateNested({ each: true })
|
@ValidateNested({ each: true })
|
||||||
@@ -279,6 +297,65 @@ export class UpdateInvoiceItemTemplateDto {
|
|||||||
isActive?: boolean;
|
isActive?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class CreateInvoiceAccountTemplateDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(255)
|
||||||
|
bankName!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(255)
|
||||||
|
accountHolderName?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(64)
|
||||||
|
cardNumber?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(64)
|
||||||
|
iban?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
sortOrder?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateInvoiceAccountTemplateDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(255)
|
||||||
|
bankName?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(255)
|
||||||
|
accountHolderName?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(64)
|
||||||
|
cardNumber?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(64)
|
||||||
|
iban?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
sortOrder?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
isActive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export class InvoiceTemplateItemInputDto {
|
export class InvoiceTemplateItemInputDto {
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -337,6 +414,12 @@ export class CreateInvoiceTemplateDto {
|
|||||||
@Type(() => InvoiceKeyPointInputDto)
|
@Type(() => InvoiceKeyPointInputDto)
|
||||||
keyPoints?: InvoiceKeyPointInputDto[];
|
keyPoints?: InvoiceKeyPointInputDto[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => InvoicePaymentMethodInputDto)
|
||||||
|
paymentMethods?: InvoicePaymentMethodInputDto[];
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsArray()
|
@IsArray()
|
||||||
@ValidateNested({ each: true })
|
@ValidateNested({ each: true })
|
||||||
@@ -373,6 +456,12 @@ export class UpdateInvoiceTemplateDto {
|
|||||||
@Type(() => InvoiceKeyPointInputDto)
|
@Type(() => InvoiceKeyPointInputDto)
|
||||||
keyPoints?: InvoiceKeyPointInputDto[];
|
keyPoints?: InvoiceKeyPointInputDto[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => InvoicePaymentMethodInputDto)
|
||||||
|
paymentMethods?: InvoicePaymentMethodInputDto[];
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsArray()
|
@IsArray()
|
||||||
@ValidateNested({ each: true })
|
@ValidateNested({ each: true })
|
||||||
|
|||||||
@@ -20,9 +20,11 @@ import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
|||||||
import { AuthUser } from '../auth/auth.types';
|
import { AuthUser } from '../auth/auth.types';
|
||||||
import {
|
import {
|
||||||
CreateInvoiceDto,
|
CreateInvoiceDto,
|
||||||
|
CreateInvoiceAccountTemplateDto,
|
||||||
CreateInvoiceItemTemplateDto,
|
CreateInvoiceItemTemplateDto,
|
||||||
CreateInvoiceTemplateDto,
|
CreateInvoiceTemplateDto,
|
||||||
ListInvoicesDto,
|
ListInvoicesDto,
|
||||||
|
UpdateInvoiceAccountTemplateDto,
|
||||||
UpdateInvoiceDto,
|
UpdateInvoiceDto,
|
||||||
UpdateInvoiceItemTemplateDto,
|
UpdateInvoiceItemTemplateDto,
|
||||||
UpdateInvoiceStatusDto,
|
UpdateInvoiceStatusDto,
|
||||||
@@ -63,6 +65,41 @@ export class InvoicesController {
|
|||||||
return this.service.deletePlatformTemplate(templateId, user);
|
return this.service.deletePlatformTemplate(templateId, user);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Platform invoice account templates (settings)
|
||||||
|
@Get('invoice-account-templates')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
listAccountTemplates(@CurrentUser() user: AuthUser) {
|
||||||
|
return this.service.listPlatformAccountTemplates(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('invoice-account-templates')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
createAccountTemplate(
|
||||||
|
@Body() dto: CreateInvoiceAccountTemplateDto,
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
) {
|
||||||
|
return this.service.createPlatformAccountTemplate(dto, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch('invoice-account-templates/:templateId')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
updateAccountTemplate(
|
||||||
|
@Param('templateId') templateId: string,
|
||||||
|
@Body() dto: UpdateInvoiceAccountTemplateDto,
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
) {
|
||||||
|
return this.service.updatePlatformAccountTemplate(templateId, dto, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('invoice-account-templates/:templateId')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
deleteAccountTemplate(
|
||||||
|
@Param('templateId') templateId: string,
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
) {
|
||||||
|
return this.service.deletePlatformAccountTemplate(templateId, user);
|
||||||
|
}
|
||||||
|
|
||||||
// Platform invoice templates (settings)
|
// Platform invoice templates (settings)
|
||||||
@Get('invoice-templates')
|
@Get('invoice-templates')
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@@ -237,6 +274,47 @@ export class BusinessInvoicesController {
|
|||||||
return this.service.deleteBusinessItemTemplate(businessId, templateId, user);
|
return this.service.deleteBusinessItemTemplate(businessId, templateId, user);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Invoice account templates (business-scoped)
|
||||||
|
@Get('invoice-account-templates')
|
||||||
|
@RequireBusinessPermission('invoice_templates.read')
|
||||||
|
listAccountTemplatesBusiness(
|
||||||
|
@Param('businessId') businessId: string,
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
) {
|
||||||
|
return this.service.listBusinessAccountTemplates(businessId, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('invoice-account-templates')
|
||||||
|
@RequireBusinessPermission('invoice_templates.manage')
|
||||||
|
createAccountTemplateBusiness(
|
||||||
|
@Param('businessId') businessId: string,
|
||||||
|
@Body() dto: CreateInvoiceAccountTemplateDto,
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
) {
|
||||||
|
return this.service.createBusinessAccountTemplate(businessId, dto, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch('invoice-account-templates/:templateId')
|
||||||
|
@RequireBusinessPermission('invoice_templates.manage')
|
||||||
|
updateAccountTemplateBusiness(
|
||||||
|
@Param('businessId') businessId: string,
|
||||||
|
@Param('templateId') templateId: string,
|
||||||
|
@Body() dto: UpdateInvoiceAccountTemplateDto,
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
) {
|
||||||
|
return this.service.updateBusinessAccountTemplate(businessId, templateId, dto, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('invoice-account-templates/:templateId')
|
||||||
|
@RequireBusinessPermission('invoice_templates.manage')
|
||||||
|
deleteAccountTemplateBusiness(
|
||||||
|
@Param('businessId') businessId: string,
|
||||||
|
@Param('templateId') templateId: string,
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
) {
|
||||||
|
return this.service.deleteBusinessAccountTemplate(businessId, templateId, user);
|
||||||
|
}
|
||||||
|
|
||||||
// Invoice templates (business-scoped)
|
// Invoice templates (business-scoped)
|
||||||
@Get('invoice-templates')
|
@Get('invoice-templates')
|
||||||
@RequireBusinessPermission('invoice_templates.read')
|
@RequireBusinessPermission('invoice_templates.read')
|
||||||
|
|||||||
@@ -11,13 +11,16 @@ import { PermissionsService } from '../auth/permissions.service';
|
|||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import {
|
import {
|
||||||
CreateInvoiceDto,
|
CreateInvoiceDto,
|
||||||
|
CreateInvoiceAccountTemplateDto,
|
||||||
CreateInvoiceItemTemplateDto,
|
CreateInvoiceItemTemplateDto,
|
||||||
CreateInvoiceTemplateDto,
|
CreateInvoiceTemplateDto,
|
||||||
InvoiceAccountInputDto,
|
InvoiceAccountInputDto,
|
||||||
InvoiceItemInputDto,
|
InvoiceItemInputDto,
|
||||||
InvoiceKeyPointInputDto,
|
InvoiceKeyPointInputDto,
|
||||||
|
InvoicePaymentMethodInputDto,
|
||||||
InvoiceTemplateItemInputDto,
|
InvoiceTemplateItemInputDto,
|
||||||
ListInvoicesDto,
|
ListInvoicesDto,
|
||||||
|
UpdateInvoiceAccountTemplateDto,
|
||||||
UpdateInvoiceDto,
|
UpdateInvoiceDto,
|
||||||
UpdateInvoiceItemTemplateDto,
|
UpdateInvoiceItemTemplateDto,
|
||||||
UpdateInvoiceStatusDto,
|
UpdateInvoiceStatusDto,
|
||||||
@@ -231,6 +234,34 @@ export class InvoicesService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private serializeAccountTemplate(row: {
|
||||||
|
id: bigint;
|
||||||
|
ownerScope: InvoiceOwnerScope;
|
||||||
|
businessId: bigint | null;
|
||||||
|
bankName: string;
|
||||||
|
accountHolderName: string | null;
|
||||||
|
cardNumber: string | null;
|
||||||
|
iban: string | null;
|
||||||
|
sortOrder: number;
|
||||||
|
isActive: boolean;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
id: row.id.toString(),
|
||||||
|
ownerScope: row.ownerScope,
|
||||||
|
businessId: row.businessId?.toString() ?? null,
|
||||||
|
bankName: row.bankName,
|
||||||
|
accountHolderName: row.accountHolderName,
|
||||||
|
cardNumber: row.cardNumber,
|
||||||
|
iban: row.iban,
|
||||||
|
sortOrder: row.sortOrder,
|
||||||
|
isActive: row.isActive,
|
||||||
|
createdAt: row.createdAt,
|
||||||
|
updatedAt: row.updatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private readonly invoiceInclude = {
|
private readonly invoiceInclude = {
|
||||||
business: {
|
business: {
|
||||||
select: {
|
select: {
|
||||||
@@ -250,12 +281,14 @@ export class InvoicesService {
|
|||||||
user: { select: { id: true, firstName: true, lastName: true, cellNumber: true } },
|
user: { select: { id: true, firstName: true, lastName: true, cellNumber: true } },
|
||||||
items: { orderBy: [{ sortOrder: 'asc' as const }, { id: 'asc' as const }] },
|
items: { orderBy: [{ sortOrder: 'asc' as const }, { id: 'asc' as const }] },
|
||||||
keyPoints: { orderBy: [{ sortOrder: 'asc' as const }, { id: 'asc' as const }] },
|
keyPoints: { orderBy: [{ sortOrder: 'asc' as const }, { id: 'asc' as const }] },
|
||||||
|
paymentMethods: { orderBy: [{ sortOrder: 'asc' as const }, { id: 'asc' as const }] },
|
||||||
accounts: { orderBy: [{ sortOrder: 'asc' as const }, { id: 'asc' as const }] },
|
accounts: { orderBy: [{ sortOrder: 'asc' as const }, { id: 'asc' as const }] },
|
||||||
};
|
};
|
||||||
|
|
||||||
private readonly invoiceTemplateInclude = {
|
private readonly invoiceTemplateInclude = {
|
||||||
items: { orderBy: [{ sortOrder: 'asc' as const }, { id: 'asc' as const }] },
|
items: { orderBy: [{ sortOrder: 'asc' as const }, { id: 'asc' as const }] },
|
||||||
keyPoints: { orderBy: [{ sortOrder: 'asc' as const }, { id: 'asc' as const }] },
|
keyPoints: { orderBy: [{ sortOrder: 'asc' as const }, { id: 'asc' as const }] },
|
||||||
|
paymentMethods: { orderBy: [{ sortOrder: 'asc' as const }, { id: 'asc' as const }] },
|
||||||
accounts: { orderBy: [{ sortOrder: 'asc' as const }, { id: 'asc' as const }] },
|
accounts: { orderBy: [{ sortOrder: 'asc' as const }, { id: 'asc' as const }] },
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -271,6 +304,18 @@ export class InvoicesService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private serializePaymentMethod(row: {
|
||||||
|
id: bigint;
|
||||||
|
text: string;
|
||||||
|
sortOrder: number;
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
id: row.id.toString(),
|
||||||
|
text: row.text,
|
||||||
|
sortOrder: row.sortOrder,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private serializeAccount(row: {
|
private serializeAccount(row: {
|
||||||
id: bigint;
|
id: bigint;
|
||||||
bankName: string;
|
bankName: string;
|
||||||
@@ -343,6 +388,11 @@ export class InvoicesService {
|
|||||||
text: string;
|
text: string;
|
||||||
sortOrder: number;
|
sortOrder: number;
|
||||||
}>;
|
}>;
|
||||||
|
paymentMethods?: Array<{
|
||||||
|
id: bigint;
|
||||||
|
text: string;
|
||||||
|
sortOrder: number;
|
||||||
|
}>;
|
||||||
accounts?: Array<{
|
accounts?: Array<{
|
||||||
id: bigint;
|
id: bigint;
|
||||||
bankName: string;
|
bankName: string;
|
||||||
@@ -372,6 +422,10 @@ export class InvoicesService {
|
|||||||
includeNested && row.keyPoints
|
includeNested && row.keyPoints
|
||||||
? row.keyPoints.map((kp) => this.serializeKeyPoint(kp))
|
? row.keyPoints.map((kp) => this.serializeKeyPoint(kp))
|
||||||
: undefined,
|
: undefined,
|
||||||
|
paymentMethods:
|
||||||
|
includeNested && row.paymentMethods
|
||||||
|
? row.paymentMethods.map((pm) => this.serializePaymentMethod(pm))
|
||||||
|
: undefined,
|
||||||
accounts:
|
accounts:
|
||||||
includeNested && row.accounts
|
includeNested && row.accounts
|
||||||
? row.accounts.map((acc) => this.serializeAccount(acc))
|
? row.accounts.map((acc) => this.serializeAccount(acc))
|
||||||
@@ -488,6 +542,11 @@ export class InvoicesService {
|
|||||||
text: string;
|
text: string;
|
||||||
sortOrder: number;
|
sortOrder: number;
|
||||||
}>;
|
}>;
|
||||||
|
paymentMethods?: Array<{
|
||||||
|
id: bigint;
|
||||||
|
text: string;
|
||||||
|
sortOrder: number;
|
||||||
|
}>;
|
||||||
accounts?: Array<{
|
accounts?: Array<{
|
||||||
id: bigint;
|
id: bigint;
|
||||||
bankName: string;
|
bankName: string;
|
||||||
@@ -564,6 +623,10 @@ export class InvoicesService {
|
|||||||
includeNested && row.keyPoints
|
includeNested && row.keyPoints
|
||||||
? row.keyPoints.map((kp) => this.serializeKeyPoint(kp))
|
? row.keyPoints.map((kp) => this.serializeKeyPoint(kp))
|
||||||
: undefined,
|
: undefined,
|
||||||
|
paymentMethods:
|
||||||
|
includeNested && row.paymentMethods
|
||||||
|
? row.paymentMethods.map((pm) => this.serializePaymentMethod(pm))
|
||||||
|
: undefined,
|
||||||
accounts:
|
accounts:
|
||||||
includeNested && row.accounts
|
includeNested && row.accounts
|
||||||
? row.accounts.map((acc) => this.serializeAccount(acc))
|
? row.accounts.map((acc) => this.serializeAccount(acc))
|
||||||
@@ -581,6 +644,14 @@ export class InvoicesService {
|
|||||||
return { text };
|
return { text };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private normalizePaymentMethodInput(paymentMethod: InvoicePaymentMethodInputDto) {
|
||||||
|
const text = paymentMethod.text.trim();
|
||||||
|
if (!text) {
|
||||||
|
throw new BadRequestException('Each payment method requires text');
|
||||||
|
}
|
||||||
|
return { text };
|
||||||
|
}
|
||||||
|
|
||||||
private normalizeAccountInput(account: InvoiceAccountInputDto) {
|
private normalizeAccountInput(account: InvoiceAccountInputDto) {
|
||||||
const bankName = account.bankName.trim();
|
const bankName = account.bankName.trim();
|
||||||
if (!bankName) {
|
if (!bankName) {
|
||||||
@@ -753,6 +824,83 @@ export class InvoicesService {
|
|||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Account templates (platform settings) ---
|
||||||
|
|
||||||
|
async listPlatformAccountTemplates(actor: AuthUser) {
|
||||||
|
await this.assertSuperAdmin(actor);
|
||||||
|
const rows = await this.prisma.invoiceAccountTemplate.findMany({
|
||||||
|
where: { ownerScope: InvoiceOwnerScope.platform },
|
||||||
|
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
|
||||||
|
});
|
||||||
|
return { items: rows.map((row) => this.serializeAccountTemplate(row)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async createPlatformAccountTemplate(
|
||||||
|
dto: CreateInvoiceAccountTemplateDto,
|
||||||
|
actor: AuthUser,
|
||||||
|
) {
|
||||||
|
await this.assertSuperAdmin(actor);
|
||||||
|
const bankName = dto.bankName.trim();
|
||||||
|
if (!bankName) {
|
||||||
|
throw new BadRequestException('Bank name is required');
|
||||||
|
}
|
||||||
|
const row = await this.prisma.invoiceAccountTemplate.create({
|
||||||
|
data: {
|
||||||
|
ownerScope: InvoiceOwnerScope.platform,
|
||||||
|
bankName,
|
||||||
|
accountHolderName: dto.accountHolderName?.trim() || null,
|
||||||
|
cardNumber: dto.cardNumber?.trim() || null,
|
||||||
|
iban: dto.iban?.trim() || null,
|
||||||
|
sortOrder: dto.sortOrder ?? 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return this.serializeAccountTemplate(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updatePlatformAccountTemplate(
|
||||||
|
templateIdRaw: string,
|
||||||
|
dto: UpdateInvoiceAccountTemplateDto,
|
||||||
|
actor: AuthUser,
|
||||||
|
) {
|
||||||
|
await this.assertSuperAdmin(actor);
|
||||||
|
const templateId = BigInt(templateIdRaw);
|
||||||
|
const existing = await this.prisma.invoiceAccountTemplate.findFirst({
|
||||||
|
where: { id: templateId, ownerScope: InvoiceOwnerScope.platform },
|
||||||
|
});
|
||||||
|
if (!existing) {
|
||||||
|
throw new NotFoundException('Invoice account template not found');
|
||||||
|
}
|
||||||
|
const row = await this.prisma.invoiceAccountTemplate.update({
|
||||||
|
where: { id: templateId },
|
||||||
|
data: {
|
||||||
|
...(dto.bankName !== undefined ? { bankName: dto.bankName.trim() } : {}),
|
||||||
|
...(dto.accountHolderName !== undefined
|
||||||
|
? { accountHolderName: dto.accountHolderName?.trim() || null }
|
||||||
|
: {}),
|
||||||
|
...(dto.cardNumber !== undefined
|
||||||
|
? { cardNumber: dto.cardNumber?.trim() || null }
|
||||||
|
: {}),
|
||||||
|
...(dto.iban !== undefined ? { iban: dto.iban?.trim() || null } : {}),
|
||||||
|
...(dto.sortOrder !== undefined ? { sortOrder: dto.sortOrder } : {}),
|
||||||
|
...(dto.isActive !== undefined ? { isActive: dto.isActive } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return this.serializeAccountTemplate(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deletePlatformAccountTemplate(templateIdRaw: string, actor: AuthUser) {
|
||||||
|
await this.assertSuperAdmin(actor);
|
||||||
|
const templateId = BigInt(templateIdRaw);
|
||||||
|
const existing = await this.prisma.invoiceAccountTemplate.findFirst({
|
||||||
|
where: { id: templateId, ownerScope: InvoiceOwnerScope.platform },
|
||||||
|
});
|
||||||
|
if (!existing) {
|
||||||
|
throw new NotFoundException('Invoice account template not found');
|
||||||
|
}
|
||||||
|
await this.prisma.invoiceAccountTemplate.delete({ where: { id: templateId } });
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
// --- Invoice templates (platform settings) ---
|
// --- Invoice templates (platform settings) ---
|
||||||
|
|
||||||
async listPlatformInvoiceTemplates(actor: AuthUser) {
|
async listPlatformInvoiceTemplates(actor: AuthUser) {
|
||||||
@@ -793,6 +941,9 @@ export class InvoicesService {
|
|||||||
|
|
||||||
const items = dto.items.map((item) => this.normalizeTemplateItemInput(item));
|
const items = dto.items.map((item) => this.normalizeTemplateItemInput(item));
|
||||||
const keyPoints = (dto.keyPoints ?? []).map((kp) => this.normalizeKeyPointInput(kp));
|
const keyPoints = (dto.keyPoints ?? []).map((kp) => this.normalizeKeyPointInput(kp));
|
||||||
|
const paymentMethods = (dto.paymentMethods ?? []).map((pm) =>
|
||||||
|
this.normalizePaymentMethodInput(pm),
|
||||||
|
);
|
||||||
const accounts = (dto.accounts ?? []).map((acc) => this.normalizeAccountInput(acc));
|
const accounts = (dto.accounts ?? []).map((acc) => this.normalizeAccountInput(acc));
|
||||||
|
|
||||||
const row = await this.prisma.invoiceTemplate.create({
|
const row = await this.prisma.invoiceTemplate.create({
|
||||||
@@ -819,6 +970,12 @@ export class InvoicesService {
|
|||||||
sortOrder: index,
|
sortOrder: index,
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
|
paymentMethods: {
|
||||||
|
create: paymentMethods.map((pm, index) => ({
|
||||||
|
text: pm.text,
|
||||||
|
sortOrder: index,
|
||||||
|
})),
|
||||||
|
},
|
||||||
accounts: {
|
accounts: {
|
||||||
create: accounts.map((acc, index) => ({
|
create: accounts.map((acc, index) => ({
|
||||||
bankName: acc.bankName,
|
bankName: acc.bankName,
|
||||||
@@ -856,6 +1013,10 @@ export class InvoicesService {
|
|||||||
dto.keyPoints !== undefined
|
dto.keyPoints !== undefined
|
||||||
? dto.keyPoints.map((kp) => this.normalizeKeyPointInput(kp))
|
? dto.keyPoints.map((kp) => this.normalizeKeyPointInput(kp))
|
||||||
: null;
|
: null;
|
||||||
|
const replacePaymentMethods =
|
||||||
|
dto.paymentMethods !== undefined
|
||||||
|
? dto.paymentMethods.map((pm) => this.normalizePaymentMethodInput(pm))
|
||||||
|
: null;
|
||||||
const replaceAccounts =
|
const replaceAccounts =
|
||||||
dto.accounts !== undefined
|
dto.accounts !== undefined
|
||||||
? dto.accounts.map((acc) => this.normalizeAccountInput(acc))
|
? dto.accounts.map((acc) => this.normalizeAccountInput(acc))
|
||||||
@@ -868,6 +1029,9 @@ export class InvoicesService {
|
|||||||
if (replaceKeyPoints !== null) {
|
if (replaceKeyPoints !== null) {
|
||||||
await tx.invoiceTemplateKeyPoint.deleteMany({ where: { templateId } });
|
await tx.invoiceTemplateKeyPoint.deleteMany({ where: { templateId } });
|
||||||
}
|
}
|
||||||
|
if (replacePaymentMethods !== null) {
|
||||||
|
await tx.invoiceTemplatePaymentMethod.deleteMany({ where: { templateId } });
|
||||||
|
}
|
||||||
if (replaceAccounts !== null) {
|
if (replaceAccounts !== null) {
|
||||||
await tx.invoiceTemplateAccount.deleteMany({ where: { templateId } });
|
await tx.invoiceTemplateAccount.deleteMany({ where: { templateId } });
|
||||||
}
|
}
|
||||||
@@ -905,6 +1069,16 @@ export class InvoicesService {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
|
...(replacePaymentMethods !== null
|
||||||
|
? {
|
||||||
|
paymentMethods: {
|
||||||
|
create: replacePaymentMethods.map((pm, index) => ({
|
||||||
|
text: pm.text,
|
||||||
|
sortOrder: index,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
...(replaceAccounts !== null
|
...(replaceAccounts !== null
|
||||||
? {
|
? {
|
||||||
accounts: {
|
accounts: {
|
||||||
@@ -1062,6 +1236,105 @@ export class InvoicesService {
|
|||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Account templates (business-scoped) ---
|
||||||
|
|
||||||
|
async listBusinessAccountTemplates(businessIdRaw: string, actor: AuthUser) {
|
||||||
|
const businessId = BigInt(businessIdRaw);
|
||||||
|
await this.assertBusinessExists(businessId);
|
||||||
|
await this.assertPermission(businessId, actor.id, 'invoice_templates.read');
|
||||||
|
|
||||||
|
const rows = await this.prisma.invoiceAccountTemplate.findMany({
|
||||||
|
where: { ownerScope: InvoiceOwnerScope.business, businessId },
|
||||||
|
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
|
||||||
|
});
|
||||||
|
return { items: rows.map((row) => this.serializeAccountTemplate(row)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async createBusinessAccountTemplate(
|
||||||
|
businessIdRaw: string,
|
||||||
|
dto: CreateInvoiceAccountTemplateDto,
|
||||||
|
actor: AuthUser,
|
||||||
|
) {
|
||||||
|
const businessId = BigInt(businessIdRaw);
|
||||||
|
await this.assertBusinessExists(businessId);
|
||||||
|
await this.assertPermission(businessId, actor.id, 'invoice_templates.manage');
|
||||||
|
|
||||||
|
const bankName = dto.bankName.trim();
|
||||||
|
if (!bankName) {
|
||||||
|
throw new BadRequestException('Bank name is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = await this.prisma.invoiceAccountTemplate.create({
|
||||||
|
data: {
|
||||||
|
ownerScope: InvoiceOwnerScope.business,
|
||||||
|
businessId,
|
||||||
|
bankName,
|
||||||
|
accountHolderName: dto.accountHolderName?.trim() || null,
|
||||||
|
cardNumber: dto.cardNumber?.trim() || null,
|
||||||
|
iban: dto.iban?.trim() || null,
|
||||||
|
sortOrder: dto.sortOrder ?? 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return this.serializeAccountTemplate(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateBusinessAccountTemplate(
|
||||||
|
businessIdRaw: string,
|
||||||
|
templateIdRaw: string,
|
||||||
|
dto: UpdateInvoiceAccountTemplateDto,
|
||||||
|
actor: AuthUser,
|
||||||
|
) {
|
||||||
|
const businessId = BigInt(businessIdRaw);
|
||||||
|
await this.assertBusinessExists(businessId);
|
||||||
|
await this.assertPermission(businessId, actor.id, 'invoice_templates.manage');
|
||||||
|
|
||||||
|
const templateId = BigInt(templateIdRaw);
|
||||||
|
const existing = await this.prisma.invoiceAccountTemplate.findFirst({
|
||||||
|
where: { id: templateId, ownerScope: InvoiceOwnerScope.business, businessId },
|
||||||
|
});
|
||||||
|
if (!existing) {
|
||||||
|
throw new NotFoundException('Invoice account template not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = await this.prisma.invoiceAccountTemplate.update({
|
||||||
|
where: { id: templateId },
|
||||||
|
data: {
|
||||||
|
...(dto.bankName !== undefined ? { bankName: dto.bankName.trim() } : {}),
|
||||||
|
...(dto.accountHolderName !== undefined
|
||||||
|
? { accountHolderName: dto.accountHolderName?.trim() || null }
|
||||||
|
: {}),
|
||||||
|
...(dto.cardNumber !== undefined
|
||||||
|
? { cardNumber: dto.cardNumber?.trim() || null }
|
||||||
|
: {}),
|
||||||
|
...(dto.iban !== undefined ? { iban: dto.iban?.trim() || null } : {}),
|
||||||
|
...(dto.sortOrder !== undefined ? { sortOrder: dto.sortOrder } : {}),
|
||||||
|
...(dto.isActive !== undefined ? { isActive: dto.isActive } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return this.serializeAccountTemplate(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteBusinessAccountTemplate(
|
||||||
|
businessIdRaw: string,
|
||||||
|
templateIdRaw: string,
|
||||||
|
actor: AuthUser,
|
||||||
|
) {
|
||||||
|
const businessId = BigInt(businessIdRaw);
|
||||||
|
await this.assertBusinessExists(businessId);
|
||||||
|
await this.assertPermission(businessId, actor.id, 'invoice_templates.manage');
|
||||||
|
|
||||||
|
const templateId = BigInt(templateIdRaw);
|
||||||
|
const existing = await this.prisma.invoiceAccountTemplate.findFirst({
|
||||||
|
where: { id: templateId, ownerScope: InvoiceOwnerScope.business, businessId },
|
||||||
|
});
|
||||||
|
if (!existing) {
|
||||||
|
throw new NotFoundException('Invoice account template not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prisma.invoiceAccountTemplate.delete({ where: { id: templateId } });
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
// --- Invoice templates (business-scoped) ---
|
// --- Invoice templates (business-scoped) ---
|
||||||
|
|
||||||
async listBusinessInvoiceTemplates(businessIdRaw: string, actor: AuthUser) {
|
async listBusinessInvoiceTemplates(businessIdRaw: string, actor: AuthUser) {
|
||||||
@@ -1116,6 +1389,9 @@ export class InvoicesService {
|
|||||||
|
|
||||||
const items = dto.items.map((item) => this.normalizeTemplateItemInput(item));
|
const items = dto.items.map((item) => this.normalizeTemplateItemInput(item));
|
||||||
const keyPoints = (dto.keyPoints ?? []).map((kp) => this.normalizeKeyPointInput(kp));
|
const keyPoints = (dto.keyPoints ?? []).map((kp) => this.normalizeKeyPointInput(kp));
|
||||||
|
const paymentMethods = (dto.paymentMethods ?? []).map((pm) =>
|
||||||
|
this.normalizePaymentMethodInput(pm),
|
||||||
|
);
|
||||||
const accounts = (dto.accounts ?? []).map((acc) => this.normalizeAccountInput(acc));
|
const accounts = (dto.accounts ?? []).map((acc) => this.normalizeAccountInput(acc));
|
||||||
|
|
||||||
const row = await this.prisma.invoiceTemplate.create({
|
const row = await this.prisma.invoiceTemplate.create({
|
||||||
@@ -1143,6 +1419,12 @@ export class InvoicesService {
|
|||||||
sortOrder: index,
|
sortOrder: index,
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
|
paymentMethods: {
|
||||||
|
create: paymentMethods.map((pm, index) => ({
|
||||||
|
text: pm.text,
|
||||||
|
sortOrder: index,
|
||||||
|
})),
|
||||||
|
},
|
||||||
accounts: {
|
accounts: {
|
||||||
create: accounts.map((acc, index) => ({
|
create: accounts.map((acc, index) => ({
|
||||||
bankName: acc.bankName,
|
bankName: acc.bankName,
|
||||||
@@ -1183,6 +1465,10 @@ export class InvoicesService {
|
|||||||
dto.keyPoints !== undefined
|
dto.keyPoints !== undefined
|
||||||
? dto.keyPoints.map((kp) => this.normalizeKeyPointInput(kp))
|
? dto.keyPoints.map((kp) => this.normalizeKeyPointInput(kp))
|
||||||
: null;
|
: null;
|
||||||
|
const replacePaymentMethods =
|
||||||
|
dto.paymentMethods !== undefined
|
||||||
|
? dto.paymentMethods.map((pm) => this.normalizePaymentMethodInput(pm))
|
||||||
|
: null;
|
||||||
const replaceAccounts =
|
const replaceAccounts =
|
||||||
dto.accounts !== undefined
|
dto.accounts !== undefined
|
||||||
? dto.accounts.map((acc) => this.normalizeAccountInput(acc))
|
? dto.accounts.map((acc) => this.normalizeAccountInput(acc))
|
||||||
@@ -1195,6 +1481,9 @@ export class InvoicesService {
|
|||||||
if (replaceKeyPoints !== null) {
|
if (replaceKeyPoints !== null) {
|
||||||
await tx.invoiceTemplateKeyPoint.deleteMany({ where: { templateId } });
|
await tx.invoiceTemplateKeyPoint.deleteMany({ where: { templateId } });
|
||||||
}
|
}
|
||||||
|
if (replacePaymentMethods !== null) {
|
||||||
|
await tx.invoiceTemplatePaymentMethod.deleteMany({ where: { templateId } });
|
||||||
|
}
|
||||||
if (replaceAccounts !== null) {
|
if (replaceAccounts !== null) {
|
||||||
await tx.invoiceTemplateAccount.deleteMany({ where: { templateId } });
|
await tx.invoiceTemplateAccount.deleteMany({ where: { templateId } });
|
||||||
}
|
}
|
||||||
@@ -1232,6 +1521,16 @@ export class InvoicesService {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
|
...(replacePaymentMethods !== null
|
||||||
|
? {
|
||||||
|
paymentMethods: {
|
||||||
|
create: replacePaymentMethods.map((pm, index) => ({
|
||||||
|
text: pm.text,
|
||||||
|
sortOrder: index,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
...(replaceAccounts !== null
|
...(replaceAccounts !== null
|
||||||
? {
|
? {
|
||||||
accounts: {
|
accounts: {
|
||||||
@@ -1304,6 +1603,7 @@ export class InvoicesService {
|
|||||||
: null,
|
: null,
|
||||||
items: serialized.items,
|
items: serialized.items,
|
||||||
keyPoints: serialized.keyPoints,
|
keyPoints: serialized.keyPoints,
|
||||||
|
paymentMethods: serialized.paymentMethods,
|
||||||
accounts: serialized.accounts,
|
accounts: serialized.accounts,
|
||||||
subtotal: serialized.subtotal,
|
subtotal: serialized.subtotal,
|
||||||
total: serialized.total,
|
total: serialized.total,
|
||||||
@@ -1465,6 +1765,9 @@ export class InvoicesService {
|
|||||||
|
|
||||||
const items = dto.items.map((item) => this.normalizeItemInput(item));
|
const items = dto.items.map((item) => this.normalizeItemInput(item));
|
||||||
const keyPoints = (dto.keyPoints ?? []).map((kp) => this.normalizeKeyPointInput(kp));
|
const keyPoints = (dto.keyPoints ?? []).map((kp) => this.normalizeKeyPointInput(kp));
|
||||||
|
const paymentMethods = (dto.paymentMethods ?? []).map((pm) =>
|
||||||
|
this.normalizePaymentMethodInput(pm),
|
||||||
|
);
|
||||||
const accounts = (dto.accounts ?? []).map((acc) => this.normalizeAccountInput(acc));
|
const accounts = (dto.accounts ?? []).map((acc) => this.normalizeAccountInput(acc));
|
||||||
const invoiceTemplateId = dto.invoiceTemplateId?.trim()
|
const invoiceTemplateId = dto.invoiceTemplateId?.trim()
|
||||||
? BigInt(dto.invoiceTemplateId)
|
? BigInt(dto.invoiceTemplateId)
|
||||||
@@ -1514,6 +1817,12 @@ export class InvoicesService {
|
|||||||
sortOrder: index,
|
sortOrder: index,
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
|
paymentMethods: {
|
||||||
|
create: paymentMethods.map((pm, index) => ({
|
||||||
|
text: pm.text,
|
||||||
|
sortOrder: index,
|
||||||
|
})),
|
||||||
|
},
|
||||||
accounts: {
|
accounts: {
|
||||||
create: accounts.map((acc, index) => ({
|
create: accounts.map((acc, index) => ({
|
||||||
bankName: acc.bankName,
|
bankName: acc.bankName,
|
||||||
@@ -1600,6 +1909,9 @@ export class InvoicesService {
|
|||||||
|
|
||||||
const items = dto.items.map((item) => this.normalizeItemInput(item));
|
const items = dto.items.map((item) => this.normalizeItemInput(item));
|
||||||
const keyPoints = (dto.keyPoints ?? []).map((kp) => this.normalizeKeyPointInput(kp));
|
const keyPoints = (dto.keyPoints ?? []).map((kp) => this.normalizeKeyPointInput(kp));
|
||||||
|
const paymentMethods = (dto.paymentMethods ?? []).map((pm) =>
|
||||||
|
this.normalizePaymentMethodInput(pm),
|
||||||
|
);
|
||||||
const accounts = (dto.accounts ?? []).map((acc) => this.normalizeAccountInput(acc));
|
const accounts = (dto.accounts ?? []).map((acc) => this.normalizeAccountInput(acc));
|
||||||
|
|
||||||
let userId: bigint | undefined;
|
let userId: bigint | undefined;
|
||||||
@@ -1629,6 +1941,7 @@ export class InvoicesService {
|
|||||||
const row = await this.prisma.$transaction(async (tx) => {
|
const row = await this.prisma.$transaction(async (tx) => {
|
||||||
await tx.invoiceItem.deleteMany({ where: { invoiceId } });
|
await tx.invoiceItem.deleteMany({ where: { invoiceId } });
|
||||||
await tx.invoiceKeyPoint.deleteMany({ where: { invoiceId } });
|
await tx.invoiceKeyPoint.deleteMany({ where: { invoiceId } });
|
||||||
|
await tx.invoicePaymentMethod.deleteMany({ where: { invoiceId } });
|
||||||
await tx.invoiceAccount.deleteMany({ where: { invoiceId } });
|
await tx.invoiceAccount.deleteMany({ where: { invoiceId } });
|
||||||
|
|
||||||
return tx.invoice.update({
|
return tx.invoice.update({
|
||||||
@@ -1657,6 +1970,12 @@ export class InvoicesService {
|
|||||||
sortOrder: index,
|
sortOrder: index,
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
|
paymentMethods: {
|
||||||
|
create: paymentMethods.map((pm, index) => ({
|
||||||
|
text: pm.text,
|
||||||
|
sortOrder: index,
|
||||||
|
})),
|
||||||
|
},
|
||||||
accounts: {
|
accounts: {
|
||||||
create: accounts.map((acc, index) => ({
|
create: accounts.map((acc, index) => ({
|
||||||
bankName: acc.bankName,
|
bankName: acc.bankName,
|
||||||
|
|||||||
@@ -60,13 +60,23 @@ export class LegacySourceS3Service implements OnModuleDestroy {
|
|||||||
const slug = slugHint.trim();
|
const slug = slugHint.trim();
|
||||||
add(`${slug}_${oldBusinessId}`);
|
add(`${slug}_${oldBusinessId}`);
|
||||||
add(`${slug.replace(/-/g, '')}_${oldBusinessId}`);
|
add(`${slug.replace(/-/g, '')}_${oldBusinessId}`);
|
||||||
|
add(`${slug.replace(/_/g, '')}_${oldBusinessId}`);
|
||||||
|
add(`${slug.replace(/_/g, '-')}_${oldBusinessId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const listed = await this.listTopLevelPrefixes();
|
// ListBucket may be denied on the old bucket; slug candidates + HeadObject still work.
|
||||||
for (const prefix of listed) {
|
try {
|
||||||
if (prefix.endsWith(`_${oldBusinessId}`)) {
|
const listed = await this.listTopLevelPrefixes();
|
||||||
add(prefix);
|
for (const prefix of listed) {
|
||||||
|
if (prefix.endsWith(`_${oldBusinessId}`)) {
|
||||||
|
add(prefix);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
console.warn(
|
||||||
|
`Legacy S3 list prefixes failed for business ${oldBusinessId}; using slug candidates only: ${message}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!candidates.length) {
|
if (!candidates.length) {
|
||||||
@@ -75,7 +85,8 @@ export class LegacySourceS3Service implements OnModuleDestroy {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const sample = probes.slice(0, 12);
|
// Spread probes so early missing files don't hide a valid prefix.
|
||||||
|
const sample = this.pickProbeSample(probes, 12);
|
||||||
let bestPrefix = candidates[0];
|
let bestPrefix = candidates[0];
|
||||||
let bestHits = -1;
|
let bestHits = -1;
|
||||||
|
|
||||||
@@ -96,6 +107,16 @@ export class LegacySourceS3Service implements OnModuleDestroy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (bestHits <= 0 && sample.length > 0) {
|
||||||
|
for (const prefix of candidates) {
|
||||||
|
if (await this.prefixHasAnyObject(prefix)) {
|
||||||
|
bestPrefix = prefix;
|
||||||
|
bestHits = 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (bestHits <= 0 && sample.length > 0) {
|
if (bestHits <= 0 && sample.length > 0) {
|
||||||
throw new ServiceUnavailableException(
|
throw new ServiceUnavailableException(
|
||||||
`Old S3 files not found for business ${oldBusinessId} (tried: ${candidates.join(', ')}).`,
|
`Old S3 files not found for business ${oldBusinessId} (tried: ${candidates.join(', ')}).`,
|
||||||
@@ -106,6 +127,35 @@ export class LegacySourceS3Service implements OnModuleDestroy {
|
|||||||
return bestPrefix;
|
return bestPrefix;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private pickProbeSample<T>(items: T[], n: number): T[] {
|
||||||
|
if (items.length <= n) return [...items];
|
||||||
|
const out: T[] = [];
|
||||||
|
const seen = new Set<number>();
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const idx = Math.floor((i * (items.length - 1)) / Math.max(n - 1, 1));
|
||||||
|
if (seen.has(idx)) continue;
|
||||||
|
seen.add(idx);
|
||||||
|
out.push(items[idx]);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async prefixHasAnyObject(prefix: string): Promise<boolean> {
|
||||||
|
const { client, bucket } = this.getClient();
|
||||||
|
try {
|
||||||
|
const res = await client.send(
|
||||||
|
new ListObjectsV2Command({
|
||||||
|
Bucket: bucket,
|
||||||
|
Prefix: `${prefix}/`,
|
||||||
|
MaxKeys: 1,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return (res.Contents?.length ?? 0) > 0;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async getObjectBuffer(key: string): Promise<Buffer> {
|
async getObjectBuffer(key: string): Promise<Buffer> {
|
||||||
const { client, bucket } = this.getClient();
|
const { client, bucket } = this.getClient();
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
export const MAIL_IPV4 = '185.214.101.41';
|
export const MAIL_IPV4 = '185.214.101.41';
|
||||||
export const MAIL_HOSTNAME = 'mail.meshkee.com';
|
export const MAIL_HOSTNAME = 'mail.meshkee.com';
|
||||||
export const MAIL_MX_PRIORITY = 10;
|
export const MAIL_MX_PRIORITY = 10;
|
||||||
export const MAIL_SPF = `v=spf1 ip4:${MAIL_IPV4} -all`;
|
export const MAIL_SPF = 'v=spf1 mx -all';
|
||||||
/** Old provider selector (dkim._domainkey.example.com) — removed on mail DNS update. */
|
/** Old provider selector (dkim._domainkey.example.com) — removed on mail DNS update. */
|
||||||
export const LEGACY_DKIM_NAME = 'dkim._domainkey';
|
export const LEGACY_DKIM_NAME = 'dkim._domainkey';
|
||||||
export const DMARC_NAME = '_dmarc';
|
export const DMARC_NAME = '_dmarc';
|
||||||
@@ -11,13 +11,29 @@ export type MailDkimKey = {
|
|||||||
text: string;
|
text: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Bare selector, or `name._domainkey` / `name._domainkey.example.com.` */
|
||||||
|
export function normalizeDkimSelector(raw: string | undefined | null): string | null {
|
||||||
|
if (raw == null) return null;
|
||||||
|
let input = raw.trim().replace(/^"+|"+$/g, '').trim();
|
||||||
|
if (!input) return null;
|
||||||
|
|
||||||
|
const hostMatch = input.match(/^([a-z0-9][a-z0-9-]*)\._domainkey(?:\.|$)/i);
|
||||||
|
if (hostMatch) return hostMatch[1].toLowerCase();
|
||||||
|
|
||||||
|
if (/^[a-z0-9][a-z0-9-]*$/i.test(input)) return input.toLowerCase();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export function parseDkimPaste(
|
export function parseDkimPaste(
|
||||||
raw: string,
|
raw: string,
|
||||||
expectedKind: 'ed25519' | 'rsa',
|
expectedKind: 'ed25519' | 'rsa',
|
||||||
|
selectorHint?: string,
|
||||||
): MailDkimKey {
|
): MailDkimKey {
|
||||||
const input = raw.trim();
|
const input = raw.trim();
|
||||||
if (!input) {
|
if (!input) {
|
||||||
throw new Error(`Paste the ${expectedKind} DKIM line from Stalwart.`);
|
throw new Error(
|
||||||
|
`Paste the ${expectedKind} DKIM TXT value (v=DKIM1; …) or the full Stalwart zone line.`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const quoted = [...input.matchAll(/"([^"]*)"/g)].map((match) => match[1]).join('');
|
const quoted = [...input.matchAll(/"([^"]*)"/g)].map((match) => match[1]).join('');
|
||||||
@@ -28,9 +44,18 @@ export function parseDkimPaste(
|
|||||||
let text = (txtMatch ? txtMatch[0] : blob).trim();
|
let text = (txtMatch ? txtMatch[0] : blob).trim();
|
||||||
text = text.replace(/^"+|"+$/g, '').replace(/\s+/g, ' ').replace(/[.\s]+$/g, '').trim();
|
text = text.replace(/^"+|"+$/g, '').replace(/\s+/g, ' ').replace(/[.\s]+$/g, '').trim();
|
||||||
if (!/^v=DKIM1/i.test(text)) {
|
if (!/^v=DKIM1/i.test(text)) {
|
||||||
throw new Error(
|
// Stalwart sometimes copies only the public key / p=… body.
|
||||||
`Could not find a v=DKIM1 value in the ${expectedKind} field. Paste the Stalwart zone line.`,
|
const bare = text.replace(/^p=/i, '').replace(/\s+/g, '');
|
||||||
);
|
if (/^[A-Za-z0-9+/=]+$/.test(bare) && bare.length >= 20) {
|
||||||
|
text =
|
||||||
|
expectedKind === 'ed25519'
|
||||||
|
? `v=DKIM1; k=ed25519; h=sha256; p=${bare}`
|
||||||
|
: `v=DKIM1; k=rsa; h=sha256; p=${bare}`;
|
||||||
|
} else {
|
||||||
|
throw new Error(
|
||||||
|
`Could not find a v=DKIM1 value in the ${expectedKind} field. Paste the TXT value or Stalwart zone line.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const kind = /k=ed25519/i.test(text)
|
const kind = /k=ed25519/i.test(text)
|
||||||
@@ -45,14 +70,16 @@ export function parseDkimPaste(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const nameMatch = compact.match(/([a-z0-9][a-z0-9-]*)\._domainkey/i);
|
const nameMatch = compact.match(/([a-z0-9][a-z0-9-]*)\._domainkey/i);
|
||||||
if (!nameMatch) {
|
const selector =
|
||||||
|
(nameMatch ? nameMatch[1].toLowerCase() : null) || normalizeDkimSelector(selectorHint);
|
||||||
|
if (!selector) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'Include the selector name (for example v1-ed25519-20260831._domainkey) from the Stalwart zone file.',
|
'Could not find a DKIM selector. Enter the selector (for example v1-ed25519-20260901), or paste a zone line that includes …._domainkey.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
selector: nameMatch[1].toLowerCase(),
|
selector,
|
||||||
text,
|
text,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,11 @@ const IMAGE_MIME_TYPES = new Set([
|
|||||||
|
|
||||||
const VIDEO_MIME_TYPES = new Set(['video/mp4', 'video/webm']);
|
const VIDEO_MIME_TYPES = new Set(['video/mp4', 'video/webm']);
|
||||||
|
|
||||||
|
const PDF_MIME_TYPES = new Set(['application/pdf']);
|
||||||
|
|
||||||
|
/** Product catalog PDFs are capped lower than general media uploads. */
|
||||||
|
const PDF_MAX_FILE_SIZE_BYTES = 2 * 1024 * 1024;
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class MediaService {
|
export class MediaService {
|
||||||
private readonly maxFileSizeBytes: number;
|
private readonly maxFileSizeBytes: number;
|
||||||
@@ -375,6 +380,14 @@ export class MediaService {
|
|||||||
throw new BadRequestException('Uploaded file is empty');
|
throw new BadRequestException('Uploaded file is empty');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isPdf =
|
||||||
|
PDF_MIME_TYPES.has(file.mimetype) ||
|
||||||
|
file.originalname.toLowerCase().endsWith('.pdf');
|
||||||
|
|
||||||
|
if (isPdf) {
|
||||||
|
return this.uploadPdf(businessId, file, uploadedBy);
|
||||||
|
}
|
||||||
|
|
||||||
if (file.size > this.maxFileSizeBytes) {
|
if (file.size > this.maxFileSizeBytes) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`File ${file.originalname} exceeds the maximum allowed size`,
|
`File ${file.originalname} exceeds the maximum allowed size`,
|
||||||
@@ -409,7 +422,7 @@ export class MediaService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`Unsupported file type: ${file.mimetype || 'unknown'}. Use JPEG, PNG, WebP, or GIF.`,
|
`Unsupported file type: ${file.mimetype || 'unknown'}. Use JPEG, PNG, WebP, GIF, or PDF.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -443,6 +456,54 @@ export class MediaService {
|
|||||||
return this.serialize(created);
|
return this.serialize(created);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async uploadPdf(
|
||||||
|
businessId: bigint,
|
||||||
|
file: Express.Multer.File,
|
||||||
|
uploadedBy: bigint,
|
||||||
|
) {
|
||||||
|
if (file.size > PDF_MAX_FILE_SIZE_BYTES) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`PDF ${file.originalname} exceeds the maximum allowed size of 2MB`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const header = file.buffer.subarray(0, 5).toString('utf8');
|
||||||
|
if (!header.startsWith('%PDF-')) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`File ${file.originalname} is not a valid PDF`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileName = `${randomUUID()}.pdf`;
|
||||||
|
const storageKey = businessMediaKey(businessId, fileName);
|
||||||
|
const contentType = 'application/pdf';
|
||||||
|
|
||||||
|
const stored = await this.storage.upload({
|
||||||
|
key: storageKey,
|
||||||
|
body: file.buffer,
|
||||||
|
contentType,
|
||||||
|
});
|
||||||
|
|
||||||
|
const created = await this.prisma.media.create({
|
||||||
|
data: {
|
||||||
|
businessId,
|
||||||
|
uploadedBy,
|
||||||
|
mediaType: MediaType.document,
|
||||||
|
storageDisk: stored.storageDisk,
|
||||||
|
storagePath: stored.storagePath,
|
||||||
|
publicUrl: stored.publicUrl,
|
||||||
|
fileName,
|
||||||
|
originalFileName: file.originalname,
|
||||||
|
mimeType: contentType,
|
||||||
|
fileSizeBytes: BigInt(file.size),
|
||||||
|
width: null,
|
||||||
|
height: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.serialize(created);
|
||||||
|
}
|
||||||
|
|
||||||
private contentTypeFromFormat(format?: string) {
|
private contentTypeFromFormat(format?: string) {
|
||||||
switch (format) {
|
switch (format) {
|
||||||
case 'jpeg':
|
case 'jpeg':
|
||||||
@@ -469,6 +530,8 @@ export class MediaService {
|
|||||||
return '.webp';
|
return '.webp';
|
||||||
case 'image/gif':
|
case 'image/gif':
|
||||||
return '.gif';
|
return '.gif';
|
||||||
|
case 'application/pdf':
|
||||||
|
return '.pdf';
|
||||||
default:
|
default:
|
||||||
return '.jpg';
|
return '.jpg';
|
||||||
}
|
}
|
||||||
@@ -493,6 +556,8 @@ export class MediaService {
|
|||||||
return '.mp4';
|
return '.mp4';
|
||||||
case 'video/webm':
|
case 'video/webm':
|
||||||
return '.webm';
|
return '.webm';
|
||||||
|
case 'application/pdf':
|
||||||
|
return '.pdf';
|
||||||
default:
|
default:
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import {
|
import {
|
||||||
ArrayMinSize,
|
ArrayMinSize,
|
||||||
|
IsBoolean,
|
||||||
IsDateString,
|
IsDateString,
|
||||||
IsEnum,
|
IsEnum,
|
||||||
|
IsIn,
|
||||||
IsInt,
|
IsInt,
|
||||||
IsNumber,
|
IsNumber,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
@@ -11,7 +13,7 @@ import {
|
|||||||
ValidateIf,
|
ValidateIf,
|
||||||
ValidateNested,
|
ValidateNested,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
import { Type } from 'class-transformer';
|
import { Transform, Type } from 'class-transformer';
|
||||||
import { OrderStatus, TransactionType } from '@prisma/client';
|
import { OrderStatus, TransactionType } from '@prisma/client';
|
||||||
import { ShippingAddressDto } from '../../cart/dto/cart.dto';
|
import { ShippingAddressDto } from '../../cart/dto/cart.dto';
|
||||||
|
|
||||||
@@ -166,4 +168,18 @@ export class ListOrdersDto {
|
|||||||
@Min(0)
|
@Min(0)
|
||||||
@Type(() => Number)
|
@Type(() => Number)
|
||||||
maxTotal?: number;
|
maxTotal?: number;
|
||||||
|
|
||||||
|
/** Filter by payment outcome derived from transactions. */
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(['paid', 'unpaid', 'failed'])
|
||||||
|
paymentStatus?: 'paid' | 'unpaid' | 'failed';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When true, only return the authenticated user's own orders
|
||||||
|
* (even if they also have orders.read staff permission).
|
||||||
|
*/
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(({ value }) => value === true || value === 'true' || value === '1')
|
||||||
|
@IsBoolean()
|
||||||
|
selfOnly?: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
} from '@prisma/client';
|
} from '@prisma/client';
|
||||||
import { AuthUser } from '../auth/auth.types';
|
import { AuthUser } from '../auth/auth.types';
|
||||||
import { BusinessSettingsService } from '../business-settings/business-settings.service';
|
import { BusinessSettingsService } from '../business-settings/business-settings.service';
|
||||||
|
import { isSystemPaymentProcessStep } from '../business-settings/business-settings.util';
|
||||||
import { PermissionsService } from '../auth/permissions.service';
|
import { PermissionsService } from '../auth/permissions.service';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import {
|
import {
|
||||||
@@ -28,6 +29,9 @@ import {
|
|||||||
} from './dto/order.dto';
|
} from './dto/order.dto';
|
||||||
import { ShippingAddressDto } from '../cart/dto/cart.dto';
|
import { ShippingAddressDto } from '../cart/dto/cart.dto';
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
type OrderWithItems = Prisma.OrderGetPayload<{
|
type OrderWithItems = Prisma.OrderGetPayload<{
|
||||||
include: typeof ORDER_RELATIONS_INCLUDE;
|
include: typeof ORDER_RELATIONS_INCLUDE;
|
||||||
}>;
|
}>;
|
||||||
@@ -109,9 +113,9 @@ export class OrdersService {
|
|||||||
|
|
||||||
const where: Prisma.OrderWhereInput = {
|
const where: Prisma.OrderWhereInput = {
|
||||||
businessId,
|
businessId,
|
||||||
...(scope === 'customer' ? { userId: actor.id } : {}),
|
...(scope === 'customer' || query.selfOnly ? { userId: actor.id } : {}),
|
||||||
...(query.status ? { status: query.status } : {}),
|
...(query.status ? { status: query.status } : {}),
|
||||||
...(scope === 'admin' && query.customerUserId
|
...(scope === 'admin' && !query.selfOnly && query.customerUserId
|
||||||
? { userId: BigInt(query.customerUserId) }
|
? { userId: BigInt(query.customerUserId) }
|
||||||
: {}),
|
: {}),
|
||||||
...(query.orderNumber
|
...(query.orderNumber
|
||||||
@@ -146,11 +150,12 @@ export class OrdersService {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
...(scope === 'admin' && query.customerQuery?.trim()
|
...(scope === 'admin' && !query.selfOnly && query.customerQuery?.trim()
|
||||||
? {
|
? {
|
||||||
customer: this.buildCustomerSearchFilter(query.customerQuery.trim()),
|
customer: this.buildCustomerSearchFilter(query.customerQuery.trim()),
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
|
...this.paymentStatusWhere(query.paymentStatus),
|
||||||
};
|
};
|
||||||
|
|
||||||
const [items, total] = await Promise.all([
|
const [items, total] = await Promise.all([
|
||||||
@@ -376,6 +381,7 @@ export class OrdersService {
|
|||||||
source: OrderSource;
|
source: OrderSource;
|
||||||
cartItems: CartItemForOrder[];
|
cartItems: CartItemForOrder[];
|
||||||
shippingAddress: Record<string, unknown>;
|
shippingAddress: Record<string, unknown>;
|
||||||
|
shippingTotal?: number;
|
||||||
addressId: bigint | null;
|
addressId: bigint | null;
|
||||||
customerNotes: string | null;
|
customerNotes: string | null;
|
||||||
adminNotes: string | null;
|
adminNotes: string | null;
|
||||||
@@ -390,7 +396,10 @@ export class OrdersService {
|
|||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
|
||||||
const orderTotal = preparedItems.reduce((sum, item) => sum + item.lineTotal, 0);
|
const subtotal = preparedItems.reduce((sum, item) => sum + item.lineTotal, 0);
|
||||||
|
const shippingTotal = Math.max(0, Number(input.shippingTotal ?? 0));
|
||||||
|
const discountTotal = 0;
|
||||||
|
const orderTotal = subtotal + shippingTotal - discountTotal;
|
||||||
|
|
||||||
return this.createOrder({
|
return this.createOrder({
|
||||||
businessId: input.businessId,
|
businessId: input.businessId,
|
||||||
@@ -399,6 +408,8 @@ export class OrdersService {
|
|||||||
source: input.source,
|
source: input.source,
|
||||||
items: preparedItems,
|
items: preparedItems,
|
||||||
shippingAddress: input.shippingAddress,
|
shippingAddress: input.shippingAddress,
|
||||||
|
shippingTotal,
|
||||||
|
discountTotal,
|
||||||
addressId: input.addressId,
|
addressId: input.addressId,
|
||||||
customerNotes: input.customerNotes,
|
customerNotes: input.customerNotes,
|
||||||
adminNotes: input.adminNotes,
|
adminNotes: input.adminNotes,
|
||||||
@@ -419,6 +430,8 @@ export class OrdersService {
|
|||||||
source: OrderSource;
|
source: OrderSource;
|
||||||
items: PreparedOrderItem[];
|
items: PreparedOrderItem[];
|
||||||
shippingAddress: Record<string, unknown>;
|
shippingAddress: Record<string, unknown>;
|
||||||
|
shippingTotal?: number;
|
||||||
|
discountTotal?: number;
|
||||||
addressId: bigint | null;
|
addressId: bigint | null;
|
||||||
customerNotes: string | null;
|
customerNotes: string | null;
|
||||||
adminNotes: string | null;
|
adminNotes: string | null;
|
||||||
@@ -426,8 +439,8 @@ export class OrdersService {
|
|||||||
payments?: TransactionPaymentInput[];
|
payments?: TransactionPaymentInput[];
|
||||||
}) {
|
}) {
|
||||||
const subtotal = input.items.reduce((sum, item) => sum + item.lineTotal, 0);
|
const subtotal = input.items.reduce((sum, item) => sum + item.lineTotal, 0);
|
||||||
const shippingTotal = 0;
|
const shippingTotal = Math.max(0, Number(input.shippingTotal ?? 0));
|
||||||
const discountTotal = 0;
|
const discountTotal = Math.max(0, Number(input.discountTotal ?? 0));
|
||||||
const total = subtotal + shippingTotal - discountTotal;
|
const total = subtotal + shippingTotal - discountTotal;
|
||||||
const processStepId = await this.defaultProcessStepId(input.businessId);
|
const processStepId = await this.defaultProcessStepId(input.businessId);
|
||||||
|
|
||||||
@@ -750,12 +763,55 @@ export class OrdersService {
|
|||||||
return { OR: or };
|
return { OR: or };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private paymentStatusWhere(
|
||||||
|
paymentStatus: 'paid' | 'unpaid' | 'failed' | undefined,
|
||||||
|
): Prisma.OrderWhereInput {
|
||||||
|
if (paymentStatus === 'paid') {
|
||||||
|
return {
|
||||||
|
transactions: { some: { status: 'completed' } },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (paymentStatus === 'failed') {
|
||||||
|
return {
|
||||||
|
AND: [
|
||||||
|
{ transactions: { some: { status: 'failed' } } },
|
||||||
|
{ transactions: { none: { status: 'completed' } } },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (paymentStatus === 'unpaid') {
|
||||||
|
return {
|
||||||
|
transactions: { none: { status: 'completed' } },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
private derivePaymentStatus(
|
||||||
|
transactions: Array<{ status: string }>,
|
||||||
|
): 'paid' | 'unpaid' | 'failed' {
|
||||||
|
if (transactions.some((tx) => tx.status === 'completed')) return 'paid';
|
||||||
|
if (
|
||||||
|
transactions.length > 0 &&
|
||||||
|
transactions.every((tx) => tx.status === 'failed')
|
||||||
|
) {
|
||||||
|
return 'failed';
|
||||||
|
}
|
||||||
|
return 'unpaid';
|
||||||
|
}
|
||||||
|
|
||||||
private async defaultProcessStepId(businessId: bigint) {
|
private async defaultProcessStepId(businessId: bigint) {
|
||||||
const steps = await this.businessSettings.getOrderProcessSteps(businessId);
|
const steps = await this.businessSettings.getOrderProcessSteps(businessId);
|
||||||
return steps[0]?.id ?? 'processing';
|
return steps[0]?.id ?? 'processing';
|
||||||
}
|
}
|
||||||
|
|
||||||
private async assertValidProcessStepId(businessId: bigint, processStepId: string) {
|
private async assertValidProcessStepId(businessId: bigint, processStepId: string) {
|
||||||
|
if (isSystemPaymentProcessStep(processStepId)) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Payment steps are set automatically by the payment gateway and cannot be changed manually',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const steps = await this.businessSettings.getOrderProcessSteps(businessId);
|
const steps = await this.businessSettings.getOrderProcessSteps(businessId);
|
||||||
const valid = steps.some((step) => step.id === processStepId);
|
const valid = steps.some((step) => step.id === processStepId);
|
||||||
if (!valid) {
|
if (!valid) {
|
||||||
@@ -855,6 +911,7 @@ export class OrdersService {
|
|||||||
processStepLabel: processStep?.label ?? null,
|
processStepLabel: processStep?.label ?? null,
|
||||||
processStepLabelFa: processStep?.labelFa?.trim() || null,
|
processStepLabelFa: processStep?.labelFa?.trim() || null,
|
||||||
processStepColor: processStep?.color ?? null,
|
processStepColor: processStep?.color ?? null,
|
||||||
|
paymentStatus: this.derivePaymentStatus(order.transactions ?? []),
|
||||||
source: order.source,
|
source: order.source,
|
||||||
subtotal: Number(order.subtotal),
|
subtotal: Number(order.subtotal),
|
||||||
shippingTotal: Number(order.shippingTotal),
|
shippingTotal: Number(order.shippingTotal),
|
||||||
@@ -874,20 +931,27 @@ export class OrdersService {
|
|||||||
cellNumber: order.customer.cellNumber,
|
cellNumber: order.customer.cellNumber,
|
||||||
email: order.customer.email,
|
email: order.customer.email,
|
||||||
},
|
},
|
||||||
items: order.items.map((item) => ({
|
items: order.items.map((item) => {
|
||||||
id: item.id.toString(),
|
const content = isRecord(item.product.content) ? item.product.content : null
|
||||||
storeItemVariantId: item.storeItemVariantId?.toString() ?? null,
|
const productNameFa =
|
||||||
productId: item.productId.toString(),
|
content && typeof content.nameFa === 'string' ? content.nameFa.trim() : ''
|
||||||
productTitle: item.productTitle,
|
return {
|
||||||
productImage: this.resolveProductImage(item.product, galleryByProductId),
|
id: item.id.toString(),
|
||||||
variantSku: item.variantSku,
|
storeItemVariantId: item.storeItemVariantId?.toString() ?? null,
|
||||||
unitPrice: Number(item.unitPrice),
|
productId: item.productId.toString(),
|
||||||
compareAtPrice:
|
productTitle: item.productTitle,
|
||||||
item.compareAtPrice === null ? null : Number(item.compareAtPrice),
|
productNameFa: productNameFa || null,
|
||||||
quantity: item.quantity,
|
productSlug: item.product.slug ?? null,
|
||||||
lineTotal: Number(item.lineTotal),
|
productImage: this.resolveProductImage(item.product, galleryByProductId),
|
||||||
selections: item.selectionsSnapshot,
|
variantSku: item.variantSku,
|
||||||
})),
|
unitPrice: Number(item.unitPrice),
|
||||||
|
compareAtPrice:
|
||||||
|
item.compareAtPrice === null ? null : Number(item.compareAtPrice),
|
||||||
|
quantity: item.quantity,
|
||||||
|
lineTotal: Number(item.lineTotal),
|
||||||
|
selections: item.selectionsSnapshot,
|
||||||
|
}
|
||||||
|
}),
|
||||||
transactions: (order.transactions ?? []).map((transaction) =>
|
transactions: (order.transactions ?? []).map((transaction) =>
|
||||||
this.serializeTransaction(transaction),
|
this.serializeTransaction(transaction),
|
||||||
),
|
),
|
||||||
@@ -895,6 +959,18 @@ export class OrdersService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private serializeTransaction(transaction: Transaction) {
|
private serializeTransaction(transaction: Transaction) {
|
||||||
|
const meta = isRecord(transaction.gatewayMeta)
|
||||||
|
? transaction.gatewayMeta
|
||||||
|
: null;
|
||||||
|
const verify = meta && isRecord(meta.verify) ? meta.verify : null;
|
||||||
|
const cardPan =
|
||||||
|
(verify && typeof verify.cardPan === 'string' && verify.cardPan.trim()) ||
|
||||||
|
(meta && typeof meta.cardPan === 'string' && meta.cardPan.trim()) ||
|
||||||
|
null;
|
||||||
|
const completedAt =
|
||||||
|
(meta && typeof meta.completedAt === 'string' && meta.completedAt.trim()) ||
|
||||||
|
null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: transaction.id.toString(),
|
id: transaction.id.toString(),
|
||||||
type: transaction.type,
|
type: transaction.type,
|
||||||
@@ -904,6 +980,8 @@ export class OrdersService {
|
|||||||
gatewayType: transaction.gatewayType,
|
gatewayType: transaction.gatewayType,
|
||||||
gatewayRef: transaction.gatewayRef ?? null,
|
gatewayRef: transaction.gatewayRef ?? null,
|
||||||
gatewayTrackId: transaction.gatewayTrackId ?? null,
|
gatewayTrackId: transaction.gatewayTrackId ?? null,
|
||||||
|
cardPan,
|
||||||
|
paidAt: completedAt,
|
||||||
transferAccount: transaction.transferAccount,
|
transferAccount: transaction.transferAccount,
|
||||||
transferRefNumber: transaction.transferRefNumber,
|
transferRefNumber: transaction.transferRefNumber,
|
||||||
notes: transaction.notes,
|
notes: transaction.notes,
|
||||||
|
|||||||
@@ -303,6 +303,8 @@ export class PaymentsService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await this.advanceOrderAfterPayment(businessId, transaction.orderId);
|
||||||
|
|
||||||
// Keep order as pending for fulfillment; payment completion is on the transaction.
|
// Keep order as pending for fulfillment; payment completion is on the transaction.
|
||||||
if (!returnUrl) {
|
if (!returnUrl) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
@@ -392,6 +394,27 @@ export class PaymentsService {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async advanceOrderAfterPayment(
|
||||||
|
businessId: bigint,
|
||||||
|
orderId: bigint,
|
||||||
|
) {
|
||||||
|
const steps = await this.settings.getOrderProcessSteps(businessId);
|
||||||
|
const paymentSuccessful = steps.find((step) => step.id === 'payment-successful');
|
||||||
|
if (!paymentSuccessful) return;
|
||||||
|
|
||||||
|
await this.prisma.order.updateMany({
|
||||||
|
where: {
|
||||||
|
id: orderId,
|
||||||
|
businessId,
|
||||||
|
processStepId: { in: ['awaiting-payment', steps[0]?.id].filter(Boolean) as string[] },
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
processStepId: 'payment-successful',
|
||||||
|
status: 'confirmed',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private async markFailed(transactionId: bigint, meta: GatewayMeta) {
|
private async markFailed(transactionId: bigint, meta: GatewayMeta) {
|
||||||
await this.prisma.transaction.update({
|
await this.prisma.transaction.update({
|
||||||
where: { id: transactionId },
|
where: { id: transactionId },
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { IsEnum, IsOptional, IsString, MinLength, ValidateIf } from 'class-validator';
|
import { IsEnum, IsOptional, IsString, MaxLength, MinLength, ValidateIf } from 'class-validator';
|
||||||
|
|
||||||
export enum ProductAiLanguage {
|
export enum ProductAiLanguage {
|
||||||
en = 'en',
|
en = 'en',
|
||||||
@@ -16,8 +16,10 @@ export class CreateProductByAiDto {
|
|||||||
@MinLength(1)
|
@MinLength(1)
|
||||||
categoryId!: string;
|
categoryId!: string;
|
||||||
|
|
||||||
|
/** Product brief for AI: model name, optional URL, and notes. */
|
||||||
@IsString()
|
@IsString()
|
||||||
@MinLength(2)
|
@MinLength(2)
|
||||||
|
@MaxLength(4000)
|
||||||
name!: string;
|
name!: string;
|
||||||
|
|
||||||
@IsEnum(ProductAiLanguage)
|
@IsEnum(ProductAiLanguage)
|
||||||
@@ -50,3 +52,11 @@ export class FillProductFieldByAiDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
categoryId?: string;
|
categoryId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class FillProductTechnicalInfoByAiDto {
|
||||||
|
/** Editable user prompt / research brief from the dashboard modal. */
|
||||||
|
@IsString()
|
||||||
|
@MinLength(10)
|
||||||
|
@MaxLength(8000)
|
||||||
|
prompt!: string;
|
||||||
|
}
|
||||||
|
|||||||
@@ -104,6 +104,11 @@ export class CreateProductDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
featuredMediaId?: string;
|
featuredMediaId?: string;
|
||||||
|
|
||||||
|
/** Optional PDF catalog file (media id; application/pdf, max 2MB). */
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
catalogMediaId?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsArray()
|
@IsArray()
|
||||||
@IsString({ each: true })
|
@IsString({ each: true })
|
||||||
@@ -165,6 +170,11 @@ export class UpdateProductDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
featuredMediaId?: string | null;
|
featuredMediaId?: string | null;
|
||||||
|
|
||||||
|
/** Optional PDF catalog file (media id). Pass null to clear. */
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
catalogMediaId?: string | null;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsArray()
|
@IsArray()
|
||||||
@IsString({ each: true })
|
@IsString({ each: true })
|
||||||
|
|||||||
@@ -5,15 +5,21 @@ import {
|
|||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { ContentStatus, TechnicalFieldType } from '@prisma/client';
|
import { ContentStatus, TechnicalFieldType } from '@prisma/client';
|
||||||
import { AuthUser } from '../auth/auth.types';
|
import { AuthUser } from '../auth/auth.types';
|
||||||
import { CategoryTechnicalFormService } from '../categories/category-technical-form.service';
|
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { CreateProductByAiDto, FillProductFieldByAiDto, ProductAiFillField, ProductAiLanguage } from './dto/product-ai.dto';
|
import {
|
||||||
|
CreateProductByAiDto,
|
||||||
|
FillProductFieldByAiDto,
|
||||||
|
FillProductTechnicalInfoByAiDto,
|
||||||
|
ProductAiFillField,
|
||||||
|
ProductAiLanguage,
|
||||||
|
} from './dto/product-ai.dto';
|
||||||
import { ProductTechnicalInfoService } from './product-technical-info.service';
|
import { ProductTechnicalInfoService } from './product-technical-info.service';
|
||||||
import { ProductsService } from './products.service';
|
import { ProductsService } from './products.service';
|
||||||
import {
|
import {
|
||||||
requestAiJsonCompletion,
|
requestAiJsonCompletion,
|
||||||
resolveAiProvider,
|
resolveAiProvider,
|
||||||
} from '../common/ai-provider.util';
|
} from '../common/ai-provider.util';
|
||||||
|
import { loadReferencePagesFromText } from '../common/ai-reference-pages.util';
|
||||||
import { normalizeAiDescriptionHtml } from '../common/ai-html.util';
|
import { normalizeAiDescriptionHtml } from '../common/ai-html.util';
|
||||||
|
|
||||||
type TechnicalFormField = {
|
type TechnicalFormField = {
|
||||||
@@ -30,7 +36,6 @@ type AiProductDraft = {
|
|||||||
summary: string;
|
summary: string;
|
||||||
descriptionHtml: string;
|
descriptionHtml: string;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
technicalValues: Record<string, string | string[]>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -40,7 +45,6 @@ export class ProductAiService {
|
|||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly products: ProductsService,
|
private readonly products: ProductsService,
|
||||||
private readonly technicalInfo: ProductTechnicalInfoService,
|
private readonly technicalInfo: ProductTechnicalInfoService,
|
||||||
private readonly technicalFormService: CategoryTechnicalFormService,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async createFromAi(
|
async createFromAi(
|
||||||
@@ -63,16 +67,10 @@ export class ProductAiService {
|
|||||||
throw new BadRequestException('Product category not found');
|
throw new BadRequestException('Product category not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
const form = await this.technicalFormService.getFormForCategory(
|
|
||||||
businessId,
|
|
||||||
categoryId,
|
|
||||||
);
|
|
||||||
|
|
||||||
const draft = await this.generateDraft({
|
const draft = await this.generateDraft({
|
||||||
categoryName: category.name,
|
categoryName: category.name,
|
||||||
productName: dto.name.trim(),
|
productName: dto.name.trim(),
|
||||||
language: dto.language,
|
language: dto.language,
|
||||||
fields: form?.fields ?? [],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const created = await this.products.create(
|
const created = await this.products.create(
|
||||||
@@ -89,26 +87,8 @@ export class ProductAiService {
|
|||||||
actor,
|
actor,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (form && Object.keys(draft.technicalValues).length > 0) {
|
|
||||||
const values = Object.entries(draft.technicalValues)
|
|
||||||
.filter(([, value]) => {
|
|
||||||
if (Array.isArray(value)) return value.length > 0;
|
|
||||||
return value !== '';
|
|
||||||
})
|
|
||||||
.map(([fieldKey, value]) => ({ fieldKey, value }));
|
|
||||||
|
|
||||||
if (values.length > 0) {
|
|
||||||
await this.technicalInfo.replaceForProduct(
|
|
||||||
businessIdRaw,
|
|
||||||
created.product.id,
|
|
||||||
{ values },
|
|
||||||
actor,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
message: 'Product created with AI. Add images and variations next.',
|
message: 'Product created with AI. Add images, variations, and technical info next.',
|
||||||
product: created.product,
|
product: created.product,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -251,25 +231,198 @@ Aim for 2–4 short paragraphs and optionally a bullet list.`;
|
|||||||
return { field: ProductAiFillField.description, html };
|
return { field: ProductAiFillField.description, html };
|
||||||
}
|
}
|
||||||
|
|
||||||
private async generateDraft(input: {
|
/**
|
||||||
categoryName: string;
|
* Suggest technical form values for an existing product. Does not persist —
|
||||||
productName: string;
|
* the dashboard applies the map and the user saves.
|
||||||
language: ProductAiLanguage;
|
*/
|
||||||
fields: TechnicalFormField[];
|
async fillTechnicalInfo(
|
||||||
}): Promise<AiProductDraft> {
|
businessIdRaw: string,
|
||||||
const provider = resolveAiProvider(this.config);
|
productIdRaw: string,
|
||||||
|
dto: FillProductTechnicalInfoByAiDto,
|
||||||
|
actor: AuthUser,
|
||||||
|
) {
|
||||||
|
const businessId = BigInt(businessIdRaw);
|
||||||
|
const productId = BigInt(productIdRaw);
|
||||||
|
const prompt = dto.prompt.trim();
|
||||||
|
|
||||||
const technicalSchema = input.fields.map((field) => ({
|
const info = await this.technicalInfo.getForProduct(
|
||||||
|
businessIdRaw,
|
||||||
|
productIdRaw,
|
||||||
|
actor,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!info.form || info.form.fields.length === 0) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'No technical form is defined for this product category',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const product = await this.prisma.product.findFirst({
|
||||||
|
where: { id: productId, businessId },
|
||||||
|
select: { title: true, content: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!product) {
|
||||||
|
throw new BadRequestException('Product not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const productContent =
|
||||||
|
product.content && typeof product.content === 'object' && !Array.isArray(product.content)
|
||||||
|
? (product.content as Record<string, unknown>)
|
||||||
|
: {};
|
||||||
|
const nameFa = String(productContent.nameFa ?? '').trim();
|
||||||
|
|
||||||
|
const fields: TechnicalFormField[] = info.form.fields.map((field) => ({
|
||||||
|
key: field.key,
|
||||||
|
label: field.label,
|
||||||
|
type: field.type,
|
||||||
|
isRequired: field.isRequired,
|
||||||
|
options: field.options.map((option) => ({
|
||||||
|
value: option.value,
|
||||||
|
label: option.label,
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const technicalSchema = fields.map((field) => ({
|
||||||
key: field.key,
|
key: field.key,
|
||||||
label: field.label,
|
label: field.label,
|
||||||
type: field.type,
|
type: field.type,
|
||||||
required: field.isRequired,
|
required: field.isRequired,
|
||||||
options:
|
options:
|
||||||
field.type === 'select' || field.type === 'multi_select'
|
field.type === 'select' || field.type === 'multi_select'
|
||||||
? field.options.map((option) => option.value)
|
? field.options.map((option) => ({
|
||||||
|
value: option.value,
|
||||||
|
label: option.label,
|
||||||
|
}))
|
||||||
: undefined,
|
: undefined,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const provider = resolveAiProvider(this.config);
|
||||||
|
const referencePages = await loadReferencePagesFromText(prompt);
|
||||||
|
const usableReferences = referencePages.filter((page) => page.text);
|
||||||
|
|
||||||
|
const systemPrompt = `You fill e-commerce product technical specification forms for Iranian stores.
|
||||||
|
Return ONLY valid JSON with this shape:
|
||||||
|
{ "technicalValues": { "fieldKey": "value" } }
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- Use exact field keys from the provided technicalFields list.
|
||||||
|
- For select fields use one allowed option "value" exactly (not the label).
|
||||||
|
- For multi_select fields use an array of allowed option values.
|
||||||
|
- For multi_value fields use an array of free-text strings.
|
||||||
|
- If a multi_value field is labeled «سایر ویژگیها» (or similar “other features”), fill it with any remaining real product attributes/features that do not fit the other dedicated fields — one short string per leftover item (e.g. extra capabilities, included accessories, standards, certifications, special notes from the product page). Do not duplicate values already placed in other fields.
|
||||||
|
- For text/textarea fields use plain strings in the same language as the field labels / user prompt (usually Persian).
|
||||||
|
- When referencePages are provided, treat them as the source of truth. Copy specs from that page text (capacity, power, dimensions, weight, materials, programs, etc.).
|
||||||
|
- Convert units carefully (e.g. mm → cm for dimension fields labeled in cm: 279x300x464 mm → 27.9x30x46.4 cm).
|
||||||
|
- Prefer the product-specific specs near the main product title; ignore unrelated related-products / upsell tables if they conflict.
|
||||||
|
- Do NOT invent or guess specs from memory when a reference page was provided.
|
||||||
|
- Leave a field out if the reference does not contain that value.
|
||||||
|
- Follow the user's research brief / instructions when provided.`;
|
||||||
|
|
||||||
|
const userPrompt = JSON.stringify({
|
||||||
|
instructions: prompt,
|
||||||
|
product: {
|
||||||
|
title: product.title,
|
||||||
|
nameFa: nameFa || null,
|
||||||
|
},
|
||||||
|
technicalFields: technicalSchema,
|
||||||
|
referencePages: usableReferences.map((page) => ({
|
||||||
|
url: page.url,
|
||||||
|
text: page.text,
|
||||||
|
})),
|
||||||
|
referenceFetchErrors: referencePages
|
||||||
|
.filter((page) => !page.text)
|
||||||
|
.map((page) => ({ url: page.url, error: page.error ?? 'unknown' })),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (referencePages.length > 0 && usableReferences.length === 0) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Could not load the product page URL from the prompt. Check the link and try again.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let content: string;
|
||||||
|
try {
|
||||||
|
content = await requestAiJsonCompletion(provider, systemPrompt, userPrompt, 0.2);
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : 'AI request failed';
|
||||||
|
throw new BadRequestException(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed: { technicalValues?: Record<string, unknown> };
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(content) as { technicalValues?: Record<string, unknown> };
|
||||||
|
} catch {
|
||||||
|
throw new BadRequestException('AI returned invalid JSON');
|
||||||
|
}
|
||||||
|
|
||||||
|
const technicalValues = this.normalizeTechnicalValues(
|
||||||
|
parsed.technicalValues ?? {},
|
||||||
|
fields,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (Object.keys(technicalValues).length === 0) {
|
||||||
|
throw new BadRequestException('AI returned no usable technical values');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { values: technicalValues };
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeTechnicalValues(
|
||||||
|
rawTechnical: Record<string, unknown>,
|
||||||
|
fields: TechnicalFormField[],
|
||||||
|
): Record<string, string | string[]> {
|
||||||
|
const technicalValues: Record<string, string | string[]> = {};
|
||||||
|
|
||||||
|
for (const field of fields) {
|
||||||
|
const raw = rawTechnical[field.key];
|
||||||
|
if (raw === undefined || raw === null || raw === '') continue;
|
||||||
|
|
||||||
|
if (field.type === 'multi_select') {
|
||||||
|
const values = Array.isArray(raw) ? raw : [String(raw)];
|
||||||
|
const allowed = new Set(field.options.map((option) => option.value));
|
||||||
|
const filtered = values
|
||||||
|
.map((value) => String(value).trim())
|
||||||
|
.filter((value) => allowed.has(value));
|
||||||
|
if (filtered.length > 0) {
|
||||||
|
technicalValues[field.key] = filtered;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field.type === 'multi_value') {
|
||||||
|
const values = Array.isArray(raw) ? raw : [String(raw)];
|
||||||
|
const filtered = values.map((value) => String(value).trim()).filter(Boolean);
|
||||||
|
if (filtered.length > 0) {
|
||||||
|
technicalValues[field.key] = filtered;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field.type === 'select') {
|
||||||
|
const value = String(raw).trim();
|
||||||
|
const allowed = field.options.some((option) => option.value === value);
|
||||||
|
if (allowed) {
|
||||||
|
technicalValues[field.key] = value;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
technicalValues[field.key] = String(raw).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
return technicalValues;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async generateDraft(input: {
|
||||||
|
categoryName: string;
|
||||||
|
productName: string;
|
||||||
|
language: ProductAiLanguage;
|
||||||
|
}): Promise<AiProductDraft> {
|
||||||
|
const provider = resolveAiProvider(this.config);
|
||||||
|
const referencePages = await loadReferencePagesFromText(input.productName);
|
||||||
|
const usableReferences = referencePages.filter((page) => page.text);
|
||||||
|
|
||||||
const inputLanguage =
|
const inputLanguage =
|
||||||
input.language === ProductAiLanguage.fa ? 'Persian (Farsi)' : 'English';
|
input.language === ProductAiLanguage.fa ? 'Persian (Farsi)' : 'English';
|
||||||
|
|
||||||
@@ -280,30 +433,38 @@ Return ONLY valid JSON with this shape:
|
|||||||
"nameFa": "Persian product name",
|
"nameFa": "Persian product name",
|
||||||
"summary": "short listing summary",
|
"summary": "short listing summary",
|
||||||
"descriptionHtml": "HTML description using <p> and <ul><li> only",
|
"descriptionHtml": "HTML description using <p> and <ul><li> only",
|
||||||
"tags": ["tag1", "tag2"],
|
"tags": ["tag1", "tag2"]
|
||||||
"technicalValues": { "fieldKey": "value" }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Rules:
|
Rules:
|
||||||
- Be factual and realistic for the named product in the given category.
|
- The user input is a research brief in ${inputLanguage}. It may include a model/product name, a product page URL, and extra notes — not a final storefront title.
|
||||||
- If technical form fields are provided, fill technicalValues using exact field keys.
|
- Infer the real product from the brief (and any URL/model clues). Do not copy URLs, markdown, or raw notes into title/nameFa.
|
||||||
- For select fields use one allowed option value exactly.
|
- Write clean storefront titles: "title" in English, "nameFa" in Persian. Keep brand/model accurate.
|
||||||
- For multi_select fields use an array of allowed option values.
|
- When referencePages are provided, treat them as the source of truth for capacity, power, dimensions, and description facts.
|
||||||
- For text/textarea fields use plain strings.
|
- Prefer the main product specs near the product title; ignore conflicting related-product / upsell blocks.
|
||||||
- The user input name is in ${inputLanguage}; keep that spelling in the matching field and translate/generate the other language field.
|
- Do NOT invent specs from memory when a reference page was provided.
|
||||||
|
- Be factual and realistic for the identified product in the given category.
|
||||||
|
- Do NOT return technical form fields — product content only (title, names, summary, description, tags).
|
||||||
|
- Prefer the input language for summary/description tone, but always return both title and nameFa.
|
||||||
- descriptionHtml should be 2-4 paragraphs with key specs.
|
- descriptionHtml should be 2-4 paragraphs with key specs.
|
||||||
- tags: 3-8 relevant lowercase tags.`;
|
- tags: 3-8 relevant lowercase tags.`;
|
||||||
|
|
||||||
const userPrompt = JSON.stringify({
|
const userPrompt = JSON.stringify({
|
||||||
category: input.categoryName,
|
category: input.categoryName,
|
||||||
inputName: input.productName,
|
researchBrief: input.productName,
|
||||||
inputLanguage: input.language,
|
inputLanguage: input.language,
|
||||||
technicalFields: technicalSchema,
|
referencePages: usableReferences.map((page) => ({
|
||||||
|
url: page.url,
|
||||||
|
text: page.text,
|
||||||
|
})),
|
||||||
|
referenceFetchErrors: referencePages
|
||||||
|
.filter((page) => !page.text)
|
||||||
|
.map((page) => ({ url: page.url, error: page.error ?? 'unknown' })),
|
||||||
});
|
});
|
||||||
|
|
||||||
let content: string;
|
let content: string;
|
||||||
try {
|
try {
|
||||||
content = await requestAiJsonCompletion(provider, systemPrompt, userPrompt);
|
content = await requestAiJsonCompletion(provider, systemPrompt, userPrompt, 0.3);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : 'AI request failed';
|
const message = err instanceof Error ? err.message : 'AI request failed';
|
||||||
throw new BadRequestException(message);
|
throw new BadRequestException(message);
|
||||||
@@ -324,18 +485,11 @@ Rules:
|
|||||||
input: {
|
input: {
|
||||||
productName: string;
|
productName: string;
|
||||||
language: ProductAiLanguage;
|
language: ProductAiLanguage;
|
||||||
fields: TechnicalFormField[];
|
|
||||||
},
|
},
|
||||||
): AiProductDraft {
|
): AiProductDraft {
|
||||||
const title =
|
const fallbackName = this.shortProductLabel(input.productName);
|
||||||
input.language === ProductAiLanguage.en
|
const title = String(draft.title ?? '').trim() || fallbackName;
|
||||||
? input.productName
|
const nameFa = String(draft.nameFa ?? '').trim() || fallbackName;
|
||||||
: String(draft.title ?? input.productName).trim();
|
|
||||||
|
|
||||||
const nameFa =
|
|
||||||
input.language === ProductAiLanguage.fa
|
|
||||||
? input.productName
|
|
||||||
: String(draft.nameFa ?? '').trim();
|
|
||||||
|
|
||||||
const summary = String(draft.summary ?? '').trim();
|
const summary = String(draft.summary ?? '').trim();
|
||||||
const descriptionHtml = normalizeAiDescriptionHtml(
|
const descriptionHtml = normalizeAiDescriptionHtml(
|
||||||
@@ -353,37 +507,6 @@ Rules:
|
|||||||
throw new BadRequestException('AI draft is missing a Persian product name');
|
throw new BadRequestException('AI draft is missing a Persian product name');
|
||||||
}
|
}
|
||||||
|
|
||||||
const technicalValues: Record<string, string | string[]> = {};
|
|
||||||
const rawTechnical = draft.technicalValues ?? {};
|
|
||||||
|
|
||||||
for (const field of input.fields) {
|
|
||||||
const raw = rawTechnical[field.key];
|
|
||||||
if (raw === undefined || raw === null || raw === '') continue;
|
|
||||||
|
|
||||||
if (field.type === 'multi_select') {
|
|
||||||
const values = Array.isArray(raw) ? raw : [String(raw)];
|
|
||||||
const allowed = new Set(field.options.map((option) => option.value));
|
|
||||||
const filtered = values
|
|
||||||
.map((value) => String(value).trim())
|
|
||||||
.filter((value) => allowed.has(value));
|
|
||||||
if (filtered.length > 0) {
|
|
||||||
technicalValues[field.key] = filtered;
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (field.type === 'select') {
|
|
||||||
const value = String(raw).trim();
|
|
||||||
const allowed = field.options.some((option) => option.value === value);
|
|
||||||
if (allowed) {
|
|
||||||
technicalValues[field.key] = value;
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
technicalValues[field.key] = String(raw).trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title,
|
title,
|
||||||
nameFa,
|
nameFa,
|
||||||
@@ -392,7 +515,16 @@ Rules:
|
|||||||
descriptionHtml ||
|
descriptionHtml ||
|
||||||
`<p>${summary || `${title} product description.`}</p>`,
|
`<p>${summary || `${title} product description.`}</p>`,
|
||||||
tags,
|
tags,
|
||||||
technicalValues,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** First non-URL line of the research brief, truncated for fallback titles. */
|
||||||
|
private shortProductLabel(brief: string): string {
|
||||||
|
const line =
|
||||||
|
brief
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((part) => part.trim())
|
||||||
|
.find((part) => part && !/^https?:\/\//i.test(part)) ?? brief.trim();
|
||||||
|
return line.slice(0, 120).trim();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ import { PermissionsService } from '../auth/permissions.service';
|
|||||||
import { CategoryTechnicalFormService } from '../categories/category-technical-form.service';
|
import { CategoryTechnicalFormService } from '../categories/category-technical-form.service';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { ReplaceProductTechnicalInfoDto } from './dto/product-technical-info.dto';
|
import { ReplaceProductTechnicalInfoDto } from './dto/product-technical-info.dto';
|
||||||
|
import {
|
||||||
|
decodeMultiValueText,
|
||||||
|
encodeMultiValueText,
|
||||||
|
} from '../common/technical-multi-value.util';
|
||||||
|
|
||||||
type FormField = {
|
type FormField = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -159,6 +163,22 @@ export class ProductTechnicalInfoService {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (field.type === 'multi_value') {
|
||||||
|
const items = Array.isArray(entry.value)
|
||||||
|
? entry.value.map((item) => String(item).trim()).filter(Boolean)
|
||||||
|
: [];
|
||||||
|
if (!items.length) continue;
|
||||||
|
await tx.productTechnicalFieldValue.create({
|
||||||
|
data: {
|
||||||
|
businessId,
|
||||||
|
productId: product.id,
|
||||||
|
fieldId,
|
||||||
|
textValue: encodeMultiValueText(items),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (field.type === 'text' || field.type === 'textarea') {
|
if (field.type === 'text' || field.type === 'textarea') {
|
||||||
await tx.productTechnicalFieldValue.create({
|
await tx.productTechnicalFieldValue.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -185,6 +205,9 @@ export class ProductTechnicalInfoService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const selectedValues = entry.value as string[];
|
const selectedValues = entry.value as string[];
|
||||||
|
if (!Array.isArray(selectedValues) || !selectedValues.length) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const fieldValue = await tx.productTechnicalFieldValue.create({
|
const fieldValue = await tx.productTechnicalFieldValue.create({
|
||||||
data: {
|
data: {
|
||||||
businessId,
|
businessId,
|
||||||
@@ -230,6 +253,24 @@ export class ProductTechnicalInfoService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (field.type === 'multi_value') {
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Field "${field.label}" expects an array of text values`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const items = value.map((item) => String(item).trim()).filter(Boolean);
|
||||||
|
if (!items.length) {
|
||||||
|
if (field.isRequired) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Required field "${field.label}" cannot be empty`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (field.type === 'select') {
|
if (field.type === 'select') {
|
||||||
if (typeof value !== 'string') {
|
if (typeof value !== 'string') {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
@@ -303,7 +344,17 @@ export class ProductTechnicalInfoService {
|
|||||||
fieldKey: field.key,
|
fieldKey: field.key,
|
||||||
fieldLabel: field.label,
|
fieldLabel: field.label,
|
||||||
type: field.type,
|
type: field.type,
|
||||||
value: field.type === 'multi_select' ? [] : null,
|
value:
|
||||||
|
field.type === 'multi_select' || field.type === 'multi_value' ? [] : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field.type === 'multi_value') {
|
||||||
|
return {
|
||||||
|
fieldKey: field.key,
|
||||||
|
fieldLabel: field.label,
|
||||||
|
type: field.type,
|
||||||
|
value: decodeMultiValueText(record.textValue),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,11 @@ import { RequireBusinessPermission } from '../auth/decorators/require-business-p
|
|||||||
import { BusinessPermissionGuard } from '../auth/guards/business-permission.guard';
|
import { BusinessPermissionGuard } from '../auth/guards/business-permission.guard';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
import { WebsiteAnalyticsService } from '../website-analytics/website-analytics.service';
|
import { WebsiteAnalyticsService } from '../website-analytics/website-analytics.service';
|
||||||
import { CreateProductByAiDto, FillProductFieldByAiDto } from './dto/product-ai.dto';
|
import {
|
||||||
|
CreateProductByAiDto,
|
||||||
|
FillProductFieldByAiDto,
|
||||||
|
FillProductTechnicalInfoByAiDto,
|
||||||
|
} from './dto/product-ai.dto';
|
||||||
import { TranslationEntityType } from '@prisma/client';
|
import { TranslationEntityType } from '@prisma/client';
|
||||||
import { UpsertTranslationDto, TranslateAllDto } from '../translations/dto/translation.dto';
|
import { UpsertTranslationDto, TranslateAllDto } from '../translations/dto/translation.dto';
|
||||||
import { TranslationsService } from '../translations/translations.service';
|
import { TranslationsService } from '../translations/translations.service';
|
||||||
@@ -208,6 +212,17 @@ export class ProductsController {
|
|||||||
return this.technicalInfoService.getForProduct(businessId, productId, user);
|
return this.technicalInfoService.getForProduct(businessId, productId, user);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post(':productId/technical-info/ai-fill')
|
||||||
|
@RequireBusinessPermission('products.update')
|
||||||
|
fillTechnicalInfoByAi(
|
||||||
|
@Param('businessId') businessId: string,
|
||||||
|
@Param('productId') productId: string,
|
||||||
|
@Body() dto: FillProductTechnicalInfoByAiDto,
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
) {
|
||||||
|
return this.aiService.fillTechnicalInfo(businessId, productId, dto, user);
|
||||||
|
}
|
||||||
|
|
||||||
@Put(':productId/technical-info')
|
@Put(':productId/technical-info')
|
||||||
@RequireBusinessPermission('products.update')
|
@RequireBusinessPermission('products.update')
|
||||||
replaceTechnicalInfo(
|
replaceTechnicalInfo(
|
||||||
|
|||||||
@@ -43,12 +43,14 @@ function slugify(value: string): string {
|
|||||||
type ProductWithRelations = Prisma.ProductGetPayload<{
|
type ProductWithRelations = Prisma.ProductGetPayload<{
|
||||||
include: {
|
include: {
|
||||||
featuredMedia: true;
|
featuredMedia: true;
|
||||||
|
catalogMedia: true;
|
||||||
brand: { include: { imageMedia: true } };
|
brand: { include: { imageMedia: true } };
|
||||||
};
|
};
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
const productListInclude = {
|
const productListInclude = {
|
||||||
featuredMedia: true,
|
featuredMedia: true,
|
||||||
|
catalogMedia: true,
|
||||||
brand: { include: { imageMedia: true } },
|
brand: { include: { imageMedia: true } },
|
||||||
} satisfies Prisma.ProductInclude;
|
} satisfies Prisma.ProductInclude;
|
||||||
|
|
||||||
@@ -105,10 +107,7 @@ export class ProductsService {
|
|||||||
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'desc' }],
|
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'desc' }],
|
||||||
skip,
|
skip,
|
||||||
take: pageSize,
|
take: pageSize,
|
||||||
include: {
|
include: productListInclude,
|
||||||
featuredMedia: true,
|
|
||||||
brand: { include: { imageMedia: true } },
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
this.prisma.product.count({ where }),
|
this.prisma.product.count({ where }),
|
||||||
]);
|
]);
|
||||||
@@ -140,10 +139,7 @@ export class ProductsService {
|
|||||||
|
|
||||||
const product = await this.prisma.product.findFirst({
|
const product = await this.prisma.product.findFirst({
|
||||||
where: { id: productId, businessId },
|
where: { id: productId, businessId },
|
||||||
include: {
|
include: productListInclude,
|
||||||
featuredMedia: true,
|
|
||||||
brand: { include: { imageMedia: true } },
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!product) {
|
if (!product) {
|
||||||
@@ -168,10 +164,7 @@ export class ProductsService {
|
|||||||
orderBy: [{ sortOrder: 'asc' }, { publishedAt: 'desc' }, { createdAt: 'desc' }],
|
orderBy: [{ sortOrder: 'asc' }, { publishedAt: 'desc' }, { createdAt: 'desc' }],
|
||||||
skip,
|
skip,
|
||||||
take: pageSize,
|
take: pageSize,
|
||||||
include: {
|
include: productListInclude,
|
||||||
featuredMedia: true,
|
|
||||||
brand: { include: { imageMedia: true } },
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
this.prisma.product.count({ where }),
|
this.prisma.product.count({ where }),
|
||||||
]);
|
]);
|
||||||
@@ -338,6 +331,13 @@ export class ProductsService {
|
|||||||
await this.assertMediaBelongsToBusiness(businessId, featuredMediaId);
|
await this.assertMediaBelongsToBusiness(businessId, featuredMediaId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const catalogMediaId = dto.catalogMediaId
|
||||||
|
? BigInt(dto.catalogMediaId)
|
||||||
|
: null;
|
||||||
|
if (catalogMediaId) {
|
||||||
|
await this.assertCatalogPdfBelongsToBusiness(businessId, catalogMediaId);
|
||||||
|
}
|
||||||
|
|
||||||
const galleryMediaIds = await this.resolveGalleryMediaIds(
|
const galleryMediaIds = await this.resolveGalleryMediaIds(
|
||||||
businessId,
|
businessId,
|
||||||
dto.galleryMediaIds ?? [],
|
dto.galleryMediaIds ?? [],
|
||||||
@@ -363,6 +363,7 @@ export class ProductsService {
|
|||||||
content: this.buildContent(dto.nameFa, dto.descriptionHtml),
|
content: this.buildContent(dto.nameFa, dto.descriptionHtml),
|
||||||
status,
|
status,
|
||||||
featuredMediaId,
|
featuredMediaId,
|
||||||
|
catalogMediaId,
|
||||||
brandId,
|
brandId,
|
||||||
publishedAt: status === ContentStatus.published ? new Date() : null,
|
publishedAt: status === ContentStatus.published ? new Date() : null,
|
||||||
metadata: applySeoMetaToMetadata(
|
metadata: applySeoMetaToMetadata(
|
||||||
@@ -370,10 +371,7 @@ export class ProductsService {
|
|||||||
normalizeSeoMetaInput(dto.seoMetaTitle, dto.seoMetaDescription),
|
normalizeSeoMetaInput(dto.seoMetaTitle, dto.seoMetaDescription),
|
||||||
) as Prisma.InputJsonValue,
|
) as Prisma.InputJsonValue,
|
||||||
},
|
},
|
||||||
include: {
|
include: productListInclude,
|
||||||
featuredMedia: true,
|
|
||||||
brand: { include: { imageMedia: true } },
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (dto.categoryId) {
|
if (dto.categoryId) {
|
||||||
@@ -440,6 +438,16 @@ export class ProductsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let catalogMediaId: bigint | null | undefined = undefined;
|
||||||
|
if (dto.catalogMediaId !== undefined) {
|
||||||
|
if (dto.catalogMediaId === null || dto.catalogMediaId === '') {
|
||||||
|
catalogMediaId = null;
|
||||||
|
} else {
|
||||||
|
catalogMediaId = BigInt(dto.catalogMediaId);
|
||||||
|
await this.assertCatalogPdfBelongsToBusiness(businessId, catalogMediaId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const existingContent = this.asRecord(existing.content);
|
const existingContent = this.asRecord(existing.content);
|
||||||
const existingMetadata = this.asRecord(existing.metadata);
|
const existingMetadata = this.asRecord(existing.metadata);
|
||||||
|
|
||||||
@@ -496,16 +504,14 @@ export class ProductsService {
|
|||||||
: {}),
|
: {}),
|
||||||
...(dto.status !== undefined ? { status: dto.status } : {}),
|
...(dto.status !== undefined ? { status: dto.status } : {}),
|
||||||
...(featuredMediaId !== undefined ? { featuredMediaId } : {}),
|
...(featuredMediaId !== undefined ? { featuredMediaId } : {}),
|
||||||
|
...(catalogMediaId !== undefined ? { catalogMediaId } : {}),
|
||||||
...(brandId !== undefined ? { brandId } : {}),
|
...(brandId !== undefined ? { brandId } : {}),
|
||||||
...(publishedAt !== undefined ? { publishedAt } : {}),
|
...(publishedAt !== undefined ? { publishedAt } : {}),
|
||||||
slug,
|
slug,
|
||||||
content: nextContent as Prisma.InputJsonValue,
|
content: nextContent as Prisma.InputJsonValue,
|
||||||
metadata: nextMetadata as Prisma.InputJsonValue,
|
metadata: nextMetadata as Prisma.InputJsonValue,
|
||||||
},
|
},
|
||||||
include: {
|
include: productListInclude,
|
||||||
featuredMedia: true,
|
|
||||||
brand: { include: { imageMedia: true } },
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (dto.categoryId !== undefined) {
|
if (dto.categoryId !== undefined) {
|
||||||
@@ -563,6 +569,13 @@ export class ProductsService {
|
|||||||
) {
|
) {
|
||||||
dropped.push(existing.featuredMediaId);
|
dropped.push(existing.featuredMediaId);
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
catalogMediaId !== undefined &&
|
||||||
|
existing.catalogMediaId != null &&
|
||||||
|
existing.catalogMediaId !== catalogMediaId
|
||||||
|
) {
|
||||||
|
dropped.push(existing.catalogMediaId);
|
||||||
|
}
|
||||||
await this.mediaService.deleteUnused(businessId, dropped);
|
await this.mediaService.deleteUnused(businessId, dropped);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -590,6 +603,9 @@ export class ProductsService {
|
|||||||
productId,
|
productId,
|
||||||
existing.featuredMediaId,
|
existing.featuredMediaId,
|
||||||
);
|
);
|
||||||
|
if (existing.catalogMediaId != null) {
|
||||||
|
mediaIds.push(existing.catalogMediaId);
|
||||||
|
}
|
||||||
|
|
||||||
await this.prisma.$transaction([
|
await this.prisma.$transaction([
|
||||||
this.prisma.mediaAttachment.deleteMany({
|
this.prisma.mediaAttachment.deleteMany({
|
||||||
@@ -718,6 +734,9 @@ export class ProductsService {
|
|||||||
...readSeoMetaFromMetadata(metadata),
|
...readSeoMetaFromMetadata(metadata),
|
||||||
thumbnailUrl,
|
thumbnailUrl,
|
||||||
thumbnailMediaId: product.featuredMediaId?.toString() ?? null,
|
thumbnailMediaId: product.featuredMediaId?.toString() ?? null,
|
||||||
|
catalogMediaId: product.catalogMediaId?.toString() ?? null,
|
||||||
|
catalogUrl: product.catalogMedia?.publicUrl ?? null,
|
||||||
|
catalogFileName: product.catalogMedia?.originalFileName ?? null,
|
||||||
image,
|
image,
|
||||||
thumbnail: thumbnailUrl ?? image,
|
thumbnail: thumbnailUrl ?? image,
|
||||||
images: galleryAttachments.map((item) => ({
|
images: galleryAttachments.map((item) => ({
|
||||||
@@ -827,6 +846,24 @@ export class ProductsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async assertCatalogPdfBelongsToBusiness(
|
||||||
|
businessId: bigint,
|
||||||
|
mediaId: bigint,
|
||||||
|
) {
|
||||||
|
const media = await this.prisma.media.findFirst({
|
||||||
|
where: { id: mediaId, businessId },
|
||||||
|
});
|
||||||
|
if (!media) {
|
||||||
|
throw new BadRequestException('Catalog file not found for this business');
|
||||||
|
}
|
||||||
|
if (media.mimeType !== 'application/pdf') {
|
||||||
|
throw new BadRequestException('Catalog file must be a PDF');
|
||||||
|
}
|
||||||
|
if (media.fileSizeBytes > BigInt(2 * 1024 * 1024)) {
|
||||||
|
throw new BadRequestException('Catalog PDF must be 2MB or smaller');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async assertCategoryBelongsToBusiness(
|
private async assertCategoryBelongsToBusiness(
|
||||||
businessId: bigint,
|
businessId: bigint,
|
||||||
categoryId: bigint,
|
categoryId: bigint,
|
||||||
|
|||||||
@@ -70,6 +70,136 @@ export class RedisService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signup in progress: name + tenant stored until OTP proves phone ownership.
|
||||||
|
*/
|
||||||
|
async setPendingSignup(
|
||||||
|
cellNumber: string,
|
||||||
|
payload: {
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
businessId: string;
|
||||||
|
},
|
||||||
|
ttlSeconds: number,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.redis.set(
|
||||||
|
`auth:pending-signup:${cellNumber}`,
|
||||||
|
JSON.stringify(payload),
|
||||||
|
'EX',
|
||||||
|
ttlSeconds,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPendingSignup(cellNumber: string): Promise<{
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
businessId: string;
|
||||||
|
} | null> {
|
||||||
|
const raw = await this.redis.get(`auth:pending-signup:${cellNumber}`);
|
||||||
|
if (!raw) return null;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw) as {
|
||||||
|
firstName?: unknown;
|
||||||
|
lastName?: unknown;
|
||||||
|
businessId?: unknown;
|
||||||
|
};
|
||||||
|
if (
|
||||||
|
typeof parsed.firstName !== 'string' ||
|
||||||
|
typeof parsed.lastName !== 'string' ||
|
||||||
|
typeof parsed.businessId !== 'string' ||
|
||||||
|
!parsed.firstName ||
|
||||||
|
!parsed.lastName ||
|
||||||
|
!parsed.businessId
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
firstName: parsed.firstName,
|
||||||
|
lastName: parsed.lastName,
|
||||||
|
businessId: parsed.businessId,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deletePendingSignup(cellNumber: string): Promise<void> {
|
||||||
|
await this.redis.del(`auth:pending-signup:${cellNumber}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One-time token after signup OTP: complete account create/link without re-OTP.
|
||||||
|
*/
|
||||||
|
async setSignupVerifiedToken(
|
||||||
|
token: string,
|
||||||
|
payload: {
|
||||||
|
cellNumber: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
businessId: string;
|
||||||
|
existingUserId: string | null;
|
||||||
|
},
|
||||||
|
ttlSeconds: number,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.redis.set(
|
||||||
|
`auth:signup-verified:${token}`,
|
||||||
|
JSON.stringify(payload),
|
||||||
|
'EX',
|
||||||
|
ttlSeconds,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async consumeSignupVerifiedToken(token: string): Promise<{
|
||||||
|
cellNumber: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
businessId: string;
|
||||||
|
existingUserId: string | null;
|
||||||
|
} | null> {
|
||||||
|
const key = `auth:signup-verified:${token}`;
|
||||||
|
const result = await this.redis.multi().get(key).del(key).exec();
|
||||||
|
const raw = result?.[0]?.[1];
|
||||||
|
if (typeof raw !== 'string' || !raw) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw) as {
|
||||||
|
cellNumber?: unknown;
|
||||||
|
firstName?: unknown;
|
||||||
|
lastName?: unknown;
|
||||||
|
businessId?: unknown;
|
||||||
|
existingUserId?: unknown;
|
||||||
|
};
|
||||||
|
if (
|
||||||
|
typeof parsed.cellNumber !== 'string' ||
|
||||||
|
typeof parsed.firstName !== 'string' ||
|
||||||
|
typeof parsed.lastName !== 'string' ||
|
||||||
|
typeof parsed.businessId !== 'string' ||
|
||||||
|
!parsed.cellNumber ||
|
||||||
|
!parsed.firstName ||
|
||||||
|
!parsed.lastName ||
|
||||||
|
!parsed.businessId
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const existingUserId =
|
||||||
|
parsed.existingUserId === null || parsed.existingUserId === undefined
|
||||||
|
? null
|
||||||
|
: typeof parsed.existingUserId === 'string'
|
||||||
|
? parsed.existingUserId
|
||||||
|
: null;
|
||||||
|
return {
|
||||||
|
cellNumber: parsed.cellNumber,
|
||||||
|
firstName: parsed.firstName,
|
||||||
|
lastName: parsed.lastName,
|
||||||
|
businessId: parsed.businessId,
|
||||||
|
existingUserId,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async setHandoffTicket(
|
async setHandoffTicket(
|
||||||
ticket: string,
|
ticket: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import {
|
||||||
|
ArrayMaxSize,
|
||||||
|
IsArray,
|
||||||
|
IsDateString,
|
||||||
|
IsInt,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
Matches,
|
||||||
|
MaxLength,
|
||||||
|
Min,
|
||||||
|
MinLength,
|
||||||
|
ValidateNested,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
const TIME_HH_MM = /^([01]\d|2[0-3]):[0-5]\d$/;
|
||||||
|
|
||||||
|
export class ListRollCallsDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
dateFrom?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
dateTo?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
page?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
pageSize?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RollCallTaskInputDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(500)
|
||||||
|
title!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CreateRollCallDto {
|
||||||
|
@IsDateString()
|
||||||
|
workDate!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@Matches(TIME_HH_MM, { message: 'startTime must be HH:mm' })
|
||||||
|
startTime!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@Matches(TIME_HH_MM, { message: 'endTime must be HH:mm' })
|
||||||
|
endTime!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@ArrayMaxSize(100)
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => RollCallTaskInputDto)
|
||||||
|
tasks?: RollCallTaskInputDto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateRollCallDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
workDate?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@Matches(TIME_HH_MM, { message: 'startTime must be HH:mm' })
|
||||||
|
startTime?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@Matches(TIME_HH_MM, { message: 'endTime must be HH:mm' })
|
||||||
|
endTime?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@ArrayMaxSize(100)
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => RollCallTaskInputDto)
|
||||||
|
tasks?: RollCallTaskInputDto[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { AuthUser } from '../auth/auth.types';
|
||||||
|
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||||
|
import { RequireBusinessPermission } from '../auth/decorators/require-business-permission.decorator';
|
||||||
|
import { BusinessPermissionGuard } from '../auth/guards/business-permission.guard';
|
||||||
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
|
import {
|
||||||
|
CreateRollCallDto,
|
||||||
|
ListRollCallsDto,
|
||||||
|
UpdateRollCallDto,
|
||||||
|
} from './dto/roll-call.dto';
|
||||||
|
import { RollCallsService } from './roll-calls.service';
|
||||||
|
|
||||||
|
@Controller('businesses/:businessId/roll-calls')
|
||||||
|
@UseGuards(JwtAuthGuard, BusinessPermissionGuard)
|
||||||
|
export class RollCallsController {
|
||||||
|
constructor(private readonly service: RollCallsService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@RequireBusinessPermission('roll_calls.read')
|
||||||
|
list(
|
||||||
|
@Param('businessId') businessId: string,
|
||||||
|
@Query() query: ListRollCallsDto,
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
) {
|
||||||
|
return this.service.list(businessId, query, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':rollCallId')
|
||||||
|
@RequireBusinessPermission('roll_calls.read')
|
||||||
|
getOne(
|
||||||
|
@Param('businessId') businessId: string,
|
||||||
|
@Param('rollCallId') rollCallId: string,
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
) {
|
||||||
|
return this.service.getOne(businessId, rollCallId, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@RequireBusinessPermission('roll_calls.create')
|
||||||
|
create(
|
||||||
|
@Param('businessId') businessId: string,
|
||||||
|
@Body() dto: CreateRollCallDto,
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
) {
|
||||||
|
return this.service.create(businessId, dto, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':rollCallId')
|
||||||
|
@RequireBusinessPermission('roll_calls.update')
|
||||||
|
update(
|
||||||
|
@Param('businessId') businessId: string,
|
||||||
|
@Param('rollCallId') rollCallId: string,
|
||||||
|
@Body() dto: UpdateRollCallDto,
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
) {
|
||||||
|
return this.service.update(businessId, rollCallId, dto, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':rollCallId')
|
||||||
|
@RequireBusinessPermission('roll_calls.delete')
|
||||||
|
remove(
|
||||||
|
@Param('businessId') businessId: string,
|
||||||
|
@Param('rollCallId') rollCallId: string,
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
) {
|
||||||
|
return this.service.remove(businessId, rollCallId, user);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { AuthModule } from '../auth/auth.module';
|
||||||
|
import { RollCallsController } from './roll-calls.controller';
|
||||||
|
import { RollCallsService } from './roll-calls.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [AuthModule],
|
||||||
|
controllers: [RollCallsController],
|
||||||
|
providers: [RollCallsService],
|
||||||
|
})
|
||||||
|
export class RollCallsModule {}
|
||||||
@@ -0,0 +1,364 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ConflictException,
|
||||||
|
ForbiddenException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Prisma, RollCall, RollCallTask } from '@prisma/client';
|
||||||
|
import { AuthUser } from '../auth/auth.types';
|
||||||
|
import { PermissionsService } from '../auth/permissions.service';
|
||||||
|
import { normalizeBusinessSettings } from '../business-settings/business-settings.util';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import {
|
||||||
|
CreateRollCallDto,
|
||||||
|
ListRollCallsDto,
|
||||||
|
UpdateRollCallDto,
|
||||||
|
} from './dto/roll-call.dto';
|
||||||
|
|
||||||
|
type RollCallWithTasks = RollCall & { tasks: RollCallTask[] };
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class RollCallsService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly permissions: PermissionsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async list(businessIdRaw: string, query: ListRollCallsDto, actor: AuthUser) {
|
||||||
|
const businessId = BigInt(businessIdRaw);
|
||||||
|
await this.assertModuleEnabled(businessId);
|
||||||
|
await this.assertPermission(businessId, actor.id, 'roll_calls.read');
|
||||||
|
|
||||||
|
const page = query.page ?? 1;
|
||||||
|
const pageSize = Math.min(Math.max(query.pageSize ?? 24, 1), 100);
|
||||||
|
const skip = (page - 1) * pageSize;
|
||||||
|
|
||||||
|
const where = this.buildWhere(businessId, actor.id, query);
|
||||||
|
|
||||||
|
const [items, total, allForSummary] = await Promise.all([
|
||||||
|
this.prisma.rollCall.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: [{ workDate: 'desc' }, { id: 'desc' }],
|
||||||
|
skip,
|
||||||
|
take: pageSize,
|
||||||
|
include: {
|
||||||
|
tasks: { orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }] },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
this.prisma.rollCall.count({ where }),
|
||||||
|
this.prisma.rollCall.findMany({
|
||||||
|
where,
|
||||||
|
select: {
|
||||||
|
startTime: true,
|
||||||
|
endTime: true,
|
||||||
|
_count: { select: { tasks: true } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const totalWorkingMinutes = allForSummary.reduce(
|
||||||
|
(sum, row) => sum + this.workingMinutes(row.startTime, row.endTime),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const totalTasks = allForSummary.reduce(
|
||||||
|
(sum, row) => sum + row._count.tasks,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: items.map((item) => this.serialize(item)),
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
summary: {
|
||||||
|
totalDays: total,
|
||||||
|
totalWorkingMinutes,
|
||||||
|
totalTasks,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getOne(
|
||||||
|
businessIdRaw: string,
|
||||||
|
rollCallIdRaw: string,
|
||||||
|
actor: AuthUser,
|
||||||
|
) {
|
||||||
|
const businessId = BigInt(businessIdRaw);
|
||||||
|
await this.assertModuleEnabled(businessId);
|
||||||
|
await this.assertPermission(businessId, actor.id, 'roll_calls.read');
|
||||||
|
const rollCall = await this.findOwnedOrThrow(
|
||||||
|
businessId,
|
||||||
|
actor.id,
|
||||||
|
BigInt(rollCallIdRaw),
|
||||||
|
);
|
||||||
|
return { rollCall: this.serialize(rollCall) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(
|
||||||
|
businessIdRaw: string,
|
||||||
|
dto: CreateRollCallDto,
|
||||||
|
actor: AuthUser,
|
||||||
|
) {
|
||||||
|
const businessId = BigInt(businessIdRaw);
|
||||||
|
await this.assertModuleEnabled(businessId);
|
||||||
|
await this.assertPermission(businessId, actor.id, 'roll_calls.create');
|
||||||
|
|
||||||
|
const workDate = this.parseWorkDate(dto.workDate);
|
||||||
|
const tasks = this.normalizeTasks((dto.tasks ?? []).map((task) => task.title));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const created = await this.prisma.rollCall.create({
|
||||||
|
data: {
|
||||||
|
businessId,
|
||||||
|
userId: actor.id,
|
||||||
|
workDate,
|
||||||
|
startTime: dto.startTime,
|
||||||
|
endTime: dto.endTime,
|
||||||
|
tasks: {
|
||||||
|
create: tasks.map((title, index) => ({
|
||||||
|
title,
|
||||||
|
sortOrder: index,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
tasks: { orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }] },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
rollCall: this.serialize(created),
|
||||||
|
message: 'Roll call created successfully',
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
if (
|
||||||
|
err instanceof Prisma.PrismaClientKnownRequestError &&
|
||||||
|
err.code === 'P2002'
|
||||||
|
) {
|
||||||
|
throw new ConflictException(
|
||||||
|
'A roll call already exists for this day',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(
|
||||||
|
businessIdRaw: string,
|
||||||
|
rollCallIdRaw: string,
|
||||||
|
dto: UpdateRollCallDto,
|
||||||
|
actor: AuthUser,
|
||||||
|
) {
|
||||||
|
const businessId = BigInt(businessIdRaw);
|
||||||
|
await this.assertModuleEnabled(businessId);
|
||||||
|
await this.assertPermission(businessId, actor.id, 'roll_calls.update');
|
||||||
|
const rollCallId = BigInt(rollCallIdRaw);
|
||||||
|
await this.findOwnedOrThrow(businessId, actor.id, rollCallId);
|
||||||
|
|
||||||
|
const workDate =
|
||||||
|
dto.workDate !== undefined ? this.parseWorkDate(dto.workDate) : undefined;
|
||||||
|
const tasks =
|
||||||
|
dto.tasks !== undefined
|
||||||
|
? this.normalizeTasks(dto.tasks.map((task) => task.title))
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updated = await this.prisma.$transaction(async (tx) => {
|
||||||
|
await tx.rollCall.update({
|
||||||
|
where: { id: rollCallId },
|
||||||
|
data: {
|
||||||
|
...(workDate !== undefined ? { workDate } : {}),
|
||||||
|
...(dto.startTime !== undefined ? { startTime: dto.startTime } : {}),
|
||||||
|
...(dto.endTime !== undefined ? { endTime: dto.endTime } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (tasks !== undefined) {
|
||||||
|
await tx.rollCallTask.deleteMany({ where: { rollCallId } });
|
||||||
|
if (tasks.length > 0) {
|
||||||
|
await tx.rollCallTask.createMany({
|
||||||
|
data: tasks.map((title, index) => ({
|
||||||
|
rollCallId,
|
||||||
|
title,
|
||||||
|
sortOrder: index,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx.rollCall.findUniqueOrThrow({
|
||||||
|
where: { id: rollCallId },
|
||||||
|
include: {
|
||||||
|
tasks: { orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }] },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
rollCall: this.serialize(updated),
|
||||||
|
message: 'Roll call updated successfully',
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
if (
|
||||||
|
err instanceof Prisma.PrismaClientKnownRequestError &&
|
||||||
|
err.code === 'P2002'
|
||||||
|
) {
|
||||||
|
throw new ConflictException(
|
||||||
|
'A roll call already exists for this day',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(
|
||||||
|
businessIdRaw: string,
|
||||||
|
rollCallIdRaw: string,
|
||||||
|
actor: AuthUser,
|
||||||
|
) {
|
||||||
|
const businessId = BigInt(businessIdRaw);
|
||||||
|
await this.assertModuleEnabled(businessId);
|
||||||
|
await this.assertPermission(businessId, actor.id, 'roll_calls.delete');
|
||||||
|
const rollCallId = BigInt(rollCallIdRaw);
|
||||||
|
await this.findOwnedOrThrow(businessId, actor.id, rollCallId);
|
||||||
|
|
||||||
|
await this.prisma.rollCall.delete({ where: { id: rollCallId } });
|
||||||
|
return { message: 'Roll call deleted successfully' };
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildWhere(
|
||||||
|
businessId: bigint,
|
||||||
|
userId: bigint,
|
||||||
|
query: ListRollCallsDto,
|
||||||
|
): Prisma.RollCallWhereInput {
|
||||||
|
const workDate: Prisma.DateTimeFilter = {};
|
||||||
|
|
||||||
|
if (query.dateFrom) {
|
||||||
|
workDate.gte = this.parseWorkDate(query.dateFrom);
|
||||||
|
}
|
||||||
|
if (query.dateTo) {
|
||||||
|
workDate.lte = this.parseWorkDate(query.dateTo);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
businessId,
|
||||||
|
userId,
|
||||||
|
...(Object.keys(workDate).length > 0 ? { workDate } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async findOwnedOrThrow(
|
||||||
|
businessId: bigint,
|
||||||
|
userId: bigint,
|
||||||
|
rollCallId: bigint,
|
||||||
|
) {
|
||||||
|
const rollCall = await this.prisma.rollCall.findFirst({
|
||||||
|
where: { id: rollCallId, businessId, userId },
|
||||||
|
include: {
|
||||||
|
tasks: { orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }] },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!rollCall) {
|
||||||
|
throw new NotFoundException('Roll call not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return rollCall;
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseWorkDate(value: string): Date {
|
||||||
|
const match = /^(\d{4})-(\d{2})-(\d{2})/.exec(value.trim());
|
||||||
|
if (!match) {
|
||||||
|
throw new BadRequestException('workDate must be YYYY-MM-DD');
|
||||||
|
}
|
||||||
|
const year = Number(match[1]);
|
||||||
|
const month = Number(match[2]);
|
||||||
|
const day = Number(match[3]);
|
||||||
|
const date = new Date(Date.UTC(year, month - 1, day));
|
||||||
|
if (
|
||||||
|
date.getUTCFullYear() !== year ||
|
||||||
|
date.getUTCMonth() !== month - 1 ||
|
||||||
|
date.getUTCDate() !== day
|
||||||
|
) {
|
||||||
|
throw new BadRequestException('Invalid workDate');
|
||||||
|
}
|
||||||
|
return date;
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeTasks(titles: string[]): string[] {
|
||||||
|
return titles.map((title) => title.trim()).filter((title) => title.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private workingMinutes(startTime: string, endTime: string): number {
|
||||||
|
const start = this.timeToMinutes(startTime);
|
||||||
|
const end = this.timeToMinutes(endTime);
|
||||||
|
if (end >= start) return end - start;
|
||||||
|
return 24 * 60 - start + end;
|
||||||
|
}
|
||||||
|
|
||||||
|
private timeToMinutes(value: string): number {
|
||||||
|
const [hours, minutes] = value.split(':').map(Number);
|
||||||
|
return hours * 60 + minutes;
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatWorkDate(value: Date): string {
|
||||||
|
return value.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
private serialize(item: RollCallWithTasks) {
|
||||||
|
const workingMinutes = this.workingMinutes(item.startTime, item.endTime);
|
||||||
|
return {
|
||||||
|
id: item.id.toString(),
|
||||||
|
businessId: item.businessId.toString(),
|
||||||
|
userId: item.userId.toString(),
|
||||||
|
workDate: this.formatWorkDate(item.workDate),
|
||||||
|
startTime: item.startTime,
|
||||||
|
endTime: item.endTime,
|
||||||
|
workingMinutes,
|
||||||
|
taskCount: item.tasks.length,
|
||||||
|
tasks: item.tasks.map((task) => ({
|
||||||
|
id: task.id.toString(),
|
||||||
|
title: task.title,
|
||||||
|
sortOrder: task.sortOrder,
|
||||||
|
})),
|
||||||
|
createdAt: item.createdAt.toISOString(),
|
||||||
|
updatedAt: item.updatedAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertModuleEnabled(businessId: bigint) {
|
||||||
|
const business = await this.prisma.business.findFirst({
|
||||||
|
where: { id: businessId },
|
||||||
|
select: { settings: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!business) {
|
||||||
|
throw new NotFoundException('Business not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const settings = normalizeBusinessSettings(business.settings);
|
||||||
|
if (!settings.modules.enabled.includes('roll_call')) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
'Roll call module is not enabled for this business',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertPermission(
|
||||||
|
businessId: bigint,
|
||||||
|
userId: bigint,
|
||||||
|
permission: string,
|
||||||
|
) {
|
||||||
|
const allowed = await this.permissions.hasBusinessPermission(
|
||||||
|
userId,
|
||||||
|
businessId,
|
||||||
|
permission,
|
||||||
|
);
|
||||||
|
if (!allowed) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
`Missing permission: ${permission} for this business`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import {
|
||||||
|
ArrayMinSize,
|
||||||
|
IsArray,
|
||||||
|
IsBoolean,
|
||||||
|
IsIn,
|
||||||
|
IsInt,
|
||||||
|
IsNumber,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
MaxLength,
|
||||||
|
Min,
|
||||||
|
MinLength,
|
||||||
|
ValidateNested,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
export const SHIPPING_METHOD_IDS = [
|
||||||
|
'post',
|
||||||
|
'tipax',
|
||||||
|
'mahex',
|
||||||
|
'freight',
|
||||||
|
'air',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type ShippingMethodId = (typeof SHIPPING_METHOD_IDS)[number];
|
||||||
|
|
||||||
|
export class ListShippingCostGroupsDto {
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
page?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
pageSize?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Boolean)
|
||||||
|
@IsBoolean()
|
||||||
|
isActive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ShippingProvinceExceptionDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
provinceId!: string;
|
||||||
|
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber({ maxDecimalPlaces: 2 })
|
||||||
|
@Min(0)
|
||||||
|
amount!: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CreateShippingCostGroupDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(255)
|
||||||
|
nameFa!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(255)
|
||||||
|
nameEn!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@IsArray()
|
||||||
|
@ArrayMinSize(1)
|
||||||
|
@IsString({ each: true })
|
||||||
|
@IsIn([...SHIPPING_METHOD_IDS], { each: true })
|
||||||
|
methods!: ShippingMethodId[];
|
||||||
|
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber({ maxDecimalPlaces: 2 })
|
||||||
|
@Min(0)
|
||||||
|
defaultAmount!: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
pasKraye?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
categoryIds?: string[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => ShippingProvinceExceptionDto)
|
||||||
|
provinceExceptions?: ShippingProvinceExceptionDto[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
sortOrder?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
isActive?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
isDefault?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateShippingCostGroupDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(255)
|
||||||
|
nameFa?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(255)
|
||||||
|
nameEn?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
description?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@ArrayMinSize(1)
|
||||||
|
@IsString({ each: true })
|
||||||
|
@IsIn([...SHIPPING_METHOD_IDS], { each: true })
|
||||||
|
methods?: ShippingMethodId[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber({ maxDecimalPlaces: 2 })
|
||||||
|
@Min(0)
|
||||||
|
defaultAmount?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
pasKraye?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
categoryIds?: string[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => ShippingProvinceExceptionDto)
|
||||||
|
provinceExceptions?: ShippingProvinceExceptionDto[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
sortOrder?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
isActive?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
isDefault?: boolean;
|
||||||
|
}
|
||||||
@@ -57,6 +57,11 @@ export class BatchCreateStoreItemsDto {
|
|||||||
@MinLength(1)
|
@MinLength(1)
|
||||||
productId!: string;
|
productId!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(({ value }) => value === 'true' || value === true)
|
||||||
|
@IsBoolean()
|
||||||
|
isUsed?: boolean;
|
||||||
|
|
||||||
@IsArray()
|
@IsArray()
|
||||||
@ValidateNested({ each: true })
|
@ValidateNested({ each: true })
|
||||||
@Type(() => CreateStoreItemVariantDto)
|
@Type(() => CreateStoreItemVariantDto)
|
||||||
@@ -79,6 +84,11 @@ export class ListStoreItemsDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
name?: string;
|
name?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(({ value }) => value === 'true' || value === true)
|
||||||
|
@IsBoolean()
|
||||||
|
isUsed?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ListPublicStoreItemsDto {
|
export class ListPublicStoreItemsDto {
|
||||||
@@ -120,6 +130,11 @@ export class ListPublicStoreItemsDto {
|
|||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
isFestival?: boolean;
|
isFestival?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(({ value }) => value === 'true' || value === true)
|
||||||
|
@IsBoolean()
|
||||||
|
isUsed?: boolean;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Type(() => Number)
|
@Type(() => Number)
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
@@ -209,6 +224,11 @@ export class SyncProductStoreItemVariantsDto {
|
|||||||
@MinLength(1)
|
@MinLength(1)
|
||||||
productId!: string;
|
productId!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(({ value }) => value === 'true' || value === true)
|
||||||
|
@IsBoolean()
|
||||||
|
isUsed?: boolean;
|
||||||
|
|
||||||
@IsArray()
|
@IsArray()
|
||||||
@ValidateNested({ each: true })
|
@ValidateNested({ each: true })
|
||||||
@Type(() => SyncStoreItemVariantRowDto)
|
@Type(() => SyncStoreItemVariantRowDto)
|
||||||
|
|||||||
@@ -0,0 +1,206 @@
|
|||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import {
|
||||||
|
IsBoolean,
|
||||||
|
IsIn,
|
||||||
|
IsInt,
|
||||||
|
IsNumber,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
MaxLength,
|
||||||
|
Min,
|
||||||
|
MinLength,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
export const STORE_LOAN_IMAGE_ASPECTS = ['1:1', '3:2'] as const;
|
||||||
|
export type StoreLoanImageAspect = (typeof STORE_LOAN_IMAGE_ASPECTS)[number];
|
||||||
|
|
||||||
|
export const STORE_LOAN_MONTH_STEPS = [2, 3, 4, 6, 12] as const;
|
||||||
|
export type StoreLoanMonthStep = (typeof STORE_LOAN_MONTH_STEPS)[number];
|
||||||
|
|
||||||
|
export class ListStoreLoanMethodsDto {
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
page?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
pageSize?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Boolean)
|
||||||
|
@IsBoolean()
|
||||||
|
isActive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CreateStoreLoanMethodDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(255)
|
||||||
|
nameFa!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(255)
|
||||||
|
nameEn!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
imageMediaId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(STORE_LOAN_IMAGE_ASPECTS)
|
||||||
|
imageAspectRatio?: StoreLoanImageAspect;
|
||||||
|
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber({ maxDecimalPlaces: 2 })
|
||||||
|
@Min(0)
|
||||||
|
minShoppingAmount!: number;
|
||||||
|
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber({ maxDecimalPlaces: 2 })
|
||||||
|
@Min(0)
|
||||||
|
minAmount!: number;
|
||||||
|
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber({ maxDecimalPlaces: 2 })
|
||||||
|
@Min(0)
|
||||||
|
maxAmount!: number;
|
||||||
|
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber({ maxDecimalPlaces: 4 })
|
||||||
|
@Min(0)
|
||||||
|
interest!: number;
|
||||||
|
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
returnMonthsMin!: number;
|
||||||
|
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
returnMonthsMax!: number;
|
||||||
|
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@IsIn([...STORE_LOAN_MONTH_STEPS])
|
||||||
|
returnMonthsStep!: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
sortOrder?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
isActive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateStoreLoanMethodDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(255)
|
||||||
|
nameFa?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(255)
|
||||||
|
nameEn?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
description?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
imageMediaId?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(STORE_LOAN_IMAGE_ASPECTS)
|
||||||
|
imageAspectRatio?: StoreLoanImageAspect;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber({ maxDecimalPlaces: 2 })
|
||||||
|
@Min(0)
|
||||||
|
minShoppingAmount?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber({ maxDecimalPlaces: 2 })
|
||||||
|
@Min(0)
|
||||||
|
minAmount?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber({ maxDecimalPlaces: 2 })
|
||||||
|
@Min(0)
|
||||||
|
maxAmount?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber({ maxDecimalPlaces: 4 })
|
||||||
|
@Min(0)
|
||||||
|
interest?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
returnMonthsMin?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
returnMonthsMax?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@IsIn([...STORE_LOAN_MONTH_STEPS])
|
||||||
|
returnMonthsStep?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
sortOrder?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
isActive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Public website calculator — methodId is required (pick method first when multiple exist). */
|
||||||
|
export class CalculateStoreLoanMethodDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
methodId!: string;
|
||||||
|
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber({ maxDecimalPlaces: 2 })
|
||||||
|
@Min(0)
|
||||||
|
totalValue!: number;
|
||||||
|
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber({ maxDecimalPlaces: 2 })
|
||||||
|
@Min(0)
|
||||||
|
creditAmount!: number;
|
||||||
|
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
months!: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { AuthUser } from '../auth/auth.types';
|
||||||
|
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||||
|
import { RequireBusinessPermission } from '../auth/decorators/require-business-permission.decorator';
|
||||||
|
import { BusinessPermissionGuard } from '../auth/guards/business-permission.guard';
|
||||||
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
|
import {
|
||||||
|
CreateShippingCostGroupDto,
|
||||||
|
ListShippingCostGroupsDto,
|
||||||
|
UpdateShippingCostGroupDto,
|
||||||
|
} from './dto/shipping-cost-groups.dto';
|
||||||
|
import { ShippingCostGroupsService } from './shipping-cost-groups.service';
|
||||||
|
|
||||||
|
@Controller('tenants/:host/shipping-cost-groups')
|
||||||
|
export class PublicShippingCostGroupsController {
|
||||||
|
constructor(private readonly service: ShippingCostGroupsService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
list(@Param('host') host: string) {
|
||||||
|
return this.service.listPublic(host);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Controller('businesses/:businessId/shipping-cost-groups')
|
||||||
|
@UseGuards(JwtAuthGuard, BusinessPermissionGuard)
|
||||||
|
export class ShippingCostGroupsController {
|
||||||
|
constructor(private readonly service: ShippingCostGroupsService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@RequireBusinessPermission('products.read')
|
||||||
|
list(
|
||||||
|
@Param('businessId') businessId: string,
|
||||||
|
@Query() query: ListShippingCostGroupsDto,
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
) {
|
||||||
|
return this.service.list(businessId, query, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':groupId')
|
||||||
|
@RequireBusinessPermission('products.read')
|
||||||
|
getOne(
|
||||||
|
@Param('businessId') businessId: string,
|
||||||
|
@Param('groupId') groupId: string,
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
) {
|
||||||
|
return this.service.getOne(businessId, groupId, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@RequireBusinessPermission('products.update')
|
||||||
|
create(
|
||||||
|
@Param('businessId') businessId: string,
|
||||||
|
@Body() dto: CreateShippingCostGroupDto,
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
) {
|
||||||
|
return this.service.create(businessId, dto, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':groupId')
|
||||||
|
@RequireBusinessPermission('products.update')
|
||||||
|
update(
|
||||||
|
@Param('businessId') businessId: string,
|
||||||
|
@Param('groupId') groupId: string,
|
||||||
|
@Body() dto: UpdateShippingCostGroupDto,
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
) {
|
||||||
|
return this.service.update(businessId, groupId, dto, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':groupId')
|
||||||
|
@RequireBusinessPermission('products.update')
|
||||||
|
remove(
|
||||||
|
@Param('businessId') businessId: string,
|
||||||
|
@Param('groupId') groupId: string,
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
) {
|
||||||
|
return this.service.remove(businessId, groupId, user);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,720 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ForbiddenException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
import { AuthUser } from '../auth/auth.types';
|
||||||
|
import { PermissionsService } from '../auth/permissions.service';
|
||||||
|
import { normalizeBusinessSettings } from '../business-settings/business-settings.util';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { TenantService } from '../tenant/tenant.service';
|
||||||
|
import {
|
||||||
|
CreateShippingCostGroupDto,
|
||||||
|
ListShippingCostGroupsDto,
|
||||||
|
SHIPPING_METHOD_IDS,
|
||||||
|
type ShippingMethodId,
|
||||||
|
UpdateShippingCostGroupDto,
|
||||||
|
} from './dto/shipping-cost-groups.dto';
|
||||||
|
|
||||||
|
type GroupWithRelations = Prisma.ShippingCostGroupGetPayload<{
|
||||||
|
include: {
|
||||||
|
categories: {
|
||||||
|
include: {
|
||||||
|
category: {
|
||||||
|
select: { id: true; name: true; nameFa: true; slug: true };
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
cityRates: true;
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
|
||||||
|
const METHOD_SET = new Set<string>(SHIPPING_METHOD_IDS);
|
||||||
|
|
||||||
|
const STORE_SHIPPING_TITLE = 'ارسال توسط مجموعه';
|
||||||
|
|
||||||
|
const METHOD_LABELS_FA: Record<ShippingMethodId, string> = {
|
||||||
|
post: 'پست',
|
||||||
|
tipax: 'تیپاکس',
|
||||||
|
mahex: 'ماهکس',
|
||||||
|
freight: 'باربری',
|
||||||
|
air: 'ارسال هوایی',
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ShippingCostGroupsService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly permissions: PermissionsService,
|
||||||
|
private readonly tenant: TenantService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async list(
|
||||||
|
businessIdRaw: string,
|
||||||
|
query: ListShippingCostGroupsDto,
|
||||||
|
actor: AuthUser,
|
||||||
|
) {
|
||||||
|
const businessId = BigInt(businessIdRaw);
|
||||||
|
await this.assertPermission(businessId, actor.id, 'products.read');
|
||||||
|
await this.assertStoreModuleEnabled(businessId);
|
||||||
|
|
||||||
|
const page = query.page ?? 1;
|
||||||
|
const pageSize = query.pageSize ?? 50;
|
||||||
|
const skip = (page - 1) * pageSize;
|
||||||
|
|
||||||
|
const where: Prisma.ShippingCostGroupWhereInput = {
|
||||||
|
businessId,
|
||||||
|
...(query.isActive !== undefined ? { isActive: query.isActive } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const [items, total] = await Promise.all([
|
||||||
|
this.prisma.shippingCostGroup.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: [{ isDefault: 'desc' }, { sortOrder: 'asc' }, { createdAt: 'desc' }],
|
||||||
|
skip,
|
||||||
|
take: pageSize,
|
||||||
|
include: this.groupInclude(),
|
||||||
|
}),
|
||||||
|
this.prisma.shippingCostGroup.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: items.map((item) => this.serialize(item)),
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getOne(
|
||||||
|
businessIdRaw: string,
|
||||||
|
groupIdRaw: string,
|
||||||
|
actor: AuthUser,
|
||||||
|
) {
|
||||||
|
const businessId = BigInt(businessIdRaw);
|
||||||
|
const groupId = BigInt(groupIdRaw);
|
||||||
|
await this.assertPermission(businessId, actor.id, 'products.read');
|
||||||
|
await this.assertStoreModuleEnabled(businessId);
|
||||||
|
|
||||||
|
const group = await this.findOrThrow(businessId, groupId);
|
||||||
|
return { group: this.serialize(group) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(
|
||||||
|
businessIdRaw: string,
|
||||||
|
dto: CreateShippingCostGroupDto,
|
||||||
|
actor: AuthUser,
|
||||||
|
) {
|
||||||
|
const businessId = BigInt(businessIdRaw);
|
||||||
|
await this.assertPermission(businessId, actor.id, 'products.update');
|
||||||
|
await this.assertStoreModuleEnabled(businessId);
|
||||||
|
|
||||||
|
const methods = this.normalizeMethods(dto.methods);
|
||||||
|
const categoryIds = await this.resolveCategoryIds(
|
||||||
|
businessId,
|
||||||
|
dto.categoryIds ?? [],
|
||||||
|
);
|
||||||
|
const provinceIds = await this.resolveProvinceIds(
|
||||||
|
dto.provinceExceptions ?? [],
|
||||||
|
);
|
||||||
|
const isDefault = dto.isDefault === true;
|
||||||
|
const pasKraye = dto.pasKraye === true;
|
||||||
|
const defaultAmount = pasKraye ? 0 : dto.defaultAmount;
|
||||||
|
|
||||||
|
const created = await this.prisma.$transaction(async (tx) => {
|
||||||
|
if (isDefault) {
|
||||||
|
await tx.shippingCostGroup.updateMany({
|
||||||
|
where: { businessId, isDefault: true },
|
||||||
|
data: { isDefault: false },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx.shippingCostGroup.create({
|
||||||
|
data: {
|
||||||
|
businessId,
|
||||||
|
nameFa: dto.nameFa.trim(),
|
||||||
|
nameEn: dto.nameEn.trim(),
|
||||||
|
description: dto.description?.trim() || null,
|
||||||
|
methods,
|
||||||
|
defaultAmount,
|
||||||
|
pasKraye,
|
||||||
|
sortOrder: dto.sortOrder ?? 0,
|
||||||
|
isActive: dto.isActive ?? true,
|
||||||
|
isDefault,
|
||||||
|
...(categoryIds.length
|
||||||
|
? {
|
||||||
|
categories: {
|
||||||
|
create: categoryIds.map((categoryId) => ({ categoryId })),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
...(provinceIds.length
|
||||||
|
? {
|
||||||
|
cityRates: {
|
||||||
|
create: provinceIds.map(({ provinceId, amount }) => ({
|
||||||
|
cityId: provinceId,
|
||||||
|
amount,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
include: this.groupInclude(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
message: 'Shipping cost group created successfully',
|
||||||
|
group: this.serialize(created),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(
|
||||||
|
businessIdRaw: string,
|
||||||
|
groupIdRaw: string,
|
||||||
|
dto: UpdateShippingCostGroupDto,
|
||||||
|
actor: AuthUser,
|
||||||
|
) {
|
||||||
|
const businessId = BigInt(businessIdRaw);
|
||||||
|
const groupId = BigInt(groupIdRaw);
|
||||||
|
await this.assertPermission(businessId, actor.id, 'products.update');
|
||||||
|
await this.assertStoreModuleEnabled(businessId);
|
||||||
|
|
||||||
|
await this.findOrThrow(businessId, groupId);
|
||||||
|
|
||||||
|
const data: Prisma.ShippingCostGroupUpdateInput = {};
|
||||||
|
if (dto.nameFa !== undefined) data.nameFa = dto.nameFa.trim();
|
||||||
|
if (dto.nameEn !== undefined) data.nameEn = dto.nameEn.trim();
|
||||||
|
if (dto.description !== undefined) {
|
||||||
|
data.description =
|
||||||
|
dto.description == null ? null : dto.description.trim() || null;
|
||||||
|
}
|
||||||
|
if (dto.methods !== undefined) {
|
||||||
|
data.methods = this.normalizeMethods(dto.methods);
|
||||||
|
}
|
||||||
|
if (dto.pasKraye !== undefined) {
|
||||||
|
data.pasKraye = dto.pasKraye;
|
||||||
|
if (dto.pasKraye) {
|
||||||
|
data.defaultAmount = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (dto.defaultAmount !== undefined && dto.pasKraye !== true) {
|
||||||
|
data.defaultAmount = dto.defaultAmount;
|
||||||
|
}
|
||||||
|
if (dto.sortOrder !== undefined) data.sortOrder = dto.sortOrder;
|
||||||
|
if (dto.isActive !== undefined) data.isActive = dto.isActive;
|
||||||
|
if (dto.isDefault !== undefined) data.isDefault = dto.isDefault;
|
||||||
|
|
||||||
|
if (dto.categoryIds !== undefined) {
|
||||||
|
const categoryIds = await this.resolveCategoryIds(
|
||||||
|
businessId,
|
||||||
|
dto.categoryIds,
|
||||||
|
);
|
||||||
|
data.categories = {
|
||||||
|
deleteMany: {},
|
||||||
|
...(categoryIds.length
|
||||||
|
? {
|
||||||
|
create: categoryIds.map((categoryId) => ({ categoryId })),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dto.provinceExceptions !== undefined) {
|
||||||
|
const provinceIds = await this.resolveProvinceIds(dto.provinceExceptions);
|
||||||
|
data.cityRates = {
|
||||||
|
deleteMany: {},
|
||||||
|
...(provinceIds.length
|
||||||
|
? {
|
||||||
|
create: provinceIds.map(({ provinceId, amount }) => ({
|
||||||
|
cityId: provinceId,
|
||||||
|
amount,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await this.prisma.$transaction(async (tx) => {
|
||||||
|
if (dto.isDefault === true) {
|
||||||
|
await tx.shippingCostGroup.updateMany({
|
||||||
|
where: {
|
||||||
|
businessId,
|
||||||
|
isDefault: true,
|
||||||
|
id: { not: groupId },
|
||||||
|
},
|
||||||
|
data: { isDefault: false },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx.shippingCostGroup.update({
|
||||||
|
where: { id: groupId },
|
||||||
|
data,
|
||||||
|
include: this.groupInclude(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
message: 'Shipping cost group updated successfully',
|
||||||
|
group: this.serialize(updated),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(
|
||||||
|
businessIdRaw: string,
|
||||||
|
groupIdRaw: string,
|
||||||
|
actor: AuthUser,
|
||||||
|
) {
|
||||||
|
const businessId = BigInt(businessIdRaw);
|
||||||
|
const groupId = BigInt(groupIdRaw);
|
||||||
|
await this.assertPermission(businessId, actor.id, 'products.update');
|
||||||
|
await this.assertStoreModuleEnabled(businessId);
|
||||||
|
|
||||||
|
await this.findOrThrow(businessId, groupId);
|
||||||
|
await this.prisma.shippingCostGroup.delete({ where: { id: groupId } });
|
||||||
|
|
||||||
|
return { message: 'Shipping cost group deleted successfully' };
|
||||||
|
}
|
||||||
|
|
||||||
|
async listPublic(host: string) {
|
||||||
|
const business = await this.tenant.resolveBusinessByDomain(host);
|
||||||
|
const settings = normalizeBusinessSettings(business.settings);
|
||||||
|
if (!settings.modules.enabled.includes('store')) {
|
||||||
|
return {
|
||||||
|
enabled: false,
|
||||||
|
staticShippingEnabled: false,
|
||||||
|
staticShippingAmount: 0,
|
||||||
|
staticShippingProvinceExceptions: [],
|
||||||
|
items: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = await this.prisma.shippingCostGroup.findMany({
|
||||||
|
where: { businessId: business.id, isActive: true },
|
||||||
|
orderBy: [{ isDefault: 'desc' }, { sortOrder: 'asc' }, { createdAt: 'desc' }],
|
||||||
|
include: this.groupInclude(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
enabled: true,
|
||||||
|
staticShippingEnabled: settings.store.staticShippingEnabled,
|
||||||
|
staticShippingAmount: settings.store.staticShippingAmount,
|
||||||
|
staticShippingProvinceExceptions:
|
||||||
|
settings.store.staticShippingProvinceExceptions,
|
||||||
|
items: items.map((item) => this.serialize(item)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server-side shipping quote for checkout. Method ids from the client are only
|
||||||
|
* used when they belong to the matched group's methods (for labels).
|
||||||
|
*/
|
||||||
|
async quoteForCheckout(input: {
|
||||||
|
businessId: bigint;
|
||||||
|
deliveryMode: 'delivery' | 'pickup';
|
||||||
|
provinceName: string | null;
|
||||||
|
cartItems: Array<{
|
||||||
|
productName: string;
|
||||||
|
categoryIds: string[];
|
||||||
|
}>;
|
||||||
|
selectedMethodsByGroupId?: Record<string, string>;
|
||||||
|
}): Promise<{
|
||||||
|
total: number;
|
||||||
|
groups: Array<{
|
||||||
|
groupId: string;
|
||||||
|
title: string;
|
||||||
|
methodId: string | null;
|
||||||
|
methodLabel: string | null;
|
||||||
|
amount: number;
|
||||||
|
pasKraye: boolean;
|
||||||
|
itemNames: string[];
|
||||||
|
}>;
|
||||||
|
}> {
|
||||||
|
if (input.deliveryMode === 'pickup' || input.cartItems.length === 0) {
|
||||||
|
return { total: 0, groups: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const business = await this.prisma.business.findUnique({
|
||||||
|
where: { id: input.businessId },
|
||||||
|
select: { settings: true },
|
||||||
|
});
|
||||||
|
if (!business) {
|
||||||
|
return {
|
||||||
|
total: 0,
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
groupId: 'ungrouped',
|
||||||
|
title: STORE_SHIPPING_TITLE,
|
||||||
|
methodId: null,
|
||||||
|
methodLabel: null,
|
||||||
|
amount: 0,
|
||||||
|
pasKraye: false,
|
||||||
|
itemNames: input.cartItems.map((item) => item.productName),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const settings = normalizeBusinessSettings(business.settings);
|
||||||
|
if (!settings.modules.enabled.includes('store')) {
|
||||||
|
return {
|
||||||
|
total: 0,
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
groupId: 'ungrouped',
|
||||||
|
title: STORE_SHIPPING_TITLE,
|
||||||
|
methodId: null,
|
||||||
|
methodLabel: null,
|
||||||
|
amount: 0,
|
||||||
|
pasKraye: false,
|
||||||
|
itemNames: input.cartItems.map((item) => item.productName),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const provinceId = await this.resolveProvinceId(input.provinceName);
|
||||||
|
const selected = input.selectedMethodsByGroupId ?? {};
|
||||||
|
|
||||||
|
if (settings.store.staticShippingEnabled) {
|
||||||
|
const amount = this.resolveProvinceAmount(
|
||||||
|
settings.store.staticShippingAmount,
|
||||||
|
settings.store.staticShippingProvinceExceptions,
|
||||||
|
provinceId,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
total: amount,
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
groupId: 'static',
|
||||||
|
title: STORE_SHIPPING_TITLE,
|
||||||
|
methodId: null,
|
||||||
|
methodLabel: null,
|
||||||
|
amount,
|
||||||
|
pasKraye: false,
|
||||||
|
itemNames: input.cartItems.map((item) => item.productName),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const groups = await this.prisma.shippingCostGroup.findMany({
|
||||||
|
where: { businessId: input.businessId, isActive: true },
|
||||||
|
orderBy: [{ isDefault: 'desc' }, { sortOrder: 'asc' }, { createdAt: 'desc' }],
|
||||||
|
include: this.groupInclude(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const serialized = groups.map((item) => this.serialize(item));
|
||||||
|
const defaultGroup = serialized.find((group) => group.isDefault) ?? null;
|
||||||
|
const buckets = new Map<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
groupId: string;
|
||||||
|
title: string;
|
||||||
|
methods: ShippingMethodId[];
|
||||||
|
amount: number;
|
||||||
|
pasKraye: boolean;
|
||||||
|
itemNames: string[];
|
||||||
|
isDefault: boolean;
|
||||||
|
}
|
||||||
|
>();
|
||||||
|
const ungroupedNames: string[] = [];
|
||||||
|
|
||||||
|
for (const item of input.cartItems) {
|
||||||
|
const matched = this.findGroupForCategoryIds(serialized, item.categoryIds);
|
||||||
|
const group = matched ?? defaultGroup;
|
||||||
|
if (!group) {
|
||||||
|
ungroupedNames.push(item.productName);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const existing = buckets.get(group.id);
|
||||||
|
if (existing) {
|
||||||
|
existing.itemNames.push(item.productName);
|
||||||
|
} else {
|
||||||
|
const pasKraye = group.pasKraye === true;
|
||||||
|
buckets.set(group.id, {
|
||||||
|
groupId: group.id,
|
||||||
|
title: group.isDefault
|
||||||
|
? STORE_SHIPPING_TITLE
|
||||||
|
: group.nameFa || group.nameEn,
|
||||||
|
methods: group.methods,
|
||||||
|
amount: pasKraye
|
||||||
|
? 0
|
||||||
|
: this.resolveProvinceAmount(
|
||||||
|
group.defaultAmount,
|
||||||
|
group.provinceExceptions,
|
||||||
|
provinceId,
|
||||||
|
),
|
||||||
|
pasKraye,
|
||||||
|
itemNames: [item.productName],
|
||||||
|
isDefault: group.isDefault,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ungroupedNames.length > 0) {
|
||||||
|
buckets.set('ungrouped', {
|
||||||
|
groupId: 'ungrouped',
|
||||||
|
title: STORE_SHIPPING_TITLE,
|
||||||
|
methods: [],
|
||||||
|
amount: 0,
|
||||||
|
pasKraye: false,
|
||||||
|
itemNames: ungroupedNames,
|
||||||
|
isDefault: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const resultGroups = [...buckets.values()].map((bucket) => {
|
||||||
|
const requested = selected[bucket.groupId];
|
||||||
|
const methodId =
|
||||||
|
requested && bucket.methods.includes(requested as ShippingMethodId)
|
||||||
|
? (requested as ShippingMethodId)
|
||||||
|
: bucket.methods[0] ?? null;
|
||||||
|
return {
|
||||||
|
groupId: bucket.groupId,
|
||||||
|
title: bucket.title,
|
||||||
|
methodId,
|
||||||
|
methodLabel: methodId ? METHOD_LABELS_FA[methodId] : null,
|
||||||
|
amount: bucket.amount,
|
||||||
|
pasKraye: bucket.pasKraye,
|
||||||
|
itemNames: bucket.itemNames,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
total: resultGroups.reduce((sum, group) => sum + group.amount, 0),
|
||||||
|
groups: resultGroups,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveProvinceId(provinceName: string | null) {
|
||||||
|
const needle = provinceName?.trim();
|
||||||
|
if (!needle) return null;
|
||||||
|
const match = await this.prisma.city.findFirst({
|
||||||
|
where: {
|
||||||
|
level: 'province',
|
||||||
|
isActive: true,
|
||||||
|
OR: [
|
||||||
|
{ nameFa: needle },
|
||||||
|
{ nameEn: needle },
|
||||||
|
{ slug: needle },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
return match?.id.toString() ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveProvinceAmount(
|
||||||
|
defaultAmount: number,
|
||||||
|
exceptions: Array<{ provinceId: string; amount: number }> | undefined,
|
||||||
|
provinceId: string | null,
|
||||||
|
) {
|
||||||
|
if (!provinceId || !exceptions?.length) return defaultAmount;
|
||||||
|
const match = exceptions.find((item) => item.provinceId === provinceId);
|
||||||
|
return match ? match.amount : defaultAmount;
|
||||||
|
}
|
||||||
|
|
||||||
|
private findGroupForCategoryIds(
|
||||||
|
groups: Array<{
|
||||||
|
id: string;
|
||||||
|
isDefault: boolean;
|
||||||
|
categoryIds: string[];
|
||||||
|
nameFa: string;
|
||||||
|
nameEn: string;
|
||||||
|
methods: ShippingMethodId[];
|
||||||
|
defaultAmount: number;
|
||||||
|
pasKraye: boolean;
|
||||||
|
provinceExceptions?: Array<{ provinceId: string; amount: number }>;
|
||||||
|
}>,
|
||||||
|
categoryIds: string[],
|
||||||
|
) {
|
||||||
|
const categorySet = new Set(categoryIds);
|
||||||
|
const byCategory = groups.filter((group) =>
|
||||||
|
group.categoryIds.some((id) => categorySet.has(id)),
|
||||||
|
);
|
||||||
|
if (byCategory.length === 0) return null;
|
||||||
|
return byCategory.find((group) => !group.isDefault) ?? byCategory[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private groupInclude() {
|
||||||
|
return {
|
||||||
|
categories: {
|
||||||
|
include: {
|
||||||
|
category: {
|
||||||
|
select: { id: true, name: true, nameFa: true, slug: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
cityRates: true,
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
private serialize(group: GroupWithRelations) {
|
||||||
|
return {
|
||||||
|
id: group.id.toString(),
|
||||||
|
businessId: group.businessId.toString(),
|
||||||
|
nameFa: group.nameFa,
|
||||||
|
nameEn: group.nameEn,
|
||||||
|
description: group.description,
|
||||||
|
methods: group.methods.filter((method): method is ShippingMethodId =>
|
||||||
|
METHOD_SET.has(method),
|
||||||
|
),
|
||||||
|
defaultAmount: Number(group.defaultAmount),
|
||||||
|
sortOrder: group.sortOrder,
|
||||||
|
isActive: group.isActive,
|
||||||
|
isDefault: group.isDefault,
|
||||||
|
pasKraye: group.pasKraye,
|
||||||
|
categoryIds: group.categories.map((row) => row.categoryId.toString()),
|
||||||
|
categories: group.categories.map((row) => ({
|
||||||
|
id: row.category.id.toString(),
|
||||||
|
name: row.category.name,
|
||||||
|
nameFa: row.category.nameFa,
|
||||||
|
slug: row.category.slug,
|
||||||
|
})),
|
||||||
|
cityRates: group.cityRates.map((rate) => ({
|
||||||
|
id: rate.id.toString(),
|
||||||
|
cityId: rate.cityId.toString(),
|
||||||
|
amount: Number(rate.amount),
|
||||||
|
})),
|
||||||
|
provinceExceptions: group.cityRates.map((rate) => ({
|
||||||
|
id: rate.id.toString(),
|
||||||
|
provinceId: rate.cityId.toString(),
|
||||||
|
amount: Number(rate.amount),
|
||||||
|
})),
|
||||||
|
createdAt: group.createdAt.toISOString(),
|
||||||
|
updatedAt: group.updatedAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeMethods(methods: ShippingMethodId[]) {
|
||||||
|
const unique = [...new Set(methods)];
|
||||||
|
if (!unique.length) {
|
||||||
|
throw new BadRequestException('Select at least one shipping method');
|
||||||
|
}
|
||||||
|
for (const method of unique) {
|
||||||
|
if (!METHOD_SET.has(method)) {
|
||||||
|
throw new BadRequestException(`Invalid shipping method: ${method}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return unique;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveProvinceIds(
|
||||||
|
exceptions: Array<{ provinceId: string; amount: number }>,
|
||||||
|
) {
|
||||||
|
if (!exceptions.length) return [] as Array<{ provinceId: bigint; amount: number }>;
|
||||||
|
|
||||||
|
const unique = new Map<string, number>();
|
||||||
|
for (const entry of exceptions) {
|
||||||
|
const provinceId = entry.provinceId.trim();
|
||||||
|
if (!provinceId) continue;
|
||||||
|
if (!Number.isFinite(entry.amount) || entry.amount < 0) {
|
||||||
|
throw new BadRequestException('Invalid province exception amount');
|
||||||
|
}
|
||||||
|
unique.set(provinceId, entry.amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ids = [...unique.keys()].map((id) => {
|
||||||
|
try {
|
||||||
|
return BigInt(id);
|
||||||
|
} catch {
|
||||||
|
throw new BadRequestException(`Invalid province id: ${id}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const found = await this.prisma.city.findMany({
|
||||||
|
where: {
|
||||||
|
id: { in: ids },
|
||||||
|
level: 'province',
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (found.length !== ids.length) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'One or more provinces were not found',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...unique.entries()].map(([provinceId, amount]) => ({
|
||||||
|
provinceId: BigInt(provinceId),
|
||||||
|
amount,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveCategoryIds(businessId: bigint, rawIds: string[]) {
|
||||||
|
if (!rawIds.length) return [] as bigint[];
|
||||||
|
|
||||||
|
const unique = [...new Set(rawIds.map((id) => id.trim()).filter(Boolean))];
|
||||||
|
const ids = unique.map((id) => {
|
||||||
|
try {
|
||||||
|
return BigInt(id);
|
||||||
|
} catch {
|
||||||
|
throw new BadRequestException(`Invalid category id: ${id}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const found = await this.prisma.category.findMany({
|
||||||
|
where: {
|
||||||
|
businessId,
|
||||||
|
entityType: 'product',
|
||||||
|
id: { in: ids },
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (found.length !== ids.length) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'One or more categories were not found for this business',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async findOrThrow(businessId: bigint, groupId: bigint) {
|
||||||
|
const group = await this.prisma.shippingCostGroup.findFirst({
|
||||||
|
where: { id: groupId, businessId },
|
||||||
|
include: this.groupInclude(),
|
||||||
|
});
|
||||||
|
if (!group) {
|
||||||
|
throw new NotFoundException('Shipping cost group not found');
|
||||||
|
}
|
||||||
|
return group;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertPermission(
|
||||||
|
businessId: bigint,
|
||||||
|
userId: bigint,
|
||||||
|
permission: string,
|
||||||
|
) {
|
||||||
|
const allowed = await this.permissions.hasBusinessPermission(
|
||||||
|
userId,
|
||||||
|
businessId,
|
||||||
|
permission,
|
||||||
|
);
|
||||||
|
if (!allowed) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
`Missing permission: ${permission} for this business`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertStoreModuleEnabled(businessId: bigint) {
|
||||||
|
const business = await this.prisma.business.findUnique({
|
||||||
|
where: { id: businessId },
|
||||||
|
select: { settings: true },
|
||||||
|
});
|
||||||
|
if (!business) {
|
||||||
|
throw new NotFoundException('Business not found');
|
||||||
|
}
|
||||||
|
const settings = normalizeBusinessSettings(business.settings);
|
||||||
|
if (!settings.modules.enabled.includes('store')) {
|
||||||
|
throw new ForbiddenException('Store module is not enabled');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -71,23 +71,44 @@ export class StoreItemsService {
|
|||||||
const name = query.name?.trim();
|
const name = query.name?.trim();
|
||||||
const where: Prisma.StoreItemVariantWhereInput = {
|
const where: Prisma.StoreItemVariantWhereInput = {
|
||||||
businessId,
|
businessId,
|
||||||
...(name
|
...(query.isUsed
|
||||||
? {
|
? {
|
||||||
storeItem: {
|
storeItem: {
|
||||||
product: {
|
isUsed: true,
|
||||||
OR: [
|
...(name
|
||||||
{ title: { contains: name, mode: 'insensitive' } },
|
? {
|
||||||
{
|
product: {
|
||||||
content: {
|
OR: [
|
||||||
path: ['nameFa'],
|
{ title: { contains: name, mode: 'insensitive' } },
|
||||||
string_contains: name,
|
{
|
||||||
|
content: {
|
||||||
|
path: ['nameFa'],
|
||||||
|
string_contains: name,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
},
|
}
|
||||||
],
|
: {}),
|
||||||
},
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
: {}),
|
: name
|
||||||
|
? {
|
||||||
|
storeItem: {
|
||||||
|
product: {
|
||||||
|
OR: [
|
||||||
|
{ title: { contains: name, mode: 'insensitive' } },
|
||||||
|
{
|
||||||
|
content: {
|
||||||
|
path: ['nameFa'],
|
||||||
|
string_contains: name,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
const [items, total] = await Promise.all([
|
const [items, total] = await Promise.all([
|
||||||
@@ -467,7 +488,9 @@ export class StoreItemsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const created = await this.prisma.$transaction(async (tx) => {
|
const created = await this.prisma.$transaction(async (tx) => {
|
||||||
const storeItem = await this.ensureStoreItem(businessId, productId, tx);
|
const storeItem = await this.ensureStoreItem(businessId, productId, tx, {
|
||||||
|
isUsed: dto.isUsed,
|
||||||
|
});
|
||||||
const baseSortOrder = await tx.storeItemVariant.count({
|
const baseSortOrder = await tx.storeItemVariant.count({
|
||||||
where: { storeItemId: storeItem.id },
|
where: { storeItemId: storeItem.id },
|
||||||
});
|
});
|
||||||
@@ -719,7 +742,9 @@ export class StoreItemsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await this.prisma.$transaction(async (tx) => {
|
await this.prisma.$transaction(async (tx) => {
|
||||||
const storeItem = await this.ensureStoreItem(businessId, productId, tx);
|
const storeItem = await this.ensureStoreItem(businessId, productId, tx, {
|
||||||
|
isUsed: dto.isUsed,
|
||||||
|
});
|
||||||
const baseSortOrder = await tx.storeItemVariant.count({
|
const baseSortOrder = await tx.storeItemVariant.count({
|
||||||
where: { storeItemId: storeItem.id },
|
where: { storeItemId: storeItem.id },
|
||||||
});
|
});
|
||||||
@@ -947,17 +972,28 @@ export class StoreItemsService {
|
|||||||
businessId: bigint,
|
businessId: bigint,
|
||||||
productId: bigint,
|
productId: bigint,
|
||||||
tx: Prisma.TransactionClient = this.prisma,
|
tx: Prisma.TransactionClient = this.prisma,
|
||||||
|
options?: { isUsed?: boolean },
|
||||||
) {
|
) {
|
||||||
const existing = await tx.storeItem.findUnique({
|
const existing = await tx.storeItem.findUnique({
|
||||||
where: { businessId_productId: { businessId, productId } },
|
where: { businessId_productId: { businessId, productId } },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
|
if (options?.isUsed !== undefined && existing.isUsed !== options.isUsed) {
|
||||||
|
return tx.storeItem.update({
|
||||||
|
where: { id: existing.id },
|
||||||
|
data: { isUsed: options.isUsed },
|
||||||
|
});
|
||||||
|
}
|
||||||
return existing;
|
return existing;
|
||||||
}
|
}
|
||||||
|
|
||||||
return tx.storeItem.create({
|
return tx.storeItem.create({
|
||||||
data: { businessId, productId },
|
data: {
|
||||||
|
businessId,
|
||||||
|
productId,
|
||||||
|
isUsed: options?.isUsed ?? false,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1126,6 +1162,7 @@ export class StoreItemsService {
|
|||||||
stockQuantity: variant.stockQuantity,
|
stockQuantity: variant.stockQuantity,
|
||||||
rewardPoints: variant.rewardPoints,
|
rewardPoints: variant.rewardPoints,
|
||||||
isFestival: variant.isFestival,
|
isFestival: variant.isFestival,
|
||||||
|
isUsed: variant.storeItem.isUsed,
|
||||||
sortOrder: variant.sortOrder,
|
sortOrder: variant.sortOrder,
|
||||||
createdAt: variant.createdAt,
|
createdAt: variant.createdAt,
|
||||||
updatedAt: variant.updatedAt,
|
updatedAt: variant.updatedAt,
|
||||||
@@ -1257,6 +1294,7 @@ export class StoreItemsService {
|
|||||||
isActive: true,
|
isActive: true,
|
||||||
storeItem: {
|
storeItem: {
|
||||||
isActive: true,
|
isActive: true,
|
||||||
|
...(query.isUsed ? { isUsed: true } : {}),
|
||||||
product: {
|
product: {
|
||||||
status: ContentStatus.published,
|
status: ContentStatus.published,
|
||||||
...(query.brandId ? { brandId: BigInt(query.brandId) } : {}),
|
...(query.brandId ? { brandId: BigInt(query.brandId) } : {}),
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { AuthUser } from '../auth/auth.types';
|
||||||
|
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||||
|
import { RequireBusinessPermission } from '../auth/decorators/require-business-permission.decorator';
|
||||||
|
import { BusinessPermissionGuard } from '../auth/guards/business-permission.guard';
|
||||||
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
|
import {
|
||||||
|
CalculateStoreLoanMethodDto,
|
||||||
|
CreateStoreLoanMethodDto,
|
||||||
|
ListStoreLoanMethodsDto,
|
||||||
|
UpdateStoreLoanMethodDto,
|
||||||
|
} from './dto/store-loan-methods.dto';
|
||||||
|
import { StoreLoanMethodsService } from './store-loan-methods.service';
|
||||||
|
|
||||||
|
@Controller('tenants/:host/store-loan-methods')
|
||||||
|
export class PublicStoreLoanMethodsController {
|
||||||
|
constructor(private readonly service: StoreLoanMethodsService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
list(@Param('host') host: string) {
|
||||||
|
return this.service.listPublic(host);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('calculate')
|
||||||
|
calculate(
|
||||||
|
@Param('host') host: string,
|
||||||
|
@Body() dto: CalculateStoreLoanMethodDto,
|
||||||
|
) {
|
||||||
|
return this.service.calculatePublic(host, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':methodId')
|
||||||
|
getOne(@Param('host') host: string, @Param('methodId') methodId: string) {
|
||||||
|
return this.service.getOnePublic(host, methodId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Controller('businesses/:businessId/store-loan-methods')
|
||||||
|
@UseGuards(JwtAuthGuard, BusinessPermissionGuard)
|
||||||
|
export class StoreLoanMethodsController {
|
||||||
|
constructor(private readonly service: StoreLoanMethodsService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@RequireBusinessPermission('products.read')
|
||||||
|
list(
|
||||||
|
@Param('businessId') businessId: string,
|
||||||
|
@Query() query: ListStoreLoanMethodsDto,
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
) {
|
||||||
|
return this.service.list(businessId, query, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':methodId')
|
||||||
|
@RequireBusinessPermission('products.read')
|
||||||
|
getOne(
|
||||||
|
@Param('businessId') businessId: string,
|
||||||
|
@Param('methodId') methodId: string,
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
) {
|
||||||
|
return this.service.getOne(businessId, methodId, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@RequireBusinessPermission('products.update')
|
||||||
|
create(
|
||||||
|
@Param('businessId') businessId: string,
|
||||||
|
@Body() dto: CreateStoreLoanMethodDto,
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
) {
|
||||||
|
return this.service.create(businessId, dto, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':methodId')
|
||||||
|
@RequireBusinessPermission('products.update')
|
||||||
|
update(
|
||||||
|
@Param('businessId') businessId: string,
|
||||||
|
@Param('methodId') methodId: string,
|
||||||
|
@Body() dto: UpdateStoreLoanMethodDto,
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
) {
|
||||||
|
return this.service.update(businessId, methodId, dto, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':methodId')
|
||||||
|
@RequireBusinessPermission('products.update')
|
||||||
|
remove(
|
||||||
|
@Param('businessId') businessId: string,
|
||||||
|
@Param('methodId') methodId: string,
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
) {
|
||||||
|
return this.service.remove(businessId, methodId, user);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,571 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ForbiddenException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
import { AuthUser } from '../auth/auth.types';
|
||||||
|
import { PermissionsService } from '../auth/permissions.service';
|
||||||
|
import { normalizeBusinessSettings } from '../business-settings/business-settings.util';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { TenantService } from '../tenant/tenant.service';
|
||||||
|
import {
|
||||||
|
CalculateStoreLoanMethodDto,
|
||||||
|
CreateStoreLoanMethodDto,
|
||||||
|
ListStoreLoanMethodsDto,
|
||||||
|
STORE_LOAN_IMAGE_ASPECTS,
|
||||||
|
STORE_LOAN_MONTH_STEPS,
|
||||||
|
UpdateStoreLoanMethodDto,
|
||||||
|
} from './dto/store-loan-methods.dto';
|
||||||
|
|
||||||
|
type LoanMethodWithImage = Prisma.StoreLoanMethodGetPayload<{
|
||||||
|
include: { imageMedia: true };
|
||||||
|
}>;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class StoreLoanMethodsService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly permissions: PermissionsService,
|
||||||
|
private readonly tenant: TenantService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async list(
|
||||||
|
businessIdRaw: string,
|
||||||
|
query: ListStoreLoanMethodsDto,
|
||||||
|
actor: AuthUser,
|
||||||
|
) {
|
||||||
|
const businessId = BigInt(businessIdRaw);
|
||||||
|
await this.assertPermission(businessId, actor.id, 'products.read');
|
||||||
|
await this.assertModuleEnabled(businessId);
|
||||||
|
|
||||||
|
const page = query.page ?? 1;
|
||||||
|
const pageSize = query.pageSize ?? 20;
|
||||||
|
const skip = (page - 1) * pageSize;
|
||||||
|
|
||||||
|
const where: Prisma.StoreLoanMethodWhereInput = {
|
||||||
|
businessId,
|
||||||
|
...(query.isActive !== undefined ? { isActive: query.isActive } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const [items, total] = await Promise.all([
|
||||||
|
this.prisma.storeLoanMethod.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'desc' }],
|
||||||
|
skip,
|
||||||
|
take: pageSize,
|
||||||
|
include: { imageMedia: true },
|
||||||
|
}),
|
||||||
|
this.prisma.storeLoanMethod.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: items.map((item) => this.serialize(item)),
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getOne(
|
||||||
|
businessIdRaw: string,
|
||||||
|
methodIdRaw: string,
|
||||||
|
actor: AuthUser,
|
||||||
|
) {
|
||||||
|
const businessId = BigInt(businessIdRaw);
|
||||||
|
const methodId = BigInt(methodIdRaw);
|
||||||
|
await this.assertPermission(businessId, actor.id, 'products.read');
|
||||||
|
await this.assertModuleEnabled(businessId);
|
||||||
|
|
||||||
|
const method = await this.findOrThrow(businessId, methodId);
|
||||||
|
return { method: this.serialize(method) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(
|
||||||
|
businessIdRaw: string,
|
||||||
|
dto: CreateStoreLoanMethodDto,
|
||||||
|
actor: AuthUser,
|
||||||
|
) {
|
||||||
|
const businessId = BigInt(businessIdRaw);
|
||||||
|
await this.assertPermission(businessId, actor.id, 'products.update');
|
||||||
|
await this.assertModuleEnabled(businessId);
|
||||||
|
this.assertAmountRanges(dto);
|
||||||
|
this.assertReturnMonths(
|
||||||
|
dto.returnMonthsMin,
|
||||||
|
dto.returnMonthsMax,
|
||||||
|
dto.returnMonthsStep,
|
||||||
|
);
|
||||||
|
|
||||||
|
let imageMediaId: bigint | null = null;
|
||||||
|
if (dto.imageMediaId) {
|
||||||
|
imageMediaId = BigInt(dto.imageMediaId);
|
||||||
|
await this.assertImageMedia(businessId, imageMediaId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const created = await this.prisma.storeLoanMethod.create({
|
||||||
|
data: {
|
||||||
|
businessId,
|
||||||
|
nameFa: dto.nameFa.trim(),
|
||||||
|
nameEn: dto.nameEn.trim(),
|
||||||
|
description: dto.description?.trim() || null,
|
||||||
|
imageMediaId,
|
||||||
|
imageAspectRatio: dto.imageAspectRatio ?? '1:1',
|
||||||
|
minShoppingAmount: dto.minShoppingAmount,
|
||||||
|
minAmount: dto.minAmount,
|
||||||
|
maxAmount: dto.maxAmount,
|
||||||
|
interest: dto.interest,
|
||||||
|
returnMonthsMin: dto.returnMonthsMin,
|
||||||
|
returnMonthsMax: dto.returnMonthsMax,
|
||||||
|
returnMonthsStep: dto.returnMonthsStep,
|
||||||
|
sortOrder: dto.sortOrder ?? 0,
|
||||||
|
isActive: dto.isActive ?? true,
|
||||||
|
},
|
||||||
|
include: { imageMedia: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
message: 'Loan method created successfully',
|
||||||
|
method: this.serialize(created),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(
|
||||||
|
businessIdRaw: string,
|
||||||
|
methodIdRaw: string,
|
||||||
|
dto: UpdateStoreLoanMethodDto,
|
||||||
|
actor: AuthUser,
|
||||||
|
) {
|
||||||
|
const businessId = BigInt(businessIdRaw);
|
||||||
|
const methodId = BigInt(methodIdRaw);
|
||||||
|
await this.assertPermission(businessId, actor.id, 'products.update');
|
||||||
|
await this.assertModuleEnabled(businessId);
|
||||||
|
|
||||||
|
const existing = await this.findOrThrow(businessId, methodId);
|
||||||
|
|
||||||
|
const minShoppingAmount =
|
||||||
|
dto.minShoppingAmount !== undefined
|
||||||
|
? dto.minShoppingAmount
|
||||||
|
: Number(existing.minShoppingAmount);
|
||||||
|
const minAmount =
|
||||||
|
dto.minAmount !== undefined ? dto.minAmount : Number(existing.minAmount);
|
||||||
|
const maxAmount =
|
||||||
|
dto.maxAmount !== undefined ? dto.maxAmount : Number(existing.maxAmount);
|
||||||
|
const returnMonthsMin =
|
||||||
|
dto.returnMonthsMin !== undefined
|
||||||
|
? dto.returnMonthsMin
|
||||||
|
: existing.returnMonthsMin;
|
||||||
|
const returnMonthsMax =
|
||||||
|
dto.returnMonthsMax !== undefined
|
||||||
|
? dto.returnMonthsMax
|
||||||
|
: existing.returnMonthsMax;
|
||||||
|
const returnMonthsStep =
|
||||||
|
dto.returnMonthsStep !== undefined
|
||||||
|
? dto.returnMonthsStep
|
||||||
|
: existing.returnMonthsStep;
|
||||||
|
|
||||||
|
this.assertAmountRanges({
|
||||||
|
minShoppingAmount,
|
||||||
|
minAmount,
|
||||||
|
maxAmount,
|
||||||
|
});
|
||||||
|
this.assertReturnMonths(returnMonthsMin, returnMonthsMax, returnMonthsStep);
|
||||||
|
|
||||||
|
let imageMediaId: bigint | null | undefined = undefined;
|
||||||
|
if (dto.imageMediaId !== undefined) {
|
||||||
|
if (dto.imageMediaId === null || dto.imageMediaId === '') {
|
||||||
|
imageMediaId = null;
|
||||||
|
} else {
|
||||||
|
imageMediaId = BigInt(dto.imageMediaId);
|
||||||
|
await this.assertImageMedia(businessId, imageMediaId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
dto.imageAspectRatio !== undefined &&
|
||||||
|
!STORE_LOAN_IMAGE_ASPECTS.includes(dto.imageAspectRatio)
|
||||||
|
) {
|
||||||
|
throw new BadRequestException('Invalid image aspect ratio');
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await this.prisma.storeLoanMethod.update({
|
||||||
|
where: { id: methodId },
|
||||||
|
data: {
|
||||||
|
...(dto.nameFa !== undefined ? { nameFa: dto.nameFa.trim() } : {}),
|
||||||
|
...(dto.nameEn !== undefined ? { nameEn: dto.nameEn.trim() } : {}),
|
||||||
|
...(dto.description !== undefined
|
||||||
|
? { description: dto.description?.trim() || null }
|
||||||
|
: {}),
|
||||||
|
...(imageMediaId !== undefined ? { imageMediaId } : {}),
|
||||||
|
...(dto.imageAspectRatio !== undefined
|
||||||
|
? { imageAspectRatio: dto.imageAspectRatio }
|
||||||
|
: {}),
|
||||||
|
...(dto.minShoppingAmount !== undefined
|
||||||
|
? { minShoppingAmount: dto.minShoppingAmount }
|
||||||
|
: {}),
|
||||||
|
...(dto.minAmount !== undefined ? { minAmount: dto.minAmount } : {}),
|
||||||
|
...(dto.maxAmount !== undefined ? { maxAmount: dto.maxAmount } : {}),
|
||||||
|
...(dto.interest !== undefined ? { interest: dto.interest } : {}),
|
||||||
|
...(dto.returnMonthsMin !== undefined
|
||||||
|
? { returnMonthsMin: dto.returnMonthsMin }
|
||||||
|
: {}),
|
||||||
|
...(dto.returnMonthsMax !== undefined
|
||||||
|
? { returnMonthsMax: dto.returnMonthsMax }
|
||||||
|
: {}),
|
||||||
|
...(dto.returnMonthsStep !== undefined
|
||||||
|
? { returnMonthsStep: dto.returnMonthsStep }
|
||||||
|
: {}),
|
||||||
|
...(dto.sortOrder !== undefined ? { sortOrder: dto.sortOrder } : {}),
|
||||||
|
...(dto.isActive !== undefined ? { isActive: dto.isActive } : {}),
|
||||||
|
},
|
||||||
|
include: { imageMedia: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
message: 'Loan method updated successfully',
|
||||||
|
method: this.serialize(updated),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(
|
||||||
|
businessIdRaw: string,
|
||||||
|
methodIdRaw: string,
|
||||||
|
actor: AuthUser,
|
||||||
|
) {
|
||||||
|
const businessId = BigInt(businessIdRaw);
|
||||||
|
const methodId = BigInt(methodIdRaw);
|
||||||
|
await this.assertPermission(businessId, actor.id, 'products.update');
|
||||||
|
await this.assertModuleEnabled(businessId);
|
||||||
|
|
||||||
|
await this.findOrThrow(businessId, methodId);
|
||||||
|
await this.prisma.storeLoanMethod.delete({ where: { id: methodId } });
|
||||||
|
|
||||||
|
return { message: 'Loan method deleted successfully' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Public website: active methods when store + store_installments are enabled. */
|
||||||
|
async listPublic(host: string) {
|
||||||
|
const business = await this.tenant.resolveBusinessByDomain(host);
|
||||||
|
const enabled = this.isModuleEnabled(business.settings);
|
||||||
|
|
||||||
|
if (!enabled) {
|
||||||
|
return { enabled: false, items: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = await this.prisma.storeLoanMethod.findMany({
|
||||||
|
where: { businessId: business.id, isActive: true },
|
||||||
|
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'desc' }],
|
||||||
|
include: { imageMedia: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
enabled: true,
|
||||||
|
items: items.map((item) => this.serializePublic(item)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getOnePublic(host: string, methodIdRaw: string) {
|
||||||
|
const business = await this.tenant.resolveBusinessByDomain(host);
|
||||||
|
if (!this.isModuleEnabled(business.settings)) {
|
||||||
|
throw new NotFoundException('Loan method not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const methodId = BigInt(methodIdRaw);
|
||||||
|
const method = await this.prisma.storeLoanMethod.findFirst({
|
||||||
|
where: { id: methodId, businessId: business.id, isActive: true },
|
||||||
|
include: { imageMedia: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!method) {
|
||||||
|
throw new NotFoundException('Loan method not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { method: this.serializePublic(method) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public calculator. Always requires methodId — when a site has multiple
|
||||||
|
* credit methods, the website must ask the shopper which method first.
|
||||||
|
*/
|
||||||
|
async calculatePublic(host: string, dto: CalculateStoreLoanMethodDto) {
|
||||||
|
const business = await this.tenant.resolveBusinessByDomain(host);
|
||||||
|
if (!this.isModuleEnabled(business.settings)) {
|
||||||
|
throw new NotFoundException('Loan method not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const methodId = BigInt(dto.methodId);
|
||||||
|
const method = await this.prisma.storeLoanMethod.findFirst({
|
||||||
|
where: { id: methodId, businessId: business.id, isActive: true },
|
||||||
|
include: { imageMedia: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!method) {
|
||||||
|
throw new NotFoundException('Loan method not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalValue = Math.round(dto.totalValue);
|
||||||
|
const creditAmount = Math.round(dto.creditAmount);
|
||||||
|
const months = Math.round(dto.months);
|
||||||
|
const minShopping = Number(method.minShoppingAmount);
|
||||||
|
const minAmount = Number(method.minAmount);
|
||||||
|
const maxAmount = Number(method.maxAmount);
|
||||||
|
const interestPercent = Number(method.interest);
|
||||||
|
|
||||||
|
if (totalValue < minShopping) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Total purchase must be at least ${minShopping}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (creditAmount < minAmount || creditAmount > maxAmount) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Credit amount must be between ${minAmount} and ${maxAmount}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (creditAmount > totalValue) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Credit amount cannot exceed total purchase value',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const monthOptions = this.listReturnMonthOptions(
|
||||||
|
method.returnMonthsMin,
|
||||||
|
method.returnMonthsMax,
|
||||||
|
method.returnMonthsStep,
|
||||||
|
);
|
||||||
|
if (!monthOptions.includes(months)) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Months must be one of: ${monthOptions.join(', ')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const plan = this.calculatePlan({
|
||||||
|
totalValue,
|
||||||
|
creditAmount,
|
||||||
|
interestPercent,
|
||||||
|
months,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
method: this.serializePublic(method),
|
||||||
|
plan,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private listReturnMonthOptions(min: number, max: number, step: number) {
|
||||||
|
if (min > max || step < 1) return [] as number[];
|
||||||
|
const options: number[] = [];
|
||||||
|
for (let value = min; value <= max; value += step) {
|
||||||
|
options.push(value);
|
||||||
|
}
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flat annual interest prorated by term months:
|
||||||
|
* fee = credit × (interest% / 100) × (months / 12)
|
||||||
|
*/
|
||||||
|
private calculatePlan(input: {
|
||||||
|
totalValue: number;
|
||||||
|
creditAmount: number;
|
||||||
|
interestPercent: number;
|
||||||
|
months: number;
|
||||||
|
startDate?: Date;
|
||||||
|
}) {
|
||||||
|
const cashPayment = input.totalValue - input.creditAmount;
|
||||||
|
const interestFee = Math.round(
|
||||||
|
input.creditAmount *
|
||||||
|
(input.interestPercent / 100) *
|
||||||
|
(input.months / 12),
|
||||||
|
);
|
||||||
|
const totalRepay = input.creditAmount + interestFee;
|
||||||
|
const basePayment = Math.floor(totalRepay / input.months);
|
||||||
|
const remainder = totalRepay - basePayment * input.months;
|
||||||
|
|
||||||
|
const start = input.startDate ? new Date(input.startDate) : new Date();
|
||||||
|
const installments: Array<{
|
||||||
|
index: number;
|
||||||
|
dueDate: string;
|
||||||
|
amount: number;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
for (let index = 1; index <= input.months; index += 1) {
|
||||||
|
const dueDate = new Date(start);
|
||||||
|
dueDate.setMonth(dueDate.getMonth() + index);
|
||||||
|
const amount = basePayment + (index === input.months ? remainder : 0);
|
||||||
|
installments.push({
|
||||||
|
index,
|
||||||
|
dueDate: dueDate.toISOString(),
|
||||||
|
amount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalValue: input.totalValue,
|
||||||
|
creditAmount: input.creditAmount,
|
||||||
|
cashPayment,
|
||||||
|
interestFee,
|
||||||
|
totalRepay,
|
||||||
|
months: input.months,
|
||||||
|
installments,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private serializePublic(method: LoanMethodWithImage) {
|
||||||
|
return {
|
||||||
|
id: method.id.toString(),
|
||||||
|
nameFa: method.nameFa,
|
||||||
|
nameEn: method.nameEn,
|
||||||
|
description: method.description,
|
||||||
|
imageUrl: method.imageMedia?.publicUrl ?? null,
|
||||||
|
imageAspectRatio: method.imageAspectRatio,
|
||||||
|
minShoppingAmount: Number(method.minShoppingAmount),
|
||||||
|
minAmount: Number(method.minAmount),
|
||||||
|
maxAmount: Number(method.maxAmount),
|
||||||
|
interest: Number(method.interest),
|
||||||
|
returnMonthsMin: method.returnMonthsMin,
|
||||||
|
returnMonthsMax: method.returnMonthsMax,
|
||||||
|
returnMonthsStep: method.returnMonthsStep,
|
||||||
|
returnMonthOptions: this.listReturnMonthOptions(
|
||||||
|
method.returnMonthsMin,
|
||||||
|
method.returnMonthsMax,
|
||||||
|
method.returnMonthsStep,
|
||||||
|
),
|
||||||
|
sortOrder: method.sortOrder,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private isModuleEnabled(settingsRaw: unknown) {
|
||||||
|
const settings = normalizeBusinessSettings(settingsRaw);
|
||||||
|
return (
|
||||||
|
settings.modules.enabled.includes('store') &&
|
||||||
|
settings.modules.enabled.includes('store_installments')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async findOrThrow(businessId: bigint, methodId: bigint) {
|
||||||
|
const method = await this.prisma.storeLoanMethod.findFirst({
|
||||||
|
where: { id: methodId, businessId },
|
||||||
|
include: { imageMedia: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!method) {
|
||||||
|
throw new NotFoundException('Loan method not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return method;
|
||||||
|
}
|
||||||
|
|
||||||
|
private serialize(method: LoanMethodWithImage) {
|
||||||
|
return {
|
||||||
|
id: method.id.toString(),
|
||||||
|
businessId: method.businessId.toString(),
|
||||||
|
nameFa: method.nameFa,
|
||||||
|
nameEn: method.nameEn,
|
||||||
|
description: method.description,
|
||||||
|
imageMediaId: method.imageMediaId?.toString() ?? null,
|
||||||
|
imageUrl: method.imageMedia?.publicUrl ?? null,
|
||||||
|
imageAspectRatio: method.imageAspectRatio,
|
||||||
|
minShoppingAmount: Number(method.minShoppingAmount),
|
||||||
|
minAmount: Number(method.minAmount),
|
||||||
|
maxAmount: Number(method.maxAmount),
|
||||||
|
interest: Number(method.interest),
|
||||||
|
returnMonthsMin: method.returnMonthsMin,
|
||||||
|
returnMonthsMax: method.returnMonthsMax,
|
||||||
|
returnMonthsStep: method.returnMonthsStep,
|
||||||
|
sortOrder: method.sortOrder,
|
||||||
|
isActive: method.isActive,
|
||||||
|
createdAt: method.createdAt,
|
||||||
|
updatedAt: method.updatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertAmountRanges(input: {
|
||||||
|
minShoppingAmount: number;
|
||||||
|
minAmount: number;
|
||||||
|
maxAmount: number;
|
||||||
|
}) {
|
||||||
|
if (input.minAmount > input.maxAmount) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Minimum loan amount cannot be greater than maximum loan amount',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertReturnMonths(min: number, max: number, step: number) {
|
||||||
|
if (min > max) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Minimum return months cannot be greater than maximum return months',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(STORE_LOAN_MONTH_STEPS as readonly number[]).includes(step)) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Return months step must be one of: ${STORE_LOAN_MONTH_STEPS.join(', ')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((max - min) % step !== 0) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Maximum return months must be reachable from minimum using the step',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertImageMedia(businessId: bigint, mediaId: bigint) {
|
||||||
|
const media = await this.prisma.media.findFirst({
|
||||||
|
where: { id: mediaId, businessId },
|
||||||
|
select: { id: true, mimeType: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!media) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Loan method image media not found for this business',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!media.mimeType.startsWith('image/')) {
|
||||||
|
throw new BadRequestException('Loan method image must be an image file');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertModuleEnabled(businessId: bigint) {
|
||||||
|
const business = await this.prisma.business.findFirst({
|
||||||
|
where: { id: businessId },
|
||||||
|
select: { settings: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!business) {
|
||||||
|
throw new NotFoundException('Business not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const settings = normalizeBusinessSettings(business.settings);
|
||||||
|
if (
|
||||||
|
!settings.modules.enabled.includes('store') ||
|
||||||
|
!settings.modules.enabled.includes('store_installments')
|
||||||
|
) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
'Installments & facilities module is not enabled for this business',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertPermission(
|
||||||
|
businessId: bigint,
|
||||||
|
userId: bigint,
|
||||||
|
permission: string,
|
||||||
|
) {
|
||||||
|
const allowed = await this.permissions.hasBusinessPermission(
|
||||||
|
userId,
|
||||||
|
businessId,
|
||||||
|
permission,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!allowed) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
`Missing permission: ${permission} for this business`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -473,6 +473,7 @@ export class StoreSpecialsService {
|
|||||||
productNameFa: (content.nameFa as string | null | undefined) ?? '',
|
productNameFa: (content.nameFa as string | null | undefined) ?? '',
|
||||||
productImage,
|
productImage,
|
||||||
isActive: storeItem.isActive,
|
isActive: storeItem.isActive,
|
||||||
|
isUsed: storeItem.isUsed,
|
||||||
sortOrder: storeItem.sortOrder,
|
sortOrder: storeItem.sortOrder,
|
||||||
variants: storeItem.variants.map((variant) => {
|
variants: storeItem.variants.map((variant) => {
|
||||||
const selections = variant.selections.map((selection) => ({
|
const selections = variant.selections.map((selection) => ({
|
||||||
|
|||||||
@@ -10,6 +10,16 @@ import { StoreSpecialsAiService } from './store-specials-ai.service';
|
|||||||
import { StoreSpecialsService } from './store-specials.service';
|
import { StoreSpecialsService } from './store-specials.service';
|
||||||
import { StoreItemsController, PublicStoreItemsController } from './store-items.controller';
|
import { StoreItemsController, PublicStoreItemsController } from './store-items.controller';
|
||||||
import { StoreItemsService } from './store-items.service';
|
import { StoreItemsService } from './store-items.service';
|
||||||
|
import {
|
||||||
|
PublicStoreLoanMethodsController,
|
||||||
|
StoreLoanMethodsController,
|
||||||
|
} from './store-loan-methods.controller';
|
||||||
|
import { StoreLoanMethodsService } from './store-loan-methods.service';
|
||||||
|
import {
|
||||||
|
PublicShippingCostGroupsController,
|
||||||
|
ShippingCostGroupsController,
|
||||||
|
} from './shipping-cost-groups.controller';
|
||||||
|
import { ShippingCostGroupsService } from './shipping-cost-groups.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [AuthModule, TenantModule, WebsiteAnalyticsModule],
|
imports: [AuthModule, TenantModule, WebsiteAnalyticsModule],
|
||||||
@@ -18,8 +28,23 @@ import { StoreItemsService } from './store-items.service';
|
|||||||
PublicStoreItemsController,
|
PublicStoreItemsController,
|
||||||
PublicStoreSpecialsController,
|
PublicStoreSpecialsController,
|
||||||
StoreSpecialsController,
|
StoreSpecialsController,
|
||||||
|
PublicStoreLoanMethodsController,
|
||||||
|
StoreLoanMethodsController,
|
||||||
|
PublicShippingCostGroupsController,
|
||||||
|
ShippingCostGroupsController,
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
StoreItemsService,
|
||||||
|
StoreSpecialsService,
|
||||||
|
StoreSpecialsAiService,
|
||||||
|
StoreLoanMethodsService,
|
||||||
|
ShippingCostGroupsService,
|
||||||
|
],
|
||||||
|
exports: [
|
||||||
|
StoreItemsService,
|
||||||
|
StoreSpecialsService,
|
||||||
|
StoreLoanMethodsService,
|
||||||
|
ShippingCostGroupsService,
|
||||||
],
|
],
|
||||||
providers: [StoreItemsService, StoreSpecialsService, StoreSpecialsAiService],
|
|
||||||
exports: [StoreItemsService, StoreSpecialsService],
|
|
||||||
})
|
})
|
||||||
export class StoreModule {}
|
export class StoreModule {}
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ import {
|
|||||||
normalizeSeoMetaInput,
|
normalizeSeoMetaInput,
|
||||||
readSeoMetaFromMetadata,
|
readSeoMetaFromMetadata,
|
||||||
} from '../common/seo-meta.util';
|
} from '../common/seo-meta.util';
|
||||||
|
import {
|
||||||
|
decodeMultiValueText,
|
||||||
|
encodeMultiValueText,
|
||||||
|
} from '../common/technical-multi-value.util';
|
||||||
import { MediaService } from '../media/media.service';
|
import { MediaService } from '../media/media.service';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import {
|
import {
|
||||||
@@ -1162,6 +1166,21 @@ export class UserProductsService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (field.type === 'multi_value') {
|
||||||
|
if (entry.optionId || entry.optionIds?.length) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Field "${field.label}" expects a text list value`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const items = decodeMultiValueText(entry.textValue);
|
||||||
|
if (field.isRequired && !items.length) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Required field "${field.label}" cannot be empty`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (field.type === 'select') {
|
if (field.type === 'select') {
|
||||||
if (entry.textValue || entry.optionIds?.length) {
|
if (entry.textValue || entry.optionIds?.length) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
@@ -1206,6 +1225,9 @@ export class UserProductsService {
|
|||||||
if (field.type === 'text' || field.type === 'textarea') {
|
if (field.type === 'text' || field.type === 'textarea') {
|
||||||
return Boolean(entry.textValue?.trim());
|
return Boolean(entry.textValue?.trim());
|
||||||
}
|
}
|
||||||
|
if (field.type === 'multi_value') {
|
||||||
|
return decodeMultiValueText(entry.textValue).length > 0;
|
||||||
|
}
|
||||||
if (field.type === 'select') {
|
if (field.type === 'select') {
|
||||||
return Boolean(entry.optionId);
|
return Boolean(entry.optionId);
|
||||||
}
|
}
|
||||||
@@ -1255,6 +1277,20 @@ export class UserProductsService {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (field.type === 'multi_value') {
|
||||||
|
const items = decodeMultiValueText(entry.textValue);
|
||||||
|
if (!items.length) continue;
|
||||||
|
await tx.userProductTechnicalFieldValue.create({
|
||||||
|
data: {
|
||||||
|
businessId,
|
||||||
|
userProductId,
|
||||||
|
fieldId,
|
||||||
|
textValue: encodeMultiValueText(items),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (field.type === 'select') {
|
if (field.type === 'select') {
|
||||||
await tx.userProductTechnicalFieldValue.create({
|
await tx.userProductTechnicalFieldValue.create({
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
@@ -54,8 +54,9 @@ After changing `next.config`, commit, push, and redeploy so PM2 switches to stan
|
|||||||
2. `GET /tenants/{domain}/website/favicon` → `faviconUrl` for `<link rel="icon">` / Next.js `metadata.icons` (falls back to `logoUrl` when no dedicated favicon). Also returns `logoUrl`, `logoDarkUrl`, `hasDedicatedFavicon`.
|
2. `GET /tenants/{domain}/website/favicon` → `faviconUrl` for `<link rel="icon">` / Next.js `metadata.icons` (falls back to `logoUrl` when no dedicated favicon). Also returns `logoUrl`, `logoDarkUrl`, `hasDedicatedFavicon`.
|
||||||
3. Homepage: business-info, **static-images**, sliders, category-groups, brand-groups, store-specials (`source` repeats the tenant setting; items are store listings when `source` is `store_item`)
|
3. Homepage: business-info, **static-images**, sliders, category-groups, brand-groups, store-specials (`source` repeats the tenant setting; items are store listings when `source` is `store_item`)
|
||||||
4. Catalog: categories, products (`GET /products/{slug}` includes `relatedProducts`: same category then same brand, in-stock first), store-items (`name` instant search: in-stock first, then `updatedAt`), **user-products** (customer stock listings). Portfolios list newest first (`sortOrder` desc, then `publishedAt` / `createdAt` desc); each portfolio may include nullable `projectUrl` (external website link). List filters: products/blogs/portfolios/videos accept `?tag=` (exact match on `metadata.tags`).
|
4. Catalog: categories, products (`GET /products/{slug}` includes `relatedProducts`: same category then same brand, in-stock first), store-items (`name` instant search: in-stock first, then `updatedAt`), **user-products** (customer stock listings). Portfolios list newest first (`sortOrder` desc, then `publishedAt` / `createdAt` desc); each portfolio may include nullable `projectUrl` (external website link). List filters: products/blogs/portfolios/videos accept `?tag=` (exact match on `metadata.tags`).
|
||||||
5. Shopping cart on **this** site only: header icon + badge + mini-cart popup. Persist a **guest cart** and **Continue** to `https://customer.<WEBSITE_DOMAIN>` (see Shopping cart). Do not implement login or checkout here.
|
5. **Installments / credit** (only when `enabledModules` includes `store` and `store_installments`, or list returns `enabled: true`): see Installments below.
|
||||||
6. Bank payment callbacks stay on the store apex via nginx (`https://<WEBSITE_DOMAIN>/meshkee/payments/{gateway}/callback`) — infrastructure only. The website app does **not** implement payment or checkout pages; that UI is the customer dashboard.
|
6. Shopping cart on **this** site only: header icon + badge + mini-cart popup. Persist a **guest cart** and **Continue** to `https://customer.<WEBSITE_DOMAIN>` (see Shopping cart). Do not implement login or checkout here.
|
||||||
|
7. Bank payment callbacks stay on the store apex via nginx (`https://<WEBSITE_DOMAIN>/meshkee/payments/{gateway}/callback`) — infrastructure only. The website app does **not** implement payment or checkout pages; that UI is the customer dashboard.
|
||||||
|
|
||||||
### Favicon + logos
|
### Favicon + logos
|
||||||
- `GET /tenants/{domain}/website/favicon` → `{ faviconUrl, logoUrl, logoDarkUrl, hasDedicatedFavicon }`
|
- `GET /tenants/{domain}/website/favicon` → `{ faviconUrl, logoUrl, logoDarkUrl, hasDedicatedFavicon }`
|
||||||
@@ -164,6 +165,34 @@ The dashboard reads `guestCart` (query or hash), then `localStorage`, then the s
|
|||||||
**Wrong:** storefront `/cart` or `/checkout` pages; `POST /businesses/{id}/cart/checkout`; a shop-built login.
|
**Wrong:** storefront `/cart` or `/checkout` pages; `POST /businesses/{id}/cart/checkout`; a shop-built login.
|
||||||
**Right:** local guest mini-cart → redirect to `customer.<WEBSITE_DOMAIN>`.
|
**Right:** local guest mini-cart → redirect to `customer.<WEBSITE_DOMAIN>`.
|
||||||
|
|
||||||
|
### Installments & credit methods
|
||||||
|
|
||||||
|
Optional module (`store` + `store_installments`). Hide the UI when `GET /tenants/{domain}/store-loan-methods` returns `enabled: false` or empty `items`.
|
||||||
|
|
||||||
|
| Step | Endpoint |
|
||||||
|
|------|----------|
|
||||||
|
| 1. List methods | `GET /tenants/{domain}/store-loan-methods` → `{ enabled, items[] }` |
|
||||||
|
| 2. Pick method | **If `items.length > 1`, ask the shopper which method** (name + image). If exactly one, auto-select. Never skip this when multiple exist. |
|
||||||
|
| 3. Collect inputs | Purchase total (`totalValue` ≥ `minShoppingAmount`), credit amount (within `minAmount`–`maxAmount`, ≤ total), months from `returnMonthOptions` / slider with `returnMonthsStep` |
|
||||||
|
| 4. Calculate | `POST /tenants/{domain}/store-loan-methods/calculate` with `{ methodId, totalValue, creditAmount, months }` |
|
||||||
|
|
||||||
|
```json
|
||||||
|
POST /tenants/{domain}/store-loan-methods/calculate
|
||||||
|
{
|
||||||
|
"methodId": "1",
|
||||||
|
"totalValue": 5000000,
|
||||||
|
"creditAmount": 2000000,
|
||||||
|
"months": 12
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Response `{ method, plan }` — `plan` has `cashPayment`, `interestFee`, `totalRepay`, and `installments[]` (`index`, `dueDate`, `amount`). Fee = `round(credit × (interest/100) × (months/12))`.
|
||||||
|
|
||||||
|
Optional: `GET /tenants/{domain}/store-loan-methods/{methodId}` for a single method.
|
||||||
|
|
||||||
|
**Wrong:** calculating without a chosen `methodId`, or auto-picking the first method when several are listed.
|
||||||
|
**Right:** method picker first (when count > 1) → then amounts → calculate.
|
||||||
|
|
||||||
### Checkout & payments (customer dashboard — not this website)
|
### Checkout & payments (customer dashboard — not this website)
|
||||||
|
|
||||||
OpenAPI **Cart** / checkout / payment-method routes are for `https://customer.<WEBSITE_DOMAIN>`, not storefront JavaScript.
|
OpenAPI **Cart** / checkout / payment-method routes are for `https://customer.<WEBSITE_DOMAIN>`, not storefront JavaScript.
|
||||||
@@ -331,6 +360,24 @@ These are **required on every crawlable page** (home, listing, product/blog/port
|
|||||||
**Open Graph (recommended)**
|
**Open Graph (recommended)**
|
||||||
- Set `og:title`, `og:description`, and `og:image` on important pages (home + detail pages) from CMS media when available.
|
- Set `og:title`, `og:description`, and `og:image` on important pages (home + detail pages) from CMS media when available.
|
||||||
|
|
||||||
|
### Site-wide meta tags + trust badges (eNamad / Samandehi)
|
||||||
|
|
||||||
|
Business admins configure this under **Website → Tags & Badges**.
|
||||||
|
|
||||||
|
1. `GET /tenants/{domain}/website/business-info`
|
||||||
|
2. For each item in `metaTags` (array of `{ html }`), emit the full HTML once in the root layout `<head>`.
|
||||||
|
|
||||||
|
```html
|
||||||
|
{html}
|
||||||
|
```
|
||||||
|
|
||||||
|
Example `html` value: `<meta name="google-site-verification" content="…">` (also supports `property`, `http-equiv`, etc.). Parse attributes into a `<meta />` element, or inject the sanitized string into `<head>`. Do **not** wrap it again as `name`/`content` — `html` is already the whole tag. The dashboard “Name” field is an admin label only and is **not** in this public payload.
|
||||||
|
|
||||||
|
3. For each item in `trustBadges` (array of `{ kind, embedHtml }`), render `embedHtml` in the site footer (e.g. `dangerouslySetInnerHTML`). These are HTML widgets only (not meta). Scripts are stripped by the API — still treat the HTML as CMS-controlled content.
|
||||||
|
|
||||||
|
- When arrays are empty, omit tags / footer badges.
|
||||||
|
- Do **not** hardcode eNamad/Samandehi HTML in the website repo when these fields are present.
|
||||||
|
|
||||||
### Site-wide Schema.org JSON-LD (Organization / LocalBusiness)
|
### Site-wide Schema.org JSON-LD (Organization / LocalBusiness)
|
||||||
|
|
||||||
Business admins configure this under **Website → Settings → Structured data**. The API builds a ready `jsonLd` object for you.
|
Business admins configure this under **Website → Settings → Structured data**. The API builds a ready `jsonLd` object for you.
|
||||||
@@ -398,6 +445,7 @@ Website rules:
|
|||||||
4. Public URL is included via CMS sitemap and/or `sitemap-config.json` static pages
|
4. Public URL is included via CMS sitemap and/or `sitemap-config.json` static pages
|
||||||
5. Site-wide Organization/LocalBusiness JSON-LD from `business-info.schema.jsonLd` when enabled
|
5. Site-wide Organization/LocalBusiness JSON-LD from `business-info.schema.jsonLd` when enabled
|
||||||
6. Product/blog detail pages emit `product.schema.jsonLd` / `blog.schema.jsonLd` when present
|
6. Product/blog detail pages emit `product.schema.jsonLd` / `blog.schema.jsonLd` when present
|
||||||
|
7. Site-wide `metaTags` + footer `trustBadges` from business-info when present
|
||||||
|
|
||||||
If OpenAPI and this brief conflict, **OpenAPI wins**.
|
If OpenAPI and this brief conflict, **OpenAPI wins**.
|
||||||
|
|
||||||
|
|||||||
@@ -69,6 +69,10 @@
|
|||||||
"key": "storeItemVariantId",
|
"key": "storeItemVariantId",
|
||||||
"value": ""
|
"value": ""
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"key": "loanMethodId",
|
||||||
|
"value": ""
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"key": "cartItemId",
|
"key": "cartItemId",
|
||||||
"value": ""
|
"value": ""
|
||||||
@@ -1592,6 +1596,52 @@
|
|||||||
"method": "GET",
|
"method": "GET",
|
||||||
"url": "{{baseUrl}}/tenants/{{domain}}/store-specials"
|
"url": "{{baseUrl}}/tenants/{{domain}}/store-specials"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "List store loan methods (website)",
|
||||||
|
"event": [
|
||||||
|
{
|
||||||
|
"listen": "test",
|
||||||
|
"script": {
|
||||||
|
"exec": [
|
||||||
|
"if (pm.response.code === 200) {",
|
||||||
|
" const json = pm.response.json();",
|
||||||
|
" const first = json.items && json.items[0];",
|
||||||
|
" if (first && first.id) pm.collectionVariables.set('loanMethodId', first.id);",
|
||||||
|
"}"
|
||||||
|
],
|
||||||
|
"type": "text/javascript"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"request": {
|
||||||
|
"method": "GET",
|
||||||
|
"url": "{{baseUrl}}/tenants/{{domain}}/store-loan-methods"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Get store loan method (website)",
|
||||||
|
"request": {
|
||||||
|
"method": "GET",
|
||||||
|
"url": "{{baseUrl}}/tenants/{{domain}}/store-loan-methods/{{loanMethodId}}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Calculate store loan plan (website)",
|
||||||
|
"request": {
|
||||||
|
"method": "POST",
|
||||||
|
"header": [
|
||||||
|
{
|
||||||
|
"key": "Content-Type",
|
||||||
|
"value": "application/json"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"body": {
|
||||||
|
"mode": "raw",
|
||||||
|
"raw": "{\n \"methodId\": \"{{loanMethodId}}\",\n \"totalValue\": 5000000,\n \"creditAmount\": 2000000,\n \"months\": 12\n}"
|
||||||
|
},
|
||||||
|
"url": "{{baseUrl}}/tenants/{{domain}}/store-loan-methods/calculate"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -44,6 +44,10 @@
|
|||||||
{
|
{
|
||||||
"name": "Store"
|
"name": "Store"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "Installments",
|
||||||
|
"description": "Credit / installment methods (`store` + `store_installments`). List methods first; if more than one, ask the shopper which method before calculate. POST .../calculate always requires methodId."
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "Torob",
|
"name": "Torob",
|
||||||
"description": "Product API v3 for Torob. Called by Torob (not storefront JS). Nginx on the shop apex proxies POST /torob_api/v3/products. Only tenants with the store module enabled; otherwise 404."
|
"description": "Product API v3 for Torob. Called by Torob (not storefront JS). Nginx on the shop apex proxies POST /torob_api/v3/products. Only tenants with the store module enabled; otherwise 404."
|
||||||
@@ -567,6 +571,38 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": ["enabled", "type", "sameAs", "jsonLd"]
|
"required": ["enabled", "type", "sameAs", "jsonLd"]
|
||||||
|
},
|
||||||
|
"metaTags": {
|
||||||
|
"type": "array",
|
||||||
|
"description": "Custom <meta> HTML snippets for the site <head>. Each item is { html } with a full <meta …> tag. Configured under Website → Tags & Badges (name there is an admin label only).",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"html": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Full sanitized <meta …> HTML tag to inject in <head>."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["html"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"trustBadges": {
|
||||||
|
"type": "array",
|
||||||
|
"description": "Enabled eNamad / Samandehi footer widgets (sanitized HTML). Empty when none are shown.",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"kind": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["enamad", "samandehi"]
|
||||||
|
},
|
||||||
|
"embedHtml": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Trusted HTML snippet (scripts stripped server-side)."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["kind", "embedHtml"]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -835,6 +871,122 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/tenants/{domain}/store-loan-methods": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"Store",
|
||||||
|
"Installments"
|
||||||
|
],
|
||||||
|
"summary": "Active installment / credit methods",
|
||||||
|
"description": "Requires business modules `store` + `store_installments`. When disabled, returns `{ enabled: false, items: [] }`.\n\n**Multi-method UX:** if `items.length > 1`, ask the shopper which method before collecting amounts or calling calculate. If exactly one, auto-select it. Always pass that `methodId` to `POST .../calculate`.",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/parameters/domain"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "{ enabled: boolean, items: StoreLoanMethod[] } — each item has id, nameFa, nameEn, description, imageUrl, imageAspectRatio, minShoppingAmount, minAmount, maxAmount, interest, returnMonthsMin/Max/Step, returnMonthOptions[], sortOrder"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/tenants/{domain}/store-loan-methods/calculate": {
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"Store",
|
||||||
|
"Installments"
|
||||||
|
],
|
||||||
|
"summary": "Calculate installment plan for a chosen method",
|
||||||
|
"description": "`methodId` is **required**. When the site has multiple credit methods, list them first and let the shopper pick one before calling this endpoint.\n\nFee formula (flat annual interest prorated by term): `interestFee = round(creditAmount × (interest/100) × (months/12))`.",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/parameters/domain"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"methodId",
|
||||||
|
"totalValue",
|
||||||
|
"creditAmount",
|
||||||
|
"months"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"methodId": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Chosen method id from GET .../store-loan-methods"
|
||||||
|
},
|
||||||
|
"totalValue": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "Total purchase amount (must be ≥ method.minShoppingAmount)"
|
||||||
|
},
|
||||||
|
"creditAmount": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "Credit portion (within method min/max and ≤ totalValue)"
|
||||||
|
},
|
||||||
|
"months": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Must be in method.returnMonthOptions"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"example": {
|
||||||
|
"methodId": "1",
|
||||||
|
"totalValue": 5000000,
|
||||||
|
"creditAmount": 2000000,
|
||||||
|
"months": 12
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "{ method, plan } — plan has totalValue, creditAmount, cashPayment, interestFee, totalRepay, months, installments[{ index, dueDate, amount }]"
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Validation failed (amount/months out of range)"
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Module off or method not found / inactive"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/tenants/{domain}/store-loan-methods/{methodId}": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"Store",
|
||||||
|
"Installments"
|
||||||
|
],
|
||||||
|
"summary": "One active installment / credit method",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/parameters/domain"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "methodId",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "{ method: StoreLoanMethod }"
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Module off or method not found / inactive"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/tenants/{domain}/categories": {
|
"/tenants/{domain}/categories": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ import {
|
|||||||
} from '../business-profile/business-profile.util';
|
} from '../business-profile/business-profile.util';
|
||||||
import { normalizeBusinessSettings } from '../business-settings/business-settings.util';
|
import { normalizeBusinessSettings } from '../business-settings/business-settings.util';
|
||||||
import { buildWebsiteSchemaJsonLd } from '../business-settings/website-schema.util';
|
import { buildWebsiteSchemaJsonLd } from '../business-settings/website-schema.util';
|
||||||
|
import {
|
||||||
|
toPublicWebsiteMetaTags,
|
||||||
|
toPublicWebsiteTrustBadges,
|
||||||
|
} from '../business-settings/website-tags-badges.util';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { TenantService } from '../tenant/tenant.service';
|
import { TenantService } from '../tenant/tenant.service';
|
||||||
|
|
||||||
@@ -90,6 +94,8 @@ export class WebsiteBusinessInfoService {
|
|||||||
sameAs: schemaSettings.sameAs,
|
sameAs: schemaSettings.sameAs,
|
||||||
jsonLd,
|
jsonLd,
|
||||||
},
|
},
|
||||||
|
metaTags: toPublicWebsiteMetaTags(settings.website.tagsAndBadges),
|
||||||
|
trustBadges: toPublicWebsiteTrustBadges(settings.website.tagsAndBadges),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user