Author SHA1 Message Date
Alireza HassaniandCursor eb0644e6c1 Grant roll-call permissions to editor and viewer staff.
Roll call is a staff work log, so smaller team roles can use it when the module is on.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-10 12:33:57 +03:30
Alireza HassaniandCursor 3558c441b9 Add opt-in roll_call business module gate.
Keeps Roll Call off by default and blocks the API until super-admin enables it.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 14:15:43 +03:30
Alireza HassaniandCursor a6c2d4764e Add roll-call API for admin work-day logs with tasks.
Lets owners/admins track start/stop sessions and task lists per business day.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 13:21:01 +03:30
Alireza HassaniandCursor d237d00f65 Make business profile address postal code optional.
Allow empty or missing postalCode on profile addresses so saves are not blocked by MinLength validation.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-04 18:42:22 +03:30
Alireza HassaniandCursor 6f2ce12c0f Add business category create, update, and delete APIs.
Super-admins can manage the marketplace category tree via POST/PATCH/DELETE on /api/v1/business-categories.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-04 11:44:26 +03:30
Alireza HassaniandCursor a9e29b79fd Accept marketplace banners at 18:6 (3:1) aspect ratio.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-03 20:46:44 +03:30
Alireza HassaniandCursor e0cd2327eb Treat website meta tags as label + full HTML snippets.
Public business-info now exposes metaTags as { html }, matching how admins paste complete <meta> tags in Tags & Badges.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-03 18:39:19 +03:30
Alireza HassaniandCursor 63ae9b22b9 Expose website tags and badges via business settings and business-info.
Stores custom meta tags and eNamad/Samandehi footer HTML in settings so storefronts can render them from the public API.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-03 18:07:13 +03:30
Alireza HassaniandCursor cffc828328 Scope customer order lists with selfOnly and expose product FA names.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-02 23:00:48 +03:30
Alireza HassaniandCursor 41d3f8f4eb Add پس کرایه (freight collect) on shipping cost groups.
When enabled, checkout shipping amount is 0 while customers still see the پس کرایه label.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-02 22:37:28 +03:30
Alireza HassaniandCursor 7f6fa3672d Add paymentStatus filter and field on order list API.
Support paid/unpaid/failed filtering from completed transactions for dashboard order tabs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-02 22:22:23 +03:30
Alireza HassaniandCursor 0d9e866a57 Expose gateway tracking fields on order transactions.
Include authority, track id, masked card, and paid-at from gateway meta for support and reconciliation.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-02 22:15:10 +03:30
Alireza HassaniandCursor 0a330b8749 Lock payment process steps to gateway-only updates.
Reject admin PATCH of awaiting-payment / payment-successful so order payment status cannot be set manually.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-02 22:07:32 +03:30
Alireza HassaniandCursor 88ef4e82a1 Add shipping cost groups and reject spam contact-form SMS.
Wire cart/checkout shipping fees with category groups and static rates, reject gibberish contact submissions, and SMS only valid Iranian mobiles.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-02 21:52:41 +03:30
Alireza HassaniandCursor 1a49576752 Add OTP-first signup and product catalog PDF uploads.
Signup verifies phone before password; existing Meshkee accounts keep their password. Products can attach an optional PDF catalog (max 2MB) via Pars Pack media.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-27 08:57:08 +03:30
Alireza HassaniandCursor 4a9e10e614 Add store loan methods with public website calculate API.
Supports dashboard CRUD and tenant list/get/calculate so storefronts can offer installments, requiring a method pick when several exist.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-24 23:17:51 +03:30
Alireza HassaniandCursor 2a01873c95 Add invoice bank account templates API and migration.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-21 23:31:58 +03:30
Alireza HassaniandCursor 503b70a690 Prefer text fields in technical-form AI and add invoice payment methods.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-21 20:02:05 +03:30
Alireza HassaniandCursor f8b865b0aa Skip technical form fill on AI product create.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-19 15:43:18 +03:30
Alireza HassaniandCursor a1e73d1c9e Accept custom color hexes when saving category color variations.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-19 15:28:14 +03:30
Alireza HassaniandCursor 00d0284454 Add multi_value technical fields and FA category AI forms.
Support duplicatable free-text lists, always append سایر ویژگی‌ها on AI category forms, and fill leftover product specs into that field.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-19 13:50:04 +03:30
Alireza HassaniandCursor ac04e51c8b Fetch product page URLs when generating technical AI content.
Ground product technical fill, product AI create, and category technical form suggestions on page text instead of inventing specs from the URL alone.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-19 12:40:40 +03:30
Alireza HassaniandCursor 94e9a8ed1c Add isUsed on store items with API list filters.
Persists used/second-hand status per listing and supports isUsed=true on business and public store-item queries.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-19 12:17:28 +03:30
Alireza HassaniandCursor c6b7629c77 Add product technical-info AI fill endpoint.
Lets the dashboard suggest technical form values from a user prompt without auto-saving.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-19 11:53:34 +03:30
Alireza HassaniandCursor 57800634ca Treat AI create input as a research brief and localize FA prompts.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-19 08:20:34 +03:30
Alireza HassaniandCursor d37b920c01 Add businesses renewal sort, low-SMS alert, and contact form SMS.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-18 17:17:59 +03:30
Alireza HassaniandCursor 94cb7eaa33 Accept bare DKIM public-key pastes without v=DKIM1 prefix.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-18 14:45:09 +03:30
Alireza HassaniandCursor 50b716f040 Make Ed25519 DKIM optional for Email DNS updates.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-18 14:26:05 +03:30
Alireza HassaniandCursor 5e7bdc0cf2 Use SPF v=spf1 mx -all for Email DNS upserts.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-18 11:29:53 +03:30
85 changed files with 6598 additions and 269 deletions
@@ -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;
+62
View File
@@ -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
View File
@@ -214,6 +214,8 @@ StoreItem 1──* StoreItemVariant (purchasable SKUs: price, stock, variation c
StoreItemVariant 1──* StoreItemVariantSelection → CategoryVariationOption StoreItemVariant 1──* StoreItemVariantSelection → CategoryVariationOption
Product 1──* ProductTechnicalFieldValue → CategoryTechnicalFormField Product 1──* ProductTechnicalFieldValue → CategoryTechnicalFormField
Business 1──* StoreLoanMethod (installments / facilities; module `store_installments`)
UserProduct *── Category (via CategoryAssignment, entityType user_product → product categories) UserProduct *── Category (via CategoryAssignment, entityType user_product → product categories)
UserProduct 1──* UserProductTechnicalFieldValue → CategoryTechnicalFormField UserProduct 1──* UserProductTechnicalFieldValue → CategoryTechnicalFormField
UserProduct → City (country, city, optional district) UserProduct → City (country, city, optional district)
@@ -284,6 +286,9 @@ All routes are prefixed with `/api/v1`.
| GET | `/tenants/:host/portfolios/by-id/:portfolioId` | Public portfolio by id | | GET | `/tenants/:host/portfolios/by-id/:portfolioId` | Public portfolio by id |
| POST | `/businesses/:id/website/sitemap/sync` | Import manifest from `GET https://{domain}/meshkee/sitemap-config.json` | | POST | `/businesses/:id/website/sitemap/sync` | Import manifest from `GET https://{domain}/meshkee/sitemap-config.json` |
| GET | `/tenants/:host/store-specials` | Active store specials (`source` is `product` or `store_item`) | | GET | `/tenants/:host/store-specials` | Active store specials (`source` is `product` or `store_item`) |
| GET | `/tenants/:host/store-loan-methods` | Active installment/credit methods (`enabled` + `items[]`; needs `store` + `store_installments`) |
| GET | `/tenants/:host/store-loan-methods/:methodId` | One active method |
| POST | `/tenants/:host/store-loan-methods/calculate` | Installment plan — body requires `methodId` (pick method first if multiple) |
| GET | `/tenants/:host/website/category-groups` | Homepage category rows | | GET | `/tenants/:host/website/category-groups` | Homepage category rows |
| GET | `/tenants/:host/website/brand-groups` | Homepage brand rows | | GET | `/tenants/:host/website/brand-groups` | Homepage brand rows |
| GET | `/tenants/:host/website/sliders` | Homepage sliders with slides | | GET | `/tenants/:host/website/sliders` | Homepage sliders with slides |
@@ -518,6 +523,42 @@ Stored in `businesses.settings.website.specialProductsSource` (`product` | `stor
- Dashboard: `PATCH /businesses/:id/settings` with `{ website: { specialProductsSource } }`. - Dashboard: `PATCH /businesses/:id/settings` with `{ website: { specialProductsSource } }`.
- Public: `GET /tenants/:host` includes `specialProductsSource`; `GET /tenants/:host/store-specials` includes top-level `source`. - Public: `GET /tenants/:host` includes `specialProductsSource`; `GET /tenants/:host/store-specials` includes top-level `source`.
### Store loan methods (installments & facilities)
Requires modules `store` + `store_installments` (opt-in). Dashboard: Store → اقساط و تسهیلات.
| Field | Notes |
|-------|-------|
| `nameFa` / `nameEn` | Display names |
| `minShoppingAmount` | Minimum purchase total to use this method |
| `minAmount` / `maxAmount` | Allowed credit range |
| `interest` | Flat annual %; fee = credit × (interest/100) × (months/12) |
| `returnMonthsMin` / `Max` / `Step` | Term options (step ∈ 2,3,4,6,12) |
| `imageMediaId` / `imageAspectRatio` | Optional image (`1:1` or `3:2`) |
**Dashboard (auth, `products.read` / `products.update`):**
- `GET/POST /businesses/:businessId/store-loan-methods`
- `GET/PATCH/DELETE /businesses/:businessId/store-loan-methods/:methodId`
**Public website:**
- `GET /tenants/:host/store-loan-methods` → `{ enabled, items[] }` (`enabled: false` + empty when module off)
- `GET /tenants/:host/store-loan-methods/:methodId` → `{ method }` (active only)
- `POST /tenants/:host/store-loan-methods/calculate` → `{ method, plan }` — **always requires `methodId`**
Calculator UX when a site has multiple methods: list first → if `items.length > 1`, ask the shopper which method → then collect purchase total / credit / months → `POST .../calculate`. If exactly one method, auto-select it. Never calculate without a chosen `methodId`.
```json
POST /tenants/:host/store-loan-methods/calculate
{
"methodId": "1",
"totalValue": 5000000,
"creditAmount": 2000000,
"months": 12
}
```
`plan` includes `cashPayment`, `interestFee`, `totalRepay`, and monthly `installments[]` (`index`, `dueDate`, `amount`). Public items also include `returnMonthOptions[]`.
**Example — batch create variants for a product:** **Example — batch create variants for a product:**
```json ```json
@@ -659,7 +700,7 @@ See `.env.example` for the full list. Key groups:
- Multi-tenant auth (register, login, passwordless OTP login, reset password via SMS, profile) - Multi-tenant auth (register, login, passwordless OTP login, reset password via SMS, profile)
- Super admin: users, businesses, domains, system business categories - Super admin: users, businesses, domains, system business categories
- Super admin add/update domain upserts tenant DNS via ArvanCloud or Cloudflare (`@` ANAME/CNAME, `www`/`business`/`customer`/`api` CNAMEs; Cloudflare DNS-only) - Super admin add/update domain upserts tenant DNS via ArvanCloud or Cloudflare (`@` ANAME/CNAME, `www`/`business`/`customer`/`api` CNAMEs; Cloudflare DNS-only)
- Super admin Websites ⋯ **Email DNS** upserts Stalwart mail records (mail A `185.214.101.41`, MX `mail.meshkee.com`, SPF, two DKIM TXT, autoconfig/autodiscover) on Arvan or Cloudflare; removes extra MX, `dkim._domainkey`, and `_dmarc` - Super admin Websites ⋯ **Email DNS** upserts Stalwart mail records (mail A `185.214.101.41`, MX `mail.meshkee.com`, SPF `v=spf1 mx -all`, RSA DKIM required, Ed25519 DKIM optional, autoconfig/autodiscover) on Arvan or Cloudflare; removes extra MX, `dkim._domainkey`, and `_dmarc`
- Super admin: selective migrate-from-old + purge-data (portfolio categories + portfolios; oversized images resized to max 1280×1280; purge removes portfolios + images) - Super admin: selective migrate-from-old + purge-data (portfolio categories + portfolios; oversized images resized to max 1280×1280; purge removes portfolios + images)
- Business team management - Business team management
- Media upload (S3 + Sharp) - Media upload (S3 + Sharp)
@@ -667,6 +708,7 @@ See `.env.example` for the full list. Key groups:
- Products CRUD - Products CRUD
- Product variation values (which options a product offers) - Product variation values (which options a product offers)
- Store items / product variants (price, stock, SKU) - Store items / product variants (price, stock, SKU)
- Store loan methods / installments (`store_installments`): dashboard CRUD + public list/get/calculate
- Torob Product API v3 (`POST /tenants/:host/torob_api/v3/products`) for store-module websites; nginx proxies `/torob_api/v3/products` on the shop apex - Torob Product API v3 (`POST /tenants/:host/torob_api/v3/products`) for store-module websites; nginx proxies `/torob_api/v3/products` on the shop apex
- Shopping cart + checkout + orders (customer + admin) - Shopping cart + checkout + orders (customer + admin)
- Online e-payment (Mellat + ZarinPal; stubs for SEP / Snapp Pay / DigiPay) - Online e-payment (Mellat + ZarinPal; stubs for SEP / Snapp Pay / DigiPay)
+19
View File
@@ -331,6 +331,24 @@ These are **required on every crawlable page** (home, listing, product/blog/port
**Open Graph (recommended)** **Open Graph (recommended)**
- Set `og:title`, `og:description`, and `og:image` on important pages (home + detail pages) from CMS media when available. - Set `og:title`, `og:description`, and `og:image` on important pages (home + detail pages) from CMS media when available.
### Site-wide meta tags + trust badges (eNamad / Samandehi)
Business admins configure this under **Website → Tags & Badges**.
1. `GET /tenants/{domain}/website/business-info`
2. For each item in `metaTags` (array of `{ html }`), emit the full HTML once in the root layout `<head>`.
```html
{html}
```
Example `html` value: `<meta name="google-site-verification" content="…">` (also supports `property`, `http-equiv`, etc.). Parse attributes into a `<meta />` element, or inject the sanitized string into `<head>`. Do **not** wrap it again as `name`/`content` — `html` is already the whole tag. The dashboard “Name” field is an admin label only and is **not** in this public payload.
3. For each item in `trustBadges` (array of `{ kind, embedHtml }`), render `embedHtml` in the site footer (e.g. `dangerouslySetInnerHTML`). These are HTML widgets only (not meta). Scripts are stripped by the API — still treat the HTML as CMS-controlled content.
- When arrays are empty, omit tags / footer badges.
- Do **not** hardcode eNamad/Samandehi HTML in the website repo when these fields are present.
### Site-wide Schema.org JSON-LD (Organization / LocalBusiness) ### Site-wide Schema.org JSON-LD (Organization / LocalBusiness)
Business admins configure this under **Website → Settings → Structured data**. The API builds a ready `jsonLd` object for you. Business admins configure this under **Website → Settings → Structured data**. The API builds a ready `jsonLd` object for you.
@@ -398,6 +416,7 @@ Website rules:
4. Public URL is included via CMS sitemap and/or `sitemap-config.json` static pages 4. Public URL is included via CMS sitemap and/or `sitemap-config.json` static pages
5. Site-wide Organization/LocalBusiness JSON-LD from `business-info.schema.jsonLd` when enabled 5. Site-wide Organization/LocalBusiness JSON-LD from `business-info.schema.jsonLd` when enabled
6. Product/blog detail pages emit `product.schema.jsonLd` / `blog.schema.jsonLd` when present 6. Product/blog detail pages emit `product.schema.jsonLd` / `blog.schema.jsonLd` when present
7. Site-wide `metaTags` + footer `trustBadges` from business-info when present
If OpenAPI and this brief conflict, **OpenAPI wins**. If OpenAPI and this brief conflict, **OpenAPI wins**.
+32
View File
@@ -567,6 +567,38 @@
} }
}, },
"required": ["enabled", "type", "sameAs", "jsonLd"] "required": ["enabled", "type", "sameAs", "jsonLd"]
},
"metaTags": {
"type": "array",
"description": "Custom <meta> HTML snippets for the site <head>. Each item is { html } with a full <meta …> tag. Configured under Website → Tags & Badges (name there is an admin label only).",
"items": {
"type": "object",
"properties": {
"html": {
"type": "string",
"description": "Full sanitized <meta …> HTML tag to inject in <head>."
}
},
"required": ["html"]
}
},
"trustBadges": {
"type": "array",
"description": "Enabled eNamad / Samandehi footer widgets (sanitized HTML). Empty when none are shown.",
"items": {
"type": "object",
"properties": {
"kind": {
"type": "string",
"enum": ["enamad", "samandehi"]
},
"embedHtml": {
"type": "string",
"description": "Trusted HTML snippet (scripts stripped server-side)."
}
},
"required": ["kind", "embedHtml"]
}
} }
} }
} }
+173
View File
@@ -51,6 +51,7 @@ model User {
userProducts UserProduct[] userProducts UserProduct[]
userRoles UserRole[] userRoles UserRole[]
smsTemplatesVerified SmsTemplate[] @relation("SmsTemplateVerifier") smsTemplatesVerified SmsTemplate[] @relation("SmsTemplateVerifier")
rollCalls RollCall[]
@@index([cellNumber], map: "idx_users_cell_number") @@index([cellNumber], map: "idx_users_cell_number")
@@map("users") @@map("users")
@@ -143,6 +144,7 @@ model Business {
expertReviews ExpertReview[] expertReviews ExpertReview[]
favorites Favorite[] favorites Favorite[]
invoiceItemTemplates InvoiceItemTemplate[] invoiceItemTemplates InvoiceItemTemplate[]
invoiceAccountTemplates InvoiceAccountTemplate[]
invoiceTemplates InvoiceTemplate[] invoiceTemplates InvoiceTemplate[]
invoices Invoice[] @relation("InvoiceBusiness") invoices Invoice[] @relation("InvoiceBusiness")
invoicesIssued Invoice[] @relation("InvoiceIssuerBusiness") invoicesIssued Invoice[] @relation("InvoiceIssuerBusiness")
@@ -159,6 +161,8 @@ model Business {
shoppingCards ShoppingCard[] shoppingCards ShoppingCard[]
storeItemVariants StoreItemVariant[] storeItemVariants StoreItemVariant[]
storeItems StoreItem[] storeItems StoreItem[]
storeLoanMethods StoreLoanMethod[]
shippingCostGroups ShippingCostGroup[]
storeSpecials StoreSpecial[] storeSpecials StoreSpecial[]
transactions Transaction[] transactions Transaction[]
userProductTechnicalFieldValues UserProductTechnicalFieldValue[] userProductTechnicalFieldValues UserProductTechnicalFieldValue[]
@@ -169,6 +173,7 @@ model Business {
website_sliders website_sliders[] website_sliders website_sliders[]
websitePageViews WebsitePageView[] websitePageViews WebsitePageView[]
websitePageViewCounters WebsitePageViewCounter[] websitePageViewCounters WebsitePageViewCounter[]
rollCalls RollCall[]
@@map("businesses") @@map("businesses")
} }
@@ -378,6 +383,7 @@ model Media {
oldId BigInt? @map("old_id") oldId BigInt? @map("old_id")
blogs blogs[] blogs blogs[]
brandImages Brand[] brandImages Brand[]
storeLoanMethodImages StoreLoanMethod[]
faviconBusinesses Business[] @relation("BusinessFavicon") faviconBusinesses Business[] @relation("BusinessFavicon")
logoBusinesses Business[] @relation("BusinessLogo") logoBusinesses Business[] @relation("BusinessLogo")
logoDarkBusinesses Business[] @relation("BusinessLogoDark") logoDarkBusinesses Business[] @relation("BusinessLogoDark")
@@ -390,6 +396,7 @@ model Media {
instructions instructions[] instructions instructions[]
workshops workshops[] workshops workshops[]
featuredProducts Product[] @relation("ProductFeaturedMedia") featuredProducts Product[] @relation("ProductFeaturedMedia")
catalogProducts Product[] @relation("ProductCatalogMedia")
featuredUserProducts UserProduct[] @relation("UserProductFeaturedMedia") featuredUserProducts UserProduct[] @relation("UserProductFeaturedMedia")
website_image_slot_items website_image_slot_items[] website_image_slot_items website_image_slot_items[]
website_slider_slides website_slider_slides[] website_slider_slides website_slider_slides[]
@@ -439,6 +446,7 @@ model Category {
assignments CategoryAssignment[] assignments CategoryAssignment[]
technicalForm CategoryTechnicalForm? technicalForm CategoryTechnicalForm?
variations CategoryVariation[] variations CategoryVariation[]
shippingCostGroupCategories ShippingCostGroupCategory[]
website_category_group_items website_category_group_items[] website_category_group_items website_category_group_items[]
@@unique([businessId, entityType, slug], map: "categories_business_entity_slug_unique") @@unique([businessId, entityType, slug], map: "categories_business_entity_slug_unique")
@@ -521,6 +529,7 @@ model Product {
stockQuantity Int? @map("stock_quantity") stockQuantity Int? @map("stock_quantity")
status ContentStatus @default(draft) status ContentStatus @default(draft)
featuredMediaId BigInt? @map("featured_media_id") featuredMediaId BigInt? @map("featured_media_id")
catalogMediaId BigInt? @map("catalog_media_id")
sortOrder Int @default(0) @map("sort_order") sortOrder Int @default(0) @map("sort_order")
publishedAt DateTime? @map("published_at") @db.Timestamptz(6) publishedAt DateTime? @map("published_at") @db.Timestamptz(6)
metadata Json @default("{}") metadata Json @default("{}")
@@ -538,6 +547,7 @@ model Product {
brand Brand? @relation(fields: [brandId], references: [id], onUpdate: NoAction) brand Brand? @relation(fields: [brandId], references: [id], onUpdate: NoAction)
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction) business Business @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction)
featuredMedia Media? @relation("ProductFeaturedMedia", fields: [featuredMediaId], references: [id], onUpdate: NoAction) featuredMedia Media? @relation("ProductFeaturedMedia", fields: [featuredMediaId], references: [id], onUpdate: NoAction)
catalogMedia Media? @relation("ProductCatalogMedia", fields: [catalogMediaId], references: [id], onUpdate: NoAction)
shoppingCardItems ShoppingCardItem[] shoppingCardItems ShoppingCardItem[]
storeItem StoreItem[] storeItem StoreItem[]
@@ -546,6 +556,7 @@ model Product {
@@index([brandId], map: "idx_products_brand_id") @@index([brandId], map: "idx_products_brand_id")
@@index([businessId, status], map: "idx_products_business_status") @@index([businessId, status], map: "idx_products_business_status")
@@index([businessId, publishedAt(sort: Desc)], map: "idx_products_business_published") @@index([businessId, publishedAt(sort: Desc)], map: "idx_products_business_published")
@@index([catalogMediaId], map: "idx_products_catalog_media_id")
@@map("products") @@map("products")
} }
@@ -936,6 +947,7 @@ model City {
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6) updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
parent City? @relation("CityTree", fields: [parentId], references: [id], onDelete: Cascade, onUpdate: NoAction) parent City? @relation("CityTree", fields: [parentId], references: [id], onDelete: Cascade, onUpdate: NoAction)
children City[] @relation("CityTree") children City[] @relation("CityTree")
shippingCostCityRates ShippingCostGroupCityRate[]
userProductsAsCity UserProduct[] @relation("UserProductCity") userProductsAsCity UserProduct[] @relation("UserProductCity")
userProductsAsCountry UserProduct[] @relation("UserProductCountry") userProductsAsCountry UserProduct[] @relation("UserProductCountry")
userProductsAsDistrict UserProduct[] @relation("UserProductDistrict") userProductsAsDistrict UserProduct[] @relation("UserProductDistrict")
@@ -1141,6 +1153,7 @@ model StoreItem {
businessId BigInt @map("business_id") businessId BigInt @map("business_id")
productId BigInt @map("product_id") productId BigInt @map("product_id")
isActive Boolean @default(true) @map("is_active") isActive Boolean @default(true) @map("is_active")
isUsed Boolean @default(false) @map("is_used")
sortOrder Int @default(0) @map("sort_order") sortOrder Int @default(0) @map("sort_order")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6) updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
@@ -1152,6 +1165,7 @@ model StoreItem {
@@unique([businessId, productId], map: "store_items_business_product_unique") @@unique([businessId, productId], map: "store_items_business_product_unique")
@@index([businessId], map: "idx_store_items_business_id") @@index([businessId], map: "idx_store_items_business_id")
@@index([productId], map: "idx_store_items_product_id") @@index([productId], map: "idx_store_items_product_id")
@@index([businessId, isUsed], map: "idx_store_items_business_is_used")
@@map("store_items") @@map("store_items")
} }
@@ -1217,6 +1231,84 @@ model StoreSpecial {
@@map("store_specials") @@map("store_specials")
} }
model StoreLoanMethod {
id BigInt @id @default(autoincrement())
businessId BigInt @map("business_id")
nameFa String @map("name") @db.VarChar(255)
nameEn String @map("name_en") @db.VarChar(255)
description String?
imageMediaId BigInt? @map("image_media_id")
imageAspectRatio String @default("1:1") @map("image_aspect_ratio") @db.VarChar(10)
minShoppingAmount Decimal @map("min_shopping_amount") @db.Decimal(12, 2)
minAmount Decimal @map("min_amount") @db.Decimal(12, 2)
maxAmount Decimal @map("max_amount") @db.Decimal(12, 2)
interest Decimal @db.Decimal(8, 4)
returnMonthsMin Int @map("return_months_min")
returnMonthsMax Int @map("return_months_max")
returnMonthsStep Int @default(3) @map("return_months_step")
sortOrder Int @default(0) @map("sort_order")
isActive Boolean @default(true) @map("is_active")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction)
imageMedia Media? @relation(fields: [imageMediaId], references: [id], onUpdate: NoAction)
@@index([businessId], map: "idx_store_loan_methods_business_id")
@@index([imageMediaId], map: "idx_store_loan_methods_image_media_id")
@@index([businessId, sortOrder], map: "idx_store_loan_methods_business_sort_order")
@@map("store_loan_methods")
}
model ShippingCostGroup {
id BigInt @id @default(autoincrement())
businessId BigInt @map("business_id")
nameFa String @map("name") @db.VarChar(255)
nameEn String @map("name_en") @db.VarChar(255)
description String?
methods String[] @default([])
defaultAmount Decimal @default(0) @map("default_amount") @db.Decimal(12, 2)
sortOrder Int @default(0) @map("sort_order")
isActive Boolean @default(true) @map("is_active")
isDefault Boolean @default(false) @map("is_default")
pasKraye Boolean @default(false) @map("pas_kraye")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction)
cityRates ShippingCostGroupCityRate[]
categories ShippingCostGroupCategory[]
@@index([businessId], map: "idx_shipping_cost_groups_business_id")
@@index([businessId, sortOrder], map: "idx_shipping_cost_groups_business_sort_order")
@@map("shipping_cost_groups")
}
model ShippingCostGroupCityRate {
id BigInt @id @default(autoincrement())
shippingCostGroupId BigInt @map("shipping_cost_group_id")
cityId BigInt @map("city_id")
amount Decimal @db.Decimal(12, 2)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
shippingCostGroup ShippingCostGroup @relation(fields: [shippingCostGroupId], references: [id], onDelete: Cascade, onUpdate: NoAction)
city City @relation(fields: [cityId], references: [id], onDelete: Cascade, onUpdate: NoAction)
@@unique([shippingCostGroupId, cityId], map: "shipping_cost_group_city_rates_unique")
@@index([shippingCostGroupId], map: "idx_shipping_cost_group_city_rates_group_id")
@@index([cityId], map: "idx_shipping_cost_group_city_rates_city_id")
@@map("shipping_cost_group_city_rates")
}
model ShippingCostGroupCategory {
shippingCostGroupId BigInt @map("shipping_cost_group_id")
categoryId BigInt @map("category_id")
shippingCostGroup ShippingCostGroup @relation(fields: [shippingCostGroupId], references: [id], onDelete: Cascade, onUpdate: NoAction)
category Category @relation(fields: [categoryId], references: [id], onDelete: Cascade, onUpdate: NoAction)
@@id([shippingCostGroupId, categoryId])
@@index([categoryId], map: "idx_shipping_cost_group_categories_category_id")
@@map("shipping_cost_group_categories")
}
model website_brand_group_items { model website_brand_group_items {
id BigInt @id @default(autoincrement()) id BigInt @id @default(autoincrement())
group_id BigInt group_id BigInt
@@ -1368,6 +1460,25 @@ model InvoiceItemTemplate {
@@map("invoice_item_templates") @@map("invoice_item_templates")
} }
model InvoiceAccountTemplate {
id BigInt @id @default(autoincrement())
ownerScope InvoiceOwnerScope @map("owner_scope")
businessId BigInt? @map("business_id")
bankName String @map("bank_name") @db.VarChar(255)
accountHolderName String? @map("account_holder_name") @db.VarChar(255)
cardNumber String? @map("card_number") @db.VarChar(64)
iban String? @db.VarChar(64)
sortOrder Int @default(0) @map("sort_order")
isActive Boolean @default(true) @map("is_active")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
business Business? @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction)
@@index([ownerScope, sortOrder], map: "idx_invoice_account_templates_owner_scope")
@@index([businessId], map: "idx_invoice_account_templates_business_id")
@@map("invoice_account_templates")
}
model InvoiceTemplate { model InvoiceTemplate {
id BigInt @id @default(autoincrement()) id BigInt @id @default(autoincrement())
ownerScope InvoiceOwnerScope @map("owner_scope") ownerScope InvoiceOwnerScope @map("owner_scope")
@@ -1381,6 +1492,7 @@ model InvoiceTemplate {
accounts InvoiceTemplateAccount[] accounts InvoiceTemplateAccount[]
items InvoiceTemplateItem[] items InvoiceTemplateItem[]
keyPoints InvoiceTemplateKeyPoint[] keyPoints InvoiceTemplateKeyPoint[]
paymentMethods InvoiceTemplatePaymentMethod[]
business Business? @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction) business Business? @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction)
invoices Invoice[] invoices Invoice[]
@@ -1421,6 +1533,19 @@ model InvoiceTemplateKeyPoint {
@@map("invoice_template_key_points") @@map("invoice_template_key_points")
} }
model InvoiceTemplatePaymentMethod {
id BigInt @id @default(autoincrement())
templateId BigInt @map("template_id")
text String
sortOrder Int @default(0) @map("sort_order")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
template InvoiceTemplate @relation(fields: [templateId], references: [id], onDelete: Cascade, onUpdate: NoAction)
@@index([templateId, sortOrder], map: "idx_invoice_template_payment_methods_template_id")
@@map("invoice_template_payment_methods")
}
model InvoiceTemplateAccount { model InvoiceTemplateAccount {
id BigInt @id @default(autoincrement()) id BigInt @id @default(autoincrement())
templateId BigInt @map("template_id") templateId BigInt @map("template_id")
@@ -1456,6 +1581,7 @@ model Invoice {
accounts InvoiceAccount[] accounts InvoiceAccount[]
items InvoiceItem[] items InvoiceItem[]
keyPoints InvoiceKeyPoint[] keyPoints InvoiceKeyPoint[]
paymentMethods InvoicePaymentMethod[]
business Business @relation("InvoiceBusiness", fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction) business Business @relation("InvoiceBusiness", fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction)
invoiceTemplate InvoiceTemplate? @relation(fields: [invoiceTemplateId], references: [id], onUpdate: NoAction) invoiceTemplate InvoiceTemplate? @relation(fields: [invoiceTemplateId], references: [id], onUpdate: NoAction)
issuer User? @relation("InvoiceIssuer", fields: [issuedBy], references: [id], onUpdate: NoAction) issuer User? @relation("InvoiceIssuer", fields: [issuedBy], references: [id], onUpdate: NoAction)
@@ -1502,6 +1628,19 @@ model InvoiceKeyPoint {
@@map("invoice_key_points") @@map("invoice_key_points")
} }
model InvoicePaymentMethod {
id BigInt @id @default(autoincrement())
invoiceId BigInt @map("invoice_id")
text String
sortOrder Int @default(0) @map("sort_order")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
invoice Invoice @relation(fields: [invoiceId], references: [id], onDelete: Cascade, onUpdate: NoAction)
@@index([invoiceId, sortOrder], map: "idx_invoice_payment_methods_invoice_id")
@@map("invoice_payment_methods")
}
model InvoiceAccount { model InvoiceAccount {
id BigInt @id @default(autoincrement()) id BigInt @id @default(autoincrement())
invoiceId BigInt @map("invoice_id") invoiceId BigInt @map("invoice_id")
@@ -1603,6 +1742,7 @@ model UserProduct {
enum MediaType { enum MediaType {
image image
video video
document
@@map("media_type") @@map("media_type")
} }
@@ -1658,6 +1798,7 @@ enum TechnicalFieldType {
textarea textarea
select select
multi_select multi_select
multi_value
@@map("technical_field_type") @@map("technical_field_type")
} }
@@ -1823,3 +1964,35 @@ enum TranslationStatus {
@@map("translation_status") @@map("translation_status")
} }
model RollCall {
id BigInt @id @default(autoincrement())
businessId BigInt @map("business_id")
userId BigInt @map("user_id")
workDate DateTime @map("work_date") @db.Date
startTime String @map("start_time") @db.VarChar(5)
endTime String @map("end_time") @db.VarChar(5)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction)
user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: NoAction)
tasks RollCallTask[]
@@unique([businessId, userId, workDate], map: "roll_calls_business_user_date_unique")
@@index([businessId], map: "idx_roll_calls_business_id")
@@index([userId], map: "idx_roll_calls_user_id")
@@index([businessId, userId, workDate(sort: Desc)], map: "idx_roll_calls_business_user_date")
@@map("roll_calls")
}
model RollCallTask {
id BigInt @id @default(autoincrement())
rollCallId BigInt @map("roll_call_id")
title String @db.VarChar(500)
sortOrder Int @default(0) @map("sort_order")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
rollCall RollCall @relation(fields: [rollCallId], references: [id], onDelete: Cascade, onUpdate: NoAction)
@@index([rollCallId], map: "idx_roll_call_tasks_roll_call_id")
@@map("roll_call_tasks")
}
+2
View File
@@ -47,6 +47,7 @@ import { PaymentsModule } from './payments/payments.module';
import { SitemapModule } from './sitemap/sitemap.module'; import { SitemapModule } from './sitemap/sitemap.module';
import { SmsTemplatesModule } from './sms-templates/sms-templates.module'; import { SmsTemplatesModule } from './sms-templates/sms-templates.module';
import { TorobModule } from './torob/torob.module'; import { TorobModule } from './torob/torob.module';
import { RollCallsModule } from './roll-calls/roll-calls.module';
@Module({ @Module({
imports: [ imports: [
@@ -98,6 +99,7 @@ import { TorobModule } from './torob/torob.module';
SitemapModule, SitemapModule,
SmsTemplatesModule, SmsTemplatesModule,
TorobModule, TorobModule,
RollCallsModule,
], ],
}) })
export class AppModule {} export class AppModule {}
+18
View File
@@ -18,6 +18,9 @@ import { RefreshTokenDto } from './dto/refresh-token.dto';
import { RegisterDto } from './dto/register.dto'; import { RegisterDto } from './dto/register.dto';
import { ResetPasswordDto } from './dto/reset-password.dto'; import { ResetPasswordDto } from './dto/reset-password.dto';
import { SendOtpDto } from './dto/send-otp.dto'; import { SendOtpDto } from './dto/send-otp.dto';
import { SignupCompleteDto } from './dto/signup-complete.dto';
import { SignupStartDto } from './dto/signup-start.dto';
import { SignupVerifyOtpDto } from './dto/signup-verify-otp.dto';
import { UpdateProfileDto } from './dto/update-profile.dto'; import { UpdateProfileDto } from './dto/update-profile.dto';
import { UpsertUserAddressDto } from './dto/upsert-user-address.dto'; import { UpsertUserAddressDto } from './dto/upsert-user-address.dto';
import { VerifyOtpDto } from './dto/verify-otp.dto'; import { VerifyOtpDto } from './dto/verify-otp.dto';
@@ -37,6 +40,21 @@ export class AuthController {
return this.authService.register(dto); return this.authService.register(dto);
} }
@Post('signup/start')
startSignup(@Body() dto: SignupStartDto) {
return this.authService.startSignup(dto);
}
@Post('signup/verify-otp')
verifySignupOtp(@Body() dto: SignupVerifyOtpDto) {
return this.authService.verifySignupOtp(dto.cellNumber, dto.code);
}
@Post('signup/complete')
completeSignup(@Body() dto: SignupCompleteDto) {
return this.authService.completeSignup(dto);
}
@Post('login') @Post('login')
login(@Body() dto: LoginDto) { login(@Body() dto: LoginDto) {
return this.authService.login(dto); return this.authService.login(dto);
+338 -22
View File
@@ -24,6 +24,8 @@ import {
import { ChangePasswordDto } from './dto/change-password.dto'; import { ChangePasswordDto } from './dto/change-password.dto';
import { LoginDto } from './dto/login.dto'; import { LoginDto } from './dto/login.dto';
import { RegisterDto } from './dto/register.dto'; import { RegisterDto } from './dto/register.dto';
import { SignupCompleteDto } from './dto/signup-complete.dto';
import { SignupStartDto } from './dto/signup-start.dto';
import { UpdateProfileDto } from './dto/update-profile.dto'; import { UpdateProfileDto } from './dto/update-profile.dto';
import { PermissionsService } from './permissions.service'; import { PermissionsService } from './permissions.service';
import { parseUserProfile } from './profile.util'; import { parseUserProfile } from './profile.util';
@@ -33,6 +35,8 @@ import { SmsBillingService } from './sms-billing.service';
const OTP_TTL_SECONDS = 300; const OTP_TTL_SECONDS = 300;
/** Pending cross-site password change (longer than OTP so resend still works). */ /** Pending cross-site password change (longer than OTP so resend still works). */
const PENDING_PASSWORD_TTL_SECONDS = 600; const PENDING_PASSWORD_TTL_SECONDS = 600;
/** Pending signup + post-OTP registration token. */
const PENDING_SIGNUP_TTL_SECONDS = 600;
const HANDOFF_TTL_SECONDS = 60; const HANDOFF_TTL_SECONDS = 60;
const HANDOFF_RATE_LIMIT = 10; const HANDOFF_RATE_LIMIT = 10;
const HANDOFF_RATE_WINDOW_SECONDS = 60; const HANDOFF_RATE_WINDOW_SECONDS = 60;
@@ -212,6 +216,271 @@ export class AuthService {
}; };
} }
/**
* Signup step 1: name + number. Stores pending signup and sends OTP
* (OTP is allowed before the user row exists).
*/
async startSignup(dto: SignupStartDto) {
const business = await this.tenant.resolveBusinessByDomain(dto.domain);
const existingUser = await this.prisma.user.findUnique({
where: { cellNumber: dto.cellNumber },
include: {
businessCustomers: {
include: { business: { select: { id: true, name: true, nameFa: true } } },
},
},
});
const alreadyOnThisSite = existingUser?.businessCustomers.some(
(item) => item.businessId === business.id,
);
if (alreadyOnThisSite) {
throw new ConflictException(
'This cell number is already registered on this website',
);
}
await this.redis.setPendingSignup(
dto.cellNumber,
{
firstName: dto.firstName.trim(),
lastName: dto.lastName.trim(),
businessId: business.id.toString(),
},
PENDING_SIGNUP_TTL_SECONDS,
);
const smsEnabled = this.sms.isEnabled();
if (!smsEnabled) {
const sites = this.otherSiteNames(existingUser, business.id);
const registrationToken = await this.issueSignupVerifiedToken({
cellNumber: dto.cellNumber,
firstName: dto.firstName.trim(),
lastName: dto.lastName.trim(),
businessId: business.id.toString(),
existingUserId: existingUser ? existingUser.id.toString() : null,
});
await this.redis.deletePendingSignup(dto.cellNumber);
return {
enabled: false,
requiresOtp: false,
existingAccount: Boolean(existingUser),
sites,
registrationToken,
message:
'SMS verification is currently disabled. Continue to finish registration.',
};
}
await this.dispatchOtpSms(dto.cellNumber, business);
return {
enabled: true,
requiresOtp: true,
message: 'Verification code sent',
expiresInSeconds: OTP_TTL_SECONDS,
};
}
/**
* Signup step 2: verify OTP, then report whether the phone already has a
* Meshkee account. Returns a one-time registrationToken for step 3.
*/
async verifySignupOtp(cellNumber: string, code: string) {
const pending = await this.redis.getPendingSignup(cellNumber);
if (!pending) {
throw new BadRequestException(
'Signup session expired. Please start registration again.',
);
}
await this.consumeOtpCode(cellNumber, code);
const businessId = BigInt(pending.businessId);
const existingUser = await this.prisma.user.findUnique({
where: { cellNumber },
include: {
businessCustomers: {
include: { business: { select: { id: true, name: true, nameFa: true } } },
},
},
});
const alreadyOnThisSite = existingUser?.businessCustomers.some(
(item) => item.businessId === businessId,
);
if (alreadyOnThisSite) {
await this.redis.deletePendingSignup(cellNumber);
throw new ConflictException(
'This cell number is already registered on this website',
);
}
const sites = this.otherSiteNames(existingUser, businessId);
const registrationToken = await this.issueSignupVerifiedToken({
cellNumber,
firstName: pending.firstName,
lastName: pending.lastName,
businessId: pending.businessId,
existingUserId: existingUser ? existingUser.id.toString() : null,
});
await this.redis.deletePendingSignup(cellNumber);
return {
verified: true,
existingAccount: Boolean(existingUser),
sites,
registrationToken,
message: existingUser
? 'Phone verified. You already have a Meshkee account — your password stays the same.'
: 'Phone verified. Choose a password to finish creating your account.',
};
}
/**
* Signup step 3: create a new account (password required) or link an
* existing Meshkee account (password unchanged).
*/
async completeSignup(dto: SignupCompleteDto) {
const verified = await this.redis.consumeSignupVerifiedToken(
dto.registrationToken,
);
if (!verified) {
throw new BadRequestException(
'Registration session expired. Please start registration again.',
);
}
const businessId = BigInt(verified.businessId);
const business = await this.prisma.business.findUnique({
where: { id: businessId },
select: { id: true, name: true, slug: true },
});
if (!business) {
throw new BadRequestException('Business not found for this registration.');
}
const customerRole = await this.prisma.role.findUnique({
where: { slug: 'customer' },
});
if (!customerRole) {
throw new Error('Customer role is missing. Run database migrations first.');
}
const linkedExisting = Boolean(verified.existingUserId);
if (!linkedExisting) {
const password = dto.password?.trim();
if (!password || password.length < 8) {
throw new BadRequestException(
'Password is required (at least 8 characters) for new accounts.',
);
}
}
const userId = await this.prisma.$transaction(async (tx) => {
let userId: bigint;
if (verified.existingUserId) {
userId = BigInt(verified.existingUserId);
const existing = await tx.user.findUnique({
where: { id: userId },
include: {
businessCustomers: { select: { businessId: true } },
},
});
if (!existing) {
throw new BadRequestException(
'Existing account not found. Please start registration again.',
);
}
if (
existing.businessCustomers.some((item) => item.businessId === businessId)
) {
throw new ConflictException(
'This cell number is already registered on this website',
);
}
await tx.user.update({
where: { id: userId },
data: {
isActive: true,
cellVerifiedAt: existing.cellVerifiedAt ?? new Date(),
},
});
} else {
const collision = await tx.user.findUnique({
where: { cellNumber: verified.cellNumber },
select: { id: true },
});
if (collision) {
throw new ConflictException(
'This cell number is already registered. Please start registration again.',
);
}
const passwordHash = await bcrypt.hash(dto.password!.trim(), 10);
const created = await tx.user.create({
data: {
cellNumber: verified.cellNumber,
passwordHash,
firstName: verified.firstName,
lastName: verified.lastName,
cellVerifiedAt: new Date(),
isActive: true,
},
select: { id: true },
});
userId = created.id;
}
await tx.businessCustomer.create({
data: {
businessId,
userId,
},
});
const hasCustomerRole = await tx.userRole.findUnique({
where: {
userId_roleId: {
userId,
roleId: customerRole.id,
},
},
});
if (!hasCustomerRole) {
await tx.userRole.create({
data: {
userId,
roleId: customerRole.id,
},
});
}
return userId;
});
const authUser = await this.getAuthUser(userId);
const tokens = await this.issueTokens(authUser);
return {
message: linkedExisting
? 'Joined this website with your existing Meshkee account.'
: 'Registration successful.',
existingAccount: linkedExisting,
passwordUpdated: false,
user: this.serializeUser(authUser),
registeredBusiness: {
id: business.id,
name: business.name,
slug: business.slug,
},
...tokens,
};
}
async login(dto: LoginDto) { async login(dto: LoginDto) {
const user = await this.prisma.user.findUnique({ const user = await this.prisma.user.findUnique({
where: { cellNumber: dto.cellNumber }, where: { cellNumber: dto.cellNumber },
@@ -430,26 +699,7 @@ export class AuthService {
} }
const business = await this.tenant.resolveBusinessByDomain(host); const business = await this.tenant.resolveBusinessByDomain(host);
const businessNameFa = await this.dispatchOtpSms(cellNumber, business);
business.nameFa?.trim() || business.name?.trim() || undefined;
const code = this.generateOtpCode();
const brand = businessNameFa?.trim();
const message = brand
? `کد تایید شما: ${code}\n${brand}`
: `کد تایید شما: ${code}`;
const charged = await this.smsBilling.charge(business.id, message, 1, 'auth');
await this.redis.setOtp(cellNumber, code, OTP_TTL_SECONDS);
try {
await this.sms.sendMessage(cellNumber, message);
} catch (err) {
await this.smsBilling.refund(business.id, charged, 1, 'auth');
if (err instanceof HttpException) {
throw err;
}
throw new ServiceUnavailableException('Unable to send SMS');
}
return { return {
enabled: true, enabled: true,
@@ -545,17 +795,83 @@ export class AuthService {
throw new UnauthorizedException('Cell number is not registered'); throw new UnauthorizedException('Cell number is not registered');
} }
if (!this.sms.isEnabled()) { await this.consumeOtpCode(cellNumber, code);
return user; 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); const storedCode = await this.redis.getOtp(cellNumber);
if (!storedCode || storedCode !== code) { if (!storedCode || storedCode !== code) {
throw new UnauthorizedException('Invalid or expired verification code'); throw new UnauthorizedException('Invalid or expired verification code');
} }
await this.redis.deleteOtp(cellNumber); await this.redis.deleteOtp(cellNumber);
return user; }
private async dispatchOtpSms(
cellNumber: string,
business: { id: bigint; name: string | null; nameFa: string | null },
) {
const businessNameFa =
business.nameFa?.trim() || business.name?.trim() || undefined;
const code = this.generateOtpCode();
const brand = businessNameFa?.trim();
const message = brand
? `کد تایید شما: ${code}\n${brand}`
: `کد تایید شما: ${code}`;
const charged = await this.smsBilling.charge(business.id, message, 1, 'auth');
await this.redis.setOtp(cellNumber, code, OTP_TTL_SECONDS);
try {
await this.sms.sendMessage(cellNumber, message);
} catch (err) {
await this.smsBilling.refund(business.id, charged, 1, 'auth');
if (err instanceof HttpException) {
throw err;
}
throw new ServiceUnavailableException('Unable to send SMS');
}
}
private otherSiteNames(
existingUser:
| {
businessCustomers: {
businessId: bigint;
business: { name: string; nameFa: string | null };
}[];
}
| null
| undefined,
excludeBusinessId: bigint,
): string[] {
if (!existingUser) return [];
return existingUser.businessCustomers
.filter((item) => item.businessId !== excludeBusinessId)
.map((item) => item.business.nameFa?.trim() || item.business.name)
.filter(Boolean);
}
private async issueSignupVerifiedToken(payload: {
cellNumber: string;
firstName: string;
lastName: string;
businessId: string;
existingUserId: string | null;
}): Promise<string> {
const registrationToken = randomBytes(32).toString('hex');
await this.redis.setSignupVerifiedToken(
registrationToken,
payload,
PENDING_SIGNUP_TTL_SECONDS,
);
return registrationToken;
} }
private async ensureCustomerMembershipForDomain( private async ensureCustomerMembershipForDomain(
+17
View File
@@ -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;
}
+23
View File
@@ -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;
}
+14
View File
@@ -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;
}
+12
View File
@@ -501,6 +501,18 @@ export function toGamaMsisdn(raw: string): string | null {
return msisdn; return msisdn;
} }
/**
* Iranian mobile only (local 09xxxxxxxxx → `989xxxxxxxxx`).
* Rejects landlines, foreign numbers, and invalid formats.
*/
export function toIranianMobileMsisdn(raw: string): string | null {
const msisdn = toGamaMsisdn(raw);
if (!msisdn || !/^989\d{9}$/.test(msisdn)) {
return null;
}
return msisdn;
}
export function maskMsisdn(msisdn: string): string { export function maskMsisdn(msisdn: string): string {
if (msisdn.length < 6) return '***'; if (msisdn.length < 6) return '***';
return `${msisdn.slice(0, 4)}****${msisdn.slice(-3)}`; return `${msisdn.slice(0, 4)}****${msisdn.slice(-3)}`;
+68 -2
View File
@@ -130,6 +130,8 @@ export class BusinessAdminService {
const nameLike = query.name ? `%${query.name.trim()}%` : null; const nameLike = query.name ? `%${query.name.trim()}%` : null;
const domainLike = query.domain ? `%${query.domain.trim()}%` : null; const domainLike = query.domain ? `%${query.domain.trim()}%` : null;
const categoryLike = query.category ? `%${query.category.trim()}%` : null; const categoryLike = query.category ? `%${query.category.trim()}%` : null;
const sortByRenewal = query.sort === 'renewal';
const lowSmsThreshold = 100;
const where = Prisma.sql` const where = Prisma.sql`
WHERE 1=1 WHERE 1=1
@@ -151,7 +153,40 @@ export class BusinessAdminService {
` : Prisma.empty} ` : Prisma.empty}
`; `;
const [items, totalRow] = await Promise.all([ // Matches super-admin nextAnnualRenewal(): future contract date as-is,
// otherwise this calendar year's month/day (overdue if already past).
const nextRenewalDateSql = Prisma.sql`
CASE
WHEN COALESCE(b.annual_renewal_at, b.created_at) IS NULL THEN NULL
WHEN COALESCE(b.annual_renewal_at, b.created_at)::date > CURRENT_DATE
THEN COALESCE(b.annual_renewal_at, b.created_at)::date
ELSE make_date(
EXTRACT(YEAR FROM CURRENT_DATE)::int,
EXTRACT(MONTH FROM COALESCE(b.annual_renewal_at, b.created_at))::int,
LEAST(
EXTRACT(DAY FROM COALESCE(b.annual_renewal_at, b.created_at))::int,
EXTRACT(
DAY FROM (
DATE_TRUNC(
'month',
make_date(
EXTRACT(YEAR FROM CURRENT_DATE)::int,
EXTRACT(MONTH FROM COALESCE(b.annual_renewal_at, b.created_at))::int,
1
)
) + INTERVAL '1 month - 1 day'
)
)::int
)
)
END
`;
const orderBy = sortByRenewal
? Prisma.sql`ORDER BY ${nextRenewalDateSql} ASC NULLS LAST, b.created_at DESC`
: Prisma.sql`ORDER BY b.created_at DESC`;
const [items, totalRow, lowSmsRows] = await Promise.all([
this.prisma.$queryRaw<BusinessRow[]>(Prisma.sql` this.prisma.$queryRaw<BusinessRow[]>(Prisma.sql`
SELECT SELECT
b.id AS "id", b.id AS "id",
@@ -205,7 +240,7 @@ export class BusinessAdminService {
LIMIT 1 LIMIT 1
) own ON TRUE ) own ON TRUE
${where} ${where}
ORDER BY b.created_at DESC ${orderBy}
LIMIT ${pageSize} OFFSET ${skip} LIMIT ${pageSize} OFFSET ${skip}
`), `),
this.prisma.$queryRaw<{ total: number }[]>(Prisma.sql` this.prisma.$queryRaw<{ total: number }[]>(Prisma.sql`
@@ -213,6 +248,30 @@ export class BusinessAdminService {
FROM businesses b FROM businesses b
${where} ${where}
`), `),
this.prisma.$queryRaw<
{
id: bigint;
name: string;
domain: string | null;
smsBalance: number;
}[]
>(Prisma.sql`
SELECT
b.id AS "id",
b.name AS "name",
dom.host AS "domain",
b.sms_balance AS "smsBalance"
FROM businesses b
LEFT JOIN LATERAL (
SELECT d.host
FROM domains d
WHERE d.business_id = b.id
ORDER BY d.is_primary DESC, d.created_at DESC
LIMIT 1
) dom ON TRUE
WHERE b.sms_balance < ${lowSmsThreshold}
ORDER BY b.sms_balance ASC, b.name ASC
`),
]); ]);
return { return {
@@ -260,6 +319,13 @@ export class BusinessAdminService {
total: totalRow[0]?.total ?? 0, total: totalRow[0]?.total ?? 0,
page, page,
pageSize, pageSize,
lowSmsBusinesses: lowSmsRows.map((row) => ({
id: row.id,
name: row.name,
domain: row.domain,
smsBalance: Number(row.smsBalance ?? 0),
})),
lowSmsThreshold,
}; };
} }
@@ -1,6 +1,11 @@
import { import {
Body,
Controller, Controller,
Delete,
Get, Get,
Param,
Patch,
Post,
Query, Query,
UseGuards, UseGuards,
} from '@nestjs/common'; } from '@nestjs/common';
@@ -9,6 +14,10 @@ import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { CurrentUser } from '../auth/decorators/current-user.decorator'; import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { AuthUser } from '../auth/auth.types'; import { AuthUser } from '../auth/auth.types';
import { BusinessCategoriesService } from './business-categories.service'; import { BusinessCategoriesService } from './business-categories.service';
import {
CreateBusinessCategoryDto,
UpdateBusinessCategoryDto,
} from './dto/business-category.dto';
class ListBusinessCategoriesDto { class ListBusinessCategoriesDto {
@IsOptional() @IsOptional()
@@ -29,4 +38,32 @@ export class BusinessCategoriesController {
) { ) {
return this.service.list(user, query.q); return this.service.list(user, query.q);
} }
@Post()
@UseGuards(JwtAuthGuard)
create(
@CurrentUser() user: AuthUser,
@Body() dto: CreateBusinessCategoryDto,
) {
return this.service.create(user, dto);
}
@Patch(':categoryId')
@UseGuards(JwtAuthGuard)
update(
@CurrentUser() user: AuthUser,
@Param('categoryId') categoryId: string,
@Body() dto: UpdateBusinessCategoryDto,
) {
return this.service.update(user, categoryId, dto);
}
@Delete(':categoryId')
@UseGuards(JwtAuthGuard)
remove(
@CurrentUser() user: AuthUser,
@Param('categoryId') categoryId: string,
) {
return this.service.remove(user, categoryId);
}
} }
+228 -10
View File
@@ -1,8 +1,27 @@
import { Injectable, ForbiddenException } from '@nestjs/common'; import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { PermissionsService } from '../auth/permissions.service'; import { PermissionsService } from '../auth/permissions.service';
import { AuthUser } from '../auth/auth.types'; import { AuthUser } from '../auth/auth.types';
import {
CreateBusinessCategoryDto,
UpdateBusinessCategoryDto,
} from './dto/business-category.dto';
function slugify(value: string): string {
return (
value
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '') || 'category'
);
}
@Injectable() @Injectable()
export class BusinessCategoriesService { export class BusinessCategoriesService {
@@ -12,15 +31,7 @@ export class BusinessCategoriesService {
) {} ) {}
async list(actor: AuthUser, q?: string) { async list(actor: AuthUser, q?: string) {
const isSuperAdmin = await this.permissions.isSuperAdmin(actor.id); await this.assertCanRead(actor);
const canRead =
isSuperAdmin ||
actor.roles.includes('business_owner') ||
actor.roles.includes('business_staff');
if (!canRead) {
throw new ForbiddenException('Access denied');
}
const term = q?.trim(); const term = q?.trim();
if (term) { if (term) {
@@ -88,4 +99,211 @@ export class BusinessCategoriesService {
return { items: categories }; return { items: categories };
} }
async create(actor: AuthUser, dto: CreateBusinessCategoryDto) {
await this.assertCanManage(actor);
const parentId = await this.resolveParentId(dto.parentId);
const slug = await this.ensureUniqueSlug(dto.slug ?? slugify(dto.name));
const created = await this.prisma.businessCategory.create({
data: {
parentId,
name: dto.name.trim(),
nameFa: dto.nameFa?.trim() || null,
slug,
description: dto.description?.trim() || null,
icon: dto.icon?.trim() || null,
sortOrder: dto.sortOrder ?? 0,
isActive: true,
},
select: {
id: true,
parentId: true,
name: true,
nameFa: true,
slug: true,
description: true,
icon: true,
sortOrder: true,
tags: true,
},
});
return { item: created };
}
async update(
actor: AuthUser,
categoryIdRaw: string,
dto: UpdateBusinessCategoryDto,
) {
await this.assertCanManage(actor);
const categoryId = this.parseId(categoryIdRaw);
const existing = await this.prisma.businessCategory.findFirst({
where: { id: categoryId, isActive: true },
});
if (!existing) {
throw new NotFoundException('Category not found');
}
let parentId: bigint | null | undefined = undefined;
if (dto.parentId !== undefined) {
if (dto.parentId === null || dto.parentId === '') {
parentId = null;
} else {
parentId = await this.resolveParentId(dto.parentId);
if (parentId === categoryId) {
throw new BadRequestException('Category cannot be its own parent');
}
const descendantIds = await this.collectDescendantIds(categoryId);
if (parentId && descendantIds.includes(parentId)) {
throw new BadRequestException(
'Cannot move category under its own descendant',
);
}
}
}
let slug = existing.slug;
if (dto.slug) {
slug = await this.ensureUniqueSlug(dto.slug, categoryId);
} else if (dto.name && dto.name !== existing.name) {
slug = await this.ensureUniqueSlug(slugify(dto.name), categoryId);
}
const updated = await this.prisma.businessCategory.update({
where: { id: categoryId },
data: {
...(dto.name !== undefined ? { name: dto.name.trim() } : {}),
...(dto.nameFa !== undefined
? { nameFa: dto.nameFa?.trim() || null }
: {}),
...(dto.description !== undefined
? { description: dto.description?.trim() || null }
: {}),
...(dto.icon !== undefined ? { icon: dto.icon?.trim() || null } : {}),
...(dto.sortOrder !== undefined ? { sortOrder: dto.sortOrder } : {}),
...(parentId !== undefined ? { parentId } : {}),
slug,
},
select: {
id: true,
parentId: true,
name: true,
nameFa: true,
slug: true,
description: true,
icon: true,
sortOrder: true,
tags: true,
},
});
return { item: updated };
}
async remove(actor: AuthUser, categoryIdRaw: string) {
await this.assertCanManage(actor);
const categoryId = this.parseId(categoryIdRaw);
const existing = await this.prisma.businessCategory.findFirst({
where: { id: categoryId, isActive: true },
select: { id: true },
});
if (!existing) {
throw new NotFoundException('Category not found');
}
const descendantIds = await this.collectDescendantIds(categoryId);
await this.prisma.businessCategory.updateMany({
where: { id: { in: descendantIds } },
data: { isActive: false },
});
return {
message: 'Category deleted successfully',
deletedIds: descendantIds.map((id) => id.toString()),
};
}
private async assertCanRead(actor: AuthUser) {
const isSuperAdmin = await this.permissions.isSuperAdmin(actor.id);
const canRead =
isSuperAdmin ||
actor.roles.includes('business_owner') ||
actor.roles.includes('business_staff');
if (!canRead) {
throw new ForbiddenException('Access denied');
}
}
private async assertCanManage(actor: AuthUser) {
const isSuperAdmin = await this.permissions.isSuperAdmin(actor.id);
if (!isSuperAdmin) {
throw new ForbiddenException('Only super admins can manage categories');
}
}
private parseId(value: string) {
if (!/^\d+$/.test(value)) {
throw new BadRequestException('Invalid category id');
}
return BigInt(value);
}
private async resolveParentId(parentIdRaw?: string | null) {
if (parentIdRaw == null || parentIdRaw === '') return null;
const parentId = this.parseId(parentIdRaw);
const parent = await this.prisma.businessCategory.findFirst({
where: { id: parentId, isActive: true },
select: { id: true },
});
if (!parent) {
throw new BadRequestException('Parent category not found');
}
return parent.id;
}
private async ensureUniqueSlug(base: string, excludeId?: bigint) {
let candidate = slugify(base);
let suffix = 2;
while (true) {
const existing = await this.prisma.businessCategory.findFirst({
where: {
slug: candidate,
...(excludeId ? { id: { not: excludeId } } : {}),
},
select: { id: true },
});
if (!existing) return candidate;
candidate = `${slugify(base)}-${suffix}`;
suffix += 1;
}
}
private async collectDescendantIds(rootId: bigint): Promise<bigint[]> {
const all = await this.prisma.businessCategory.findMany({
where: { isActive: true },
select: { id: true, parentId: true },
});
const result: bigint[] = [rootId];
const queue = [rootId];
while (queue.length > 0) {
const current = queue.shift()!;
for (const child of all) {
if (child.parentId === current && !result.includes(child.id)) {
result.push(child.id);
queue.push(child.id);
}
}
}
return result;
}
} }
@@ -0,0 +1,90 @@
import { Type } from 'class-transformer';
import {
IsInt,
IsOptional,
IsString,
Matches,
MaxLength,
Min,
MinLength,
ValidateIf,
} from 'class-validator';
export class CreateBusinessCategoryDto {
@IsString()
@MinLength(2)
@MaxLength(255)
name!: string;
@IsOptional()
@IsString()
@MaxLength(255)
nameFa?: string;
@IsOptional()
@ValidateIf((_, value) => value !== null && value !== '')
@IsString()
parentId?: string | null;
@IsOptional()
@IsString()
description?: string;
@IsOptional()
@IsString()
@Matches(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, {
message: 'slug must be lowercase letters, numbers, and hyphens',
})
slug?: string;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
sortOrder?: number;
@IsOptional()
@IsString()
@MaxLength(100)
icon?: string;
}
export class UpdateBusinessCategoryDto {
@IsOptional()
@IsString()
@MinLength(2)
@MaxLength(255)
name?: string;
@IsOptional()
@IsString()
@MaxLength(255)
nameFa?: string | null;
@IsOptional()
@ValidateIf((_, value) => value !== null && value !== '')
@IsString()
parentId?: string | null;
@IsOptional()
@IsString()
description?: string | null;
@IsOptional()
@IsString()
@Matches(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, {
message: 'slug must be lowercase letters, numbers, and hyphens',
})
slug?: string;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
sortOrder?: number;
@IsOptional()
@IsString()
@MaxLength(100)
icon?: string | null;
}
@@ -1,5 +1,5 @@
import { Type } from 'class-transformer'; import { Type } from 'class-transformer';
import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator'; import { IsIn, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
export class ListBusinessesDto { export class ListBusinessesDto {
@IsOptional() @IsOptional()
@@ -26,5 +26,10 @@ export class ListBusinessesDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
category?: string; category?: string;
/** `created` (default) or `renewal` (nearest next/overdue renewal first). */
@IsOptional()
@IsIn(['created', 'renewal'])
sort?: 'created' | 'renewal';
} }
+25 -4
View File
@@ -1379,7 +1379,7 @@ export class LegacyMigrateService {
.filter((m) => Number(m.size) > 0) .filter((m) => Number(m.size) > 0)
.slice(0, 12) .slice(0, 12)
.map((m) => ({ id: m.id, fileName: m.file_name })); .map((m) => ({ id: m.id, fileName: m.file_name }));
const storagePrefix = await this.legacySourceS3.resolveBusinessPrefix( const storagePrefix = await this.tryResolveStoragePrefix(
oldBizId, oldBizId,
oldSlug, oldSlug,
probeMedia, probeMedia,
@@ -1417,7 +1417,7 @@ export class LegacyMigrateService {
for (const media of articleMedia) { for (const media of articleMedia) {
const size = Number(media.size) || 0; const size = Number(media.size) || 0;
if (size <= 0) { if (size <= 0 || !storagePrefix) {
imagesFailed += 1; imagesFailed += 1;
continue; continue;
} }
@@ -1611,7 +1611,7 @@ export class LegacyMigrateService {
.filter((m) => Number(m.size) > 0) .filter((m) => Number(m.size) > 0)
.slice(0, 12) .slice(0, 12)
.map((m) => ({ id: m.id, fileName: m.file_name })); .map((m) => ({ id: m.id, fileName: m.file_name }));
const storagePrefix = await this.legacySourceS3.resolveBusinessPrefix( const storagePrefix = await this.tryResolveStoragePrefix(
oldBizId, oldBizId,
oldSlug, oldSlug,
probeMedia, probeMedia,
@@ -1648,7 +1648,7 @@ export class LegacyMigrateService {
for (const media of itemMedia) { for (const media of itemMedia) {
const size = Number(media.size) || 0; const size = Number(media.size) || 0;
if (size <= 0) { if (size <= 0 || !storagePrefix) {
imagesFailed += 1; imagesFailed += 1;
continue; continue;
} }
@@ -1978,6 +1978,27 @@ export class LegacyMigrateService {
}; };
} }
private async tryResolveStoragePrefix(
oldBizId: number,
oldSlug: string | null,
probeMedia: Array<{ id: number; fileName: string }>,
): Promise<string | null> {
if (!probeMedia.length) return null;
try {
return await this.legacySourceS3.resolveBusinessPrefix(
oldBizId,
oldSlug,
probeMedia,
);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.warn(
`Legacy migrate: skipping image copy for business ${oldBizId}: ${message}`,
);
return null;
}
}
private async copyLegacyMedia(input: { private async copyLegacyMedia(input: {
businessId: bigint; businessId: bigint;
oldMedia: OldMediaRow; oldMedia: OldMediaRow;
@@ -494,7 +494,7 @@ export class BusinessProfileService {
province: item.province.trim(), province: item.province.trim(),
city: item.city.trim(), city: item.city.trim(),
address: item.address.trim(), address: item.address.trim(),
postalCode: item.postalCode.trim(), postalCode: item.postalCode?.trim() || null,
landline: item.landline?.trim() || null, landline: item.landline?.trim() || null,
}; };
@@ -549,7 +549,7 @@ export class BusinessProfileService {
} }
} }
/** Recommended banner aspect 18:4.5 (4:1). Soft-check when dimensions exist. */ /** Recommended banner aspect 18:6 (3:1). Soft-check when dimensions exist. */
private async assertBannerMedia(businessId: bigint, mediaId: bigint) { private async assertBannerMedia(businessId: bigint, mediaId: bigint) {
const media = await this.prisma.media.findFirst({ const media = await this.prisma.media.findFirst({
where: { id: mediaId, businessId }, where: { id: mediaId, businessId },
@@ -562,10 +562,10 @@ export class BusinessProfileService {
if (media.width && media.height && media.height > 0) { if (media.width && media.height && media.height > 0) {
const ratio = media.width / media.height; const ratio = media.width / media.height;
const target = 18 / 4.5; // 4 const target = 18 / 6; // 3
if (Math.abs(ratio - target) / target > 0.12) { if (Math.abs(ratio - target) / target > 0.12) {
throw new BadRequestException( throw new BadRequestException(
'Banner aspect ratio should be about 18:4.5 (4:1)', 'Banner aspect ratio should be about 18:6 (3:1)',
); );
} }
} }
@@ -41,7 +41,7 @@ export type BusinessProfile = {
logoDarkUrl: string | null; logoDarkUrl: string | null;
faviconMediaId: string | null; faviconMediaId: string | null;
faviconUrl: string | null; faviconUrl: string | null;
/** Recommended aspect ratio 18:4.5 (4:1). */ /** Recommended aspect ratio 18:6 (3:1). */
bannerMediaId: string | null; bannerMediaId: string | null;
bannerUrl: string | null; bannerUrl: string | null;
categoryIds: string[]; categoryIds: string[];
@@ -71,9 +71,10 @@ class BusinessAddressDto {
@MinLength(1) @MinLength(1)
address!: string; address!: string;
@IsOptional()
@IsString() @IsString()
@MinLength(1) @MaxLength(20)
postalCode!: string; postalCode?: string | null;
@IsOptional() @IsOptional()
@IsString() @IsString()
@@ -136,7 +137,7 @@ export class UpdateBusinessProfileDto {
@IsInt() @IsInt()
faviconMediaId?: number | null; faviconMediaId?: number | null;
/** Marketplace/storefront banner; recommended aspect 18:4.5 (4:1). */ /** Marketplace/storefront banner; recommended aspect 18:6 (3:1). */
@IsOptional() @IsOptional()
@Type(() => Number) @Type(() => Number)
@IsInt() @IsInt()
@@ -18,6 +18,7 @@ import {
toPrismaJson, toPrismaJson,
} from './business-settings.util'; } from './business-settings.util';
import { normalizeWebsiteSchemaSettings } from './website-schema.util'; import { normalizeWebsiteSchemaSettings } from './website-schema.util';
import { normalizeWebsiteTagsAndBadges } from './website-tags-badges.util';
import { UpdateBusinessSettingsDto } from './dto/update-business-settings.dto'; import { UpdateBusinessSettingsDto } from './dto/update-business-settings.dto';
import { normalizeBusinessPrimaryColorId } from './business-primary-colors'; import { normalizeBusinessPrimaryColorId } from './business-primary-colors';
@@ -170,6 +171,21 @@ export class BusinessSettingsService {
}).store.orderProcessSteps }).store.orderProcessSteps
: current.store.orderProcessSteps, : current.store.orderProcessSteps,
ePayment: ePaymentPatch ?? current.store.ePayment, ePayment: ePaymentPatch ?? current.store.ePayment,
staticShippingEnabled:
dto.store.staticShippingEnabled ??
current.store.staticShippingEnabled,
staticShippingAmount:
dto.store.staticShippingAmount ??
current.store.staticShippingAmount,
staticShippingProvinceExceptions:
dto.store.staticShippingProvinceExceptions !== undefined
? normalizeBusinessSettings({
store: {
staticShippingProvinceExceptions:
dto.store.staticShippingProvinceExceptions,
},
}).store.staticShippingProvinceExceptions
: current.store.staticShippingProvinceExceptions,
}; };
} }
@@ -202,6 +218,31 @@ export class BusinessSettingsService {
: current.website.schema.sameAs, : current.website.schema.sameAs,
}) })
: current.website.schema, : current.website.schema,
tagsAndBadges:
dto.website.tagsAndBadges !== undefined
? normalizeWebsiteTagsAndBadges({
...current.website.tagsAndBadges,
...dto.website.tagsAndBadges,
metaTags:
dto.website.tagsAndBadges.metaTags !== undefined
? dto.website.tagsAndBadges.metaTags
: current.website.tagsAndBadges.metaTags,
enamad:
dto.website.tagsAndBadges.enamad !== undefined
? {
...current.website.tagsAndBadges.enamad,
...dto.website.tagsAndBadges.enamad,
}
: current.website.tagsAndBadges.enamad,
samandehi:
dto.website.tagsAndBadges.samandehi !== undefined
? {
...current.website.tagsAndBadges.samandehi,
...dto.website.tagsAndBadges.samandehi,
}
: current.website.tagsAndBadges.samandehi,
})
: current.website.tagsAndBadges,
}; };
} }
@@ -92,6 +92,12 @@ export type EPaymentSettings = {
gateways: PaymentGatewaysSettings; gateways: PaymentGatewaysSettings;
}; };
/** Per-province override for the static flat shipping fee. */
export type StaticShippingProvinceException = {
provinceId: string;
amount: number;
};
/** Per-business store / sales settings. */ /** Per-business store / sales settings. */
export type StoreSettings = { export type StoreSettings = {
onlineSellEnabled: boolean; onlineSellEnabled: boolean;
@@ -99,6 +105,14 @@ export type StoreSettings = {
torobEnabled: boolean; torobEnabled: boolean;
orderProcessSteps: OrderProcessStep[]; orderProcessSteps: OrderProcessStep[];
ePayment: EPaymentSettings; ePayment: EPaymentSettings;
/**
* When true, every order uses `staticShippingAmount` and shipping cost groups are ignored.
*/
staticShippingEnabled: boolean;
/** Flat shipping fee in IRT applied to all orders when static shipping is enabled. */
staticShippingAmount: number;
/** Province-specific overrides of the static flat fee. */
staticShippingProvinceExceptions: StaticShippingProvinceException[];
}; };
/** Where website special-product carousels read from. */ /** Where website special-product carousels read from. */
@@ -113,6 +127,40 @@ export type WebsiteSettings = {
sitemapConfig?: WebsiteSitemapConfig | null; sitemapConfig?: WebsiteSitemapConfig | null;
/** Site-wide Schema.org Organization / LocalBusiness settings (Phase A). */ /** Site-wide Schema.org Organization / LocalBusiness settings (Phase A). */
schema: WebsiteSchemaSettings; schema: WebsiteSchemaSettings;
/** Custom <meta> tags + eNamad / Samandehi trust badges. */
tagsAndBadges: WebsiteTagsAndBadgesSettings;
};
/**
* Custom head meta tag.
* `name` = admin label only; `content` = full `<meta …>` HTML to inject.
*/
export type WebsiteMetaTag = {
id: string;
name: string;
content: string;
};
/** Fixed trust integrations (eNamad / Samandehi) — footer HTML only. */
export type WebsiteTrustBadgeSettings = {
/** Footer widget HTML; empty = not shown. */
embedHtml: string;
};
export type WebsiteTagsAndBadgesSettings = {
metaTags: WebsiteMetaTag[];
enamad: WebsiteTrustBadgeSettings;
samandehi: WebsiteTrustBadgeSettings;
};
export const DEFAULT_WEBSITE_TRUST_BADGE: WebsiteTrustBadgeSettings = {
embedHtml: '',
};
export const DEFAULT_WEBSITE_TAGS_AND_BADGES: WebsiteTagsAndBadgesSettings = {
metaTags: [],
enamad: { ...DEFAULT_WEBSITE_TRUST_BADGE },
samandehi: { ...DEFAULT_WEBSITE_TRUST_BADGE },
}; };
/** Schema.org @type for the site-wide entity JSON-LD. */ /** Schema.org @type for the site-wide entity JSON-LD. */
@@ -188,6 +236,7 @@ export type WebsiteSitemapConfig = {
export const BUSINESS_DASHBOARD_MODULE_IDS = [ export const BUSINESS_DASHBOARD_MODULE_IDS = [
'products', 'products',
'store', 'store',
'store_installments',
'portfolio', 'portfolio',
'blog', 'blog',
'warehouse', 'warehouse',
@@ -196,6 +245,7 @@ export const BUSINESS_DASHBOARD_MODULE_IDS = [
'workshops', 'workshops',
'finance', 'finance',
'multilanguage_data', 'multilanguage_data',
'roll_call',
] as const; ] as const;
/** Optional customer-dashboard modules. Always-on: home, profile, addresses, orders, favorites. */ /** Optional customer-dashboard modules. Always-on: home, profile, addresses, orders, favorites. */
@@ -300,7 +350,11 @@ export const DEFAULT_NEW_BUSINESS_MODULES: BusinessModuleId[] = [
*/ */
export const DEFAULT_ENABLED_BUSINESS_MODULES: BusinessModuleId[] = export const DEFAULT_ENABLED_BUSINESS_MODULES: BusinessModuleId[] =
BUSINESS_DASHBOARD_MODULE_IDS.filter( BUSINESS_DASHBOARD_MODULE_IDS.filter(
(id) => id !== 'workshops' && id !== 'multilanguage_data', (id) =>
id !== 'workshops' &&
id !== 'multilanguage_data' &&
id !== 'store_installments' &&
id !== 'roll_call',
); );
export const DEFAULT_HOME_CHARTS: [HomeChartId, HomeChartId] = [ export const DEFAULT_HOME_CHARTS: [HomeChartId, HomeChartId] = [
@@ -352,6 +406,9 @@ export const DEFAULT_BUSINESS_SETTINGS: BusinessSettings = {
torobEnabled: false, torobEnabled: false,
orderProcessSteps: DEFAULT_ORDER_PROCESS_STEPS, orderProcessSteps: DEFAULT_ORDER_PROCESS_STEPS,
ePayment: { ...DEFAULT_EPAYMENT_SETTINGS }, ePayment: { ...DEFAULT_EPAYMENT_SETTINGS },
staticShippingEnabled: true,
staticShippingAmount: 0,
staticShippingProvinceExceptions: [],
}, },
modules: { modules: {
enabled: DEFAULT_ENABLED_BUSINESS_MODULES, enabled: DEFAULT_ENABLED_BUSINESS_MODULES,
@@ -360,6 +417,11 @@ export const DEFAULT_BUSINESS_SETTINGS: BusinessSettings = {
website: { website: {
specialProductsSource: 'store_item', specialProductsSource: 'store_item',
schema: { ...DEFAULT_WEBSITE_SCHEMA_SETTINGS }, schema: { ...DEFAULT_WEBSITE_SCHEMA_SETTINGS },
tagsAndBadges: {
metaTags: [],
enamad: { ...DEFAULT_WEBSITE_TRUST_BADGE },
samandehi: { ...DEFAULT_WEBSITE_TRUST_BADGE },
},
}, },
sms: { sms: {
senderNumber: null, senderNumber: null,
@@ -34,12 +34,14 @@ import {
WebsiteSitemapConfig, WebsiteSitemapConfig,
ZarinpalGatewaySettings, ZarinpalGatewaySettings,
DEFAULT_WEBSITE_SCHEMA_SETTINGS, DEFAULT_WEBSITE_SCHEMA_SETTINGS,
DEFAULT_WEBSITE_TAGS_AND_BADGES,
} from './business-settings.types'; } from './business-settings.types';
import { import {
defaultOrderStepColor, defaultOrderStepColor,
normalizeOrderStepColor, normalizeOrderStepColor,
} from './order-step-colors'; } from './order-step-colors';
import { normalizeWebsiteSchemaSettings } from './website-schema.util'; import { normalizeWebsiteSchemaSettings } from './website-schema.util';
import { normalizeWebsiteTagsAndBadges } from './website-tags-badges.util';
function isRecord(value: unknown): value is Record<string, unknown> { function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value); return typeof value === 'object' && value !== null && !Array.isArray(value);
@@ -119,6 +121,44 @@ function readBoolean(value: unknown, fallback: boolean) {
return typeof value === 'boolean' ? value : fallback; return typeof value === 'boolean' ? value : fallback;
} }
function readNonNegativeNumber(value: unknown, fallback: number) {
if (typeof value === 'number' && Number.isFinite(value) && value >= 0) {
return value;
}
if (typeof value === 'string' && value.trim() !== '') {
const parsed = Number(value);
if (Number.isFinite(parsed) && parsed >= 0) return parsed;
}
return fallback;
}
function readStaticShippingProvinceExceptions(
value: unknown,
): BusinessSettings['store']['staticShippingProvinceExceptions'] {
if (!Array.isArray(value)) return [];
const seen = new Set<string>();
const items: BusinessSettings['store']['staticShippingProvinceExceptions'] =
[];
for (const entry of value) {
if (!isRecord(entry)) continue;
const provinceId =
typeof entry.provinceId === 'string'
? entry.provinceId.trim()
: typeof entry.provinceId === 'number'
? String(entry.provinceId)
: '';
if (!provinceId || seen.has(provinceId)) continue;
const amount = readNonNegativeNumber(entry.amount, Number.NaN);
if (!Number.isFinite(amount)) continue;
seen.add(provinceId);
items.push({ provinceId, amount });
}
return items;
}
function readString(value: unknown, fallback = '') { function readString(value: unknown, fallback = '') {
return typeof value === 'string' ? value.trim() : fallback; return typeof value === 'string' ? value.trim() : fallback;
} }
@@ -332,7 +372,17 @@ export function listPublicPaymentGateways(settings: EPaymentSettings) {
}; };
} }
const PAYMENT_PROCESS_STEP_IDS = ['awaiting-payment', 'payment-successful'] as const; /** System-managed payment steps — set by gateway callbacks, not admin UI. */
export const PAYMENT_PROCESS_STEP_IDS = [
'awaiting-payment',
'payment-successful',
] as const;
export type PaymentProcessStepId = (typeof PAYMENT_PROCESS_STEP_IDS)[number];
export function isSystemPaymentProcessStep(stepId: string): boolean {
return (PAYMENT_PROCESS_STEP_IDS as readonly string[]).includes(stepId);
}
/** Prepend default payment steps when an older store settings JSON omits them. */ /** Prepend default payment steps when an older store settings JSON omits them. */
function ensurePaymentProcessSteps(steps: OrderProcessStep[]): OrderProcessStep[] { function ensurePaymentProcessSteps(steps: OrderProcessStep[]): OrderProcessStep[] {
@@ -431,6 +481,17 @@ export function normalizeBusinessSettings(raw: unknown): BusinessSettings {
), ),
orderProcessSteps: readOrderProcessSteps(store.orderProcessSteps), orderProcessSteps: readOrderProcessSteps(store.orderProcessSteps),
ePayment: normalizeEPaymentSettings(store.ePayment), ePayment: normalizeEPaymentSettings(store.ePayment),
staticShippingEnabled: readBoolean(
store.staticShippingEnabled,
DEFAULT_BUSINESS_SETTINGS.store.staticShippingEnabled,
),
staticShippingAmount: readNonNegativeNumber(
store.staticShippingAmount,
DEFAULT_BUSINESS_SETTINGS.store.staticShippingAmount,
),
staticShippingProvinceExceptions: readStaticShippingProvinceExceptions(
store.staticShippingProvinceExceptions,
),
}, },
modules: { modules: {
// Missing `modules` → all enabled (legacy). Explicit `{ enabled: [] }` stays empty. // Missing `modules` → all enabled (legacy). Explicit `{ enabled: [] }` stays empty.
@@ -446,6 +507,11 @@ export function normalizeBusinessSettings(raw: unknown): BusinessSettings {
schema: normalizeWebsiteSchemaSettings( schema: normalizeWebsiteSchemaSettings(
isRecord(website) ? website.schema : DEFAULT_WEBSITE_SCHEMA_SETTINGS, isRecord(website) ? website.schema : DEFAULT_WEBSITE_SCHEMA_SETTINGS,
), ),
tagsAndBadges: normalizeWebsiteTagsAndBadges(
isRecord(website)
? website.tagsAndBadges
: DEFAULT_WEBSITE_TAGS_AND_BADGES,
),
}, },
sms: { sms: {
senderNumber: normalizeSmsSenderNumber(sms.senderNumber), senderNumber: normalizeSmsSenderNumber(sms.senderNumber),
@@ -487,6 +553,14 @@ export function mergeBusinessSettings(
current.store.ePayment, current.store.ePayment,
patch.store?.ePayment, patch.store?.ePayment,
), ),
staticShippingEnabled:
patch.store?.staticShippingEnabled ??
current.store.staticShippingEnabled,
staticShippingAmount:
patch.store?.staticShippingAmount ?? current.store.staticShippingAmount,
staticShippingProvinceExceptions:
patch.store?.staticShippingProvinceExceptions ??
current.store.staticShippingProvinceExceptions,
}, },
modules: { modules: {
enabled: patch.modules?.enabled ?? current.modules.enabled, enabled: patch.modules?.enabled ?? current.modules.enabled,
@@ -504,6 +578,10 @@ export function mergeBusinessSettings(
patch.website?.schema !== undefined patch.website?.schema !== undefined
? patch.website.schema ? patch.website.schema
: current.website.schema, : current.website.schema,
tagsAndBadges:
patch.website?.tagsAndBadges !== undefined
? patch.website.tagsAndBadges
: current.website.tagsAndBadges,
}, },
sms: { sms: {
senderNumber: senderNumber:
@@ -5,8 +5,11 @@ import {
IsArray, IsArray,
IsBoolean, IsBoolean,
IsIn, IsIn,
IsNumber,
IsOptional, IsOptional,
IsString, IsString,
MaxLength,
Min,
MinLength, MinLength,
ValidateIf, ValidateIf,
ValidateNested, ValidateNested,
@@ -163,6 +166,17 @@ class EPaymentSettingsDto {
gateways?: PaymentGatewaysSettingsDto; gateways?: PaymentGatewaysSettingsDto;
} }
class StaticShippingProvinceExceptionDto {
@IsString()
@MinLength(1)
provinceId!: string;
@Type(() => Number)
@IsNumber({ maxDecimalPlaces: 2 })
@Min(0)
amount!: number;
}
class StoreSettingsDto { class StoreSettingsDto {
@IsOptional() @IsOptional()
@IsBoolean() @IsBoolean()
@@ -182,6 +196,22 @@ class StoreSettingsDto {
@ValidateNested() @ValidateNested()
@Type(() => EPaymentSettingsDto) @Type(() => EPaymentSettingsDto)
ePayment?: EPaymentSettingsDto; ePayment?: EPaymentSettingsDto;
@IsOptional()
@IsBoolean()
staticShippingEnabled?: boolean;
@IsOptional()
@Type(() => Number)
@IsNumber({ maxDecimalPlaces: 2 })
@Min(0)
staticShippingAmount?: number;
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => StaticShippingProvinceExceptionDto)
staticShippingProvinceExceptions?: StaticShippingProvinceExceptionDto[];
} }
class ModulesSettingsDto { class ModulesSettingsDto {
@@ -233,6 +263,51 @@ class WebsiteSchemaSettingsDto {
sameAs?: string[]; sameAs?: string[];
} }
class WebsiteMetaTagDto {
@IsOptional()
@IsString()
@MaxLength(64)
id?: string;
/** Admin label only (not necessarily the HTML name= attribute). */
@IsString()
@MinLength(1)
@MaxLength(80)
name!: string;
/** Full `<meta …>` HTML snippet. */
@IsString()
@MinLength(1)
@MaxLength(4000)
content!: string;
}
class WebsiteTrustBadgeDto {
@IsOptional()
@IsString()
@MaxLength(10000)
embedHtml?: string;
}
class WebsiteTagsAndBadgesDto {
@IsOptional()
@IsArray()
@ArrayMaxSize(40)
@ValidateNested({ each: true })
@Type(() => WebsiteMetaTagDto)
metaTags?: WebsiteMetaTagDto[];
@IsOptional()
@ValidateNested()
@Type(() => WebsiteTrustBadgeDto)
enamad?: WebsiteTrustBadgeDto;
@IsOptional()
@ValidateNested()
@Type(() => WebsiteTrustBadgeDto)
samandehi?: WebsiteTrustBadgeDto;
}
class WebsiteSettingsDto { class WebsiteSettingsDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
@@ -243,6 +318,11 @@ class WebsiteSettingsDto {
@ValidateNested() @ValidateNested()
@Type(() => WebsiteSchemaSettingsDto) @Type(() => WebsiteSchemaSettingsDto)
schema?: WebsiteSchemaSettingsDto; schema?: WebsiteSchemaSettingsDto;
@IsOptional()
@ValidateNested()
@Type(() => WebsiteTagsAndBadgesDto)
tagsAndBadges?: WebsiteTagsAndBadgesDto;
} }
export class UpdateBusinessSettingsDto { export class UpdateBusinessSettingsDto {
@@ -0,0 +1,171 @@
import { randomUUID } from 'crypto';
import {
DEFAULT_WEBSITE_TRUST_BADGE,
type WebsiteMetaTag,
type WebsiteTagsAndBadgesSettings,
type WebsiteTrustBadgeSettings,
} from './business-settings.types';
const MAX_META_TAGS = 40;
const MAX_LABEL_LEN = 80;
const MAX_META_HTML_LEN = 4000;
const MAX_EMBED_LEN = 10000;
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function readString(value: unknown, maxLen: number): string {
if (typeof value !== 'string') return '';
return value.trim().slice(0, maxLen);
}
/** Strip scripts / handlers from pasted trust-seal HTML. */
export function sanitizeTrustEmbedHtml(raw: string): string {
let html = raw.slice(0, MAX_EMBED_LEN);
html = html.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, '');
html = html.replace(/<script\b[^>]*\/?>/gi, '');
html = html.replace(/\son\w+\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, '');
html = html.replace(/javascript\s*:/gi, '');
return html.trim();
}
/**
* Keep a single <meta …> tag. Reject anything else.
* Legacy plain content + attr-name is wrapped into a meta tag.
*/
export function sanitizeMetaTagHtml(
rawHtml: string,
legacyAttrName?: string,
): string | null {
let html = rawHtml.trim().slice(0, MAX_META_HTML_LEN);
if (!html) return null;
html = html.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, '');
html = html.replace(/<script\b[^>]*\/?>/gi, '');
html = html.replace(/\son\w+\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, '');
html = html.replace(/javascript\s*:/gi, '');
html = html.trim();
if (!/^<meta\b/i.test(html)) {
// Legacy: name was HTML meta name, content was only the content value.
const attr = (legacyAttrName ?? '').trim();
if (!attr || !/^[a-zA-Z0-9_.:-]{1,80}$/.test(attr) || /[<>]/.test(html)) {
return null;
}
const escaped = html.replace(/&/g, '&amp;').replace(/"/g, '&quot;');
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 },
};
}
+8 -1
View File
@@ -3,11 +3,18 @@ import { AuthModule } from '../auth/auth.module';
import { BusinessSettingsModule } from '../business-settings/business-settings.module'; import { BusinessSettingsModule } from '../business-settings/business-settings.module';
import { OrdersModule } from '../orders/orders.module'; import { OrdersModule } from '../orders/orders.module';
import { PaymentsModule } from '../payments/payments.module'; import { PaymentsModule } from '../payments/payments.module';
import { StoreModule } from '../store/store.module';
import { CartController } from './cart.controller'; import { CartController } from './cart.controller';
import { CartService } from './cart.service'; import { CartService } from './cart.service';
@Module({ @Module({
imports: [AuthModule, OrdersModule, PaymentsModule, BusinessSettingsModule], imports: [
AuthModule,
OrdersModule,
PaymentsModule,
BusinessSettingsModule,
StoreModule,
],
controllers: [CartController], controllers: [CartController],
providers: [CartService], providers: [CartService],
}) })
+114 -10
View File
@@ -20,6 +20,7 @@ import { TransactionType } from '@prisma/client';
import { BusinessSettingsService } from '../business-settings/business-settings.service'; import { BusinessSettingsService } from '../business-settings/business-settings.service';
import type { PaymentGatewayId } from '../business-settings/business-settings.types'; import type { PaymentGatewayId } from '../business-settings/business-settings.types';
import { listPublicPaymentGateways } from '../business-settings/business-settings.util'; import { listPublicPaymentGateways } from '../business-settings/business-settings.util';
import { ShippingCostGroupsService } from '../store/shipping-cost-groups.service';
const cartVariantInclude = { const cartVariantInclude = {
storeItem: { storeItem: {
@@ -59,6 +60,7 @@ export class CartService {
private readonly orders: OrdersService, private readonly orders: OrdersService,
private readonly payments: PaymentsService, private readonly payments: PaymentsService,
private readonly businessSettings: BusinessSettingsService, private readonly businessSettings: BusinessSettingsService,
private readonly shippingCostGroups: ShippingCostGroupsService,
) {} ) {}
async getCart(businessIdRaw: string, actor: AuthUser) { async getCart(businessIdRaw: string, actor: AuthUser) {
@@ -66,7 +68,7 @@ export class CartService {
await this.assertCustomerAccess(businessId, actor); await this.assertCustomerAccess(businessId, actor);
const cart = await this.getOrCreateCart(businessId, actor.id); const cart = await this.getOrCreateCart(businessId, actor.id);
return { cart: this.serializeCart(cart) }; return { cart: await this.serializeCart(cart) };
} }
async addItem(businessIdRaw: string, dto: AddCartItemDto, actor: AuthUser) { async addItem(businessIdRaw: string, dto: AddCartItemDto, actor: AuthUser) {
@@ -97,7 +99,7 @@ export class CartService {
const refreshed = await this.loadCart(cart.id); const refreshed = await this.loadCart(cart.id);
return { return {
message: 'Cart item quantity updated', message: 'Cart item quantity updated',
cart: this.serializeCart(refreshed), cart: await this.serializeCart(refreshed),
}; };
} }
@@ -114,7 +116,7 @@ export class CartService {
const refreshed = await this.loadCart(cart.id); const refreshed = await this.loadCart(cart.id);
return { return {
message: 'Item added to cart', message: 'Item added to cart',
cart: this.serializeCart(refreshed), cart: await this.serializeCart(refreshed),
}; };
} }
@@ -145,7 +147,7 @@ export class CartService {
const refreshed = await this.loadCart(cart.id); const refreshed = await this.loadCart(cart.id);
return { return {
message: 'Cart item updated', message: 'Cart item updated',
cart: this.serializeCart(refreshed), cart: await this.serializeCart(refreshed),
}; };
} }
@@ -166,7 +168,7 @@ export class CartService {
const refreshed = await this.loadCart(cart.id); const refreshed = await this.loadCart(cart.id);
return { return {
message: 'Cart item removed', message: 'Cart item removed',
cart: this.serializeCart(refreshed), cart: await this.serializeCart(refreshed),
}; };
} }
@@ -180,7 +182,7 @@ export class CartService {
const refreshed = await this.loadCart(cart.id); const refreshed = await this.loadCart(cart.id);
return { return {
message: 'Cart cleared', message: 'Cart cleared',
cart: this.serializeCart(refreshed), cart: await this.serializeCart(refreshed),
}; };
} }
@@ -204,6 +206,71 @@ export class CartService {
dto.shippingAddress, dto.shippingAddress,
); );
const deliveryMode =
dto.deliveryMode === 'pickup' || dto.deliveryMode === 'delivery'
? dto.deliveryMode
: dto.addressId
? 'delivery'
: 'pickup';
const selectedMethodsByGroupId: Record<string, string> = {};
for (const group of dto.shippingGroups ?? []) {
if (group.methodId?.trim()) {
selectedMethodsByGroupId[group.groupId] = group.methodId.trim();
}
}
const productIds = [
...new Set(
cart.items.map((item) => item.storeItemVariant.storeItem.product.id),
),
];
const assignments =
productIds.length === 0
? []
: await this.prisma.categoryAssignment.findMany({
where: {
businessId,
entityType: 'product',
entityId: { in: productIds },
},
select: { entityId: true, categoryId: true },
});
const categoryIdsByProduct = new Map<string, string[]>();
for (const row of assignments) {
const key = row.entityId.toString();
const list = categoryIdsByProduct.get(key) ?? [];
list.push(row.categoryId.toString());
categoryIdsByProduct.set(key, list);
}
const shippingQuote = await this.shippingCostGroups.quoteForCheckout({
businessId,
deliveryMode,
provinceName:
deliveryMode === 'delivery' ? String(shippingAddress.province ?? '') : null,
cartItems: cart.items.map((item) => {
const product = item.storeItemVariant.storeItem.product;
const content = this.asRecord(product.content);
const nameFa = (content.nameFa as string | null | undefined)?.trim();
return {
productName: nameFa || product.title,
categoryIds:
categoryIdsByProduct.get(product.id.toString()) ?? [],
};
}),
selectedMethodsByGroupId,
});
const enrichedShippingAddress = {
...shippingAddress,
deliveryMode,
shipping: {
total: shippingQuote.total,
groups: shippingQuote.groups,
},
};
const isEPayment = dto.payment.type === TransactionType.e_payment_gate; const isEPayment = dto.payment.type === TransactionType.e_payment_gate;
let gatewayType: PaymentGatewayId | undefined; let gatewayType: PaymentGatewayId | undefined;
@@ -234,7 +301,8 @@ export class CartService {
createdBy: actor.id, createdBy: actor.id,
source: 'website', source: 'website',
cartItems: cart.items, cartItems: cart.items,
shippingAddress, shippingAddress: enrichedShippingAddress,
shippingTotal: shippingQuote.total,
addressId: dto.addressId ? BigInt(dto.addressId) : null, addressId: dto.addressId ? BigInt(dto.addressId) : null,
customerNotes: dto.customerNotes?.trim() || null, customerNotes: dto.customerNotes?.trim() || null,
adminNotes: null, adminNotes: null,
@@ -393,8 +461,40 @@ export class CartService {
} }
} }
private serializeCart(cart: CartWithItems) { private async serializeCart(cart: CartWithItems) {
const items = cart.items.map((item) => this.serializeCartItem(item)); const productIds = [
...new Set(
cart.items.map((item) => item.storeItemVariant.storeItem.product.id),
),
];
const assignments =
productIds.length === 0
? []
: await this.prisma.categoryAssignment.findMany({
where: {
businessId: cart.businessId,
entityType: 'product',
entityId: { in: productIds },
},
select: { entityId: true, categoryId: true },
});
const categoryIdsByProduct = new Map<string, string[]>();
for (const row of assignments) {
const key = row.entityId.toString();
const list = categoryIdsByProduct.get(key) ?? [];
list.push(row.categoryId.toString());
categoryIdsByProduct.set(key, list);
}
const items = cart.items.map((item) => {
const productId = item.storeItemVariant.storeItem.product.id.toString();
return this.serializeCartItem(
item,
categoryIdsByProduct.get(productId) ?? [],
);
});
const subtotal = items.reduce((sum, item) => sum + item.lineTotal, 0); const subtotal = items.reduce((sum, item) => sum + item.lineTotal, 0);
return { return {
@@ -407,7 +507,10 @@ export class CartService {
}; };
} }
private serializeCartItem(item: CartWithItems['items'][number]) { private serializeCartItem(
item: CartWithItems['items'][number],
categoryIds: string[],
) {
const variant = item.storeItemVariant; const variant = item.storeItemVariant;
const product = variant.storeItem.product; const product = variant.storeItem.product;
const content = this.asRecord(product.content); const content = this.asRecord(product.content);
@@ -431,6 +534,7 @@ export class CartService {
productTitle: product.title, productTitle: product.title,
productNameFa: (content.nameFa as string | null | undefined) ?? '', productNameFa: (content.nameFa as string | null | undefined) ?? '',
productImage: product.featuredMedia?.publicUrl ?? null, productImage: product.featuredMedia?.publicUrl ?? null,
categoryIds,
sku: variant.sku, sku: variant.sku,
selections, selections,
label: selections.map((entry) => entry.value).join(' · ') || product.title, label: selections.map((entry) => entry.value).join(' · ') || product.title,
+42
View File
@@ -1,5 +1,8 @@
import { import {
IsArray,
IsIn,
IsInt, IsInt,
IsNumber,
IsOptional, IsOptional,
IsString, IsString,
IsUrl, IsUrl,
@@ -53,6 +56,35 @@ export class ShippingAddressDto {
landline?: string; landline?: string;
} }
export class CheckoutShippingGroupDto {
@IsString()
@MinLength(1)
groupId!: string;
@IsOptional()
@IsString()
title?: string;
@IsOptional()
@IsString()
methodId?: string;
@IsOptional()
@IsString()
methodLabel?: string;
@IsOptional()
@Type(() => Number)
@IsNumber({ maxDecimalPlaces: 2 })
@Min(0)
amount?: number;
@IsOptional()
@IsArray()
@IsString({ each: true })
itemNames?: string[];
}
export class CheckoutCartDto { export class CheckoutCartDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
@@ -64,6 +96,16 @@ export class CheckoutCartDto {
@Type(() => ShippingAddressDto) @Type(() => ShippingAddressDto)
shippingAddress?: ShippingAddressDto; shippingAddress?: ShippingAddressDto;
@IsOptional()
@IsIn(['delivery', 'pickup'])
deliveryMode?: 'delivery' | 'pickup';
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => CheckoutShippingGroupDto)
shippingGroups?: CheckoutShippingGroupDto[];
@IsOptional() @IsOptional()
@IsString() @IsString()
customerNotes?: string; customerNotes?: string;
+1 -1
View File
@@ -105,7 +105,7 @@ Rules:
- Use 2-6 top-level categories when appropriate. - Use 2-6 top-level categories when appropriate.
- Add subcategories only where they help shoppers browse (max depth ${MAX_DEPTH}). - Add subcategories only where they help shoppers browse (max depth ${MAX_DEPTH}).
- Every node needs nameEn and nameFa (Farsi in Persian script). - Every node needs nameEn and nameFa (Farsi in Persian script).
- Descriptions are optional, max 120 characters, English only. - Descriptions are optional, max 120 characters. Match the user prompt language (Persian if the prompt is Persian, otherwise English).
- Do not duplicate category names at the same level. - Do not duplicate category names at the same level.
- Total nodes must not exceed ${MAX_TOTAL}.`; - Total nodes must not exceed ${MAX_TOTAL}.`;
@@ -10,6 +10,7 @@ import {
requestAiJsonCompletion, requestAiJsonCompletion,
resolveAiProvider, resolveAiProvider,
} from '../common/ai-provider.util'; } from '../common/ai-provider.util';
import { loadReferencePagesFromText } from '../common/ai-reference-pages.util';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { CategoryTechnicalFormService } from './category-technical-form.service'; import { CategoryTechnicalFormService } from './category-technical-form.service';
import { import {
@@ -26,7 +27,22 @@ type AiTechnicalFormDraft = {
}[]; }[];
}; };
const ALLOWED_TYPES = new Set(['text', 'textarea', 'select', 'multi_select']); const ALLOWED_TYPES = new Set([
'text',
'textarea',
'select',
'multi_select',
'multi_value',
]);
const OTHER_FEATURES_LABEL = 'سایر ویژگی‌ها';
const OTHER_FEATURES_LABELS = new Set([
'سایر ویژگی‌ها',
'سایر ویژگی ها',
'other features',
'other feature',
'additional features',
]);
@Injectable() @Injectable()
export class CategoryTechnicalFormAiService { export class CategoryTechnicalFormAiService {
@@ -86,30 +102,47 @@ export class CategoryTechnicalFormAiService {
existingFieldCount: number; existingFieldCount: number;
}): Promise<TechnicalFormFieldInputDto[]> { }): Promise<TechnicalFormFieldInputDto[]> {
const provider = resolveAiProvider(this.config); const provider = resolveAiProvider(this.config);
const referencePages = await loadReferencePagesFromText(input.hint ?? '');
const usableReferences = referencePages.filter((page) => page.text);
const systemPrompt = `You design technical specification forms for e-commerce product categories on an Iranian marketplace. if (referencePages.length > 0 && usableReferences.length === 0) {
Return ONLY valid JSON with this shape: throw new BadRequestException(
'Could not load the product page URL from the prompt. Check the link and try again.',
);
}
const systemPrompt = `تو طراح فرم مشخصات فنی برای دسته‌بندی‌های فروشگاهی در یک مارکت‌پلیس ایرانی هستی.
فقط JSON معتبر با این شکل برگردان:
{ {
"fields": [ "fields": [
{ {
"label": "Field label in English", "label": "برچسب فیلد",
"type": "text | textarea | select | multi_select", "type": "text | textarea | select | multi_select | multi_value",
"isRequired": true, "isRequired": true,
"options": ["Option A", "Option B"] "options": ["گزینه ۱", "گزینه ۲"]
} }
] ]
} }
Rules: قوانین:
- Generate 5-12 practical fields that buyers need for products in the given category. - بین ۵ تا ۱۱ فیلد کاربردی تولید کن که خریداران برای محصولات این دسته نیاز دارند (سیستم همیشه «سایر ویژگی‌ها» را اضافه می‌کند).
- Use a mix of text, textarea, select, and multi_select where appropriate. - ترجیحاً از type=text استفاده کن تا فروشنده بتواند هر مقدار دلخواهی را آزادانه وارد کند.
- select and multi_select fields must include 3-8 realistic options. - از select و multi_select حتی‌المقدور استفاده نکن. فقط وقتی مجموعه گزینه‌ها واقعاً ثابت، کوتاه و استاندارد است (مثلاً بله/خیر یا حداکثر چند گزینه قطعی) مجازند؛ برای ظرفیت، توان، ابعاد، مدل، جنس، رنگ آزاد و مشابه آن‌ها همیشه text بگذار.
- text fields are for short values (dimensions, weight, model year). - اگر بین text و select مردد هستی، حتماً text انتخاب کن.
- textarea fields are for longer specs (features, care instructions). - فیلدهای select و multi_select (در صورت استفاده) باید ۳ تا ۸ گزینه واقعی داشته باشند.
- Mark important buyer-facing specs as required. - multi_value یک لیست تکراری از مقادیر متنی آزاد است (بدون گزینه) برای مقادیر کوتاه چندتایی.
- Labels must be unique and human-readable. - text برای مقادیر کوتاه (ابعاد، وزن، سال مدل، ظرفیت، توان).
- Do not include price, stock, SKU, or warranty period fields. - textarea برای مشخصات طولانی‌تر (ویژگی‌ها، دستورالعمل نگهداری).
- Prefer metric units common in Iran when relevant.`; - اگر برای این دسته مناسب است، فیلدهای «ابعاد» و «وزن» را به‌صورت text اضافه کن (واحد متریک رایج ایران را در برچسب بیاور، مثلاً سانتی‌متر / کیلوگرم). اگر برای دسته بی‌معناست، اضافه نکن.
- مشخصات مهم خریدار را الزامی علامت بزن.
- برچسب‌ها باید یکتا و خوانا باشند.
- همیشه برچسب‌ها و گزینه‌ها را به فارسی بنویس.
- فیلدهای قیمت، موجودی، SKU یا مدت گارانتی نساز.
- در صورت مرتبط بودن از واحدهای متریک رایج در ایران استفاده کن.
- اگر referencePages داده شده، آن‌ها منبع حقیقت هستند: فیلدهایی بساز که با مشخصات واقعی همان صفحات هم‌خوان باشد.
- مشخصات اصلی نزدیک عنوان محصول را ترجیح بده؛ بلوک‌های محصول مرتبط/آپسل را نادیده بگیر.
- وقتی صفحه مرجع هست از حافظه فیلد اختراع نکن — همان چیزی را بازتاب بده که صفحه واقعاً دارد.
- یک فیلد «سایر ویژگی‌ها» خودت نساز؛ سیستم آن را به‌صورت multi_value اضافه می‌کند.`;
const userPrompt = JSON.stringify({ const userPrompt = JSON.stringify({
category: input.categoryName, category: input.categoryName,
@@ -119,11 +152,23 @@ Rules:
input.existingFieldCount > 0 input.existingFieldCount > 0
? 'Category already has a form; suggest a fresh complete replacement set.' ? 'Category already has a form; suggest a fresh complete replacement set.'
: 'Category has no form yet.', : 'Category has no form yet.',
referencePages: usableReferences.map((page) => ({
url: page.url,
text: page.text,
})),
referenceFetchErrors: referencePages
.filter((page) => !page.text)
.map((page) => ({ url: page.url, error: page.error ?? 'unknown' })),
}); });
let content: string; let content: string;
try { try {
content = await requestAiJsonCompletion(provider, systemPrompt, userPrompt); content = await requestAiJsonCompletion(
provider,
systemPrompt,
userPrompt,
usableReferences.length > 0 ? 0.2 : 0.4,
);
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : 'AI request failed'; const message = err instanceof Error ? err.message : 'AI request failed';
throw new BadRequestException(message); throw new BadRequestException(message);
@@ -148,7 +193,8 @@ Rules:
const label = String(raw.label ?? '').trim(); const label = String(raw.label ?? '').trim();
if (!label) continue; if (!label) continue;
const normalizedLabel = label.toLowerCase(); const normalizedLabel = label.toLowerCase().replace(/\s+/g, ' ').trim();
if (OTHER_FEATURES_LABELS.has(normalizedLabel)) continue;
if (labels.has(normalizedLabel)) continue; if (labels.has(normalizedLabel)) continue;
labels.add(normalizedLabel); labels.add(normalizedLabel);
@@ -172,9 +218,15 @@ Rules:
} }
fields.push(field); fields.push(field);
if (fields.length >= 12) break; if (fields.length >= 11) break;
} }
fields.push({
label: OTHER_FEATURES_LABEL,
type: 'multi_value',
isRequired: false,
});
if (!fields.length) { if (!fields.length) {
throw new BadRequestException('AI did not return any valid technical form fields'); throw new BadRequestException('AI did not return any valid technical form fields');
} }
+45 -4
View File
@@ -54,6 +54,31 @@ function uniqueSlug(base: string, used: Set<string>, fallbackIndex: number): str
return next; return next;
} }
function normalizeHex(value: string | null | undefined): string | null {
if (value == null) return null;
const trimmed = value.trim();
if (!isHexColor(trimmed)) return null;
return trimmed.toUpperCase();
}
/** Map value labels → provided hex (later entries win for duplicate labels). */
function buildColorHexByLabel(
values: string[],
colorHexes: (string | null)[] | undefined,
): Map<string, string> {
const map = new Map<string, string>();
if (!colorHexes?.length) return map;
values.forEach((raw, index) => {
const label = raw.trim();
if (!label) return;
const hex = normalizeHex(colorHexes[index] ?? null);
if (hex) map.set(label, hex);
});
return map;
}
type ExistingVariation = { type ExistingVariation = {
id: bigint; id: bigint;
name: string; name: string;
@@ -126,6 +151,10 @@ export class CategoryVariationsService {
const uniqueValues = [ const uniqueValues = [
...new Set(variation.values.map((v) => v.trim()).filter(Boolean)), ...new Set(variation.values.map((v) => v.trim()).filter(Boolean)),
]; ];
const hexByLabel = buildColorHexByLabel(
variation.values,
variation.colorHexes,
);
if (current) { if (current) {
matchedExistingIds.add(current.id); matchedExistingIds.add(current.id);
@@ -137,7 +166,7 @@ export class CategoryVariationsService {
sortOrder: index, sortOrder: index,
}, },
}); });
await this.syncOptions(tx, current, variation.type, uniqueValues); await this.syncOptions(tx, current, variation.type, uniqueValues, hexByLabel);
} else { } else {
const created = await tx.categoryVariation.create({ const created = await tx.categoryVariation.create({
data: { data: {
@@ -148,7 +177,13 @@ export class CategoryVariationsService {
sortOrder: index, sortOrder: index,
}, },
}); });
await this.createOptions(tx, created.id, variation.type, uniqueValues); await this.createOptions(
tx,
created.id,
variation.type,
uniqueValues,
hexByLabel,
);
} }
} }
@@ -203,6 +238,7 @@ export class CategoryVariationsService {
current: ExistingVariation, current: ExistingVariation,
type: CategoryVariationInputDto['type'], type: CategoryVariationInputDto['type'],
labels: string[], labels: string[],
hexByLabel: Map<string, string> = new Map(),
) { ) {
const usedSlugs = new Set<string>(); const usedSlugs = new Set<string>();
const matchedOptionIds = new Set<bigint>(); const matchedOptionIds = new Set<bigint>();
@@ -215,7 +251,8 @@ export class CategoryVariationsService {
const colorHex = const colorHex =
type === 'color' type === 'color'
? resolveColorHex(label) ?? ? hexByLabel.get(label) ??
resolveColorHex(label) ??
existingOption?.colorHex ?? existingOption?.colorHex ??
(existingOption && isHexColor(existingOption.value) (existingOption && isHexColor(existingOption.value)
? existingOption.value.trim() ? existingOption.value.trim()
@@ -270,6 +307,7 @@ export class CategoryVariationsService {
variationId: bigint, variationId: bigint,
type: CategoryVariationInputDto['type'], type: CategoryVariationInputDto['type'],
labels: string[], labels: string[],
hexByLabel: Map<string, string> = new Map(),
) { ) {
const usedSlugs = new Set<string>(); const usedSlugs = new Set<string>();
await tx.categoryVariationOption.createMany({ await tx.categoryVariationOption.createMany({
@@ -277,7 +315,10 @@ export class CategoryVariationsService {
variationId, variationId,
label, label,
value: uniqueSlug(slugifyValue(label), usedSlugs, optionIndex + 1), value: uniqueSlug(slugifyValue(label), usedSlugs, optionIndex + 1),
colorHex: type === 'color' ? resolveColorHex(label) : null, colorHex:
type === 'color'
? hexByLabel.get(label) ?? resolveColorHex(label)
: null,
sortOrder: optionIndex, sortOrder: optionIndex,
})), })),
}); });
@@ -17,8 +17,8 @@ export class TechnicalFormFieldInputDto {
label!: string; label!: string;
@IsString() @IsString()
@IsIn(['text', 'textarea', 'select', 'multi_select']) @IsIn(['text', 'textarea', 'select', 'multi_select', 'multi_value'])
type!: 'text' | 'textarea' | 'select' | 'multi_select'; type!: 'text' | 'textarea' | 'select' | 'multi_select' | 'multi_value';
@IsOptional() @IsOptional()
@IsBoolean() @IsBoolean()
@@ -3,6 +3,7 @@ import {
ArrayMinSize, ArrayMinSize,
IsArray, IsArray,
IsIn, IsIn,
IsOptional,
IsString, IsString,
MinLength, MinLength,
ValidateNested, ValidateNested,
@@ -21,6 +22,11 @@ export class CategoryVariationInputDto {
@ArrayMinSize(1) @ArrayMinSize(1)
@IsString({ each: true }) @IsString({ each: true })
values!: string[]; values!: string[];
/** Optional parallel list of hex colors for `color` variations (same order as values). */
@IsOptional()
@IsArray()
colorHexes?: (string | null)[];
} }
export class ReplaceCategoryVariationsDto { export class ReplaceCategoryVariationsDto {
+123
View File
@@ -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(/&nbsp;/gi, ' ')
.replace(/&amp;/gi, '&')
.replace(/&lt;/gi, '<')
.replace(/&gt;/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;
}
+25
View File
@@ -0,0 +1,25 @@
/** Encode free-text list values for storage in text_value columns. */
export function encodeMultiValueText(values: string[]): string {
const cleaned = values.map((item) => String(item).trim()).filter(Boolean);
return JSON.stringify(cleaned);
}
/** Decode free-text list values from text_value storage. */
export function decodeMultiValueText(raw: string | null | undefined): string[] {
if (raw == null) return [];
const trimmed = raw.trim();
if (!trimmed) return [];
if (trimmed.startsWith('[')) {
try {
const parsed = JSON.parse(trimmed) as unknown;
if (Array.isArray(parsed)) {
return parsed.map((item) => String(item).trim()).filter(Boolean);
}
} catch {
// fall through
}
}
return [trimmed];
}
@@ -0,0 +1,66 @@
/**
* Heuristics to reject obvious bot / gibberish contact submissions
* before they are stored or trigger SMS.
*/
/** Single Latin token with chaotic mixed case (e.g. nuKnGmIVvgMoplagJYbJHIf). */
export function looksLikeRandomToken(value: string): boolean {
const s = value.trim();
if (s.length < 12) return false;
if (!/^[A-Za-z]+$/.test(s)) return false;
// Normal Title Case word: "Something"
if (/^[A-Z][a-z]+$/.test(s)) return false;
// ALL CAPS short-ish brand-like tokens are less common at this length
if (/^[A-Z]+$/.test(s) && s.length < 16) return false;
// all lowercase long word without spaces is still suspicious when very long
if (/^[a-z]+$/.test(s)) return s.length >= 18;
let caseSwitches = 0;
for (let i = 1; i < s.length; i++) {
const prevUpper = s[i - 1]! >= 'A' && s[i - 1]! <= 'Z';
const curUpper = s[i]! >= 'A' && s[i]! <= 'Z';
if (prevUpper !== curUpper) caseSwitches += 1;
}
// Random camelNoise tends to flip case often
if (caseSwitches >= 5) return true;
if (caseSwitches >= 3 && caseSwitches / (s.length - 1) >= 0.28) return true;
return false;
}
/** True when the submission looks like spam and should be rejected. */
export function isLikelySpamContactSubmission(input: {
title: string;
name: string;
text: string;
}): boolean {
const title = input.title.trim();
const name = input.name.trim();
const text = input.text.trim();
if (looksLikeRandomToken(name)) return true;
const gibberishFields = [title, name, text].filter((v) =>
looksLikeRandomToken(v),
).length;
if (gibberishFields >= 2) return true;
// Entire payload is one unbroken Latin blob with no spaces / Persian
const combined = `${title} ${name} ${text}`;
const hasPersian = /[\u0600-\u06FF]/.test(combined);
const hasSpace = /\s/.test(name) || /\s/.test(text);
if (
!hasPersian &&
!hasSpace &&
/^[A-Za-z0-9]+$/.test(name) &&
name.length >= 14 &&
text.length >= 14 &&
/^[A-Za-z0-9]+$/.test(text)
) {
return true;
}
return false;
}
@@ -1,9 +1,19 @@
import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'; import {
BadRequestException,
ForbiddenException,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { AuthUser } from '../auth/auth.types'; import { AuthUser } from '../auth/auth.types';
import { PermissionsService } from '../auth/permissions.service'; import { PermissionsService } from '../auth/permissions.service';
import { SmsBillingService } from '../auth/sms-billing.service';
import type { SmsSendType } from '../auth/sms-billing';
import { SmsService, toIranianMobileMsisdn } from '../auth/sms.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { TenantService } from '../tenant/tenant.service'; import { TenantService } from '../tenant/tenant.service';
import { isLikelySpamContactSubmission } from './contact-submission-spam';
import { CreateContactSubmissionDto } from './dto/create-contact-submission.dto'; import { CreateContactSubmissionDto } from './dto/create-contact-submission.dto';
import { ListContactSubmissionsDto } from './dto/list-contact-submissions.dto'; import { ListContactSubmissionsDto } from './dto/list-contact-submissions.dto';
@@ -19,32 +29,164 @@ type ContactSubmissionRow = {
@Injectable() @Injectable()
export class ContactSubmissionsService { export class ContactSubmissionsService {
private readonly logger = new Logger(ContactSubmissionsService.name);
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly permissions: PermissionsService, private readonly permissions: PermissionsService,
private readonly tenant: TenantService, private readonly tenant: TenantService,
private readonly sms: SmsService,
private readonly smsBilling: SmsBillingService,
) {} ) {}
async createPublic(host: string, dto: CreateContactSubmissionDto) { async createPublic(host: string, dto: CreateContactSubmissionDto) {
const business = await this.tenant.resolveBusinessByDomain(host); const business = await this.tenant.resolveBusinessByDomain(host);
const title = dto.title.trim();
const name = dto.name.trim();
const text = dto.text.trim();
const email = dto.email?.trim() || null;
const cellNumber = dto.cellNumber?.trim() || null;
if (isLikelySpamContactSubmission({ title, name, text })) {
this.logger.warn(
`Contact form rejected as spam for business ${business.id}: name="${name}"`,
);
throw new BadRequestException('Invalid submission');
}
const created = await this.prisma.contactSubmission.create({ const created = await this.prisma.contactSubmission.create({
data: { data: {
businessId: business.id, businessId: business.id,
title: dto.title.trim(), title,
name: dto.name.trim(), name,
email: dto.email?.trim() || null, email,
cellNumber: dto.cellNumber?.trim() || null, cellNumber,
text: dto.text.trim(), text,
}, },
}); });
const businessLabel =
business.nameFa?.trim() || business.name?.trim() || undefined;
await this.notifyContactSubmissionSms({
businessId: business.id,
businessLabel,
contactName: created.name,
contacterCellNumber: created.cellNumber,
});
return { return {
submission: this.serialize(created), submission: this.serialize(created),
message: 'Contact form submitted successfully', message: 'Contact form submitted successfully',
}; };
} }
/**
* Best-effort SMS via Meshkee service (main) line:
* 1) business owner — new contact named in the message
* 2) contacter — confirmation, only for a valid Iranian mobile
* Never fails the contact form submission.
*/
private async notifyContactSubmissionSms(input: {
businessId: bigint;
businessLabel?: string;
contactName: string;
contacterCellNumber: string | null;
}): Promise<void> {
if (!this.sms.isEnabled()) {
return;
}
const name = input.contactName.trim() || 'یک بازدیدکننده';
await this.sendBilledSms({
businessId: input.businessId,
cellNumber: await this.resolveOwnerCellNumber(input.businessId),
message: `پیام جدید از فرم تماس با ما: ${name}`,
type: 'admin',
label: 'owner',
});
if (!toIranianMobileMsisdn(input.contacterCellNumber ?? '')) {
return;
}
const brand = input.businessLabel?.trim();
const confirmation = brand
? `${name} عزیز، پیام شما به ${brand} دریافت شد.`
: `${name} عزیز، پیام شما دریافت شد.`;
await this.sendBilledSms({
businessId: input.businessId,
cellNumber: input.contacterCellNumber,
message: confirmation,
type: 'auto_customer',
label: 'contacter',
});
}
private async resolveOwnerCellNumber(
businessId: bigint,
): Promise<string | null> {
const owner = await this.prisma.businessUser.findFirst({
where: { businessId, isOwner: true },
orderBy: { userId: 'asc' },
select: { user: { select: { cellNumber: true } } },
});
return owner?.user.cellNumber?.trim() || null;
}
private async sendBilledSms(input: {
businessId: bigint;
cellNumber: string | null | undefined;
message: string;
type: SmsSendType;
label: string;
}): Promise<void> {
const cellNumber = input.cellNumber?.trim();
if (!cellNumber || !toIranianMobileMsisdn(cellNumber)) {
this.logger.warn(
`Contact form ${input.label} SMS skipped for business ${input.businessId}: not a valid Iranian mobile`,
);
return;
}
let charged = 0;
try {
charged = await this.smsBilling.charge(
input.businessId,
input.message,
1,
input.type,
);
} catch (err) {
const reason = err instanceof Error ? err.message : String(err);
this.logger.warn(
`Contact form ${input.label} SMS not charged for business ${input.businessId}: ${reason}`,
);
return;
}
try {
await this.sms.sendMessage(cellNumber, input.message, {
source: this.sms.getServiceSource(),
});
} catch (err) {
if (charged > 0) {
await this.smsBilling.refund(
input.businessId,
charged,
1,
input.type,
);
}
const reason = err instanceof Error ? err.message : String(err);
this.logger.warn(
`Contact form ${input.label} SMS failed for business ${input.businessId}: ${reason}`,
);
}
}
async list( async list(
businessIdRaw: string, businessIdRaw: string,
query: ListContactSubmissionsDto, query: ListContactSubmissionsDto,
+6 -3
View File
@@ -939,22 +939,25 @@ export class DomainAdminService {
let ed25519; let ed25519;
let rsa; let rsa;
try { try {
if (dto.ed25519Key?.trim()) {
ed25519 = parseDkimPaste(dto.ed25519Key, 'ed25519', dto.ed25519Selector); ed25519 = parseDkimPaste(dto.ed25519Key, 'ed25519', dto.ed25519Selector);
}
rsa = parseDkimPaste(dto.rsaKey, 'rsa', dto.rsaSelector); rsa = parseDkimPaste(dto.rsaKey, 'rsa', dto.rsaSelector);
} catch (err) { } catch (err) {
throw new BadRequestException(err instanceof Error ? err.message : 'Invalid DKIM key'); throw new BadRequestException(err instanceof Error ? err.message : 'Invalid DKIM key');
} }
if (ed25519.selector === rsa.selector) { if (ed25519 && ed25519.selector === rsa.selector) {
throw new BadRequestException('Ed25519 and RSA DKIM selectors must be different.'); throw new BadRequestException('Ed25519 and RSA DKIM selectors must be different.');
} }
const keys = ed25519 ? [ed25519, rsa] : [rsa];
const provider = dto.dnsProvider === 'cloudflare' ? 'cloudflare' : 'arvan'; const provider = dto.dnsProvider === 'cloudflare' ? 'cloudflare' : 'arvan';
const host = domain.host.trim().toLowerCase(); const host = domain.host.trim().toLowerCase();
try { try {
const result = const result =
provider === 'cloudflare' provider === 'cloudflare'
? await this.cloudflareDns.ensureMailRecords(host, [ed25519, rsa]) ? await this.cloudflareDns.ensureMailRecords(host, keys)
: await this.arvanDns.ensureMailRecords(host, [ed25519, rsa]); : await this.arvanDns.ensureMailRecords(host, keys);
const parts = [ const parts = [
result.created.length ? `created ${result.created.join(', ')}` : '', result.created.length ? `created ${result.created.join(', ')}` : '',
+4 -2
View File
@@ -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'; import { PARKED_DNS_PROVIDERS } from '../parked-host.util';
export class ApplyMailDnsDto { export class ApplyMailDnsDto {
@IsIn(PARKED_DNS_PROVIDERS) @IsIn(PARKED_DNS_PROVIDERS)
dnsProvider!: (typeof PARKED_DNS_PROVIDERS)[number]; dnsProvider!: (typeof PARKED_DNS_PROVIDERS)[number];
/** Optional — omit or leave empty when only publishing RSA from Stalwart. */
@ValidateIf((_, value) => value != null && String(value).trim() !== '')
@IsString() @IsString()
@MinLength(20) @MinLength(20)
@MaxLength(8000) @MaxLength(8000)
ed25519Key!: string; ed25519Key?: string;
@IsString() @IsString()
@MinLength(20) @MinLength(20)
+90 -1
View File
@@ -57,6 +57,12 @@ export class InvoiceKeyPointInputDto {
text!: string; text!: string;
} }
export class InvoicePaymentMethodInputDto {
@IsString()
@MinLength(1)
text!: string;
}
export class InvoiceAccountInputDto { export class InvoiceAccountInputDto {
@IsString() @IsString()
@MinLength(1) @MinLength(1)
@@ -114,6 +120,12 @@ export class CreateInvoiceDto {
@Type(() => InvoiceKeyPointInputDto) @Type(() => InvoiceKeyPointInputDto)
keyPoints?: InvoiceKeyPointInputDto[]; keyPoints?: InvoiceKeyPointInputDto[];
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => InvoicePaymentMethodInputDto)
paymentMethods?: InvoicePaymentMethodInputDto[];
@IsOptional() @IsOptional()
@IsArray() @IsArray()
@ValidateNested({ each: true }) @ValidateNested({ each: true })
@@ -134,7 +146,7 @@ export class UpdateInvoiceStatusDto {
notes?: string; notes?: string;
} }
/** Full content replace for an existing invoice (items / key points / accounts). */ /** Full content replace for an existing invoice (items / key points / payment methods / accounts). */
export class UpdateInvoiceDto { export class UpdateInvoiceDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
@@ -169,6 +181,12 @@ export class UpdateInvoiceDto {
@Type(() => InvoiceKeyPointInputDto) @Type(() => InvoiceKeyPointInputDto)
keyPoints?: InvoiceKeyPointInputDto[]; keyPoints?: InvoiceKeyPointInputDto[];
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => InvoicePaymentMethodInputDto)
paymentMethods?: InvoicePaymentMethodInputDto[];
@IsOptional() @IsOptional()
@IsArray() @IsArray()
@ValidateNested({ each: true }) @ValidateNested({ each: true })
@@ -279,6 +297,65 @@ export class UpdateInvoiceItemTemplateDto {
isActive?: boolean; isActive?: boolean;
} }
export class CreateInvoiceAccountTemplateDto {
@IsString()
@MinLength(1)
@MaxLength(255)
bankName!: string;
@IsOptional()
@IsString()
@MaxLength(255)
accountHolderName?: string;
@IsOptional()
@IsString()
@MaxLength(64)
cardNumber?: string;
@IsOptional()
@IsString()
@MaxLength(64)
iban?: string;
@IsOptional()
@Type(() => Number)
@IsInt()
sortOrder?: number;
}
export class UpdateInvoiceAccountTemplateDto {
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(255)
bankName?: string;
@IsOptional()
@IsString()
@MaxLength(255)
accountHolderName?: string | null;
@IsOptional()
@IsString()
@MaxLength(64)
cardNumber?: string | null;
@IsOptional()
@IsString()
@MaxLength(64)
iban?: string | null;
@IsOptional()
@Type(() => Number)
@IsInt()
sortOrder?: number;
@IsOptional()
@IsBoolean()
isActive?: boolean;
}
export class InvoiceTemplateItemInputDto { export class InvoiceTemplateItemInputDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
@@ -337,6 +414,12 @@ export class CreateInvoiceTemplateDto {
@Type(() => InvoiceKeyPointInputDto) @Type(() => InvoiceKeyPointInputDto)
keyPoints?: InvoiceKeyPointInputDto[]; keyPoints?: InvoiceKeyPointInputDto[];
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => InvoicePaymentMethodInputDto)
paymentMethods?: InvoicePaymentMethodInputDto[];
@IsOptional() @IsOptional()
@IsArray() @IsArray()
@ValidateNested({ each: true }) @ValidateNested({ each: true })
@@ -373,6 +456,12 @@ export class UpdateInvoiceTemplateDto {
@Type(() => InvoiceKeyPointInputDto) @Type(() => InvoiceKeyPointInputDto)
keyPoints?: InvoiceKeyPointInputDto[]; keyPoints?: InvoiceKeyPointInputDto[];
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => InvoicePaymentMethodInputDto)
paymentMethods?: InvoicePaymentMethodInputDto[];
@IsOptional() @IsOptional()
@IsArray() @IsArray()
@ValidateNested({ each: true }) @ValidateNested({ each: true })
+78
View File
@@ -20,9 +20,11 @@ import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { AuthUser } from '../auth/auth.types'; import { AuthUser } from '../auth/auth.types';
import { import {
CreateInvoiceDto, CreateInvoiceDto,
CreateInvoiceAccountTemplateDto,
CreateInvoiceItemTemplateDto, CreateInvoiceItemTemplateDto,
CreateInvoiceTemplateDto, CreateInvoiceTemplateDto,
ListInvoicesDto, ListInvoicesDto,
UpdateInvoiceAccountTemplateDto,
UpdateInvoiceDto, UpdateInvoiceDto,
UpdateInvoiceItemTemplateDto, UpdateInvoiceItemTemplateDto,
UpdateInvoiceStatusDto, UpdateInvoiceStatusDto,
@@ -63,6 +65,41 @@ export class InvoicesController {
return this.service.deletePlatformTemplate(templateId, user); return this.service.deletePlatformTemplate(templateId, user);
} }
// Platform invoice account templates (settings)
@Get('invoice-account-templates')
@UseGuards(JwtAuthGuard)
listAccountTemplates(@CurrentUser() user: AuthUser) {
return this.service.listPlatformAccountTemplates(user);
}
@Post('invoice-account-templates')
@UseGuards(JwtAuthGuard)
createAccountTemplate(
@Body() dto: CreateInvoiceAccountTemplateDto,
@CurrentUser() user: AuthUser,
) {
return this.service.createPlatformAccountTemplate(dto, user);
}
@Patch('invoice-account-templates/:templateId')
@UseGuards(JwtAuthGuard)
updateAccountTemplate(
@Param('templateId') templateId: string,
@Body() dto: UpdateInvoiceAccountTemplateDto,
@CurrentUser() user: AuthUser,
) {
return this.service.updatePlatformAccountTemplate(templateId, dto, user);
}
@Delete('invoice-account-templates/:templateId')
@UseGuards(JwtAuthGuard)
deleteAccountTemplate(
@Param('templateId') templateId: string,
@CurrentUser() user: AuthUser,
) {
return this.service.deletePlatformAccountTemplate(templateId, user);
}
// Platform invoice templates (settings) // Platform invoice templates (settings)
@Get('invoice-templates') @Get('invoice-templates')
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@@ -237,6 +274,47 @@ export class BusinessInvoicesController {
return this.service.deleteBusinessItemTemplate(businessId, templateId, user); return this.service.deleteBusinessItemTemplate(businessId, templateId, user);
} }
// Invoice account templates (business-scoped)
@Get('invoice-account-templates')
@RequireBusinessPermission('invoice_templates.read')
listAccountTemplatesBusiness(
@Param('businessId') businessId: string,
@CurrentUser() user: AuthUser,
) {
return this.service.listBusinessAccountTemplates(businessId, user);
}
@Post('invoice-account-templates')
@RequireBusinessPermission('invoice_templates.manage')
createAccountTemplateBusiness(
@Param('businessId') businessId: string,
@Body() dto: CreateInvoiceAccountTemplateDto,
@CurrentUser() user: AuthUser,
) {
return this.service.createBusinessAccountTemplate(businessId, dto, user);
}
@Patch('invoice-account-templates/:templateId')
@RequireBusinessPermission('invoice_templates.manage')
updateAccountTemplateBusiness(
@Param('businessId') businessId: string,
@Param('templateId') templateId: string,
@Body() dto: UpdateInvoiceAccountTemplateDto,
@CurrentUser() user: AuthUser,
) {
return this.service.updateBusinessAccountTemplate(businessId, templateId, dto, user);
}
@Delete('invoice-account-templates/:templateId')
@RequireBusinessPermission('invoice_templates.manage')
deleteAccountTemplateBusiness(
@Param('businessId') businessId: string,
@Param('templateId') templateId: string,
@CurrentUser() user: AuthUser,
) {
return this.service.deleteBusinessAccountTemplate(businessId, templateId, user);
}
// Invoice templates (business-scoped) // Invoice templates (business-scoped)
@Get('invoice-templates') @Get('invoice-templates')
@RequireBusinessPermission('invoice_templates.read') @RequireBusinessPermission('invoice_templates.read')
+319
View File
@@ -11,13 +11,16 @@ import { PermissionsService } from '../auth/permissions.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { import {
CreateInvoiceDto, CreateInvoiceDto,
CreateInvoiceAccountTemplateDto,
CreateInvoiceItemTemplateDto, CreateInvoiceItemTemplateDto,
CreateInvoiceTemplateDto, CreateInvoiceTemplateDto,
InvoiceAccountInputDto, InvoiceAccountInputDto,
InvoiceItemInputDto, InvoiceItemInputDto,
InvoiceKeyPointInputDto, InvoiceKeyPointInputDto,
InvoicePaymentMethodInputDto,
InvoiceTemplateItemInputDto, InvoiceTemplateItemInputDto,
ListInvoicesDto, ListInvoicesDto,
UpdateInvoiceAccountTemplateDto,
UpdateInvoiceDto, UpdateInvoiceDto,
UpdateInvoiceItemTemplateDto, UpdateInvoiceItemTemplateDto,
UpdateInvoiceStatusDto, UpdateInvoiceStatusDto,
@@ -231,6 +234,34 @@ export class InvoicesService {
}; };
} }
private serializeAccountTemplate(row: {
id: bigint;
ownerScope: InvoiceOwnerScope;
businessId: bigint | null;
bankName: string;
accountHolderName: string | null;
cardNumber: string | null;
iban: string | null;
sortOrder: number;
isActive: boolean;
createdAt: Date;
updatedAt: Date;
}) {
return {
id: row.id.toString(),
ownerScope: row.ownerScope,
businessId: row.businessId?.toString() ?? null,
bankName: row.bankName,
accountHolderName: row.accountHolderName,
cardNumber: row.cardNumber,
iban: row.iban,
sortOrder: row.sortOrder,
isActive: row.isActive,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
private readonly invoiceInclude = { private readonly invoiceInclude = {
business: { business: {
select: { select: {
@@ -250,12 +281,14 @@ export class InvoicesService {
user: { select: { id: true, firstName: true, lastName: true, cellNumber: true } }, user: { select: { id: true, firstName: true, lastName: true, cellNumber: true } },
items: { orderBy: [{ sortOrder: 'asc' as const }, { id: 'asc' as const }] }, items: { orderBy: [{ sortOrder: 'asc' as const }, { id: 'asc' as const }] },
keyPoints: { orderBy: [{ sortOrder: 'asc' as const }, { id: 'asc' as const }] }, keyPoints: { orderBy: [{ sortOrder: 'asc' as const }, { id: 'asc' as const }] },
paymentMethods: { orderBy: [{ sortOrder: 'asc' as const }, { id: 'asc' as const }] },
accounts: { orderBy: [{ sortOrder: 'asc' as const }, { id: 'asc' as const }] }, accounts: { orderBy: [{ sortOrder: 'asc' as const }, { id: 'asc' as const }] },
}; };
private readonly invoiceTemplateInclude = { private readonly invoiceTemplateInclude = {
items: { orderBy: [{ sortOrder: 'asc' as const }, { id: 'asc' as const }] }, items: { orderBy: [{ sortOrder: 'asc' as const }, { id: 'asc' as const }] },
keyPoints: { orderBy: [{ sortOrder: 'asc' as const }, { id: 'asc' as const }] }, keyPoints: { orderBy: [{ sortOrder: 'asc' as const }, { id: 'asc' as const }] },
paymentMethods: { orderBy: [{ sortOrder: 'asc' as const }, { id: 'asc' as const }] },
accounts: { orderBy: [{ sortOrder: 'asc' as const }, { id: 'asc' as const }] }, accounts: { orderBy: [{ sortOrder: 'asc' as const }, { id: 'asc' as const }] },
}; };
@@ -271,6 +304,18 @@ export class InvoicesService {
}; };
} }
private serializePaymentMethod(row: {
id: bigint;
text: string;
sortOrder: number;
}) {
return {
id: row.id.toString(),
text: row.text,
sortOrder: row.sortOrder,
};
}
private serializeAccount(row: { private serializeAccount(row: {
id: bigint; id: bigint;
bankName: string; bankName: string;
@@ -343,6 +388,11 @@ export class InvoicesService {
text: string; text: string;
sortOrder: number; sortOrder: number;
}>; }>;
paymentMethods?: Array<{
id: bigint;
text: string;
sortOrder: number;
}>;
accounts?: Array<{ accounts?: Array<{
id: bigint; id: bigint;
bankName: string; bankName: string;
@@ -372,6 +422,10 @@ export class InvoicesService {
includeNested && row.keyPoints includeNested && row.keyPoints
? row.keyPoints.map((kp) => this.serializeKeyPoint(kp)) ? row.keyPoints.map((kp) => this.serializeKeyPoint(kp))
: undefined, : undefined,
paymentMethods:
includeNested && row.paymentMethods
? row.paymentMethods.map((pm) => this.serializePaymentMethod(pm))
: undefined,
accounts: accounts:
includeNested && row.accounts includeNested && row.accounts
? row.accounts.map((acc) => this.serializeAccount(acc)) ? row.accounts.map((acc) => this.serializeAccount(acc))
@@ -488,6 +542,11 @@ export class InvoicesService {
text: string; text: string;
sortOrder: number; sortOrder: number;
}>; }>;
paymentMethods?: Array<{
id: bigint;
text: string;
sortOrder: number;
}>;
accounts?: Array<{ accounts?: Array<{
id: bigint; id: bigint;
bankName: string; bankName: string;
@@ -564,6 +623,10 @@ export class InvoicesService {
includeNested && row.keyPoints includeNested && row.keyPoints
? row.keyPoints.map((kp) => this.serializeKeyPoint(kp)) ? row.keyPoints.map((kp) => this.serializeKeyPoint(kp))
: undefined, : undefined,
paymentMethods:
includeNested && row.paymentMethods
? row.paymentMethods.map((pm) => this.serializePaymentMethod(pm))
: undefined,
accounts: accounts:
includeNested && row.accounts includeNested && row.accounts
? row.accounts.map((acc) => this.serializeAccount(acc)) ? row.accounts.map((acc) => this.serializeAccount(acc))
@@ -581,6 +644,14 @@ export class InvoicesService {
return { text }; return { text };
} }
private normalizePaymentMethodInput(paymentMethod: InvoicePaymentMethodInputDto) {
const text = paymentMethod.text.trim();
if (!text) {
throw new BadRequestException('Each payment method requires text');
}
return { text };
}
private normalizeAccountInput(account: InvoiceAccountInputDto) { private normalizeAccountInput(account: InvoiceAccountInputDto) {
const bankName = account.bankName.trim(); const bankName = account.bankName.trim();
if (!bankName) { if (!bankName) {
@@ -753,6 +824,83 @@ export class InvoicesService {
return { ok: true }; return { ok: true };
} }
// --- Account templates (platform settings) ---
async listPlatformAccountTemplates(actor: AuthUser) {
await this.assertSuperAdmin(actor);
const rows = await this.prisma.invoiceAccountTemplate.findMany({
where: { ownerScope: InvoiceOwnerScope.platform },
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
});
return { items: rows.map((row) => this.serializeAccountTemplate(row)) };
}
async createPlatformAccountTemplate(
dto: CreateInvoiceAccountTemplateDto,
actor: AuthUser,
) {
await this.assertSuperAdmin(actor);
const bankName = dto.bankName.trim();
if (!bankName) {
throw new BadRequestException('Bank name is required');
}
const row = await this.prisma.invoiceAccountTemplate.create({
data: {
ownerScope: InvoiceOwnerScope.platform,
bankName,
accountHolderName: dto.accountHolderName?.trim() || null,
cardNumber: dto.cardNumber?.trim() || null,
iban: dto.iban?.trim() || null,
sortOrder: dto.sortOrder ?? 0,
},
});
return this.serializeAccountTemplate(row);
}
async updatePlatformAccountTemplate(
templateIdRaw: string,
dto: UpdateInvoiceAccountTemplateDto,
actor: AuthUser,
) {
await this.assertSuperAdmin(actor);
const templateId = BigInt(templateIdRaw);
const existing = await this.prisma.invoiceAccountTemplate.findFirst({
where: { id: templateId, ownerScope: InvoiceOwnerScope.platform },
});
if (!existing) {
throw new NotFoundException('Invoice account template not found');
}
const row = await this.prisma.invoiceAccountTemplate.update({
where: { id: templateId },
data: {
...(dto.bankName !== undefined ? { bankName: dto.bankName.trim() } : {}),
...(dto.accountHolderName !== undefined
? { accountHolderName: dto.accountHolderName?.trim() || null }
: {}),
...(dto.cardNumber !== undefined
? { cardNumber: dto.cardNumber?.trim() || null }
: {}),
...(dto.iban !== undefined ? { iban: dto.iban?.trim() || null } : {}),
...(dto.sortOrder !== undefined ? { sortOrder: dto.sortOrder } : {}),
...(dto.isActive !== undefined ? { isActive: dto.isActive } : {}),
},
});
return this.serializeAccountTemplate(row);
}
async deletePlatformAccountTemplate(templateIdRaw: string, actor: AuthUser) {
await this.assertSuperAdmin(actor);
const templateId = BigInt(templateIdRaw);
const existing = await this.prisma.invoiceAccountTemplate.findFirst({
where: { id: templateId, ownerScope: InvoiceOwnerScope.platform },
});
if (!existing) {
throw new NotFoundException('Invoice account template not found');
}
await this.prisma.invoiceAccountTemplate.delete({ where: { id: templateId } });
return { ok: true };
}
// --- Invoice templates (platform settings) --- // --- Invoice templates (platform settings) ---
async listPlatformInvoiceTemplates(actor: AuthUser) { async listPlatformInvoiceTemplates(actor: AuthUser) {
@@ -793,6 +941,9 @@ export class InvoicesService {
const items = dto.items.map((item) => this.normalizeTemplateItemInput(item)); const items = dto.items.map((item) => this.normalizeTemplateItemInput(item));
const keyPoints = (dto.keyPoints ?? []).map((kp) => this.normalizeKeyPointInput(kp)); const keyPoints = (dto.keyPoints ?? []).map((kp) => this.normalizeKeyPointInput(kp));
const paymentMethods = (dto.paymentMethods ?? []).map((pm) =>
this.normalizePaymentMethodInput(pm),
);
const accounts = (dto.accounts ?? []).map((acc) => this.normalizeAccountInput(acc)); const accounts = (dto.accounts ?? []).map((acc) => this.normalizeAccountInput(acc));
const row = await this.prisma.invoiceTemplate.create({ const row = await this.prisma.invoiceTemplate.create({
@@ -819,6 +970,12 @@ export class InvoicesService {
sortOrder: index, sortOrder: index,
})), })),
}, },
paymentMethods: {
create: paymentMethods.map((pm, index) => ({
text: pm.text,
sortOrder: index,
})),
},
accounts: { accounts: {
create: accounts.map((acc, index) => ({ create: accounts.map((acc, index) => ({
bankName: acc.bankName, bankName: acc.bankName,
@@ -856,6 +1013,10 @@ export class InvoicesService {
dto.keyPoints !== undefined dto.keyPoints !== undefined
? dto.keyPoints.map((kp) => this.normalizeKeyPointInput(kp)) ? dto.keyPoints.map((kp) => this.normalizeKeyPointInput(kp))
: null; : null;
const replacePaymentMethods =
dto.paymentMethods !== undefined
? dto.paymentMethods.map((pm) => this.normalizePaymentMethodInput(pm))
: null;
const replaceAccounts = const replaceAccounts =
dto.accounts !== undefined dto.accounts !== undefined
? dto.accounts.map((acc) => this.normalizeAccountInput(acc)) ? dto.accounts.map((acc) => this.normalizeAccountInput(acc))
@@ -868,6 +1029,9 @@ export class InvoicesService {
if (replaceKeyPoints !== null) { if (replaceKeyPoints !== null) {
await tx.invoiceTemplateKeyPoint.deleteMany({ where: { templateId } }); await tx.invoiceTemplateKeyPoint.deleteMany({ where: { templateId } });
} }
if (replacePaymentMethods !== null) {
await tx.invoiceTemplatePaymentMethod.deleteMany({ where: { templateId } });
}
if (replaceAccounts !== null) { if (replaceAccounts !== null) {
await tx.invoiceTemplateAccount.deleteMany({ where: { templateId } }); await tx.invoiceTemplateAccount.deleteMany({ where: { templateId } });
} }
@@ -905,6 +1069,16 @@ export class InvoicesService {
}, },
} }
: {}), : {}),
...(replacePaymentMethods !== null
? {
paymentMethods: {
create: replacePaymentMethods.map((pm, index) => ({
text: pm.text,
sortOrder: index,
})),
},
}
: {}),
...(replaceAccounts !== null ...(replaceAccounts !== null
? { ? {
accounts: { accounts: {
@@ -1062,6 +1236,105 @@ export class InvoicesService {
return { ok: true }; return { ok: true };
} }
// --- Account templates (business-scoped) ---
async listBusinessAccountTemplates(businessIdRaw: string, actor: AuthUser) {
const businessId = BigInt(businessIdRaw);
await this.assertBusinessExists(businessId);
await this.assertPermission(businessId, actor.id, 'invoice_templates.read');
const rows = await this.prisma.invoiceAccountTemplate.findMany({
where: { ownerScope: InvoiceOwnerScope.business, businessId },
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
});
return { items: rows.map((row) => this.serializeAccountTemplate(row)) };
}
async createBusinessAccountTemplate(
businessIdRaw: string,
dto: CreateInvoiceAccountTemplateDto,
actor: AuthUser,
) {
const businessId = BigInt(businessIdRaw);
await this.assertBusinessExists(businessId);
await this.assertPermission(businessId, actor.id, 'invoice_templates.manage');
const bankName = dto.bankName.trim();
if (!bankName) {
throw new BadRequestException('Bank name is required');
}
const row = await this.prisma.invoiceAccountTemplate.create({
data: {
ownerScope: InvoiceOwnerScope.business,
businessId,
bankName,
accountHolderName: dto.accountHolderName?.trim() || null,
cardNumber: dto.cardNumber?.trim() || null,
iban: dto.iban?.trim() || null,
sortOrder: dto.sortOrder ?? 0,
},
});
return this.serializeAccountTemplate(row);
}
async updateBusinessAccountTemplate(
businessIdRaw: string,
templateIdRaw: string,
dto: UpdateInvoiceAccountTemplateDto,
actor: AuthUser,
) {
const businessId = BigInt(businessIdRaw);
await this.assertBusinessExists(businessId);
await this.assertPermission(businessId, actor.id, 'invoice_templates.manage');
const templateId = BigInt(templateIdRaw);
const existing = await this.prisma.invoiceAccountTemplate.findFirst({
where: { id: templateId, ownerScope: InvoiceOwnerScope.business, businessId },
});
if (!existing) {
throw new NotFoundException('Invoice account template not found');
}
const row = await this.prisma.invoiceAccountTemplate.update({
where: { id: templateId },
data: {
...(dto.bankName !== undefined ? { bankName: dto.bankName.trim() } : {}),
...(dto.accountHolderName !== undefined
? { accountHolderName: dto.accountHolderName?.trim() || null }
: {}),
...(dto.cardNumber !== undefined
? { cardNumber: dto.cardNumber?.trim() || null }
: {}),
...(dto.iban !== undefined ? { iban: dto.iban?.trim() || null } : {}),
...(dto.sortOrder !== undefined ? { sortOrder: dto.sortOrder } : {}),
...(dto.isActive !== undefined ? { isActive: dto.isActive } : {}),
},
});
return this.serializeAccountTemplate(row);
}
async deleteBusinessAccountTemplate(
businessIdRaw: string,
templateIdRaw: string,
actor: AuthUser,
) {
const businessId = BigInt(businessIdRaw);
await this.assertBusinessExists(businessId);
await this.assertPermission(businessId, actor.id, 'invoice_templates.manage');
const templateId = BigInt(templateIdRaw);
const existing = await this.prisma.invoiceAccountTemplate.findFirst({
where: { id: templateId, ownerScope: InvoiceOwnerScope.business, businessId },
});
if (!existing) {
throw new NotFoundException('Invoice account template not found');
}
await this.prisma.invoiceAccountTemplate.delete({ where: { id: templateId } });
return { ok: true };
}
// --- Invoice templates (business-scoped) --- // --- Invoice templates (business-scoped) ---
async listBusinessInvoiceTemplates(businessIdRaw: string, actor: AuthUser) { async listBusinessInvoiceTemplates(businessIdRaw: string, actor: AuthUser) {
@@ -1116,6 +1389,9 @@ export class InvoicesService {
const items = dto.items.map((item) => this.normalizeTemplateItemInput(item)); const items = dto.items.map((item) => this.normalizeTemplateItemInput(item));
const keyPoints = (dto.keyPoints ?? []).map((kp) => this.normalizeKeyPointInput(kp)); const keyPoints = (dto.keyPoints ?? []).map((kp) => this.normalizeKeyPointInput(kp));
const paymentMethods = (dto.paymentMethods ?? []).map((pm) =>
this.normalizePaymentMethodInput(pm),
);
const accounts = (dto.accounts ?? []).map((acc) => this.normalizeAccountInput(acc)); const accounts = (dto.accounts ?? []).map((acc) => this.normalizeAccountInput(acc));
const row = await this.prisma.invoiceTemplate.create({ const row = await this.prisma.invoiceTemplate.create({
@@ -1143,6 +1419,12 @@ export class InvoicesService {
sortOrder: index, sortOrder: index,
})), })),
}, },
paymentMethods: {
create: paymentMethods.map((pm, index) => ({
text: pm.text,
sortOrder: index,
})),
},
accounts: { accounts: {
create: accounts.map((acc, index) => ({ create: accounts.map((acc, index) => ({
bankName: acc.bankName, bankName: acc.bankName,
@@ -1183,6 +1465,10 @@ export class InvoicesService {
dto.keyPoints !== undefined dto.keyPoints !== undefined
? dto.keyPoints.map((kp) => this.normalizeKeyPointInput(kp)) ? dto.keyPoints.map((kp) => this.normalizeKeyPointInput(kp))
: null; : null;
const replacePaymentMethods =
dto.paymentMethods !== undefined
? dto.paymentMethods.map((pm) => this.normalizePaymentMethodInput(pm))
: null;
const replaceAccounts = const replaceAccounts =
dto.accounts !== undefined dto.accounts !== undefined
? dto.accounts.map((acc) => this.normalizeAccountInput(acc)) ? dto.accounts.map((acc) => this.normalizeAccountInput(acc))
@@ -1195,6 +1481,9 @@ export class InvoicesService {
if (replaceKeyPoints !== null) { if (replaceKeyPoints !== null) {
await tx.invoiceTemplateKeyPoint.deleteMany({ where: { templateId } }); await tx.invoiceTemplateKeyPoint.deleteMany({ where: { templateId } });
} }
if (replacePaymentMethods !== null) {
await tx.invoiceTemplatePaymentMethod.deleteMany({ where: { templateId } });
}
if (replaceAccounts !== null) { if (replaceAccounts !== null) {
await tx.invoiceTemplateAccount.deleteMany({ where: { templateId } }); await tx.invoiceTemplateAccount.deleteMany({ where: { templateId } });
} }
@@ -1232,6 +1521,16 @@ export class InvoicesService {
}, },
} }
: {}), : {}),
...(replacePaymentMethods !== null
? {
paymentMethods: {
create: replacePaymentMethods.map((pm, index) => ({
text: pm.text,
sortOrder: index,
})),
},
}
: {}),
...(replaceAccounts !== null ...(replaceAccounts !== null
? { ? {
accounts: { accounts: {
@@ -1304,6 +1603,7 @@ export class InvoicesService {
: null, : null,
items: serialized.items, items: serialized.items,
keyPoints: serialized.keyPoints, keyPoints: serialized.keyPoints,
paymentMethods: serialized.paymentMethods,
accounts: serialized.accounts, accounts: serialized.accounts,
subtotal: serialized.subtotal, subtotal: serialized.subtotal,
total: serialized.total, total: serialized.total,
@@ -1465,6 +1765,9 @@ export class InvoicesService {
const items = dto.items.map((item) => this.normalizeItemInput(item)); const items = dto.items.map((item) => this.normalizeItemInput(item));
const keyPoints = (dto.keyPoints ?? []).map((kp) => this.normalizeKeyPointInput(kp)); const keyPoints = (dto.keyPoints ?? []).map((kp) => this.normalizeKeyPointInput(kp));
const paymentMethods = (dto.paymentMethods ?? []).map((pm) =>
this.normalizePaymentMethodInput(pm),
);
const accounts = (dto.accounts ?? []).map((acc) => this.normalizeAccountInput(acc)); const accounts = (dto.accounts ?? []).map((acc) => this.normalizeAccountInput(acc));
const invoiceTemplateId = dto.invoiceTemplateId?.trim() const invoiceTemplateId = dto.invoiceTemplateId?.trim()
? BigInt(dto.invoiceTemplateId) ? BigInt(dto.invoiceTemplateId)
@@ -1514,6 +1817,12 @@ export class InvoicesService {
sortOrder: index, sortOrder: index,
})), })),
}, },
paymentMethods: {
create: paymentMethods.map((pm, index) => ({
text: pm.text,
sortOrder: index,
})),
},
accounts: { accounts: {
create: accounts.map((acc, index) => ({ create: accounts.map((acc, index) => ({
bankName: acc.bankName, bankName: acc.bankName,
@@ -1600,6 +1909,9 @@ export class InvoicesService {
const items = dto.items.map((item) => this.normalizeItemInput(item)); const items = dto.items.map((item) => this.normalizeItemInput(item));
const keyPoints = (dto.keyPoints ?? []).map((kp) => this.normalizeKeyPointInput(kp)); const keyPoints = (dto.keyPoints ?? []).map((kp) => this.normalizeKeyPointInput(kp));
const paymentMethods = (dto.paymentMethods ?? []).map((pm) =>
this.normalizePaymentMethodInput(pm),
);
const accounts = (dto.accounts ?? []).map((acc) => this.normalizeAccountInput(acc)); const accounts = (dto.accounts ?? []).map((acc) => this.normalizeAccountInput(acc));
let userId: bigint | undefined; let userId: bigint | undefined;
@@ -1629,6 +1941,7 @@ export class InvoicesService {
const row = await this.prisma.$transaction(async (tx) => { const row = await this.prisma.$transaction(async (tx) => {
await tx.invoiceItem.deleteMany({ where: { invoiceId } }); await tx.invoiceItem.deleteMany({ where: { invoiceId } });
await tx.invoiceKeyPoint.deleteMany({ where: { invoiceId } }); await tx.invoiceKeyPoint.deleteMany({ where: { invoiceId } });
await tx.invoicePaymentMethod.deleteMany({ where: { invoiceId } });
await tx.invoiceAccount.deleteMany({ where: { invoiceId } }); await tx.invoiceAccount.deleteMany({ where: { invoiceId } });
return tx.invoice.update({ return tx.invoice.update({
@@ -1657,6 +1970,12 @@ export class InvoicesService {
sortOrder: index, sortOrder: index,
})), })),
}, },
paymentMethods: {
create: paymentMethods.map((pm, index) => ({
text: pm.text,
sortOrder: index,
})),
},
accounts: { accounts: {
create: accounts.map((acc, index) => ({ create: accounts.map((acc, index) => ({
bankName: acc.bankName, bankName: acc.bankName,
+51 -1
View File
@@ -60,14 +60,24 @@ export class LegacySourceS3Service implements OnModuleDestroy {
const slug = slugHint.trim(); const slug = slugHint.trim();
add(`${slug}_${oldBusinessId}`); add(`${slug}_${oldBusinessId}`);
add(`${slug.replace(/-/g, '')}_${oldBusinessId}`); add(`${slug.replace(/-/g, '')}_${oldBusinessId}`);
add(`${slug.replace(/_/g, '')}_${oldBusinessId}`);
add(`${slug.replace(/_/g, '-')}_${oldBusinessId}`);
} }
// ListBucket may be denied on the old bucket; slug candidates + HeadObject still work.
try {
const listed = await this.listTopLevelPrefixes(); const listed = await this.listTopLevelPrefixes();
for (const prefix of listed) { for (const prefix of listed) {
if (prefix.endsWith(`_${oldBusinessId}`)) { if (prefix.endsWith(`_${oldBusinessId}`)) {
add(prefix); add(prefix);
} }
} }
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.warn(
`Legacy S3 list prefixes failed for business ${oldBusinessId}; using slug candidates only: ${message}`,
);
}
if (!candidates.length) { if (!candidates.length) {
throw new ServiceUnavailableException( 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 bestPrefix = candidates[0];
let bestHits = -1; let bestHits = -1;
@@ -96,6 +107,16 @@ export class LegacySourceS3Service implements OnModuleDestroy {
} }
} }
if (bestHits <= 0 && sample.length > 0) {
for (const prefix of candidates) {
if (await this.prefixHasAnyObject(prefix)) {
bestPrefix = prefix;
bestHits = 1;
break;
}
}
}
if (bestHits <= 0 && sample.length > 0) { if (bestHits <= 0 && sample.length > 0) {
throw new ServiceUnavailableException( throw new ServiceUnavailableException(
`Old S3 files not found for business ${oldBusinessId} (tried: ${candidates.join(', ')}).`, `Old S3 files not found for business ${oldBusinessId} (tried: ${candidates.join(', ')}).`,
@@ -106,6 +127,35 @@ export class LegacySourceS3Service implements OnModuleDestroy {
return bestPrefix; return bestPrefix;
} }
private pickProbeSample<T>(items: T[], n: number): T[] {
if (items.length <= n) return [...items];
const out: T[] = [];
const seen = new Set<number>();
for (let i = 0; i < n; i++) {
const idx = Math.floor((i * (items.length - 1)) / Math.max(n - 1, 1));
if (seen.has(idx)) continue;
seen.add(idx);
out.push(items[idx]);
}
return out;
}
private async prefixHasAnyObject(prefix: string): Promise<boolean> {
const { client, bucket } = this.getClient();
try {
const res = await client.send(
new ListObjectsV2Command({
Bucket: bucket,
Prefix: `${prefix}/`,
MaxKeys: 1,
}),
);
return (res.Contents?.length ?? 0) > 0;
} catch {
return false;
}
}
async getObjectBuffer(key: string): Promise<Buffer> { async getObjectBuffer(key: string): Promise<Buffer> {
const { client, bucket } = this.getClient(); const { client, bucket } = this.getClient();
try { try {
+10 -1
View File
@@ -1,7 +1,7 @@
export const MAIL_IPV4 = '185.214.101.41'; export const MAIL_IPV4 = '185.214.101.41';
export const MAIL_HOSTNAME = 'mail.meshkee.com'; export const MAIL_HOSTNAME = 'mail.meshkee.com';
export const MAIL_MX_PRIORITY = 10; export const MAIL_MX_PRIORITY = 10;
export const MAIL_SPF = `v=spf1 ip4:${MAIL_IPV4} -all`; export const MAIL_SPF = 'v=spf1 mx -all';
/** Old provider selector (dkim._domainkey.example.com) — removed on mail DNS update. */ /** Old provider selector (dkim._domainkey.example.com) — removed on mail DNS update. */
export const LEGACY_DKIM_NAME = 'dkim._domainkey'; export const LEGACY_DKIM_NAME = 'dkim._domainkey';
export const DMARC_NAME = '_dmarc'; export const DMARC_NAME = '_dmarc';
@@ -44,10 +44,19 @@ export function parseDkimPaste(
let text = (txtMatch ? txtMatch[0] : blob).trim(); let text = (txtMatch ? txtMatch[0] : blob).trim();
text = text.replace(/^"+|"+$/g, '').replace(/\s+/g, ' ').replace(/[.\s]+$/g, '').trim(); text = text.replace(/^"+|"+$/g, '').replace(/\s+/g, ' ').replace(/[.\s]+$/g, '').trim();
if (!/^v=DKIM1/i.test(text)) { if (!/^v=DKIM1/i.test(text)) {
// 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( throw new Error(
`Could not find a v=DKIM1 value in the ${expectedKind} field. Paste the TXT value or Stalwart zone line.`, `Could not find a v=DKIM1 value in the ${expectedKind} field. Paste the TXT value or Stalwart zone line.`,
); );
} }
}
const kind = /k=ed25519/i.test(text) const kind = /k=ed25519/i.test(text)
? 'ed25519' ? 'ed25519'
+66 -1
View File
@@ -26,6 +26,11 @@ const IMAGE_MIME_TYPES = new Set([
const VIDEO_MIME_TYPES = new Set(['video/mp4', 'video/webm']); const VIDEO_MIME_TYPES = new Set(['video/mp4', 'video/webm']);
const PDF_MIME_TYPES = new Set(['application/pdf']);
/** Product catalog PDFs are capped lower than general media uploads. */
const PDF_MAX_FILE_SIZE_BYTES = 2 * 1024 * 1024;
@Injectable() @Injectable()
export class MediaService { export class MediaService {
private readonly maxFileSizeBytes: number; private readonly maxFileSizeBytes: number;
@@ -375,6 +380,14 @@ export class MediaService {
throw new BadRequestException('Uploaded file is empty'); throw new BadRequestException('Uploaded file is empty');
} }
const isPdf =
PDF_MIME_TYPES.has(file.mimetype) ||
file.originalname.toLowerCase().endsWith('.pdf');
if (isPdf) {
return this.uploadPdf(businessId, file, uploadedBy);
}
if (file.size > this.maxFileSizeBytes) { if (file.size > this.maxFileSizeBytes) {
throw new BadRequestException( throw new BadRequestException(
`File ${file.originalname} exceeds the maximum allowed size`, `File ${file.originalname} exceeds the maximum allowed size`,
@@ -409,7 +422,7 @@ export class MediaService {
} }
throw new BadRequestException( throw new BadRequestException(
`Unsupported file type: ${file.mimetype || 'unknown'}. Use JPEG, PNG, WebP, or GIF.`, `Unsupported file type: ${file.mimetype || 'unknown'}. Use JPEG, PNG, WebP, GIF, or PDF.`,
); );
} }
@@ -443,6 +456,54 @@ export class MediaService {
return this.serialize(created); return this.serialize(created);
} }
private async uploadPdf(
businessId: bigint,
file: Express.Multer.File,
uploadedBy: bigint,
) {
if (file.size > PDF_MAX_FILE_SIZE_BYTES) {
throw new BadRequestException(
`PDF ${file.originalname} exceeds the maximum allowed size of 2MB`,
);
}
const header = file.buffer.subarray(0, 5).toString('utf8');
if (!header.startsWith('%PDF-')) {
throw new BadRequestException(
`File ${file.originalname} is not a valid PDF`,
);
}
const fileName = `${randomUUID()}.pdf`;
const storageKey = businessMediaKey(businessId, fileName);
const contentType = 'application/pdf';
const stored = await this.storage.upload({
key: storageKey,
body: file.buffer,
contentType,
});
const created = await this.prisma.media.create({
data: {
businessId,
uploadedBy,
mediaType: MediaType.document,
storageDisk: stored.storageDisk,
storagePath: stored.storagePath,
publicUrl: stored.publicUrl,
fileName,
originalFileName: file.originalname,
mimeType: contentType,
fileSizeBytes: BigInt(file.size),
width: null,
height: null,
},
});
return this.serialize(created);
}
private contentTypeFromFormat(format?: string) { private contentTypeFromFormat(format?: string) {
switch (format) { switch (format) {
case 'jpeg': case 'jpeg':
@@ -469,6 +530,8 @@ export class MediaService {
return '.webp'; return '.webp';
case 'image/gif': case 'image/gif':
return '.gif'; return '.gif';
case 'application/pdf':
return '.pdf';
default: default:
return '.jpg'; return '.jpg';
} }
@@ -493,6 +556,8 @@ export class MediaService {
return '.mp4'; return '.mp4';
case 'video/webm': case 'video/webm':
return '.webm'; return '.webm';
case 'application/pdf':
return '.pdf';
default: default:
return ''; return '';
} }
+17 -1
View File
@@ -1,7 +1,9 @@
import { import {
ArrayMinSize, ArrayMinSize,
IsBoolean,
IsDateString, IsDateString,
IsEnum, IsEnum,
IsIn,
IsInt, IsInt,
IsNumber, IsNumber,
IsOptional, IsOptional,
@@ -11,7 +13,7 @@ import {
ValidateIf, ValidateIf,
ValidateNested, ValidateNested,
} from 'class-validator'; } from 'class-validator';
import { Type } from 'class-transformer'; import { Transform, Type } from 'class-transformer';
import { OrderStatus, TransactionType } from '@prisma/client'; import { OrderStatus, TransactionType } from '@prisma/client';
import { ShippingAddressDto } from '../../cart/dto/cart.dto'; import { ShippingAddressDto } from '../../cart/dto/cart.dto';
@@ -166,4 +168,18 @@ export class ListOrdersDto {
@Min(0) @Min(0)
@Type(() => Number) @Type(() => Number)
maxTotal?: number; maxTotal?: number;
/** Filter by payment outcome derived from transactions. */
@IsOptional()
@IsIn(['paid', 'unpaid', 'failed'])
paymentStatus?: 'paid' | 'unpaid' | 'failed';
/**
* When true, only return the authenticated user's own orders
* (even if they also have orders.read staff permission).
*/
@IsOptional()
@Transform(({ value }) => value === true || value === 'true' || value === '1')
@IsBoolean()
selfOnly?: boolean;
} }
+86 -8
View File
@@ -15,6 +15,7 @@ import {
} from '@prisma/client'; } from '@prisma/client';
import { AuthUser } from '../auth/auth.types'; import { AuthUser } from '../auth/auth.types';
import { BusinessSettingsService } from '../business-settings/business-settings.service'; import { BusinessSettingsService } from '../business-settings/business-settings.service';
import { isSystemPaymentProcessStep } from '../business-settings/business-settings.util';
import { PermissionsService } from '../auth/permissions.service'; import { PermissionsService } from '../auth/permissions.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { import {
@@ -28,6 +29,9 @@ import {
} from './dto/order.dto'; } from './dto/order.dto';
import { ShippingAddressDto } from '../cart/dto/cart.dto'; import { ShippingAddressDto } from '../cart/dto/cart.dto';
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
type OrderWithItems = Prisma.OrderGetPayload<{ type OrderWithItems = Prisma.OrderGetPayload<{
include: typeof ORDER_RELATIONS_INCLUDE; include: typeof ORDER_RELATIONS_INCLUDE;
}>; }>;
@@ -109,9 +113,9 @@ export class OrdersService {
const where: Prisma.OrderWhereInput = { const where: Prisma.OrderWhereInput = {
businessId, businessId,
...(scope === 'customer' ? { userId: actor.id } : {}), ...(scope === 'customer' || query.selfOnly ? { userId: actor.id } : {}),
...(query.status ? { status: query.status } : {}), ...(query.status ? { status: query.status } : {}),
...(scope === 'admin' && query.customerUserId ...(scope === 'admin' && !query.selfOnly && query.customerUserId
? { userId: BigInt(query.customerUserId) } ? { userId: BigInt(query.customerUserId) }
: {}), : {}),
...(query.orderNumber ...(query.orderNumber
@@ -146,11 +150,12 @@ export class OrdersService {
}, },
} }
: {}), : {}),
...(scope === 'admin' && query.customerQuery?.trim() ...(scope === 'admin' && !query.selfOnly && query.customerQuery?.trim()
? { ? {
customer: this.buildCustomerSearchFilter(query.customerQuery.trim()), customer: this.buildCustomerSearchFilter(query.customerQuery.trim()),
} }
: {}), : {}),
...this.paymentStatusWhere(query.paymentStatus),
}; };
const [items, total] = await Promise.all([ const [items, total] = await Promise.all([
@@ -376,6 +381,7 @@ export class OrdersService {
source: OrderSource; source: OrderSource;
cartItems: CartItemForOrder[]; cartItems: CartItemForOrder[];
shippingAddress: Record<string, unknown>; shippingAddress: Record<string, unknown>;
shippingTotal?: number;
addressId: bigint | null; addressId: bigint | null;
customerNotes: string | null; customerNotes: string | null;
adminNotes: string | null; adminNotes: string | null;
@@ -390,7 +396,10 @@ export class OrdersService {
})), })),
); );
const orderTotal = preparedItems.reduce((sum, item) => sum + item.lineTotal, 0); const subtotal = preparedItems.reduce((sum, item) => sum + item.lineTotal, 0);
const shippingTotal = Math.max(0, Number(input.shippingTotal ?? 0));
const discountTotal = 0;
const orderTotal = subtotal + shippingTotal - discountTotal;
return this.createOrder({ return this.createOrder({
businessId: input.businessId, businessId: input.businessId,
@@ -399,6 +408,8 @@ export class OrdersService {
source: input.source, source: input.source,
items: preparedItems, items: preparedItems,
shippingAddress: input.shippingAddress, shippingAddress: input.shippingAddress,
shippingTotal,
discountTotal,
addressId: input.addressId, addressId: input.addressId,
customerNotes: input.customerNotes, customerNotes: input.customerNotes,
adminNotes: input.adminNotes, adminNotes: input.adminNotes,
@@ -419,6 +430,8 @@ export class OrdersService {
source: OrderSource; source: OrderSource;
items: PreparedOrderItem[]; items: PreparedOrderItem[];
shippingAddress: Record<string, unknown>; shippingAddress: Record<string, unknown>;
shippingTotal?: number;
discountTotal?: number;
addressId: bigint | null; addressId: bigint | null;
customerNotes: string | null; customerNotes: string | null;
adminNotes: string | null; adminNotes: string | null;
@@ -426,8 +439,8 @@ export class OrdersService {
payments?: TransactionPaymentInput[]; payments?: TransactionPaymentInput[];
}) { }) {
const subtotal = input.items.reduce((sum, item) => sum + item.lineTotal, 0); const subtotal = input.items.reduce((sum, item) => sum + item.lineTotal, 0);
const shippingTotal = 0; const shippingTotal = Math.max(0, Number(input.shippingTotal ?? 0));
const discountTotal = 0; const discountTotal = Math.max(0, Number(input.discountTotal ?? 0));
const total = subtotal + shippingTotal - discountTotal; const total = subtotal + shippingTotal - discountTotal;
const processStepId = await this.defaultProcessStepId(input.businessId); const processStepId = await this.defaultProcessStepId(input.businessId);
@@ -750,12 +763,55 @@ export class OrdersService {
return { OR: or }; return { OR: or };
} }
private paymentStatusWhere(
paymentStatus: 'paid' | 'unpaid' | 'failed' | undefined,
): Prisma.OrderWhereInput {
if (paymentStatus === 'paid') {
return {
transactions: { some: { status: 'completed' } },
};
}
if (paymentStatus === 'failed') {
return {
AND: [
{ transactions: { some: { status: 'failed' } } },
{ transactions: { none: { status: 'completed' } } },
],
};
}
if (paymentStatus === 'unpaid') {
return {
transactions: { none: { status: 'completed' } },
};
}
return {};
}
private derivePaymentStatus(
transactions: Array<{ status: string }>,
): 'paid' | 'unpaid' | 'failed' {
if (transactions.some((tx) => tx.status === 'completed')) return 'paid';
if (
transactions.length > 0 &&
transactions.every((tx) => tx.status === 'failed')
) {
return 'failed';
}
return 'unpaid';
}
private async defaultProcessStepId(businessId: bigint) { private async defaultProcessStepId(businessId: bigint) {
const steps = await this.businessSettings.getOrderProcessSteps(businessId); const steps = await this.businessSettings.getOrderProcessSteps(businessId);
return steps[0]?.id ?? 'processing'; return steps[0]?.id ?? 'processing';
} }
private async assertValidProcessStepId(businessId: bigint, processStepId: string) { private async assertValidProcessStepId(businessId: bigint, processStepId: string) {
if (isSystemPaymentProcessStep(processStepId)) {
throw new BadRequestException(
'Payment steps are set automatically by the payment gateway and cannot be changed manually',
);
}
const steps = await this.businessSettings.getOrderProcessSteps(businessId); const steps = await this.businessSettings.getOrderProcessSteps(businessId);
const valid = steps.some((step) => step.id === processStepId); const valid = steps.some((step) => step.id === processStepId);
if (!valid) { if (!valid) {
@@ -855,6 +911,7 @@ export class OrdersService {
processStepLabel: processStep?.label ?? null, processStepLabel: processStep?.label ?? null,
processStepLabelFa: processStep?.labelFa?.trim() || null, processStepLabelFa: processStep?.labelFa?.trim() || null,
processStepColor: processStep?.color ?? null, processStepColor: processStep?.color ?? null,
paymentStatus: this.derivePaymentStatus(order.transactions ?? []),
source: order.source, source: order.source,
subtotal: Number(order.subtotal), subtotal: Number(order.subtotal),
shippingTotal: Number(order.shippingTotal), shippingTotal: Number(order.shippingTotal),
@@ -874,11 +931,17 @@ export class OrdersService {
cellNumber: order.customer.cellNumber, cellNumber: order.customer.cellNumber,
email: order.customer.email, email: order.customer.email,
}, },
items: order.items.map((item) => ({ items: order.items.map((item) => {
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(), id: item.id.toString(),
storeItemVariantId: item.storeItemVariantId?.toString() ?? null, storeItemVariantId: item.storeItemVariantId?.toString() ?? null,
productId: item.productId.toString(), productId: item.productId.toString(),
productTitle: item.productTitle, productTitle: item.productTitle,
productNameFa: productNameFa || null,
productSlug: item.product.slug ?? null,
productImage: this.resolveProductImage(item.product, galleryByProductId), productImage: this.resolveProductImage(item.product, galleryByProductId),
variantSku: item.variantSku, variantSku: item.variantSku,
unitPrice: Number(item.unitPrice), unitPrice: Number(item.unitPrice),
@@ -887,7 +950,8 @@ export class OrdersService {
quantity: item.quantity, quantity: item.quantity,
lineTotal: Number(item.lineTotal), lineTotal: Number(item.lineTotal),
selections: item.selectionsSnapshot, selections: item.selectionsSnapshot,
})), }
}),
transactions: (order.transactions ?? []).map((transaction) => transactions: (order.transactions ?? []).map((transaction) =>
this.serializeTransaction(transaction), this.serializeTransaction(transaction),
), ),
@@ -895,6 +959,18 @@ export class OrdersService {
} }
private serializeTransaction(transaction: Transaction) { private serializeTransaction(transaction: Transaction) {
const meta = isRecord(transaction.gatewayMeta)
? transaction.gatewayMeta
: null;
const verify = meta && isRecord(meta.verify) ? meta.verify : null;
const cardPan =
(verify && typeof verify.cardPan === 'string' && verify.cardPan.trim()) ||
(meta && typeof meta.cardPan === 'string' && meta.cardPan.trim()) ||
null;
const completedAt =
(meta && typeof meta.completedAt === 'string' && meta.completedAt.trim()) ||
null;
return { return {
id: transaction.id.toString(), id: transaction.id.toString(),
type: transaction.type, type: transaction.type,
@@ -904,6 +980,8 @@ export class OrdersService {
gatewayType: transaction.gatewayType, gatewayType: transaction.gatewayType,
gatewayRef: transaction.gatewayRef ?? null, gatewayRef: transaction.gatewayRef ?? null,
gatewayTrackId: transaction.gatewayTrackId ?? null, gatewayTrackId: transaction.gatewayTrackId ?? null,
cardPan,
paidAt: completedAt,
transferAccount: transaction.transferAccount, transferAccount: transaction.transferAccount,
transferRefNumber: transaction.transferRefNumber, transferRefNumber: transaction.transferRefNumber,
notes: transaction.notes, notes: transaction.notes,
+23
View File
@@ -303,6 +303,8 @@ export class PaymentsService {
}, },
}); });
await this.advanceOrderAfterPayment(businessId, transaction.orderId);
// Keep order as pending for fulfillment; payment completion is on the transaction. // Keep order as pending for fulfillment; payment completion is on the transaction.
if (!returnUrl) { if (!returnUrl) {
throw new BadRequestException( throw new BadRequestException(
@@ -392,6 +394,27 @@ export class PaymentsService {
return null; return null;
} }
private async advanceOrderAfterPayment(
businessId: bigint,
orderId: bigint,
) {
const steps = await this.settings.getOrderProcessSteps(businessId);
const paymentSuccessful = steps.find((step) => step.id === 'payment-successful');
if (!paymentSuccessful) return;
await this.prisma.order.updateMany({
where: {
id: orderId,
businessId,
processStepId: { in: ['awaiting-payment', steps[0]?.id].filter(Boolean) as string[] },
},
data: {
processStepId: 'payment-successful',
status: 'confirmed',
},
});
}
private async markFailed(transactionId: bigint, meta: GatewayMeta) { private async markFailed(transactionId: bigint, meta: GatewayMeta) {
await this.prisma.transaction.update({ await this.prisma.transaction.update({
where: { id: transactionId }, where: { id: transactionId },
+11 -1
View File
@@ -1,4 +1,4 @@
import { IsEnum, IsOptional, IsString, MinLength, ValidateIf } from 'class-validator'; import { IsEnum, IsOptional, IsString, MaxLength, MinLength, ValidateIf } from 'class-validator';
export enum ProductAiLanguage { export enum ProductAiLanguage {
en = 'en', en = 'en',
@@ -16,8 +16,10 @@ export class CreateProductByAiDto {
@MinLength(1) @MinLength(1)
categoryId!: string; categoryId!: string;
/** Product brief for AI: model name, optional URL, and notes. */
@IsString() @IsString()
@MinLength(2) @MinLength(2)
@MaxLength(4000)
name!: string; name!: string;
@IsEnum(ProductAiLanguage) @IsEnum(ProductAiLanguage)
@@ -50,3 +52,11 @@ export class FillProductFieldByAiDto {
@IsString() @IsString()
categoryId?: string; categoryId?: string;
} }
export class FillProductTechnicalInfoByAiDto {
/** Editable user prompt / research brief from the dashboard modal. */
@IsString()
@MinLength(10)
@MaxLength(8000)
prompt!: string;
}
+10
View File
@@ -104,6 +104,11 @@ export class CreateProductDto {
@IsString() @IsString()
featuredMediaId?: string; featuredMediaId?: string;
/** Optional PDF catalog file (media id; application/pdf, max 2MB). */
@IsOptional()
@IsString()
catalogMediaId?: string;
@IsOptional() @IsOptional()
@IsArray() @IsArray()
@IsString({ each: true }) @IsString({ each: true })
@@ -165,6 +170,11 @@ export class UpdateProductDto {
@IsString() @IsString()
featuredMediaId?: string | null; featuredMediaId?: string | null;
/** Optional PDF catalog file (media id). Pass null to clear. */
@IsOptional()
@IsString()
catalogMediaId?: string | null;
@IsOptional() @IsOptional()
@IsArray() @IsArray()
@IsString({ each: true }) @IsString({ each: true })
+223 -91
View File
@@ -5,15 +5,21 @@ import {
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { ContentStatus, TechnicalFieldType } from '@prisma/client'; import { ContentStatus, TechnicalFieldType } from '@prisma/client';
import { AuthUser } from '../auth/auth.types'; import { AuthUser } from '../auth/auth.types';
import { CategoryTechnicalFormService } from '../categories/category-technical-form.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { CreateProductByAiDto, FillProductFieldByAiDto, ProductAiFillField, ProductAiLanguage } from './dto/product-ai.dto'; import {
CreateProductByAiDto,
FillProductFieldByAiDto,
FillProductTechnicalInfoByAiDto,
ProductAiFillField,
ProductAiLanguage,
} from './dto/product-ai.dto';
import { ProductTechnicalInfoService } from './product-technical-info.service'; import { ProductTechnicalInfoService } from './product-technical-info.service';
import { ProductsService } from './products.service'; import { ProductsService } from './products.service';
import { import {
requestAiJsonCompletion, requestAiJsonCompletion,
resolveAiProvider, resolveAiProvider,
} from '../common/ai-provider.util'; } from '../common/ai-provider.util';
import { loadReferencePagesFromText } from '../common/ai-reference-pages.util';
import { normalizeAiDescriptionHtml } from '../common/ai-html.util'; import { normalizeAiDescriptionHtml } from '../common/ai-html.util';
type TechnicalFormField = { type TechnicalFormField = {
@@ -30,7 +36,6 @@ type AiProductDraft = {
summary: string; summary: string;
descriptionHtml: string; descriptionHtml: string;
tags: string[]; tags: string[];
technicalValues: Record<string, string | string[]>;
}; };
@Injectable() @Injectable()
@@ -40,7 +45,6 @@ export class ProductAiService {
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly products: ProductsService, private readonly products: ProductsService,
private readonly technicalInfo: ProductTechnicalInfoService, private readonly technicalInfo: ProductTechnicalInfoService,
private readonly technicalFormService: CategoryTechnicalFormService,
) {} ) {}
async createFromAi( async createFromAi(
@@ -63,16 +67,10 @@ export class ProductAiService {
throw new BadRequestException('Product category not found'); throw new BadRequestException('Product category not found');
} }
const form = await this.technicalFormService.getFormForCategory(
businessId,
categoryId,
);
const draft = await this.generateDraft({ const draft = await this.generateDraft({
categoryName: category.name, categoryName: category.name,
productName: dto.name.trim(), productName: dto.name.trim(),
language: dto.language, language: dto.language,
fields: form?.fields ?? [],
}); });
const created = await this.products.create( const created = await this.products.create(
@@ -89,26 +87,8 @@ export class ProductAiService {
actor, actor,
); );
if (form && Object.keys(draft.technicalValues).length > 0) {
const values = Object.entries(draft.technicalValues)
.filter(([, value]) => {
if (Array.isArray(value)) return value.length > 0;
return value !== '';
})
.map(([fieldKey, value]) => ({ fieldKey, value }));
if (values.length > 0) {
await this.technicalInfo.replaceForProduct(
businessIdRaw,
created.product.id,
{ values },
actor,
);
}
}
return { return {
message: 'Product created with AI. Add images and variations next.', message: 'Product created with AI. Add images, variations, and technical info next.',
product: created.product, product: created.product,
}; };
} }
@@ -251,25 +231,198 @@ Aim for 2–4 short paragraphs and optionally a bullet list.`;
return { field: ProductAiFillField.description, html }; return { field: ProductAiFillField.description, html };
} }
private async generateDraft(input: { /**
categoryName: string; * Suggest technical form values for an existing product. Does not persist —
productName: string; * the dashboard applies the map and the user saves.
language: ProductAiLanguage; */
fields: TechnicalFormField[]; async fillTechnicalInfo(
}): Promise<AiProductDraft> { businessIdRaw: string,
const provider = resolveAiProvider(this.config); productIdRaw: string,
dto: FillProductTechnicalInfoByAiDto,
actor: AuthUser,
) {
const businessId = BigInt(businessIdRaw);
const productId = BigInt(productIdRaw);
const prompt = dto.prompt.trim();
const technicalSchema = input.fields.map((field) => ({ const info = await this.technicalInfo.getForProduct(
businessIdRaw,
productIdRaw,
actor,
);
if (!info.form || info.form.fields.length === 0) {
throw new BadRequestException(
'No technical form is defined for this product category',
);
}
const product = await this.prisma.product.findFirst({
where: { id: productId, businessId },
select: { title: true, content: true },
});
if (!product) {
throw new BadRequestException('Product not found');
}
const productContent =
product.content && typeof product.content === 'object' && !Array.isArray(product.content)
? (product.content as Record<string, unknown>)
: {};
const nameFa = String(productContent.nameFa ?? '').trim();
const fields: TechnicalFormField[] = info.form.fields.map((field) => ({
key: field.key,
label: field.label,
type: field.type,
isRequired: field.isRequired,
options: field.options.map((option) => ({
value: option.value,
label: option.label,
})),
}));
const technicalSchema = fields.map((field) => ({
key: field.key, key: field.key,
label: field.label, label: field.label,
type: field.type, type: field.type,
required: field.isRequired, required: field.isRequired,
options: options:
field.type === 'select' || field.type === 'multi_select' field.type === 'select' || field.type === 'multi_select'
? field.options.map((option) => option.value) ? field.options.map((option) => ({
value: option.value,
label: option.label,
}))
: undefined, : undefined,
})); }));
const provider = resolveAiProvider(this.config);
const referencePages = await loadReferencePagesFromText(prompt);
const usableReferences = referencePages.filter((page) => page.text);
const systemPrompt = `You fill e-commerce product technical specification forms for Iranian stores.
Return ONLY valid JSON with this shape:
{ "technicalValues": { "fieldKey": "value" } }
Rules:
- Use exact field keys from the provided technicalFields list.
- For select fields use one allowed option "value" exactly (not the label).
- For multi_select fields use an array of allowed option values.
- For multi_value fields use an array of free-text strings.
- If a multi_value field is labeled «سایر ویژگی‌ها» (or similar “other features”), fill it with any remaining real product attributes/features that do not fit the other dedicated fields — one short string per leftover item (e.g. extra capabilities, included accessories, standards, certifications, special notes from the product page). Do not duplicate values already placed in other fields.
- For text/textarea fields use plain strings in the same language as the field labels / user prompt (usually Persian).
- When referencePages are provided, treat them as the source of truth. Copy specs from that page text (capacity, power, dimensions, weight, materials, programs, etc.).
- Convert units carefully (e.g. mm → cm for dimension fields labeled in cm: 279x300x464 mm → 27.9x30x46.4 cm).
- Prefer the product-specific specs near the main product title; ignore unrelated related-products / upsell tables if they conflict.
- Do NOT invent or guess specs from memory when a reference page was provided.
- Leave a field out if the reference does not contain that value.
- Follow the user's research brief / instructions when provided.`;
const userPrompt = JSON.stringify({
instructions: prompt,
product: {
title: product.title,
nameFa: nameFa || null,
},
technicalFields: technicalSchema,
referencePages: usableReferences.map((page) => ({
url: page.url,
text: page.text,
})),
referenceFetchErrors: referencePages
.filter((page) => !page.text)
.map((page) => ({ url: page.url, error: page.error ?? 'unknown' })),
});
if (referencePages.length > 0 && usableReferences.length === 0) {
throw new BadRequestException(
'Could not load the product page URL from the prompt. Check the link and try again.',
);
}
let content: string;
try {
content = await requestAiJsonCompletion(provider, systemPrompt, userPrompt, 0.2);
} catch (err) {
const message = err instanceof Error ? err.message : 'AI request failed';
throw new BadRequestException(message);
}
let parsed: { technicalValues?: Record<string, unknown> };
try {
parsed = JSON.parse(content) as { technicalValues?: Record<string, unknown> };
} catch {
throw new BadRequestException('AI returned invalid JSON');
}
const technicalValues = this.normalizeTechnicalValues(
parsed.technicalValues ?? {},
fields,
);
if (Object.keys(technicalValues).length === 0) {
throw new BadRequestException('AI returned no usable technical values');
}
return { values: technicalValues };
}
private normalizeTechnicalValues(
rawTechnical: Record<string, unknown>,
fields: TechnicalFormField[],
): Record<string, string | string[]> {
const technicalValues: Record<string, string | string[]> = {};
for (const field of fields) {
const raw = rawTechnical[field.key];
if (raw === undefined || raw === null || raw === '') continue;
if (field.type === 'multi_select') {
const values = Array.isArray(raw) ? raw : [String(raw)];
const allowed = new Set(field.options.map((option) => option.value));
const filtered = values
.map((value) => String(value).trim())
.filter((value) => allowed.has(value));
if (filtered.length > 0) {
technicalValues[field.key] = filtered;
}
continue;
}
if (field.type === 'multi_value') {
const values = Array.isArray(raw) ? raw : [String(raw)];
const filtered = values.map((value) => String(value).trim()).filter(Boolean);
if (filtered.length > 0) {
technicalValues[field.key] = filtered;
}
continue;
}
if (field.type === 'select') {
const value = String(raw).trim();
const allowed = field.options.some((option) => option.value === value);
if (allowed) {
technicalValues[field.key] = value;
}
continue;
}
technicalValues[field.key] = String(raw).trim();
}
return technicalValues;
}
private async generateDraft(input: {
categoryName: string;
productName: string;
language: ProductAiLanguage;
}): Promise<AiProductDraft> {
const provider = resolveAiProvider(this.config);
const referencePages = await loadReferencePagesFromText(input.productName);
const usableReferences = referencePages.filter((page) => page.text);
const inputLanguage = const inputLanguage =
input.language === ProductAiLanguage.fa ? 'Persian (Farsi)' : 'English'; input.language === ProductAiLanguage.fa ? 'Persian (Farsi)' : 'English';
@@ -280,30 +433,38 @@ Return ONLY valid JSON with this shape:
"nameFa": "Persian product name", "nameFa": "Persian product name",
"summary": "short listing summary", "summary": "short listing summary",
"descriptionHtml": "HTML description using <p> and <ul><li> only", "descriptionHtml": "HTML description using <p> and <ul><li> only",
"tags": ["tag1", "tag2"], "tags": ["tag1", "tag2"]
"technicalValues": { "fieldKey": "value" }
} }
Rules: Rules:
- Be factual and realistic for the named product in the given category. - The user input is a research brief in ${inputLanguage}. It may include a model/product name, a product page URL, and extra notes — not a final storefront title.
- If technical form fields are provided, fill technicalValues using exact field keys. - Infer the real product from the brief (and any URL/model clues). Do not copy URLs, markdown, or raw notes into title/nameFa.
- For select fields use one allowed option value exactly. - Write clean storefront titles: "title" in English, "nameFa" in Persian. Keep brand/model accurate.
- For multi_select fields use an array of allowed option values. - When referencePages are provided, treat them as the source of truth for capacity, power, dimensions, and description facts.
- For text/textarea fields use plain strings. - Prefer the main product specs near the product title; ignore conflicting related-product / upsell blocks.
- The user input name is in ${inputLanguage}; keep that spelling in the matching field and translate/generate the other language field. - Do NOT invent specs from memory when a reference page was provided.
- Be factual and realistic for the identified product in the given category.
- Do NOT return technical form fields — product content only (title, names, summary, description, tags).
- Prefer the input language for summary/description tone, but always return both title and nameFa.
- descriptionHtml should be 2-4 paragraphs with key specs. - descriptionHtml should be 2-4 paragraphs with key specs.
- tags: 3-8 relevant lowercase tags.`; - tags: 3-8 relevant lowercase tags.`;
const userPrompt = JSON.stringify({ const userPrompt = JSON.stringify({
category: input.categoryName, category: input.categoryName,
inputName: input.productName, researchBrief: input.productName,
inputLanguage: input.language, inputLanguage: input.language,
technicalFields: technicalSchema, referencePages: usableReferences.map((page) => ({
url: page.url,
text: page.text,
})),
referenceFetchErrors: referencePages
.filter((page) => !page.text)
.map((page) => ({ url: page.url, error: page.error ?? 'unknown' })),
}); });
let content: string; let content: string;
try { try {
content = await requestAiJsonCompletion(provider, systemPrompt, userPrompt); content = await requestAiJsonCompletion(provider, systemPrompt, userPrompt, 0.3);
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : 'AI request failed'; const message = err instanceof Error ? err.message : 'AI request failed';
throw new BadRequestException(message); throw new BadRequestException(message);
@@ -324,18 +485,11 @@ Rules:
input: { input: {
productName: string; productName: string;
language: ProductAiLanguage; language: ProductAiLanguage;
fields: TechnicalFormField[];
}, },
): AiProductDraft { ): AiProductDraft {
const title = const fallbackName = this.shortProductLabel(input.productName);
input.language === ProductAiLanguage.en const title = String(draft.title ?? '').trim() || fallbackName;
? input.productName const nameFa = String(draft.nameFa ?? '').trim() || fallbackName;
: String(draft.title ?? input.productName).trim();
const nameFa =
input.language === ProductAiLanguage.fa
? input.productName
: String(draft.nameFa ?? '').trim();
const summary = String(draft.summary ?? '').trim(); const summary = String(draft.summary ?? '').trim();
const descriptionHtml = normalizeAiDescriptionHtml( const descriptionHtml = normalizeAiDescriptionHtml(
@@ -353,37 +507,6 @@ Rules:
throw new BadRequestException('AI draft is missing a Persian product name'); throw new BadRequestException('AI draft is missing a Persian product name');
} }
const technicalValues: Record<string, string | string[]> = {};
const rawTechnical = draft.technicalValues ?? {};
for (const field of input.fields) {
const raw = rawTechnical[field.key];
if (raw === undefined || raw === null || raw === '') continue;
if (field.type === 'multi_select') {
const values = Array.isArray(raw) ? raw : [String(raw)];
const allowed = new Set(field.options.map((option) => option.value));
const filtered = values
.map((value) => String(value).trim())
.filter((value) => allowed.has(value));
if (filtered.length > 0) {
technicalValues[field.key] = filtered;
}
continue;
}
if (field.type === 'select') {
const value = String(raw).trim();
const allowed = field.options.some((option) => option.value === value);
if (allowed) {
technicalValues[field.key] = value;
}
continue;
}
technicalValues[field.key] = String(raw).trim();
}
return { return {
title, title,
nameFa, nameFa,
@@ -392,7 +515,16 @@ Rules:
descriptionHtml || descriptionHtml ||
`<p>${summary || `${title} product description.`}</p>`, `<p>${summary || `${title} product description.`}</p>`,
tags, tags,
technicalValues,
}; };
} }
/** First non-URL line of the research brief, truncated for fallback titles. */
private shortProductLabel(brief: string): string {
const line =
brief
.split(/\r?\n/)
.map((part) => part.trim())
.find((part) => part && !/^https?:\/\//i.test(part)) ?? brief.trim();
return line.slice(0, 120).trim();
}
} }
+52 -1
View File
@@ -10,6 +10,10 @@ import { PermissionsService } from '../auth/permissions.service';
import { CategoryTechnicalFormService } from '../categories/category-technical-form.service'; import { CategoryTechnicalFormService } from '../categories/category-technical-form.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { ReplaceProductTechnicalInfoDto } from './dto/product-technical-info.dto'; import { ReplaceProductTechnicalInfoDto } from './dto/product-technical-info.dto';
import {
decodeMultiValueText,
encodeMultiValueText,
} from '../common/technical-multi-value.util';
type FormField = { type FormField = {
id: string; id: string;
@@ -159,6 +163,22 @@ export class ProductTechnicalInfoService {
continue; continue;
} }
if (field.type === 'multi_value') {
const items = Array.isArray(entry.value)
? entry.value.map((item) => String(item).trim()).filter(Boolean)
: [];
if (!items.length) continue;
await tx.productTechnicalFieldValue.create({
data: {
businessId,
productId: product.id,
fieldId,
textValue: encodeMultiValueText(items),
},
});
continue;
}
if (field.type === 'text' || field.type === 'textarea') { if (field.type === 'text' || field.type === 'textarea') {
await tx.productTechnicalFieldValue.create({ await tx.productTechnicalFieldValue.create({
data: { data: {
@@ -185,6 +205,9 @@ export class ProductTechnicalInfoService {
} }
const selectedValues = entry.value as string[]; const selectedValues = entry.value as string[];
if (!Array.isArray(selectedValues) || !selectedValues.length) {
continue;
}
const fieldValue = await tx.productTechnicalFieldValue.create({ const fieldValue = await tx.productTechnicalFieldValue.create({
data: { data: {
businessId, businessId,
@@ -230,6 +253,24 @@ export class ProductTechnicalInfoService {
return; return;
} }
if (field.type === 'multi_value') {
if (!Array.isArray(value)) {
throw new BadRequestException(
`Field "${field.label}" expects an array of text values`,
);
}
const items = value.map((item) => String(item).trim()).filter(Boolean);
if (!items.length) {
if (field.isRequired) {
throw new BadRequestException(
`Required field "${field.label}" cannot be empty`,
);
}
return;
}
return;
}
if (field.type === 'select') { if (field.type === 'select') {
if (typeof value !== 'string') { if (typeof value !== 'string') {
throw new BadRequestException( throw new BadRequestException(
@@ -303,7 +344,17 @@ export class ProductTechnicalInfoService {
fieldKey: field.key, fieldKey: field.key,
fieldLabel: field.label, fieldLabel: field.label,
type: field.type, type: field.type,
value: field.type === 'multi_select' ? [] : null, value:
field.type === 'multi_select' || field.type === 'multi_value' ? [] : null,
};
}
if (field.type === 'multi_value') {
return {
fieldKey: field.key,
fieldLabel: field.label,
type: field.type,
value: decodeMultiValueText(record.textValue),
}; };
} }
+16 -1
View File
@@ -17,7 +17,11 @@ import { RequireBusinessPermission } from '../auth/decorators/require-business-p
import { BusinessPermissionGuard } from '../auth/guards/business-permission.guard'; import { BusinessPermissionGuard } from '../auth/guards/business-permission.guard';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { WebsiteAnalyticsService } from '../website-analytics/website-analytics.service'; import { WebsiteAnalyticsService } from '../website-analytics/website-analytics.service';
import { CreateProductByAiDto, FillProductFieldByAiDto } from './dto/product-ai.dto'; import {
CreateProductByAiDto,
FillProductFieldByAiDto,
FillProductTechnicalInfoByAiDto,
} from './dto/product-ai.dto';
import { TranslationEntityType } from '@prisma/client'; import { TranslationEntityType } from '@prisma/client';
import { UpsertTranslationDto, TranslateAllDto } from '../translations/dto/translation.dto'; import { UpsertTranslationDto, TranslateAllDto } from '../translations/dto/translation.dto';
import { TranslationsService } from '../translations/translations.service'; import { TranslationsService } from '../translations/translations.service';
@@ -208,6 +212,17 @@ export class ProductsController {
return this.technicalInfoService.getForProduct(businessId, productId, user); return this.technicalInfoService.getForProduct(businessId, productId, user);
} }
@Post(':productId/technical-info/ai-fill')
@RequireBusinessPermission('products.update')
fillTechnicalInfoByAi(
@Param('businessId') businessId: string,
@Param('productId') productId: string,
@Body() dto: FillProductTechnicalInfoByAiDto,
@CurrentUser() user: AuthUser,
) {
return this.aiService.fillTechnicalInfo(businessId, productId, dto, user);
}
@Put(':productId/technical-info') @Put(':productId/technical-info')
@RequireBusinessPermission('products.update') @RequireBusinessPermission('products.update')
replaceTechnicalInfo( replaceTechnicalInfo(
+57 -20
View File
@@ -43,12 +43,14 @@ function slugify(value: string): string {
type ProductWithRelations = Prisma.ProductGetPayload<{ type ProductWithRelations = Prisma.ProductGetPayload<{
include: { include: {
featuredMedia: true; featuredMedia: true;
catalogMedia: true;
brand: { include: { imageMedia: true } }; brand: { include: { imageMedia: true } };
}; };
}>; }>;
const productListInclude = { const productListInclude = {
featuredMedia: true, featuredMedia: true,
catalogMedia: true,
brand: { include: { imageMedia: true } }, brand: { include: { imageMedia: true } },
} satisfies Prisma.ProductInclude; } satisfies Prisma.ProductInclude;
@@ -105,10 +107,7 @@ export class ProductsService {
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'desc' }], orderBy: [{ sortOrder: 'asc' }, { createdAt: 'desc' }],
skip, skip,
take: pageSize, take: pageSize,
include: { include: productListInclude,
featuredMedia: true,
brand: { include: { imageMedia: true } },
},
}), }),
this.prisma.product.count({ where }), this.prisma.product.count({ where }),
]); ]);
@@ -140,10 +139,7 @@ export class ProductsService {
const product = await this.prisma.product.findFirst({ const product = await this.prisma.product.findFirst({
where: { id: productId, businessId }, where: { id: productId, businessId },
include: { include: productListInclude,
featuredMedia: true,
brand: { include: { imageMedia: true } },
},
}); });
if (!product) { if (!product) {
@@ -168,10 +164,7 @@ export class ProductsService {
orderBy: [{ sortOrder: 'asc' }, { publishedAt: 'desc' }, { createdAt: 'desc' }], orderBy: [{ sortOrder: 'asc' }, { publishedAt: 'desc' }, { createdAt: 'desc' }],
skip, skip,
take: pageSize, take: pageSize,
include: { include: productListInclude,
featuredMedia: true,
brand: { include: { imageMedia: true } },
},
}), }),
this.prisma.product.count({ where }), this.prisma.product.count({ where }),
]); ]);
@@ -338,6 +331,13 @@ export class ProductsService {
await this.assertMediaBelongsToBusiness(businessId, featuredMediaId); await this.assertMediaBelongsToBusiness(businessId, featuredMediaId);
} }
const catalogMediaId = dto.catalogMediaId
? BigInt(dto.catalogMediaId)
: null;
if (catalogMediaId) {
await this.assertCatalogPdfBelongsToBusiness(businessId, catalogMediaId);
}
const galleryMediaIds = await this.resolveGalleryMediaIds( const galleryMediaIds = await this.resolveGalleryMediaIds(
businessId, businessId,
dto.galleryMediaIds ?? [], dto.galleryMediaIds ?? [],
@@ -363,6 +363,7 @@ export class ProductsService {
content: this.buildContent(dto.nameFa, dto.descriptionHtml), content: this.buildContent(dto.nameFa, dto.descriptionHtml),
status, status,
featuredMediaId, featuredMediaId,
catalogMediaId,
brandId, brandId,
publishedAt: status === ContentStatus.published ? new Date() : null, publishedAt: status === ContentStatus.published ? new Date() : null,
metadata: applySeoMetaToMetadata( metadata: applySeoMetaToMetadata(
@@ -370,10 +371,7 @@ export class ProductsService {
normalizeSeoMetaInput(dto.seoMetaTitle, dto.seoMetaDescription), normalizeSeoMetaInput(dto.seoMetaTitle, dto.seoMetaDescription),
) as Prisma.InputJsonValue, ) as Prisma.InputJsonValue,
}, },
include: { include: productListInclude,
featuredMedia: true,
brand: { include: { imageMedia: true } },
},
}); });
if (dto.categoryId) { if (dto.categoryId) {
@@ -440,6 +438,16 @@ export class ProductsService {
} }
} }
let catalogMediaId: bigint | null | undefined = undefined;
if (dto.catalogMediaId !== undefined) {
if (dto.catalogMediaId === null || dto.catalogMediaId === '') {
catalogMediaId = null;
} else {
catalogMediaId = BigInt(dto.catalogMediaId);
await this.assertCatalogPdfBelongsToBusiness(businessId, catalogMediaId);
}
}
const existingContent = this.asRecord(existing.content); const existingContent = this.asRecord(existing.content);
const existingMetadata = this.asRecord(existing.metadata); const existingMetadata = this.asRecord(existing.metadata);
@@ -496,16 +504,14 @@ export class ProductsService {
: {}), : {}),
...(dto.status !== undefined ? { status: dto.status } : {}), ...(dto.status !== undefined ? { status: dto.status } : {}),
...(featuredMediaId !== undefined ? { featuredMediaId } : {}), ...(featuredMediaId !== undefined ? { featuredMediaId } : {}),
...(catalogMediaId !== undefined ? { catalogMediaId } : {}),
...(brandId !== undefined ? { brandId } : {}), ...(brandId !== undefined ? { brandId } : {}),
...(publishedAt !== undefined ? { publishedAt } : {}), ...(publishedAt !== undefined ? { publishedAt } : {}),
slug, slug,
content: nextContent as Prisma.InputJsonValue, content: nextContent as Prisma.InputJsonValue,
metadata: nextMetadata as Prisma.InputJsonValue, metadata: nextMetadata as Prisma.InputJsonValue,
}, },
include: { include: productListInclude,
featuredMedia: true,
brand: { include: { imageMedia: true } },
},
}); });
if (dto.categoryId !== undefined) { if (dto.categoryId !== undefined) {
@@ -563,6 +569,13 @@ export class ProductsService {
) { ) {
dropped.push(existing.featuredMediaId); dropped.push(existing.featuredMediaId);
} }
if (
catalogMediaId !== undefined &&
existing.catalogMediaId != null &&
existing.catalogMediaId !== catalogMediaId
) {
dropped.push(existing.catalogMediaId);
}
await this.mediaService.deleteUnused(businessId, dropped); await this.mediaService.deleteUnused(businessId, dropped);
return { return {
@@ -590,6 +603,9 @@ export class ProductsService {
productId, productId,
existing.featuredMediaId, existing.featuredMediaId,
); );
if (existing.catalogMediaId != null) {
mediaIds.push(existing.catalogMediaId);
}
await this.prisma.$transaction([ await this.prisma.$transaction([
this.prisma.mediaAttachment.deleteMany({ this.prisma.mediaAttachment.deleteMany({
@@ -718,6 +734,9 @@ export class ProductsService {
...readSeoMetaFromMetadata(metadata), ...readSeoMetaFromMetadata(metadata),
thumbnailUrl, thumbnailUrl,
thumbnailMediaId: product.featuredMediaId?.toString() ?? null, thumbnailMediaId: product.featuredMediaId?.toString() ?? null,
catalogMediaId: product.catalogMediaId?.toString() ?? null,
catalogUrl: product.catalogMedia?.publicUrl ?? null,
catalogFileName: product.catalogMedia?.originalFileName ?? null,
image, image,
thumbnail: thumbnailUrl ?? image, thumbnail: thumbnailUrl ?? image,
images: galleryAttachments.map((item) => ({ images: galleryAttachments.map((item) => ({
@@ -827,6 +846,24 @@ export class ProductsService {
} }
} }
private async assertCatalogPdfBelongsToBusiness(
businessId: bigint,
mediaId: bigint,
) {
const media = await this.prisma.media.findFirst({
where: { id: mediaId, businessId },
});
if (!media) {
throw new BadRequestException('Catalog file not found for this business');
}
if (media.mimeType !== 'application/pdf') {
throw new BadRequestException('Catalog file must be a PDF');
}
if (media.fileSizeBytes > BigInt(2 * 1024 * 1024)) {
throw new BadRequestException('Catalog PDF must be 2MB or smaller');
}
}
private async assertCategoryBelongsToBusiness( private async assertCategoryBelongsToBusiness(
businessId: bigint, businessId: bigint,
categoryId: bigint, categoryId: bigint,
+130
View File
@@ -70,6 +70,136 @@ export class RedisService {
} }
} }
/**
* Signup in progress: name + tenant stored until OTP proves phone ownership.
*/
async setPendingSignup(
cellNumber: string,
payload: {
firstName: string;
lastName: string;
businessId: string;
},
ttlSeconds: number,
): Promise<void> {
await this.redis.set(
`auth:pending-signup:${cellNumber}`,
JSON.stringify(payload),
'EX',
ttlSeconds,
);
}
async getPendingSignup(cellNumber: string): Promise<{
firstName: string;
lastName: string;
businessId: string;
} | null> {
const raw = await this.redis.get(`auth:pending-signup:${cellNumber}`);
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as {
firstName?: unknown;
lastName?: unknown;
businessId?: unknown;
};
if (
typeof parsed.firstName !== 'string' ||
typeof parsed.lastName !== 'string' ||
typeof parsed.businessId !== 'string' ||
!parsed.firstName ||
!parsed.lastName ||
!parsed.businessId
) {
return null;
}
return {
firstName: parsed.firstName,
lastName: parsed.lastName,
businessId: parsed.businessId,
};
} catch {
return null;
}
}
async deletePendingSignup(cellNumber: string): Promise<void> {
await this.redis.del(`auth:pending-signup:${cellNumber}`);
}
/**
* One-time token after signup OTP: complete account create/link without re-OTP.
*/
async setSignupVerifiedToken(
token: string,
payload: {
cellNumber: string;
firstName: string;
lastName: string;
businessId: string;
existingUserId: string | null;
},
ttlSeconds: number,
): Promise<void> {
await this.redis.set(
`auth:signup-verified:${token}`,
JSON.stringify(payload),
'EX',
ttlSeconds,
);
}
async consumeSignupVerifiedToken(token: string): Promise<{
cellNumber: string;
firstName: string;
lastName: string;
businessId: string;
existingUserId: string | null;
} | null> {
const key = `auth:signup-verified:${token}`;
const result = await this.redis.multi().get(key).del(key).exec();
const raw = result?.[0]?.[1];
if (typeof raw !== 'string' || !raw) {
return null;
}
try {
const parsed = JSON.parse(raw) as {
cellNumber?: unknown;
firstName?: unknown;
lastName?: unknown;
businessId?: unknown;
existingUserId?: unknown;
};
if (
typeof parsed.cellNumber !== 'string' ||
typeof parsed.firstName !== 'string' ||
typeof parsed.lastName !== 'string' ||
typeof parsed.businessId !== 'string' ||
!parsed.cellNumber ||
!parsed.firstName ||
!parsed.lastName ||
!parsed.businessId
) {
return null;
}
const existingUserId =
parsed.existingUserId === null || parsed.existingUserId === undefined
? null
: typeof parsed.existingUserId === 'string'
? parsed.existingUserId
: null;
return {
cellNumber: parsed.cellNumber,
firstName: parsed.firstName,
lastName: parsed.lastName,
businessId: parsed.businessId,
existingUserId,
};
} catch {
return null;
}
}
async setHandoffTicket( async setHandoffTicket(
ticket: string, ticket: string,
userId: string, userId: string,
+88
View File
@@ -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[];
}
+79
View File
@@ -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);
}
}
+11
View File
@@ -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 {}
+364
View File
@@ -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`,
);
}
}
}
+171
View File
@@ -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;
}
+20
View File
@@ -57,6 +57,11 @@ export class BatchCreateStoreItemsDto {
@MinLength(1) @MinLength(1)
productId!: string; productId!: string;
@IsOptional()
@Transform(({ value }) => value === 'true' || value === true)
@IsBoolean()
isUsed?: boolean;
@IsArray() @IsArray()
@ValidateNested({ each: true }) @ValidateNested({ each: true })
@Type(() => CreateStoreItemVariantDto) @Type(() => CreateStoreItemVariantDto)
@@ -79,6 +84,11 @@ export class ListStoreItemsDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
name?: string; name?: string;
@IsOptional()
@Transform(({ value }) => value === 'true' || value === true)
@IsBoolean()
isUsed?: boolean;
} }
export class ListPublicStoreItemsDto { export class ListPublicStoreItemsDto {
@@ -120,6 +130,11 @@ export class ListPublicStoreItemsDto {
@IsBoolean() @IsBoolean()
isFestival?: boolean; isFestival?: boolean;
@IsOptional()
@Transform(({ value }) => value === 'true' || value === true)
@IsBoolean()
isUsed?: boolean;
@IsOptional() @IsOptional()
@Type(() => Number) @Type(() => Number)
@IsNumber() @IsNumber()
@@ -209,6 +224,11 @@ export class SyncProductStoreItemVariantsDto {
@MinLength(1) @MinLength(1)
productId!: string; productId!: string;
@IsOptional()
@Transform(({ value }) => value === 'true' || value === true)
@IsBoolean()
isUsed?: boolean;
@IsArray() @IsArray()
@ValidateNested({ each: true }) @ValidateNested({ each: true })
@Type(() => SyncStoreItemVariantRowDto) @Type(() => SyncStoreItemVariantRowDto)
+206
View File
@@ -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);
}
}
+720
View File
@@ -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');
}
}
}
+41 -3
View File
@@ -71,7 +71,28 @@ export class StoreItemsService {
const name = query.name?.trim(); const name = query.name?.trim();
const where: Prisma.StoreItemVariantWhereInput = { const where: Prisma.StoreItemVariantWhereInput = {
businessId, businessId,
...(query.isUsed
? {
storeItem: {
isUsed: true,
...(name ...(name
? {
product: {
OR: [
{ title: { contains: name, mode: 'insensitive' } },
{
content: {
path: ['nameFa'],
string_contains: name,
},
},
],
},
}
: {}),
},
}
: name
? { ? {
storeItem: { storeItem: {
product: { product: {
@@ -467,7 +488,9 @@ export class StoreItemsService {
} }
const created = await this.prisma.$transaction(async (tx) => { const created = await this.prisma.$transaction(async (tx) => {
const storeItem = await this.ensureStoreItem(businessId, productId, tx); const storeItem = await this.ensureStoreItem(businessId, productId, tx, {
isUsed: dto.isUsed,
});
const baseSortOrder = await tx.storeItemVariant.count({ const baseSortOrder = await tx.storeItemVariant.count({
where: { storeItemId: storeItem.id }, where: { storeItemId: storeItem.id },
}); });
@@ -719,7 +742,9 @@ export class StoreItemsService {
} }
await this.prisma.$transaction(async (tx) => { await this.prisma.$transaction(async (tx) => {
const storeItem = await this.ensureStoreItem(businessId, productId, tx); const storeItem = await this.ensureStoreItem(businessId, productId, tx, {
isUsed: dto.isUsed,
});
const baseSortOrder = await tx.storeItemVariant.count({ const baseSortOrder = await tx.storeItemVariant.count({
where: { storeItemId: storeItem.id }, where: { storeItemId: storeItem.id },
}); });
@@ -947,17 +972,28 @@ export class StoreItemsService {
businessId: bigint, businessId: bigint,
productId: bigint, productId: bigint,
tx: Prisma.TransactionClient = this.prisma, tx: Prisma.TransactionClient = this.prisma,
options?: { isUsed?: boolean },
) { ) {
const existing = await tx.storeItem.findUnique({ const existing = await tx.storeItem.findUnique({
where: { businessId_productId: { businessId, productId } }, where: { businessId_productId: { businessId, productId } },
}); });
if (existing) { if (existing) {
if (options?.isUsed !== undefined && existing.isUsed !== options.isUsed) {
return tx.storeItem.update({
where: { id: existing.id },
data: { isUsed: options.isUsed },
});
}
return existing; return existing;
} }
return tx.storeItem.create({ return tx.storeItem.create({
data: { businessId, productId }, data: {
businessId,
productId,
isUsed: options?.isUsed ?? false,
},
}); });
} }
@@ -1126,6 +1162,7 @@ export class StoreItemsService {
stockQuantity: variant.stockQuantity, stockQuantity: variant.stockQuantity,
rewardPoints: variant.rewardPoints, rewardPoints: variant.rewardPoints,
isFestival: variant.isFestival, isFestival: variant.isFestival,
isUsed: variant.storeItem.isUsed,
sortOrder: variant.sortOrder, sortOrder: variant.sortOrder,
createdAt: variant.createdAt, createdAt: variant.createdAt,
updatedAt: variant.updatedAt, updatedAt: variant.updatedAt,
@@ -1257,6 +1294,7 @@ export class StoreItemsService {
isActive: true, isActive: true,
storeItem: { storeItem: {
isActive: true, isActive: true,
...(query.isUsed ? { isUsed: true } : {}),
product: { product: {
status: ContentStatus.published, status: ContentStatus.published,
...(query.brandId ? { brandId: BigInt(query.brandId) } : {}), ...(query.brandId ? { brandId: BigInt(query.brandId) } : {}),
+103
View File
@@ -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);
}
}
+571
View File
@@ -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`,
);
}
}
}
+1
View File
@@ -473,6 +473,7 @@ export class StoreSpecialsService {
productNameFa: (content.nameFa as string | null | undefined) ?? '', productNameFa: (content.nameFa as string | null | undefined) ?? '',
productImage, productImage,
isActive: storeItem.isActive, isActive: storeItem.isActive,
isUsed: storeItem.isUsed,
sortOrder: storeItem.sortOrder, sortOrder: storeItem.sortOrder,
variants: storeItem.variants.map((variant) => { variants: storeItem.variants.map((variant) => {
const selections = variant.selections.map((selection) => ({ const selections = variant.selections.map((selection) => ({
+27 -2
View File
@@ -10,6 +10,16 @@ import { StoreSpecialsAiService } from './store-specials-ai.service';
import { StoreSpecialsService } from './store-specials.service'; import { StoreSpecialsService } from './store-specials.service';
import { StoreItemsController, PublicStoreItemsController } from './store-items.controller'; import { StoreItemsController, PublicStoreItemsController } from './store-items.controller';
import { StoreItemsService } from './store-items.service'; import { StoreItemsService } from './store-items.service';
import {
PublicStoreLoanMethodsController,
StoreLoanMethodsController,
} from './store-loan-methods.controller';
import { StoreLoanMethodsService } from './store-loan-methods.service';
import {
PublicShippingCostGroupsController,
ShippingCostGroupsController,
} from './shipping-cost-groups.controller';
import { ShippingCostGroupsService } from './shipping-cost-groups.service';
@Module({ @Module({
imports: [AuthModule, TenantModule, WebsiteAnalyticsModule], imports: [AuthModule, TenantModule, WebsiteAnalyticsModule],
@@ -18,8 +28,23 @@ import { StoreItemsService } from './store-items.service';
PublicStoreItemsController, PublicStoreItemsController,
PublicStoreSpecialsController, PublicStoreSpecialsController,
StoreSpecialsController, StoreSpecialsController,
PublicStoreLoanMethodsController,
StoreLoanMethodsController,
PublicShippingCostGroupsController,
ShippingCostGroupsController,
],
providers: [
StoreItemsService,
StoreSpecialsService,
StoreSpecialsAiService,
StoreLoanMethodsService,
ShippingCostGroupsService,
],
exports: [
StoreItemsService,
StoreSpecialsService,
StoreLoanMethodsService,
ShippingCostGroupsService,
], ],
providers: [StoreItemsService, StoreSpecialsService, StoreSpecialsAiService],
exports: [StoreItemsService, StoreSpecialsService],
}) })
export class StoreModule {} export class StoreModule {}
@@ -19,6 +19,10 @@ import {
normalizeSeoMetaInput, normalizeSeoMetaInput,
readSeoMetaFromMetadata, readSeoMetaFromMetadata,
} from '../common/seo-meta.util'; } from '../common/seo-meta.util';
import {
decodeMultiValueText,
encodeMultiValueText,
} from '../common/technical-multi-value.util';
import { MediaService } from '../media/media.service'; import { MediaService } from '../media/media.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { import {
@@ -1162,6 +1166,21 @@ export class UserProductsService {
return; return;
} }
if (field.type === 'multi_value') {
if (entry.optionId || entry.optionIds?.length) {
throw new BadRequestException(
`Field "${field.label}" expects a text list value`,
);
}
const items = decodeMultiValueText(entry.textValue);
if (field.isRequired && !items.length) {
throw new BadRequestException(
`Required field "${field.label}" cannot be empty`,
);
}
return;
}
if (field.type === 'select') { if (field.type === 'select') {
if (entry.textValue || entry.optionIds?.length) { if (entry.textValue || entry.optionIds?.length) {
throw new BadRequestException( throw new BadRequestException(
@@ -1206,6 +1225,9 @@ export class UserProductsService {
if (field.type === 'text' || field.type === 'textarea') { if (field.type === 'text' || field.type === 'textarea') {
return Boolean(entry.textValue?.trim()); return Boolean(entry.textValue?.trim());
} }
if (field.type === 'multi_value') {
return decodeMultiValueText(entry.textValue).length > 0;
}
if (field.type === 'select') { if (field.type === 'select') {
return Boolean(entry.optionId); return Boolean(entry.optionId);
} }
@@ -1255,6 +1277,20 @@ export class UserProductsService {
continue; continue;
} }
if (field.type === 'multi_value') {
const items = decodeMultiValueText(entry.textValue);
if (!items.length) continue;
await tx.userProductTechnicalFieldValue.create({
data: {
businessId,
userProductId,
fieldId,
textValue: encodeMultiValueText(items),
},
});
continue;
}
if (field.type === 'select') { if (field.type === 'select') {
await tx.userProductTechnicalFieldValue.create({ await tx.userProductTechnicalFieldValue.create({
data: { data: {
+50 -2
View File
@@ -54,8 +54,9 @@ After changing `next.config`, commit, push, and redeploy so PM2 switches to stan
2. `GET /tenants/{domain}/website/favicon` → `faviconUrl` for `<link rel="icon">` / Next.js `metadata.icons` (falls back to `logoUrl` when no dedicated favicon). Also returns `logoUrl`, `logoDarkUrl`, `hasDedicatedFavicon`. 2. `GET /tenants/{domain}/website/favicon` → `faviconUrl` for `<link rel="icon">` / Next.js `metadata.icons` (falls back to `logoUrl` when no dedicated favicon). Also returns `logoUrl`, `logoDarkUrl`, `hasDedicatedFavicon`.
3. Homepage: business-info, **static-images**, sliders, category-groups, brand-groups, store-specials (`source` repeats the tenant setting; items are store listings when `source` is `store_item`) 3. Homepage: business-info, **static-images**, sliders, category-groups, brand-groups, store-specials (`source` repeats the tenant setting; items are store listings when `source` is `store_item`)
4. Catalog: categories, products (`GET /products/{slug}` includes `relatedProducts`: same category then same brand, in-stock first), store-items (`name` instant search: in-stock first, then `updatedAt`), **user-products** (customer stock listings). Portfolios list newest first (`sortOrder` desc, then `publishedAt` / `createdAt` desc); each portfolio may include nullable `projectUrl` (external website link). List filters: products/blogs/portfolios/videos accept `?tag=` (exact match on `metadata.tags`). 4. Catalog: categories, products (`GET /products/{slug}` includes `relatedProducts`: same category then same brand, in-stock first), store-items (`name` instant search: in-stock first, then `updatedAt`), **user-products** (customer stock listings). Portfolios list newest first (`sortOrder` desc, then `publishedAt` / `createdAt` desc); each portfolio may include nullable `projectUrl` (external website link). List filters: products/blogs/portfolios/videos accept `?tag=` (exact match on `metadata.tags`).
5. Shopping cart on **this** site only: header icon + badge + mini-cart popup. Persist a **guest cart** and **Continue** to `https://customer.<WEBSITE_DOMAIN>` (see Shopping cart). Do not implement login or checkout here. 5. **Installments / credit** (only when `enabledModules` includes `store` and `store_installments`, or list returns `enabled: true`): see Installments below.
6. Bank payment callbacks stay on the store apex via nginx (`https://<WEBSITE_DOMAIN>/meshkee/payments/{gateway}/callback`) — infrastructure only. The website app does **not** implement payment or checkout pages; that UI is the customer dashboard. 6. Shopping cart on **this** site only: header icon + badge + mini-cart popup. Persist a **guest cart** and **Continue** to `https://customer.<WEBSITE_DOMAIN>` (see Shopping cart). Do not implement login or checkout here.
7. Bank payment callbacks stay on the store apex via nginx (`https://<WEBSITE_DOMAIN>/meshkee/payments/{gateway}/callback`) — infrastructure only. The website app does **not** implement payment or checkout pages; that UI is the customer dashboard.
### Favicon + logos ### Favicon + logos
- `GET /tenants/{domain}/website/favicon` → `{ faviconUrl, logoUrl, logoDarkUrl, hasDedicatedFavicon }` - `GET /tenants/{domain}/website/favicon` → `{ faviconUrl, logoUrl, logoDarkUrl, hasDedicatedFavicon }`
@@ -164,6 +165,34 @@ The dashboard reads `guestCart` (query or hash), then `localStorage`, then the s
**Wrong:** storefront `/cart` or `/checkout` pages; `POST /businesses/{id}/cart/checkout`; a shop-built login. **Wrong:** storefront `/cart` or `/checkout` pages; `POST /businesses/{id}/cart/checkout`; a shop-built login.
**Right:** local guest mini-cart → redirect to `customer.<WEBSITE_DOMAIN>`. **Right:** local guest mini-cart → redirect to `customer.<WEBSITE_DOMAIN>`.
### Installments & credit methods
Optional module (`store` + `store_installments`). Hide the UI when `GET /tenants/{domain}/store-loan-methods` returns `enabled: false` or empty `items`.
| Step | Endpoint |
|------|----------|
| 1. List methods | `GET /tenants/{domain}/store-loan-methods` → `{ enabled, items[] }` |
| 2. Pick method | **If `items.length > 1`, ask the shopper which method** (name + image). If exactly one, auto-select. Never skip this when multiple exist. |
| 3. Collect inputs | Purchase total (`totalValue` ≥ `minShoppingAmount`), credit amount (within `minAmount`–`maxAmount`, ≤ total), months from `returnMonthOptions` / slider with `returnMonthsStep` |
| 4. Calculate | `POST /tenants/{domain}/store-loan-methods/calculate` with `{ methodId, totalValue, creditAmount, months }` |
```json
POST /tenants/{domain}/store-loan-methods/calculate
{
"methodId": "1",
"totalValue": 5000000,
"creditAmount": 2000000,
"months": 12
}
```
Response `{ method, plan }` — `plan` has `cashPayment`, `interestFee`, `totalRepay`, and `installments[]` (`index`, `dueDate`, `amount`). Fee = `round(credit × (interest/100) × (months/12))`.
Optional: `GET /tenants/{domain}/store-loan-methods/{methodId}` for a single method.
**Wrong:** calculating without a chosen `methodId`, or auto-picking the first method when several are listed.
**Right:** method picker first (when count > 1) → then amounts → calculate.
### Checkout & payments (customer dashboard — not this website) ### Checkout & payments (customer dashboard — not this website)
OpenAPI **Cart** / checkout / payment-method routes are for `https://customer.<WEBSITE_DOMAIN>`, not storefront JavaScript. OpenAPI **Cart** / checkout / payment-method routes are for `https://customer.<WEBSITE_DOMAIN>`, not storefront JavaScript.
@@ -331,6 +360,24 @@ These are **required on every crawlable page** (home, listing, product/blog/port
**Open Graph (recommended)** **Open Graph (recommended)**
- Set `og:title`, `og:description`, and `og:image` on important pages (home + detail pages) from CMS media when available. - Set `og:title`, `og:description`, and `og:image` on important pages (home + detail pages) from CMS media when available.
### Site-wide meta tags + trust badges (eNamad / Samandehi)
Business admins configure this under **Website → Tags & Badges**.
1. `GET /tenants/{domain}/website/business-info`
2. For each item in `metaTags` (array of `{ html }`), emit the full HTML once in the root layout `<head>`.
```html
{html}
```
Example `html` value: `<meta name="google-site-verification" content="…">` (also supports `property`, `http-equiv`, etc.). Parse attributes into a `<meta />` element, or inject the sanitized string into `<head>`. Do **not** wrap it again as `name`/`content` — `html` is already the whole tag. The dashboard “Name” field is an admin label only and is **not** in this public payload.
3. For each item in `trustBadges` (array of `{ kind, embedHtml }`), render `embedHtml` in the site footer (e.g. `dangerouslySetInnerHTML`). These are HTML widgets only (not meta). Scripts are stripped by the API — still treat the HTML as CMS-controlled content.
- When arrays are empty, omit tags / footer badges.
- Do **not** hardcode eNamad/Samandehi HTML in the website repo when these fields are present.
### Site-wide Schema.org JSON-LD (Organization / LocalBusiness) ### Site-wide Schema.org JSON-LD (Organization / LocalBusiness)
Business admins configure this under **Website → Settings → Structured data**. The API builds a ready `jsonLd` object for you. Business admins configure this under **Website → Settings → Structured data**. The API builds a ready `jsonLd` object for you.
@@ -398,6 +445,7 @@ Website rules:
4. Public URL is included via CMS sitemap and/or `sitemap-config.json` static pages 4. Public URL is included via CMS sitemap and/or `sitemap-config.json` static pages
5. Site-wide Organization/LocalBusiness JSON-LD from `business-info.schema.jsonLd` when enabled 5. Site-wide Organization/LocalBusiness JSON-LD from `business-info.schema.jsonLd` when enabled
6. Product/blog detail pages emit `product.schema.jsonLd` / `blog.schema.jsonLd` when present 6. Product/blog detail pages emit `product.schema.jsonLd` / `blog.schema.jsonLd` when present
7. Site-wide `metaTags` + footer `trustBadges` from business-info when present
If OpenAPI and this brief conflict, **OpenAPI wins**. If OpenAPI and this brief conflict, **OpenAPI wins**.
@@ -69,6 +69,10 @@
"key": "storeItemVariantId", "key": "storeItemVariantId",
"value": "" "value": ""
}, },
{
"key": "loanMethodId",
"value": ""
},
{ {
"key": "cartItemId", "key": "cartItemId",
"value": "" "value": ""
@@ -1592,6 +1596,52 @@
"method": "GET", "method": "GET",
"url": "{{baseUrl}}/tenants/{{domain}}/store-specials" "url": "{{baseUrl}}/tenants/{{domain}}/store-specials"
} }
},
{
"name": "List store loan methods (website)",
"event": [
{
"listen": "test",
"script": {
"exec": [
"if (pm.response.code === 200) {",
" const json = pm.response.json();",
" const first = json.items && json.items[0];",
" if (first && first.id) pm.collectionVariables.set('loanMethodId', first.id);",
"}"
],
"type": "text/javascript"
}
}
],
"request": {
"method": "GET",
"url": "{{baseUrl}}/tenants/{{domain}}/store-loan-methods"
}
},
{
"name": "Get store loan method (website)",
"request": {
"method": "GET",
"url": "{{baseUrl}}/tenants/{{domain}}/store-loan-methods/{{loanMethodId}}"
}
},
{
"name": "Calculate store loan plan (website)",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"methodId\": \"{{loanMethodId}}\",\n \"totalValue\": 5000000,\n \"creditAmount\": 2000000,\n \"months\": 12\n}"
},
"url": "{{baseUrl}}/tenants/{{domain}}/store-loan-methods/calculate"
}
} }
] ]
}, },
+152
View File
@@ -44,6 +44,10 @@
{ {
"name": "Store" "name": "Store"
}, },
{
"name": "Installments",
"description": "Credit / installment methods (`store` + `store_installments`). List methods first; if more than one, ask the shopper which method before calculate. POST .../calculate always requires methodId."
},
{ {
"name": "Torob", "name": "Torob",
"description": "Product API v3 for Torob. Called by Torob (not storefront JS). Nginx on the shop apex proxies POST /torob_api/v3/products. Only tenants with the store module enabled; otherwise 404." "description": "Product API v3 for Torob. Called by Torob (not storefront JS). Nginx on the shop apex proxies POST /torob_api/v3/products. Only tenants with the store module enabled; otherwise 404."
@@ -567,6 +571,38 @@
} }
}, },
"required": ["enabled", "type", "sameAs", "jsonLd"] "required": ["enabled", "type", "sameAs", "jsonLd"]
},
"metaTags": {
"type": "array",
"description": "Custom <meta> HTML snippets for the site <head>. Each item is { html } with a full <meta …> tag. Configured under Website → Tags & Badges (name there is an admin label only).",
"items": {
"type": "object",
"properties": {
"html": {
"type": "string",
"description": "Full sanitized <meta …> HTML tag to inject in <head>."
}
},
"required": ["html"]
}
},
"trustBadges": {
"type": "array",
"description": "Enabled eNamad / Samandehi footer widgets (sanitized HTML). Empty when none are shown.",
"items": {
"type": "object",
"properties": {
"kind": {
"type": "string",
"enum": ["enamad", "samandehi"]
},
"embedHtml": {
"type": "string",
"description": "Trusted HTML snippet (scripts stripped server-side)."
}
},
"required": ["kind", "embedHtml"]
}
} }
} }
} }
@@ -835,6 +871,122 @@
} }
} }
}, },
"/tenants/{domain}/store-loan-methods": {
"get": {
"tags": [
"Store",
"Installments"
],
"summary": "Active installment / credit methods",
"description": "Requires business modules `store` + `store_installments`. When disabled, returns `{ enabled: false, items: [] }`.\n\n**Multi-method UX:** if `items.length > 1`, ask the shopper which method before collecting amounts or calling calculate. If exactly one, auto-select it. Always pass that `methodId` to `POST .../calculate`.",
"parameters": [
{
"$ref": "#/components/parameters/domain"
}
],
"responses": {
"200": {
"description": "{ enabled: boolean, items: StoreLoanMethod[] } — each item has id, nameFa, nameEn, description, imageUrl, imageAspectRatio, minShoppingAmount, minAmount, maxAmount, interest, returnMonthsMin/Max/Step, returnMonthOptions[], sortOrder"
}
}
}
},
"/tenants/{domain}/store-loan-methods/calculate": {
"post": {
"tags": [
"Store",
"Installments"
],
"summary": "Calculate installment plan for a chosen method",
"description": "`methodId` is **required**. When the site has multiple credit methods, list them first and let the shopper pick one before calling this endpoint.\n\nFee formula (flat annual interest prorated by term): `interestFee = round(creditAmount × (interest/100) × (months/12))`.",
"parameters": [
{
"$ref": "#/components/parameters/domain"
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"methodId",
"totalValue",
"creditAmount",
"months"
],
"properties": {
"methodId": {
"type": "string",
"description": "Chosen method id from GET .../store-loan-methods"
},
"totalValue": {
"type": "number",
"description": "Total purchase amount (must be ≥ method.minShoppingAmount)"
},
"creditAmount": {
"type": "number",
"description": "Credit portion (within method min/max and ≤ totalValue)"
},
"months": {
"type": "integer",
"description": "Must be in method.returnMonthOptions"
}
}
},
"example": {
"methodId": "1",
"totalValue": 5000000,
"creditAmount": 2000000,
"months": 12
}
}
}
},
"responses": {
"200": {
"description": "{ method, plan } — plan has totalValue, creditAmount, cashPayment, interestFee, totalRepay, months, installments[{ index, dueDate, amount }]"
},
"400": {
"description": "Validation failed (amount/months out of range)"
},
"404": {
"description": "Module off or method not found / inactive"
}
}
}
},
"/tenants/{domain}/store-loan-methods/{methodId}": {
"get": {
"tags": [
"Store",
"Installments"
],
"summary": "One active installment / credit method",
"parameters": [
{
"$ref": "#/components/parameters/domain"
},
{
"name": "methodId",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "{ method: StoreLoanMethod }"
},
"404": {
"description": "Module off or method not found / inactive"
}
}
}
},
"/tenants/{domain}/categories": { "/tenants/{domain}/categories": {
"get": { "get": {
"tags": [ "tags": [
@@ -6,6 +6,10 @@ import {
} from '../business-profile/business-profile.util'; } from '../business-profile/business-profile.util';
import { normalizeBusinessSettings } from '../business-settings/business-settings.util'; import { normalizeBusinessSettings } from '../business-settings/business-settings.util';
import { buildWebsiteSchemaJsonLd } from '../business-settings/website-schema.util'; import { buildWebsiteSchemaJsonLd } from '../business-settings/website-schema.util';
import {
toPublicWebsiteMetaTags,
toPublicWebsiteTrustBadges,
} from '../business-settings/website-tags-badges.util';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { TenantService } from '../tenant/tenant.service'; import { TenantService } from '../tenant/tenant.service';
@@ -90,6 +94,8 @@ export class WebsiteBusinessInfoService {
sameAs: schemaSettings.sameAs, sameAs: schemaSettings.sameAs,
jsonLd, jsonLd,
}, },
metaTags: toPublicWebsiteMetaTags(settings.website.tagsAndBadges),
trustBadges: toPublicWebsiteTrustBadges(settings.website.tagsAndBadges),
}; };
} }
} }