Compare commits
29
Commits
19c0ea25e1
...
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 |
@@ -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
|
||||
Product 1──* ProductTechnicalFieldValue → CategoryTechnicalFormField
|
||||
|
||||
Business 1──* StoreLoanMethod (installments / facilities; module `store_installments`)
|
||||
|
||||
UserProduct *── Category (via CategoryAssignment, entityType user_product → product categories)
|
||||
UserProduct 1──* UserProductTechnicalFieldValue → CategoryTechnicalFormField
|
||||
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 |
|
||||
| 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-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/brand-groups` | Homepage brand rows |
|
||||
| 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 } }`.
|
||||
- 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:**
|
||||
|
||||
```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)
|
||||
- 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 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)
|
||||
- Business team management
|
||||
- Media upload (S3 + Sharp)
|
||||
@@ -667,6 +708,7 @@ See `.env.example` for the full list. Key groups:
|
||||
- Products CRUD
|
||||
- Product variation values (which options a product offers)
|
||||
- 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
|
||||
- Shopping cart + checkout + orders (customer + admin)
|
||||
- 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)**
|
||||
- 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)
|
||||
|
||||
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
|
||||
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
|
||||
7. Site-wide `metaTags` + footer `trustBadges` from business-info when present
|
||||
|
||||
If OpenAPI and this brief conflict, **OpenAPI wins**.
|
||||
|
||||
|
||||
@@ -567,6 +567,38 @@
|
||||
}
|
||||
},
|
||||
"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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,6 +51,7 @@ model User {
|
||||
userProducts UserProduct[]
|
||||
userRoles UserRole[]
|
||||
smsTemplatesVerified SmsTemplate[] @relation("SmsTemplateVerifier")
|
||||
rollCalls RollCall[]
|
||||
|
||||
@@index([cellNumber], map: "idx_users_cell_number")
|
||||
@@map("users")
|
||||
@@ -143,6 +144,7 @@ model Business {
|
||||
expertReviews ExpertReview[]
|
||||
favorites Favorite[]
|
||||
invoiceItemTemplates InvoiceItemTemplate[]
|
||||
invoiceAccountTemplates InvoiceAccountTemplate[]
|
||||
invoiceTemplates InvoiceTemplate[]
|
||||
invoices Invoice[] @relation("InvoiceBusiness")
|
||||
invoicesIssued Invoice[] @relation("InvoiceIssuerBusiness")
|
||||
@@ -159,6 +161,8 @@ model Business {
|
||||
shoppingCards ShoppingCard[]
|
||||
storeItemVariants StoreItemVariant[]
|
||||
storeItems StoreItem[]
|
||||
storeLoanMethods StoreLoanMethod[]
|
||||
shippingCostGroups ShippingCostGroup[]
|
||||
storeSpecials StoreSpecial[]
|
||||
transactions Transaction[]
|
||||
userProductTechnicalFieldValues UserProductTechnicalFieldValue[]
|
||||
@@ -169,6 +173,7 @@ model Business {
|
||||
website_sliders website_sliders[]
|
||||
websitePageViews WebsitePageView[]
|
||||
websitePageViewCounters WebsitePageViewCounter[]
|
||||
rollCalls RollCall[]
|
||||
|
||||
@@map("businesses")
|
||||
}
|
||||
@@ -378,6 +383,7 @@ model Media {
|
||||
oldId BigInt? @map("old_id")
|
||||
blogs blogs[]
|
||||
brandImages Brand[]
|
||||
storeLoanMethodImages StoreLoanMethod[]
|
||||
faviconBusinesses Business[] @relation("BusinessFavicon")
|
||||
logoBusinesses Business[] @relation("BusinessLogo")
|
||||
logoDarkBusinesses Business[] @relation("BusinessLogoDark")
|
||||
@@ -390,6 +396,7 @@ model Media {
|
||||
instructions instructions[]
|
||||
workshops workshops[]
|
||||
featuredProducts Product[] @relation("ProductFeaturedMedia")
|
||||
catalogProducts Product[] @relation("ProductCatalogMedia")
|
||||
featuredUserProducts UserProduct[] @relation("UserProductFeaturedMedia")
|
||||
website_image_slot_items website_image_slot_items[]
|
||||
website_slider_slides website_slider_slides[]
|
||||
@@ -439,6 +446,7 @@ model Category {
|
||||
assignments CategoryAssignment[]
|
||||
technicalForm CategoryTechnicalForm?
|
||||
variations CategoryVariation[]
|
||||
shippingCostGroupCategories ShippingCostGroupCategory[]
|
||||
website_category_group_items website_category_group_items[]
|
||||
|
||||
@@unique([businessId, entityType, slug], map: "categories_business_entity_slug_unique")
|
||||
@@ -521,6 +529,7 @@ model Product {
|
||||
stockQuantity Int? @map("stock_quantity")
|
||||
status ContentStatus @default(draft)
|
||||
featuredMediaId BigInt? @map("featured_media_id")
|
||||
catalogMediaId BigInt? @map("catalog_media_id")
|
||||
sortOrder Int @default(0) @map("sort_order")
|
||||
publishedAt DateTime? @map("published_at") @db.Timestamptz(6)
|
||||
metadata Json @default("{}")
|
||||
@@ -538,6 +547,7 @@ model Product {
|
||||
brand Brand? @relation(fields: [brandId], references: [id], onUpdate: NoAction)
|
||||
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||
featuredMedia Media? @relation("ProductFeaturedMedia", fields: [featuredMediaId], references: [id], onUpdate: NoAction)
|
||||
catalogMedia Media? @relation("ProductCatalogMedia", fields: [catalogMediaId], references: [id], onUpdate: NoAction)
|
||||
shoppingCardItems ShoppingCardItem[]
|
||||
storeItem StoreItem[]
|
||||
|
||||
@@ -546,6 +556,7 @@ model Product {
|
||||
@@index([brandId], map: "idx_products_brand_id")
|
||||
@@index([businessId, status], map: "idx_products_business_status")
|
||||
@@index([businessId, publishedAt(sort: Desc)], map: "idx_products_business_published")
|
||||
@@index([catalogMediaId], map: "idx_products_catalog_media_id")
|
||||
@@map("products")
|
||||
}
|
||||
|
||||
@@ -936,6 +947,7 @@ model City {
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||
parent City? @relation("CityTree", fields: [parentId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||
children City[] @relation("CityTree")
|
||||
shippingCostCityRates ShippingCostGroupCityRate[]
|
||||
userProductsAsCity UserProduct[] @relation("UserProductCity")
|
||||
userProductsAsCountry UserProduct[] @relation("UserProductCountry")
|
||||
userProductsAsDistrict UserProduct[] @relation("UserProductDistrict")
|
||||
@@ -1141,6 +1153,7 @@ model StoreItem {
|
||||
businessId BigInt @map("business_id")
|
||||
productId BigInt @map("product_id")
|
||||
isActive Boolean @default(true) @map("is_active")
|
||||
isUsed Boolean @default(false) @map("is_used")
|
||||
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)
|
||||
@@ -1152,6 +1165,7 @@ model StoreItem {
|
||||
@@unique([businessId, productId], map: "store_items_business_product_unique")
|
||||
@@index([businessId], map: "idx_store_items_business_id")
|
||||
@@index([productId], map: "idx_store_items_product_id")
|
||||
@@index([businessId, isUsed], map: "idx_store_items_business_is_used")
|
||||
@@map("store_items")
|
||||
}
|
||||
|
||||
@@ -1217,6 +1231,84 @@ model StoreSpecial {
|
||||
@@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 {
|
||||
id BigInt @id @default(autoincrement())
|
||||
group_id BigInt
|
||||
@@ -1368,6 +1460,25 @@ model InvoiceItemTemplate {
|
||||
@@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 {
|
||||
id BigInt @id @default(autoincrement())
|
||||
ownerScope InvoiceOwnerScope @map("owner_scope")
|
||||
@@ -1381,6 +1492,7 @@ model InvoiceTemplate {
|
||||
accounts InvoiceTemplateAccount[]
|
||||
items InvoiceTemplateItem[]
|
||||
keyPoints InvoiceTemplateKeyPoint[]
|
||||
paymentMethods InvoiceTemplatePaymentMethod[]
|
||||
business Business? @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||
invoices Invoice[]
|
||||
|
||||
@@ -1421,6 +1533,19 @@ model InvoiceTemplateKeyPoint {
|
||||
@@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 {
|
||||
id BigInt @id @default(autoincrement())
|
||||
templateId BigInt @map("template_id")
|
||||
@@ -1456,6 +1581,7 @@ model Invoice {
|
||||
accounts InvoiceAccount[]
|
||||
items InvoiceItem[]
|
||||
keyPoints InvoiceKeyPoint[]
|
||||
paymentMethods InvoicePaymentMethod[]
|
||||
business Business @relation("InvoiceBusiness", fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||
invoiceTemplate InvoiceTemplate? @relation(fields: [invoiceTemplateId], references: [id], onUpdate: NoAction)
|
||||
issuer User? @relation("InvoiceIssuer", fields: [issuedBy], references: [id], onUpdate: NoAction)
|
||||
@@ -1502,6 +1628,19 @@ model InvoiceKeyPoint {
|
||||
@@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 {
|
||||
id BigInt @id @default(autoincrement())
|
||||
invoiceId BigInt @map("invoice_id")
|
||||
@@ -1603,6 +1742,7 @@ model UserProduct {
|
||||
enum MediaType {
|
||||
image
|
||||
video
|
||||
document
|
||||
|
||||
@@map("media_type")
|
||||
}
|
||||
@@ -1658,6 +1798,7 @@ enum TechnicalFieldType {
|
||||
textarea
|
||||
select
|
||||
multi_select
|
||||
multi_value
|
||||
|
||||
@@map("technical_field_type")
|
||||
}
|
||||
@@ -1823,3 +1964,35 @@ enum TranslationStatus {
|
||||
|
||||
@@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 { SmsTemplatesModule } from './sms-templates/sms-templates.module';
|
||||
import { TorobModule } from './torob/torob.module';
|
||||
import { RollCallsModule } from './roll-calls/roll-calls.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -98,6 +99,7 @@ import { TorobModule } from './torob/torob.module';
|
||||
SitemapModule,
|
||||
SmsTemplatesModule,
|
||||
TorobModule,
|
||||
RollCallsModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
@@ -18,6 +18,9 @@ import { RefreshTokenDto } from './dto/refresh-token.dto';
|
||||
import { RegisterDto } from './dto/register.dto';
|
||||
import { ResetPasswordDto } from './dto/reset-password.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 { UpsertUserAddressDto } from './dto/upsert-user-address.dto';
|
||||
import { VerifyOtpDto } from './dto/verify-otp.dto';
|
||||
@@ -37,6 +40,21 @@ export class AuthController {
|
||||
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')
|
||||
login(@Body() dto: LoginDto) {
|
||||
return this.authService.login(dto);
|
||||
|
||||
+338
-22
@@ -24,6 +24,8 @@ import {
|
||||
import { ChangePasswordDto } from './dto/change-password.dto';
|
||||
import { LoginDto } from './dto/login.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 { PermissionsService } from './permissions.service';
|
||||
import { parseUserProfile } from './profile.util';
|
||||
@@ -33,6 +35,8 @@ import { SmsBillingService } from './sms-billing.service';
|
||||
const OTP_TTL_SECONDS = 300;
|
||||
/** Pending cross-site password change (longer than OTP so resend still works). */
|
||||
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_RATE_LIMIT = 10;
|
||||
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) {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { cellNumber: dto.cellNumber },
|
||||
@@ -430,26 +699,7 @@ export class AuthService {
|
||||
}
|
||||
|
||||
const business = await this.tenant.resolveBusinessByDomain(host);
|
||||
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');
|
||||
}
|
||||
await this.dispatchOtpSms(cellNumber, business);
|
||||
|
||||
return {
|
||||
enabled: true,
|
||||
@@ -545,17 +795,83 @@ export class AuthService {
|
||||
throw new UnauthorizedException('Cell number is not registered');
|
||||
}
|
||||
|
||||
if (!this.sms.isEnabled()) {
|
||||
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()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const storedCode = await this.redis.getOtp(cellNumber);
|
||||
if (!storedCode || storedCode !== code) {
|
||||
throw new UnauthorizedException('Invalid or expired verification code');
|
||||
}
|
||||
|
||||
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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
if (msisdn.length < 6) return '***';
|
||||
return `${msisdn.slice(0, 4)}****${msisdn.slice(-3)}`;
|
||||
|
||||
@@ -130,6 +130,8 @@ export class BusinessAdminService {
|
||||
const nameLike = query.name ? `%${query.name.trim()}%` : null;
|
||||
const domainLike = query.domain ? `%${query.domain.trim()}%` : null;
|
||||
const categoryLike = query.category ? `%${query.category.trim()}%` : null;
|
||||
const sortByRenewal = query.sort === 'renewal';
|
||||
const lowSmsThreshold = 100;
|
||||
|
||||
const where = Prisma.sql`
|
||||
WHERE 1=1
|
||||
@@ -151,7 +153,40 @@ export class BusinessAdminService {
|
||||
` : 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`
|
||||
SELECT
|
||||
b.id AS "id",
|
||||
@@ -205,7 +240,7 @@ export class BusinessAdminService {
|
||||
LIMIT 1
|
||||
) own ON TRUE
|
||||
${where}
|
||||
ORDER BY b.created_at DESC
|
||||
${orderBy}
|
||||
LIMIT ${pageSize} OFFSET ${skip}
|
||||
`),
|
||||
this.prisma.$queryRaw<{ total: number }[]>(Prisma.sql`
|
||||
@@ -213,6 +248,30 @@ export class BusinessAdminService {
|
||||
FROM businesses b
|
||||
${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 {
|
||||
@@ -260,6 +319,13 @@ export class BusinessAdminService {
|
||||
total: totalRow[0]?.total ?? 0,
|
||||
page,
|
||||
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 {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
@@ -9,6 +14,10 @@ import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import { AuthUser } from '../auth/auth.types';
|
||||
import { BusinessCategoriesService } from './business-categories.service';
|
||||
import {
|
||||
CreateBusinessCategoryDto,
|
||||
UpdateBusinessCategoryDto,
|
||||
} from './dto/business-category.dto';
|
||||
|
||||
class ListBusinessCategoriesDto {
|
||||
@IsOptional()
|
||||
@@ -29,4 +38,32 @@ export class BusinessCategoriesController {
|
||||
) {
|
||||
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 { PrismaService } from '../prisma/prisma.service';
|
||||
import { PermissionsService } from '../auth/permissions.service';
|
||||
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()
|
||||
export class BusinessCategoriesService {
|
||||
@@ -12,15 +31,7 @@ export class BusinessCategoriesService {
|
||||
) {}
|
||||
|
||||
async list(actor: AuthUser, q?: string) {
|
||||
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');
|
||||
}
|
||||
await this.assertCanRead(actor);
|
||||
|
||||
const term = q?.trim();
|
||||
if (term) {
|
||||
@@ -88,4 +99,211 @@ export class BusinessCategoriesService {
|
||||
|
||||
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 { IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
|
||||
import { IsIn, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
|
||||
|
||||
export class ListBusinessesDto {
|
||||
@IsOptional()
|
||||
@@ -26,5 +26,10 @@ export class ListBusinessesDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
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)
|
||||
.slice(0, 12)
|
||||
.map((m) => ({ id: m.id, fileName: m.file_name }));
|
||||
const storagePrefix = await this.legacySourceS3.resolveBusinessPrefix(
|
||||
const storagePrefix = await this.tryResolveStoragePrefix(
|
||||
oldBizId,
|
||||
oldSlug,
|
||||
probeMedia,
|
||||
@@ -1417,7 +1417,7 @@ export class LegacyMigrateService {
|
||||
|
||||
for (const media of articleMedia) {
|
||||
const size = Number(media.size) || 0;
|
||||
if (size <= 0) {
|
||||
if (size <= 0 || !storagePrefix) {
|
||||
imagesFailed += 1;
|
||||
continue;
|
||||
}
|
||||
@@ -1611,7 +1611,7 @@ export class LegacyMigrateService {
|
||||
.filter((m) => Number(m.size) > 0)
|
||||
.slice(0, 12)
|
||||
.map((m) => ({ id: m.id, fileName: m.file_name }));
|
||||
const storagePrefix = await this.legacySourceS3.resolveBusinessPrefix(
|
||||
const storagePrefix = await this.tryResolveStoragePrefix(
|
||||
oldBizId,
|
||||
oldSlug,
|
||||
probeMedia,
|
||||
@@ -1648,7 +1648,7 @@ export class LegacyMigrateService {
|
||||
|
||||
for (const media of itemMedia) {
|
||||
const size = Number(media.size) || 0;
|
||||
if (size <= 0) {
|
||||
if (size <= 0 || !storagePrefix) {
|
||||
imagesFailed += 1;
|
||||
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: {
|
||||
businessId: bigint;
|
||||
oldMedia: OldMediaRow;
|
||||
|
||||
@@ -494,7 +494,7 @@ export class BusinessProfileService {
|
||||
province: item.province.trim(),
|
||||
city: item.city.trim(),
|
||||
address: item.address.trim(),
|
||||
postalCode: item.postalCode.trim(),
|
||||
postalCode: item.postalCode?.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) {
|
||||
const media = await this.prisma.media.findFirst({
|
||||
where: { id: mediaId, businessId },
|
||||
@@ -562,10 +562,10 @@ export class BusinessProfileService {
|
||||
|
||||
if (media.width && media.height && media.height > 0) {
|
||||
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) {
|
||||
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;
|
||||
faviconMediaId: string | null;
|
||||
faviconUrl: string | null;
|
||||
/** Recommended aspect ratio 18:4.5 (4:1). */
|
||||
/** Recommended aspect ratio 18:6 (3:1). */
|
||||
bannerMediaId: string | null;
|
||||
bannerUrl: string | null;
|
||||
categoryIds: string[];
|
||||
|
||||
@@ -71,9 +71,10 @@ class BusinessAddressDto {
|
||||
@MinLength(1)
|
||||
address!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
postalCode!: string;
|
||||
@MaxLength(20)
|
||||
postalCode?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@@ -136,7 +137,7 @@ export class UpdateBusinessProfileDto {
|
||||
@IsInt()
|
||||
faviconMediaId?: number | null;
|
||||
|
||||
/** Marketplace/storefront banner; recommended aspect 18:4.5 (4:1). */
|
||||
/** Marketplace/storefront banner; recommended aspect 18:6 (3:1). */
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
toPrismaJson,
|
||||
} from './business-settings.util';
|
||||
import { normalizeWebsiteSchemaSettings } from './website-schema.util';
|
||||
import { normalizeWebsiteTagsAndBadges } from './website-tags-badges.util';
|
||||
import { UpdateBusinessSettingsDto } from './dto/update-business-settings.dto';
|
||||
import { normalizeBusinessPrimaryColorId } from './business-primary-colors';
|
||||
|
||||
@@ -170,6 +171,21 @@ export class BusinessSettingsService {
|
||||
}).store.orderProcessSteps
|
||||
: current.store.orderProcessSteps,
|
||||
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,
|
||||
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;
|
||||
};
|
||||
|
||||
/** Per-province override for the static flat shipping fee. */
|
||||
export type StaticShippingProvinceException = {
|
||||
provinceId: string;
|
||||
amount: number;
|
||||
};
|
||||
|
||||
/** Per-business store / sales settings. */
|
||||
export type StoreSettings = {
|
||||
onlineSellEnabled: boolean;
|
||||
@@ -99,6 +105,14 @@ export type StoreSettings = {
|
||||
torobEnabled: boolean;
|
||||
orderProcessSteps: OrderProcessStep[];
|
||||
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. */
|
||||
@@ -113,6 +127,40 @@ export type WebsiteSettings = {
|
||||
sitemapConfig?: WebsiteSitemapConfig | null;
|
||||
/** Site-wide Schema.org Organization / LocalBusiness settings (Phase A). */
|
||||
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. */
|
||||
@@ -188,6 +236,7 @@ export type WebsiteSitemapConfig = {
|
||||
export const BUSINESS_DASHBOARD_MODULE_IDS = [
|
||||
'products',
|
||||
'store',
|
||||
'store_installments',
|
||||
'portfolio',
|
||||
'blog',
|
||||
'warehouse',
|
||||
@@ -196,6 +245,7 @@ export const BUSINESS_DASHBOARD_MODULE_IDS = [
|
||||
'workshops',
|
||||
'finance',
|
||||
'multilanguage_data',
|
||||
'roll_call',
|
||||
] as const;
|
||||
|
||||
/** 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[] =
|
||||
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] = [
|
||||
@@ -352,6 +406,9 @@ export const DEFAULT_BUSINESS_SETTINGS: BusinessSettings = {
|
||||
torobEnabled: false,
|
||||
orderProcessSteps: DEFAULT_ORDER_PROCESS_STEPS,
|
||||
ePayment: { ...DEFAULT_EPAYMENT_SETTINGS },
|
||||
staticShippingEnabled: true,
|
||||
staticShippingAmount: 0,
|
||||
staticShippingProvinceExceptions: [],
|
||||
},
|
||||
modules: {
|
||||
enabled: DEFAULT_ENABLED_BUSINESS_MODULES,
|
||||
@@ -360,6 +417,11 @@ export const DEFAULT_BUSINESS_SETTINGS: BusinessSettings = {
|
||||
website: {
|
||||
specialProductsSource: 'store_item',
|
||||
schema: { ...DEFAULT_WEBSITE_SCHEMA_SETTINGS },
|
||||
tagsAndBadges: {
|
||||
metaTags: [],
|
||||
enamad: { ...DEFAULT_WEBSITE_TRUST_BADGE },
|
||||
samandehi: { ...DEFAULT_WEBSITE_TRUST_BADGE },
|
||||
},
|
||||
},
|
||||
sms: {
|
||||
senderNumber: null,
|
||||
|
||||
@@ -34,12 +34,14 @@ import {
|
||||
WebsiteSitemapConfig,
|
||||
ZarinpalGatewaySettings,
|
||||
DEFAULT_WEBSITE_SCHEMA_SETTINGS,
|
||||
DEFAULT_WEBSITE_TAGS_AND_BADGES,
|
||||
} from './business-settings.types';
|
||||
import {
|
||||
defaultOrderStepColor,
|
||||
normalizeOrderStepColor,
|
||||
} from './order-step-colors';
|
||||
import { normalizeWebsiteSchemaSettings } from './website-schema.util';
|
||||
import { normalizeWebsiteTagsAndBadges } from './website-tags-badges.util';
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
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;
|
||||
}
|
||||
|
||||
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 = '') {
|
||||
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. */
|
||||
function ensurePaymentProcessSteps(steps: OrderProcessStep[]): OrderProcessStep[] {
|
||||
@@ -431,6 +481,17 @@ export function normalizeBusinessSettings(raw: unknown): BusinessSettings {
|
||||
),
|
||||
orderProcessSteps: readOrderProcessSteps(store.orderProcessSteps),
|
||||
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: {
|
||||
// Missing `modules` → all enabled (legacy). Explicit `{ enabled: [] }` stays empty.
|
||||
@@ -446,6 +507,11 @@ export function normalizeBusinessSettings(raw: unknown): BusinessSettings {
|
||||
schema: normalizeWebsiteSchemaSettings(
|
||||
isRecord(website) ? website.schema : DEFAULT_WEBSITE_SCHEMA_SETTINGS,
|
||||
),
|
||||
tagsAndBadges: normalizeWebsiteTagsAndBadges(
|
||||
isRecord(website)
|
||||
? website.tagsAndBadges
|
||||
: DEFAULT_WEBSITE_TAGS_AND_BADGES,
|
||||
),
|
||||
},
|
||||
sms: {
|
||||
senderNumber: normalizeSmsSenderNumber(sms.senderNumber),
|
||||
@@ -487,6 +553,14 @@ export function mergeBusinessSettings(
|
||||
current.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: {
|
||||
enabled: patch.modules?.enabled ?? current.modules.enabled,
|
||||
@@ -504,6 +578,10 @@ export function mergeBusinessSettings(
|
||||
patch.website?.schema !== undefined
|
||||
? patch.website.schema
|
||||
: current.website.schema,
|
||||
tagsAndBadges:
|
||||
patch.website?.tagsAndBadges !== undefined
|
||||
? patch.website.tagsAndBadges
|
||||
: current.website.tagsAndBadges,
|
||||
},
|
||||
sms: {
|
||||
senderNumber:
|
||||
|
||||
@@ -5,8 +5,11 @@ import {
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsIn,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
Min,
|
||||
MinLength,
|
||||
ValidateIf,
|
||||
ValidateNested,
|
||||
@@ -163,6 +166,17 @@ class EPaymentSettingsDto {
|
||||
gateways?: PaymentGatewaysSettingsDto;
|
||||
}
|
||||
|
||||
class StaticShippingProvinceExceptionDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
provinceId!: string;
|
||||
|
||||
@Type(() => Number)
|
||||
@IsNumber({ maxDecimalPlaces: 2 })
|
||||
@Min(0)
|
||||
amount!: number;
|
||||
}
|
||||
|
||||
class StoreSettingsDto {
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
@@ -182,6 +196,22 @@ class StoreSettingsDto {
|
||||
@ValidateNested()
|
||||
@Type(() => 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 {
|
||||
@@ -233,6 +263,51 @@ class WebsiteSchemaSettingsDto {
|
||||
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 {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@@ -243,6 +318,11 @@ class WebsiteSettingsDto {
|
||||
@ValidateNested()
|
||||
@Type(() => WebsiteSchemaSettingsDto)
|
||||
schema?: WebsiteSchemaSettingsDto;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => WebsiteTagsAndBadgesDto)
|
||||
tagsAndBadges?: WebsiteTagsAndBadgesDto;
|
||||
}
|
||||
|
||||
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 { OrdersModule } from '../orders/orders.module';
|
||||
import { PaymentsModule } from '../payments/payments.module';
|
||||
import { StoreModule } from '../store/store.module';
|
||||
import { CartController } from './cart.controller';
|
||||
import { CartService } from './cart.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule, OrdersModule, PaymentsModule, BusinessSettingsModule],
|
||||
imports: [
|
||||
AuthModule,
|
||||
OrdersModule,
|
||||
PaymentsModule,
|
||||
BusinessSettingsModule,
|
||||
StoreModule,
|
||||
],
|
||||
controllers: [CartController],
|
||||
providers: [CartService],
|
||||
})
|
||||
|
||||
+114
-10
@@ -20,6 +20,7 @@ import { TransactionType } from '@prisma/client';
|
||||
import { BusinessSettingsService } from '../business-settings/business-settings.service';
|
||||
import type { PaymentGatewayId } from '../business-settings/business-settings.types';
|
||||
import { listPublicPaymentGateways } from '../business-settings/business-settings.util';
|
||||
import { ShippingCostGroupsService } from '../store/shipping-cost-groups.service';
|
||||
|
||||
const cartVariantInclude = {
|
||||
storeItem: {
|
||||
@@ -59,6 +60,7 @@ export class CartService {
|
||||
private readonly orders: OrdersService,
|
||||
private readonly payments: PaymentsService,
|
||||
private readonly businessSettings: BusinessSettingsService,
|
||||
private readonly shippingCostGroups: ShippingCostGroupsService,
|
||||
) {}
|
||||
|
||||
async getCart(businessIdRaw: string, actor: AuthUser) {
|
||||
@@ -66,7 +68,7 @@ export class CartService {
|
||||
await this.assertCustomerAccess(businessId, actor);
|
||||
|
||||
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) {
|
||||
@@ -97,7 +99,7 @@ export class CartService {
|
||||
const refreshed = await this.loadCart(cart.id);
|
||||
return {
|
||||
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);
|
||||
return {
|
||||
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);
|
||||
return {
|
||||
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);
|
||||
return {
|
||||
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);
|
||||
return {
|
||||
message: 'Cart cleared',
|
||||
cart: this.serializeCart(refreshed),
|
||||
cart: await this.serializeCart(refreshed),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -204,6 +206,71 @@ export class CartService {
|
||||
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;
|
||||
let gatewayType: PaymentGatewayId | undefined;
|
||||
|
||||
@@ -234,7 +301,8 @@ export class CartService {
|
||||
createdBy: actor.id,
|
||||
source: 'website',
|
||||
cartItems: cart.items,
|
||||
shippingAddress,
|
||||
shippingAddress: enrichedShippingAddress,
|
||||
shippingTotal: shippingQuote.total,
|
||||
addressId: dto.addressId ? BigInt(dto.addressId) : null,
|
||||
customerNotes: dto.customerNotes?.trim() || null,
|
||||
adminNotes: null,
|
||||
@@ -393,8 +461,40 @@ export class CartService {
|
||||
}
|
||||
}
|
||||
|
||||
private serializeCart(cart: CartWithItems) {
|
||||
const items = cart.items.map((item) => this.serializeCartItem(item));
|
||||
private async serializeCart(cart: CartWithItems) {
|
||||
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);
|
||||
|
||||
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 product = variant.storeItem.product;
|
||||
const content = this.asRecord(product.content);
|
||||
@@ -431,6 +534,7 @@ export class CartService {
|
||||
productTitle: product.title,
|
||||
productNameFa: (content.nameFa as string | null | undefined) ?? '',
|
||||
productImage: product.featuredMedia?.publicUrl ?? null,
|
||||
categoryIds,
|
||||
sku: variant.sku,
|
||||
selections,
|
||||
label: selections.map((entry) => entry.value).join(' · ') || product.title,
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import {
|
||||
IsArray,
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUrl,
|
||||
@@ -53,6 +56,35 @@ export class ShippingAddressDto {
|
||||
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 {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@@ -64,6 +96,16 @@ export class CheckoutCartDto {
|
||||
@Type(() => ShippingAddressDto)
|
||||
shippingAddress?: ShippingAddressDto;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['delivery', 'pickup'])
|
||||
deliveryMode?: 'delivery' | 'pickup';
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => CheckoutShippingGroupDto)
|
||||
shippingGroups?: CheckoutShippingGroupDto[];
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
customerNotes?: string;
|
||||
|
||||
@@ -105,7 +105,7 @@ Rules:
|
||||
- Use 2-6 top-level categories when appropriate.
|
||||
- Add subcategories only where they help shoppers browse (max depth ${MAX_DEPTH}).
|
||||
- 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.
|
||||
- Total nodes must not exceed ${MAX_TOTAL}.`;
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
requestAiJsonCompletion,
|
||||
resolveAiProvider,
|
||||
} from '../common/ai-provider.util';
|
||||
import { loadReferencePagesFromText } from '../common/ai-reference-pages.util';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { CategoryTechnicalFormService } from './category-technical-form.service';
|
||||
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()
|
||||
export class CategoryTechnicalFormAiService {
|
||||
@@ -86,30 +102,47 @@ export class CategoryTechnicalFormAiService {
|
||||
existingFieldCount: number;
|
||||
}): Promise<TechnicalFormFieldInputDto[]> {
|
||||
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.
|
||||
Return ONLY valid JSON with this shape:
|
||||
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.',
|
||||
);
|
||||
}
|
||||
|
||||
const systemPrompt = `تو طراح فرم مشخصات فنی برای دستهبندیهای فروشگاهی در یک مارکتپلیس ایرانی هستی.
|
||||
فقط JSON معتبر با این شکل برگردان:
|
||||
{
|
||||
"fields": [
|
||||
{
|
||||
"label": "Field label in English",
|
||||
"type": "text | textarea | select | multi_select",
|
||||
"label": "برچسب فیلد",
|
||||
"type": "text | textarea | select | multi_select | multi_value",
|
||||
"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.
|
||||
- select and multi_select fields must include 3-8 realistic options.
|
||||
- text fields are for short values (dimensions, weight, model year).
|
||||
- textarea fields are for longer specs (features, care instructions).
|
||||
- Mark important buyer-facing specs as required.
|
||||
- Labels must be unique and human-readable.
|
||||
- Do not include price, stock, SKU, or warranty period fields.
|
||||
- Prefer metric units common in Iran when relevant.`;
|
||||
قوانین:
|
||||
- بین ۵ تا ۱۱ فیلد کاربردی تولید کن که خریداران برای محصولات این دسته نیاز دارند (سیستم همیشه «سایر ویژگیها» را اضافه میکند).
|
||||
- ترجیحاً از type=text استفاده کن تا فروشنده بتواند هر مقدار دلخواهی را آزادانه وارد کند.
|
||||
- از select و multi_select حتیالمقدور استفاده نکن. فقط وقتی مجموعه گزینهها واقعاً ثابت، کوتاه و استاندارد است (مثلاً بله/خیر یا حداکثر چند گزینه قطعی) مجازند؛ برای ظرفیت، توان، ابعاد، مدل، جنس، رنگ آزاد و مشابه آنها همیشه text بگذار.
|
||||
- اگر بین text و select مردد هستی، حتماً text انتخاب کن.
|
||||
- فیلدهای select و multi_select (در صورت استفاده) باید ۳ تا ۸ گزینه واقعی داشته باشند.
|
||||
- multi_value یک لیست تکراری از مقادیر متنی آزاد است (بدون گزینه) برای مقادیر کوتاه چندتایی.
|
||||
- text برای مقادیر کوتاه (ابعاد، وزن، سال مدل، ظرفیت، توان).
|
||||
- textarea برای مشخصات طولانیتر (ویژگیها، دستورالعمل نگهداری).
|
||||
- اگر برای این دسته مناسب است، فیلدهای «ابعاد» و «وزن» را بهصورت text اضافه کن (واحد متریک رایج ایران را در برچسب بیاور، مثلاً سانتیمتر / کیلوگرم). اگر برای دسته بیمعناست، اضافه نکن.
|
||||
- مشخصات مهم خریدار را الزامی علامت بزن.
|
||||
- برچسبها باید یکتا و خوانا باشند.
|
||||
- همیشه برچسبها و گزینهها را به فارسی بنویس.
|
||||
- فیلدهای قیمت، موجودی، SKU یا مدت گارانتی نساز.
|
||||
- در صورت مرتبط بودن از واحدهای متریک رایج در ایران استفاده کن.
|
||||
- اگر referencePages داده شده، آنها منبع حقیقت هستند: فیلدهایی بساز که با مشخصات واقعی همان صفحات همخوان باشد.
|
||||
- مشخصات اصلی نزدیک عنوان محصول را ترجیح بده؛ بلوکهای محصول مرتبط/آپسل را نادیده بگیر.
|
||||
- وقتی صفحه مرجع هست از حافظه فیلد اختراع نکن — همان چیزی را بازتاب بده که صفحه واقعاً دارد.
|
||||
- یک فیلد «سایر ویژگیها» خودت نساز؛ سیستم آن را بهصورت multi_value اضافه میکند.`;
|
||||
|
||||
const userPrompt = JSON.stringify({
|
||||
category: input.categoryName,
|
||||
@@ -119,11 +152,23 @@ Rules:
|
||||
input.existingFieldCount > 0
|
||||
? 'Category already has a form; suggest a fresh complete replacement set.'
|
||||
: '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;
|
||||
try {
|
||||
content = await requestAiJsonCompletion(provider, systemPrompt, userPrompt);
|
||||
content = await requestAiJsonCompletion(
|
||||
provider,
|
||||
systemPrompt,
|
||||
userPrompt,
|
||||
usableReferences.length > 0 ? 0.2 : 0.4,
|
||||
);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'AI request failed';
|
||||
throw new BadRequestException(message);
|
||||
@@ -148,7 +193,8 @@ Rules:
|
||||
const label = String(raw.label ?? '').trim();
|
||||
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;
|
||||
labels.add(normalizedLabel);
|
||||
|
||||
@@ -172,9 +218,15 @@ Rules:
|
||||
}
|
||||
|
||||
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) {
|
||||
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;
|
||||
}
|
||||
|
||||
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 = {
|
||||
id: bigint;
|
||||
name: string;
|
||||
@@ -126,6 +151,10 @@ export class CategoryVariationsService {
|
||||
const uniqueValues = [
|
||||
...new Set(variation.values.map((v) => v.trim()).filter(Boolean)),
|
||||
];
|
||||
const hexByLabel = buildColorHexByLabel(
|
||||
variation.values,
|
||||
variation.colorHexes,
|
||||
);
|
||||
|
||||
if (current) {
|
||||
matchedExistingIds.add(current.id);
|
||||
@@ -137,7 +166,7 @@ export class CategoryVariationsService {
|
||||
sortOrder: index,
|
||||
},
|
||||
});
|
||||
await this.syncOptions(tx, current, variation.type, uniqueValues);
|
||||
await this.syncOptions(tx, current, variation.type, uniqueValues, hexByLabel);
|
||||
} else {
|
||||
const created = await tx.categoryVariation.create({
|
||||
data: {
|
||||
@@ -148,7 +177,13 @@ export class CategoryVariationsService {
|
||||
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,
|
||||
type: CategoryVariationInputDto['type'],
|
||||
labels: string[],
|
||||
hexByLabel: Map<string, string> = new Map(),
|
||||
) {
|
||||
const usedSlugs = new Set<string>();
|
||||
const matchedOptionIds = new Set<bigint>();
|
||||
@@ -215,7 +251,8 @@ export class CategoryVariationsService {
|
||||
|
||||
const colorHex =
|
||||
type === 'color'
|
||||
? resolveColorHex(label) ??
|
||||
? hexByLabel.get(label) ??
|
||||
resolveColorHex(label) ??
|
||||
existingOption?.colorHex ??
|
||||
(existingOption && isHexColor(existingOption.value)
|
||||
? existingOption.value.trim()
|
||||
@@ -270,6 +307,7 @@ export class CategoryVariationsService {
|
||||
variationId: bigint,
|
||||
type: CategoryVariationInputDto['type'],
|
||||
labels: string[],
|
||||
hexByLabel: Map<string, string> = new Map(),
|
||||
) {
|
||||
const usedSlugs = new Set<string>();
|
||||
await tx.categoryVariationOption.createMany({
|
||||
@@ -277,7 +315,10 @@ export class CategoryVariationsService {
|
||||
variationId,
|
||||
label,
|
||||
value: uniqueSlug(slugifyValue(label), usedSlugs, optionIndex + 1),
|
||||
colorHex: type === 'color' ? resolveColorHex(label) : null,
|
||||
colorHex:
|
||||
type === 'color'
|
||||
? hexByLabel.get(label) ?? resolveColorHex(label)
|
||||
: null,
|
||||
sortOrder: optionIndex,
|
||||
})),
|
||||
});
|
||||
|
||||
@@ -17,8 +17,8 @@ export class TechnicalFormFieldInputDto {
|
||||
label!: string;
|
||||
|
||||
@IsString()
|
||||
@IsIn(['text', 'textarea', 'select', 'multi_select'])
|
||||
type!: 'text' | 'textarea' | 'select' | 'multi_select';
|
||||
@IsIn(['text', 'textarea', 'select', 'multi_select', 'multi_value'])
|
||||
type!: 'text' | 'textarea' | 'select' | 'multi_select' | 'multi_value';
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
ArrayMinSize,
|
||||
IsArray,
|
||||
IsIn,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MinLength,
|
||||
ValidateNested,
|
||||
@@ -21,6 +22,11 @@ export class CategoryVariationInputDto {
|
||||
@ArrayMinSize(1)
|
||||
@IsString({ each: true })
|
||||
values!: string[];
|
||||
|
||||
/** Optional parallel list of hex colors for `color` variations (same order as values). */
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
colorHexes?: (string | null)[];
|
||||
}
|
||||
|
||||
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 { AuthUser } from '../auth/auth.types';
|
||||
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 { TenantService } from '../tenant/tenant.service';
|
||||
import { isLikelySpamContactSubmission } from './contact-submission-spam';
|
||||
import { CreateContactSubmissionDto } from './dto/create-contact-submission.dto';
|
||||
import { ListContactSubmissionsDto } from './dto/list-contact-submissions.dto';
|
||||
|
||||
@@ -19,32 +29,164 @@ type ContactSubmissionRow = {
|
||||
|
||||
@Injectable()
|
||||
export class ContactSubmissionsService {
|
||||
private readonly logger = new Logger(ContactSubmissionsService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly permissions: PermissionsService,
|
||||
private readonly tenant: TenantService,
|
||||
private readonly sms: SmsService,
|
||||
private readonly smsBilling: SmsBillingService,
|
||||
) {}
|
||||
|
||||
async createPublic(host: string, dto: CreateContactSubmissionDto) {
|
||||
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({
|
||||
data: {
|
||||
businessId: business.id,
|
||||
title: dto.title.trim(),
|
||||
name: dto.name.trim(),
|
||||
email: dto.email?.trim() || null,
|
||||
cellNumber: dto.cellNumber?.trim() || null,
|
||||
text: dto.text.trim(),
|
||||
title,
|
||||
name,
|
||||
email,
|
||||
cellNumber,
|
||||
text,
|
||||
},
|
||||
});
|
||||
|
||||
const businessLabel =
|
||||
business.nameFa?.trim() || business.name?.trim() || undefined;
|
||||
await this.notifyContactSubmissionSms({
|
||||
businessId: business.id,
|
||||
businessLabel,
|
||||
contactName: created.name,
|
||||
contacterCellNumber: created.cellNumber,
|
||||
});
|
||||
|
||||
return {
|
||||
submission: this.serialize(created),
|
||||
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(
|
||||
businessIdRaw: string,
|
||||
query: ListContactSubmissionsDto,
|
||||
|
||||
@@ -939,22 +939,25 @@ export class DomainAdminService {
|
||||
let ed25519;
|
||||
let rsa;
|
||||
try {
|
||||
if (dto.ed25519Key?.trim()) {
|
||||
ed25519 = parseDkimPaste(dto.ed25519Key, 'ed25519', dto.ed25519Selector);
|
||||
}
|
||||
rsa = parseDkimPaste(dto.rsaKey, 'rsa', dto.rsaSelector);
|
||||
} catch (err) {
|
||||
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.');
|
||||
}
|
||||
|
||||
const keys = ed25519 ? [ed25519, rsa] : [rsa];
|
||||
const provider = dto.dnsProvider === 'cloudflare' ? 'cloudflare' : 'arvan';
|
||||
const host = domain.host.trim().toLowerCase();
|
||||
try {
|
||||
const result =
|
||||
provider === 'cloudflare'
|
||||
? await this.cloudflareDns.ensureMailRecords(host, [ed25519, rsa])
|
||||
: await this.arvanDns.ensureMailRecords(host, [ed25519, rsa]);
|
||||
? await this.cloudflareDns.ensureMailRecords(host, keys)
|
||||
: await this.arvanDns.ensureMailRecords(host, keys);
|
||||
|
||||
const parts = [
|
||||
result.created.length ? `created ${result.created.join(', ')}` : '',
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import { IsIn, IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
|
||||
import { IsIn, IsOptional, IsString, MaxLength, MinLength, ValidateIf } from 'class-validator';
|
||||
import { PARKED_DNS_PROVIDERS } from '../parked-host.util';
|
||||
|
||||
export class ApplyMailDnsDto {
|
||||
@IsIn(PARKED_DNS_PROVIDERS)
|
||||
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()
|
||||
@MinLength(20)
|
||||
@MaxLength(8000)
|
||||
ed25519Key!: string;
|
||||
ed25519Key?: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(20)
|
||||
|
||||
@@ -57,6 +57,12 @@ export class InvoiceKeyPointInputDto {
|
||||
text!: string;
|
||||
}
|
||||
|
||||
export class InvoicePaymentMethodInputDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
text!: string;
|
||||
}
|
||||
|
||||
export class InvoiceAccountInputDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@@ -114,6 +120,12 @@ export class CreateInvoiceDto {
|
||||
@Type(() => InvoiceKeyPointInputDto)
|
||||
keyPoints?: InvoiceKeyPointInputDto[];
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => InvoicePaymentMethodInputDto)
|
||||
paymentMethods?: InvoicePaymentMethodInputDto[];
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@@ -134,7 +146,7 @@ export class UpdateInvoiceStatusDto {
|
||||
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 {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@@ -169,6 +181,12 @@ export class UpdateInvoiceDto {
|
||||
@Type(() => InvoiceKeyPointInputDto)
|
||||
keyPoints?: InvoiceKeyPointInputDto[];
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => InvoicePaymentMethodInputDto)
|
||||
paymentMethods?: InvoicePaymentMethodInputDto[];
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@@ -279,6 +297,65 @@ export class UpdateInvoiceItemTemplateDto {
|
||||
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 {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@@ -337,6 +414,12 @@ export class CreateInvoiceTemplateDto {
|
||||
@Type(() => InvoiceKeyPointInputDto)
|
||||
keyPoints?: InvoiceKeyPointInputDto[];
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => InvoicePaymentMethodInputDto)
|
||||
paymentMethods?: InvoicePaymentMethodInputDto[];
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@@ -373,6 +456,12 @@ export class UpdateInvoiceTemplateDto {
|
||||
@Type(() => InvoiceKeyPointInputDto)
|
||||
keyPoints?: InvoiceKeyPointInputDto[];
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => InvoicePaymentMethodInputDto)
|
||||
paymentMethods?: InvoicePaymentMethodInputDto[];
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
|
||||
@@ -20,9 +20,11 @@ import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import { AuthUser } from '../auth/auth.types';
|
||||
import {
|
||||
CreateInvoiceDto,
|
||||
CreateInvoiceAccountTemplateDto,
|
||||
CreateInvoiceItemTemplateDto,
|
||||
CreateInvoiceTemplateDto,
|
||||
ListInvoicesDto,
|
||||
UpdateInvoiceAccountTemplateDto,
|
||||
UpdateInvoiceDto,
|
||||
UpdateInvoiceItemTemplateDto,
|
||||
UpdateInvoiceStatusDto,
|
||||
@@ -63,6 +65,41 @@ export class InvoicesController {
|
||||
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)
|
||||
@Get('invoice-templates')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@@ -237,6 +274,47 @@ export class BusinessInvoicesController {
|
||||
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)
|
||||
@Get('invoice-templates')
|
||||
@RequireBusinessPermission('invoice_templates.read')
|
||||
|
||||
@@ -11,13 +11,16 @@ import { PermissionsService } from '../auth/permissions.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import {
|
||||
CreateInvoiceDto,
|
||||
CreateInvoiceAccountTemplateDto,
|
||||
CreateInvoiceItemTemplateDto,
|
||||
CreateInvoiceTemplateDto,
|
||||
InvoiceAccountInputDto,
|
||||
InvoiceItemInputDto,
|
||||
InvoiceKeyPointInputDto,
|
||||
InvoicePaymentMethodInputDto,
|
||||
InvoiceTemplateItemInputDto,
|
||||
ListInvoicesDto,
|
||||
UpdateInvoiceAccountTemplateDto,
|
||||
UpdateInvoiceDto,
|
||||
UpdateInvoiceItemTemplateDto,
|
||||
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 = {
|
||||
business: {
|
||||
select: {
|
||||
@@ -250,12 +281,14 @@ export class InvoicesService {
|
||||
user: { select: { id: true, firstName: true, lastName: true, cellNumber: true } },
|
||||
items: { 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 }] },
|
||||
};
|
||||
|
||||
private readonly invoiceTemplateInclude = {
|
||||
items: { 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 }] },
|
||||
};
|
||||
|
||||
@@ -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: {
|
||||
id: bigint;
|
||||
bankName: string;
|
||||
@@ -343,6 +388,11 @@ export class InvoicesService {
|
||||
text: string;
|
||||
sortOrder: number;
|
||||
}>;
|
||||
paymentMethods?: Array<{
|
||||
id: bigint;
|
||||
text: string;
|
||||
sortOrder: number;
|
||||
}>;
|
||||
accounts?: Array<{
|
||||
id: bigint;
|
||||
bankName: string;
|
||||
@@ -372,6 +422,10 @@ export class InvoicesService {
|
||||
includeNested && row.keyPoints
|
||||
? row.keyPoints.map((kp) => this.serializeKeyPoint(kp))
|
||||
: undefined,
|
||||
paymentMethods:
|
||||
includeNested && row.paymentMethods
|
||||
? row.paymentMethods.map((pm) => this.serializePaymentMethod(pm))
|
||||
: undefined,
|
||||
accounts:
|
||||
includeNested && row.accounts
|
||||
? row.accounts.map((acc) => this.serializeAccount(acc))
|
||||
@@ -488,6 +542,11 @@ export class InvoicesService {
|
||||
text: string;
|
||||
sortOrder: number;
|
||||
}>;
|
||||
paymentMethods?: Array<{
|
||||
id: bigint;
|
||||
text: string;
|
||||
sortOrder: number;
|
||||
}>;
|
||||
accounts?: Array<{
|
||||
id: bigint;
|
||||
bankName: string;
|
||||
@@ -564,6 +623,10 @@ export class InvoicesService {
|
||||
includeNested && row.keyPoints
|
||||
? row.keyPoints.map((kp) => this.serializeKeyPoint(kp))
|
||||
: undefined,
|
||||
paymentMethods:
|
||||
includeNested && row.paymentMethods
|
||||
? row.paymentMethods.map((pm) => this.serializePaymentMethod(pm))
|
||||
: undefined,
|
||||
accounts:
|
||||
includeNested && row.accounts
|
||||
? row.accounts.map((acc) => this.serializeAccount(acc))
|
||||
@@ -581,6 +644,14 @@ export class InvoicesService {
|
||||
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) {
|
||||
const bankName = account.bankName.trim();
|
||||
if (!bankName) {
|
||||
@@ -753,6 +824,83 @@ export class InvoicesService {
|
||||
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) ---
|
||||
|
||||
async listPlatformInvoiceTemplates(actor: AuthUser) {
|
||||
@@ -793,6 +941,9 @@ export class InvoicesService {
|
||||
|
||||
const items = dto.items.map((item) => this.normalizeTemplateItemInput(item));
|
||||
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 row = await this.prisma.invoiceTemplate.create({
|
||||
@@ -819,6 +970,12 @@ export class InvoicesService {
|
||||
sortOrder: index,
|
||||
})),
|
||||
},
|
||||
paymentMethods: {
|
||||
create: paymentMethods.map((pm, index) => ({
|
||||
text: pm.text,
|
||||
sortOrder: index,
|
||||
})),
|
||||
},
|
||||
accounts: {
|
||||
create: accounts.map((acc, index) => ({
|
||||
bankName: acc.bankName,
|
||||
@@ -856,6 +1013,10 @@ export class InvoicesService {
|
||||
dto.keyPoints !== undefined
|
||||
? dto.keyPoints.map((kp) => this.normalizeKeyPointInput(kp))
|
||||
: null;
|
||||
const replacePaymentMethods =
|
||||
dto.paymentMethods !== undefined
|
||||
? dto.paymentMethods.map((pm) => this.normalizePaymentMethodInput(pm))
|
||||
: null;
|
||||
const replaceAccounts =
|
||||
dto.accounts !== undefined
|
||||
? dto.accounts.map((acc) => this.normalizeAccountInput(acc))
|
||||
@@ -868,6 +1029,9 @@ export class InvoicesService {
|
||||
if (replaceKeyPoints !== null) {
|
||||
await tx.invoiceTemplateKeyPoint.deleteMany({ where: { templateId } });
|
||||
}
|
||||
if (replacePaymentMethods !== null) {
|
||||
await tx.invoiceTemplatePaymentMethod.deleteMany({ where: { templateId } });
|
||||
}
|
||||
if (replaceAccounts !== null) {
|
||||
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
|
||||
? {
|
||||
accounts: {
|
||||
@@ -1062,6 +1236,105 @@ export class InvoicesService {
|
||||
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) ---
|
||||
|
||||
async listBusinessInvoiceTemplates(businessIdRaw: string, actor: AuthUser) {
|
||||
@@ -1116,6 +1389,9 @@ export class InvoicesService {
|
||||
|
||||
const items = dto.items.map((item) => this.normalizeTemplateItemInput(item));
|
||||
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 row = await this.prisma.invoiceTemplate.create({
|
||||
@@ -1143,6 +1419,12 @@ export class InvoicesService {
|
||||
sortOrder: index,
|
||||
})),
|
||||
},
|
||||
paymentMethods: {
|
||||
create: paymentMethods.map((pm, index) => ({
|
||||
text: pm.text,
|
||||
sortOrder: index,
|
||||
})),
|
||||
},
|
||||
accounts: {
|
||||
create: accounts.map((acc, index) => ({
|
||||
bankName: acc.bankName,
|
||||
@@ -1183,6 +1465,10 @@ export class InvoicesService {
|
||||
dto.keyPoints !== undefined
|
||||
? dto.keyPoints.map((kp) => this.normalizeKeyPointInput(kp))
|
||||
: null;
|
||||
const replacePaymentMethods =
|
||||
dto.paymentMethods !== undefined
|
||||
? dto.paymentMethods.map((pm) => this.normalizePaymentMethodInput(pm))
|
||||
: null;
|
||||
const replaceAccounts =
|
||||
dto.accounts !== undefined
|
||||
? dto.accounts.map((acc) => this.normalizeAccountInput(acc))
|
||||
@@ -1195,6 +1481,9 @@ export class InvoicesService {
|
||||
if (replaceKeyPoints !== null) {
|
||||
await tx.invoiceTemplateKeyPoint.deleteMany({ where: { templateId } });
|
||||
}
|
||||
if (replacePaymentMethods !== null) {
|
||||
await tx.invoiceTemplatePaymentMethod.deleteMany({ where: { templateId } });
|
||||
}
|
||||
if (replaceAccounts !== null) {
|
||||
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
|
||||
? {
|
||||
accounts: {
|
||||
@@ -1304,6 +1603,7 @@ export class InvoicesService {
|
||||
: null,
|
||||
items: serialized.items,
|
||||
keyPoints: serialized.keyPoints,
|
||||
paymentMethods: serialized.paymentMethods,
|
||||
accounts: serialized.accounts,
|
||||
subtotal: serialized.subtotal,
|
||||
total: serialized.total,
|
||||
@@ -1465,6 +1765,9 @@ export class InvoicesService {
|
||||
|
||||
const items = dto.items.map((item) => this.normalizeItemInput(item));
|
||||
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 invoiceTemplateId = dto.invoiceTemplateId?.trim()
|
||||
? BigInt(dto.invoiceTemplateId)
|
||||
@@ -1514,6 +1817,12 @@ export class InvoicesService {
|
||||
sortOrder: index,
|
||||
})),
|
||||
},
|
||||
paymentMethods: {
|
||||
create: paymentMethods.map((pm, index) => ({
|
||||
text: pm.text,
|
||||
sortOrder: index,
|
||||
})),
|
||||
},
|
||||
accounts: {
|
||||
create: accounts.map((acc, index) => ({
|
||||
bankName: acc.bankName,
|
||||
@@ -1600,6 +1909,9 @@ export class InvoicesService {
|
||||
|
||||
const items = dto.items.map((item) => this.normalizeItemInput(item));
|
||||
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));
|
||||
|
||||
let userId: bigint | undefined;
|
||||
@@ -1629,6 +1941,7 @@ export class InvoicesService {
|
||||
const row = await this.prisma.$transaction(async (tx) => {
|
||||
await tx.invoiceItem.deleteMany({ where: { invoiceId } });
|
||||
await tx.invoiceKeyPoint.deleteMany({ where: { invoiceId } });
|
||||
await tx.invoicePaymentMethod.deleteMany({ where: { invoiceId } });
|
||||
await tx.invoiceAccount.deleteMany({ where: { invoiceId } });
|
||||
|
||||
return tx.invoice.update({
|
||||
@@ -1657,6 +1970,12 @@ export class InvoicesService {
|
||||
sortOrder: index,
|
||||
})),
|
||||
},
|
||||
paymentMethods: {
|
||||
create: paymentMethods.map((pm, index) => ({
|
||||
text: pm.text,
|
||||
sortOrder: index,
|
||||
})),
|
||||
},
|
||||
accounts: {
|
||||
create: accounts.map((acc, index) => ({
|
||||
bankName: acc.bankName,
|
||||
|
||||
@@ -60,14 +60,24 @@ export class LegacySourceS3Service implements OnModuleDestroy {
|
||||
const slug = slugHint.trim();
|
||||
add(`${slug}_${oldBusinessId}`);
|
||||
add(`${slug.replace(/-/g, '')}_${oldBusinessId}`);
|
||||
add(`${slug.replace(/_/g, '')}_${oldBusinessId}`);
|
||||
add(`${slug.replace(/_/g, '-')}_${oldBusinessId}`);
|
||||
}
|
||||
|
||||
// ListBucket may be denied on the old bucket; slug candidates + HeadObject still work.
|
||||
try {
|
||||
const listed = await this.listTopLevelPrefixes();
|
||||
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) {
|
||||
throw new ServiceUnavailableException(
|
||||
@@ -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 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) {
|
||||
throw new ServiceUnavailableException(
|
||||
`Old S3 files not found for business ${oldBusinessId} (tried: ${candidates.join(', ')}).`,
|
||||
@@ -106,6 +127,35 @@ export class LegacySourceS3Service implements OnModuleDestroy {
|
||||
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> {
|
||||
const { client, bucket } = this.getClient();
|
||||
try {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export const MAIL_IPV4 = '185.214.101.41';
|
||||
export const MAIL_HOSTNAME = 'mail.meshkee.com';
|
||||
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. */
|
||||
export const LEGACY_DKIM_NAME = 'dkim._domainkey';
|
||||
export const DMARC_NAME = '_dmarc';
|
||||
@@ -44,10 +44,19 @@ export function parseDkimPaste(
|
||||
let text = (txtMatch ? txtMatch[0] : blob).trim();
|
||||
text = text.replace(/^"+|"+$/g, '').replace(/\s+/g, ' ').replace(/[.\s]+$/g, '').trim();
|
||||
if (!/^v=DKIM1/i.test(text)) {
|
||||
// Stalwart sometimes copies only the public key / p=… body.
|
||||
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)
|
||||
? 'ed25519'
|
||||
|
||||
@@ -26,6 +26,11 @@ const IMAGE_MIME_TYPES = new Set([
|
||||
|
||||
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()
|
||||
export class MediaService {
|
||||
private readonly maxFileSizeBytes: number;
|
||||
@@ -375,6 +380,14 @@ export class MediaService {
|
||||
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) {
|
||||
throw new BadRequestException(
|
||||
`File ${file.originalname} exceeds the maximum allowed size`,
|
||||
@@ -409,7 +422,7 @@ export class MediaService {
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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) {
|
||||
switch (format) {
|
||||
case 'jpeg':
|
||||
@@ -469,6 +530,8 @@ export class MediaService {
|
||||
return '.webp';
|
||||
case 'image/gif':
|
||||
return '.gif';
|
||||
case 'application/pdf':
|
||||
return '.pdf';
|
||||
default:
|
||||
return '.jpg';
|
||||
}
|
||||
@@ -493,6 +556,8 @@ export class MediaService {
|
||||
return '.mp4';
|
||||
case 'video/webm':
|
||||
return '.webm';
|
||||
case 'application/pdf':
|
||||
return '.pdf';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import {
|
||||
ArrayMinSize,
|
||||
IsBoolean,
|
||||
IsDateString,
|
||||
IsEnum,
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
@@ -11,7 +13,7 @@ import {
|
||||
ValidateIf,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { Transform, Type } from 'class-transformer';
|
||||
import { OrderStatus, TransactionType } from '@prisma/client';
|
||||
import { ShippingAddressDto } from '../../cart/dto/cart.dto';
|
||||
|
||||
@@ -166,4 +168,18 @@ export class ListOrdersDto {
|
||||
@Min(0)
|
||||
@Type(() => 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';
|
||||
import { AuthUser } from '../auth/auth.types';
|
||||
import { BusinessSettingsService } from '../business-settings/business-settings.service';
|
||||
import { isSystemPaymentProcessStep } from '../business-settings/business-settings.util';
|
||||
import { PermissionsService } from '../auth/permissions.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import {
|
||||
@@ -28,6 +29,9 @@ import {
|
||||
} from './dto/order.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<{
|
||||
include: typeof ORDER_RELATIONS_INCLUDE;
|
||||
}>;
|
||||
@@ -109,9 +113,9 @@ export class OrdersService {
|
||||
|
||||
const where: Prisma.OrderWhereInput = {
|
||||
businessId,
|
||||
...(scope === 'customer' ? { userId: actor.id } : {}),
|
||||
...(scope === 'customer' || query.selfOnly ? { userId: actor.id } : {}),
|
||||
...(query.status ? { status: query.status } : {}),
|
||||
...(scope === 'admin' && query.customerUserId
|
||||
...(scope === 'admin' && !query.selfOnly && query.customerUserId
|
||||
? { userId: BigInt(query.customerUserId) }
|
||||
: {}),
|
||||
...(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()),
|
||||
}
|
||||
: {}),
|
||||
...this.paymentStatusWhere(query.paymentStatus),
|
||||
};
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
@@ -376,6 +381,7 @@ export class OrdersService {
|
||||
source: OrderSource;
|
||||
cartItems: CartItemForOrder[];
|
||||
shippingAddress: Record<string, unknown>;
|
||||
shippingTotal?: number;
|
||||
addressId: bigint | null;
|
||||
customerNotes: 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({
|
||||
businessId: input.businessId,
|
||||
@@ -399,6 +408,8 @@ export class OrdersService {
|
||||
source: input.source,
|
||||
items: preparedItems,
|
||||
shippingAddress: input.shippingAddress,
|
||||
shippingTotal,
|
||||
discountTotal,
|
||||
addressId: input.addressId,
|
||||
customerNotes: input.customerNotes,
|
||||
adminNotes: input.adminNotes,
|
||||
@@ -419,6 +430,8 @@ export class OrdersService {
|
||||
source: OrderSource;
|
||||
items: PreparedOrderItem[];
|
||||
shippingAddress: Record<string, unknown>;
|
||||
shippingTotal?: number;
|
||||
discountTotal?: number;
|
||||
addressId: bigint | null;
|
||||
customerNotes: string | null;
|
||||
adminNotes: string | null;
|
||||
@@ -426,8 +439,8 @@ export class OrdersService {
|
||||
payments?: TransactionPaymentInput[];
|
||||
}) {
|
||||
const subtotal = input.items.reduce((sum, item) => sum + item.lineTotal, 0);
|
||||
const shippingTotal = 0;
|
||||
const discountTotal = 0;
|
||||
const shippingTotal = Math.max(0, Number(input.shippingTotal ?? 0));
|
||||
const discountTotal = Math.max(0, Number(input.discountTotal ?? 0));
|
||||
const total = subtotal + shippingTotal - discountTotal;
|
||||
const processStepId = await this.defaultProcessStepId(input.businessId);
|
||||
|
||||
@@ -750,12 +763,55 @@ export class OrdersService {
|
||||
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) {
|
||||
const steps = await this.businessSettings.getOrderProcessSteps(businessId);
|
||||
return steps[0]?.id ?? 'processing';
|
||||
}
|
||||
|
||||
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 valid = steps.some((step) => step.id === processStepId);
|
||||
if (!valid) {
|
||||
@@ -855,6 +911,7 @@ export class OrdersService {
|
||||
processStepLabel: processStep?.label ?? null,
|
||||
processStepLabelFa: processStep?.labelFa?.trim() || null,
|
||||
processStepColor: processStep?.color ?? null,
|
||||
paymentStatus: this.derivePaymentStatus(order.transactions ?? []),
|
||||
source: order.source,
|
||||
subtotal: Number(order.subtotal),
|
||||
shippingTotal: Number(order.shippingTotal),
|
||||
@@ -874,11 +931,17 @@ export class OrdersService {
|
||||
cellNumber: order.customer.cellNumber,
|
||||
email: order.customer.email,
|
||||
},
|
||||
items: order.items.map((item) => ({
|
||||
items: order.items.map((item) => {
|
||||
const content = isRecord(item.product.content) ? item.product.content : null
|
||||
const productNameFa =
|
||||
content && typeof content.nameFa === 'string' ? content.nameFa.trim() : ''
|
||||
return {
|
||||
id: item.id.toString(),
|
||||
storeItemVariantId: item.storeItemVariantId?.toString() ?? null,
|
||||
productId: item.productId.toString(),
|
||||
productTitle: item.productTitle,
|
||||
productNameFa: productNameFa || null,
|
||||
productSlug: item.product.slug ?? null,
|
||||
productImage: this.resolveProductImage(item.product, galleryByProductId),
|
||||
variantSku: item.variantSku,
|
||||
unitPrice: Number(item.unitPrice),
|
||||
@@ -887,7 +950,8 @@ export class OrdersService {
|
||||
quantity: item.quantity,
|
||||
lineTotal: Number(item.lineTotal),
|
||||
selections: item.selectionsSnapshot,
|
||||
})),
|
||||
}
|
||||
}),
|
||||
transactions: (order.transactions ?? []).map((transaction) =>
|
||||
this.serializeTransaction(transaction),
|
||||
),
|
||||
@@ -895,6 +959,18 @@ export class OrdersService {
|
||||
}
|
||||
|
||||
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 {
|
||||
id: transaction.id.toString(),
|
||||
type: transaction.type,
|
||||
@@ -904,6 +980,8 @@ export class OrdersService {
|
||||
gatewayType: transaction.gatewayType,
|
||||
gatewayRef: transaction.gatewayRef ?? null,
|
||||
gatewayTrackId: transaction.gatewayTrackId ?? null,
|
||||
cardPan,
|
||||
paidAt: completedAt,
|
||||
transferAccount: transaction.transferAccount,
|
||||
transferRefNumber: transaction.transferRefNumber,
|
||||
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.
|
||||
if (!returnUrl) {
|
||||
throw new BadRequestException(
|
||||
@@ -392,6 +394,27 @@ export class PaymentsService {
|
||||
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) {
|
||||
await this.prisma.transaction.update({
|
||||
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 {
|
||||
en = 'en',
|
||||
@@ -16,8 +16,10 @@ export class CreateProductByAiDto {
|
||||
@MinLength(1)
|
||||
categoryId!: string;
|
||||
|
||||
/** Product brief for AI: model name, optional URL, and notes. */
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@MaxLength(4000)
|
||||
name!: string;
|
||||
|
||||
@IsEnum(ProductAiLanguage)
|
||||
@@ -50,3 +52,11 @@ export class FillProductFieldByAiDto {
|
||||
@IsString()
|
||||
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()
|
||||
featuredMediaId?: string;
|
||||
|
||||
/** Optional PDF catalog file (media id; application/pdf, max 2MB). */
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
catalogMediaId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
@@ -165,6 +170,11 @@ export class UpdateProductDto {
|
||||
@IsString()
|
||||
featuredMediaId?: string | null;
|
||||
|
||||
/** Optional PDF catalog file (media id). Pass null to clear. */
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
catalogMediaId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
|
||||
@@ -5,15 +5,21 @@ import {
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { ContentStatus, TechnicalFieldType } from '@prisma/client';
|
||||
import { AuthUser } from '../auth/auth.types';
|
||||
import { CategoryTechnicalFormService } from '../categories/category-technical-form.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 { ProductsService } from './products.service';
|
||||
import {
|
||||
requestAiJsonCompletion,
|
||||
resolveAiProvider,
|
||||
} from '../common/ai-provider.util';
|
||||
import { loadReferencePagesFromText } from '../common/ai-reference-pages.util';
|
||||
import { normalizeAiDescriptionHtml } from '../common/ai-html.util';
|
||||
|
||||
type TechnicalFormField = {
|
||||
@@ -30,7 +36,6 @@ type AiProductDraft = {
|
||||
summary: string;
|
||||
descriptionHtml: string;
|
||||
tags: string[];
|
||||
technicalValues: Record<string, string | string[]>;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
@@ -40,7 +45,6 @@ export class ProductAiService {
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly products: ProductsService,
|
||||
private readonly technicalInfo: ProductTechnicalInfoService,
|
||||
private readonly technicalFormService: CategoryTechnicalFormService,
|
||||
) {}
|
||||
|
||||
async createFromAi(
|
||||
@@ -63,16 +67,10 @@ export class ProductAiService {
|
||||
throw new BadRequestException('Product category not found');
|
||||
}
|
||||
|
||||
const form = await this.technicalFormService.getFormForCategory(
|
||||
businessId,
|
||||
categoryId,
|
||||
);
|
||||
|
||||
const draft = await this.generateDraft({
|
||||
categoryName: category.name,
|
||||
productName: dto.name.trim(),
|
||||
language: dto.language,
|
||||
fields: form?.fields ?? [],
|
||||
});
|
||||
|
||||
const created = await this.products.create(
|
||||
@@ -89,26 +87,8 @@ export class ProductAiService {
|
||||
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 {
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -251,25 +231,198 @@ Aim for 2–4 short paragraphs and optionally a bullet list.`;
|
||||
return { field: ProductAiFillField.description, html };
|
||||
}
|
||||
|
||||
private async generateDraft(input: {
|
||||
categoryName: string;
|
||||
productName: string;
|
||||
language: ProductAiLanguage;
|
||||
fields: TechnicalFormField[];
|
||||
}): Promise<AiProductDraft> {
|
||||
const provider = resolveAiProvider(this.config);
|
||||
/**
|
||||
* Suggest technical form values for an existing product. Does not persist —
|
||||
* the dashboard applies the map and the user saves.
|
||||
*/
|
||||
async fillTechnicalInfo(
|
||||
businessIdRaw: string,
|
||||
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,
|
||||
label: field.label,
|
||||
type: field.type,
|
||||
required: field.isRequired,
|
||||
options:
|
||||
field.type === 'select' || field.type === 'multi_select'
|
||||
? field.options.map((option) => option.value)
|
||||
? field.options.map((option) => ({
|
||||
value: option.value,
|
||||
label: option.label,
|
||||
}))
|
||||
: 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 =
|
||||
input.language === ProductAiLanguage.fa ? 'Persian (Farsi)' : 'English';
|
||||
|
||||
@@ -280,30 +433,38 @@ Return ONLY valid JSON with this shape:
|
||||
"nameFa": "Persian product name",
|
||||
"summary": "short listing summary",
|
||||
"descriptionHtml": "HTML description using <p> and <ul><li> only",
|
||||
"tags": ["tag1", "tag2"],
|
||||
"technicalValues": { "fieldKey": "value" }
|
||||
"tags": ["tag1", "tag2"]
|
||||
}
|
||||
|
||||
Rules:
|
||||
- Be factual and realistic for the named product in the given category.
|
||||
- If technical form fields are provided, fill technicalValues using exact field keys.
|
||||
- For select fields use one allowed option value exactly.
|
||||
- For multi_select fields use an array of allowed option values.
|
||||
- For text/textarea fields use plain strings.
|
||||
- The user input name is in ${inputLanguage}; keep that spelling in the matching field and translate/generate the other language field.
|
||||
- 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.
|
||||
- Infer the real product from the brief (and any URL/model clues). Do not copy URLs, markdown, or raw notes into title/nameFa.
|
||||
- Write clean storefront titles: "title" in English, "nameFa" in Persian. Keep brand/model accurate.
|
||||
- When referencePages are provided, treat them as the source of truth for capacity, power, dimensions, and description facts.
|
||||
- Prefer the main product specs near the product title; ignore conflicting related-product / upsell blocks.
|
||||
- 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.
|
||||
- tags: 3-8 relevant lowercase tags.`;
|
||||
|
||||
const userPrompt = JSON.stringify({
|
||||
category: input.categoryName,
|
||||
inputName: input.productName,
|
||||
researchBrief: input.productName,
|
||||
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;
|
||||
try {
|
||||
content = await requestAiJsonCompletion(provider, systemPrompt, userPrompt);
|
||||
content = await requestAiJsonCompletion(provider, systemPrompt, userPrompt, 0.3);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'AI request failed';
|
||||
throw new BadRequestException(message);
|
||||
@@ -324,18 +485,11 @@ Rules:
|
||||
input: {
|
||||
productName: string;
|
||||
language: ProductAiLanguage;
|
||||
fields: TechnicalFormField[];
|
||||
},
|
||||
): AiProductDraft {
|
||||
const title =
|
||||
input.language === ProductAiLanguage.en
|
||||
? input.productName
|
||||
: String(draft.title ?? input.productName).trim();
|
||||
|
||||
const nameFa =
|
||||
input.language === ProductAiLanguage.fa
|
||||
? input.productName
|
||||
: String(draft.nameFa ?? '').trim();
|
||||
const fallbackName = this.shortProductLabel(input.productName);
|
||||
const title = String(draft.title ?? '').trim() || fallbackName;
|
||||
const nameFa = String(draft.nameFa ?? '').trim() || fallbackName;
|
||||
|
||||
const summary = String(draft.summary ?? '').trim();
|
||||
const descriptionHtml = normalizeAiDescriptionHtml(
|
||||
@@ -353,37 +507,6 @@ Rules:
|
||||
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 {
|
||||
title,
|
||||
nameFa,
|
||||
@@ -392,7 +515,16 @@ Rules:
|
||||
descriptionHtml ||
|
||||
`<p>${summary || `${title} product description.`}</p>`,
|
||||
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 { PrismaService } from '../prisma/prisma.service';
|
||||
import { ReplaceProductTechnicalInfoDto } from './dto/product-technical-info.dto';
|
||||
import {
|
||||
decodeMultiValueText,
|
||||
encodeMultiValueText,
|
||||
} from '../common/technical-multi-value.util';
|
||||
|
||||
type FormField = {
|
||||
id: string;
|
||||
@@ -159,6 +163,22 @@ export class ProductTechnicalInfoService {
|
||||
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') {
|
||||
await tx.productTechnicalFieldValue.create({
|
||||
data: {
|
||||
@@ -185,6 +205,9 @@ export class ProductTechnicalInfoService {
|
||||
}
|
||||
|
||||
const selectedValues = entry.value as string[];
|
||||
if (!Array.isArray(selectedValues) || !selectedValues.length) {
|
||||
continue;
|
||||
}
|
||||
const fieldValue = await tx.productTechnicalFieldValue.create({
|
||||
data: {
|
||||
businessId,
|
||||
@@ -230,6 +253,24 @@ export class ProductTechnicalInfoService {
|
||||
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 (typeof value !== 'string') {
|
||||
throw new BadRequestException(
|
||||
@@ -303,7 +344,17 @@ export class ProductTechnicalInfoService {
|
||||
fieldKey: field.key,
|
||||
fieldLabel: field.label,
|
||||
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 { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
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 { UpsertTranslationDto, TranslateAllDto } from '../translations/dto/translation.dto';
|
||||
import { TranslationsService } from '../translations/translations.service';
|
||||
@@ -208,6 +212,17 @@ export class ProductsController {
|
||||
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')
|
||||
@RequireBusinessPermission('products.update')
|
||||
replaceTechnicalInfo(
|
||||
|
||||
@@ -43,12 +43,14 @@ function slugify(value: string): string {
|
||||
type ProductWithRelations = Prisma.ProductGetPayload<{
|
||||
include: {
|
||||
featuredMedia: true;
|
||||
catalogMedia: true;
|
||||
brand: { include: { imageMedia: true } };
|
||||
};
|
||||
}>;
|
||||
|
||||
const productListInclude = {
|
||||
featuredMedia: true,
|
||||
catalogMedia: true,
|
||||
brand: { include: { imageMedia: true } },
|
||||
} satisfies Prisma.ProductInclude;
|
||||
|
||||
@@ -105,10 +107,7 @@ export class ProductsService {
|
||||
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'desc' }],
|
||||
skip,
|
||||
take: pageSize,
|
||||
include: {
|
||||
featuredMedia: true,
|
||||
brand: { include: { imageMedia: true } },
|
||||
},
|
||||
include: productListInclude,
|
||||
}),
|
||||
this.prisma.product.count({ where }),
|
||||
]);
|
||||
@@ -140,10 +139,7 @@ export class ProductsService {
|
||||
|
||||
const product = await this.prisma.product.findFirst({
|
||||
where: { id: productId, businessId },
|
||||
include: {
|
||||
featuredMedia: true,
|
||||
brand: { include: { imageMedia: true } },
|
||||
},
|
||||
include: productListInclude,
|
||||
});
|
||||
|
||||
if (!product) {
|
||||
@@ -168,10 +164,7 @@ export class ProductsService {
|
||||
orderBy: [{ sortOrder: 'asc' }, { publishedAt: 'desc' }, { createdAt: 'desc' }],
|
||||
skip,
|
||||
take: pageSize,
|
||||
include: {
|
||||
featuredMedia: true,
|
||||
brand: { include: { imageMedia: true } },
|
||||
},
|
||||
include: productListInclude,
|
||||
}),
|
||||
this.prisma.product.count({ where }),
|
||||
]);
|
||||
@@ -338,6 +331,13 @@ export class ProductsService {
|
||||
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(
|
||||
businessId,
|
||||
dto.galleryMediaIds ?? [],
|
||||
@@ -363,6 +363,7 @@ export class ProductsService {
|
||||
content: this.buildContent(dto.nameFa, dto.descriptionHtml),
|
||||
status,
|
||||
featuredMediaId,
|
||||
catalogMediaId,
|
||||
brandId,
|
||||
publishedAt: status === ContentStatus.published ? new Date() : null,
|
||||
metadata: applySeoMetaToMetadata(
|
||||
@@ -370,10 +371,7 @@ export class ProductsService {
|
||||
normalizeSeoMetaInput(dto.seoMetaTitle, dto.seoMetaDescription),
|
||||
) as Prisma.InputJsonValue,
|
||||
},
|
||||
include: {
|
||||
featuredMedia: true,
|
||||
brand: { include: { imageMedia: true } },
|
||||
},
|
||||
include: productListInclude,
|
||||
});
|
||||
|
||||
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 existingMetadata = this.asRecord(existing.metadata);
|
||||
|
||||
@@ -496,16 +504,14 @@ export class ProductsService {
|
||||
: {}),
|
||||
...(dto.status !== undefined ? { status: dto.status } : {}),
|
||||
...(featuredMediaId !== undefined ? { featuredMediaId } : {}),
|
||||
...(catalogMediaId !== undefined ? { catalogMediaId } : {}),
|
||||
...(brandId !== undefined ? { brandId } : {}),
|
||||
...(publishedAt !== undefined ? { publishedAt } : {}),
|
||||
slug,
|
||||
content: nextContent as Prisma.InputJsonValue,
|
||||
metadata: nextMetadata as Prisma.InputJsonValue,
|
||||
},
|
||||
include: {
|
||||
featuredMedia: true,
|
||||
brand: { include: { imageMedia: true } },
|
||||
},
|
||||
include: productListInclude,
|
||||
});
|
||||
|
||||
if (dto.categoryId !== undefined) {
|
||||
@@ -563,6 +569,13 @@ export class ProductsService {
|
||||
) {
|
||||
dropped.push(existing.featuredMediaId);
|
||||
}
|
||||
if (
|
||||
catalogMediaId !== undefined &&
|
||||
existing.catalogMediaId != null &&
|
||||
existing.catalogMediaId !== catalogMediaId
|
||||
) {
|
||||
dropped.push(existing.catalogMediaId);
|
||||
}
|
||||
await this.mediaService.deleteUnused(businessId, dropped);
|
||||
|
||||
return {
|
||||
@@ -590,6 +603,9 @@ export class ProductsService {
|
||||
productId,
|
||||
existing.featuredMediaId,
|
||||
);
|
||||
if (existing.catalogMediaId != null) {
|
||||
mediaIds.push(existing.catalogMediaId);
|
||||
}
|
||||
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.mediaAttachment.deleteMany({
|
||||
@@ -718,6 +734,9 @@ export class ProductsService {
|
||||
...readSeoMetaFromMetadata(metadata),
|
||||
thumbnailUrl,
|
||||
thumbnailMediaId: product.featuredMediaId?.toString() ?? null,
|
||||
catalogMediaId: product.catalogMediaId?.toString() ?? null,
|
||||
catalogUrl: product.catalogMedia?.publicUrl ?? null,
|
||||
catalogFileName: product.catalogMedia?.originalFileName ?? null,
|
||||
image,
|
||||
thumbnail: thumbnailUrl ?? image,
|
||||
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(
|
||||
businessId: 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(
|
||||
ticket: 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)
|
||||
productId!: string;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => value === 'true' || value === true)
|
||||
@IsBoolean()
|
||||
isUsed?: boolean;
|
||||
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => CreateStoreItemVariantDto)
|
||||
@@ -79,6 +84,11 @@ export class ListStoreItemsDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => value === 'true' || value === true)
|
||||
@IsBoolean()
|
||||
isUsed?: boolean;
|
||||
}
|
||||
|
||||
export class ListPublicStoreItemsDto {
|
||||
@@ -120,6 +130,11 @@ export class ListPublicStoreItemsDto {
|
||||
@IsBoolean()
|
||||
isFestival?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => value === 'true' || value === true)
|
||||
@IsBoolean()
|
||||
isUsed?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@@ -209,6 +224,11 @@ export class SyncProductStoreItemVariantsDto {
|
||||
@MinLength(1)
|
||||
productId!: string;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => value === 'true' || value === true)
|
||||
@IsBoolean()
|
||||
isUsed?: boolean;
|
||||
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@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,7 +71,28 @@ export class StoreItemsService {
|
||||
const name = query.name?.trim();
|
||||
const where: Prisma.StoreItemVariantWhereInput = {
|
||||
businessId,
|
||||
...(query.isUsed
|
||||
? {
|
||||
storeItem: {
|
||||
isUsed: true,
|
||||
...(name
|
||||
? {
|
||||
product: {
|
||||
OR: [
|
||||
{ title: { contains: name, mode: 'insensitive' } },
|
||||
{
|
||||
content: {
|
||||
path: ['nameFa'],
|
||||
string_contains: name,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
}
|
||||
: name
|
||||
? {
|
||||
storeItem: {
|
||||
product: {
|
||||
@@ -467,7 +488,9 @@ export class StoreItemsService {
|
||||
}
|
||||
|
||||
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({
|
||||
where: { storeItemId: storeItem.id },
|
||||
});
|
||||
@@ -719,7 +742,9 @@ export class StoreItemsService {
|
||||
}
|
||||
|
||||
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({
|
||||
where: { storeItemId: storeItem.id },
|
||||
});
|
||||
@@ -947,17 +972,28 @@ export class StoreItemsService {
|
||||
businessId: bigint,
|
||||
productId: bigint,
|
||||
tx: Prisma.TransactionClient = this.prisma,
|
||||
options?: { isUsed?: boolean },
|
||||
) {
|
||||
const existing = await tx.storeItem.findUnique({
|
||||
where: { businessId_productId: { businessId, productId } },
|
||||
});
|
||||
|
||||
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 tx.storeItem.create({
|
||||
data: { businessId, productId },
|
||||
data: {
|
||||
businessId,
|
||||
productId,
|
||||
isUsed: options?.isUsed ?? false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1126,6 +1162,7 @@ export class StoreItemsService {
|
||||
stockQuantity: variant.stockQuantity,
|
||||
rewardPoints: variant.rewardPoints,
|
||||
isFestival: variant.isFestival,
|
||||
isUsed: variant.storeItem.isUsed,
|
||||
sortOrder: variant.sortOrder,
|
||||
createdAt: variant.createdAt,
|
||||
updatedAt: variant.updatedAt,
|
||||
@@ -1257,6 +1294,7 @@ export class StoreItemsService {
|
||||
isActive: true,
|
||||
storeItem: {
|
||||
isActive: true,
|
||||
...(query.isUsed ? { isUsed: true } : {}),
|
||||
product: {
|
||||
status: ContentStatus.published,
|
||||
...(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) ?? '',
|
||||
productImage,
|
||||
isActive: storeItem.isActive,
|
||||
isUsed: storeItem.isUsed,
|
||||
sortOrder: storeItem.sortOrder,
|
||||
variants: storeItem.variants.map((variant) => {
|
||||
const selections = variant.selections.map((selection) => ({
|
||||
|
||||
@@ -10,6 +10,16 @@ import { StoreSpecialsAiService } from './store-specials-ai.service';
|
||||
import { StoreSpecialsService } from './store-specials.service';
|
||||
import { StoreItemsController, PublicStoreItemsController } from './store-items.controller';
|
||||
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({
|
||||
imports: [AuthModule, TenantModule, WebsiteAnalyticsModule],
|
||||
@@ -18,8 +28,23 @@ import { StoreItemsService } from './store-items.service';
|
||||
PublicStoreItemsController,
|
||||
PublicStoreSpecialsController,
|
||||
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 {}
|
||||
|
||||
@@ -19,6 +19,10 @@ import {
|
||||
normalizeSeoMetaInput,
|
||||
readSeoMetaFromMetadata,
|
||||
} from '../common/seo-meta.util';
|
||||
import {
|
||||
decodeMultiValueText,
|
||||
encodeMultiValueText,
|
||||
} from '../common/technical-multi-value.util';
|
||||
import { MediaService } from '../media/media.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import {
|
||||
@@ -1162,6 +1166,21 @@ export class UserProductsService {
|
||||
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 (entry.textValue || entry.optionIds?.length) {
|
||||
throw new BadRequestException(
|
||||
@@ -1206,6 +1225,9 @@ export class UserProductsService {
|
||||
if (field.type === 'text' || field.type === 'textarea') {
|
||||
return Boolean(entry.textValue?.trim());
|
||||
}
|
||||
if (field.type === 'multi_value') {
|
||||
return decodeMultiValueText(entry.textValue).length > 0;
|
||||
}
|
||||
if (field.type === 'select') {
|
||||
return Boolean(entry.optionId);
|
||||
}
|
||||
@@ -1255,6 +1277,20 @@ export class UserProductsService {
|
||||
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') {
|
||||
await tx.userProductTechnicalFieldValue.create({
|
||||
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`.
|
||||
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`).
|
||||
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.
|
||||
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.
|
||||
5. **Installments / credit** (only when `enabledModules` includes `store` and `store_installments`, or list returns `enabled: true`): see Installments below.
|
||||
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
|
||||
- `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.
|
||||
**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)
|
||||
|
||||
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)**
|
||||
- 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)
|
||||
|
||||
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
|
||||
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
|
||||
7. Site-wide `metaTags` + footer `trustBadges` from business-info when present
|
||||
|
||||
If OpenAPI and this brief conflict, **OpenAPI wins**.
|
||||
|
||||
|
||||
@@ -69,6 +69,10 @@
|
||||
"key": "storeItemVariantId",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"key": "loanMethodId",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"key": "cartItemId",
|
||||
"value": ""
|
||||
@@ -1592,6 +1596,52 @@
|
||||
"method": "GET",
|
||||
"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": "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",
|
||||
"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"]
|
||||
},
|
||||
"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": {
|
||||
"get": {
|
||||
"tags": [
|
||||
|
||||
@@ -6,6 +6,10 @@ import {
|
||||
} from '../business-profile/business-profile.util';
|
||||
import { normalizeBusinessSettings } from '../business-settings/business-settings.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 { TenantService } from '../tenant/tenant.service';
|
||||
|
||||
@@ -90,6 +94,8 @@ export class WebsiteBusinessInfoService {
|
||||
sameAs: schemaSettings.sameAs,
|
||||
jsonLd,
|
||||
},
|
||||
metaTags: toPublicWebsiteMetaTags(settings.website.tagsAndBadges),
|
||||
trustBadges: toPublicWebsiteTrustBadges(settings.website.tagsAndBadges),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user