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
155 changed files with 10377 additions and 441 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;
}
}
```
+28 -10
View File
@@ -1,7 +1,7 @@
# Meshkee CMS API — Project Context
> Living reference for developers and AI assistants working on this codebase.
> Last updated: August 21, 2026
> Last updated: September 3, 2026
## What This Project Is
@@ -167,6 +167,7 @@ Example super admin: `+989121111111` / `password`
| `054_seed_iran_provinces_cities.sql` | Seed Iran provinces + cities when missing (004 seed may never have run) |
| `055_user_products_listing_fields.sql` | User product listing fields: `price_currency`, `delivery_note`, `condition` (`user_product_condition`), `technical_notes` |
| `058_invoice_user_id.sql` | `invoices.user_id` billed user (backfill from business owner) |
| `080_entity_translations.sql` | `entity_translations` for product/category EN+AR (AI or manual) |
Docker mounts `./database/migrations` into Postgres init — migrations run automatically only on **first** volume creation. Use `migrate.sh` for subsequent migrations.
@@ -179,6 +180,9 @@ Docker mounts `./database/migrations` into Postgres init — migrations run auto
| Enum | Values |
|------|--------|
| `MediaEntityType` | `product`, `blog`, `portfolio`, `customer`, `user_product` |
| `TranslationEntityType` | `product`, `category` |
| `TranslationLocale` | `en`, `ar` |
| `TranslationStatus` | `pending`, `ready`, `stale`, `failed` |
| `ContentStatus` | `draft`, `published`, `archived` |
| `VariationType` | `color`, `size`, `custom` |
| `CityLevel` | `country`, `province`, `city`, `district` |
@@ -265,13 +269,14 @@ All routes are prefixed with `/api/v1`.
| GET | `/tenants/:host/sitemap.xml` | Sitemap **index** → main + dynamic child sitemaps |
| GET | `/tenants/:host/sitemap-main.xml` | Static pages + `/products/category/{id}/{faSlug}` |
| GET | `/tenants/:host/sitemap-products.xml` | Published products `/products/{id}/{faSlug}` |
| GET | `/tenants/:host/sitemap-blogs.xml` | Published blogs `/blog/{id}/{slug}` |
| GET | `/tenants/:host/sitemap-portfolios.xml` | Published portfolios `/portfolios/{id}/{slug}` |
| GET | `/tenants/:host/sitemap-blogs.xml` | Published blogs `/blog/{slug}` |
| GET | `/tenants/:host/sitemap-portfolios.xml` | Published portfolios `/portfolio/{slug}` |
| GET | `/tenants/:host/sitemap-videos.xml` | Published videos `/videos/{slug}` |
| GET | `/tenants/:host/sitemap-instructions.xml` | Published instructions `/instructions/{slug}` |
| GET | `/tenants/:host/sitemap-instructions.xml` | Published instructions `/instruction/{slug}` |
| GET | `/tenants/:host/sitemap-workshops.xml` | Published workshops `/workshops/{slug}` |
| GET | `/tenants/:host/sitemap-user-products.xml` | Published user products `/user-products/{slug}` (module `customer_products`) |
| GET | `/tenants/:host/sitemap-user-products.xml` | Published user products `/user-products/{id}/{slug}` (module `customer_products`) |
| GET | `/tenants/:host/robots.txt` | robots.txt pointing to apex `/sitemap.xml` |
| POST | `/tenants/:host/torob_api/v3/products` | Torob Product API v3 (JWT). Requires store module + `settings.store.torobEnabled`. Nginx: `POST https://{host}/torob_api/v3/products` |
| GET | `/tenants/:host/categories/by-id/:categoryId` | Public category by id (for category landing pages) |
| GET | `/tenants/:host/categories/by-slug/:slug` | Public category by CMS slug |
| GET | `/tenants/:host/products/by-id/:productId` | Public product detail by id (for `{id}/{nameFaSlug}` storefront routes) |
@@ -324,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
@@ -332,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)
@@ -350,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)
@@ -440,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.
---
@@ -452,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)
@@ -642,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)
@@ -650,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
+226 -27
View File
@@ -22,25 +22,40 @@ You are building a **Meshkee business website (storefront)**. You must use the M
### Hard rules
1. Resolve tenant first: `GET /tenants/<WEBSITE_DOMAIN>` → save `businessId` from `id`.
2. All public content uses `/tenants/<WEBSITE_DOMAIN>/...` (no auth).
3. Cart, orders, favorites use `/businesses/<businessId>/...` with `Authorization: Bearer <accessToken>`.
4. Customer register body must include `"domain": "<WEBSITE_DOMAIN>"`. If the cell already exists on another Meshkee site and the password differs, API returns `409` with `CELL_EXISTS_OTHER_SITE:...`. Retry register with `"acknowledgeExistingAccount": true` to link that account (profile unchanged; **password is replaced** with the new signup password), then complete SMS OTP.
5. Cell numbers are E.164 (`+98912...`).
6. Do not call dashboard/CMS routes (`/businesses/.../products` write APIs, media upload, domain-admin, etc.).
7. **Partner SMS** (`POST /public/sms/send`) is for external partner backends with an issued `X-Api-Key` only — not for normal storefront UI. See https://api.meshkee.com/docs/website/SMS.md
8. **Technical details (labels + values):** Product/user-product detail responses may include `technicalValues` with **values only** (`fieldId` + `textValue` / `optionId` / `optionIds` — **no field labels**). To render a label→value specs table you **must** call the matching `.../technical-info` endpoint and join `form.fields[].id` ↔ `values[].fieldId`. Never invent a separate “variation fields” or “category fields” public route — those do not exist on the website API.
3. **Auth + checkout are not this website.** Login, register, OTP, shopping-cart process, addresses, and payment already exist for every Meshkee site at `https://customer.<WEBSITE_DOMAIN>`. Do **not** add `/login`, `/register`, `/checkout`, or `/cart` routes (or equivalent pages) on the storefront. Do **not** call `/businesses/{businessId}/cart` or `/cart/checkout` from this site.
4. **Detect login** via parent-domain cookies (see Shared login). Not token handoff, not an API SSO endpoint. If the shopper needs to sign in, **redirect** to the customer dashboard login — do not invent a login UI.
5. Storefront cart = header icon + quantity badge + mini-cart popup + **Continue** redirect (see Shopping cart). Server cart / orders / payment APIs are customer-dashboard only. Favorites may still use Bearer if cookies exist.
6. Customer-dashboard auth APIs (storefronts must **not** call these): register body includes `"domain": "<WEBSITE_DOMAIN>"`. If the cell already exists on another Meshkee site and the password differs, API returns `409` with `CELL_EXISTS_OTHER_SITE:...`. Retry register with `"acknowledgeExistingAccount": true` to link that account (profile unchanged; **password is replaced** with the new signup password), then complete SMS OTP.
7. Cell numbers are E.164 (`+98912...`).
8. Do not call dashboard/CMS routes (`/businesses/.../products` write APIs, media upload, domain-admin, etc.).
9. **Partner SMS** (`POST /public/sms/send`) is for external partner backends with an issued `X-Api-Key` only — not for normal storefront UI. See https://api.meshkee.com/docs/website/SMS.md
10. **Technical details (labels + values):** Product/user-product detail responses may include `technicalValues` with **values only** (`fieldId` + `textValue` / `optionId` / `optionIds` — **no field labels**). To render a label→value specs table you **must** call the matching `.../technical-info` endpoint and join `form.fields[].id` ↔ `values[].fieldId`. Never invent a separate “variation fields” or “category fields” public route — those do not exist on the website API.
11. **Torob:** Do **not** add a Next.js route for `/torob_api`. Meshkee nginx on the store apex proxies `POST /torob_api/v3/products` to the API. Only businesses with the **store** module **and** Store settings → Torob switch on return products (otherwise 404). Storefront UI must not call this endpoint.
12. **Production runtime (required):** Meshkee hosts many Next.js storefronts on one shared websites VM. Every Next site **must** set `output: "standalone"` in `next.config` (`.ts` / `.mjs` / `.js`). Deploy detects `.next/standalone/server.js`, points PM2 at that `server.js`, and **deletes the full `node_modules`**. Target RSS is **~80–120 MB**. Do **not** ship `next start` with a full `node_modules` runtime (that uses ~150–500+ MB and OOMs the host). Do **not** use `output: "export"` unless the project explicitly asks for a static export. Vinext apps (`vinext` in `package.json` / `vinext start`) are a separate intentional stack — do not pretend they use Next standalone.
### Production runtime (Next.js)
```ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
// Leaner production runtime: deploy uses .next/standalone and drops node_modules.
output: "standalone",
// ...images, rewrites, etc.
};
export default nextConfig;
```
After changing `next.config`, commit, push, and redeploy so PM2 switches to standalone. A correct deploy log says `standalone build detected` / `deploy ok (standalone)`; PM2 script is `.next/standalone/server.js`, not `node_modules/next/dist/bin/next`.
### Typical bootstrap sequence
1. `GET /tenants/{domain}` → branding + `businessId` + `specialProductsSource` (`product` or `store_item`)
2. `GET /tenants/{domain}/website/favicon` → `faviconUrl` for `<link rel="icon">` / Next.js `metadata.icons` (falls back to `logoUrl` when no dedicated favicon). Also returns `logoUrl`, `logoDarkUrl`, `hasDedicatedFavicon`.
3. Homepage: business-info, **static-images**, sliders, category-groups, brand-groups, store-specials (`source` repeats the tenant setting; items are store listings when `source` is `store_item`)
4. Catalog: categories, products (`GET /products/{slug}` includes `relatedProducts`: same category then same brand, in-stock first), store-items (`name` instant search: in-stock first, then `updatedAt`), **user-products** (customer stock listings). Portfolios list newest first (`sortOrder` desc, then `publishedAt` / `createdAt` desc); each portfolio may include nullable `projectUrl` (external website link). List filters: products/blogs/portfolios/videos accept `?tag=` (exact match on `metadata.tags`).
5. Auth: register/login → store tokens. Optional: `POST /auth/send-otp` then `POST /auth/login-otp` (passwordless) or `POST /auth/reset-password` (forgot password). `POST /auth/verify-otp` only marks the cell verified (no tokens).
6. Cart checkout with `addressId` or inline `shippingAddress` + `payment`
- For online pay: `payment.type = "e_payment_gate"`, `gatewayType` (e.g. `"mellat"` or `"zarinpal"`), and absolute `returnUrl`
- Response includes `payment.redirect` `{ method, url, fields }` — POST/redirect shopper to the bank
- Meshkee registers the bank `callback_url` on the **store apex** (`https://YOUR_WEBSITE_DOMAIN/meshkee/payments/{gateway}/callback`), which nginx proxies to the API. ZarinPal/Mellat domain checks must match the store domain, not `api.meshkee.com`.
- After verify, API redirects the browser to `returnUrl?status=success|failed&orderId=…`
- Enabled gateways: `GET /tenants/{domain}` → `ePayment`, or `GET /businesses/{businessId}/payments/methods`
5. Shopping cart on **this** site only: header icon + badge + mini-cart popup. Persist a **guest cart** and **Continue** to `https://customer.<WEBSITE_DOMAIN>` (see Shopping cart). Do not implement login or checkout here.
6. Bank payment callbacks stay on the store apex via nginx (`https://<WEBSITE_DOMAIN>/meshkee/payments/{gateway}/callback`) — infrastructure only. The website app does **not** implement payment or checkout pages; that UI is the customer dashboard.
### Favicon + logos
- `GET /tenants/{domain}/website/favicon` → `{ faviconUrl, logoUrl, logoDarkUrl, hasDedicatedFavicon }`
@@ -48,11 +63,120 @@ You are building a **Meshkee business website (storefront)**. You must use the M
- When `hasDedicatedFavicon` is false, `faviconUrl` equals the light `logoUrl` — still safe to use as tab icon.
- Header/footer logos: `logoUrl` on light backgrounds, `logoDarkUrl` on dark (fallback to `logoUrl` in CSS when null).
### Shared login with customer dashboard (`customer.<WEBSITE_DOMAIN>`)
The Meshkee **customer dashboard** lives at `https://customer.<WEBSITE_DOMAIN>` (e.g. `customer.sgemed.com`). **That** app owns login, register, OTP, and password reset for every storefront. The shop and the dashboard stay in sync via **parent-domain cookies** — not a login page on `{apex}`.
**Storefront requirements:**
- Do **not** build login / register / OTP / forgot-password UI.
- If the shopper must authenticate, send them to `https://customer.<WEBSITE_DOMAIN>/login?redirect=…` (relative redirect path only, e.g. `/checkout/cart`).
- On page load: if the access-token cookie exists, treat them as logged in (Continue can skip login).
- Do not invent OAuth/SSO APIs. Do not use `POST /auth/handoff` (staff-only into `business.<WEBSITE_DOMAIN>`).
**Cookie contract** (written by the **customer dashboard** on login; storefronts only **read** them):
| Cookie name | Value |
|-------------|--------|
| `meshkee_customer_access_token` | access JWT (URL-encoded; may be chunked as `name_n` + `name_0`…) |
| `meshkee_customer_refresh_token` | refresh JWT (same) |
| Attribute | Value |
|-----------|--------|
| `Domain` | `.<WEBSITE_DOMAIN>` (leading dot), e.g. `.sgemed.com` |
| `Path` | `/` |
| `SameSite` | `Lax` |
| `Max-Age` | ~30 days (cleared on logout) |
| `Secure` | set on HTTPS |
API calls (favorites, etc.) still send `Authorization: Bearer <accessToken>` from that cookie. Cookies are **not** sent to the API for auth.
**Wrong:** a custom login page on the storefront, or calling `/auth/login` from shop UI.
**Right:** redirect to `customer.<WEBSITE_DOMAIN>` and read apex cookies.
### Shopping cart on the storefront
Website scope is a **mini-cart only**. The full cart, checkout, addresses, and payment run on the customer dashboard.
**UI**
- Header shopping-cart icon.
- Badge = sum of line quantities (hide or `0` when empty).
- Click → popup: lines, qty, remove, total, **Continue**.
**Add to cart** (always a **variant**, never a product id)
1. `GET /tenants/{domain}/store-items/by-product/{productId}` → `{ storeItem: { variants: [...] } | null }`.
2. If `storeItem` is null or `variants` is empty → hide Add to cart (not for sale).
3. **In-stock only:** `stockQuantity === null` (unlimited) or `stockQuantity > 0`. Disable / omit the rest.
4. **If more than one in-stock variant:** list them first (`label` is already joined, e.g. `قرمز · XL` from `selections`). The shopper **must pick one** — do not add until they choose.
5. **If exactly one in-stock variant:** add that variant (no picker required).
6. Push that variant into the guest cart. `id` = variant `id` (`storeItemVariantId`). If that `id` is already in the cart, **increment `quantity`** instead of adding a second line.
7. Selling price: `discountedPrice ?? price`. Optional `originalPrice` = `price` when `discountedPrice` is set.
Do not call `POST /businesses/{id}/cart/items` from the storefront. Mini-cart is local only.
**Guest cart** (must match the customer dashboard):
| | |
|--|--|
| Storage key / cookie name | `meshkee-guest-cart` |
| Persist | `localStorage` **and** cookie `Domain=.<WEBSITE_DOMAIN>`, `Path=/`, `SameSite=Lax`, ~30 days |
| Cookie size | if `encodeURIComponent(json)` is longer than ~3500 chars, skip the cookie and rely on the URL param |
Each line:
```json
{
"id": "<storeItemVariantId>",
"name": "...",
"slug": "...",
"price": 123000,
"originalPrice": 150000,
"image": "...",
"quantity": 1
}
```
`id` **must** be `storeItemVariantId` (not product id, not store-item id). `price` is IRT, numeric.
**URL payload** (always pass on Continue — cookies can be dropped or too large):
```js
function encodeGuestCart(items) {
const json = JSON.stringify(items)
return btoa(unescape(encodeURIComponent(json)))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '')
}
```
Query/hash param name: `guestCart`.
**Continue** (replace `<WEBSITE_DOMAIN>`; `encoded = encodeGuestCart(items)`):
- Logged in (`meshkee_customer_access_token` present):
`https://customer.<WEBSITE_DOMAIN>/checkout/cart?guestCart=<encoded>`
- Not logged in:
`https://customer.<WEBSITE_DOMAIN>/login?redirect=${encodeURIComponent('/checkout/cart?guestCart=' + encoded)}`
The dashboard reads `guestCart` (query or hash), then `localStorage`, then the shared cookie, and syncs lines into the server cart after login.
**Wrong:** storefront `/cart` or `/checkout` pages; `POST /businesses/{id}/cart/checkout`; a shop-built login.
**Right:** local guest mini-cart → redirect to `customer.<WEBSITE_DOMAIN>`.
### Checkout & payments (customer dashboard — not this website)
OpenAPI **Cart** / checkout / payment-method routes are for `https://customer.<WEBSITE_DOMAIN>`, not storefront JavaScript.
Nginx on the shop apex still proxies bank callbacks (`https://<WEBSITE_DOMAIN>/meshkee/payments/{gateway}/callback`) to the API. Do not add a Next.js page for that path. After pay, the API redirects to the dashboard `returnUrl` (`/checkout/result?status=…`).
### User products (customer listings)
Public marketplace listings owned by customers — not catalog `products`.
- `GET /tenants/{domain}/user-products` — list published (`name`/`q`, `categoryId`, `cityId`, `countryId`, `condition`, `promoted`, pagination)
- `GET /tenants/{domain}/user-products/{slug}` — details + gallery (`technicalValues` = values only, no labels)
- `GET /tenants/{domain}/user-products/{slug}/technical-info` — **required for specs UI**: `{ form: { fields: [{ id, label, key, type, ... }] }, values: [...] }`
- `GET /tenants/{domain}/user-products` — list published (`name`/`q`, `categoryId`, `cityId`, `countryId`, `condition`, `promoted`, pagination). Each item includes `id`, `pathSlug`, `titleFa`/`titleEn`.
- `GET /tenants/{domain}/user-products/by-id/{id}` — **preferred** for storefront pages (slug segment is SEO-only)
- `GET /tenants/{domain}/user-products/by-id/{id}/technical-info` — specs labels + values by id
- `GET /tenants/{domain}/user-products/{slug}` — legacy resolve by DB slug (still supported)
- `GET /tenants/{domain}/user-products/{slug}/technical-info` — legacy specs by slug
Use product categories from `GET /tenants/{domain}/categories?entityType=product` for filters. Creating/editing listings is customer-dashboard only (`/businesses/.../my-user-products`), not website-facing.
### Technical details / specs table (catalog products + user products)
@@ -61,7 +185,7 @@ Use product categories from `GET /tenants/{domain}/categories?entityType=product
| Detail page | Specs endpoint (labels + values) |
|-------------|----------------------------------|
| `GET .../products/{slug}` or `.../products/by-id/{id}` | `GET .../products/{slug}/technical-info` or `.../products/by-id/{id}/technical-info` |
| `GET .../user-products/{slug}` | `GET .../user-products/{slug}/technical-info` |
| `GET .../user-products/by-id/{id}` (or `{slug}`) | `GET .../user-products/by-id/{id}/technical-info` (or `{slug}/technical-info`) |
**How to render:**
1. Call `technical-info` (same slug/id as the detail page).
@@ -74,12 +198,22 @@ Use product categories from `GET /tenants/{domain}/categories?entityType=product
**Wrong:** expecting `fieldName` / `fieldNameFa` on each `technicalValues` item in the product detail response.
### Analytics (dashboard charts)
Page views are recorded **automatically** when the website calls the normal public list/detail APIs (blogs, products, portfolios, videos, store-items, and homepage sliders). No extra website code is required for those.
Page views are recorded **automatically** for roughly one count per page load:
Optional explicit record (e.g. custom home without sliders):
- `POST /tenants/{domain}/analytics/views` body `{ "kind": "home"|"blog_detail"|"product_detail"|..., "entityId"?: "...", "path"?: "/blog/my-post" }`
- `kind` values: `home`, `portfolio_list`, `portfolio_detail`, `product_list`, `product_detail`, `store_item_list`, `store_item_detail`, `blog_list`, `blog_detail`, `video_list`, `video_detail` (legacy aliases `website`/`product`/`portfolio`/`blog` still accepted)
- **Home** — `GET /tenants/{domain}/website/static-images?pageKey=home`
- **About / Contact / other static pages** — `GET .../static-images?pageKey=about` (or `contact`, etc.) → counted as `static_page` with `path=/about`
- **Content detail** — product, user-product, blog, portfolio, video, and store-item **detail** GETs
List APIs (`products`, `blogs`, …) and homepage sliders **do not** count (avoids multi-API inflation). Known bots (and empty User-Agent) are skipped. A browser refresh counts again.
Call `static-images?pageKey=…` on About/Contact (and similar) so those pages appear in dashboard statistics.
Optional explicit record (custom pages without static-images):
- `POST /tenants/{domain}/analytics/views` body `{ "kind": "home"|"static_page"|"blog_detail"|"product_detail"|..., "entityId"?: "...", "path"?: "/blog/my-post" }`
- `kind` values: `home`, `static_page`, `portfolio_list`, `portfolio_detail`, `product_list`, `product_detail`, `store_item_list`, `store_item_detail`, `blog_list`, `blog_detail`, `video_list`, `video_detail`, `user_product_list`, `user_product_detail` (legacy aliases `website`/`product`/`portfolio`/`blog` still accepted). Prefer detail / `home` / `static_page` for real page visits.
- Events kept ~6 months for charts; lifetime totals kept forever in counters.
- The business dashboard **Product views** chart counts both `product_detail` and `user_product_detail`.
- The **Pages** statistics row counts `static_page` (about, contact, …).
### Static images
Named slots the business dashboard can replace. Fetch once per page:
@@ -152,10 +286,13 @@ Minimal example:
- **Canonical detail URLs (Meshkee default for all sites):**
- Product: `/products/{id}/{nameFaSlug}` — build `nameFaSlug` from `nameFa` (fallback `title`); resolve page via `GET /tenants/{domain}/products/by-id/{id}` (slug segment is SEO-only; redirect to canonical if it drifts).
- Product category: `/products/category/{categoryId}/{nameFaSlug}` — build slug from `nameFa` (fallback `name`); resolve via `GET /tenants/{domain}/categories/by-id/{id}`, then list products with `categoryId`.
- User product (customer listing): `/user-products/{slug}` — use the listing’s `slug` from the API; resolve via `GET /tenants/{domain}/user-products/{slug}`.
- Blog: `/blog/{id}/{titleSlug}` — `GET /tenants/{domain}/blogs/by-id/{id}`
- Portfolio: `/portfolios/{id}/{titleFaSlug}` — `GET /tenants/{domain}/portfolios/by-id/{id}`
- When linking from lists/cards, use the same `{id}/{slug}` shape for details (slugify Farsi title: spaces → `-`, keep Persian letters). Category links use `/products/category/{id}/{nameFaSlug}`. User-product cards use `/user-products/{slug}`.
- User product (customer listing): `/user-products/{id}/{pathSlug}` — use `id` + `pathSlug` from the list/detail API (or slugify `titleFa` / `titleEn`); resolve via `GET /tenants/{domain}/user-products/by-id/{id}` (slug segment is SEO-only).
- Blog: `/blog/{slug}` — use the blog’s `slug` from the API; resolve via `GET /tenants/{domain}/blogs/{slug}` (or list + match). Prefer slug routes over id.
- Portfolio: `/portfolio/{slug}` — use the portfolio’s `slug` from the API; resolve via `GET /tenants/{domain}/portfolios/{slug}`.
- Instruction: `/instruction/{slug}` — use the instruction’s `slug` from the API; resolve via `GET /tenants/{domain}/instructions/{slug}`.
- Video: `/videos/{slug}` — use the video’s `slug` from the API.
- Workshop: `/workshops/{slug}` — use the workshop’s `slug` from the API.
- When linking from lists/cards, use the same slug-based detail paths. Category links use `/products/category/{id}/{nameFaSlug}`. User-product cards use `/user-products/{id}/{pathSlug}`.
- Omit `templates` in `sitemap-config.json` unless this site uses non-default paths.
- **Sitemap files (proxied by nginx — do not ship local copies):**
- `/sitemap.xml` — sitemap **index** (lists child sitemaps for enabled modules)
@@ -194,11 +331,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**.
@@ -206,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}}"
}
},
@@ -1354,11 +1354,27 @@
}
}
},
{
"name": "Get user product by id",
"request": {
"method": "GET",
"description": "Preferred for /user-products/{id}/{pathSlug} pages. technicalValues are values only — use technical-info for specs UI.",
"url": "{{baseUrl}}/tenants/{{domain}}/user-products/by-id/{{userProductId}}"
}
},
{
"name": "Get user product technical info by id",
"request": {
"method": "GET",
"description": "Required for technical-details UI when page is resolved by id: form.fields (labels) + values.",
"url": "{{baseUrl}}/tenants/{{domain}}/user-products/by-id/{{userProductId}}/technical-info"
}
},
{
"name": "Get user product by slug",
"request": {
"method": "GET",
"description": "Listing detail + gallery. technicalValues are values only (fieldId + text/option) — no labels. Use technical-info for specs UI.",
"description": "Legacy slug resolve. Prefer by-id for storefront pages. technicalValues are values only (fieldId + text/option) — no labels.",
"url": "{{baseUrl}}/tenants/{{domain}}/user-products/{{userProductSlug}}"
}
},
@@ -1366,7 +1382,7 @@
"name": "Get user product technical info by slug",
"request": {
"method": "GET",
"description": "Required for technical-details UI: form.fields (labels) + values. Join field.id ↔ value.fieldId. No separate category-variation-fields public endpoint.",
"description": "Legacy specs by slug. Prefer by-id technical-info. Join field.id ↔ value.fieldId.",
"url": "{{baseUrl}}/tenants/{{domain}}/user-products/{{userProductSlug}}/technical-info"
}
}
@@ -1487,6 +1503,37 @@
}
]
},
{
"name": "Torob",
"item": [
{
"name": "Product API v3 (store module)",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
},
{
"key": "X-Torob-Token",
"value": "{{torobJwt}}"
},
{
"key": "X-Torob-Token-Version",
"value": "1"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"page\": 1,\n \"sort\": \"date_added_desc\"\n}"
},
"url": "{{baseUrl}}/tenants/{{domain}}/torob_api/v3/products",
"description": "Torob-only. 404 if the tenant does not have the store module. Prefer the shop-domain URL POST https://{domain}/torob_api/v3/products after nginx is patched."
}
}
]
},
{
"name": "Homepage",
"item": [
+3 -1
View File
@@ -24,6 +24,7 @@ X-Api-Key: <partner-secret>
| `domain` | yes | Allowlisted partner apex, e.g. `baloutpastry.com` (`www.` is stripped) |
| `to` | yes | Mobile: `09…`, `9…`, `+989…`, or `989…` |
| `message` | yes | Free text, max 700 characters |
| `source` | no | Gama shortcode override (8–16 digits). Default: advertising `5000990009` |
### Example (Balout)
@@ -72,7 +73,8 @@ First allowlisted partner: **baloutpastry.com**.
## Sender line (v1)
Uses the **service** shortcode only (`SendQuick`). Advertising / bulk / OTP pattern APIs are not exposed yet.
Default sender is the **advertising** shortcode (`5000990009` / `SMS_GAMA_SOURCE_ADVERTISE`).
Pass `source` to override (e.g. a business-owned shortcode). OTP/login on Meshkee dashboards uses the **service** shortcode (`5000110005`) via `/auth/send-otp`, not this partner endpoint.
---
+55 -4
View File
@@ -89,9 +89,48 @@
<li>Variable <code>domain</code> = website apex only (no <code>www</code>/<code>api</code>/<code>customer</code>/<code>business</code>).</li>
<li><code>GET /tenants/{domain}</code> → <code>businessId</code>.</li>
<li>Public pages: <code>/tenants/{domain}/...</code> (no auth) — products, <strong>user-products</strong>, blogs, portfolios, store-items, etc.</li>
<li>Cart / orders / favorites: <code>/businesses/{businessId}/...</code> + Bearer JWT.</li>
<li>Favorites (optional, if cookies exist): <code>/businesses/{businessId}/...</code> + Bearer JWT.</li>
<li>Login, checkout, server cart, orders: <strong>customer dashboard</strong> at <code>https://customer.{domain}</code> — not pages on the shop.</li>
</ol>
<h2>Shared login with customer dashboard</h2>
<p>
Do <strong>not</strong> build a login / register / OTP page on the storefront.
Send shoppers to <code>https://customer.{domain}/login</code>.
The customer dashboard writes parent-domain cookies; the shop only
<strong>reads</strong> them to detect an existing session:
</p>
<ul>
<li><code>meshkee_customer_access_token</code></li>
<li><code>meshkee_customer_refresh_token</code></li>
</ul>
<p>
Set <code>Domain=.{domain}</code>, <code>Path=/</code>, <code>SameSite=Lax</code>.
API auth is still <code>Authorization: Bearer &lt;accessToken&gt;</code> (cookies are not sent to the API).
Do not use <code>/auth/handoff</code> for shoppers (staff-only into the business dashboard).
Full detail: <a href="/docs/website/AI_PROMPT.md">AI_PROMPT.md</a>.
</p>
<h2>Shopping cart on the storefront</h2>
<p>
The shop implements a <strong>mini-cart only</strong>: header icon, quantity badge,
popup, Continue. Persist a guest cart as <code>meshkee-guest-cart</code>
(localStorage + parent-domain cookie). Each line <code>id</code> is
<code>storeItemVariantId</code>.
Add-to-cart: <code>GET /tenants/{domain}/store-items/by-product/{productId}</code>,
list variants if there is more than one, then add the chosen variant
(same id already in cart → increment quantity). Continue goes to the customer dashboard:
</p>
<ul>
<li>Logged in → <code>https://customer.{domain}/checkout/cart?guestCart=…</code></li>
<li>Not logged in → <code>https://customer.{domain}/login?redirect=/checkout/cart?guestCart=…</code></li>
</ul>
<p>
Do not call <code>/cart</code> or <code>/cart/checkout</code> from the website,
and do not add shop <code>/login</code> or <code>/checkout</code> routes.
Encoding and payload: <a href="/docs/website/AI_PROMPT.md">AI_PROMPT.md</a> (Shopping cart).
</p>
<h2>Branding (favicon + logos)</h2>
<p>
<code>GET /tenants/{domain}/website/favicon</code> returns
@@ -104,20 +143,32 @@
<h2>User products (customer listings)</h2>
<p>
Marketplace-style stock listings created by customers. Public read-only under
<code>/tenants/{domain}/user-products</code> (list / details / technical-info).
<code>/tenants/{domain}/user-products</code> (list / by-id / details / technical-info).
Storefront URLs: <code>/user-products/{id}/{pathSlug}</code>.
See OpenAPI tag <strong>User Products</strong>.
</p>
<p>
<strong>Technical details:</strong> detail responses include
<code>technicalValues</code> with <em>values only</em> (no field labels).
For a label→value specs table, call
<code>GET /tenants/{domain}/user-products/{slug}/technical-info</code>
<code>GET /tenants/{domain}/user-products/by-id/{id}/technical-info</code>
(catalog products:
<code>.../products/{slug}/technical-info</code>) and join
<code>.../products/by-id/{id}/technical-info</code>) and join
<code>form.fields[].id</code> ↔ <code>values[].fieldId</code>.
There is no public <code>product-category-variation-fields</code> route.
</p>
<h2>Torob (price comparison)</h2>
<p>
<code>POST /tenants/{domain}/torob_api/v3/products</code> is for
<strong>Torob</strong>, not storefront JavaScript. On the live shop,
nginx proxies <code>POST https://{domain}/torob_api/v3/products</code>
to that API. It only returns catalog store items when the business has
the <strong>store</strong> module enabled <em>and</em> Store settings →
Torob is on; otherwise 404.
Do not implement this path in Next.js.
</p>
<h2>For a new website AI / designer</h2>
<ol>
<li>Open <a href="/docs/website/AI_PROMPT.md">AI_PROMPT.md</a> and paste it into the AI chat.</li>
+231 -21
View File
@@ -3,7 +3,7 @@
"info": {
"title": "Meshkee Website API",
"version": "1.0.0",
"description": "Global storefront API for every Meshkee business website.\n\n**Not domain-specific.** Replace `{domain}` with the website apex (e.g. `sanihome.ir`).\n\n**Base URL:** `https://api.meshkee.com/api/v1` (or `https://api.{domain}/api/v1` if that alias is configured).\n\n**Tenant rule:** public content uses `/tenants/{domain}/...`. After login, cart/orders/favorites use `/businesses/{businessId}/...` with Bearer JWT.\n\n**Technical specs (AI agents):** Detail endpoints may return `technicalValues` with **values only** (`fieldId` + text/option ids — **no labels**). To render label→value technical details, always call the matching `.../technical-info` endpoint and join `form.fields[].id` to `values[].fieldId`. There is no public `product-category-variation-fields` route.\n\n**Docs:** https://api.meshkee.com/docs/website"
"description": "Global storefront API for every Meshkee business website.\n\n**Not domain-specific.** Replace `{domain}` with the website apex (e.g. `sanihome.ir`).\n\n**Base URL:** `https://api.meshkee.com/api/v1` (or `https://api.{domain}/api/v1` if that alias is configured).\n\n**Tenant rule:** public content uses `/tenants/{domain}/...`.\n\n**Storefront vs customer dashboard:** Do **not** build login, register, OTP, checkout, or cart pages on the shop. Those live at `https://customer.{domain}` for every Meshkee site. The website mini-cart is a **local guest cart** (`meshkee-guest-cart`); Continue redirects to the dashboard. Load variants with `GET /tenants/{domain}/store-items/by-product/{productId}` — if more than one in-stock variant, list them and let the shopper pick one before add. See AI_PROMPT.md.\n\n**Shared login:** the customer dashboard writes parent-domain cookies `meshkee_customer_access_token` / `meshkee_customer_refresh_token` on `Domain=.{domain}`. The shop only **reads** them (to skip login on Continue). API calls still use Bearer. Not `POST /auth/handoff` (staff SSO into the business dashboard).\n\n**Technical specs (AI agents):** Detail endpoints may return `technicalValues` with **values only** (`fieldId` + text/option ids — **no labels**). To render label→value technical details, always call the matching `.../technical-info` endpoint and join `form.fields[].id` to `values[].fieldId`. There is no public `product-category-variation-fields` route.\n\n**Docs:** https://api.meshkee.com/docs/website"
},
"servers": [
{
@@ -39,11 +39,15 @@
},
{
"name": "User Products",
"description": "Customer marketplace listings. Detail returns `technicalValues` without labels; use `GET .../user-products/{slug}/technical-info` for form field labels + values."
"description": "Customer marketplace listings. Prefer `GET .../user-products/by-id/{id}` for storefront pages. Detail returns `technicalValues` without labels; use `.../technical-info` for form field labels + values."
},
{
"name": "Store"
},
{
"name": "Torob",
"description": "Product API v3 for Torob. Called by Torob (not storefront JS). Nginx on the shop apex proxies POST /torob_api/v3/products. Only tenants with the store module enabled; otherwise 404."
},
{
"name": "Blogs"
},
@@ -60,7 +64,8 @@
"name": "Contact"
},
{
"name": "Auth"
"name": "Auth",
"description": "Used by the **customer dashboard** (`https://customer.{domain}`), not storefront UI. Do not build login/register/OTP pages on the shop — redirect to `https://customer.{domain}/login`.\n\nAPI calls use `Authorization: Bearer <accessToken>`. The dashboard writes parent-domain cookies `meshkee_customer_access_token` / `meshkee_customer_refresh_token` on `Domain=.{domain}`; the shop only reads them. Do not use `/auth/handoff` for shoppers (staff-only into the business dashboard)."
},
{
"name": "Addresses"
@@ -69,7 +74,8 @@
"name": "Cities"
},
{
"name": "Cart"
"name": "Cart",
"description": "Server cart + checkout — **customer dashboard only**. Storefronts must not call these routes. The shop keeps a local guest mini-cart (`meshkee-guest-cart`); add-to-cart uses `storeItemVariantId` from `GET /tenants/{domain}/store-items/by-product/{productId}` (list variants if more than one, then add the chosen line)."
},
{
"name": "Orders"
@@ -82,7 +88,7 @@
},
{
"name": "Payments",
"description": "Online e-payment gateways (website checkout)"
"description": "Online e-payment gateways — customer dashboard checkout, not storefront pages. Bank callbacks are nginx on the shop apex (`/meshkee/payments/{gateway}/callback`)."
}
],
"components": {
@@ -90,7 +96,8 @@
"bearerAuth": {
"type": "http",
"scheme": "bearer",
"bearerFormat": "JWT"
"bearerFormat": "JWT",
"description": "Customer access JWT from customer-dashboard login/register/OTP/refresh. Storefronts do not obtain this via a shop login page — they read `meshkee_customer_access_token` if the dashboard already set it on `Domain=.{domain}`. Always send Bearer on API requests; cookies are not sent to the API."
},
"apiKeyAuth": {
"type": "apiKey",
@@ -188,6 +195,18 @@
"store_item"
],
"description": "Where homepage special-product carousels read from. Defaults to store_item when the store module is enabled, otherwise product."
},
"schema": {
"type": "object",
"description": "Site-wide Schema.org flags. Full jsonLd is on GET /tenants/{domain}/website/business-info.",
"properties": {
"enabled": { "type": "boolean" },
"type": {
"type": "string",
"enum": ["Organization", "LocalBusiness"]
}
},
"required": ["enabled", "type"]
}
}
}
@@ -415,7 +434,7 @@
"SEO"
],
"summary": "User products sitemap urlset",
"description": "Published customer marketplace listings. Default paths: `/user-products/{slug}`. Included in the index when the `customer_products` module is enabled. Override path via sitemap-config `templates.userProduct`.",
"description": "Published customer marketplace listings. Default paths: `/user-products/{id}/{slug}` (slug from titleFa/titleEn). Included in the index when the `customer_products` module is enabled. Override path via sitemap-config `templates.userProduct`.",
"parameters": [
{
"$ref": "#/components/parameters/domain"
@@ -523,7 +542,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"]
}
}
}
}
@@ -587,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"
@@ -605,6 +649,7 @@
"type": "string",
"enum": [
"home",
"static_page",
"portfolio_list",
"portfolio_detail",
"product_list",
@@ -615,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."
}
}
}
@@ -637,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"] }
}
}
}
@@ -994,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."
}
}
}
@@ -1073,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."
}
}
}
@@ -1218,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: [...] }"
}
}
}
@@ -1422,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": [
@@ -1494,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"
@@ -1510,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)."
}
}
}
@@ -1536,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)."
}
}
}
@@ -1847,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"
+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."
+49 -6
View File
@@ -212,16 +212,45 @@ NODE
exit 0
fi
echo "no standalone output — using next start (legacy)"
# Vinext (Vite production server) or legacy `next start`.
# Detect Vinext from package.json / CLI — do not assume every Next-looking
# app has a `.next` production build (vinext writes `dist/` instead).
uses_vinext=false
if node -e '
const fs = require("fs");
const p = JSON.parse(fs.readFileSync("package.json", "utf8"));
const d = Object.assign({}, p.dependencies || {}, p.devDependencies || {});
const s = String((p.scripts || {}).start || "");
const ok = Boolean(d.vinext) || s.includes("vinext") || fs.existsSync("node_modules/vinext/dist/cli.js");
process.exit(ok ? 0 : 1);
'; then
uses_vinext=true
fi
if $uses_vinext; then
if [[ ! -d dist ]]; then
echo "vinext project but dist/ is missing — build did not produce a production bundle"
write_status "failed" "vinext build produced no dist/"
exit 1
fi
echo "no Next standalone output — using vinext start"
elif [[ -f .next/BUILD_ID ]]; then
echo "no standalone output — using next start (legacy)"
else
echo "build produced neither .next/standalone, Vinext dist/, nor .next/BUILD_ID"
write_status "failed" "no production server output (need Next standalone, BUILD_ID, or vinext dist/)"
exit 1
fi
# Reset cwd/script in case a previous deploy used standalone. Do not
# `pm2 restart --update-env`: the deploy agent has PORT=9050 and that
# leaks into the site, so nginx (site port) gets 502.
SLUG="$SLUG" ROOT="$ROOT" ECOSYSTEM="$ECOSYSTEM" node <<'NODE'
VINEXT="$uses_vinext" SLUG="$SLUG" ROOT="$ROOT" ECOSYSTEM="$ECOSYSTEM" node <<'NODE'
const fs = require('fs');
const path = process.env.ECOSYSTEM;
const slug = process.env.SLUG;
const root = process.env.ROOT;
const usesVinext = process.env.VINEXT === 'true';
let cfg = { apps: [] };
try {
delete require.cache[require.resolve(path)];
@@ -243,7 +272,9 @@ const port =
[fromEnv, fromArgs].find((p) => Number.isFinite(p) && p > 0 && p !== 9050) || 3005;
app.cwd = root;
app.script = 'node_modules/next/dist/bin/next';
app.script = usesVinext
? 'node_modules/vinext/dist/cli.js'
: 'node_modules/next/dist/bin/next';
app.args = 'start --hostname 127.0.0.1 --port ' + port;
app.env = {
NODE_ENV: 'production',
@@ -276,13 +307,25 @@ lines.push(' ],');
lines.push('};');
lines.push('');
fs.writeFileSync(path, lines.join('\n'));
console.log('ecosystem updated for next start:', slug, 'port', port);
console.log(
'ecosystem updated for',
usesVinext ? 'vinext start' : 'next start',
':',
slug,
'port',
port,
);
NODE
pm2 delete "$SLUG" || true
pm2 start "$ECOSYSTEM" --only "$SLUG"
pm2 save || true
echo "==== $(date -u +%Y-%m-%dT%H:%M:%SZ) deploy ok: $SLUG ===="
write_status "success" "Deployed origin/$BRANCH"
if $uses_vinext; then
echo "==== $(date -u +%Y-%m-%dT%H:%M:%SZ) deploy ok (vinext): $SLUG ===="
write_status "success" "Deployed origin/$BRANCH (vinext)"
else
echo "==== $(date -u +%Y-%m-%dT%H:%M:%SZ) deploy ok: $SLUG ===="
write_status "success" "Deployed origin/$BRANCH"
fi
trap - ERR
+83
View File
@@ -0,0 +1,83 @@
#!/usr/bin/env bash
# Parked alias: nginx 301 from parked apex+www to the canonical storefront.
# SSL is issued separately via ssl.sh (certbot --nginx) so the cert is in
# certbot's auto-renewal list.
set -euo pipefail
HOST="${1:-}"
CANONICAL="${2:-}"
if [[ -z "$HOST" || -z "$CANONICAL" ]]; then
echo "usage: park.sh <parked-host> <canonical-host>" >&2
exit 1
fi
if [[ ! "$HOST" =~ ^[a-z0-9.-]+$ || ! "$CANONICAL" =~ ^[a-z0-9.-]+$ ]]; then
echo "invalid host" >&2
exit 1
fi
NGINX_AVAILABLE="/etc/nginx/sites-available/$HOST"
NGINX_ENABLED="/etc/nginx/sites-enabled/$HOST"
SSL_DIR="/etc/nginx/ssl/$HOST"
LIST_FILE="/opt/websites-agent/parked-hosts.txt"
mkdir -p "$SSL_DIR" /opt/websites-agent /var/www/certbot
if [[ ! -f "$SSL_DIR/fullchain.pem" || ! -f "$SSL_DIR/privkey.pem" ]]; then
openssl req -x509 -nodes -newkey rsa:2048 -days 825 \
-keyout "$SSL_DIR/privkey.pem" \
-out "$SSL_DIR/fullchain.pem" \
-subj "/CN=$HOST" \
-addext "subjectAltName=DNS:$HOST,DNS:www.$HOST" \
>/dev/null 2>&1
echo "origin self-signed cert created: $SSL_DIR"
fi
# If certbot already issued a live cert, prefer it.
LE_LIVE="/etc/letsencrypt/live/$HOST"
if [[ -f "$LE_LIVE/fullchain.pem" && -f "$LE_LIVE/privkey.pem" ]]; then
CERT="$LE_LIVE/fullchain.pem"
KEY="$LE_LIVE/privkey.pem"
else
CERT="$SSL_DIR/fullchain.pem"
KEY="$SSL_DIR/privkey.pem"
fi
cat >"$NGINX_AVAILABLE" <<NGINX
server {
listen 80;
listen [::]:80;
server_name ${HOST} www.${HOST};
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://${CANONICAL}\$request_uri;
}
}
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name ${HOST} www.${HOST};
ssl_certificate ${CERT};
ssl_certificate_key ${KEY};
return 301 https://${CANONICAL}\$request_uri;
}
NGINX
ln -sfn "$NGINX_AVAILABLE" "$NGINX_ENABLED"
nginx -t
systemctl reload nginx
touch "$LIST_FILE"
if ! grep -qxF "$HOST" "$LIST_FILE" 2>/dev/null; then
echo "$HOST" >>"$LIST_FILE"
fi
echo "park ok: $HOST → https://$CANONICAL (apex+www)"
+58
View File
@@ -0,0 +1,58 @@
#!/usr/bin/env bash
# Insert / update the Torob Product API v3 proxy on a storefront nginx site.
# Torob POSTs https://{host}/torob_api/v3/products (JWT aud = shop host).
set -euo pipefail
HOST="${1:-}"
API_HOST="${MESHKEE_API_HOST:-api.meshkee.com}"
if [[ -z "$HOST" ]]; then
echo "usage: patch-nginx-torob.sh <host>" >&2
exit 1
fi
NGINX_AVAILABLE="/etc/nginx/sites-available/$HOST"
if [[ ! -f "$NGINX_AVAILABLE" ]]; then
echo "missing nginx site for $HOST" >&2
exit 1
fi
PATH_LOC="/torob_api/v3/products"
if grep -qF "location = ${PATH_LOC}" "$NGINX_AVAILABLE"; then
echo "already present: ${PATH_LOC}"
exit 0
fi
block="$(cat <<NGINX
location = ${PATH_LOC} {
proxy_pass https://${API_HOST}/api/v1/tenants/${HOST}/torob_api/v3/products;
proxy_set_header Host ${API_HOST};
proxy_ssl_server_name on;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme;
proxy_set_header X-Torob-Token \$http_x_torob_token;
proxy_set_header X-Torob-Token-Version \$http_x_torob_token_version;
proxy_pass_request_body on;
proxy_pass_request_headers on;
}
NGINX
)"
tmp="$(mktemp)"
awk -v block="$block" '
/location \/ \{/ && !done {
print block
done = 1
}
{ print }
' "$NGINX_AVAILABLE" >"$tmp"
mv "$tmp" "$NGINX_AVAILABLE"
echo "added: ${PATH_LOC}"
nginx -t
systemctl reload nginx
echo "patched nginx Torob product API location for $HOST"
+16
View File
@@ -252,6 +252,19 @@ PROXY_COMMON=$(cat <<PROXY
proxy_pass_request_headers on;
}
location = /torob_api/v3/products {
proxy_pass https://api.meshkee.com/api/v1/tenants/${HOST}/torob_api/v3/products;
proxy_set_header Host api.meshkee.com;
proxy_ssl_server_name on;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme;
proxy_set_header X-Torob-Token \$http_x_torob_token;
proxy_set_header X-Torob-Token-Version \$http_x_torob_token_version;
proxy_pass_request_body on;
proxy_pass_request_headers on;
}
location / {
proxy_pass http://127.0.0.1:${PORT};
proxy_http_version 1.1;
@@ -300,6 +313,9 @@ else
if [[ -x "$SCRIPT_DIR/patch-nginx-pay.sh" ]]; then
"$SCRIPT_DIR/patch-nginx-pay.sh" "$HOST" || true
fi
if [[ -x "$SCRIPT_DIR/patch-nginx-torob.sh" ]]; then
"$SCRIPT_DIR/patch-nginx-torob.sh" "$HOST" || true
fi
fi
# SSL is issued separately via ssl.sh / Super Admin "Issue SSL" — do not run
+68
View File
@@ -265,6 +265,74 @@ const server = http.createServer(async (req, res) => {
}
}
if (req.method === 'POST' && req.url === '/park') {
if (!assertAuth(req, res)) return;
let body;
try {
body = await readJson(req);
} catch {
return send(res, 400, { error: 'invalid json' });
}
const host = String(body.host || '').trim().toLowerCase();
const canonicalHost = String(body.canonicalHost || body.canonical_host || '')
.trim()
.toLowerCase();
if (!host || !/^[a-z0-9.-]+$/.test(host)) {
return send(res, 400, { error: 'valid host is required' });
}
if (!canonicalHost || !/^[a-z0-9.-]+$/.test(canonicalHost)) {
return send(res, 400, { error: 'valid canonicalHost is required' });
}
try {
const result = await runScript('/opt/websites-agent/park.sh', [host, canonicalHost]);
return send(res, 200, {
status: 'parked',
host,
canonicalHost,
log: (result.stdout || '').slice(-2000),
});
} catch (err) {
return send(res, 500, {
error: 'park failed',
detail: err instanceof Error ? err.message.slice(0, 2000) : String(err),
});
}
}
if (req.method === 'POST' && req.url === '/unpark') {
if (!assertAuth(req, res)) return;
let body;
try {
body = await readJson(req);
} catch {
return send(res, 400, { error: 'invalid json' });
}
const host = String(body.host || '').trim().toLowerCase();
if (!host || !/^[a-z0-9.-]+$/.test(host)) {
return send(res, 400, { error: 'valid host is required' });
}
try {
const result = await runScript('/opt/websites-agent/unpark.sh', [host]);
return send(res, 200, {
status: 'unparked',
host,
log: (result.stdout || '').slice(-2000),
});
} catch (err) {
return send(res, 500, {
error: 'unpark failed',
detail: err instanceof Error ? err.message.slice(0, 2000) : String(err),
});
}
}
return send(res, 404, { error: 'not found' });
});
+31
View File
@@ -0,0 +1,31 @@
#!/usr/bin/env bash
# Remove a parked-alias nginx site. Does not delete Arvan DNS or LE certs.
set -euo pipefail
HOST="${1:-}"
if [[ -z "$HOST" ]]; then
echo "usage: unpark.sh <parked-host>" >&2
exit 1
fi
if [[ ! "$HOST" =~ ^[a-z0-9.-]+$ ]]; then
echo "invalid host" >&2
exit 1
fi
NGINX_AVAILABLE="/etc/nginx/sites-available/$HOST"
NGINX_ENABLED="/etc/nginx/sites-enabled/$HOST"
LIST_FILE="/opt/websites-agent/parked-hosts.txt"
rm -f "$NGINX_ENABLED"
rm -f "$NGINX_AVAILABLE"
if [[ -f "$LIST_FILE" ]]; then
grep -vxF "$HOST" "$LIST_FILE" >"${LIST_FILE}.tmp" || true
mv "${LIST_FILE}.tmp" "$LIST_FILE"
fi
nginx -t
systemctl reload nginx
echo "unpark ok: $HOST"
+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;
}
}
+87 -13
View File
@@ -12,7 +12,16 @@ import {
import { AuthUser } from '../auth/auth.types';
import { PermissionsService } from '../auth/permissions.service';
import { BusinessSettingsService } from '../business-settings/business-settings.service';
import { normalizeBusinessSettings } from '../business-settings/business-settings.util';
import { buildBlogPostingJsonLd } from '../business-settings/website-entity-schema.util';
import {
applySeoMetaToMetadata,
normalizeSeoMetaInput,
readSeoMetaFromMetadata,
} from '../common/seo-meta.util';
import { PrismaService } from '../prisma/prisma.service';
import { DEFAULT_SITEMAP_PATH_TEMPLATES } from '../sitemap/sitemap.constants';
import { resolveSitemapConfig } from '../sitemap/sitemap-config.util';
import { SitemapService } from '../sitemap/sitemap.service';
import { TenantService } from '../tenant/tenant.service';
import {
@@ -138,7 +147,10 @@ export class BlogsService {
status,
featured_media_id: featuredMediaId,
published_at: status === ContentStatus.published ? new Date() : null,
metadata: this.buildMetadata(dto.tags) as Prisma.InputJsonValue,
metadata: applySeoMetaToMetadata(
this.buildMetadata(dto.tags),
normalizeSeoMetaInput(dto.seoMetaTitle, dto.seoMetaDescription),
) as Prisma.InputJsonValue,
},
include: blogInclude,
});
@@ -218,10 +230,13 @@ export class BlogsService {
nextContent.html = dto.mainTextHtml ?? '';
}
const nextMetadata = { ...existingMetadata };
if (dto.tags !== undefined) {
nextMetadata.tags = dto.tags;
}
const nextMetadata = applySeoMetaToMetadata(
{
...existingMetadata,
...(dto.tags !== undefined ? { tags: dto.tags } : {}),
},
normalizeSeoMetaInput(dto.seoMetaTitle, dto.seoMetaDescription),
);
let publishedAt: Date | null | undefined = undefined;
if (dto.status !== undefined) {
@@ -374,10 +389,7 @@ export class BlogsService {
}
return {
blog: await this.serializeBlog(blog, {
includeComments: true,
approvedCommentsOnly: true,
}),
blog: await this.serializePublicBlog(host, business, blog),
};
}
@@ -405,10 +417,71 @@ export class BlogsService {
}
return {
blog: await this.serializeBlog(blog, {
includeComments: true,
approvedCommentsOnly: true,
}),
blog: await this.serializePublicBlog(host, business, blog),
};
}
private async serializePublicBlog(
host: string,
business: { id: bigint; settings: Prisma.JsonValue; name: string; nameFa: string | null },
blog: BlogWithRelations,
) {
const serialized = await this.serializeBlog(blog, {
includeComments: true,
approvedCommentsOnly: true,
});
const settings = normalizeBusinessSettings(business.settings);
const normalizedHost = host
.toLowerCase()
.trim()
.replace(/^(customer|business)\./, '');
const domain = await this.prisma.domain.findFirst({
where: { businessId: business.id, isPrimary: true },
select: { host: true },
});
const primaryHost = domain?.host ?? normalizedHost;
const sitemap = resolveSitemapConfig(
settings.website.sitemapConfig ?? null,
primaryHost,
);
const media = await this.prisma.business.findUnique({
where: { id: business.id },
select: { logoMedia: { select: { publicUrl: true } } },
});
const authorName = serialized.author
? [serialized.author.firstName, serialized.author.lastName]
.filter(Boolean)
.join(' ')
.trim()
: '';
const jsonLd = settings.website.schema.enabled
? buildBlogPostingJsonLd({
host: primaryHost,
id: serialized.id,
title: serialized.seoMetaTitle || serialized.title,
slug: serialized.slug,
abstract: serialized.seoMetaDescription || serialized.abstract,
mainTextHtml: serialized.seoMetaDescription
? null
: serialized.mainTextHtml,
titleImageUrl: serialized.titleImageUrl,
publishedAt: serialized.publishedAt,
updatedAt: serialized.updatedAt,
authorName: authorName || null,
publisherName: business.nameFa || business.name,
publisherLogoUrl: media?.logoMedia?.publicUrl ?? null,
pathTemplate: sitemap.templates.blog ?? DEFAULT_SITEMAP_PATH_TEMPLATES.blog,
})
: null;
return {
...serialized,
schema: { jsonLd },
};
}
@@ -608,6 +681,7 @@ export class BlogsService {
categoryId: categoryAssignment?.categoryId.toString() ?? null,
categoryName: categoryAssignment?.category.name ?? '',
tags: Array.isArray(metadata.tags) ? (metadata.tags as string[]) : [],
...readSeoMetaFromMetadata(metadata),
authorId: blog.author_id?.toString() ?? null,
author: blog.author
? {
+21
View File
@@ -9,6 +9,7 @@ import {
Matches,
Min,
MinLength,
MaxLength,
} from 'class-validator';
export class ListBlogsDto {
@@ -107,6 +108,16 @@ export class CreateBlogDto {
@IsArray()
@IsString({ each: true })
tags?: string[];
@IsOptional()
@IsString()
@MaxLength(70)
seoMetaTitle?: string | null;
@IsOptional()
@IsString()
@MaxLength(160)
seoMetaDescription?: string | null;
@IsOptional()
@IsString()
@@ -152,6 +163,16 @@ export class UpdateBlogDto {
@IsArray()
@IsString({ each: true })
tags?: string[];
@IsOptional()
@IsString()
@MaxLength(70)
seoMetaTitle?: string | null;
@IsOptional()
@IsString()
@MaxLength(160)
seoMetaDescription?: string | null;
@IsOptional()
@IsString()
+2 -1
View File
@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { ArvanDnsModule } from '../arvan-dns/arvan-dns.module';
import { CloudflareDnsModule } from '../cloudflare-dns/cloudflare-dns.module';
import { WebsiteDeployModule } from '../website-deploy/website-deploy.module';
import { BusinessCategoriesController } from './business-categories.controller';
import { BusinessCategoriesService } from './business-categories.service';
@@ -10,7 +11,7 @@ import { LegacyMigrateService } from './legacy-migrate.service';
import { LegacyPurgeService } from './legacy-purge.service';
@Module({
imports: [AuthModule, WebsiteDeployModule, ArvanDnsModule],
imports: [AuthModule, WebsiteDeployModule, ArvanDnsModule, CloudflareDnsModule],
controllers: [BusinessAdminController, BusinessCategoriesController],
providers: [
BusinessAdminService,
+141 -7
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,
@@ -675,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,
@@ -684,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 {
await this.arvanDns.ensureTenantRecords(host);
if (provider === 'cloudflare') {
await this.cloudflareDns.ensureTenantRecords(host);
} else {
await this.arvanDns.ensureTenantRecords(host);
}
return null;
} catch (err) {
if (err instanceof HttpException) {
@@ -700,7 +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';
}
}
@@ -1225,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;
@@ -1251,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,
@@ -1267,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()
@@ -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;
}
+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,
};
+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],
})
+470 -29
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}
@@ -107,6 +130,11 @@ export class DomainAdminService {
...row,
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),
})),
};
});
@@ -231,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 }> = [];
@@ -281,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)`
@@ -464,7 +524,43 @@ export class DomainAdminService {
},
});
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');
@@ -552,51 +648,396 @@ 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.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 {
await this.arvanDns.ensureTenantRecords(host);
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) {
if (err instanceof HttpException) {
const res = err.getResponse();
if (typeof res === 'string') return res;
if (res && typeof res === 'object' && 'message' in res) {
const message = (res as { message: unknown }).message;
return Array.isArray(message) ? message.map(String).join(', ') : String(message);
}
}
if (err && typeof err === 'object' && 'message' in err && typeof err.message === 'string') {
return err.message;
}
return 'Arvan DNS update failed';
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;
if (res && typeof res === 'object' && 'message' in res) {
const message = (res as { message: unknown }).message;
return Array.isArray(message) ? message.map(String).join(', ') : String(message);
}
}
if (err && typeof err === 'object' && 'message' in err && typeof err.message === 'string') {
return err.message;
}
return fallback;
}
async disable(domainIdRaw: string, dto: DisableDomainDto, actor: AuthUser) {
await this.assertSuperAdmin(actor);
@@ -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 {}
+271
View File
@@ -0,0 +1,271 @@
import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { MediaEntityType, MediaType } from '@prisma/client';
import { AuthUser } from '../auth/auth.types';
import { PermissionsService } from '../auth/permissions.service';
import { MediaService } from '../media/media.service';
import { PrismaService } from '../prisma/prisma.service';
import { AddGalleryDto, ListGalleryDto } from './dto/gallery.dto';
@Injectable()
export class GalleryService {
constructor(
private readonly prisma: PrismaService,
private readonly media: MediaService,
private readonly permissions: PermissionsService,
) {}
async list(businessIdRaw: string, query: ListGalleryDto, actor: AuthUser) {
const businessId = BigInt(businessIdRaw);
await this.assertCanRead(businessId, actor.id);
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 24;
const skip = (page - 1) * pageSize;
const where = {
businessId,
entityType: MediaEntityType.gallery,
entityId: businessId,
};
const [rows, total] = await Promise.all([
this.prisma.mediaAttachment.findMany({
where,
include: { media: true },
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
skip,
take: pageSize,
}),
this.prisma.mediaAttachment.count({ where }),
]);
return {
items: rows.map((row) => this.serialize(row)),
total,
page,
pageSize,
};
}
async add(
businessIdRaw: string,
input: {
files?: Express.Multer.File[];
dto?: AddGalleryDto;
},
actor: AuthUser,
) {
const businessId = BigInt(businessIdRaw);
await this.assertCanCreate(businessId, actor.id);
const business = await this.prisma.business.findUnique({
where: { id: businessId },
select: { id: true, isActive: true },
});
if (!business?.isActive) {
throw new NotFoundException('Business not found');
}
const files = input.files ?? [];
const mediaIdsFromBody = (input.dto?.mediaIds ?? [])
.map((id) => id.trim())
.filter(Boolean);
if (!files.length && !mediaIdsFromBody.length) {
throw new BadRequestException(
'Provide files to upload and/or mediaIds to attach',
);
}
const uploadedIds: bigint[] = [];
if (files.length) {
const uploaded = await this.media.uploadMany(
businessIdRaw,
files,
actor,
);
for (const item of uploaded.items) {
uploadedIds.push(BigInt(item.id));
}
}
const existingIds: bigint[] = [];
for (const raw of mediaIdsFromBody) {
const mediaId = BigInt(raw);
const media = await this.prisma.media.findFirst({
where: { id: mediaId, businessId, mediaType: MediaType.image },
select: { id: true },
});
if (!media) {
throw new BadRequestException(
`Image media ${raw} was not found for this business`,
);
}
existingIds.push(media.id);
}
const candidateIds = [...uploadedIds, ...existingIds];
const uniqueIds: bigint[] = [];
const seen = new Set<string>();
for (const id of candidateIds) {
const key = id.toString();
if (seen.has(key)) continue;
seen.add(key);
uniqueIds.push(id);
}
const alreadyAttached = await this.prisma.mediaAttachment.findMany({
where: {
businessId,
entityType: MediaEntityType.gallery,
entityId: businessId,
mediaId: { in: uniqueIds },
},
select: { mediaId: true },
});
const attachedSet = new Set(
alreadyAttached.map((row) => row.mediaId.toString()),
);
const toAttach = uniqueIds.filter((id) => !attachedSet.has(id.toString()));
if (!toAttach.length) {
const current = await this.prisma.mediaAttachment.findMany({
where: {
businessId,
entityType: MediaEntityType.gallery,
entityId: businessId,
mediaId: { in: uniqueIds },
},
include: { media: true },
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
});
return { items: current.map((row) => this.serialize(row)) };
}
const maxSort = await this.prisma.mediaAttachment.aggregate({
where: {
businessId,
entityType: MediaEntityType.gallery,
entityId: businessId,
},
_max: { sortOrder: true },
});
let nextSort = (maxSort._max.sortOrder ?? -1) + 1;
const created = [];
for (const mediaId of toAttach) {
const row = await this.prisma.mediaAttachment.create({
data: {
businessId,
mediaId,
entityType: MediaEntityType.gallery,
entityId: businessId,
sortOrder: nextSort,
isFeatured: false,
},
include: { media: true },
});
nextSort += 1;
created.push(row);
}
return { items: created.map((row) => this.serialize(row)) };
}
async remove(businessIdRaw: string, mediaIdRaw: string, actor: AuthUser) {
const businessId = BigInt(businessIdRaw);
const mediaId = BigInt(mediaIdRaw);
await this.assertCanDelete(businessId, actor.id);
const attachment = await this.prisma.mediaAttachment.findFirst({
where: {
businessId,
entityType: MediaEntityType.gallery,
entityId: businessId,
mediaId,
},
});
if (!attachment) {
throw new NotFoundException('Gallery item not found');
}
await this.prisma.mediaAttachment.delete({ where: { id: attachment.id } });
await this.media.deleteUnused(businessId, [mediaId]);
return { message: 'Gallery item removed' };
}
private serialize(row: {
id: bigint;
mediaId: bigint;
sortOrder: number;
createdAt: Date;
media: {
id: bigint;
mediaType: MediaType;
publicUrl: string;
altText: string | null;
fileName: string;
width: number | null;
height: number | null;
createdAt: Date;
};
}) {
return {
id: row.media.id.toString(),
attachmentId: row.id.toString(),
mediaId: row.mediaId.toString(),
mediaType: row.media.mediaType,
publicUrl: row.media.publicUrl,
altText: row.media.altText,
fileName: row.media.fileName,
width: row.media.width,
height: row.media.height,
sortOrder: row.sortOrder,
createdAt: row.createdAt.toISOString(),
};
}
private async assertCanRead(businessId: bigint, userId: bigint) {
const allowed = await this.permissions.hasBusinessPermission(
userId,
businessId,
'media.read',
);
if (!allowed) {
throw new ForbiddenException('You cannot view gallery for this business');
}
}
private async assertCanCreate(businessId: bigint, userId: bigint) {
const allowed = await this.permissions.hasBusinessPermission(
userId,
businessId,
'media.create',
);
if (!allowed) {
throw new ForbiddenException(
'You cannot add gallery items for this business',
);
}
}
private async assertCanDelete(businessId: bigint, userId: bigint) {
const allowed = await this.permissions.hasBusinessPermission(
userId,
businessId,
'media.delete',
);
if (!allowed) {
throw new ForbiddenException(
'You cannot remove gallery items for this business',
);
}
}
}
+21
View File
@@ -9,6 +9,7 @@ import {
Matches,
Min,
MinLength,
MaxLength,
} from 'class-validator';
export class ListInstructionsDto {
@@ -114,6 +115,16 @@ export class CreateInstructionDto {
@IsArray()
@IsString({ each: true })
tags?: string[];
@IsOptional()
@IsString()
@MaxLength(70)
seoMetaTitle?: string | null;
@IsOptional()
@IsString()
@MaxLength(160)
seoMetaDescription?: string | null;
@IsOptional()
@IsString()
@@ -173,6 +184,16 @@ export class UpdateInstructionDto {
@IsArray()
@IsString({ each: true })
tags?: string[];
@IsOptional()
@IsString()
@MaxLength(70)
seoMetaTitle?: string | null;
@IsOptional()
@IsString()
@MaxLength(160)
seoMetaDescription?: string | null;
@IsOptional()
@IsString()
+17 -5
View File
@@ -13,6 +13,11 @@ import {
import { AuthUser } from '../auth/auth.types';
import { PermissionsService } from '../auth/permissions.service';
import { BusinessSettingsService } from '../business-settings/business-settings.service';
import {
applySeoMetaToMetadata,
normalizeSeoMetaInput,
readSeoMetaFromMetadata,
} from '../common/seo-meta.util';
import { PrismaService } from '../prisma/prisma.service';
import { TenantService } from '../tenant/tenant.service';
import {
@@ -166,7 +171,10 @@ export class InstructionsService {
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: instructionInclude,
});
@@ -260,10 +268,13 @@ export class InstructionsService {
nextContent.html = dto.descriptionHtml ?? '';
}
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) {
@@ -661,6 +672,7 @@ export class InstructionsService {
categoryId: categoryAssignment?.categoryId.toString() ?? null,
categoryName: categoryAssignment?.category.name ?? '',
tags: Array.isArray(metadata.tags) ? (metadata.tags as string[]) : [],
...readSeoMetaFromMetadata(metadata),
authorId: video.author_id?.toString() ?? null,
author: video.author
? {
+76
View File
@@ -0,0 +1,76 @@
export const MAIL_IPV4 = '185.214.101.41';
export const MAIL_HOSTNAME = 'mail.meshkee.com';
export const MAIL_MX_PRIORITY = 10;
export const MAIL_SPF = `v=spf1 ip4:${MAIL_IPV4} -all`;
/** Old provider selector (dkim._domainkey.example.com) — removed on mail DNS update. */
export const LEGACY_DKIM_NAME = 'dkim._domainkey';
export const DMARC_NAME = '_dmarc';
export type MailDkimKey = {
selector: string;
text: string;
};
/** Bare selector, or `name._domainkey` / `name._domainkey.example.com.` */
export function normalizeDkimSelector(raw: string | undefined | null): string | null {
if (raw == null) return null;
let input = raw.trim().replace(/^"+|"+$/g, '').trim();
if (!input) return null;
const hostMatch = input.match(/^([a-z0-9][a-z0-9-]*)\._domainkey(?:\.|$)/i);
if (hostMatch) return hostMatch[1].toLowerCase();
if (/^[a-z0-9][a-z0-9-]*$/i.test(input)) return input.toLowerCase();
return null;
}
export function parseDkimPaste(
raw: string,
expectedKind: 'ed25519' | 'rsa',
selectorHint?: string,
): MailDkimKey {
const input = raw.trim();
if (!input) {
throw new Error(
`Paste the ${expectedKind} DKIM TXT value (v=DKIM1; …) or the full Stalwart zone line.`,
);
}
const quoted = [...input.matchAll(/"([^"]*)"/g)].map((match) => match[1]).join('');
const compact = input.replace(/[()\n\r\t]+/g, ' ').replace(/\s+/g, ' ').trim();
const blob = (quoted || compact).replace(/"\s+"/g, '');
const txtMatch = blob.match(/v=DKIM1[\s\S]*/i);
let text = (txtMatch ? txtMatch[0] : blob).trim();
text = text.replace(/^"+|"+$/g, '').replace(/\s+/g, ' ').replace(/[.\s]+$/g, '').trim();
if (!/^v=DKIM1/i.test(text)) {
throw new Error(
`Could not find a v=DKIM1 value in the ${expectedKind} field. Paste the TXT value or Stalwart zone line.`,
);
}
const kind = /k=ed25519/i.test(text)
? 'ed25519'
: /k=rsa/i.test(text)
? 'rsa'
: null;
if (kind && kind !== expectedKind) {
throw new Error(
`That paste is ${kind} DKIM. Put it in the ${kind === 'ed25519' ? 'Ed25519' : 'RSA'} field.`,
);
}
const nameMatch = compact.match(/([a-z0-9][a-z0-9-]*)\._domainkey/i);
const selector =
(nameMatch ? nameMatch[1].toLowerCase() : null) || normalizeDkimSelector(selectorHint);
if (!selector) {
throw new Error(
'Could not find a DKIM selector. Enter the selector (for example v1-ed25519-20260901), or paste a zone line that includes …._domainkey.',
);
}
return {
selector,
text,
};
}
+8 -1
View File
@@ -269,9 +269,15 @@ export class MediaService {
{ logoMediaId: { in: uniqueIds } },
{ logoDarkMediaId: { in: uniqueIds } },
{ faviconMediaId: { in: uniqueIds } },
{ bannerMediaId: { in: uniqueIds } },
],
},
select: { logoMediaId: true, logoDarkMediaId: true, faviconMediaId: true },
select: {
logoMediaId: true,
logoDarkMediaId: true,
faviconMediaId: true,
bannerMediaId: true,
},
}),
]);
@@ -288,6 +294,7 @@ export class MediaService {
addId(business?.logoMediaId);
addId(business?.logoDarkMediaId);
addId(business?.faviconMediaId);
addId(business?.bannerMediaId);
const deletableIds = uniqueIds.filter((id) => !stillLinked.has(id.toString()));
if (!deletableIds.length) return 0;
+21
View File
@@ -10,6 +10,7 @@ import {
Matches,
Min,
MinLength,
MaxLength,
} from 'class-validator';
export class ListPortfoliosDto {
@@ -113,6 +114,16 @@ export class CreatePortfolioDto {
@IsArray()
@IsString({ each: true })
tags?: string[];
@IsOptional()
@IsString()
@MaxLength(70)
seoMetaTitle?: string | null;
@IsOptional()
@IsString()
@MaxLength(160)
seoMetaDescription?: string | null;
@IsOptional()
@IsArray()
@@ -174,6 +185,16 @@ export class UpdatePortfolioDto {
@IsArray()
@IsString({ each: true })
tags?: string[];
@IsOptional()
@IsString()
@MaxLength(70)
seoMetaTitle?: string | null;
@IsOptional()
@IsString()
@MaxLength(160)
seoMetaDescription?: string | null;
@IsOptional()
@IsArray()
+23 -7
View File
@@ -3,6 +3,7 @@ import {
Controller,
Delete,
Get,
Headers,
Param,
Patch,
Post,
@@ -99,19 +100,24 @@ export class PublicPortfoliosController {
) {}
@Get()
async list(@Param('host') host: string, @Query() query: ListPublicPortfoliosDto) {
const result = await this.service.listPublic(host, query);
this.analytics.trackPublicPage(host, 'portfolio_list');
return result;
list(@Param('host') host: string, @Query() query: ListPublicPortfoliosDto) {
return this.service.listPublic(host, query);
}
@Get('by-id/:portfolioId')
async getById(
@Param('host') host: string,
@Param('portfolioId') portfolioId: string,
@Headers('user-agent') userAgent?: string,
) {
const result = await this.service.getPublicById(host, portfolioId);
this.analytics.trackPublicPage(host, 'portfolio_detail', result.portfolio.id);
this.analytics.trackPublicPage(
host,
'portfolio_detail',
result.portfolio.id,
undefined,
userAgent,
);
return result;
}
@@ -133,9 +139,19 @@ export class PublicPortfoliosController {
}
@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, 'portfolio_detail', result.portfolio.id);
this.analytics.trackPublicPage(
host,
'portfolio_detail',
result.portfolio.id,
undefined,
userAgent,
);
return result;
}
}
+17 -5
View File
@@ -12,6 +12,11 @@ import {
import { AuthUser } from '../auth/auth.types';
import { PermissionsService } from '../auth/permissions.service';
import { BusinessSettingsService } from '../business-settings/business-settings.service';
import {
applySeoMetaToMetadata,
normalizeSeoMetaInput,
readSeoMetaFromMetadata,
} from '../common/seo-meta.util';
import { MediaService } from '../media/media.service';
import { PrismaService } from '../prisma/prisma.service';
import { SitemapService } from '../sitemap/sitemap.service';
@@ -166,7 +171,10 @@ export class PortfoliosService {
featured_media_id: featuredMediaId,
sort_order: sortOrder,
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: portfolioInclude,
});
@@ -265,10 +273,13 @@ export class PortfoliosService {
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) {
@@ -786,6 +797,7 @@ export class PortfoliosService {
categoryId: categoryAssignment?.categoryId.toString() ?? null,
categoryName: categoryAssignment?.category.name ?? '',
tags: Array.isArray(metadata.tags) ? (metadata.tags as string[]) : [],
...readSeoMetaFromMetadata(metadata),
titleImageUrl:
portfolio.media?.publicUrl ??
galleryAttachments[0]?.media.publicUrl ??

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