57 Commits
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
Alireza Hassani 19c0ea25e1 Fix Arvan mail A record value to array of IPs
Arvan CDN 4.0 rejects A creates when value is a single { ip } object
(422: value must be an array). Send [{ ip }] and compare listed A
values as arrays so ensureMailRecords no longer fails before DKIM.
2026-09-18 10:56:32 +03:30
Alireza Hassani 95d851f9dd Accept value-only DKIM TXT pastes for Email DNS
Allow v=DKIM1… pastes without a zone name by taking an optional
selector hint so Arvan/Cloudflare still get the right _domainkey record.
2026-09-18 10:38:06 +03:30
Alireza HassaniandCursor b7ca306097 Count page views once per load and skip known bots.
Stop auto-counting list APIs and homepage sliders, track static pages via pageKey, and add store-specials AI list matching.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-16 17:33:44 +03:30
Alireza HassaniandCursor 6517877bed Use id+slug paths for user products and count their visits.
Align storefront/sitemap URLs with catalog products, add by-id public APIs, and include user-product detail views in the product visit chart.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-15 15:35:46 +03:30
Alireza HassaniandCursor 3aef62c1b8 Add business gallery API and marketplace profile fields.
Expose list/add/remove gallery endpoints via media_attachments, plus profile title, banner, and category tag/nameFa support needed by the marketplace dashboard.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-11 14:41:32 +03:30
Alireza HassaniandCursor d68ef2d08a Add business profile AI fill endpoint for abstract and description.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-11 09:36:06 +03:30
Alireza HassaniandCursor aaf7a83742 Add Schema.org JSON-LD, SEO meta overrides, and resilient bulk SMS.
Expose Organization/LocalBusiness and Product/BlogPosting jsonLd, optional seoMeta fields on content/categories, and continue mass SMS after per-recipient failures.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-09 23:30:24 +03:30
Alireza HassaniandCursor db305c58a4 Allow creating customers with only first or last name.
Password stays optional; missing password gets a random hash server-side.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-09 12:32:18 +03:30
Alireza HassaniandCursor a2dacfdc97 Track SMS sent counts by type (mass, singular, auth, auto, admin).
Charge/refund bump the matching counter plus total; expose smsSentByType on business APIs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-09 11:31:17 +03:30
Alireza HassaniandCursor 717a203d48 Fix SMS billing build and make credit conversion idempotent.
Import the purchase-price constant for Nest build, and only convert IRT-scale balances so migrate re-runs do not divide credits again.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-09 11:12:27 +03:30
Alireza HassaniandCursor 584c01fcee Switch SMS billing to credit units with IRT top-ups.
Balance is SMS credits (1 per 64 chars); top-ups add floor(IRT/500) credits, track sms_sent_count, and convert existing IRT balances.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-09 11:10:22 +03:30
Alireza HassaniandCursor 07f3304c07 Fix advertising SMS shortcode to 5000990009.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-08 17:43:24 +03:30
Alireza HassaniandCursor 4a1d60dbef Add SMS templates, prepaid IRT billing, and advertising shortcode 9000590009.
Charge ceil(len/64)*400 IRT per recipient across OTP, customer, and partner SMS; seed new businesses with 500k IRT; default partner sends to the advertising line.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-08 16:24:15 +03:30
Alireza HassaniandCursor 4623061a4f Update PROJECT_CONTEXT sitemap path docs for slug-based content URLs.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-08 14:53:07 +03:30
Alireza HassaniandCursor 9a020d748d Align storefront content URLs with live slug-based routes.
Use /blog/{slug}, /portfolio/{slug}, and /instruction/{slug} in sitemap defaults and emit CMS slugs for blogs/portfolios instead of title-derived paths.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-08 14:51:12 +03:30
Alireza HassaniandCursor 4823cf97f6 Require Next.js standalone output in website AI prompt and deploy.
Document mandatory output: "standalone" for shared-VM RAM, and align websites-agent deploy to prefer .next/standalone/server.js.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-08 14:21:44 +03:30
Alireza HassaniandCursor b1dcecc468 Add customer Excel import/export and Gama bulk SMS sending.
Business SMS uses optional settings.sms.senderNumber (else advertising shortcode) with SendQuick for one recipient and SendBulk for quantity.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-08 14:12:48 +03:30
Alireza HassaniandCursor cf459807d8 Document storefront mini-cart and customer-dashboard checkout.
Website agents must add variants to a local guest cart and redirect to customer.{domain} for login and checkout instead of building those pages on the shop.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-06 08:55:11 +03:30
Alireza HassaniandCursor 5c701d0841 Use Node http for long storefront deploys instead of undici.
This API VM's Node 20 build has no node:undici, so require() crash-looped the process and took login down.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-04 15:51:07 +03:30
Alireza HassaniandCursor 53fe8b72bd Allow storefront deploys to wait up to 30 minutes.
Node fetch was aborting at undici's 5-minute headers timeout, so Super Admin marked long npm ci / next builds as failed while the websites VM was still compiling.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-04 15:22:23 +03:30
Alireza HassaniandCursor d14f91a606 Add catalog EN/AR translations with AI translate-all by locale.
Store product/category translations in entity_translations, expose CMS APIs, and gate the MultiLanguage Data module for businesses.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-04 09:51:11 +03:30
Alireza HassaniandCursor 42cf29bc4e Add Torob Product API v3 for store-module sites with a store setting to enable it.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-03 15:26:23 +03:30
Alireza HassaniandCursor 6f1b13b6dc Add Email DNS upsert for Stalwart DKIM, MX, and SPF.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 22:00:04 +03:30
Alireza HassaniandCursor 6b8073f998 Expose visit summary totals for the Statistics dashboard.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 11:10:52 +03:30
Alireza HassaniandCursor f42e6fe2a4 Allow add/edit domain to upsert tenant DNS on Cloudflare or Arvan.
Update DNS writes @, www, business, customer, and api records on the chosen provider instead of Arvan only.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-31 18:46:48 +03:30
Alireza HassaniandCursor d1f60b5313 Add parked alias domains with Arvan or Cloudflare DNS and websites SSL.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-31 18:09:09 +03:30
Alireza HassaniandCursor 5813e9fc53 Allow adding or editing a storefront git repo from the Websites domain editor.
Provision uses the same websites-VM agent as Businesses; DNS still goes through Arvan ensureTenantRecords.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-31 16:32:20 +03:30
Alireza HassaniandCursor cda901d049 Probe tenant dashboard TLS via business/customer.meshkee.com.
The API VM resolver often NXDOMAINs business.* / customer.* CNAMEs even when the certs are valid, which made the SSL button always fail after a successful issue.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-31 16:32:10 +03:30
210 changed files with 16976 additions and 685 deletions
+7 -1
View File
@@ -30,7 +30,8 @@ SMS_GAMA_BASE_URL=https://sms.igama.ir/api/v1
SMS_GAMA_USERNAME=
SMS_GAMA_PASSWORD=
SMS_GAMA_SOURCE_SERVICE=5000110005
# Optional later: SMS_GAMA_SOURCE_ADVERTISE=500099000005
# Optional: advertising / unverified-template shortcode (default 5000990009)
SMS_GAMA_SOURCE_ADVERTISE=5000990009
# Partner gateway: domain:apiKey pairs, comma-separated (www. is stripped)
# Example: SMS_PARTNERS=baloutpastry.com:replace-with-long-random-secret
SMS_PARTNERS=
@@ -101,6 +102,11 @@ ARVAN_API_KEY=
# Optional override; default https://napi.arvancloud.ir/cdn/4.0
# ARVAN_API_BASE_URL=https://napi.arvancloud.ir/cdn/4.0
# Cloudflare DNS (API token with Zone.Zone Read + Zone.DNS Edit)
# Header sent as: Authorization: Bearer <CLOUDFLARE_API_TOKEN>
CLOUDFLARE_API_TOKEN=
# CLOUDFLARE_API_BASE_URL=https://api.cloudflare.com/client/v4
# Public domain for platform invoice links (https://{domain}/invoices/{id})
INVOICE_PUBLIC_DOMAIN=meshkee.com
# Optional full origin for local (overrides domain + business host), e.g. https://meshkee.app:5174
+1
View File
@@ -3,3 +3,4 @@ node_modules/
dist/
.DS_Store
*.log
tmp/
@@ -0,0 +1,7 @@
-- Parked alias hosts that 301 to the canonical domain (apex + www only).
ALTER TABLE domains
ADD COLUMN IF NOT EXISTS parked_hosts TEXT[] NOT NULL DEFAULT '{}';
CREATE INDEX IF NOT EXISTS idx_domains_parked_hosts
ON domains USING GIN (parked_hosts);
@@ -0,0 +1,4 @@
-- DNS provider per parked alias: { "samanandish.co": "cloudflare" }
ALTER TABLE domains
ADD COLUMN IF NOT EXISTS parked_dns JSONB NOT NULL DEFAULT '{}';
@@ -0,0 +1,51 @@
-- AI / manual catalog translations (EN, AR) for products and categories.
-- Source text stays on the entity; extra locales live here, one row per entity + locale.
DO $$ BEGIN
CREATE TYPE translation_entity_type AS ENUM ('product', 'category');
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
CREATE TYPE translation_locale AS ENUM ('en', 'ar');
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
CREATE TYPE translation_status AS ENUM ('pending', 'ready', 'stale', 'failed');
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
CREATE TABLE IF NOT EXISTS entity_translations (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
business_id BIGINT NOT NULL,
entity_type translation_entity_type NOT NULL,
entity_id BIGINT NOT NULL,
locale translation_locale NOT NULL,
fields JSONB NOT NULL DEFAULT '{}'::jsonb,
source_hash VARCHAR(64) NOT NULL,
status translation_status NOT NULL DEFAULT 'pending',
generated_by VARCHAR(16) NOT NULL DEFAULT 'ai',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT entity_translations_business_id_fkey
FOREIGN KEY (business_id) REFERENCES businesses (id) ON DELETE CASCADE,
CONSTRAINT entity_translations_entity_locale_unique
UNIQUE (entity_type, entity_id, locale)
);
CREATE INDEX IF NOT EXISTS idx_entity_translations_business_id
ON entity_translations (business_id);
CREATE INDEX IF NOT EXISTS idx_entity_translations_entity
ON entity_translations (entity_type, entity_id);
DROP TRIGGER IF EXISTS entity_translations_set_updated_at ON entity_translations;
CREATE TRIGGER entity_translations_set_updated_at
BEFORE UPDATE ON entity_translations
FOR EACH ROW
EXECUTE FUNCTION set_updated_at();
+36
View File
@@ -0,0 +1,36 @@
-- SMS templates per business (verified by super-admin → Meshkee service shortcode).
CREATE TABLE IF NOT EXISTS sms_templates (
id BIGSERIAL PRIMARY KEY,
business_id BIGINT NOT NULL REFERENCES businesses (id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
body TEXT NOT NULL,
is_verified BOOLEAN NOT NULL DEFAULT FALSE,
verified_at TIMESTAMPTZ NULL,
verified_by BIGINT NULL REFERENCES users (id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_sms_templates_business_id
ON sms_templates (business_id);
CREATE INDEX IF NOT EXISTS idx_sms_templates_business_verified
ON sms_templates (business_id, is_verified);
CREATE INDEX IF NOT EXISTS idx_sms_templates_created_at
ON sms_templates (created_at DESC);
CREATE OR REPLACE FUNCTION sms_templates_set_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = now();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS sms_templates_set_updated_at ON sms_templates;
CREATE TRIGGER sms_templates_set_updated_at
BEFORE UPDATE ON sms_templates
FOR EACH ROW
EXECUTE FUNCTION sms_templates_set_updated_at();
@@ -0,0 +1,7 @@
-- Per-business prepaid SMS credit (messages). Deducted when customer SMS is sent.
ALTER TABLE businesses
ADD COLUMN IF NOT EXISTS sms_balance INTEGER NOT NULL DEFAULT 0;
COMMENT ON COLUMN businesses.sms_balance IS
'Prepaid SMS message credits for this business; decremented on customer SMS send.';
@@ -0,0 +1,21 @@
-- SMS template verification workflow: free | pending | verified
ALTER TABLE sms_templates
ADD COLUMN IF NOT EXISTS verification_status VARCHAR(32) NOT NULL DEFAULT 'free';
UPDATE sms_templates
SET verification_status = 'verified'
WHERE is_verified = TRUE;
ALTER TABLE sms_templates
DROP CONSTRAINT IF EXISTS sms_templates_verification_status_check;
ALTER TABLE sms_templates
ADD CONSTRAINT sms_templates_verification_status_check
CHECK (verification_status IN ('free', 'pending', 'verified'));
CREATE INDEX IF NOT EXISTS idx_sms_templates_verification_status
ON sms_templates (verification_status);
CREATE INDEX IF NOT EXISTS idx_sms_templates_business_status
ON sms_templates (business_id, verification_status);
@@ -0,0 +1,6 @@
-- SMS balance is prepaid IRT (not message counts).
-- Billing: ceil(message_length / 64) * 400 IRT per recipient
-- (login OTP, customer SMS, partner/public SMS, etc.).
COMMENT ON COLUMN businesses.sms_balance IS
'Prepaid SMS balance in IRT. Each 64-character segment costs 400 IRT per recipient.';
@@ -0,0 +1,8 @@
-- Lifetime count of SMS messages successfully billed to each business
-- (incremented on charge, decremented on refund). One unit = one recipient.
ALTER TABLE businesses
ADD COLUMN IF NOT EXISTS sms_sent_count INTEGER NOT NULL DEFAULT 0;
COMMENT ON COLUMN businesses.sms_sent_count IS
'Lifetime SMS messages billed to this business (one per recipient). Incremented on charge, decremented on refund.';
@@ -0,0 +1,12 @@
-- Convert prepaid SMS balance from IRT to credit units (idempotent).
-- Previous billing: 400 IRT per 64-char segment.
-- New model: balance is SMS credits (1 credit = 1 segment); top-up at 500 IRT/credit.
-- Only convert rows that still look like IRT balances (>= 100_000). Already-converted
-- credit balances are below that and are left unchanged on re-run.
UPDATE businesses
SET sms_balance = sms_balance / 400
WHERE sms_balance >= 100000;
COMMENT ON COLUMN businesses.sms_balance IS
'Prepaid SMS credit units. 1 unit = one 64-character segment per recipient. Top-up adds floor(IRT / 500) credits.';
@@ -0,0 +1,20 @@
-- Per-type lifetime SMS send counters (one unit = one recipient).
-- sms_sent_count remains the total across all types.
ALTER TABLE businesses
ADD COLUMN IF NOT EXISTS sms_sent_mass INTEGER NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS sms_sent_singular INTEGER NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS sms_sent_auth INTEGER NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS sms_sent_auto_customer INTEGER NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS sms_sent_admin INTEGER NOT NULL DEFAULT 0;
COMMENT ON COLUMN businesses.sms_sent_mass IS
'Lifetime bulk/mass customer SMS messages billed (one per recipient).';
COMMENT ON COLUMN businesses.sms_sent_singular IS
'Lifetime single-recipient SMS messages billed (customer one-to-one, partner public).';
COMMENT ON COLUMN businesses.sms_sent_auth IS
'Lifetime auth/OTP SMS messages billed.';
COMMENT ON COLUMN businesses.sms_sent_auto_customer IS
'Lifetime automatic customer SMS (birthdays, rewards, …) — reserved.';
COMMENT ON COLUMN businesses.sms_sent_admin IS
'Lifetime admin notification SMS (new order, visits, …) — reserved.';
@@ -0,0 +1,6 @@
-- Optional SEO overrides for category pages (same keys as content metadata JSON).
ALTER TABLE categories
ADD COLUMN IF NOT EXISTS metadata JSONB NOT NULL DEFAULT '{}'::jsonb;
COMMENT ON COLUMN categories.metadata IS
'JSON bag for category SEO overrides: seoMetaTitle, seoMetaDescription';
@@ -0,0 +1,9 @@
-- Add search tags + Persian name on platform business categories
ALTER TABLE business_categories
ADD COLUMN IF NOT EXISTS tags TEXT[] NOT NULL DEFAULT '{}';
ALTER TABLE business_categories
ADD COLUMN IF NOT EXISTS name_fa VARCHAR(255);
CREATE INDEX IF NOT EXISTS idx_business_categories_tags
ON business_categories USING GIN (tags);
@@ -0,0 +1,13 @@
-- Marketplace / storefront banner image for a business (aspect 18:4.5 ≈ 4:1)
ALTER TABLE businesses
ADD COLUMN IF NOT EXISTS banner_media_id BIGINT;
ALTER TABLE businesses
DROP CONSTRAINT IF EXISTS businesses_banner_media_id_fkey;
ALTER TABLE businesses
ADD CONSTRAINT businesses_banner_media_id_fkey
FOREIGN KEY (banner_media_id) REFERENCES media (id) ON DELETE SET NULL;
COMMENT ON COLUMN businesses.banner_media_id IS
'Optional marketplace/storefront banner; recommended aspect ratio 18:4.5 (4:1).';
@@ -0,0 +1,3 @@
-- Business-level gallery (media_attachments with entity_type = gallery, entity_id = business_id)
ALTER TYPE media_entity_type ADD VALUE IF NOT EXISTS 'gallery';
@@ -0,0 +1,4 @@
-- Track public customer marketplace (user product) list/detail page views.
ALTER TYPE website_page_kind ADD VALUE IF NOT EXISTS 'user_product_list';
ALTER TYPE website_page_kind ADD VALUE IF NOT EXISTS 'user_product_detail';
@@ -0,0 +1,3 @@
-- Count About / Contact / other static pages via static-images?pageKey=...
ALTER TYPE website_page_kind ADD VALUE IF NOT EXISTS 'static_page';
@@ -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;
+14
View File
@@ -153,6 +153,19 @@ server {
client_max_body_size 15M;
# Storefront deploy (npm ci + next build) often exceeds 5 minutes
location ~ ^/api/v1/domains/[^/]+/deploy$ {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 60s;
proxy_send_timeout 1800s;
proxy_read_timeout 1800s;
}
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
@@ -160,6 +173,7 @@ server {
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 120s;
}
}
```
+70 -10
View File
@@ -1,7 +1,7 @@
# Meshkee CMS API — Project Context
> Living reference for developers and AI assistants working on this codebase.
> Last updated: August 21, 2026
> Last updated: September 3, 2026
## What This Project Is
@@ -167,6 +167,7 @@ Example super admin: `+989121111111` / `password`
| `054_seed_iran_provinces_cities.sql` | Seed Iran provinces + cities when missing (004 seed may never have run) |
| `055_user_products_listing_fields.sql` | User product listing fields: `price_currency`, `delivery_note`, `condition` (`user_product_condition`), `technical_notes` |
| `058_invoice_user_id.sql` | `invoices.user_id` billed user (backfill from business owner) |
| `080_entity_translations.sql` | `entity_translations` for product/category EN+AR (AI or manual) |
Docker mounts `./database/migrations` into Postgres init — migrations run automatically only on **first** volume creation. Use `migrate.sh` for subsequent migrations.
@@ -179,6 +180,9 @@ Docker mounts `./database/migrations` into Postgres init — migrations run auto
| Enum | Values |
|------|--------|
| `MediaEntityType` | `product`, `blog`, `portfolio`, `customer`, `user_product` |
| `TranslationEntityType` | `product`, `category` |
| `TranslationLocale` | `en`, `ar` |
| `TranslationStatus` | `pending`, `ready`, `stale`, `failed` |
| `ContentStatus` | `draft`, `published`, `archived` |
| `VariationType` | `color`, `size`, `custom` |
| `CityLevel` | `country`, `province`, `city`, `district` |
@@ -210,6 +214,8 @@ StoreItem 1──* StoreItemVariant (purchasable SKUs: price, stock, variation c
StoreItemVariant 1──* StoreItemVariantSelection → CategoryVariationOption
Product 1──* ProductTechnicalFieldValue → CategoryTechnicalFormField
Business 1──* StoreLoanMethod (installments / facilities; module `store_installments`)
UserProduct *── Category (via CategoryAssignment, entityType user_product → product categories)
UserProduct 1──* UserProductTechnicalFieldValue → CategoryTechnicalFormField
UserProduct → City (country, city, optional district)
@@ -265,13 +271,14 @@ All routes are prefixed with `/api/v1`.
| GET | `/tenants/:host/sitemap.xml` | Sitemap **index** → main + dynamic child sitemaps |
| GET | `/tenants/:host/sitemap-main.xml` | Static pages + `/products/category/{id}/{faSlug}` |
| GET | `/tenants/:host/sitemap-products.xml` | Published products `/products/{id}/{faSlug}` |
| GET | `/tenants/:host/sitemap-blogs.xml` | Published blogs `/blog/{id}/{slug}` |
| GET | `/tenants/:host/sitemap-portfolios.xml` | Published portfolios `/portfolios/{id}/{slug}` |
| GET | `/tenants/:host/sitemap-blogs.xml` | Published blogs `/blog/{slug}` |
| GET | `/tenants/:host/sitemap-portfolios.xml` | Published portfolios `/portfolio/{slug}` |
| GET | `/tenants/:host/sitemap-videos.xml` | Published videos `/videos/{slug}` |
| GET | `/tenants/:host/sitemap-instructions.xml` | Published instructions `/instructions/{slug}` |
| GET | `/tenants/:host/sitemap-instructions.xml` | Published instructions `/instruction/{slug}` |
| GET | `/tenants/:host/sitemap-workshops.xml` | Published workshops `/workshops/{slug}` |
| GET | `/tenants/:host/sitemap-user-products.xml` | Published user products `/user-products/{slug}` (module `customer_products`) |
| GET | `/tenants/:host/sitemap-user-products.xml` | Published user products `/user-products/{id}/{slug}` (module `customer_products`) |
| GET | `/tenants/:host/robots.txt` | robots.txt pointing to apex `/sitemap.xml` |
| POST | `/tenants/:host/torob_api/v3/products` | Torob Product API v3 (JWT). Requires store module + `settings.store.torobEnabled`. Nginx: `POST https://{host}/torob_api/v3/products` |
| GET | `/tenants/:host/categories/by-id/:categoryId` | Public category by id (for category landing pages) |
| GET | `/tenants/:host/categories/by-slug/:slug` | Public category by CMS slug |
| GET | `/tenants/:host/products/by-id/:productId` | Public product detail by id (for `{id}/{nameFaSlug}` storefront routes) |
@@ -279,6 +286,9 @@ All routes are prefixed with `/api/v1`.
| GET | `/tenants/:host/portfolios/by-id/:portfolioId` | Public portfolio by id |
| POST | `/businesses/:id/website/sitemap/sync` | Import manifest from `GET https://{domain}/meshkee/sitemap-config.json` |
| GET | `/tenants/:host/store-specials` | Active store specials (`source` is `product` or `store_item`) |
| GET | `/tenants/:host/store-loan-methods` | Active installment/credit methods (`enabled` + `items[]`; needs `store` + `store_installments`) |
| GET | `/tenants/:host/store-loan-methods/:methodId` | One active method |
| POST | `/tenants/:host/store-loan-methods/calculate` | Installment plan — body requires `methodId` (pick method first if multiple) |
| GET | `/tenants/:host/website/category-groups` | Homepage category rows |
| GET | `/tenants/:host/website/brand-groups` | Homepage brand rows |
| GET | `/tenants/:host/website/sliders` | Homepage sliders with slides |
@@ -324,6 +334,10 @@ Pattern: `/businesses/:businessId/<resource>`
| GET | `/categories/color-presets` | Predefined color palette |
| GET/PUT | `/categories/:id/variations` | Category variation options |
| GET/PUT | `/categories/:id/technical-form` | Technical form definition |
| POST | `/categories/translations/ai-all` | Generate missing EN+AR for all product categories (skips existing) |
| GET | `/categories/:id/translations` | EN/AR translations (AI or edited) |
| POST | `/categories/:id/translations/:locale/ai` | Generate EN or AR with AI |
| PUT | `/categories/:id/translations/:locale` | Save edited EN or AR text |
#### Products — notable sub-routes
@@ -332,6 +346,10 @@ Pattern: `/businesses/:businessId/<resource>`
| GET/PUT | `/products/:id/variations` | Which category variation options apply to this product |
| GET/POST/PATCH/DELETE | `/products/:id/variants` | Removed — use `/store-items` |
| GET/PUT | `/products/:id/technical-info` | Product technical data |
| POST | `/products/translations/ai-all` | Generate missing EN+AR for all products (skips existing) |
| GET | `/products/:id/translations` | EN/AR translations (AI or edited) |
| POST | `/products/:id/translations/:locale/ai` | Generate EN or AR with AI |
| PUT | `/products/:id/translations/:locale` | Save edited EN or AR text |
#### My user products (customer — JWT, must be business customer)
@@ -350,9 +368,11 @@ Base: `/tenants/:host/user-products`
| Method | Path | Description |
|--------|------|-------------|
| GET | `/` | List published listings (`name`/`q`, `categoryId`, `cityId`, `countryId`, `condition`, `promoted`, pagination) |
| GET | `/:slug` | Details + gallery (`images`, `galleryMediaIds`) + technical values |
| GET | `/:slug/technical-info` | Category technical form + values |
| GET | `/` | List published listings (`name`/`q`, `categoryId`, `cityId`, `countryId`, `condition`, `promoted`, pagination); items include `pathSlug` |
| GET | `/by-id/:id` | Details by id (preferred for `/user-products/{id}/{pathSlug}` pages) |
| GET | `/by-id/:id/technical-info` | Category technical form + values by id |
| GET | `/:slug` | Details by DB slug (legacy) |
| GET | `/:slug/technical-info` | Category technical form + values by slug (legacy) |
#### Cart (customer — JWT, must be business customer)
@@ -440,7 +460,7 @@ Each resource typically has: `read`, `create`, `update`, `delete` (+ `publish` f
- Dashboard SSO: `POST /auth/handoff` (JWT) stores a one-time Redis ticket (`sso:handoff:{ticket}`, 60s). Business dashboard calls `POST /auth/handoff/consume` and receives tokens.
- JWT payload: `sub`, `cellNumber`, `roles`, `dashboard`, `type`
- SMS provider: Gama (`sms.igama.ir`) SendQuick via service shortcode (`SMS_GAMA_*`)
- Partner gateway (external sites like Balout): `POST /api/v1/public/sms/send` with `X-Api-Key` + body `{ domain, to, message }`; partners configured in `SMS_PARTNERS` (`domain:apiKey` pairs). Rate limits: 30/partner/min and 5/destination/min. Not part of storefront website-api docs.
- Partner gateway (external sites like Balout): `POST /api/v1/public/sms/send` with `X-Api-Key` + body `{ domain, to, message, source? }`; default sender advertising `5000990009`; partners configured in `SMS_PARTNERS` (`domain:apiKey` pairs). Rate limits: 30/partner/min and 5/destination/min. Not part of storefront website-api docs.
---
@@ -452,6 +472,7 @@ Each resource typically has: `read`, `create`, `update`, `delete` (+ `publish` f
- One category per product (via `CategoryAssignment`)
- Content JSON: `{ nameFa, html }`; metadata JSON: `{ tags }`
- i18n: Persian name in `content.nameFa`, summary in `description`
- Extra locales (EN, AR) in `entity_translations` (globe button in business dashboard; AI first, then optional edit)
### Category variations (product categories)
@@ -502,6 +523,42 @@ Stored in `businesses.settings.website.specialProductsSource` (`product` | `stor
- Dashboard: `PATCH /businesses/:id/settings` with `{ website: { specialProductsSource } }`.
- Public: `GET /tenants/:host` includes `specialProductsSource`; `GET /tenants/:host/store-specials` includes top-level `source`.
### Store loan methods (installments & facilities)
Requires modules `store` + `store_installments` (opt-in). Dashboard: Store → اقساط و تسهیلات.
| Field | Notes |
|-------|-------|
| `nameFa` / `nameEn` | Display names |
| `minShoppingAmount` | Minimum purchase total to use this method |
| `minAmount` / `maxAmount` | Allowed credit range |
| `interest` | Flat annual %; fee = credit × (interest/100) × (months/12) |
| `returnMonthsMin` / `Max` / `Step` | Term options (step ∈ 2,3,4,6,12) |
| `imageMediaId` / `imageAspectRatio` | Optional image (`1:1` or `3:2`) |
**Dashboard (auth, `products.read` / `products.update`):**
- `GET/POST /businesses/:businessId/store-loan-methods`
- `GET/PATCH/DELETE /businesses/:businessId/store-loan-methods/:methodId`
**Public website:**
- `GET /tenants/:host/store-loan-methods` → `{ enabled, items[] }` (`enabled: false` + empty when module off)
- `GET /tenants/:host/store-loan-methods/:methodId` → `{ method }` (active only)
- `POST /tenants/:host/store-loan-methods/calculate` → `{ method, plan }` — **always requires `methodId`**
Calculator UX when a site has multiple methods: list first → if `items.length > 1`, ask the shopper which method → then collect purchase total / credit / months → `POST .../calculate`. If exactly one method, auto-select it. Never calculate without a chosen `methodId`.
```json
POST /tenants/:host/store-loan-methods/calculate
{
"methodId": "1",
"totalValue": 5000000,
"creditAmount": 2000000,
"months": 12
}
```
`plan` includes `cashPayment`, `interestFee`, `totalRepay`, and monthly `installments[]` (`index`, `dueDate`, `amount`). Public items also include `returnMonthOptions[]`.
**Example — batch create variants for a product:**
```json
@@ -642,7 +699,8 @@ See `.env.example` for the full list. Key groups:
- Multi-tenant auth (register, login, passwordless OTP login, reset password via SMS, profile)
- Super admin: users, businesses, domains, system business categories
- Super admin add/update domain upserts ArvanCloud DNS (`@` ANAME, `www`/`business`/`customer`/`api` CNAMEs)
- 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 `v=spf1 mx -all`, RSA DKIM required, Ed25519 DKIM optional, autoconfig/autodiscover) on Arvan or Cloudflare; removes extra MX, `dkim._domainkey`, and `_dmarc`
- Super admin: selective migrate-from-old + purge-data (portfolio categories + portfolios; oversized images resized to max 1280×1280; purge removes portfolios + images)
- Business team management
- Media upload (S3 + Sharp)
@@ -650,6 +708,8 @@ See `.env.example` for the full list. Key groups:
- Products CRUD
- Product variation values (which options a product offers)
- Store items / product variants (price, stock, SKU)
- Store loan methods / installments (`store_installments`): dashboard CRUD + public list/get/calculate
- Torob Product API v3 (`POST /tenants/:host/torob_api/v3/products`) for store-module websites; nginx proxies `/torob_api/v3/products` on the shop apex
- Shopping cart + checkout + orders (customer + admin)
- Online e-payment (Mellat + ZarinPal; stubs for SEP / Snapp Pay / DigiPay)
- Product technical info
+245 -27
View File
@@ -22,25 +22,40 @@ You are building a **Meshkee business website (storefront)**. You must use the M
### Hard rules
1. Resolve tenant first: `GET /tenants/<WEBSITE_DOMAIN>` → save `businessId` from `id`.
2. All public content uses `/tenants/<WEBSITE_DOMAIN>/...` (no auth).
3. Cart, orders, favorites use `/businesses/<businessId>/...` with `Authorization: Bearer <accessToken>`.
4. Customer register body must include `"domain": "<WEBSITE_DOMAIN>"`. If the cell already exists on another Meshkee site and the password differs, API returns `409` with `CELL_EXISTS_OTHER_SITE:...`. Retry register with `"acknowledgeExistingAccount": true` to link that account (profile unchanged; **password is replaced** with the new signup password), then complete SMS OTP.
5. Cell numbers are E.164 (`+98912...`).
6. Do not call dashboard/CMS routes (`/businesses/.../products` write APIs, media upload, domain-admin, etc.).
7. **Partner SMS** (`POST /public/sms/send`) is for external partner backends with an issued `X-Api-Key` only — not for normal storefront UI. See https://api.meshkee.com/docs/website/SMS.md
8. **Technical details (labels + values):** Product/user-product detail responses may include `technicalValues` with **values only** (`fieldId` + `textValue` / `optionId` / `optionIds` — **no field labels**). To render a label→value specs table you **must** call the matching `.../technical-info` endpoint and join `form.fields[].id` ↔ `values[].fieldId`. Never invent a separate “variation fields” or “category fields” public route — those do not exist on the website API.
3. **Auth + checkout are not this website.** Login, register, OTP, shopping-cart process, addresses, and payment already exist for every Meshkee site at `https://customer.<WEBSITE_DOMAIN>`. Do **not** add `/login`, `/register`, `/checkout`, or `/cart` routes (or equivalent pages) on the storefront. Do **not** call `/businesses/{businessId}/cart` or `/cart/checkout` from this site.
4. **Detect login** via parent-domain cookies (see Shared login). Not token handoff, not an API SSO endpoint. If the shopper needs to sign in, **redirect** to the customer dashboard login — do not invent a login UI.
5. Storefront cart = header icon + quantity badge + mini-cart popup + **Continue** redirect (see Shopping cart). Server cart / orders / payment APIs are customer-dashboard only. Favorites may still use Bearer if cookies exist.
6. Customer-dashboard auth APIs (storefronts must **not** call these): register body includes `"domain": "<WEBSITE_DOMAIN>"`. If the cell already exists on another Meshkee site and the password differs, API returns `409` with `CELL_EXISTS_OTHER_SITE:...`. Retry register with `"acknowledgeExistingAccount": true` to link that account (profile unchanged; **password is replaced** with the new signup password), then complete SMS OTP.
7. Cell numbers are E.164 (`+98912...`).
8. Do not call dashboard/CMS routes (`/businesses/.../products` write APIs, media upload, domain-admin, etc.).
9. **Partner SMS** (`POST /public/sms/send`) is for external partner backends with an issued `X-Api-Key` only — not for normal storefront UI. See https://api.meshkee.com/docs/website/SMS.md
10. **Technical details (labels + values):** Product/user-product detail responses may include `technicalValues` with **values only** (`fieldId` + `textValue` / `optionId` / `optionIds` — **no field labels**). To render a label→value specs table you **must** call the matching `.../technical-info` endpoint and join `form.fields[].id` ↔ `values[].fieldId`. Never invent a separate “variation fields” or “category fields” public route — those do not exist on the website API.
11. **Torob:** Do **not** add a Next.js route for `/torob_api`. Meshkee nginx on the store apex proxies `POST /torob_api/v3/products` to the API. Only businesses with the **store** module **and** Store settings → Torob switch on return products (otherwise 404). Storefront UI must not call this endpoint.
12. **Production runtime (required):** Meshkee hosts many Next.js storefronts on one shared websites VM. Every Next site **must** set `output: "standalone"` in `next.config` (`.ts` / `.mjs` / `.js`). Deploy detects `.next/standalone/server.js`, points PM2 at that `server.js`, and **deletes the full `node_modules`**. Target RSS is **~80–120 MB**. Do **not** ship `next start` with a full `node_modules` runtime (that uses ~150–500+ MB and OOMs the host). Do **not** use `output: "export"` unless the project explicitly asks for a static export. Vinext apps (`vinext` in `package.json` / `vinext start`) are a separate intentional stack — do not pretend they use Next standalone.
### Production runtime (Next.js)
```ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
// Leaner production runtime: deploy uses .next/standalone and drops node_modules.
output: "standalone",
// ...images, rewrites, etc.
};
export default nextConfig;
```
After changing `next.config`, commit, push, and redeploy so PM2 switches to standalone. A correct deploy log says `standalone build detected` / `deploy ok (standalone)`; PM2 script is `.next/standalone/server.js`, not `node_modules/next/dist/bin/next`.
### Typical bootstrap sequence
1. `GET /tenants/{domain}` → branding + `businessId` + `specialProductsSource` (`product` or `store_item`)
2. `GET /tenants/{domain}/website/favicon` → `faviconUrl` for `<link rel="icon">` / Next.js `metadata.icons` (falls back to `logoUrl` when no dedicated favicon). Also returns `logoUrl`, `logoDarkUrl`, `hasDedicatedFavicon`.
3. Homepage: business-info, **static-images**, sliders, category-groups, brand-groups, store-specials (`source` repeats the tenant setting; items are store listings when `source` is `store_item`)
4. Catalog: categories, products (`GET /products/{slug}` includes `relatedProducts`: same category then same brand, in-stock first), store-items (`name` instant search: in-stock first, then `updatedAt`), **user-products** (customer stock listings). Portfolios list newest first (`sortOrder` desc, then `publishedAt` / `createdAt` desc); each portfolio may include nullable `projectUrl` (external website link). List filters: products/blogs/portfolios/videos accept `?tag=` (exact match on `metadata.tags`).
5. Auth: register/login → store tokens. Optional: `POST /auth/send-otp` then `POST /auth/login-otp` (passwordless) or `POST /auth/reset-password` (forgot password). `POST /auth/verify-otp` only marks the cell verified (no tokens).
6. Cart checkout with `addressId` or inline `shippingAddress` + `payment`
- For online pay: `payment.type = "e_payment_gate"`, `gatewayType` (e.g. `"mellat"` or `"zarinpal"`), and absolute `returnUrl`
- Response includes `payment.redirect` `{ method, url, fields }` — POST/redirect shopper to the bank
- Meshkee registers the bank `callback_url` on the **store apex** (`https://YOUR_WEBSITE_DOMAIN/meshkee/payments/{gateway}/callback`), which nginx proxies to the API. ZarinPal/Mellat domain checks must match the store domain, not `api.meshkee.com`.
- After verify, API redirects the browser to `returnUrl?status=success|failed&orderId=…`
- Enabled gateways: `GET /tenants/{domain}` → `ePayment`, or `GET /businesses/{businessId}/payments/methods`
5. Shopping cart on **this** site only: header icon + badge + mini-cart popup. Persist a **guest cart** and **Continue** to `https://customer.<WEBSITE_DOMAIN>` (see Shopping cart). Do not implement login or checkout here.
6. Bank payment callbacks stay on the store apex via nginx (`https://<WEBSITE_DOMAIN>/meshkee/payments/{gateway}/callback`) — infrastructure only. The website app does **not** implement payment or checkout pages; that UI is the customer dashboard.
### Favicon + logos
- `GET /tenants/{domain}/website/favicon` → `{ faviconUrl, logoUrl, logoDarkUrl, hasDedicatedFavicon }`
@@ -48,11 +63,120 @@ You are building a **Meshkee business website (storefront)**. You must use the M
- When `hasDedicatedFavicon` is false, `faviconUrl` equals the light `logoUrl` — still safe to use as tab icon.
- Header/footer logos: `logoUrl` on light backgrounds, `logoDarkUrl` on dark (fallback to `logoUrl` in CSS when null).
### Shared login with customer dashboard (`customer.<WEBSITE_DOMAIN>`)
The Meshkee **customer dashboard** lives at `https://customer.<WEBSITE_DOMAIN>` (e.g. `customer.sgemed.com`). **That** app owns login, register, OTP, and password reset for every storefront. The shop and the dashboard stay in sync via **parent-domain cookies** — not a login page on `{apex}`.
**Storefront requirements:**
- Do **not** build login / register / OTP / forgot-password UI.
- If the shopper must authenticate, send them to `https://customer.<WEBSITE_DOMAIN>/login?redirect=…` (relative redirect path only, e.g. `/checkout/cart`).
- On page load: if the access-token cookie exists, treat them as logged in (Continue can skip login).
- Do not invent OAuth/SSO APIs. Do not use `POST /auth/handoff` (staff-only into `business.<WEBSITE_DOMAIN>`).
**Cookie contract** (written by the **customer dashboard** on login; storefronts only **read** them):
| Cookie name | Value |
|-------------|--------|
| `meshkee_customer_access_token` | access JWT (URL-encoded; may be chunked as `name_n` + `name_0`…) |
| `meshkee_customer_refresh_token` | refresh JWT (same) |
| Attribute | Value |
|-----------|--------|
| `Domain` | `.<WEBSITE_DOMAIN>` (leading dot), e.g. `.sgemed.com` |
| `Path` | `/` |
| `SameSite` | `Lax` |
| `Max-Age` | ~30 days (cleared on logout) |
| `Secure` | set on HTTPS |
API calls (favorites, etc.) still send `Authorization: Bearer <accessToken>` from that cookie. Cookies are **not** sent to the API for auth.
**Wrong:** a custom login page on the storefront, or calling `/auth/login` from shop UI.
**Right:** redirect to `customer.<WEBSITE_DOMAIN>` and read apex cookies.
### Shopping cart on the storefront
Website scope is a **mini-cart only**. The full cart, checkout, addresses, and payment run on the customer dashboard.
**UI**
- Header shopping-cart icon.
- Badge = sum of line quantities (hide or `0` when empty).
- Click → popup: lines, qty, remove, total, **Continue**.
**Add to cart** (always a **variant**, never a product id)
1. `GET /tenants/{domain}/store-items/by-product/{productId}` → `{ storeItem: { variants: [...] } | null }`.
2. If `storeItem` is null or `variants` is empty → hide Add to cart (not for sale).
3. **In-stock only:** `stockQuantity === null` (unlimited) or `stockQuantity > 0`. Disable / omit the rest.
4. **If more than one in-stock variant:** list them first (`label` is already joined, e.g. `قرمز · XL` from `selections`). The shopper **must pick one** — do not add until they choose.
5. **If exactly one in-stock variant:** add that variant (no picker required).
6. Push that variant into the guest cart. `id` = variant `id` (`storeItemVariantId`). If that `id` is already in the cart, **increment `quantity`** instead of adding a second line.
7. Selling price: `discountedPrice ?? price`. Optional `originalPrice` = `price` when `discountedPrice` is set.
Do not call `POST /businesses/{id}/cart/items` from the storefront. Mini-cart is local only.
**Guest cart** (must match the customer dashboard):
| | |
|--|--|
| Storage key / cookie name | `meshkee-guest-cart` |
| Persist | `localStorage` **and** cookie `Domain=.<WEBSITE_DOMAIN>`, `Path=/`, `SameSite=Lax`, ~30 days |
| Cookie size | if `encodeURIComponent(json)` is longer than ~3500 chars, skip the cookie and rely on the URL param |
Each line:
```json
{
"id": "<storeItemVariantId>",
"name": "...",
"slug": "...",
"price": 123000,
"originalPrice": 150000,
"image": "...",
"quantity": 1
}
```
`id` **must** be `storeItemVariantId` (not product id, not store-item id). `price` is IRT, numeric.
**URL payload** (always pass on Continue — cookies can be dropped or too large):
```js
function encodeGuestCart(items) {
const json = JSON.stringify(items)
return btoa(unescape(encodeURIComponent(json)))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '')
}
```
Query/hash param name: `guestCart`.
**Continue** (replace `<WEBSITE_DOMAIN>`; `encoded = encodeGuestCart(items)`):
- Logged in (`meshkee_customer_access_token` present):
`https://customer.<WEBSITE_DOMAIN>/checkout/cart?guestCart=<encoded>`
- Not logged in:
`https://customer.<WEBSITE_DOMAIN>/login?redirect=${encodeURIComponent('/checkout/cart?guestCart=' + encoded)}`
The dashboard reads `guestCart` (query or hash), then `localStorage`, then the shared cookie, and syncs lines into the server cart after 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>`.
### Checkout & payments (customer dashboard — not this website)
OpenAPI **Cart** / checkout / payment-method routes are for `https://customer.<WEBSITE_DOMAIN>`, not storefront JavaScript.
Nginx on the shop apex still proxies bank callbacks (`https://<WEBSITE_DOMAIN>/meshkee/payments/{gateway}/callback`) to the API. Do not add a Next.js page for that path. After pay, the API redirects to the dashboard `returnUrl` (`/checkout/result?status=…`).
### User products (customer listings)
Public marketplace listings owned by customers — not catalog `products`.
- `GET /tenants/{domain}/user-products` — list published (`name`/`q`, `categoryId`, `cityId`, `countryId`, `condition`, `promoted`, pagination)
- `GET /tenants/{domain}/user-products/{slug}` — details + gallery (`technicalValues` = values only, no labels)
- `GET /tenants/{domain}/user-products/{slug}/technical-info` — **required for specs UI**: `{ form: { fields: [{ id, label, key, type, ... }] }, values: [...] }`
- `GET /tenants/{domain}/user-products` — list published (`name`/`q`, `categoryId`, `cityId`, `countryId`, `condition`, `promoted`, pagination). Each item includes `id`, `pathSlug`, `titleFa`/`titleEn`.
- `GET /tenants/{domain}/user-products/by-id/{id}` — **preferred** for storefront pages (slug segment is SEO-only)
- `GET /tenants/{domain}/user-products/by-id/{id}/technical-info` — specs labels + values by id
- `GET /tenants/{domain}/user-products/{slug}` — legacy resolve by DB slug (still supported)
- `GET /tenants/{domain}/user-products/{slug}/technical-info` — legacy specs by slug
Use product categories from `GET /tenants/{domain}/categories?entityType=product` for filters. Creating/editing listings is customer-dashboard only (`/businesses/.../my-user-products`), not website-facing.
### Technical details / specs table (catalog products + user products)
@@ -61,7 +185,7 @@ Use product categories from `GET /tenants/{domain}/categories?entityType=product
| Detail page | Specs endpoint (labels + values) |
|-------------|----------------------------------|
| `GET .../products/{slug}` or `.../products/by-id/{id}` | `GET .../products/{slug}/technical-info` or `.../products/by-id/{id}/technical-info` |
| `GET .../user-products/{slug}` | `GET .../user-products/{slug}/technical-info` |
| `GET .../user-products/by-id/{id}` (or `{slug}`) | `GET .../user-products/by-id/{id}/technical-info` (or `{slug}/technical-info`) |
**How to render:**
1. Call `technical-info` (same slug/id as the detail page).
@@ -74,12 +198,22 @@ Use product categories from `GET /tenants/{domain}/categories?entityType=product
**Wrong:** expecting `fieldName` / `fieldNameFa` on each `technicalValues` item in the product detail response.
### Analytics (dashboard charts)
Page views are recorded **automatically** when the website calls the normal public list/detail APIs (blogs, products, portfolios, videos, store-items, and homepage sliders). No extra website code is required for those.
Page views are recorded **automatically** for roughly one count per page load:
Optional explicit record (e.g. custom home without sliders):
- `POST /tenants/{domain}/analytics/views` body `{ "kind": "home"|"blog_detail"|"product_detail"|..., "entityId"?: "...", "path"?: "/blog/my-post" }`
- `kind` values: `home`, `portfolio_list`, `portfolio_detail`, `product_list`, `product_detail`, `store_item_list`, `store_item_detail`, `blog_list`, `blog_detail`, `video_list`, `video_detail` (legacy aliases `website`/`product`/`portfolio`/`blog` still accepted)
- **Home** — `GET /tenants/{domain}/website/static-images?pageKey=home`
- **About / Contact / other static pages** — `GET .../static-images?pageKey=about` (or `contact`, etc.) → counted as `static_page` with `path=/about`
- **Content detail** — product, user-product, blog, portfolio, video, and store-item **detail** GETs
List APIs (`products`, `blogs`, …) and homepage sliders **do not** count (avoids multi-API inflation). Known bots (and empty User-Agent) are skipped. A browser refresh counts again.
Call `static-images?pageKey=…` on About/Contact (and similar) so those pages appear in dashboard statistics.
Optional explicit record (custom pages without static-images):
- `POST /tenants/{domain}/analytics/views` body `{ "kind": "home"|"static_page"|"blog_detail"|"product_detail"|..., "entityId"?: "...", "path"?: "/blog/my-post" }`
- `kind` values: `home`, `static_page`, `portfolio_list`, `portfolio_detail`, `product_list`, `product_detail`, `store_item_list`, `store_item_detail`, `blog_list`, `blog_detail`, `video_list`, `video_detail`, `user_product_list`, `user_product_detail` (legacy aliases `website`/`product`/`portfolio`/`blog` still accepted). Prefer detail / `home` / `static_page` for real page visits.
- Events kept ~6 months for charts; lifetime totals kept forever in counters.
- The business dashboard **Product views** chart counts both `product_detail` and `user_product_detail`.
- The **Pages** statistics row counts `static_page` (about, contact, …).
### Static images
Named slots the business dashboard can replace. Fetch once per page:
@@ -152,10 +286,13 @@ Minimal example:
- **Canonical detail URLs (Meshkee default for all sites):**
- Product: `/products/{id}/{nameFaSlug}` — build `nameFaSlug` from `nameFa` (fallback `title`); resolve page via `GET /tenants/{domain}/products/by-id/{id}` (slug segment is SEO-only; redirect to canonical if it drifts).
- Product category: `/products/category/{categoryId}/{nameFaSlug}` — build slug from `nameFa` (fallback `name`); resolve via `GET /tenants/{domain}/categories/by-id/{id}`, then list products with `categoryId`.
- User product (customer listing): `/user-products/{slug}` — use the listing’s `slug` from the API; resolve via `GET /tenants/{domain}/user-products/{slug}`.
- Blog: `/blog/{id}/{titleSlug}` — `GET /tenants/{domain}/blogs/by-id/{id}`
- Portfolio: `/portfolios/{id}/{titleFaSlug}` — `GET /tenants/{domain}/portfolios/by-id/{id}`
- When linking from lists/cards, use the same `{id}/{slug}` shape for details (slugify Farsi title: spaces → `-`, keep Persian letters). Category links use `/products/category/{id}/{nameFaSlug}`. User-product cards use `/user-products/{slug}`.
- User product (customer listing): `/user-products/{id}/{pathSlug}` — use `id` + `pathSlug` from the list/detail API (or slugify `titleFa` / `titleEn`); resolve via `GET /tenants/{domain}/user-products/by-id/{id}` (slug segment is SEO-only).
- Blog: `/blog/{slug}` — use the blog’s `slug` from the API; resolve via `GET /tenants/{domain}/blogs/{slug}` (or list + match). Prefer slug routes over id.
- Portfolio: `/portfolio/{slug}` — use the portfolio’s `slug` from the API; resolve via `GET /tenants/{domain}/portfolios/{slug}`.
- Instruction: `/instruction/{slug}` — use the instruction’s `slug` from the API; resolve via `GET /tenants/{domain}/instructions/{slug}`.
- Video: `/videos/{slug}` — use the video’s `slug` from the API.
- Workshop: `/workshops/{slug}` — use the workshop’s `slug` from the API.
- When linking from lists/cards, use the same slug-based detail paths. Category links use `/products/category/{id}/{nameFaSlug}`. User-product cards use `/user-products/{id}/{pathSlug}`.
- Omit `templates` in `sitemap-config.json` unless this site uses non-default paths.
- **Sitemap files (proxied by nginx — do not ship local copies):**
- `/sitemap.xml` — sitemap **index** (lists child sitemaps for enabled modules)
@@ -194,11 +331,92 @@ These are **required on every crawlable page** (home, listing, product/blog/port
**Open Graph (recommended)**
- Set `og:title`, `og:description`, and `og:image` on important pages (home + detail pages) from CMS media when available.
### Site-wide meta tags + trust badges (eNamad / Samandehi)
Business admins configure this under **Website → Tags & Badges**.
1. `GET /tenants/{domain}/website/business-info`
2. For each item in `metaTags` (array of `{ html }`), emit the full HTML once in the root layout `<head>`.
```html
{html}
```
Example `html` value: `<meta name="google-site-verification" content="…">` (also supports `property`, `http-equiv`, etc.). Parse attributes into a `<meta />` element, or inject the sanitized string into `<head>`. Do **not** wrap it again as `name`/`content` — `html` is already the whole tag. The dashboard “Name” field is an admin label only and is **not** in this public payload.
3. For each item in `trustBadges` (array of `{ kind, embedHtml }`), render `embedHtml` in the site footer (e.g. `dangerouslySetInnerHTML`). These are HTML widgets only (not meta). Scripts are stripped by the API — still treat the HTML as CMS-controlled content.
- When arrays are empty, omit tags / footer badges.
- Do **not** hardcode eNamad/Samandehi HTML in the website repo when these fields are present.
### Site-wide Schema.org JSON-LD (Organization / LocalBusiness)
Business admins configure this under **Website → Settings → Structured data**. The API builds a ready `jsonLd` object for you.
1. `GET /tenants/{domain}/website/business-info`
2. If `schema.enabled` and `schema.jsonLd` is an object, render once in the root layout (or every public page):
```html
<script type="application/ld+json">
…paste schema.jsonLd…
</script>
```
In Next.js App Router, put it in the root layout via `<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(schema.jsonLd) }} />` when `jsonLd` is non-null.
- Do **not** invent Organization fields locally when `jsonLd` is present — use the CMS object.
- When `schema.enabled` is false, `jsonLd` is `null` — omit the script tag.
- Tenant resolve also exposes `{ schema: { enabled, type } }` for lightweight checks; full payload is on business-info.
- Social profile URLs from business profile are merged into `sameAs` automatically; admin can add extra https URLs.
### Product + blog Schema.org JSON-LD (auto)
When **Website → Settings → Structured data** is enabled, detail endpoints include a ready `schema.jsonLd` (no admin fields to edit):
| Page | Endpoint | `@type` |
|------|----------|---------|
| Product detail | `GET /tenants/{domain}/products/by-id/{id}` (or by slug) | `Product` (+ `Offer` when store min price exists) |
| Blog detail | `GET /tenants/{domain}/blogs/{slug}` (or by id) | `BlogPosting` |
On the product/blog page:
```tsx
{product.schema?.jsonLd ? (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(product.schema.jsonLd) }}
/>
) : null}
```
- Emit **in addition to** the site-wide Organization/LocalBusiness script from business-info.
- When structured data is disabled, `schema.jsonLd` is `null` — omit the tag.
- Do not invent Product/Article fields locally when `jsonLd` is present.
### Per-page SEO meta overrides (optional)
CMS forms expose optional **SEO title** / **SEO description** on products, blogs, portfolios, videos, instructions, workshops, user-products, and categories.
Public detail payloads include:
- `seoMetaTitle` (`string | null`)
- `seoMetaDescription` (`string | null`)
Website rules:
1. Prefer `seoMetaTitle` for `<title>` when non-null; else use the normal title/`nameFa`.
2. Prefer `seoMetaDescription` for `<meta name="description">` when non-null; else summary/abstract/about.
3. Product/blog `schema.jsonLd` already prefers these overrides when set.
4. Empty/null means “no override” — never show placeholder text as meta.
**Checklist before shipping a page**
1. Unique `<title>` and meta description
1. Unique `<title>` and meta description (use `seoMeta*` when present)
2. One clear H1 + sensible H2 sections
3. All content images have alt text
4. Public URL is included via CMS sitemap and/or `sitemap-config.json` static pages
5. Site-wide Organization/LocalBusiness JSON-LD from `business-info.schema.jsonLd` when enabled
6. Product/blog detail pages emit `product.schema.jsonLd` / `blog.schema.jsonLd` when present
7. Site-wide `metaTags` + footer `trustBadges` from business-info when present
If OpenAPI and this brief conflict, **OpenAPI wins**.
@@ -206,4 +424,4 @@ If OpenAPI and this brief conflict, **OpenAPI wins**.
## What to tell each website team
Replace `<WEBSITE_DOMAIN>` once per project. Everything else is global — same Postman, same OpenAPI, same base URL.
Replace `<WEBSITE_DOMAIN>` once per project. Everything else is global — same Postman, same OpenAPI, same base URL. Remind them: **`output: "standalone"` is mandatory** for Meshkee Next deploys (shared VM RAM).
@@ -1,7 +1,7 @@
{
"info": {
"name": "Meshkee Website API (Global)",
"description": "# Meshkee Website API — Global reference for all storefronts\n\nCanonical docs: https://api.meshkee.com/docs/website\n\nThis collection is **not** tied to one business. Every Meshkee website (any domain) uses the same endpoints.\n\n## How multi-tenancy works\n1. Set collection variable `domain` = the **website apex** only (e.g. `example.com`, `sanihome.ir`). Never use `www.` / `api.` / `customer.` / `business.` here.\n2. Set `baseUrl` (see below).\n3. Run **Resolve tenant** → saves `businessId`.\n4. Public content: `/tenants/{{domain}}/...` (no auth).\n5. After login: cart / orders / favorites use `/businesses/{{businessId}}/...` with Bearer token.\n\n## baseUrl options (same Nest API)\n- Preferred central: `https://api.meshkee.com/api/v1`\n- Per-site alias (if DNS+SSL configured): `https://api.{{domain}}/api/v1`\n- Local: `http://localhost:3000/api/v1`\n\nTenant is always taken from the **path** (`/tenants/{domain}`), not from the API hostname.\n\n## Auth\n- Public: no header\n- Customer: `Authorization: Bearer {{accessToken}}`\n- Register requires body field `domain` = same website apex\n\n## Not in this collection\nCMS / dashboard / super-admin APIs (staff only).\n",
"description": "# Meshkee Website API — Global reference for all storefronts\n\nCanonical docs: https://api.meshkee.com/docs/website\n\nThis collection is **not** tied to one business. Every Meshkee website (any domain) uses the same endpoints.\n\n## How multi-tenancy works\n1. Set collection variable `domain` = the **website apex** only (e.g. `example.com`, `sanihome.ir`). Never use `www.` / `api.` / `customer.` / `business.` here.\n2. Set `baseUrl` (see below).\n3. Run **Resolve tenant** → saves `businessId`.\n4. Public content: `/tenants/{{domain}}/...` (no auth).\n5. **Do not** build login or checkout on the storefront. Auth, server cart, addresses, and payment live on `https://customer.{{domain}}`. The shop mini-cart is a local guest cart (`meshkee-guest-cart`); add-to-cart loads `GET /tenants/{{domain}}/store-items/by-product/{productId}`, lists variants if there is more than one, then adds the chosen `storeItemVariantId`. Continue redirects to the customer dashboard (see AI_PROMPT.md).\n6. Favorites (optional) may use `/businesses/{{businessId}}/...` with Bearer if the shopper already has dashboard cookies.\n\n## baseUrl options (same Nest API)\n- Preferred central: `https://api.meshkee.com/api/v1`\n- Per-site alias (if DNS+SSL configured): `https://api.{{domain}}/api/v1`\n- Local: `http://localhost:3000/api/v1`\n\nTenant is always taken from the **path** (`/tenants/{domain}`), not from the API hostname.\n\n## Auth\n- Public: no header\n- Customer dashboard: `Authorization: Bearer {{accessToken}}`\n- Storefronts must **not** call register/login — redirect to `https://customer.{{domain}}/login`\n- The dashboard writes parent-domain cookies `meshkee_customer_access_token` and `meshkee_customer_refresh_token` on `Domain=.{{domain}}` (`Path=/`, `SameSite=Lax`). The shop only **reads** them. Do **not** use `/auth/handoff` for shoppers (staff SSO into the business dashboard).\n\n## Not in this collection\nCMS / dashboard / super-admin APIs (staff only).\n",
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
},
"variable": [
@@ -957,7 +957,7 @@
"name": "Get published portfolio by id (website)",
"request": {
"method": "GET",
"description": "Preferred for /portfolios/{id}/{titleFaSlug} pages. Includes nullable `projectUrl`.",
"description": "Preferred for /portfolio/{slug} pages. Includes nullable `projectUrl`.",
"url": "{{baseUrl}}/tenants/{{domain}}/portfolios/by-id/{{portfolioId}}"
}
},
@@ -1354,11 +1354,27 @@
}
}
},
{
"name": "Get user product by id",
"request": {
"method": "GET",
"description": "Preferred for /user-products/{id}/{pathSlug} pages. technicalValues are values only — use technical-info for specs UI.",
"url": "{{baseUrl}}/tenants/{{domain}}/user-products/by-id/{{userProductId}}"
}
},
{
"name": "Get user product technical info by id",
"request": {
"method": "GET",
"description": "Required for technical-details UI when page is resolved by id: form.fields (labels) + values.",
"url": "{{baseUrl}}/tenants/{{domain}}/user-products/by-id/{{userProductId}}/technical-info"
}
},
{
"name": "Get user product by slug",
"request": {
"method": "GET",
"description": "Listing detail + gallery. technicalValues are values only (fieldId + text/option) — no labels. Use technical-info for specs UI.",
"description": "Legacy slug resolve. Prefer by-id for storefront pages. technicalValues are values only (fieldId + text/option) — no labels.",
"url": "{{baseUrl}}/tenants/{{domain}}/user-products/{{userProductSlug}}"
}
},
@@ -1366,7 +1382,7 @@
"name": "Get user product technical info by slug",
"request": {
"method": "GET",
"description": "Required for technical-details UI: form.fields (labels) + values. Join field.id ↔ value.fieldId. No separate category-variation-fields public endpoint.",
"description": "Legacy specs by slug. Prefer by-id technical-info. Join field.id ↔ value.fieldId.",
"url": "{{baseUrl}}/tenants/{{domain}}/user-products/{{userProductSlug}}/technical-info"
}
}
@@ -1487,6 +1503,37 @@
}
]
},
{
"name": "Torob",
"item": [
{
"name": "Product API v3 (store module)",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
},
{
"key": "X-Torob-Token",
"value": "{{torobJwt}}"
},
{
"key": "X-Torob-Token-Version",
"value": "1"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"page\": 1,\n \"sort\": \"date_added_desc\"\n}"
},
"url": "{{baseUrl}}/tenants/{{domain}}/torob_api/v3/products",
"description": "Torob-only. 404 if the tenant does not have the store module. Prefer the shop-domain URL POST https://{domain}/torob_api/v3/products after nginx is patched."
}
}
]
},
{
"name": "Homepage",
"item": [
+3 -1
View File
@@ -24,6 +24,7 @@ X-Api-Key: <partner-secret>
| `domain` | yes | Allowlisted partner apex, e.g. `baloutpastry.com` (`www.` is stripped) |
| `to` | yes | Mobile: `09…`, `9…`, `+989…`, or `989…` |
| `message` | yes | Free text, max 700 characters |
| `source` | no | Gama shortcode override (8–16 digits). Default: advertising `5000990009` |
### Example (Balout)
@@ -72,7 +73,8 @@ First allowlisted partner: **baloutpastry.com**.
## Sender line (v1)
Uses the **service** shortcode only (`SendQuick`). Advertising / bulk / OTP pattern APIs are not exposed yet.
Default sender is the **advertising** shortcode (`5000990009` / `SMS_GAMA_SOURCE_ADVERTISE`).
Pass `source` to override (e.g. a business-owned shortcode). OTP/login on Meshkee dashboards uses the **service** shortcode (`5000110005`) via `/auth/send-otp`, not this partner endpoint.
---
+55 -4
View File
@@ -89,9 +89,48 @@
<li>Variable <code>domain</code> = website apex only (no <code>www</code>/<code>api</code>/<code>customer</code>/<code>business</code>).</li>
<li><code>GET /tenants/{domain}</code> → <code>businessId</code>.</li>
<li>Public pages: <code>/tenants/{domain}/...</code> (no auth) — products, <strong>user-products</strong>, blogs, portfolios, store-items, etc.</li>
<li>Cart / orders / favorites: <code>/businesses/{businessId}/...</code> + Bearer JWT.</li>
<li>Favorites (optional, if cookies exist): <code>/businesses/{businessId}/...</code> + Bearer JWT.</li>
<li>Login, checkout, server cart, orders: <strong>customer dashboard</strong> at <code>https://customer.{domain}</code> — not pages on the shop.</li>
</ol>
<h2>Shared login with customer dashboard</h2>
<p>
Do <strong>not</strong> build a login / register / OTP page on the storefront.
Send shoppers to <code>https://customer.{domain}/login</code>.
The customer dashboard writes parent-domain cookies; the shop only
<strong>reads</strong> them to detect an existing session:
</p>
<ul>
<li><code>meshkee_customer_access_token</code></li>
<li><code>meshkee_customer_refresh_token</code></li>
</ul>
<p>
Set <code>Domain=.{domain}</code>, <code>Path=/</code>, <code>SameSite=Lax</code>.
API auth is still <code>Authorization: Bearer &lt;accessToken&gt;</code> (cookies are not sent to the API).
Do not use <code>/auth/handoff</code> for shoppers (staff-only into the business dashboard).
Full detail: <a href="/docs/website/AI_PROMPT.md">AI_PROMPT.md</a>.
</p>
<h2>Shopping cart on the storefront</h2>
<p>
The shop implements a <strong>mini-cart only</strong>: header icon, quantity badge,
popup, Continue. Persist a guest cart as <code>meshkee-guest-cart</code>
(localStorage + parent-domain cookie). Each line <code>id</code> is
<code>storeItemVariantId</code>.
Add-to-cart: <code>GET /tenants/{domain}/store-items/by-product/{productId}</code>,
list variants if there is more than one, then add the chosen variant
(same id already in cart → increment quantity). Continue goes to the customer dashboard:
</p>
<ul>
<li>Logged in → <code>https://customer.{domain}/checkout/cart?guestCart=…</code></li>
<li>Not logged in → <code>https://customer.{domain}/login?redirect=/checkout/cart?guestCart=…</code></li>
</ul>
<p>
Do not call <code>/cart</code> or <code>/cart/checkout</code> from the website,
and do not add shop <code>/login</code> or <code>/checkout</code> routes.
Encoding and payload: <a href="/docs/website/AI_PROMPT.md">AI_PROMPT.md</a> (Shopping cart).
</p>
<h2>Branding (favicon + logos)</h2>
<p>
<code>GET /tenants/{domain}/website/favicon</code> returns
@@ -104,20 +143,32 @@
<h2>User products (customer listings)</h2>
<p>
Marketplace-style stock listings created by customers. Public read-only under
<code>/tenants/{domain}/user-products</code> (list / details / technical-info).
<code>/tenants/{domain}/user-products</code> (list / by-id / details / technical-info).
Storefront URLs: <code>/user-products/{id}/{pathSlug}</code>.
See OpenAPI tag <strong>User Products</strong>.
</p>
<p>
<strong>Technical details:</strong> detail responses include
<code>technicalValues</code> with <em>values only</em> (no field labels).
For a label→value specs table, call
<code>GET /tenants/{domain}/user-products/{slug}/technical-info</code>
<code>GET /tenants/{domain}/user-products/by-id/{id}/technical-info</code>
(catalog products:
<code>.../products/{slug}/technical-info</code>) and join
<code>.../products/by-id/{id}/technical-info</code>) and join
<code>form.fields[].id</code> ↔ <code>values[].fieldId</code>.
There is no public <code>product-category-variation-fields</code> route.
</p>
<h2>Torob (price comparison)</h2>
<p>
<code>POST /tenants/{domain}/torob_api/v3/products</code> is for
<strong>Torob</strong>, not storefront JavaScript. On the live shop,
nginx proxies <code>POST https://{domain}/torob_api/v3/products</code>
to that API. It only returns catalog store items when the business has
the <strong>store</strong> module enabled <em>and</em> Store settings →
Torob is on; otherwise 404.
Do not implement this path in Next.js.
</p>
<h2>For a new website AI / designer</h2>
<ol>
<li>Open <a href="/docs/website/AI_PROMPT.md">AI_PROMPT.md</a> and paste it into the AI chat.</li>
+263 -21
View File
@@ -3,7 +3,7 @@
"info": {
"title": "Meshkee Website API",
"version": "1.0.0",
"description": "Global storefront API for every Meshkee business website.\n\n**Not domain-specific.** Replace `{domain}` with the website apex (e.g. `sanihome.ir`).\n\n**Base URL:** `https://api.meshkee.com/api/v1` (or `https://api.{domain}/api/v1` if that alias is configured).\n\n**Tenant rule:** public content uses `/tenants/{domain}/...`. After login, cart/orders/favorites use `/businesses/{businessId}/...` with Bearer JWT.\n\n**Technical specs (AI agents):** Detail endpoints may return `technicalValues` with **values only** (`fieldId` + text/option ids — **no labels**). To render label→value technical details, always call the matching `.../technical-info` endpoint and join `form.fields[].id` to `values[].fieldId`. There is no public `product-category-variation-fields` route.\n\n**Docs:** https://api.meshkee.com/docs/website"
"description": "Global storefront API for every Meshkee business website.\n\n**Not domain-specific.** Replace `{domain}` with the website apex (e.g. `sanihome.ir`).\n\n**Base URL:** `https://api.meshkee.com/api/v1` (or `https://api.{domain}/api/v1` if that alias is configured).\n\n**Tenant rule:** public content uses `/tenants/{domain}/...`.\n\n**Storefront vs customer dashboard:** Do **not** build login, register, OTP, checkout, or cart pages on the shop. Those live at `https://customer.{domain}` for every Meshkee site. The website mini-cart is a **local guest cart** (`meshkee-guest-cart`); Continue redirects to the dashboard. Load variants with `GET /tenants/{domain}/store-items/by-product/{productId}` — if more than one in-stock variant, list them and let the shopper pick one before add. See AI_PROMPT.md.\n\n**Shared login:** the customer dashboard writes parent-domain cookies `meshkee_customer_access_token` / `meshkee_customer_refresh_token` on `Domain=.{domain}`. The shop only **reads** them (to skip login on Continue). API calls still use Bearer. Not `POST /auth/handoff` (staff SSO into the business dashboard).\n\n**Technical specs (AI agents):** Detail endpoints may return `technicalValues` with **values only** (`fieldId` + text/option ids — **no labels**). To render label→value technical details, always call the matching `.../technical-info` endpoint and join `form.fields[].id` to `values[].fieldId`. There is no public `product-category-variation-fields` route.\n\n**Docs:** https://api.meshkee.com/docs/website"
},
"servers": [
{
@@ -39,11 +39,15 @@
},
{
"name": "User Products",
"description": "Customer marketplace listings. Detail returns `technicalValues` without labels; use `GET .../user-products/{slug}/technical-info` for form field labels + values."
"description": "Customer marketplace listings. Prefer `GET .../user-products/by-id/{id}` for storefront pages. Detail returns `technicalValues` without labels; use `.../technical-info` for form field labels + values."
},
{
"name": "Store"
},
{
"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."
},
{
"name": "Blogs"
},
@@ -60,7 +64,8 @@
"name": "Contact"
},
{
"name": "Auth"
"name": "Auth",
"description": "Used by the **customer dashboard** (`https://customer.{domain}`), not storefront UI. Do not build login/register/OTP pages on the shop — redirect to `https://customer.{domain}/login`.\n\nAPI calls use `Authorization: Bearer <accessToken>`. The dashboard writes parent-domain cookies `meshkee_customer_access_token` / `meshkee_customer_refresh_token` on `Domain=.{domain}`; the shop only reads them. Do not use `/auth/handoff` for shoppers (staff-only into the business dashboard)."
},
{
"name": "Addresses"
@@ -69,7 +74,8 @@
"name": "Cities"
},
{
"name": "Cart"
"name": "Cart",
"description": "Server cart + checkout — **customer dashboard only**. Storefronts must not call these routes. The shop keeps a local guest mini-cart (`meshkee-guest-cart`); add-to-cart uses `storeItemVariantId` from `GET /tenants/{domain}/store-items/by-product/{productId}` (list variants if more than one, then add the chosen line)."
},
{
"name": "Orders"
@@ -82,7 +88,7 @@
},
{
"name": "Payments",
"description": "Online e-payment gateways (website checkout)"
"description": "Online e-payment gateways — customer dashboard checkout, not storefront pages. Bank callbacks are nginx on the shop apex (`/meshkee/payments/{gateway}/callback`)."
}
],
"components": {
@@ -90,7 +96,8 @@
"bearerAuth": {
"type": "http",
"scheme": "bearer",
"bearerFormat": "JWT"
"bearerFormat": "JWT",
"description": "Customer access JWT from customer-dashboard login/register/OTP/refresh. Storefronts do not obtain this via a shop login page — they read `meshkee_customer_access_token` if the dashboard already set it on `Domain=.{domain}`. Always send Bearer on API requests; cookies are not sent to the API."
},
"apiKeyAuth": {
"type": "apiKey",
@@ -188,6 +195,18 @@
"store_item"
],
"description": "Where homepage special-product carousels read from. Defaults to store_item when the store module is enabled, otherwise product."
},
"schema": {
"type": "object",
"description": "Site-wide Schema.org flags. Full jsonLd is on GET /tenants/{domain}/website/business-info.",
"properties": {
"enabled": { "type": "boolean" },
"type": {
"type": "string",
"enum": ["Organization", "LocalBusiness"]
}
},
"required": ["enabled", "type"]
}
}
}
@@ -415,7 +434,7 @@
"SEO"
],
"summary": "User products sitemap urlset",
"description": "Published customer marketplace listings. Default paths: `/user-products/{slug}`. Included in the index when the `customer_products` module is enabled. Override path via sitemap-config `templates.userProduct`.",
"description": "Published customer marketplace listings. Default paths: `/user-products/{id}/{slug}` (slug from titleFa/titleEn). Included in the index when the `customer_products` module is enabled. Override path via sitemap-config `templates.userProduct`.",
"parameters": [
{
"$ref": "#/components/parameters/domain"
@@ -523,7 +542,64 @@
}
},
"socialMedia": { "type": "object" },
"addresses": { "type": "array", "items": { "type": "object" } }
"addresses": { "type": "array", "items": { "type": "object" } },
"schema": {
"type": "object",
"description": "Site-wide Schema.org settings + ready-to-emit jsonLd (Phase A).",
"properties": {
"enabled": { "type": "boolean" },
"type": {
"type": "string",
"enum": ["Organization", "LocalBusiness"]
},
"name": { "type": "string", "nullable": true },
"nameFa": { "type": "string", "nullable": true },
"description": { "type": "string", "nullable": true },
"priceRange": { "type": "string", "nullable": true },
"sameAs": {
"type": "array",
"items": { "type": "string" }
},
"jsonLd": {
"type": "object",
"nullable": true,
"description": "Null when schema.enabled is false. Otherwise emit as application/ld+json on every public page (or at least the layout)."
}
},
"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"]
}
}
}
}
}
@@ -587,7 +663,7 @@
"post": {
"tags": ["Analytics"],
"summary": "Record a storefront page view",
"description": "Optional explicit view counter. Most page views are recorded automatically when public list/detail APIs are called (blogs, products, portfolios, videos, store-items, homepage sliders). Use this for custom pages. No auth required. Events retained ~6 months; lifetime counters kept forever.",
"description": "Optional explicit view counter. Most page views are recorded automatically on: (1) GET website/static-images?pageKey=home → home, (2) GET static-images?pageKey=about|contact|… → static_page, (3) public detail GETs for products, user-products, blogs, portfolios, videos, store-items. List APIs and homepage sliders do not count. Known bots / empty User-Agent are skipped. Refresh counts again. Use this POST for custom pages that do not hit those endpoints. No auth required. Events retained ~6 months; lifetime counters kept forever.",
"parameters": [
{
"$ref": "#/components/parameters/domain"
@@ -605,6 +681,7 @@
"type": "string",
"enum": [
"home",
"static_page",
"portfolio_list",
"portfolio_detail",
"product_list",
@@ -615,20 +692,22 @@
"blog_detail",
"video_list",
"video_detail",
"user_product_list",
"user_product_detail",
"website",
"product",
"portfolio",
"blog"
],
"description": "Canonical kinds preferred. Legacy aliases: website→home, product→product_detail, portfolio→portfolio_detail, blog→blog_detail."
"description": "Canonical kinds preferred. Prefer home, static_page, and *_detail for page visits. Legacy aliases: website→home, product→product_detail, portfolio→portfolio_detail, blog→blog_detail."
},
"entityId": {
"type": "string",
"description": "Required for *_detail kinds (product, portfolio, blog, video, store item)."
"description": "Required for *_detail kinds (product, user product, portfolio, blog, video, store item)."
},
"path": {
"type": "string",
"description": "Optional page path (e.g. `/`, `/blog/my-post`)."
"description": "Optional page path (e.g. `/`, `/about`, `/blog/my-post`). Recommended for static_page."
}
}
}
@@ -637,13 +716,14 @@
},
"responses": {
"200": {
"description": "View recorded",
"description": "View recorded (or skipped for bots)",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"ok": { "type": "boolean", "enum": [true] }
"ok": { "type": "boolean", "enum": [true] },
"skipped": { "type": "string", "enum": ["bot"] }
}
}
}
@@ -994,7 +1074,7 @@
],
"responses": {
"200": {
"description": "{ product, relatedProducts } — same shape as product-by-slug"
"description": "{ product, relatedProducts }. `product.schema.jsonLd` is Product Schema.org JSON-LD when website structured data is enabled (null otherwise). Related products omit schema."
}
}
}
@@ -1073,7 +1153,7 @@
],
"responses": {
"200": {
"description": "{ product, relatedProducts } — relatedProducts uses the product list-card shape (incl. store). Ranked by relativity (same category, then same brand) with availability beside it (in-stock first within each group). Up to 8 items; excludes the current product."
"description": "{ product, relatedProducts }. `product.schema.jsonLd` is Product Schema.org JSON-LD when website structured data is enabled (null otherwise). Related products omit schema. relatedProducts uses the product list-card shape (incl. store). Ranked by relativity (same category, then same brand) with availability beside it (in-stock first within each group). Up to 8 items; excludes the current product."
}
}
}
@@ -1218,7 +1298,64 @@
],
"responses": {
"200": {
"description": "{ items: UserProductListItem[], total, page, pageSize }. Each item includes id, slug, titleFa/titleEn, price, priceCurrency, condition, city/country names, imageUrl, category*, promoted, publishedAt."
"description": "{ items: UserProductListItem[], total, page, pageSize }. Each item includes id, slug, pathSlug, titleFa/titleEn, price, priceCurrency, condition, city/country names, imageUrl, category*, promoted, publishedAt."
}
}
}
},
"/tenants/{domain}/user-products/by-id/{productId}": {
"get": {
"tags": [
"User Products"
],
"summary": "User product by id (preferred for /user-products/{id}/{pathSlug} pages)",
"description": "Full published listing resolved by id. Prefer this for storefront detail pages — the path slug segment is SEO-only.\n\n**Important:** `technicalValues` items are **values only**. For a label→value table, call `GET /tenants/{domain}/user-products/by-id/{productId}/technical-info`.",
"parameters": [
{
"$ref": "#/components/parameters/domain"
},
{
"name": "productId",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "{ product } with gallery, pathSlug, technicalValues (values only), countryId, cityId"
},
"404": {
"description": "Not found or not published"
}
}
}
},
"/tenants/{domain}/user-products/by-id/{productId}/technical-info": {
"get": {
"tags": [
"User Products"
],
"summary": "User product technical form + values by id",
"description": "**Use this for the listing specs UI** when the page is resolved by id. Same shape as the slug technical-info route.",
"parameters": [
{
"$ref": "#/components/parameters/domain"
},
{
"name": "productId",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "{ form: { id, categoryId, fields: [...] } | null, values: [...] }"
}
}
}
@@ -1422,6 +1559,111 @@
}
}
},
"/tenants/{domain}/torob_api/v3/products": {
"post": {
"tags": [
"Torob"
],
"summary": "Torob Product API v3 (store module only)",
"description": "Torob POSTs here (or to `https://{domain}/torob_api/v3/products` which nginx proxies). JWT in `X-Torob-Token` (EdDSA, aud = shop host). Returns 404 when the tenant does not have the **store** module. Page size is 100.",
"parameters": [
{
"$ref": "#/components/parameters/domain"
},
{
"name": "X-Torob-Token",
"in": "header",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "X-Torob-Token-Version",
"in": "header",
"schema": {
"type": "string",
"example": "1"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"oneOf": [
{
"type": "object",
"required": [
"page",
"sort"
],
"properties": {
"page": {
"type": "integer",
"minimum": 1
},
"sort": {
"type": "string",
"enum": [
"date_added_desc",
"date_updated_desc"
]
}
}
},
{
"type": "object",
"required": [
"page_urls"
],
"properties": {
"page_urls": {
"type": "array",
"minItems": 1,
"items": {
"type": "string"
}
}
}
},
{
"type": "object",
"required": [
"page_uniques"
],
"properties": {
"page_uniques": {
"type": "array",
"minItems": 1,
"items": {
"type": "string"
}
}
}
}
]
}
}
}
},
"responses": {
"200": {
"description": "{ api_version: torob_api_v3, current_page, total, max_pages, products[] }"
},
"400": {
"description": "{ error: string }"
},
"401": {
"description": "Invalid or missing Torob JWT"
},
"404": {
"description": "Unknown domain, store module off, or Torob switch off in store settings"
}
}
}
},
"/tenants/{domain}/blogs": {
"get": {
"tags": [
@@ -1494,7 +1736,7 @@
"tags": [
"Blogs"
],
"summary": "Blog by id (preferred for /blog/{id}/{titleSlug} pages)",
"summary": "Blog by id (legacy; prefer slug routes `/blog/{slug}`)",
"parameters": [
{
"$ref": "#/components/parameters/domain"
@@ -1510,7 +1752,7 @@
],
"responses": {
"200": {
"description": "{ blog }"
"description": "{ blog }. `blog.schema.jsonLd` is BlogPosting Schema.org JSON-LD when website structured data is enabled (null otherwise)."
}
}
}
@@ -1536,7 +1778,7 @@
],
"responses": {
"200": {
"description": "{ blog }"
"description": "{ blog }. `blog.schema.jsonLd` is BlogPosting Schema.org JSON-LD when website structured data is enabled (null otherwise)."
}
}
}
@@ -1847,7 +2089,7 @@
"tags": [
"Portfolios"
],
"summary": "Portfolio by id (preferred for /portfolios/{id}/{titleFaSlug} pages)",
"summary": "Portfolio by id (legacy; prefer slug routes `/portfolio/{slug}`)",
"parameters": [
{
"$ref": "#/components/parameters/domain"
+265 -5
View File
@@ -50,6 +50,8 @@ model User {
transactions Transaction[] @relation("TransactionCustomer")
userProducts UserProduct[]
userRoles UserRole[]
smsTemplatesVerified SmsTemplate[] @relation("SmsTemplateVerifier")
rollCalls RollCall[]
@@index([cellNumber], map: "idx_users_cell_number")
@@map("users")
@@ -69,6 +71,28 @@ model UserTitle {
@@map("user_titles")
}
model SmsTemplate {
id BigInt @id @default(autoincrement())
businessId BigInt @map("business_id")
name String @db.VarChar(255)
body String @db.Text
verificationStatus String @default("free") @map("verification_status") @db.VarChar(32)
isVerified Boolean @default(false) @map("is_verified")
verifiedAt DateTime? @map("verified_at") @db.Timestamptz(6)
verifiedBy BigInt? @map("verified_by")
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)
verifier User? @relation("SmsTemplateVerifier", fields: [verifiedBy], references: [id], onDelete: SetNull, onUpdate: NoAction)
@@index([businessId], map: "idx_sms_templates_business_id")
@@index([businessId, isVerified], map: "idx_sms_templates_business_verified")
@@index([verificationStatus], map: "idx_sms_templates_verification_status")
@@index([businessId, verificationStatus], map: "idx_sms_templates_business_status")
@@index([createdAt(sort: Desc)], map: "idx_sms_templates_created_at")
@@map("sms_templates")
}
model Business {
id BigInt @id @default(autoincrement())
name String @db.VarChar(255)
@@ -87,17 +111,27 @@ model Business {
logoMediaId BigInt? @map("logo_media_id")
logoDarkMediaId BigInt? @map("logo_dark_media_id")
faviconMediaId BigInt? @map("favicon_media_id")
bannerMediaId BigInt? @map("banner_media_id")
oldBusinessId BigInt? @map("old_business_id")
annualRenewalAt DateTime? @map("annual_renewal_at") @db.Timestamptz(6)
smsBalance Int @default(0) @map("sms_balance")
smsSentCount Int @default(0) @map("sms_sent_count")
smsSentMass Int @default(0) @map("sms_sent_mass")
smsSentSingular Int @default(0) @map("sms_sent_singular")
smsSentAuth Int @default(0) @map("sms_sent_auth")
smsSentAutoCustomer Int @default(0) @map("sms_sent_auto_customer")
smsSentAdmin Int @default(0) @map("sms_sent_admin")
addresses Address[]
blogs blogs[]
brands Brand[]
categoryAssignments BusinessCategoryAssignment[]
businessCustomers BusinessCustomer[]
businessUsers BusinessUser[]
smsTemplates SmsTemplate[]
faviconMedia Media? @relation("BusinessFavicon", fields: [faviconMediaId], references: [id], onUpdate: NoAction)
logoMedia Media? @relation("BusinessLogo", fields: [logoMediaId], references: [id], onUpdate: NoAction)
logoDarkMedia Media? @relation("BusinessLogoDark", fields: [logoDarkMediaId], references: [id], onUpdate: NoAction)
bannerMedia Media? @relation("BusinessBanner", fields: [bannerMediaId], references: [id], onUpdate: NoAction)
carts Cart[]
categories Category[]
contentCategoryAssignments CategoryAssignment[]
@@ -106,9 +140,11 @@ model Business {
comments Comment[]
contactSubmissions ContactSubmission[]
domains Domain[]
entityTranslations EntityTranslation[]
expertReviews ExpertReview[]
favorites Favorite[]
invoiceItemTemplates InvoiceItemTemplate[]
invoiceAccountTemplates InvoiceAccountTemplate[]
invoiceTemplates InvoiceTemplate[]
invoices Invoice[] @relation("InvoiceBusiness")
invoicesIssued Invoice[] @relation("InvoiceIssuerBusiness")
@@ -125,6 +161,8 @@ model Business {
shoppingCards ShoppingCard[]
storeItemVariants StoreItemVariant[]
storeItems StoreItem[]
storeLoanMethods StoreLoanMethod[]
shippingCostGroups ShippingCostGroup[]
storeSpecials StoreSpecial[]
transactions Transaction[]
userProductTechnicalFieldValues UserProductTechnicalFieldValue[]
@@ -135,6 +173,7 @@ model Business {
website_sliders website_sliders[]
websitePageViews WebsitePageView[]
websitePageViewCounters WebsitePageViewCounter[]
rollCalls RollCall[]
@@map("businesses")
}
@@ -143,11 +182,13 @@ model BusinessCategory {
id BigInt @id @default(autoincrement())
parentId BigInt? @map("parent_id")
name String @db.VarChar(255)
nameFa String? @map("name_fa") @db.VarChar(255)
slug String @unique(map: "business_categories_slug_unique") @db.VarChar(255)
description String?
icon String? @db.VarChar(100)
sortOrder Int @default(0) @map("sort_order")
isActive Boolean @default(true) @map("is_active")
tags String[] @default([])
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
parent BusinessCategory? @relation("BusinessCategoryTree", fields: [parentId], references: [id], onUpdate: NoAction)
@@ -188,6 +229,8 @@ model Domain {
lastDeployStatus String? @map("last_deploy_status") @db.VarChar(32)
deploySlug String? @map("deploy_slug") @db.VarChar(64)
gitRepoUrl String? @map("git_repo_url") @db.VarChar(512)
parkedHosts String[] @default([]) @map("parked_hosts")
parkedDns Json @default("{}") @map("parked_dns")
aiPromptsPublicId String? @unique(map: "domains_ai_prompts_public_id_unique") @map("ai_prompts_public_id") @db.VarChar(32)
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction)
aiPrompts DomainAiPrompt[]
@@ -340,9 +383,11 @@ model Media {
oldId BigInt? @map("old_id")
blogs blogs[]
brandImages Brand[]
storeLoanMethodImages StoreLoanMethod[]
faviconBusinesses Business[] @relation("BusinessFavicon")
logoBusinesses Business[] @relation("BusinessLogo")
logoDarkBusinesses Business[] @relation("BusinessLogoDark")
bannerBusinesses Business[] @relation("BusinessBanner")
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction)
uploader User? @relation(fields: [uploadedBy], references: [id], onUpdate: NoAction)
attachments MediaAttachment[]
@@ -351,6 +396,7 @@ model Media {
instructions instructions[]
workshops workshops[]
featuredProducts Product[] @relation("ProductFeaturedMedia")
catalogProducts Product[] @relation("ProductCatalogMedia")
featuredUserProducts UserProduct[] @relation("UserProductFeaturedMedia")
website_image_slot_items website_image_slot_items[]
website_slider_slides website_slider_slides[]
@@ -393,12 +439,14 @@ model Category {
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
nameFa String? @map("name_fa") @db.VarChar(255)
oldId BigInt? @map("old_id")
metadata Json @default("{}")
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction)
parent Category? @relation("CategoryTree", fields: [parentId], references: [id], onUpdate: NoAction)
children Category[] @relation("CategoryTree")
assignments CategoryAssignment[]
technicalForm CategoryTechnicalForm?
variations CategoryVariation[]
shippingCostGroupCategories ShippingCostGroupCategory[]
website_category_group_items website_category_group_items[]
@@unique([businessId, entityType, slug], map: "categories_business_entity_slug_unique")
@@ -481,6 +529,7 @@ model Product {
stockQuantity Int? @map("stock_quantity")
status ContentStatus @default(draft)
featuredMediaId BigInt? @map("featured_media_id")
catalogMediaId BigInt? @map("catalog_media_id")
sortOrder Int @default(0) @map("sort_order")
publishedAt DateTime? @map("published_at") @db.Timestamptz(6)
metadata Json @default("{}")
@@ -498,6 +547,7 @@ model Product {
brand Brand? @relation(fields: [brandId], references: [id], onUpdate: NoAction)
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction)
featuredMedia Media? @relation("ProductFeaturedMedia", fields: [featuredMediaId], references: [id], onUpdate: NoAction)
catalogMedia Media? @relation("ProductCatalogMedia", fields: [catalogMediaId], references: [id], onUpdate: NoAction)
shoppingCardItems ShoppingCardItem[]
storeItem StoreItem[]
@@ -506,6 +556,7 @@ model Product {
@@index([brandId], map: "idx_products_brand_id")
@@index([businessId, status], map: "idx_products_business_status")
@@index([businessId, publishedAt(sort: Desc)], map: "idx_products_business_published")
@@index([catalogMediaId], map: "idx_products_catalog_media_id")
@@map("products")
}
@@ -896,6 +947,7 @@ model City {
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
parent City? @relation("CityTree", fields: [parentId], references: [id], onDelete: Cascade, onUpdate: NoAction)
children City[] @relation("CityTree")
shippingCostCityRates ShippingCostGroupCityRate[]
userProductsAsCity UserProduct[] @relation("UserProductCity")
userProductsAsCountry UserProduct[] @relation("UserProductCountry")
userProductsAsDistrict UserProduct[] @relation("UserProductDistrict")
@@ -1101,6 +1153,7 @@ model StoreItem {
businessId BigInt @map("business_id")
productId BigInt @map("product_id")
isActive Boolean @default(true) @map("is_active")
isUsed Boolean @default(false) @map("is_used")
sortOrder Int @default(0) @map("sort_order")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
@@ -1112,6 +1165,7 @@ model StoreItem {
@@unique([businessId, productId], map: "store_items_business_product_unique")
@@index([businessId], map: "idx_store_items_business_id")
@@index([productId], map: "idx_store_items_product_id")
@@index([businessId, isUsed], map: "idx_store_items_business_is_used")
@@map("store_items")
}
@@ -1177,6 +1231,84 @@ model StoreSpecial {
@@map("store_specials")
}
model StoreLoanMethod {
id BigInt @id @default(autoincrement())
businessId BigInt @map("business_id")
nameFa String @map("name") @db.VarChar(255)
nameEn String @map("name_en") @db.VarChar(255)
description String?
imageMediaId BigInt? @map("image_media_id")
imageAspectRatio String @default("1:1") @map("image_aspect_ratio") @db.VarChar(10)
minShoppingAmount Decimal @map("min_shopping_amount") @db.Decimal(12, 2)
minAmount Decimal @map("min_amount") @db.Decimal(12, 2)
maxAmount Decimal @map("max_amount") @db.Decimal(12, 2)
interest Decimal @db.Decimal(8, 4)
returnMonthsMin Int @map("return_months_min")
returnMonthsMax Int @map("return_months_max")
returnMonthsStep Int @default(3) @map("return_months_step")
sortOrder Int @default(0) @map("sort_order")
isActive Boolean @default(true) @map("is_active")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction)
imageMedia Media? @relation(fields: [imageMediaId], references: [id], onUpdate: NoAction)
@@index([businessId], map: "idx_store_loan_methods_business_id")
@@index([imageMediaId], map: "idx_store_loan_methods_image_media_id")
@@index([businessId, sortOrder], map: "idx_store_loan_methods_business_sort_order")
@@map("store_loan_methods")
}
model ShippingCostGroup {
id BigInt @id @default(autoincrement())
businessId BigInt @map("business_id")
nameFa String @map("name") @db.VarChar(255)
nameEn String @map("name_en") @db.VarChar(255)
description String?
methods String[] @default([])
defaultAmount Decimal @default(0) @map("default_amount") @db.Decimal(12, 2)
sortOrder Int @default(0) @map("sort_order")
isActive Boolean @default(true) @map("is_active")
isDefault Boolean @default(false) @map("is_default")
pasKraye Boolean @default(false) @map("pas_kraye")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction)
cityRates ShippingCostGroupCityRate[]
categories ShippingCostGroupCategory[]
@@index([businessId], map: "idx_shipping_cost_groups_business_id")
@@index([businessId, sortOrder], map: "idx_shipping_cost_groups_business_sort_order")
@@map("shipping_cost_groups")
}
model ShippingCostGroupCityRate {
id BigInt @id @default(autoincrement())
shippingCostGroupId BigInt @map("shipping_cost_group_id")
cityId BigInt @map("city_id")
amount Decimal @db.Decimal(12, 2)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
shippingCostGroup ShippingCostGroup @relation(fields: [shippingCostGroupId], references: [id], onDelete: Cascade, onUpdate: NoAction)
city City @relation(fields: [cityId], references: [id], onDelete: Cascade, onUpdate: NoAction)
@@unique([shippingCostGroupId, cityId], map: "shipping_cost_group_city_rates_unique")
@@index([shippingCostGroupId], map: "idx_shipping_cost_group_city_rates_group_id")
@@index([cityId], map: "idx_shipping_cost_group_city_rates_city_id")
@@map("shipping_cost_group_city_rates")
}
model ShippingCostGroupCategory {
shippingCostGroupId BigInt @map("shipping_cost_group_id")
categoryId BigInt @map("category_id")
shippingCostGroup ShippingCostGroup @relation(fields: [shippingCostGroupId], references: [id], onDelete: Cascade, onUpdate: NoAction)
category Category @relation(fields: [categoryId], references: [id], onDelete: Cascade, onUpdate: NoAction)
@@id([shippingCostGroupId, categoryId])
@@index([categoryId], map: "idx_shipping_cost_group_categories_category_id")
@@map("shipping_cost_group_categories")
}
model website_brand_group_items {
id BigInt @id @default(autoincrement())
group_id BigInt
@@ -1328,6 +1460,25 @@ model InvoiceItemTemplate {
@@map("invoice_item_templates")
}
model InvoiceAccountTemplate {
id BigInt @id @default(autoincrement())
ownerScope InvoiceOwnerScope @map("owner_scope")
businessId BigInt? @map("business_id")
bankName String @map("bank_name") @db.VarChar(255)
accountHolderName String? @map("account_holder_name") @db.VarChar(255)
cardNumber String? @map("card_number") @db.VarChar(64)
iban String? @db.VarChar(64)
sortOrder Int @default(0) @map("sort_order")
isActive Boolean @default(true) @map("is_active")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
business Business? @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction)
@@index([ownerScope, sortOrder], map: "idx_invoice_account_templates_owner_scope")
@@index([businessId], map: "idx_invoice_account_templates_business_id")
@@map("invoice_account_templates")
}
model InvoiceTemplate {
id BigInt @id @default(autoincrement())
ownerScope InvoiceOwnerScope @map("owner_scope")
@@ -1338,11 +1489,12 @@ model InvoiceTemplate {
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)
accounts InvoiceTemplateAccount[]
items InvoiceTemplateItem[]
keyPoints InvoiceTemplateKeyPoint[]
business Business? @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction)
invoices Invoice[]
accounts InvoiceTemplateAccount[]
items InvoiceTemplateItem[]
keyPoints InvoiceTemplateKeyPoint[]
paymentMethods InvoiceTemplatePaymentMethod[]
business Business? @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction)
invoices Invoice[]
@@index([ownerScope, sortOrder], map: "idx_invoice_templates_owner_scope")
@@map("invoice_templates")
@@ -1381,6 +1533,19 @@ model InvoiceTemplateKeyPoint {
@@map("invoice_template_key_points")
}
model InvoiceTemplatePaymentMethod {
id BigInt @id @default(autoincrement())
templateId BigInt @map("template_id")
text String
sortOrder Int @default(0) @map("sort_order")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
template InvoiceTemplate @relation(fields: [templateId], references: [id], onDelete: Cascade, onUpdate: NoAction)
@@index([templateId, sortOrder], map: "idx_invoice_template_payment_methods_template_id")
@@map("invoice_template_payment_methods")
}
model InvoiceTemplateAccount {
id BigInt @id @default(autoincrement())
templateId BigInt @map("template_id")
@@ -1416,6 +1581,7 @@ model Invoice {
accounts InvoiceAccount[]
items InvoiceItem[]
keyPoints InvoiceKeyPoint[]
paymentMethods InvoicePaymentMethod[]
business Business @relation("InvoiceBusiness", fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction)
invoiceTemplate InvoiceTemplate? @relation(fields: [invoiceTemplateId], references: [id], onUpdate: NoAction)
issuer User? @relation("InvoiceIssuer", fields: [issuedBy], references: [id], onUpdate: NoAction)
@@ -1462,6 +1628,19 @@ model InvoiceKeyPoint {
@@map("invoice_key_points")
}
model InvoicePaymentMethod {
id BigInt @id @default(autoincrement())
invoiceId BigInt @map("invoice_id")
text String
sortOrder Int @default(0) @map("sort_order")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
invoice Invoice @relation(fields: [invoiceId], references: [id], onDelete: Cascade, onUpdate: NoAction)
@@index([invoiceId, sortOrder], map: "idx_invoice_payment_methods_invoice_id")
@@map("invoice_payment_methods")
}
model InvoiceAccount {
id BigInt @id @default(autoincrement())
invoiceId BigInt @map("invoice_id")
@@ -1563,6 +1742,7 @@ model UserProduct {
enum MediaType {
image
video
document
@@map("media_type")
}
@@ -1576,6 +1756,7 @@ enum MediaEntityType {
video
instruction
workshop
gallery
@@map("media_entity_type")
}
@@ -1617,6 +1798,7 @@ enum TechnicalFieldType {
textarea
select
multi_select
multi_value
@@map("technical_field_type")
}
@@ -1704,6 +1886,9 @@ enum WebsitePageKind {
blog_detail
video_list
video_detail
user_product_list
user_product_detail
static_page
@@map("website_page_kind")
}
@@ -1736,3 +1921,78 @@ model WebsitePageViewCounter {
@@index([businessId, pageKind], map: "idx_website_page_view_counters_business_kind")
@@map("website_page_view_counters")
}
model EntityTranslation {
id BigInt @id @default(autoincrement())
businessId BigInt @map("business_id")
entityType TranslationEntityType @map("entity_type")
entityId BigInt @map("entity_id")
locale TranslationLocale
fields Json @default("{}")
sourceHash String @map("source_hash") @db.VarChar(64)
status TranslationStatus @default(pending)
generatedBy String @default("ai") @map("generated_by") @db.VarChar(16)
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)
@@unique([entityType, entityId, locale], map: "entity_translations_entity_locale_unique")
@@index([businessId], map: "idx_entity_translations_business_id")
@@index([entityType, entityId], map: "idx_entity_translations_entity")
@@map("entity_translations")
}
enum TranslationEntityType {
product
category
@@map("translation_entity_type")
}
enum TranslationLocale {
en
ar
@@map("translation_locale")
}
enum TranslationStatus {
pending
ready
stale
failed
@@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")
}
+15
View File
@@ -7,11 +7,14 @@
# GET /deploy-status?slug=… — last deploy status JSON for slug
# POST /provision { slug, host, gitRepoUrl } — clone + nginx + ecosystem + allowlist (no certbot)
# POST /ssl { host, slug? } — certbot for apex + www (nginx must exist)
# POST /park { host, canonicalHost } — nginx 301 apex+www → https://canonical
# POST /unpark { host } — remove parked nginx site
# GET /health
#
# Scripts on the VM (same folder):
# patch-nginx-seo.sh <host> — add /sitemap.xml + /robots.txt API proxies to an existing nginx site
# patch-nginx-pay.sh <host> — add /meshkee/payments/{zarinpal|mellat}/callback API proxies
# patch-nginx-torob.sh <host> — add /torob_api/v3/products API proxy (store-module tenants)
#
# Env (.env): PORT, DEPLOY_TOKEN, ALLOWED_SLUGS
#
@@ -20,3 +23,15 @@
# deploy.sh checks out origin/HEAD (falls back to main, then master).
# If the site builds with Next `output: 'standalone'`, deploy copies static/public
# into `.next/standalone`, points PM2 at `server.js`, and removes full `node_modules`.
# Vinext apps (`vinext` in package.json / `vinext start`) have no `.next` production
# build — deploy runs `vinext start` against `dist/` instead of `next start`.
# A build that produces neither standalone, `.next/BUILD_ID`, nor Vinext `dist/`
# is marked failed (avoids nginx 502 from a crash-looping `next start`).
#
# REQUIRED for all Meshkee Next storefronts: next.config must include
# output: "standalone"
# Without it, PM2 runs `next start` + full node_modules (~150–500MB RSS each) and
# the shared websites VM OOMs. Target ~80–120MB RSS per site via standalone.
# Paste for website-building agents:
# "Always set output: 'standalone' in next.config. Deploy expects .next/standalone
# and deletes node_modules. Never ship next start / full node_modules runtime."
+49 -6
View File
@@ -212,16 +212,45 @@ NODE
exit 0
fi
echo "no standalone output — using next start (legacy)"
# Vinext (Vite production server) or legacy `next start`.
# Detect Vinext from package.json / CLI — do not assume every Next-looking
# app has a `.next` production build (vinext writes `dist/` instead).
uses_vinext=false
if node -e '
const fs = require("fs");
const p = JSON.parse(fs.readFileSync("package.json", "utf8"));
const d = Object.assign({}, p.dependencies || {}, p.devDependencies || {});
const s = String((p.scripts || {}).start || "");
const ok = Boolean(d.vinext) || s.includes("vinext") || fs.existsSync("node_modules/vinext/dist/cli.js");
process.exit(ok ? 0 : 1);
'; then
uses_vinext=true
fi
if $uses_vinext; then
if [[ ! -d dist ]]; then
echo "vinext project but dist/ is missing — build did not produce a production bundle"
write_status "failed" "vinext build produced no dist/"
exit 1
fi
echo "no Next standalone output — using vinext start"
elif [[ -f .next/BUILD_ID ]]; then
echo "no standalone output — using next start (legacy)"
else
echo "build produced neither .next/standalone, Vinext dist/, nor .next/BUILD_ID"
write_status "failed" "no production server output (need Next standalone, BUILD_ID, or vinext dist/)"
exit 1
fi
# Reset cwd/script in case a previous deploy used standalone. Do not
# `pm2 restart --update-env`: the deploy agent has PORT=9050 and that
# leaks into the site, so nginx (site port) gets 502.
SLUG="$SLUG" ROOT="$ROOT" ECOSYSTEM="$ECOSYSTEM" node <<'NODE'
VINEXT="$uses_vinext" SLUG="$SLUG" ROOT="$ROOT" ECOSYSTEM="$ECOSYSTEM" node <<'NODE'
const fs = require('fs');
const path = process.env.ECOSYSTEM;
const slug = process.env.SLUG;
const root = process.env.ROOT;
const usesVinext = process.env.VINEXT === 'true';
let cfg = { apps: [] };
try {
delete require.cache[require.resolve(path)];
@@ -243,7 +272,9 @@ const port =
[fromEnv, fromArgs].find((p) => Number.isFinite(p) && p > 0 && p !== 9050) || 3005;
app.cwd = root;
app.script = 'node_modules/next/dist/bin/next';
app.script = usesVinext
? 'node_modules/vinext/dist/cli.js'
: 'node_modules/next/dist/bin/next';
app.args = 'start --hostname 127.0.0.1 --port ' + port;
app.env = {
NODE_ENV: 'production',
@@ -276,13 +307,25 @@ lines.push(' ],');
lines.push('};');
lines.push('');
fs.writeFileSync(path, lines.join('\n'));
console.log('ecosystem updated for next start:', slug, 'port', port);
console.log(
'ecosystem updated for',
usesVinext ? 'vinext start' : 'next start',
':',
slug,
'port',
port,
);
NODE
pm2 delete "$SLUG" || true
pm2 start "$ECOSYSTEM" --only "$SLUG"
pm2 save || true
echo "==== $(date -u +%Y-%m-%dT%H:%M:%SZ) deploy ok: $SLUG ===="
write_status "success" "Deployed origin/$BRANCH"
if $uses_vinext; then
echo "==== $(date -u +%Y-%m-%dT%H:%M:%SZ) deploy ok (vinext): $SLUG ===="
write_status "success" "Deployed origin/$BRANCH (vinext)"
else
echo "==== $(date -u +%Y-%m-%dT%H:%M:%SZ) deploy ok: $SLUG ===="
write_status "success" "Deployed origin/$BRANCH"
fi
trap - ERR
+83
View File
@@ -0,0 +1,83 @@
#!/usr/bin/env bash
# Parked alias: nginx 301 from parked apex+www to the canonical storefront.
# SSL is issued separately via ssl.sh (certbot --nginx) so the cert is in
# certbot's auto-renewal list.
set -euo pipefail
HOST="${1:-}"
CANONICAL="${2:-}"
if [[ -z "$HOST" || -z "$CANONICAL" ]]; then
echo "usage: park.sh <parked-host> <canonical-host>" >&2
exit 1
fi
if [[ ! "$HOST" =~ ^[a-z0-9.-]+$ || ! "$CANONICAL" =~ ^[a-z0-9.-]+$ ]]; then
echo "invalid host" >&2
exit 1
fi
NGINX_AVAILABLE="/etc/nginx/sites-available/$HOST"
NGINX_ENABLED="/etc/nginx/sites-enabled/$HOST"
SSL_DIR="/etc/nginx/ssl/$HOST"
LIST_FILE="/opt/websites-agent/parked-hosts.txt"
mkdir -p "$SSL_DIR" /opt/websites-agent /var/www/certbot
if [[ ! -f "$SSL_DIR/fullchain.pem" || ! -f "$SSL_DIR/privkey.pem" ]]; then
openssl req -x509 -nodes -newkey rsa:2048 -days 825 \
-keyout "$SSL_DIR/privkey.pem" \
-out "$SSL_DIR/fullchain.pem" \
-subj "/CN=$HOST" \
-addext "subjectAltName=DNS:$HOST,DNS:www.$HOST" \
>/dev/null 2>&1
echo "origin self-signed cert created: $SSL_DIR"
fi
# If certbot already issued a live cert, prefer it.
LE_LIVE="/etc/letsencrypt/live/$HOST"
if [[ -f "$LE_LIVE/fullchain.pem" && -f "$LE_LIVE/privkey.pem" ]]; then
CERT="$LE_LIVE/fullchain.pem"
KEY="$LE_LIVE/privkey.pem"
else
CERT="$SSL_DIR/fullchain.pem"
KEY="$SSL_DIR/privkey.pem"
fi
cat >"$NGINX_AVAILABLE" <<NGINX
server {
listen 80;
listen [::]:80;
server_name ${HOST} www.${HOST};
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://${CANONICAL}\$request_uri;
}
}
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name ${HOST} www.${HOST};
ssl_certificate ${CERT};
ssl_certificate_key ${KEY};
return 301 https://${CANONICAL}\$request_uri;
}
NGINX
ln -sfn "$NGINX_AVAILABLE" "$NGINX_ENABLED"
nginx -t
systemctl reload nginx
touch "$LIST_FILE"
if ! grep -qxF "$HOST" "$LIST_FILE" 2>/dev/null; then
echo "$HOST" >>"$LIST_FILE"
fi
echo "park ok: $HOST → https://$CANONICAL (apex+www)"
+58
View File
@@ -0,0 +1,58 @@
#!/usr/bin/env bash
# Insert / update the Torob Product API v3 proxy on a storefront nginx site.
# Torob POSTs https://{host}/torob_api/v3/products (JWT aud = shop host).
set -euo pipefail
HOST="${1:-}"
API_HOST="${MESHKEE_API_HOST:-api.meshkee.com}"
if [[ -z "$HOST" ]]; then
echo "usage: patch-nginx-torob.sh <host>" >&2
exit 1
fi
NGINX_AVAILABLE="/etc/nginx/sites-available/$HOST"
if [[ ! -f "$NGINX_AVAILABLE" ]]; then
echo "missing nginx site for $HOST" >&2
exit 1
fi
PATH_LOC="/torob_api/v3/products"
if grep -qF "location = ${PATH_LOC}" "$NGINX_AVAILABLE"; then
echo "already present: ${PATH_LOC}"
exit 0
fi
block="$(cat <<NGINX
location = ${PATH_LOC} {
proxy_pass https://${API_HOST}/api/v1/tenants/${HOST}/torob_api/v3/products;
proxy_set_header Host ${API_HOST};
proxy_ssl_server_name on;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme;
proxy_set_header X-Torob-Token \$http_x_torob_token;
proxy_set_header X-Torob-Token-Version \$http_x_torob_token_version;
proxy_pass_request_body on;
proxy_pass_request_headers on;
}
NGINX
)"
tmp="$(mktemp)"
awk -v block="$block" '
/location \/ \{/ && !done {
print block
done = 1
}
{ print }
' "$NGINX_AVAILABLE" >"$tmp"
mv "$tmp" "$NGINX_AVAILABLE"
echo "added: ${PATH_LOC}"
nginx -t
systemctl reload nginx
echo "patched nginx Torob product API location for $HOST"
+16
View File
@@ -252,6 +252,19 @@ PROXY_COMMON=$(cat <<PROXY
proxy_pass_request_headers on;
}
location = /torob_api/v3/products {
proxy_pass https://api.meshkee.com/api/v1/tenants/${HOST}/torob_api/v3/products;
proxy_set_header Host api.meshkee.com;
proxy_ssl_server_name on;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme;
proxy_set_header X-Torob-Token \$http_x_torob_token;
proxy_set_header X-Torob-Token-Version \$http_x_torob_token_version;
proxy_pass_request_body on;
proxy_pass_request_headers on;
}
location / {
proxy_pass http://127.0.0.1:${PORT};
proxy_http_version 1.1;
@@ -300,6 +313,9 @@ else
if [[ -x "$SCRIPT_DIR/patch-nginx-pay.sh" ]]; then
"$SCRIPT_DIR/patch-nginx-pay.sh" "$HOST" || true
fi
if [[ -x "$SCRIPT_DIR/patch-nginx-torob.sh" ]]; then
"$SCRIPT_DIR/patch-nginx-torob.sh" "$HOST" || true
fi
fi
# SSL is issued separately via ssl.sh / Super Admin "Issue SSL" — do not run
+68
View File
@@ -265,6 +265,74 @@ const server = http.createServer(async (req, res) => {
}
}
if (req.method === 'POST' && req.url === '/park') {
if (!assertAuth(req, res)) return;
let body;
try {
body = await readJson(req);
} catch {
return send(res, 400, { error: 'invalid json' });
}
const host = String(body.host || '').trim().toLowerCase();
const canonicalHost = String(body.canonicalHost || body.canonical_host || '')
.trim()
.toLowerCase();
if (!host || !/^[a-z0-9.-]+$/.test(host)) {
return send(res, 400, { error: 'valid host is required' });
}
if (!canonicalHost || !/^[a-z0-9.-]+$/.test(canonicalHost)) {
return send(res, 400, { error: 'valid canonicalHost is required' });
}
try {
const result = await runScript('/opt/websites-agent/park.sh', [host, canonicalHost]);
return send(res, 200, {
status: 'parked',
host,
canonicalHost,
log: (result.stdout || '').slice(-2000),
});
} catch (err) {
return send(res, 500, {
error: 'park failed',
detail: err instanceof Error ? err.message.slice(0, 2000) : String(err),
});
}
}
if (req.method === 'POST' && req.url === '/unpark') {
if (!assertAuth(req, res)) return;
let body;
try {
body = await readJson(req);
} catch {
return send(res, 400, { error: 'invalid json' });
}
const host = String(body.host || '').trim().toLowerCase();
if (!host || !/^[a-z0-9.-]+$/.test(host)) {
return send(res, 400, { error: 'valid host is required' });
}
try {
const result = await runScript('/opt/websites-agent/unpark.sh', [host]);
return send(res, 200, {
status: 'unparked',
host,
log: (result.stdout || '').slice(-2000),
});
} catch (err) {
return send(res, 500, {
error: 'unpark failed',
detail: err instanceof Error ? err.message.slice(0, 2000) : String(err),
});
}
}
return send(res, 404, { error: 'not found' });
});
+31
View File
@@ -0,0 +1,31 @@
#!/usr/bin/env bash
# Remove a parked-alias nginx site. Does not delete Arvan DNS or LE certs.
set -euo pipefail
HOST="${1:-}"
if [[ -z "$HOST" ]]; then
echo "usage: unpark.sh <parked-host>" >&2
exit 1
fi
if [[ ! "$HOST" =~ ^[a-z0-9.-]+$ ]]; then
echo "invalid host" >&2
exit 1
fi
NGINX_AVAILABLE="/etc/nginx/sites-available/$HOST"
NGINX_ENABLED="/etc/nginx/sites-enabled/$HOST"
LIST_FILE="/opt/websites-agent/parked-hosts.txt"
rm -f "$NGINX_ENABLED"
rm -f "$NGINX_AVAILABLE"
if [[ -f "$LIST_FILE" ]]; then
grep -vxF "$HOST" "$LIST_FILE" >"${LIST_FILE}.tmp" || true
mv "${LIST_FILE}.tmp" "$LIST_FILE"
fi
nginx -t
systemctl reload nginx
echo "unpark ok: $HOST"
+8
View File
@@ -17,6 +17,7 @@ import { CategoriesModule } from './categories/categories.module';
import { ProductsModule } from './products/products.module';
import { BlogsModule } from './blogs/blogs.module';
import { PortfoliosModule } from './portfolios/portfolios.module';
import { GalleryModule } from './gallery/gallery.module';
import { VideosModule } from './videos/videos.module';
import { InstructionsModule } from './instructions/instructions.module';
import { WorkshopsModule } from './workshops/workshops.module';
@@ -44,6 +45,9 @@ import { LegacyMysqlModule } from './legacy-mysql/legacy-mysql.module';
import { PublicSmsModule } from './public-sms/public-sms.module';
import { PaymentsModule } from './payments/payments.module';
import { SitemapModule } from './sitemap/sitemap.module';
import { SmsTemplatesModule } from './sms-templates/sms-templates.module';
import { TorobModule } from './torob/torob.module';
import { RollCallsModule } from './roll-calls/roll-calls.module';
@Module({
imports: [
@@ -67,6 +71,7 @@ import { SitemapModule } from './sitemap/sitemap.module';
ProductsModule,
BlogsModule,
PortfoliosModule,
GalleryModule,
VideosModule,
InstructionsModule,
WorkshopsModule,
@@ -92,6 +97,9 @@ import { SitemapModule } from './sitemap/sitemap.module';
AiPromptsModule,
PublicSmsModule,
SitemapModule,
SmsTemplatesModule,
TorobModule,
RollCallsModule,
],
})
export class AppModule {}
+411 -10
View File
@@ -1,15 +1,32 @@
import { Injectable, Logger, ServiceUnavailableException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import {
DMARC_NAME,
LEGACY_DKIM_NAME,
MAIL_HOSTNAME,
MAIL_IPV4,
MAIL_MX_PRIORITY,
MAIL_SPF,
type MailDkimKey,
} from '../mail-dns/mail-dns';
const DEFAULT_BASE_URL = 'https://napi.arvancloud.ir/cdn/4.0';
const TTL = 120;
type ArvanRecordType = 'aname' | 'cname';
type ArvanRecordType = 'aname' | 'cname' | 'a' | 'mx' | 'txt';
type DesiredRecord = {
type: ArvanRecordType;
name: string;
value: Record<string, unknown>;
/** A records use an array of `{ ip }`; other types use a single object. */
value: Record<string, unknown> | Array<Record<string, unknown>>;
};
type DnsMutationResult = {
created: string[];
updated: string[];
skipped: string[];
removed: string[];
};
type ArvanDnsRecord = {
@@ -18,7 +35,7 @@ type ArvanDnsRecord = {
name: string;
ttl?: number;
cloud?: boolean;
value?: Record<string, unknown> | null;
value?: Record<string, unknown> | Array<Record<string, unknown>> | null;
};
@Injectable()
@@ -35,6 +52,120 @@ export class ArvanDnsService {
created: string[];
updated: string[];
skipped: string[];
}> {
return this.ensureRecords(hostRaw, this.desiredTenantRecords);
}
/**
* Parked alias: apex ANAME + www CNAME only (no business/customer/api).
* Zone must already exist in Arvan.
*/
async ensureParkedRecords(hostRaw: string): Promise<{
created: string[];
updated: string[];
skipped: string[];
}> {
return this.ensureRecords(hostRaw, this.desiredParkedRecords);
}
/**
* Stalwart mail for a Meshkee website: mail A, MX, SPF, DKIM, autoconfig.
* DNS-only (not orange-cloud). Does not touch website ANAME/CNAME records.
*/
async ensureMailRecords(hostRaw: string, keys: MailDkimKey[]): Promise<DnsMutationResult> {
const host = hostRaw.trim().toLowerCase();
if (!host) {
throw new ServiceUnavailableException('Domain host is required for Arvan DNS');
}
const auth = this.authHeader();
if (!auth) {
throw new ServiceUnavailableException('Arvan DNS is not configured');
}
const zone = await this.request('GET', `/domains/${encodeURIComponent(host)}`, auth);
if (zone.status === 404) {
throw new ServiceUnavailableException(
`Arvan zone "${host}" was not found. Add the domain in Arvan first.`,
);
}
if (!zone.ok) {
throw new ServiceUnavailableException(
`Arvan could not load zone "${host}" (${zone.status})${zone.message ? `: ${zone.message}` : ''}`,
);
}
const existing = await this.listRecords(host, auth);
const created: string[] = [];
const updated: string[] = [];
const skipped: string[] = [];
const removed: string[] = [];
await this.replaceConflicting(
host,
auth,
existing,
'mail',
['cname', 'aname'],
removed,
);
await this.upsertTyped(host, auth, existing, {
type: 'a',
name: 'mail',
// Arvan CDN 4.0 requires A `value` as an array of IP objects.
value: [{ ip: MAIL_IPV4 }],
}, created, updated, skipped);
await this.replaceExtraMx(host, auth, existing, removed);
await this.replaceConflicting(
host,
auth,
existing,
LEGACY_DKIM_NAME,
['txt', 'cname'],
removed,
);
await this.replaceConflicting(host, auth, existing, DMARC_NAME, ['txt', 'cname'], removed);
await this.upsertTyped(host, auth, existing, {
type: 'mx',
name: '@',
value: { host: `${MAIL_HOSTNAME}.`, priority: MAIL_MX_PRIORITY },
}, created, updated, skipped);
await this.upsertSpf(host, auth, existing, created, updated, skipped);
for (const key of keys) {
await this.upsertTyped(host, auth, existing, {
type: 'txt',
name: `${key.selector}._domainkey`,
value: { text: key.text },
}, created, updated, skipped);
}
await this.upsertTyped(host, auth, existing, {
type: 'cname',
name: 'autoconfig',
value: { host: `${MAIL_HOSTNAME}.`, host_header: 'source' },
}, created, updated, skipped);
await this.upsertTyped(host, auth, existing, {
type: 'cname',
name: 'autodiscover',
value: { host: `${MAIL_HOSTNAME}.`, host_header: 'source' },
}, created, updated, skipped);
this.logger.log(
`Arvan mail DNS ${host}: created=${created.join(',') || '-'} updated=${updated.join(',') || '-'} skipped=${skipped.join(',') || '-'} removed=${removed.join(',') || '-'}`,
);
return { created, updated, skipped, removed };
}
private async ensureRecords(
hostRaw: string,
desiredFor: (apex: string) => DesiredRecord[],
): Promise<{
created: string[];
updated: string[];
skipped: string[];
}> {
const host = hostRaw.trim().toLowerCase();
if (!host) {
@@ -63,7 +194,7 @@ export class ArvanDnsService {
const updated: string[] = [];
const skipped: string[] = [];
for (const desired of this.desiredRecords(host)) {
for (const desired of desiredFor(host)) {
const match = existing.find(
(item) => item.name === desired.name && item.type === desired.type,
);
@@ -129,7 +260,7 @@ export class ArvanDnsService {
return { created, updated, skipped };
}
private desiredRecords(apex: string): DesiredRecord[] {
private desiredTenantRecords(apex: string): DesiredRecord[] {
return [
{
type: 'aname',
@@ -159,12 +290,282 @@ export class ArvanDnsService {
];
}
private sameTarget(desired: DesiredRecord, existing: ArvanDnsRecord): boolean {
const current = existing.value ?? {};
if (desired.type === 'aname') {
return this.fqdn(String(current.location ?? '')) === this.fqdn(String(desired.value.location ?? ''));
/** Apex + www only — parked aliases 301 to the canonical Meshkee domain. */
private desiredParkedRecords(apex: string): DesiredRecord[] {
return [
{
type: 'aname',
name: '@',
value: { location: 'ns.meshkee.com.', host_header: 'source' },
},
{
type: 'cname',
name: 'www',
value: { host: `${apex}.`, host_header: 'source' },
},
];
}
private async upsertTyped(
host: string,
auth: string,
existing: ArvanDnsRecord[],
desired: DesiredRecord,
created: string[],
updated: string[],
skipped: string[],
) {
const match = existing.find(
(item) => this.relativeName(item.name, host) === desired.name && item.type === desired.type,
);
const label = `${desired.type.toUpperCase()} ${desired.name}`;
if (match && this.sameTarget(desired, match)) {
skipped.push(label);
return;
}
return this.fqdn(String(current.host ?? '')) === this.fqdn(String(desired.value.host ?? ''));
if (match) {
const put = await this.request(
'PUT',
`/domains/${encodeURIComponent(host)}/dns-records/${match.id}`,
auth,
{
type: desired.type,
name: desired.name,
ttl: match.ttl || TTL,
cloud: false,
value: desired.value,
},
);
if (!put.ok) {
throw new ServiceUnavailableException(
`Arvan failed to update ${label} (${put.status})${put.message ? `: ${put.message}` : ''}`,
);
}
match.value = desired.value;
updated.push(label);
return;
}
const post = await this.request('POST', `/domains/${encodeURIComponent(host)}/dns-records`, auth, {
type: desired.type,
name: desired.name,
ttl: TTL,
cloud: false,
value: desired.value,
});
if (!post.ok) {
throw new ServiceUnavailableException(
`Arvan failed to create ${label} (${post.status})${post.message ? `: ${post.message}` : ''}`,
);
}
const createdId =
post.body && typeof post.body === 'object' && 'data' in post.body
? String((post.body.data as { id?: string } | undefined)?.id ?? '')
: '';
existing.push({
id: createdId || `new-${desired.type}-${desired.name}`,
type: desired.type,
name: desired.name,
value: desired.value,
});
created.push(label);
}
private async upsertSpf(
host: string,
auth: string,
existing: ArvanDnsRecord[],
created: string[],
updated: string[],
skipped: string[],
) {
const match = existing.find(
(item) =>
this.relativeName(item.name, host) === '@' &&
item.type === 'txt' &&
this.txtValue(item).toLowerCase().startsWith('v=spf1'),
);
const desired: DesiredRecord = {
type: 'txt',
name: '@',
value: { text: MAIL_SPF },
};
if (match) {
if (this.sameTarget(desired, match)) {
skipped.push('TXT @ SPF');
return;
}
const put = await this.request(
'PUT',
`/domains/${encodeURIComponent(host)}/dns-records/${match.id}`,
auth,
{
type: 'txt',
name: '@',
ttl: match.ttl || TTL,
cloud: false,
value: desired.value,
},
);
if (!put.ok) {
throw new ServiceUnavailableException(
`Arvan failed to update TXT @ SPF (${put.status})${put.message ? `: ${put.message}` : ''}`,
);
}
match.value = desired.value;
updated.push('TXT @ SPF');
return;
}
const post = await this.request('POST', `/domains/${encodeURIComponent(host)}/dns-records`, auth, {
type: 'txt',
name: '@',
ttl: TTL,
cloud: false,
value: desired.value,
});
if (!post.ok) {
throw new ServiceUnavailableException(
`Arvan failed to create TXT @ SPF (${post.status})${post.message ? `: ${post.message}` : ''}`,
);
}
existing.push({
id: 'new-spf',
type: 'txt',
name: '@',
value: desired.value,
});
created.push('TXT @ SPF');
}
private async replaceExtraMx(
host: string,
auth: string,
existing: ArvanDnsRecord[],
removed: string[],
) {
const mx = existing.filter((item) => item.type === 'mx');
const keep = mx.find((item) => {
const name = this.relativeName(item.name, host);
return name === '@' && this.fqdn(this.mxHost(this.recordValueObject(item.value))) === MAIL_HOSTNAME;
});
for (const item of mx) {
if (keep && item.id === keep.id) continue;
const del = await this.request(
'DELETE',
`/domains/${encodeURIComponent(host)}/dns-records/${item.id}`,
auth,
);
if (!del.ok) {
throw new ServiceUnavailableException(
`Arvan failed to remove extra MX (${del.status})${del.message ? `: ${del.message}` : ''}`,
);
}
const name = this.relativeName(item.name, host);
removed.push(`MX ${name} ${this.mxHost(this.recordValueObject(item.value)) || item.id}`);
const index = existing.indexOf(item);
if (index >= 0) existing.splice(index, 1);
}
}
private async replaceConflicting(
host: string,
auth: string,
existing: ArvanDnsRecord[],
name: string,
types: string[],
removed: string[],
) {
const conflicts = existing.filter(
(item) =>
this.relativeName(item.name, host) === name && types.includes(item.type),
);
for (const item of conflicts) {
const del = await this.request(
'DELETE',
`/domains/${encodeURIComponent(host)}/dns-records/${item.id}`,
auth,
);
if (!del.ok) {
throw new ServiceUnavailableException(
`Arvan failed to replace ${item.type} ${name} (${del.status})${del.message ? `: ${del.message}` : ''}`,
);
}
removed.push(`${item.type.toUpperCase()} ${name}`);
const index = existing.indexOf(item);
if (index >= 0) existing.splice(index, 1);
}
}
private sameTarget(desired: DesiredRecord, existing: ArvanDnsRecord): boolean {
const current = this.recordValueObject(existing.value);
const desiredObj = this.recordValueObject(desired.value);
if (desired.type === 'aname') {
return this.fqdn(String(current.location ?? '')) === this.fqdn(String(desiredObj.location ?? ''));
}
if (desired.type === 'a') {
return this.aIps(existing.value).join(',') === this.aIps(desired.value).join(',');
}
if (desired.type === 'mx') {
return (
this.fqdn(this.mxHost(current)) === this.fqdn(String(desiredObj.host ?? '')) &&
Number(current.priority ?? 0) === Number(desiredObj.priority ?? 0)
);
}
if (desired.type === 'txt') {
return this.txtValue(existing).replace(/\s+/g, '') === String(desiredObj.text ?? '').replace(/\s+/g, '');
}
return this.fqdn(String(current.host ?? '')) === this.fqdn(String(desiredObj.host ?? ''));
}
/** Arvan A records return `value` as `[{ ip }]`; normalize to IP list for compare/send. */
private aIps(value: DesiredRecord['value'] | ArvanDnsRecord['value']): string[] {
if (Array.isArray(value)) {
return value
.map((item) => String(item?.ip ?? '').trim())
.filter(Boolean);
}
if (value && typeof value === 'object' && 'ip' in value) {
const ip = String(value.ip ?? '').trim();
return ip ? [ip] : [];
}
return [];
}
private recordValueObject(
value: DesiredRecord['value'] | ArvanDnsRecord['value'],
): Record<string, unknown> {
if (Array.isArray(value)) {
return (value[0] as Record<string, unknown> | undefined) ?? {};
}
return value ?? {};
}
private txtValue(record: ArvanDnsRecord): string {
const value = this.recordValueObject(record.value);
if (typeof value.text === 'string') return value.text;
if (value.txt && typeof value.txt === 'object' && 'text' in value.txt) {
return String((value.txt as { text?: unknown }).text ?? '');
}
return '';
}
private mxHost(value: Record<string, unknown>): string {
if (typeof value.host === 'string') return value.host;
if (value.mx && typeof value.mx === 'object' && value.mx && 'host' in value.mx) {
return String((value.mx as { host?: unknown }).host ?? '');
}
return '';
}
private relativeName(name: string, zone: string): string {
const n = name.replace(/\.$/, '').toLowerCase();
const z = zone.replace(/\.$/, '').toLowerCase();
if (n === z || n === '@') return '@';
if (n.endsWith(`.${z}`)) return n.slice(0, -(z.length + 1));
return n;
}
private fqdn(value: string): string {
+18
View File
@@ -18,6 +18,9 @@ import { RefreshTokenDto } from './dto/refresh-token.dto';
import { RegisterDto } from './dto/register.dto';
import { ResetPasswordDto } from './dto/reset-password.dto';
import { SendOtpDto } from './dto/send-otp.dto';
import { SignupCompleteDto } from './dto/signup-complete.dto';
import { SignupStartDto } from './dto/signup-start.dto';
import { SignupVerifyOtpDto } from './dto/signup-verify-otp.dto';
import { UpdateProfileDto } from './dto/update-profile.dto';
import { UpsertUserAddressDto } from './dto/upsert-user-address.dto';
import { VerifyOtpDto } from './dto/verify-otp.dto';
@@ -37,6 +40,21 @@ export class AuthController {
return this.authService.register(dto);
}
@Post('signup/start')
startSignup(@Body() dto: SignupStartDto) {
return this.authService.startSignup(dto);
}
@Post('signup/verify-otp')
verifySignupOtp(@Body() dto: SignupVerifyOtpDto) {
return this.authService.verifySignupOtp(dto.cellNumber, dto.code);
}
@Post('signup/complete')
completeSignup(@Body() dto: SignupCompleteDto) {
return this.authService.completeSignup(dto);
}
@Post('login')
login(@Body() dto: LoginDto) {
return this.authService.login(dto);
+9 -1
View File
@@ -9,6 +9,7 @@ import { AuthService } from './auth.service';
import { BusinessPermissionGuard } from './guards/business-permission.guard';
import { PermissionsService } from './permissions.service';
import { SmsService } from './sms.service';
import { SmsBillingService } from './sms-billing.service';
import { JwtStrategy } from './strategies/jwt.strategy';
import { UserAddressesService } from './user-addresses.service';
@@ -33,10 +34,17 @@ import { UserAddressesService } from './user-addresses.service';
AuthService,
UserAddressesService,
SmsService,
SmsBillingService,
PermissionsService,
BusinessPermissionGuard,
JwtStrategy,
],
exports: [AuthService, PermissionsService, BusinessPermissionGuard, SmsService],
exports: [
AuthService,
PermissionsService,
BusinessPermissionGuard,
SmsService,
SmsBillingService,
],
})
export class AuthModule {}
+346 -23
View File
@@ -24,14 +24,19 @@ import {
import { ChangePasswordDto } from './dto/change-password.dto';
import { LoginDto } from './dto/login.dto';
import { RegisterDto } from './dto/register.dto';
import { SignupCompleteDto } from './dto/signup-complete.dto';
import { SignupStartDto } from './dto/signup-start.dto';
import { UpdateProfileDto } from './dto/update-profile.dto';
import { PermissionsService } from './permissions.service';
import { parseUserProfile } from './profile.util';
import { SmsService } from './sms.service';
import { SmsBillingService } from './sms-billing.service';
const OTP_TTL_SECONDS = 300;
/** Pending cross-site password change (longer than OTP so resend still works). */
const PENDING_PASSWORD_TTL_SECONDS = 600;
/** Pending signup + post-OTP registration token. */
const PENDING_SIGNUP_TTL_SECONDS = 600;
const HANDOFF_TTL_SECONDS = 60;
const HANDOFF_RATE_LIMIT = 10;
const HANDOFF_RATE_WINDOW_SECONDS = 60;
@@ -44,6 +49,7 @@ export class AuthService {
private readonly config: ConfigService,
private readonly redis: RedisService,
private readonly sms: SmsService,
private readonly smsBilling: SmsBillingService,
private readonly tenant: TenantService,
private readonly permissions: PermissionsService,
) {}
@@ -210,6 +216,271 @@ export class AuthService {
};
}
/**
* Signup step 1: name + number. Stores pending signup and sends OTP
* (OTP is allowed before the user row exists).
*/
async startSignup(dto: SignupStartDto) {
const business = await this.tenant.resolveBusinessByDomain(dto.domain);
const existingUser = await this.prisma.user.findUnique({
where: { cellNumber: dto.cellNumber },
include: {
businessCustomers: {
include: { business: { select: { id: true, name: true, nameFa: true } } },
},
},
});
const alreadyOnThisSite = existingUser?.businessCustomers.some(
(item) => item.businessId === business.id,
);
if (alreadyOnThisSite) {
throw new ConflictException(
'This cell number is already registered on this website',
);
}
await this.redis.setPendingSignup(
dto.cellNumber,
{
firstName: dto.firstName.trim(),
lastName: dto.lastName.trim(),
businessId: business.id.toString(),
},
PENDING_SIGNUP_TTL_SECONDS,
);
const smsEnabled = this.sms.isEnabled();
if (!smsEnabled) {
const sites = this.otherSiteNames(existingUser, business.id);
const registrationToken = await this.issueSignupVerifiedToken({
cellNumber: dto.cellNumber,
firstName: dto.firstName.trim(),
lastName: dto.lastName.trim(),
businessId: business.id.toString(),
existingUserId: existingUser ? existingUser.id.toString() : null,
});
await this.redis.deletePendingSignup(dto.cellNumber);
return {
enabled: false,
requiresOtp: false,
existingAccount: Boolean(existingUser),
sites,
registrationToken,
message:
'SMS verification is currently disabled. Continue to finish registration.',
};
}
await this.dispatchOtpSms(dto.cellNumber, business);
return {
enabled: true,
requiresOtp: true,
message: 'Verification code sent',
expiresInSeconds: OTP_TTL_SECONDS,
};
}
/**
* Signup step 2: verify OTP, then report whether the phone already has a
* Meshkee account. Returns a one-time registrationToken for step 3.
*/
async verifySignupOtp(cellNumber: string, code: string) {
const pending = await this.redis.getPendingSignup(cellNumber);
if (!pending) {
throw new BadRequestException(
'Signup session expired. Please start registration again.',
);
}
await this.consumeOtpCode(cellNumber, code);
const businessId = BigInt(pending.businessId);
const existingUser = await this.prisma.user.findUnique({
where: { cellNumber },
include: {
businessCustomers: {
include: { business: { select: { id: true, name: true, nameFa: true } } },
},
},
});
const alreadyOnThisSite = existingUser?.businessCustomers.some(
(item) => item.businessId === businessId,
);
if (alreadyOnThisSite) {
await this.redis.deletePendingSignup(cellNumber);
throw new ConflictException(
'This cell number is already registered on this website',
);
}
const sites = this.otherSiteNames(existingUser, businessId);
const registrationToken = await this.issueSignupVerifiedToken({
cellNumber,
firstName: pending.firstName,
lastName: pending.lastName,
businessId: pending.businessId,
existingUserId: existingUser ? existingUser.id.toString() : null,
});
await this.redis.deletePendingSignup(cellNumber);
return {
verified: true,
existingAccount: Boolean(existingUser),
sites,
registrationToken,
message: existingUser
? 'Phone verified. You already have a Meshkee account — your password stays the same.'
: 'Phone verified. Choose a password to finish creating your account.',
};
}
/**
* Signup step 3: create a new account (password required) or link an
* existing Meshkee account (password unchanged).
*/
async completeSignup(dto: SignupCompleteDto) {
const verified = await this.redis.consumeSignupVerifiedToken(
dto.registrationToken,
);
if (!verified) {
throw new BadRequestException(
'Registration session expired. Please start registration again.',
);
}
const businessId = BigInt(verified.businessId);
const business = await this.prisma.business.findUnique({
where: { id: businessId },
select: { id: true, name: true, slug: true },
});
if (!business) {
throw new BadRequestException('Business not found for this registration.');
}
const customerRole = await this.prisma.role.findUnique({
where: { slug: 'customer' },
});
if (!customerRole) {
throw new Error('Customer role is missing. Run database migrations first.');
}
const linkedExisting = Boolean(verified.existingUserId);
if (!linkedExisting) {
const password = dto.password?.trim();
if (!password || password.length < 8) {
throw new BadRequestException(
'Password is required (at least 8 characters) for new accounts.',
);
}
}
const userId = await this.prisma.$transaction(async (tx) => {
let userId: bigint;
if (verified.existingUserId) {
userId = BigInt(verified.existingUserId);
const existing = await tx.user.findUnique({
where: { id: userId },
include: {
businessCustomers: { select: { businessId: true } },
},
});
if (!existing) {
throw new BadRequestException(
'Existing account not found. Please start registration again.',
);
}
if (
existing.businessCustomers.some((item) => item.businessId === businessId)
) {
throw new ConflictException(
'This cell number is already registered on this website',
);
}
await tx.user.update({
where: { id: userId },
data: {
isActive: true,
cellVerifiedAt: existing.cellVerifiedAt ?? new Date(),
},
});
} else {
const collision = await tx.user.findUnique({
where: { cellNumber: verified.cellNumber },
select: { id: true },
});
if (collision) {
throw new ConflictException(
'This cell number is already registered. Please start registration again.',
);
}
const passwordHash = await bcrypt.hash(dto.password!.trim(), 10);
const created = await tx.user.create({
data: {
cellNumber: verified.cellNumber,
passwordHash,
firstName: verified.firstName,
lastName: verified.lastName,
cellVerifiedAt: new Date(),
isActive: true,
},
select: { id: true },
});
userId = created.id;
}
await tx.businessCustomer.create({
data: {
businessId,
userId,
},
});
const hasCustomerRole = await tx.userRole.findUnique({
where: {
userId_roleId: {
userId,
roleId: customerRole.id,
},
},
});
if (!hasCustomerRole) {
await tx.userRole.create({
data: {
userId,
roleId: customerRole.id,
},
});
}
return userId;
});
const authUser = await this.getAuthUser(userId);
const tokens = await this.issueTokens(authUser);
return {
message: linkedExisting
? 'Joined this website with your existing Meshkee account.'
: 'Registration successful.',
existingAccount: linkedExisting,
passwordUpdated: false,
user: this.serializeUser(authUser),
registeredBusiness: {
id: business.id,
name: business.name,
slug: business.slug,
},
...tokens,
};
}
async login(dto: LoginDto) {
const user = await this.prisma.user.findUnique({
where: { cellNumber: dto.cellNumber },
@@ -403,7 +674,7 @@ export class AuthService {
return { message: 'Password changed successfully' };
}
async sendOtp(cellNumber: string, domain?: string) {
async sendOtp(cellNumber: string, domain: string) {
if (!this.sms.isEnabled()) {
return {
enabled: false,
@@ -420,29 +691,15 @@ export class AuthService {
throw new UnauthorizedException('Cell number is not registered');
}
let businessNameFa: string | undefined;
const host = domain?.trim();
if (host) {
try {
const business = await this.tenant.resolveBusinessByDomain(host);
businessNameFa =
business.nameFa?.trim() || business.name?.trim() || undefined;
} catch {
// Domain may be unknown — still send OTP without branding suffix
}
if (!host) {
throw new BadRequestException(
'Domain is required to send SMS (prepaid balance is billed per business).',
);
}
const code = this.generateOtpCode();
await this.redis.setOtp(cellNumber, code, OTP_TTL_SECONDS);
try {
await this.sms.sendVerificationCode(cellNumber, code, businessNameFa);
} catch (err) {
if (err instanceof HttpException) {
throw err;
}
throw new ServiceUnavailableException('Unable to send SMS');
}
const business = await this.tenant.resolveBusinessByDomain(host);
await this.dispatchOtpSms(cellNumber, business);
return {
enabled: true,
@@ -538,8 +795,14 @@ export class AuthService {
throw new UnauthorizedException('Cell number is not registered');
}
await this.consumeOtpCode(cellNumber, code);
return user;
}
/** Validates and consumes the OTP code without requiring a user row. */
private async consumeOtpCode(cellNumber: string, code: string) {
if (!this.sms.isEnabled()) {
return user;
return;
}
const storedCode = await this.redis.getOtp(cellNumber);
@@ -548,7 +811,67 @@ export class AuthService {
}
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(
+3 -4
View File
@@ -1,4 +1,4 @@
import { IsOptional, IsString, Matches, MaxLength } from 'class-validator';
import { IsString, Matches, MaxLength } from 'class-validator';
export class SendOtpDto {
@IsString()
@@ -7,9 +7,8 @@ export class SendOtpDto {
})
cellNumber!: string;
/** Tenant host/apex (e.g. sanihome.ir or business.sanihome.ir) — used to brand OTP SMS. */
@IsOptional()
/** Tenant host/apex — required so OTP SMS is billed to the business. */
@IsString()
@MaxLength(253)
domain?: string;
domain!: string;
}
+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;
}
+138
View File
@@ -0,0 +1,138 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { TenantService } from '../tenant/tenant.service';
import {
calculateSmsCredits,
irtToSmsCredits,
smsSentColumnForType,
SMS_PURCHASE_PRICE_PER_CREDIT_IRT,
type SmsSendType,
} from './sms-billing';
@Injectable()
export class SmsBillingService {
constructor(
private readonly prisma: PrismaService,
private readonly tenant: TenantService,
) {}
/** Credits required for a message (1 credit = 64 chars × recipient). */
costForMessage(message: string, recipientCount = 1): number {
return calculateSmsCredits(message.trim().length, recipientCount);
}
creditsFromTopUpIrt(irtAmount: number): number {
return irtToSmsCredits(irtAmount);
}
/**
* Resolve tenant business from any Meshkee / external domain host.
* Returns null when the host is not linked to an active business.
*/
async findBusinessIdByDomain(host: string | undefined | null): Promise<bigint | null> {
const trimmed = host?.trim();
if (!trimmed) return null;
try {
const business = await this.tenant.resolveBusinessByDomain(trimmed);
return business.id;
} catch {
return null;
}
}
async requireBusinessIdByDomain(host: string): Promise<bigint> {
const id = await this.findBusinessIdByDomain(host);
if (!id) {
throw new NotFoundException(
`No business found for domain: ${host}. Link the domain to a business first.`,
);
}
return id;
}
/** Atomically deduct SMS credits for a message. Returns credits charged. */
async charge(
businessId: bigint,
message: string,
recipientCount = 1,
type: SmsSendType = 'singular',
): Promise<number> {
return this.chargeAmount(
businessId,
this.costForMessage(message, recipientCount),
recipientCount,
type,
);
}
/**
* Atomically deduct credits and bump sms_sent_count + typed counter.
* Returns credits charged.
*/
async chargeAmount(
businessId: bigint,
amount: number,
recipientCount = 1,
type: SmsSendType = 'singular',
): Promise<number> {
const cost = Math.floor(amount);
const count = Math.max(0, Math.floor(recipientCount));
if (cost <= 0) return 0;
const typedColumn = smsSentColumnForType(type);
const updated = await this.prisma.$executeRaw(
Prisma.sql`
UPDATE businesses
SET sms_balance = sms_balance - ${cost},
sms_sent_count = sms_sent_count + ${count},
${Prisma.raw(typedColumn)} = ${Prisma.raw(typedColumn)} + ${count}
WHERE id = ${businessId} AND sms_balance >= ${cost}
`,
);
if (updated === 0) {
throw new BadRequestException(
'Insufficient SMS balance. Contact Meshkee to top up.',
);
}
return cost;
}
async refund(
businessId: bigint,
amount: number,
recipientCount = 1,
type: SmsSendType = 'singular',
): Promise<void> {
const credit = Math.floor(amount);
const count = Math.max(0, Math.floor(recipientCount));
if (credit <= 0 && count <= 0) return;
const typedColumn = smsSentColumnForType(type);
await this.prisma.$executeRaw(
Prisma.sql`
UPDATE businesses
SET sms_balance = sms_balance + ${credit},
sms_sent_count = GREATEST(0, sms_sent_count - ${count}),
${Prisma.raw(typedColumn)} = GREATEST(0, ${Prisma.raw(typedColumn)} - ${count})
WHERE id = ${businessId}
`,
);
}
/** Add credits from an IRT top-up. Returns credits added. */
async addTopUpIrt(businessId: bigint, irtAmount: number): Promise<number> {
const credits = irtToSmsCredits(irtAmount);
if (credits <= 0) {
throw new BadRequestException(
`Top-up amount is too small to purchase any SMS credits (${SMS_PURCHASE_PRICE_PER_CREDIT_IRT} IRT each).`,
);
}
await this.prisma.$executeRaw`
UPDATE businesses
SET sms_balance = sms_balance + ${credits}
WHERE id = ${businessId}
`;
return credits;
}
}
+83
View File
@@ -0,0 +1,83 @@
/**
* Prepaid SMS billing in credit units.
* - 1 credit = one 64-character segment for one recipient
* - Top-up: floor(IRT / 500) credits (e.g. 1,000,000 IRT → 2,000 credits)
*/
export const SMS_SEGMENT_CHARS = 64;
/** IRT (toman) price to purchase one SMS credit. */
export const SMS_PURCHASE_PRICE_PER_CREDIT_IRT = 500;
/** Starting credits for newly created businesses (≈ 500,000 IRT at purchase price). */
export const DEFAULT_NEW_BUSINESS_SMS_CREDITS = 1_000;
/** Lifetime send counter categories (one unit = one recipient). */
export type SmsSendType =
| 'mass'
| 'singular'
| 'auth'
| 'auto_customer'
| 'admin';
export const SMS_SEND_TYPES: readonly SmsSendType[] = [
'mass',
'singular',
'auth',
'auto_customer',
'admin',
] as const;
/** Whitelisted DB columns for typed send counters. */
export function smsSentColumnForType(type: SmsSendType): string {
switch (type) {
case 'mass':
return 'sms_sent_mass';
case 'singular':
return 'sms_sent_singular';
case 'auth':
return 'sms_sent_auth';
case 'auto_customer':
return 'sms_sent_auto_customer';
case 'admin':
return 'sms_sent_admin';
default: {
const _exhaustive: never = type;
return _exhaustive;
}
}
}
/**
* Credits charged for one or more recipients.
* 1–64 chars → 1, 65–128 → 2, etc.
*/
export function calculateSmsCredits(
messageLength: number,
recipientCount = 1,
): number {
const length = Math.max(0, Math.floor(messageLength));
const recipients = Math.max(0, Math.floor(recipientCount));
if (recipients === 0 || length === 0) return 0;
const segments = Math.max(1, Math.ceil(length / SMS_SEGMENT_CHARS));
return segments * recipients;
}
/** Convert a top-up amount in IRT into SMS credits. */
export function irtToSmsCredits(irtAmount: number): number {
const irt = Math.max(0, Math.floor(irtAmount));
if (irt <= 0) return 0;
return Math.floor(irt / SMS_PURCHASE_PRICE_PER_CREDIT_IRT);
}
/** @deprecated Use calculateSmsCredits — balance is no longer stored in IRT. */
export function calculateSmsCostIrt(
messageLength: number,
recipientCount = 1,
): number {
return calculateSmsCredits(messageLength, recipientCount);
}
/** @deprecated Use DEFAULT_NEW_BUSINESS_SMS_CREDITS */
export const DEFAULT_NEW_BUSINESS_SMS_BALANCE_IRT = DEFAULT_NEW_BUSINESS_SMS_CREDITS;
/** @deprecated Purchase price constant — not a per-send IRT charge. */
export const SMS_COST_PER_SEGMENT_IRT = SMS_PURCHASE_PRICE_PER_CREDIT_IRT;
+281 -7
View File
@@ -10,6 +10,22 @@ export type SmsSendResult = {
serverId: string;
};
export type SmsBulkSendResult = {
serverIds: string[];
recipientCount: number;
};
export type SmsSendOptions = {
/** Gama shortcode override. When omitted, uses SMS_GAMA_SOURCE_SERVICE. */
source?: string;
};
/** Default Meshkee advertising shortcode when a business has no own SMS number. */
export const DEFAULT_ADVERTISING_SMS_SOURCE = '5000990009';
/** Gama batch size limit is undocumented; keep chunks conservative. */
const GAMA_BULK_CHUNK_SIZE = 100;
type GamaQuickResponse = {
success?: boolean;
message?: string | null;
@@ -17,6 +33,13 @@ type GamaQuickResponse = {
body?: number | string | null;
};
type GamaBulkResponse = {
success?: boolean;
message?: string | null;
errors?: unknown;
body?: Array<number | string> | null;
};
@Injectable()
export class SmsService {
private readonly logger = new Logger(SmsService.name);
@@ -27,6 +50,72 @@ export class SmsService {
return this.config.get<string>('SMS_ENABLED', 'false') === 'true';
}
/** Advertising / customer-broadcast fallback shortcode. */
getAdvertisingSource(): string {
return (
this.config.get<string>('SMS_GAMA_SOURCE_ADVERTISE')?.trim() ||
DEFAULT_ADVERTISING_SMS_SOURCE
);
}
/** Meshkee service shortcode (OTP + verified SMS templates). */
getServiceSource(): string {
return (
this.config.get<string>('SMS_GAMA_SOURCE_SERVICE')?.trim() ||
'5000110005'
);
}
/**
* Best-effort account credit from Gama. Returns null when the provider
* does not expose credit over REST (check the Gama panel instead).
*/
async getAccountCredit(): Promise<{ balance: number; currency?: string } | null> {
if (!this.isEnabled() || this.isProxyConfigured()) {
return null;
}
const username = this.config.get<string>('SMS_GAMA_USERNAME')?.trim();
const password = this.config.get<string>('SMS_GAMA_PASSWORD')?.trim();
const baseUrl = (
this.config.get<string>('SMS_GAMA_BASE_URL') ?? 'https://sms.igama.ir/api/v1'
).replace(/\/$/, '');
if (!username || !password) return null;
// Gama REST docs do not document a credit endpoint; try common paths quietly.
for (const path of ['/account/credit', '/credit', '/user/credit']) {
try {
const response = await fetch(`${baseUrl}${path}`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify({ username, password }),
signal: AbortSignal.timeout(8_000),
});
if (!response.ok) continue;
const payload = (await response.json()) as {
success?: boolean;
body?: number | { credit?: number; balance?: number };
};
if (!payload.success) continue;
const raw =
typeof payload.body === 'number'
? payload.body
: payload.body?.credit ?? payload.body?.balance;
if (typeof raw === 'number' && Number.isFinite(raw)) {
return { balance: raw };
}
} catch {
// ignore and try next path
}
}
return null;
}
async sendVerificationCode(
cellNumber: string,
code: string,
@@ -46,7 +135,11 @@ export class SmsService {
return this.sendMessage(cellNumber, message);
}
async sendMessage(cellNumber: string, message: string): Promise<SmsSendResult> {
async sendMessage(
cellNumber: string,
message: string,
options?: SmsSendOptions,
): Promise<SmsSendResult> {
if (!this.isEnabled()) {
this.logger.warn(`SMS disabled — message for ${cellNumber} not sent: ${message}`);
return { serverId: 'disabled' };
@@ -65,11 +158,78 @@ export class SmsService {
throw new BadRequestException('Invalid cellphone number');
}
const sourceOverride = options?.source?.trim() || undefined;
// Local proxy path has no per-sender shortcode unless we pass source through.
if (this.isProxyConfigured()) {
return this.sendViaProxy(destination, text);
return this.sendViaProxy(destination, text, sourceOverride);
}
return this.sendQuick(destination, text);
return this.sendQuick(destination, text, sourceOverride);
}
/**
* One message → many destinations via Gama SendBulk (`/send/bulk`).
* Falls back to sequential SendQuick / proxy when only one number or proxy is configured.
*/
async sendBulkMessage(
cellNumbers: string[],
message: string,
options?: SmsSendOptions,
): Promise<SmsBulkSendResult> {
if (!this.isEnabled()) {
this.logger.warn(
`SMS disabled — bulk message for ${cellNumbers.length} recipients not sent: ${message}`,
);
return { serverIds: ['disabled'], recipientCount: cellNumbers.length };
}
const text = message.trim();
if (!text) {
throw new BadRequestException('Message is empty');
}
if (text.length > 700) {
throw new BadRequestException('Message is too long (max 700 characters)');
}
const destinations: string[] = [];
const seen = new Set<string>();
for (const raw of cellNumbers) {
const msisdn = toGamaMsisdn(raw);
if (!msisdn || seen.has(msisdn)) continue;
seen.add(msisdn);
destinations.push(msisdn);
}
if (destinations.length === 0) {
throw new BadRequestException('No valid cellphone numbers to send to');
}
const sourceOverride = options?.source?.trim() || undefined;
if (destinations.length === 1) {
const single = await this.sendMessage(destinations[0], text, options);
return { serverIds: [single.serverId], recipientCount: 1 };
}
// Local proxy only supports single destination — send sequentially.
if (this.isProxyConfigured()) {
const serverIds: string[] = [];
for (const destination of destinations) {
const result = await this.sendViaProxy(destination, text, sourceOverride);
serverIds.push(result.serverId);
}
return { serverIds, recipientCount: destinations.length };
}
const serverIds: string[] = [];
for (let i = 0; i < destinations.length; i += GAMA_BULK_CHUNK_SIZE) {
const chunk = destinations.slice(i, i + GAMA_BULK_CHUNK_SIZE);
const chunkIds = await this.sendBulk(chunk, text, sourceOverride);
serverIds.push(...chunkIds);
}
return { serverIds, recipientCount: destinations.length };
}
private isProxyConfigured(): boolean {
@@ -80,7 +240,11 @@ export class SmsService {
}
/** Local-dev path: deliver via remote Meshkee partner SMS gateway (production reaches Gama). */
private async sendViaProxy(destination: string, message: string): Promise<SmsSendResult> {
private async sendViaProxy(
destination: string,
message: string,
source?: string,
): Promise<SmsSendResult> {
const url = this.config.get<string>('SMS_PROXY_URL')!.trim();
const apiKey = this.config.get<string>('SMS_PROXY_API_KEY')!.trim();
const domain = this.config.get<string>('SMS_PROXY_DOMAIN')!.trim();
@@ -99,6 +263,7 @@ export class SmsService {
domain,
to: destination,
message,
...(source ? { source } : {}),
}),
signal: AbortSignal.timeout(20_000),
});
@@ -144,10 +309,16 @@ export class SmsService {
return { serverId };
}
private async sendQuick(destination: string, message: string): Promise<SmsSendResult> {
private async sendQuick(
destination: string,
message: string,
sourceOverride?: string,
): Promise<SmsSendResult> {
const username = this.config.get<string>('SMS_GAMA_USERNAME')?.trim();
const password = this.config.get<string>('SMS_GAMA_PASSWORD')?.trim();
const source = this.config.get<string>('SMS_GAMA_SOURCE_SERVICE')?.trim();
const source =
sourceOverride?.trim() ||
this.config.get<string>('SMS_GAMA_SOURCE_SERVICE')?.trim();
const baseUrl = (
this.config.get<string>('SMS_GAMA_BASE_URL') ?? 'https://sms.igama.ir/api/v1'
).replace(/\/$/, '');
@@ -209,9 +380,97 @@ export class SmsService {
throw new ServiceUnavailableException('SMS provider returned an empty server id');
}
this.logger.log(`SMS sent to ${masked} via Gama (serverId=${serverId})`);
this.logger.log(
`SMS sent to ${masked} via Gama source=${source} (serverId=${serverId})`,
);
return { serverId };
}
/** Gama SendBulk — one message, many MSISDNs (`destinations` array). */
private async sendBulk(
destinations: string[],
message: string,
sourceOverride?: string,
): Promise<string[]> {
const username = this.config.get<string>('SMS_GAMA_USERNAME')?.trim();
const password = this.config.get<string>('SMS_GAMA_PASSWORD')?.trim();
const source =
sourceOverride?.trim() ||
this.config.get<string>('SMS_GAMA_SOURCE_SERVICE')?.trim();
const baseUrl = (
this.config.get<string>('SMS_GAMA_BASE_URL') ?? 'https://sms.igama.ir/api/v1'
).replace(/\/$/, '');
if (!username || !password || !source) {
this.logger.error('SMS provider credentials are not configured');
throw new ServiceUnavailableException('SMS provider is not configured yet');
}
const url = `${baseUrl}/send/bulk`;
let response: Response;
try {
response = await fetch(url, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify({
username,
password,
message,
source,
destinations,
delay: 0,
expire: null,
}),
signal: AbortSignal.timeout(30_000),
});
} catch (err) {
this.logger.error(
`Gama SendBulk network error for ${destinations.length} recipients: ${
err instanceof Error ? err.message : err
}`,
);
throw new ServiceUnavailableException('SMS provider is unreachable');
}
let payload: GamaBulkResponse;
try {
payload = (await response.json()) as GamaBulkResponse;
} catch {
this.logger.error(
`Gama SendBulk returned non-JSON for ${destinations.length} recipients (HTTP ${response.status})`,
);
throw new ServiceUnavailableException('SMS provider returned an invalid response');
}
if (!response.ok || !payload.success) {
const providerMessage =
typeof payload.message === 'string' && payload.message.trim()
? payload.message.trim()
: `HTTP ${response.status}`;
this.logger.warn(
`Gama SendBulk failed for ${destinations.length} recipients: ${providerMessage}`,
);
throw mapGamaError(providerMessage);
}
const body = Array.isArray(payload.body) ? payload.body : [];
const serverIds = body.map((id) => String(id)).filter(Boolean);
if (serverIds.length === 0) {
this.logger.warn(
`Gama SendBulk succeeded without serverIds for ${destinations.length} recipients`,
);
throw new ServiceUnavailableException('SMS provider returned an empty server id');
}
this.logger.log(
`SMS bulk sent to ${destinations.length} via Gama source=${source} (ids=${serverIds.length})`,
);
return serverIds;
}
}
/** Accept 09… / 9… / +989… / 989… → Gama MSISDN `989…` (no plus). */
@@ -242,6 +501,18 @@ export function toGamaMsisdn(raw: string): string | null {
return msisdn;
}
/**
* Iranian mobile only (local 09xxxxxxxxx → `989xxxxxxxxx`).
* Rejects landlines, foreign numbers, and invalid formats.
*/
export function toIranianMobileMsisdn(raw: string): string | null {
const msisdn = toGamaMsisdn(raw);
if (!msisdn || !/^989\d{9}$/.test(msisdn)) {
return null;
}
return msisdn;
}
export function maskMsisdn(msisdn: string): string {
if (msisdn.length < 6) return '***';
return `${msisdn.slice(0, 4)}****${msisdn.slice(-3)}`;
@@ -273,5 +544,8 @@ function mapGamaError(message: string): Error {
if (lower.includes('message is empty')) {
return new BadRequestException('Message is empty');
}
if (lower.includes('maximum amount') || lower.includes('batch')) {
return new BadRequestException('Too many SMS destinations in one batch');
}
return new ServiceUnavailableException('SMS provider rejected the request');
}
+27 -8
View File
@@ -3,6 +3,7 @@ import {
Controller,
Delete,
Get,
Headers,
Param,
Patch,
Post,
@@ -99,16 +100,24 @@ export class PublicBlogsController {
) {}
@Get()
async list(@Param('host') host: string, @Query() query: ListPublicBlogsDto) {
const result = await this.service.listPublic(host, query);
this.analytics.trackPublicPage(host, 'blog_list');
return result;
list(@Param('host') host: string, @Query() query: ListPublicBlogsDto) {
return this.service.listPublic(host, query);
}
@Get('by-id/:blogId')
async getById(@Param('host') host: string, @Param('blogId') blogId: string) {
async getById(
@Param('host') host: string,
@Param('blogId') blogId: string,
@Headers('user-agent') userAgent?: string,
) {
const result = await this.service.getPublicById(host, blogId);
this.analytics.trackPublicPage(host, 'blog_detail', result.blog.id);
this.analytics.trackPublicPage(
host,
'blog_detail',
result.blog.id,
undefined,
userAgent,
);
return result;
}
@@ -127,9 +136,19 @@ export class PublicBlogsController {
}
@Get(':slug')
async getBySlug(@Param('host') host: string, @Param('slug') slug: string) {
async getBySlug(
@Param('host') host: string,
@Param('slug') slug: string,
@Headers('user-agent') userAgent?: string,
) {
const result = await this.service.getPublicBySlug(host, slug);
this.analytics.trackPublicPage(host, 'blog_detail', result.blog.id);
this.analytics.trackPublicPage(
host,
'blog_detail',
result.blog.id,
undefined,
userAgent,
);
return result;
}
}
+87 -13
View File
@@ -12,7 +12,16 @@ import {
import { AuthUser } from '../auth/auth.types';
import { PermissionsService } from '../auth/permissions.service';
import { BusinessSettingsService } from '../business-settings/business-settings.service';
import { normalizeBusinessSettings } from '../business-settings/business-settings.util';
import { buildBlogPostingJsonLd } from '../business-settings/website-entity-schema.util';
import {
applySeoMetaToMetadata,
normalizeSeoMetaInput,
readSeoMetaFromMetadata,
} from '../common/seo-meta.util';
import { PrismaService } from '../prisma/prisma.service';
import { DEFAULT_SITEMAP_PATH_TEMPLATES } from '../sitemap/sitemap.constants';
import { resolveSitemapConfig } from '../sitemap/sitemap-config.util';
import { SitemapService } from '../sitemap/sitemap.service';
import { TenantService } from '../tenant/tenant.service';
import {
@@ -138,7 +147,10 @@ export class BlogsService {
status,
featured_media_id: featuredMediaId,
published_at: status === ContentStatus.published ? new Date() : null,
metadata: this.buildMetadata(dto.tags) as Prisma.InputJsonValue,
metadata: applySeoMetaToMetadata(
this.buildMetadata(dto.tags),
normalizeSeoMetaInput(dto.seoMetaTitle, dto.seoMetaDescription),
) as Prisma.InputJsonValue,
},
include: blogInclude,
});
@@ -218,10 +230,13 @@ export class BlogsService {
nextContent.html = dto.mainTextHtml ?? '';
}
const nextMetadata = { ...existingMetadata };
if (dto.tags !== undefined) {
nextMetadata.tags = dto.tags;
}
const nextMetadata = applySeoMetaToMetadata(
{
...existingMetadata,
...(dto.tags !== undefined ? { tags: dto.tags } : {}),
},
normalizeSeoMetaInput(dto.seoMetaTitle, dto.seoMetaDescription),
);
let publishedAt: Date | null | undefined = undefined;
if (dto.status !== undefined) {
@@ -374,10 +389,7 @@ export class BlogsService {
}
return {
blog: await this.serializeBlog(blog, {
includeComments: true,
approvedCommentsOnly: true,
}),
blog: await this.serializePublicBlog(host, business, blog),
};
}
@@ -405,10 +417,71 @@ export class BlogsService {
}
return {
blog: await this.serializeBlog(blog, {
includeComments: true,
approvedCommentsOnly: true,
}),
blog: await this.serializePublicBlog(host, business, blog),
};
}
private async serializePublicBlog(
host: string,
business: { id: bigint; settings: Prisma.JsonValue; name: string; nameFa: string | null },
blog: BlogWithRelations,
) {
const serialized = await this.serializeBlog(blog, {
includeComments: true,
approvedCommentsOnly: true,
});
const settings = normalizeBusinessSettings(business.settings);
const normalizedHost = host
.toLowerCase()
.trim()
.replace(/^(customer|business)\./, '');
const domain = await this.prisma.domain.findFirst({
where: { businessId: business.id, isPrimary: true },
select: { host: true },
});
const primaryHost = domain?.host ?? normalizedHost;
const sitemap = resolveSitemapConfig(
settings.website.sitemapConfig ?? null,
primaryHost,
);
const media = await this.prisma.business.findUnique({
where: { id: business.id },
select: { logoMedia: { select: { publicUrl: true } } },
});
const authorName = serialized.author
? [serialized.author.firstName, serialized.author.lastName]
.filter(Boolean)
.join(' ')
.trim()
: '';
const jsonLd = settings.website.schema.enabled
? buildBlogPostingJsonLd({
host: primaryHost,
id: serialized.id,
title: serialized.seoMetaTitle || serialized.title,
slug: serialized.slug,
abstract: serialized.seoMetaDescription || serialized.abstract,
mainTextHtml: serialized.seoMetaDescription
? null
: serialized.mainTextHtml,
titleImageUrl: serialized.titleImageUrl,
publishedAt: serialized.publishedAt,
updatedAt: serialized.updatedAt,
authorName: authorName || null,
publisherName: business.nameFa || business.name,
publisherLogoUrl: media?.logoMedia?.publicUrl ?? null,
pathTemplate: sitemap.templates.blog ?? DEFAULT_SITEMAP_PATH_TEMPLATES.blog,
})
: null;
return {
...serialized,
schema: { jsonLd },
};
}
@@ -608,6 +681,7 @@ export class BlogsService {
categoryId: categoryAssignment?.categoryId.toString() ?? null,
categoryName: categoryAssignment?.category.name ?? '',
tags: Array.isArray(metadata.tags) ? (metadata.tags as string[]) : [],
...readSeoMetaFromMetadata(metadata),
authorId: blog.author_id?.toString() ?? null,
author: blog.author
? {
+21
View File
@@ -9,6 +9,7 @@ import {
Matches,
Min,
MinLength,
MaxLength,
} from 'class-validator';
export class ListBlogsDto {
@@ -107,6 +108,16 @@ export class CreateBlogDto {
@IsArray()
@IsString({ each: true })
tags?: string[];
@IsOptional()
@IsString()
@MaxLength(70)
seoMetaTitle?: string | null;
@IsOptional()
@IsString()
@MaxLength(160)
seoMetaDescription?: string | null;
@IsOptional()
@IsString()
@@ -152,6 +163,16 @@ export class UpdateBlogDto {
@IsArray()
@IsString({ each: true })
tags?: string[];
@IsOptional()
@IsString()
@MaxLength(70)
seoMetaTitle?: string | null;
@IsOptional()
@IsString()
@MaxLength(160)
seoMetaDescription?: string | null;
@IsOptional()
@IsString()
+2 -1
View File
@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { ArvanDnsModule } from '../arvan-dns/arvan-dns.module';
import { CloudflareDnsModule } from '../cloudflare-dns/cloudflare-dns.module';
import { WebsiteDeployModule } from '../website-deploy/website-deploy.module';
import { BusinessCategoriesController } from './business-categories.controller';
import { BusinessCategoriesService } from './business-categories.service';
@@ -10,7 +11,7 @@ import { LegacyMigrateService } from './legacy-migrate.service';
import { LegacyPurgeService } from './legacy-purge.service';
@Module({
imports: [AuthModule, WebsiteDeployModule, ArvanDnsModule],
imports: [AuthModule, WebsiteDeployModule, ArvanDnsModule, CloudflareDnsModule],
controllers: [BusinessAdminController, BusinessCategoriesController],
providers: [
BusinessAdminService,
+209 -9
View File
@@ -9,6 +9,10 @@ import {
import { Prisma } from '@prisma/client';
import * as bcrypt from 'bcrypt';
import { PermissionsService } from '../auth/permissions.service';
import {
DEFAULT_NEW_BUSINESS_SMS_CREDITS,
SMS_PURCHASE_PRICE_PER_CREDIT_IRT,
} from '../auth/sms-billing';
import { PrismaService } from '../prisma/prisma.service';
import { AuthUser } from '../auth/auth.types';
import { AddDomainDto } from './dto/add-domain.dto';
@@ -26,10 +30,13 @@ import { LegacyPurgeService } from './legacy-purge.service';
import { normalizeBusinessPrimaryColorId } from '../business-settings/business-primary-colors';
import type { BusinessPrimaryColorId } from '../business-settings/business-primary-colors';
import {
mergeBusinessSettings,
normalizeBusinessSettings,
normalizeDashboardLocale,
normalizeDashboardThemeMode,
normalizeEnabledBusinessModules,
normalizeHomeCharts,
normalizeSmsSenderNumber,
toPrismaJson,
} from '../business-settings/business-settings.util';
import type {
@@ -45,6 +52,7 @@ import {
DEFAULT_NEW_BUSINESS_MODULES,
} from '../business-settings/business-settings.types';
import { ArvanDnsService } from '../arvan-dns/arvan-dns.service';
import { CloudflareDnsService } from '../cloudflare-dns/cloudflare-dns.service';
import { WebsiteDeployAgentService } from '../website-deploy/website-deploy-agent.service';
import {
deploySlugFromHost,
@@ -76,6 +84,14 @@ type BusinessRow = {
themeMode: string | null;
enabledModules: unknown;
homeCharts: unknown;
smsSenderNumber: string | null;
smsBalance: number;
smsSentCount: number;
smsSentMass: number;
smsSentSingular: number;
smsSentAuth: number;
smsSentAutoCustomer: number;
smsSentAdmin: number;
};
function slugify(value: string) {
@@ -95,6 +111,7 @@ export class BusinessAdminService {
private readonly legacyPurge: LegacyPurgeService,
private readonly websiteDeployAgent: WebsiteDeployAgentService,
private readonly arvanDns: ArvanDnsService,
private readonly cloudflareDns: CloudflareDnsService,
) {}
private async assertSuperAdmin(actor: AuthUser) {
@@ -113,6 +130,8 @@ export class BusinessAdminService {
const nameLike = query.name ? `%${query.name.trim()}%` : null;
const domainLike = query.domain ? `%${query.domain.trim()}%` : null;
const categoryLike = query.category ? `%${query.category.trim()}%` : null;
const sortByRenewal = query.sort === 'renewal';
const lowSmsThreshold = 100;
const where = Prisma.sql`
WHERE 1=1
@@ -134,7 +153,40 @@ export class BusinessAdminService {
` : Prisma.empty}
`;
const [items, totalRow] = await Promise.all([
// Matches super-admin nextAnnualRenewal(): future contract date as-is,
// otherwise this calendar year's month/day (overdue if already past).
const nextRenewalDateSql = Prisma.sql`
CASE
WHEN COALESCE(b.annual_renewal_at, b.created_at) IS NULL THEN NULL
WHEN COALESCE(b.annual_renewal_at, b.created_at)::date > CURRENT_DATE
THEN COALESCE(b.annual_renewal_at, b.created_at)::date
ELSE make_date(
EXTRACT(YEAR FROM CURRENT_DATE)::int,
EXTRACT(MONTH FROM COALESCE(b.annual_renewal_at, b.created_at))::int,
LEAST(
EXTRACT(DAY FROM COALESCE(b.annual_renewal_at, b.created_at))::int,
EXTRACT(
DAY FROM (
DATE_TRUNC(
'month',
make_date(
EXTRACT(YEAR FROM CURRENT_DATE)::int,
EXTRACT(MONTH FROM COALESCE(b.annual_renewal_at, b.created_at))::int,
1
)
) + INTERVAL '1 month - 1 day'
)
)::int
)
)
END
`;
const orderBy = sortByRenewal
? Prisma.sql`ORDER BY ${nextRenewalDateSql} ASC NULLS LAST, b.created_at DESC`
: Prisma.sql`ORDER BY b.created_at DESC`;
const [items, totalRow, lowSmsRows] = await Promise.all([
this.prisma.$queryRaw<BusinessRow[]>(Prisma.sql`
SELECT
b.id AS "id",
@@ -159,7 +211,15 @@ export class BusinessAdminService {
b.settings->'branding'->>'defaultLocale' AS "defaultLocale",
b.settings->'branding'->>'themeMode' AS "themeMode",
b.settings->'modules'->'enabled' AS "enabledModules",
b.settings->'modules'->'charts' AS "homeCharts"
b.settings->'modules'->'charts' AS "homeCharts",
b.settings->'sms'->>'senderNumber' AS "smsSenderNumber",
b.sms_balance AS "smsBalance",
b.sms_sent_count AS "smsSentCount",
b.sms_sent_mass AS "smsSentMass",
b.sms_sent_singular AS "smsSentSingular",
b.sms_sent_auth AS "smsSentAuth",
b.sms_sent_auto_customer AS "smsSentAutoCustomer",
b.sms_sent_admin AS "smsSentAdmin"
FROM businesses b
LEFT JOIN LATERAL (
SELECT d.id, d.host, d.ssl_enabled, d.git_repo_url, d.deploy_slug
@@ -180,7 +240,7 @@ export class BusinessAdminService {
LIMIT 1
) own ON TRUE
${where}
ORDER BY b.created_at DESC
${orderBy}
LIMIT ${pageSize} OFFSET ${skip}
`),
this.prisma.$queryRaw<{ total: number }[]>(Prisma.sql`
@@ -188,6 +248,30 @@ export class BusinessAdminService {
FROM businesses b
${where}
`),
this.prisma.$queryRaw<
{
id: bigint;
name: string;
domain: string | null;
smsBalance: number;
}[]
>(Prisma.sql`
SELECT
b.id AS "id",
b.name AS "name",
dom.host AS "domain",
b.sms_balance AS "smsBalance"
FROM businesses b
LEFT JOIN LATERAL (
SELECT d.host
FROM domains d
WHERE d.business_id = b.id
ORDER BY d.is_primary DESC, d.created_at DESC
LIMIT 1
) dom ON TRUE
WHERE b.sms_balance < ${lowSmsThreshold}
ORDER BY b.sms_balance ASC, b.name ASC
`),
]);
return {
@@ -220,11 +304,28 @@ export class BusinessAdminService {
enabledModules,
moduleCount: enabledModules.length,
homeCharts,
smsSenderNumber: normalizeSmsSenderNumber(item.smsSenderNumber),
smsBalance: Number(item.smsBalance ?? 0),
smsSentCount: Number(item.smsSentCount ?? 0),
smsSentByType: {
mass: Number(item.smsSentMass ?? 0),
singular: Number(item.smsSentSingular ?? 0),
auth: Number(item.smsSentAuth ?? 0),
autoCustomer: Number(item.smsSentAutoCustomer ?? 0),
admin: Number(item.smsSentAdmin ?? 0),
},
};
}),
total: totalRow[0]?.total ?? 0,
page,
pageSize,
lowSmsBusinesses: lowSmsRows.map((row) => ({
id: row.id,
name: row.name,
domain: row.domain,
smsBalance: Number(row.smsBalance ?? 0),
})),
lowSmsThreshold,
};
}
@@ -241,15 +342,27 @@ export class BusinessAdminService {
name: string;
nameFa: string | null;
slug: string;
ownerName: string | null;
ownerCellNumber: string | null;
}[]
>(Prisma.sql`
SELECT DISTINCT
b.id AS "id",
b.name AS "name",
b.name_fa AS "nameFa",
b.slug AS "slug"
b.slug AS "slug",
NULLIF(TRIM(BOTH ' ' FROM COALESCE(u.first_name, '') || ' ' || COALESCE(u.last_name, '')), '') AS "ownerName",
u.cell_number AS "ownerCellNumber"
FROM businesses b
LEFT JOIN domains d ON d.business_id = b.id
LEFT JOIN LATERAL (
SELECT bu.user_id
FROM business_users bu
WHERE bu.business_id = b.id AND bu.is_owner = TRUE
ORDER BY bu.created_at ASC
LIMIT 1
) own ON TRUE
LEFT JOIN users u ON u.id = own.user_id
WHERE b.is_active = TRUE
AND (
b.name ILIKE ${like}
@@ -268,6 +381,8 @@ export class BusinessAdminService {
nameFa: business.nameFa,
slug: business.slug,
label: this.formatBusinessLabel(business),
ownerName: business.ownerName,
ownerCellNumber: business.ownerCellNumber,
})),
};
}
@@ -338,6 +453,8 @@ export class BusinessAdminService {
take: 1,
},
domains: { orderBy: [{ isPrimary: 'desc' }, { createdAt: 'asc' }] },
logoMedia: true,
bannerMedia: true,
},
});
@@ -379,6 +496,7 @@ export class BusinessAdminService {
: new Date(),
oldBusinessId:
dto.oldBusinessId !== undefined ? BigInt(dto.oldBusinessId) : undefined,
smsBalance: DEFAULT_NEW_BUSINESS_SMS_CREDITS,
settings: toPrismaJson({
...DEFAULT_BUSINESS_SETTINGS,
modules: {
@@ -446,7 +564,47 @@ export class BusinessAdminService {
await this.assertOldBusinessIdAvailable(BigInt(dto.oldBusinessId), businessId);
}
if (dto.smsSenderNumber !== undefined) {
if (
dto.smsSenderNumber !== null &&
dto.smsSenderNumber !== '' &&
!normalizeSmsSenderNumber(dto.smsSenderNumber)
) {
throw new BadRequestException(
'smsSenderNumber must be an 8–16 digit shortcode',
);
}
}
if (dto.smsTopUpIrt !== undefined && dto.smsTopUpIrt < 1) {
throw new BadRequestException('smsTopUpIrt must be a positive integer');
}
const topUpCredits =
dto.smsTopUpIrt !== undefined
? Math.floor(dto.smsTopUpIrt / SMS_PURCHASE_PRICE_PER_CREDIT_IRT)
: 0;
if (dto.smsTopUpIrt !== undefined && topUpCredits < 1) {
throw new BadRequestException(
`Top-up amount is too small to purchase any SMS credits (${SMS_PURCHASE_PRICE_PER_CREDIT_IRT} IRT each).`,
);
}
await this.prisma.$transaction(async (tx) => {
const nextSettings =
dto.smsSenderNumber !== undefined
? toPrismaJson(
mergeBusinessSettings(normalizeBusinessSettings(business.settings), {
sms: {
senderNumber:
dto.smsSenderNumber === null || dto.smsSenderNumber === ''
? null
: normalizeSmsSenderNumber(dto.smsSenderNumber),
},
}),
)
: undefined;
await tx.business.update({
where: { id: businessId },
data: {
@@ -468,6 +626,10 @@ export class BusinessAdminService {
: new Date(dto.annualRenewalAt),
}
: {}),
...(nextSettings !== undefined ? { settings: nextSettings } : {}),
...(topUpCredits > 0
? { smsBalance: { increment: topUpCredits } }
: {}),
},
});
@@ -571,7 +733,9 @@ export class BusinessAdminService {
},
});
const dnsError = dto.updateDns ? await this.applyArvanDns(host) : null;
const dnsError = dto.updateDns
? await this.applyTenantDns(host, dto.dnsProvider === 'cloudflare' ? 'cloudflare' : 'arvan')
: null;
return {
...domain,
@@ -675,7 +839,9 @@ export class BusinessAdminService {
},
});
const dnsError = dto.updateDns ? await this.applyArvanDns(host) : null;
const dnsError = dto.updateDns
? await this.applyTenantDns(host, dto.dnsProvider === 'cloudflare' ? 'cloudflare' : 'arvan')
: null;
return {
...updated,
@@ -684,9 +850,16 @@ export class BusinessAdminService {
};
}
private async applyArvanDns(host: string): Promise<string | null> {
private async applyTenantDns(
host: string,
provider: 'arvan' | 'cloudflare',
): Promise<string | null> {
try {
await this.arvanDns.ensureTenantRecords(host);
if (provider === 'cloudflare') {
await this.cloudflareDns.ensureTenantRecords(host);
} else {
await this.arvanDns.ensureTenantRecords(host);
}
return null;
} catch (err) {
if (err instanceof HttpException) {
@@ -700,7 +873,7 @@ export class BusinessAdminService {
if (err && typeof err === 'object' && 'message' in err && typeof err.message === 'string') {
return err.message;
}
return 'Arvan DNS update failed';
return provider === 'cloudflare' ? 'Cloudflare DNS update failed' : 'Arvan DNS update failed';
}
}
@@ -1225,6 +1398,14 @@ export class BusinessAdminService {
annualRenewalAt: Date | null;
createdAt: Date;
updatedAt: Date;
settings: unknown;
smsBalance?: number;
smsSentCount?: number;
smsSentMass?: number;
smsSentSingular?: number;
smsSentAuth?: number;
smsSentAutoCustomer?: number;
smsSentAdmin?: number;
categoryAssignments: {
category: {
id: bigint;
@@ -1251,9 +1432,14 @@ export class BusinessAdminService {
isVerified: boolean;
sslEnabled: boolean;
}[];
logoMediaId?: bigint | null;
logoMedia?: { publicUrl: string } | null;
bannerMediaId?: bigint | null;
bannerMedia?: { publicUrl: string } | null;
},
) {
const owner = business.businessUsers[0]?.user ?? null;
const settings = normalizeBusinessSettings(business.settings);
return {
id: business.id,
@@ -1267,6 +1453,20 @@ export class BusinessAdminService {
annualRenewalAt: business.annualRenewalAt,
createdAt: business.createdAt,
updatedAt: business.updatedAt,
logoMediaId: business.logoMediaId?.toString() ?? null,
logoUrl: business.logoMedia?.publicUrl ?? null,
bannerMediaId: business.bannerMediaId?.toString() ?? null,
bannerUrl: business.bannerMedia?.publicUrl ?? null,
smsSenderNumber: settings.sms.senderNumber,
smsBalance: business.smsBalance ?? 0,
smsSentCount: business.smsSentCount ?? 0,
smsSentByType: {
mass: business.smsSentMass ?? 0,
singular: business.smsSentSingular ?? 0,
auth: business.smsSentAuth ?? 0,
autoCustomer: business.smsSentAutoCustomer ?? 0,
admin: business.smsSentAdmin ?? 0,
},
categories: business.categoryAssignments.map((a) => ({
id: a.category.id,
name: a.category.name,
@@ -1,8 +1,30 @@
import { Controller, Get, UseGuards } from '@nestjs/common';
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import { IsOptional, IsString, MaxLength } from 'class-validator';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { AuthUser } from '../auth/auth.types';
import { BusinessCategoriesService } from './business-categories.service';
import {
CreateBusinessCategoryDto,
UpdateBusinessCategoryDto,
} from './dto/business-category.dto';
class ListBusinessCategoriesDto {
@IsOptional()
@IsString()
@MaxLength(100)
q?: string;
}
@Controller('business-categories')
export class BusinessCategoriesController {
@@ -10,7 +32,38 @@ export class BusinessCategoriesController {
@Get()
@UseGuards(JwtAuthGuard)
list(@CurrentUser() user: AuthUser) {
return this.service.list(user);
list(
@CurrentUser() user: AuthUser,
@Query() query: ListBusinessCategoriesDto,
) {
return this.service.list(user, query.q);
}
@Post()
@UseGuards(JwtAuthGuard)
create(
@CurrentUser() user: AuthUser,
@Body() dto: CreateBusinessCategoryDto,
) {
return this.service.create(user, dto);
}
@Patch(':categoryId')
@UseGuards(JwtAuthGuard)
update(
@CurrentUser() user: AuthUser,
@Param('categoryId') categoryId: string,
@Body() dto: UpdateBusinessCategoryDto,
) {
return this.service.update(user, categoryId, dto);
}
@Delete(':categoryId')
@UseGuards(JwtAuthGuard)
remove(
@CurrentUser() user: AuthUser,
@Param('categoryId') categoryId: string,
) {
return this.service.remove(user, categoryId);
}
}
@@ -1,7 +1,27 @@
import { Injectable, ForbiddenException } from '@nestjs/common';
import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { PermissionsService } from '../auth/permissions.service';
import { AuthUser } from '../auth/auth.types';
import {
CreateBusinessCategoryDto,
UpdateBusinessCategoryDto,
} from './dto/business-category.dto';
function slugify(value: string): string {
return (
value
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '') || 'category'
);
}
@Injectable()
export class BusinessCategoriesService {
@@ -10,15 +30,55 @@ export class BusinessCategoriesService {
private readonly permissions: PermissionsService,
) {}
async list(actor: AuthUser) {
const isSuperAdmin = await this.permissions.isSuperAdmin(actor.id);
const canRead =
isSuperAdmin ||
actor.roles.includes('business_owner') ||
actor.roles.includes('business_staff');
async list(actor: AuthUser, q?: string) {
await this.assertCanRead(actor);
if (!canRead) {
throw new ForbiddenException('Access denied');
const term = q?.trim();
if (term) {
const pattern = `%${term}%`;
const rows = await this.prisma.$queryRaw<
{
id: bigint;
parent_id: bigint | null;
name: string;
name_fa: string | null;
slug: string;
description: string | null;
icon: string | null;
sort_order: number;
tags: string[];
}[]
>(Prisma.sql`
SELECT
id, parent_id, name, name_fa, slug, description, icon, sort_order, tags
FROM business_categories
WHERE is_active = TRUE
AND (
name ILIKE ${pattern}
OR COALESCE(name_fa, '') ILIKE ${pattern}
OR slug ILIKE ${pattern}
OR EXISTS (
SELECT 1
FROM unnest(tags) AS tag
WHERE tag ILIKE ${pattern}
)
)
ORDER BY sort_order ASC, name ASC
`);
return {
items: rows.map((row) => ({
id: row.id,
parentId: row.parent_id,
name: row.name,
nameFa: row.name_fa,
slug: row.slug,
description: row.description,
icon: row.icon,
sortOrder: row.sort_order,
tags: row.tags ?? [],
})),
};
}
const categories = await this.prisma.businessCategory.findMany({
@@ -28,13 +88,222 @@ export class BusinessCategoriesService {
id: true,
parentId: true,
name: true,
nameFa: true,
slug: true,
description: true,
icon: true,
sortOrder: true,
tags: true,
},
});
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;
}
}
+7 -2
View File
@@ -1,4 +1,4 @@
import { IsBoolean, IsOptional, IsString, Matches, MinLength } from 'class-validator';
import { IsBoolean, IsIn, IsOptional, IsString, Matches, MinLength } from 'class-validator';
import { GIT_REPO_URL_RE } from '../../website-deploy/website-deploy.util';
export class AddDomainDto {
@@ -10,11 +10,16 @@ export class AddDomainDto {
@IsBoolean()
isPrimary?: boolean;
/** When true, upsert Meshkee tenant records in Arvan DNS. */
/** When true, upsert Meshkee tenant records (@, www, business, customer, api). */
@IsOptional()
@IsBoolean()
updateDns?: boolean;
/** DNS host for tenant records. Ignored unless updateDns is true. Default arvan. */
@IsOptional()
@IsIn(['arvan', 'cloudflare'])
dnsProvider?: 'arvan' | 'cloudflare';
/** HTTPS or SSH git URL — when set, provisions storefront deploy on the websites VM. */
@IsOptional()
@IsString()
@@ -0,0 +1,90 @@
import { Type } from 'class-transformer';
import {
IsInt,
IsOptional,
IsString,
Matches,
MaxLength,
Min,
MinLength,
ValidateIf,
} from 'class-validator';
export class CreateBusinessCategoryDto {
@IsString()
@MinLength(2)
@MaxLength(255)
name!: string;
@IsOptional()
@IsString()
@MaxLength(255)
nameFa?: string;
@IsOptional()
@ValidateIf((_, value) => value !== null && value !== '')
@IsString()
parentId?: string | null;
@IsOptional()
@IsString()
description?: string;
@IsOptional()
@IsString()
@Matches(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, {
message: 'slug must be lowercase letters, numbers, and hyphens',
})
slug?: string;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
sortOrder?: number;
@IsOptional()
@IsString()
@MaxLength(100)
icon?: string;
}
export class UpdateBusinessCategoryDto {
@IsOptional()
@IsString()
@MinLength(2)
@MaxLength(255)
name?: string;
@IsOptional()
@IsString()
@MaxLength(255)
nameFa?: string | null;
@IsOptional()
@ValidateIf((_, value) => value !== null && value !== '')
@IsString()
parentId?: string | null;
@IsOptional()
@IsString()
description?: string | null;
@IsOptional()
@IsString()
@Matches(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, {
message: 'slug must be lowercase letters, numbers, and hyphens',
})
slug?: string;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
sortOrder?: number;
@IsOptional()
@IsString()
@MaxLength(100)
icon?: string | null;
}
@@ -1,5 +1,5 @@
import { Type } from 'class-transformer';
import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
import { IsIn, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
export class ListBusinessesDto {
@IsOptional()
@@ -26,5 +26,10 @@ export class ListBusinessesDto {
@IsOptional()
@IsString()
category?: string;
/** `created` (default) or `renewal` (nearest next/overdue renewal first). */
@IsOptional()
@IsIn(['created', 'renewal'])
sort?: 'created' | 'renewal';
}
@@ -7,6 +7,7 @@ import {
IsPositive,
IsString,
Matches,
Min,
MinLength,
ValidateIf,
} from 'class-validator';
@@ -59,4 +60,26 @@ export class UpdateBusinessDto {
@ValidateIf((_, value) => value !== null)
@IsDateString()
annualRenewalAt?: string | null;
/**
* Business SMS shortcode for customer messages (e.g. 5000123456).
* Empty string or null clears it (falls back to Meshkee advertising number).
*/
@IsOptional()
@ValidateIf((_, value) => value !== null && value !== '')
@IsString()
@Matches(/^\d{8,16}$/, {
message: 'smsSenderNumber must be an 8–16 digit shortcode',
})
smsSenderNumber?: string | null;
/**
* IRT (toman) amount to add as SMS credits.
* Credits added = floor(smsTopUpIrt / 500). Does not replace existing balance.
*/
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
smsTopUpIrt?: number;
}
+7 -2
View File
@@ -1,4 +1,4 @@
import { IsBoolean, IsOptional, IsString, Matches, MinLength } from 'class-validator';
import { IsBoolean, IsIn, IsOptional, IsString, Matches, MinLength } from 'class-validator';
import { GIT_REPO_URL_RE } from '../../website-deploy/website-deploy.util';
export class UpdateDomainDto {
@@ -6,11 +6,16 @@ export class UpdateDomainDto {
@MinLength(1)
host!: string;
/** When true, upsert Meshkee tenant records in Arvan DNS. */
/** When true, upsert Meshkee tenant records (@, www, business, customer, api). */
@IsOptional()
@IsBoolean()
updateDns?: boolean;
/** DNS host for tenant records. Ignored unless updateDns is true. Default arvan. */
@IsOptional()
@IsIn(['arvan', 'cloudflare'])
dnsProvider?: 'arvan' | 'cloudflare';
/** HTTPS or SSH git URL — when set, provisions storefront deploy on the websites VM. */
@IsOptional()
@IsString()
+25 -4
View File
@@ -1379,7 +1379,7 @@ export class LegacyMigrateService {
.filter((m) => Number(m.size) > 0)
.slice(0, 12)
.map((m) => ({ id: m.id, fileName: m.file_name }));
const storagePrefix = await this.legacySourceS3.resolveBusinessPrefix(
const storagePrefix = await this.tryResolveStoragePrefix(
oldBizId,
oldSlug,
probeMedia,
@@ -1417,7 +1417,7 @@ export class LegacyMigrateService {
for (const media of articleMedia) {
const size = Number(media.size) || 0;
if (size <= 0) {
if (size <= 0 || !storagePrefix) {
imagesFailed += 1;
continue;
}
@@ -1611,7 +1611,7 @@ export class LegacyMigrateService {
.filter((m) => Number(m.size) > 0)
.slice(0, 12)
.map((m) => ({ id: m.id, fileName: m.file_name }));
const storagePrefix = await this.legacySourceS3.resolveBusinessPrefix(
const storagePrefix = await this.tryResolveStoragePrefix(
oldBizId,
oldSlug,
probeMedia,
@@ -1648,7 +1648,7 @@ export class LegacyMigrateService {
for (const media of itemMedia) {
const size = Number(media.size) || 0;
if (size <= 0) {
if (size <= 0 || !storagePrefix) {
imagesFailed += 1;
continue;
}
@@ -1978,6 +1978,27 @@ export class LegacyMigrateService {
};
}
private async tryResolveStoragePrefix(
oldBizId: number,
oldSlug: string | null,
probeMedia: Array<{ id: number; fileName: string }>,
): Promise<string | null> {
if (!probeMedia.length) return null;
try {
return await this.legacySourceS3.resolveBusinessPrefix(
oldBizId,
oldSlug,
probeMedia,
);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.warn(
`Legacy migrate: skipping image copy for business ${oldBizId}: ${message}`,
);
return null;
}
}
private async copyLegacyMedia(input: {
businessId: bigint;
oldMedia: OldMediaRow;
@@ -0,0 +1,99 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { AuthUser } from '../auth/auth.types';
import { normalizeAiDescriptionHtml } from '../common/ai-html.util';
import {
requestAiJsonCompletion,
resolveAiProvider,
} from '../common/ai-provider.util';
import {
BusinessProfileAiFillField,
FillBusinessProfileFieldByAiDto,
} from './dto/business-profile-ai.dto';
@Injectable()
export class BusinessProfileAiService {
constructor(private readonly config: ConfigService) {}
async fillField(
_businessIdRaw: string,
dto: FillBusinessProfileFieldByAiDto,
_actor: AuthUser,
) {
const prompt = dto.prompt?.trim();
if (!prompt || prompt.length < 10) {
throw new BadRequestException('Prompt is required');
}
const nameEn = dto.nameEn?.trim() || '';
const nameFa = dto.nameFa?.trim() || '';
if (!nameEn && !nameFa) {
throw new BadRequestException(
'Enter a business name before using AI fill',
);
}
const provider = resolveAiProvider(this.config);
const isAbout = dto.field === BusinessProfileAiFillField.about;
const systemPrompt = isAbout
? `You write short business abstracts for an Iranian B2B marketplace listing.
Return ONLY valid JSON: { "text": "..." }
Follow the user's instructions for language and tone.
Keep the abstract to 1–4 sentences. No HTML. No markdown.
Prefer a clear, professional, informative style about what the business does.
Never write sales CTAs, discounts, or vague praise.`
: `You write business profile descriptions as HTML for an advanced rich-text editor on an Iranian marketplace.
Return ONLY valid JSON: { "html": "..." }
The "html" value must be a raw HTML fragment (real tags, not escaped entities, not markdown, not code fences).
Allowed tags only: p, ul, ol, li, strong, em, br.
Do not wrap in html/body/div. Do not include scripts, styles, classes, or attributes.
Follow the user's instructions for language and tone.
Prefer a professional, informative structure about the company, offerings, and strengths.
Never write sales CTAs, discounts, or vague marketing slogans.
Aim for 2–5 short paragraphs and optionally a bullet list.`;
const userPrompt = JSON.stringify({
instructions: prompt,
business: {
nameEn: nameEn || null,
nameFa: nameFa || null,
categories: dto.categories?.trim() || null,
},
});
let content: string;
try {
content = await requestAiJsonCompletion(
provider,
systemPrompt,
userPrompt,
0.5,
);
} catch (err) {
const message = err instanceof Error ? err.message : 'AI request failed';
throw new BadRequestException(message);
}
let parsed: { text?: unknown; html?: unknown };
try {
parsed = JSON.parse(content) as { text?: unknown; html?: unknown };
} catch {
throw new BadRequestException('AI returned invalid JSON');
}
if (isAbout) {
const text = String(parsed.text ?? '').trim();
if (!text) {
throw new BadRequestException('AI returned an empty abstract');
}
return { field: BusinessProfileAiFillField.about, text };
}
const html = normalizeAiDescriptionHtml(String(parsed.html ?? ''));
if (!html) {
throw new BadRequestException('AI returned an empty description');
}
return { field: BusinessProfileAiFillField.vision, html };
}
}
@@ -1,16 +1,21 @@
import { Body, Controller, Get, Param, Patch, UseGuards } from '@nestjs/common';
import { Body, Controller, Get, Param, Patch, Post, 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 { BusinessProfileAiService } from './business-profile-ai.service';
import { BusinessProfileService } from './business-profile.service';
import { FillBusinessProfileFieldByAiDto } from './dto/business-profile-ai.dto';
import { UpdateBusinessProfileDto } from './dto/update-business-profile.dto';
@Controller('businesses/:businessId/profile')
@UseGuards(JwtAuthGuard, BusinessPermissionGuard)
export class BusinessProfileController {
constructor(private readonly service: BusinessProfileService) {}
constructor(
private readonly service: BusinessProfileService,
private readonly aiService: BusinessProfileAiService,
) {}
@Get()
@RequireBusinessPermission('business.read')
@@ -27,4 +32,14 @@ export class BusinessProfileController {
) {
return this.service.update(businessId, dto, user);
}
@Post('ai-fill')
@RequireBusinessPermission('business.update')
fillFieldByAi(
@Param('businessId') businessId: string,
@Body() dto: FillBusinessProfileFieldByAiDto,
@CurrentUser() user: AuthUser,
) {
return this.aiService.fillField(businessId, dto, user);
}
}
@@ -1,11 +1,12 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { BusinessProfileAiService } from './business-profile-ai.service';
import { BusinessProfileController } from './business-profile.controller';
import { BusinessProfileService } from './business-profile.service';
@Module({
imports: [AuthModule],
controllers: [BusinessProfileController],
providers: [BusinessProfileService],
providers: [BusinessProfileService, BusinessProfileAiService],
})
export class BusinessProfileModule {}
@@ -70,6 +70,7 @@ export class BusinessProfileService {
logoMedia: true,
logoDarkMedia: true,
faviconMedia: true,
bannerMedia: true,
},
});
@@ -102,6 +103,7 @@ export class BusinessProfileService {
logoMedia: true,
logoDarkMedia: true,
faviconMedia: true,
bannerMedia: true,
},
});
if (!business) {
@@ -144,6 +146,7 @@ export class BusinessProfileService {
logoMedia: true,
logoDarkMedia: true,
faviconMedia: true,
bannerMedia: true,
},
});
@@ -167,6 +170,10 @@ export class BusinessProfileService {
await this.assertLogoMedia(businessId, BigInt(dto.faviconMediaId));
}
if (dto.bannerMediaId !== undefined && dto.bannerMediaId !== null) {
await this.assertBannerMedia(businessId, BigInt(dto.bannerMediaId));
}
const previousFaviconMediaId = business.faviconMediaId;
const faviconExplicitlySet = dto.faviconMediaId !== undefined;
const logoChanged =
@@ -180,6 +187,7 @@ export class BusinessProfileService {
if (dto.nameEn !== undefined) data.name = dto.nameEn.trim();
if (dto.nameFa !== undefined) data.nameFa = dto.nameFa.trim();
if (dto.title !== undefined) data.description = dto.title.trim() || null;
if (dto.about !== undefined) data.about = dto.about.trim() || null;
if (dto.vision !== undefined) data.vision = dto.vision.trim() || null;
if (dto.emails !== undefined) {
@@ -188,7 +196,9 @@ export class BusinessProfileService {
);
}
if (dto.phoneNumbers !== undefined) {
data.phoneNumbers = toPrismaJsonPhoneNumbers(dto.phoneNumbers);
data.phoneNumbers = toPrismaJsonPhoneNumbers(
normalizePhoneNumbers(dto.phoneNumbers),
);
}
if (dto.socialMedia !== undefined) {
data.socialMedia = toPrismaJsonSocialMedia({
@@ -222,6 +232,13 @@ export class BusinessProfileService {
: { connect: { id: BigInt(dto.faviconMediaId) } };
}
if (dto.bannerMediaId !== undefined) {
data.bannerMedia =
dto.bannerMediaId === null
? { disconnect: true }
: { connect: { id: BigInt(dto.bannerMediaId) } };
}
if (Object.keys(data).length > 0) {
await tx.business.update({
where: { id: businessId },
@@ -395,6 +412,7 @@ export class BusinessProfileService {
private serializeProfile(business: {
name: string;
nameFa: string | null;
description: string | null;
about: string | null;
vision: string | null;
emails: unknown;
@@ -406,11 +424,14 @@ export class BusinessProfileService {
logoDarkMedia: { publicUrl: string } | null;
faviconMediaId: bigint | null;
faviconMedia: { publicUrl: string } | null;
bannerMediaId: bigint | null;
bannerMedia: { publicUrl: string } | null;
categoryAssignments: { categoryId: bigint }[];
}): BusinessProfile {
return {
nameEn: business.name,
nameFa: business.nameFa ?? '',
title: business.description ?? '',
about: business.about ?? '',
vision: business.vision ?? '',
emails: normalizeEmails(business.emails),
@@ -425,6 +446,8 @@ export class BusinessProfileService {
business.faviconMedia?.publicUrl ??
business.logoMedia?.publicUrl ??
null,
bannerMediaId: business.bannerMediaId?.toString() ?? null,
bannerUrl: business.bannerMedia?.publicUrl ?? null,
categoryIds: business.categoryAssignments.map((item) =>
item.categoryId.toString(),
),
@@ -433,6 +456,7 @@ export class BusinessProfileService {
private serializeAddress(address: {
id: bigint;
label: string | null;
province: string;
city: string;
address: string;
@@ -441,6 +465,7 @@ export class BusinessProfileService {
}): BusinessAddress {
return {
id: address.id.toString(),
name: address.label ?? '',
province: address.province,
city: address.city,
address: address.address,
@@ -465,10 +490,11 @@ export class BusinessProfileService {
for (const item of addresses) {
const payload = {
label: item.name?.trim() || null,
province: item.province.trim(),
city: item.city.trim(),
address: item.address.trim(),
postalCode: item.postalCode.trim(),
postalCode: item.postalCode?.trim() || null,
landline: item.landline?.trim() || null,
};
@@ -523,6 +549,28 @@ export class BusinessProfileService {
}
}
/** Recommended banner aspect 18:6 (3:1). Soft-check when dimensions exist. */
private async assertBannerMedia(businessId: bigint, mediaId: bigint) {
const media = await this.prisma.media.findFirst({
where: { id: mediaId, businessId },
select: { id: true, width: true, height: true },
});
if (!media) {
throw new BadRequestException('Banner media not found for this business');
}
if (media.width && media.height && media.height > 0) {
const ratio = media.width / media.height;
const target = 18 / 6; // 3
if (Math.abs(ratio - target) / target > 0.12) {
throw new BadRequestException(
'Banner aspect ratio should be about 18:6 (3:1)',
);
}
}
}
private async assertPermission(
businessId: bigint,
userId: bigint,
@@ -1,6 +1,8 @@
export type BusinessPhoneType = 'landline' | 'cell';
export type BusinessPhoneNumber = {
/** Display label — presets like Cell/Landline or any custom text */
label: string;
type: BusinessPhoneType;
number: string;
};
@@ -26,6 +28,8 @@ export const DEFAULT_BUSINESS_SOCIAL_MEDIA: BusinessSocialMedia = {
export type BusinessProfile = {
nameEn: string;
nameFa: string;
/** Single-line business title / tagline (stored as businesses.description). */
title: string;
about: string;
vision: string;
emails: string[];
@@ -37,11 +41,15 @@ export type BusinessProfile = {
logoDarkUrl: string | null;
faviconMediaId: string | null;
faviconUrl: string | null;
/** Recommended aspect ratio 18:6 (3:1). */
bannerMediaId: string | null;
bannerUrl: string | null;
categoryIds: string[];
};
export type BusinessAddress = {
id: string;
name: string;
province: string;
city: string;
address: string;
+20 -5
View File
@@ -25,10 +25,17 @@ export function normalizePhoneNumbers(raw: unknown): BusinessPhoneNumber[] {
return raw
.filter(isRecord)
.map((item) => ({
type: (item.type === 'landline' ? 'landline' : 'cell') as BusinessPhoneNumber['type'],
number: readString(item.number).trim(),
}))
.map((item) => {
const type = (
item.type === 'landline' ? 'landline' : 'cell'
) as BusinessPhoneNumber['type'];
const number = readString(item.number).trim();
const labelRaw = readString(item.label).trim();
const label =
labelRaw || (type === 'landline' ? 'Landline' : 'Cell');
return { label, type, number };
})
.filter((item) => item.number.length > 0);
}
@@ -50,7 +57,15 @@ export function toPrismaJsonEmails(emails: string[]) {
}
export function toPrismaJsonPhoneNumbers(phoneNumbers: BusinessPhoneNumber[]) {
return phoneNumbers;
return phoneNumbers.map((item) => {
const label =
item.label?.trim() || (item.type === 'landline' ? 'Landline' : 'Cell');
return {
label,
type: item.type === 'landline' ? 'landline' : 'cell',
number: item.number.trim(),
};
});
}
export function toPrismaJsonSocialMedia(socialMedia: BusinessSocialMedia) {
@@ -0,0 +1,27 @@
import { IsEnum, IsOptional, IsString, MinLength } from 'class-validator';
export enum BusinessProfileAiFillField {
about = 'about',
vision = 'vision',
}
export class FillBusinessProfileFieldByAiDto {
@IsEnum(BusinessProfileAiFillField)
field!: BusinessProfileAiFillField;
@IsString()
@MinLength(10)
prompt!: string;
@IsOptional()
@IsString()
nameEn?: string;
@IsOptional()
@IsString()
nameFa?: string;
@IsOptional()
@IsString()
categories?: string;
}
@@ -5,11 +5,17 @@ import {
IsInt,
IsOptional,
IsString,
MaxLength,
MinLength,
ValidateNested,
} from 'class-validator';
class BusinessPhoneNumberDto {
@IsOptional()
@IsString()
@MinLength(1)
label?: string;
@IsIn(['landline', 'cell'])
type!: 'landline' | 'cell';
@@ -49,6 +55,10 @@ class BusinessAddressDto {
@IsString()
id?: string;
@IsOptional()
@IsString()
name?: string | null;
@IsString()
@MinLength(1)
province!: string;
@@ -61,9 +71,10 @@ class BusinessAddressDto {
@MinLength(1)
address!: string;
@IsOptional()
@IsString()
@MinLength(1)
postalCode!: string;
@MaxLength(20)
postalCode?: string | null;
@IsOptional()
@IsString()
@@ -81,6 +92,12 @@ export class UpdateBusinessProfileDto {
@MinLength(2)
nameFa?: string;
/** Single-line business title / tagline. */
@IsOptional()
@IsString()
@MaxLength(255)
title?: string;
@IsOptional()
@IsString()
about?: string;
@@ -120,6 +137,12 @@ export class UpdateBusinessProfileDto {
@IsInt()
faviconMediaId?: number | null;
/** Marketplace/storefront banner; recommended aspect 18:6 (3:1). */
@IsOptional()
@Type(() => Number)
@IsInt()
bannerMediaId?: number | null;
@IsOptional()
@IsArray()
@Type(() => Number)
@@ -17,6 +17,8 @@ import {
sanitizeBusinessSettingsForClient,
toPrismaJson,
} from './business-settings.util';
import { normalizeWebsiteSchemaSettings } from './website-schema.util';
import { normalizeWebsiteTagsAndBadges } from './website-tags-badges.util';
import { UpdateBusinessSettingsDto } from './dto/update-business-settings.dto';
import { normalizeBusinessPrimaryColorId } from './business-primary-colors';
@@ -162,12 +164,28 @@ export class BusinessSettingsService {
patch.store = {
onlineSellEnabled:
dto.store.onlineSellEnabled ?? current.store.onlineSellEnabled,
torobEnabled: dto.store.torobEnabled ?? current.store.torobEnabled,
orderProcessSteps: dto.store.orderProcessSteps
? normalizeBusinessSettings({
store: { orderProcessSteps: dto.store.orderProcessSteps },
}).store.orderProcessSteps
: current.store.orderProcessSteps,
ePayment: ePaymentPatch ?? current.store.ePayment,
staticShippingEnabled:
dto.store.staticShippingEnabled ??
current.store.staticShippingEnabled,
staticShippingAmount:
dto.store.staticShippingAmount ??
current.store.staticShippingAmount,
staticShippingProvinceExceptions:
dto.store.staticShippingProvinceExceptions !== undefined
? normalizeBusinessSettings({
store: {
staticShippingProvinceExceptions:
dto.store.staticShippingProvinceExceptions,
},
}).store.staticShippingProvinceExceptions
: current.store.staticShippingProvinceExceptions,
};
}
@@ -189,6 +207,42 @@ export class BusinessSettingsService {
specialProductsSource:
dto.website.specialProductsSource ??
current.website.specialProductsSource,
schema:
dto.website.schema !== undefined
? normalizeWebsiteSchemaSettings({
...current.website.schema,
...dto.website.schema,
sameAs:
dto.website.schema.sameAs !== undefined
? dto.website.schema.sameAs
: current.website.schema.sameAs,
})
: current.website.schema,
tagsAndBadges:
dto.website.tagsAndBadges !== undefined
? normalizeWebsiteTagsAndBadges({
...current.website.tagsAndBadges,
...dto.website.tagsAndBadges,
metaTags:
dto.website.tagsAndBadges.metaTags !== undefined
? dto.website.tagsAndBadges.metaTags
: current.website.tagsAndBadges.metaTags,
enamad:
dto.website.tagsAndBadges.enamad !== undefined
? {
...current.website.tagsAndBadges.enamad,
...dto.website.tagsAndBadges.enamad,
}
: current.website.tagsAndBadges.enamad,
samandehi:
dto.website.tagsAndBadges.samandehi !== undefined
? {
...current.website.tagsAndBadges.samandehi,
...dto.website.tagsAndBadges.samandehi,
}
: current.website.tagsAndBadges.samandehi,
})
: current.website.tagsAndBadges,
};
}
@@ -92,11 +92,27 @@ export type EPaymentSettings = {
gateways: PaymentGatewaysSettings;
};
/** Per-province override for the static flat shipping fee. */
export type StaticShippingProvinceException = {
provinceId: string;
amount: number;
};
/** Per-business store / sales settings. */
export type StoreSettings = {
onlineSellEnabled: boolean;
/** When true, Torob Product API v3 is available for this tenant (store module still required). */
torobEnabled: boolean;
orderProcessSteps: OrderProcessStep[];
ePayment: EPaymentSettings;
/**
* When true, every order uses `staticShippingAmount` and shipping cost groups are ignored.
*/
staticShippingEnabled: boolean;
/** Flat shipping fee in IRT applied to all orders when static shipping is enabled. */
staticShippingAmount: number;
/** Province-specific overrides of the static flat fee. */
staticShippingProvinceExceptions: StaticShippingProvinceException[];
};
/** Where website special-product carousels read from. */
@@ -109,6 +125,77 @@ export type WebsiteSettings = {
specialProductsSource: SpecialProductsSource;
/** Imported from GET https://{domain}/meshkee/sitemap-config.json */
sitemapConfig?: WebsiteSitemapConfig | null;
/** Site-wide Schema.org Organization / LocalBusiness settings (Phase A). */
schema: WebsiteSchemaSettings;
/** Custom <meta> tags + eNamad / Samandehi trust badges. */
tagsAndBadges: WebsiteTagsAndBadgesSettings;
};
/**
* Custom head meta tag.
* `name` = admin label only; `content` = full `<meta …>` HTML to inject.
*/
export type WebsiteMetaTag = {
id: string;
name: string;
content: string;
};
/** Fixed trust integrations (eNamad / Samandehi) — footer HTML only. */
export type WebsiteTrustBadgeSettings = {
/** Footer widget HTML; empty = not shown. */
embedHtml: string;
};
export type WebsiteTagsAndBadgesSettings = {
metaTags: WebsiteMetaTag[];
enamad: WebsiteTrustBadgeSettings;
samandehi: WebsiteTrustBadgeSettings;
};
export const DEFAULT_WEBSITE_TRUST_BADGE: WebsiteTrustBadgeSettings = {
embedHtml: '',
};
export const DEFAULT_WEBSITE_TAGS_AND_BADGES: WebsiteTagsAndBadgesSettings = {
metaTags: [],
enamad: { ...DEFAULT_WEBSITE_TRUST_BADGE },
samandehi: { ...DEFAULT_WEBSITE_TRUST_BADGE },
};
/** Schema.org @type for the site-wide entity JSON-LD. */
export const WEBSITE_SCHEMA_TYPE_IDS = ['Organization', 'LocalBusiness'] as const;
export type WebsiteSchemaType = (typeof WEBSITE_SCHEMA_TYPE_IDS)[number];
/**
* Business-admin overrides for site-wide structured data.
* Empty name/description fall back to business profile fields on the public API.
*/
export type WebsiteSchemaSettings = {
/** When false, websites should not emit Organization/LocalBusiness JSON-LD. */
enabled: boolean;
type: WebsiteSchemaType;
/** Optional override; empty → business `name`. */
name: string | null;
/** Optional override; empty → business `nameFa`. */
nameFa: string | null;
/** Optional override; empty → business `about`. */
description: string | null;
/** Optional LocalBusiness priceRange (e.g. "$$" or "۱۰۰٬۰۰۰–۵٬۰۰۰٬۰۰۰ تومان"). */
priceRange: string | null;
/** Extra sameAs profile URLs (merged with social links from business profile). */
sameAs: string[];
};
export const DEFAULT_WEBSITE_SCHEMA_SETTINGS: WebsiteSchemaSettings = {
enabled: true,
type: 'Organization',
name: null,
nameFa: null,
description: null,
priceRange: null,
sameAs: [],
};
export type SitemapChangefreq =
@@ -149,6 +236,7 @@ export type WebsiteSitemapConfig = {
export const BUSINESS_DASHBOARD_MODULE_IDS = [
'products',
'store',
'store_installments',
'portfolio',
'blog',
'warehouse',
@@ -156,6 +244,8 @@ export const BUSINESS_DASHBOARD_MODULE_IDS = [
'instructions',
'workshops',
'finance',
'multilanguage_data',
'roll_call',
] as const;
/** Optional customer-dashboard modules. Always-on: home, profile, addresses, orders, favorites. */
@@ -192,6 +282,12 @@ export type ModulesSettings = {
charts: [HomeChartId, HomeChartId];
};
/** Per-business SMS sender (Gama shortcode). Empty → Meshkee advertising number. */
export type SmsSettings = {
/** Outbound shortcode for customer SMS, e.g. "5000123456". */
senderNumber: string | null;
};
/** Top-level business settings stored in `businesses.settings` JSONB. */
export type BusinessSettings = {
branding: BrandingSettings;
@@ -199,6 +295,7 @@ export type BusinessSettings = {
store: StoreSettings;
modules: ModulesSettings;
website: WebsiteSettings;
sms: SmsSettings;
};
export const DEFAULT_ORDER_PROCESS_STEPS: OrderProcessStep[] = [
@@ -252,7 +349,13 @@ export const DEFAULT_NEW_BUSINESS_MODULES: BusinessModuleId[] = [
* enabled except opt-in modules. Customer modules stay opt-in.
*/
export const DEFAULT_ENABLED_BUSINESS_MODULES: BusinessModuleId[] =
BUSINESS_DASHBOARD_MODULE_IDS.filter((id) => id !== 'workshops');
BUSINESS_DASHBOARD_MODULE_IDS.filter(
(id) =>
id !== 'workshops' &&
id !== 'multilanguage_data' &&
id !== 'store_installments' &&
id !== 'roll_call',
);
export const DEFAULT_HOME_CHARTS: [HomeChartId, HomeChartId] = [
'orders_30d',
@@ -300,8 +403,12 @@ export const DEFAULT_BUSINESS_SETTINGS: BusinessSettings = {
},
store: {
onlineSellEnabled: true,
torobEnabled: false,
orderProcessSteps: DEFAULT_ORDER_PROCESS_STEPS,
ePayment: { ...DEFAULT_EPAYMENT_SETTINGS },
staticShippingEnabled: true,
staticShippingAmount: 0,
staticShippingProvinceExceptions: [],
},
modules: {
enabled: DEFAULT_ENABLED_BUSINESS_MODULES,
@@ -309,5 +416,14 @@ export const DEFAULT_BUSINESS_SETTINGS: BusinessSettings = {
},
website: {
specialProductsSource: 'store_item',
schema: { ...DEFAULT_WEBSITE_SCHEMA_SETTINGS },
tagsAndBadges: {
metaTags: [],
enamad: { ...DEFAULT_WEBSITE_TRUST_BADGE },
samandehi: { ...DEFAULT_WEBSITE_TRUST_BADGE },
},
},
sms: {
senderNumber: null,
},
};
+113 -1
View File
@@ -33,11 +33,15 @@ import {
StubGatewaySettings,
WebsiteSitemapConfig,
ZarinpalGatewaySettings,
DEFAULT_WEBSITE_SCHEMA_SETTINGS,
DEFAULT_WEBSITE_TAGS_AND_BADGES,
} from './business-settings.types';
import {
defaultOrderStepColor,
normalizeOrderStepColor,
} from './order-step-colors';
import { normalizeWebsiteSchemaSettings } from './website-schema.util';
import { normalizeWebsiteTagsAndBadges } from './website-tags-badges.util';
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
@@ -117,10 +121,58 @@ function readBoolean(value: unknown, fallback: boolean) {
return typeof value === 'boolean' ? value : fallback;
}
function readNonNegativeNumber(value: unknown, fallback: number) {
if (typeof value === 'number' && Number.isFinite(value) && value >= 0) {
return value;
}
if (typeof value === 'string' && value.trim() !== '') {
const parsed = Number(value);
if (Number.isFinite(parsed) && parsed >= 0) return parsed;
}
return fallback;
}
function readStaticShippingProvinceExceptions(
value: unknown,
): BusinessSettings['store']['staticShippingProvinceExceptions'] {
if (!Array.isArray(value)) return [];
const seen = new Set<string>();
const items: BusinessSettings['store']['staticShippingProvinceExceptions'] =
[];
for (const entry of value) {
if (!isRecord(entry)) continue;
const provinceId =
typeof entry.provinceId === 'string'
? entry.provinceId.trim()
: typeof entry.provinceId === 'number'
? String(entry.provinceId)
: '';
if (!provinceId || seen.has(provinceId)) continue;
const amount = readNonNegativeNumber(entry.amount, Number.NaN);
if (!Number.isFinite(amount)) continue;
seen.add(provinceId);
items.push({ provinceId, amount });
}
return items;
}
function readString(value: unknown, fallback = '') {
return typeof value === 'string' ? value.trim() : fallback;
}
/** Gama / Iranian service shortcodes are numeric (e.g. 5000123456). */
export function normalizeSmsSenderNumber(value: unknown): string | null {
if (value == null) return null;
if (typeof value !== 'string' && typeof value !== 'number') return null;
const digits = String(value).replace(/\D/g, '');
if (!digits) return null;
if (digits.length < 8 || digits.length > 16) return null;
return digits;
}
function isPaymentGatewayId(value: unknown): value is PaymentGatewayId {
return (
typeof value === 'string' &&
@@ -320,7 +372,17 @@ export function listPublicPaymentGateways(settings: EPaymentSettings) {
};
}
const PAYMENT_PROCESS_STEP_IDS = ['awaiting-payment', 'payment-successful'] as const;
/** System-managed payment steps — set by gateway callbacks, not admin UI. */
export const PAYMENT_PROCESS_STEP_IDS = [
'awaiting-payment',
'payment-successful',
] as const;
export type PaymentProcessStepId = (typeof PAYMENT_PROCESS_STEP_IDS)[number];
export function isSystemPaymentProcessStep(stepId: string): boolean {
return (PAYMENT_PROCESS_STEP_IDS as readonly string[]).includes(stepId);
}
/** Prepend default payment steps when an older store settings JSON omits them. */
function ensurePaymentProcessSteps(steps: OrderProcessStep[]): OrderProcessStep[] {
@@ -380,6 +442,7 @@ export function normalizeBusinessSettings(raw: unknown): BusinessSettings {
const store = isRecord(source.store) ? source.store : {};
const modules = isRecord(source.modules) ? source.modules : {};
const website = isRecord(source.website) ? source.website : {};
const sms = isRecord(source.sms) ? source.sms : {};
const hasModulesKey = Object.prototype.hasOwnProperty.call(source, 'modules');
const enabledModules = hasModulesKey
? Array.isArray(modules.enabled)
@@ -412,8 +475,23 @@ export function normalizeBusinessSettings(raw: unknown): BusinessSettings {
store.onlineSellEnabled,
DEFAULT_BUSINESS_SETTINGS.store.onlineSellEnabled,
),
torobEnabled: readBoolean(
store.torobEnabled,
DEFAULT_BUSINESS_SETTINGS.store.torobEnabled,
),
orderProcessSteps: readOrderProcessSteps(store.orderProcessSteps),
ePayment: normalizeEPaymentSettings(store.ePayment),
staticShippingEnabled: readBoolean(
store.staticShippingEnabled,
DEFAULT_BUSINESS_SETTINGS.store.staticShippingEnabled,
),
staticShippingAmount: readNonNegativeNumber(
store.staticShippingAmount,
DEFAULT_BUSINESS_SETTINGS.store.staticShippingAmount,
),
staticShippingProvinceExceptions: readStaticShippingProvinceExceptions(
store.staticShippingProvinceExceptions,
),
},
modules: {
// Missing `modules` → all enabled (legacy). Explicit `{ enabled: [] }` stays empty.
@@ -426,6 +504,17 @@ export function normalizeBusinessSettings(raw: unknown): BusinessSettings {
enabledModules,
),
sitemapConfig: normalizeWebsiteSitemapConfig(website.sitemapConfig),
schema: normalizeWebsiteSchemaSettings(
isRecord(website) ? website.schema : DEFAULT_WEBSITE_SCHEMA_SETTINGS,
),
tagsAndBadges: normalizeWebsiteTagsAndBadges(
isRecord(website)
? website.tagsAndBadges
: DEFAULT_WEBSITE_TAGS_AND_BADGES,
),
},
sms: {
senderNumber: normalizeSmsSenderNumber(sms.senderNumber),
},
};
}
@@ -457,12 +546,21 @@ export function mergeBusinessSettings(
store: {
onlineSellEnabled:
patch.store?.onlineSellEnabled ?? current.store.onlineSellEnabled,
torobEnabled: patch.store?.torobEnabled ?? current.store.torobEnabled,
orderProcessSteps:
patch.store?.orderProcessSteps ?? current.store.orderProcessSteps,
ePayment: mergeEPaymentSettings(
current.store.ePayment,
patch.store?.ePayment,
),
staticShippingEnabled:
patch.store?.staticShippingEnabled ??
current.store.staticShippingEnabled,
staticShippingAmount:
patch.store?.staticShippingAmount ?? current.store.staticShippingAmount,
staticShippingProvinceExceptions:
patch.store?.staticShippingProvinceExceptions ??
current.store.staticShippingProvinceExceptions,
},
modules: {
enabled: patch.modules?.enabled ?? current.modules.enabled,
@@ -476,6 +574,20 @@ export function mergeBusinessSettings(
patch.website?.sitemapConfig !== undefined
? patch.website.sitemapConfig
: current.website.sitemapConfig,
schema:
patch.website?.schema !== undefined
? patch.website.schema
: current.website.schema,
tagsAndBadges:
patch.website?.tagsAndBadges !== undefined
? patch.website.tagsAndBadges
: current.website.tagsAndBadges,
},
sms: {
senderNumber:
patch.sms?.senderNumber !== undefined
? patch.sms.senderNumber
: current.sms.senderNumber,
},
};
}
@@ -5,8 +5,11 @@ import {
IsArray,
IsBoolean,
IsIn,
IsNumber,
IsOptional,
IsString,
MaxLength,
Min,
MinLength,
ValidateIf,
ValidateNested,
@@ -18,6 +21,7 @@ import {
HOME_CHART_IDS,
PAYMENT_GATEWAY_IDS,
SPECIAL_PRODUCTS_SOURCE_IDS,
WEBSITE_SCHEMA_TYPE_IDS,
} from '../business-settings.types';
class BrandingSettingsDto {
@@ -162,11 +166,26 @@ class EPaymentSettingsDto {
gateways?: PaymentGatewaysSettingsDto;
}
class StaticShippingProvinceExceptionDto {
@IsString()
@MinLength(1)
provinceId!: string;
@Type(() => Number)
@IsNumber({ maxDecimalPlaces: 2 })
@Min(0)
amount!: number;
}
class StoreSettingsDto {
@IsOptional()
@IsBoolean()
onlineSellEnabled?: boolean;
@IsOptional()
@IsBoolean()
torobEnabled?: boolean;
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@@ -177,6 +196,22 @@ class StoreSettingsDto {
@ValidateNested()
@Type(() => EPaymentSettingsDto)
ePayment?: EPaymentSettingsDto;
@IsOptional()
@IsBoolean()
staticShippingEnabled?: boolean;
@IsOptional()
@Type(() => Number)
@IsNumber({ maxDecimalPlaces: 2 })
@Min(0)
staticShippingAmount?: number;
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => StaticShippingProvinceExceptionDto)
staticShippingProvinceExceptions?: StaticShippingProvinceExceptionDto[];
}
class ModulesSettingsDto {
@@ -195,11 +230,99 @@ class ModulesSettingsDto {
charts?: string[];
}
class WebsiteSchemaSettingsDto {
@IsOptional()
@IsBoolean()
enabled?: boolean;
@IsOptional()
@IsString()
@IsIn([...WEBSITE_SCHEMA_TYPE_IDS])
type?: (typeof WEBSITE_SCHEMA_TYPE_IDS)[number];
@IsOptional()
@IsString()
name?: string | null;
@IsOptional()
@IsString()
nameFa?: string | null;
@IsOptional()
@IsString()
description?: string | null;
@IsOptional()
@IsString()
priceRange?: string | null;
@IsOptional()
@IsArray()
@IsString({ each: true })
@ArrayMaxSize(20)
sameAs?: string[];
}
class WebsiteMetaTagDto {
@IsOptional()
@IsString()
@MaxLength(64)
id?: string;
/** Admin label only (not necessarily the HTML name= attribute). */
@IsString()
@MinLength(1)
@MaxLength(80)
name!: string;
/** Full `<meta …>` HTML snippet. */
@IsString()
@MinLength(1)
@MaxLength(4000)
content!: string;
}
class WebsiteTrustBadgeDto {
@IsOptional()
@IsString()
@MaxLength(10000)
embedHtml?: string;
}
class WebsiteTagsAndBadgesDto {
@IsOptional()
@IsArray()
@ArrayMaxSize(40)
@ValidateNested({ each: true })
@Type(() => WebsiteMetaTagDto)
metaTags?: WebsiteMetaTagDto[];
@IsOptional()
@ValidateNested()
@Type(() => WebsiteTrustBadgeDto)
enamad?: WebsiteTrustBadgeDto;
@IsOptional()
@ValidateNested()
@Type(() => WebsiteTrustBadgeDto)
samandehi?: WebsiteTrustBadgeDto;
}
class WebsiteSettingsDto {
@IsOptional()
@IsString()
@IsIn([...SPECIAL_PRODUCTS_SOURCE_IDS])
specialProductsSource?: (typeof SPECIAL_PRODUCTS_SOURCE_IDS)[number];
@IsOptional()
@ValidateNested()
@Type(() => WebsiteSchemaSettingsDto)
schema?: WebsiteSchemaSettingsDto;
@IsOptional()
@ValidateNested()
@Type(() => WebsiteTagsAndBadgesDto)
tagsAndBadges?: WebsiteTagsAndBadgesDto;
}
export class UpdateBusinessSettingsDto {
@@ -0,0 +1,167 @@
import { DEFAULT_SITEMAP_PATH_TEMPLATES } from '../sitemap/sitemap.constants';
import { slugifyForUrl } from '../sitemap/seo-slug.util';
import {
applyPathTemplate,
buildAbsoluteUrl,
} from '../sitemap/sitemap-xml.util';
export type WebsiteEntityJsonLd = Record<string, unknown>;
function plainText(value: string | null | undefined, maxLen = 5000): string {
if (!value) return '';
return value
.replace(/<[^>]+>/g, ' ')
.replace(/&nbsp;/gi, ' ')
.replace(/\s+/g, ' ')
.trim()
.slice(0, maxLen);
}
function absoluteSiteUrl(host: string): string {
return `https://${host.replace(/^https?:\/\//i, '').replace(/\/+$/, '')}`;
}
/**
* Product detail JSON-LD for storefronts.
* Offer is included when a store min price exists.
*/
export function buildProductJsonLd(input: {
host: string;
id: string;
title: string;
nameFa?: string | null;
summary?: string | null;
descriptionHtml?: string | null;
imageUrls: string[];
brandName?: string | null;
pathTemplate?: string;
store?: {
minPrice: number | null;
maxPrice?: number | null;
inStock: boolean;
} | null;
}): WebsiteEntityJsonLd {
const baseUrl = absoluteSiteUrl(input.host);
const name =
input.nameFa?.trim() || input.title.trim() || `Product ${input.id}`;
const pathSlug = slugifyForUrl(input.nameFa?.trim() || input.title, 'product');
const path = applyPathTemplate(
input.pathTemplate ?? DEFAULT_SITEMAP_PATH_TEMPLATES.product,
{ id: input.id, slug: pathSlug },
);
const url = buildAbsoluteUrl(baseUrl, path);
const description =
plainText(input.summary) ||
plainText(input.descriptionHtml) ||
undefined;
const images = input.imageUrls.map((u) => u.trim()).filter(Boolean);
const jsonLd: WebsiteEntityJsonLd = {
'@context': 'https://schema.org',
'@type': 'Product',
name,
url,
sku: input.id,
};
if (description) jsonLd.description = description;
if (images.length === 1) jsonLd.image = images[0];
if (images.length > 1) jsonLd.image = images;
const brandName = input.brandName?.trim();
if (brandName) {
jsonLd.brand = {
'@type': 'Brand',
name: brandName,
};
}
const minPrice = input.store?.minPrice;
if (typeof minPrice === 'number' && Number.isFinite(minPrice) && minPrice >= 0) {
jsonLd.offers = {
'@type': 'Offer',
url,
priceCurrency: 'IRR',
price: String(minPrice),
availability: input.store?.inStock
? 'https://schema.org/InStock'
: 'https://schema.org/OutOfStock',
};
}
return jsonLd;
}
/**
* Blog detail JSON-LD (BlogPosting) for storefronts.
*/
export function buildBlogPostingJsonLd(input: {
host: string;
id: string;
title: string;
slug: string;
abstract?: string | null;
mainTextHtml?: string | null;
titleImageUrl?: string | null;
publishedAt?: Date | string | null;
updatedAt?: Date | string | null;
authorName?: string | null;
publisherName?: string | null;
publisherLogoUrl?: string | null;
pathTemplate?: string;
}): WebsiteEntityJsonLd {
const baseUrl = absoluteSiteUrl(input.host);
const path = applyPathTemplate(
input.pathTemplate ?? DEFAULT_SITEMAP_PATH_TEMPLATES.blog,
{ id: input.id, slug: input.slug },
);
const url = buildAbsoluteUrl(baseUrl, path);
const description =
plainText(input.abstract) || plainText(input.mainTextHtml) || undefined;
const jsonLd: WebsiteEntityJsonLd = {
'@context': 'https://schema.org',
'@type': 'BlogPosting',
headline: input.title.trim() || `Post ${input.id}`,
mainEntityOfPage: {
'@type': 'WebPage',
'@id': url,
},
url,
};
if (description) jsonLd.description = description;
if (input.titleImageUrl?.trim()) jsonLd.image = input.titleImageUrl.trim();
if (input.publishedAt) {
jsonLd.datePublished = new Date(input.publishedAt).toISOString();
}
if (input.updatedAt) {
jsonLd.dateModified = new Date(input.updatedAt).toISOString();
}
const authorName = input.authorName?.trim();
if (authorName) {
jsonLd.author = {
'@type': 'Person',
name: authorName,
};
}
const publisherName = input.publisherName?.trim();
if (publisherName) {
const publisher: WebsiteEntityJsonLd = {
'@type': 'Organization',
name: publisherName,
};
if (input.publisherLogoUrl?.trim()) {
publisher.logo = {
'@type': 'ImageObject',
url: input.publisherLogoUrl.trim(),
};
}
jsonLd.publisher = publisher;
}
return jsonLd;
}
@@ -0,0 +1,195 @@
import {
DEFAULT_WEBSITE_SCHEMA_SETTINGS,
WEBSITE_SCHEMA_TYPE_IDS,
type WebsiteSchemaSettings,
type WebsiteSchemaType,
} from './business-settings.types';
import type { BusinessSocialMedia } from '../business-profile/business-profile.types';
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function readTrimmed(value: unknown): string | null {
if (typeof value !== 'string') return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
function normalizeSameAs(raw: unknown): string[] {
if (!Array.isArray(raw)) return [];
const seen = new Set<string>();
const out: string[] = [];
for (const item of raw) {
if (typeof item !== 'string') continue;
const url = item.trim();
if (!url || seen.has(url)) continue;
if (!/^https?:\/\//i.test(url)) continue;
seen.add(url);
out.push(url);
if (out.length >= 20) break;
}
return out;
}
export function normalizeWebsiteSchemaSettings(
raw: unknown,
): WebsiteSchemaSettings {
const source = isRecord(raw) ? raw : {};
const typeRaw = typeof source.type === 'string' ? source.type : '';
const type = (WEBSITE_SCHEMA_TYPE_IDS as readonly string[]).includes(typeRaw)
? (typeRaw as WebsiteSchemaType)
: DEFAULT_WEBSITE_SCHEMA_SETTINGS.type;
return {
enabled:
typeof source.enabled === 'boolean'
? source.enabled
: DEFAULT_WEBSITE_SCHEMA_SETTINGS.enabled,
type,
name: readTrimmed(source.name),
nameFa: readTrimmed(source.nameFa),
description: readTrimmed(source.description),
priceRange: readTrimmed(source.priceRange),
sameAs: normalizeSameAs(source.sameAs),
};
}
/** Turn social handles / URLs into absolute profile URLs for schema.org sameAs. */
export function socialMediaToSameAsUrls(social: BusinessSocialMedia): string[] {
const urls: string[] = [];
const push = (value: string, build: (handle: string) => string) => {
const trimmed = value.trim();
if (!trimmed) return;
if (/^https?:\/\//i.test(trimmed)) {
urls.push(trimmed);
return;
}
const handle = trimmed.replace(/^@/, '').replace(/^\/+/, '');
if (!handle) return;
urls.push(build(handle));
};
push(social.instagram, (h) => `https://www.instagram.com/${h}`);
push(social.telegram, (h) =>
h.startsWith('t.me/') ? `https://${h}` : `https://t.me/${h}`,
);
push(social.linkedin, (h) =>
h.includes('linkedin.com') ? (h.startsWith('http') ? h : `https://${h}`) : `https://www.linkedin.com/in/${h}`,
);
push(social.youtube, (h) =>
h.includes('youtube.com') || h.includes('youtu.be')
? h.startsWith('http')
? h
: `https://${h}`
: `https://www.youtube.com/@${h}`,
);
push(social.aparat, (h) =>
h.includes('aparat.com')
? h.startsWith('http')
? h
: `https://${h}`
: `https://www.aparat.com/${h}`,
);
// WhatsApp is a phone link, not a public profile — skip for sameAs.
return normalizeSameAs(urls);
}
export type WebsiteSchemaJsonLd = Record<string, unknown>;
export type BuildWebsiteSchemaJsonLdInput = {
settings: WebsiteSchemaSettings;
host: string;
name: string;
nameFa: string;
about: string;
logoUrl: string | null;
emails: string[];
phoneNumbers: Array<{ type: string; number: string; label?: string }>;
socialMedia: BusinessSocialMedia;
addresses: Array<{
province: string;
city: string;
address: string;
postalCode: string | null;
landline: string | null;
}>;
};
/**
* Build site-wide Organization / LocalBusiness JSON-LD for storefronts.
* Returns null when schema is disabled.
*/
export function buildWebsiteSchemaJsonLd(
input: BuildWebsiteSchemaJsonLdInput,
): WebsiteSchemaJsonLd | null {
const { settings } = input;
if (!settings.enabled) return null;
const displayName =
settings.nameFa ||
input.nameFa?.trim() ||
settings.name ||
input.name?.trim() ||
input.host;
const description =
settings.description || input.about?.trim() || undefined;
const sameAs = normalizeSameAs([
...socialMediaToSameAsUrls(input.socialMedia),
...settings.sameAs,
]);
const telephone =
input.phoneNumbers.find((p) => p.number.trim())?.number.trim() ||
input.addresses.find((a) => a.landline?.trim())?.landline?.trim() ||
undefined;
const email = input.emails.find((e) => e.trim())?.trim() || undefined;
const url = `https://${input.host.replace(/^https?:\/\//i, '').replace(/\/+$/, '')}`;
const jsonLd: WebsiteSchemaJsonLd = {
'@context': 'https://schema.org',
'@type': settings.type,
name: displayName,
url,
};
if (description) jsonLd.description = description;
if (input.logoUrl) {
jsonLd.logo = input.logoUrl;
jsonLd.image = input.logoUrl;
}
if (telephone) jsonLd.telephone = telephone;
if (email) jsonLd.email = email;
if (sameAs.length > 0) jsonLd.sameAs = sameAs;
const primaryAddress = input.addresses[0];
if (primaryAddress) {
const street = primaryAddress.address?.trim();
const city = primaryAddress.city?.trim();
const province = primaryAddress.province?.trim();
const postalCode = primaryAddress.postalCode?.trim() || undefined;
if (street || city || province || postalCode) {
jsonLd.address = {
'@type': 'PostalAddress',
...(street ? { streetAddress: street } : {}),
...(city ? { addressLocality: city } : {}),
...(province ? { addressRegion: province } : {}),
...(postalCode ? { postalCode } : {}),
addressCountry: 'IR',
};
}
}
if (settings.type === 'LocalBusiness' && settings.priceRange) {
jsonLd.priceRange = settings.priceRange;
}
return jsonLd;
}
@@ -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 { OrdersModule } from '../orders/orders.module';
import { PaymentsModule } from '../payments/payments.module';
import { StoreModule } from '../store/store.module';
import { CartController } from './cart.controller';
import { CartService } from './cart.service';
@Module({
imports: [AuthModule, OrdersModule, PaymentsModule, BusinessSettingsModule],
imports: [
AuthModule,
OrdersModule,
PaymentsModule,
BusinessSettingsModule,
StoreModule,
],
controllers: [CartController],
providers: [CartService],
})
+114 -10
View File
@@ -20,6 +20,7 @@ import { TransactionType } from '@prisma/client';
import { BusinessSettingsService } from '../business-settings/business-settings.service';
import type { PaymentGatewayId } from '../business-settings/business-settings.types';
import { listPublicPaymentGateways } from '../business-settings/business-settings.util';
import { ShippingCostGroupsService } from '../store/shipping-cost-groups.service';
const cartVariantInclude = {
storeItem: {
@@ -59,6 +60,7 @@ export class CartService {
private readonly orders: OrdersService,
private readonly payments: PaymentsService,
private readonly businessSettings: BusinessSettingsService,
private readonly shippingCostGroups: ShippingCostGroupsService,
) {}
async getCart(businessIdRaw: string, actor: AuthUser) {
@@ -66,7 +68,7 @@ export class CartService {
await this.assertCustomerAccess(businessId, actor);
const cart = await this.getOrCreateCart(businessId, actor.id);
return { cart: this.serializeCart(cart) };
return { cart: await this.serializeCart(cart) };
}
async addItem(businessIdRaw: string, dto: AddCartItemDto, actor: AuthUser) {
@@ -97,7 +99,7 @@ export class CartService {
const refreshed = await this.loadCart(cart.id);
return {
message: 'Cart item quantity updated',
cart: this.serializeCart(refreshed),
cart: await this.serializeCart(refreshed),
};
}
@@ -114,7 +116,7 @@ export class CartService {
const refreshed = await this.loadCart(cart.id);
return {
message: 'Item added to cart',
cart: this.serializeCart(refreshed),
cart: await this.serializeCart(refreshed),
};
}
@@ -145,7 +147,7 @@ export class CartService {
const refreshed = await this.loadCart(cart.id);
return {
message: 'Cart item updated',
cart: this.serializeCart(refreshed),
cart: await this.serializeCart(refreshed),
};
}
@@ -166,7 +168,7 @@ export class CartService {
const refreshed = await this.loadCart(cart.id);
return {
message: 'Cart item removed',
cart: this.serializeCart(refreshed),
cart: await this.serializeCart(refreshed),
};
}
@@ -180,7 +182,7 @@ export class CartService {
const refreshed = await this.loadCart(cart.id);
return {
message: 'Cart cleared',
cart: this.serializeCart(refreshed),
cart: await this.serializeCart(refreshed),
};
}
@@ -204,6 +206,71 @@ export class CartService {
dto.shippingAddress,
);
const deliveryMode =
dto.deliveryMode === 'pickup' || dto.deliveryMode === 'delivery'
? dto.deliveryMode
: dto.addressId
? 'delivery'
: 'pickup';
const selectedMethodsByGroupId: Record<string, string> = {};
for (const group of dto.shippingGroups ?? []) {
if (group.methodId?.trim()) {
selectedMethodsByGroupId[group.groupId] = group.methodId.trim();
}
}
const productIds = [
...new Set(
cart.items.map((item) => item.storeItemVariant.storeItem.product.id),
),
];
const assignments =
productIds.length === 0
? []
: await this.prisma.categoryAssignment.findMany({
where: {
businessId,
entityType: 'product',
entityId: { in: productIds },
},
select: { entityId: true, categoryId: true },
});
const categoryIdsByProduct = new Map<string, string[]>();
for (const row of assignments) {
const key = row.entityId.toString();
const list = categoryIdsByProduct.get(key) ?? [];
list.push(row.categoryId.toString());
categoryIdsByProduct.set(key, list);
}
const shippingQuote = await this.shippingCostGroups.quoteForCheckout({
businessId,
deliveryMode,
provinceName:
deliveryMode === 'delivery' ? String(shippingAddress.province ?? '') : null,
cartItems: cart.items.map((item) => {
const product = item.storeItemVariant.storeItem.product;
const content = this.asRecord(product.content);
const nameFa = (content.nameFa as string | null | undefined)?.trim();
return {
productName: nameFa || product.title,
categoryIds:
categoryIdsByProduct.get(product.id.toString()) ?? [],
};
}),
selectedMethodsByGroupId,
});
const enrichedShippingAddress = {
...shippingAddress,
deliveryMode,
shipping: {
total: shippingQuote.total,
groups: shippingQuote.groups,
},
};
const isEPayment = dto.payment.type === TransactionType.e_payment_gate;
let gatewayType: PaymentGatewayId | undefined;
@@ -234,7 +301,8 @@ export class CartService {
createdBy: actor.id,
source: 'website',
cartItems: cart.items,
shippingAddress,
shippingAddress: enrichedShippingAddress,
shippingTotal: shippingQuote.total,
addressId: dto.addressId ? BigInt(dto.addressId) : null,
customerNotes: dto.customerNotes?.trim() || null,
adminNotes: null,
@@ -393,8 +461,40 @@ export class CartService {
}
}
private serializeCart(cart: CartWithItems) {
const items = cart.items.map((item) => this.serializeCartItem(item));
private async serializeCart(cart: CartWithItems) {
const productIds = [
...new Set(
cart.items.map((item) => item.storeItemVariant.storeItem.product.id),
),
];
const assignments =
productIds.length === 0
? []
: await this.prisma.categoryAssignment.findMany({
where: {
businessId: cart.businessId,
entityType: 'product',
entityId: { in: productIds },
},
select: { entityId: true, categoryId: true },
});
const categoryIdsByProduct = new Map<string, string[]>();
for (const row of assignments) {
const key = row.entityId.toString();
const list = categoryIdsByProduct.get(key) ?? [];
list.push(row.categoryId.toString());
categoryIdsByProduct.set(key, list);
}
const items = cart.items.map((item) => {
const productId = item.storeItemVariant.storeItem.product.id.toString();
return this.serializeCartItem(
item,
categoryIdsByProduct.get(productId) ?? [],
);
});
const subtotal = items.reduce((sum, item) => sum + item.lineTotal, 0);
return {
@@ -407,7 +507,10 @@ export class CartService {
};
}
private serializeCartItem(item: CartWithItems['items'][number]) {
private serializeCartItem(
item: CartWithItems['items'][number],
categoryIds: string[],
) {
const variant = item.storeItemVariant;
const product = variant.storeItem.product;
const content = this.asRecord(product.content);
@@ -431,6 +534,7 @@ export class CartService {
productTitle: product.title,
productNameFa: (content.nameFa as string | null | undefined) ?? '',
productImage: product.featuredMedia?.publicUrl ?? null,
categoryIds,
sku: variant.sku,
selections,
label: selections.map((entry) => entry.value).join(' · ') || product.title,
+42
View File
@@ -1,5 +1,8 @@
import {
IsArray,
IsIn,
IsInt,
IsNumber,
IsOptional,
IsString,
IsUrl,
@@ -53,6 +56,35 @@ export class ShippingAddressDto {
landline?: string;
}
export class CheckoutShippingGroupDto {
@IsString()
@MinLength(1)
groupId!: string;
@IsOptional()
@IsString()
title?: string;
@IsOptional()
@IsString()
methodId?: string;
@IsOptional()
@IsString()
methodLabel?: string;
@IsOptional()
@Type(() => Number)
@IsNumber({ maxDecimalPlaces: 2 })
@Min(0)
amount?: number;
@IsOptional()
@IsArray()
@IsString({ each: true })
itemNames?: string[];
}
export class CheckoutCartDto {
@IsOptional()
@IsString()
@@ -64,6 +96,16 @@ export class CheckoutCartDto {
@Type(() => ShippingAddressDto)
shippingAddress?: ShippingAddressDto;
@IsOptional()
@IsIn(['delivery', 'pickup'])
deliveryMode?: 'delivery' | 'pickup';
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => CheckoutShippingGroupDto)
shippingGroups?: CheckoutShippingGroupDto[];
@IsOptional()
@IsString()
customerNotes?: string;
+65
View File
@@ -26,6 +26,9 @@ import {
SuggestCategoryTechnicalFormDto,
} from './dto/category-technical-form.dto';
import { ReplaceCategoryVariationsDto } from './dto/category-variation.dto';
import { TranslationEntityType } from '@prisma/client';
import { UpsertTranslationDto, TranslateAllDto } from '../translations/dto/translation.dto';
import { TranslationsService } from '../translations/translations.service';
import { GenerateCategoriesDto } from './dto/category-ai.dto';
@Controller('businesses/:businessId/categories')
@@ -37,6 +40,7 @@ export class CategoriesController {
private readonly technicalFormService: CategoryTechnicalFormService,
private readonly technicalFormAiService: CategoryTechnicalFormAiService,
private readonly categoryAiService: CategoryAiService,
private readonly translations: TranslationsService,
) {}
@Get('color-presets')
@@ -65,6 +69,16 @@ export class CategoriesController {
return this.categoryAiService.generateProductCategories(businessId, dto, user);
}
@Post('translations/ai-all')
@RequireBusinessPermission('categories.update')
translateAllWithAi(
@Param('businessId') businessId: string,
@Body() dto: TranslateAllDto,
@CurrentUser() user: AuthUser,
) {
return this.translations.translateAllCategories(businessId, user, dto.locale);
}
@Post()
@RequireBusinessPermission('categories.create')
create(
@@ -86,6 +100,57 @@ export class CategoriesController {
return this.service.update(businessId, categoryId, dto, user);
}
@Get(':categoryId/translations')
@RequireBusinessPermission('categories.read')
listTranslations(
@Param('businessId') businessId: string,
@Param('categoryId') categoryId: string,
@CurrentUser() user: AuthUser,
) {
return this.translations.list(
businessId,
TranslationEntityType.category,
categoryId,
user,
);
}
@Post(':categoryId/translations/:locale/ai')
@RequireBusinessPermission('categories.update')
translateWithAi(
@Param('businessId') businessId: string,
@Param('categoryId') categoryId: string,
@Param('locale') locale: string,
@CurrentUser() user: AuthUser,
) {
return this.translations.translateWithAi(
businessId,
TranslationEntityType.category,
categoryId,
locale,
user,
);
}
@Put(':categoryId/translations/:locale')
@RequireBusinessPermission('categories.update')
saveTranslation(
@Param('businessId') businessId: string,
@Param('categoryId') categoryId: string,
@Param('locale') locale: string,
@Body() dto: UpsertTranslationDto,
@CurrentUser() user: AuthUser,
) {
return this.translations.save(
businessId,
TranslationEntityType.category,
categoryId,
locale,
dto,
user,
);
}
@Get(':categoryId/variations')
@RequireBusinessPermission('categories.read')
listVariations(
+2 -1
View File
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { SitemapModule } from '../sitemap/sitemap.module';
import { TenantModule } from '../tenant/tenant.module';
import { TranslationsModule } from '../translations/translations.module';
import { CategoriesController, PublicCategoriesController } from './categories.controller';
import { CategoriesService } from './categories.service';
import { CategoryTechnicalFormAiService } from './category-technical-form-ai.service';
@@ -10,7 +11,7 @@ import { CategoryTechnicalFormService } from './category-technical-form.service'
import { CategoryVariationsService } from './category-variations.service';
@Module({
imports: [AuthModule, SitemapModule, TenantModule],
imports: [AuthModule, SitemapModule, TenantModule, TranslationsModule],
controllers: [CategoriesController, PublicCategoriesController],
providers: [
CategoriesService,
+78 -2
View File
@@ -4,14 +4,31 @@ import {
Injectable,
NotFoundException,
} from '@nestjs/common';
import { MediaEntityType } from '@prisma/client';
import {
MediaEntityType,
Prisma,
TranslationEntityType,
TranslationStatus,
} from '@prisma/client';
import { AuthUser } from '../auth/auth.types';
import { PermissionsService } from '../auth/permissions.service';
import {
applySeoMetaToMetadata,
normalizeSeoMetaInput,
readSeoMetaFromMetadata,
} from '../common/seo-meta.util';
import { PrismaService } from '../prisma/prisma.service';
import { SitemapService } from '../sitemap/sitemap.service';
import { TenantService } from '../tenant/tenant.service';
import { CreateCategoryDto, ListCategoriesDto, UpdateCategoryDto } from './dto/category.dto';
function asRecord(value: Prisma.JsonValue): Record<string, unknown> {
if (value && typeof value === 'object' && !Array.isArray(value)) {
return value as Record<string, unknown>;
}
return {};
}
function slugify(value: string): string {
return (
value
@@ -45,9 +62,18 @@ export class CategoriesService {
},
});
const translationCounts = await this.translationCounts(
businessId,
items.map((item) => item.id),
);
return {
items: items.map((item) =>
this.serialize(item, item._count.variations),
this.serialize(
item,
item._count.variations,
translationCounts.get(item.id.toString()) ?? 0,
),
),
};
}
@@ -144,6 +170,10 @@ export class CategoriesService {
slug,
description: dto.description?.trim() || null,
sortOrder: dto.sortOrder ?? 0,
metadata: applySeoMetaToMetadata(
{},
normalizeSeoMetaInput(dto.seoMetaTitle, dto.seoMetaDescription),
) as Prisma.InputJsonValue,
},
});
@@ -224,6 +254,14 @@ export class CategoriesService {
...(dto.sortOrder !== undefined ? { sortOrder: dto.sortOrder } : {}),
...(dto.isActive !== undefined ? { isActive: dto.isActive } : {}),
...(parentId !== undefined ? { parentId } : {}),
...(dto.seoMetaTitle !== undefined || dto.seoMetaDescription !== undefined
? {
metadata: applySeoMetaToMetadata(
asRecord(existing.metadata),
normalizeSeoMetaInput(dto.seoMetaTitle, dto.seoMetaDescription),
) as Prisma.InputJsonValue,
}
: {}),
slug,
},
});
@@ -252,6 +290,13 @@ export class CategoriesService {
const descendantIds = await this.collectDescendantIds(categoryId);
await this.prisma.$transaction([
this.prisma.entityTranslation.deleteMany({
where: {
businessId,
entityType: TranslationEntityType.category,
entityId: { in: descendantIds },
},
}),
this.prisma.category.deleteMany({
where: { id: { in: descendantIds } },
}),
@@ -338,6 +383,33 @@ export class CategoriesService {
}
}
private async translationCounts(
businessId: bigint,
categoryIds: bigint[],
): Promise<Map<string, number>> {
const map = new Map<string, number>();
if (categoryIds.length === 0) {
return map;
}
const grouped = await this.prisma.entityTranslation.groupBy({
by: ['entityId'],
where: {
businessId,
entityType: TranslationEntityType.category,
entityId: { in: categoryIds },
status: { in: [TranslationStatus.ready, TranslationStatus.stale] },
},
_count: { entityId: true },
});
for (const row of grouped) {
map.set(row.entityId.toString(), row._count.entityId);
}
return map;
}
private serialize(
category: {
id: bigint;
@@ -348,12 +420,14 @@ export class CategoriesService {
nameFa: string | null;
slug: string;
description: string | null;
metadata?: Prisma.JsonValue;
sortOrder: number;
isActive: boolean;
createdAt: Date;
updatedAt: Date;
},
variationCount = 0,
translationCount?: number,
) {
return {
id: category.id.toString(),
@@ -364,9 +438,11 @@ export class CategoriesService {
nameFa: category.nameFa,
slug: category.slug,
description: category.description,
...readSeoMetaFromMetadata(asRecord(category.metadata ?? {})),
sortOrder: category.sortOrder,
isActive: category.isActive,
variationCount,
...(translationCount !== undefined ? { translationCount } : {}),
createdAt: category.createdAt,
updatedAt: category.updatedAt,
};
+1 -1
View File
@@ -105,7 +105,7 @@ Rules:
- Use 2-6 top-level categories when appropriate.
- Add subcategories only where they help shoppers browse (max depth ${MAX_DEPTH}).
- Every node needs nameEn and nameFa (Farsi in Persian script).
- Descriptions are optional, max 120 characters, English only.
- Descriptions are optional, max 120 characters. Match the user prompt language (Persian if the prompt is Persian, otherwise English).
- Do not duplicate category names at the same level.
- Total nodes must not exceed ${MAX_TOTAL}.`;
@@ -10,6 +10,7 @@ import {
requestAiJsonCompletion,
resolveAiProvider,
} from '../common/ai-provider.util';
import { loadReferencePagesFromText } from '../common/ai-reference-pages.util';
import { PrismaService } from '../prisma/prisma.service';
import { CategoryTechnicalFormService } from './category-technical-form.service';
import {
@@ -26,7 +27,22 @@ type AiTechnicalFormDraft = {
}[];
};
const ALLOWED_TYPES = new Set(['text', 'textarea', 'select', 'multi_select']);
const ALLOWED_TYPES = new Set([
'text',
'textarea',
'select',
'multi_select',
'multi_value',
]);
const OTHER_FEATURES_LABEL = 'سایر ویژگی‌ها';
const OTHER_FEATURES_LABELS = new Set([
'سایر ویژگی‌ها',
'سایر ویژگی ها',
'other features',
'other feature',
'additional features',
]);
@Injectable()
export class CategoryTechnicalFormAiService {
@@ -86,30 +102,47 @@ export class CategoryTechnicalFormAiService {
existingFieldCount: number;
}): Promise<TechnicalFormFieldInputDto[]> {
const provider = resolveAiProvider(this.config);
const referencePages = await loadReferencePagesFromText(input.hint ?? '');
const usableReferences = referencePages.filter((page) => page.text);
const systemPrompt = `You design technical specification forms for e-commerce product categories on an Iranian marketplace.
Return ONLY valid JSON with this shape:
if (referencePages.length > 0 && usableReferences.length === 0) {
throw new BadRequestException(
'Could not load the product page URL from the prompt. Check the link and try again.',
);
}
const systemPrompt = `تو طراح فرم مشخصات فنی برای دسته‌بندی‌های فروشگاهی در یک مارکت‌پلیس ایرانی هستی.
فقط JSON معتبر با این شکل برگردان:
{
"fields": [
{
"label": "Field label in English",
"type": "text | textarea | select | multi_select",
"label": "برچسب فیلد",
"type": "text | textarea | select | multi_select | multi_value",
"isRequired": true,
"options": ["Option A", "Option B"]
"options": ["گزینه ۱", "گزینه ۲"]
}
]
}
Rules:
- Generate 5-12 practical fields that buyers need for products in the given category.
- Use a mix of text, textarea, select, and multi_select where appropriate.
- select and multi_select fields must include 3-8 realistic options.
- text fields are for short values (dimensions, weight, model year).
- textarea fields are for longer specs (features, care instructions).
- Mark important buyer-facing specs as required.
- Labels must be unique and human-readable.
- Do not include price, stock, SKU, or warranty period fields.
- Prefer metric units common in Iran when relevant.`;
قوانین:
- بین ۵ تا ۱۱ فیلد کاربردی تولید کن که خریداران برای محصولات این دسته نیاز دارند (سیستم همیشه «سایر ویژگی‌ها» را اضافه می‌کند).
- ترجیحاً از type=text استفاده کن تا فروشنده بتواند هر مقدار دلخواهی را آزادانه وارد کند.
- از select و multi_select حتی‌المقدور استفاده نکن. فقط وقتی مجموعه گزینه‌ها واقعاً ثابت، کوتاه و استاندارد است (مثلاً بله/خیر یا حداکثر چند گزینه قطعی) مجازند؛ برای ظرفیت، توان، ابعاد، مدل، جنس، رنگ آزاد و مشابه آن‌ها همیشه text بگذار.
- اگر بین text و select مردد هستی، حتماً text انتخاب کن.
- فیلدهای select و multi_select (در صورت استفاده) باید ۳ تا ۸ گزینه واقعی داشته باشند.
- multi_value یک لیست تکراری از مقادیر متنی آزاد است (بدون گزینه) برای مقادیر کوتاه چندتایی.
- text برای مقادیر کوتاه (ابعاد، وزن، سال مدل، ظرفیت، توان).
- textarea برای مشخصات طولانی‌تر (ویژگی‌ها، دستورالعمل نگهداری).
- اگر برای این دسته مناسب است، فیلدهای «ابعاد» و «وزن» را به‌صورت text اضافه کن (واحد متریک رایج ایران را در برچسب بیاور، مثلاً سانتی‌متر / کیلوگرم). اگر برای دسته بی‌معناست، اضافه نکن.
- مشخصات مهم خریدار را الزامی علامت بزن.
- برچسب‌ها باید یکتا و خوانا باشند.
- همیشه برچسب‌ها و گزینه‌ها را به فارسی بنویس.
- فیلدهای قیمت، موجودی، SKU یا مدت گارانتی نساز.
- در صورت مرتبط بودن از واحدهای متریک رایج در ایران استفاده کن.
- اگر referencePages داده شده، آن‌ها منبع حقیقت هستند: فیلدهایی بساز که با مشخصات واقعی همان صفحات هم‌خوان باشد.
- مشخصات اصلی نزدیک عنوان محصول را ترجیح بده؛ بلوک‌های محصول مرتبط/آپسل را نادیده بگیر.
- وقتی صفحه مرجع هست از حافظه فیلد اختراع نکن — همان چیزی را بازتاب بده که صفحه واقعاً دارد.
- یک فیلد «سایر ویژگی‌ها» خودت نساز؛ سیستم آن را به‌صورت multi_value اضافه می‌کند.`;
const userPrompt = JSON.stringify({
category: input.categoryName,
@@ -119,11 +152,23 @@ Rules:
input.existingFieldCount > 0
? 'Category already has a form; suggest a fresh complete replacement set.'
: 'Category has no form yet.',
referencePages: usableReferences.map((page) => ({
url: page.url,
text: page.text,
})),
referenceFetchErrors: referencePages
.filter((page) => !page.text)
.map((page) => ({ url: page.url, error: page.error ?? 'unknown' })),
});
let content: string;
try {
content = await requestAiJsonCompletion(provider, systemPrompt, userPrompt);
content = await requestAiJsonCompletion(
provider,
systemPrompt,
userPrompt,
usableReferences.length > 0 ? 0.2 : 0.4,
);
} catch (err) {
const message = err instanceof Error ? err.message : 'AI request failed';
throw new BadRequestException(message);
@@ -148,7 +193,8 @@ Rules:
const label = String(raw.label ?? '').trim();
if (!label) continue;
const normalizedLabel = label.toLowerCase();
const normalizedLabel = label.toLowerCase().replace(/\s+/g, ' ').trim();
if (OTHER_FEATURES_LABELS.has(normalizedLabel)) continue;
if (labels.has(normalizedLabel)) continue;
labels.add(normalizedLabel);
@@ -172,9 +218,15 @@ Rules:
}
fields.push(field);
if (fields.length >= 12) break;
if (fields.length >= 11) break;
}
fields.push({
label: OTHER_FEATURES_LABEL,
type: 'multi_value',
isRequired: false,
});
if (!fields.length) {
throw new BadRequestException('AI did not return any valid technical form fields');
}
+45 -4
View File
@@ -54,6 +54,31 @@ function uniqueSlug(base: string, used: Set<string>, fallbackIndex: number): str
return next;
}
function normalizeHex(value: string | null | undefined): string | null {
if (value == null) return null;
const trimmed = value.trim();
if (!isHexColor(trimmed)) return null;
return trimmed.toUpperCase();
}
/** Map value labels → provided hex (later entries win for duplicate labels). */
function buildColorHexByLabel(
values: string[],
colorHexes: (string | null)[] | undefined,
): Map<string, string> {
const map = new Map<string, string>();
if (!colorHexes?.length) return map;
values.forEach((raw, index) => {
const label = raw.trim();
if (!label) return;
const hex = normalizeHex(colorHexes[index] ?? null);
if (hex) map.set(label, hex);
});
return map;
}
type ExistingVariation = {
id: bigint;
name: string;
@@ -126,6 +151,10 @@ export class CategoryVariationsService {
const uniqueValues = [
...new Set(variation.values.map((v) => v.trim()).filter(Boolean)),
];
const hexByLabel = buildColorHexByLabel(
variation.values,
variation.colorHexes,
);
if (current) {
matchedExistingIds.add(current.id);
@@ -137,7 +166,7 @@ export class CategoryVariationsService {
sortOrder: index,
},
});
await this.syncOptions(tx, current, variation.type, uniqueValues);
await this.syncOptions(tx, current, variation.type, uniqueValues, hexByLabel);
} else {
const created = await tx.categoryVariation.create({
data: {
@@ -148,7 +177,13 @@ export class CategoryVariationsService {
sortOrder: index,
},
});
await this.createOptions(tx, created.id, variation.type, uniqueValues);
await this.createOptions(
tx,
created.id,
variation.type,
uniqueValues,
hexByLabel,
);
}
}
@@ -203,6 +238,7 @@ export class CategoryVariationsService {
current: ExistingVariation,
type: CategoryVariationInputDto['type'],
labels: string[],
hexByLabel: Map<string, string> = new Map(),
) {
const usedSlugs = new Set<string>();
const matchedOptionIds = new Set<bigint>();
@@ -215,7 +251,8 @@ export class CategoryVariationsService {
const colorHex =
type === 'color'
? resolveColorHex(label) ??
? hexByLabel.get(label) ??
resolveColorHex(label) ??
existingOption?.colorHex ??
(existingOption && isHexColor(existingOption.value)
? existingOption.value.trim()
@@ -270,6 +307,7 @@ export class CategoryVariationsService {
variationId: bigint,
type: CategoryVariationInputDto['type'],
labels: string[],
hexByLabel: Map<string, string> = new Map(),
) {
const usedSlugs = new Set<string>();
await tx.categoryVariationOption.createMany({
@@ -277,7 +315,10 @@ export class CategoryVariationsService {
variationId,
label,
value: uniqueSlug(slugifyValue(label), usedSlugs, optionIndex + 1),
colorHex: type === 'color' ? resolveColorHex(label) : null,
colorHex:
type === 'color'
? hexByLabel.get(label) ?? resolveColorHex(label)
: null,
sortOrder: optionIndex,
})),
});
@@ -17,8 +17,8 @@ export class TechnicalFormFieldInputDto {
label!: string;
@IsString()
@IsIn(['text', 'textarea', 'select', 'multi_select'])
type!: 'text' | 'textarea' | 'select' | 'multi_select';
@IsIn(['text', 'textarea', 'select', 'multi_select', 'multi_value'])
type!: 'text' | 'textarea' | 'select' | 'multi_select' | 'multi_value';
@IsOptional()
@IsBoolean()
@@ -3,6 +3,7 @@ import {
ArrayMinSize,
IsArray,
IsIn,
IsOptional,
IsString,
MinLength,
ValidateNested,
@@ -21,6 +22,11 @@ export class CategoryVariationInputDto {
@ArrayMinSize(1)
@IsString({ each: true })
values!: string[];
/** Optional parallel list of hex colors for `color` variations (same order as values). */
@IsOptional()
@IsArray()
colorHexes?: (string | null)[];
}
export class ReplaceCategoryVariationsDto {
+30 -1
View File
@@ -1,6 +1,15 @@
import { MediaEntityType } from '@prisma/client';
import { Type } from 'class-transformer';
import { IsEnum, IsInt, IsOptional, IsString, Matches, Min, MinLength } from 'class-validator';
import {
IsEnum,
IsInt,
IsOptional,
IsString,
Matches,
MaxLength,
Min,
MinLength,
} from 'class-validator';
export class ListCategoriesDto {
@IsOptional()
@@ -29,6 +38,16 @@ export class CreateCategoryDto {
@IsString()
description?: string;
@IsOptional()
@IsString()
@MaxLength(70)
seoMetaTitle?: string | null;
@IsOptional()
@IsString()
@MaxLength(160)
seoMetaDescription?: string | null;
@IsOptional()
@IsString()
@Matches(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, {
@@ -61,6 +80,16 @@ export class UpdateCategoryDto {
@IsString()
description?: string | null;
@IsOptional()
@IsString()
@MaxLength(70)
seoMetaTitle?: string | null;
@IsOptional()
@IsString()
@MaxLength(160)
seoMetaDescription?: string | null;
@IsOptional()
@IsString()
@Matches(/^[a-z0-9]+(?:-[a-z0-9]+)*$/)
@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { CloudflareDnsService } from './cloudflare-dns.service';
@Module({
providers: [CloudflareDnsService],
exports: [CloudflareDnsService],
})
export class CloudflareDnsModule {}
@@ -0,0 +1,627 @@
import { Injectable, Logger, ServiceUnavailableException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import {
DMARC_NAME,
LEGACY_DKIM_NAME,
MAIL_HOSTNAME,
MAIL_IPV4,
MAIL_MX_PRIORITY,
MAIL_SPF,
type MailDkimKey,
} from '../mail-dns/mail-dns';
const DEFAULT_BASE_URL = 'https://api.cloudflare.com/client/v4';
const TTL = 120;
const TARGET = 'ns.meshkee.com';
type CfRecord = {
id: string;
type: string;
name: string;
content: string;
proxied?: boolean;
ttl?: number;
priority?: number;
};
@Injectable()
export class CloudflareDnsService {
private readonly logger = new Logger(CloudflareDnsService.name);
constructor(private readonly config: ConfigService) {}
/**
* Parked alias: apex + www CNAME to Meshkee websites, DNS-only (not proxied)
* so Let's Encrypt HTTP-01 on the websites VM can complete.
*/
async ensureParkedRecords(hostRaw: string): Promise<{
created: string[];
updated: string[];
skipped: string[];
}> {
return this.ensureCnameRecords(hostRaw, (host) => [
{ relative: '@', content: TARGET },
{ relative: 'www', content: host },
]);
}
/**
* Full Meshkee tenant: @ + www + business + customer + api, DNS-only.
*/
async ensureTenantRecords(hostRaw: string): Promise<{
created: string[];
updated: string[];
skipped: string[];
}> {
return this.ensureCnameRecords(hostRaw, (host) => [
{ relative: '@', content: TARGET },
{ relative: 'www', content: host },
{ relative: 'business', content: 'business.meshkee.com' },
{ relative: 'customer', content: 'customer.meshkee.com' },
{ relative: 'api', content: 'api.meshkee.com' },
]);
}
/**
* Stalwart mail for a Meshkee website. All records DNS-only (not proxied).
*/
async ensureMailRecords(hostRaw: string, keys: MailDkimKey[]): Promise<{
created: string[];
updated: string[];
skipped: string[];
removed: string[];
}> {
const host = hostRaw.trim().toLowerCase();
if (!host) {
throw new ServiceUnavailableException('Domain host is required for Cloudflare DNS');
}
const token = this.apiToken();
if (!token) {
throw new ServiceUnavailableException(
'Cloudflare DNS is not configured (CLOUDFLARE_API_TOKEN)',
);
}
const zoneId = await this.findZoneId(host, token);
const existing = await this.listRecords(zoneId, token);
const created: string[] = [];
const updated: string[] = [];
const skipped: string[] = [];
const removed: string[] = [];
await this.deleteRecordsOfTypes(zoneId, token, existing, host, 'mail', ['CNAME'], removed);
await this.upsertCf(
zoneId,
token,
existing,
host,
{ type: 'A', name: 'mail', content: MAIL_IPV4, proxied: false },
created,
updated,
skipped,
);
await this.replaceExtraMx(zoneId, token, existing, host, removed);
await this.deleteRecordsOfTypes(
zoneId,
token,
existing,
host,
LEGACY_DKIM_NAME,
['TXT', 'CNAME'],
removed,
);
await this.deleteRecordsOfTypes(
zoneId,
token,
existing,
host,
DMARC_NAME,
['TXT', 'CNAME'],
removed,
);
await this.upsertCf(
zoneId,
token,
existing,
host,
{
type: 'MX',
name: '@',
content: MAIL_HOSTNAME,
priority: MAIL_MX_PRIORITY,
proxied: false,
},
created,
updated,
skipped,
);
await this.upsertSpf(zoneId, token, existing, host, created, updated, skipped);
for (const key of keys) {
await this.upsertCf(
zoneId,
token,
existing,
host,
{
type: 'TXT',
name: `${key.selector}._domainkey`,
content: key.text,
proxied: false,
},
created,
updated,
skipped,
);
}
await this.upsertCf(
zoneId,
token,
existing,
host,
{
type: 'CNAME',
name: 'autoconfig',
content: MAIL_HOSTNAME,
proxied: false,
},
created,
updated,
skipped,
);
await this.upsertCf(
zoneId,
token,
existing,
host,
{
type: 'CNAME',
name: 'autodiscover',
content: MAIL_HOSTNAME,
proxied: false,
},
created,
updated,
skipped,
);
this.logger.log(
`Cloudflare mail DNS ${host}: created=${created.join(',') || '-'} updated=${updated.join(',') || '-'} skipped=${skipped.join(',') || '-'} removed=${removed.join(',') || '-'}`,
);
return { created, updated, skipped, removed };
}
private async ensureCnameRecords(
hostRaw: string,
desiredFor: (apex: string) => Array<{ relative: string; content: string }>,
): Promise<{
created: string[];
updated: string[];
skipped: string[];
}> {
const host = hostRaw.trim().toLowerCase();
if (!host) {
throw new ServiceUnavailableException('Domain host is required for Cloudflare DNS');
}
const token = this.apiToken();
if (!token) {
throw new ServiceUnavailableException(
'Cloudflare DNS is not configured (CLOUDFLARE_API_TOKEN)',
);
}
const zoneId = await this.findZoneId(host, token);
const existing = await this.listRecords(zoneId, token);
const created: string[] = [];
const updated: string[] = [];
const skipped: string[] = [];
for (const item of desiredFor(host)) {
const label = `CNAME ${item.relative}`;
const match = existing.find(
(rec) => rec.type === 'CNAME' && this.relativeName(rec.name, host) === item.relative,
);
if (match && this.sameCname(match, item.content) && match.proxied === false) {
skipped.push(label);
continue;
}
await this.deleteConflictingAddress(zoneId, token, existing, host, item.relative);
if (match) {
const patch = await this.request(
'PATCH',
`/zones/${zoneId}/dns_records/${match.id}`,
token,
{
type: 'CNAME',
name: item.relative,
content: item.content,
ttl: TTL,
proxied: false,
},
);
if (!patch.ok) {
throw new ServiceUnavailableException(
`Cloudflare failed to update ${label} (${patch.status})${patch.message ? `: ${patch.message}` : ''}`,
);
}
updated.push(label);
continue;
}
const post = await this.request('POST', `/zones/${zoneId}/dns_records`, token, {
type: 'CNAME',
name: item.relative,
content: item.content,
ttl: TTL,
proxied: false,
});
if (!post.ok) {
throw new ServiceUnavailableException(
`Cloudflare failed to create ${label} (${post.status})${post.message ? `: ${post.message}` : ''}`,
);
}
created.push(label);
}
this.logger.log(
`Cloudflare DNS ${host}: created=${created.join(',') || '-'} updated=${updated.join(',') || '-'} skipped=${skipped.join(',') || '-'}`,
);
return { created, updated, skipped };
}
private async findZoneId(host: string, token: string): Promise<string> {
const res = await this.request(
'GET',
`/zones?name=${encodeURIComponent(host)}&status=active`,
token,
);
if (!res.ok) {
throw new ServiceUnavailableException(
`Cloudflare could not load zone "${host}" (${res.status})${res.message ? `: ${res.message}` : ''}`,
);
}
const result = Array.isArray(res.body.result) ? res.body.result : [];
const zone = result.find(
(item) =>
item &&
typeof item === 'object' &&
'name' in item &&
String((item as { name: string }).name).toLowerCase() === host,
) as { id?: string } | undefined;
if (!zone?.id) {
throw new ServiceUnavailableException(
`Cloudflare zone "${host}" was not found. Add the domain in Cloudflare first.`,
);
}
return zone.id;
}
private async listRecords(zoneId: string, token: string): Promise<CfRecord[]> {
const items: CfRecord[] = [];
let page = 1;
for (;;) {
const res = await this.request(
'GET',
`/zones/${zoneId}/dns_records?per_page=100&page=${page}`,
token,
);
if (!res.ok) {
throw new ServiceUnavailableException(
`Cloudflare could not list DNS records (${res.status})${res.message ? `: ${res.message}` : ''}`,
);
}
const data = Array.isArray(res.body.result) ? (res.body.result as CfRecord[]) : [];
items.push(...data);
const info = res.body.result_info as { total_pages?: number } | undefined;
const lastPage = info?.total_pages ?? 1;
if (page >= lastPage) break;
page += 1;
}
return items;
}
private async deleteConflictingAddress(
zoneId: string,
token: string,
existing: CfRecord[],
zone: string,
relative: string,
) {
const conflicts = existing.filter(
(rec) =>
(rec.type === 'A' || rec.type === 'AAAA') &&
this.relativeName(rec.name, zone) === relative,
);
for (const rec of conflicts) {
const del = await this.request('DELETE', `/zones/${zoneId}/dns_records/${rec.id}`, token);
if (!del.ok) {
throw new ServiceUnavailableException(
`Cloudflare failed to replace ${rec.type} ${relative} (${del.status})${del.message ? `: ${del.message}` : ''}`,
);
}
}
}
private async upsertCf(
zoneId: string,
token: string,
existing: CfRecord[],
zone: string,
desired: {
type: 'A' | 'MX' | 'TXT' | 'CNAME';
name: string;
content: string;
priority?: number;
proxied: boolean;
},
created: string[],
updated: string[],
skipped: string[],
) {
const label = `${desired.type} ${desired.name}`;
const match = existing.find(
(rec) => rec.type === desired.type && this.relativeName(rec.name, zone) === desired.name,
);
const same =
match &&
this.sameContent(match, desired.content) &&
(desired.priority === undefined || this.mxPriority(match) === desired.priority) &&
(desired.proxied === false ? match.proxied !== true : true);
if (match && same && match.proxied === false) {
skipped.push(label);
return;
}
const body: Record<string, unknown> = {
type: desired.type,
name: desired.name,
content: desired.content,
ttl: TTL,
proxied: desired.type === 'A' || desired.type === 'CNAME' ? desired.proxied : undefined,
};
if (desired.priority !== undefined) body.priority = desired.priority;
if (desired.type === 'MX' || desired.type === 'TXT') delete body.proxied;
if (match) {
const patch = await this.request(
'PATCH',
`/zones/${zoneId}/dns_records/${match.id}`,
token,
body,
);
if (!patch.ok) {
throw new ServiceUnavailableException(
`Cloudflare failed to update ${label} (${patch.status})${patch.message ? `: ${patch.message}` : ''}`,
);
}
match.content = desired.content;
match.proxied = desired.proxied;
updated.push(label);
return;
}
const post = await this.request('POST', `/zones/${zoneId}/dns_records`, token, body);
if (!post.ok) {
throw new ServiceUnavailableException(
`Cloudflare failed to create ${label} (${post.status})${post.message ? `: ${post.message}` : ''}`,
);
}
existing.push({
id: String((post.body.result as { id?: string } | undefined)?.id ?? `new-${label}`),
type: desired.type,
name: desired.name,
content: desired.content,
proxied: desired.proxied,
});
created.push(label);
}
private async upsertSpf(
zoneId: string,
token: string,
existing: CfRecord[],
zone: string,
created: string[],
updated: string[],
skipped: string[],
) {
const match = existing.find(
(rec) =>
rec.type === 'TXT' &&
this.relativeName(rec.name, zone) === '@' &&
rec.content.replace(/^"|"$/g, '').toLowerCase().startsWith('v=spf1'),
);
if (match) {
if (this.sameContent(match, MAIL_SPF)) {
skipped.push('TXT @ SPF');
return;
}
const patch = await this.request(
'PATCH',
`/zones/${zoneId}/dns_records/${match.id}`,
token,
{ type: 'TXT', name: '@', content: MAIL_SPF, ttl: TTL },
);
if (!patch.ok) {
throw new ServiceUnavailableException(
`Cloudflare failed to update TXT @ SPF (${patch.status})${patch.message ? `: ${patch.message}` : ''}`,
);
}
match.content = MAIL_SPF;
updated.push('TXT @ SPF');
return;
}
const post = await this.request('POST', `/zones/${zoneId}/dns_records`, token, {
type: 'TXT',
name: '@',
content: MAIL_SPF,
ttl: TTL,
});
if (!post.ok) {
throw new ServiceUnavailableException(
`Cloudflare failed to create TXT @ SPF (${post.status})${post.message ? `: ${post.message}` : ''}`,
);
}
existing.push({
id: String((post.body.result as { id?: string } | undefined)?.id ?? 'new-spf'),
type: 'TXT',
name: '@',
content: MAIL_SPF,
});
created.push('TXT @ SPF');
}
private async replaceExtraMx(
zoneId: string,
token: string,
existing: CfRecord[],
zone: string,
removed: string[],
) {
const mx = existing.filter((rec) => rec.type === 'MX');
const keep = mx.find(
(rec) =>
this.relativeName(rec.name, zone) === '@' && this.sameContent(rec, MAIL_HOSTNAME),
);
for (const rec of mx) {
if (keep && rec.id === keep.id) continue;
const del = await this.request('DELETE', `/zones/${zoneId}/dns_records/${rec.id}`, token);
if (!del.ok) {
throw new ServiceUnavailableException(
`Cloudflare failed to remove extra MX (${del.status})${del.message ? `: ${del.message}` : ''}`,
);
}
removed.push(`MX ${this.relativeName(rec.name, zone)} ${rec.content}`);
const index = existing.indexOf(rec);
if (index >= 0) existing.splice(index, 1);
}
}
private async deleteRecordsOfTypes(
zoneId: string,
token: string,
existing: CfRecord[],
zone: string,
name: string,
types: string[],
removed: string[],
) {
const conflicts = existing.filter(
(rec) => types.includes(rec.type) && this.relativeName(rec.name, zone) === name,
);
for (const rec of conflicts) {
const del = await this.request('DELETE', `/zones/${zoneId}/dns_records/${rec.id}`, token);
if (!del.ok) {
throw new ServiceUnavailableException(
`Cloudflare failed to replace ${rec.type} ${name} (${del.status})${del.message ? `: ${del.message}` : ''}`,
);
}
removed.push(`${rec.type} ${name}`);
const index = existing.indexOf(rec);
if (index >= 0) existing.splice(index, 1);
}
}
private sameContent(rec: CfRecord, content: string): boolean {
return rec.content.replace(/\.$/, '').replace(/^"|"$/g, '').toLowerCase() ===
content.replace(/\.$/, '').replace(/^"|"$/g, '').toLowerCase();
}
private mxPriority(rec: CfRecord): number {
return Number(rec.priority ?? 0);
}
private relativeName(name: string, zone: string): string {
const n = name.replace(/\.$/, '').toLowerCase();
const z = zone.replace(/\.$/, '').toLowerCase();
if (n === z || n === '@') return '@';
if (n.endsWith(`.${z}`)) return n.slice(0, -(z.length + 1));
return n;
}
private sameCname(rec: CfRecord, content: string): boolean {
return rec.content.replace(/\.$/, '').toLowerCase() === content.replace(/\.$/, '').toLowerCase();
}
private apiToken(): string | null {
return this.config.get<string>('CLOUDFLARE_API_TOKEN')?.trim() || null;
}
private baseUrl(): string {
return (
this.config.get<string>('CLOUDFLARE_API_BASE_URL')?.trim().replace(/\/$/, '') ||
DEFAULT_BASE_URL
);
}
private async request(
method: string,
path: string,
token: string,
body?: unknown,
): Promise<{
ok: boolean;
status: number;
body: Record<string, unknown>;
message: string;
}> {
let response: Response;
try {
response = await fetch(`${this.baseUrl()}${path}`, {
method,
headers: {
Accept: 'application/json',
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: body === undefined ? undefined : JSON.stringify(body),
signal: AbortSignal.timeout(20_000),
});
} catch (err) {
const detail = err instanceof Error ? err.message : null;
this.logger.warn(`Cloudflare DNS request failed ${method} ${path}${detail ? `: ${detail}` : ''}`);
throw new ServiceUnavailableException(
detail
? `Could not reach Cloudflare DNS API (${detail})`
: 'Could not reach Cloudflare DNS API',
);
}
const text = await response.text().catch(() => '');
let parsed: Record<string, unknown> = {};
try {
parsed = text ? (JSON.parse(text) as Record<string, unknown>) : {};
} catch {
/* keep raw */
}
const errors = parsed.errors;
const errorMessage =
Array.isArray(errors) && errors[0] && typeof errors[0] === 'object' && 'message' in errors[0]
? String((errors[0] as { message: unknown }).message)
: '';
const message =
errorMessage ||
(typeof parsed.message === 'string' && parsed.message) ||
(text && !response.ok ? text.slice(0, 300) : '');
return {
ok: response.ok && parsed.success !== false,
status: response.status,
body: parsed,
message,
};
}
}
+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;
}
+15
View File
@@ -19,6 +19,21 @@ export function startOfLocalDay(offsetDays = 0): Date {
return date;
}
/** Start of the current local week. `weekStartsOn`: 0 Sun … 6 Sat. Default Saturday (Iran). */
export function startOfLocalWeek(weekStartsOn = 6): Date {
const today = startOfLocalDay(0);
const diff = (today.getDay() - weekStartsOn + 7) % 7;
return startOfLocalDay(diff);
}
/** First day of the current local month at 00:00:00. */
export function startOfLocalMonth(): Date {
const date = new Date();
date.setDate(1);
date.setHours(0, 0, 0, 0);
return date;
}
function pad2(n: number) {
return String(n).padStart(2, '0');
}

Some files were not shown because too many files have changed in this diff Show More