Author SHA1 Message Date
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
Alireza HassaniandCursor dcf8b6423a Drop the domain from public AI prompt URLs.
Copied links now use business name and prompt name only; old host-prefixed URLs still resolve.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-30 16:19:45 +03:30
Alireza HassaniandCursor 73588db1c6 Keep product migrate from crashing the API on large image copies.
PM2 was restarting at 512MB while Sharp cached buffers, which returned 502 to the migrate UI after categories had already succeeded.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-30 15:02:40 +03:30
Alireza HassaniandCursor a5f2f8d63b Fix SSL list badges, labeled AI prompt URLs, and product-category migrate.
Storefront SSL now follows apex expiry instead of requiring www, prompt links include domain and prompt name, and extra color variations merge instead of aborting migrate.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-30 14:47:10 +03:30
Alireza HassaniandCursor e37cb4a20d Stop storefront deploys from inheriting the agent PORT and leftover standalone cwd.
pm2 restart --update-env leaked PORT=9050 onto Next sites and left nginx 502 when a build dropped standalone output.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-30 09:41:37 +03:30
Alireza HassaniandCursor 0c1c3f7ff9 Add AI English name translation and aggregate website view analytics.
Website views chart uses kind=all across page kinds; home hits also count static-images loads.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 20:39:37 +03:30
Alireza HassaniandCursor 0d9c2fd8ea Add published user products to the storefront sitemap.
Expose /sitemap-user-products.xml when customer_products is enabled, invalidate on listing mutations, and proxy the new path from nginx provision scripts.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-27 07:16:05 +03:30
Alireza HassaniandCursor fb04425df7 Clarify technical-info docs and ship pending owner/color fixes.
Document that product specs need /technical-info for labels, auto-verify admin-created business owners, and expand category color presets with Farsi aliases.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-27 06:46:28 +03:30
Alireza HassaniandCursor 3d3abeb65b Patch sitemap SEO proxies on both HTTP and HTTPS nginx blocks.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-26 19:10:15 +03:30
Alireza HassaniandCursor 730407ec3a Split blogs and other modules into child sitemaps.
Move blogs, portfolios, videos, instructions, and workshops out of sitemap-main into dedicated files like products, and allow saving migrated Farsi category color labels without wiping store-item links.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-26 19:02:38 +03:30
Alireza HassaniandCursor 5850c90d84 Re-provision storefront nginx when domain host is renamed.
Also create origin HTTPS with a self-signed cert on provision so Cloudflare Full does not fall through to another site's default SSL vhost.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-26 17:31:56 +03:30
171 changed files with 12189 additions and 842 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';
+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;
}
}
```
+32 -8
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` |
@@ -262,10 +266,17 @@ All routes are prefixed with `/api/v1`.
| POST | `/auth/verify-otp` | Verify OTP (marks cell verified; no tokens) |
| POST | `/auth/handoff/consume` | One-time SSO ticket → tokens (customer → business dashboard) |
| GET | `/tenants/:host` | Resolve business from domain (`specialProductsSource`, `enabledModules`, `homeCharts`, `ePayment`) |
| GET | `/tenants/:host/sitemap.xml` | Sitemap **index** → main + products child sitemaps |
| GET | `/tenants/:host/sitemap-main.xml` | Static pages + `/products/category/{id}/{faSlug}` + blogs/portfolios |
| 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/{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 `/instruction/{slug}` |
| GET | `/tenants/:host/sitemap-workshops.xml` | Published workshops `/workshops/{slug}` |
| 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) |
@@ -318,6 +329,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
@@ -326,6 +341,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)
@@ -344,9 +363,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)
@@ -434,7 +455,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.
---
@@ -446,6 +467,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)
@@ -636,7 +658,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, two DKIM TXT, 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)
@@ -644,6 +667,7 @@ See `.env.example` for the full list. Key groups:
- Products CRUD
- Product variation values (which options a product offers)
- Store items / product variants (price, stock, SKU)
- 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
+252 -27
View File
@@ -22,24 +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
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 }`
@@ -47,20 +63,157 @@ 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
- `GET /tenants/{domain}/user-products/{slug}/technical-info` — category form + 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.
### 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.
### Technical details / specs table (catalog products + user products)
**Do not** render only `technicalValues` from the detail endpoint — users will see bare values (e.g. `SAMP`, `1992`) with no labels.
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)
| 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/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).
2. For each `values[]` entry, find `form.fields` where `field.id === value.fieldId`.
3. Show `field.label` (or localized label if the site uses FA/EN) next to the value (`textValue`, or resolve option labels from `field.options` when `optionId` / `optionIds` are set).
4. Follow `form.fields` `sortOrder` for display order.
5. If `form` is null/empty or `values` is empty, hide the technical section.
**Wrong:** `GET .../product-category-variation-fields?categoryId=…` (does not exist → 404).
**Wrong:** expecting `fieldName` / `fieldNameFa` on each `technicalValues` item in the product detail response.
### Analytics (dashboard charts)
Page views are recorded **automatically** for roughly one count per page load:
- **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:
@@ -133,14 +286,24 @@ 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`.
- 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 (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**
- `/sitemap-main.xml` — static pages + categories + blogs + portfolios
- `/sitemap.xml` — sitemap **index** (lists child sitemaps for enabled modules)
- `/sitemap-main.xml` — static pages + product categories
- `/sitemap-products.xml` — published products (when products module is enabled)
- `/sitemap-blogs.xml` — published blogs (when blog module is enabled)
- `/sitemap-portfolios.xml` — published portfolios (when portfolio module is enabled)
- `/sitemap-videos.xml` — published videos (when videos module is enabled)
- `/sitemap-instructions.xml` — published instructions (when instructions module is enabled)
- `/sitemap-workshops.xml` — published workshops (when workshops module is enabled)
- `/sitemap-user-products.xml` — published user products / customer listings (when `customer_products` module is enabled)
- `/robots.txt` — points at `/sitemap.xml`
### On-page SEO (every public page)
@@ -168,11 +331,73 @@ 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 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
If OpenAPI and this brief conflict, **OpenAPI wins**.
@@ -180,4 +405,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}}"
}
},
@@ -1237,6 +1237,7 @@
"name": "Get product technical info by slug",
"request": {
"method": "GET",
"description": "Field labels + values for the product specs UI. Join form.fields[].id to values[].fieldId. Detail alone has no labels.",
"url": "{{baseUrl}}/tenants/{{domain}}/products/{{productSlug}}/technical-info"
}
}
@@ -1353,10 +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": "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}}"
}
},
@@ -1364,6 +1382,7 @@
"name": "Get user product technical info by slug",
"request": {
"method": "GET",
"description": "Legacy specs by slug. Prefer by-id technical-info. Join field.id ↔ value.fieldId.",
"url": "{{baseUrl}}/tenants/{{domain}}/user-products/{{userProductSlug}}/technical-info"
}
}
@@ -1484,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.
---
+63 -2
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,9 +143,31 @@
<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/by-id/{id}/technical-info</code>
(catalog products:
<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>
+396 -31
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**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": [
{
@@ -34,14 +34,20 @@
"name": "Categories"
},
{
"name": "Products"
"name": "Products",
"description": "Catalog products. For a specs/technical-details table on the product page, call `GET .../products/{slug}/technical-info` (or by-id). Do not rely on detail-only payloads for field labels."
},
{
"name": "User Products"
"name": "User Products",
"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"
},
@@ -58,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"
@@ -67,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"
@@ -80,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": {
@@ -88,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",
@@ -186,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"]
}
}
}
@@ -201,7 +222,7 @@
"SEO"
],
"summary": "XML sitemap for search engines",
"description": "Sitemap **index** for this tenant. Points to `/sitemap-main.xml` (static pages, product categories, blogs, portfolios) and `/sitemap-products.xml` (published products when the products module is enabled). Category paths: `/products/category/{id}/{nameFaSlug}`. Product paths: `/products/{id}/{nameFaSlug}`. Proxied from `https://{domain}/sitemap.xml`. Cached in Redis; refreshed after CMS writes and manifest sync.",
"description": "Sitemap **index** for this tenant. Points to `/sitemap-main.xml` (static pages + product categories) plus module child sitemaps when enabled: `/sitemap-products.xml`, `/sitemap-blogs.xml`, `/sitemap-portfolios.xml`, `/sitemap-videos.xml`, `/sitemap-instructions.xml`, `/sitemap-workshops.xml`, `/sitemap-user-products.xml` (when `customer_products` is enabled). Proxied from `https://{domain}/sitemap.xml`. Cached in Redis; refreshed after CMS writes and manifest sync.",
"parameters": [
{
"$ref": "#/components/parameters/domain"
@@ -237,7 +258,7 @@
"tags": [
"SEO"
],
"summary": "Main sitemap urlset (static + categories + blogs + portfolios)",
"summary": "Main sitemap urlset (static pages + product categories)",
"parameters": [
{
"$ref": "#/components/parameters/domain"
@@ -282,6 +303,157 @@
}
}
},
"/tenants/{domain}/sitemap-blogs.xml": {
"get": {
"tags": [
"SEO"
],
"summary": "Blogs-only sitemap urlset",
"parameters": [
{
"$ref": "#/components/parameters/domain"
}
],
"responses": {
"200": {
"description": "Sitemap XML (urlset) of published blogs",
"content": {
"application/xml": {
"schema": {
"type": "string"
}
}
}
}
}
}
},
"/tenants/{domain}/sitemap-portfolios.xml": {
"get": {
"tags": [
"SEO"
],
"summary": "Portfolios-only sitemap urlset",
"parameters": [
{
"$ref": "#/components/parameters/domain"
}
],
"responses": {
"200": {
"description": "Sitemap XML (urlset) of published portfolios",
"content": {
"application/xml": {
"schema": {
"type": "string"
}
}
}
}
}
}
},
"/tenants/{domain}/sitemap-videos.xml": {
"get": {
"tags": [
"SEO"
],
"summary": "Videos-only sitemap urlset",
"parameters": [
{
"$ref": "#/components/parameters/domain"
}
],
"responses": {
"200": {
"description": "Sitemap XML (urlset) of published videos",
"content": {
"application/xml": {
"schema": {
"type": "string"
}
}
}
}
}
}
},
"/tenants/{domain}/sitemap-instructions.xml": {
"get": {
"tags": [
"SEO"
],
"summary": "Instructions-only sitemap urlset",
"parameters": [
{
"$ref": "#/components/parameters/domain"
}
],
"responses": {
"200": {
"description": "Sitemap XML (urlset) of published instructions",
"content": {
"application/xml": {
"schema": {
"type": "string"
}
}
}
}
}
}
},
"/tenants/{domain}/sitemap-workshops.xml": {
"get": {
"tags": [
"SEO"
],
"summary": "Workshops-only sitemap urlset",
"parameters": [
{
"$ref": "#/components/parameters/domain"
}
],
"responses": {
"200": {
"description": "Sitemap XML (urlset) of published workshops",
"content": {
"application/xml": {
"schema": {
"type": "string"
}
}
}
}
}
}
},
"/tenants/{domain}/sitemap-user-products.xml": {
"get": {
"tags": [
"SEO"
],
"summary": "User products sitemap urlset",
"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"
}
],
"responses": {
"200": {
"description": "Sitemap XML (urlset) of published user products",
"content": {
"application/xml": {
"schema": {
"type": "string"
}
}
}
}
}
}
},
"/tenants/{domain}/robots.txt": {
"get": {
"tags": [
@@ -370,7 +542,32 @@
}
},
"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"]
}
}
}
}
@@ -434,7 +631,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"
@@ -452,6 +649,7 @@
"type": "string",
"enum": [
"home",
"static_page",
"portfolio_list",
"portfolio_detail",
"product_list",
@@ -462,20 +660,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."
}
}
}
@@ -484,13 +684,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"] }
}
}
}
@@ -841,7 +1042,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."
}
}
}
@@ -878,6 +1079,7 @@
"Products"
],
"summary": "Product technical info by id",
"description": "Returns category technical form schema (field labels) plus this product’s submitted values. **Use this for the product specs / technical-details UI** — join `form.fields[].id` ↔ `values[].fieldId`. Product detail alone does not include field labels.",
"parameters": [
{
"$ref": "#/components/parameters/domain"
@@ -893,7 +1095,7 @@
],
"responses": {
"200": {
"description": "Technical form + values"
"description": "{ form: { id, categoryId, fields: [{ id, label, key, type, isRequired, sortOrder, options: [{ id, label, ... }] }] } | null, values: [{ fieldId, textValue?, optionId?, optionIds? }] }. Render label→value; hide section if form/fields/values empty."
}
}
}
@@ -919,7 +1121,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."
}
}
}
@@ -955,7 +1157,8 @@
"tags": [
"Products"
],
"summary": "Product technical specs",
"summary": "Product technical specs (labels + values)",
"description": "Returns category technical form schema (field labels) plus this product’s submitted values. **Required for specs UI.** Join `form.fields[].id` ↔ `values[].fieldId`. Do not invent a separate category-fields public endpoint.",
"parameters": [
{
"$ref": "#/components/parameters/domain"
@@ -971,7 +1174,7 @@
],
"responses": {
"200": {
"description": "{ form, values }"
"description": "{ form: { id, categoryId, fields: [{ id, label, key, type, isRequired, sortOrder, options }] } | null, values: [{ fieldId, textValue?, optionId?, optionIds? }] }"
}
}
}
@@ -1063,7 +1266,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: [...] }"
}
}
}
@@ -1074,7 +1334,7 @@
"User Products"
],
"summary": "User product details by slug",
"description": "Full published listing: location IDs, gallery images (`images`, `galleryMediaIds`), technical field values, delivery/technical notes.",
"description": "Full published listing: location IDs, gallery images (`images`, `galleryMediaIds`), delivery/technical notes, and `technicalValues`.\n\n**Important:** `technicalValues` items are **values only** (`fieldId` + `textValue` / `optionId` / `optionIds`). They do **not** include field labels (`label`, `fieldName`, etc.). For a label→value technical-details table, call `GET /tenants/{domain}/user-products/{slug}/technical-info` and join on `fieldId`.",
"parameters": [
{
"$ref": "#/components/parameters/domain"
@@ -1090,7 +1350,7 @@
],
"responses": {
"200": {
"description": "{ product } with gallery (`images`: [{ mediaId, url }]), featuredMediaId, technicalValues, countryId, cityId, countrySlug"
"description": "{ product } with gallery (`images`: [{ mediaId, url }]), featuredMediaId, technicalValues (values only — no labels), countryId, cityId, countrySlug"
},
"404": {
"description": "Not found or not published"
@@ -1103,8 +1363,8 @@
"tags": [
"User Products"
],
"summary": "User product technical form + values",
"description": "Category technical form schema plus the listing’s submitted values (same shape as dashboard technical values).",
"summary": "User product technical form + values (labels)",
"description": "**Use this for the listing specs / technical-details UI.** Returns the category technical form (field `id`, `label`, `key`, `type`, `sortOrder`, `options`) plus the listing’s submitted `values`.\n\nJoin `form.fields[].id` ↔ `values[].fieldId` and render label→value. Sort by `sortOrder`. Hide the section if `form`/fields/`values` are empty.\n\nThere is **no** public `product-category-variation-fields` (or similar) endpoint — use this path only.",
"parameters": [
{
"$ref": "#/components/parameters/domain"
@@ -1120,7 +1380,7 @@
],
"responses": {
"200": {
"description": "{ form, values }"
"description": "{ form: { id, categoryId, fields: [{ id, label, key, type, isRequired, sortOrder, options: [{ id, label, ... }] }] } | null, values: [{ fieldId, textValue?, optionId?, optionIds? }] }"
}
}
}
@@ -1267,6 +1527,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": [
@@ -1339,7 +1704,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"
@@ -1355,7 +1720,7 @@
],
"responses": {
"200": {
"description": "{ blog }"
"description": "{ blog }. `blog.schema.jsonLd` is BlogPosting Schema.org JSON-LD when website structured data is enabled (null otherwise)."
}
}
}
@@ -1381,7 +1746,7 @@
],
"responses": {
"200": {
"description": "{ blog }"
"description": "{ blog }. `blog.schema.jsonLd` is BlogPosting Schema.org JSON-LD when website structured data is enabled (null otherwise)."
}
}
}
@@ -1692,7 +2057,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"
+1 -1
View File
@@ -9,7 +9,7 @@ module.exports = {
env: {
NODE_ENV: 'production',
},
max_memory_restart: '512M',
max_memory_restart: '2G',
time: true,
},
],
+87
View File
@@ -50,6 +50,7 @@ model User {
transactions Transaction[] @relation("TransactionCustomer")
userProducts UserProduct[]
userRoles UserRole[]
smsTemplatesVerified SmsTemplate[] @relation("SmsTemplateVerifier")
@@index([cellNumber], map: "idx_users_cell_number")
@@map("users")
@@ -69,6 +70,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 +110,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,6 +139,7 @@ model Business {
comments Comment[]
contactSubmissions ContactSubmission[]
domains Domain[]
entityTranslations EntityTranslation[]
expertReviews ExpertReview[]
favorites Favorite[]
invoiceItemTemplates InvoiceItemTemplate[]
@@ -143,11 +177,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 +224,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[]
@@ -343,6 +381,7 @@ model Media {
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[]
@@ -393,6 +432,7 @@ 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")
@@ -1576,6 +1616,7 @@ enum MediaEntityType {
video
instruction
workshop
gallery
@@map("media_entity_type")
}
@@ -1704,6 +1745,9 @@ enum WebsitePageKind {
blog_detail
video_list
video_detail
user_product_list
user_product_detail
static_page
@@map("website_page_kind")
}
@@ -1736,3 +1780,46 @@ 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")
}
+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."
+111 -1
View File
@@ -212,10 +212,120 @@ NODE
exit 0
fi
# 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)"
pm2 restart "$SLUG" --update-env || pm2 start "$ECOSYSTEM" --only "$SLUG"
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.
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)];
cfg = require(path);
if (!Array.isArray(cfg.apps)) cfg.apps = [];
} catch (_) {
cfg = { apps: [] };
}
const app = cfg.apps.find((a) => a.name === slug);
if (!app) {
console.error('ecosystem entry missing for', slug);
process.exit(1);
}
const fromEnv = Number(app.env && app.env.PORT);
const fromArgs = Number((String(app.args || '').match(/--port\s+(\d+)/) || [])[1]);
const port =
[fromEnv, fromArgs].find((p) => Number.isFinite(p) && p > 0 && p !== 9050) || 3005;
app.cwd = root;
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',
PORT: String(port),
};
const lines = ['module.exports = {', ' apps: ['];
cfg.apps.forEach((entry, idx) => {
lines.push(' {');
lines.push(` name: ${JSON.stringify(entry.name)},`);
lines.push(` cwd: ${JSON.stringify(entry.cwd)},`);
lines.push(` script: ${JSON.stringify(entry.script)},`);
if (entry.args) {
lines.push(` args: ${JSON.stringify(entry.args)},`);
}
lines.push(' env: {');
for (const [key, value] of Object.entries(entry.env || {})) {
lines.push(` ${key}: ${JSON.stringify(String(value))},`);
}
lines.push(' },');
lines.push(` error_file: ${JSON.stringify(entry.error_file)},`);
lines.push(` out_file: ${JSON.stringify(entry.out_file)},`);
lines.push(' time: true,');
if (entry.max_memory_restart) {
lines.push(` max_memory_restart: ${JSON.stringify(entry.max_memory_restart)},`);
}
lines.push(idx === cfg.apps.length - 1 ? ' }' : ' },');
});
lines.push(' ],');
lines.push('};');
lines.push('');
fs.writeFileSync(path, lines.join('\n'));
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
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)"
+86 -41
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env bash
# Insert / update Meshkee SEO proxy locations on an existing storefront nginx site.
# Covers: /sitemap.xml (index), /sitemap-main.xml, /sitemap-products.xml, /robots.txt
# Covers HTTP + HTTPS server blocks: sitemap index, child sitemaps, robots.txt
set -euo pipefail
HOST="${1:-}"
@@ -18,50 +18,95 @@ if [[ ! -f "$NGINX_AVAILABLE" ]]; then
exit 1
fi
proxy_location() {
local path="$1"
local upstream="$2"
cat <<NGINX
location = ${path} {
proxy_pass https://${API_HOST}/api/v1/tenants/${HOST}/${upstream};
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;
add_header Cache-Control "public, max-age=900";
}
python3 - "$NGINX_AVAILABLE" "$HOST" "$API_HOST" <<'PY'
import sys
from pathlib import Path
NGINX
}
path = Path(sys.argv[1])
host = sys.argv[2]
api = sys.argv[3]
text = path.read_text()
ensure_location() {
local path="$1"
local upstream="$2"
if grep -q "location = ${path}" "$NGINX_AVAILABLE"; then
echo "already present: ${path}"
return 0
fi
paths = [
("/sitemap.xml", "sitemap.xml"),
("/sitemap-main.xml", "sitemap-main.xml"),
("/sitemap-products.xml", "sitemap-products.xml"),
("/sitemap-blogs.xml", "sitemap-blogs.xml"),
("/sitemap-portfolios.xml", "sitemap-portfolios.xml"),
("/sitemap-videos.xml", "sitemap-videos.xml"),
("/sitemap-instructions.xml", "sitemap-instructions.xml"),
("/sitemap-workshops.xml", "sitemap-workshops.xml"),
("/sitemap-user-products.xml", "sitemap-user-products.xml"),
("/robots.txt", "robots.txt"),
]
local block
block="$(proxy_location "$path" "$upstream")"
local tmp
tmp="$(mktemp)"
awk -v block="$block" '
/location \/ \{/ && !done {
print block
done = 1
}
{ print }
' "$NGINX_AVAILABLE" >"$tmp"
mv "$tmp" "$NGINX_AVAILABLE"
echo "added: ${path}"
}
def block(loc: str, upstream: str) -> str:
return (
f" location = {loc} {{\n"
f" proxy_pass https://{api}/api/v1/tenants/{host}/{upstream};\n"
f" proxy_set_header Host {api};\n"
f" proxy_ssl_server_name on;\n"
f" proxy_set_header X-Real-IP $remote_addr;\n"
f" proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n"
f" proxy_set_header X-Forwarded-Proto $scheme;\n"
f" add_header Cache-Control \"public, max-age=900\";\n"
f" }}\n\n"
)
ensure_location /sitemap.xml sitemap.xml
ensure_location /sitemap-main.xml sitemap-main.xml
ensure_location /sitemap-products.xml sitemap-products.xml
ensure_location /robots.txt robots.txt
parts: list[tuple[str, str]] = []
idx = 0
while True:
start = text.find("server {", idx)
if start < 0:
parts.append(("tail", text[idx:]))
break
if start > idx:
parts.append(("other", text[idx:start]))
i = start + len("server {")
depth = 1
while i < len(text) and depth:
if text[i] == "{":
depth += 1
elif text[i] == "}":
depth -= 1
i += 1
parts.append(("server", text[start:i]))
idx = i
changed = False
out: list[str] = []
for kind, chunk in parts:
if kind != "server":
out.append(chunk)
continue
server = chunk
if host not in server:
out.append(server)
continue
insert_at = server.find(" location / {")
if insert_at < 0:
insert_at = server.find("\tlocation / {")
if insert_at < 0:
out.append(server)
continue
additions = ""
for loc, upstream in paths:
if f"location = {loc}" not in server:
additions += block(loc, upstream)
changed = True
if additions:
server = server[:insert_at] + additions + server[insert_at:]
out.append(server)
if changed:
path.write_text("".join(out))
print(f"updated: {host}")
else:
print(f"already present: {host}")
PY
nginx -t
systemctl reload nginx
+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"
+113 -7
View File
@@ -115,13 +115,21 @@ NODE
echo "using port $PORT for $SLUG"
if [[ ! -f "$NGINX_AVAILABLE" ]]; then
cat >"$NGINX_AVAILABLE" <<NGINX
server {
listen 80;
listen [::]:80;
server_name ${HOST} www.${HOST};
# Origin TLS so Cloudflare Full / direct HTTPS hit this vhost (not another site's default_server).
# Certbot / Cloudflare Origin CA can replace these files later.
SSL_DIR="/etc/nginx/ssl/$HOST"
mkdir -p "$SSL_DIR"
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
PROXY_COMMON=$(cat <<PROXY
location = /sitemap.xml {
proxy_pass https://api.meshkee.com/api/v1/tenants/${HOST}/sitemap.xml;
proxy_set_header Host api.meshkee.com;
@@ -152,6 +160,66 @@ server {
add_header Cache-Control "public, max-age=900";
}
location = /sitemap-blogs.xml {
proxy_pass https://api.meshkee.com/api/v1/tenants/${HOST}/sitemap-blogs.xml;
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;
add_header Cache-Control "public, max-age=900";
}
location = /sitemap-portfolios.xml {
proxy_pass https://api.meshkee.com/api/v1/tenants/${HOST}/sitemap-portfolios.xml;
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;
add_header Cache-Control "public, max-age=900";
}
location = /sitemap-videos.xml {
proxy_pass https://api.meshkee.com/api/v1/tenants/${HOST}/sitemap-videos.xml;
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;
add_header Cache-Control "public, max-age=900";
}
location = /sitemap-instructions.xml {
proxy_pass https://api.meshkee.com/api/v1/tenants/${HOST}/sitemap-instructions.xml;
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;
add_header Cache-Control "public, max-age=900";
}
location = /sitemap-workshops.xml {
proxy_pass https://api.meshkee.com/api/v1/tenants/${HOST}/sitemap-workshops.xml;
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;
add_header Cache-Control "public, max-age=900";
}
location = /sitemap-user-products.xml {
proxy_pass https://api.meshkee.com/api/v1/tenants/${HOST}/sitemap-user-products.xml;
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;
add_header Cache-Control "public, max-age=900";
}
location = /robots.txt {
proxy_pass https://api.meshkee.com/api/v1/tenants/${HOST}/robots.txt;
proxy_set_header Host api.meshkee.com;
@@ -184,6 +252,19 @@ server {
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;
@@ -195,12 +276,34 @@ server {
proxy_set_header X-Forwarded-Proto \$scheme;
proxy_cache_bypass \$http_upgrade;
}
PROXY
)
if [[ ! -f "$NGINX_AVAILABLE" ]]; then
cat >"$NGINX_AVAILABLE" <<NGINX
server {
listen 80;
listen [::]:80;
server_name ${HOST} www.${HOST};
${PROXY_COMMON}
}
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name ${HOST} www.${HOST};
ssl_certificate ${SSL_DIR}/fullchain.pem;
ssl_certificate_key ${SSL_DIR}/privkey.pem;
${PROXY_COMMON}
}
NGINX
ln -sfn "$NGINX_AVAILABLE" "$NGINX_ENABLED"
nginx -t
systemctl reload nginx
echo "nginx site created for $HOST → :$PORT"
echo "nginx site created for $HOST → :$PORT (http+https)"
else
echo "nginx site already exists: $NGINX_AVAILABLE"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
@@ -210,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"
+18
View File
@@ -99,6 +99,24 @@ export class AiPromptsController {
return this.service.removeDomainPrompt(domainId, promptId, user);
}
@Get('public/ai-prompts/:host/:name/:prompt/:publicId')
@Header('Content-Type', 'text/plain; charset=utf-8')
getPublicLegacyHost(@Param('publicId') publicId: string) {
return this.service.getPublicText(publicId);
}
@Get('public/ai-prompts/:name/:prompt/:publicId')
@Header('Content-Type', 'text/plain; charset=utf-8')
getPublicLabeled(@Param('publicId') publicId: string) {
return this.service.getPublicText(publicId);
}
@Get('public/ai-prompts/:name/:publicId')
@Header('Content-Type', 'text/plain; charset=utf-8')
getPublicNamed(@Param('publicId') publicId: string) {
return this.service.getPublicText(publicId);
}
@Get('public/ai-prompts/:publicId')
@Header('Content-Type', 'text/plain; charset=utf-8')
getPublic(@Param('publicId') publicId: string) {
+74 -9
View File
@@ -40,11 +40,40 @@ export class AiPromptsService {
throw new BadRequestException('Unable to allocate a public prompt id');
}
private publicUrl(publicId: string): string {
private slugSegment(value: string, fallback = 'site'): string {
const slug = value
.trim()
.toLowerCase()
.replace(/['"]/g, '')
.replace(/[^a-z0-9\u0600-\u06ff]+/gi, '-')
.replace(/-+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 80);
return slug || fallback;
}
private publicUrl(
publicId: string,
labels?: {
name?: string | null;
promptName?: string | null;
},
): string {
const base =
this.config.get<string>('API_PUBLIC_BASE_URL')?.replace(/\/$/, '') ||
'https://api.meshkee.com';
return `${base}/api/v1/public/ai-prompts/${publicId}`;
const nameSlug = labels?.name?.trim()
? this.slugSegment(labels.name)
: '';
const promptSlug = labels?.promptName?.trim()
? this.slugSegment(labels.promptName)
: '';
const parts = [
nameSlug ? encodeURIComponent(nameSlug) : '',
promptSlug ? encodeURIComponent(promptSlug) : '',
publicId,
].filter(Boolean);
return `${base}/api/v1/public/ai-prompts/${parts.join('/')}`;
}
private serializeTemplate(row: {
@@ -63,7 +92,8 @@ export class AiPromptsService {
};
}
private serializeDomainPrompt(row: {
private serializeDomainPrompt(
row: {
id: bigint;
domainId: bigint;
sourceTemplateId: bigint | null;
@@ -73,7 +103,12 @@ export class AiPromptsService {
sortOrder: number;
createdAt: Date;
updatedAt: Date;
}) {
},
labels?: {
host?: string | null;
name?: string | null;
},
) {
return {
id: row.id.toString(),
domainId: row.domainId.toString(),
@@ -81,13 +116,28 @@ export class AiPromptsService {
name: row.name,
body: row.body,
publicId: row.publicId,
publicUrl: this.publicUrl(row.publicId),
publicUrl: this.publicUrl(row.publicId, {
name: labels?.name,
promptName: row.name,
}),
sortOrder: row.sortOrder,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
private async domainLabels(domainId: bigint) {
const domain = await this.prisma.domain.findUnique({
where: { id: domainId },
select: {
host: true,
business: { select: { name: true } },
},
});
if (!domain) return undefined;
return { host: domain.host, name: domain.business.name };
}
async list(actor: AuthUser) {
await this.assertSuperAdmin(actor);
const items = await this.prisma.aiPromptTemplate.findMany({
@@ -163,7 +213,12 @@ export class AiPromptsService {
domainId: domain.id.toString(),
host: domain.host,
businessName: domain.business.name,
items: items.map((row) => this.serializeDomainPrompt(row)),
items: items.map((row) =>
this.serializeDomainPrompt(row, {
host: domain.host,
name: domain.business.name,
}),
),
};
}
@@ -176,7 +231,11 @@ export class AiPromptsService {
const domainId = BigInt(domainIdRaw);
const domain = await this.prisma.domain.findUnique({
where: { id: domainId },
select: { id: true },
select: {
id: true,
host: true,
business: { select: { name: true } },
},
});
if (!domain) throw new NotFoundException('Website not found');
@@ -205,7 +264,10 @@ export class AiPromptsService {
sortOrder: (maxSort._max.sortOrder ?? -1) + 1,
},
});
return this.serializeDomainPrompt(created);
return this.serializeDomainPrompt(created, {
host: domain.host,
name: domain.business.name,
});
}
async updateDomainPrompt(
@@ -230,7 +292,10 @@ export class AiPromptsService {
...(dto.body !== undefined ? { body: dto.body.trim() } : {}),
},
});
return this.serializeDomainPrompt(updated);
return this.serializeDomainPrompt(
updated,
await this.domainLabels(domainId),
);
}
async removeDomainPrompt(
+6
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,8 @@ 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';
@Module({
imports: [
@@ -67,6 +70,7 @@ import { SitemapModule } from './sitemap/sitemap.module';
ProductsModule,
BlogsModule,
PortfoliosModule,
GalleryModule,
VideosModule,
InstructionsModule,
WorkshopsModule,
@@ -92,6 +96,8 @@ import { SitemapModule } from './sitemap/sitemap.module';
AiPromptsModule,
PublicSmsModule,
SitemapModule,
SmsTemplatesModule,
TorobModule,
],
})
export class AppModule {}
+378 -3
View File
@@ -1,10 +1,19 @@
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;
@@ -12,6 +21,13 @@ type DesiredRecord = {
value: Record<string, unknown>;
};
type DnsMutationResult = {
created: string[];
updated: string[];
skipped: string[];
removed: string[];
};
type ArvanDnsRecord = {
id: string;
type: string;
@@ -35,6 +51,119 @@ 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',
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 +192,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 +258,7 @@ export class ArvanDnsService {
return { created, updated, skipped };
}
private desiredRecords(apex: string): DesiredRecord[] {
private desiredTenantRecords(apex: string): DesiredRecord[] {
return [
{
type: 'aname',
@@ -159,14 +288,260 @@ export class ArvanDnsService {
];
}
/** 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;
}
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(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(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 = existing.value ?? {};
if (desired.type === 'aname') {
return this.fqdn(String(current.location ?? '')) === this.fqdn(String(desired.value.location ?? ''));
}
if (desired.type === 'a') {
return String(current.ip ?? '') === String(desired.value.ip ?? '');
}
if (desired.type === 'mx') {
return (
this.fqdn(this.mxHost(current)) === this.fqdn(String(desired.value.host ?? '')) &&
Number(current.priority ?? 0) === Number(desired.value.priority ?? 0)
);
}
if (desired.type === 'txt') {
return this.txtValue(existing).replace(/\s+/g, '') === String(desired.value.text ?? '').replace(/\s+/g, '');
}
return this.fqdn(String(current.host ?? '')) === this.fqdn(String(desired.value.host ?? ''));
}
private txtValue(record: ArvanDnsRecord): string {
const value = 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 {
return value.trim().replace(/\.$/, '').toLowerCase();
}
+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 {}
+18 -11
View File
@@ -28,6 +28,7 @@ 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). */
@@ -44,6 +45,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,
) {}
@@ -403,7 +405,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,24 +422,29 @@ 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 business = await this.tenant.resolveBusinessByDomain(host);
const businessNameFa =
business.nameFa?.trim() || business.name?.trim() || undefined;
const code = this.generateOtpCode();
const brand = businessNameFa?.trim();
const message = brand
? `کد تایید شما: ${code}\n${brand}`
: `کد تایید شما: ${code}`;
const charged = await this.smsBilling.charge(business.id, message, 1, 'auth');
await this.redis.setOtp(cellNumber, code, OTP_TTL_SECONDS);
try {
await this.sms.sendVerificationCode(cellNumber, code, businessNameFa);
await this.sms.sendMessage(cellNumber, message);
} catch (err) {
await this.smsBilling.refund(business.id, charged, 1, 'auth');
if (err instanceof HttpException) {
throw err;
}
+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;
}
+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;
+269 -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). */
@@ -273,5 +532,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;
}
}
+85 -11
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, {
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,
+162 -15
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) {
@@ -159,7 +176,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
@@ -220,6 +245,16 @@ 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,
@@ -241,15 +276,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 +315,8 @@ export class BusinessAdminService {
nameFa: business.nameFa,
slug: business.slug,
label: this.formatBusinessLabel(business),
ownerName: business.ownerName,
ownerCellNumber: business.ownerCellNumber,
})),
};
}
@@ -338,6 +387,8 @@ export class BusinessAdminService {
take: 1,
},
domains: { orderBy: [{ isPrimary: 'desc' }, { createdAt: 'asc' }] },
logoMedia: true,
bannerMedia: true,
},
});
@@ -379,6 +430,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 +498,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 +560,10 @@ export class BusinessAdminService {
: new Date(dto.annualRenewalAt),
}
: {}),
...(nextSettings !== undefined ? { settings: nextSettings } : {}),
...(topUpCredits > 0
? { smsBalance: { increment: topUpCredits } }
: {}),
},
});
@@ -571,7 +667,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,
@@ -619,15 +717,19 @@ export class BusinessAdminService {
let deploySlug = domain.deploySlug;
let provisionError: string | null = null;
let nextGitRepoUrl = domain.gitRepoUrl;
const hostChanged = domain.host !== host;
const effectiveGitRepoUrl = gitRepoUrl || domain.gitRepoUrl?.trim() || null;
// Only provision when wiring a new/changed repo or when deploy slug is missing.
// Re-saving the same git URL must not block the API on clone/nginx/certbot again.
// Only provision when wiring a new/changed repo, deploy slug is missing, or
// the apex host changed (nginx vhost is keyed by host — rename must bind the new name).
// Re-saving the same git URL + same host must not block the API on clone again.
const alreadyWired =
!!gitRepoUrl &&
!!effectiveGitRepoUrl &&
!!domain.deploySlug?.trim() &&
domain.gitRepoUrl?.trim() === gitRepoUrl;
(!gitRepoUrl || domain.gitRepoUrl?.trim() === gitRepoUrl) &&
!hostChanged;
if (gitRepoUrl && !alreadyWired) {
if (effectiveGitRepoUrl && !alreadyWired) {
const slug = domain.deploySlug?.trim() || deploySlugFromHost(host);
if (!slug) {
throw new BadRequestException('Could not derive deploy slug from host');
@@ -645,10 +747,10 @@ export class BusinessAdminService {
await this.websiteDeployAgent.provision({
slug,
host,
gitRepoUrl: normalizeGitRepoUrlForClone(gitRepoUrl),
gitRepoUrl: normalizeGitRepoUrlForClone(effectiveGitRepoUrl),
});
deploySlug = slug;
nextGitRepoUrl = gitRepoUrl;
nextGitRepoUrl = effectiveGitRepoUrl;
} catch (err) {
if (err && typeof err === 'object' && 'message' in err && typeof err.message === 'string') {
provisionError = err.message;
@@ -658,7 +760,7 @@ export class BusinessAdminService {
}
} else if (alreadyWired) {
deploySlug = domain.deploySlug;
nextGitRepoUrl = gitRepoUrl;
nextGitRepoUrl = effectiveGitRepoUrl;
}
const updated = await this.prisma.domain.update({
@@ -671,7 +773,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,
@@ -680,9 +784,16 @@ export class BusinessAdminService {
};
}
private async applyArvanDns(host: string): Promise<string | null> {
private async applyTenantDns(
host: string,
provider: 'arvan' | 'cloudflare',
): Promise<string | null> {
try {
if (provider === 'cloudflare') {
await this.cloudflareDns.ensureTenantRecords(host);
} else {
await this.arvanDns.ensureTenantRecords(host);
}
return null;
} catch (err) {
if (err instanceof HttpException) {
@@ -696,7 +807,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';
}
}
@@ -1125,13 +1236,17 @@ export class BusinessAdminService {
// Users can own multiple businesses. If the cell number already exists,
// refresh name + password from the create form, then attach as owner.
// Admin-created owners are trusted — always mark the cell verified.
if (existing) {
if (!existing.isActive) {
throw new BadRequestException('Owner user is inactive');
}
return tx.user.update({
where: { id: existing.id },
data: nameData,
data: {
...nameData,
cellVerifiedAt: existing.cellVerifiedAt ?? new Date(),
},
});
}
@@ -1198,6 +1313,11 @@ export class BusinessAdminService {
data: { userId: ownerUserId, roleId: businessOwnerRole.id },
});
}
await tx.user.update({
where: { id: ownerUserId },
data: { cellVerifiedAt: new Date() },
});
}
private serializeBusiness(
@@ -1212,6 +1332,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;
@@ -1238,9 +1366,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,
@@ -1254,6 +1387,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,16 +1,32 @@
import { Controller, Get, UseGuards } from '@nestjs/common';
import {
Controller,
Get,
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';
class ListBusinessCategoriesDto {
@IsOptional()
@IsString()
@MaxLength(100)
q?: string;
}
@Controller('business-categories')
export class BusinessCategoriesController {
constructor(private readonly service: BusinessCategoriesService) {}
@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);
}
}
@@ -1,4 +1,5 @@
import { Injectable, ForbiddenException } 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';
@@ -10,7 +11,7 @@ export class BusinessCategoriesService {
private readonly permissions: PermissionsService,
) {}
async list(actor: AuthUser) {
async list(actor: AuthUser, q?: string) {
const isSuperAdmin = await this.permissions.isSuperAdmin(actor.id);
const canRead =
isSuperAdmin ||
@@ -21,6 +22,54 @@ export class BusinessCategoriesService {
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({
where: { isActive: true },
orderBy: [{ sortOrder: 'asc' }, { name: 'asc' }],
@@ -28,10 +77,12 @@ export class BusinessCategoriesService {
id: true,
parentId: true,
name: true,
nameFa: true,
slug: true,
description: true,
icon: true,
sortOrder: true,
tags: true,
},
});
+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()
@@ -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()
+60 -8
View File
@@ -19,6 +19,11 @@ import { StorageService } from '../storage/storage.service';
import { PrismaService } from '../prisma/prisma.service';
import { MigrateFromOldEntity } from './dto/migrate-from-old.dto';
// Legacy product images are large; Sharp's default cache plus PM2's 512MB cap
// was restarting the API mid-migrate (nginx 502, UI "Unable to migrate").
sharp.cache(false);
sharp.concurrency(1);
export type MigrateEntityCounts = {
created: number;
skipped: number;
@@ -2482,6 +2487,7 @@ export class LegacyMigrateService {
continue;
}
try {
const titleEn =
(row.name_en ?? '').trim() || (row.name ?? '').trim() || 'Product';
const nameFa = (row.name ?? '').trim() || null;
@@ -2620,6 +2626,21 @@ export class LegacyMigrateService {
}
created += 1;
} catch (err) {
console.error(
`Legacy migrate: product old id ${row.id} failed`,
err instanceof Error ? err.message : err,
);
skipped += 1;
}
if ((index + 1) % 25 === 0) {
const rssMb = Math.round(process.memoryUsage().rss / 1024 / 1024);
console.log(
`Legacy migrate: products ${index + 1}/${products.length} created=${created} skipped=${skipped} rss=${rssMb}MB`,
);
await new Promise((resolve) => setImmediate(resolve));
}
}
return {
@@ -2869,13 +2890,20 @@ export class LegacyMigrateService {
}
// Load already-migrated variations to stay idempotent.
// Meshkee allows at most one color and one size variation per category.
const existingVariations = await this.prisma.categoryVariation.findMany({
where: { businessId },
select: { id: true, categoryId: true, name: true },
select: { id: true, categoryId: true, name: true, variationType: true },
});
const existingVarSet = new Set(
existingVariations.map((v) => `${v.categoryId}:${v.name}`),
);
const variationIdByType = new Map<string, bigint>();
for (const v of existingVariations) {
if (v.variationType === 'color' || v.variationType === 'size') {
variationIdByType.set(`${v.categoryId}:${v.variationType}`, v.id);
}
}
for (const attr of attributes) {
const newCategoryId = oldToNew.get(attr.attributable_id);
@@ -2883,12 +2911,20 @@ export class LegacyMigrateService {
const variationName = (attr.name ?? '').trim() || 'Variation';
const varKey = `${newCategoryId}:${variationName}`;
if (existingVarSet.has(varKey)) continue;
const variationType = this.legacyAttributeTypeToVariationType(
attr.attribute_type,
);
const typeKey =
variationType === 'color' || variationType === 'size'
? `${newCategoryId}:${variationType}`
: null;
let variationId = typeKey ? variationIdByType.get(typeKey) : undefined;
if (!variationId && existingVarSet.has(varKey)) continue;
if (!variationId) {
try {
const variation = await this.prisma.categoryVariation.create({
data: {
businessId,
@@ -2899,9 +2935,27 @@ export class LegacyMigrateService {
},
select: { id: true },
});
variationId = variation.id;
existingVarSet.add(varKey);
if (typeKey) variationIdByType.set(typeKey, variationId);
} catch {
// Unique: one color/size per category, or custom name already exists.
if (typeKey) {
const existing = await this.prisma.categoryVariation.findFirst({
where: { categoryId: newCategoryId, variationType },
select: { id: true },
});
variationId = existing?.id;
if (variationId) variationIdByType.set(typeKey, variationId);
}
if (!variationId) continue;
}
}
const options = valuesByAttr.get(attr.id) ?? [];
const existingOptionCount = await this.prisma.categoryVariationOption.count(
{ where: { variationId } },
);
for (const [idx, opt] of options.entries()) {
const label = (opt.name ?? '').trim() || 'Option';
const value =
@@ -2912,14 +2966,12 @@ export class LegacyMigrateService {
try {
await this.prisma.categoryVariationOption.create({
data: {
variationId: variation.id,
variationId,
label,
value,
colorHex:
variationType === 'color'
? this.guessColorHex(label)
: null,
sortOrder: idx,
variationType === 'color' ? this.guessColorHex(label) : null,
sortOrder: existingOptionCount + idx,
},
});
} catch {
@@ -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,6 +490,7 @@ 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(),
@@ -523,6 +549,28 @@ export class BusinessProfileService {
}
}
/** Recommended banner aspect 18:4.5 (4: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 / 4.5; // 4
if (Math.abs(ratio - target) / target > 0.12) {
throw new BadRequestException(
'Banner aspect ratio should be about 18:4.5 (4: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:4.5 (4: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;
@@ -81,6 +91,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 +136,12 @@ export class UpdateBusinessProfileDto {
@IsInt()
faviconMediaId?: number | null;
/** Marketplace/storefront banner; recommended aspect 18:4.5 (4:1). */
@IsOptional()
@Type(() => Number)
@IsInt()
bannerMediaId?: number | null;
@IsOptional()
@IsArray()
@Type(() => Number)
@@ -17,6 +17,7 @@ import {
sanitizeBusinessSettingsForClient,
toPrismaJson,
} from './business-settings.util';
import { normalizeWebsiteSchemaSettings } from './website-schema.util';
import { UpdateBusinessSettingsDto } from './dto/update-business-settings.dto';
import { normalizeBusinessPrimaryColorId } from './business-primary-colors';
@@ -162,6 +163,7 @@ 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 },
@@ -189,6 +191,17 @@ 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,
};
}
@@ -95,6 +95,8 @@ export type EPaymentSettings = {
/** 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;
};
@@ -109,6 +111,43 @@ 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;
};
/** 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 =
@@ -156,6 +195,7 @@ export const BUSINESS_DASHBOARD_MODULE_IDS = [
'instructions',
'workshops',
'finance',
'multilanguage_data',
] as const;
/** Optional customer-dashboard modules. Always-on: home, profile, addresses, orders, favorites. */
@@ -192,6 +232,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 +245,7 @@ export type BusinessSettings = {
store: StoreSettings;
modules: ModulesSettings;
website: WebsiteSettings;
sms: SmsSettings;
};
export const DEFAULT_ORDER_PROCESS_STEPS: OrderProcessStep[] = [
@@ -252,7 +299,9 @@ 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',
);
export const DEFAULT_HOME_CHARTS: [HomeChartId, HomeChartId] = [
'orders_30d',
@@ -300,6 +349,7 @@ export const DEFAULT_BUSINESS_SETTINGS: BusinessSettings = {
},
store: {
onlineSellEnabled: true,
torobEnabled: false,
orderProcessSteps: DEFAULT_ORDER_PROCESS_STEPS,
ePayment: { ...DEFAULT_EPAYMENT_SETTINGS },
},
@@ -309,5 +359,9 @@ export const DEFAULT_BUSINESS_SETTINGS: BusinessSettings = {
},
website: {
specialProductsSource: 'store_item',
schema: { ...DEFAULT_WEBSITE_SCHEMA_SETTINGS },
},
sms: {
senderNumber: null,
},
};
@@ -33,11 +33,13 @@ import {
StubGatewaySettings,
WebsiteSitemapConfig,
ZarinpalGatewaySettings,
DEFAULT_WEBSITE_SCHEMA_SETTINGS,
} from './business-settings.types';
import {
defaultOrderStepColor,
normalizeOrderStepColor,
} from './order-step-colors';
import { normalizeWebsiteSchemaSettings } from './website-schema.util';
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
@@ -121,6 +123,16 @@ 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' &&
@@ -380,6 +392,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,6 +425,10 @@ 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),
},
@@ -426,6 +443,12 @@ export function normalizeBusinessSettings(raw: unknown): BusinessSettings {
enabledModules,
),
sitemapConfig: normalizeWebsiteSitemapConfig(website.sitemapConfig),
schema: normalizeWebsiteSchemaSettings(
isRecord(website) ? website.schema : DEFAULT_WEBSITE_SCHEMA_SETTINGS,
),
},
sms: {
senderNumber: normalizeSmsSenderNumber(sms.senderNumber),
},
};
}
@@ -457,6 +480,7 @@ 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(
@@ -476,6 +500,16 @@ export function mergeBusinessSettings(
patch.website?.sitemapConfig !== undefined
? patch.website.sitemapConfig
: current.website.sitemapConfig,
schema:
patch.website?.schema !== undefined
? patch.website.schema
: current.website.schema,
},
sms: {
senderNumber:
patch.sms?.senderNumber !== undefined
? patch.sms.senderNumber
: current.sms.senderNumber,
},
};
}
@@ -18,6 +18,7 @@ import {
HOME_CHART_IDS,
PAYMENT_GATEWAY_IDS,
SPECIAL_PRODUCTS_SOURCE_IDS,
WEBSITE_SCHEMA_TYPE_IDS,
} from '../business-settings.types';
class BrandingSettingsDto {
@@ -167,6 +168,10 @@ class StoreSettingsDto {
@IsBoolean()
onlineSellEnabled?: boolean;
@IsOptional()
@IsBoolean()
torobEnabled?: boolean;
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@@ -195,11 +200,49 @@ 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 WebsiteSettingsDto {
@IsOptional()
@IsString()
@IsIn([...SPECIAL_PRODUCTS_SOURCE_IDS])
specialProductsSource?: (typeof SPECIAL_PRODUCTS_SOURCE_IDS)[number];
@IsOptional()
@ValidateNested()
@Type(() => WebsiteSchemaSettingsDto)
schema?: WebsiteSchemaSettingsDto;
}
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;
}
@@ -137,6 +137,13 @@ export class BusinessTeamService {
},
}));
if (existingUser && !existingUser.cellVerifiedAt) {
await tx.user.update({
where: { id: user.id },
data: { cellVerifiedAt: new Date() },
});
}
const businessUser = await tx.businessUser.create({
data: {
businessId,
+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,
};
+198 -38
View File
@@ -4,28 +4,70 @@ import {
Injectable,
NotFoundException,
} from '@nestjs/common';
import { MediaEntityType, VariationType } from '@prisma/client';
import { MediaEntityType, Prisma, VariationType } from '@prisma/client';
import { AuthUser } from '../auth/auth.types';
import { PermissionsService } from '../auth/permissions.service';
import { PrismaService } from '../prisma/prisma.service';
import {
COLOR_PRESETS,
getColorHex,
isColorPreset,
} from './color-presets';
import { COLOR_PRESETS, isHexColor, resolveColorHex } from './color-presets';
import {
CategoryVariationInputDto,
ReplaceCategoryVariationsDto,
} from './dto/category-variation.dto';
function slugifyValue(value: string): string {
return value
const ascii = value
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
if (ascii) {
return ascii;
}
// Keep letters/numbers from any script (e.g. Farsi labels) when ASCII strip is empty
const unicode = value
.trim()
.toLowerCase()
.normalize('NFKC')
.replace(/\s+/g, '-')
.replace(/[^\p{L}\p{N}-]+/gu, '')
.replace(/-+/g, '-')
.replace(/^-+|-+$/g, '');
return unicode;
}
function uniqueSlug(base: string, used: Set<string>, fallbackIndex: number): string {
let value = base || `option-${fallbackIndex}`;
if (!used.has(value)) {
used.add(value);
return value;
}
let suffix = 2;
while (used.has(`${value}-${suffix}`)) {
suffix += 1;
}
const next = `${value}-${suffix}`;
used.add(next);
return next;
}
type ExistingVariation = {
id: bigint;
name: string;
variationType: VariationType;
sortOrder: number;
options: {
id: bigint;
label: string;
value: string;
colorHex: string | null;
sortOrder: number;
}[];
};
@Injectable()
export class CategoryVariationsService {
constructor(
@@ -70,32 +112,57 @@ export class CategoryVariationsService {
this.validateVariationInputs(dto.variations);
await this.prisma.$transaction(async (tx) => {
await tx.categoryVariation.deleteMany({
const existing = await tx.categoryVariation.findMany({
where: { businessId, categoryId },
include: { options: { orderBy: { sortOrder: 'asc' } } },
orderBy: { sortOrder: 'asc' },
});
const matchedExistingIds = new Set<bigint>();
for (const [index, variation] of dto.variations.entries()) {
const created = await tx.categoryVariation.create({
const current = this.matchExistingVariation(existing, variation, matchedExistingIds);
const name = this.resolveVariationName(variation);
const uniqueValues = [
...new Set(variation.values.map((v) => v.trim()).filter(Boolean)),
];
if (current) {
matchedExistingIds.add(current.id);
await tx.categoryVariation.update({
where: { id: current.id },
data: {
businessId,
categoryId,
name: this.resolveVariationName(variation),
name,
variationType: variation.type as VariationType,
sortOrder: index,
},
});
await this.syncOptions(tx, current, variation.type, uniqueValues);
} else {
const created = await tx.categoryVariation.create({
data: {
businessId,
categoryId,
name,
variationType: variation.type as VariationType,
sortOrder: index,
},
});
await this.createOptions(tx, created.id, variation.type, uniqueValues);
}
}
const uniqueValues = [...new Set(variation.values.map((v) => v.trim()).filter(Boolean))];
await tx.categoryVariationOption.createMany({
data: uniqueValues.map((label, optionIndex) => ({
variationId: created.id,
label,
value: slugifyValue(label) || `option-${optionIndex + 1}`,
colorHex:
variation.type === 'color' ? getColorHex(label) : null,
sortOrder: optionIndex,
})),
const removed = existing.filter((item) => !matchedExistingIds.has(item.id));
if (removed.length) {
const removedIds = removed.map((item) => item.id);
await tx.storeItemVariantSelection.deleteMany({
where: { variationId: { in: removedIds } },
});
await tx.productVariationValue.deleteMany({
where: { variationId: { in: removedIds } },
});
await tx.categoryVariation.deleteMany({
where: { id: { in: removedIds } },
});
}
});
@@ -113,6 +180,109 @@ export class CategoryVariationsService {
return variations.map((item) => this.serialize(item));
}
private matchExistingVariation(
existing: ExistingVariation[],
incoming: CategoryVariationInputDto,
alreadyMatched: Set<bigint>,
): ExistingVariation | undefined {
const candidates = existing.filter((item) => !alreadyMatched.has(item.id));
if (incoming.type === 'color' || incoming.type === 'size') {
return candidates.find((item) => item.variationType === incoming.type);
}
const name = incoming.name.trim().toLowerCase();
return candidates.find(
(item) =>
item.variationType === 'custom' && item.name.trim().toLowerCase() === name,
);
}
private async syncOptions(
tx: Prisma.TransactionClient,
current: ExistingVariation,
type: CategoryVariationInputDto['type'],
labels: string[],
) {
const usedSlugs = new Set<string>();
const matchedOptionIds = new Set<bigint>();
for (const [optionIndex, label] of labels.entries()) {
const existingOption = current.options.find(
(option) =>
!matchedOptionIds.has(option.id) && option.label.trim() === label,
);
const colorHex =
type === 'color'
? resolveColorHex(label) ??
existingOption?.colorHex ??
(existingOption && isHexColor(existingOption.value)
? existingOption.value.trim()
: null)
: null;
if (existingOption) {
matchedOptionIds.add(existingOption.id);
usedSlugs.add(existingOption.value);
await tx.categoryVariationOption.update({
where: { id: existingOption.id },
data: {
label,
colorHex,
sortOrder: optionIndex,
},
});
continue;
}
const value = uniqueSlug(slugifyValue(label), usedSlugs, optionIndex + 1);
await tx.categoryVariationOption.create({
data: {
variationId: current.id,
label,
value,
colorHex,
sortOrder: optionIndex,
},
});
}
const removedOptions = current.options.filter(
(option) => !matchedOptionIds.has(option.id),
);
if (removedOptions.length) {
const removedOptionIds = removedOptions.map((option) => option.id);
await tx.storeItemVariantSelection.deleteMany({
where: { optionId: { in: removedOptionIds } },
});
await tx.productVariationValue.deleteMany({
where: { optionId: { in: removedOptionIds } },
});
await tx.categoryVariationOption.deleteMany({
where: { id: { in: removedOptionIds } },
});
}
}
private async createOptions(
tx: Prisma.TransactionClient,
variationId: bigint,
type: CategoryVariationInputDto['type'],
labels: string[],
) {
const usedSlugs = new Set<string>();
await tx.categoryVariationOption.createMany({
data: labels.map((label, optionIndex) => ({
variationId,
label,
value: uniqueSlug(slugifyValue(label), usedSlugs, optionIndex + 1),
colorHex: type === 'color' ? resolveColorHex(label) : null,
sortOrder: optionIndex,
})),
});
}
private validateVariationInputs(variations: CategoryVariationInputDto[]) {
const colorCount = variations.filter((v) => v.type === 'color').length;
const sizeCount = variations.filter((v) => v.type === 'size').length;
@@ -128,22 +298,10 @@ export class CategoryVariationsService {
const customNames = new Set<string>();
for (const variation of variations) {
if (variation.type === 'color') {
for (const value of variation.values) {
if (!isColorPreset(value.trim())) {
throw new BadRequestException(
`Invalid color "${value}". Choose from the predefined color palette.`,
);
}
}
}
if (variation.type === 'size' || variation.type === 'custom') {
const values = variation.values.map((v) => v.trim()).filter(Boolean);
if (!values.length) {
throw new BadRequestException('Each variation must have at least one value');
}
}
if (variation.type === 'custom') {
const name = variation.name.trim().toLowerCase();
@@ -162,10 +320,10 @@ export class CategoryVariationsService {
private resolveVariationName(variation: CategoryVariationInputDto): string {
if (variation.type === 'color') {
return 'Color';
return variation.name.trim() || 'Color';
}
if (variation.type === 'size') {
return 'Size';
return variation.name.trim() || 'Size';
}
return variation.name.trim();
}
@@ -226,7 +384,9 @@ export class CategoryVariationsService {
id: option.id.toString(),
label: option.label,
value: option.value,
colorHex: option.colorHex,
colorHex:
option.colorHex ??
(isHexColor(option.value) ? option.value.trim() : null),
sortOrder: option.sortOrder,
})),
};
+138 -11
View File
@@ -1,21 +1,124 @@
export const COLOR_PRESETS = [
{ name: 'Red', hex: '#EF4444' },
{ name: 'Blue', hex: '#3B82F6' },
{ name: 'Dark Red', hex: '#B91C1C' },
{ name: 'Crimson', hex: '#DC143C' },
{ name: 'Maroon', hex: '#7F1D1D' },
{ name: 'Rose', hex: '#F43F5E' },
{ name: 'Pink', hex: '#EC4899' },
{ name: 'Hot Pink', hex: '#DB2777' },
{ name: 'Magenta', hex: '#D946EF' },
{ name: 'Coral', hex: '#FB7185' },
{ name: 'Salmon', hex: '#FDA4AF' },
{ name: 'Orange', hex: '#F97316' },
{ name: 'Dark Orange', hex: '#C2410C' },
{ name: 'Amber', hex: '#F59E0B' },
{ name: 'Yellow', hex: '#EAB308' },
{ name: 'Mustard', hex: '#CA8A04' },
{ name: 'Gold', hex: '#D4AF37' },
{ name: 'Bronze', hex: '#B45309' },
{ name: 'Copper', hex: '#B87333' },
{ name: 'Brown', hex: '#92400E' },
{ name: 'Dark Brown', hex: '#5C3A21' },
{ name: 'Tan', hex: '#D2B48C' },
{ name: 'Beige', hex: '#D4C4A8' },
{ name: 'Cream', hex: '#FFF7ED' },
{ name: 'Ivory', hex: '#FFFFF0' },
{ name: 'Khaki', hex: '#C3B091' },
{ name: 'Olive', hex: '#808000' },
{ name: 'Lime', hex: '#84CC16' },
{ name: 'Green', hex: '#22C55E' },
{ name: 'Dark Green', hex: '#166534' },
{ name: 'Forest', hex: '#14532D' },
{ name: 'Emerald', hex: '#059669' },
{ name: 'Mint', hex: '#6EE7B7' },
{ name: 'Teal', hex: '#14B8A6' },
{ name: 'Turquoise', hex: '#2DD4BF' },
{ name: 'Cyan', hex: '#06B6D4' },
{ name: 'Sky Blue', hex: '#38BDF8' },
{ name: 'Light Blue', hex: '#93C5FD' },
{ name: 'Blue', hex: '#3B82F6' },
{ name: 'Royal Blue', hex: '#1D4ED8' },
{ name: 'Navy', hex: '#1E3A5F' },
{ name: 'Indigo', hex: '#4F46E5' },
{ name: 'Purple', hex: '#A855F7' },
{ name: 'Violet', hex: '#7C3AED' },
{ name: 'Lavender', hex: '#C4B5FD' },
{ name: 'Lilac', hex: '#D8B4FE' },
{ name: 'Burgundy', hex: '#9F1239' },
{ name: 'Wine', hex: '#881337' },
{ name: 'Gray', hex: '#6B7280' },
{ name: 'Light Gray', hex: '#D1D5DB' },
{ name: 'Dark Gray', hex: '#374151' },
{ name: 'Charcoal', hex: '#1F2937' },
{ name: 'Silver', hex: '#C0C0C0' },
{ name: 'Black', hex: '#111827' },
{ name: 'White', hex: '#FFFFFF' },
{ name: 'Silver', hex: '#C0C0C0' },
{ name: 'Gold', hex: '#D4AF37' },
{ name: 'Navy', hex: '#1E3A5F' },
{ name: 'Yellow', hex: '#EAB308' },
{ name: 'Orange', hex: '#F97316' },
{ name: 'Purple', hex: '#A855F7' },
{ name: 'Pink', hex: '#EC4899' },
{ name: 'Brown', hex: '#92400E' },
{ name: 'Gray', hex: '#6B7280' },
{ name: 'Beige', hex: '#D4C4A8' },
] as const;
type ColorPresetName = (typeof COLOR_PRESETS)[number]['name'];
/** Common Farsi labels from legacy migrations → English preset name */
const COLOR_ALIASES: Record<string, ColorPresetName> = {
قرمز: 'Red',
'قرمز تیره': 'Dark Red',
آلبالویی: 'Crimson',
عنابی: 'Maroon',
صورتی: 'Pink',
'صورتی تند': 'Hot Pink',
سرخابی: 'Magenta',
مرجانی: 'Coral',
سالمون: 'Salmon',
نارنجی: 'Orange',
کهربایی: 'Amber',
زرد: 'Yellow',
خردلی: 'Mustard',
طلایی: 'Gold',
طلائی: 'Gold',
برنزی: 'Bronze',
مسی: 'Copper',
قهوهای: 'Brown',
'قهوه ای': 'Brown',
قهوه‌ای: 'Brown',
'قهوه‌ای تیره': 'Dark Brown',
خاکی: 'Tan',
بژ: 'Beige',
کرم: 'Cream',
عاجی: 'Ivory',
زیتونی: 'Olive',
لیمویی: 'Lime',
سبز: 'Green',
'سبز تیره': 'Dark Green',
زمردی: 'Emerald',
نعنایی: 'Mint',
سبزآبی: 'Teal',
فیروزهای: 'Turquoise',
'فیروزه ای': 'Turquoise',
فیروزه‌ای: 'Turquoise',
آبی: 'Blue',
آسمانی: 'Sky Blue',
'آبی روشن': 'Light Blue',
'آبی سلطنتی': 'Royal Blue',
سرمهای: 'Navy',
'سرمه ای': 'Navy',
سرمه‌ای: 'Navy',
نیلی: 'Indigo',
بنفش: 'Purple',
یاسی: 'Lilac',
اسطوخودوسی: 'Lavender',
بادمجانی: 'Burgundy',
خاکستری: 'Gray',
طوسی: 'Gray',
'خاکستری روشن': 'Light Gray',
'خاکستری تیره': 'Dark Gray',
ذغالی: 'Charcoal',
نقرهای: 'Silver',
'نقره ای': 'Silver',
نقره‌ای: 'Silver',
مشکی: 'Black',
سیاه: 'Black',
سفید: 'White',
};
export const COLOR_PRESET_NAMES = COLOR_PRESETS.map((color) => color.name);
export function isColorPreset(value: string): boolean {
@@ -25,3 +128,27 @@ export function isColorPreset(value: string): boolean {
export function getColorHex(name: string): string | null {
return COLOR_PRESETS.find((color) => color.name === name)?.hex ?? null;
}
function normalizeColorKey(value: string): string {
return value
.trim()
.toLowerCase()
.replace(/\u200c/g, '') // ZWNJ
.replace(/\s+/g, ' ');
}
/** Resolve hex from English preset name or known Farsi alias. */
export function resolveColorHex(label: string): string | null {
const trimmed = label.trim();
if (!trimmed) return null;
const fromPreset = getColorHex(trimmed);
if (fromPreset) return fromPreset;
const alias = COLOR_ALIASES[trimmed] ?? COLOR_ALIASES[normalizeColorKey(trimmed)];
return alias ? getColorHex(alias) : null;
}
export function isHexColor(value: string): boolean {
return /^#[0-9a-fA-F]{6}$/.test(value.trim());
}
+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,
};
}
}
+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');
}
+23
View File
@@ -0,0 +1,23 @@
import { IsOptional, IsString, MaxLength } from 'class-validator';
import {
SEO_META_DESCRIPTION_MAX,
SEO_META_TITLE_MAX,
} from '../common/seo-meta.util';
/** Optional mixin fields — copy onto Create/Update DTOs (Nest does not support multi-inheritance well). */
export const SEO_META_DTO_DECORATORS = {
titleMax: SEO_META_TITLE_MAX,
descriptionMax: SEO_META_DESCRIPTION_MAX,
} as const;
export class SeoMetaDto {
@IsOptional()
@IsString()
@MaxLength(SEO_META_TITLE_MAX)
seoMetaTitle?: string | null;
@IsOptional()
@IsString()
@MaxLength(SEO_META_DESCRIPTION_MAX)
seoMetaDescription?: string | null;
}
+67
View File
@@ -0,0 +1,67 @@
/**
* Per-page SEO overrides stored in entity `metadata` JSON.
* Empty / null → websites fall back to title / summary / about fields.
*/
export const SEO_META_TITLE_MAX = 70;
export const SEO_META_DESCRIPTION_MAX = 160;
export type SeoMetaFields = {
seoMetaTitle: string | null;
seoMetaDescription: string | null;
};
export function emptySeoMeta(): SeoMetaFields {
return { seoMetaTitle: null, seoMetaDescription: null };
}
export function readSeoMetaFromMetadata(metadata: Record<string, unknown>): SeoMetaFields {
const title =
typeof metadata.seoMetaTitle === 'string' ? metadata.seoMetaTitle.trim() : '';
const description =
typeof metadata.seoMetaDescription === 'string'
? metadata.seoMetaDescription.trim()
: '';
return {
seoMetaTitle: title ? title.slice(0, SEO_META_TITLE_MAX) : null,
seoMetaDescription: description
? description.slice(0, SEO_META_DESCRIPTION_MAX)
: null,
};
}
/** Normalize optional DTO strings (undefined = leave unchanged when merging). */
export function normalizeSeoMetaInput(
seoMetaTitle?: string | null,
seoMetaDescription?: string | null,
): Partial<SeoMetaFields> {
const out: Partial<SeoMetaFields> = {};
if (seoMetaTitle !== undefined) {
const trimmed = seoMetaTitle?.trim() ?? '';
out.seoMetaTitle = trimmed ? trimmed.slice(0, SEO_META_TITLE_MAX) : null;
}
if (seoMetaDescription !== undefined) {
const trimmed = seoMetaDescription?.trim() ?? '';
out.seoMetaDescription = trimmed
? trimmed.slice(0, SEO_META_DESCRIPTION_MAX)
: null;
}
return out;
}
/** Merge SEO keys into a metadata object (preserves tags and other keys). */
export function applySeoMetaToMetadata(
metadata: Record<string, unknown>,
seo: Partial<SeoMetaFields>,
): Record<string, unknown> {
const next = { ...metadata };
if (seo.seoMetaTitle !== undefined) {
if (seo.seoMetaTitle) next.seoMetaTitle = seo.seoMetaTitle;
else delete next.seoMetaTitle;
}
if (seo.seoMetaDescription !== undefined) {
if (seo.seoMetaDescription) next.seoMetaDescription = seo.seoMetaDescription;
else delete next.seoMetaDescription;
}
return next;
}
+19 -1
View File
@@ -1,6 +1,8 @@
import * as tls from 'tls';
const DEFAULT_TIMEOUT_MS = 4000;
const DASHBOARD_BUSINESS_HOST = 'business.meshkee.com';
const DASHBOARD_CUSTOMER_HOST = 'customer.meshkee.com';
export type TlsProbeResult = {
ok: boolean;
@@ -8,6 +10,22 @@ export type TlsProbeResult = {
expiresAt: Date | null;
};
/**
* Tenant dashboard names CNAME to Meshkee dashboards. The API VM resolver often
* NXDOMAINs those CNAMEs even when public DNS (and browsers) are fine — so TCP
* to the known dashboard host and use the tenant name only as SNI/identity.
*/
function tcpHostForTlsProbe(sniHost: string): string {
const host = sniHost.trim().toLowerCase();
if (host.startsWith('business.') && host !== DASHBOARD_BUSINESS_HOST) {
return DASHBOARD_BUSINESS_HOST;
}
if (host.startsWith('customer.') && host !== DASHBOARD_CUSTOMER_HOST) {
return DASHBOARD_CUSTOMER_HOST;
}
return host;
}
function peerMatchesHost(host: string, socket: tls.TLSSocket): boolean {
const cert = socket.getPeerCertificate(true);
if (!cert || Object.keys(cert).length === 0) {
@@ -64,7 +82,7 @@ export function probeTlsHostDetailed(
try {
socket = tls.connect(
{
host,
host: tcpHostForTlsProbe(host),
port: 443,
servername: host,
rejectUnauthorized: true,
+340
View File
@@ -0,0 +1,340 @@
import { BadRequestException } from '@nestjs/common';
import ExcelJS from 'exceljs';
export const CUSTOMERS_SHEET_NAME = 'Customers';
export const CUSTOMER_EXCEL_HEADERS = {
phone: 'phone',
firstName: 'first_name',
lastName: 'last_name',
} as const;
type HeaderKey = keyof typeof CUSTOMER_EXCEL_HEADERS;
const HEADER_ALIASES: Record<string, HeaderKey> = {
phone: 'phone',
cell: 'phone',
cell_number: 'phone',
cellnumber: 'phone',
mobile: 'phone',
'mobile number': 'phone',
'phone number': 'phone',
شماره: 'phone',
'شماره موبایل': 'phone',
موبایل: 'phone',
first_name: 'firstName',
firstname: 'firstName',
'first name': 'firstName',
name: 'firstName',
نام: 'firstName',
last_name: 'lastName',
lastname: 'lastName',
'last name': 'lastName',
'نام خانوادگی': 'lastName',
};
const PERSIAN_DIGITS = '۰۱۲۳۴۵۶۷۸۹';
const ARABIC_DIGITS = '٠١٢٣٤٥٦٧٨٩';
const E164_RE = /^\+[1-9]\d{6,14}$/;
export interface CustomerExcelRow {
phone: string;
firstName: string;
lastName: string;
}
export interface ParsedCustomerExcelRow {
rowNumber: number;
phone: string;
firstName: string | null;
lastName: string | null;
}
export async function buildCustomersExcel(rows: CustomerExcelRow[]): Promise<Buffer> {
const workbook = new ExcelJS.Workbook();
workbook.creator = 'Meshkee';
workbook.created = new Date();
const sheet = workbook.addWorksheet(CUSTOMERS_SHEET_NAME);
sheet.columns = [
{ header: CUSTOMER_EXCEL_HEADERS.phone, key: 'phone', width: 16 },
{ header: CUSTOMER_EXCEL_HEADERS.firstName, key: 'firstName', width: 18 },
{ header: CUSTOMER_EXCEL_HEADERS.lastName, key: 'lastName', width: 20 },
{ width: 3 },
{ header: 'دستورالعمل', width: 48 },
{ header: 'Instructions', width: 48 },
];
const header = sheet.getRow(1);
header.height = 22;
header.font = { bold: true, size: 11 };
header.alignment = { vertical: 'middle' };
for (let col = 1; col <= 3; col += 1) {
const cell = header.getCell(col);
cell.fill = {
type: 'pattern',
pattern: 'solid',
fgColor: { argb: 'FFE8EEF7' },
};
cell.font = { bold: true, size: 11 };
cell.alignment = { vertical: 'middle' };
}
for (let col = 5; col <= 6; col += 1) {
const cell = header.getCell(col);
cell.font = { bold: true, size: 11 };
cell.alignment = { vertical: 'middle' };
}
for (const row of rows) {
const added = sheet.addRow({
phone: formatPhoneForExcel(row.phone),
firstName: row.firstName,
lastName: row.lastName,
});
added.height = 20;
added.font = { size: 11 };
const phoneCell = added.getCell('phone');
phoneCell.numFmt = '@';
phoneCell.alignment = { vertical: 'middle', readingOrder: 'ltr', horizontal: 'left' };
const firstNameCell = added.getCell('firstName');
firstNameCell.alignment = {
vertical: 'middle',
readingOrder: looksPersian(row.firstName) ? 'rtl' : 'ltr',
};
const lastNameCell = added.getCell('lastName');
lastNameCell.alignment = {
vertical: 'middle',
readingOrder: looksPersian(row.lastName) ? 'rtl' : 'ltr',
};
}
writeInstructionColumns(sheet);
sheet.views = [{ state: 'frozen', ySplit: 1, rightToLeft: false }];
const buffer = await workbook.xlsx.writeBuffer();
return Buffer.from(buffer);
}
export async function parseCustomersExcel(buffer: Buffer): Promise<ParsedCustomerExcelRow[]> {
const workbook = new ExcelJS.Workbook();
await workbook.xlsx.load(buffer as unknown as Parameters<ExcelJS.Xlsx['load']>[0]);
const sheet =
workbook.getWorksheet(CUSTOMERS_SHEET_NAME) ?? workbook.worksheets[0];
if (!sheet) {
throw new BadRequestException('Excel file has no worksheet.');
}
const headerMap = readHeaderMap(sheet);
const phoneCol = headerMap.phone;
if (!phoneCol) {
throw new BadRequestException(
'Excel is missing required column: phone (or cell / mobile / شماره).',
);
}
const firstNameCol = headerMap.firstName;
const lastNameCol = headerMap.lastName;
const rows: ParsedCustomerExcelRow[] = [];
const seenPhones = new Set<string>();
sheet.eachRow((row, rowNumber) => {
if (rowNumber === 1) return;
const rawPhone = cellText(row.getCell(phoneCol).value);
const firstName = firstNameCol
? optionalName(cellText(row.getCell(firstNameCol).value))
: null;
const lastName = lastNameCol
? optionalName(cellText(row.getCell(lastNameCol).value))
: null;
if (!rawPhone && !firstName && !lastName) return;
if (!rawPhone) {
throw new BadRequestException(`Row ${rowNumber}: phone is required.`);
}
const phone = normalizeCellToE164(rawPhone);
if (!phone) {
throw new BadRequestException(
`Row ${rowNumber}: phone "${rawPhone}" is not a valid mobile number.`,
);
}
if (seenPhones.has(phone)) {
throw new BadRequestException(
`Row ${rowNumber}: duplicate phone "${phone}".`,
);
}
seenPhones.add(phone);
rows.push({ rowNumber, phone, firstName, lastName });
});
if (rows.length === 0) {
throw new BadRequestException('Excel has no customer rows to import.');
}
if (rows.length > 10_000) {
throw new BadRequestException('Excel has too many rows (max 10,000).');
}
return rows;
}
export function customersExcelFilename(now = new Date()) {
const parts = new Intl.DateTimeFormat('en-GB', {
timeZone: 'Asia/Tehran',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23',
}).formatToParts(now);
const pick = (type: Intl.DateTimeFormatPartTypes) =>
parts.find((part) => part.type === type)?.value ?? '';
const date = `${pick('year')}-${pick('month')}-${pick('day')}`;
const time = `${pick('hour')}-${pick('minute')}`;
return `customers - ${date} - ${time}.xlsx`;
}
/** Normalize Iranian/local input to E.164 (e.g. +989121111111). */
export function normalizeCellToE164(input: string): string | null {
const trimmed = input.trim();
if (!trimmed) return null;
if (E164_RE.test(trimmed)) return trimmed;
const digits = toLatinDigits(trimmed).replace(/\D/g, '');
if (!digits) return null;
if (digits.startsWith('98') && digits.length >= 12) {
const value = `+${digits}`;
return E164_RE.test(value) ? value : null;
}
if (digits.startsWith('0') && digits.length >= 11) {
const value = `+98${digits.slice(1)}`;
return E164_RE.test(value) ? value : null;
}
if (digits.length === 10 && digits.startsWith('9')) {
const value = `+98${digits}`;
return E164_RE.test(value) ? value : null;
}
if (/^[1-9]\d{6,14}$/.test(digits)) {
const value = `+${digits}`;
return E164_RE.test(value) ? value : null;
}
return null;
}
function writeInstructionColumns(sheet: ExcelJS.Worksheet) {
const faLines = [
'فقط ستون phone الزامی است.',
'نام و نام خانوادگی اختیاری‌اند (برای شخصی‌سازی پیامک).',
'رمز عبور لازم نیست — حساب‌های جدید با رمز تصادفی ساخته می‌شوند.',
'فرمت شماره: 0912… یا +98912…',
'شماره‌های تکراری در فایل یا مشتری‌های موجود نادیده گرفته می‌شوند.',
'این فایل برای افزودن مشتری جهت ارسال پیامک است.',
];
const enLines = [
'Only the phone column is required.',
'first_name / last_name are optional (for SMS personalization).',
'No password column — new accounts get a random unusable password.',
'Phone format: 0912… or +98912…',
'Duplicates in the file or already-linked customers are skipped.',
'Use this file to add customers for SMS sending.',
];
faLines.forEach((line, index) => {
const row = sheet.getRow(index + 2);
if (!row.height || row.height < 20) {
row.height = 20;
}
const faCell = row.getCell(5);
faCell.value = line;
faCell.font = { size: 11 };
faCell.alignment = { wrapText: true, vertical: 'top', readingOrder: 'rtl' };
const enCell = row.getCell(6);
enCell.value = enLines[index] ?? '';
enCell.font = { size: 11 };
enCell.alignment = { wrapText: true, vertical: 'top', readingOrder: 'ltr' };
});
}
/** Prefer local 09… display for Iranian numbers in the spreadsheet. */
function formatPhoneForExcel(phone: string) {
if (phone.startsWith('+98') && phone.length === 13) {
return `0${phone.slice(3)}`;
}
return phone;
}
function looksPersian(value: string) {
return /[\u0600-\u06FF]/.test(value);
}
function readHeaderMap(sheet: ExcelJS.Worksheet): Partial<Record<HeaderKey, number>> {
const headerRow = sheet.getRow(1);
const map: Partial<Record<HeaderKey, number>> = {};
headerRow.eachCell((cell, colNumber) => {
const key = HEADER_ALIASES[normalizeHeader(cellText(cell.value))];
if (key && !map[key]) {
map[key] = colNumber;
}
});
return map;
}
function normalizeHeader(value: string) {
return value.trim().toLowerCase().replace(/\s+/g, ' ');
}
function optionalName(value: string): string | null {
const trimmed = value.trim();
return trimmed ? trimmed.slice(0, 100) : null;
}
function cellText(value: ExcelJS.CellValue): string {
if (value == null || value === '') return '';
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
return String(value).trim();
}
if (value instanceof Date) return '';
if (typeof value === 'object' && 'text' in value && typeof value.text === 'string') {
return value.text.trim();
}
if (typeof value === 'object' && 'richText' in value && Array.isArray(value.richText)) {
return value.richText.map((part) => part.text).join('').trim();
}
if (typeof value === 'object' && 'result' in value) {
return cellText(value.result as ExcelJS.CellValue);
}
return String(value).trim();
}
function toLatinDigits(value: string) {
return [...value]
.map((char) => {
const persian = PERSIAN_DIGITS.indexOf(char);
if (persian >= 0) return String(persian);
const arabic = ARABIC_DIGITS.indexOf(char);
if (arabic >= 0) return String(arabic);
return char;
})
.join('');
}
+65
View File
@@ -1,4 +1,5 @@
import {
BadRequestException,
Body,
Controller,
Delete,
@@ -7,8 +8,13 @@ import {
Patch,
Post,
Query,
StreamableFile,
UploadedFile,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { memoryStorage } from 'multer';
import { AuthUser } from '../auth/auth.types';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { RequireBusinessPermission } from '../auth/decorators/require-business-permission.decorator';
@@ -16,9 +22,12 @@ import { BusinessPermissionGuard } from '../auth/guards/business-permission.guar
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { DailyActivityQueryDto } from '../common/dto/daily-activity-query.dto';
import { CustomersService } from './customers.service';
import { customersExcelFilename } from './customers-excel';
import { CreateCustomerDto } from './dto/create-customer.dto';
import { ListCustomersDto } from './dto/list-customers.dto';
import { SearchCustomersDto } from './dto/search-customers.dto';
import { SendCustomerSmsDto } from './dto/send-customer-sms.dto';
import { SendCustomersBulkSmsDto } from './dto/send-customers-bulk-sms.dto';
import { UpdateCustomerDto } from './dto/update-customer.dto';
@Controller('businesses/:businessId/customers')
@@ -56,6 +65,40 @@ export class CustomersController {
return this.service.search(businessId, query, user);
}
@Get('export')
@RequireBusinessPermission('orders.read')
async exportExcel(
@Param('businessId') businessId: string,
@CurrentUser() user: AuthUser,
) {
const buffer = await this.service.exportExcel(businessId, user);
const filename = customersExcelFilename();
return new StreamableFile(buffer, {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
disposition: `attachment; filename="${filename}"; filename*=UTF-8''${encodeURIComponent(filename)}`,
length: buffer.length,
});
}
@Post('import')
@RequireBusinessPermission('orders.create')
@UseInterceptors(
FileInterceptor('file', {
storage: memoryStorage(),
limits: { fileSize: 8 * 1024 * 1024 },
}),
)
importExcel(
@Param('businessId') businessId: string,
@UploadedFile() file: Express.Multer.File,
@CurrentUser() user: AuthUser,
) {
if (!file) {
throw new BadRequestException('Excel file is required.');
}
return this.service.importExcel(businessId, file, user);
}
@Post()
@RequireBusinessPermission('orders.create')
create(
@@ -66,6 +109,28 @@ export class CustomersController {
return this.service.create(businessId, body, user);
}
/** Bulk / quantity SMS via Gama SendBulk. Empty userIds → all enabled customers. */
@Post('sms')
@RequireBusinessPermission('orders.update')
sendBulkSms(
@Param('businessId') businessId: string,
@Body() body: SendCustomersBulkSmsDto,
@CurrentUser() user: AuthUser,
) {
return this.service.sendBulkSms(businessId, body, user);
}
@Post(':userId/sms')
@RequireBusinessPermission('orders.update')
sendSms(
@Param('businessId') businessId: string,
@Param('userId') userId: string,
@Body() body: SendCustomerSmsDto,
@CurrentUser() user: AuthUser,
) {
return this.service.sendSms(businessId, userId, body, user);
}
@Patch(':userId')
@RequireBusinessPermission('orders.update')
update(
+2 -1
View File
@@ -2,9 +2,10 @@ import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { CustomersController } from './customers.controller';
import { CustomersService } from './customers.service';
import { SmsTemplatesModule } from '../sms-templates/sms-templates.module';
@Module({
imports: [AuthModule],
imports: [AuthModule, SmsTemplatesModule],
controllers: [CustomersController],
providers: [CustomersService],
})
+604 -8
View File
@@ -1,17 +1,40 @@
import { BadRequestException, ConflictException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
import {
BadRequestException,
ConflictException,
ForbiddenException,
HttpException,
Injectable,
NotFoundException,
ServiceUnavailableException,
} from '@nestjs/common';
import { Prisma } from '@prisma/client';
import * as bcrypt from 'bcrypt';
import { randomBytes } from 'crypto';
import { AuthUser } from '../auth/auth.types';
import { PermissionsService } from '../auth/permissions.service';
import { SmsBillingService } from '../auth/sms-billing.service';
import { SmsService } from '../auth/sms.service';
import {
buildDualDailyActivitySeries,
startOfLocalDay,
} from '../common/daily-activity';
import { normalizeBusinessSettings } from '../business-settings/business-settings.util';
import { PrismaService } from '../prisma/prisma.service';
import {
buildCustomersExcel,
parseCustomersExcel,
} from './customers-excel';
import { CreateCustomerDto } from './dto/create-customer.dto';
import { ListCustomersDto } from './dto/list-customers.dto';
import { SearchCustomersDto } from './dto/search-customers.dto';
import { SendCustomerSmsDto } from './dto/send-customer-sms.dto';
import { SendCustomersBulkSmsDto } from './dto/send-customers-bulk-sms.dto';
import { UpdateCustomerDto } from './dto/update-customer.dto';
import { SmsTemplatesService } from '../sms-templates/sms-templates.service';
import {
renderSmsTemplate,
smsTemplateUsesVariables,
} from '../sms-templates/sms-template-render';
type CustomerListRow = {
id: bigint;
@@ -33,6 +56,9 @@ export class CustomersService {
constructor(
private readonly prisma: PrismaService,
private readonly permissions: PermissionsService,
private readonly sms: SmsService,
private readonly smsBilling: SmsBillingService,
private readonly smsTemplates: SmsTemplatesService,
) {}
async list(
@@ -231,6 +257,210 @@ export class CustomersService {
};
}
async exportExcel(businessIdRaw: string, actor: AuthUser) {
const businessId = BigInt(businessIdRaw);
await this.assertPermission(businessId, actor.id, 'orders.read');
const rows = await this.prisma.$queryRaw<
{
cellNumber: string;
firstName: string | null;
lastName: string | null;
}[]
>(Prisma.sql`
SELECT
u.cell_number AS "cellNumber",
u.first_name AS "firstName",
u.last_name AS "lastName"
FROM business_customers bc
JOIN users u ON u.id = bc.user_id
WHERE bc.business_id = ${businessId}
ORDER BY bc.created_at DESC, u.id DESC
`);
return buildCustomersExcel(
rows.map((row) => ({
phone: row.cellNumber,
firstName: row.firstName?.trim() ?? '',
lastName: row.lastName?.trim() ?? '',
})),
);
}
async importExcel(
businessIdRaw: string,
file: Express.Multer.File | undefined,
actor: AuthUser,
) {
const businessId = BigInt(businessIdRaw);
await this.assertPermission(businessId, actor.id, 'orders.create');
if (!file?.buffer?.length) {
throw new BadRequestException('Excel file is required.');
}
const name = file.originalname?.toLowerCase() ?? '';
if (name && !name.endsWith('.xlsx')) {
throw new BadRequestException('Please upload an .xlsx Excel file.');
}
const business = await this.prisma.business.findUnique({
where: { id: businessId },
});
if (!business?.isActive) {
throw new NotFoundException('Business not found');
}
const customerRole = await this.prisma.role.findUnique({
where: { slug: 'customer' },
});
if (!customerRole) {
throw new Error('Customer role is missing. Run database migrations first.');
}
const rows = await parseCustomersExcel(file.buffer);
const phones = rows.map((row) => row.phone);
const existingUsers = await this.prisma.user.findMany({
where: { cellNumber: { in: phones } },
select: {
id: true,
cellNumber: true,
cellVerifiedAt: true,
businessCustomers: {
where: { businessId },
select: { id: true },
},
},
});
const userByPhone = new Map(
existingUsers.map((user) => [user.cellNumber, user]),
);
const existingUserIds = existingUsers.map((user) => user.id);
const staffMemberships =
existingUserIds.length === 0
? []
: await this.prisma.businessUser.findMany({
where: {
businessId,
userId: { in: existingUserIds },
},
select: { userId: true },
});
const staffUserIds = new Set(
staffMemberships.map((row) => row.userId.toString()),
);
let created = 0;
let linked = 0;
let skipped = 0;
const skipSamples: string[] = [];
const noteSkip = (message: string) => {
skipped += 1;
if (skipSamples.length < 12) {
skipSamples.push(message);
}
};
const placeholderPasswordHash = await bcrypt.hash(
randomBytes(32).toString('hex'),
10,
);
for (const row of rows) {
const existing = userByPhone.get(row.phone);
if (existing?.businessCustomers.length) {
noteSkip(`Row ${row.rowNumber}: already a customer (${row.phone}).`);
continue;
}
if (existing && staffUserIds.has(existing.id.toString())) {
noteSkip(
`Row ${row.rowNumber}: user is staff of this business (${row.phone}).`,
);
continue;
}
const wasNew = !existing;
await this.prisma.$transaction(async (tx) => {
let userId = existing?.id;
if (!userId) {
const account = await tx.user.create({
data: {
cellNumber: row.phone,
passwordHash: placeholderPasswordHash,
firstName: row.firstName,
lastName: row.lastName,
cellVerifiedAt: new Date(),
},
});
userId = account.id;
userByPhone.set(row.phone, {
id: account.id,
cellNumber: row.phone,
cellVerifiedAt: account.cellVerifiedAt,
businessCustomers: [{ id: BigInt(0) }],
});
} else if (existing && !existing.cellVerifiedAt) {
await tx.user.update({
where: { id: userId },
data: { cellVerifiedAt: new Date() },
});
}
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,
},
});
}
const cached = userByPhone.get(row.phone);
if (cached && cached.businessCustomers.length === 0) {
cached.businessCustomers = [{ id: BigInt(0) }];
}
});
if (wasNew) {
created += 1;
} else {
linked += 1;
}
}
return {
message: 'Customers imported.',
total: rows.length,
created,
linked,
skipped,
skipSamples,
};
}
async create(
businessIdRaw: string,
dto: CreateCustomerDto,
@@ -277,13 +507,19 @@ export class CustomersService {
}
}
if (!existingUser && !dto.password) {
throw new BadRequestException('password is required for new users');
}
const passwordHash = existingUser
? existingUser.passwordHash
: await bcrypt.hash(dto.password!, 10);
: await bcrypt.hash(
// Password optional: random unusable hash when omitted (OTP login still works).
dto.password?.trim() || randomBytes(32).toString('hex'),
10,
);
const firstName = dto.firstName?.trim() || null;
const lastName = dto.lastName?.trim() || null;
if (!firstName && !lastName) {
throw new BadRequestException('firstName or lastName is required');
}
const membership = await this.prisma.$transaction(async (tx) => {
const account =
@@ -293,8 +529,8 @@ export class CustomersService {
cellNumber: dto.cellNumber,
passwordHash,
email: dto.email,
firstName: dto.firstName,
lastName: dto.lastName,
firstName,
lastName,
firstNameEn: dto.firstNameEn?.trim() || null,
lastNameEn: dto.lastNameEn?.trim() || null,
cellVerifiedAt: new Date(),
@@ -463,6 +699,366 @@ export class CustomersService {
return { success: true };
}
async sendSms(
businessIdRaw: string,
userIdRaw: string,
dto: SendCustomerSmsDto,
actor: AuthUser,
) {
const businessId = BigInt(businessIdRaw);
const userId = BigInt(userIdRaw);
await this.assertPermission(businessId, actor.id, 'orders.update');
const membership = await this.prisma.businessCustomer.findUnique({
where: {
businessId_userId: { businessId, userId },
},
include: {
user: { include: { title: true } },
business: { select: { settings: true, isActive: true } },
},
});
if (!membership || !membership.business.isActive) {
throw new NotFoundException('Customer not found for this business');
}
if (!this.sms.isEnabled()) {
return {
enabled: false,
message: 'SMS is disabled. Message was not sent.',
};
}
const resolved = await this.resolveOutboundSms(
businessId,
membership.business.settings,
dto.message,
dto.templateId,
{
firstName: membership.user.firstName,
lastName: membership.user.lastName,
titleName: membership.user.title?.nameFa || membership.user.title?.nameEn,
},
);
const charged = await this.smsBilling.charge(
businessId,
resolved.message,
1,
'singular',
);
try {
const result = await this.sms.sendMessage(
membership.user.cellNumber,
resolved.message,
{ source: resolved.source },
);
return {
enabled: true,
message: 'Message sent successfully',
serverId: result.serverId,
source: resolved.source,
templateId: resolved.templateId,
templateVerified: resolved.templateVerified,
};
} catch (err) {
await this.smsBilling.refund(businessId, charged, 1, 'singular');
if (
err instanceof BadRequestException ||
err instanceof ServiceUnavailableException
) {
throw err;
}
throw new ServiceUnavailableException('Unable to send SMS');
}
}
/**
* Bulk SMS. Empty/omitted userIds → all enabled business customers.
* Always sends per-recipient so one failure does not abort the rest.
* Returns a report with sent/failed counts and per-number errors.
*/
async sendBulkSms(
businessIdRaw: string,
dto: SendCustomersBulkSmsDto,
actor: AuthUser,
) {
const businessId = BigInt(businessIdRaw);
await this.assertPermission(businessId, actor.id, 'orders.update');
const business = await this.prisma.business.findUnique({
where: { id: businessId },
select: { settings: true, isActive: true },
});
if (!business?.isActive) {
throw new NotFoundException('Business not found');
}
const selectedIds = (dto.userIds ?? [])
.map((id) => id.trim())
.filter(Boolean);
type Recipient = {
cellNumber: string;
firstName: string | null;
lastName: string | null;
titleName: string | null;
};
let recipients: Recipient[];
if (selectedIds.length > 0) {
const userIds = selectedIds.map((id) => {
try {
return BigInt(id);
} catch {
throw new BadRequestException('Invalid user id');
}
});
const rows = await this.prisma.$queryRaw<
{
cellNumber: string;
firstName: string | null;
lastName: string | null;
titleName: string | null;
}[]
>(Prisma.sql`
SELECT DISTINCT ON (u.id)
u.cell_number AS "cellNumber",
u.first_name AS "firstName",
u.last_name AS "lastName",
COALESCE(ut.name_fa, ut.name_en) AS "titleName"
FROM users u
LEFT JOIN user_titles ut ON ut.id = u.title_id
LEFT JOIN business_customers bc
ON bc.user_id = u.id AND bc.business_id = ${businessId}
LEFT JOIN business_users bu
ON bu.user_id = u.id AND bu.business_id = ${businessId}
WHERE u.id IN (${Prisma.join(userIds)})
AND (bc.id IS NOT NULL OR bu.id IS NOT NULL)
`);
recipients = rows;
} else {
const rows = await this.prisma.businessCustomer.findMany({
where: { businessId, isEnabled: true },
select: {
user: {
select: {
cellNumber: true,
firstName: true,
lastName: true,
title: { select: { nameFa: true, nameEn: true } },
},
},
},
});
recipients = rows.map((r) => ({
cellNumber: r.user.cellNumber,
firstName: r.user.firstName,
lastName: r.user.lastName,
titleName: r.user.title?.nameFa || r.user.title?.nameEn || null,
}));
}
if (recipients.length === 0) {
throw new BadRequestException('No recipients found to send SMS');
}
if (!this.sms.isEnabled()) {
return {
enabled: false,
message: 'SMS is disabled. Message was not sent.',
recipientCount: recipients.length,
sentCount: 0,
failedCount: 0,
failures: [],
};
}
const payloads: Array<{
cellNumber: string;
message: string;
source: string;
cost: number;
templateId: string | null;
templateVerified: boolean | null;
}> = [];
for (const recipient of recipients) {
const resolved = await this.resolveOutboundSms(
businessId,
business.settings,
dto.message,
dto.templateId,
{
firstName: recipient.firstName,
lastName: recipient.lastName,
titleName: recipient.titleName,
},
);
payloads.push({
cellNumber: recipient.cellNumber,
message: resolved.message,
source: resolved.source,
cost: this.smsBilling.costForMessage(resolved.message),
templateId: resolved.templateId,
templateVerified: resolved.templateVerified,
});
}
const serverIds: string[] = [];
const failures: Array<{ cellNumber: string; error: string }> = [];
let sentCount = 0;
let balanceExhausted = false;
for (const payload of payloads) {
if (balanceExhausted) {
failures.push({
cellNumber: payload.cellNumber,
error: 'Insufficient SMS balance. Contact Meshkee to top up.',
});
continue;
}
let charged = 0;
try {
charged = await this.smsBilling.chargeAmount(
businessId,
payload.cost,
1,
'mass',
);
} catch (err) {
const message = this.smsFailureMessage(err);
if (message.toLowerCase().includes('insufficient sms balance')) {
balanceExhausted = true;
}
failures.push({ cellNumber: payload.cellNumber, error: message });
continue;
}
try {
const result = await this.sms.sendMessage(
payload.cellNumber,
payload.message,
{ source: payload.source },
);
serverIds.push(result.serverId);
sentCount += 1;
} catch (err) {
if (charged > 0) {
await this.smsBilling.refund(businessId, charged, 1, 'mass');
}
failures.push({
cellNumber: payload.cellNumber,
error: this.smsFailureMessage(err),
});
}
}
const sample = payloads[0];
const failedCount = failures.length;
const message =
failedCount === 0
? 'Message sent successfully'
: sentCount === 0
? 'No messages were sent'
: `Sent ${sentCount} of ${payloads.length}; ${failedCount} failed`;
return {
enabled: true,
message,
recipientCount: payloads.length,
sentCount,
failedCount,
serverIds,
failures,
source: sample?.source,
templateId: sample?.templateId,
templateVerified: sample?.templateVerified,
};
}
private smsFailureMessage(err: unknown): string {
if (err instanceof HttpException) {
const response = err.getResponse();
if (typeof response === 'string' && response.trim()) {
return response.trim();
}
if (typeof response === 'object' && response !== null && 'message' in response) {
const message = (response as { message?: string | string[] }).message;
if (Array.isArray(message)) {
return message.filter(Boolean).join(', ') || err.message;
}
if (typeof message === 'string' && message.trim()) {
return message.trim();
}
}
return err.message || 'Unable to send SMS';
}
if (err instanceof Error && err.message.trim()) {
return err.message.trim();
}
return 'Unable to send SMS';
}
/**
* Resolve message text + Gama shortcode for customer SMS.
* Verified template → service line (fixed). Unverified template → advertising line.
* Free text → business senderNumber or advertising (unchanged).
*/
private async resolveOutboundSms(
businessId: bigint,
businessSettings: unknown,
messageRaw: string,
templateIdRaw: string | undefined,
vars: {
firstName?: string | null;
lastName?: string | null;
titleName?: string | null;
},
) {
const settings = normalizeBusinessSettings(businessSettings);
let message = messageRaw.trim();
let templateId: string | null = null;
let templateVerified: boolean | null = null;
let source =
settings.sms.senderNumber?.trim() || this.sms.getAdvertisingSource();
if (templateIdRaw?.trim()) {
const template = await this.smsTemplates.getTemplateForSend(
businessId,
BigInt(templateIdRaw.trim()),
);
templateId = template.id.toString();
templateVerified = template.isVerified;
// Verified templates always use the approved body (sender is also fixed).
const draft = template.isVerified
? template.body
: message || template.body;
message = renderSmsTemplate(draft, vars);
source = template.isVerified
? this.sms.getServiceSource()
: this.sms.getAdvertisingSource();
} else if (smsTemplateUsesVariables(message)) {
message = renderSmsTemplate(message, vars);
}
if (!message.trim()) {
throw new BadRequestException('Message is empty');
}
return {
message: message.trim(),
source,
templateId,
templateVerified,
};
}
private formatLabel(user: {
firstName: string | null;
lastName: string | null;
+9 -4
View File
@@ -4,6 +4,7 @@ import {
IsString,
Matches,
MinLength,
ValidateIf,
} from 'class-validator';
export class CreateCustomerDto {
@@ -18,13 +19,17 @@ export class CreateCustomerDto {
@MinLength(8)
password?: string;
/** Required when lastName is empty — at least one of first/last name must be set. */
@ValidateIf((o: CreateCustomerDto) => !o.lastName?.trim())
@IsString()
@MinLength(2)
firstName!: string;
@MinLength(1)
firstName?: string;
/** Required when firstName is empty — at least one of first/last name must be set. */
@ValidateIf((o: CreateCustomerDto) => !o.firstName?.trim())
@IsString()
@MinLength(2)
lastName!: string;
@MinLength(1)
lastName?: string;
@IsOptional()
@IsString()
@@ -0,0 +1,13 @@
import { IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
export class SendCustomerSmsDto {
@IsString()
@MinLength(1)
@MaxLength(700)
message!: string;
/** Optional SMS template id — body may still be sent as the rendered text. */
@IsOptional()
@IsString()
templateId?: string;
}
@@ -0,0 +1,31 @@
import { Type } from 'class-transformer';
import {
ArrayMaxSize,
IsArray,
IsOptional,
IsString,
MaxLength,
MinLength,
} from 'class-validator';
export class SendCustomersBulkSmsDto {
@IsString()
@MinLength(1)
@MaxLength(700)
message!: string;
/**
* When omitted or empty → send to all enabled business customers.
* When provided → only those user ids that belong to this business.
*/
@IsOptional()
@IsArray()
@ArrayMaxSize(5000)
@IsString({ each: true })
@Type(() => String)
userIds?: string[];
@IsOptional()
@IsString()
templateId?: string;
}
@@ -7,6 +7,7 @@ import {
Param,
Patch,
Post,
Put,
Query,
UseGuards,
} from '@nestjs/common';
@@ -17,6 +18,8 @@ import { DisableDomainDto } from './dto/disable-domain.dto';
import { ListDomainsDto } from './dto/list-domains.dto';
import { ToggleSslDto } from './dto/toggle-ssl.dto';
import { UpdateDomainAdminDto } from './dto/update-domain-admin.dto';
import { UpdateParkedHostsDto } from './dto/update-parked-hosts.dto';
import { ApplyMailDnsDto } from './dto/apply-mail-dns.dto';
import { DomainAdminService } from './domain-admin.service';
@Controller('domains')
@@ -59,6 +62,26 @@ export class DomainAdminController {
return this.service.issueSsl(domainId, user);
}
@Put(':domainId/parked-hosts')
@UseGuards(JwtAuthGuard)
setParkedHosts(
@Param('domainId') domainId: string,
@Body() dto: UpdateParkedHostsDto,
@CurrentUser() user: AuthUser,
) {
return this.service.setParkedHosts(domainId, dto, user);
}
@Post(':domainId/mail-dns')
@UseGuards(JwtAuthGuard)
applyMailDns(
@Param('domainId') domainId: string,
@Body() dto: ApplyMailDnsDto,
@CurrentUser() user: AuthUser,
) {
return this.service.applyMailDns(domainId, dto, user);
}
@Patch(':domainId')
@UseGuards(JwtAuthGuard)
update(
+2 -1
View File
@@ -2,12 +2,13 @@ import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
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 { DomainAdminController } from './domain-admin.controller';
import { DomainAdminService } from './domain-admin.service';
@Module({
imports: [AuthModule, ConfigModule, WebsiteDeployModule, ArvanDnsModule],
imports: [AuthModule, ConfigModule, WebsiteDeployModule, ArvanDnsModule, CloudflareDnsModule],
controllers: [DomainAdminController],
providers: [DomainAdminService],
})
+483 -43
View File
@@ -10,6 +10,7 @@ import {
import { ConfigService } from '@nestjs/config';
import { Prisma } from '@prisma/client';
import { ArvanDnsService } from '../arvan-dns/arvan-dns.service';
import { CloudflareDnsService } from '../cloudflare-dns/cloudflare-dns.service';
import { AuthUser } from '../auth/auth.types';
import { PermissionsService } from '../auth/permissions.service';
import {
@@ -19,10 +20,25 @@ import {
} from '../common/tls-probe';
import { PrismaService } from '../prisma/prisma.service';
import { WebsiteDeployAgentService } from '../website-deploy/website-deploy-agent.service';
import {
deploySlugFromHost,
isValidGitRepoUrl,
normalizeGitRepoUrlForClone,
} from '../website-deploy/website-deploy.util';
import { DisableDomainDto } from './dto/disable-domain.dto';
import { ListDomainsDto } from './dto/list-domains.dto';
import { ToggleSslDto } from './dto/toggle-ssl.dto';
import { UpdateDomainAdminDto } from './dto/update-domain-admin.dto';
import { UpdateParkedHostsDto } from './dto/update-parked-hosts.dto';
import { ApplyMailDnsDto } from './dto/apply-mail-dns.dto';
import { parseDkimPaste } from '../mail-dns/mail-dns';
import {
isValidParkedHost,
parseParkedDnsMap,
uniqueParkedAliases,
uniqueParkedHosts,
} from './parked-host.util';
import type { ParkedDnsProvider } from './parked-host.util';
type DomainRow = {
id: bigint;
@@ -37,6 +53,9 @@ type DomainRow = {
lastDeployedAt: Date | null;
lastDeployStatus: string | null;
deploySlug: string | null;
gitRepoUrl: string | null;
parkedHosts: string[];
parkedDns: unknown;
};
@Injectable()
@@ -47,6 +66,7 @@ export class DomainAdminService {
private readonly config: ConfigService,
private readonly websiteDeployAgent: WebsiteDeployAgentService,
private readonly arvanDns: ArvanDnsService,
private readonly cloudflareDns: CloudflareDnsService,
) {}
private async assertSuperAdmin(actor: AuthUser) {
@@ -82,7 +102,10 @@ export class DomainAdminService {
d.created_at AS "createdAt",
d.last_deployed_at AS "lastDeployedAt",
d.last_deploy_status AS "lastDeployStatus",
d.deploy_slug AS "deploySlug"
d.deploy_slug AS "deploySlug",
d.git_repo_url AS "gitRepoUrl",
COALESCE(d.parked_hosts, '{}') AS "parkedHosts",
COALESCE(d.parked_dns, '{}'::jsonb) AS "parkedDns"
FROM domains d
JOIN businesses b ON b.id = d.business_id
${where}
@@ -98,13 +121,20 @@ export class DomainAdminService {
const now = Date.now();
const items = rows.map((row) => {
const certStillValid =
!row.sslExpiresAt || row.sslExpiresAt.getTime() > now;
const expiryMs = row.sslExpiresAt?.getTime() ?? null;
const certStillValid = expiryMs == null || expiryMs > now;
// Prefer stored notAfter when present (daily probe is only a writer).
const sslEnabled =
expiryMs != null ? expiryMs > now : row.sslEnabled && certStillValid;
return {
...row,
// List badge: issued/probed OK and cert not past notAfter.
sslEnabled: row.sslEnabled && certStillValid,
sslEnabled,
sslExpiresAt: row.sslExpiresAt,
parkedHosts: Array.isArray(row.parkedHosts) ? row.parkedHosts : [],
parkedAliases: (Array.isArray(row.parkedHosts) ? row.parkedHosts : []).map((host) => ({
host,
dns: parseParkedDnsMap(row.parkedDns)[host] ?? ('arvan' as const),
})),
};
});
@@ -208,8 +238,9 @@ export class DomainAdminService {
? domain.host
: `www.${domain.host}`;
const wwwProbe = await probeTlsHostDetailed(wwwHost);
const ok = apexProbe.ok && wwwProbe.ok;
if (ok) {
const apexOk = apexProbe.ok;
const bothOk = apexOk && wwwProbe.ok;
if (apexOk) {
const sslExpiresAt = earliestTlsExpiry(apexProbe, wwwProbe);
await this.prisma.domain.update({
where: { id: domain.id },
@@ -218,10 +249,8 @@ export class DomainAdminService {
...(sslExpiresAt ? { sslExpiresAt } : {}),
},
});
continue;
}
if (domain.sslEnabled) {
if (bothOk) continue;
} else if (domain.sslEnabled) {
await this.prisma.domain.update({
where: { id: domain.id },
data: { sslEnabled: false, sslExpiresAt: null },
@@ -230,15 +259,6 @@ export class DomainAdminService {
targets.push(domain);
}
if (targets.length === 0) {
return {
status: 'ok' as const,
message: 'No storefront domains need SSL',
issued: [] as string[],
failed: [] as Array<{ host: string; error: string }>,
};
}
const issued: string[] = [];
const failed: Array<{ host: string; error: string }> = [];
@@ -253,23 +273,23 @@ export class DomainAdminService {
? domain.host
: `www.${domain.host}`;
const wwwProbe = await probeTlsHostDetailed(wwwHost);
const wwwOk = apexProbe.ok && wwwProbe.ok;
const sslExpiresAt = wwwOk
const apexOk = apexProbe.ok;
const sslExpiresAt = apexOk
? earliestTlsExpiry(apexProbe, wwwProbe)
: null;
await this.prisma.domain.update({
where: { id: domain.id },
data: {
sslEnabled: wwwOk,
sslEnabled: apexOk,
sslExpiresAt,
},
});
if (wwwOk) {
if (apexOk) {
issued.push(domain.host);
} else {
failed.push({
host: domain.host,
error: 'Certbot finished but TLS probe still failed (apex or www)',
error: 'Certbot finished but TLS probe still failed on apex',
});
}
} catch (err) {
@@ -280,6 +300,47 @@ export class DomainAdminService {
}
}
const parkedTargets = await this.prisma.domain.findMany({
where: { isActive: true, parkedHosts: { isEmpty: false } },
select: { parkedHosts: true },
});
const parkedSeen = new Set<string>();
for (const row of parkedTargets) {
for (const parked of uniqueParkedHosts(row.parkedHosts ?? [])) {
if (parkedSeen.has(parked)) continue;
parkedSeen.add(parked);
const apexOk = await probeTlsHost(parked);
const wwwOk = await probeTlsHost(`www.${parked}`);
if (apexOk && wwwOk) continue;
try {
await this.websiteDeployAgent.issueSsl({ host: parked });
const okNow =
(await probeTlsHost(parked)) && (await probeTlsHost(`www.${parked}`));
if (okNow) issued.push(parked);
else {
failed.push({
host: parked,
error: 'Certbot finished but parked TLS probe still failed',
});
}
} catch (err) {
failed.push({
host: parked,
error: err instanceof Error ? err.message : 'Parked SSL issue failed',
});
}
}
}
if (targets.length === 0 && issued.length === 0 && failed.length === 0) {
return {
status: 'ok' as const,
message: 'No storefront or parked domains need SSL',
issued,
failed,
};
}
const message =
failed.length === 0
? `Issued SSL for ${issued.length} website(s)`
@@ -446,26 +507,60 @@ export class DomainAdminService {
}
}
const storefrontOk = apexOk && wwwOk;
// Re-probe apex/www once more when already-ok so we persist current cert expiry.
if (storefrontOk && (!apexProbe.expiresAt || !wwwProbe.expiresAt)) {
// List badge follows apex. www can still fail (no DNS) without marking Invalid.
if (apexOk && !apexProbe.expiresAt) {
apexProbe = await probeTlsHostDetailed(apex);
wwwProbe = await probeTlsHostDetailed(wwwHost);
apexOk = apexProbe.ok;
wwwOk = wwwProbe.ok;
}
const sslExpiresAt =
apexOk && wwwOk ? earliestTlsExpiry(apexProbe, wwwProbe) : null;
const sslExpiresAt = apexOk
? earliestTlsExpiry(apexProbe, wwwOk ? wwwProbe : apexProbe)
: null;
const updated = await this.prisma.domain.update({
where: { id: domainId },
data: {
sslEnabled: storefrontOk && apexOk && wwwOk,
sslExpiresAt: storefrontOk && apexOk && wwwOk ? sslExpiresAt : null,
sslEnabled: apexOk,
sslExpiresAt: apexOk ? sslExpiresAt : null,
},
});
const parts = [hosts.apex, hosts.www, hosts.business, hosts.customer];
const parkedResults: HostResult[] = [];
for (const parked of uniqueParkedHosts(domain.parkedHosts ?? [])) {
const parkedWww = `www.${parked}`;
const parkedApexOk = await probeTlsHost(parked);
const parkedWwwOk = await probeTlsHost(parkedWww);
if (parkedApexOk && parkedWwwOk) {
parkedResults.push({
host: parked,
status: 'ok',
detail: 'Already valid',
});
continue;
}
try {
await this.websiteDeployAgent.park({
host: parked,
canonicalHost: apex.replace(/^www\./, ''),
});
await this.websiteDeployAgent.issueSsl({ host: parked });
const okNow = (await probeTlsHost(parked)) && (await probeTlsHost(parkedWww));
parkedResults.push({
host: parked,
status: okNow ? 'issued' : 'failed',
detail: okNow
? 'Parked alias SSL issued on websites VM'
: 'Certbot ran but parked HTTPS probe still failed',
});
} catch (err) {
parkedResults.push({
host: parked,
status: 'failed',
detail: err instanceof Error ? err.message : 'Parked SSL failed',
});
}
}
const parts = [...[hosts.apex, hosts.www, hosts.business, hosts.customer], ...parkedResults];
const failed = parts.filter((p) => p.status === 'failed');
const issued = parts.filter((p) => p.status === 'issued');
@@ -553,36 +648,382 @@ export class DomainAdminService {
throw new NotFoundException('Domain not found');
}
const host = (dto.host?.trim() || domain.host).toLowerCase();
const gitRepoUrl = dto.gitRepoUrl?.trim() || null;
if (dto.host) {
const host = dto.host.trim();
const existing = await this.prisma.domain.findUnique({ where: { host } });
if (existing && existing.id !== domainId) {
throw new ConflictException('Domain host is already taken');
}
}
if (gitRepoUrl && !isValidGitRepoUrl(gitRepoUrl)) {
throw new BadRequestException(
'gitRepoUrl must be a git URL (e.g. https://git.meshkee.com/Meshkee-Websites/oaktasty.git)',
);
}
let deploySlug = domain.deploySlug;
let provisionError: string | null = null;
let nextGitRepoUrl = domain.gitRepoUrl;
const hostChanged = domain.host !== host;
const effectiveGitRepoUrl = gitRepoUrl || domain.gitRepoUrl?.trim() || null;
const alreadyWired =
!!effectiveGitRepoUrl &&
!!domain.deploySlug?.trim() &&
(!gitRepoUrl || domain.gitRepoUrl?.trim() === gitRepoUrl) &&
!hostChanged;
if (effectiveGitRepoUrl && !alreadyWired) {
const slug = domain.deploySlug?.trim() || deploySlugFromHost(host);
if (!slug) {
throw new BadRequestException('Could not derive deploy slug from host');
}
const slugTaken = await this.prisma.domain.findFirst({
where: { deploySlug: slug, NOT: { id: domainId } },
select: { id: true },
});
if (slugTaken) {
throw new ConflictException(`Deploy slug "${slug}" is already in use`);
}
try {
await this.websiteDeployAgent.provision({
slug,
host,
gitRepoUrl: normalizeGitRepoUrlForClone(effectiveGitRepoUrl),
});
deploySlug = slug;
nextGitRepoUrl = effectiveGitRepoUrl;
} catch (err) {
if (
err &&
typeof err === 'object' &&
'message' in err &&
typeof err.message === 'string'
) {
provisionError = err.message;
} else {
provisionError = 'Storefront provision failed on websites server';
}
}
} else if (alreadyWired) {
deploySlug = domain.deploySlug;
nextGitRepoUrl = effectiveGitRepoUrl;
}
const updated = await this.prisma.domain.update({
where: { id: domainId },
data: {
host: dto.host?.trim(),
host,
expiresAt: dto.expiresAt !== undefined ? new Date(dto.expiresAt) : undefined,
...(gitRepoUrl && !provisionError
? { deploySlug, gitRepoUrl: nextGitRepoUrl }
: {}),
},
});
const host = updated.host;
const dnsError = dto.updateDns ? await this.applyArvanDns(host) : null;
// Same DNS helper as business-admin domain add/edit (`ensureTenantRecords`).
const dnsError = dto.updateDns
? await this.applyTenantDns(host, dto.dnsProvider === 'cloudflare' ? 'cloudflare' : 'arvan')
: null;
return {
...updated,
provisionError,
dnsError,
};
}
private async applyArvanDns(host: string): Promise<string | null> {
async setParkedHosts(domainIdRaw: string, dto: UpdateParkedHostsDto, actor: AuthUser) {
await this.assertSuperAdmin(actor);
const domainId = BigInt(domainIdRaw);
const domain = await this.prisma.domain.findUnique({ where: { id: domainId } });
if (!domain) {
throw new NotFoundException('Domain not found');
}
const canonical = domain.host.trim().toLowerCase().replace(/^www\./, '');
const nextAliases = uniqueParkedAliases(dto.aliases ?? []);
const currentDns = parseParkedDnsMap(domain.parkedDns);
const currentHosts = uniqueParkedHosts(domain.parkedHosts ?? []);
const currentAliases = currentHosts.map((host) => ({
host,
dns: currentDns[host] ?? ('arvan' as ParkedDnsProvider),
}));
for (const alias of nextAliases) {
if (!isValidParkedHost(alias.host)) {
throw new BadRequestException(`Invalid parked domain: ${alias.host}`);
}
if (alias.host === canonical) {
throw new BadRequestException('Parked domain cannot be the same as the main domain');
}
if (/^(business|customer|api)\./.test(alias.host)) {
throw new BadRequestException(
`Parked domain cannot be a dashboard/API host: ${alias.host}`,
);
}
}
const nextHosts = nextAliases.map((item) => item.host);
for (const host of nextHosts) {
const clash = await this.prisma.domain.findFirst({
where: {
NOT: { id: domainId },
OR: [{ host }, { parkedHosts: { has: host } }],
},
select: { host: true },
});
if (clash) {
throw new ConflictException(
`"${host}" is already used by ${clash.host}`,
);
}
}
const currentSet = new Set(currentHosts);
const nextSet = new Set(nextHosts);
const currentDnsByHost = new Map(currentAliases.map((item) => [item.host, item.dns]));
const nextDnsByHost = new Map(nextAliases.map((item) => [item.host, item.dns]));
const toAdd = nextHosts.filter((h) => !currentSet.has(h));
const toRemove = currentHosts.filter((h) => !nextSet.has(h));
const toRefreshDns = nextHosts.filter(
(h) => currentSet.has(h) && currentDnsByHost.get(h) !== nextDnsByHost.get(h),
);
const results: Array<{
host: string;
action: 'add' | 'remove' | 'keep';
ok: boolean;
dns?: string | null;
redirect?: string | null;
ssl?: string | null;
error?: string;
}> = [];
const saved = new Set(currentHosts);
for (const host of toRemove) {
try {
await this.arvanDns.ensureTenantRecords(host);
await this.websiteDeployAgent.unpark({ host });
saved.delete(host);
results.push({ host, action: 'remove', ok: true });
} catch (err) {
const error = err instanceof Error ? err.message : 'Unpark failed';
results.push({ host, action: 'remove', ok: false, error });
}
}
for (const host of toAdd) {
const dnsProvider = nextDnsByHost.get(host) ?? 'arvan';
const dnsError = await this.applyParkedDns(host, dnsProvider);
if (dnsError) {
results.push({
host,
action: 'add',
ok: false,
dns: dnsError,
error: dnsError,
});
continue;
}
try {
await this.websiteDeployAgent.park({ host, canonicalHost: canonical });
} catch (err) {
const error = err instanceof Error ? err.message : 'Redirect vhost failed';
results.push({
host,
action: 'add',
ok: false,
dns: 'ok',
redirect: error,
error,
});
continue;
}
let sslDetail: string | null = 'issued';
try {
await this.websiteDeployAgent.issueSsl({ host });
} catch (err) {
sslDetail = err instanceof Error ? err.message : 'SSL issue failed';
}
saved.add(host);
results.push({
host,
action: 'add',
ok: sslDetail === 'issued',
dns: 'ok',
redirect: 'ok',
ssl: sslDetail,
...(sslDetail === 'issued' ? {} : { error: sslDetail }),
});
}
for (const host of toRefreshDns) {
const dnsProvider = nextDnsByHost.get(host) ?? 'arvan';
const dnsError = await this.applyParkedDns(host, dnsProvider);
results.push({
host,
action: 'keep',
ok: !dnsError,
dns: dnsError || 'ok',
...(dnsError ? { error: dnsError } : {}),
});
}
for (const host of nextHosts) {
if (!toAdd.includes(host) && !toRefreshDns.includes(host)) {
results.push({ host, action: 'keep', ok: true });
}
}
const parkedHosts = [
...nextHosts.filter((h) => saved.has(h)),
...[...saved].filter((h) => !nextSet.has(h)),
];
const parkedDns: Record<string, ParkedDnsProvider> = {};
for (const alias of nextAliases) {
if (saved.has(alias.host)) parkedDns[alias.host] = alias.dns;
}
for (const host of [...saved]) {
if (!parkedDns[host]) parkedDns[host] = currentDnsByHost.get(host) ?? 'arvan';
}
const updated = await this.prisma.domain.update({
where: { id: domainId },
data: { parkedHosts, parkedDns },
});
const parkedAliases = parkedHosts.map((host) => ({
host,
dns: parkedDns[host] ?? ('arvan' as const),
}));
const failed = results.filter((r) => !r.ok);
const message =
failed.length === 0
? parkedHosts.length
? `Parked domains updated (${parkedHosts.join(', ')}).`
: 'Parked domains cleared.'
: failed.map((f) => `${f.host}: ${f.error || 'failed'}`).join(' · ');
return {
host: updated.host,
parkedHosts,
parkedAliases,
results,
status: failed.length === 0 ? ('ok' as const) : ('partial' as const),
message,
};
}
async applyMailDns(domainIdRaw: string, dto: ApplyMailDnsDto, actor: AuthUser) {
await this.assertSuperAdmin(actor);
const domainId = BigInt(domainIdRaw);
const domain = await this.prisma.domain.findUnique({
where: { id: domainId },
select: { id: true, host: true },
});
if (!domain) throw new NotFoundException('Website not found');
let ed25519;
let rsa;
try {
ed25519 = parseDkimPaste(dto.ed25519Key, 'ed25519', dto.ed25519Selector);
rsa = parseDkimPaste(dto.rsaKey, 'rsa', dto.rsaSelector);
} catch (err) {
throw new BadRequestException(err instanceof Error ? err.message : 'Invalid DKIM key');
}
if (ed25519.selector === rsa.selector) {
throw new BadRequestException('Ed25519 and RSA DKIM selectors must be different.');
}
const provider = dto.dnsProvider === 'cloudflare' ? 'cloudflare' : 'arvan';
const host = domain.host.trim().toLowerCase();
try {
const result =
provider === 'cloudflare'
? await this.cloudflareDns.ensureMailRecords(host, [ed25519, rsa])
: await this.arvanDns.ensureMailRecords(host, [ed25519, rsa]);
const parts = [
result.created.length ? `created ${result.created.join(', ')}` : '',
result.updated.length ? `updated ${result.updated.join(', ')}` : '',
result.removed.length ? `removed ${result.removed.join(', ')}` : '',
].filter(Boolean);
return {
host,
dnsProvider: provider,
created: result.created,
updated: result.updated,
skipped: result.skipped,
removed: result.removed,
message: parts.length
? `Mail DNS updated on ${provider === 'cloudflare' ? 'Cloudflare' : 'Arvan'} (${parts.join('; ')}).`
: `Mail DNS already up to date on ${provider === 'cloudflare' ? 'Cloudflare' : 'Arvan'}.`,
};
} catch (err) {
throw new ServiceUnavailableException(
this.httpErrorMessage(
err,
provider === 'cloudflare'
? 'Cloudflare mail DNS update failed'
: 'Arvan mail DNS update failed',
),
);
}
}
private async applyParkedDns(
host: string,
provider: ParkedDnsProvider,
): Promise<string | null> {
try {
if (provider === 'cloudflare') {
await this.cloudflareDns.ensureParkedRecords(host);
} else {
await this.arvanDns.ensureParkedRecords(host);
}
return null;
} catch (err) {
return this.httpErrorMessage(
err,
provider === 'cloudflare' ? 'Cloudflare DNS update failed' : 'Arvan DNS update failed',
);
}
}
private async applyTenantDns(
host: string,
provider: ParkedDnsProvider,
): Promise<string | null> {
try {
if (provider === 'cloudflare') {
await this.cloudflareDns.ensureTenantRecords(host);
} else {
await this.arvanDns.ensureTenantRecords(host);
}
return null;
} catch (err) {
return this.httpErrorMessage(
err,
provider === 'cloudflare' ? 'Cloudflare DNS update failed' : 'Arvan DNS update failed',
);
}
}
private httpErrorMessage(err: unknown, fallback: string): string {
if (err instanceof HttpException) {
const res = err.getResponse();
if (typeof res === 'string') return res;
@@ -594,8 +1035,7 @@ export class DomainAdminService {
if (err && typeof err === 'object' && 'message' in err && typeof err.message === 'string') {
return err.message;
}
return 'Arvan DNS update failed';
}
return fallback;
}
async disable(domainIdRaw: string, dto: DisableDomainDto, actor: AuthUser) {
@@ -0,0 +1,29 @@
import { IsIn, IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
import { PARKED_DNS_PROVIDERS } from '../parked-host.util';
export class ApplyMailDnsDto {
@IsIn(PARKED_DNS_PROVIDERS)
dnsProvider!: (typeof PARKED_DNS_PROVIDERS)[number];
@IsString()
@MinLength(20)
@MaxLength(8000)
ed25519Key!: string;
@IsString()
@MinLength(20)
@MaxLength(8000)
rsaKey!: string;
/** Required when ed25519Key is a value-only `v=DKIM1; …` paste (no `…._domainkey`). */
@IsOptional()
@IsString()
@MaxLength(253)
ed25519Selector?: string;
/** Required when rsaKey is a value-only `v=DKIM1; …` paste (no `…._domainkey`). */
@IsOptional()
@IsString()
@MaxLength(253)
rsaSelector?: string;
}
@@ -1,4 +1,5 @@
import { IsBoolean, IsDateString, IsOptional, IsString, MinLength } from 'class-validator';
import { IsBoolean, IsDateString, IsIn, IsOptional, IsString, Matches, MinLength } from 'class-validator';
import { GIT_REPO_URL_RE } from '../../website-deploy/website-deploy.util';
export class UpdateDomainAdminDto {
@IsOptional()
@@ -10,8 +11,22 @@ export class UpdateDomainAdminDto {
@IsDateString()
expiresAt?: 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()
@Matches(GIT_REPO_URL_RE, {
message:
'gitRepoUrl must be a git URL (e.g. https://git.meshkee.com/Meshkee-Websites/oaktasty.git)',
})
gitRepoUrl?: string;
}
@@ -0,0 +1,18 @@
import { IsArray, IsIn, IsString, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';
import { PARKED_DNS_PROVIDERS } from '../parked-host.util';
export class ParkedAliasDto {
@IsString()
host!: string;
@IsIn(PARKED_DNS_PROVIDERS)
dns!: (typeof PARKED_DNS_PROVIDERS)[number];
}
export class UpdateParkedHostsDto {
@IsArray()
@ValidateNested({ each: true })
@Type(() => ParkedAliasDto)
aliases!: ParkedAliasDto[];
}
+61
View File
@@ -0,0 +1,61 @@
const HOST_RE =
/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$/;
export const PARKED_DNS_PROVIDERS = ['arvan', 'cloudflare'] as const;
export type ParkedDnsProvider = (typeof PARKED_DNS_PROVIDERS)[number];
export type ParkedAlias = {
host: string;
dns: ParkedDnsProvider;
};
/** Strip protocol, path, trailing dot, and leading www. */
export function normalizeParkedHost(raw: string): string {
let host = raw.trim().toLowerCase();
host = host.replace(/^https?:\/\//, '');
host = host.split('/')[0] ?? host;
host = host.split(':')[0] ?? host;
host = host.replace(/\.$/, '');
host = host.replace(/^www\./, '');
return host;
}
export function isValidParkedHost(host: string): boolean {
return HOST_RE.test(host) && !host.endsWith('.local');
}
export function isParkedDnsProvider(value: unknown): value is ParkedDnsProvider {
return value === 'arvan' || value === 'cloudflare';
}
export function parseParkedDnsMap(raw: unknown): Record<string, ParkedDnsProvider> {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {};
const out: Record<string, ParkedDnsProvider> = {};
for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
const host = normalizeParkedHost(key);
if (host && isParkedDnsProvider(value)) out[host] = value;
}
return out;
}
export function uniqueParkedAliases(
raw: Array<{ host?: string; dns?: string } | string>,
): ParkedAlias[] {
const seen = new Set<string>();
const out: ParkedAlias[] = [];
for (const item of raw) {
const host = normalizeParkedHost(typeof item === 'string' ? item : (item.host ?? ''));
if (!host || seen.has(host)) continue;
seen.add(host);
const dnsRaw = typeof item === 'string' ? 'arvan' : item.dns;
out.push({
host,
dns: isParkedDnsProvider(dnsRaw) ? dnsRaw : 'arvan',
});
}
return out;
}
export function uniqueParkedHosts(raw: string[]): string[] {
return uniqueParkedAliases(raw).map((item) => item.host);
}
+59
View File
@@ -0,0 +1,59 @@
import { Transform, Type } from 'class-transformer';
import {
ArrayMaxSize,
IsArray,
IsInt,
IsOptional,
IsString,
Max,
Min,
} from 'class-validator';
function toStringArray(value: unknown): string[] | undefined {
if (value == null || value === '') return undefined;
if (Array.isArray(value)) {
return value.map((item) => String(item)).filter(Boolean);
}
if (typeof value === 'string') {
const trimmed = value.trim();
if (!trimmed) return undefined;
try {
const parsed = JSON.parse(trimmed) as unknown;
if (Array.isArray(parsed)) {
return parsed.map((item) => String(item)).filter(Boolean);
}
} catch {
// comma-separated fallback
}
return trimmed
.split(',')
.map((item) => item.trim())
.filter(Boolean);
}
return undefined;
}
export class ListGalleryDto {
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
pageSize?: number;
}
export class AddGalleryDto {
/** Attach existing business media (also accepted as multipart field). */
@IsOptional()
@Transform(({ value }) => toStringArray(value))
@IsArray()
@ArrayMaxSize(50)
@IsString({ each: true })
mediaIds?: string[];
}
+67
View File
@@ -0,0 +1,67 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Post,
Query,
UploadedFiles,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { FilesInterceptor } from '@nestjs/platform-express';
import { memoryStorage } from 'multer';
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 { AddGalleryDto, ListGalleryDto } from './dto/gallery.dto';
import { GalleryService } from './gallery.service';
@Controller('businesses/:businessId/gallery')
@UseGuards(JwtAuthGuard, BusinessPermissionGuard)
export class GalleryController {
constructor(private readonly galleryService: GalleryService) {}
@Get()
@RequireBusinessPermission('media.read')
list(
@Param('businessId') businessId: string,
@Query() query: ListGalleryDto,
@CurrentUser() user: AuthUser,
) {
return this.galleryService.list(businessId, query, user);
}
@Post()
@RequireBusinessPermission('media.create')
@UseInterceptors(
FilesInterceptor('files', 20, {
storage: memoryStorage(),
}),
)
add(
@Param('businessId') businessId: string,
@UploadedFiles() files: Express.Multer.File[],
@Body() dto: AddGalleryDto,
@CurrentUser() user: AuthUser,
) {
return this.galleryService.add(
businessId,
{ files: files ?? [], dto },
user,
);
}
@Delete(':mediaId')
@RequireBusinessPermission('media.delete')
remove(
@Param('businessId') businessId: string,
@Param('mediaId') mediaId: string,
@CurrentUser() user: AuthUser,
) {
return this.galleryService.remove(businessId, mediaId, user);
}
}
+13
View File
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { MediaModule } from '../media/media.module';
import { GalleryController } from './gallery.controller';
import { GalleryService } from './gallery.service';
@Module({
imports: [AuthModule, MediaModule],
controllers: [GalleryController],
providers: [GalleryService],
exports: [GalleryService],
})
export class GalleryModule {}

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